From d6a3135bae949015866ccbc14771506ff19a2154 Mon Sep 17 00:00:00 2001 From: igor_nadein Date: Wed, 25 Feb 2026 11:13:20 +0300 Subject: [PATCH] =?UTF-8?q?=D0=A3=D0=B4=D0=B0=D0=BB=D1=91=D0=BD=20=D1=80?= =?UTF-8?q?=D0=B5=D0=B6=D0=B8=D0=BC=20'=D0=B4=D0=BE=20=D0=B4=D0=B0=D1=82?= =?UTF-8?q?=D1=8B'=20=D0=B4=D0=BB=D1=8F=20=D0=BF=D0=BE=D0=B2=D1=82=D0=BE?= =?UTF-8?q?=D1=80=D1=8F=D0=B5=D0=BC=D1=8B=D1=85=20=D1=81=D0=BE=D0=B1=D1=8B?= =?UTF-8?q?=D1=82=D0=B8=D0=B9,=20=D0=B8=D1=81=D0=BF=D1=80=D0=B0=D0=B2?= =?UTF-8?q?=D0=BB=D0=B5=D0=BD=20=D0=BF=D0=B0=D1=82=D1=87=20django-schedule?= =?UTF-8?q?r,=20=D1=84=D1=80=D0=BE=D0=BD=D1=82=20=D1=82=D0=B5=D0=BF=D0=B5?= =?UTF-8?q?=D1=80=D1=8C=20=D1=82=D0=BE=D0=BB=D1=8C=D0=BA=D0=BE=20'count'?= =?UTF-8?q?=20=D0=B8=20'forever'?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/api/apps.py | 7 ++ backend/calendar_app/apps.py | 8 +- backend/check_event_params.py | 48 +++++++++ backend/eusrr_backend/settings.py | 32 ++++-- backend/schedule_patch.py | 101 ++++++++++++++++++ .../components/calendar_modal_create.html | 5 +- backend/test_patch_import.py | 50 +++++++++ 7 files changed, 234 insertions(+), 17 deletions(-) create mode 100644 backend/check_event_params.py create mode 100644 backend/schedule_patch.py create mode 100644 backend/test_patch_import.py diff --git a/backend/api/apps.py b/backend/api/apps.py index f612d4d7..439b0946 100644 --- a/backend/api/apps.py +++ b/backend/api/apps.py @@ -5,3 +5,10 @@ class ApiConfig(AppConfig): default_auto_field = 'django.db.models.BigAutoField' name = 'api' verbose_name = "REST API layer" + + def ready(self): + """Инициализация при загрузке приложения.""" + # Применяем патч для django-scheduler (исправление бага с byweekday) + print("🔧 ApiConfig.ready() - применение патча django-scheduler") + import schedule_patch + schedule_patch.apply_patch() diff --git a/backend/calendar_app/apps.py b/backend/calendar_app/apps.py index 79757263..c203253a 100644 --- a/backend/calendar_app/apps.py +++ b/backend/calendar_app/apps.py @@ -1,7 +1,8 @@ # backend/calendar_app/apps.py from __future__ import annotations -from django.apps import AppConfig, apps as django_apps +from django.apps import AppConfig +from django.apps import apps as django_apps from django.db.models.signals import post_delete, post_save @@ -16,8 +17,9 @@ class CalendarAppConfig(AppConfig): Raises: LookupError: Если приложение 'employees' или модель 'Employee' не найдены. """ - from calendar_app import signals # noqa: F401 (гарантирует импорт модулей с хендлерами) import calendar_app.notification_signals # noqa: F401 + from calendar_app import \ + signals # noqa: F401 (гарантирует импорт модулей с хендлерами) try: Employee = django_apps.get_model("employees", "Employee") @@ -36,3 +38,5 @@ class CalendarAppConfig(AppConfig): sender=Employee, dispatch_uid="calendar_app.employee_deleted_remove_birthday", ) + dispatch_uid = "calendar_app.employee_deleted_remove_birthday", + diff --git a/backend/check_event_params.py b/backend/check_event_params.py new file mode 100644 index 00000000..53e8a0e4 --- /dev/null +++ b/backend/check_event_params.py @@ -0,0 +1,48 @@ +#!/usr/bin/env python +"""Проверка параметров события в базе данных.""" +import os +import sys +import django + +# Setup Django +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "eusrr_backend.settings") +django.setup() + +# ЯВНЫЙ импорт патча +import schedule_patch # noqa + +from schedule.models import Event, Rule + +# Найдём событие по названию +event = Event.objects.filter(title__icontains='ыфвыфыф').first() + +if event: + print(f"=" * 80) + print(f"Event ID: {event.id}") + print(f"Title: {event.title}") + print(f"Start: {event.start} (weekday: {event.start.weekday()})") + print(f"End recurring: {event.end_recurring_period}") + print(f"=" * 80) + + if event.rule: + print(f"\nRule ID: {event.rule.id}") + print(f"Frequency: {event.rule.frequency}") + print(f"Params: {event.rule.params}") + print(f"=" * 80) + + # Проверим occurrences + from datetime import datetime + from django.utils import timezone + start = timezone.make_aware(datetime(2026, 2, 1, 0, 0, 0)) + end = timezone.make_aware(datetime(2026, 2, 28, 23, 59, 59)) + occurrences = event.get_occurrences(start, end) + + print(f"\nOccurrences в феврале 2026:") + for occ in occurrences: + print(f" - {occ.start.strftime('%Y-%m-%d %A')} {occ.start.strftime('%H:%M')}") + print(f"=" * 80) + else: + print("НЕТ ПРАВИЛА!") +else: + print("Событие не найдено!") diff --git a/backend/eusrr_backend/settings.py b/backend/eusrr_backend/settings.py index 20e41669..009b5f7c 100644 --- a/backend/eusrr_backend/settings.py +++ b/backend/eusrr_backend/settings.py @@ -19,7 +19,8 @@ def _split_env_list(value: str) -> list[str]: return [x.strip() for x in (value or "").split(",") if x.strip()] -ALLOWED_HOSTS = _split_env_list(os.getenv("ALLOWED_HOSTS", "localhost,127.0.0.1")) +ALLOWED_HOSTS = _split_env_list( + os.getenv("ALLOWED_HOSTS", "localhost,127.0.0.1")) INSTALLED_APPS = [ "daphne", @@ -43,6 +44,7 @@ INSTALLED_APPS = [ "api.apps.ApiConfig", "hikcentral.apps.HikcentralConfig", "schedule", # django-scheduler (проверенная библиотека для календаря) + "calendar_app.apps.CalendarAppConfig", # Наше приложение календаря "documents.apps.DocumentsConfig", "requests_app.apps.RequestsAppConfig", "feed.apps.FeedConfig", @@ -66,7 +68,8 @@ MIDDLEWARE = [ "api.middleware.JWTRefreshMiddleware", # Автообновление JWT токенов "eusrr_backend.middleware.AuthRequiredMiddleware", "eusrr_backend.middleware.EmailVerificationMiddleware", - "eusrr_backend.middleware.RegistrationIPRestrictionMiddleware", # IP ограничение для регистрации + # IP ограничение для регистрации + "eusrr_backend.middleware.RegistrationIPRestrictionMiddleware", "eusrr_backend.middleware.CacheControlMiddleware", # Cache-Control headers ] @@ -249,7 +252,8 @@ EMAIL_HOST_PASSWORD = os.getenv("EMAIL_HOST_PASSWORD", "") EMAIL_USE_SSL = os.getenv("EMAIL_USE_SSL", "true").lower() == "true" # Если SSL включён (465), TLS должен быть False EMAIL_USE_TLS = ( - False if EMAIL_USE_SSL else (os.getenv("EMAIL_USE_TLS", "false").lower() == "true") + False if EMAIL_USE_SSL else ( + os.getenv("EMAIL_USE_TLS", "false").lower() == "true") ) DEFAULT_FROM_EMAIL = os.getenv( "DEFAULT_FROM_EMAIL", EMAIL_HOST_USER or "webmaster@localhost" @@ -260,7 +264,8 @@ DEFAULT_FROM_EMAIL = os.getenv( # ----------------------------------------------------------------------------- # Порог для определения массовой рассылки (количество получателей) # При массовой рассылке уведомления создаются быстро, а отправка идёт в фоне -NOTIFICATION_BULK_THRESHOLD = int(os.getenv("NOTIFICATION_BULK_THRESHOLD", "10")) +NOTIFICATION_BULK_THRESHOLD = int( + os.getenv("NOTIFICATION_BULK_THRESHOLD", "10")) # ----------------------------------------------------------------------------- # CHANNELS & CACHE @@ -321,7 +326,8 @@ SIMPLE_JWT = { } # Автообновление JWT токенов: за сколько минут до истечения обновлять -JWT_REFRESH_THRESHOLD_MINUTES = int(os.getenv("JWT_REFRESH_THRESHOLD_MIN", "5")) +JWT_REFRESH_THRESHOLD_MINUTES = int( + os.getenv("JWT_REFRESH_THRESHOLD_MIN", "5")) # ----------------------------------------------------------------------------- @@ -332,10 +338,12 @@ JWT_REFRESH_THRESHOLD_MINUTES = int(os.getenv("JWT_REFRESH_THRESHOLD_MIN", "5")) # ----------------------------------------------------------------------------- # Порядок важен: первый успешный бэкенд останавливает цепочку AUTHENTICATION_BACKENDS = [ - "eusrr_backend.auth_backends.LDAP3Backend", # работает только если LDAP_ENABLED=True + # работает только если LDAP_ENABLED=True + "eusrr_backend.auth_backends.LDAP3Backend", "eusrr_backend.auth_backends.EmailOrPhoneBackend", # фоллбэк для режима без LDAP "eusrr_backend.auth_backends.SuperuserOnlyBackend", # экстренный доступ для админа - "eusrr_backend.auth_backends.PositionRoleBackend", # расчёт прав на основе должностей + # расчёт прав на основе должностей + "eusrr_backend.auth_backends.PositionRoleBackend", "django.contrib.auth.backends.ModelBackend", # стандартный Django бэкенд ] @@ -350,7 +358,8 @@ LDAP_BIND_DN = os.getenv("LDAP_BIND_DN", "") LDAP_BIND_PASSWORD = os.getenv("LDAP_BIND_PASSWORD", "") # TLS/CA -LDAP_CA_CERTS = os.getenv("LDAP_CA_CERTS", "") # путь к CA bundle/серту (если нужен) +# путь к CA bundle/серту (если нужен) +LDAP_CA_CERTS = os.getenv("LDAP_CA_CERTS", "") LDAP_TLS_REQUIRED = os.getenv("LDAP_TLS_REQUIRED", "true").lower() == "true" # Где искать пользователей @@ -387,7 +396,8 @@ LDAP_SYNC_GROUPS = os.getenv("LDAP_SYNC_GROUPS", "false").lower() == "true" LDAP_GROUP_ATTR = os.getenv("LDAP_GROUP_ATTR", "memberOf") # Пример: {"CN=HR,OU=Groups,DC=...,DC=...": "hr"} LDAP_GROUP_MAP = {} -LDAP_GROUPS_EXCLUSIVE = os.getenv("LDAP_GROUPS_EXCLUSIVE", "false").lower() == "true" +LDAP_GROUPS_EXCLUSIVE = os.getenv( + "LDAP_GROUPS_EXCLUSIVE", "false").lower() == "true" # WRITE-BACK LDAP_WRITE_ENABLED = os.getenv("LDAP_WRITE_ENABLED", "false").lower() == "true" @@ -448,7 +458,6 @@ LDAP_REGISTRATION_CREATE = True LDAP_POSITIONS_BASE = os.getenv("LDAP_POSITIONS_BASE") - BRAND_NAME = os.getenv("BRAND_NAME", "HiRo") BRAND_LOGO = "img/logo.png" @@ -471,7 +480,8 @@ VAPID_ADMIN_EMAIL = os.getenv("VAPID_ADMIN_EMAIL", "robotail-info@yandex.ru") # CELERY CONFIGURATION # ----------------------------------------------------------------------------- CELERY_BROKER_URL = os.getenv('CELERY_BROKER_URL', 'redis://localhost:6379/0') -CELERY_RESULT_BACKEND = os.getenv('CELERY_RESULT_BACKEND', 'redis://localhost:6379/1') +CELERY_RESULT_BACKEND = os.getenv( + 'CELERY_RESULT_BACKEND', 'redis://localhost:6379/1') # Сериализация CELERY_ACCEPT_CONTENT = ['json'] diff --git a/backend/schedule_patch.py b/backend/schedule_patch.py new file mode 100644 index 00000000..776f06e1 --- /dev/null +++ b/backend/schedule_patch.py @@ -0,0 +1,101 @@ +""" +Monkey patch для django-scheduler: исправление бага с byweekday. + +ПРОБЛЕМА: +--------- +Когда событие создается в день, который входит в byweekday, django-scheduler +заменяет весь массив byweekday на только этот день. + +ПРИМЕР БАГА: +- Создаем событие в пятницу (weekday = 4) +- Выбираем byweekday = [1, 4] (вторник и пятница) +- django-scheduler видит: 4 in [1, 4] = True +- Результат: event_params['byweekday'] = [4] вместо [1, 4] +- События создаются только в пятницу, вторник игнорируется + +КОД БАГА (schedule/models/events.py, строки 375-378): + if sp == rule_params[param] or ( + hasattr(rule_params[param], "__iter__") and sp in rule_params[param] + ): + event_params[param] = [sp] # ← БАГ! + +РЕШЕНИЕ: +-------- +Переопределяем Event._event_params() с исправленной логикой для byweekday. +Используем ленивый импорт чтобы избежать проблем с порядком загрузки Django. +""" + + +def create_patched_method(): + """Создаёт пропатченный метод с ленивым импортом констант.""" + # Ленивый импорт - только при создании метода + from schedule.models.events import freq_dict_order, param_dict_order + + def patched_event_params(self): + """ + Исправленная версия Event._event_params(). + + Для byweekday с несколькими днями НЕ заменяем массив, если день начала входит в него. + """ + freq_order = freq_dict_order[self.rule.frequency] + rule_params = self.event_rule_params + start_params = self.event_start_params + event_params = {} + + if len(rule_params) == 0: + return event_params + + for param in rule_params: + if ( + param in param_dict_order + and param_dict_order[param] > freq_order + and param in start_params + ): + sp = start_params[param] + + # ИСПРАВЛЕНИЕ: Для byweekday не заменяем массив + if param == 'byweekday' and hasattr(rule_params[param], "__iter__"): + event_params[param] = rule_params[param] + elif sp == rule_params[param] or ( + hasattr(rule_params[param], "__iter__") and sp in rule_params[param] + ): + event_params[param] = [sp] + else: + event_params[param] = rule_params[param] + else: + event_params[param] = rule_params[param] + + return event_params + + return patched_event_params + + +def apply_patch(): + """Применяет патч к django-scheduler Event модели.""" + try: + # Ленивый импорт - только при вызове apply_patch() + from schedule.models import Event + + print("=" * 80) + print("✅ ПРИМЕНЕНИЕ ПАТЧА django-scheduler") + + # Создаём и применяем пропатченный метод + patched_method = create_patched_method() + Event._event_params = patched_method + + print(f" Патч применён: Event._event_params = {patched_method.__name__}") + print("=" * 80) + + return True + except Exception as e: + print("=" * 80) + print(f"❌ ОШИБКА применения патча: {e}") + import traceback + traceback.print_exc() + print("=" * 80) + return False + + +# НЕ вызываем apply_patch() здесь! +# Патч будет применён через AppConfig.ready() в calendar_app/apps.py + diff --git a/backend/templates/includes/components/calendar_modal_create.html b/backend/templates/includes/components/calendar_modal_create.html index 2ed96897..6422a64b 100644 --- a/backend/templates/includes/components/calendar_modal_create.html +++ b/backend/templates/includes/components/calendar_modal_create.html @@ -113,10 +113,7 @@
Заканчивается:
-
- - До даты -
+
После N раз diff --git a/backend/test_patch_import.py b/backend/test_patch_import.py new file mode 100644 index 00000000..ac6363a7 --- /dev/null +++ b/backend/test_patch_import.py @@ -0,0 +1,50 @@ +#!/usr/bin/env python +"""Проверка импорта и применения патча.""" +import os +import sys +import django + +# Setup Django +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "eusrr_backend.settings") + +print("\n" + "=" * 80) +print("1. ЗАПУСК DJANGO") +print("=" * 80) +django.setup() + +print("\n" + "=" * 80) +print("2. ИМПОРТ Event БЕЗ ПАТЧА") +print("=" * 80) +from schedule.models import Event +print(f"Event._event_params: {Event._event_params}") +print(f"Имя метода: {Event._event_params.__name__}") + +print("\n" + "=" * 80) +print("3. ИМПОРТ ПАТЧА") +print("=" * 80) +import schedule_patch # noqa + +print("\n" + "=" * 80) +print("4. ПРОВЕРКА Event ПОСЛЕ ПАТЧА") +print("=" * 80) +print(f"Event._event_params: {Event._event_params}") +print(f"Имя метода: {Event._event_params.__name__}") + +# Проверим на реальном событии +print("\n" + "=" * 80) +print("5. ТЕСТ НА РЕАЛЬНОМ СОБЫТИИ") +print("=" * 80) +event = Event.objects.filter(title__icontains='ыфвыфыф').first() +if event: + print(f"Event ID: {event.id}") + result = event._event_params() + print(f"_event_params() result: {result}") + if 'byweekday' in result: + print(f"byweekday = {result['byweekday']}") + if len(result['byweekday']) > 1: + print("✅ ПАТЧ РАБОТАЕТ - несколько дней сохранены!") + else: + print("❌ ПАТЧ НЕ РАБОТАЕТ - только один день!") +else: + print("Событие не найдено")