Compare commits
17 Commits
995b539a67
...
test
| Author | SHA1 | Date | |
|---|---|---|---|
| 4f66edbb21 | |||
| 38f35d1915 | |||
| ca8e70781c | |||
| 4ebe15522f | |||
| 0f1c749b33 | |||
| 8e757afe39 | |||
| 5100c5d17c | |||
| 81d33bebef | |||
| 3500d433ea | |||
| c713b47d58 | |||
| ddd0ffbcb0 | |||
| 36a8548562 | |||
| f5cf4c32da | |||
| 019e4f90c7 | |||
| 2f2cd7d578 | |||
| 0fa431350d | |||
| 715f1f992c |
@@ -7,30 +7,15 @@ on:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
deploy:
|
deploy:
|
||||||
runs-on: [docker, host]
|
runs-on: [docker:host]
|
||||||
steps:
|
steps:
|
||||||
- name: Prepare workspace
|
- name: Checkout code
|
||||||
run: |
|
uses: actions/checkout@v3
|
||||||
rm -rf /tmp/olaper
|
|
||||||
mkdir -p /tmp/olaper
|
|
||||||
cd /tmp/olaper
|
|
||||||
apk update && apk add openssh docker-cli
|
|
||||||
mkdir -p ~/.ssh
|
|
||||||
chmod 700 ~/.ssh
|
|
||||||
ssh-keyscan -p 2222 10.25.100.250 >> ~/.ssh/known_hosts
|
|
||||||
git clone --branch prod ssh://git@10.25.100.250:2222/serty/olaper.git .
|
|
||||||
|
|
||||||
- name: Build Docker image
|
- name: Build Docker image
|
||||||
run: |
|
run: |
|
||||||
cd /tmp/olaper
|
|
||||||
docker build -t olaper:latest .
|
docker build -t olaper:latest .
|
||||||
|
|
||||||
- name: Create volume (if not exists)
|
|
||||||
run: |
|
|
||||||
if ! docker volume inspect olaper_data >/dev/null 2>&1; then
|
|
||||||
docker volume create olaper_data
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Stop old container (if running)
|
- name: Stop old container (if running)
|
||||||
run: |
|
run: |
|
||||||
if [ "$(docker ps -q -f name=olaper)" ]; then
|
if [ "$(docker ps -q -f name=olaper)" ]; then
|
||||||
@@ -39,14 +24,20 @@ jobs:
|
|||||||
|
|
||||||
- name: Run new container
|
- name: Run new container
|
||||||
run: |
|
run: |
|
||||||
|
PORT=5005
|
||||||
|
CONTAINER_ID=$(docker ps --format '{{.ID}} {{.Ports}}' | grep ":$PORT->" | awk '{print $1}')
|
||||||
|
|
||||||
|
if [ -n "$CONTAINER_ID" ]; then
|
||||||
|
echo "Stopping container using port $PORT..."
|
||||||
|
docker stop "$CONTAINER_ID"
|
||||||
|
docker rm "$CONTAINER_ID"
|
||||||
|
fi
|
||||||
|
|
||||||
docker run -d \
|
docker run -d \
|
||||||
--name olaper \
|
--name olaper \
|
||||||
--restart always \
|
--restart always \
|
||||||
-p 5005:5005 \
|
-p ${PORT}:5005 \
|
||||||
-e SECRET_KEY=${{ secrets.SECRET_KEY }} \
|
-e SECRET_KEY=${{ secrets.SECRET_KEY }} \
|
||||||
-e ENCRYPTION_KEY=${{ secrets.ENCRYPTION_KEY }} \
|
-e ENCRYPTION_KEY=${{ secrets.ENCRYPTION_KEY }} \
|
||||||
-v olaper_data:/opt/olaper/data \
|
-v mholaper_data:/app/data \
|
||||||
olaper:latest
|
olaper:latest
|
||||||
|
|
||||||
- name: Cleanup
|
|
||||||
run: rm -rf /tmp/olaper
|
|
||||||
|
|||||||
48
.gitea/workflows/testing.yml
Normal file
48
.gitea/workflows/testing.yml
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
name: Test Build
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test-build:
|
||||||
|
runs-on: [docker, host]
|
||||||
|
steps:
|
||||||
|
- name: Install Docker CLI (for Alpine-based runner)
|
||||||
|
run: |
|
||||||
|
if ! command -v docker &> /dev/null; then
|
||||||
|
apk update && apk add docker-cli
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Prepare SSH and clone repo
|
||||||
|
run: |
|
||||||
|
apk add --no-cache openssh git
|
||||||
|
mkdir -p /root/.ssh
|
||||||
|
chmod 700 /root/.ssh
|
||||||
|
ssh-keyscan -p 2222 10.25.100.250 >> /root/.ssh/known_hosts
|
||||||
|
rm -rf /tmp/olaper
|
||||||
|
git clone --branch main ssh://git@10.25.100.250:2222/serty/olaper.git /tmp/olaper
|
||||||
|
|
||||||
|
- name: Build test Docker image
|
||||||
|
run: |
|
||||||
|
cd /tmp/olaper
|
||||||
|
docker build -t olaper:test .
|
||||||
|
|
||||||
|
- name: (Optional) Run container for testing
|
||||||
|
run: |
|
||||||
|
# Удаляем предыдущий тестовый контейнер
|
||||||
|
if [ "$(docker ps -q -f name=olaper_test)" ]; then
|
||||||
|
docker stop olaper_test && docker rm olaper_test
|
||||||
|
fi
|
||||||
|
|
||||||
|
docker run -d \
|
||||||
|
--name olaper_test \
|
||||||
|
-p 5050:5005 \
|
||||||
|
-v /home/master/olaper-debug/data:/opt/olaper/data \
|
||||||
|
-e SECRET_KEY=${{ secrets.SECRET_KEY }} \
|
||||||
|
-e ENCRYPTION_KEY=${{ secrets.ENCRYPTION_KEY }} \
|
||||||
|
olaper:test
|
||||||
|
|
||||||
|
- name: Cleanup source
|
||||||
|
run: rm -rf /tmp/olaper
|
||||||
607
app.py
607
app.py
@@ -1,533 +1,118 @@
|
|||||||
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 os
|
||||||
import logging
|
from flask import Flask, session, request
|
||||||
from werkzeug.utils import secure_filename
|
from sqlalchemy import inspect
|
||||||
import shutil
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
from google_sheets import GoogleSheets
|
# 1. Загрузка переменных окружения - в самом верху
|
||||||
from request_module import ReqModule
|
load_dotenv()
|
||||||
from utils import *
|
|
||||||
from models import db, User, UserConfig
|
|
||||||
|
|
||||||
|
# 2. Импорт расширений из центрального файла
|
||||||
|
from extensions import scheduler, db, migrate, login_manager, babel
|
||||||
|
from models import init_encryption
|
||||||
|
|
||||||
|
# 3. Фабрика приложений
|
||||||
|
def create_app():
|
||||||
|
"""
|
||||||
|
Создает и конфигурирует экземпляр Flask приложения.
|
||||||
|
"""
|
||||||
app = Flask(__name__)
|
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['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['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)
|
db.init_app(app)
|
||||||
migrate = Migrate(app, db)
|
migrate.init_app(app, db)
|
||||||
|
|
||||||
os.makedirs(DATA_DIR, exist_ok=True)
|
|
||||||
|
|
||||||
# --- Flask-Login Configuration ---
|
|
||||||
login_manager = LoginManager()
|
|
||||||
login_manager.init_app(app)
|
login_manager.init_app(app)
|
||||||
login_manager.login_view = 'login' # Redirect to 'login' view if user tries to access protected page
|
babel.init_app(app, locale_selector=get_locale)
|
||||||
|
scheduler.init_app(app)
|
||||||
|
|
||||||
@login_manager.user_loader
|
init_encryption(app)
|
||||||
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()
|
from routes import main_bp, execute_olap_export
|
||||||
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
|
app.register_blueprint(main_bp)
|
||||||
|
|
||||||
|
login_manager.login_view = 'main.login'
|
||||||
|
login_manager.login_message = "Пожалуйста, войдите, чтобы получить доступ к этой странице."
|
||||||
|
login_manager.login_message_category = "info"
|
||||||
|
|
||||||
# --- Helper Functions ---
|
with app.app_context():
|
||||||
def get_user_config():
|
if inspect(db.engine).has_table('user_config'):
|
||||||
"""Gets the config for the currently logged-in user, creating if it doesn't exist."""
|
from models import User, UserConfig
|
||||||
if not current_user.is_authenticated:
|
all_configs = UserConfig.query.all()
|
||||||
return None # Or return a default empty config object if preferred for anonymous users
|
for config in all_configs:
|
||||||
config = UserConfig.query.filter_by(user_id=current_user.id).first()
|
user_id = config.user_id
|
||||||
if not config:
|
mappings = config.mappings
|
||||||
config = UserConfig(user_id=current_user.id)
|
for sheer_title, params in mappings.items():
|
||||||
db.session.add(config)
|
if isinstance(params, dict):
|
||||||
# Commit immediately or defer, depending on workflow
|
cron_schedule = params.get('schedule_cron')
|
||||||
# db.session.commit() # Let's commit when saving changes
|
if cron_schedule:
|
||||||
logger.info(f"Created new UserConfig for user {current_user.id}")
|
job_id = f"user_{user_id}_sheet_{sheer_title}"
|
||||||
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:
|
try:
|
||||||
db.session.commit()
|
scheduler.add_job(
|
||||||
flash('Registration successful! Please log in.', 'success')
|
id=job_id,
|
||||||
logger.info(f"User '{username}' registered successfully.")
|
func=execute_olap_export,
|
||||||
return redirect(url_for('login'))
|
trigger='cron',
|
||||||
except Exception as e:
|
args=[user_id, sheer_title],
|
||||||
db.session.rollback()
|
**_parse_cron_string(cron_schedule)
|
||||||
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.logger.info(f"Job {job_id} loaded on startup.")
|
||||||
@app.route('/configure_rms', methods=['POST'])
|
except Exception as e:
|
||||||
@login_required
|
app.logger.error(f"Failed to load job {job_id}: {e}")
|
||||||
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:
|
else:
|
||||||
flash('Authorization error on RMS server.', 'error')
|
app.logger.warning("Database tables not found. Skipping job loading on startup. Run 'flask init-db' to create the tables.")
|
||||||
|
scheduler.start()
|
||||||
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
|
|
||||||
|
|
||||||
|
# --- Регистрация команд CLI ---
|
||||||
|
from models import User, UserConfig
|
||||||
@app.cli.command('init-db')
|
@app.cli.command('init-db')
|
||||||
def init_db_command():
|
def init_db_command():
|
||||||
"""Creates the database tables."""
|
"""Создает или пересоздает таблицы в базе данных."""
|
||||||
|
print("Creating database tables...")
|
||||||
db.create_all()
|
db.create_all()
|
||||||
print('Initialized the database.')
|
print("Database tables created successfully.")
|
||||||
|
|
||||||
|
return app
|
||||||
|
|
||||||
|
# --- Точка входа для запуска ---
|
||||||
|
app = create_app()
|
||||||
|
|
||||||
|
# --- Вспомогательная функция для парсинга cron ---
|
||||||
|
def _parse_cron_string(cron_str):
|
||||||
|
"""Парсит строку cron в словарь для APScheduler."""
|
||||||
|
parts = cron_str.split()
|
||||||
|
if len(parts) != 5:
|
||||||
|
raise ValueError("Invalid cron string format. Expected 5 parts.")
|
||||||
|
|
||||||
|
keys = ['minute', 'hour', 'day', 'month', 'day_of_week']
|
||||||
|
return {keys[i]: part for i, part in enumerate(parts)}
|
||||||
|
|
||||||
# --- Main Execution ---
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
# Ensure the database exists before running
|
# Для прямого запуска через `python app.py` (удобно для отладки)
|
||||||
with app.app_context():
|
app.run(host='0.0.0.0', port=int(os.environ.get("PORT", 5005)))
|
||||||
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
|
|
||||||
3
babel.cfg
Normal file
3
babel.cfg
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
[python: **.py]
|
||||||
|
[jinja2: **/templates/**.html]
|
||||||
|
extensions=jinja2.ext.i18n
|
||||||
13
extensions.py
Normal file
13
extensions.py
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
from flask_sqlalchemy import SQLAlchemy
|
||||||
|
from flask_migrate import Migrate
|
||||||
|
from flask_login import LoginManager
|
||||||
|
from flask_babel import Babel
|
||||||
|
from flask_apscheduler import APScheduler
|
||||||
|
|
||||||
|
# Создаем экземпляры расширений здесь, без привязки к приложению.
|
||||||
|
# Теперь любой модуль может безопасно импортировать их отсюда.
|
||||||
|
db = SQLAlchemy()
|
||||||
|
migrate = Migrate()
|
||||||
|
login_manager = LoginManager()
|
||||||
|
babel = Babel()
|
||||||
|
scheduler = APScheduler()
|
||||||
136
google_sheets.py
136
google_sheets.py
@@ -5,127 +5,99 @@ from gspread.utils import rowcol_to_a1
|
|||||||
# Настройка логирования
|
# Настройка логирования
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
def log_exceptions(func):
|
def log_exceptions(func):
|
||||||
"""Декоратор для логирования исключений."""
|
"""Декоратор для стандартизированного логирования исключений в методах класса."""
|
||||||
def wrapper(self, *args, **kwargs):
|
def wrapper(self, *args, **kwargs):
|
||||||
try:
|
try:
|
||||||
return func(self, *args, **kwargs)
|
return func(self, *args, **kwargs)
|
||||||
except gspread.exceptions.APIError as e:
|
except gspread.exceptions.APIError as e:
|
||||||
# Логируем специфичные ошибки API Google
|
# Логируем специфичные ошибки API Google с деталями
|
||||||
logger.error(f"Google API Error in {func.__name__}: {e.response.status_code} - {e.response.text}")
|
error_details = e.response.json().get('error', {})
|
||||||
raise # Перевыбрасываем, чтобы app.py мог обработать
|
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:
|
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
|
raise
|
||||||
return wrapper
|
return wrapper
|
||||||
|
|
||||||
|
|
||||||
class GoogleSheets:
|
class GoogleSheets:
|
||||||
def __init__(self, cred_file, sheet_url):
|
def __init__(self, cred_file, sheet_url):
|
||||||
"""Инициализация клиента Google Sheets."""
|
"""
|
||||||
|
Инициализация клиента для работы с Google Sheets.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
cred_file (str): Путь к JSON-файлу с учетными данными сервисного аккаунта.
|
||||||
|
sheet_url (str): URL Google Таблицы.
|
||||||
|
"""
|
||||||
try:
|
try:
|
||||||
|
# Используем service_account для аутентификации
|
||||||
self.client = gspread.service_account(filename=cred_file)
|
self.client = gspread.service_account(filename=cred_file)
|
||||||
self.sheet_url = sheet_url
|
# Открываем таблицу по URL
|
||||||
self.spreadsheet = self.client.open_by_url(sheet_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:
|
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
|
raise
|
||||||
|
|
||||||
@log_exceptions
|
@log_exceptions
|
||||||
def get_sheet(self, sheet_name):
|
def get_sheet(self, sheet_name):
|
||||||
"""Возвращает объект листа по имени."""
|
"""Возвращает объект листа (worksheet) по его имени."""
|
||||||
try:
|
try:
|
||||||
sheet = self.spreadsheet.worksheet(sheet_name)
|
sheet = self.spreadsheet.worksheet(sheet_name)
|
||||||
logger.debug(f"Retrieved worksheet object for '{sheet_name}'")
|
logger.debug(f"Получен объект листа '{sheet_name}'")
|
||||||
return sheet
|
return sheet
|
||||||
except gspread.exceptions.WorksheetNotFound:
|
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
|
raise
|
||||||
|
|
||||||
@log_exceptions
|
@log_exceptions
|
||||||
def get_sheets(self):
|
def get_sheets(self):
|
||||||
"""Получение списка листов в таблице."""
|
"""Получает список всех листов в таблице."""
|
||||||
sheets = self.spreadsheet.worksheets()
|
sheets = self.spreadsheet.worksheets()
|
||||||
|
# Собираем информацию о листах: название и ID
|
||||||
sheet_data = [{"title": sheet.title, "id": sheet.id} for sheet in sheets]
|
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
|
return sheet_data
|
||||||
|
|
||||||
@log_exceptions
|
@log_exceptions
|
||||||
def update_cell(self, sheet_name, cell, new_value):
|
def clear_and_write_data(self, sheet_name, data):
|
||||||
"""Обновляет значение ячейки с логированием старого значения."""
|
|
||||||
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"):
|
|
||||||
"""
|
"""
|
||||||
Очищает ВЕСЬ указанный лист и записывает новые данные (список списков),
|
Очищает указанный лист и записывает на него новые данные.
|
||||||
начиная с ячейки start_cell.
|
|
||||||
|
Args:
|
||||||
|
sheet_name (str): Имя листа для записи.
|
||||||
|
data (list): Список списков с данными для записи (первый список - заголовки).
|
||||||
"""
|
"""
|
||||||
if not isinstance(data, list):
|
if not isinstance(data, list):
|
||||||
raise TypeError("Data must be a list of lists.")
|
raise TypeError("Данные для записи должны быть списком списков.")
|
||||||
|
|
||||||
sheet = self.get_sheet(sheet_name)
|
sheet = self.get_sheet(sheet_name)
|
||||||
|
|
||||||
logger.info(f"Clearing entire sheet '{sheet_name}'...")
|
logger.info(f"Очистка листа '{sheet_name}'...")
|
||||||
sheet.clear() # Очищаем весь лист
|
sheet.clear()
|
||||||
logger.info(f"Sheet '{sheet_name}' cleared.")
|
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.")
|
if not data:
|
||||||
return # Ничего не записываем, если данных нет
|
logger.warning(f"Нет данных для записи на лист '{sheet_name}' после очистки.")
|
||||||
|
return # Завершаем, если список данных пуст
|
||||||
|
|
||||||
num_rows = len(data)
|
num_rows = len(data)
|
||||||
num_cols = len(data[0]) # Предполагаем, что все строки имеют одинаковую длину
|
num_cols = len(data[0]) if data and data[0] else 0
|
||||||
|
|
||||||
# Рассчитываем конечную ячейку на основе начальной и размеров данных
|
logger.info(f"Запись {num_rows} строк и {num_cols} колонок на лист '{sheet_name}'...")
|
||||||
try:
|
# Используем метод update для записи всего диапазона одним API-вызовом
|
||||||
start_row, start_col = gspread.utils.a1_to_rowcol(start_cell)
|
sheet.update(data, value_input_option='USER_ENTERED')
|
||||||
end_row = start_row + num_rows - 1
|
logger.info(f"Данные успешно записаны на лист '{sheet_name}'.")
|
||||||
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
|
|
||||||
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 ""
|
||||||
|
|
||||||
94
models.py
94
models.py
@@ -8,34 +8,36 @@ import logging
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
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)
|
# 2. Инициализация Fernet вынесена в функцию, чтобы она вызывалась ПОСЛЕ загрузки .env
|
||||||
# For development, we can generate/load it from a file.
|
fernet = None
|
||||||
encryption_key_str = os.environ.get('ENCRYPTION_KEY')
|
|
||||||
|
def init_encryption(app):
|
||||||
|
"""Инициализирует Fernet после того, как конфигурация загружена."""
|
||||||
|
global fernet
|
||||||
|
encryption_key_str = app.config.get('ENCRYPTION_KEY')
|
||||||
if not encryption_key_str:
|
if not encryption_key_str:
|
||||||
logger.error("ENCRYPTION_KEY environment variable not set! RMS password encryption will fail.")
|
logger.error("Переменная окружения ENCRYPTION_KEY не установлена! Шифрование паролей RMS не будет работать.")
|
||||||
# Можно либо упасть с ошибкой, либо использовать временный ключ (НЕ РЕКОМЕНДУЕТСЯ для продакшена)
|
logger.warning("Генерируется временный ключ шифрования. Для продакшена ОБЯЗАТЕЛЬНО установите ENCRYPTION_KEY!")
|
||||||
# raise ValueError("ENCRYPTION_KEY environment variable is required.")
|
encryption_key = Fernet.generate_key()
|
||||||
# Для локального запуска без установки переменной, можно временно сгенерировать:
|
|
||||||
logger.warning("Generating temporary encryption key. SET ENCRYPTION_KEY ENV VAR FOR PRODUCTION!")
|
|
||||||
ENCRYPTION_KEY = Fernet.generate_key()
|
|
||||||
else:
|
else:
|
||||||
try:
|
try:
|
||||||
ENCRYPTION_KEY = encryption_key_str.encode('utf-8')
|
encryption_key = encryption_key_str.encode('utf-8')
|
||||||
# Простая проверка, что ключ валидный для Fernet
|
Fernet(encryption_key)
|
||||||
Fernet(ENCRYPTION_KEY)
|
logger.info("Ключ шифрования ENCRYPTION_KEY успешно загружен.")
|
||||||
logger.info("Successfully loaded ENCRYPTION_KEY from environment variable.")
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Invalid ENCRYPTION_KEY format in environment variable: {e}")
|
logger.critical(f"Недопустимый формат ключа ENCRYPTION_KEY: {e}")
|
||||||
raise ValueError("Invalid ENCRYPTION_KEY format.") from e
|
raise ValueError("Недопустимый формат ключа ENCRYPTION_KEY.") from e
|
||||||
|
fernet = Fernet(encryption_key)
|
||||||
|
|
||||||
fernet = Fernet(ENCRYPTION_KEY)
|
|
||||||
|
|
||||||
class User(db.Model, UserMixin):
|
class User(db.Model, UserMixin):
|
||||||
|
# ... код класса User остается без изменений ...
|
||||||
id = db.Column(db.Integer, primary_key=True)
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
username = db.Column(db.String(80), unique=True, nullable=False)
|
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")
|
config = db.relationship('UserConfig', backref='user', uselist=False, cascade="all, delete-orphan")
|
||||||
|
|
||||||
def set_password(self, password):
|
def set_password(self, password):
|
||||||
@@ -47,81 +49,73 @@ class User(db.Model, UserMixin):
|
|||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
return f'<User {self.username}>'
|
return f'<User {self.username}>'
|
||||||
|
|
||||||
|
|
||||||
class UserConfig(db.Model):
|
class UserConfig(db.Model):
|
||||||
|
# ... код класса UserConfig почти без изменений ...
|
||||||
id = db.Column(db.Integer, primary_key=True)
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False, unique=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_host = db.Column(db.String(200))
|
||||||
rms_login = db.Column(db.String(100))
|
rms_login = db.Column(db.String(100))
|
||||||
rms_password_encrypted = db.Column(db.LargeBinary) # Store encrypted password
|
rms_password_encrypted = db.Column(db.LargeBinary)
|
||||||
|
google_cred_file_path = db.Column(db.String(300))
|
||||||
# Google Config
|
|
||||||
google_cred_file_path = db.Column(db.String(300)) # Store path, not content
|
|
||||||
google_sheet_url = db.Column(db.String(300))
|
google_sheet_url = db.Column(db.String(300))
|
||||||
google_client_email = db.Column(db.String(200)) # Store for display
|
google_client_email = db.Column(db.String(200))
|
||||||
|
|
||||||
# Mappings, Presets, Sheets (Stored as JSON strings)
|
|
||||||
mappings_json = db.Column(db.Text, default='{}')
|
|
||||||
presets_json = db.Column(db.Text, default='[]')
|
presets_json = db.Column(db.Text, default='[]')
|
||||||
sheets_json = db.Column(db.Text, default='[]')
|
sheets_json = db.Column(db.Text, default='[]')
|
||||||
|
mappings_json = db.Column(db.Text, default='{}')
|
||||||
# --- Helper properties for easy access ---
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def rms_password(self):
|
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:
|
if self.rms_password_encrypted:
|
||||||
try:
|
try:
|
||||||
return fernet.decrypt(self.rms_password_encrypted).decode('utf-8')
|
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
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@rms_password.setter
|
@rms_password.setter
|
||||||
def rms_password(self, value):
|
def rms_password(self, value):
|
||||||
|
"""Шифрует пароль RMS при установке."""
|
||||||
|
if not fernet:
|
||||||
|
raise RuntimeError("Fernet encryption is not initialized. Call init_encryption(app) first.")
|
||||||
if value:
|
if value:
|
||||||
self.rms_password_encrypted = fernet.encrypt(value.encode('utf-8'))
|
self.rms_password_encrypted = fernet.encrypt(value.encode('utf-8'))
|
||||||
else:
|
else:
|
||||||
self.rms_password_encrypted = None # Or handle as needed
|
self.rms_password_encrypted = None
|
||||||
|
|
||||||
@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)
|
|
||||||
|
|
||||||
|
# ... остальные properties (presets, sheets, mappings) и методы (get_rms_dict, get_google_dict) остаются без изменений ...
|
||||||
@property
|
@property
|
||||||
def presets(self):
|
def presets(self):
|
||||||
return json.loads(self.presets_json or '[]')
|
return json.loads(self.presets_json or '[]')
|
||||||
|
|
||||||
@presets.setter
|
@presets.setter
|
||||||
def presets(self, value):
|
def presets(self, value):
|
||||||
self.presets_json = json.dumps(value or [], ensure_ascii=False)
|
self.presets_json = json.dumps(value or [], ensure_ascii=False)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def sheets(self):
|
def sheets(self):
|
||||||
return json.loads(self.sheets_json or '[]')
|
return json.loads(self.sheets_json or '[]')
|
||||||
|
|
||||||
@sheets.setter
|
@sheets.setter
|
||||||
def sheets(self, value):
|
def sheets(self, value):
|
||||||
self.sheets_json = json.dumps(value or [], ensure_ascii=False)
|
self.sheets_json = json.dumps(value or [], ensure_ascii=False)
|
||||||
|
@property
|
||||||
# Convenience getter for template display
|
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):
|
def get_rms_dict(self):
|
||||||
return {
|
return {
|
||||||
'host': self.rms_host or '',
|
'host': self.rms_host or '',
|
||||||
'login': self.rms_login or '',
|
'login': self.rms_login or '',
|
||||||
'password': self.rms_password or '' # Use decrypted password here if needed for display/form population (be cautious!)
|
'password_is_set': bool(self.rms_password_encrypted)
|
||||||
# Usually, password fields are left blank in forms for security.
|
|
||||||
}
|
}
|
||||||
|
|
||||||
def get_google_dict(self):
|
def get_google_dict(self):
|
||||||
return {
|
return {
|
||||||
'cred_file': self.google_cred_file_path or '', # Maybe just indicate if file exists?
|
'cred_file_is_set': bool(self.google_cred_file_path and os.path.exists(self.google_cred_file_path)),
|
||||||
'sheet_url': self.google_sheet_url or ''
|
'sheet_url': self.google_sheet_url or ''
|
||||||
}
|
}
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
return f'<UserConfig for User ID {self.user_id}>'
|
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 = logging.getLogger(__name__)
|
||||||
logger.setLevel(logging.DEBUG)
|
# Уровень логирования настраивается в основном модуле app.py
|
||||||
|
# logger.setLevel(logging.DEBUG)
|
||||||
|
|
||||||
class ReqModule:
|
class ReqModule:
|
||||||
def __init__(self, host, rmsLogin, password):
|
def __init__(self, host, rmsLogin, password):
|
||||||
self.host = host
|
self.host = host
|
||||||
self.rmsLogin = rmsLogin
|
self.rmsLogin = rmsLogin
|
||||||
|
# Пароль для API iiko/Syrve должен передаваться в виде SHA1-хэша
|
||||||
self.password = hashlib.sha1(password.encode('utf-8')).hexdigest()
|
self.password = hashlib.sha1(password.encode('utf-8')).hexdigest()
|
||||||
self.token = None
|
self.token = None
|
||||||
self.session = requests.Session()
|
self.session = requests.Session()
|
||||||
|
|
||||||
def login(self):
|
def login(self):
|
||||||
|
"""Выполняет авторизацию на сервере RMS и получает токен."""
|
||||||
logger.info(f"Вызов метода login с логином: {self.rmsLogin}")
|
logger.info(f"Вызов метода login с логином: {self.rmsLogin}")
|
||||||
try:
|
try:
|
||||||
response = self.session.post(
|
response = self.session.post(
|
||||||
@@ -22,19 +25,25 @@ class ReqModule:
|
|||||||
data={'login': self.rmsLogin, 'pass': self.password},
|
data={'login': self.rmsLogin, 'pass': self.password},
|
||||||
headers={'Content-Type': 'application/x-www-form-urlencoded'}
|
headers={'Content-Type': 'application/x-www-form-urlencoded'}
|
||||||
)
|
)
|
||||||
if response.status_code == 200:
|
response.raise_for_status() # Вызовет исключение для статусов 4xx/5xx
|
||||||
self.token = response.text
|
self.token = response.text
|
||||||
logger.info(f'Получен токен: {self.token}')
|
logger.info(f'Успешно получен токен: {self.token[:8]}...') # Логируем только часть токена
|
||||||
return True
|
return True
|
||||||
elif response.status_code == 401:
|
except requests.exceptions.HTTPError as e:
|
||||||
logger.error(f'Ошибка авторизации. {response.text}')
|
if e.response.status_code == 401:
|
||||||
raise Exception('Unauthorized')
|
logger.error(f'Ошибка авторизации (401). Неверный логин или пароль. Ответ сервера: {e.response.text}')
|
||||||
|
else:
|
||||||
|
logger.error(f'HTTP ошибка при авторизации: {e}')
|
||||||
|
return False # Возвращаем False вместо выброса исключения для удобства обработки в app.py
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f'Error in get_token: {str(e)}')
|
logger.error(f'Непредвиденная ошибка в login: {str(e)}')
|
||||||
raise
|
raise # Выбрасываем исключение для критических ошибок (например, недоступность хоста)
|
||||||
|
|
||||||
def logout(self):
|
def logout(self):
|
||||||
"""Функция для освобождения токена авторизации."""
|
"""Освобождает токен авторизации на сервере RMS."""
|
||||||
|
if not self.token:
|
||||||
|
logger.warning("Попытка вызова logout без активного токена.")
|
||||||
|
return False
|
||||||
try:
|
try:
|
||||||
response = self.session.post(
|
response = self.session.post(
|
||||||
f'{self.host}/api/logout',
|
f'{self.host}/api/logout',
|
||||||
@@ -42,15 +51,20 @@ class ReqModule:
|
|||||||
headers={'Content-Type': 'application/x-www-form-urlencoded'}
|
headers={'Content-Type': 'application/x-www-form-urlencoded'}
|
||||||
)
|
)
|
||||||
if response.status_code == 200:
|
if response.status_code == 200:
|
||||||
logger.info(f"{self.token} -- Токен освобожден")
|
logger.info(f"Токен {self.token[:8]}... успешно освобожден.")
|
||||||
self.token = None
|
self.token = None
|
||||||
return True
|
return True
|
||||||
|
else:
|
||||||
|
logger.warning(f"Не удалось освободить токен. Статус: {response.status_code}, Ответ: {response.text}")
|
||||||
|
return False
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f'Ошибка освобождения токена. {str(e)}')
|
logger.error(f'Ошибка при освобождении токена: {str(e)}')
|
||||||
raise
|
raise
|
||||||
|
|
||||||
def take_olap(self, params):
|
def take_olap(self, params):
|
||||||
"""Функция для отправки кастомного OLAP-запроса."""
|
"""Отправляет кастомный OLAP-запрос на сервер RMS."""
|
||||||
|
if not self.token:
|
||||||
|
raise Exception("Невозможно выполнить запрос take_olap: отсутствует токен авторизации.")
|
||||||
try:
|
try:
|
||||||
cookies = {'key': self.token}
|
cookies = {'key': self.token}
|
||||||
response = self.session.post(
|
response = self.session.post(
|
||||||
@@ -58,30 +72,32 @@ class ReqModule:
|
|||||||
json=params,
|
json=params,
|
||||||
cookies=cookies
|
cookies=cookies
|
||||||
)
|
)
|
||||||
if response.status_code == 200:
|
response.raise_for_status() # Проверка на HTTP ошибки
|
||||||
return response.json()
|
return response.json()
|
||||||
else:
|
except requests.exceptions.RequestException as e:
|
||||||
logger.error(f'Не удалось получить кастомный OLAP. Status code: {response.status_code} \nText: {response.text}')
|
logger.error(f'Ошибка при выполнении OLAP-запроса: {e}. URL: {e.request.url if e.request else "N/A"}')
|
||||||
raise Exception('Request failed')
|
raise Exception(f'Ошибка сети при запросе OLAP: {e}')
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f'Error in send_olap_request: {str(e)}')
|
logger.error(f'Непредвиденная ошибка в take_olap: {str(e)}')
|
||||||
raise
|
raise
|
||||||
|
|
||||||
def take_presets(self):
|
def take_presets(self):
|
||||||
"""Функция генерации шаблонов OLAP-запросов"""
|
"""Получает список доступных OLAP-отчетов (пресетов) с сервера RMS."""
|
||||||
|
if not self.token:
|
||||||
|
raise Exception("Невозможно выполнить запрос take_presets: отсутствует токен авторизации.")
|
||||||
try:
|
try:
|
||||||
cookies = {'key': self.token}
|
cookies = {'key': self.token}
|
||||||
response = self.session.get(
|
response = self.session.get(
|
||||||
f'{self.host}/api/v2/reports/olap/presets',
|
f'{self.host}/api/v2/reports/olap/presets',
|
||||||
cookies=cookies
|
cookies=cookies
|
||||||
)
|
)
|
||||||
if response.status_code == 200:
|
response.raise_for_status() # Проверка на HTTP ошибки
|
||||||
presets = response.json()
|
presets = response.json()
|
||||||
logger.info('Пресеты переданы в генератор шаблонов')
|
logger.info(f"Успешно получено {len(presets)} пресетов OLAP-отчетов.")
|
||||||
return presets
|
return presets
|
||||||
else:
|
except requests.exceptions.RequestException as e:
|
||||||
logger.error(f"Не удалось получить пресеты. {response.text}")
|
logger.error(f"Сетевая ошибка при получении пресетов: {e}")
|
||||||
raise Exception('Take presets failed')
|
raise Exception(f'Ошибка сети при получении пресетов: {e}')
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f'Ошибка получения пресетов: {str(e)}')
|
logger.error(f'Непредвиденная ошибка в take_presets: {str(e)}')
|
||||||
raise
|
raise
|
||||||
BIN
requirements.txt
BIN
requirements.txt
Binary file not shown.
538
routes.py
Normal file
538
routes.py
Normal file
@@ -0,0 +1,538 @@
|
|||||||
|
# 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, scheduler
|
||||||
|
# Импортируем наши классы и утилиты
|
||||||
|
from models import User, UserConfig
|
||||||
|
from google_sheets import GoogleSheets
|
||||||
|
from request_module import ReqModule
|
||||||
|
from utils import calculate_period_dates, get_dates, generate_template_from_preset, render_temp
|
||||||
|
|
||||||
|
|
||||||
|
# --- Создание блюпринта ---
|
||||||
|
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))
|
||||||
|
|
||||||
|
def _parse_cron_string(cron_str):
|
||||||
|
"""Парсит строку cron в словарь для APScheduler. Локальная копия для удобства."""
|
||||||
|
parts = cron_str.split()
|
||||||
|
if len(parts) != 5:
|
||||||
|
raise ValueError("Invalid cron string format. Expected 5 parts.")
|
||||||
|
keys = ['minute', 'hour', 'day', 'month', 'day_of_week']
|
||||||
|
return {keys[i]: part for i, part in enumerate(parts)}
|
||||||
|
|
||||||
|
# --- Маршруты ---
|
||||||
|
|
||||||
|
@main_bp.route('/language/<language>')
|
||||||
|
def set_language(language=None):
|
||||||
|
session['language'] = language
|
||||||
|
return redirect(request.referrer or url_for('.index'))
|
||||||
|
|
||||||
|
@main_bp.route('/login', methods=['GET', 'POST'])
|
||||||
|
def login():
|
||||||
|
if current_user.is_authenticated:
|
||||||
|
return redirect(url_for('.index'))
|
||||||
|
if request.method == 'POST':
|
||||||
|
username = request.form.get('username')
|
||||||
|
password = request.form.get('password')
|
||||||
|
user = User.query.filter_by(username=username).first()
|
||||||
|
if user is None or not user.check_password(password):
|
||||||
|
flash(_('Invalid username or password'), 'error')
|
||||||
|
return redirect(url_for('.login'))
|
||||||
|
login_user(user, remember=request.form.get('remember'))
|
||||||
|
flash(_('Login successful!'), 'success')
|
||||||
|
next_page = request.args.get('next')
|
||||||
|
return redirect(next_page or url_for('.index'))
|
||||||
|
return render_template('login.html')
|
||||||
|
|
||||||
|
@main_bp.route('/register', methods=['GET', 'POST'])
|
||||||
|
def register():
|
||||||
|
if current_user.is_authenticated:
|
||||||
|
return redirect(url_for('.index'))
|
||||||
|
if request.method == 'POST':
|
||||||
|
username = request.form.get('username')
|
||||||
|
password = request.form.get('password')
|
||||||
|
if not username or not password:
|
||||||
|
flash(_('Username and password are required.'), 'error')
|
||||||
|
return redirect(url_for('.register'))
|
||||||
|
if User.query.filter_by(username=username).first():
|
||||||
|
flash(_('Username already exists.'), 'error')
|
||||||
|
return redirect(url_for('.register'))
|
||||||
|
|
||||||
|
user = User(username=username)
|
||||||
|
user.set_password(password)
|
||||||
|
user.config = UserConfig()
|
||||||
|
db.session.add(user)
|
||||||
|
try:
|
||||||
|
db.session.commit()
|
||||||
|
flash(_('Registration successful! Please log in.'), 'success')
|
||||||
|
return redirect(url_for('.login'))
|
||||||
|
except Exception as e:
|
||||||
|
db.session.rollback()
|
||||||
|
flash(_('An error occurred during registration. Please try again.'), 'error')
|
||||||
|
return redirect(url_for('.register'))
|
||||||
|
|
||||||
|
return render_template('register.html')
|
||||||
|
|
||||||
|
@main_bp.route('/logout')
|
||||||
|
@login_required
|
||||||
|
def logout():
|
||||||
|
logout_user()
|
||||||
|
flash(_('You have been logged out.'), 'success')
|
||||||
|
return redirect(url_for('.index'))
|
||||||
|
|
||||||
|
@main_bp.route('/')
|
||||||
|
@login_required
|
||||||
|
def index():
|
||||||
|
config = g.user_config
|
||||||
|
clean_mappings = {}
|
||||||
|
if config.mappings:
|
||||||
|
for key, value in config.mappings.items():
|
||||||
|
if isinstance(value, dict):
|
||||||
|
clean_mappings[key] = value
|
||||||
|
else:
|
||||||
|
clean_mappings[key] = {
|
||||||
|
'report_id': value,
|
||||||
|
'schedule_cron': None,
|
||||||
|
'schedule_period': None
|
||||||
|
}
|
||||||
|
return render_template(
|
||||||
|
'index.html',
|
||||||
|
rms_config=config.get_rms_dict(),
|
||||||
|
google_config=config.get_google_dict(),
|
||||||
|
presets=config.presets,
|
||||||
|
sheets=config.sheets,
|
||||||
|
mappings=clean_mappings,
|
||||||
|
client_email=config.google_client_email
|
||||||
|
)
|
||||||
|
|
||||||
|
@main_bp.route('/configure_rms', methods=['POST'])
|
||||||
|
@login_required
|
||||||
|
def configure_rms():
|
||||||
|
config = g.user_config
|
||||||
|
try:
|
||||||
|
host = request.form.get('host', '').strip()
|
||||||
|
login = request.form.get('login', '').strip()
|
||||||
|
password = request.form.get('password', '')
|
||||||
|
|
||||||
|
if not config.rms_password and not password:
|
||||||
|
flash(_('Password is required for the first time.'), 'error')
|
||||||
|
return redirect(url_for('.index'))
|
||||||
|
|
||||||
|
if not host or not login:
|
||||||
|
flash(_('Host and Login fields must be filled.'), 'error')
|
||||||
|
return redirect(url_for('.index'))
|
||||||
|
|
||||||
|
effective_password = password if password else config.rms_password
|
||||||
|
|
||||||
|
req_module = ReqModule(host, login, effective_password)
|
||||||
|
if req_module.login():
|
||||||
|
presets_data = req_module.take_presets()
|
||||||
|
req_module.logout()
|
||||||
|
|
||||||
|
config.rms_host = host
|
||||||
|
config.rms_login = login
|
||||||
|
if password:
|
||||||
|
config.rms_password = password
|
||||||
|
config.presets = presets_data
|
||||||
|
|
||||||
|
db.session.commit()
|
||||||
|
flash(_('Successfully authorized on RMS server. Received %(num)s presets.', num=len(presets_data)), 'success')
|
||||||
|
else:
|
||||||
|
flash(_('Authorization error on RMS server. Check host, login or password.'), 'error')
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
db.session.rollback()
|
||||||
|
flash(_('Error configuring RMS: %(error)s', error=str(e)), 'error')
|
||||||
|
|
||||||
|
return redirect(url_for('.index'))
|
||||||
|
|
||||||
|
@main_bp.route('/upload_credentials', methods=['POST'])
|
||||||
|
@login_required
|
||||||
|
def upload_credentials():
|
||||||
|
config = g.user_config
|
||||||
|
if 'cred_file' not in request.files or request.files['cred_file'].filename == '':
|
||||||
|
flash(_('No file was selected.'), 'error')
|
||||||
|
return redirect(url_for('.index'))
|
||||||
|
|
||||||
|
cred_file = request.files['cred_file']
|
||||||
|
filename = cred_file.filename
|
||||||
|
# Получаем путь для сохранения файла в папке пользователя
|
||||||
|
user_cred_path = get_user_upload_path(filename)
|
||||||
|
temp_path = None
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Сначала сохраняем файл во временную директорию для проверки
|
||||||
|
temp_dir = os.path.join(current_app.config['DATA_DIR'], "temp")
|
||||||
|
os.makedirs(temp_dir, exist_ok=True)
|
||||||
|
temp_path = os.path.join(temp_dir, f"temp_{current_user.id}_{filename}")
|
||||||
|
cred_file.save(temp_path)
|
||||||
|
|
||||||
|
with open(temp_path, 'r', encoding='utf-8') as f:
|
||||||
|
cred_data = json.load(f)
|
||||||
|
client_email = cred_data.get('client_email')
|
||||||
|
|
||||||
|
if not client_email:
|
||||||
|
flash(_('Could not find client_email in the credentials file.'), 'error')
|
||||||
|
# Не забываем удалить временный файл при ошибке
|
||||||
|
if os.path.exists(temp_path):
|
||||||
|
os.remove(temp_path)
|
||||||
|
return redirect(url_for('.index'))
|
||||||
|
|
||||||
|
# Если все хорошо, перемещаем файл из временной папки в постоянную
|
||||||
|
shutil.move(temp_path, user_cred_path)
|
||||||
|
|
||||||
|
# Сохраняем путь к файлу и email в базу данных
|
||||||
|
config.google_cred_file_path = user_cred_path
|
||||||
|
config.google_client_email = client_email
|
||||||
|
config.sheets = [] # Сбрасываем список листов при смене credentials
|
||||||
|
|
||||||
|
db.session.commit()
|
||||||
|
flash(_('Credentials file successfully uploaded. Email: %(email)s', email=client_email), 'success')
|
||||||
|
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
flash(_('Error: Uploaded file is not a valid JSON.'), 'error')
|
||||||
|
except Exception as e:
|
||||||
|
db.session.rollback()
|
||||||
|
flash(_('Error processing credentials: %(error)s', error=str(e)), 'error')
|
||||||
|
finally:
|
||||||
|
# Гарантированно удаляем временный файл, если он еще существует
|
||||||
|
if temp_path and os.path.exists(temp_path):
|
||||||
|
os.remove(temp_path)
|
||||||
|
|
||||||
|
return redirect(url_for('.index'))
|
||||||
|
|
||||||
|
@main_bp.route('/configure_google', methods=['POST'])
|
||||||
|
@login_required
|
||||||
|
def configure_google():
|
||||||
|
config = g.user_config
|
||||||
|
sheet_url = request.form.get('sheet_url', '').strip()
|
||||||
|
|
||||||
|
if not sheet_url:
|
||||||
|
flash(_('Sheet URL must be provided.'), 'error')
|
||||||
|
return redirect(url_for('.index'))
|
||||||
|
|
||||||
|
config.google_sheet_url = sheet_url
|
||||||
|
|
||||||
|
cred_path = config.google_cred_file_path
|
||||||
|
if not cred_path or not os.path.isfile(cred_path):
|
||||||
|
flash(_('Please upload a valid credentials file first.'), 'warning')
|
||||||
|
config.sheets = []
|
||||||
|
db.session.commit()
|
||||||
|
return redirect(url_for('.index'))
|
||||||
|
|
||||||
|
try:
|
||||||
|
gs_client = GoogleSheets(cred_path, sheet_url)
|
||||||
|
sheets_data = gs_client.get_sheets()
|
||||||
|
config.sheets = sheets_data
|
||||||
|
|
||||||
|
db.session.commit()
|
||||||
|
flash(_('Successfully connected to Google Sheets. Found %(num)s sheets. Settings saved.', num=len(sheets_data)), 'success')
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
db.session.rollback()
|
||||||
|
config.sheets = []
|
||||||
|
flash(_('Error connecting to Google Sheets: %(error)s. Check the URL and service account permissions.', error=str(e)), 'error')
|
||||||
|
try:
|
||||||
|
db.session.commit()
|
||||||
|
except Exception:
|
||||||
|
db.session.rollback()
|
||||||
|
|
||||||
|
return redirect(url_for('.index'))
|
||||||
|
|
||||||
|
@main_bp.route('/mapping_set', methods=['POST'])
|
||||||
|
@login_required
|
||||||
|
def mapping_set():
|
||||||
|
config = g.user_config
|
||||||
|
try:
|
||||||
|
new_mappings = {}
|
||||||
|
# Сохраняем существующие настройки расписания при обновлении отчетов
|
||||||
|
current_mappings = config.mappings or {}
|
||||||
|
|
||||||
|
for sheet in config.sheets:
|
||||||
|
report_key = f"sheet_{sheet['id']}"
|
||||||
|
selected_report_id = request.form.get(report_key)
|
||||||
|
|
||||||
|
if selected_report_id:
|
||||||
|
# Получаем существующие данные расписания для этого листа
|
||||||
|
existing_schedule = current_mappings.get(sheet['title'], {})
|
||||||
|
schedule_cron = None
|
||||||
|
schedule_period = None
|
||||||
|
|
||||||
|
if isinstance(existing_mapping_value, dict):
|
||||||
|
schedule_cron = existing_mapping_value.get('schedule_cron')
|
||||||
|
schedule_period = existing_mapping_value.get('schedule_period')
|
||||||
|
|
||||||
|
# Сохраняем новые настройки расписания в новом словаре
|
||||||
|
new_mappings[sheet['title']] = {
|
||||||
|
'report_id': selected_report_id,
|
||||||
|
'schedule_cron': schedule_cron,
|
||||||
|
'schedule_period': schedule_period
|
||||||
|
}
|
||||||
|
|
||||||
|
config.mappings = new_mappings
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
flash(_('Mappings updated successfully.'), 'success')
|
||||||
|
except Exception as e:
|
||||||
|
db.session.rollback()
|
||||||
|
flash(_('Error updating mappings: %(error)s', error=str(e)), 'error')
|
||||||
|
|
||||||
|
return redirect(url_for('.index'))
|
||||||
|
|
||||||
|
|
||||||
|
def execute_olap_export(user_id, sheet_title, start_date_str=None, end_date_str=None):
|
||||||
|
"""
|
||||||
|
Основная логика выгрузки OLAP-отчета. Может вызываться как из эндпоинта, так и из планировщика.
|
||||||
|
Если start_date_str и end_date_str не переданы, вычисляет их на основе расписания.
|
||||||
|
"""
|
||||||
|
app = current_app._get_current_object()
|
||||||
|
with app.app_context():
|
||||||
|
user = db.session.get(User, user_id)
|
||||||
|
if not user:
|
||||||
|
app.logger.error(f"Task failed: User with ID {user_id} not found.")
|
||||||
|
return
|
||||||
|
|
||||||
|
config = user.config
|
||||||
|
req_module = None
|
||||||
|
|
||||||
|
try:
|
||||||
|
mappings = config.mappings
|
||||||
|
mapping_info = mappings.get(sheet_title)
|
||||||
|
|
||||||
|
if not mapping_info or not mapping_info.get('report_id'):
|
||||||
|
raise ValueError(f"No report is assigned to sheet '{sheet_title}'.")
|
||||||
|
|
||||||
|
report_id = mapping_info['report_id']
|
||||||
|
|
||||||
|
# Если даты не переданы (вызов из планировщика), вычисляем их
|
||||||
|
if not start_date_str or not end_date_str:
|
||||||
|
period_key = mapping_info.get('schedule_period')
|
||||||
|
if not period_key:
|
||||||
|
raise ValueError(f"Scheduled task for sheet '{sheet_title}' is missing a period setting.")
|
||||||
|
from_date, to_date = calculate_period_dates(period_key)
|
||||||
|
app.logger.info(f"Executing scheduled job for user {user_id}, sheet '{sheet_title}', period '{period_key}' ({from_date} to {to_date})")
|
||||||
|
else:
|
||||||
|
from_date, to_date = get_dates(start_date_str, end_date_str)
|
||||||
|
app.logger.info(f"Executing manual job for user {user_id}, sheet '{sheet_title}' ({from_date} to {to_date})")
|
||||||
|
|
||||||
|
# Проверка полноты конфигурации
|
||||||
|
if not all([config.rms_host, config.rms_login, config.rms_password, config.google_cred_file_path, config.google_sheet_url]):
|
||||||
|
raise ValueError('RMS or Google Sheets configuration is incomplete.')
|
||||||
|
|
||||||
|
preset = next((p for p in config.presets if p.get('id') == report_id), None)
|
||||||
|
if not preset:
|
||||||
|
raise ValueError(f'Preset with ID "{report_id}" not found in saved configuration.')
|
||||||
|
|
||||||
|
template = generate_template_from_preset(preset)
|
||||||
|
json_body = render_temp(template, {"from_date": from_date, "to_date": to_date})
|
||||||
|
|
||||||
|
req_module = ReqModule(config.rms_host, config.rms_login, config.rms_password)
|
||||||
|
gs_client = GoogleSheets(config.google_cred_file_path, config.google_sheet_url)
|
||||||
|
|
||||||
|
if req_module.login():
|
||||||
|
result = req_module.take_olap(json_body)
|
||||||
|
|
||||||
|
# Код обработки данных (остается без изменений)
|
||||||
|
if 'data' not in result or not isinstance(result['data'], list):
|
||||||
|
raise ValueError(f'Unexpected response format from RMS for report "{preset.get("name", report_id)}".')
|
||||||
|
|
||||||
|
report_data = result['data']
|
||||||
|
|
||||||
|
if not report_data:
|
||||||
|
gs_client.clear_and_write_data(sheet_title, [])
|
||||||
|
app.logger.warning(f"Report '{preset.get('name', report_id)}' for user {user_id} returned no data. Sheet '{sheet_title}' cleared.")
|
||||||
|
return
|
||||||
|
|
||||||
|
processed_data = []
|
||||||
|
first_item = report_data[0]
|
||||||
|
is_pivoted = isinstance(first_item, dict) and 'row' in first_item and 'cells' in first_item
|
||||||
|
|
||||||
|
if is_pivoted:
|
||||||
|
for row_item in report_data:
|
||||||
|
row_values = row_item.get('row', {})
|
||||||
|
cells = row_item.get('cells', [])
|
||||||
|
if not cells:
|
||||||
|
processed_data.append(row_values.copy())
|
||||||
|
else:
|
||||||
|
for cell in cells:
|
||||||
|
new_flat_row = row_values.copy()
|
||||||
|
new_flat_row.update(cell.get('col', {}))
|
||||||
|
new_flat_row.update(cell.get('values', {}))
|
||||||
|
processed_data.append(new_flat_row)
|
||||||
|
else:
|
||||||
|
processed_data = [item for item in report_data if isinstance(item, dict)]
|
||||||
|
|
||||||
|
all_keys = set()
|
||||||
|
for row in processed_data:
|
||||||
|
all_keys.update(row.keys())
|
||||||
|
|
||||||
|
row_group_fields = preset.get('groupByRowFields', [])
|
||||||
|
col_group_fields = preset.get('groupByColFields', [])
|
||||||
|
agg_fields = preset.get('aggregateFields', [])
|
||||||
|
|
||||||
|
ordered_headers = []
|
||||||
|
for field in row_group_fields + col_group_fields + agg_fields:
|
||||||
|
if field in all_keys:
|
||||||
|
ordered_headers.append(field)
|
||||||
|
all_keys.remove(field)
|
||||||
|
ordered_headers.extend(sorted(list(all_keys)))
|
||||||
|
|
||||||
|
data_to_insert = [ordered_headers]
|
||||||
|
for row in processed_data:
|
||||||
|
row_data = []
|
||||||
|
for header in ordered_headers:
|
||||||
|
value = row.get(header, '')
|
||||||
|
value_str = str(value) if value is not None else ''
|
||||||
|
if value_str.startswith(('=', '+', '-', '@')):
|
||||||
|
row_data.append("'" + value_str)
|
||||||
|
else:
|
||||||
|
row_data.append(value_str)
|
||||||
|
data_to_insert.append(row_data)
|
||||||
|
|
||||||
|
gs_client.clear_and_write_data(sheet_title, data_to_insert)
|
||||||
|
app.logger.info(f"Successfully wrote {len(data_to_insert) - 1} rows to sheet '{sheet_title}' for user {user_id}.")
|
||||||
|
else:
|
||||||
|
raise Exception('Error authorizing on RMS server when trying to get a report.')
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
app.logger.error(f"Error in execute_olap_export for user {user_id}, sheet '{sheet_title}': {e}", exc_info=True)
|
||||||
|
finally:
|
||||||
|
if req_module and req_module.token:
|
||||||
|
req_module.logout()
|
||||||
|
|
||||||
|
|
||||||
|
@main_bp.route('/render_olap', methods=['POST'])
|
||||||
|
@login_required
|
||||||
|
def render_olap():
|
||||||
|
sheet_title = next((key for key in request.form if key.startswith('render_')), '').replace('render_', '')
|
||||||
|
start_date = request.form.get('start_date')
|
||||||
|
end_date = request.form.get('end_date')
|
||||||
|
|
||||||
|
if not sheet_title:
|
||||||
|
flash(_('Error: Could not determine which sheet to render the report for.'), 'error')
|
||||||
|
return redirect(url_for('.index'))
|
||||||
|
if not start_date or not end_date:
|
||||||
|
flash(_('Error: Start date and end date are required for manual rendering.'), 'error')
|
||||||
|
return redirect(url_for('.index'))
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Просто вызываем нашу новую универсальную функцию
|
||||||
|
execute_olap_export(current_user.id, sheet_title, start_date, end_date)
|
||||||
|
flash(_('Report generation task for sheet "%(sheet)s" has been started. The data will appear shortly.', sheet=sheet_title), 'success')
|
||||||
|
except Exception as e:
|
||||||
|
flash(_('An unexpected error occurred: %(error)s', error=str(e)), 'error')
|
||||||
|
current_app.logger.error(f"Unexpected error in render_olap route: {e}", exc_info=True)
|
||||||
|
|
||||||
|
return redirect(url_for('.index'))
|
||||||
|
|
||||||
|
|
||||||
|
@main_bp.route('/save_schedule', methods=['POST'])
|
||||||
|
@login_required
|
||||||
|
def save_schedule():
|
||||||
|
config = g.user_config
|
||||||
|
try:
|
||||||
|
updated_mappings = config.mappings or {}
|
||||||
|
|
||||||
|
for sheet_title, params in updated_mappings.items():
|
||||||
|
if not isinstance(params, dict):
|
||||||
|
continue
|
||||||
|
|
||||||
|
cron_value = request.form.get(f"cron-{sheet_title}", "").strip()
|
||||||
|
period_value = request.form.get(f"period-{sheet_title}", "").strip()
|
||||||
|
|
||||||
|
# Обработка кастомного периода N дней
|
||||||
|
if period_value == 'last_N_days':
|
||||||
|
try:
|
||||||
|
custom_days = int(request.form.get(f"custom_days-{sheet_title}", 0))
|
||||||
|
if custom_days > 0:
|
||||||
|
period_value = f"last_{custom_days}_days"
|
||||||
|
else:
|
||||||
|
period_value = "" # Сбрасываем, если введено 0 или некорректное значение
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
period_value = ""
|
||||||
|
|
||||||
|
params['schedule_cron'] = cron_value if cron_value else None
|
||||||
|
params['schedule_period'] = period_value if period_value else None
|
||||||
|
|
||||||
|
job_id = f"user_{current_user.id}_sheet_{sheet_title}"
|
||||||
|
|
||||||
|
# Удаляем старую задачу, если она была
|
||||||
|
if scheduler.get_job(job_id):
|
||||||
|
scheduler.remove_job(job_id)
|
||||||
|
current_app.logger.info(f"Removed existing job: {job_id}")
|
||||||
|
|
||||||
|
# Добавляем новую задачу, если есть cron-расписание
|
||||||
|
if cron_value and period_value:
|
||||||
|
try:
|
||||||
|
cron_params = _parse_cron_string(cron_value)
|
||||||
|
scheduler.add_job(
|
||||||
|
id=job_id,
|
||||||
|
func=execute_olap_export,
|
||||||
|
trigger='cron',
|
||||||
|
args=[current_user.id, sheet_title],
|
||||||
|
replace_existing=True,
|
||||||
|
**cron_params
|
||||||
|
)
|
||||||
|
current_app.logger.info(f"Added/updated job: {job_id} with schedule '{cron_value}'")
|
||||||
|
except ValueError as ve:
|
||||||
|
flash(_('Invalid cron format for sheet "%(sheet)s": %(error)s', sheet=sheet_title, error=ve), 'error')
|
||||||
|
except Exception as e:
|
||||||
|
flash(_('Error scheduling job for sheet "%(sheet)s": %(error)s', sheet=sheet_title, error=e), 'error')
|
||||||
|
|
||||||
|
config.mappings = updated_mappings
|
||||||
|
db.session.commit()
|
||||||
|
flash(_('Schedule settings saved successfully.'), 'success')
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
db.session.rollback()
|
||||||
|
flash(_('An error occurred while saving the schedule: %(error)s', error=str(e)), 'error')
|
||||||
|
current_app.logger.error(f"Error in save_schedule: {e}", exc_info=True)
|
||||||
|
|
||||||
|
return redirect(url_for('.index'))
|
||||||
@@ -107,17 +107,18 @@ button:disabled {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.content {
|
.content {
|
||||||
padding: 15px;
|
padding: 0 18px; /* Добавляем горизонтальный паддинг, но убираем вертикальный */
|
||||||
display: none;
|
max-height: 0; /* Изначально контент сжат по высоте */
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background-color: #fefefe; /* Very light background */
|
transition: max-height 0.3s ease-out; /* Плавный переход для высоты */
|
||||||
|
background-color: #fefefe;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 500px;
|
max-width: 500px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
border: 1px solid #eee;
|
border: 1px solid #eee;
|
||||||
border-top: none; /* No top border to connect visually with collapsible */
|
border-top: none;
|
||||||
border-radius: 0 0 8px 8px; /* Rounded corners only at the bottom */
|
border-radius: 0 0 8px 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.content h3 { /* Style for internal content headings */
|
.content h3 { /* Style for internal content headings */
|
||||||
@@ -257,3 +258,31 @@ small {
|
|||||||
vertical-align: middle; /* Выравнивание по вертикали */
|
vertical-align: middle; /* Выравнивание по вертикали */
|
||||||
box-shadow: none; /* Убираем тень, если есть */
|
box-shadow: none; /* Убираем тень, если есть */
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.cron-constructor {
|
||||||
|
border: 1px solid #e0e0e0;
|
||||||
|
padding: 15px;
|
||||||
|
margin-top: 15px;
|
||||||
|
border-radius: 5px;
|
||||||
|
background-color: #fdfdfd;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cron-constructor h4 {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cron-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cron-row select {
|
||||||
|
flex-grow: 1;
|
||||||
|
min-width: 60px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#cron-output {
|
||||||
|
background-color: #e9ecef;
|
||||||
|
font-family: monospace;
|
||||||
|
}
|
||||||
@@ -1,24 +1,28 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="ru">
|
<html lang="{{ session.get('language', 'ru') }}">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<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') }}">
|
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
||||||
<h1>MyHoreca OLAP-to-GoogleSheets</h1>
|
<h1>{{ _('MyHoreca OLAP-to-GoogleSheets') }}</h1>
|
||||||
|
|
||||||
{% if current_user.is_authenticated %}
|
{% if current_user.is_authenticated %}
|
||||||
<div class="user-info">
|
<div class="user-info">
|
||||||
Logged in as: <strong>{{ current_user.username }}</strong> |
|
{{ _('Logged in as:') }} <strong>{{ current_user.username }}</strong> |
|
||||||
<a href="{{ url_for('logout') }}">Logout</a>
|
<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>
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
<div class="user-info">
|
<div class="user-info">
|
||||||
<a href="{{ url_for('login') }}">Login</a> |
|
<a href="{{ url_for('.login') }}">{{ _('Login') }}</a> |
|
||||||
<a href="{{ url_for('register') }}">Register</a>
|
<a href="{{ url_for('.register') }}">{{ _('Register') }}</a>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
@@ -34,105 +38,105 @@
|
|||||||
{% if current_user.is_authenticated %}
|
{% if current_user.is_authenticated %}
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<!-- Секция RMS-сервера -->
|
<!-- Секция 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">
|
<div class="content">
|
||||||
<h3>RMS Server Configuration</h3>
|
<h3>{{ _('RMS Server Configuration') }}</h3>
|
||||||
<p>
|
<p>
|
||||||
Enter the details for your RMS server API. This information is used to connect,
|
{% 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.
|
authenticate, and retrieve the list of available OLAP report presets.{% endtrans %}
|
||||||
</p>
|
</p>
|
||||||
<form action="{{ url_for('configure_rms') }}" method="post">
|
<form action="{{ url_for('.configure_rms') }}" method="post">
|
||||||
<label for="host">RMS-host (e.g., http://your-rms-api.com/resto):</label>
|
<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 />
|
<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 />
|
<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>
|
<label for="password">{{ _('API Password:') }}</label>
|
||||||
<input type="password" id="password" name="password" value="" {% if not rms_config.get('password') %}required{% endif %} /><br />
|
<input type="password" id="password" name="password" value="" {% if not rms_config.password_is_set %}required{% endif %} /><br />
|
||||||
{% if rms_config.get('password') %}
|
{% if rms_config.password_is_set %}
|
||||||
<small>Password is saved and will be used. Enter only if you need to change it.</small><br/>
|
<small>{{ _('Password is saved. Enter a new one only if you need to change it.') }}</small><br/>
|
||||||
{% else %}
|
{% else %}
|
||||||
<small>Enter the API password for your RMS server.</small><br/>
|
<small>{{ _('Enter the API password for your RMS server.') }}</small><br/>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<button type="submit">Check and Save RMS-config</button>
|
<button type="submit">{{ _('Check and Save RMS-config') }}</button>
|
||||||
</form>
|
</form>
|
||||||
{% if presets %}
|
{% 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') %}
|
{% 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 %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Секция Google-таблиц -->
|
<!-- Секция Google-таблиц -->
|
||||||
<button type="button" class="collapsible" {% if not rms_config.get('host') %}disabled title="Configure RMS first"{% endif %}>
|
<button type="button" class="collapsible" {% if not rms_config.get('host') %}disabled title="{{ _('Configure RMS first') }}"{% endif %}>
|
||||||
2. Google Sheets Configuration
|
2. {{ _('Google Sheets Configuration') }}
|
||||||
</button>
|
</button>
|
||||||
<div class="content">
|
<div class="content">
|
||||||
<h3>Google Sheets Configuration</h3>
|
<h3>{{ _('Google Sheets Configuration') }}</h3>
|
||||||
<p>
|
<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
|
credentials for a Google Service Account. This account will act on behalf
|
||||||
of the application.
|
of the application.{% endtrans %}
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
<strong>How to get credentials:</strong>
|
<strong>{{ _('How to get credentials:') }}</strong>
|
||||||
<br>1. Go to Google Cloud Console.
|
<br>1. {{ _('Go to Google Cloud Console.') }}
|
||||||
<br>2. Create a new project or select an existing one.
|
<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>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>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>5. {{ _('Give it a name and grant it the "Editor" role.') }}
|
||||||
<br>6. Create a JSON key for the service account. Download this file.
|
<br>6. {{ _('Create a JSON key for the service account and download the file.') }}
|
||||||
<br>7. Share your target Google Sheet with the service account's email address (found in the downloaded JSON file, key `client_email`).
|
<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>
|
</p>
|
||||||
<form action="{{ url_for('upload_credentials') }}" method="post" enctype="multipart/form-data">
|
<form action="{{ url_for('.upload_credentials') }}" method="post" enctype="multipart/form-data">
|
||||||
<label for="cred_file">Service Account Credentials (JSON file):</label>
|
<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 />
|
<input type="file" id="cred_file" name="cred_file" accept=".json" /><br />
|
||||||
{% if client_email %}
|
{% if client_email %}
|
||||||
<p><strong>Current Service Account Email:</strong> <code>{{ client_email }}</code></p>
|
<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/>
|
<small>{{ _('Upload a new file only if you need to change credentials.') }}</small><br/>
|
||||||
{% else %}
|
{% 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 %}
|
{% endif %}
|
||||||
<button type="submit">Upload Credentials</button>
|
<button type="submit">{{ _('Upload Credentials') }}</button>
|
||||||
</form>
|
</form>
|
||||||
<hr>
|
<hr>
|
||||||
<p>
|
<p>
|
||||||
Enter the URL of the Google Sheet you want to use. The service account email
|
{% 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.
|
(shown above after uploading credentials) must have edit access to this sheet.{% endtrans %}
|
||||||
</p>
|
</p>
|
||||||
<form action="{{ url_for('configure_google') }}" method="post">
|
<form action="{{ url_for('.configure_google') }}" method="post">
|
||||||
<label for="sheet_url">Google Sheet URL:</label>
|
<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/..."/>
|
<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 %}>
|
<button type="submit" {% if not client_email %}disabled title="{{ _('Upload Service Account Credentials first') }}"{% endif %}>
|
||||||
Connect Google Sheets
|
{{ _('Connect Google Sheets') }}
|
||||||
</button>
|
</button>
|
||||||
{% if sheets %}
|
{% 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') %}
|
{% 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 %}
|
{% endif %}
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Секция сопоставления листов-отчетов -->
|
<!-- Секция сопоставления листов-отчетов -->
|
||||||
<button type="button" class="collapsible" {% if not sheets or not presets %}disabled title="Configure RMS and Google Sheets first"{% endif %}>
|
<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
|
3. {{ _('Mapping Sheets to OLAP Reports') }}
|
||||||
</button>
|
</button>
|
||||||
<div class="content">
|
<div class="content">
|
||||||
<h3>Map Worksheets to OLAP Reports</h3>
|
<h3>{{ _('Map Worksheets to OLAP Reports') }}</h3>
|
||||||
<p>
|
<p>
|
||||||
Select which OLAP report from RMS should be rendered into each specific worksheet
|
{% trans %}Select which OLAP report from RMS should be rendered into each specific worksheet
|
||||||
(tab) in your Google Sheet.
|
(tab) in your Google Sheet.{% endtrans %}
|
||||||
</p>
|
</p>
|
||||||
{% if sheets and presets %}
|
{% if sheets and presets %}
|
||||||
<form action="{{ url_for('mapping_set') }}" method="post">
|
<form action="{{ url_for('.mapping_set') }}" method="post">
|
||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Worksheet (Google Sheets)</th>
|
<th>{{ _('Worksheet (Google Sheets)') }}</th>
|
||||||
<th>OLAP-report (RMS)</th>
|
<th>{{ _('OLAP-report (RMS)') }}</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -140,11 +144,10 @@
|
|||||||
<tr>
|
<tr>
|
||||||
<td>{{ sheet.title }}</td>
|
<td>{{ sheet.title }}</td>
|
||||||
<td>
|
<td>
|
||||||
<!-- Use sheet.id for unique name -->
|
|
||||||
<select name="sheet_{{ sheet.id }}">
|
<select name="sheet_{{ sheet.id }}">
|
||||||
<option value="">-- Not set --</option>
|
<option value="">-- {{ _('Not set') }} --</option>
|
||||||
{% for preset in presets %}
|
{% for preset in presets %}
|
||||||
<option value="{{ preset['id'] }}" {% if mappings.get(sheet.title) == preset['id'] %}selected{% endif %}>
|
<option value="{{ preset['id'] }}" {% if mappings.get(sheet.title, {}).get('report_id') == preset['id'] %}selected{% endif %}>
|
||||||
{{ preset['name'] }} ({{ preset['id'] }})
|
{{ preset['name'] }} ({{ preset['id'] }})
|
||||||
</option>
|
</option>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
@@ -154,58 +157,54 @@
|
|||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
<button type="submit">Save Mappings</button>
|
<button type="submit">{{ _('Save Mappings') }}</button>
|
||||||
</form>
|
</form>
|
||||||
{% elif not sheets and not presets %}
|
{% 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 %}
|
{% 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 %}
|
{% elif not presets %}
|
||||||
<p>OLAP presets are not loaded. Check RMS configuration.</p>
|
<p>{{ _('OLAP presets are not loaded. Check RMS configuration.') }}</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Секция отрисовки отчетов на листах -->
|
<!-- Секция отрисовки отчетов на листах -->
|
||||||
<button type="button" class="collapsible" {% if not mappings or mappings|length == 0 %}disabled title="Configure Mappings first"{% endif %}>
|
<button type="button" class="collapsible" {% if not mappings or mappings|length == 0 %}disabled title="{{ _('Configure Mappings first') }}"{% endif %}>
|
||||||
4. Render Reports to Sheets
|
4. {{ _('Render Reports to Sheets') }}
|
||||||
</button>
|
</button>
|
||||||
<div class="content">
|
<div class="content">
|
||||||
<h3>Render Reports</h3>
|
<h3>{{ _('Render Reports') }}</h3>
|
||||||
<p>
|
<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,
|
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>
|
</p>
|
||||||
{% if mappings and mappings|length > 0 %}
|
{% if mappings and mappings|length > 0 %}
|
||||||
<form action="{{ url_for('render_olap') }}" method="post">
|
<form action="{{ url_for('.render_olap') }}" method="post">
|
||||||
<label for="start_date">From Date:</label>
|
<label for="start_date">{{ _('From Date:') }}</label>
|
||||||
<input type="date" id="start_date" name="start_date" required /><br />
|
<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 />
|
<input type="date" id="end_date" name="end_date" required /><br />
|
||||||
|
|
||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Worksheet</th>
|
<th>{{ _('Worksheet') }}</th>
|
||||||
<th>Mapped OLAP Report</th>
|
<th>{{ _('Mapped OLAP Report') }}</th>
|
||||||
<th>Action</th>
|
<th>{{ _('Action') }}</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{# Iterate through sheets loaded from Google, check for mapping #}
|
|
||||||
{% for sheet in sheets %}
|
{% for sheet in sheets %}
|
||||||
{% set report_id = mappings.get(sheet.title) %}
|
{% set mappings = mappings.get(sheet.title) %}
|
||||||
{% if report_id %} {# Only display rows with a valid mapping #}
|
{% if mapping_info and mapping_info.get('report_id') %}
|
||||||
{# Find the preset name by ID using Jinja filters #}
|
{% set report_id = mapping_info.get('report_id') %}
|
||||||
{# Find the preset dictionary where 'id' attribute equals report_id #}
|
|
||||||
{% set matching_presets = presets | selectattr('id', 'equalto', report_id) | list %}
|
{% set matching_presets = presets | selectattr('id', 'equalto', report_id) | list %}
|
||||||
{% set preset_name = 'ID: ' + report_id %} {# Default display if preset not found or unnamed #}
|
{% set preset_name = _('ID: ') + report_id %}
|
||||||
|
|
||||||
{# If a matching preset was found, get its name #}
|
|
||||||
{% if matching_presets %}
|
{% if matching_presets %}
|
||||||
{% set preset = matching_presets[0] %}
|
{% set preset = matching_presets[0] %}
|
||||||
{% set preset_name = preset.get('name', 'Unnamed Preset') %}
|
{% set preset_name = preset.get('name', _('Unnamed Preset')) %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<tr>
|
<tr>
|
||||||
@@ -213,7 +212,7 @@
|
|||||||
<td>{{ preset_name }}</td>
|
<td>{{ preset_name }}</td>
|
||||||
<td>
|
<td>
|
||||||
<button type="submit" name="render_{{ sheet.title }}">
|
<button type="submit" name="render_{{ sheet.title }}">
|
||||||
Render to sheet
|
{{ _('Render to sheet') }}
|
||||||
</button>
|
</button>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -223,11 +222,75 @@
|
|||||||
</table>
|
</table>
|
||||||
</form>
|
</form>
|
||||||
{% else %}
|
{% else %}
|
||||||
<p>No mappings configured yet.</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>
|
<p><small>{{ _('Please go to the "Mapping Sheets to OLAP Reports" section (Step 3) to set up mappings.') }}</small></p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<button type="button" class="collapsible" {% if not mappings or mappings|length == 0 %}disabled title="{{ _('Configure Mappings first') }}"{% endif %}>
|
||||||
|
5. {{ _('Scheduling Automatic Reports') }}
|
||||||
|
</button>
|
||||||
|
<div class="content">
|
||||||
|
<h3>{{ _('Schedule Settings') }}</h3>
|
||||||
|
<p>
|
||||||
|
{% trans %}Here you can set up a CRON schedule for automatic report generation.
|
||||||
|
The report will be generated for the specified period relative to the execution time.{% endtrans %}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="cron-constructor">
|
||||||
|
<h4>{{ _('Cron Schedule Builder') }}</h4>
|
||||||
|
<p><small>{% trans %}Use this tool to build a cron string, then copy it to the desired field below.{% endtrans %}</small></p>
|
||||||
|
<div class="cron-row">
|
||||||
|
<select id="cron-minute"><option value="*">*</option>{% for i in range(60) %}<option value="{{i}}">{{'%02d'|format(i)}}</option>{% endfor %}</select>
|
||||||
|
<select id="cron-hour"><option value="*">*</option>{% for i in range(24) %}<option value="{{i}}">{{'%02d'|format(i)}}</option>{% endfor %}</select>
|
||||||
|
<select id="cron-day"><option value="*">*</option>{% for i in range(1, 32) %}<option value="{{i}}">{{i}}</option>{% endfor %}</select>
|
||||||
|
<select id="cron-month"><option value="*">*</option>{% for i in range(1, 13) %}<option value="{{i}}">{{i}}</option>{% endfor %}</select>
|
||||||
|
<select id="cron-day-of-week"><option value="*">*</option><option value="1">{{_('Mon')}}</option><option value="2">{{_('Tue')}}</option><option value="3">{{_('Wed')}}</option><option value="4">{{_('Thu')}}</option><option value="5">{{_('Fri')}}</option><option value="6">{{_('Sat')}}</option><option value="0">{{_('Sun')}}</option></select>
|
||||||
|
</div>
|
||||||
|
<label for="cron-output">{{ _('Generated Cron String:') }}</label>
|
||||||
|
<input type="text" id="cron-output" readonly>
|
||||||
|
<p id="cron-human-readable"></p>
|
||||||
|
</div>
|
||||||
|
<hr>
|
||||||
|
|
||||||
|
<form action="{{ url_for('.save_schedule') }}" method="post">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>{{ _('Worksheet') }}</th>
|
||||||
|
<th>{{ _('Schedule (Cron)') }}</th>
|
||||||
|
<th>{{ _('Report Period') }}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for sheet_title, params in mappings.items() %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ sheet_title }}</td>
|
||||||
|
<td>
|
||||||
|
<input type="text" name="cron-{{ sheet_title }}" value="{{ params.get('schedule_cron', '') }}" placeholder="e.g., 0 2 * * 1">
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{% set current_period = params.get('schedule_period', '') %}
|
||||||
|
<select name="period-{{ sheet_title }}" class="period-select" data-target="custom-days-{{ sheet_title }}">
|
||||||
|
<option value="">-- {{ _('Not Scheduled') }} --</option>
|
||||||
|
<option value="previous_week" {% if current_period == 'previous_week' %}selected{% endif %}>{{ _('Previous Week') }}</option>
|
||||||
|
<option value="last_7_days" {% if current_period == 'last_7_days' %}selected{% endif %}>{{ _('Last 7 Days') }}</option>
|
||||||
|
<option value="previous_month" {% if current_period == 'previous_month' %}selected{% endif %}>{{ _('Previous Month') }}</option>
|
||||||
|
<option value="current_month" {% if current_period == 'current_month' %}selected{% endif %}>{{ _('Current Month (to yesterday)') }}</option>
|
||||||
|
<option value="last_N_days" {% if current_period and current_period.startswith('last_') and current_period.endswith('_days') %}selected{% endif %}>{{ _('Last N Days') }}</option>
|
||||||
|
</select>
|
||||||
|
<div id="custom-days-{{ sheet_title }}" class="custom-days-input" style="display: none; margin-top: 5px;">
|
||||||
|
<input type="number" name="custom_days-{{ sheet_title }}" min="1" placeholder="N" style="width: 60px;" value="{% if current_period and current_period.startswith('last_') %}{{ current_period.split('_')[1] }}{% endif %}">
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<button type="submit">{{ _('Save Schedule') }}</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
</div> <!-- End Container -->
|
</div> <!-- End Container -->
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
@@ -235,56 +298,62 @@
|
|||||||
var coll = document.getElementsByClassName("collapsible");
|
var coll = document.getElementsByClassName("collapsible");
|
||||||
for (var i = 0; i < coll.length; i++) {
|
for (var i = 0; i < coll.length; i++) {
|
||||||
coll[i].addEventListener("click", function () {
|
coll[i].addEventListener("click", function () {
|
||||||
// Не переключать, если кнопка отключена
|
// Не выполнять действие, если кнопка отключена
|
||||||
if (this.disabled) return;
|
if (this.disabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
this.classList.toggle("active");
|
this.classList.toggle("active");
|
||||||
var content = this.nextElementSibling;
|
var content = this.nextElementSibling;
|
||||||
if (content.style.display === "block") {
|
|
||||||
content.style.display = "none";
|
// Если max-height установлен (т.е. секция открыта), то скрыть ее
|
||||||
|
if (content.style.maxHeight) {
|
||||||
|
content.style.maxHeight = null;
|
||||||
} else {
|
} else {
|
||||||
content.style.display = "block";
|
// Иначе (секция закрыта), установить max-height равным высоте контента
|
||||||
|
// Это "раскроет" секцию с плавной анимацией
|
||||||
|
content.style.maxHeight = content.scrollHeight + "px";
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Optional: Auto-expand sections based on config state?
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
// This requires passing more state from the Flask app to the template.
|
// Cron конструктор
|
||||||
// For now, keep it simple with manual expansion.
|
const cronInputs = ['cron-minute', 'cron-hour', 'cron-day', 'cron-month', 'cron-day-of-week'];
|
||||||
// window.addEventListener('load', () => {
|
const cronOutput = document.getElementById('cron-output');
|
||||||
// // 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");
|
function updateCronString() {
|
||||||
|
if (!cronOutput) return;
|
||||||
|
const values = cronInputs.map(id => document.getElementById(id).value);
|
||||||
|
cronOutput.value = values.join(' ');
|
||||||
|
}
|
||||||
|
|
||||||
// if (rmsConfigured && !googleCredsExist) {
|
cronInputs.forEach(id => {
|
||||||
// // Find and click Google Sheets collapsible
|
const el = document.getElementById(id);
|
||||||
// for (let i = 0; i < collapsibles.length; i++) {
|
if(el) el.addEventListener('change', updateCronString);
|
||||||
// if (collapsibles[i].innerText.includes("Google Sheets Configuration")) {
|
});
|
||||||
// collapsibles[i].click();
|
updateCronString(); // Initial call
|
||||||
// break;
|
|
||||||
// }
|
// Управление видимостью поля "N дней"
|
||||||
// }
|
document.querySelectorAll('.period-select').forEach(select => {
|
||||||
// } else if (rmsConfigured && googleCredsExist && googleSheetUrlSet && presetsLoaded && sheetsLoaded && !mappingsExist) {
|
const targetId = select.dataset.target;
|
||||||
// // Find and click Mapping collapsible
|
const targetDiv = document.getElementById(targetId);
|
||||||
// for (let i = 0; i in collapsibles.length; i++) { // <-- Potential typo here, should be <
|
|
||||||
// if (collapsibles[i].innerText.includes("Mapping Sheets to OLAP Reports")) {
|
function toggleCustomInput() {
|
||||||
// collapsibles[i].click();
|
if (select.value === 'last_N_days') {
|
||||||
// break;
|
targetDiv.style.display = 'block';
|
||||||
// }
|
} else {
|
||||||
// }
|
targetDiv.style.display = 'none';
|
||||||
// }
|
}
|
||||||
// // Add more conditions as needed
|
}
|
||||||
// });
|
|
||||||
|
select.addEventListener('change', toggleCustomInput);
|
||||||
|
toggleCustomInput(); // Initial call on page load
|
||||||
|
});
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
{% else %}
|
{% else %}
|
||||||
<p style="text-align: center; margin-top: 50px;">Please, <a href="{{ url_for('login') }}">login</a> or <a href="{{ url_for('register') }}">register</a></p>
|
<p style="text-align: center; margin-top: 50px;">{{ _('Please,') }} <a href="{{ url_for('.login') }}">{{ _('login') }}</a> {{ _('or') }} <a href="{{ url_for('.register') }}">{{ _('register') }}</a></p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@@ -1,12 +1,16 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html>
|
<html>
|
||||||
<head>
|
<head>
|
||||||
<title>Login</title>
|
<title>{{ _('Login') }}</title>
|
||||||
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}"> <!-- Link to your CSS -->
|
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="auth-container"> {# <-- Add this wrapper div #}
|
<div class="auth-container">
|
||||||
<h1>Login</h1>
|
<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) %}
|
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||||
{% if messages %}
|
{% if messages %}
|
||||||
{% for category, message in messages %}
|
{% for category, message in messages %}
|
||||||
@@ -15,17 +19,16 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
{% endwith %}
|
{% endwith %}
|
||||||
<form method="post">
|
<form method="post">
|
||||||
<label for="username">Username:</label>
|
<label for="username">{{ _('Username:') }}</label>
|
||||||
<input type="text" id="username" name="username" required><br>
|
<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>
|
<input type="password" id="password" name="password" required><br>
|
||||||
{# Adjusted label and input for "Remember Me" #}
|
|
||||||
<label for="remember">
|
<label for="remember">
|
||||||
<input type="checkbox" name="remember" id="remember"> Remember Me
|
<input type="checkbox" name="remember" id="remember"> {{ _('Remember Me') }}
|
||||||
</label><br>
|
</label><br>
|
||||||
<button type="submit">Login</button>
|
<button type="submit">{{ _('Login') }}</button>
|
||||||
</form>
|
</form>
|
||||||
<p>Don't have an account? <a href="{{ url_for('register') }}">Register here</a></p>
|
<p>{{ _("Don't have an account?") }} <a href="{{ url_for('.register') }}">{{ _('Register here') }}</a></p>
|
||||||
</div> {# <-- Close the wrapper div #}
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@@ -1,12 +1,16 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html>
|
<html>
|
||||||
<head>
|
<head>
|
||||||
<title>Register</title>
|
<title>{{ _('Register') }}</title>
|
||||||
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}"> <!-- Link to your CSS -->
|
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}"> <!-- Link to your CSS -->
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="auth-container"> {# <-- Add this wrapper div #}
|
<div class="auth-container">
|
||||||
<h1>Register</h1>
|
<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) %}
|
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||||
{% if messages %}
|
{% if messages %}
|
||||||
{% for category, message in messages %}
|
{% for category, message in messages %}
|
||||||
@@ -15,13 +19,13 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
{% endwith %}
|
{% endwith %}
|
||||||
<form method="post">
|
<form method="post">
|
||||||
<label for="username">Username:</label>
|
<label for="username">{{ _('Username:') }}</label>
|
||||||
<input type="text" id="username" name="username" required><br>
|
<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>
|
<input type="password" id="password" name="password" required><br>
|
||||||
<button type="submit">Register</button>
|
<button type="submit">{{ _('Register') }}</button>
|
||||||
</form>
|
</form>
|
||||||
<p>Already have an account? <a href="{{ url_for('login') }}">Login here</a></p>
|
<p>{{ _("Already have an account?") }} <a href="{{ url_for('.login') }}">{{ _('Login here') }}</a></p>
|
||||||
</div> {# <-- Close the wrapper div #}
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</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 "Войдите здесь"
|
||||||
174
utils.py
174
utils.py
@@ -1,55 +1,49 @@
|
|||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
from jinja2 import Template
|
from jinja2 import Template
|
||||||
from datetime import datetime
|
from datetime import datetime, date, timedelta
|
||||||
|
from dateutil.relativedelta import relativedelta
|
||||||
|
|
||||||
# Настройка логирования
|
# Настройка логирования
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
# Уровень логирования уже должен быть настроен в app.py или основном модуле
|
|
||||||
# logger.setLevel(logging.DEBUG) # Можно убрать, если настраивается глобально
|
|
||||||
|
|
||||||
# Функция load_temps удалена, так как пресеты загружаются из API RMS
|
|
||||||
|
|
||||||
|
|
||||||
def generate_template_from_preset(preset):
|
def generate_template_from_preset(preset):
|
||||||
"""
|
"""
|
||||||
Генерирует один шаблон запроса OLAP на основе пресета,
|
Генерирует один шаблон запроса OLAP на основе пресета из RMS API.
|
||||||
подставляя плейсхолдеры для дат в соответствующий фильтр.
|
Функция заменяет существующие фильтры по дате на универсальные плейсхолдеры
|
||||||
|
Jinja2 (`{{ from_date }}` и `{{ to_date }}`).
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
preset (dict): Словарь с пресетом OLAP-отчета из API RMS.
|
preset (dict): Словарь с пресетом OLAP-отчета из API RMS.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
dict: Словарь, представляющий шаблон запроса OLAP, готовый для рендеринга.
|
dict: Словарь, представляющий шаблон запроса OLAP, готовый для рендеринга.
|
||||||
Возвращает None, если входной preset некорректен.
|
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
ValueError: Если preset не является словарем или не содержит необходимых ключей.
|
ValueError: Если пресет некорректен (не словарь или отсутствуют ключи).
|
||||||
Exception: Другие непредвиденные ошибки при обработке.
|
Exception: Другие непредвиденные ошибки при обработке.
|
||||||
"""
|
"""
|
||||||
if not isinstance(preset, dict):
|
if not isinstance(preset, dict):
|
||||||
logger.error("Ошибка генерации шаблона: входной 'preset' не является словарем.")
|
logger.error("Ошибка генерации шаблона: входной 'preset' не является словарем.")
|
||||||
raise ValueError("Preset должен быть словарем.")
|
raise ValueError("Пресет должен быть словарем.")
|
||||||
if not all(k in preset for k in ["reportType", "groupByRowFields", "aggregateFields", "filters"]):
|
|
||||||
logger.error(f"Ошибка генерации шаблона: пресет {preset.get('id', 'N/A')} не содержит всех необходимых ключей.")
|
required_keys = ["reportType", "groupByRowFields", "aggregateFields", "filters"]
|
||||||
raise ValueError("Пресет не содержит необходимых ключей (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:
|
try:
|
||||||
# Копируем основные поля из пресета
|
# Создаем глубокую копию, чтобы не изменять оригинальный объект пресета
|
||||||
template = {
|
template = json.loads(json.dumps(preset))
|
||||||
"reportType": preset["reportType"],
|
|
||||||
"groupByRowFields": preset.get("groupByRowFields", []), # Используем get для необязательных полей
|
|
||||||
"aggregateFields": preset.get("aggregateFields", []),
|
|
||||||
"filters": preset.get("filters", {}) # Работаем с копией фильтров
|
|
||||||
}
|
|
||||||
|
|
||||||
# --- Обработка фильтров дат ---
|
# Удаляем ненужные для запроса поля, которые приходят из API
|
||||||
# Создаем копию словаря фильтров, чтобы безопасно удалять элементы
|
template.pop('id', None)
|
||||||
current_filters = dict(template.get("filters", {})) # Используем get с default
|
template.pop('name', None)
|
||||||
|
|
||||||
|
current_filters = template.get("filters", {})
|
||||||
filters_to_remove = []
|
filters_to_remove = []
|
||||||
date_filter_found_and_modified = False
|
|
||||||
|
|
||||||
# Сначала найдем и удалим все существующие фильтры типа DateRange
|
# Находим и запоминаем все существующие фильтры типа DateRange для удаления
|
||||||
for key, value in current_filters.items():
|
for key, value in current_filters.items():
|
||||||
if isinstance(value, dict) and value.get("filterType") == "DateRange":
|
if isinstance(value, dict) and value.get("filterType") == "DateRange":
|
||||||
filters_to_remove.append(key)
|
filters_to_remove.append(key)
|
||||||
@@ -58,28 +52,23 @@ def generate_template_from_preset(preset):
|
|||||||
del current_filters[key]
|
del current_filters[key]
|
||||||
logger.debug(f"Удален существующий DateRange фильтр '{key}' из пресета {preset.get('id', 'N/A')}.")
|
logger.debug(f"Удален существующий DateRange фильтр '{key}' из пресета {preset.get('id', 'N/A')}.")
|
||||||
|
|
||||||
# Теперь добавляем правильный фильтр дат в зависимости от типа отчета
|
# Определяем правильный ключ для фильтра по дате на основе типа отчета
|
||||||
report_type = template["reportType"]
|
report_type = template["reportType"]
|
||||||
|
date_filter_key = None
|
||||||
if report_type in ["SALES", "DELIVERIES"]:
|
if report_type in ["SALES", "DELIVERIES"]:
|
||||||
# Для отчетов SALES и DELIVERIES используем "OpenDate.Typed"
|
# Для отчетов по продажам и доставкам используется "OpenDate.Typed"
|
||||||
# См. https://ru.iiko.help/articles/api-documentations/olap-2/a/h3__951638809
|
|
||||||
date_filter_key = "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":
|
elif report_type == "TRANSACTIONS":
|
||||||
# Для отчетов по проводкам (TRANSACTIONS) используем "DateTime.DateTyped"
|
# Для отчетов по проводкам используется "DateTime.DateTyped"
|
||||||
# См. комментарий пользователя и общие практики iiko API
|
|
||||||
date_filter_key = "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] = {
|
current_filters[date_filter_key] = {
|
||||||
"filterType": "DateRange",
|
"filterType": "DateRange",
|
||||||
"from": "{{ from_date }}",
|
"from": "{{ from_date }}",
|
||||||
@@ -87,73 +76,46 @@ def generate_template_from_preset(preset):
|
|||||||
"includeLow": True,
|
"includeLow": True,
|
||||||
"includeHigh": True
|
"includeHigh": True
|
||||||
}
|
}
|
||||||
date_filter_found_and_modified = True # Считаем, что мы успешно добавили нужный фильтр
|
|
||||||
|
|
||||||
else:
|
|
||||||
# Для ВСЕХ ОСТАЛЬНЫХ типов отчетов:
|
|
||||||
# Пытаемся найти *любой* ключ, который может содержать дату (логика по умолчанию).
|
|
||||||
# Это менее надежно, чем явное указание ключей для SALES/DELIVERIES/TRANSACTIONS.
|
|
||||||
# Если в пресете для других типов отчетов нет стандартного поля даты,
|
|
||||||
# или оно называется иначе, этот блок может не сработать корректно.
|
|
||||||
# Мы уже удалили все DateRange фильтры. Если для этого типа отчета
|
|
||||||
# нужен был какой-то специфический DateRange фильтр, он был удален.
|
|
||||||
# Это потенциальная проблема, если неизвестные типы отчетов полагаются
|
|
||||||
# на предопределенные DateRange фильтры с другими ключами.
|
|
||||||
# Пока оставляем так: если тип отчета неизвестен, DateRange фильтр не добавляется.
|
|
||||||
logger.warning(f"Для неизвестного типа отчета '{report_type}' ({preset.get('id', 'N/A')}) не удалось автоматически определить стандартный ключ фильтра даты. "
|
|
||||||
f"Фильтр по дате не будет добавлен автоматически. Если он нужен, пресет должен содержать его с другим filterType или его нужно добавить вручную.")
|
|
||||||
# В этом случае date_filter_found_and_modified останется False
|
|
||||||
|
|
||||||
# Обновляем фильтры в шаблоне
|
|
||||||
template["filters"] = current_filters
|
|
||||||
|
|
||||||
# Логируем результат
|
|
||||||
if date_filter_found_and_modified:
|
|
||||||
logger.info(f"Шаблон для пресета {preset.get('id', 'N/A')} ('{preset.get('name', '')}') успешно сгенерирован с фильтром даты.")
|
logger.info(f"Шаблон для пресета {preset.get('id', 'N/A')} ('{preset.get('name', '')}') успешно сгенерирован с фильтром даты.")
|
||||||
else:
|
|
||||||
logger.warning(f"Шаблон для пресета {preset.get('id', 'N/A')} ('{preset.get('name', '')}') сгенерирован, но фильтр даты не был добавлен/модифицирован (тип отчета: {report_type}).")
|
|
||||||
|
|
||||||
|
template["filters"] = current_filters
|
||||||
return template
|
return template
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Непредвиденная ошибка при генерации шаблона из пресета {preset.get('id', 'N/A')}: {str(e)}", exc_info=True)
|
logger.error(f"Непредвиденная ошибка при генерации шаблона из пресета {preset.get('id', 'N/A')}: {str(e)}", exc_info=True)
|
||||||
raise # Перевыбрасываем ошибку
|
raise
|
||||||
|
|
||||||
|
|
||||||
def render_temp(template_dict, context):
|
def render_temp(template_dict, context):
|
||||||
"""
|
"""
|
||||||
Рендерит шаблон (представленный словарем) с использованием Jinja2.
|
Рендерит шаблон (представленный словарем) с использованием Jinja2,
|
||||||
|
подставляя значения из контекста (например, даты).
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
template_dict (dict): Словарь, представляющий шаблон OLAP-запроса.
|
template_dict (dict): Словарь-шаблон OLAP-запроса.
|
||||||
context (dict): Словарь с переменными для рендеринга (например, {'from_date': '...', 'to_date': '...'}).
|
context (dict): Словарь с переменными для рендеринга (например, {'from_date': '...', 'to_date': '...'}).
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
dict: Словарь с отрендеренным OLAP-запросом.
|
dict: Словарь с отрендеренным OLAP-запросом, готовый к отправке.
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
Exception: Ошибки при рендеринге или парсинге JSON.
|
Exception: Ошибки при рендеринге или парсинге JSON.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
# Преобразуем словарь шаблона в строку JSON для Jinja
|
# Преобразуем словарь шаблона в строку JSON
|
||||||
template_str = json.dumps(template_dict)
|
template_str = json.dumps(template_dict)
|
||||||
|
# Рендерим строку с помощью Jinja, подставляя переменные из context
|
||||||
# Рендерим строку с помощью Jinja
|
|
||||||
rendered_str = Template(template_str).render(context)
|
rendered_str = Template(template_str).render(context)
|
||||||
|
|
||||||
# Преобразуем отрендеренную строку обратно в словарь Python
|
# Преобразуем отрендеренную строку обратно в словарь Python
|
||||||
rendered_dict = json.loads(rendered_str)
|
rendered_dict = json.loads(rendered_str)
|
||||||
|
logger.info('Шаблон OLAP-запроса успешно отрендерен с датами.')
|
||||||
logger.info('Шаблон OLAP-запроса успешно отрендерен.')
|
|
||||||
return rendered_dict
|
return rendered_dict
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Ошибка рендеринга шаблона: {str(e)}", exc_info=True)
|
logger.error(f"Ошибка рендеринга шаблона: {str(e)}", exc_info=True)
|
||||||
raise
|
raise
|
||||||
|
|
||||||
|
|
||||||
def get_dates(start_date, end_date):
|
def get_dates(start_date, end_date):
|
||||||
"""
|
"""
|
||||||
Проверяет даты на корректность и формат YYYY-MM-DD.
|
Проверяет и форматирует даты.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
start_date (str): Дата начала в формате 'YYYY-MM-DD'.
|
start_date (str): Дата начала в формате 'YYYY-MM-DD'.
|
||||||
@@ -169,8 +131,8 @@ def get_dates(start_date, end_date):
|
|||||||
try:
|
try:
|
||||||
start = datetime.strptime(start_date, date_format)
|
start = datetime.strptime(start_date, date_format)
|
||||||
end = datetime.strptime(end_date, date_format)
|
end = datetime.strptime(end_date, date_format)
|
||||||
except ValueError:
|
except (ValueError, TypeError):
|
||||||
logger.error(f"Некорректный формат дат: start='{start_date}', end='{end_date}'. Ожидается YYYY-MM-DD.")
|
logger.error(f"Некорректный формат или тип дат: start='{start_date}', end='{end_date}'. Ожидается YYYY-MM-DD.")
|
||||||
raise ValueError("Некорректный формат даты. Используйте YYYY-MM-DD.")
|
raise ValueError("Некорректный формат даты. Используйте YYYY-MM-DD.")
|
||||||
|
|
||||||
if start > end:
|
if start > end:
|
||||||
@@ -178,3 +140,51 @@ def get_dates(start_date, end_date):
|
|||||||
raise ValueError("Дата начала не может быть позже даты окончания.")
|
raise ValueError("Дата начала не может быть позже даты окончания.")
|
||||||
|
|
||||||
return start_date, end_date
|
return start_date, end_date
|
||||||
|
|
||||||
|
def calculate_period_dates(period_key):
|
||||||
|
"""
|
||||||
|
Вычисляет начальную и конечную даты на основе строкового ключа.
|
||||||
|
Возвращает кортеж (start_date_str, end_date_str) в формате YYYY-MM-DD.
|
||||||
|
"""
|
||||||
|
if not period_key:
|
||||||
|
raise ValueError("Period key cannot be empty.")
|
||||||
|
|
||||||
|
today = date.today()
|
||||||
|
yesterday = today - timedelta(days=1)
|
||||||
|
date_format = "%Y-%m-%d"
|
||||||
|
|
||||||
|
# За прошлую неделю (с пн по вс)
|
||||||
|
if period_key == 'previous_week':
|
||||||
|
start_of_last_week = today - timedelta(days=today.weekday() + 7)
|
||||||
|
end_of_last_week = start_of_last_week + timedelta(days=6)
|
||||||
|
return start_of_last_week.strftime(date_format), end_of_last_week.strftime(date_format)
|
||||||
|
|
||||||
|
# За последние 7 дней (не включая сегодня)
|
||||||
|
if period_key == 'last_7_days':
|
||||||
|
start_date = today - timedelta(days=7)
|
||||||
|
return start_date.strftime(date_format), yesterday.strftime(date_format)
|
||||||
|
|
||||||
|
# За прошлый месяц
|
||||||
|
if period_key == 'previous_month':
|
||||||
|
last_month = today - relativedelta(months=1)
|
||||||
|
start_date = last_month.replace(day=1)
|
||||||
|
end_date = start_date + relativedelta(months=1) - timedelta(days=1)
|
||||||
|
return start_date.strftime(date_format), end_date.strftime(date_format)
|
||||||
|
|
||||||
|
# За текущий месяц (до вчера)
|
||||||
|
if period_key == 'current_month':
|
||||||
|
start_date = today.replace(day=1)
|
||||||
|
return start_date.strftime(date_format), yesterday.strftime(date_format)
|
||||||
|
|
||||||
|
# Динамический ключ "За последние N дней"
|
||||||
|
if period_key.startswith('last_') and period_key.endswith('_days'):
|
||||||
|
try:
|
||||||
|
days = int(period_key.split('_')[1])
|
||||||
|
if days <= 0:
|
||||||
|
raise ValueError("Number of days must be positive.")
|
||||||
|
start_date = today - timedelta(days=days)
|
||||||
|
return start_date.strftime(date_format), yesterday.strftime(date_format)
|
||||||
|
except (ValueError, IndexError):
|
||||||
|
raise ValueError(f"Invalid dynamic period key: {period_key}")
|
||||||
|
|
||||||
|
raise ValueError(f"Unknown period key: {period_key}")
|
||||||
Reference in New Issue
Block a user