2024-08-07 13:25:48 +03:00
|
|
|
|
import asyncio
|
2024-01-25 22:41:27 +03:00
|
|
|
|
import os
|
2022-11-24 18:19:43 +01:00
|
|
|
|
import time
|
2025-08-17 16:33:54 +03:00
|
|
|
|
from datetime import UTC, datetime, timedelta
|
2025-06-02 02:56:11 +03:00
|
|
|
|
from pathlib import Path
|
2025-08-17 16:33:54 +03:00
|
|
|
|
from typing import ClassVar
|
2025-03-20 11:55:21 +03:00
|
|
|
|
|
2024-08-07 13:37:08 +03:00
|
|
|
|
# ga
|
2024-01-28 16:26:40 +03:00
|
|
|
|
from google.analytics.data_v1beta import BetaAnalyticsDataClient
|
2025-02-11 12:00:35 +03:00
|
|
|
|
from google.analytics.data_v1beta.types import (
|
|
|
|
|
|
DateRange,
|
|
|
|
|
|
Dimension,
|
|
|
|
|
|
Metric,
|
|
|
|
|
|
RunReportRequest,
|
|
|
|
|
|
)
|
2024-12-12 02:03:19 +03:00
|
|
|
|
from google.analytics.data_v1beta.types import Filter as GAFilter
|
2022-11-22 01:23:16 +03:00
|
|
|
|
|
[0.9.7] - 2025-08-18
### 🔄 Изменения
- **SQLAlchemy KeyError** - исправление ошибки `KeyError: Reaction` при инициализации
- **Исправлена ошибка SQLAlchemy**: Устранена проблема `InvalidRequestError: When initializing mapper Mapper[Shout(shout)], expression Reaction failed to locate a name (Reaction)`
### 🧪 Тестирование
- **Исправление тестов** - адаптация к новой структуре моделей
- **RBAC инициализация** - добавление `rbac.initialize_rbac()` в `conftest.py`
- **Создан тест для getSession**: Добавлен комплексный тест `test_getSession_cookies.py` с проверкой всех сценариев
- **Покрытие edge cases**: Тесты проверяют работу с валидными/невалидными токенами, отсутствующими пользователями
- **Мокирование зависимостей**: Использование unittest.mock для изоляции тестируемого кода
### 🔧 Рефакторинг
- **Упрощена архитектура**: Убраны сложные конструкции с отложенными импортами, заменены на чистую архитектуру
- **Перемещение моделей** - `Author` и связанные модели перенесены в `orm/author.py`: Вынесены базовые модели пользователей (`Author`, `AuthorFollower`, `AuthorBookmark`, `AuthorRating`) из `orm.author` в отдельный модуль
- **Устранены циклические импорты**: Разорван цикл между `auth.core` → `orm.community` → `orm.author` через реструктуризацию архитектуры
- **Создан модуль `utils/password.py`**: Класс `Password` вынесен в utils для избежания циклических зависимостей
- **Оптимизированы импорты моделей**: Убран прямой импорт `Shout` из `orm/community.py`, заменен на строковые ссылки
### 🔧 Авторизация с cookies
- **getSession теперь работает с cookies**: Мутация `getSession` теперь может получать токен из httpOnly cookies даже без заголовка Authorization
- **Убрано требование авторизации**: `getSession` больше не требует декоратор `@login_required`, работает автономно
- **Поддержка dual-авторизации**: Токен может быть получен как из заголовка Authorization, так и из cookie `session_token`
- **Автоматическая установка cookies**: Middleware автоматически устанавливает httpOnly cookies при успешном `getSession`
- **Обновлена GraphQL схема**: `SessionInfo` теперь содержит поля `success`, `error` и опциональные `token`, `author`
- **Единообразная обработка токенов**: Все модули теперь используют централизованные функции для работы с токенами
- **Улучшена обработка ошибок**: Добавлена детальная валидация токенов и пользователей в `getSession`
- **Логирование операций**: Добавлены подробные логи для отслеживания процесса авторизации
### 📝 Документация
- **Обновлена схема GraphQL**: `SessionInfo` тип теперь соответствует новому формату ответа
- Обновлена документация RBAC
- Обновлена документация авторизации с cookies
2025-08-18 14:25:25 +03:00
|
|
|
|
from orm.author import Author
|
2024-01-23 16:04:38 +03:00
|
|
|
|
from orm.shout import Shout, ShoutAuthor, ShoutTopic
|
|
|
|
|
|
from orm.topic import Topic
|
2025-08-17 17:56:31 +03:00
|
|
|
|
from storage.db import local_session
|
|
|
|
|
|
from storage.redis import redis
|
2024-08-07 13:37:50 +03:00
|
|
|
|
from utils.logger import root_logger as logger
|
2024-01-23 16:04:38 +03:00
|
|
|
|
|
2024-08-07 13:37:08 +03:00
|
|
|
|
GOOGLE_KEYFILE_PATH = os.environ.get("GOOGLE_KEYFILE_PATH", "/dump/google-service.json")
|
|
|
|
|
|
GOOGLE_PROPERTY_ID = os.environ.get("GOOGLE_PROPERTY_ID", "")
|
2024-01-28 15:54:38 +03:00
|
|
|
|
|
2024-01-13 15:44:56 +03:00
|
|
|
|
|
2022-11-18 20:54:37 +03:00
|
|
|
|
class ViewedStorage:
|
2025-04-14 19:53:14 +03:00
|
|
|
|
"""
|
|
|
|
|
|
Класс для хранения и доступа к данным о просмотрах.
|
|
|
|
|
|
Использует Redis в качестве основного хранилища и Google Analytics для сбора новых данных.
|
|
|
|
|
|
"""
|
2025-04-15 20:16:01 +03:00
|
|
|
|
|
2024-08-07 13:37:08 +03:00
|
|
|
|
lock = asyncio.Lock()
|
2025-06-02 02:56:11 +03:00
|
|
|
|
views_by_shout: ClassVar[dict] = {}
|
|
|
|
|
|
shouts_by_topic: ClassVar[dict] = {}
|
|
|
|
|
|
shouts_by_author: ClassVar[dict] = {}
|
2024-08-07 13:37:08 +03:00
|
|
|
|
views = None
|
2024-01-23 16:04:38 +03:00
|
|
|
|
period = 60 * 60 # каждый час
|
2025-08-17 16:33:54 +03:00
|
|
|
|
analytics_client: BetaAnalyticsDataClient | None = None
|
2024-08-07 13:37:08 +03:00
|
|
|
|
auth_result = None
|
2024-11-02 04:44:07 +03:00
|
|
|
|
running = False
|
2025-04-14 19:53:14 +03:00
|
|
|
|
redis_views_key = None
|
|
|
|
|
|
last_update_timestamp = 0
|
2025-08-17 16:33:54 +03:00
|
|
|
|
start_date = datetime.now(tz=UTC).strftime("%Y-%m-%d")
|
|
|
|
|
|
_background_task: asyncio.Task | None = None
|
2022-11-18 20:54:37 +03:00
|
|
|
|
|
2022-11-20 10:48:40 +03:00
|
|
|
|
@staticmethod
|
2025-06-02 02:56:11 +03:00
|
|
|
|
async def init() -> None:
|
2025-04-14 19:53:14 +03:00
|
|
|
|
"""Подключение к клиенту Google Analytics и загрузка данных о просмотрах из Redis"""
|
2022-11-22 10:29:54 +03:00
|
|
|
|
self = ViewedStorage
|
2024-08-07 13:37:08 +03:00
|
|
|
|
async with self.lock:
|
2025-04-14 19:53:14 +03:00
|
|
|
|
# Загрузка предварительно подсчитанных просмотров из Redis
|
|
|
|
|
|
await self.load_views_from_redis()
|
2024-08-07 13:37:08 +03:00
|
|
|
|
|
|
|
|
|
|
os.environ.setdefault("GOOGLE_APPLICATION_CREDENTIALS", GOOGLE_KEYFILE_PATH)
|
2025-06-02 02:56:11 +03:00
|
|
|
|
if GOOGLE_KEYFILE_PATH and Path(GOOGLE_KEYFILE_PATH).is_file():
|
2024-08-07 13:37:08 +03:00
|
|
|
|
# Using a default constructor instructs the client to use the credentials
|
|
|
|
|
|
# specified in GOOGLE_APPLICATION_CREDENTIALS environment variable.
|
|
|
|
|
|
self.analytics_client = BetaAnalyticsDataClient()
|
2024-10-23 11:29:44 +03:00
|
|
|
|
logger.info(" * Google Analytics credentials accepted")
|
2024-08-07 13:37:08 +03:00
|
|
|
|
|
|
|
|
|
|
# Запуск фоновой задачи
|
2025-06-02 02:56:11 +03:00
|
|
|
|
task = asyncio.create_task(self.worker())
|
|
|
|
|
|
# Store reference to prevent garbage collection
|
|
|
|
|
|
self._background_task = task
|
2024-08-07 13:37:08 +03:00
|
|
|
|
else:
|
2024-10-15 11:12:09 +03:00
|
|
|
|
logger.warning(" * please, add Google Analytics credentials file")
|
2024-11-02 04:44:07 +03:00
|
|
|
|
self.running = False
|
2022-11-20 10:48:40 +03:00
|
|
|
|
|
2024-01-22 19:17:39 +03:00
|
|
|
|
@staticmethod
|
2025-06-02 02:56:11 +03:00
|
|
|
|
async def load_views_from_redis() -> None:
|
2025-04-14 19:53:14 +03:00
|
|
|
|
"""Загрузка предварительно подсчитанных просмотров из Redis"""
|
2024-01-22 19:17:39 +03:00
|
|
|
|
self = ViewedStorage
|
2025-04-15 20:16:01 +03:00
|
|
|
|
|
2025-04-14 19:53:14 +03:00
|
|
|
|
# Подключаемся к Redis если соединение не установлено
|
2025-08-01 11:09:01 +03:00
|
|
|
|
try:
|
|
|
|
|
|
if not await redis.ping():
|
|
|
|
|
|
await redis.connect()
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.warning(f"Redis connection check failed: {e}")
|
|
|
|
|
|
# Try to connect anyway
|
2025-04-14 19:53:14 +03:00
|
|
|
|
await redis.connect()
|
2025-04-15 20:16:01 +03:00
|
|
|
|
|
2025-05-25 23:21:53 +03:00
|
|
|
|
# Логируем настройки Redis соединения
|
2025-05-26 13:31:25 +03:00
|
|
|
|
logger.info("* Redis connected")
|
2025-05-25 23:21:53 +03:00
|
|
|
|
|
2025-04-14 19:53:14 +03:00
|
|
|
|
# Получаем список всех ключей migrated_views_* и находим самый последний
|
|
|
|
|
|
keys = await redis.execute("KEYS", "migrated_views_*")
|
2025-08-01 11:09:01 +03:00
|
|
|
|
if keys is None:
|
|
|
|
|
|
keys = []
|
|
|
|
|
|
logger.warning("Redis KEYS command returned None, treating as empty list")
|
2025-06-02 02:56:11 +03:00
|
|
|
|
logger.info("Raw Redis result for 'KEYS migrated_views_*': %d", len(keys))
|
2025-05-29 12:37:39 +03:00
|
|
|
|
|
2025-05-25 23:21:53 +03:00
|
|
|
|
# Декодируем байтовые строки, если есть
|
|
|
|
|
|
if keys and isinstance(keys[0], bytes):
|
2025-05-29 12:37:39 +03:00
|
|
|
|
keys = [k.decode("utf-8") for k in keys]
|
2025-06-02 02:56:11 +03:00
|
|
|
|
logger.info("Decoded keys: %s", keys)
|
2025-05-29 12:37:39 +03:00
|
|
|
|
|
2025-04-14 19:53:14 +03:00
|
|
|
|
if not keys:
|
2025-08-27 16:37:34 +03:00
|
|
|
|
logger.info(" * No migrated_views keys found in Redis - views will be 0")
|
2025-04-14 19:53:14 +03:00
|
|
|
|
return
|
2025-04-15 20:16:01 +03:00
|
|
|
|
|
2025-04-14 19:53:14 +03:00
|
|
|
|
# Фильтруем только ключи timestamp формата (исключаем migrated_views_slugs)
|
|
|
|
|
|
timestamp_keys = [k for k in keys if k != "migrated_views_slugs"]
|
2025-06-02 02:56:11 +03:00
|
|
|
|
logger.info("Timestamp keys after filtering: %s", timestamp_keys)
|
2025-05-29 12:37:39 +03:00
|
|
|
|
|
2025-04-14 19:53:14 +03:00
|
|
|
|
if not timestamp_keys:
|
2025-08-27 16:37:34 +03:00
|
|
|
|
logger.info(" * No migrated_views timestamp keys found in Redis - views will be 0")
|
2025-04-14 19:53:14 +03:00
|
|
|
|
return
|
2025-04-15 20:16:01 +03:00
|
|
|
|
|
2025-04-14 19:53:14 +03:00
|
|
|
|
# Сортируем по времени создания (в названии ключа) и берем последний
|
|
|
|
|
|
timestamp_keys.sort()
|
|
|
|
|
|
latest_key = timestamp_keys[-1]
|
|
|
|
|
|
self.redis_views_key = latest_key
|
2025-06-02 02:56:11 +03:00
|
|
|
|
logger.info("Selected latest key: %s", latest_key)
|
2025-04-15 20:16:01 +03:00
|
|
|
|
|
2025-04-14 19:53:14 +03:00
|
|
|
|
# Получаем метку времени создания для установки start_date
|
|
|
|
|
|
timestamp = await redis.execute("HGET", latest_key, "_timestamp")
|
|
|
|
|
|
if timestamp:
|
|
|
|
|
|
self.last_update_timestamp = int(timestamp)
|
2025-08-17 16:33:54 +03:00
|
|
|
|
timestamp_dt = datetime.fromtimestamp(int(timestamp), tz=UTC)
|
2025-04-14 19:53:14 +03:00
|
|
|
|
self.start_date = timestamp_dt.strftime("%Y-%m-%d")
|
2025-04-15 20:16:01 +03:00
|
|
|
|
|
2025-04-14 19:53:14 +03:00
|
|
|
|
# Если данные сегодняшние, считаем их актуальными
|
2025-08-17 16:33:54 +03:00
|
|
|
|
now_date = datetime.now(tz=UTC).strftime("%Y-%m-%d")
|
2024-10-15 11:12:09 +03:00
|
|
|
|
if now_date == self.start_date:
|
2025-04-14 19:53:14 +03:00
|
|
|
|
logger.info(" * Views data is up to date!")
|
2024-03-12 15:57:46 +03:00
|
|
|
|
else:
|
2025-06-02 02:56:11 +03:00
|
|
|
|
logger.warning("Views data is from %s, may need update", self.start_date)
|
2025-04-15 20:16:01 +03:00
|
|
|
|
|
2025-08-27 16:37:34 +03:00
|
|
|
|
# 🔎 ЗАГРУЖАЕМ ДАННЫЕ из Redis в views_by_shout
|
|
|
|
|
|
logger.info("🔍 Loading views data from Redis key: %s", latest_key)
|
|
|
|
|
|
|
|
|
|
|
|
# Получаем все данные из hash
|
|
|
|
|
|
views_data = await redis.execute("HGETALL", latest_key)
|
|
|
|
|
|
|
|
|
|
|
|
if views_data and len(views_data) > 0:
|
|
|
|
|
|
# Преобразуем список [key1, value1, key2, value2] в словарь
|
|
|
|
|
|
views_dict = {}
|
|
|
|
|
|
try:
|
|
|
|
|
|
# Проверяем что views_data это словарь или список
|
|
|
|
|
|
if isinstance(views_data, dict):
|
|
|
|
|
|
# Если это уже словарь
|
|
|
|
|
|
for key, value in views_data.items():
|
|
|
|
|
|
key_str = key.decode("utf-8") if isinstance(key, bytes) else str(key)
|
|
|
|
|
|
value_str = value.decode("utf-8") if isinstance(value, bytes) else str(value)
|
|
|
|
|
|
|
|
|
|
|
|
if not key_str.startswith("_"):
|
|
|
|
|
|
try:
|
|
|
|
|
|
views_dict[key_str] = int(value_str)
|
|
|
|
|
|
except (ValueError, TypeError):
|
|
|
|
|
|
logger.warning(f"🔍 Invalid views value for {key_str}: {value_str}")
|
|
|
|
|
|
|
|
|
|
|
|
elif isinstance(views_data, list | tuple):
|
|
|
|
|
|
# Если это список [key1, value1, key2, value2]
|
|
|
|
|
|
for i in range(0, len(views_data), 2):
|
|
|
|
|
|
if i + 1 < len(views_data):
|
|
|
|
|
|
key = (
|
|
|
|
|
|
views_data[i].decode("utf-8")
|
|
|
|
|
|
if isinstance(views_data[i], bytes)
|
|
|
|
|
|
else str(views_data[i])
|
|
|
|
|
|
)
|
|
|
|
|
|
value = (
|
|
|
|
|
|
views_data[i + 1].decode("utf-8")
|
|
|
|
|
|
if isinstance(views_data[i + 1], bytes)
|
|
|
|
|
|
else str(views_data[i + 1])
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
# Пропускаем служебные ключи
|
|
|
|
|
|
if not key.startswith("_"):
|
|
|
|
|
|
try:
|
|
|
|
|
|
views_dict[key] = int(value)
|
|
|
|
|
|
except (ValueError, TypeError):
|
|
|
|
|
|
logger.warning(f"🔍 Invalid views value for {key}: {value}")
|
|
|
|
|
|
else:
|
|
|
|
|
|
logger.warning(f"🔍 Unexpected Redis data format: {type(views_data)}")
|
|
|
|
|
|
|
|
|
|
|
|
# Загружаем данные в класс
|
|
|
|
|
|
self.views_by_shout.update(views_dict)
|
|
|
|
|
|
logger.info("🔍 Loaded %d shouts with views from Redis", len(views_dict))
|
|
|
|
|
|
|
|
|
|
|
|
# Показываем образцы загруженных данных только если есть данные
|
|
|
|
|
|
if views_dict:
|
|
|
|
|
|
sample_items = list(views_dict.items())[:3]
|
|
|
|
|
|
logger.info("🔍 Sample loaded data: %s", sample_items)
|
|
|
|
|
|
else:
|
|
|
|
|
|
logger.debug("🔍 No valid views data found in Redis hash - views will be 0")
|
|
|
|
|
|
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.warning(f"🔍 Error parsing Redis views data: {e} - views will be 0")
|
|
|
|
|
|
else:
|
|
|
|
|
|
logger.debug("🔍 Redis hash is empty for key: %s - views will be 0", latest_key)
|
|
|
|
|
|
|
2025-04-14 19:53:14 +03:00
|
|
|
|
# Выводим информацию о количестве загруженных записей
|
|
|
|
|
|
total_entries = await redis.execute("HGET", latest_key, "_total")
|
|
|
|
|
|
if total_entries:
|
2025-06-02 02:56:11 +03:00
|
|
|
|
logger.info("%s shouts with views loaded from Redis key: %s", total_entries, latest_key)
|
2024-01-22 19:17:39 +03:00
|
|
|
|
|
2025-06-02 02:56:11 +03:00
|
|
|
|
logger.info("Found migrated_views keys: %s", keys)
|
2025-05-25 23:21:53 +03:00
|
|
|
|
|
2024-08-07 13:37:08 +03:00
|
|
|
|
# noinspection PyTypeChecker
|
2022-11-22 01:23:16 +03:00
|
|
|
|
@staticmethod
|
2025-06-02 02:56:11 +03:00
|
|
|
|
async def update_pages() -> None:
|
2025-04-14 19:53:14 +03:00
|
|
|
|
"""Запрос всех страниц от Google Analytics, отсортированных по количеству просмотров"""
|
2022-11-22 01:23:16 +03:00
|
|
|
|
self = ViewedStorage
|
2024-10-15 11:12:09 +03:00
|
|
|
|
logger.info(" ⎧ views update from Google Analytics ---")
|
2024-11-02 04:44:07 +03:00
|
|
|
|
if self.running:
|
2024-01-23 16:04:38 +03:00
|
|
|
|
try:
|
|
|
|
|
|
start = time.time()
|
2024-08-07 13:37:08 +03:00
|
|
|
|
async with self.lock:
|
|
|
|
|
|
if self.analytics_client:
|
|
|
|
|
|
request = RunReportRequest(
|
|
|
|
|
|
property=f"properties/{GOOGLE_PROPERTY_ID}",
|
|
|
|
|
|
dimensions=[Dimension(name="pagePath")],
|
|
|
|
|
|
metrics=[Metric(name="screenPageViews")],
|
|
|
|
|
|
date_ranges=[DateRange(start_date=self.start_date, end_date="today")],
|
|
|
|
|
|
)
|
|
|
|
|
|
response = self.analytics_client.run_report(request)
|
|
|
|
|
|
if response and isinstance(response.rows, list):
|
|
|
|
|
|
slugs = set()
|
|
|
|
|
|
for row in response.rows:
|
|
|
|
|
|
print(
|
|
|
|
|
|
row.dimension_values[0].value,
|
|
|
|
|
|
row.metric_values[0].value,
|
|
|
|
|
|
)
|
|
|
|
|
|
# Извлечение путей страниц из ответа Google Analytics
|
|
|
|
|
|
if isinstance(row.dimension_values, list):
|
|
|
|
|
|
page_path = row.dimension_values[0].value
|
|
|
|
|
|
slug = page_path.split("discours.io/")[-1]
|
2024-12-12 02:03:19 +03:00
|
|
|
|
fresh_views = int(row.metric_values[0].value)
|
2024-08-07 13:37:08 +03:00
|
|
|
|
|
|
|
|
|
|
# Обновление данных в хранилище
|
|
|
|
|
|
self.views_by_shout[slug] = self.views_by_shout.get(slug, 0)
|
2024-12-12 02:03:19 +03:00
|
|
|
|
self.views_by_shout[slug] += fresh_views
|
2024-08-07 13:37:08 +03:00
|
|
|
|
self.update_topics(slug)
|
|
|
|
|
|
|
|
|
|
|
|
# Запись путей страниц для логирования
|
|
|
|
|
|
slugs.add(slug)
|
|
|
|
|
|
|
2025-06-02 02:56:11 +03:00
|
|
|
|
logger.info("collected pages: %d", len(slugs))
|
2024-08-07 13:37:08 +03:00
|
|
|
|
|
|
|
|
|
|
end = time.time()
|
2025-06-02 02:56:11 +03:00
|
|
|
|
logger.info("views update time: %.2fs", end - start)
|
|
|
|
|
|
except (ConnectionError, TimeoutError, ValueError) as error:
|
2024-01-28 12:03:41 +03:00
|
|
|
|
logger.error(error)
|
2024-11-02 04:44:07 +03:00
|
|
|
|
self.running = False
|
2022-11-18 20:54:37 +03:00
|
|
|
|
|
2022-11-19 14:35:34 +03:00
|
|
|
|
@staticmethod
|
2025-08-27 12:15:01 +03:00
|
|
|
|
def get_shout(shout_slug: str = "", shout_id: int = 0) -> int:
|
2025-04-14 19:53:14 +03:00
|
|
|
|
"""
|
2025-08-27 12:15:01 +03:00
|
|
|
|
🔎 Синхронное получение метрики просмотров shout по slug или id из кеша.
|
|
|
|
|
|
|
|
|
|
|
|
Использует кешированные данные из views_by_shout (in-memory кеш).
|
|
|
|
|
|
Для обновления данных используется асинхронный фоновый процесс.
|
2025-04-15 20:16:01 +03:00
|
|
|
|
|
2025-04-14 19:53:14 +03:00
|
|
|
|
Args:
|
|
|
|
|
|
shout_slug: Slug публикации
|
|
|
|
|
|
shout_id: ID публикации
|
2025-04-15 20:16:01 +03:00
|
|
|
|
|
2025-04-14 19:53:14 +03:00
|
|
|
|
Returns:
|
2025-08-27 12:15:01 +03:00
|
|
|
|
int: Количество просмотров из кеша
|
2025-04-14 19:53:14 +03:00
|
|
|
|
"""
|
2022-11-19 14:35:34 +03:00
|
|
|
|
self = ViewedStorage
|
2025-04-15 20:16:01 +03:00
|
|
|
|
|
2025-08-27 16:37:34 +03:00
|
|
|
|
# 🔍 DEBUG: Логируем только если кеш пустой и это первый запрос
|
|
|
|
|
|
cache_size = len(self.views_by_shout)
|
|
|
|
|
|
if cache_size == 0 and shout_slug:
|
|
|
|
|
|
logger.debug(f"🔍 ViewedStorage cache is empty for slug '{shout_slug}'")
|
|
|
|
|
|
|
2025-08-27 12:15:01 +03:00
|
|
|
|
# 🔎 Используем только in-memory кеш для быстрого доступа
|
|
|
|
|
|
if shout_slug:
|
2025-08-27 16:37:34 +03:00
|
|
|
|
views = self.views_by_shout.get(shout_slug, 0)
|
|
|
|
|
|
if views > 0:
|
|
|
|
|
|
logger.debug(f"🔍 Found {views} views for slug '{shout_slug}'")
|
|
|
|
|
|
return views
|
2025-04-15 20:16:01 +03:00
|
|
|
|
|
2025-08-27 15:22:18 +03:00
|
|
|
|
# 🔎 Для ID ищем slug в БД и затем получаем views_count
|
2025-08-27 12:15:01 +03:00
|
|
|
|
if shout_id:
|
2025-08-27 15:22:18 +03:00
|
|
|
|
try:
|
|
|
|
|
|
with local_session() as session:
|
|
|
|
|
|
from orm.shout import Shout
|
2025-08-27 16:37:34 +03:00
|
|
|
|
|
2025-08-27 15:22:18 +03:00
|
|
|
|
shout = session.query(Shout).where(Shout.id == shout_id).first()
|
|
|
|
|
|
if shout and shout.slug:
|
2025-08-27 16:37:34 +03:00
|
|
|
|
views = self.views_by_shout.get(shout.slug, 0)
|
|
|
|
|
|
logger.debug(f"🔍 Found slug '{shout.slug}' for id {shout_id}, views: {views}")
|
|
|
|
|
|
return views
|
|
|
|
|
|
logger.debug(f"🔍 No shout found with id {shout_id} or missing slug")
|
2025-08-27 15:22:18 +03:00
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.warning(f"Failed to get shout slug for id {shout_id}: {e}")
|
2025-08-27 12:15:01 +03:00
|
|
|
|
return 0
|
2025-04-15 20:16:01 +03:00
|
|
|
|
|
2025-08-27 16:37:34 +03:00
|
|
|
|
logger.debug("🔍 get_shout called without slug or id")
|
2025-08-27 12:15:01 +03:00
|
|
|
|
return 0
|
2023-11-03 13:10:22 +03:00
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
2025-06-02 02:56:11 +03:00
|
|
|
|
async def get_shout_media(shout_slug: str) -> dict[str, int]:
|
2024-08-07 13:30:41 +03:00
|
|
|
|
"""Получение метрики воспроизведения shout по slug."""
|
2023-11-03 13:10:22 +03:00
|
|
|
|
self = ViewedStorage
|
2024-12-12 02:03:19 +03:00
|
|
|
|
|
|
|
|
|
|
# TODO: get media plays from Google Analytics
|
|
|
|
|
|
|
2024-08-07 13:30:41 +03:00
|
|
|
|
return self.views_by_shout.get(shout_slug, 0)
|
2022-11-19 14:35:34 +03:00
|
|
|
|
|
2022-11-21 08:18:50 +03:00
|
|
|
|
@staticmethod
|
2025-08-27 12:15:01 +03:00
|
|
|
|
def get_topic(topic_slug: str) -> int:
|
2024-08-07 13:30:41 +03:00
|
|
|
|
"""Получение суммарного значения просмотров темы."""
|
2022-11-21 08:18:50 +03:00
|
|
|
|
self = ViewedStorage
|
2025-04-14 19:53:14 +03:00
|
|
|
|
views_count = 0
|
|
|
|
|
|
for shout_slug in self.shouts_by_topic.get(topic_slug, []):
|
2025-08-27 12:15:01 +03:00
|
|
|
|
views_count += self.get_shout(shout_slug=shout_slug)
|
2025-04-14 19:53:14 +03:00
|
|
|
|
return views_count
|
2022-11-21 08:18:50 +03:00
|
|
|
|
|
2024-01-22 18:42:45 +03:00
|
|
|
|
@staticmethod
|
2025-08-27 12:15:01 +03:00
|
|
|
|
def get_author(author_slug: str) -> int:
|
2024-08-07 13:30:41 +03:00
|
|
|
|
"""Получение суммарного значения просмотров автора."""
|
2024-01-22 18:42:45 +03:00
|
|
|
|
self = ViewedStorage
|
2025-04-14 19:53:14 +03:00
|
|
|
|
views_count = 0
|
|
|
|
|
|
for shout_slug in self.shouts_by_author.get(author_slug, []):
|
2025-08-27 12:15:01 +03:00
|
|
|
|
views_count += self.get_shout(shout_slug=shout_slug)
|
2025-04-14 19:53:14 +03:00
|
|
|
|
return views_count
|
2024-01-22 18:42:45 +03:00
|
|
|
|
|
2022-11-22 16:58:55 +03:00
|
|
|
|
@staticmethod
|
2025-06-02 02:56:11 +03:00
|
|
|
|
def update_topics(shout_slug: str) -> None:
|
2024-08-07 13:37:08 +03:00
|
|
|
|
"""Обновление счетчиков темы по slug shout"""
|
2022-11-22 16:58:55 +03:00
|
|
|
|
self = ViewedStorage
|
2023-11-22 21:23:15 +03:00
|
|
|
|
with local_session() as session:
|
2025-04-14 19:53:14 +03:00
|
|
|
|
# Определение вспомогательной функции для избежания повторения кода
|
2025-06-02 02:56:11 +03:00
|
|
|
|
def update_groups(dictionary: dict, key: str, value: str) -> None:
|
|
|
|
|
|
dictionary[key] = list({*dictionary.get(key, []), value})
|
2024-01-22 21:20:17 +03:00
|
|
|
|
|
2024-01-23 16:04:38 +03:00
|
|
|
|
# Обновление тем и авторов с использованием вспомогательной функции
|
2024-11-02 04:44:07 +03:00
|
|
|
|
for [_st, topic] in (
|
2025-05-29 12:37:39 +03:00
|
|
|
|
session.query(ShoutTopic, Topic).join(Topic).join(Shout).where(Shout.slug == shout_slug).all()
|
2024-01-25 22:41:27 +03:00
|
|
|
|
):
|
2024-01-22 21:20:17 +03:00
|
|
|
|
update_groups(self.shouts_by_topic, topic.slug, shout_slug)
|
|
|
|
|
|
|
2024-11-02 04:44:07 +03:00
|
|
|
|
for [_st, author] in (
|
2025-05-29 12:37:39 +03:00
|
|
|
|
session.query(ShoutAuthor, Author).join(Author).join(Shout).where(Shout.slug == shout_slug).all()
|
2024-01-25 22:41:27 +03:00
|
|
|
|
):
|
2024-01-22 21:20:17 +03:00
|
|
|
|
update_groups(self.shouts_by_author, author.slug, shout_slug)
|
2024-01-22 18:42:45 +03:00
|
|
|
|
|
2024-11-02 04:44:07 +03:00
|
|
|
|
@staticmethod
|
2025-06-02 02:56:11 +03:00
|
|
|
|
async def stop() -> None:
|
2024-11-02 04:44:07 +03:00
|
|
|
|
"""Остановка фоновой задачи"""
|
|
|
|
|
|
self = ViewedStorage
|
|
|
|
|
|
async with self.lock:
|
|
|
|
|
|
self.running = False
|
|
|
|
|
|
logger.info("ViewedStorage worker was stopped.")
|
|
|
|
|
|
|
2023-11-03 13:10:22 +03:00
|
|
|
|
@staticmethod
|
2025-06-02 02:56:11 +03:00
|
|
|
|
async def worker() -> None:
|
2024-08-07 13:37:08 +03:00
|
|
|
|
"""Асинхронная задача обновления"""
|
2022-11-22 01:23:16 +03:00
|
|
|
|
failed = 0
|
2022-11-22 10:29:54 +03:00
|
|
|
|
self = ViewedStorage
|
2023-10-06 01:45:32 +03:00
|
|
|
|
|
2024-11-02 04:44:07 +03:00
|
|
|
|
while self.running:
|
2024-11-02 04:28:16 +03:00
|
|
|
|
try:
|
|
|
|
|
|
await self.update_pages()
|
|
|
|
|
|
failed = 0
|
2025-06-02 02:56:11 +03:00
|
|
|
|
except (ConnectionError, TimeoutError, ValueError) as exc:
|
2024-11-02 04:28:16 +03:00
|
|
|
|
failed += 1
|
|
|
|
|
|
logger.debug(exc)
|
2025-06-02 02:56:11 +03:00
|
|
|
|
logger.info("update failed #%d, wait 10 secs", failed)
|
2024-11-02 04:28:16 +03:00
|
|
|
|
if failed > 3:
|
|
|
|
|
|
logger.info(" - views update failed, not trying anymore")
|
2024-11-02 04:44:07 +03:00
|
|
|
|
self.running = False
|
2024-11-02 04:28:16 +03:00
|
|
|
|
break
|
|
|
|
|
|
if failed == 0:
|
2025-08-17 16:33:54 +03:00
|
|
|
|
when = datetime.now(UTC) + timedelta(seconds=self.period)
|
2024-11-02 04:28:16 +03:00
|
|
|
|
t = format(when.astimezone().isoformat())
|
2025-06-02 02:56:11 +03:00
|
|
|
|
logger.info(" ⎩ next update: %s", t.split("T")[0] + " " + t.split("T")[1].split(".")[0])
|
2024-11-02 04:28:16 +03:00
|
|
|
|
await asyncio.sleep(self.period)
|
|
|
|
|
|
else:
|
|
|
|
|
|
await asyncio.sleep(10)
|
|
|
|
|
|
logger.info(" - try to update views again")
|
2024-12-12 02:03:19 +03:00
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
|
async def update_slug_views(slug: str) -> int:
|
|
|
|
|
|
"""
|
|
|
|
|
|
Получает fresh статистику просмотров для указанного slug.
|
|
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
|
slug: Идентификатор страницы
|
|
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
|
int: Количество просмотров
|
|
|
|
|
|
"""
|
|
|
|
|
|
self = ViewedStorage
|
|
|
|
|
|
if not self.analytics_client:
|
|
|
|
|
|
logger.warning("Google Analytics client not initialized")
|
|
|
|
|
|
return 0
|
|
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
|
# Создаем фильтр для точного совпадения конца URL
|
|
|
|
|
|
request = RunReportRequest(
|
|
|
|
|
|
property=f"properties/{GOOGLE_PROPERTY_ID}",
|
|
|
|
|
|
date_ranges=[DateRange(start_date=self.start_date, end_date="today")],
|
|
|
|
|
|
dimensions=[Dimension(name="pagePath")],
|
|
|
|
|
|
dimension_filter=GAFilter(
|
|
|
|
|
|
field_name="pagePath",
|
|
|
|
|
|
string_filter=GAFilter.StringFilter(
|
|
|
|
|
|
value=f".*/{slug}$", # Используем регулярное выражение для точного совпадения конца URL
|
|
|
|
|
|
match_type=GAFilter.StringFilter.MatchType.FULL_REGEXP,
|
|
|
|
|
|
case_sensitive=False, # Включаем чувствительность к регистру для точности
|
|
|
|
|
|
),
|
|
|
|
|
|
),
|
|
|
|
|
|
metrics=[Metric(name="screenPageViews")],
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
response = self.analytics_client.run_report(request)
|
|
|
|
|
|
|
|
|
|
|
|
if not response.rows:
|
|
|
|
|
|
return 0
|
|
|
|
|
|
|
|
|
|
|
|
views = int(response.rows[0].metric_values[0].value)
|
2025-06-02 02:56:11 +03:00
|
|
|
|
except (ConnectionError, ValueError, AttributeError):
|
|
|
|
|
|
logger.exception("Google Analytics API Error")
|
|
|
|
|
|
return 0
|
|
|
|
|
|
else:
|
2024-12-12 02:03:19 +03:00
|
|
|
|
# Кэшируем результат
|
|
|
|
|
|
self.views_by_shout[slug] = views
|
|
|
|
|
|
return views
|