create_draft fix

This commit is contained in:
Untone 2025-05-21 18:29:46 +03:00
parent ebf9dfcf62
commit 5874d3ccae
8 changed files with 272 additions and 106 deletions

View File

@ -8,16 +8,16 @@
- Управление пользователями (блокировка, изменение ролей, отключение звука) - Управление пользователями (блокировка, изменение ролей, отключение звука)
- Пагинация и поиск пользователей по email, имени и ID - Пагинация и поиск пользователей по email, имени и ID
- Расширение GraphQL схемы для админки: - Расширение GraphQL схемы для админки:
- Типы AdminUserInfo, AdminUserUpdateInput, AuthResult, Permission, SessionInfo - Типы `AdminUserInfo`, `AdminUserUpdateInput`, `AuthResult`, `Permission`, `SessionInfo`
- Мутации для управления пользователями и авторизации - Мутации для управления пользователями и авторизации
- Улучшения серверной части: - Улучшения серверной части:
- Поддержка HTTPS через Granian с помощью mkcert - Поддержка HTTPS через `Granian` с помощью `mkcert`
- Параметры запуска `--https`, `--workers`, `--domain` - Параметры запуска `--https`, `--workers`, `--domain`
- Система авторизации и аутентификации: - Система авторизации и аутентификации:
- Локальная система аутентификации с сессиями в Redis - Локальная система аутентификации с сессиями в `Redis`
- Система ролей и разрешений (RBAC) - Система ролей и разрешений (RBAC)
- Защита от брутфорс атак - Защита от брутфорс атак
- Поддержка httpOnly cookies для токенов - Поддержка `httpOnly` cookies для токенов
- Мультиязычные email уведомления - Мультиязычные email уведомления
### Изменено ### Изменено
@ -44,6 +44,7 @@
- "Cannot return null for non-nullable field Mutation.login" - "Cannot return null for non-nullable field Mutation.login"
- "Author password is empty" при авторизации - "Author password is empty" при авторизации
- "Author object has no attribute username" - "Author object has no attribute username"
- Метод dict() класса Author теперь корректно сериализует роли как список словарей
- Обработка ошибок: - Обработка ошибок:
- Улучшена валидация email и username - Улучшена валидация email и username
- Исправлена обработка истекших токенов - Исправлена обработка истекших токенов
@ -258,7 +259,7 @@
#### [0.4.4] #### [0.4.4]
- `followers_stat` removed for shout - `followers_stat` removed for shout
- sqlite3 support added - sqlite3 support added
- `rating_stat` and `comments_count` fixes - `rating_stat` and `commented_stat` fixes
#### [0.4.3] #### [0.4.3]
- cache reimplemented - cache reimplemented
@ -414,4 +415,22 @@
#### [0.2.7] #### [0.2.7]
- `loadFollowedReactions` now with ` - `loadFollowedReactions` now with `login_required`
- notifier service api draft
- added `shout` visibility kind in schema
- community isolated from author in orm
#### [0.2.6]
- redis connection pool
- auth context fixes
- communities orm, resolvers, schema
#### [0.2.5]
- restructured
- all users have their profiles as authors in core
- `gittask`, `inbox` and `auth` logics removed
- `settings` moved to base and now smaller
- new outside auth schema
- removed `gittask`, `auth`, `inbox`, `migration`

9
cache/cache.py vendored
View File

@ -384,11 +384,8 @@ async def invalidate_shouts_cache(cache_keys: List[str]):
""" """
Инвалидирует кэш выборок публикаций по переданным ключам. Инвалидирует кэш выборок публикаций по переданным ключам.
""" """
for key in cache_keys: for cache_key in cache_keys:
try: try:
# Формируем полный ключ кэша
cache_key = f"shouts:{key}"
# Удаляем основной кэш # Удаляем основной кэш
await redis.execute("DEL", cache_key) await redis.execute("DEL", cache_key)
logger.debug(f"Invalidated cache key: {cache_key}") logger.debug(f"Invalidated cache key: {cache_key}")
@ -397,8 +394,8 @@ async def invalidate_shouts_cache(cache_keys: List[str]):
await redis.execute("SETEX", f"{cache_key}:invalidated", CACHE_TTL, "1") await redis.execute("SETEX", f"{cache_key}:invalidated", CACHE_TTL, "1")
# Если это кэш темы, инвалидируем также связанные ключи # Если это кэш темы, инвалидируем также связанные ключи
if key.startswith("topic_"): if cache_key.startswith("topic_"):
topic_id = key.split("_")[1] topic_id = cache_key.split("_")[1]
related_keys = [ related_keys = [
f"topic:id:{topic_id}", f"topic:id:{topic_id}",
f"topic:authors:{topic_id}", f"topic:authors:{topic_id}",

View File

@ -249,16 +249,129 @@ async def get_topics_with_stats(limit=10, offset=0, by="title"):
### Точечная инвалидация кеша при изменении данных ### Точечная инвалидация кеша при изменении данных
```python ```python
async def update_topic(topic_id, new_data): async def update_author(author_id, data):
# Обновление данных в базе # Обновление данных в базе
# ... # ...
# Точечная инвалидация кеша только для измененной темы # Инвалидация только кеша этого автора
await invalidate_topics_cache(topic_id) await invalidate_authors_cache(author_id)
return updated_topic return result
``` ```
## Ключи кеширования
Ниже приведен полный список форматов ключей, используемых в системе кеширования Discours.
### Ключи для публикаций (Shout)
| Формат ключа | Описание | Пример |
|--------------|----------|--------|
| `shouts:{id}` | Публикация по ID | `shouts:123` |
| `shouts:{id}:invalidated` | Флаг инвалидации публикации | `shouts:123:invalidated` |
| `shouts:feed:limit={n}:offset={m}` | Основная лента публикаций | `shouts:feed:limit=20:offset=0` |
| `shouts:recent:limit={n}` | Последние публикации | `shouts:recent:limit=10` |
| `shouts:random_top:limit={n}` | Случайные топовые публикации | `shouts:random_top:limit=5` |
| `shouts:unrated:limit={n}` | Неоцененные публикации | `shouts:unrated:limit=20` |
| `shouts:coauthored:limit={n}` | Совместные публикации | `shouts:coauthored:limit=10` |
### Ключи для авторов (Author)
| Формат ключа | Описание | Пример |
|--------------|----------|--------|
| `author:id:{id}` | Автор по ID | `author:id:123` |
| `author:slug:{slug}` | Автор по слагу | `author:slug:john-doe` |
| `author:user_id:{user_id}` | Автор по ID пользователя | `author:user_id:abc123` |
| `author:{id}` | Публикации автора | `author:123` |
| `authored:{id}` | Публикации, созданные автором | `authored:123` |
| `authors:all:basic` | Базовый список всех авторов | `authors:all:basic` |
| `authors:stats:limit={n}:offset={m}:sort={field}` | Список авторов с пагинацией и сортировкой | `authors:stats:limit=20:offset=0:sort=name` |
| `author:followers:{id}` | Подписчики автора | `author:followers:123` |
| `author:following:{id}` | Авторы, на которых подписан автор | `author:following:123` |
### Ключи для тем (Topic)
| Формат ключа | Описание | Пример |
|--------------|----------|--------|
| `topic:id:{id}` | Тема по ID | `topic:id:123` |
| `topic:slug:{slug}` | Тема по слагу | `topic:slug:technology` |
| `topic:{id}` | Публикации по теме | `topic:123` |
| `topic_shouts_{id}` | Публикации по теме (старый формат) | `topic_shouts_123` |
| `topics:all:basic` | Базовый список всех тем | `topics:all:basic` |
| `topics:stats:limit={n}:offset={m}:sort={field}` | Список тем с пагинацией и сортировкой | `topics:stats:limit=20:offset=0:sort=name` |
| `topic:authors:{id}` | Авторы темы | `topic:authors:123` |
| `topic:followers:{id}` | Подписчики темы | `topic:followers:123` |
| `topic:stats:{id}` | Статистика темы | `topic:stats:123` |
### Ключи для реакций (Reaction)
| Формат ключа | Описание | Пример |
|--------------|----------|--------|
| `reactions:shout:{id}:limit={n}:offset={m}` | Реакции на публикацию | `reactions:shout:123:limit=20:offset=0` |
| `reactions:comment:{id}:limit={n}:offset={m}` | Реакции на комментарий | `reactions:comment:456:limit=20:offset=0` |
| `reactions:author:{id}:limit={n}:offset={m}` | Реакции автора | `reactions:author:123:limit=20:offset=0` |
| `reactions:followed:author:{id}:limit={n}` | Реакции авторов, на которых подписан пользователь | `reactions:followed:author:123:limit=20` |
### Ключи для сообществ (Community)
| Формат ключа | Описание | Пример |
|--------------|----------|--------|
| `community:id:{id}` | Сообщество по ID | `community:id:123` |
| `community:slug:{slug}` | Сообщество по слагу | `community:slug:tech-club` |
| `communities:all:basic` | Базовый список всех сообществ | `communities:all:basic` |
| `community:authors:{id}` | Авторы сообщества | `community:authors:123` |
| `community:shouts:{id}:limit={n}:offset={m}` | Публикации сообщества | `community:shouts:123:limit=20:offset=0` |
### Ключи для подписок (Follow)
| Формат ключа | Описание | Пример |
|--------------|----------|--------|
| `follow:author:{follower_id}:authors` | Авторы, на которых подписан пользователь | `follow:author:123:authors` |
| `follow:author:{follower_id}:topics` | Темы, на которые подписан пользователь | `follow:author:123:topics` |
| `follow:topic:{topic_id}:authors` | Авторы, подписанные на тему | `follow:topic:456:authors` |
| `follow:author:{author_id}:followers` | Подписчики автора | `follow:author:123:followers` |
### Ключи для черновиков (Draft)
| Формат ключа | Описание | Пример |
|--------------|----------|--------|
| `draft:id:{id}` | Черновик по ID | `draft:id:123` |
| `drafts:author:{id}` | Черновики автора | `drafts:author:123` |
| `drafts:all:limit={n}:offset={m}` | Список всех черновиков с пагинацией | `drafts:all:limit=20:offset=0` |
### Ключи для статистики
| Формат ключа | Описание | Пример |
|--------------|----------|--------|
| `stats:shout:{id}` | Статистика публикации | `stats:shout:123` |
| `stats:author:{id}` | Статистика автора | `stats:author:123` |
| `stats:topic:{id}` | Статистика темы | `stats:topic:123` |
| `stats:community:{id}` | Статистика сообщества | `stats:community:123` |
### Ключи для поиска
| Формат ключа | Описание | Пример |
|--------------|----------|--------|
| `search:query:{query}:limit={n}:offset={m}` | Результаты поиска | `search:query:технологии:limit=20:offset=0` |
| `search:author:{query}:limit={n}` | Результаты поиска авторов | `search:author:иван:limit=10` |
| `search:topic:{query}:limit={n}` | Результаты поиска тем | `search:topic:наука:limit=10` |
### Служебные ключи
| Формат ключа | Описание | Пример |
|--------------|----------|--------|
| `revalidation:{entity_type}:{entity_id}` | Метка для ревалидации | `revalidation:author:123` |
| `revalidation:batch:{entity_type}` | Батчевая ревалидация | `revalidation:batch:shouts` |
| `lock:{resource}` | Блокировка ресурса | `lock:precache` |
| `views:shout:{id}` | Счетчик просмотров публикации | `views:shout:123` |
### Важные замечания по использованию ключей
1. При инвалидации кеша публикаций через `invalidate_shouts_cache()` необходимо передавать список ID публикаций, а не ключи кеша.
2. Функция `invalidate_shout_related_cache()` автоматически инвалидирует все связанные ключи для публикации, включая ключи авторов и тем.
3. Для большинства операций с кешем следует использовать асинхронные функции с префиксом `await`.
4. При создании новых ключей кеша следует придерживаться существующих конвенций именования.
## Отладка и мониторинг ## Отладка и мониторинг
Система кеширования использует логгер для отслеживания операций: Система кеширования использует логгер для отслеживания операций:

View File

@ -32,17 +32,51 @@ from auth.internal import verify_internal_auth
@mutation.field("getSession") @mutation.field("getSession")
@login_required @login_required
async def get_current_user(_, info): async def get_current_user(_, info):
"""get current user""" """
auth: AuthCredentials = info.context["request"].auth Получает информацию о текущем пользователе.
token = info.context["request"].headers.get(SESSION_TOKEN_HEADER)
Требует авторизации через декоратор login_required.
with local_session() as session:
author = session.query(Author).where(Author.id == auth.author_id).one() Args:
author.last_seen = int(time.time()) _: Родительский объект (не используется)
session.commit() info: Контекст GraphQL запроса
# Здесь можно не применять фильтрацию, так как пользователь получает свои данные Returns:
return {"token": token, "author": author} dict: Объект с токеном и данными автора
"""
# Получаем данные авторизации из контекста запроса
user_id = info.context.get("user_id")
if not user_id:
logger.error("[getSession] Пользователь не авторизован")
from graphql.error import GraphQLError
raise GraphQLError("Требуется авторизация")
# Получаем токен из заголовка
req = info.context.get("request")
token = req.headers.get(SESSION_TOKEN_HEADER)
if token and token.startswith("Bearer "):
token = token.split("Bearer ")[-1].strip()
# Получаем данные автора
author = info.context.get("author")
# Если автор не найден в контексте, пробуем получить из БД
if not author:
logger.debug(f"[getSession] Автор не найден в контексте для пользователя {user_id}, получаем из БД")
with local_session() as session:
try:
db_author = session.query(Author).filter(Author.id == user_id).one()
db_author.last_seen = int(time.time())
session.commit()
author = db_author
except Exception as e:
logger.error(f"[getSession] Ошибка при получении автора из БД: {e}")
from graphql.error import GraphQLError
raise GraphQLError("Ошибка при получении данных пользователя")
# Возвращаем данные сессии
logger.info(f"[getSession] Успешно получена сессия для пользователя {user_id}")
return {"token": token or '', "author": author}
@mutation.field("confirmEmail") @mutation.field("confirmEmail")
@ -63,7 +97,7 @@ async def confirm_email(_, info, token):
user = session.query(Author).where(Author.id == user_id).first() user = session.query(Author).where(Author.id == user_id).first()
if not user: if not user:
logger.warning(f"[auth] confirmEmail: Пользователь с ID {user_id} не найден.") logger.warning(f"[auth] confirmEmail: Пользователь с ID {user_id} не найден.")
return {"success": False, "error": "Пользователь не найден"} return {"success": False, "token": None, "author": None, "error": "Пользователь не найден"}
# Создаем сессионный токен с новым форматом вызова и явным временем истечения # Создаем сессионный токен с новым форматом вызова и явным временем истечения
device_info = {"email": user.email} if hasattr(user, "email") else None device_info = {"email": user.email} if hasattr(user, "email") else None

View File

@ -403,7 +403,11 @@ async def get_author_follows(_, info, slug="", user=None, author_id=0):
if hasattr(temp_author, key): if hasattr(temp_author, key):
setattr(temp_author, key, value) setattr(temp_author, key, value)
# Добавляем отфильтрованную версию # Добавляем отфильтрованную версию
followed_authors.append(temp_author.dict(current_user_id, is_admin)) # temp_author - это объект Author, который мы хотим сериализовать
# current_user_id - ID текущего авторизованного пользователя (может быть None)
# is_admin - булево значение, является ли текущий пользователь админом
has_access = is_admin or (current_user_id is not None and str(current_user_id) == str(temp_author.id))
followed_authors.append(temp_author.dict(access=has_access))
# TODO: Get followed communities too # TODO: Get followed communities too
return { return {
@ -447,7 +451,11 @@ async def get_author_follows_authors(_, info, slug="", user=None, author_id=None
if hasattr(temp_author, key): if hasattr(temp_author, key):
setattr(temp_author, key, value) setattr(temp_author, key, value)
# Добавляем отфильтрованную версию # Добавляем отфильтрованную версию
followed_authors.append(temp_author.dict(current_user_id, is_admin)) # temp_author - это объект Author, который мы хотим сериализовать
# current_user_id - ID текущего авторизованного пользователя (может быть None)
# is_admin - булево значение, является ли текущий пользователь админом
has_access = is_admin or (current_user_id is not None and str(current_user_id) == str(temp_author.id))
followed_authors.append(temp_author.dict(access=has_access))
return followed_authors return followed_authors
@ -488,6 +496,10 @@ async def get_author_followers(_, info, slug: str = "", user: str = "", author_i
if hasattr(temp_author, key): if hasattr(temp_author, key):
setattr(temp_author, key, value) setattr(temp_author, key, value)
# Добавляем отфильтрованную версию # Добавляем отфильтрованную версию
followers.append(temp_author.dict(current_user_id, is_admin)) # temp_author - это объект Author, который мы хотим сериализовать
# current_user_id - ID текущего авторизованного пользователя (может быть None)
# is_admin - булево значение, является ли текущий пользователь админом
has_access = is_admin or (current_user_id is not None and str(current_user_id) == str(temp_author.id))
followers.append(temp_author.dict(access=has_access))
return followers return followers

View File

@ -461,8 +461,9 @@ async def publish_draft(_, info, draft_id: int):
session.commit() session.commit()
# Инвалидируем кеш # Инвалидируем кеш
invalidate_shouts_cache() cache_keys = [f"shouts:{shout.id}", ]
invalidate_shout_related_cache(shout.id) await invalidate_shouts_cache(cache_keys)
await invalidate_shout_related_cache(shout, author_id)
# Уведомляем о публикации # Уведомляем о публикации
await notify_shout(shout.id) await notify_shout(shout.id)

View File

@ -1,6 +1,8 @@
from functools import wraps from functools import wraps
from typing import Tuple from typing import Tuple
from starlette.requests import Request
from cache.cache import get_cached_author_by_user_id from cache.cache import get_cached_author_by_user_id
from resolvers.stat import get_with_stat from resolvers.stat import get_with_stat
from utils.logger import root_logger as logger from utils.logger import root_logger as logger
@ -8,12 +10,13 @@ from auth.internal import verify_internal_auth
from sqlalchemy import exc from sqlalchemy import exc
from services.db import local_session from services.db import local_session
from auth.orm import Author, Role from auth.orm import Author, Role
from settings import SESSION_TOKEN_HEADER
# Список разрешенных заголовков # Список разрешенных заголовков
ALLOWED_HEADERS = ["Authorization", "Content-Type"] ALLOWED_HEADERS = ["Authorization", "Content-Type"]
async def check_auth(req) -> Tuple[str, list[str], bool]: async def check_auth(req: Request) -> Tuple[str, list[str], bool]:
""" """
Проверка авторизации пользователя. Проверка авторизации пользователя.
@ -27,50 +30,54 @@ async def check_auth(req) -> Tuple[str, list[str], bool]:
- user_roles: list[str] - Список ролей пользователя - user_roles: list[str] - Список ролей пользователя
- is_admin: bool - Флаг наличия у пользователя административных прав - is_admin: bool - Флаг наличия у пользователя административных прав
""" """
# Проверяем наличие токена logger.debug(f"[check_auth] Проверка авторизации...")
token = req.headers.get("Authorization")
# Получаем заголовок авторизации
token = None
# Проверяем заголовок с учетом регистра
headers_dict = dict(req.headers.items())
logger.debug(f"[check_auth] Все заголовки: {headers_dict}")
# Ищем заголовок Authorization независимо от регистра
for header_name, header_value in headers_dict.items():
if header_name.lower() == SESSION_TOKEN_HEADER.lower():
token = header_value
logger.debug(f"[check_auth] Найден заголовок {header_name}: {token[:10]}...")
break
if not token: if not token:
logger.debug(f"[check_auth] Токен не найден в заголовках")
return "", [], False return "", [], False
# Очищаем токен от префикса Bearer если он есть # Очищаем токен от префикса Bearer если он есть
if token.startswith("Bearer "): if token.startswith("Bearer "):
token = token.split("Bearer ")[-1].strip() token = token.split("Bearer ")[-1].strip()
logger.debug(f"Checking auth token: {token[:10]}...")
# Проверяем авторизацию внутренним механизмом # Проверяем авторизацию внутренним механизмом
logger.debug("Using internal authentication") logger.debug("[check_auth] Вызов verify_internal_auth...")
user_id, user_roles = await verify_internal_auth(token) user_id, user_roles, is_admin = await verify_internal_auth(token)
logger.debug(f"[check_auth] Результат verify_internal_auth: user_id={user_id}, roles={user_roles}, is_admin={is_admin}")
# Проверяем наличие административных прав у пользователя # Если в ролях нет админа, но есть ID - проверяем в БД
is_admin = False if user_id and not is_admin:
if user_id: try:
# Быстрая проверка на админ роли в кэше with local_session() as session:
admin_roles = ['admin', 'super'] # Преобразуем user_id в число
for role in user_roles: try:
if role in admin_roles: user_id_int = int(user_id.strip())
is_admin = True except (ValueError, TypeError):
break logger.error(f"Невозможно преобразовать user_id {user_id} в число")
else:
# Если в ролях нет админа, но есть ID - проверяем в БД # Проверяем наличие админских прав через БД
if not is_admin: from auth.orm import AuthorRole
try: admin_role = session.query(AuthorRole).filter(
with local_session() as session: AuthorRole.author == user_id_int,
# Преобразуем user_id в число AuthorRole.role.in_(["admin", "super"])
try: ).first()
user_id_int = int(user_id.strip()) is_admin = admin_role is not None
except (ValueError, TypeError): except Exception as e:
logger.error(f"Невозможно преобразовать user_id {user_id} в число") logger.error(f"Ошибка при проверке прав администратора: {e}")
else:
# Проверяем наличие админских прав через БД
from auth.orm import AuthorRole
admin_role = session.query(AuthorRole).filter(
AuthorRole.author == user_id_int,
AuthorRole.role.in_(["admin", "super"])
).first()
is_admin = admin_role is not None
except Exception as e:
logger.error(f"Ошибка при проверке прав администратора: {e}")
return user_id, user_roles, is_admin return user_id, user_roles, is_admin
@ -124,13 +131,18 @@ def login_required(f):
info = args[1] info = args[1]
req = info.context.get("request") req = info.context.get("request")
logger.debug(f"[login_required] Проверка авторизации для запроса: {req.method} {req.url.path}")
logger.debug(f"[login_required] Заголовки: {req.headers}")
user_id, user_roles, is_admin = await check_auth(req) user_id, user_roles, is_admin = await check_auth(req)
if not user_id: if not user_id:
logger.debug(f"[login_required] Пользователь не авторизован, {dict(req)}, {info}")
raise GraphQLError("Требуется авторизация") raise GraphQLError("Требуется авторизация")
# Проверяем наличие роли reader # Проверяем наличие роли reader
if 'reader' not in user_roles and not is_admin: if 'reader' not in user_roles:
logger.error(f"Пользователь {user_id} не имеет роли 'reader'") logger.error(f"Пользователь {user_id} не имеет роли 'reader'")
raise GraphQLError("У вас нет необходимых прав для доступа") raise GraphQLError("У вас нет необходимых прав для доступа")
@ -192,38 +204,3 @@ def login_accepted(f):
return await f(*args, **kwargs) return await f(*args, **kwargs)
return decorated_function return decorated_function
def author_required(f):
"""Декоратор для проверки наличия роли 'author' у пользователя."""
@wraps(f)
async def decorated_function(*args, **kwargs):
from graphql.error import GraphQLError
info = args[1]
req = info.context.get("request")
user_id, user_roles, is_admin = await check_auth(req)
if not user_id:
raise GraphQLError("Требуется авторизация")
# Проверяем наличие роли author
if 'author' not in user_roles and not is_admin:
logger.error(f"Пользователь {user_id} не имеет роли 'author'")
raise GraphQLError("Для выполнения этого действия необходимы права автора")
logger.info(f"Авторизован автор {user_id} с ролями: {user_roles}")
info.context["user_id"] = user_id.strip()
info.context["roles"] = user_roles
# Проверяем права администратора
info.context["is_admin"] = is_admin
author = await get_cached_author_by_user_id(user_id, get_with_stat)
if not author:
logger.error(f"Профиль автора не найден для пользователя {user_id}")
info.context["author"] = author
return await f(*args, **kwargs)
return decorated_function

View File

@ -2,10 +2,12 @@ import asyncio
import json import json
import logging import logging
import os import os
from typing import List
import orjson import orjson
from opensearchpy import OpenSearch from opensearchpy import OpenSearch
from orm.shout import Shout
from services.redis import redis from services.redis import redis
from utils.encoders import CustomJSONEncoder from utils.encoders import CustomJSONEncoder
@ -156,7 +158,18 @@ class SearchService:
else: else:
logger.error("клиент не инициализован, невозможно проверить индекс") logger.error("клиент не инициализован, невозможно проверить индекс")
def index(self, shout): def index_shouts(self, shouts: List[Shout]):
if not SEARCH_ENABLED:
return
if self.client:
for shout in shouts:
self.index(shout)
def index(self, shout: Shout):
return self.index_shout(shout)
def index_shout(self, shout: Shout):
if not SEARCH_ENABLED: if not SEARCH_ENABLED:
return return