From 0f1c749b33881afdc8ced5e79f9ede6c1fb239f9 Mon Sep 17 00:00:00 2001 From: SERTY Date: Wed, 30 Jul 2025 18:28:55 +0300 Subject: [PATCH] Scheduler v1 --- app.py | 39 ++++- extensions.py | 4 +- requirements.txt | Bin 1474 -> 777 bytes routes.py | 356 ++++++++++++++++++++++++++----------------- static/style.css | 28 ++++ templates/index.html | 136 ++++++++++++++--- utils.py | 53 ++++++- 7 files changed, 457 insertions(+), 159 deletions(-) diff --git a/app.py b/app.py index c8f9023..76e6b09 100644 --- a/app.py +++ b/app.py @@ -6,7 +6,7 @@ from dotenv import load_dotenv 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. Фабрика приложений @@ -49,16 +49,41 @@ 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(): + from models import User, UserConfig + all_configs = UserConfig.query.all() + for config in all_configs: + user_id = config.user_id + mappings = config.mappings + for sheer_title, params in mappings.items(): + cron_schedule = params.get('schedule_cron') + if cron_schedule: + job_id = f"user_{user_id}_sheet_{sheer_title}" + try: + scheduler.add_job( + id=job_id, + func=execute_olap_export, + trigger='cron', + args=[user_id, sheer_title], + **_parse_cron_string(cron_schedule) + ) + app.logger.info(f"Job {job_id} loaded on startup.") + except Exception as e: + app.logger.error(f"Failed to load job {job_id}: {e}") + scheduler.start() + # --- Регистрация команд CLI --- from models import User, UserConfig @app.cli.command('init-db') @@ -73,6 +98,16 @@ def create_app(): # --- Точка входа для запуска --- app = create_app() +# --- Вспомогательная функция для парсинга cron --- +def _parse_cron_string(cron_str): + """Парсит строку cron в словарь для APScheduler.""" + parts = cron_str.split() + if len(parts) != 5: + raise ValueError("Invalid cron string format. Expected 5 parts.") + + keys = ['minute', 'hour', 'day', 'month', 'day_of_week'] + return {keys[i]: part for i, part in enumerate(parts)} + if __name__ == '__main__': # Для прямого запуска через `python app.py` (удобно для отладки) app.run(host='0.0.0.0', port=int(os.environ.get("PORT", 5005))) \ No newline at end of file diff --git a/extensions.py b/extensions.py index 453d06b..f1c9e79 100644 --- a/extensions.py +++ b/extensions.py @@ -2,10 +2,12 @@ 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() \ No newline at end of file +babel = Babel() +scheduler = APScheduler() \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 416c57200e07daacb6fe3d120544836a0ef11e3f..7f8b30db4784c715dabb69a4dfed38c0c928fdd1 100644 GIT binary patch literal 777 zcmY*XO^@3k5WVwXjEu3f$$`UO_E7Cgt#asbMF_E}F}Q%Gb$)%{IBA;RJf4~N#b|G* zlQT*QE;`<@*Y8I&*z@9TQc7|mc+F1wWW7=i7cW$K7e?v|en4q7=1Yt|E7fs=Fg6u; zaagFEjtedYGZ(5s@xoCII%Qj`5Ywc+yWW^u>Ud{HrS@3bGZTGGdeTbOyyZJ)(mI#e zCp{0VQfqlwvK7^T4nT<>yUrG0x|5kyzhTFa1;xQC(z@M z_g4?26R3$yus+7#+e$BGplIYCm{0$zNFDD^WE;t`K3N;QEsy;?C#%m&?a;>R5}b(% zS{k|ObLRl-;HsG``K&{4Q(O>ov(0~V;g@c1U_17q$4EqyAMl;VWj^W)ED%(~{z;M8 zZP={MsNuT@SO@MoK6Ij3bq?YdiMUsvQ>2RkIXwmaH8Z^-jsSQ7T+0wcbw+GUaoefj zH255IA;5CQ9Nj|0lKs8dTs9jLsXcx?<%KvPDrMMj;U~MV`{~r|`59?ocy z=bU+5|M^{neOQM>cn-TzhL2oVVZr^5=PJAnZ-^A!YjBFLEc%}3GDi1g9>WWWcFeyW z<0aW;*b?nS&9x7mp~OFFl$PDYwBdH0C0atrPtH?bO5zFOKIJ6EwrI8RODn@u&A$Wh zZl%Tlz&zf?ci`vt$QCck1Sj7NxN%)W_;Hk?9ZwN-{|sP$xA*QO%8_wh4a z1@2r)E>wYcaWlrUvwq8|I2<=|WZ@oX72c1gCd>_YZocNY2Qcofrw6LRtG~qg7{0*! z2{%5oL#!pa^-fnPOIw?hQPV*ETVvLCg@3sr<~7FYdZNZJG0nyn`2U0UI8-p?JGNAlGbXZPG46?Mc4q7@1)97Eh(DNHJN zHi7RTS2GsPLRCEQ{)Jre`yMaC?HFl^4b<=mYJ*C0G40HyKFOzhkU~SQwzt5icA+vB zKRI4YgZ{r=PO%f29eG!lm$t~~+%WwVec}YvbY!r3_!$;@I!Qv8=Eu*Ve+}HJp8z4W zDK&Z-Ts8kR;h(1M=Q*8b$G*mD(zn2QH1i)gS>s%DQs1PL$EW&oj=8*ZKF72%^6k5z zs(eoWI}s@}kGP)NnmB 0: + period_value = f"last_{custom_days}_days" + else: + period_value = "" # Сбрасываем, если введено 0 или некорректное значение + except (ValueError, TypeError): + period_value = "" + + params['schedule_cron'] = cron_value if cron_value else None + params['schedule_period'] = period_value if period_value else None + + job_id = f"user_{current_user.id}_sheet_{sheet_title}" + + # Удаляем старую задачу, если она была + if scheduler.get_job(job_id): + scheduler.remove_job(job_id) + current_app.logger.info(f"Removed existing job: {job_id}") + + # Добавляем новую задачу, если есть cron-расписание + if cron_value and period_value: + try: + cron_params = _parse_cron_string(cron_value) + scheduler.add_job( + id=job_id, + func=execute_olap_export, + trigger='cron', + args=[current_user.id, sheet_title], + replace_existing=True, + **cron_params + ) + current_app.logger.info(f"Added/updated job: {job_id} with schedule '{cron_value}'") + except ValueError as ve: + flash(_('Invalid cron format for sheet "%(sheet)s": %(error)s', sheet=sheet_title, error=ve), 'error') + except Exception as e: + flash(_('Error scheduling job for sheet "%(sheet)s": %(error)s', sheet=sheet_title, error=e), 'error') + + config.mappings = updated_mappings + db.session.commit() + flash(_('Schedule settings saved successfully.'), 'success') + + except Exception as e: + db.session.rollback() + flash(_('An error occurred while saving the schedule: %(error)s', error=str(e)), 'error') + current_app.logger.error(f"Error in save_schedule: {e}", exc_info=True) return redirect(url_for('.index')) \ No newline at end of file diff --git a/static/style.css b/static/style.css index 525d488..71605b6 100644 --- a/static/style.css +++ b/static/style.css @@ -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; } \ No newline at end of file diff --git a/templates/index.html b/templates/index.html index 6c9cda8..dfef50a 100644 --- a/templates/index.html +++ b/templates/index.html @@ -147,7 +147,7 @@ {% for i in range(60) %}{% endfor %} + + + + + + + +

