init commit
Some checks failed
Deploy to Production / deploy (push) Has been cancelled

This commit is contained in:
2025-07-25 03:04:51 +03:00
commit 62115fcd36
22 changed files with 2169 additions and 0 deletions

533
app.py Normal file
View File

@@ -0,0 +1,533 @@
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