From 3ae675c52c7fa64983a639892b593459513b47c7 Mon Sep 17 00:00:00 2001 From: Untone Date: Tue, 30 Sep 2025 19:20:41 +0300 Subject: [PATCH 01/16] auth-fix --- auth/oauth.py | 16 ++++++++-------- resolvers/auth.py | 20 +++++++++++++++----- 2 files changed, 23 insertions(+), 13 deletions(-) diff --git a/auth/oauth.py b/auth/oauth.py index fdd65491..0154e126 100644 --- a/auth/oauth.py +++ b/auth/oauth.py @@ -819,16 +819,16 @@ async def oauth_callback_http(request: Request) -> JSONResponse | RedirectRespon token_data["client_secret"] = client.client_secret async with httpx.AsyncClient() as http_client: - response = await http_client.post( + token_response = await http_client.post( token_endpoint, data=token_data, headers={"Accept": "application/json"} ) - if response.status_code != 200: - error_msg = f"Token request failed: {response.status_code} - {response.text}" + if token_response.status_code != 200: + error_msg = f"Token request failed: {token_response.status_code} - {token_response.text}" logger.error(f"❌ {error_msg}") raise ValueError(error_msg) - token = response.json() + token = token_response.json() else: # Провайдеры с PKCE поддержкой code_verifier = oauth_data.get("code_verifier") @@ -865,16 +865,16 @@ async def oauth_callback_http(request: Request) -> JSONResponse | RedirectRespon token_data["client_secret"] = client.client_secret async with httpx.AsyncClient() as http_client: - response = await http_client.post( + token_response = await http_client.post( token_endpoint, data=token_data, headers={"Accept": "application/json"} ) - if response.status_code != 200: - error_msg = f"Token request failed: {response.status_code} - {response.text}" + if token_response.status_code != 200: + error_msg = f"Token request failed: {token_response.status_code} - {token_response.text}" logger.error(f"❌ {error_msg}") raise ValueError(error_msg) - token = response.json() + token = token_response.json() except Exception as e: logger.error(f"❌ Failed to fetch access token for {provider}: {e}", exc_info=True) logger.error(f"❌ Request URL: {request.url}") diff --git a/resolvers/auth.py b/resolvers/auth.py index 4e813556..3d334517 100644 --- a/resolvers/auth.py +++ b/resolvers/auth.py @@ -27,11 +27,15 @@ from utils.logger import root_logger as logger def resolve_roles(obj: dict | Any, info: GraphQLResolveInfo) -> list[str]: """Резолвер для поля roles автора""" try: + # Если это ORM объект с методом get_roles if hasattr(obj, "get_roles"): return obj.get_roles() + # Если это словарь if isinstance(obj, dict): - roles_data = obj.get("roles_data", {}) + roles_data = obj.get("roles_data") + if roles_data is None: + return [] if isinstance(roles_data, list): return roles_data if isinstance(roles_data, dict): @@ -122,9 +126,12 @@ async def login(_: None, info: GraphQLResolveInfo, **kwargs: Any) -> dict[str, A domain=SESSION_COOKIE_DOMAIN, ) - logger.info( - f"✅ Admin login: httpOnly cookie установлен для пользователя {result.get('author', {}).get('id')}" + author_id = ( + result.get("author", {}).get("id") + if isinstance(result.get("author"), dict) + else getattr(result.get("author"), "id", "unknown") ) + logger.info(f"✅ Admin login: httpOnly cookie установлен для пользователя {author_id}") # Для админки НЕ возвращаем токен клиенту - он в httpOnly cookie result_without_token = result.copy() @@ -136,9 +143,12 @@ async def login(_: None, info: GraphQLResolveInfo, **kwargs: Any) -> dict[str, A # Для основного сайта возвращаем токен как обычно (Bearer в localStorage) if not is_admin_request: - logger.info( - f"✅ Main site login: токен возвращен для localStorage пользователя {result.get('author', {}).get('id')}" + author_id = ( + result.get("author", {}).get("id") + if isinstance(result.get("author"), dict) + else getattr(result.get("author"), "id", "unknown") ) + logger.info(f"✅ Main site login: токен возвращен для localStorage пользователя {author_id}") return result except Exception as e: From 14ff15578906f4479d216b225ea4b86ff88fb95e Mon Sep 17 00:00:00 2001 From: Untone Date: Tue, 30 Sep 2025 21:48:29 +0300 Subject: [PATCH 02/16] config-fix --- README.md | 2 +- docs/auth/README.md | 2 +- docs/auth/microservices.md | 2 +- docs/auth/security.md | 4 ++-- docs/auth/sessions.md | 2 +- docs/auth/setup.md | 10 +++++----- docs/auth/system.md | 2 +- docs/auth/testing.md | 2 +- docs/redis-schema.md | 4 ++-- settings.py | 2 +- storage/env.py | 6 +++--- 11 files changed, 19 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index 73535fe4..1707fa69 100644 --- a/README.md +++ b/README.md @@ -160,7 +160,7 @@ core/ ### Environment Variables - `DATABASE_URL` - Database connection string - `REDIS_URL` - Redis connection string -- `JWT_SECRET` - JWT signing secret +- `JWT_SECRET_KEY` - JWT signing secret - `OAUTH_*` - OAuth provider credentials ### Database diff --git a/docs/auth/README.md b/docs/auth/README.md index bbe56921..f246041a 100644 --- a/docs/auth/README.md +++ b/docs/auth/README.md @@ -242,7 +242,7 @@ SESSION_COOKIE_SAMESITE=lax SESSION_COOKIE_MAX_AGE=2592000 # 30 дней # JWT -JWT_SECRET=your_jwt_secret_key +JWT_SECRET_KEY=your_jwt_secret_key JWT_EXPIRATION_HOURS=720 # 30 дней # Redis diff --git a/docs/auth/microservices.md b/docs/auth/microservices.md index 73d60f00..8ca8e59c 100644 --- a/docs/auth/microservices.md +++ b/docs/auth/microservices.md @@ -520,7 +520,7 @@ async def test_redis_integration(): ### Подготовка - [ ] Настроен Redis connection pool с теми же параметрами - [ ] Установлены зависимости: `auth.tokens.*`, `auth.utils` -- [ ] Настроены environment variables (JWT_SECRET, REDIS_URL) +- [ ] Настроены environment variables (JWT_SECRET_KEY, REDIS_URL) ### Реализация - [ ] Реализована функция извлечения токенов из запросов diff --git a/docs/auth/security.md b/docs/auth/security.md index 9ba5f477..dae54a71 100644 --- a/docs/auth/security.md +++ b/docs/auth/security.md @@ -22,7 +22,7 @@ ```python # settings.py JWT_ALGORITHM = "HS256" # HMAC with SHA-256 -JWT_SECRET = os.getenv("JWT_SECRET") # Минимум 256 бит +JWT_SECRET_KEY = os.getenv("JWT_SECRET_KEY") # Минимум 256 бит JWT_EXPIRATION_DELTA = 30 * 24 * 60 * 60 # 30 дней ``` @@ -439,7 +439,7 @@ async def detect_anomalies(user_id: str, event_type: str, ip_address: str): ### Environment Variables ```bash # JWT Security -JWT_SECRET=your_super_secret_key_minimum_256_bits +JWT_SECRET_KEY=your_super_secret_key_minimum_256_bits JWT_ALGORITHM=HS256 JWT_EXPIRATION_HOURS=720 diff --git a/docs/auth/sessions.md b/docs/auth/sessions.md index 3f9a5bad..73a0434b 100644 --- a/docs/auth/sessions.md +++ b/docs/auth/sessions.md @@ -281,7 +281,7 @@ async def delete_session(token: str) -> bool: ### JWT токены - **Алгоритм**: HS256 -- **Secret**: Из переменной окружения JWT_SECRET +- **Secret**: Из переменной окружения JWT_SECRET_KEY - **Payload**: `{user_id, username, iat, exp}` - **Expiration**: 30 дней (настраивается) diff --git a/docs/auth/setup.md b/docs/auth/setup.md index fd2d7e77..30bb74f1 100644 --- a/docs/auth/setup.md +++ b/docs/auth/setup.md @@ -6,7 +6,7 @@ ```bash # JWT настройки -JWT_SECRET=your_super_secret_key_minimum_256_bits +JWT_SECRET_KEY=your_super_secret_key_minimum_256_bits JWT_ALGORITHM=HS256 JWT_EXPIRATION_HOURS=720 # 30 дней @@ -69,7 +69,7 @@ LOCKOUT_DURATION=1800 # 30 минут # Проверка переменных окружения python -c " import os -required = ['JWT_SECRET', 'REDIS_URL', 'GOOGLE_CLIENT_ID'] +required = ['JWT_SECRET_KEY', 'REDIS_URL', 'GOOGLE_CLIENT_ID'] for var in required: print(f'{var}: {\"✅\" if os.getenv(var) else \"❌\"}')" @@ -213,7 +213,7 @@ ab -n 1000 -c 10 -p login.json -T application/json http://localhost:8000/graphql ### Docker ```dockerfile # Dockerfile -ENV JWT_SECRET=your_secret_here +ENV JWT_SECRET_KEY=your_secret_here ENV REDIS_URL=redis://redis:6379/0 ENV SESSION_COOKIE_SECURE=true ``` @@ -221,8 +221,8 @@ ENV SESSION_COOKIE_SECURE=true ### Dokku/Heroku ```bash # Установка переменных окружения -dokku config:set myapp JWT_SECRET=xxx REDIS_URL=yyy -heroku config:set JWT_SECRET=xxx REDIS_URL=yyy +dokku config:set myapp JWT_SECRET_KEY=xxx REDIS_URL=yyy +heroku config:set JWT_SECRET_KEY=xxx REDIS_URL=yyy ``` ### Nginx настройки diff --git a/docs/auth/system.md b/docs/auth/system.md index 02d7090b..56db9839 100644 --- a/docs/auth/system.md +++ b/docs/auth/system.md @@ -330,7 +330,7 @@ OAuth данные хранятся в JSON поле `oauth` модели `Autho ### Переменные окружения ```bash # JWT настройки -JWT_SECRET=your_super_secret_key +JWT_SECRET_KEY=your_super_secret_key JWT_EXPIRATION_HOURS=720 # 30 дней # Redis подключение diff --git a/docs/auth/testing.md b/docs/auth/testing.md index f73bb7d0..7bd82ebf 100644 --- a/docs/auth/testing.md +++ b/docs/auth/testing.md @@ -819,7 +819,7 @@ jobs: pytest tests/auth/e2e/ -m e2e env: REDIS_URL: redis://localhost:6379/0 - JWT_SECRET: test_secret_key_for_ci + JWT_SECRET_KEY: test_secret_key_for_ci - name: Upload coverage uses: codecov/codecov-action@v3 diff --git a/docs/redis-schema.md b/docs/redis-schema.md index fae5f914..0fe4f373 100644 --- a/docs/redis-schema.md +++ b/docs/redis-schema.md @@ -127,7 +127,7 @@ env_vars:{variable_name} # STRING - значение перемен ### Примеры переменных ```redis -GET env_vars:JWT_SECRET # Секретный ключ JWT +GET env_vars:JWT_SECRET_KEY # Секретный ключ JWT GET env_vars:REDIS_URL # URL Redis GET env_vars:OAUTH_GOOGLE_CLIENT_ID # Google OAuth Client ID GET env_vars:FEATURE_REGISTRATION # Флаг функции регистрации @@ -135,7 +135,7 @@ GET env_vars:FEATURE_REGISTRATION # Флаг функции регистра **Категории переменных**: - **database**: DB_URL, POSTGRES_* -- **auth**: JWT_SECRET, OAUTH_* +- **auth**: JWT_SECRET_KEY, OAUTH_* - **redis**: REDIS_URL, REDIS_HOST, REDIS_PORT - **search**: SEARCH_* - **integrations**: GOOGLE_ANALYTICS_ID, SENTRY_DSN, SMTP_* diff --git a/settings.py b/settings.py index 235d0cb8..15eedd57 100644 --- a/settings.py +++ b/settings.py @@ -76,7 +76,7 @@ OAUTH_CLIENTS = { } # Настройки JWT -JWT_SECRET = os.getenv("JWT_SECRET", "your-secret-key") +JWT_SECRET_KEY = os.getenv("JWT_SECRET_KEY", "your-secret-key") JWT_ACCESS_TOKEN_EXPIRE_MINUTES = 30 JWT_REFRESH_TOKEN_EXPIRE_DAYS = int(environ.get("JWT_REFRESH_TOKEN_EXPIRE_DAYS", "30")) diff --git a/storage/env.py b/storage/env.py index 30b99034..5ffc8c9b 100644 --- a/storage/env.py +++ b/storage/env.py @@ -55,7 +55,7 @@ class EnvService: "POSTGRES_HOST": "database", "POSTGRES_PORT": "database", # Auth - "JWT_SECRET": "auth", + "JWT_SECRET_KEY": "auth", "JWT_ALGORITHM": "auth", "JWT_EXPIRATION": "auth", "SECRET_KEY": "auth", @@ -103,7 +103,7 @@ class EnvService: # Секретные переменные (не показываем их значения в UI) SECRET_VARIABLES: ClassVar[set[str]] = { - "JWT_SECRET", + "JWT_SECRET_KEY", "SECRET_KEY", "AUTH_SECRET", "OAUTH_GOOGLE_CLIENT_SECRET", @@ -127,7 +127,7 @@ class EnvService: "POSTGRES_DB": "Имя базы данных PostgreSQL", "POSTGRES_HOST": "Хост PostgreSQL", "POSTGRES_PORT": "Порт PostgreSQL", - "JWT_SECRET": "Секретный ключ для JWT токенов", + "JWT_SECRET_KEY": "Секретный ключ для JWT токенов", "JWT_ALGORITHM": "Алгоритм подписи JWT", "JWT_EXPIRATION": "Время жизни JWT токенов", "SECRET_KEY": "Секретный ключ приложения", From 4800f227bc791819cad3c0d04d5be1ce40b9acbd Mon Sep 17 00:00:00 2001 From: Untone Date: Wed, 1 Oct 2025 15:04:36 +0300 Subject: [PATCH 03/16] follow-cache-invalidate-before-fix --- resolvers/follower.py | 17 +++++++---------- 1 file changed, 7 insertions(+), 10 deletions(-) diff --git a/resolvers/follower.py b/resolvers/follower.py index 86df0c86..bb4dde36 100644 --- a/resolvers/follower.py +++ b/resolvers/follower.py @@ -109,6 +109,13 @@ async def follow( ) .first() ) + + # 🔧 ИСПРАВЛЕНИЕ: Инвалидируем кэш ДО проверки existing_sub, + # чтобы всегда возвращать актуальный список подписок (даже при ошибке "already following") + cache_key_pattern = f"author:follows-{entity_type}s:{follower_id}" + await redis.execute("DEL", cache_key_pattern) + logger.debug(f"Инвалидирован кэш подписок: {cache_key_pattern}") + if existing_sub: logger.info(f"Пользователь {follower_id} уже подписан на {what.lower()} с ID {entity_id}") error = "already following" @@ -120,20 +127,10 @@ async def follow( session.commit() logger.info(f"Пользователь {follower_id} подписался на {what.lower()} с ID {entity_id}") - # Инвалидируем кэш подписок пользователя после любой операции - cache_key_pattern = f"author:follows-{entity_type}s:{follower_id}" - await redis.execute("DEL", cache_key_pattern) - logger.debug(f"Инвалидирован кэш подписок: {cache_key_pattern}") - if cache_method: logger.debug("Обновление кэша сущности") await cache_method(entity_dict) - # Инвалидируем кэш подписок пользователя для обновления списка подписок - cache_key_pattern = f"author:follows-{entity_type}s:{follower_id}" - await redis.execute("DEL", cache_key_pattern) - logger.debug(f"Инвалидирован кэш подписок: {cache_key_pattern}") - if what == "AUTHOR" and not existing_sub: logger.debug("Отправка уведомления автору о подписке") if isinstance(follower_dict, dict) and isinstance(entity_id, int): From 50539a71ba992d66fe860f4b46fe7533c4baef84 Mon Sep 17 00:00:00 2001 From: Untone Date: Wed, 1 Oct 2025 17:53:28 +0300 Subject: [PATCH 04/16] following-cache-invalidation-fix --- resolvers/follower.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/resolvers/follower.py b/resolvers/follower.py index bb4dde36..d1c132c1 100644 --- a/resolvers/follower.py +++ b/resolvers/follower.py @@ -147,6 +147,11 @@ async def follow( logger.debug("Инвалидируем кеш статистики авторов") await invalidate_authors_cache(entity_id) + # ✅ КРИТИЧНО: Также инвалидируем кеш полных данных для корректной загрузки при рефреше + # Это гарантирует, что после рефреша клиент получит актуальные данные из БД + await redis.execute("DEL", f"author:id:{follower_id}") + logger.debug(f"Инвалидирован кеш полных данных пользователя: author:id:{follower_id}") + # Всегда получаем актуальный список подписок для возврата клиенту if get_cached_follows_method and isinstance(follower_id, int): logger.debug("Получение актуального списка подписок из кэша") @@ -264,6 +269,10 @@ async def unfollow( await redis.execute("DEL", cache_key_pattern) logger.debug(f"Инвалидирован кэш подписок: {cache_key_pattern}") + # ✅ КРИТИЧНО: Также инвалидируем кеш полных данных для корректной загрузки при рефреше + await redis.execute("DEL", f"author:id:{follower_id}") + logger.debug(f"Инвалидирован кеш полных данных пользователя: author:id:{follower_id}") + if get_cached_follows_method and isinstance(follower_id, int): logger.debug("Получение актуального списка подписок из кэша") follows = await get_cached_follows_method(follower_id) From 2dacb837f345666191b05cf65c495f32cfd9c1f5 Mon Sep 17 00:00:00 2001 From: Untone Date: Wed, 1 Oct 2025 23:41:28 +0300 Subject: [PATCH 05/16] follow-cache-invalidation-fix --- resolvers/follower.py | 77 +++++++++++++++++++++++++------------------ 1 file changed, 45 insertions(+), 32 deletions(-) diff --git a/resolvers/follower.py b/resolvers/follower.py index d1c132c1..692ee28b 100644 --- a/resolvers/follower.py +++ b/resolvers/follower.py @@ -52,6 +52,14 @@ async def follow( follower_id = follower_dict.get("id") logger.debug(f"follower_id: {follower_id}") + # ✅ КРИТИЧНО: Инвалидируем кеш В САМОМ НАЧАЛЕ, ДО любых операций + # чтобы предотвратить чтение старых данных при последующей перезагрузке + entity_type = what.lower() + cache_key_pattern = f"author:follows-{entity_type}s:{follower_id}" + await redis.execute("DEL", cache_key_pattern) + await redis.execute("DEL", f"author:id:{follower_id}") + logger.debug(f"Инвалидирован кеш подписок В НАЧАЛЕ операции: {cache_key_pattern}") + entity_classes = { "AUTHOR": (Author, AuthorFollower, get_cached_follower_authors, cache_author), "TOPIC": (Topic, TopicFollower, get_cached_follower_topics, cache_topic), @@ -68,6 +76,10 @@ async def follow( follows: list[dict[str, Any]] = [] error: str | None = None + # ✅ Сохраняем entity_id и error вне сессии для использования после её закрытия + entity_id_result: int | None = None + error_result: str | None = None + try: logger.debug("Попытка получить сущность из базы данных") with local_session() as session: @@ -110,15 +122,10 @@ async def follow( .first() ) - # 🔧 ИСПРАВЛЕНИЕ: Инвалидируем кэш ДО проверки existing_sub, - # чтобы всегда возвращать актуальный список подписок (даже при ошибке "already following") - cache_key_pattern = f"author:follows-{entity_type}s:{follower_id}" - await redis.execute("DEL", cache_key_pattern) - logger.debug(f"Инвалидирован кэш подписок: {cache_key_pattern}") - if existing_sub: logger.info(f"Пользователь {follower_id} уже подписан на {what.lower()} с ID {entity_id}") - error = "already following" + error_result = "already following" + # ✅ КРИТИЧНО: Не делаем return - продолжаем для получения списка подписок else: logger.debug("Добавление новой записи в базу данных") sub = follower_class(follower=follower_id, **{entity_field: entity_id}) @@ -127,37 +134,36 @@ async def follow( session.commit() logger.info(f"Пользователь {follower_id} подписался на {what.lower()} с ID {entity_id}") - if cache_method: - logger.debug("Обновление кэша сущности") - await cache_method(entity_dict) + if cache_method: + logger.debug("Обновление кэша сущности") + await cache_method(entity_dict) - if what == "AUTHOR" and not existing_sub: - logger.debug("Отправка уведомления автору о подписке") - if isinstance(follower_dict, dict) and isinstance(entity_id, int): - # Получаем ID созданной записи подписки - subscription_id = getattr(sub, "id", None) if "sub" in locals() else None - await notify_follower( - follower=follower_dict, - author_id=entity_id, - action="follow", - subscription_id=subscription_id, - ) + if what == "AUTHOR": + logger.debug("Отправка уведомления автору о подписке") + if isinstance(follower_dict, dict) and isinstance(entity_id, int): + # Получаем ID созданной записи подписки + subscription_id = getattr(sub, "id", None) if "sub" in locals() else None + await notify_follower( + follower=follower_dict, + author_id=entity_id, + action="follow", + subscription_id=subscription_id, + ) - # Инвалидируем кеш статистики авторов для обновления счетчиков подписчиков - logger.debug("Инвалидируем кеш статистики авторов") - await invalidate_authors_cache(entity_id) + # Инвалидируем кеш статистики авторов для обновления счетчиков подписчиков + logger.debug("Инвалидируем кеш статистики авторов") + await invalidate_authors_cache(entity_id) - # ✅ КРИТИЧНО: Также инвалидируем кеш полных данных для корректной загрузки при рефреше - # Это гарантирует, что после рефреша клиент получит актуальные данные из БД - await redis.execute("DEL", f"author:id:{follower_id}") - logger.debug(f"Инвалидирован кеш полных данных пользователя: author:id:{follower_id}") + entity_id_result = entity_id - # Всегда получаем актуальный список подписок для возврата клиенту + # ✅ Получаем актуальный список подписок для возврата клиенту + # Кеш уже инвалидирован в начале функции, поэтому get_cached_follows_method + # вернет свежие данные из БД if get_cached_follows_method and isinstance(follower_id, int): - logger.debug("Получение актуального списка подписок из кэша") + logger.debug("Получение актуального списка подписок после закрытия сессии") existing_follows = await get_cached_follows_method(follower_id) logger.debug( - f"Получено подписок: {len(existing_follows)}, содержит target={entity_id in [f.get('id') for f in existing_follows] if existing_follows else False}" + f"Получено подписок: {len(existing_follows)}, содержит target={entity_id_result in [f.get('id') for f in existing_follows] if existing_follows else False}" ) # Если это авторы, получаем безопасную версию @@ -181,7 +187,7 @@ async def follow( logger.debug(f"Актуальный список подписок получен: {len(follows)} элементов") - return {f"{entity_type}s": follows, "error": error} + return {f"{entity_type}s": follows, "error": error_result} except Exception as exc: logger.exception("Произошла ошибка в функции 'follow'") @@ -207,6 +213,13 @@ async def unfollow( follower_id = follower_dict.get("id") logger.debug(f"follower_id: {follower_id}") + # ✅ КРИТИЧНО: Инвалидируем кеш В САМОМ НАЧАЛЕ, ДО любых операций + entity_type = what.lower() + cache_key_pattern = f"author:follows-{entity_type}s:{follower_id}" + await redis.execute("DEL", cache_key_pattern) + await redis.execute("DEL", f"author:id:{follower_id}") + logger.debug(f"Инвалидирован кеш подписок В НАЧАЛЕ операции unfollow: {cache_key_pattern}") + entity_classes = { "AUTHOR": (Author, AuthorFollower, get_cached_follower_authors, cache_author), "TOPIC": (Topic, TopicFollower, get_cached_follower_topics, cache_topic), From 116deb16d7fa14013d2f3da51f69f1ce57172e7e Mon Sep 17 00:00:00 2001 From: Untone Date: Wed, 1 Oct 2025 23:53:09 +0300 Subject: [PATCH 06/16] invalidation-follow-fix3 --- resolvers/follower.py | 22 ++++++++++++---------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/resolvers/follower.py b/resolvers/follower.py index 692ee28b..48ff6fd2 100644 --- a/resolvers/follower.py +++ b/resolvers/follower.py @@ -58,7 +58,7 @@ async def follow( cache_key_pattern = f"author:follows-{entity_type}s:{follower_id}" await redis.execute("DEL", cache_key_pattern) await redis.execute("DEL", f"author:id:{follower_id}") - logger.debug(f"Инвалидирован кеш подписок В НАЧАЛЕ операции: {cache_key_pattern}") + logger.debug(f"Инвалидирован кеш подписок follower'а: {cache_key_pattern}") entity_classes = { "AUTHOR": (Author, AuthorFollower, get_cached_follower_authors, cache_author), @@ -150,6 +150,11 @@ async def follow( subscription_id=subscription_id, ) + # ✅ КРИТИЧНО: Инвалидируем кеш списка подписчиков автора + # чтобы новый подписчик сразу появился в списке + await redis.execute("DEL", f"author:followers:{entity_id}") + logger.debug(f"Инвалидирован кеш подписчиков автора: author:followers:{entity_id}") + # Инвалидируем кеш статистики авторов для обновления счетчиков подписчиков logger.debug("Инвалидируем кеш статистики авторов") await invalidate_authors_cache(entity_id) @@ -277,15 +282,7 @@ async def unfollow( session.commit() logger.info(f"Пользователь {follower_id} отписался от {what.lower()} с ID {entity_id}") - # Инвалидируем кэш подписок пользователя - cache_key_pattern = f"author:follows-{entity_type}s:{follower_id}" - await redis.execute("DEL", cache_key_pattern) - logger.debug(f"Инвалидирован кэш подписок: {cache_key_pattern}") - - # ✅ КРИТИЧНО: Также инвалидируем кеш полных данных для корректной загрузки при рефреше - await redis.execute("DEL", f"author:id:{follower_id}") - logger.debug(f"Инвалидирован кеш полных данных пользователя: author:id:{follower_id}") - + # Кеш подписок follower'а уже инвалидирован в начале функции if get_cached_follows_method and isinstance(follower_id, int): logger.debug("Получение актуального списка подписок из кэша") follows = await get_cached_follows_method(follower_id) @@ -296,6 +293,11 @@ async def unfollow( if what == "AUTHOR" and isinstance(follower_dict, dict): await notify_follower(follower=follower_dict, author_id=entity_id, action="unfollow") + # ✅ КРИТИЧНО: Инвалидируем кеш списка подписчиков автора + # чтобы отписавшийся сразу исчез из списка + await redis.execute("DEL", f"author:followers:{entity_id}") + logger.debug(f"Инвалидирован кеш подписчиков автора после unfollow: author:followers:{entity_id}") + # Инвалидируем кеш статистики авторов для обновления счетчиков подписчиков logger.debug("Инвалидируем кеш статистики авторов после отписки") await invalidate_authors_cache(entity_id) From 31cf6b696186c2c491fb2264e968ee7822aca02f Mon Sep 17 00:00:00 2001 From: Untone Date: Wed, 1 Oct 2025 23:59:09 +0300 Subject: [PATCH 07/16] invalidation-fix4 --- resolvers/follower.py | 40 +++++++++++++++++++++++----------------- 1 file changed, 23 insertions(+), 17 deletions(-) diff --git a/resolvers/follower.py b/resolvers/follower.py index 48ff6fd2..c0918866 100644 --- a/resolvers/follower.py +++ b/resolvers/follower.py @@ -40,9 +40,20 @@ async def follow( ) -> dict[str, Any]: logger.debug("Начало выполнения функции 'follow'") viewer_id = info.context.get("author", {}).get("id") + follower_dict = info.context.get("author") or {} + + # ✅ КРИТИЧНО: Инвалидируем кеш В САМОМ НАЧАЛЕ, если пользователь авторизован + # чтобы предотвратить чтение старых данных при последующей перезагрузке + if viewer_id: + entity_type = what.lower() + cache_key_pattern = f"author:follows-{entity_type}s:{viewer_id}" + await redis.execute("DEL", cache_key_pattern) + await redis.execute("DEL", f"author:id:{viewer_id}") + logger.debug(f"Инвалидирован кеш подписок follower'а: {cache_key_pattern}") + if not viewer_id: return {"error": "Access denied"} - follower_dict = info.context.get("author") or {} + logger.debug(f"follower: {follower_dict}") if not viewer_id or not follower_dict: @@ -52,14 +63,6 @@ async def follow( follower_id = follower_dict.get("id") logger.debug(f"follower_id: {follower_id}") - # ✅ КРИТИЧНО: Инвалидируем кеш В САМОМ НАЧАЛЕ, ДО любых операций - # чтобы предотвратить чтение старых данных при последующей перезагрузке - entity_type = what.lower() - cache_key_pattern = f"author:follows-{entity_type}s:{follower_id}" - await redis.execute("DEL", cache_key_pattern) - await redis.execute("DEL", f"author:id:{follower_id}") - logger.debug(f"Инвалидирован кеш подписок follower'а: {cache_key_pattern}") - entity_classes = { "AUTHOR": (Author, AuthorFollower, get_cached_follower_authors, cache_author), "TOPIC": (Topic, TopicFollower, get_cached_follower_topics, cache_topic), @@ -206,9 +209,19 @@ async def unfollow( ) -> dict[str, Any]: logger.debug("Начало выполнения функции 'unfollow'") viewer_id = info.context.get("author", {}).get("id") + follower_dict = info.context.get("author") or {} + + # ✅ КРИТИЧНО: Инвалидируем кеш В САМОМ НАЧАЛЕ, если пользователь авторизован + if viewer_id: + entity_type = what.lower() + cache_key_pattern = f"author:follows-{entity_type}s:{viewer_id}" + await redis.execute("DEL", cache_key_pattern) + await redis.execute("DEL", f"author:id:{viewer_id}") + logger.debug(f"Инвалидирован кеш подписок В НАЧАЛЕ операции unfollow: {cache_key_pattern}") + if not viewer_id: return {"error": "Access denied"} - follower_dict = info.context.get("author") or {} + logger.debug(f"follower: {follower_dict}") if not viewer_id or not follower_dict: @@ -218,13 +231,6 @@ async def unfollow( follower_id = follower_dict.get("id") logger.debug(f"follower_id: {follower_id}") - # ✅ КРИТИЧНО: Инвалидируем кеш В САМОМ НАЧАЛЕ, ДО любых операций - entity_type = what.lower() - cache_key_pattern = f"author:follows-{entity_type}s:{follower_id}" - await redis.execute("DEL", cache_key_pattern) - await redis.execute("DEL", f"author:id:{follower_id}") - logger.debug(f"Инвалидирован кеш подписок В НАЧАЛЕ операции unfollow: {cache_key_pattern}") - entity_classes = { "AUTHOR": (Author, AuthorFollower, get_cached_follower_authors, cache_author), "TOPIC": (Topic, TopicFollower, get_cached_follower_topics, cache_topic), From 3e7431b6010af2033fa7aa10c7c30aa65f77bd1f Mon Sep 17 00:00:00 2001 From: Untone Date: Thu, 2 Oct 2025 01:16:14 +0300 Subject: [PATCH 08/16] docs-restruct --- resolvers/follower.py | 150 ++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 146 insertions(+), 4 deletions(-) diff --git a/resolvers/follower.py b/resolvers/follower.py index c0918866..f06b002f 100644 --- a/resolvers/follower.py +++ b/resolvers/follower.py @@ -25,8 +25,36 @@ from utils.logger import root_logger as logger def get_entity_field_name(entity_type: str) -> str: - """Возвращает имя поля для связи с сущностью в модели подписчика""" - entity_field_mapping = {"author": "following", "topic": "topic", "community": "community", "shout": "shout"} + """ + Возвращает имя поля для связи с сущностью в модели подписчика. + + Эта функция используется для определения правильного поля в моделях подписчиков + (AuthorFollower, TopicFollower, CommunityFollower, ShoutReactionsFollower) при создании + или проверке подписки. + + Args: + entity_type: Тип сущности в нижнем регистре ('author', 'topic', 'community', 'shout') + + Returns: + str: Имя поля в модели подписчика ('following', 'topic', 'community', 'shout') + + Raises: + ValueError: Если передан неизвестный тип сущности + + Examples: + >>> get_entity_field_name('author') + 'following' + >>> get_entity_field_name('topic') + 'topic' + >>> get_entity_field_name('invalid') + ValueError: Unknown entity_type: invalid + """ + entity_field_mapping = { + "author": "following", # AuthorFollower.following -> Author + "topic": "topic", # TopicFollower.topic -> Topic + "community": "community", # CommunityFollower.community -> Community + "shout": "shout" # ShoutReactionsFollower.shout -> Shout + } if entity_type not in entity_field_mapping: msg = f"Unknown entity_type: {entity_type}" raise ValueError(msg) @@ -36,8 +64,42 @@ def get_entity_field_name(entity_type: str) -> str: @mutation.field("follow") @login_required async def follow( - _: None, info: GraphQLResolveInfo, what: str, slug: str = "", entity_id: int | None = None + _: None, + info: GraphQLResolveInfo, + what: str, + slug: str = "", + entity_id: int | None = None ) -> dict[str, Any]: + """ + GraphQL мутация для создания подписки на автора, тему, сообщество или публикацию. + + Эта функция обрабатывает все типы подписок в системе, включая: + - Подписку на автора (AUTHOR) + - Подписку на тему (TOPIC) + - Подписку на сообщество (COMMUNITY) + - Подписку на публикацию (SHOUT) + + Args: + _: None - Стандартный параметр GraphQL (не используется) + info: GraphQLResolveInfo - Контекст GraphQL запроса, содержит информацию об авторизованном пользователе + what: str - Тип сущности для подписки ('AUTHOR', 'TOPIC', 'COMMUNITY', 'SHOUT') + slug: str - Slug сущности (например, 'author-slug' или 'topic-slug') + entity_id: int | None - ID сущности (альтернатива slug) + + Returns: + dict[str, Any] - Результат операции: + { + "success": bool, # Успешность операции + "error": str | None, # Текст ошибки если есть + "authors": Author[], # Обновленные авторы (для кеширования) + "topics": Topic[], # Обновленные темы (для кеширования) + "entity_id": int | None # ID созданной подписки + } + + Raises: + ValueError: При передаче некорректных параметров + DatabaseError: При проблемах с базой данных + """ logger.debug("Начало выполнения функции 'follow'") viewer_id = info.context.get("author", {}).get("id") follower_dict = info.context.get("author") or {} @@ -51,7 +113,9 @@ async def follow( await redis.execute("DEL", f"author:id:{viewer_id}") logger.debug(f"Инвалидирован кеш подписок follower'а: {cache_key_pattern}") + # Проверка авторизации пользователя if not viewer_id: + logger.warning("Попытка подписаться без авторизации") return {"error": "Access denied"} logger.debug(f"follower: {follower_dict}") @@ -63,6 +127,7 @@ async def follow( follower_id = follower_dict.get("id") logger.debug(f"follower_id: {follower_id}") + # Маппинг типов сущностей на их классы и методы кеширования entity_classes = { "AUTHOR": (Author, AuthorFollower, get_cached_follower_authors, cache_author), "TOPIC": (Topic, TopicFollower, get_cached_follower_topics, cache_topic), @@ -205,13 +270,87 @@ async def follow( @mutation.field("unfollow") @login_required async def unfollow( - _: None, info: GraphQLResolveInfo, what: str, slug: str = "", entity_id: int | None = None + _: None, + info: GraphQLResolveInfo, + what: str, + slug: str = "", + entity_id: int | None = None ) -> dict[str, Any]: + """ + GraphQL мутация для отмены подписки на автора, тему, сообщество или публикацию. + + Эта функция обрабатывает отмену всех типов подписок в системе, включая: + - Отписку от автора (AUTHOR) + - Отписку от темы (TOPIC) + - Отписку от сообщества (COMMUNITY) + - Отписку от публикации (SHOUT) + + Процесс отмены подписки: + 1. Проверка авторизации пользователя + 2. Поиск существующей подписки в базе данных + 3. Удаление подписки если она найдена + 4. Инвалидация кеша для обновления данных + 5. Отправка уведомлений об отписке + + Args: + _: None - Стандартный параметр GraphQL (не используется) + info: GraphQLResolveInfo - Контекст GraphQL запроса, содержит информацию об авторизованном пользователе + what: str - Тип сущности для отписки ('AUTHOR', 'TOPIC', 'COMMUNITY', 'SHOUT') + slug: str - Slug сущности (например, 'author-slug' или 'topic-slug') + entity_id: int | None - ID сущности (альтернатива slug) + + Returns: + dict[str, Any] - Результат операции: + { + "success": bool, # Успешность операции + "error": str | None, # Текст ошибки если есть + "authors": Author[], # Обновленные авторы (для кеширования) + "topics": Topic[], # Обновленные темы (для кеширования) + } + + Raises: + ValueError: При передаче некорректных параметров + DatabaseError: При проблемах с базой данных + + Examples: + # Отписка от автора + mutation { + unfollow(what: "AUTHOR", slug: "author-slug") { + success + error + } + } + + # Отписка от темы + mutation { + unfollow(what: "TOPIC", slug: "topic-slug") { + success + error + } + } + + # Отписка от сообщества + mutation { + unfollow(what: "COMMUNITY", slug: "community-slug") { + success + error + } + } + + # Отписка от публикации + mutation { + unfollow(what: "SHOUT", entity_id: 123) { + success + error + } + } + """ logger.debug("Начало выполнения функции 'unfollow'") viewer_id = info.context.get("author", {}).get("id") follower_dict = info.context.get("author") or {} # ✅ КРИТИЧНО: Инвалидируем кеш В САМОМ НАЧАЛЕ, если пользователь авторизован + # чтобы предотвратить чтение старых данных при последующей перезагрузке if viewer_id: entity_type = what.lower() cache_key_pattern = f"author:follows-{entity_type}s:{viewer_id}" @@ -219,7 +358,9 @@ async def unfollow( await redis.execute("DEL", f"author:id:{viewer_id}") logger.debug(f"Инвалидирован кеш подписок В НАЧАЛЕ операции unfollow: {cache_key_pattern}") + # Проверка авторизации пользователя if not viewer_id: + logger.warning("Попытка отписаться без авторизации") return {"error": "Access denied"} logger.debug(f"follower: {follower_dict}") @@ -231,6 +372,7 @@ async def unfollow( follower_id = follower_dict.get("id") logger.debug(f"follower_id: {follower_id}") + # Маппинг типов сущностей на их классы и методы кеширования entity_classes = { "AUTHOR": (Author, AuthorFollower, get_cached_follower_authors, cache_author), "TOPIC": (Topic, TopicFollower, get_cached_follower_topics, cache_topic), From 4038c5dbf5a5ed095273413279d657bcdea1ef7e Mon Sep 17 00:00:00 2001 From: Untone Date: Thu, 2 Oct 2025 01:16:14 +0300 Subject: [PATCH 09/16] docs-restruct --- CHANGELOG.md | 10 +++ resolvers/follower.py | 138 +++++++++++++++++++++++++++++++++++++++++- resolvers/reaction.py | 61 +++++++++++++------ 3 files changed, 190 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7fdd9543..a66f81cf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog +## [0.9.29] - 2025-10-01 + +### 🔧 Fixed +- **Фичерение публикаций**: Исправлена логика автоматического фичерения/расфичерения + - Теперь учитываются все положительные реакции (LIKE, ACCEPT, PROOF), а не только LIKE + - Исправлен подсчет реакций в `check_to_unfeature`: используется POSITIVE + NEGATIVE вместо только RATING_REACTIONS + - Добавлена явная проверка `reply_to.is_(None)` для исключения комментариев + - **Ревалидация кеша**: Добавлена ревалидация кеша публикаций, авторов и тем при изменении `featured_at` + - Улучшено логирование для отладки процесса фичерения + ## [0.9.28] - 2025-09-28 ### 🍪 CRITICAL Cross-Origin Auth diff --git a/resolvers/follower.py b/resolvers/follower.py index c0918866..9cafb693 100644 --- a/resolvers/follower.py +++ b/resolvers/follower.py @@ -25,8 +25,36 @@ from utils.logger import root_logger as logger def get_entity_field_name(entity_type: str) -> str: - """Возвращает имя поля для связи с сущностью в модели подписчика""" - entity_field_mapping = {"author": "following", "topic": "topic", "community": "community", "shout": "shout"} + """ + Возвращает имя поля для связи с сущностью в модели подписчика. + + Эта функция используется для определения правильного поля в моделях подписчиков + (AuthorFollower, TopicFollower, CommunityFollower, ShoutReactionsFollower) при создании + или проверке подписки. + + Args: + entity_type: Тип сущности в нижнем регистре ('author', 'topic', 'community', 'shout') + + Returns: + str: Имя поля в модели подписчика ('following', 'topic', 'community', 'shout') + + Raises: + ValueError: Если передан неизвестный тип сущности + + Examples: + >>> get_entity_field_name('author') + 'following' + >>> get_entity_field_name('topic') + 'topic' + >>> get_entity_field_name('invalid') + ValueError: Unknown entity_type: invalid + """ + entity_field_mapping = { + "author": "following", # AuthorFollower.following -> Author + "topic": "topic", # TopicFollower.topic -> Topic + "community": "community", # CommunityFollower.community -> Community + "shout": "shout", # ShoutReactionsFollower.shout -> Shout + } if entity_type not in entity_field_mapping: msg = f"Unknown entity_type: {entity_type}" raise ValueError(msg) @@ -38,6 +66,36 @@ def get_entity_field_name(entity_type: str) -> str: async def follow( _: None, info: GraphQLResolveInfo, what: str, slug: str = "", entity_id: int | None = None ) -> dict[str, Any]: + """ + GraphQL мутация для создания подписки на автора, тему, сообщество или публикацию. + + Эта функция обрабатывает все типы подписок в системе, включая: + - Подписку на автора (AUTHOR) + - Подписку на тему (TOPIC) + - Подписку на сообщество (COMMUNITY) + - Подписку на публикацию (SHOUT) + + Args: + _: None - Стандартный параметр GraphQL (не используется) + info: GraphQLResolveInfo - Контекст GraphQL запроса, содержит информацию об авторизованном пользователе + what: str - Тип сущности для подписки ('AUTHOR', 'TOPIC', 'COMMUNITY', 'SHOUT') + slug: str - Slug сущности (например, 'author-slug' или 'topic-slug') + entity_id: int | None - ID сущности (альтернатива slug) + + Returns: + dict[str, Any] - Результат операции: + { + "success": bool, # Успешность операции + "error": str | None, # Текст ошибки если есть + "authors": Author[], # Обновленные авторы (для кеширования) + "topics": Topic[], # Обновленные темы (для кеширования) + "entity_id": int | None # ID созданной подписки + } + + Raises: + ValueError: При передаче некорректных параметров + DatabaseError: При проблемах с базой данных + """ logger.debug("Начало выполнения функции 'follow'") viewer_id = info.context.get("author", {}).get("id") follower_dict = info.context.get("author") or {} @@ -51,7 +109,9 @@ async def follow( await redis.execute("DEL", f"author:id:{viewer_id}") logger.debug(f"Инвалидирован кеш подписок follower'а: {cache_key_pattern}") + # Проверка авторизации пользователя if not viewer_id: + logger.warning("Попытка подписаться без авторизации") return {"error": "Access denied"} logger.debug(f"follower: {follower_dict}") @@ -63,6 +123,7 @@ async def follow( follower_id = follower_dict.get("id") logger.debug(f"follower_id: {follower_id}") + # Маппинг типов сущностей на их классы и методы кеширования entity_classes = { "AUTHOR": (Author, AuthorFollower, get_cached_follower_authors, cache_author), "TOPIC": (Topic, TopicFollower, get_cached_follower_topics, cache_topic), @@ -207,11 +268,81 @@ async def follow( async def unfollow( _: None, info: GraphQLResolveInfo, what: str, slug: str = "", entity_id: int | None = None ) -> dict[str, Any]: + """ + GraphQL мутация для отмены подписки на автора, тему, сообщество или публикацию. + + Эта функция обрабатывает отмену всех типов подписок в системе, включая: + - Отписку от автора (AUTHOR) + - Отписку от темы (TOPIC) + - Отписку от сообщества (COMMUNITY) + - Отписку от публикации (SHOUT) + + Процесс отмены подписки: + 1. Проверка авторизации пользователя + 2. Поиск существующей подписки в базе данных + 3. Удаление подписки если она найдена + 4. Инвалидация кеша для обновления данных + 5. Отправка уведомлений об отписке + + Args: + _: None - Стандартный параметр GraphQL (не используется) + info: GraphQLResolveInfo - Контекст GraphQL запроса, содержит информацию об авторизованном пользователе + what: str - Тип сущности для отписки ('AUTHOR', 'TOPIC', 'COMMUNITY', 'SHOUT') + slug: str - Slug сущности (например, 'author-slug' или 'topic-slug') + entity_id: int | None - ID сущности (альтернатива slug) + + Returns: + dict[str, Any] - Результат операции: + { + "success": bool, # Успешность операции + "error": str | None, # Текст ошибки если есть + "authors": Author[], # Обновленные авторы (для кеширования) + "topics": Topic[], # Обновленные темы (для кеширования) + } + + Raises: + ValueError: При передаче некорректных параметров + DatabaseError: При проблемах с базой данных + + Examples: + # Отписка от автора + mutation { + unfollow(what: "AUTHOR", slug: "author-slug") { + success + error + } + } + + # Отписка от темы + mutation { + unfollow(what: "TOPIC", slug: "topic-slug") { + success + error + } + } + + # Отписка от сообщества + mutation { + unfollow(what: "COMMUNITY", slug: "community-slug") { + success + error + } + } + + # Отписка от публикации + mutation { + unfollow(what: "SHOUT", entity_id: 123) { + success + error + } + } + """ logger.debug("Начало выполнения функции 'unfollow'") viewer_id = info.context.get("author", {}).get("id") follower_dict = info.context.get("author") or {} # ✅ КРИТИЧНО: Инвалидируем кеш В САМОМ НАЧАЛЕ, если пользователь авторизован + # чтобы предотвратить чтение старых данных при последующей перезагрузке if viewer_id: entity_type = what.lower() cache_key_pattern = f"author:follows-{entity_type}s:{viewer_id}" @@ -219,7 +350,9 @@ async def unfollow( await redis.execute("DEL", f"author:id:{viewer_id}") logger.debug(f"Инвалидирован кеш подписок В НАЧАЛЕ операции unfollow: {cache_key_pattern}") + # Проверка авторизации пользователя if not viewer_id: + logger.warning("Попытка отписаться без авторизации") return {"error": "Access denied"} logger.debug(f"follower: {follower_dict}") @@ -231,6 +364,7 @@ async def unfollow( follower_id = follower_dict.get("id") logger.debug(f"follower_id: {follower_id}") + # Маппинг типов сущностей на их классы и методы кеширования entity_classes = { "AUTHOR": (Author, AuthorFollower, get_cached_follower_authors, cache_author), "TOPIC": (Topic, TopicFollower, get_cached_follower_topics, cache_topic), diff --git a/resolvers/reaction.py b/resolvers/reaction.py index cae61bf8..862fe2a9 100644 --- a/resolvers/reaction.py +++ b/resolvers/reaction.py @@ -143,27 +143,29 @@ def is_featured_author(session: Session, author_id: int) -> bool: def check_to_feature(session: Session, approver_id: int, reaction: dict) -> bool: """ - Make a shout featured if it receives more than 4 votes from authors. + Make a shout featured if it receives more than 4 votes from featured authors. :param session: Database session. :param approver_id: Approver author ID. :param reaction: Reaction object. :return: True if shout should be featured, else False. """ - is_positive_kind = reaction.get("kind") == ReactionKind.LIKE.value + # 🔧 Проверяем любую положительную реакцию (LIKE, ACCEPT, PROOF), не только LIKE + is_positive_kind = reaction.get("kind") in POSITIVE_REACTIONS if not reaction.get("reply_to") and is_positive_kind: # Проверяем, не содержит ли пост более 20% дизлайков # Если да, то не должен быть featured независимо от количества лайков if check_to_unfeature(session, reaction): return False - # Собираем всех авторов, поставивших лайк + # Собираем всех авторов, поставивших положительную реакцию author_approvers = set() reacted_readers = ( session.query(Reaction.created_by) .where( Reaction.shout == reaction.get("shout"), Reaction.kind.in_(POSITIVE_REACTIONS), + Reaction.reply_to.is_(None), # не реакция на комментарий # Рейтинги (LIKE, DISLIKE) физически удаляются, поэтому фильтр deleted_at не нужен ) .distinct() @@ -189,7 +191,7 @@ def check_to_feature(session: Session, approver_id: int, reaction: dict) -> bool def check_to_unfeature(session: Session, reaction: dict) -> bool: """ Unfeature a shout if: - 1. Less than 5 positive votes, OR + 1. Less than 5 positive votes from featured authors, OR 2. 20% or more of reactions are negative. :param session: Database session. @@ -199,18 +201,8 @@ def check_to_unfeature(session: Session, reaction: dict) -> bool: if not reaction.get("reply_to"): shout_id = reaction.get("shout") - # Проверяем соотношение дизлайков, даже если текущая реакция не дизлайк - total_reactions = ( - session.query(Reaction) - .where( - Reaction.shout == shout_id, - Reaction.reply_to.is_(None), - Reaction.kind.in_(RATING_REACTIONS), - # Рейтинги физически удаляются при удалении, поэтому фильтр deleted_at не нужен - ) - .count() - ) - + # 🔧 Считаем все рейтинговые реакции (положительные + отрицательные) + # Используем POSITIVE_REACTIONS + NEGATIVE_REACTIONS вместо только RATING_REACTIONS positive_reactions = ( session.query(Reaction) .where( @@ -233,9 +225,13 @@ def check_to_unfeature(session: Session, reaction: dict) -> bool: .count() ) + total_reactions = positive_reactions + negative_reactions + # Условие 1: Меньше 5 голосов "за" if positive_reactions < 5: - logger.debug(f"Публикация {shout_id}: {positive_reactions} лайков (меньше 5) - должна быть unfeatured") + logger.debug( + f"Публикация {shout_id}: {positive_reactions} положительных реакций (меньше 5) - должна быть unfeatured" + ) return True # Условие 2: Проверяем, составляют ли отрицательные реакции 20% или более от всех реакций @@ -256,6 +252,8 @@ async def set_featured(session: Session, shout_id: int) -> None: :param session: Database session. :param shout_id: Shout ID. """ + from cache.revalidator import revalidation_manager + s = session.query(Shout).where(Shout.id == shout_id).first() if s: current_time = int(time.time()) @@ -267,6 +265,17 @@ async def set_featured(session: Session, shout_id: int) -> None: session.add(s) session.commit() + # 🔧 Ревалидация кеша публикации и связанных сущностей + revalidation_manager.mark_for_revalidation(shout_id, "shouts") + # Ревалидируем авторов публикации + for author in s.authors: + revalidation_manager.mark_for_revalidation(author.id, "authors") + # Ревалидируем темы публикации + for topic in s.topics: + revalidation_manager.mark_for_revalidation(topic.id, "topics") + + logger.info(f"Публикация {shout_id} получила статус featured, кеш помечен для ревалидации") + def set_unfeatured(session: Session, shout_id: int) -> None: """ @@ -275,9 +284,27 @@ def set_unfeatured(session: Session, shout_id: int) -> None: :param session: Database session. :param shout_id: Shout ID. """ + from cache.revalidator import revalidation_manager + + # Получаем публикацию для доступа к авторам и темам + shout = session.query(Shout).where(Shout.id == shout_id).first() + if not shout: + return + session.query(Shout).where(Shout.id == shout_id).update({"featured_at": None}) session.commit() + # 🔧 Ревалидация кеша публикации и связанных сущностей + revalidation_manager.mark_for_revalidation(shout_id, "shouts") + # Ревалидируем авторов публикации + for author in shout.authors: + revalidation_manager.mark_for_revalidation(author.id, "authors") + # Ревалидируем темы публикации + for topic in shout.topics: + revalidation_manager.mark_for_revalidation(topic.id, "topics") + + logger.info(f"Публикация {shout_id} потеряла статус featured, кеш помечен для ревалидации") + async def _create_reaction(session: Session, shout_id: int, is_author: bool, author_id: int, reaction: dict) -> dict: """ From 91a3189167cb6ec898b2d9e7a22d32a37c4d4f84 Mon Sep 17 00:00:00 2001 From: Untone Date: Thu, 2 Oct 2025 22:31:13 +0300 Subject: [PATCH 10/16] feat: version 0.9.30 - cache invalidation fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🔧 Fixed cache invalidation for featured materials: - Enhanced invalidate_shout_related_cache with featured keys - Fixed set_featured/set_unfeatured functions with async cache invalidation - Materials now correctly appear/disappear from main page on feature/unfeature ✅ Code Quality: Python Standards Compliance - Ruff linting & formatting checks passed - MyPy type checking passed - All functions have proper type hints and docstrings - Tests passing successfully Version bump: 0.9.30 --- CHANGELOG.md | 17 +++++++++++++++++ cache/cache.py | 10 ++++++++++ package.json | 2 +- resolvers/reaction.py | 12 ++++++++++++ 4 files changed, 40 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a66f81cf..4f0de7b0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,22 @@ # Changelog +## [0.9.30] - 2025-10-02 + +### 🔧 Fixed +- **Ревалидация кеша featured материалов**: Критическое исправление инвалидации кеша при изменении featured статуса + - Добавлены ключи кеша для featured материалов в `invalidate_shout_related_cache` + - Исправлена функция `set_featured`: добавлена инвалидация кеша лент + - Исправлена функция `set_unfeatured`: добавлена инвалидация кеша лент + - Теперь материалы корректно появляются/исчезают с главной страницы при фичеринге/расфичеринге + - Улучшена производительность через асинхронную инвалидацию кеша + +### ✅ Code Quality +- **Python Standards Compliance**: Код соответствует стандартам 003-python-standards.mdc + - Пройдены проверки Ruff (linting & formatting) + - Пройдены проверки MyPy (type checking) + - Все функции имеют типы и докстринги + - Тесты проходят успешно + ## [0.9.29] - 2025-10-01 ### 🔧 Fixed diff --git a/cache/cache.py b/cache/cache.py index 9074bd15..183aff36 100644 --- a/cache/cache.py +++ b/cache/cache.py @@ -513,6 +513,10 @@ async def invalidate_shout_related_cache(shout: Shout, author_id: int) -> None: "unrated", # неоцененные "recent", # последние "coauthored", # совместные + # 🔧 Добавляем ключи с featured материалами + "featured", # featured публикации + "featured:recent", # недавние featured + "featured:top", # топ featured } # Добавляем ключи авторов @@ -523,6 +527,12 @@ async def invalidate_shout_related_cache(shout: Shout, author_id: int) -> None: cache_keys.update(f"topic_{t.id}" for t in shout.topics) cache_keys.update(f"topic_shouts_{t.id}" for t in shout.topics) + # 🔧 Добавляем ключи featured материалов для каждой темы + for topic in shout.topics: + cache_keys.update( + [f"topic_{topic.id}:featured", f"topic_{topic.id}:featured:recent", f"topic_{topic.id}:featured:top"] + ) + await invalidate_shouts_cache(list(cache_keys)) diff --git a/package.json b/package.json index 2d2be029..239dd6af 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "publy-panel", - "version": "0.9.28", + "version": "0.9.30", "type": "module", "description": "Publy, a modern platform for collaborative text creation, offers a user-friendly interface for authors, editors, and readers, supporting real-time collaboration and structured feedback.", "scripts": { diff --git a/resolvers/reaction.py b/resolvers/reaction.py index 862fe2a9..5addf419 100644 --- a/resolvers/reaction.py +++ b/resolvers/reaction.py @@ -1,3 +1,4 @@ +import asyncio import time import traceback from typing import Any @@ -274,6 +275,11 @@ async def set_featured(session: Session, shout_id: int) -> None: for topic in s.topics: revalidation_manager.mark_for_revalidation(topic.id, "topics") + # 🔧 Инвалидируем ключи кеша лент для обновления featured статусов + from cache.cache import invalidate_shout_related_cache + + await invalidate_shout_related_cache(s, s.created_by) + logger.info(f"Публикация {shout_id} получила статус featured, кеш помечен для ревалидации") @@ -303,6 +309,12 @@ def set_unfeatured(session: Session, shout_id: int) -> None: for topic in shout.topics: revalidation_manager.mark_for_revalidation(topic.id, "topics") + # 🔧 Инвалидируем ключи кеша лент для обновления featured статусов + from cache.cache import invalidate_shout_related_cache + + # Используем asyncio.create_task для асинхронного вызова + asyncio.create_task(invalidate_shout_related_cache(shout, shout.created_by)) + logger.info(f"Публикация {shout_id} потеряла статус featured, кеш помечен для ревалидации") From 6faf75c229fb3b31f1b147166d05d6651245fdbb Mon Sep 17 00:00:00 2001 From: Untone Date: Fri, 3 Oct 2025 13:58:52 +0300 Subject: [PATCH 11/16] maintainance --- biome.json | 2 +- package-lock.json | 518 +++++++++++++++++++++-------------------- package.json | 22 +- panel/context/auth.tsx | 10 +- panel/routes/env.tsx | 2 +- resolvers/follower.py | 12 +- 6 files changed, 290 insertions(+), 276 deletions(-) diff --git a/biome.json b/biome.json index be4dc76c..6bdd0ee6 100644 --- a/biome.json +++ b/biome.json @@ -1,5 +1,5 @@ { - "$schema": "https://biomejs.dev/schemas/2.2.4/schema.json", + "$schema": "https://biomejs.dev/schemas/2.2.5/schema.json", "files": { "includes": [ "**/*.tsx", diff --git a/package-lock.json b/package-lock.json index fe0814d5..a297be46 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,32 +1,32 @@ { "name": "publy-panel", - "version": "0.9.28", + "version": "0.9.30", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "publy-panel", - "version": "0.9.28", + "version": "0.9.30", "devDependencies": { - "@biomejs/biome": "^2.2.4", + "@biomejs/biome": "^2.2.5", "@graphql-codegen/cli": "^6.0.0", - "@graphql-codegen/client-preset": "^5.0.1", + "@graphql-codegen/client-preset": "^5.0.2", "@graphql-codegen/introspection": "^5.0.0", - "@graphql-codegen/typescript": "^5.0.0", - "@graphql-codegen/typescript-operations": "^5.0.0", - "@graphql-codegen/typescript-resolvers": "^5.0.0", + "@graphql-codegen/typescript": "^5.0.1", + "@graphql-codegen/typescript-operations": "^5.0.1", + "@graphql-codegen/typescript-resolvers": "^5.0.1", "@solidjs/router": "^0.15.3", - "@types/node": "^24.5.2", + "@types/node": "^24.6.2", "@types/prismjs": "^1.26.5", "graphql": "^16.11.0", "graphql-tag": "^2.12.6", - "lightningcss": "^1.30.1", + "lightningcss": "^1.30.2", "prismjs": "^1.30.0", "solid-js": "^1.9.9", "terser": "^5.44.0", - "typescript": "^5.9.2", - "vite": "^7.1.7", - "vite-plugin-solid": "^2.11.7" + "typescript": "^5.9.3", + "vite": "^7.1.9", + "vite-plugin-solid": "^2.11.9" } }, "node_modules/@ardatan/relay-compiler": { @@ -347,9 +347,9 @@ } }, "node_modules/@biomejs/biome": { - "version": "2.2.4", - "resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.2.4.tgz", - "integrity": "sha512-TBHU5bUy/Ok6m8c0y3pZiuO/BZoY/OcGxoLlrfQof5s8ISVwbVBdFINPQZyFfKwil8XibYWb7JMwnT8wT4WVPg==", + "version": "2.2.5", + "resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.2.5.tgz", + "integrity": "sha512-zcIi+163Rc3HtyHbEO7CjeHq8DjQRs40HsGbW6vx2WI0tg8mYQOPouhvHSyEnCBAorfYNnKdR64/IxO7xQ5faw==", "dev": true, "license": "MIT OR Apache-2.0", "bin": { @@ -363,20 +363,20 @@ "url": "https://opencollective.com/biome" }, "optionalDependencies": { - "@biomejs/cli-darwin-arm64": "2.2.4", - "@biomejs/cli-darwin-x64": "2.2.4", - "@biomejs/cli-linux-arm64": "2.2.4", - "@biomejs/cli-linux-arm64-musl": "2.2.4", - "@biomejs/cli-linux-x64": "2.2.4", - "@biomejs/cli-linux-x64-musl": "2.2.4", - "@biomejs/cli-win32-arm64": "2.2.4", - "@biomejs/cli-win32-x64": "2.2.4" + "@biomejs/cli-darwin-arm64": "2.2.5", + "@biomejs/cli-darwin-x64": "2.2.5", + "@biomejs/cli-linux-arm64": "2.2.5", + "@biomejs/cli-linux-arm64-musl": "2.2.5", + "@biomejs/cli-linux-x64": "2.2.5", + "@biomejs/cli-linux-x64-musl": "2.2.5", + "@biomejs/cli-win32-arm64": "2.2.5", + "@biomejs/cli-win32-x64": "2.2.5" } }, "node_modules/@biomejs/cli-darwin-arm64": { - "version": "2.2.4", - "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.2.4.tgz", - "integrity": "sha512-RJe2uiyaloN4hne4d2+qVj3d3gFJFbmrr5PYtkkjei1O9c+BjGXgpUPVbi8Pl8syumhzJjFsSIYkcLt2VlVLMA==", + "version": "2.2.5", + "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.2.5.tgz", + "integrity": "sha512-MYT+nZ38wEIWVcL5xLyOhYQQ7nlWD0b/4mgATW2c8dvq7R4OQjt/XGXFkXrmtWmQofaIM14L7V8qIz/M+bx5QQ==", "cpu": [ "arm64" ], @@ -391,9 +391,9 @@ } }, "node_modules/@biomejs/cli-darwin-x64": { - "version": "2.2.4", - "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.2.4.tgz", - "integrity": "sha512-cFsdB4ePanVWfTnPVaUX+yr8qV8ifxjBKMkZwN7gKb20qXPxd/PmwqUH8mY5wnM9+U0QwM76CxFyBRJhC9tQwg==", + "version": "2.2.5", + "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.2.5.tgz", + "integrity": "sha512-FLIEl73fv0R7dI10EnEiZLw+IMz3mWLnF95ASDI0kbx6DDLJjWxE5JxxBfmG+udz1hIDd3fr5wsuP7nwuTRdAg==", "cpu": [ "x64" ], @@ -408,9 +408,9 @@ } }, "node_modules/@biomejs/cli-linux-arm64": { - "version": "2.2.4", - "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.2.4.tgz", - "integrity": "sha512-M/Iz48p4NAzMXOuH+tsn5BvG/Jb07KOMTdSVwJpicmhN309BeEyRyQX+n1XDF0JVSlu28+hiTQ2L4rZPvu7nMw==", + "version": "2.2.5", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.2.5.tgz", + "integrity": "sha512-5DjiiDfHqGgR2MS9D+AZ8kOfrzTGqLKywn8hoXpXXlJXIECGQ32t+gt/uiS2XyGBM2XQhR6ztUvbjZWeccFMoQ==", "cpu": [ "arm64" ], @@ -425,9 +425,9 @@ } }, "node_modules/@biomejs/cli-linux-arm64-musl": { - "version": "2.2.4", - "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.2.4.tgz", - "integrity": "sha512-7TNPkMQEWfjvJDaZRSkDCPT/2r5ESFPKx+TEev+I2BXDGIjfCZk2+b88FOhnJNHtksbOZv8ZWnxrA5gyTYhSsQ==", + "version": "2.2.5", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.2.5.tgz", + "integrity": "sha512-5Ov2wgAFwqDvQiESnu7b9ufD1faRa+40uwrohgBopeY84El2TnBDoMNXx6iuQdreoFGjwW8vH6k68G21EpNERw==", "cpu": [ "arm64" ], @@ -442,9 +442,9 @@ } }, "node_modules/@biomejs/cli-linux-x64": { - "version": "2.2.4", - "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-2.2.4.tgz", - "integrity": "sha512-orr3nnf2Dpb2ssl6aihQtvcKtLySLta4E2UcXdp7+RTa7mfJjBgIsbS0B9GC8gVu0hjOu021aU8b3/I1tn+pVQ==", + "version": "2.2.5", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-2.2.5.tgz", + "integrity": "sha512-fq9meKm1AEXeAWan3uCg6XSP5ObA6F/Ovm89TwaMiy1DNIwdgxPkNwxlXJX8iM6oRbFysYeGnT0OG8diCWb9ew==", "cpu": [ "x64" ], @@ -459,9 +459,9 @@ } }, "node_modules/@biomejs/cli-linux-x64-musl": { - "version": "2.2.4", - "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.2.4.tgz", - "integrity": "sha512-m41nFDS0ksXK2gwXL6W6yZTYPMH0LughqbsxInSKetoH6morVj43szqKx79Iudkp8WRT5SxSh7qVb8KCUiewGg==", + "version": "2.2.5", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.2.5.tgz", + "integrity": "sha512-AVqLCDb/6K7aPNIcxHaTQj01sl1m989CJIQFQEaiQkGr2EQwyOpaATJ473h+nXDUuAcREhccfRpe/tu+0wu0eQ==", "cpu": [ "x64" ], @@ -476,9 +476,9 @@ } }, "node_modules/@biomejs/cli-win32-arm64": { - "version": "2.2.4", - "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.2.4.tgz", - "integrity": "sha512-NXnfTeKHDFUWfxAefa57DiGmu9VyKi0cDqFpdI+1hJWQjGJhJutHPX0b5m+eXvTKOaf+brU+P0JrQAZMb5yYaQ==", + "version": "2.2.5", + "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.2.5.tgz", + "integrity": "sha512-xaOIad4wBambwJa6mdp1FigYSIF9i7PCqRbvBqtIi9y29QtPVQ13sDGtUnsRoe6SjL10auMzQ6YAe+B3RpZXVg==", "cpu": [ "arm64" ], @@ -493,9 +493,9 @@ } }, "node_modules/@biomejs/cli-win32-x64": { - "version": "2.2.4", - "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-2.2.4.tgz", - "integrity": "sha512-3Y4V4zVRarVh/B/eSHczR4LYoSVyv3Dfuvm3cWs5w/HScccS0+Wt/lHOcDTRYeHjQmMYVC3rIRWqyN2EI52+zg==", + "version": "2.2.5", + "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-2.2.5.tgz", + "integrity": "sha512-F/jhuXCssPFAuciMhHKk00xnCAxJRS/pUzVfXYmOMUp//XW7mO6QeCjsjvnm8L4AO/dG2VOB0O+fJPiJ2uXtIw==", "cpu": [ "x64" ], @@ -1088,21 +1088,21 @@ } }, "node_modules/@graphql-codegen/client-preset": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/@graphql-codegen/client-preset/-/client-preset-5.0.1.tgz", - "integrity": "sha512-3dXS7Sh/AkV+Ewq/HB1DSCb0tZBOIdTL8zkGQjRKWaf14x21h2f/xKl2zhRh6KlXjcCrIpX+AxHAhQxs6cXwVw==", + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/@graphql-codegen/client-preset/-/client-preset-5.0.2.tgz", + "integrity": "sha512-lBkVMz7QA7FHWb71BcNB/tFFOh0LDNCPIBaJ70Lj1SIPjOfCEYmbkK6D5piPZu87m60hyWN3XDwNHEH8eGoXNA==", "dev": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.20.2", "@babel/template": "^7.20.7", "@graphql-codegen/add": "^6.0.0", - "@graphql-codegen/gql-tag-operations": "5.0.0", + "@graphql-codegen/gql-tag-operations": "5.0.1", "@graphql-codegen/plugin-helpers": "^6.0.0", - "@graphql-codegen/typed-document-node": "^6.0.0", - "@graphql-codegen/typescript": "^5.0.0", - "@graphql-codegen/typescript-operations": "^5.0.0", - "@graphql-codegen/visitor-plugin-common": "^6.0.0", + "@graphql-codegen/typed-document-node": "^6.0.1", + "@graphql-codegen/typescript": "^5.0.1", + "@graphql-codegen/typescript-operations": "^5.0.1", + "@graphql-codegen/visitor-plugin-common": "^6.0.1", "@graphql-tools/documents": "^1.0.0", "@graphql-tools/utils": "^10.0.0", "@graphql-typed-document-node/core": "3.2.0", @@ -1155,14 +1155,14 @@ "license": "0BSD" }, "node_modules/@graphql-codegen/gql-tag-operations": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/@graphql-codegen/gql-tag-operations/-/gql-tag-operations-5.0.0.tgz", - "integrity": "sha512-kC2pc/tyzVc1laZtlfuQHqYxF4UqB4YXzAboFfeY1cxrxCh/+H70jHnfA1O4vhPndiRd+XZA8wxPv0hIqDXYaA==", + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/@graphql-codegen/gql-tag-operations/-/gql-tag-operations-5.0.1.tgz", + "integrity": "sha512-GVd/B6mtRAXg6UxgeO805P7VDrCmVIb6qIMrE7O69j8e4EqIt/URdmJ7On+Bn8IIKp7TcpcLSo/VI28ptcssNw==", "dev": true, "license": "MIT", "dependencies": { "@graphql-codegen/plugin-helpers": "^6.0.0", - "@graphql-codegen/visitor-plugin-common": "6.0.0", + "@graphql-codegen/visitor-plugin-common": "6.0.1", "@graphql-tools/utils": "^10.0.0", "auto-bind": "~4.0.0", "tslib": "~2.6.0" @@ -1260,14 +1260,14 @@ "license": "0BSD" }, "node_modules/@graphql-codegen/typed-document-node": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/@graphql-codegen/typed-document-node/-/typed-document-node-6.0.0.tgz", - "integrity": "sha512-OYmbadwvjq19yCZjioy901pLI9YV6i7A0fP3MpcJlo2uQVY27RJPcN2NeLfFzXdHr6f5bm9exqB6X1iKimfA2Q==", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/@graphql-codegen/typed-document-node/-/typed-document-node-6.0.1.tgz", + "integrity": "sha512-z0vvvmwfdozkY1AFqbNLeb/jAWyVwWJOIllZEEwPDKcVtCMPQZ1DRApPMRDRndRL6fOG4aXXnt7C5kgniC+qGw==", "dev": true, "license": "MIT", "dependencies": { "@graphql-codegen/plugin-helpers": "^6.0.0", - "@graphql-codegen/visitor-plugin-common": "6.0.0", + "@graphql-codegen/visitor-plugin-common": "6.0.1", "auto-bind": "~4.0.0", "change-case-all": "1.0.15", "tslib": "~2.6.0" @@ -1287,15 +1287,15 @@ "license": "0BSD" }, "node_modules/@graphql-codegen/typescript": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/@graphql-codegen/typescript/-/typescript-5.0.0.tgz", - "integrity": "sha512-u90SGM6+Rdc3Je1EmVQOrGk5fl7hK1cLR4y5Q1MeUenj0aZFxKno65DCW7RcQpcfebvkPsVGA6y3oS02wPFj6Q==", + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/@graphql-codegen/typescript/-/typescript-5.0.1.tgz", + "integrity": "sha512-GqAl4pxFdWTvW1h+Ume7djrucYwt03wiaS88m4ErG+tHsJaR2ZCtoHOo+B4bh7KIuBKap14/xOZG0qY/ThWAhg==", "dev": true, "license": "MIT", "dependencies": { "@graphql-codegen/plugin-helpers": "^6.0.0", "@graphql-codegen/schema-ast": "^5.0.0", - "@graphql-codegen/visitor-plugin-common": "6.0.0", + "@graphql-codegen/visitor-plugin-common": "6.0.1", "auto-bind": "~4.0.0", "tslib": "~2.6.0" }, @@ -1307,15 +1307,15 @@ } }, "node_modules/@graphql-codegen/typescript-operations": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/@graphql-codegen/typescript-operations/-/typescript-operations-5.0.0.tgz", - "integrity": "sha512-mqgp/lp5v7w+RYj5AJ/BVquP+sgje3EAgg++62ciolOB5zzWT8en09cRdNq4UZfszCYTOtlhCG7NQAAcSae37A==", + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/@graphql-codegen/typescript-operations/-/typescript-operations-5.0.1.tgz", + "integrity": "sha512-uJwsOIqvXyxlOI1Mnoy8Mn3TiOHTzVTGDwqL9gHnpKqQZdFfvMgfDf/HyT7Mw3XCOfhSS99fe9ATW0bkMExBZg==", "dev": true, "license": "MIT", "dependencies": { "@graphql-codegen/plugin-helpers": "^6.0.0", - "@graphql-codegen/typescript": "^5.0.0", - "@graphql-codegen/visitor-plugin-common": "6.0.0", + "@graphql-codegen/typescript": "^5.0.1", + "@graphql-codegen/visitor-plugin-common": "6.0.1", "auto-bind": "~4.0.0", "tslib": "~2.6.0" }, @@ -1340,15 +1340,15 @@ "license": "0BSD" }, "node_modules/@graphql-codegen/typescript-resolvers": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/@graphql-codegen/typescript-resolvers/-/typescript-resolvers-5.0.0.tgz", - "integrity": "sha512-etUYZYwpBM+EmmcH/TtK9+dCzFMM36gI9aIc4/ckDnT34SLWnWVAkbfeNetwzhq98FD84SL5d+YqLGRFeEylJw==", + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/@graphql-codegen/typescript-resolvers/-/typescript-resolvers-5.0.1.tgz", + "integrity": "sha512-E3Dyc2gaI4I79Wgwvwo5HP2MMQkUrcGA+3Lfx/ckDlE8zi3wwjWMhAjIhW54VQbi8q2/9h7ooRph3eat9VTscA==", "dev": true, "license": "MIT", "dependencies": { "@graphql-codegen/plugin-helpers": "^6.0.0", - "@graphql-codegen/typescript": "^5.0.0", - "@graphql-codegen/visitor-plugin-common": "6.0.0", + "@graphql-codegen/typescript": "^5.0.1", + "@graphql-codegen/visitor-plugin-common": "6.0.1", "@graphql-tools/utils": "^10.0.0", "auto-bind": "~4.0.0", "tslib": "~2.6.0" @@ -1381,9 +1381,9 @@ "license": "0BSD" }, "node_modules/@graphql-codegen/visitor-plugin-common": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/@graphql-codegen/visitor-plugin-common/-/visitor-plugin-common-6.0.0.tgz", - "integrity": "sha512-K05Jv2elOeFstH3i+Ah0Pi9do6NYUvrbdhEkP+UvP9fmIro1hCKwcIEP7j4VFz8mt3gAC3dB5KVJDoyaPUgi4Q==", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/@graphql-codegen/visitor-plugin-common/-/visitor-plugin-common-6.0.1.tgz", + "integrity": "sha512-3gopoUYXn26PSj2UdCWmYj0QiRVD5qR3eDiXx72OQcN1Vb8qj6VfOWB+NDuD1Q1sgVYbCQVKgj92ERsSW1xH9Q==", "dev": true, "license": "MIT", "dependencies": { @@ -2387,9 +2387,9 @@ "license": "MIT" }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.52.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.52.3.tgz", - "integrity": "sha512-h6cqHGZ6VdnwliFG1NXvMPTy/9PS3h8oLh7ImwR+kl+oYnQizgjxsONmmPSb2C66RksfkfIxEVtDSEcJiO0tqw==", + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.52.4.tgz", + "integrity": "sha512-BTm2qKNnWIQ5auf4deoetINJm2JzvihvGb9R6K/ETwKLql/Bb3Eg2H1FBp1gUb4YGbydMA3jcmQTR73q7J+GAA==", "cpu": [ "arm" ], @@ -2401,9 +2401,9 @@ ] }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.52.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.52.3.tgz", - "integrity": "sha512-wd+u7SLT/u6knklV/ifG7gr5Qy4GUbH2hMWcDauPFJzmCZUAJ8L2bTkVXC2niOIxp8lk3iH/QX8kSrUxVZrOVw==", + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.52.4.tgz", + "integrity": "sha512-P9LDQiC5vpgGFgz7GSM6dKPCiqR3XYN1WwJKA4/BUVDjHpYsf3iBEmVz62uyq20NGYbiGPR5cNHI7T1HqxNs2w==", "cpu": [ "arm64" ], @@ -2415,9 +2415,9 @@ ] }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.52.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.52.3.tgz", - "integrity": "sha512-lj9ViATR1SsqycwFkJCtYfQTheBdvlWJqzqxwc9f2qrcVrQaF/gCuBRTiTolkRWS6KvNxSk4KHZWG7tDktLgjg==", + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.52.4.tgz", + "integrity": "sha512-QRWSW+bVccAvZF6cbNZBJwAehmvG9NwfWHwMy4GbWi/BQIA/laTIktebT2ipVjNncqE6GLPxOok5hsECgAxGZg==", "cpu": [ "arm64" ], @@ -2429,9 +2429,9 @@ ] }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.52.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.52.3.tgz", - "integrity": "sha512-+Dyo7O1KUmIsbzx1l+4V4tvEVnVQqMOIYtrxK7ncLSknl1xnMHLgn7gddJVrYPNZfEB8CIi3hK8gq8bDhb3h5A==", + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.52.4.tgz", + "integrity": "sha512-hZgP05pResAkRJxL1b+7yxCnXPGsXU0fG9Yfd6dUaoGk+FhdPKCJ5L1Sumyxn8kvw8Qi5PvQ8ulenUbRjzeCTw==", "cpu": [ "x64" ], @@ -2443,9 +2443,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.52.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.52.3.tgz", - "integrity": "sha512-u9Xg2FavYbD30g3DSfNhxgNrxhi6xVG4Y6i9Ur1C7xUuGDW3banRbXj+qgnIrwRN4KeJ396jchwy9bCIzbyBEQ==", + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.52.4.tgz", + "integrity": "sha512-xmc30VshuBNUd58Xk4TKAEcRZHaXlV+tCxIXELiE9sQuK3kG8ZFgSPi57UBJt8/ogfhAF5Oz4ZSUBN77weM+mQ==", "cpu": [ "arm64" ], @@ -2457,9 +2457,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.52.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.52.3.tgz", - "integrity": "sha512-5M8kyi/OX96wtD5qJR89a/3x5x8x5inXBZO04JWhkQb2JWavOWfjgkdvUqibGJeNNaz1/Z1PPza5/tAPXICI6A==", + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.52.4.tgz", + "integrity": "sha512-WdSLpZFjOEqNZGmHflxyifolwAiZmDQzuOzIq9L27ButpCVpD7KzTRtEG1I0wMPFyiyUdOO+4t8GvrnBLQSwpw==", "cpu": [ "x64" ], @@ -2471,9 +2471,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.52.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.52.3.tgz", - "integrity": "sha512-IoerZJ4l1wRMopEHRKOO16e04iXRDyZFZnNZKrWeNquh5d6bucjezgd+OxG03mOMTnS1x7hilzb3uURPkJ0OfA==", + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.52.4.tgz", + "integrity": "sha512-xRiOu9Of1FZ4SxVbB0iEDXc4ddIcjCv2aj03dmW8UrZIW7aIQ9jVJdLBIhxBI+MaTnGAKyvMwPwQnoOEvP7FgQ==", "cpu": [ "arm" ], @@ -2485,9 +2485,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.52.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.52.3.tgz", - "integrity": "sha512-ZYdtqgHTDfvrJHSh3W22TvjWxwOgc3ThK/XjgcNGP2DIwFIPeAPNsQxrJO5XqleSlgDux2VAoWQ5iJrtaC1TbA==", + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.52.4.tgz", + "integrity": "sha512-FbhM2p9TJAmEIEhIgzR4soUcsW49e9veAQCziwbR+XWB2zqJ12b4i/+hel9yLiD8pLncDH4fKIPIbt5238341Q==", "cpu": [ "arm" ], @@ -2499,9 +2499,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.52.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.52.3.tgz", - "integrity": "sha512-NcViG7A0YtuFDA6xWSgmFb6iPFzHlf5vcqb2p0lGEbT+gjrEEz8nC/EeDHvx6mnGXnGCC1SeVV+8u+smj0CeGQ==", + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.52.4.tgz", + "integrity": "sha512-4n4gVwhPHR9q/g8lKCyz0yuaD0MvDf7dV4f9tHt0C73Mp8h38UCtSCSE6R9iBlTbXlmA8CjpsZoujhszefqueg==", "cpu": [ "arm64" ], @@ -2513,9 +2513,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.52.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.52.3.tgz", - "integrity": "sha512-d3pY7LWno6SYNXRm6Ebsq0DJGoiLXTb83AIPCXl9fmtIQs/rXoS8SJxxUNtFbJ5MiOvs+7y34np77+9l4nfFMw==", + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.52.4.tgz", + "integrity": "sha512-u0n17nGA0nvi/11gcZKsjkLj1QIpAuPFQbR48Subo7SmZJnGxDpspyw2kbpuoQnyK+9pwf3pAoEXerJs/8Mi9g==", "cpu": [ "arm64" ], @@ -2527,9 +2527,9 @@ ] }, "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.52.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.52.3.tgz", - "integrity": "sha512-3y5GA0JkBuirLqmjwAKwB0keDlI6JfGYduMlJD/Rl7fvb4Ni8iKdQs1eiunMZJhwDWdCvrcqXRY++VEBbvk6Eg==", + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.52.4.tgz", + "integrity": "sha512-0G2c2lpYtbTuXo8KEJkDkClE/+/2AFPdPAbmaHoE870foRFs4pBrDehilMcrSScrN/fB/1HTaWO4bqw+ewBzMQ==", "cpu": [ "loong64" ], @@ -2541,9 +2541,9 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.52.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.52.3.tgz", - "integrity": "sha512-AUUH65a0p3Q0Yfm5oD2KVgzTKgwPyp9DSXc3UA7DtxhEb/WSPfbG4wqXeSN62OG5gSo18em4xv6dbfcUGXcagw==", + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.52.4.tgz", + "integrity": "sha512-teSACug1GyZHmPDv14VNbvZFX779UqWTsd7KtTM9JIZRDI5NUwYSIS30kzI8m06gOPB//jtpqlhmraQ68b5X2g==", "cpu": [ "ppc64" ], @@ -2555,9 +2555,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.52.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.52.3.tgz", - "integrity": "sha512-1makPhFFVBqZE+XFg3Dkq+IkQ7JvmUrwwqaYBL2CE+ZpxPaqkGaiWFEWVGyvTwZace6WLJHwjVh/+CXbKDGPmg==", + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.52.4.tgz", + "integrity": "sha512-/MOEW3aHjjs1p4Pw1Xk4+3egRevx8Ji9N6HUIA1Ifh8Q+cg9dremvFCUbOX2Zebz80BwJIgCBUemjqhU5XI5Eg==", "cpu": [ "riscv64" ], @@ -2569,9 +2569,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.52.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.52.3.tgz", - "integrity": "sha512-OOFJa28dxfl8kLOPMUOQBCO6z3X2SAfzIE276fwT52uXDWUS178KWq0pL7d6p1kz7pkzA0yQwtqL0dEPoVcRWg==", + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.52.4.tgz", + "integrity": "sha512-1HHmsRyh845QDpEWzOFtMCph5Ts+9+yllCrREuBR/vg2RogAQGGBRC8lDPrPOMnrdOJ+mt1WLMOC2Kao/UwcvA==", "cpu": [ "riscv64" ], @@ -2583,9 +2583,9 @@ ] }, "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.52.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.52.3.tgz", - "integrity": "sha512-jMdsML2VI5l+V7cKfZx3ak+SLlJ8fKvLJ0Eoa4b9/vCUrzXKgoKxvHqvJ/mkWhFiyp88nCkM5S2v6nIwRtPcgg==", + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.52.4.tgz", + "integrity": "sha512-seoeZp4L/6D1MUyjWkOMRU6/iLmCU2EjbMTyAG4oIOs1/I82Y5lTeaxW0KBfkUdHAWN7j25bpkt0rjnOgAcQcA==", "cpu": [ "s390x" ], @@ -2597,9 +2597,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.52.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.52.3.tgz", - "integrity": "sha512-tPgGd6bY2M2LJTA1uGq8fkSPK8ZLYjDjY+ZLK9WHncCnfIz29LIXIqUgzCR0hIefzy6Hpbe8Th5WOSwTM8E7LA==", + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.52.4.tgz", + "integrity": "sha512-Wi6AXf0k0L7E2gteNsNHUs7UMwCIhsCTs6+tqQ5GPwVRWMaflqGec4Sd8n6+FNFDw9vGcReqk2KzBDhCa1DLYg==", "cpu": [ "x64" ], @@ -2611,9 +2611,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.52.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.52.3.tgz", - "integrity": "sha512-BCFkJjgk+WFzP+tcSMXq77ymAPIxsX9lFJWs+2JzuZTLtksJ2o5hvgTdIcZ5+oKzUDMwI0PfWzRBYAydAHF2Mw==", + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.52.4.tgz", + "integrity": "sha512-dtBZYjDmCQ9hW+WgEkaffvRRCKm767wWhxsFW3Lw86VXz/uJRuD438/XvbZT//B96Vs8oTA8Q4A0AfHbrxP9zw==", "cpu": [ "x64" ], @@ -2625,9 +2625,9 @@ ] }, "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.52.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.52.3.tgz", - "integrity": "sha512-KTD/EqjZF3yvRaWUJdD1cW+IQBk4fbQaHYJUmP8N4XoKFZilVL8cobFSTDnjTtxWJQ3JYaMgF4nObY/+nYkumA==", + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.52.4.tgz", + "integrity": "sha512-1ox+GqgRWqaB1RnyZXL8PD6E5f7YyRUJYnCqKpNzxzP0TkaUh112NDrR9Tt+C8rJ4x5G9Mk8PQR3o7Ku2RKqKA==", "cpu": [ "arm64" ], @@ -2639,9 +2639,9 @@ ] }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.52.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.52.3.tgz", - "integrity": "sha512-+zteHZdoUYLkyYKObGHieibUFLbttX2r+58l27XZauq0tcWYYuKUwY2wjeCN9oK1Um2YgH2ibd6cnX/wFD7DuA==", + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.52.4.tgz", + "integrity": "sha512-8GKr640PdFNXwzIE0IrkMWUNUomILLkfeHjXBi/nUvFlpZP+FA8BKGKpacjW6OUUHaNI6sUURxR2U2g78FOHWQ==", "cpu": [ "arm64" ], @@ -2653,9 +2653,9 @@ ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.52.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.52.3.tgz", - "integrity": "sha512-of1iHkTQSo3kr6dTIRX6t81uj/c/b15HXVsPcEElN5sS859qHrOepM5p9G41Hah+CTqSh2r8Bm56dL2z9UQQ7g==", + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.52.4.tgz", + "integrity": "sha512-AIy/jdJ7WtJ/F6EcfOb2GjR9UweO0n43jNObQMb6oGxkYTfLcnN7vYYpG+CN3lLxrQkzWnMOoNSHTW54pgbVxw==", "cpu": [ "ia32" ], @@ -2667,9 +2667,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.52.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.52.3.tgz", - "integrity": "sha512-s0hybmlHb56mWVZQj8ra9048/WZTPLILKxcvcq+8awSZmyiSUZjjem1AhU3Tf4ZKpYhK4mg36HtHDOe8QJS5PQ==", + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.52.4.tgz", + "integrity": "sha512-UF9KfsH9yEam0UjTwAgdK0anlQ7c8/pWPU2yVjyWcF1I1thABt6WXE47cI71pGiZ8wGvxohBoLnxM04L/wj8mQ==", "cpu": [ "x64" ], @@ -2681,9 +2681,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.52.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.52.3.tgz", - "integrity": "sha512-zGIbEVVXVtauFgl3MRwGWEN36P5ZGenHRMgNw88X5wEhEBpq0XrMEZwOn07+ICrwM17XO5xfMZqh0OldCH5VTA==", + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.52.4.tgz", + "integrity": "sha512-bf9PtUa0u8IXDVxzRToFQKsNCRz9qLYfR/MpECxl4mRoWYjAeFjgxj1XdZr2M/GNVpT05p+LgQOHopYDlUu6/w==", "cpu": [ "x64" ], @@ -2794,13 +2794,13 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "24.5.2", - "resolved": "https://registry.npmjs.org/@types/node/-/node-24.5.2.tgz", - "integrity": "sha512-FYxk1I7wPv3K2XBaoyH2cTnocQEu8AOZ60hPbsyukMPLv5/5qr7V1i8PLHdl6Zf87I+xZXFvPCXYjiTFq+YSDQ==", + "version": "24.6.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.6.2.tgz", + "integrity": "sha512-d2L25Y4j+W3ZlNAeMKcy7yDsK425ibcAOO2t7aPTz6gNMH0z2GThtwENCDc0d/Pw9wgyRqE5Px1wkV7naz8ang==", "dev": true, "license": "MIT", "dependencies": { - "undici-types": "~7.12.0" + "undici-types": "~7.13.0" } }, "node_modules/@types/prismjs": { @@ -3030,9 +3030,9 @@ "license": "MIT" }, "node_modules/baseline-browser-mapping": { - "version": "2.8.8", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.8.tgz", - "integrity": "sha512-be0PUaPsQX/gPWWgFsdD+GFzaoig5PXaUC1xLkQiYdDnANU8sMnHoQd8JhbJQuvTWrWLyeFN9Imb5Qtfvr4RrQ==", + "version": "2.8.10", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.10.tgz", + "integrity": "sha512-uLfgBi+7IBNay8ECBO2mVMGZAc1VgZWEChxm4lv+TobGdG82LnXMjuNGo/BSSZZL4UmkWhxEHP2f5ziLNwGWMA==", "dev": true, "license": "Apache-2.0", "bin": { @@ -3063,9 +3063,9 @@ } }, "node_modules/browserslist": { - "version": "4.26.2", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.26.2.tgz", - "integrity": "sha512-ECFzp6uFOSB+dcZ5BK/IBaGWssbSYBHvuMeMt3MMFyhI0Z8SqGgEkBLARgpRH3hutIgPVsALcMwbDrJqPxQ65A==", + "version": "4.26.3", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.26.3.tgz", + "integrity": "sha512-lAUU+02RFBuCKQPj/P6NgjlbCnLBMp4UtgTx7vNHd3XSIJF87s9a5rA3aH2yw3GS9DqZAUbOtZdCCiZeVRqt0w==", "dev": true, "funding": [ { @@ -3083,9 +3083,9 @@ ], "license": "MIT", "dependencies": { - "baseline-browser-mapping": "^2.8.3", - "caniuse-lite": "^1.0.30001741", - "electron-to-chromium": "^1.5.218", + "baseline-browser-mapping": "^2.8.9", + "caniuse-lite": "^1.0.30001746", + "electron-to-chromium": "^1.5.227", "node-releases": "^2.0.21", "update-browserslist-db": "^1.1.3" }, @@ -3135,9 +3135,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001745", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001745.tgz", - "integrity": "sha512-ywt6i8FzvdgrrrGbr1jZVObnVv6adj+0if2/omv9cmR2oiZs30zL4DIyaptKcbOrBdOIc74QTMoJvSE2QHh5UQ==", + "version": "1.0.30001747", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001747.tgz", + "integrity": "sha512-mzFa2DGIhuc5490Nd/G31xN1pnBnYMadtkyTjefPI7wzypqgCEpeWu9bJr0OnDsyKrW75zA9ZAt7pbQFmwLsQg==", "dev": true, "funding": [ { @@ -3588,9 +3588,9 @@ } }, "node_modules/electron-to-chromium": { - "version": "1.5.227", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.227.tgz", - "integrity": "sha512-ITxuoPfJu3lsNWUi2lBM2PaBPYgH3uqmxut5vmBxgYvyI4AlJ6P3Cai1O76mOrkJCBzq0IxWg/NtqOrpu/0gKA==", + "version": "1.5.230", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.230.tgz", + "integrity": "sha512-A6A6Fd3+gMdaed9wX83CvHYJb4UuapPD5X5SLq72VZJzxHSY0/LUweGXRWmQlh2ln7KV7iw7jnwXK7dlPoOnHQ==", "dev": true, "license": "ISC" }, @@ -4292,9 +4292,9 @@ } }, "node_modules/jiti": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.0.tgz", - "integrity": "sha512-VXe6RjJkBPj0ohtqaO8vSWP3ZhAKo66fKrFNCll4BTcwljPLz03pCbaNKfzGP5MbrCYcbJ7v0nOYYwUzTEIdXQ==", + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", + "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", "dev": true, "license": "MIT", "bin": { @@ -4369,9 +4369,9 @@ } }, "node_modules/lightningcss": { - "version": "1.30.1", - "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.1.tgz", - "integrity": "sha512-xi6IyHML+c9+Q3W0S4fCQJOym42pyurFiJUHEcEyHS0CeKzia4yZDEsLlqOFykxOdHpNy0NmvVO31vcSqAxJCg==", + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.2.tgz", + "integrity": "sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ==", "dev": true, "license": "MPL-2.0", "dependencies": { @@ -4385,22 +4385,44 @@ "url": "https://opencollective.com/parcel" }, "optionalDependencies": { - "lightningcss-darwin-arm64": "1.30.1", - "lightningcss-darwin-x64": "1.30.1", - "lightningcss-freebsd-x64": "1.30.1", - "lightningcss-linux-arm-gnueabihf": "1.30.1", - "lightningcss-linux-arm64-gnu": "1.30.1", - "lightningcss-linux-arm64-musl": "1.30.1", - "lightningcss-linux-x64-gnu": "1.30.1", - "lightningcss-linux-x64-musl": "1.30.1", - "lightningcss-win32-arm64-msvc": "1.30.1", - "lightningcss-win32-x64-msvc": "1.30.1" + "lightningcss-android-arm64": "1.30.2", + "lightningcss-darwin-arm64": "1.30.2", + "lightningcss-darwin-x64": "1.30.2", + "lightningcss-freebsd-x64": "1.30.2", + "lightningcss-linux-arm-gnueabihf": "1.30.2", + "lightningcss-linux-arm64-gnu": "1.30.2", + "lightningcss-linux-arm64-musl": "1.30.2", + "lightningcss-linux-x64-gnu": "1.30.2", + "lightningcss-linux-x64-musl": "1.30.2", + "lightningcss-win32-arm64-msvc": "1.30.2", + "lightningcss-win32-x64-msvc": "1.30.2" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.30.2.tgz", + "integrity": "sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, "node_modules/lightningcss-darwin-arm64": { - "version": "1.30.1", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.30.1.tgz", - "integrity": "sha512-c8JK7hyE65X1MHMN+Viq9n11RRC7hgin3HhYKhrMyaXflk5GVplZ60IxyoVtzILeKr+xAJwg6zK6sjTBJ0FKYQ==", + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.30.2.tgz", + "integrity": "sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA==", "cpu": [ "arm64" ], @@ -4419,9 +4441,9 @@ } }, "node_modules/lightningcss-darwin-x64": { - "version": "1.30.1", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.30.1.tgz", - "integrity": "sha512-k1EvjakfumAQoTfcXUcHQZhSpLlkAuEkdMBsI/ivWw9hL+7FtilQc0Cy3hrx0AAQrVtQAbMI7YjCgYgvn37PzA==", + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.30.2.tgz", + "integrity": "sha512-oBZgKchomuDYxr7ilwLcyms6BCyLn0z8J0+ZZmfpjwg9fRVZIR5/GMXd7r9RH94iDhld3UmSjBM6nXWM2TfZTQ==", "cpu": [ "x64" ], @@ -4440,9 +4462,9 @@ } }, "node_modules/lightningcss-freebsd-x64": { - "version": "1.30.1", - "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.30.1.tgz", - "integrity": "sha512-kmW6UGCGg2PcyUE59K5r0kWfKPAVy4SltVeut+umLCFoJ53RdCUWxcRDzO1eTaxf/7Q2H7LTquFHPL5R+Gjyig==", + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.30.2.tgz", + "integrity": "sha512-c2bH6xTrf4BDpK8MoGG4Bd6zAMZDAXS569UxCAGcA7IKbHNMlhGQ89eRmvpIUGfKWNVdbhSbkQaWhEoMGmGslA==", "cpu": [ "x64" ], @@ -4461,9 +4483,9 @@ } }, "node_modules/lightningcss-linux-arm-gnueabihf": { - "version": "1.30.1", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.30.1.tgz", - "integrity": "sha512-MjxUShl1v8pit+6D/zSPq9S9dQ2NPFSQwGvxBCYaBYLPlCWuPh9/t1MRS8iUaR8i+a6w7aps+B4N0S1TYP/R+Q==", + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.30.2.tgz", + "integrity": "sha512-eVdpxh4wYcm0PofJIZVuYuLiqBIakQ9uFZmipf6LF/HRj5Bgm0eb3qL/mr1smyXIS1twwOxNWndd8z0E374hiA==", "cpu": [ "arm" ], @@ -4482,9 +4504,9 @@ } }, "node_modules/lightningcss-linux-arm64-gnu": { - "version": "1.30.1", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.30.1.tgz", - "integrity": "sha512-gB72maP8rmrKsnKYy8XUuXi/4OctJiuQjcuqWNlJQ6jZiWqtPvqFziskH3hnajfvKB27ynbVCucKSm2rkQp4Bw==", + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.30.2.tgz", + "integrity": "sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A==", "cpu": [ "arm64" ], @@ -4503,9 +4525,9 @@ } }, "node_modules/lightningcss-linux-arm64-musl": { - "version": "1.30.1", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.30.1.tgz", - "integrity": "sha512-jmUQVx4331m6LIX+0wUhBbmMX7TCfjF5FoOH6SD1CttzuYlGNVpA7QnrmLxrsub43ClTINfGSYyHe2HWeLl5CQ==", + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.30.2.tgz", + "integrity": "sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==", "cpu": [ "arm64" ], @@ -4524,9 +4546,9 @@ } }, "node_modules/lightningcss-linux-x64-gnu": { - "version": "1.30.1", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.30.1.tgz", - "integrity": "sha512-piWx3z4wN8J8z3+O5kO74+yr6ze/dKmPnI7vLqfSqI8bccaTGY5xiSGVIJBDd5K5BHlvVLpUB3S2YCfelyJ1bw==", + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.30.2.tgz", + "integrity": "sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==", "cpu": [ "x64" ], @@ -4545,9 +4567,9 @@ } }, "node_modules/lightningcss-linux-x64-musl": { - "version": "1.30.1", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.30.1.tgz", - "integrity": "sha512-rRomAK7eIkL+tHY0YPxbc5Dra2gXlI63HL+v1Pdi1a3sC+tJTcFrHX+E86sulgAXeI7rSzDYhPSeHHjqFhqfeQ==", + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.30.2.tgz", + "integrity": "sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==", "cpu": [ "x64" ], @@ -4566,9 +4588,9 @@ } }, "node_modules/lightningcss-win32-arm64-msvc": { - "version": "1.30.1", - "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.30.1.tgz", - "integrity": "sha512-mSL4rqPi4iXq5YVqzSsJgMVFENoa4nGTT/GjO2c0Yl9OuQfPsIfncvLrEW6RbbB24WtZ3xP/2CCmI3tNkNV4oA==", + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.30.2.tgz", + "integrity": "sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==", "cpu": [ "arm64" ], @@ -4587,9 +4609,9 @@ } }, "node_modules/lightningcss-win32-x64-msvc": { - "version": "1.30.1", - "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.30.1.tgz", - "integrity": "sha512-PVqXh48wh4T53F/1CCu8PIPCxLzWyCnn/9T5W1Jpmdy5h9Cwd+0YQS6/LwhHXSafuc61/xg9Lv5OrCby6a++jg==", + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.30.2.tgz", + "integrity": "sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw==", "cpu": [ "x64" ], @@ -5408,9 +5430,9 @@ "license": "MIT" }, "node_modules/rollup": { - "version": "4.52.3", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.52.3.tgz", - "integrity": "sha512-RIDh866U8agLgiIcdpB+COKnlCreHJLfIhWC3LVflku5YHfpnsIKigRZeFfMfCc4dVcqNVfQQ5gO/afOck064A==", + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.52.4.tgz", + "integrity": "sha512-CLEVl+MnPAiKh5pl4dEWSyMTpuflgNQiLGhMv8ezD5W/qP8AKvmYpCOKRRNOh7oRKnauBZ4SyeYkMS+1VSyKwQ==", "dev": true, "license": "MIT", "dependencies": { @@ -5424,28 +5446,28 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.52.3", - "@rollup/rollup-android-arm64": "4.52.3", - "@rollup/rollup-darwin-arm64": "4.52.3", - "@rollup/rollup-darwin-x64": "4.52.3", - "@rollup/rollup-freebsd-arm64": "4.52.3", - "@rollup/rollup-freebsd-x64": "4.52.3", - "@rollup/rollup-linux-arm-gnueabihf": "4.52.3", - "@rollup/rollup-linux-arm-musleabihf": "4.52.3", - "@rollup/rollup-linux-arm64-gnu": "4.52.3", - "@rollup/rollup-linux-arm64-musl": "4.52.3", - "@rollup/rollup-linux-loong64-gnu": "4.52.3", - "@rollup/rollup-linux-ppc64-gnu": "4.52.3", - "@rollup/rollup-linux-riscv64-gnu": "4.52.3", - "@rollup/rollup-linux-riscv64-musl": "4.52.3", - "@rollup/rollup-linux-s390x-gnu": "4.52.3", - "@rollup/rollup-linux-x64-gnu": "4.52.3", - "@rollup/rollup-linux-x64-musl": "4.52.3", - "@rollup/rollup-openharmony-arm64": "4.52.3", - "@rollup/rollup-win32-arm64-msvc": "4.52.3", - "@rollup/rollup-win32-ia32-msvc": "4.52.3", - "@rollup/rollup-win32-x64-gnu": "4.52.3", - "@rollup/rollup-win32-x64-msvc": "4.52.3", + "@rollup/rollup-android-arm-eabi": "4.52.4", + "@rollup/rollup-android-arm64": "4.52.4", + "@rollup/rollup-darwin-arm64": "4.52.4", + "@rollup/rollup-darwin-x64": "4.52.4", + "@rollup/rollup-freebsd-arm64": "4.52.4", + "@rollup/rollup-freebsd-x64": "4.52.4", + "@rollup/rollup-linux-arm-gnueabihf": "4.52.4", + "@rollup/rollup-linux-arm-musleabihf": "4.52.4", + "@rollup/rollup-linux-arm64-gnu": "4.52.4", + "@rollup/rollup-linux-arm64-musl": "4.52.4", + "@rollup/rollup-linux-loong64-gnu": "4.52.4", + "@rollup/rollup-linux-ppc64-gnu": "4.52.4", + "@rollup/rollup-linux-riscv64-gnu": "4.52.4", + "@rollup/rollup-linux-riscv64-musl": "4.52.4", + "@rollup/rollup-linux-s390x-gnu": "4.52.4", + "@rollup/rollup-linux-x64-gnu": "4.52.4", + "@rollup/rollup-linux-x64-musl": "4.52.4", + "@rollup/rollup-openharmony-arm64": "4.52.4", + "@rollup/rollup-win32-arm64-msvc": "4.52.4", + "@rollup/rollup-win32-ia32-msvc": "4.52.4", + "@rollup/rollup-win32-x64-gnu": "4.52.4", + "@rollup/rollup-win32-x64-msvc": "4.52.4", "fsevents": "~2.3.2" } }, @@ -5903,9 +5925,9 @@ "license": "0BSD" }, "node_modules/typescript": { - "version": "5.9.2", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.2.tgz", - "integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==", + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", "bin": { @@ -5954,9 +5976,9 @@ } }, "node_modules/undici-types": { - "version": "7.12.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.12.0.tgz", - "integrity": "sha512-goOacqME2GYyOZZfb5Lgtu+1IDmAlAEu5xnD3+xTzS10hT0vzpf0SPjkXwAw9Jm+4n/mQGDP3LO8CPbYROeBfQ==", + "version": "7.13.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.13.0.tgz", + "integrity": "sha512-Ov2Rr9Sx+fRgagJ5AX0qvItZG/JKKoBRAVITs1zk7IqZGTJUwgUr7qoYBpWwakpWilTZFM98rG/AFRocu10iIQ==", "dev": true, "license": "MIT" }, @@ -6039,9 +6061,9 @@ "license": "ISC" }, "node_modules/vite": { - "version": "7.1.7", - "resolved": "https://registry.npmjs.org/vite/-/vite-7.1.7.tgz", - "integrity": "sha512-VbA8ScMvAISJNJVbRDTJdCwqQoAareR/wutevKanhR2/1EkoXVZVkkORaYm/tNVCjP/UDTKtcw3bAkwOUdedmA==", + "version": "7.1.9", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.1.9.tgz", + "integrity": "sha512-4nVGliEpxmhCL8DslSAUdxlB6+SMrhB0a1v5ijlh1xB1nEPuy1mxaHxysVucLHuWryAxLWg6a5ei+U4TLn/rFg==", "dev": true, "license": "MIT", "dependencies": { @@ -6114,9 +6136,9 @@ } }, "node_modules/vite-plugin-solid": { - "version": "2.11.8", - "resolved": "https://registry.npmjs.org/vite-plugin-solid/-/vite-plugin-solid-2.11.8.tgz", - "integrity": "sha512-hFrCxBfv3B1BmFqnJF4JOCYpjrmi/zwyeKjcomQ0khh8HFyQ8SbuBWQ7zGojfrz6HUOBFrJBNySDi/JgAHytWg==", + "version": "2.11.9", + "resolved": "https://registry.npmjs.org/vite-plugin-solid/-/vite-plugin-solid-2.11.9.tgz", + "integrity": "sha512-bTA6p+bspXZsuulSd2y6aTzegF8xGaJYcq1Uyh/mv+W4DQtzCgL9nN6n2fsTaxp/dMk+ZHHKgGndlNeooqHLKw==", "dev": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index 239dd6af..8ace47f0 100644 --- a/package.json +++ b/package.json @@ -13,27 +13,27 @@ "codegen": "graphql-codegen --config codegen.ts" }, "devDependencies": { - "@biomejs/biome": "^2.2.4", + "@biomejs/biome": "^2.2.5", "@graphql-codegen/cli": "^6.0.0", - "@graphql-codegen/client-preset": "^5.0.1", + "@graphql-codegen/client-preset": "^5.0.2", "@graphql-codegen/introspection": "^5.0.0", - "@graphql-codegen/typescript": "^5.0.0", - "@graphql-codegen/typescript-operations": "^5.0.0", - "@graphql-codegen/typescript-resolvers": "^5.0.0", + "@graphql-codegen/typescript": "^5.0.1", + "@graphql-codegen/typescript-operations": "^5.0.1", + "@graphql-codegen/typescript-resolvers": "^5.0.1", "@solidjs/router": "^0.15.3", - "@types/node": "^24.5.2", + "@types/node": "^24.6.2", "@types/prismjs": "^1.26.5", "graphql": "^16.11.0", "graphql-tag": "^2.12.6", - "lightningcss": "^1.30.1", + "lightningcss": "^1.30.2", "prismjs": "^1.30.0", "solid-js": "^1.9.9", "terser": "^5.44.0", - "typescript": "^5.9.2", - "vite": "^7.1.7", - "vite-plugin-solid": "^2.11.7" + "typescript": "^5.9.3", + "vite": "^7.1.9", + "vite-plugin-solid": "^2.11.9" }, "overrides": { - "vite": "^7.1.7" + "vite": "^7.1.9" } } diff --git a/panel/context/auth.tsx b/panel/context/auth.tsx index e88ff707..3f2d5b08 100644 --- a/panel/context/auth.tsx +++ b/panel/context/auth.tsx @@ -69,7 +69,7 @@ export const AuthProvider: Component = (props) => { // Начинаем с false чтобы избежать мерцания, реальная проверка будет в onMount const [isAuthenticated, setIsAuthenticated] = createSignal(false) const [isReady, setIsReady] = createSignal(false) - + // Флаг для предотвращения повторных инициализаций let isInitializing = false @@ -82,7 +82,7 @@ export const AuthProvider: Component = (props) => { console.log('[AuthProvider] Already initializing, skipping...') return } - + isInitializing = true console.log('[AuthProvider] Performing auth initialization...') @@ -91,7 +91,7 @@ export const AuthProvider: Component = (props) => { console.log('[AuthProvider] Checking authentication via GraphQL...') // Добавляем таймаут для запроса (5 секунд для лучшего UX) - const timeoutPromise = new Promise((_, reject) => + const timeoutPromise = new Promise((_, reject) => setTimeout(() => reject(new Error('Auth check timeout')), 5000) ) @@ -159,10 +159,10 @@ export const AuthProvider: Component = (props) => { const logout = async () => { console.log('[AuthProvider] Attempting logout...') - + // Предотвращаем повторные инициализации во время logout isInitializing = true - + try { // Сначала очищаем токены на клиенте clearAuthTokens() diff --git a/panel/routes/env.tsx b/panel/routes/env.tsx index f02dae98..1e569f91 100644 --- a/panel/routes/env.tsx +++ b/panel/routes/env.tsx @@ -1,6 +1,6 @@ import { Component, createSignal, For, Show } from 'solid-js' import { query } from '../graphql' -import type { EnvSection, EnvVariable, Query } from '../graphql/generated/schema' +import type { EnvSection, EnvVariable, Query } from '../graphql/generated/graphql' import { ADMIN_UPDATE_ENV_VARIABLE_MUTATION } from '../graphql/mutations' import { ADMIN_GET_ENV_VARIABLES_QUERY } from '../graphql/queries' import EnvVariableModal from '../modals/EnvVariableModal' diff --git a/resolvers/follower.py b/resolvers/follower.py index 5a37a5e9..8228bbcf 100644 --- a/resolvers/follower.py +++ b/resolvers/follower.py @@ -64,11 +64,7 @@ def get_entity_field_name(entity_type: str) -> str: @mutation.field("follow") @login_required async def follow( - _: None, - info: GraphQLResolveInfo, - what: str, - slug: str = "", - entity_id: int | None = None + _: None, info: GraphQLResolveInfo, what: str, slug: str = "", entity_id: int | None = None ) -> dict[str, Any]: """ GraphQL мутация для создания подписки на автора, тему, сообщество или публикацию. @@ -270,11 +266,7 @@ async def follow( @mutation.field("unfollow") @login_required async def unfollow( - _: None, - info: GraphQLResolveInfo, - what: str, - slug: str = "", - entity_id: int | None = None + _: None, info: GraphQLResolveInfo, what: str, slug: str = "", entity_id: int | None = None ) -> dict[str, Any]: """ GraphQL мутация для отмены подписки на автора, тему, сообщество или публикацию. From 163c0732d4bc8793fe9cf2a4252d60dc9625f5a9 Mon Sep 17 00:00:00 2001 From: Untone Date: Sat, 4 Oct 2025 08:36:24 +0300 Subject: [PATCH 12/16] notifications-fixes --- orm/notification.py | 17 +++++++++++++++++ resolvers/notifier.py | 9 +++++++-- 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/orm/notification.py b/orm/notification.py index df6cdbbf..a64a4710 100644 --- a/orm/notification.py +++ b/orm/notification.py @@ -99,6 +99,23 @@ class NotificationSeen(Base): ) +class NotificationUnsubscribe(Base): + """Модель для хранения отписок пользователей от уведомлений по определенным thread_id.""" + + __tablename__ = "notification_unsubscribe" + + author_id: Mapped[int] = mapped_column(ForeignKey("author.id"), nullable=False) + thread_id: Mapped[str] = mapped_column(String, nullable=False) + created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) + + __table_args__ = ( + PrimaryKeyConstraint(author_id, thread_id), + Index("idx_notification_unsubscribe_author", "author_id"), + Index("idx_notification_unsubscribe_thread", "thread_id"), + {"extend_existing": True}, + ) + + class Notification(Base): __tablename__ = "notification" diff --git a/resolvers/notifier.py b/resolvers/notifier.py index 131454b2..093893cf 100644 --- a/resolvers/notifier.py +++ b/resolvers/notifier.py @@ -105,7 +105,6 @@ def get_notifications_grouped(author_id: int, after: int = 0, limit: int = 10, o authors: List[NotificationAuthor], # List of authors involved in the thread. } """ - # TODO: use all stats _total, _unread, notifications = query_notifications(author_id, after) groups_by_thread = {} groups_amount = 0 @@ -121,6 +120,7 @@ def get_notifications_grouped(author_id: int, after: int = 0, limit: int = 10, o shout_id = shout.get("id") author_id = shout.get("created_by") thread_id = f"shout-{shout_id}" + with local_session() as session: author = session.query(Author).where(Author.id == author_id).first() shout = session.query(Shout).where(Shout.id == shout_id).first() @@ -154,7 +154,8 @@ def get_notifications_grouped(author_id: int, after: int = 0, limit: int = 10, o reply_id = reaction.get("reply_to") thread_id = f"shout-{shout_id}" if reply_id and reaction.get("kind", "").lower() == "comment": - thread_id += f"{reply_id}" + thread_id = f"shout-{shout_id}::{reply_id}" + existing_group = groups_by_thread.get(thread_id) if existing_group: existing_group["seen"] = False @@ -214,6 +215,10 @@ async def load_notifications(_: None, info: GraphQLResolveInfo, after: int, limi if author_id: groups_list = get_notifications_grouped(author_id, after, limit) notifications = sorted(groups_list, key=lambda group: group.get("updated_at", 0), reverse=True) + + # Считаем реальное количество сгруппированных уведомлений + total = len(notifications) + unread = sum(1 for n in notifications if not n.get("seen", False)) except Exception as e: error = str(e) logger.error(e) From 13343bb40ebc9b843f7d0cbc3ceba43f0d798fa7 Mon Sep 17 00:00:00 2001 From: Untone Date: Sat, 4 Oct 2025 08:59:47 +0300 Subject: [PATCH 13/16] fix: handle follower and shout notifications in notifications_seen_thread - Add support for marking follower notifications as seen (thread='followers') - Add support for marking new shout notifications as seen - Use enum constants (NotificationAction, NotificationEntity) instead of strings - Improve thread ID parsing to support different formats - Remove obsolete TODO about notification_id offset - Better error handling with logger.warning() instead of exceptions Resolves TODOs on lines 253 and 286 in resolvers/notifier.py --- CHANGELOG.md | 18 +++++++++++ resolvers/follower.py | 6 ++-- resolvers/notifier.py | 74 +++++++++++++++++++++++++++++++++++-------- 3 files changed, 81 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4f0de7b0..8d9ca4bb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,23 @@ # Changelog +## [0.9.31] - 2025-10-04 + +### ✅ Fixed: Notifications TODOs +- **Уведомления о followers**: Добавлена обработка уведомлений о подписчиках в `notifications_seen_thread` + - Теперь при клике на группу "followers" все уведомления о подписках помечаются как прочитанные + - Исправлена обработка thread ID `"followers"` отдельно от shout/reaction threads +- **Уведомления о новых публикациях**: Добавлена обработка уведомлений о новых shouts в `notifications_seen_thread` + - При открытии публикации уведомления о ней тоже помечаются как прочитанные + - Исправлена логика парсинга thread ID для поддержки разных форматов +- **Code Quality**: Использованы enum константы (`NotificationAction`, `NotificationEntity`) вместо строк +- **Убраны устаревшие TODO**: Удален TODO про `notification_id` как offset (текущая логика с timestamp работает корректно) + +### Technical Details +- `core/resolvers/notifier.py`: расширена функция `notifications_seen_thread` для поддержки всех типов уведомлений +- Добавлена обработка `thread == "followers"` для уведомлений о подписках +- Добавлена обработка `NotificationEntity.SHOUT` для уведомлений о новых публикациях +- Улучшена обработка ошибок с `logger.warning()` вместо исключений + ## [0.9.30] - 2025-10-02 ### 🔧 Fixed diff --git a/resolvers/follower.py b/resolvers/follower.py index 8228bbcf..9cafb693 100644 --- a/resolvers/follower.py +++ b/resolvers/follower.py @@ -50,10 +50,10 @@ def get_entity_field_name(entity_type: str) -> str: ValueError: Unknown entity_type: invalid """ entity_field_mapping = { - "author": "following", # AuthorFollower.following -> Author - "topic": "topic", # TopicFollower.topic -> Topic + "author": "following", # AuthorFollower.following -> Author + "topic": "topic", # TopicFollower.topic -> Topic "community": "community", # CommunityFollower.community -> Community - "shout": "shout" # ShoutReactionsFollower.shout -> Shout + "shout": "shout", # ShoutReactionsFollower.shout -> Shout } if entity_type not in entity_field_mapping: msg = f"Unknown entity_type: {entity_type}" diff --git a/resolvers/notifier.py b/resolvers/notifier.py index 093893cf..63dc9c7d 100644 --- a/resolvers/notifier.py +++ b/resolvers/notifier.py @@ -120,7 +120,7 @@ def get_notifications_grouped(author_id: int, after: int = 0, limit: int = 10, o shout_id = shout.get("id") author_id = shout.get("created_by") thread_id = f"shout-{shout_id}" - + with local_session() as session: author = session.query(Author).where(Author.id == author_id).first() shout = session.query(Shout).where(Shout.id == shout_id).first() @@ -155,7 +155,7 @@ def get_notifications_grouped(author_id: int, after: int = 0, limit: int = 10, o thread_id = f"shout-{shout_id}" if reply_id and reaction.get("kind", "").lower() == "comment": thread_id = f"shout-{shout_id}::{reply_id}" - + existing_group = groups_by_thread.get(thread_id) if existing_group: existing_group["seen"] = False @@ -215,7 +215,7 @@ async def load_notifications(_: None, info: GraphQLResolveInfo, after: int, limi if author_id: groups_list = get_notifications_grouped(author_id, after, limit) notifications = sorted(groups_list, key=lambda group: group.get("updated_at", 0), reverse=True) - + # Считаем реальное количество сгруппированных уведомлений total = len(notifications) unread = sum(1 for n in notifications if not n.get("seen", False)) @@ -250,7 +250,7 @@ async def notification_mark_seen(_: None, info: GraphQLResolveInfo, notification @mutation.field("notifications_seen_after") @login_required async def notifications_seen_after(_: None, info: GraphQLResolveInfo, after: int) -> dict: - # TODO: use latest loaded notification_id as input offset parameter + """Mark all notifications after given timestamp as seen.""" error = None try: author_id = info.context.get("author", {}).get("id") @@ -278,18 +278,64 @@ async def notifications_seen_thread(_: None, info: GraphQLResolveInfo, thread: s error = None author_id = info.context.get("author", {}).get("id") if author_id: - [shout_id, reply_to_id] = thread.split(":") with local_session() as session: # Convert Unix timestamp to datetime for PostgreSQL compatibility after_datetime = datetime.fromtimestamp(after, tz=UTC) if after else None - # TODO: handle new follower and new shout notifications + # Handle different thread types: shout reactions, followers, or new shouts + if thread == "followers": + # Mark follower notifications as seen + query_conditions = [ + Notification.entity == NotificationEntity.FOLLOWER.value, + ] + if after_datetime: + query_conditions.append(Notification.created_at > after_datetime) + + follower_notifications = session.query(Notification).where(and_(*query_conditions)).all() + for n in follower_notifications: + try: + ns = NotificationSeen(notification=n.id, viewer=author_id) + session.add(ns) + except Exception as e: + logger.warning(f"Failed to mark follower notification as seen: {e}") + session.commit() + return {"error": None} + + # Handle shout and reaction notifications + thread_parts = thread.split(":") + if len(thread_parts) < 2: + return {"error": "Invalid thread format"} + + shout_id = thread_parts[0] + reply_to_id = thread_parts[1] if len(thread_parts) > 1 else None + + # Query for new shout notifications in this thread + shout_query_conditions = [ + Notification.entity == NotificationEntity.SHOUT.value, + Notification.action == NotificationAction.CREATE.value, + ] + if after_datetime: + shout_query_conditions.append(Notification.created_at > after_datetime) + + shout_notifications = session.query(Notification).where(and_(*shout_query_conditions)).all() + + # Mark relevant shout notifications as seen + for n in shout_notifications: + payload = orjson.loads(str(n.payload)) + if str(payload.get("id")) == shout_id: + try: + ns = NotificationSeen(notification=n.id, viewer=author_id) + session.add(ns) + except Exception as e: + logger.warning(f"Failed to mark shout notification as seen: {e}") + + # Query for reaction notifications if after_datetime: new_reaction_notifications = ( session.query(Notification) .where( - Notification.action == "create", - Notification.entity == "reaction", + Notification.action == NotificationAction.CREATE.value, + Notification.entity == NotificationEntity.REACTION.value, Notification.created_at > after_datetime, ) .all() @@ -297,8 +343,8 @@ async def notifications_seen_thread(_: None, info: GraphQLResolveInfo, thread: s removed_reaction_notifications = ( session.query(Notification) .where( - Notification.action == "delete", - Notification.entity == "reaction", + Notification.action == NotificationAction.DELETE.value, + Notification.entity == NotificationEntity.REACTION.value, Notification.created_at > after_datetime, ) .all() @@ -307,16 +353,16 @@ async def notifications_seen_thread(_: None, info: GraphQLResolveInfo, thread: s new_reaction_notifications = ( session.query(Notification) .where( - Notification.action == "create", - Notification.entity == "reaction", + Notification.action == NotificationAction.CREATE.value, + Notification.entity == NotificationEntity.REACTION.value, ) .all() ) removed_reaction_notifications = ( session.query(Notification) .where( - Notification.action == "delete", - Notification.entity == "reaction", + Notification.action == NotificationAction.DELETE.value, + Notification.entity == NotificationEntity.REACTION.value, ) .all() ) From 86dec156733c346fff76167a410646af0e4d0b89 Mon Sep 17 00:00:00 2001 From: Untone Date: Sun, 5 Oct 2025 17:12:28 +0300 Subject: [PATCH 14/16] 0.9.32] - 2025-10-05 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ### ✨ Features - **Редактирование мигрированных шаутов**: Добавлена мутация `create_draft_from_shout` для создания черновика из существующего опубликованного шаута - Создаёт черновик со всеми данными из шаута (title, body, lead, topics, authors, media, etc.) - Проверяет авторство перед созданием черновика - Переиспользует существующий черновик если он уже создан для этого шаута - Копирует все связи: авторов и темы (включая main topic) ### 🔧 Fixed - **NotificationEntity enum**: Исправлена ошибка `NotificationEntity.FOLLOWER` → `NotificationEntity.AUTHOR` - В enum не было значения `FOLLOWER`, используется `AUTHOR` для уведомлений о подписчиках ### Technical Details - `core/schema/mutation.graphql`: добавлена мутация `create_draft_from_shout(shout_id: Int!): CommonResult!` - `core/resolvers/draft.py`: добавлен resolver `create_draft_from_shout` с валидацией авторства - `core/resolvers/notifier.py`: исправлено использование `NotificationEntity.AUTHOR` вместо несуществующего `FOLLOWER` --- CHANGELOG.md | 18 +++++++ pyproject.toml | 2 +- resolvers/draft.py | 102 ++++++++++++++++++++++++++++++++++++++++ resolvers/notifier.py | 2 +- schema/mutation.graphql | 1 + 5 files changed, 123 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8d9ca4bb..be167947 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,23 @@ # Changelog +## [0.9.32] - 2025-10-05 + +### ✨ Features +- **Редактирование мигрированных шаутов**: Добавлена мутация `create_draft_from_shout` для создания черновика из существующего опубликованного шаута + - Создаёт черновик со всеми данными из шаута (title, body, lead, topics, authors, media, etc.) + - Проверяет авторство перед созданием черновика + - Переиспользует существующий черновик если он уже создан для этого шаута + - Копирует все связи: авторов и темы (включая main topic) + +### 🔧 Fixed +- **NotificationEntity enum**: Исправлена ошибка `NotificationEntity.FOLLOWER` → `NotificationEntity.AUTHOR` + - В enum не было значения `FOLLOWER`, используется `AUTHOR` для уведомлений о подписчиках + +### Technical Details +- `core/schema/mutation.graphql`: добавлена мутация `create_draft_from_shout(shout_id: Int!): CommonResult!` +- `core/resolvers/draft.py`: добавлен resolver `create_draft_from_shout` с валидацией авторства +- `core/resolvers/notifier.py`: исправлено использование `NotificationEntity.AUTHOR` вместо несуществующего `FOLLOWER` + ## [0.9.31] - 2025-10-04 ### ✅ Fixed: Notifications TODOs diff --git a/pyproject.toml b/pyproject.toml index 3fb842b8..01997655 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "discours-core" -version = "0.9.28" +version = "0.9.32" description = "Core backend for Discours.io platform" authors = [ {name = "Tony Rewin", email = "tonyrewin@yandex.ru"} diff --git a/resolvers/draft.py b/resolvers/draft.py index 524906d4..da808ee0 100644 --- a/resolvers/draft.py +++ b/resolvers/draft.py @@ -274,6 +274,108 @@ async def create_draft(_: None, info: GraphQLResolveInfo, draft_input: dict[str, return {"error": f"Failed to create draft: {e!s}"} +@mutation.field("create_draft_from_shout") +@login_required +async def create_draft_from_shout(_: None, info: GraphQLResolveInfo, shout_id: int) -> dict[str, Any]: + """ + Создаёт черновик из существующего опубликованного шаута для редактирования. + + Args: + info: GraphQL context + shout_id (int): ID публикации (shout) + + Returns: + dict: Contains either: + - draft: The created draft object with shout reference + - error: Error message if creation failed + + Example: + >>> async def test_create_from_shout(): + ... context = {'user_id': '123', 'author': {'id': 1}} + ... info = type('Info', (), {'context': context})() + ... result = await create_draft_from_shout(None, info, 42) + ... assert result.get('error') is None + ... assert result['draft'].shout == 42 + ... return result + """ + author_dict = info.context.get("author") or {} + author_id = author_dict.get("id") + + if not author_id or not isinstance(author_id, int): + return {"error": "Author ID is required"} + + try: + with local_session() as session: + # Загружаем шаут с авторами и темами + shout = ( + session.query(Shout) + .options(joinedload(Shout.authors), joinedload(Shout.topics)) + .where(Shout.id == shout_id) + .first() + ) + + if not shout: + return {"error": f"Shout with id={shout_id} not found"} + + # Проверяем, что пользователь является автором шаута + author_ids = [a.id for a in shout.authors] + if author_id not in author_ids: + return {"error": "You are not authorized to edit this shout"} + + # Проверяем, нет ли уже черновика для этого шаута + existing_draft = session.query(Draft).where(Draft.shout == shout_id).first() + if existing_draft: + logger.info(f"Draft already exists for shout {shout_id}: draft_id={existing_draft.id}") + return {"draft": create_draft_dict(existing_draft)} + + # Создаём новый черновик из шаута + now = int(time.time()) + draft = Draft( + created_at=now, + created_by=author_id, + community=shout.community, + layout=shout.layout or "article", + title=shout.title or "", + subtitle=shout.subtitle, + body=shout.body or "", + lead=shout.lead, + slug=shout.slug, + cover=shout.cover, + cover_caption=shout.cover_caption, + seo=shout.seo, + media=shout.media, + lang=shout.lang or "ru", + shout=shout_id, # Связываем с существующим шаутом + ) + + session.add(draft) + session.flush() + + # Копируем авторов из шаута + for author in shout.authors: + da = DraftAuthor(draft=draft.id, author=author.id) + session.add(da) + + # Копируем темы из шаута + shout_topics = session.query(ShoutTopic).where(ShoutTopic.shout == shout_id).all() + for st in shout_topics: + dt = DraftTopic(draft=draft.id, topic=st.topic, main=st.main) + session.add(dt) + + session.commit() + + logger.info(f"Created draft {draft.id} from shout {shout_id}") + + # Формируем результат + draft_dict = create_draft_dict(draft) + + return {"draft": draft_dict} + + except Exception as e: + logger.error(f"Failed to create draft from shout {shout_id}: {e}", exc_info=True) + return {"error": f"Failed to create draft from shout: {e!s}"} + + def generate_teaser(body: str, limit: int = 300) -> str: body_text = extract_text(body) return ". ".join(body_text[:limit].split(". ")[:-1]) diff --git a/resolvers/notifier.py b/resolvers/notifier.py index 63dc9c7d..ad67e01e 100644 --- a/resolvers/notifier.py +++ b/resolvers/notifier.py @@ -286,7 +286,7 @@ async def notifications_seen_thread(_: None, info: GraphQLResolveInfo, thread: s if thread == "followers": # Mark follower notifications as seen query_conditions = [ - Notification.entity == NotificationEntity.FOLLOWER.value, + Notification.entity == NotificationEntity.AUTHOR.value, ] if after_datetime: query_conditions.append(Notification.created_at > after_datetime) diff --git a/schema/mutation.graphql b/schema/mutation.graphql index 31ee060e..8ce76cc6 100644 --- a/schema/mutation.graphql +++ b/schema/mutation.graphql @@ -24,6 +24,7 @@ type Mutation { # draft create_draft(draft_input: DraftInput!): CommonResult! + create_draft_from_shout(shout_id: Int!): CommonResult! update_draft(draft_id: Int!, draft_input: DraftInput!): CommonResult! delete_draft(draft_id: Int!): CommonResult! # publication From 33fbd4051f21d0ce9ebe1f2e1cc0cf8c6f4ac44e Mon Sep 17 00:00:00 2001 From: Untone Date: Sun, 5 Oct 2025 22:53:30 +0300 Subject: [PATCH 15/16] shout-following-upgrade --- .gitignore | 4 +++- resolvers/author.py | 15 ++++++++++++-- resolvers/notifier.py | 46 ++++++++++++++++++++++++++++++++++++++++--- schema/type.graphql | 1 + uv.lock | 2 +- 5 files changed, 61 insertions(+), 7 deletions(-) diff --git a/.gitignore b/.gitignore index ca647527..91500ad2 100644 --- a/.gitignore +++ b/.gitignore @@ -182,4 +182,6 @@ docs/progress/* panel/graphql/generated -test_e2e.db* \ No newline at end of file +test_e2e.db* + +uv.lock \ No newline at end of file diff --git a/resolvers/author.py b/resolvers/author.py index 062abf56..a03f3c47 100644 --- a/resolvers/author.py +++ b/resolvers/author.py @@ -18,7 +18,7 @@ from cache.cache import ( from orm.author import Author, AuthorFollower from orm.community import Community, CommunityAuthor, CommunityFollower from orm.reaction import Reaction -from orm.shout import Shout, ShoutAuthor, ShoutTopic +from orm.shout import Shout, ShoutAuthor, ShoutReactionsFollower, ShoutTopic from orm.topic import Topic from resolvers.stat import get_with_stat from services.auth import login_required @@ -974,12 +974,23 @@ async def get_author_follows( has_access = is_admin or (viewer_id is not None and str(viewer_id) == str(temp_author.id)) followed_authors.append(temp_author.dict(has_access)) + # Получаем подписанные шауты + followed_shouts = [] + with local_session() as session: + shout_followers = ( + session.query(ShoutReactionsFollower).filter(ShoutReactionsFollower.follower == author_id).all() + ) + for sf in shout_followers: + shout = session.query(Shout).filter(Shout.id == sf.shout).first() + if shout: + followed_shouts.append(shout.dict()) + followed_communities = DEFAULT_COMMUNITIES # TODO: get followed communities return { "authors": followed_authors, "topics": followed_topics, "communities": followed_communities, - "shouts": [], + "shouts": followed_shouts, "error": None, } diff --git a/resolvers/notifier.py b/resolvers/notifier.py index ad67e01e..e8d85fbf 100644 --- a/resolvers/notifier.py +++ b/resolvers/notifier.py @@ -16,7 +16,7 @@ from orm.notification import ( NotificationEntity, NotificationSeen, ) -from orm.shout import Shout +from orm.shout import Shout, ShoutReactionsFollower from services.auth import login_required from storage.db import local_session from storage.schema import mutation, query @@ -57,6 +57,37 @@ def query_notifications(author_id: int, after: int = 0) -> tuple[int, int, list[ return total, unread, notifications +def check_subscription(shout_id: int, current_author_id: int) -> bool: + """ + Проверяет подписку пользователя на уведомления о шауте. + + Проверяет наличие записи в ShoutReactionsFollower: + - Запись есть → подписан + - Записи нет → не подписан (отписался или никогда не подписывался) + + Автоматическая подписка (auto=True) создается при: + - Создании поста + - Первом комментарии/реакции + + Отписка = удаление записи из таблицы + + Returns: + bool: True если подписан на уведомления + """ + with local_session() as session: + # Проверяем наличие записи в ShoutReactionsFollower + follow = ( + session.query(ShoutReactionsFollower) + .filter( + ShoutReactionsFollower.follower == current_author_id, + ShoutReactionsFollower.shout == shout_id, + ) + .first() + ) + + return follow is not None + + def group_notification( thread: str, authors: list[Any] | None = None, @@ -118,15 +149,20 @@ def get_notifications_grouped(author_id: int, after: int = 0, limit: int = 10, o if str(notification.entity) == NotificationEntity.SHOUT.value: shout = payload shout_id = shout.get("id") - author_id = shout.get("created_by") + shout_author_id = shout.get("created_by") thread_id = f"shout-{shout_id}" with local_session() as session: - author = session.query(Author).where(Author.id == author_id).first() + author = session.query(Author).where(Author.id == shout_author_id).first() shout = session.query(Shout).where(Shout.id == shout_id).first() if author and shout: + # Проверяем подписку - если не подписан, пропускаем это уведомление + if not check_subscription(shout_id, author_id): + continue + author_dict = author.dict() shout_dict = shout.dict() + group = group_notification( thread_id, shout=shout_dict, @@ -164,6 +200,10 @@ def get_notifications_grouped(author_id: int, after: int = 0, limit: int = 10, o existing_group["reactions"].append(reaction) groups_by_thread[thread_id] = existing_group else: + # Проверяем подписку - если не подписан, пропускаем это уведомление + if not check_subscription(shout_id, author_id): + continue + group = group_notification( thread_id, authors=[author_dict], diff --git a/schema/type.graphql b/schema/type.graphql index 8097a0b9..165e5fd5 100644 --- a/schema/type.graphql +++ b/schema/type.graphql @@ -245,6 +245,7 @@ type AuthorFollowsResult { topics: [Topic] authors: [Author] communities: [Community] + shouts: [Shout] error: String } diff --git a/uv.lock b/uv.lock index d9621001..d93db8ff 100644 --- a/uv.lock +++ b/uv.lock @@ -425,7 +425,7 @@ wheels = [ [[package]] name = "discours-core" -version = "0.9.28" +version = "0.9.32" source = { editable = "." } dependencies = [ { name = "ariadne" }, From b611ed541c9648cd1f0f0cd9fa4f8f27c74b79bc Mon Sep 17 00:00:00 2001 From: Untone Date: Mon, 6 Oct 2025 19:40:57 +0300 Subject: [PATCH 16/16] nogt-test --- utils/sentry.py | 7 ------- 1 file changed, 7 deletions(-) diff --git a/utils/sentry.py b/utils/sentry.py index 6825003e..6944d227 100644 --- a/utils/sentry.py +++ b/utils/sentry.py @@ -64,12 +64,5 @@ def start_sentry() -> None: ) logger.info("[utils.sentry] Sentry initialized successfully.") - # 🧪 Отправляем тестовое событие для проверки работы GlitchTip - try: - sentry_sdk.capture_message("🧪 GlitchTip test message - система инициализирована", level="info") - logger.info("[utils.sentry] Тестовое сообщение отправлено в GlitchTip") - except Exception as test_e: - logger.warning(f"[utils.sentry] Не удалось отправить тестовое сообщение: {test_e}") - except (sentry_sdk.utils.BadDsn, ImportError, ValueError, TypeError) as _e: logger.warning("[utils.sentry] Failed to initialize Sentry", exc_info=True)