+ +
+ +
+ + + + + + + + + + {% for sheet_title, params in mappings.items() %} + + + + + + {% endfor %} + +
{{ _('Worksheet') }}{{ _('Schedule (Cron)') }}{{ _('Report Period') }}
{{ sheet_title }} + + + {% set current_period = params.get('schedule_period', '') %} + + +
+ +
+ + {% else %}

{{ _('Please,') }} {{ _('login') }} {{ _('or') }} {{ _('register') }}

diff --git a/utils.py b/utils.py index 9cfdc6d..b8e22d0 100644 --- a/utils.py +++ b/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,52 @@ def get_dates(start_date, end_date): logger.error(f"Дата начала '{start_date}' не может быть позже даты окончания '{end_date}'.") raise ValueError("Дата начала не может быть позже даты окончания.") - return start_date, end_date \ No newline at end of file + return start_date, end_date + +def calculate_period_dates(period_key): + """ + Вычисляет начальную и конечную даты на основе строкового ключа. + Возвращает кортеж (start_date_str, end_date_str) в формате YYYY-MM-DD. + """ + if not period_key: + raise ValueError("Period key cannot be empty.") + + today = date.today() + yesterday = today - timedelta(days=1) + date_format = "%Y-%m-%d" + + # За прошлую неделю (с пн по вс) + if period_key == 'previous_week': + start_of_last_week = today - timedelta(days=today.weekday() + 7) + end_of_last_week = start_of_last_week + timedelta(days=6) + return start_of_last_week.strftime(date_format), end_of_last_week.strftime(date_format) + + # За последние 7 дней (не включая сегодня) + if period_key == 'last_7_days': + start_date = today - timedelta(days=7) + return start_date.strftime(date_format), yesterday.strftime(date_format) + + # За прошлый месяц + if period_key == 'previous_month': + last_month = today - relativedelta(months=1) + start_date = last_month.replace(day=1) + end_date = start_date + relativedelta(months=1) - timedelta(days=1) + return start_date.strftime(date_format), end_date.strftime(date_format) + + # За текущий месяц (до вчера) + if period_key == 'current_month': + start_date = today.replace(day=1) + return start_date.strftime(date_format), yesterday.strftime(date_format) + + # Динамический ключ "За последние N дней" + if period_key.startswith('last_') and period_key.endswith('_days'): + try: + days = int(period_key.split('_')[1]) + if days <= 0: + raise ValueError("Number of days must be positive.") + start_date = today - timedelta(days=days) + return start_date.strftime(date_format), yesterday.strftime(date_format) + except (ValueError, IndexError): + raise ValueError(f"Invalid dynamic period key: {period_key}") + + raise ValueError(f"Unknown period key: {period_key}") \ No newline at end of file