Удалён режим 'до даты' для повторяемых событий, исправлен патч django-scheduler, фронт теперь только 'count' и 'forever'

This commit is contained in:
igor_nadein 2026-02-25 11:13:20 +03:00
parent ba6e4a3ef0
commit d6a3135bae
7 changed files with 234 additions and 17 deletions

View File

@ -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()

View File

@ -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",

View File

@ -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("Событие не найдено!")

View File

@ -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']

101
backend/schedule_patch.py Normal file
View File

@ -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

View File

@ -113,10 +113,7 @@
<div class="ms-4 ps-2">
<small class="text-muted d-block mb-2">Заканчивается:</small>
<div class="row g-2">
<div class="col-6">
<input type="date" class="form-control form-control-sm" name="recurrence_until" id="recurrenceUntil" placeholder="Дата окончания">
<small class="text-muted">До даты</small>
</div>
<div class="col-6">
<input type="number" class="form-control form-control-sm" name="recurrence_count" id="recurrenceCount" min="1" placeholder="Кол-во">
<small class="text-muted">После N раз</small>

View File

@ -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("Событие не найдено")