Files
olaper/app.py
SERTY 62115fcd36
Some checks failed
Deploy to Production / deploy (push) Has been cancelled
init commit
2025-07-25 03:04:51 +03:00

533 lines
26 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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