Compare commits
14 Commits
f5cf4c32da
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 6c11be1460 | |||
| 0a17b31c06 | |||
| 4f66edbb21 | |||
| 38f35d1915 | |||
| ca8e70781c | |||
| 4ebe15522f | |||
| 0f1c749b33 | |||
| 8e757afe39 | |||
| 5100c5d17c | |||
| 81d33bebef | |||
| 3500d433ea | |||
| c713b47d58 | |||
| ddd0ffbcb0 | |||
| 36a8548562 |
@@ -24,10 +24,20 @@ jobs:
|
||||
|
||||
- name: Run new container
|
||||
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 \
|
||||
--name olaper \
|
||||
--restart always \
|
||||
-p 5005:5005 \
|
||||
-p ${PORT}:5005 \
|
||||
-e SECRET_KEY=${{ secrets.SECRET_KEY }} \
|
||||
-e ENCRYPTION_KEY=${{ secrets.ENCRYPTION_KEY }} \
|
||||
-v mholaper_data:/app/data \
|
||||
olaper:latest
|
||||
|
||||
@@ -39,6 +39,7 @@ jobs:
|
||||
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
|
||||
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -4,4 +4,5 @@
|
||||
/.env
|
||||
/.idea
|
||||
cred.json
|
||||
*.db
|
||||
*.db
|
||||
*/*.log
|
||||
64
app.py
64
app.py
@@ -1,12 +1,13 @@
|
||||
import os
|
||||
from flask import Flask, session, request
|
||||
from sqlalchemy import inspect
|
||||
from dotenv import load_dotenv
|
||||
|
||||
# 1. Загрузка переменных окружения - в самом верху
|
||||
load_dotenv()
|
||||
|
||||
# 2. Импорт расширений из центрального файла
|
||||
from extensions import db, migrate, login_manager, babel
|
||||
from extensions import scheduler, db, migrate, login_manager, babel
|
||||
from models import init_encryption
|
||||
|
||||
# 3. Фабрика приложений
|
||||
@@ -16,6 +17,34 @@ def create_app():
|
||||
"""
|
||||
app = Flask(__name__)
|
||||
|
||||
if not app.debug:
|
||||
import logging
|
||||
from logging.handlers import RotatingFileHandler
|
||||
|
||||
# Создаем папку для логов, если ее нет
|
||||
log_dir = os.path.join(os.path.abspath(os.path.dirname(__file__)), 'data')
|
||||
os.makedirs(log_dir, exist_ok=True)
|
||||
|
||||
log_file = os.path.join(log_dir, 'olaper.log')
|
||||
|
||||
# Создаем обработчик, который пишет логи в файл с ротацией
|
||||
# 10 МБ на файл, храним 5 старых файлов
|
||||
file_handler = RotatingFileHandler(log_file, maxBytes=1024*1024*10, backupCount=5, encoding='utf-8')
|
||||
|
||||
# Устанавливаем формат сообщений
|
||||
file_handler.setFormatter(logging.Formatter(
|
||||
'%(asctime)s %(levelname)s: %(message)s [in %(pathname)s:%(lineno)d]'
|
||||
))
|
||||
|
||||
# Устанавливаем уровень логирования
|
||||
file_handler.setLevel(logging.INFO)
|
||||
|
||||
# Добавляем обработчик к логгеру приложения
|
||||
app.logger.addHandler(file_handler)
|
||||
|
||||
app.logger.setLevel(logging.INFO)
|
||||
app.logger.info('Application startup')
|
||||
|
||||
# --- Конфигурация приложения ---
|
||||
app.config['SECRET_KEY'] = os.environ.get('SECRET_KEY', 'default-super-secret-key-for-dev')
|
||||
|
||||
@@ -49,16 +78,47 @@ def create_app():
|
||||
migrate.init_app(app, db)
|
||||
login_manager.init_app(app)
|
||||
babel.init_app(app, locale_selector=get_locale)
|
||||
scheduler.init_app(app)
|
||||
|
||||
init_encryption(app)
|
||||
|
||||
# --- Регистрация блюпринтов ---
|
||||
from routes import main_bp
|
||||
from routes import main_bp, execute_olap_export
|
||||
app.register_blueprint(main_bp)
|
||||
|
||||
login_manager.login_view = 'main.login'
|
||||
login_manager.login_message = "Пожалуйста, войдите, чтобы получить доступ к этой странице."
|
||||
login_manager.login_message_category = "info"
|
||||
|
||||
with app.app_context():
|
||||
if inspect(db.engine).has_table('user_config'):
|
||||
from models import User, UserConfig
|
||||
from utils import _parse_cron_string
|
||||
all_configs = UserConfig.query.all()
|
||||
for config in all_configs:
|
||||
user_id = config.user_id
|
||||
mappings = config.mappings
|
||||
for sheet_title, params in mappings.items():
|
||||
if isinstance(params, dict):
|
||||
cron_schedule = params.get('schedule_cron')
|
||||
if cron_schedule:
|
||||
job_id = f"user_{user_id}_sheet_{sheet_title}"
|
||||
try:
|
||||
if not scheduler.get_job(job_id):
|
||||
scheduler.add_job(
|
||||
id=job_id,
|
||||
func=execute_olap_export,
|
||||
trigger='cron',
|
||||
args=[app, user_id, sheet_title],
|
||||
**_parse_cron_string(cron_schedule)
|
||||
)
|
||||
app.logger.info(f"Scheduled job loaded on startup: {job_id} with schedule '{cron_schedule}'")
|
||||
except Exception as e:
|
||||
app.logger.error(f"Failed to load job {job_id} with schedule '{cron_schedule}': {e}")
|
||||
else:
|
||||
app.logger.warning("Database tables not found. Skipping job loading on startup. Run 'flask init-db' to create the tables.")
|
||||
scheduler.start()
|
||||
|
||||
# --- Регистрация команд CLI ---
|
||||
from models import User, UserConfig
|
||||
@app.cli.command('init-db')
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
[python: **.py]
|
||||
[jinja2: **/templates/**.html]
|
||||
[python: **.py]
|
||||
[jinja2: **/templates/**.html]
|
||||
extensions=jinja2.ext.i18n
|
||||
224
data/olaper.log
Normal file
224
data/olaper.log
Normal file
@@ -0,0 +1,224 @@
|
||||
2025-07-31 02:19:43,501 INFO: Application startup [in c:\safe\repos\olaper\app.py:46]
|
||||
2025-07-31 02:19:48,374 ERROR: Exception on / [GET] [in C:\safe\repos\olaper\.venv\Lib\site-packages\flask\app.py:875]
|
||||
Traceback (most recent call last):
|
||||
File "C:\safe\repos\olaper\.venv\Lib\site-packages\flask\app.py", line 1511, in wsgi_app
|
||||
response = self.full_dispatch_request()
|
||||
File "C:\safe\repos\olaper\.venv\Lib\site-packages\flask\app.py", line 919, in full_dispatch_request
|
||||
rv = self.handle_user_exception(e)
|
||||
File "C:\safe\repos\olaper\.venv\Lib\site-packages\flask\app.py", line 917, in full_dispatch_request
|
||||
rv = self.dispatch_request()
|
||||
File "C:\safe\repos\olaper\.venv\Lib\site-packages\flask\app.py", line 902, in dispatch_request
|
||||
return self.ensure_sync(self.view_functions[rule.endpoint])(**view_args) # type: ignore[no-any-return]
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~^^^^^^^^^^^^^
|
||||
File "C:\safe\repos\olaper\.venv\Lib\site-packages\flask_login\utils.py", line 290, in decorated_view
|
||||
return current_app.ensure_sync(func)(*args, **kwargs)
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^
|
||||
File "c:\safe\repos\olaper\routes.py", line 145, in index
|
||||
return render_template(
|
||||
'index.html',
|
||||
...<5 lines>...
|
||||
client_email=config.google_client_email
|
||||
)
|
||||
File "C:\safe\repos\olaper\.venv\Lib\site-packages\flask\templating.py", line 149, in render_template
|
||||
template = app.jinja_env.get_or_select_template(template_name_or_list)
|
||||
File "C:\safe\repos\olaper\.venv\Lib\site-packages\jinja2\environment.py", line 1087, in get_or_select_template
|
||||
return self.get_template(template_name_or_list, parent, globals)
|
||||
~~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
File "C:\safe\repos\olaper\.venv\Lib\site-packages\jinja2\environment.py", line 1016, in get_template
|
||||
return self._load_template(name, globals)
|
||||
~~~~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^
|
||||
File "C:\safe\repos\olaper\.venv\Lib\site-packages\jinja2\environment.py", line 975, in _load_template
|
||||
template = self.loader.load(self, name, self.make_globals(globals))
|
||||
File "C:\safe\repos\olaper\.venv\Lib\site-packages\jinja2\loaders.py", line 138, in load
|
||||
code = environment.compile(source, name, filename)
|
||||
File "C:\safe\repos\olaper\.venv\Lib\site-packages\jinja2\environment.py", line 771, in compile
|
||||
self.handle_exception(source=source_hint)
|
||||
~~~~~~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^
|
||||
File "C:\safe\repos\olaper\.venv\Lib\site-packages\jinja2\environment.py", line 942, in handle_exception
|
||||
raise rewrite_traceback_stack(source=source)
|
||||
File "c:\safe\repos\olaper\templates\index.html", line 289, in template
|
||||
<a href="{{ url_for('.run_job_now', sheet_title=sheet_title) }}" class="button-link" onclick="return confirm('{{ _('Run the scheduled task for sheet \\%(sheet)s\\' now?', sheet=sheet_title) }}')">
|
||||
|
||||
jinja2.exceptions.TemplateSyntaxError: expected token ',', got 'now'
|
||||
2025-07-31 02:21:56,465 INFO: Application startup [in c:\safe\repos\olaper\app.py:46]
|
||||
2025-07-31 02:23:21,790 INFO: Added/updated job: user_1_sheet_<74><5F><EFBFBD><EFBFBD>1 with schedule '25 2 * * *' [in c:\safe\repos\olaper\routes.py:531]
|
||||
2025-07-31 02:23:37,761 INFO: Removed existing job: user_1_sheet_<74><5F><EFBFBD><EFBFBD>1 [in c:\safe\repos\olaper\routes.py:517]
|
||||
2025-07-31 02:23:37,761 INFO: Added/updated job: user_1_sheet_<74><5F><EFBFBD><EFBFBD>1 with schedule '25 2 * * *' [in c:\safe\repos\olaper\routes.py:531]
|
||||
2025-07-31 02:25:41,679 INFO: Added/updated job: user_2_sheet_<74><5F><EFBFBD><EFBFBD>1 with schedule '26 2 * * *' [in c:\safe\repos\olaper\routes.py:531]
|
||||
2025-07-31 02:36:53,978 INFO: Application startup [in c:\safe\repos\olaper\app.py:46]
|
||||
2025-07-31 02:36:54,452 ERROR: Failed to load job user_1_sheet_<74><5F><EFBFBD><EFBFBD>1: name '_parse_cron_string' is not defined [in c:\safe\repos\olaper\app.py:116]
|
||||
2025-07-31 02:36:54,453 ERROR: Failed to load job user_2_sheet_<74><5F><EFBFBD><EFBFBD>1: name '_parse_cron_string' is not defined [in c:\safe\repos\olaper\app.py:116]
|
||||
2025-07-31 02:37:28,388 INFO: Added/updated job: user_2_sheet_<74><5F><EFBFBD><EFBFBD>1 with schedule '26 2 * * *' [in c:\safe\repos\olaper\routes.py:530]
|
||||
2025-07-31 02:37:37,377 INFO: Removed existing job: user_2_sheet_<74><5F><EFBFBD><EFBFBD>1 [in c:\safe\repos\olaper\routes.py:516]
|
||||
2025-07-31 02:37:37,377 INFO: Added/updated job: user_2_sheet_<74><5F><EFBFBD><EFBFBD>1 with schedule '38 2 * * *' [in c:\safe\repos\olaper\routes.py:530]
|
||||
2025-07-31 02:38:00,003 INFO: Executing scheduled job for user 2, sheet '<27><><EFBFBD><EFBFBD>1', period 'last_10_days' (2025-07-21 to 2025-07-30) [in c:\safe\repos\olaper\routes.py:367]
|
||||
2025-07-31 02:38:03,135 INFO: Successfully wrote 713 rows to sheet '<27><><EFBFBD><EFBFBD>1' for user 2. [in c:\safe\repos\olaper\routes.py:447]
|
||||
2025-07-31 02:46:17,382 INFO: Application startup [in c:\safe\repos\olaper\app.py:46]
|
||||
2025-07-31 02:46:17,849 INFO: Scheduled job loaded on startup: user_1_sheet_Лист1 with schedule '25 2 * * *' [in c:\safe\repos\olaper\app.py:115]
|
||||
2025-07-31 02:46:17,850 INFO: Scheduled job loaded on startup: user_2_sheet_Лист1 with schedule '38 2 * * *' [in c:\safe\repos\olaper\app.py:115]
|
||||
2025-07-31 02:46:47,525 INFO: Removed existing job: user_2_sheet_Лист1 [in c:\safe\repos\olaper\routes.py:507]
|
||||
2025-07-31 02:46:47,526 INFO: Added/updated job: user_2_sheet_Лист1 with schedule '50 2 * * *' [in c:\safe\repos\olaper\routes.py:521]
|
||||
2025-07-31 02:50:00,006 INFO: Executing scheduled job for user 2, sheet 'Лист1', period 'last_15_days' (2025-07-16 to 2025-07-30) [in c:\safe\repos\olaper\routes.py:358]
|
||||
2025-07-31 02:50:02,254 INFO: Successfully wrote 805 rows to sheet 'Лист1' for user 2. [in c:\safe\repos\olaper\routes.py:438]
|
||||
2025-07-31 02:53:15,576 INFO: Application startup [in c:\safe\repos\olaper\app.py:46]
|
||||
2025-07-31 02:53:21,166 INFO: Application startup [in c:\safe\repos\olaper\app.py:46]
|
||||
2025-07-31 02:55:25,567 INFO: Application startup [in c:\safe\repos\olaper\app.py:46]
|
||||
2025-07-31 02:55:26,057 INFO: Scheduled job loaded on startup: user_1_sheet_Лист1 with schedule '25 2 * * *' [in c:\safe\repos\olaper\app.py:115]
|
||||
2025-07-31 02:55:26,058 INFO: Scheduled job loaded on startup: user_2_sheet_Лист1 with schedule '50 2 * * *' [in c:\safe\repos\olaper\app.py:115]
|
||||
2025-07-31 02:55:30,020 ERROR: Exception on / [GET] [in C:\safe\repos\olaper\.venv\Lib\site-packages\flask\app.py:875]
|
||||
Traceback (most recent call last):
|
||||
File "C:\safe\repos\olaper\.venv\Lib\site-packages\flask\app.py", line 1511, in wsgi_app
|
||||
response = self.full_dispatch_request()
|
||||
File "C:\safe\repos\olaper\.venv\Lib\site-packages\flask\app.py", line 919, in full_dispatch_request
|
||||
rv = self.handle_user_exception(e)
|
||||
File "C:\safe\repos\olaper\.venv\Lib\site-packages\flask\app.py", line 917, in full_dispatch_request
|
||||
rv = self.dispatch_request()
|
||||
File "C:\safe\repos\olaper\.venv\Lib\site-packages\flask\app.py", line 902, in dispatch_request
|
||||
return self.ensure_sync(self.view_functions[rule.endpoint])(**view_args) # type: ignore[no-any-return]
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~^^^^^^^^^^^^^
|
||||
File "C:\safe\repos\olaper\.venv\Lib\site-packages\flask_login\utils.py", line 290, in decorated_view
|
||||
return current_app.ensure_sync(func)(*args, **kwargs)
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^
|
||||
File "c:\safe\repos\olaper\routes.py", line 137, in index
|
||||
return render_template(
|
||||
'index.html',
|
||||
...<5 lines>...
|
||||
client_email=config.google_client_email
|
||||
)
|
||||
File "C:\safe\repos\olaper\.venv\Lib\site-packages\flask\templating.py", line 150, in render_template
|
||||
return _render(app, template, context)
|
||||
File "C:\safe\repos\olaper\.venv\Lib\site-packages\flask\templating.py", line 131, in _render
|
||||
rv = template.render(context)
|
||||
File "C:\safe\repos\olaper\.venv\Lib\site-packages\jinja2\environment.py", line 1295, in render
|
||||
self.environment.handle_exception()
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~^^
|
||||
File "C:\safe\repos\olaper\.venv\Lib\site-packages\jinja2\environment.py", line 942, in handle_exception
|
||||
raise rewrite_traceback_stack(source=source)
|
||||
File "c:\safe\repos\olaper\templates\index.html", line 337, in top-level template code
|
||||
// 'X-CSRFToken': '{{ csrf_token() }}'
|
||||
^^^
|
||||
File "C:\safe\repos\olaper\.venv\Lib\site-packages\jinja2\utils.py", line 92, in from_obj
|
||||
if hasattr(obj, "jinja_pass_arg"):
|
||||
~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^
|
||||
jinja2.exceptions.UndefinedError: 'csrf_token' is undefined
|
||||
2025-07-31 02:57:14,133 ERROR: Exception on / [GET] [in C:\safe\repos\olaper\.venv\Lib\site-packages\flask\app.py:875]
|
||||
Traceback (most recent call last):
|
||||
File "C:\safe\repos\olaper\.venv\Lib\site-packages\flask\app.py", line 1511, in wsgi_app
|
||||
response = self.full_dispatch_request()
|
||||
File "C:\safe\repos\olaper\.venv\Lib\site-packages\flask\app.py", line 919, in full_dispatch_request
|
||||
rv = self.handle_user_exception(e)
|
||||
File "C:\safe\repos\olaper\.venv\Lib\site-packages\flask\app.py", line 917, in full_dispatch_request
|
||||
rv = self.dispatch_request()
|
||||
File "C:\safe\repos\olaper\.venv\Lib\site-packages\flask\app.py", line 902, in dispatch_request
|
||||
return self.ensure_sync(self.view_functions[rule.endpoint])(**view_args) # type: ignore[no-any-return]
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~^^^^^^^^^^^^^
|
||||
File "C:\safe\repos\olaper\.venv\Lib\site-packages\flask_login\utils.py", line 290, in decorated_view
|
||||
return current_app.ensure_sync(func)(*args, **kwargs)
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^
|
||||
File "c:\safe\repos\olaper\routes.py", line 137, in index
|
||||
return render_template(
|
||||
'index.html',
|
||||
...<5 lines>...
|
||||
client_email=config.google_client_email
|
||||
)
|
||||
File "C:\safe\repos\olaper\.venv\Lib\site-packages\flask\templating.py", line 150, in render_template
|
||||
return _render(app, template, context)
|
||||
File "C:\safe\repos\olaper\.venv\Lib\site-packages\flask\templating.py", line 131, in _render
|
||||
rv = template.render(context)
|
||||
File "C:\safe\repos\olaper\.venv\Lib\site-packages\jinja2\environment.py", line 1295, in render
|
||||
self.environment.handle_exception()
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~^^
|
||||
File "C:\safe\repos\olaper\.venv\Lib\site-packages\jinja2\environment.py", line 942, in handle_exception
|
||||
raise rewrite_traceback_stack(source=source)
|
||||
File "c:\safe\repos\olaper\templates\index.html", line 337, in top-level template code
|
||||
body: JSON.stringify({ cron_string: cronStr })
|
||||
^^^^^^^
|
||||
File "C:\safe\repos\olaper\.venv\Lib\site-packages\jinja2\utils.py", line 92, in from_obj
|
||||
if hasattr(obj, "jinja_pass_arg"):
|
||||
~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^
|
||||
jinja2.exceptions.UndefinedError: 'csrf_token' is undefined
|
||||
2025-07-31 02:57:19,964 INFO: Application startup [in c:\safe\repos\olaper\app.py:46]
|
||||
2025-07-31 02:57:20,425 INFO: Scheduled job loaded on startup: user_1_sheet_Лист1 with schedule '25 2 * * *' [in c:\safe\repos\olaper\app.py:115]
|
||||
2025-07-31 02:57:20,426 INFO: Scheduled job loaded on startup: user_2_sheet_Лист1 with schedule '50 2 * * *' [in c:\safe\repos\olaper\app.py:115]
|
||||
2025-07-31 03:03:30,227 INFO: Application startup [in c:\safe\repos\olaper\app.py:46]
|
||||
2025-07-31 03:03:31,054 INFO: Scheduled job loaded on startup: user_1_sheet_Лист1 with schedule '25 2 * * *' [in c:\safe\repos\olaper\app.py:115]
|
||||
2025-07-31 03:03:31,055 INFO: Scheduled job loaded on startup: user_2_sheet_Лист1 with schedule '50 2 * * *' [in c:\safe\repos\olaper\app.py:115]
|
||||
2025-07-31 03:09:12,340 INFO: Application startup [in c:\safe\repos\olaper\app.py:46]
|
||||
2025-07-31 03:09:12,803 INFO: Scheduled job loaded on startup: user_1_sheet_Лист1 with schedule '25 2 * * *' [in c:\safe\repos\olaper\app.py:115]
|
||||
2025-07-31 03:09:12,803 INFO: Scheduled job loaded on startup: user_2_sheet_Лист1 with schedule '50 2 * * *' [in c:\safe\repos\olaper\app.py:115]
|
||||
2025-07-31 03:09:24,741 INFO: Application startup [in c:\safe\repos\olaper\app.py:46]
|
||||
2025-07-31 03:09:25,200 INFO: Scheduled job loaded on startup: user_1_sheet_Лист1 with schedule '25 2 * * *' [in c:\safe\repos\olaper\app.py:115]
|
||||
2025-07-31 03:09:25,201 INFO: Scheduled job loaded on startup: user_2_sheet_Лист1 with schedule '50 2 * * *' [in c:\safe\repos\olaper\app.py:115]
|
||||
2025-07-31 03:10:47,743 INFO: Application startup [in c:\safe\repos\olaper\app.py:46]
|
||||
2025-07-31 03:10:48,576 INFO: Scheduled job loaded on startup: user_1_sheet_Лист1 with schedule '25 2 * * *' [in c:\safe\repos\olaper\app.py:115]
|
||||
2025-07-31 03:10:48,576 INFO: Scheduled job loaded on startup: user_2_sheet_Лист1 with schedule '50 2 * * *' [in c:\safe\repos\olaper\app.py:115]
|
||||
2025-07-31 03:14:10,324 INFO: Application startup [in c:\safe\repos\olaper\app.py:46]
|
||||
2025-07-31 03:14:11,169 INFO: Scheduled job loaded on startup: user_1_sheet_Лист1 with schedule '25 2 * * *' [in c:\safe\repos\olaper\app.py:115]
|
||||
2025-07-31 03:14:11,170 INFO: Scheduled job loaded on startup: user_2_sheet_Лист1 with schedule '50 2 * * *' [in c:\safe\repos\olaper\app.py:115]
|
||||
2025-07-31 03:14:17,931 WARNING: Cron descriptor failed for string '20 18 * * *': get_description() got an unexpected keyword argument 'casing_type' [in c:\safe\repos\olaper\routes.py:506]
|
||||
2025-07-31 03:16:10,376 INFO: Application startup [in c:\safe\repos\olaper\app.py:46]
|
||||
2025-07-31 03:16:10,839 INFO: Scheduled job loaded on startup: user_1_sheet_Лист1 with schedule '25 2 * * *' [in c:\safe\repos\olaper\app.py:115]
|
||||
2025-07-31 03:16:10,840 INFO: Scheduled job loaded on startup: user_2_sheet_Лист1 with schedule '50 2 * * *' [in c:\safe\repos\olaper\app.py:115]
|
||||
2025-07-31 03:16:16,952 INFO: Application startup [in c:\safe\repos\olaper\app.py:46]
|
||||
2025-07-31 03:16:17,401 INFO: Scheduled job loaded on startup: user_1_sheet_Лист1 with schedule '25 2 * * *' [in c:\safe\repos\olaper\app.py:115]
|
||||
2025-07-31 03:16:17,402 INFO: Scheduled job loaded on startup: user_2_sheet_Лист1 with schedule '50 2 * * *' [in c:\safe\repos\olaper\app.py:115]
|
||||
2025-07-31 03:16:27,363 WARNING: Cron descriptor failed for string '20 18 * * *': Unknown locale configuration argument [in c:\safe\repos\olaper\routes.py:505]
|
||||
2025-07-31 03:17:27,908 INFO: Application startup [in c:\safe\repos\olaper\app.py:46]
|
||||
2025-07-31 03:17:28,360 INFO: Scheduled job loaded on startup: user_1_sheet_Лист1 with schedule '25 2 * * *' [in c:\safe\repos\olaper\app.py:115]
|
||||
2025-07-31 03:17:28,361 INFO: Scheduled job loaded on startup: user_2_sheet_Лист1 with schedule '50 2 * * *' [in c:\safe\repos\olaper\app.py:115]
|
||||
2025-07-31 03:17:31,939 WARNING: Cron descriptor failed for string '20 18 * * *': Unknown locale configuration argument [in c:\safe\repos\olaper\routes.py:505]
|
||||
2025-07-31 03:18:08,943 INFO: Application startup [in c:\safe\repos\olaper\app.py:46]
|
||||
2025-07-31 03:18:09,771 INFO: Scheduled job loaded on startup: user_1_sheet_Лист1 with schedule '25 2 * * *' [in c:\safe\repos\olaper\app.py:115]
|
||||
2025-07-31 03:18:09,772 INFO: Scheduled job loaded on startup: user_2_sheet_Лист1 with schedule '50 2 * * *' [in c:\safe\repos\olaper\app.py:115]
|
||||
2025-07-31 03:18:13,243 WARNING: Cron descriptor failed for string '20 18 * * *': Unknown locale configuration argument [in c:\safe\repos\olaper\routes.py:505]
|
||||
2025-07-31 03:18:23,676 WARNING: Cron descriptor failed for string '23 18 * * *': Unknown locale configuration argument [in c:\safe\repos\olaper\routes.py:505]
|
||||
2025-07-31 03:20:00,630 INFO: Application startup [in c:\safe\repos\olaper\app.py:46]
|
||||
2025-07-31 03:20:01,474 INFO: Scheduled job loaded on startup: user_1_sheet_Лист1 with schedule '25 2 * * *' [in c:\safe\repos\olaper\app.py:115]
|
||||
2025-07-31 03:20:01,474 INFO: Scheduled job loaded on startup: user_2_sheet_Лист1 with schedule '50 2 * * *' [in c:\safe\repos\olaper\app.py:115]
|
||||
2025-07-31 03:23:38,184 INFO: Application startup [in c:\safe\repos\olaper\app.py:46]
|
||||
2025-07-31 03:23:39,029 INFO: Scheduled job loaded on startup: user_1_sheet_Лист1 with schedule '25 2 * * *' [in c:\safe\repos\olaper\app.py:115]
|
||||
2025-07-31 03:23:39,029 INFO: Scheduled job loaded on startup: user_2_sheet_Лист1 with schedule '50 2 * * *' [in c:\safe\repos\olaper\app.py:115]
|
||||
2025-07-31 03:23:45,347 WARNING: Cron descriptor failed for string '23 18 * * *': Unknown Locale configuration argument [in c:\safe\repos\olaper\routes.py:505]
|
||||
2025-07-31 03:24:01,010 INFO: Application startup [in c:\safe\repos\olaper\app.py:46]
|
||||
2025-07-31 03:24:01,847 INFO: Scheduled job loaded on startup: user_1_sheet_Лист1 with schedule '25 2 * * *' [in c:\safe\repos\olaper\app.py:115]
|
||||
2025-07-31 03:24:01,848 INFO: Scheduled job loaded on startup: user_2_sheet_Лист1 with schedule '50 2 * * *' [in c:\safe\repos\olaper\app.py:115]
|
||||
2025-07-31 03:24:08,733 WARNING: Cron descriptor failed for string '23 18 * * *': Unknown locale configuration argument [in c:\safe\repos\olaper\routes.py:505]
|
||||
2025-07-31 03:26:26,201 INFO: Application startup [in c:\safe\repos\olaper\app.py:46]
|
||||
2025-07-31 03:26:27,048 INFO: Scheduled job loaded on startup: user_1_sheet_Лист1 with schedule '25 2 * * *' [in c:\safe\repos\olaper\app.py:115]
|
||||
2025-07-31 03:26:27,049 INFO: Scheduled job loaded on startup: user_2_sheet_Лист1 with schedule '50 2 * * *' [in c:\safe\repos\olaper\app.py:115]
|
||||
2025-07-31 03:27:34,340 INFO: Application startup [in c:\safe\repos\olaper\app.py:46]
|
||||
2025-07-31 03:27:34,799 INFO: Scheduled job loaded on startup: user_1_sheet_Лист1 with schedule '25 2 * * *' [in c:\safe\repos\olaper\app.py:115]
|
||||
2025-07-31 03:27:34,799 INFO: Scheduled job loaded on startup: user_2_sheet_Лист1 with schedule '50 2 * * *' [in c:\safe\repos\olaper\app.py:115]
|
||||
2025-07-31 03:27:50,717 INFO: Application startup [in c:\safe\repos\olaper\app.py:46]
|
||||
2025-07-31 03:27:51,179 INFO: Scheduled job loaded on startup: user_1_sheet_Лист1 with schedule '25 2 * * *' [in c:\safe\repos\olaper\app.py:115]
|
||||
2025-07-31 03:27:51,179 INFO: Scheduled job loaded on startup: user_2_sheet_Лист1 with schedule '50 2 * * *' [in c:\safe\repos\olaper\app.py:115]
|
||||
2025-07-31 03:29:27,053 INFO: Application startup [in c:\safe\repos\olaper\app.py:46]
|
||||
2025-07-31 03:29:27,894 INFO: Scheduled job loaded on startup: user_1_sheet_Лист1 with schedule '25 2 * * *' [in c:\safe\repos\olaper\app.py:115]
|
||||
2025-07-31 03:29:27,894 INFO: Scheduled job loaded on startup: user_2_sheet_Лист1 with schedule '50 2 * * *' [in c:\safe\repos\olaper\app.py:115]
|
||||
2025-07-31 03:32:46,325 INFO: Application startup [in c:\safe\repos\olaper\app.py:46]
|
||||
2025-07-31 03:32:46,788 INFO: Scheduled job loaded on startup: user_1_sheet_Лист1 with schedule '25 2 * * *' [in c:\safe\repos\olaper\app.py:115]
|
||||
2025-07-31 03:32:46,788 INFO: Scheduled job loaded on startup: user_2_sheet_Лист1 with schedule '50 2 * * *' [in c:\safe\repos\olaper\app.py:115]
|
||||
2025-07-31 03:32:54,423 WARNING: Cron descriptor failed for string '4 18 3 * *' with locale 'ru_RU': Unknown locale configuration argument [in c:\safe\repos\olaper\routes.py:507]
|
||||
2025-07-31 03:33:12,448 INFO: Application startup [in c:\safe\repos\olaper\app.py:46]
|
||||
2025-07-31 03:33:12,894 INFO: Scheduled job loaded on startup: user_1_sheet_Лист1 with schedule '25 2 * * *' [in c:\safe\repos\olaper\app.py:115]
|
||||
2025-07-31 03:33:12,894 INFO: Scheduled job loaded on startup: user_2_sheet_Лист1 with schedule '50 2 * * *' [in c:\safe\repos\olaper\app.py:115]
|
||||
2025-07-31 03:33:29,971 INFO: Application startup [in c:\safe\repos\olaper\app.py:46]
|
||||
2025-07-31 03:33:30,418 INFO: Scheduled job loaded on startup: user_1_sheet_Лист1 with schedule '25 2 * * *' [in c:\safe\repos\olaper\app.py:115]
|
||||
2025-07-31 03:33:30,418 INFO: Scheduled job loaded on startup: user_2_sheet_Лист1 with schedule '50 2 * * *' [in c:\safe\repos\olaper\app.py:115]
|
||||
2025-07-31 03:33:34,105 WARNING: Cron descriptor failed for string '4 18 3 * *' with locale 'ru_RU': Unknown locale configuration argument [in c:\safe\repos\olaper\routes.py:507]
|
||||
2025-07-31 03:34:54,314 INFO: Application startup [in c:\safe\repos\olaper\app.py:46]
|
||||
2025-07-31 03:34:54,783 INFO: Scheduled job loaded on startup: user_1_sheet_Лист1 with schedule '25 2 * * *' [in c:\safe\repos\olaper\app.py:115]
|
||||
2025-07-31 03:34:54,784 INFO: Scheduled job loaded on startup: user_2_sheet_Лист1 with schedule '50 2 * * *' [in c:\safe\repos\olaper\app.py:115]
|
||||
2025-07-31 03:45:44,279 INFO: Application startup [in c:\safe\repos\olaper\app.py:46]
|
||||
2025-07-31 03:45:44,776 INFO: Scheduled job loaded on startup: user_1_sheet_Лист1 with schedule '25 2 * * *' [in c:\safe\repos\olaper\app.py:115]
|
||||
2025-07-31 03:45:44,776 INFO: Scheduled job loaded on startup: user_2_sheet_Лист1 with schedule '50 2 * * *' [in c:\safe\repos\olaper\app.py:115]
|
||||
2025-07-31 03:45:57,114 WARNING: Cron descriptor failed for string '4 -18 3 7 5' with locale 'ru_RU': [in c:\safe\repos\olaper\routes.py:507]
|
||||
2025-07-31 03:45:59,074 WARNING: Cron descriptor failed for string '4 \18 3 7 5' with locale 'ru_RU': [in c:\safe\repos\olaper\routes.py:507]
|
||||
2025-07-31 03:46:00,538 WARNING: Cron descriptor failed for string '4 /18 3 7 5' with locale 'ru_RU': [in c:\safe\repos\olaper\routes.py:507]
|
||||
2025-07-31 03:46:08,186 WARNING: Cron descriptor failed for string '4 2/? 3 7 5' with locale 'ru_RU': [in c:\safe\repos\olaper\routes.py:507]
|
||||
2025-07-31 03:46:09,962 WARNING: Cron descriptor failed for string '4 2/ 3 7 5' with locale 'ru_RU': [in c:\safe\repos\olaper\routes.py:507]
|
||||
2025-07-31 03:46:18,499 WARNING: Cron descriptor failed for string '4 2/18 7 5' with locale 'ru_RU': Error: Expression only has 4 parts. At least 5 part are required. [in c:\safe\repos\olaper\routes.py:507]
|
||||
2025-07-31 03:46:22,058 WARNING: Cron descriptor failed for string '4 2/18 7 5' with locale 'ru_RU': Error: Expression only has 4 parts. At least 5 part are required. [in c:\safe\repos\olaper\routes.py:507]
|
||||
2025-07-31 03:48:10,579 WARNING: Cron descriptor failed for string '4 2/18 7 5' with locale 'ru_RU': Error: Expression only has 4 parts. At least 5 part are required. [in c:\safe\repos\olaper\routes.py:507]
|
||||
2025-07-31 03:48:14,027 WARNING: Cron descriptor failed for string '4 2/18 7 5' with locale 'ru_RU': Error: Expression only has 4 parts. At least 5 part are required. [in c:\safe\repos\olaper\routes.py:507]
|
||||
2025-07-31 03:48:16,964 WARNING: Cron descriptor failed for string '4 2/18 7 5 ' with locale 'ru_RU': Error: Expression only has 4 parts. At least 5 part are required. [in c:\safe\repos\olaper\routes.py:507]
|
||||
@@ -1,11 +1,13 @@
|
||||
from flask_sqlalchemy import SQLAlchemy
|
||||
from flask_migrate import Migrate
|
||||
from flask_login import LoginManager
|
||||
from flask_babel import Babel
|
||||
|
||||
# Создаем экземпляры расширений здесь, без привязки к приложению.
|
||||
# Теперь любой модуль может безопасно импортировать их отсюда.
|
||||
db = SQLAlchemy()
|
||||
migrate = Migrate()
|
||||
login_manager = LoginManager()
|
||||
babel = Babel()
|
||||
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()
|
||||
98
prompt
98
prompt
@@ -1,49 +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, пути, названия), всегда уточняй их у меня.
|
||||
Качество кода: Пиши чистый, поддерживаемый код, готовый к дальнейшему расширению функционала.
|
||||
Тема: Доработка и рефакторинг 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, пути, названия), всегда уточняй их у меня.
|
||||
Качество кода: Пиши чистый, поддерживаемый код, готовый к дальнейшему расширению функционала.
|
||||
|
||||
BIN
requirements.txt
BIN
requirements.txt
Binary file not shown.
932
routes.py
932
routes.py
@@ -1,361 +1,573 @@
|
||||
# routes.py
|
||||
import os
|
||||
import json
|
||||
import shutil
|
||||
from flask import (
|
||||
Blueprint, render_template, request, redirect, url_for, flash, g, session, current_app
|
||||
)
|
||||
from flask_login import login_user, login_required, logout_user, current_user
|
||||
from flask_babel import _
|
||||
from werkzeug.utils import secure_filename
|
||||
import gspread
|
||||
|
||||
# --- ИМПОРТ РАСШИРЕНИЙ И МОДЕЛЕЙ ---
|
||||
# Импортируем экземпляры расширений, созданные в app.py
|
||||
from extensions import db, login_manager
|
||||
# Импортируем наши классы и утилиты
|
||||
from models import User, UserConfig
|
||||
from google_sheets import GoogleSheets
|
||||
from request_module import ReqModule
|
||||
from utils import get_dates, generate_template_from_preset, render_temp
|
||||
|
||||
|
||||
# --- Создание блюпринта ---
|
||||
main_bp = Blueprint('main', __name__)
|
||||
|
||||
|
||||
# --- Регистрация обработчиков для расширений ---
|
||||
|
||||
@login_manager.user_loader
|
||||
def load_user(user_id):
|
||||
"""Загружает пользователя из БД для управления сессией."""
|
||||
return db.session.get(User, int(user_id))
|
||||
|
||||
|
||||
@main_bp.before_app_request
|
||||
def load_user_specific_data():
|
||||
"""Загружает конфигурацию пользователя в глобальный объект `g` для текущего запроса."""
|
||||
g.user_config = None
|
||||
if current_user.is_authenticated:
|
||||
g.user_config = get_user_config()
|
||||
|
||||
|
||||
# --- Вспомогательные функции, специфичные для маршрутов ---
|
||||
|
||||
def get_user_config():
|
||||
"""Получает конфиг для текущего пользователя, создавая его при необходимости."""
|
||||
if not current_user.is_authenticated:
|
||||
return None
|
||||
config = UserConfig.query.filter_by(user_id=current_user.id).first()
|
||||
if not config:
|
||||
config = UserConfig(user_id=current_user.id)
|
||||
db.session.add(config)
|
||||
return config
|
||||
|
||||
def get_user_upload_path(filename=""):
|
||||
"""Возвращает путь для загрузки файлов для текущего пользователя."""
|
||||
if not current_user.is_authenticated:
|
||||
return None
|
||||
user_dir = os.path.join(current_app.config['DATA_DIR'], str(current_user.id))
|
||||
os.makedirs(user_dir, exist_ok=True)
|
||||
return os.path.join(user_dir, secure_filename(filename))
|
||||
|
||||
|
||||
# --- Маршруты ---
|
||||
|
||||
@main_bp.route('/language/<language>')
|
||||
def set_language(language=None):
|
||||
session['language'] = language
|
||||
return redirect(request.referrer or url_for('.index'))
|
||||
|
||||
@main_bp.route('/login', methods=['GET', 'POST'])
|
||||
def login():
|
||||
if current_user.is_authenticated:
|
||||
return redirect(url_for('.index'))
|
||||
if request.method == 'POST':
|
||||
username = request.form.get('username')
|
||||
password = request.form.get('password')
|
||||
user = User.query.filter_by(username=username).first()
|
||||
if user is None or not user.check_password(password):
|
||||
flash(_('Invalid username or password'), 'error')
|
||||
return redirect(url_for('.login'))
|
||||
login_user(user, remember=request.form.get('remember'))
|
||||
flash(_('Login successful!'), 'success')
|
||||
next_page = request.args.get('next')
|
||||
return redirect(next_page or url_for('.index'))
|
||||
return render_template('login.html')
|
||||
|
||||
@main_bp.route('/register', methods=['GET', 'POST'])
|
||||
def register():
|
||||
if current_user.is_authenticated:
|
||||
return redirect(url_for('.index'))
|
||||
if request.method == 'POST':
|
||||
username = request.form.get('username')
|
||||
password = request.form.get('password')
|
||||
if not username or not password:
|
||||
flash(_('Username and password are required.'), 'error')
|
||||
return redirect(url_for('.register'))
|
||||
if User.query.filter_by(username=username).first():
|
||||
flash(_('Username already exists.'), 'error')
|
||||
return redirect(url_for('.register'))
|
||||
|
||||
user = User(username=username)
|
||||
user.set_password(password)
|
||||
user.config = UserConfig()
|
||||
db.session.add(user)
|
||||
try:
|
||||
db.session.commit()
|
||||
flash(_('Registration successful! Please log in.'), 'success')
|
||||
return redirect(url_for('.login'))
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
flash(_('An error occurred during registration. Please try again.'), 'error')
|
||||
return redirect(url_for('.register'))
|
||||
|
||||
return render_template('register.html')
|
||||
|
||||
@main_bp.route('/logout')
|
||||
@login_required
|
||||
def logout():
|
||||
logout_user()
|
||||
flash(_('You have been logged out.'), 'success')
|
||||
return redirect(url_for('.index'))
|
||||
|
||||
@main_bp.route('/')
|
||||
@login_required
|
||||
def index():
|
||||
config = g.user_config
|
||||
return render_template(
|
||||
'index.html',
|
||||
rms_config=config.get_rms_dict(),
|
||||
google_config=config.get_google_dict(),
|
||||
presets=config.presets,
|
||||
sheets=config.sheets,
|
||||
mappings=config.mappings,
|
||||
client_email=config.google_client_email
|
||||
)
|
||||
|
||||
@main_bp.route('/configure_rms', methods=['POST'])
|
||||
@login_required
|
||||
def configure_rms():
|
||||
config = g.user_config
|
||||
try:
|
||||
host = request.form.get('host', '').strip()
|
||||
login = request.form.get('login', '').strip()
|
||||
password = request.form.get('password', '')
|
||||
|
||||
if not config.rms_password and not password:
|
||||
flash(_('Password is required for the first time.'), 'error')
|
||||
return redirect(url_for('.index'))
|
||||
|
||||
if not host or not login:
|
||||
flash(_('Host and Login fields must be filled.'), 'error')
|
||||
return redirect(url_for('.index'))
|
||||
|
||||
effective_password = password if password else config.rms_password
|
||||
|
||||
req_module = ReqModule(host, login, effective_password)
|
||||
if req_module.login():
|
||||
presets_data = req_module.take_presets()
|
||||
req_module.logout()
|
||||
|
||||
config.rms_host = host
|
||||
config.rms_login = login
|
||||
if password:
|
||||
config.rms_password = password
|
||||
config.presets = presets_data
|
||||
|
||||
db.session.commit()
|
||||
flash(_('Successfully authorized on RMS server. Received %(num)s presets.', num=len(presets_data)), 'success')
|
||||
else:
|
||||
flash(_('Authorization error on RMS server. Check host, login or password.'), 'error')
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
flash(_('Error configuring RMS: %(error)s', error=str(e)), 'error')
|
||||
|
||||
return redirect(url_for('.index'))
|
||||
|
||||
@main_bp.route('/upload_credentials', methods=['POST'])
|
||||
@login_required
|
||||
def upload_credentials():
|
||||
config = g.user_config
|
||||
if 'cred_file' not in request.files or request.files['cred_file'].filename == '':
|
||||
flash(_('No file was selected.'), 'error')
|
||||
return redirect(url_for('.index'))
|
||||
|
||||
cred_file = request.files['cred_file']
|
||||
filename = cred_file.filename
|
||||
# Получаем путь для сохранения файла в папке пользователя
|
||||
user_cred_path = get_user_upload_path(filename)
|
||||
temp_path = None
|
||||
|
||||
try:
|
||||
# Сначала сохраняем файл во временную директорию для проверки
|
||||
temp_dir = os.path.join(current_app.config['DATA_DIR'], "temp")
|
||||
os.makedirs(temp_dir, exist_ok=True)
|
||||
temp_path = os.path.join(temp_dir, f"temp_{current_user.id}_{filename}")
|
||||
cred_file.save(temp_path)
|
||||
|
||||
with open(temp_path, 'r', encoding='utf-8') as f:
|
||||
cred_data = json.load(f)
|
||||
client_email = cred_data.get('client_email')
|
||||
|
||||
if not client_email:
|
||||
flash(_('Could not find client_email in the credentials file.'), 'error')
|
||||
# Не забываем удалить временный файл при ошибке
|
||||
if os.path.exists(temp_path):
|
||||
os.remove(temp_path)
|
||||
return redirect(url_for('.index'))
|
||||
|
||||
# Если все хорошо, перемещаем файл из временной папки в постоянную
|
||||
shutil.move(temp_path, user_cred_path)
|
||||
|
||||
# Сохраняем путь к файлу и email в базу данных
|
||||
config.google_cred_file_path = user_cred_path
|
||||
config.google_client_email = client_email
|
||||
config.sheets = [] # Сбрасываем список листов при смене credentials
|
||||
|
||||
db.session.commit()
|
||||
flash(_('Credentials file successfully uploaded. Email: %(email)s', email=client_email), 'success')
|
||||
|
||||
except json.JSONDecodeError:
|
||||
flash(_('Error: Uploaded file is not a valid JSON.'), 'error')
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
flash(_('Error processing credentials: %(error)s', error=str(e)), 'error')
|
||||
finally:
|
||||
# Гарантированно удаляем временный файл, если он еще существует
|
||||
if temp_path and os.path.exists(temp_path):
|
||||
os.remove(temp_path)
|
||||
|
||||
return redirect(url_for('.index'))
|
||||
|
||||
@main_bp.route('/configure_google', methods=['POST'])
|
||||
@login_required
|
||||
def configure_google():
|
||||
config = g.user_config
|
||||
sheet_url = request.form.get('sheet_url', '').strip()
|
||||
|
||||
if not sheet_url:
|
||||
flash(_('Sheet URL must be provided.'), 'error')
|
||||
return redirect(url_for('.index'))
|
||||
|
||||
config.google_sheet_url = sheet_url
|
||||
|
||||
cred_path = config.google_cred_file_path
|
||||
if not cred_path or not os.path.isfile(cred_path):
|
||||
flash(_('Please upload a valid credentials file first.'), 'warning')
|
||||
config.sheets = []
|
||||
db.session.commit()
|
||||
return redirect(url_for('.index'))
|
||||
|
||||
try:
|
||||
gs_client = GoogleSheets(cred_path, sheet_url)
|
||||
sheets_data = gs_client.get_sheets()
|
||||
config.sheets = sheets_data
|
||||
|
||||
db.session.commit()
|
||||
flash(_('Successfully connected to Google Sheets. Found %(num)s sheets. Settings saved.', num=len(sheets_data)), 'success')
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
config.sheets = []
|
||||
flash(_('Error connecting to Google Sheets: %(error)s. Check the URL and service account permissions.', error=str(e)), 'error')
|
||||
try:
|
||||
db.session.commit()
|
||||
except Exception:
|
||||
db.session.rollback()
|
||||
|
||||
return redirect(url_for('.index'))
|
||||
|
||||
@main_bp.route('/mapping_set', methods=['POST'])
|
||||
@login_required
|
||||
def mapping_set():
|
||||
config = g.user_config
|
||||
try:
|
||||
new_mappings = {}
|
||||
for sheet in config.sheets:
|
||||
report_key = f"sheet_{sheet['id']}"
|
||||
selected_report_id = request.form.get(report_key)
|
||||
if selected_report_id:
|
||||
new_mappings[sheet['title']] = selected_report_id
|
||||
|
||||
config.mappings = new_mappings
|
||||
db.session.commit()
|
||||
|
||||
flash(_('Mappings updated successfully.'), 'success')
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
flash(_('Error updating mappings: %(error)s', error=str(e)), 'error')
|
||||
|
||||
return redirect(url_for('.index'))
|
||||
|
||||
@main_bp.route('/render_olap', methods=['POST'])
|
||||
@login_required
|
||||
def render_olap():
|
||||
config = g.user_config
|
||||
sheet_title = None
|
||||
req_module = None
|
||||
|
||||
try:
|
||||
from_date, to_date = get_dates(request.form.get('start_date'), request.form.get('end_date'))
|
||||
sheet_title = next((key for key in request.form if key.startswith('render_')), '').replace('render_', '')
|
||||
if not sheet_title:
|
||||
flash(_('Error: Could not determine which sheet to render the report for.'), 'error')
|
||||
return redirect(url_for('.index'))
|
||||
|
||||
report_id = config.mappings.get(sheet_title)
|
||||
if not report_id:
|
||||
flash(_('Error: No report is assigned to sheet "%(sheet)s".', sheet=sheet_title), 'error')
|
||||
return redirect(url_for('.index'))
|
||||
|
||||
if not all([config.rms_host, config.rms_login, config.rms_password, config.google_cred_file_path, config.google_sheet_url]):
|
||||
flash(_('Error: RMS or Google Sheets configuration is incomplete.'), 'error')
|
||||
return redirect(url_for('.index'))
|
||||
|
||||
preset = next((p for p in config.presets if p.get('id') == report_id), None)
|
||||
if not preset:
|
||||
flash(_('Error: Preset with ID "%(id)s" not found in saved configuration.', id=report_id), 'error')
|
||||
return redirect(url_for('.index'))
|
||||
|
||||
template = generate_template_from_preset(preset)
|
||||
json_body = render_temp(template, {"from_date": from_date, "to_date": to_date})
|
||||
|
||||
req_module = ReqModule(config.rms_host, config.rms_login, config.rms_password)
|
||||
gs_client = GoogleSheets(config.google_cred_file_path, config.google_sheet_url)
|
||||
|
||||
if req_module.login():
|
||||
result = req_module.take_olap(json_body)
|
||||
|
||||
if 'data' not in result or not isinstance(result['data'], list):
|
||||
flash(_('Error: Unexpected response format from RMS for report "%(name)s".', name=preset.get('name', report_id)), 'error')
|
||||
return redirect(url_for('.index'))
|
||||
|
||||
data_to_insert = []
|
||||
if result['data']:
|
||||
headers = list(result['data'][0].keys())
|
||||
data_to_insert.append(headers)
|
||||
for item in result['data']:
|
||||
data_to_insert.append([item.get(h) for h in headers])
|
||||
|
||||
gs_client.clear_and_write_data(sheet_title, data_to_insert)
|
||||
|
||||
if len(data_to_insert) > 1:
|
||||
flash(_('Report "%(name)s" data successfully written to sheet "%(sheet)s".', name=preset.get('name', report_id), sheet=sheet_title), 'success')
|
||||
else:
|
||||
flash(_('Report "%(name)s" returned no data for the selected period. Sheet "%(sheet)s" has been cleared.', name=preset.get('name', report_id), sheet=sheet_title), 'warning')
|
||||
else:
|
||||
flash(_('Error authorizing on RMS server when trying to get a report.'), 'error')
|
||||
|
||||
except ValueError as ve:
|
||||
flash(_('Data Error: %(error)s', error=str(ve)), 'error')
|
||||
except gspread.exceptions.APIError as api_err:
|
||||
flash(_('Google API Error accessing sheet "%(sheet)s". Check service account permissions.', sheet=sheet_title), 'error')
|
||||
except Exception as e:
|
||||
flash(_('An unexpected error occurred: %(error)s', error=str(e)), 'error')
|
||||
finally:
|
||||
if req_module and req_module.token:
|
||||
req_module.logout()
|
||||
|
||||
# routes.py
|
||||
import os
|
||||
import json
|
||||
import shutil
|
||||
from flask import (
|
||||
Blueprint, render_template, request, redirect, url_for, flash, g, session, current_app, jsonify
|
||||
)
|
||||
from flask_login import login_user, login_required, logout_user, current_user
|
||||
from flask_babel import _
|
||||
from cron_descriptor import ExpressionDescriptor, CasingTypeEnum, Options
|
||||
from werkzeug.utils import secure_filename
|
||||
|
||||
# --- ИМПОРТ РАСШИРЕНИЙ И МОДЕЛЕЙ ---
|
||||
# Импортируем экземпляры расширений, созданные в 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, _parse_cron_string
|
||||
|
||||
|
||||
# --- Создание блюпринта ---
|
||||
main_bp = Blueprint('main', __name__)
|
||||
|
||||
# --- Регистрация обработчиков для расширений ---
|
||||
|
||||
@login_manager.user_loader
|
||||
def load_user(user_id):
|
||||
"""Загружает пользователя из БД для управления сессией."""
|
||||
return db.session.get(User, int(user_id))
|
||||
|
||||
|
||||
@main_bp.before_app_request
|
||||
def load_user_specific_data():
|
||||
"""Загружает конфигурацию пользователя в глобальный объект `g` для текущего запроса."""
|
||||
g.user_config = None
|
||||
if current_user.is_authenticated:
|
||||
g.user_config = get_user_config()
|
||||
|
||||
|
||||
# --- Вспомогательные функции, специфичные для маршрутов ---
|
||||
|
||||
def get_user_config():
|
||||
"""Получает конфиг для текущего пользователя, создавая его при необходимости."""
|
||||
if not current_user.is_authenticated:
|
||||
return None
|
||||
config = UserConfig.query.filter_by(user_id=current_user.id).first()
|
||||
if not config:
|
||||
config = UserConfig(user_id=current_user.id)
|
||||
db.session.add(config)
|
||||
return config
|
||||
|
||||
def get_user_upload_path(filename=""):
|
||||
"""Возвращает путь для загрузки файлов для текущего пользователя."""
|
||||
if not current_user.is_authenticated:
|
||||
return None
|
||||
user_dir = os.path.join(current_app.config['DATA_DIR'], str(current_user.id))
|
||||
os.makedirs(user_dir, exist_ok=True)
|
||||
return os.path.join(user_dir, secure_filename(filename))
|
||||
|
||||
# --- Маршруты ---
|
||||
|
||||
@main_bp.route('/language/<language>')
|
||||
def set_language(language=None):
|
||||
session['language'] = language
|
||||
return redirect(request.referrer or url_for('.index'))
|
||||
|
||||
@main_bp.route('/login', methods=['GET', 'POST'])
|
||||
def login():
|
||||
if current_user.is_authenticated:
|
||||
return redirect(url_for('.index'))
|
||||
if request.method == 'POST':
|
||||
username = request.form.get('username')
|
||||
password = request.form.get('password')
|
||||
user = User.query.filter_by(username=username).first()
|
||||
if user is None or not user.check_password(password):
|
||||
flash(_('Invalid username or password'), 'error')
|
||||
return redirect(url_for('.login'))
|
||||
login_user(user, remember=request.form.get('remember'))
|
||||
flash(_('Login successful!'), 'success')
|
||||
next_page = request.args.get('next')
|
||||
return redirect(next_page or url_for('.index'))
|
||||
return render_template('login.html')
|
||||
|
||||
@main_bp.route('/register', methods=['GET', 'POST'])
|
||||
def register():
|
||||
if current_user.is_authenticated:
|
||||
return redirect(url_for('.index'))
|
||||
if request.method == 'POST':
|
||||
username = request.form.get('username')
|
||||
password = request.form.get('password')
|
||||
if not username or not password:
|
||||
flash(_('Username and password are required.'), 'error')
|
||||
return redirect(url_for('.register'))
|
||||
if User.query.filter_by(username=username).first():
|
||||
flash(_('Username already exists.'), 'error')
|
||||
return redirect(url_for('.register'))
|
||||
|
||||
user = User(username=username)
|
||||
user.set_password(password)
|
||||
user.config = UserConfig()
|
||||
db.session.add(user)
|
||||
try:
|
||||
db.session.commit()
|
||||
flash(_('Registration successful! Please log in.'), 'success')
|
||||
return redirect(url_for('.login'))
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
flash(_('An error occurred during registration. Please try again.'), 'error')
|
||||
return redirect(url_for('.register'))
|
||||
|
||||
return render_template('register.html')
|
||||
|
||||
@main_bp.route('/logout')
|
||||
@login_required
|
||||
def logout():
|
||||
logout_user()
|
||||
flash(_('You have been logged out.'), 'success')
|
||||
return redirect(url_for('.index'))
|
||||
|
||||
@main_bp.route('/')
|
||||
@login_required
|
||||
def index():
|
||||
config = g.user_config
|
||||
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 {}
|
||||
|
||||
# Итерируемся по всем листам, доступным пользователю в Google Sheets
|
||||
for sheet in config.sheets:
|
||||
# Получаем ID отчета, который пользователь выбрал для этого листа в форме
|
||||
report_key = f"sheet_{sheet['id']}"
|
||||
selected_report_id = request.form.get(report_key)
|
||||
|
||||
# Если пользователь выбрал отчет (а не оставил поле пустым)
|
||||
if selected_report_id:
|
||||
# Инициализируем переменные для расписания
|
||||
schedule_cron = None
|
||||
schedule_period = None
|
||||
|
||||
# Ищем текущие настройки для этого конкретного листа в базе данных
|
||||
current_sheet_mapping = current_mappings.get(sheet.get('title'))
|
||||
|
||||
# Если для этого листа уже есть настройки, и они в новом формате (словарь),
|
||||
# то мы сохраняем его параметры расписания.
|
||||
if isinstance(current_sheet_mapping, dict):
|
||||
schedule_cron = current_sheet_mapping.get('schedule_cron')
|
||||
schedule_period = current_sheet_mapping.get('schedule_period')
|
||||
|
||||
# Создаем новую запись сопоставления.
|
||||
# Она будет содержать НОВЫЙ ID отчета и СТАРЫЕ (сохраненные) настройки расписания.
|
||||
new_mappings[sheet.get('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(app, user_id, sheet_title, start_date_str=None, end_date_str=None):
|
||||
"""
|
||||
Основная логика выгрузки OLAP-отчета. Может вызываться как из эндпоинта, так и из планировщика.
|
||||
Если start_date_str и end_date_str не переданы, вычисляет их на основе расписания.
|
||||
"""
|
||||
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_app._get_current_object(), 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('/translate_cron', methods=['POST'])
|
||||
@login_required
|
||||
def translate_cron():
|
||||
"""
|
||||
Принимает cron-строку и возвращает ее человекочитаемое описание.
|
||||
"""
|
||||
cron_string = request.json.get('cron_string')
|
||||
if not cron_string:
|
||||
return jsonify({'description': ''})
|
||||
|
||||
try:
|
||||
# 1. Получаем и преобразуем код локали
|
||||
app_locale = session.get('language', 'ru')
|
||||
locale_map = { 'ru': 'ru_RU', 'en': 'en_US' }
|
||||
cron_locale = locale_map.get(app_locale, 'en_US')
|
||||
|
||||
# 2. Создаем объект Options ТОЛЬКО для форматирования
|
||||
options = Options()
|
||||
options.locale_code = cron_locale
|
||||
options.casing_type = CasingTypeEnum.Sentence
|
||||
options.use_24hour_time_format = True
|
||||
|
||||
# 3. Создаем ExpressionDescriptor, передавая ему ЯВНО и опции, и локаль
|
||||
descriptor = ExpressionDescriptor(
|
||||
expression=cron_string,
|
||||
options=options
|
||||
)
|
||||
|
||||
# 4. Получаем описание
|
||||
description = descriptor.get_description()
|
||||
|
||||
return jsonify({'description': description})
|
||||
except Exception as e:
|
||||
current_app.logger.warning(f"Cron descriptor failed for string '{cron_string}' with locale '{cron_locale}': {e}")
|
||||
return jsonify({'description': str(_('Invalid cron format'))})
|
||||
|
||||
|
||||
@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_app._get_current_object(), 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'))
|
||||
@@ -257,4 +257,32 @@ small {
|
||||
margin-right: 5px; /* Отступ справа от чекбокса до текста */
|
||||
vertical-align: middle; /* Выравнивание по вертикали */
|
||||
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;
|
||||
}
|
||||
@@ -147,7 +147,7 @@
|
||||
<select name="sheet_{{ sheet.id }}">
|
||||
<option value="">-- {{ _('Not set') }} --</option>
|
||||
{% 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'] }})
|
||||
</option>
|
||||
{% endfor %}
|
||||
@@ -196,25 +196,26 @@
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for sheet in sheets %}
|
||||
{% set report_id = mappings.get(sheet.title) %}
|
||||
{% if report_id %}
|
||||
{% for sheet_title, mapping_info in mappings.items() %}
|
||||
{% if mapping_info and mapping_info.get('report_id') %}
|
||||
{% set report_id = mapping_info.get('report_id') %}
|
||||
{% set matching_presets = presets | selectattr('id', 'equalto', report_id) | list %}
|
||||
{% set preset_name = _('ID: ') + report_id %}
|
||||
|
||||
<!-- Отображаем строку только если для этого report_id найден соответствующий пресет -->
|
||||
{% if matching_presets %}
|
||||
{% set preset = matching_presets[0] %}
|
||||
{% set preset_name = preset.get('name', _('Unnamed Preset')) %}
|
||||
{% endif %}
|
||||
|
||||
<tr>
|
||||
<td>{{ sheet.title }}</td>
|
||||
<td>{{ preset_name }}</td>
|
||||
<td>
|
||||
<button type="submit" name="render_{{ sheet.title }}">
|
||||
{{ _('Render to sheet') }}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>{{ sheet_title }}</td>
|
||||
<td>{{ preset_name }}</td>
|
||||
<td>
|
||||
<button type="submit" name="render_{{ sheet_title }}">
|
||||
{{ _('Render to sheet') }}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
@@ -226,6 +227,71 @@
|
||||
{% endif %}
|
||||
</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">
|
||||
<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>
|
||||
<th>{{ _('Action') }}</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 -->
|
||||
|
||||
<script>
|
||||
@@ -251,6 +317,85 @@
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// --- Cron конструктор и переводчик ---
|
||||
const cronInputs = ['cron-minute', 'cron-hour', 'cron-day', 'cron-month', 'cron-day-of-week'];
|
||||
const cronOutput = document.getElementById('cron-output');
|
||||
const humanReadableOutput = document.getElementById('cron-human-readable');
|
||||
|
||||
// Функция для перевода cron-строки
|
||||
async function translateCronString(cronStr) {
|
||||
if (!humanReadableOutput) return;
|
||||
|
||||
try {
|
||||
const response = await fetch("{{ url_for('.translate_cron') }}", {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ cron_string: cronStr })
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
humanReadableOutput.textContent = 'Error communicating with server.';
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
humanReadableOutput.textContent = data.description;
|
||||
humanReadableOutput.style.color = data.description.startsWith('Invalid') ? 'red' : '#555';
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error translating cron string:', error);
|
||||
humanReadableOutput.textContent = 'Translation error.';
|
||||
}
|
||||
}
|
||||
|
||||
// Функция для обновления строки (из селектов) и запуска перевода
|
||||
function updateCronStringFromSelects() {
|
||||
if (!cronOutput) return;
|
||||
const values = cronInputs.map(id => document.getElementById(id).value);
|
||||
const newCronString = values.join(' ');
|
||||
cronOutput.value = newCronString;
|
||||
|
||||
// Вызываем перевод
|
||||
translateCronString(newCronString);
|
||||
}
|
||||
|
||||
// Слушаем изменения в выпадающих списках конструктора
|
||||
cronInputs.forEach(id => {
|
||||
const el = document.getElementById(id);
|
||||
if (el) el.addEventListener('change', updateCronStringFromSelects);
|
||||
});
|
||||
|
||||
if (cronOutput) {
|
||||
cronOutput.addEventListener('input', function() {
|
||||
translateCronString(this.value); // Переводим то, что введено вручную
|
||||
});
|
||||
}
|
||||
|
||||
// Первоначальный вызов при загрузке страницы (для начальной строки)
|
||||
updateCronStringFromSelects();
|
||||
|
||||
// --- Управление видимостью поля "N дней" ---
|
||||
document.querySelectorAll('.period-select').forEach(select => {
|
||||
const targetId = select.dataset.target;
|
||||
const targetDiv = document.getElementById(targetId);
|
||||
|
||||
function toggleCustomInput() {
|
||||
if (!targetDiv) return;
|
||||
if (select.value === 'last_N_days') {
|
||||
targetDiv.style.display = 'block';
|
||||
} else {
|
||||
targetDiv.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
select.addEventListener('change', toggleCustomInput);
|
||||
toggleCustomInput();
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% else %}
|
||||
<p style="text-align: center; margin-top: 50px;">{{ _('Please,') }} <a href="{{ url_for('.login') }}">{{ _('login') }}</a> {{ _('or') }} <a href="{{ url_for('.register') }}">{{ _('register') }}</a></p>
|
||||
|
||||
65
utils.py
65
utils.py
@@ -1,7 +1,8 @@
|
||||
import json
|
||||
import logging
|
||||
from jinja2 import Template
|
||||
from datetime import datetime
|
||||
from datetime import datetime, date, timedelta
|
||||
from dateutil.relativedelta import relativedelta
|
||||
|
||||
# Настройка логирования
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -138,4 +139,64 @@ def get_dates(start_date, end_date):
|
||||
logger.error(f"Дата начала '{start_date}' не может быть позже даты окончания '{end_date}'.")
|
||||
raise ValueError("Дата начала не может быть позже даты окончания.")
|
||||
|
||||
return start_date, end_date
|
||||
return start_date, end_date
|
||||
|
||||
def _parse_cron_string(cron_str):
|
||||
"""Парсит строку cron в словарь для APScheduler."""
|
||||
if not isinstance(cron_str, str):
|
||||
raise TypeError("cron_str must be a string.")
|
||||
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)}
|
||||
|
||||
|
||||
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