Compare commits
117 Commits
0bc55977ac
...
feat/sv-se
Author | SHA1 | Date | |
---|---|---|---|
![]() |
e1d1096674 | ||
![]() |
82870a4e47 | ||
![]() |
80b909d801 | ||
![]() |
1ada0a02f9 | ||
![]() |
44aef147b5 | ||
![]() |
2bebfbd4df | ||
![]() |
f19248184a | ||
![]() |
7df9361daa | ||
![]() |
e38a1c1338 | ||
![]() |
1281157d93 | ||
![]() |
0018749905 | ||
a6b3b21894 | |||
51de649686 | |||
2b7d5a25b5 | |||
32cb810f51 | |||
d2a8c23076 | |||
96afda77a6 | |||
785548d055 | |||
d6202561a9 | |||
3fbd2e677a | |||
4f1eab513a | |||
44852a1553 | |||
58ec60262b | |||
![]() |
c344fcee2d | ||
![]() |
a1a61a6731 | ||
![]() |
8d6ad2c84f | ||
![]() |
beba1992e9 | ||
![]() |
b0296d7747 | ||
![]() |
98e3dff35e | ||
![]() |
3782a9dffb | ||
![]() |
93c00b3dd1 | ||
5f3d90fc90 | |||
f71fc7fde9 | |||
ed71405082 | |||
79e1f15a2e | |||
b17acae0af | |||
d293819ad9 | |||
bcbfdd76e9 | |||
b735bf8cab | |||
20fd40df0e | |||
bde3211a5f | |||
4cd8883d72 | |||
0939e91700 | |||
dfbdfba2f0 | |||
b66e347c91 | |||
6d9513f1b2 | |||
af7fbd2fc9 | |||
631ad47fe8 | |||
3b3cc1c1d8 | |||
e4943f524c | |||
e7684c9c05 | |||
bdae2abe25 | |||
a310d59432 | |||
6b2ac09f74 | |||
![]() |
fac43e5997 | ||
![]() |
e7facf8d87 | ||
![]() |
3062a2b7de | ||
![]() |
c0406dbbf2 | ||
![]() |
ab4610575f | ||
![]() |
5425dbf832 | ||
![]() |
a10db2d38a | ||
5024e963e3 | |||
![]() |
83e70856cd | ||
![]() |
11654dba68 | ||
![]() |
ec9465ad40 | ||
![]() |
4d965fb27b | ||
aaa6022a53 | |||
d6ada44c7f | |||
243f836f0a | |||
536c094e72 | |||
![]() |
e382cc1ea5 | ||
6920351b82 | |||
eb216a5f36 | |||
bd129efde6 | |||
b9f6033e66 | |||
710f522c8f | |||
0de4404cb1 | |||
83d61ca76d | |||
1c61e889d6 | |||
fdedb75a2c | |||
f20000f1f6 | |||
7d50638b3a | |||
![]() |
106222b0e0 | ||
![]() |
c533241d1e | ||
![]() |
78326047bf | ||
![]() |
bc4ec79240 | ||
![]() |
a0db5707c4 | ||
![]() |
ecc443c3ad | ||
![]() |
9a02ca74ad | ||
![]() |
9ebb81cbd3 | ||
abbc074474 | |||
4f599e097f | |||
a5eaf4bb65 | |||
3c56fdfaea | |||
81a8bf3c58 | |||
fe9984e2d8 | |||
369ff757b0 | |||
615f1fe468 | |||
86ddb50cb8 | |||
31c32143d0 | |||
b63c387806 | |||
dbbfd42e08 | |||
47e12b4452 | |||
e1a1b4dc7d | |||
ca01181f37 | |||
0aff77eda6 | |||
8a95aa1209 | |||
a4a3c35f4d | |||
edece36ecc | |||
247fc98760 | |||
a1781b3800 | |||
450c73c060 | |||
3a1924279f | |||
094e7e6fe2 | |||
ae48a18536 | |||
354bda0efa | |||
856f4ffc85 |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -128,6 +128,9 @@ dmypy.json
|
|||||||
.idea
|
.idea
|
||||||
temp.*
|
temp.*
|
||||||
|
|
||||||
|
# Debug
|
||||||
|
DEBUG.log
|
||||||
|
|
||||||
discours.key
|
discours.key
|
||||||
discours.crt
|
discours.crt
|
||||||
discours.pem
|
discours.pem
|
||||||
|
131
CHANGELOG.md
131
CHANGELOG.md
@@ -1,3 +1,112 @@
|
|||||||
|
#### [0.4.20] - 2025-05-03
|
||||||
|
- Исправлена ошибка в классе `CacheRevalidationManager`: добавлена инициализация атрибута `_redis`
|
||||||
|
- Улучшена обработка соединения с Redis в менеджере ревалидации кэша:
|
||||||
|
- Автоматическое восстановление соединения в случае его потери
|
||||||
|
- Проверка соединения перед выполнением операций с кэшем
|
||||||
|
- Дополнительное логирование для упрощения диагностики проблем
|
||||||
|
- Исправлен резолвер `unpublish_shout`:
|
||||||
|
- Корректное формирование синтетического поля `publication` с `published_at: null`
|
||||||
|
- Возвращение полноценного словаря с данными вместо объекта модели
|
||||||
|
- Улучшена загрузка связанных данных (авторы, темы) для правильного формирования ответа
|
||||||
|
|
||||||
|
#### [0.4.19] - 2025-04-14
|
||||||
|
- dropped `Shout.description` and `Draft.description` to be UX-generated
|
||||||
|
- use redis to init views counters after migrator
|
||||||
|
|
||||||
|
#### [0.4.18] - 2025-04-10
|
||||||
|
- Fixed `Topic.stat.authors` and `Topic.stat.comments`
|
||||||
|
- Fixed unique constraint violation for empty slug values:
|
||||||
|
- Modified `update_draft` resolver to handle empty slug values
|
||||||
|
- Modified `create_draft` resolver to prevent empty slug values
|
||||||
|
- Added validation to prevent inserting or updating drafts with empty slug
|
||||||
|
- Fixed database error "duplicate key value violates unique constraint draft_slug_key"
|
||||||
|
|
||||||
|
#### [0.4.17] - 2025-03-26
|
||||||
|
- Fixed `'Reaction' object is not subscriptable` error in hierarchical comments:
|
||||||
|
- Modified `get_reactions_with_stat()` to convert Reaction objects to dictionaries
|
||||||
|
- Added default values for limit/offset parameters
|
||||||
|
- Fixed `load_first_replies()` implementation with proper parameter passing
|
||||||
|
- Added doctest with example usage
|
||||||
|
- Limited child comments to 100 per parent for performance
|
||||||
|
|
||||||
|
#### [0.4.16] - 2025-03-22
|
||||||
|
- Added hierarchical comments pagination:
|
||||||
|
- Created new GraphQL query `load_comments_branch` for efficient loading of hierarchical comments
|
||||||
|
- Ability to load root comments with their first N replies
|
||||||
|
- Added pagination for both root and child comments
|
||||||
|
- Using existing `comments_count` field in `Stat` type to display number of replies
|
||||||
|
- Added special `first_replies` field to store first replies to a comment
|
||||||
|
- Optimized SQL queries for efficient loading of comment hierarchies
|
||||||
|
- Implemented flexible comment sorting system (by time, rating)
|
||||||
|
|
||||||
|
#### [0.4.15] - 2025-03-22
|
||||||
|
- Upgraded caching system described `docs/caching.md`
|
||||||
|
- Module `cache/memorycache.py` removed
|
||||||
|
- Enhanced caching system with backward compatibility:
|
||||||
|
- Unified cache key generation with support for existing naming patterns
|
||||||
|
- Improved Redis operation function with better error handling
|
||||||
|
- Updated precache module to use consistent Redis interface
|
||||||
|
- Integrated revalidator with the invalidation system for better performance
|
||||||
|
- Added comprehensive documentation for the caching system
|
||||||
|
- Enhanced cached_query to support template-based cache keys
|
||||||
|
- Standardized error handling across all cache operations
|
||||||
|
- Optimized cache invalidation system:
|
||||||
|
- Added targeted invalidation for individual entities (authors, topics)
|
||||||
|
- Improved revalidation manager with individual object processing
|
||||||
|
- Implemented batched processing for high-volume invalidations
|
||||||
|
- Reduced Redis operations by using precise key invalidation instead of prefix-based wipes
|
||||||
|
- Added special handling for slug changes in topics
|
||||||
|
- Unified caching system for all models:
|
||||||
|
- Implemented abstract functions `cache_data`, `get_cached_data` and `invalidate_cache_by_prefix`
|
||||||
|
- Added `cached_query` function for unified approach to query caching
|
||||||
|
- Updated resolvers `author.py` and `topic.py` to use the new caching API
|
||||||
|
- Improved logging for cache operations to simplify debugging
|
||||||
|
- Optimized Redis memory usage through key format unification
|
||||||
|
- Improved caching and sorting in Topic and Author modules:
|
||||||
|
- Added support for dictionary sorting parameters in `by` for both modules
|
||||||
|
- Optimized cache key generation for stable behavior with various parameters
|
||||||
|
- Enhanced sorting logic with direction support and arbitrary fields
|
||||||
|
- Added `by` parameter support in the API for getting topics by community
|
||||||
|
- Performance optimizations for author-related queries:
|
||||||
|
- Added SQLAlchemy-managed indexes to `Author`, `AuthorFollower`, `AuthorRating` and `AuthorBookmark` models
|
||||||
|
- Implemented persistent Redis caching for author queries without TTL (invalidated only on changes)
|
||||||
|
- Optimized author retrieval with separate endpoints:
|
||||||
|
- `get_authors_all` - returns all non-deleted authors without statistics
|
||||||
|
- `load_authors_by` - optimized to use caching and efficient sorting and pagination
|
||||||
|
- Improved SQL queries with optimized JOIN conditions and efficient filtering
|
||||||
|
- Added pre-aggregation of statistics (shouts count, followers count) in single efficient queries
|
||||||
|
- Implemented robust cache invalidation on author updates
|
||||||
|
- Created necessary indexes for author lookups by user ID, slug, and timestamps
|
||||||
|
|
||||||
|
#### [0.4.14] - 2025-03-21
|
||||||
|
- Significant performance improvements for topic queries:
|
||||||
|
- Added database indexes to optimize JOIN operations
|
||||||
|
- Implemented persistent Redis caching for topic queries (no TTL, invalidated only on changes)
|
||||||
|
- Optimized topic retrieval with separate endpoints for different use cases:
|
||||||
|
- `get_topics_all` - returns all topics without statistics for lightweight listing
|
||||||
|
- `get_topics_by_community` - adds pagination and optimized filtering by community
|
||||||
|
- Added SQLAlchemy-managed indexes directly in ORM models for automatic schema maintenance
|
||||||
|
- Created `sync_indexes()` function for automatic index synchronization during app startup
|
||||||
|
- Reduced database load by pre-aggregating statistics in optimized SQL queries
|
||||||
|
- Added robust cache invalidation on topic create/update/delete operations
|
||||||
|
- Improved query optimization with proper JOIN conditions and specific partial indexes
|
||||||
|
|
||||||
|
#### [0.4.13] - 2025-03-20
|
||||||
|
- Fixed Topic objects serialization error in cache/memorycache.py
|
||||||
|
- Improved CustomJSONEncoder to support SQLAlchemy models with dict() method
|
||||||
|
- Enhanced error handling in cache_on_arguments decorator
|
||||||
|
- Modified `load_reactions_by` to include deleted reactions when `include_deleted=true` for proper comment tree building
|
||||||
|
- Fixed featured/unfeatured logic in reaction processing:
|
||||||
|
- Dislike reactions now properly take precedence over likes
|
||||||
|
- Featured status now requires more than 4 likes from users with featured articles
|
||||||
|
- Removed unnecessary filters for deleted reactions since rating reactions are physically deleted
|
||||||
|
- Author's featured status now based on having non-deleted articles with featured_at
|
||||||
|
|
||||||
|
#### [0.4.12] - 2025-03-19
|
||||||
|
- `delete_reaction` detects comments and uses `deleted_at` update
|
||||||
|
- `check_to_unfeature` etc. update
|
||||||
|
- dogpile dep in `services/memorycache.py` optimized
|
||||||
|
|
||||||
#### [0.4.11] - 2025-02-12
|
#### [0.4.11] - 2025-02-12
|
||||||
- `create_draft` resolver requires draft_id fixed
|
- `create_draft` resolver requires draft_id fixed
|
||||||
- `create_draft` resolver defaults body and title fields to empty string
|
- `create_draft` resolver defaults body and title fields to empty string
|
||||||
@@ -72,7 +181,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 `commented_stat` fixes
|
- `rating_stat` and `comments_count` fixes
|
||||||
|
|
||||||
#### [0.4.3]
|
#### [0.4.3]
|
||||||
- cache reimplemented
|
- cache reimplemented
|
||||||
@@ -228,22 +337,4 @@
|
|||||||
|
|
||||||
|
|
||||||
#### [0.2.7]
|
#### [0.2.7]
|
||||||
- `loadFollowedReactions` now with `login_required`
|
- `loadFollowedReactions` now with `
|
||||||
- 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`
|
|
@@ -3,6 +3,7 @@ FROM python:slim
|
|||||||
RUN apt-get update && apt-get install -y \
|
RUN apt-get update && apt-get install -y \
|
||||||
postgresql-client \
|
postgresql-client \
|
||||||
curl \
|
curl \
|
||||||
|
build-essential \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
@@ -13,4 +14,6 @@ RUN pip install -r requirements.txt
|
|||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
CMD ["python", "server.py"]
|
EXPOSE 8000
|
||||||
|
|
||||||
|
CMD ["python", "-m", "granian", "main:app", "--interface", "asgi", "--host", "0.0.0.0", "--port", "8000"]
|
1
app/resolvers/draft.py
Normal file
1
app/resolvers/draft.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
|
272
cache/cache.py
vendored
272
cache/cache.py
vendored
@@ -1,7 +1,37 @@
|
|||||||
|
"""
|
||||||
|
Caching system for the Discours platform
|
||||||
|
----------------------------------------
|
||||||
|
|
||||||
|
This module provides a comprehensive caching solution with these key components:
|
||||||
|
|
||||||
|
1. KEY NAMING CONVENTIONS:
|
||||||
|
- Entity-based keys: "entity:property:value" (e.g., "author:id:123")
|
||||||
|
- Collection keys: "entity:collection:params" (e.g., "authors:stats:limit=10:offset=0")
|
||||||
|
- Special case keys: Maintained for backwards compatibility (e.g., "topic_shouts_123")
|
||||||
|
|
||||||
|
2. CORE FUNCTIONS:
|
||||||
|
- cached_query(): High-level function for retrieving cached data or executing queries
|
||||||
|
|
||||||
|
3. ENTITY-SPECIFIC FUNCTIONS:
|
||||||
|
- cache_author(), cache_topic(): Cache entity data
|
||||||
|
- get_cached_author(), get_cached_topic(): Retrieve entity data from cache
|
||||||
|
- invalidate_cache_by_prefix(): Invalidate all keys with a specific prefix
|
||||||
|
|
||||||
|
4. CACHE INVALIDATION STRATEGY:
|
||||||
|
- Direct invalidation via invalidate_* functions for immediate changes
|
||||||
|
- Delayed invalidation via revalidation_manager for background processing
|
||||||
|
- Event-based triggers for automatic cache updates (see triggers.py)
|
||||||
|
|
||||||
|
To maintain consistency with the existing codebase, this module preserves
|
||||||
|
the original key naming patterns while providing a more structured approach
|
||||||
|
for new cache operations.
|
||||||
|
"""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import json
|
import json
|
||||||
from typing import List
|
from typing import Any, Dict, List, Optional, Union
|
||||||
|
|
||||||
|
import orjson
|
||||||
from sqlalchemy import and_, join, select
|
from sqlalchemy import and_, join, select
|
||||||
|
|
||||||
from orm.author import Author, AuthorFollower
|
from orm.author import Author, AuthorFollower
|
||||||
@@ -19,8 +49,10 @@ DEFAULT_FOLLOWS = {
|
|||||||
"communities": [{"id": 1, "name": "Дискурс", "slug": "discours", "pic": ""}],
|
"communities": [{"id": 1, "name": "Дискурс", "slug": "discours", "pic": ""}],
|
||||||
}
|
}
|
||||||
|
|
||||||
CACHE_TTL = 300 # 5 минут
|
CACHE_TTL = 300 # 5 minutes
|
||||||
|
|
||||||
|
# Key templates for common entity types
|
||||||
|
# These are used throughout the codebase and should be maintained for compatibility
|
||||||
CACHE_KEYS = {
|
CACHE_KEYS = {
|
||||||
"TOPIC_ID": "topic:id:{}",
|
"TOPIC_ID": "topic:id:{}",
|
||||||
"TOPIC_SLUG": "topic:slug:{}",
|
"TOPIC_SLUG": "topic:slug:{}",
|
||||||
@@ -37,8 +69,8 @@ CACHE_KEYS = {
|
|||||||
async def cache_topic(topic: dict):
|
async def cache_topic(topic: dict):
|
||||||
payload = json.dumps(topic, cls=CustomJSONEncoder)
|
payload = json.dumps(topic, cls=CustomJSONEncoder)
|
||||||
await asyncio.gather(
|
await asyncio.gather(
|
||||||
redis_operation("SET", f"topic:id:{topic['id']}", payload),
|
redis.execute("SET", f"topic:id:{topic['id']}", payload),
|
||||||
redis_operation("SET", f"topic:slug:{topic['slug']}", payload),
|
redis.execute("SET", f"topic:slug:{topic['slug']}", payload),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -46,30 +78,30 @@ async def cache_topic(topic: dict):
|
|||||||
async def cache_author(author: dict):
|
async def cache_author(author: dict):
|
||||||
payload = json.dumps(author, cls=CustomJSONEncoder)
|
payload = json.dumps(author, cls=CustomJSONEncoder)
|
||||||
await asyncio.gather(
|
await asyncio.gather(
|
||||||
redis_operation("SET", f"author:user:{author['user'].strip()}", str(author["id"])),
|
redis.execute("SET", f"author:user:{author['user'].strip()}", str(author["id"])),
|
||||||
redis_operation("SET", f"author:id:{author['id']}", payload),
|
redis.execute("SET", f"author:id:{author['id']}", payload),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
# Cache follows data
|
# Cache follows data
|
||||||
async def cache_follows(follower_id: int, entity_type: str, entity_id: int, is_insert=True):
|
async def cache_follows(follower_id: int, entity_type: str, entity_id: int, is_insert=True):
|
||||||
key = f"author:follows-{entity_type}s:{follower_id}"
|
key = f"author:follows-{entity_type}s:{follower_id}"
|
||||||
follows_str = await redis_operation("GET", key)
|
follows_str = await redis.execute("GET", key)
|
||||||
follows = json.loads(follows_str) if follows_str else DEFAULT_FOLLOWS[entity_type]
|
follows = orjson.loads(follows_str) if follows_str else DEFAULT_FOLLOWS[entity_type]
|
||||||
if is_insert:
|
if is_insert:
|
||||||
if entity_id not in follows:
|
if entity_id not in follows:
|
||||||
follows.append(entity_id)
|
follows.append(entity_id)
|
||||||
else:
|
else:
|
||||||
follows = [eid for eid in follows if eid != entity_id]
|
follows = [eid for eid in follows if eid != entity_id]
|
||||||
await redis_operation("SET", key, json.dumps(follows, cls=CustomJSONEncoder))
|
await redis.execute("SET", key, json.dumps(follows, cls=CustomJSONEncoder))
|
||||||
await update_follower_stat(follower_id, entity_type, len(follows))
|
await update_follower_stat(follower_id, entity_type, len(follows))
|
||||||
|
|
||||||
|
|
||||||
# Update follower statistics
|
# Update follower statistics
|
||||||
async def update_follower_stat(follower_id, entity_type, count):
|
async def update_follower_stat(follower_id, entity_type, count):
|
||||||
follower_key = f"author:id:{follower_id}"
|
follower_key = f"author:id:{follower_id}"
|
||||||
follower_str = await redis_operation("GET", follower_key)
|
follower_str = await redis.execute("GET", follower_key)
|
||||||
follower = json.loads(follower_str) if follower_str else None
|
follower = orjson.loads(follower_str) if follower_str else None
|
||||||
if follower:
|
if follower:
|
||||||
follower["stat"] = {f"{entity_type}s": count}
|
follower["stat"] = {f"{entity_type}s": count}
|
||||||
await cache_author(follower)
|
await cache_author(follower)
|
||||||
@@ -78,9 +110,9 @@ async def update_follower_stat(follower_id, entity_type, count):
|
|||||||
# Get author from cache
|
# Get author from cache
|
||||||
async def get_cached_author(author_id: int, get_with_stat):
|
async def get_cached_author(author_id: int, get_with_stat):
|
||||||
author_key = f"author:id:{author_id}"
|
author_key = f"author:id:{author_id}"
|
||||||
result = await redis_operation("GET", author_key)
|
result = await redis.execute("GET", author_key)
|
||||||
if result:
|
if result:
|
||||||
return json.loads(result)
|
return orjson.loads(result)
|
||||||
# Load from database if not found in cache
|
# Load from database if not found in cache
|
||||||
q = select(Author).where(Author.id == author_id)
|
q = select(Author).where(Author.id == author_id)
|
||||||
authors = get_with_stat(q)
|
authors = get_with_stat(q)
|
||||||
@@ -103,16 +135,16 @@ async def get_cached_topic(topic_id: int):
|
|||||||
dict: Topic data or None if not found.
|
dict: Topic data or None if not found.
|
||||||
"""
|
"""
|
||||||
topic_key = f"topic:id:{topic_id}"
|
topic_key = f"topic:id:{topic_id}"
|
||||||
cached_topic = await redis_operation("GET", topic_key)
|
cached_topic = await redis.execute("GET", topic_key)
|
||||||
if cached_topic:
|
if cached_topic:
|
||||||
return json.loads(cached_topic)
|
return orjson.loads(cached_topic)
|
||||||
|
|
||||||
# If not in cache, fetch from the database
|
# If not in cache, fetch from the database
|
||||||
with local_session() as session:
|
with local_session() as session:
|
||||||
topic = session.execute(select(Topic).where(Topic.id == topic_id)).scalar_one_or_none()
|
topic = session.execute(select(Topic).where(Topic.id == topic_id)).scalar_one_or_none()
|
||||||
if topic:
|
if topic:
|
||||||
topic_dict = topic.dict()
|
topic_dict = topic.dict()
|
||||||
await redis_operation("SET", topic_key, json.dumps(topic_dict, cls=CustomJSONEncoder))
|
await redis.execute("SET", topic_key, json.dumps(topic_dict, cls=CustomJSONEncoder))
|
||||||
return topic_dict
|
return topic_dict
|
||||||
|
|
||||||
return None
|
return None
|
||||||
@@ -121,9 +153,9 @@ async def get_cached_topic(topic_id: int):
|
|||||||
# Get topic by slug from cache
|
# Get topic by slug from cache
|
||||||
async def get_cached_topic_by_slug(slug: str, get_with_stat):
|
async def get_cached_topic_by_slug(slug: str, get_with_stat):
|
||||||
topic_key = f"topic:slug:{slug}"
|
topic_key = f"topic:slug:{slug}"
|
||||||
result = await redis_operation("GET", topic_key)
|
result = await redis.execute("GET", topic_key)
|
||||||
if result:
|
if result:
|
||||||
return json.loads(result)
|
return orjson.loads(result)
|
||||||
# Load from database if not found in cache
|
# Load from database if not found in cache
|
||||||
topic_query = select(Topic).where(Topic.slug == slug)
|
topic_query = select(Topic).where(Topic.slug == slug)
|
||||||
topics = get_with_stat(topic_query)
|
topics = get_with_stat(topic_query)
|
||||||
@@ -138,8 +170,8 @@ async def get_cached_topic_by_slug(slug: str, get_with_stat):
|
|||||||
async def get_cached_authors_by_ids(author_ids: List[int]) -> List[dict]:
|
async def get_cached_authors_by_ids(author_ids: List[int]) -> List[dict]:
|
||||||
# Fetch all author data concurrently
|
# Fetch all author data concurrently
|
||||||
keys = [f"author:id:{author_id}" for author_id in author_ids]
|
keys = [f"author:id:{author_id}" for author_id in author_ids]
|
||||||
results = await asyncio.gather(*(redis_operation("GET", key) for key in keys))
|
results = await asyncio.gather(*(redis.execute("GET", key) for key in keys))
|
||||||
authors = [json.loads(result) if result else None for result in results]
|
authors = [orjson.loads(result) if result else None for result in results]
|
||||||
# Load missing authors from database and cache
|
# Load missing authors from database and cache
|
||||||
missing_indices = [index for index, author in enumerate(authors) if author is None]
|
missing_indices = [index for index, author in enumerate(authors) if author is None]
|
||||||
if missing_indices:
|
if missing_indices:
|
||||||
@@ -165,10 +197,10 @@ async def get_cached_topic_followers(topic_id: int):
|
|||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
cache_key = CACHE_KEYS["TOPIC_FOLLOWERS"].format(topic_id)
|
cache_key = CACHE_KEYS["TOPIC_FOLLOWERS"].format(topic_id)
|
||||||
cached = await redis_operation("GET", cache_key)
|
cached = await redis.execute("GET", cache_key)
|
||||||
|
|
||||||
if cached:
|
if cached:
|
||||||
followers_ids = json.loads(cached)
|
followers_ids = orjson.loads(cached)
|
||||||
logger.debug(f"Found {len(followers_ids)} cached followers for topic #{topic_id}")
|
logger.debug(f"Found {len(followers_ids)} cached followers for topic #{topic_id}")
|
||||||
return await get_cached_authors_by_ids(followers_ids)
|
return await get_cached_authors_by_ids(followers_ids)
|
||||||
|
|
||||||
@@ -181,7 +213,7 @@ async def get_cached_topic_followers(topic_id: int):
|
|||||||
.all()
|
.all()
|
||||||
]
|
]
|
||||||
|
|
||||||
await redis_operation("SETEX", cache_key, value=json.dumps(followers_ids), ttl=CACHE_TTL)
|
await redis.execute("SETEX", cache_key, CACHE_TTL, orjson.dumps(followers_ids))
|
||||||
followers = await get_cached_authors_by_ids(followers_ids)
|
followers = await get_cached_authors_by_ids(followers_ids)
|
||||||
logger.debug(f"Cached {len(followers)} followers for topic #{topic_id}")
|
logger.debug(f"Cached {len(followers)} followers for topic #{topic_id}")
|
||||||
return followers
|
return followers
|
||||||
@@ -194,9 +226,9 @@ async def get_cached_topic_followers(topic_id: int):
|
|||||||
# Get cached author followers
|
# Get cached author followers
|
||||||
async def get_cached_author_followers(author_id: int):
|
async def get_cached_author_followers(author_id: int):
|
||||||
# Check cache for data
|
# Check cache for data
|
||||||
cached = await redis_operation("GET", f"author:followers:{author_id}")
|
cached = await redis.execute("GET", f"author:followers:{author_id}")
|
||||||
if cached:
|
if cached:
|
||||||
followers_ids = json.loads(cached)
|
followers_ids = orjson.loads(cached)
|
||||||
followers = await get_cached_authors_by_ids(followers_ids)
|
followers = await get_cached_authors_by_ids(followers_ids)
|
||||||
logger.debug(f"Cached followers for author #{author_id}: {len(followers)}")
|
logger.debug(f"Cached followers for author #{author_id}: {len(followers)}")
|
||||||
return followers
|
return followers
|
||||||
@@ -210,7 +242,7 @@ async def get_cached_author_followers(author_id: int):
|
|||||||
.filter(AuthorFollower.author == author_id, Author.id != author_id)
|
.filter(AuthorFollower.author == author_id, Author.id != author_id)
|
||||||
.all()
|
.all()
|
||||||
]
|
]
|
||||||
await redis_operation("SET", f"author:followers:{author_id}", json.dumps(followers_ids))
|
await redis.execute("SET", f"author:followers:{author_id}", orjson.dumps(followers_ids))
|
||||||
followers = await get_cached_authors_by_ids(followers_ids)
|
followers = await get_cached_authors_by_ids(followers_ids)
|
||||||
return followers
|
return followers
|
||||||
|
|
||||||
@@ -218,9 +250,9 @@ async def get_cached_author_followers(author_id: int):
|
|||||||
# Get cached follower authors
|
# Get cached follower authors
|
||||||
async def get_cached_follower_authors(author_id: int):
|
async def get_cached_follower_authors(author_id: int):
|
||||||
# Attempt to retrieve authors from cache
|
# Attempt to retrieve authors from cache
|
||||||
cached = await redis_operation("GET", f"author:follows-authors:{author_id}")
|
cached = await redis.execute("GET", f"author:follows-authors:{author_id}")
|
||||||
if cached:
|
if cached:
|
||||||
authors_ids = json.loads(cached)
|
authors_ids = orjson.loads(cached)
|
||||||
else:
|
else:
|
||||||
# Query authors from database
|
# Query authors from database
|
||||||
with local_session() as session:
|
with local_session() as session:
|
||||||
@@ -232,7 +264,7 @@ async def get_cached_follower_authors(author_id: int):
|
|||||||
.where(AuthorFollower.follower == author_id)
|
.where(AuthorFollower.follower == author_id)
|
||||||
).all()
|
).all()
|
||||||
]
|
]
|
||||||
await redis_operation("SET", f"author:follows-authors:{author_id}", json.dumps(authors_ids))
|
await redis.execute("SET", f"author:follows-authors:{author_id}", orjson.dumps(authors_ids))
|
||||||
|
|
||||||
authors = await get_cached_authors_by_ids(authors_ids)
|
authors = await get_cached_authors_by_ids(authors_ids)
|
||||||
return authors
|
return authors
|
||||||
@@ -241,9 +273,9 @@ async def get_cached_follower_authors(author_id: int):
|
|||||||
# Get cached follower topics
|
# Get cached follower topics
|
||||||
async def get_cached_follower_topics(author_id: int):
|
async def get_cached_follower_topics(author_id: int):
|
||||||
# Attempt to retrieve topics from cache
|
# Attempt to retrieve topics from cache
|
||||||
cached = await redis_operation("GET", f"author:follows-topics:{author_id}")
|
cached = await redis.execute("GET", f"author:follows-topics:{author_id}")
|
||||||
if cached:
|
if cached:
|
||||||
topics_ids = json.loads(cached)
|
topics_ids = orjson.loads(cached)
|
||||||
else:
|
else:
|
||||||
# Load topics from database and cache them
|
# Load topics from database and cache them
|
||||||
with local_session() as session:
|
with local_session() as session:
|
||||||
@@ -254,13 +286,13 @@ async def get_cached_follower_topics(author_id: int):
|
|||||||
.where(TopicFollower.follower == author_id)
|
.where(TopicFollower.follower == author_id)
|
||||||
.all()
|
.all()
|
||||||
]
|
]
|
||||||
await redis_operation("SET", f"author:follows-topics:{author_id}", json.dumps(topics_ids))
|
await redis.execute("SET", f"author:follows-topics:{author_id}", orjson.dumps(topics_ids))
|
||||||
|
|
||||||
topics = []
|
topics = []
|
||||||
for topic_id in topics_ids:
|
for topic_id in topics_ids:
|
||||||
topic_str = await redis_operation("GET", f"topic:id:{topic_id}")
|
topic_str = await redis.execute("GET", f"topic:id:{topic_id}")
|
||||||
if topic_str:
|
if topic_str:
|
||||||
topic = json.loads(topic_str)
|
topic = orjson.loads(topic_str)
|
||||||
if topic and topic not in topics:
|
if topic and topic not in topics:
|
||||||
topics.append(topic)
|
topics.append(topic)
|
||||||
|
|
||||||
@@ -280,12 +312,12 @@ async def get_cached_author_by_user_id(user_id: str, get_with_stat):
|
|||||||
dict: Dictionary with author data or None if not found.
|
dict: Dictionary with author data or None if not found.
|
||||||
"""
|
"""
|
||||||
# Attempt to find author ID by user_id in Redis cache
|
# Attempt to find author ID by user_id in Redis cache
|
||||||
author_id = await redis_operation("GET", f"author:user:{user_id.strip()}")
|
author_id = await redis.execute("GET", f"author:user:{user_id.strip()}")
|
||||||
if author_id:
|
if author_id:
|
||||||
# If ID is found, get full author data by ID
|
# If ID is found, get full author data by ID
|
||||||
author_data = await redis_operation("GET", f"author:id:{author_id}")
|
author_data = await redis.execute("GET", f"author:id:{author_id}")
|
||||||
if author_data:
|
if author_data:
|
||||||
return json.loads(author_data)
|
return orjson.loads(author_data)
|
||||||
|
|
||||||
# If data is not found in cache, query the database
|
# If data is not found in cache, query the database
|
||||||
author_query = select(Author).where(Author.user == user_id)
|
author_query = select(Author).where(Author.user == user_id)
|
||||||
@@ -295,8 +327,8 @@ async def get_cached_author_by_user_id(user_id: str, get_with_stat):
|
|||||||
author = authors[0]
|
author = authors[0]
|
||||||
author_dict = author.dict()
|
author_dict = author.dict()
|
||||||
await asyncio.gather(
|
await asyncio.gather(
|
||||||
redis_operation("SET", f"author:user:{user_id.strip()}", str(author.id)),
|
redis.execute("SET", f"author:user:{user_id.strip()}", str(author.id)),
|
||||||
redis_operation("SET", f"author:id:{author.id}", json.dumps(author_dict)),
|
redis.execute("SET", f"author:id:{author.id}", orjson.dumps(author_dict)),
|
||||||
)
|
)
|
||||||
return author_dict
|
return author_dict
|
||||||
|
|
||||||
@@ -317,9 +349,9 @@ async def get_cached_topic_authors(topic_id: int):
|
|||||||
"""
|
"""
|
||||||
# Attempt to get a list of author IDs from cache
|
# Attempt to get a list of author IDs from cache
|
||||||
rkey = f"topic:authors:{topic_id}"
|
rkey = f"topic:authors:{topic_id}"
|
||||||
cached_authors_ids = await redis_operation("GET", rkey)
|
cached_authors_ids = await redis.execute("GET", rkey)
|
||||||
if cached_authors_ids:
|
if cached_authors_ids:
|
||||||
authors_ids = json.loads(cached_authors_ids)
|
authors_ids = orjson.loads(cached_authors_ids)
|
||||||
else:
|
else:
|
||||||
# If cache is empty, get data from the database
|
# If cache is empty, get data from the database
|
||||||
with local_session() as session:
|
with local_session() as session:
|
||||||
@@ -331,7 +363,7 @@ async def get_cached_topic_authors(topic_id: int):
|
|||||||
)
|
)
|
||||||
authors_ids = [author_id for (author_id,) in session.execute(query).all()]
|
authors_ids = [author_id for (author_id,) in session.execute(query).all()]
|
||||||
# Cache the retrieved author IDs
|
# Cache the retrieved author IDs
|
||||||
await redis_operation("SET", rkey, json.dumps(authors_ids))
|
await redis.execute("SET", rkey, orjson.dumps(authors_ids))
|
||||||
|
|
||||||
# Retrieve full author details from cached IDs
|
# Retrieve full author details from cached IDs
|
||||||
if authors_ids:
|
if authors_ids:
|
||||||
@@ -352,11 +384,11 @@ async def invalidate_shouts_cache(cache_keys: List[str]):
|
|||||||
cache_key = f"shouts:{key}"
|
cache_key = f"shouts:{key}"
|
||||||
|
|
||||||
# Удаляем основной кэш
|
# Удаляем основной кэш
|
||||||
await redis_operation("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}")
|
||||||
|
|
||||||
# Добавляем ключ в список инвалидированных с TTL
|
# Добавляем ключ в список инвалидированных с TTL
|
||||||
await redis_operation("SETEX", f"{cache_key}:invalidated", value="1", ttl=CACHE_TTL)
|
await redis.execute("SETEX", f"{cache_key}:invalidated", CACHE_TTL, "1")
|
||||||
|
|
||||||
# Если это кэш темы, инвалидируем также связанные ключи
|
# Если это кэш темы, инвалидируем также связанные ключи
|
||||||
if key.startswith("topic_"):
|
if key.startswith("topic_"):
|
||||||
@@ -368,7 +400,7 @@ async def invalidate_shouts_cache(cache_keys: List[str]):
|
|||||||
f"topic:stats:{topic_id}",
|
f"topic:stats:{topic_id}",
|
||||||
]
|
]
|
||||||
for related_key in related_keys:
|
for related_key in related_keys:
|
||||||
await redis_operation("DEL", related_key)
|
await redis.execute("DEL", related_key)
|
||||||
logger.debug(f"Invalidated related key: {related_key}")
|
logger.debug(f"Invalidated related key: {related_key}")
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -379,15 +411,15 @@ async def cache_topic_shouts(topic_id: int, shouts: List[dict]):
|
|||||||
"""Кэширует список публикаций для темы"""
|
"""Кэширует список публикаций для темы"""
|
||||||
key = f"topic_shouts_{topic_id}"
|
key = f"topic_shouts_{topic_id}"
|
||||||
payload = json.dumps(shouts, cls=CustomJSONEncoder)
|
payload = json.dumps(shouts, cls=CustomJSONEncoder)
|
||||||
await redis_operation("SETEX", key, value=payload, ttl=CACHE_TTL)
|
await redis.execute("SETEX", key, CACHE_TTL, payload)
|
||||||
|
|
||||||
|
|
||||||
async def get_cached_topic_shouts(topic_id: int) -> List[dict]:
|
async def get_cached_topic_shouts(topic_id: int) -> List[dict]:
|
||||||
"""Получает кэшированный список публикаций для темы"""
|
"""Получает кэшированный список публикаций для темы"""
|
||||||
key = f"topic_shouts_{topic_id}"
|
key = f"topic_shouts_{topic_id}"
|
||||||
cached = await redis_operation("GET", key)
|
cached = await redis.execute("GET", key)
|
||||||
if cached:
|
if cached:
|
||||||
return json.loads(cached)
|
return orjson.loads(cached)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
@@ -431,27 +463,7 @@ async def invalidate_shout_related_cache(shout: Shout, author_id: int):
|
|||||||
await invalidate_shouts_cache(list(cache_keys))
|
await invalidate_shouts_cache(list(cache_keys))
|
||||||
|
|
||||||
|
|
||||||
async def redis_operation(operation: str, key: str, value=None, ttl=None):
|
# Function removed - direct Redis calls used throughout the module instead
|
||||||
"""
|
|
||||||
Унифицированная функция для работы с Redis
|
|
||||||
|
|
||||||
Args:
|
|
||||||
operation: 'GET', 'SET', 'DEL', 'SETEX'
|
|
||||||
key: ключ
|
|
||||||
value: значение (для SET/SETEX)
|
|
||||||
ttl: время жизни в секундах (для SETEX)
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
if operation == "GET":
|
|
||||||
return await redis.execute("GET", key)
|
|
||||||
elif operation == "SET":
|
|
||||||
await redis.execute("SET", key, value)
|
|
||||||
elif operation == "SETEX":
|
|
||||||
await redis.execute("SETEX", key, ttl or CACHE_TTL, value)
|
|
||||||
elif operation == "DEL":
|
|
||||||
await redis.execute("DEL", key)
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Redis {operation} error for key {key}: {e}")
|
|
||||||
|
|
||||||
|
|
||||||
async def get_cached_entity(entity_type: str, entity_id: int, get_method, cache_method):
|
async def get_cached_entity(entity_type: str, entity_id: int, get_method, cache_method):
|
||||||
@@ -465,9 +477,9 @@ async def get_cached_entity(entity_type: str, entity_id: int, get_method, cache_
|
|||||||
cache_method: метод кэширования
|
cache_method: метод кэширования
|
||||||
"""
|
"""
|
||||||
key = f"{entity_type}:id:{entity_id}"
|
key = f"{entity_type}:id:{entity_id}"
|
||||||
cached = await redis_operation("GET", key)
|
cached = await redis.execute("GET", key)
|
||||||
if cached:
|
if cached:
|
||||||
return json.loads(cached)
|
return orjson.loads(cached)
|
||||||
|
|
||||||
entity = await get_method(entity_id)
|
entity = await get_method(entity_id)
|
||||||
if entity:
|
if entity:
|
||||||
@@ -496,3 +508,121 @@ async def cache_by_id(entity, entity_id: int, cache_method):
|
|||||||
d = x.dict()
|
d = x.dict()
|
||||||
await cache_method(d)
|
await cache_method(d)
|
||||||
return d
|
return d
|
||||||
|
|
||||||
|
|
||||||
|
# Универсальная функция для сохранения данных в кеш
|
||||||
|
async def cache_data(key: str, data: Any, ttl: Optional[int] = None) -> None:
|
||||||
|
"""
|
||||||
|
Сохраняет данные в кеш по указанному ключу.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
key: Ключ кеша
|
||||||
|
data: Данные для сохранения
|
||||||
|
ttl: Время жизни кеша в секундах (None - бессрочно)
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
payload = json.dumps(data, cls=CustomJSONEncoder)
|
||||||
|
if ttl:
|
||||||
|
await redis.execute("SETEX", key, ttl, payload)
|
||||||
|
else:
|
||||||
|
await redis.execute("SET", key, payload)
|
||||||
|
logger.debug(f"Данные сохранены в кеш по ключу {key}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Ошибка при сохранении данных в кеш: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
# Универсальная функция для получения данных из кеша
|
||||||
|
async def get_cached_data(key: str) -> Optional[Any]:
|
||||||
|
"""
|
||||||
|
Получает данные из кеша по указанному ключу.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
key: Ключ кеша
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Any: Данные из кеша или None, если данных нет
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
cached_data = await redis.execute("GET", key)
|
||||||
|
if cached_data:
|
||||||
|
loaded = orjson.loads(cached_data)
|
||||||
|
logger.debug(f"Данные получены из кеша по ключу {key}: {len(loaded)}")
|
||||||
|
return loaded
|
||||||
|
return None
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Ошибка при получении данных из кеша: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# Универсальная функция для инвалидации кеша по префиксу
|
||||||
|
async def invalidate_cache_by_prefix(prefix: str) -> None:
|
||||||
|
"""
|
||||||
|
Инвалидирует все ключи кеша с указанным префиксом.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
prefix: Префикс ключей кеша для инвалидации
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
keys = await redis.execute("KEYS", f"{prefix}:*")
|
||||||
|
if keys:
|
||||||
|
await redis.execute("DEL", *keys)
|
||||||
|
logger.debug(f"Удалено {len(keys)} ключей кеша с префиксом {prefix}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Ошибка при инвалидации кеша: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
# Универсальная функция для получения и кеширования данных
|
||||||
|
async def cached_query(
|
||||||
|
cache_key: str,
|
||||||
|
query_func: callable,
|
||||||
|
ttl: Optional[int] = None,
|
||||||
|
force_refresh: bool = False,
|
||||||
|
use_key_format: bool = True,
|
||||||
|
**query_params,
|
||||||
|
) -> Any:
|
||||||
|
"""
|
||||||
|
Gets data from cache or executes query and saves result to cache.
|
||||||
|
Supports existing key formats for compatibility.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
cache_key: Cache key or key template from CACHE_KEYS
|
||||||
|
query_func: Function to execute the query
|
||||||
|
ttl: Cache TTL in seconds (None - indefinite)
|
||||||
|
force_refresh: Force cache refresh
|
||||||
|
use_key_format: Whether to check if cache_key matches a key template in CACHE_KEYS
|
||||||
|
**query_params: Parameters to pass to the query function
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Any: Data from cache or query result
|
||||||
|
"""
|
||||||
|
# Check if cache_key matches a pattern in CACHE_KEYS
|
||||||
|
actual_key = cache_key
|
||||||
|
if use_key_format and "{}" in cache_key:
|
||||||
|
# Look for a template match in CACHE_KEYS
|
||||||
|
for key_name, key_format in CACHE_KEYS.items():
|
||||||
|
if cache_key == key_format:
|
||||||
|
# We have a match, now look for the id or value to format with
|
||||||
|
for param_name, param_value in query_params.items():
|
||||||
|
if param_name in ["id", "slug", "user", "topic_id", "author_id"]:
|
||||||
|
actual_key = cache_key.format(param_value)
|
||||||
|
break
|
||||||
|
|
||||||
|
# If not forcing refresh, try to get data from cache
|
||||||
|
if not force_refresh:
|
||||||
|
cached_result = await get_cached_data(actual_key)
|
||||||
|
if cached_result is not None:
|
||||||
|
return cached_result
|
||||||
|
|
||||||
|
# If data not in cache or refresh required, execute query
|
||||||
|
try:
|
||||||
|
result = await query_func(**query_params)
|
||||||
|
if result is not None:
|
||||||
|
# Save result to cache
|
||||||
|
await cache_data(actual_key, result, ttl)
|
||||||
|
return result
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error executing query for caching: {e}")
|
||||||
|
# In case of error, return data from cache if not forcing refresh
|
||||||
|
if not force_refresh:
|
||||||
|
return await get_cached_data(actual_key)
|
||||||
|
raise
|
||||||
|
11
cache/memorycache.py
vendored
11
cache/memorycache.py
vendored
@@ -1,11 +0,0 @@
|
|||||||
from dogpile.cache import make_region
|
|
||||||
|
|
||||||
from settings import REDIS_URL
|
|
||||||
|
|
||||||
# Создание региона кэша с TTL
|
|
||||||
cache_region = make_region()
|
|
||||||
cache_region.configure(
|
|
||||||
"dogpile.cache.redis",
|
|
||||||
arguments={"url": f"{REDIS_URL}/1"},
|
|
||||||
expiration_time=3600, # Cache expiration time in seconds
|
|
||||||
)
|
|
26
cache/precache.py
vendored
26
cache/precache.py
vendored
@@ -77,37 +77,51 @@ async def precache_topics_followers(topic_id: int, session):
|
|||||||
|
|
||||||
async def precache_data():
|
async def precache_data():
|
||||||
logger.info("precaching...")
|
logger.info("precaching...")
|
||||||
|
logger.debug("Entering precache_data")
|
||||||
try:
|
try:
|
||||||
key = "authorizer_env"
|
key = "authorizer_env"
|
||||||
|
logger.debug(f"Fetching existing hash for key '{key}' from Redis")
|
||||||
# cache reset
|
# cache reset
|
||||||
value = await redis.execute("HGETALL", key)
|
value = await redis.execute("HGETALL", key)
|
||||||
|
logger.debug(f"Fetched value for '{key}': {value}")
|
||||||
await redis.execute("FLUSHDB")
|
await redis.execute("FLUSHDB")
|
||||||
|
logger.debug("Redis database flushed")
|
||||||
logger.info("redis: FLUSHDB")
|
logger.info("redis: FLUSHDB")
|
||||||
|
|
||||||
# Преобразуем словарь в список аргументов для HSET
|
# Преобразуем словарь в список аргументов для HSET
|
||||||
if value:
|
if value:
|
||||||
flattened = []
|
# Если значение - словарь, преобразуем его в плоский список для HSET
|
||||||
for field, val in value.items():
|
if isinstance(value, dict):
|
||||||
flattened.extend([field, val])
|
flattened = []
|
||||||
|
for field, val in value.items():
|
||||||
await redis.execute("HSET", key, *flattened)
|
flattened.extend([field, val])
|
||||||
|
await redis.execute("HSET", key, *flattened)
|
||||||
|
else:
|
||||||
|
# Предполагаем, что значение уже содержит список
|
||||||
|
await redis.execute("HSET", key, *value)
|
||||||
logger.info(f"redis hash '{key}' was restored")
|
logger.info(f"redis hash '{key}' was restored")
|
||||||
|
|
||||||
|
logger.info("Beginning topic precache phase")
|
||||||
with local_session() as session:
|
with local_session() as session:
|
||||||
# topics
|
# topics
|
||||||
q = select(Topic).where(Topic.community == 1)
|
q = select(Topic).where(Topic.community == 1)
|
||||||
topics = get_with_stat(q)
|
topics = get_with_stat(q)
|
||||||
|
logger.info(f"Found {len(topics)} topics to precache")
|
||||||
for topic in topics:
|
for topic in topics:
|
||||||
topic_dict = topic.dict() if hasattr(topic, "dict") else topic
|
topic_dict = topic.dict() if hasattr(topic, "dict") else topic
|
||||||
|
logger.debug(f"Precaching topic id={topic_dict.get('id')}")
|
||||||
await cache_topic(topic_dict)
|
await cache_topic(topic_dict)
|
||||||
|
logger.debug(f"Cached topic id={topic_dict.get('id')}")
|
||||||
await asyncio.gather(
|
await asyncio.gather(
|
||||||
precache_topics_followers(topic_dict["id"], session),
|
precache_topics_followers(topic_dict["id"], session),
|
||||||
precache_topics_authors(topic_dict["id"], session),
|
precache_topics_authors(topic_dict["id"], session),
|
||||||
)
|
)
|
||||||
|
logger.debug(f"Finished precaching followers and authors for topic id={topic_dict.get('id')}")
|
||||||
logger.info(f"{len(topics)} topics and their followings precached")
|
logger.info(f"{len(topics)} topics and their followings precached")
|
||||||
|
|
||||||
# authors
|
# authors
|
||||||
authors = get_with_stat(select(Author).where(Author.user.is_not(None)))
|
authors = get_with_stat(select(Author).where(Author.user.is_not(None)))
|
||||||
|
logger.info(f"Found {len(authors)} authors to precache")
|
||||||
logger.info(f"{len(authors)} authors found in database")
|
logger.info(f"{len(authors)} authors found in database")
|
||||||
for author in authors:
|
for author in authors:
|
||||||
if isinstance(author, Author):
|
if isinstance(author, Author):
|
||||||
@@ -115,10 +129,12 @@ async def precache_data():
|
|||||||
author_id = profile.get("id")
|
author_id = profile.get("id")
|
||||||
user_id = profile.get("user", "").strip()
|
user_id = profile.get("user", "").strip()
|
||||||
if author_id and user_id:
|
if author_id and user_id:
|
||||||
|
logger.debug(f"Precaching author id={author_id}")
|
||||||
await cache_author(profile)
|
await cache_author(profile)
|
||||||
await asyncio.gather(
|
await asyncio.gather(
|
||||||
precache_authors_followers(author_id, session), precache_authors_follows(author_id, session)
|
precache_authors_followers(author_id, session), precache_authors_follows(author_id, session)
|
||||||
)
|
)
|
||||||
|
logger.debug(f"Finished precaching followers and follows for author id={author_id}")
|
||||||
else:
|
else:
|
||||||
logger.error(f"fail caching {author}")
|
logger.error(f"fail caching {author}")
|
||||||
logger.info(f"{len(authors)} authors and their followings precached")
|
logger.info(f"{len(authors)} authors and their followings precached")
|
||||||
|
137
cache/revalidator.py
vendored
137
cache/revalidator.py
vendored
@@ -1,20 +1,40 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
|
|
||||||
from cache.cache import cache_author, cache_topic, get_cached_author, get_cached_topic
|
from cache.cache import (
|
||||||
|
cache_author,
|
||||||
|
cache_topic,
|
||||||
|
get_cached_author,
|
||||||
|
get_cached_topic,
|
||||||
|
invalidate_cache_by_prefix,
|
||||||
|
)
|
||||||
from resolvers.stat import get_with_stat
|
from resolvers.stat import get_with_stat
|
||||||
|
from services.redis import redis
|
||||||
from utils.logger import root_logger as logger
|
from utils.logger import root_logger as logger
|
||||||
|
|
||||||
|
CACHE_REVALIDATION_INTERVAL = 300 # 5 minutes
|
||||||
|
|
||||||
|
|
||||||
class CacheRevalidationManager:
|
class CacheRevalidationManager:
|
||||||
def __init__(self, interval=60):
|
def __init__(self, interval=CACHE_REVALIDATION_INTERVAL):
|
||||||
"""Инициализация менеджера с заданным интервалом проверки (в секундах)."""
|
"""Инициализация менеджера с заданным интервалом проверки (в секундах)."""
|
||||||
self.interval = interval
|
self.interval = interval
|
||||||
self.items_to_revalidate = {"authors": set(), "topics": set(), "shouts": set(), "reactions": set()}
|
self.items_to_revalidate = {"authors": set(), "topics": set(), "shouts": set(), "reactions": set()}
|
||||||
self.lock = asyncio.Lock()
|
self.lock = asyncio.Lock()
|
||||||
self.running = True
|
self.running = True
|
||||||
|
self.MAX_BATCH_SIZE = 10 # Максимальное количество элементов для поштучной обработки
|
||||||
|
self._redis = redis # Добавлена инициализация _redis для доступа к Redis-клиенту
|
||||||
|
|
||||||
async def start(self):
|
async def start(self):
|
||||||
"""Запуск фонового воркера для ревалидации кэша."""
|
"""Запуск фонового воркера для ревалидации кэша."""
|
||||||
|
# Проверяем, что у нас есть соединение с Redis
|
||||||
|
if not self._redis._client:
|
||||||
|
logger.warning("Redis connection not established. Waiting for connection...")
|
||||||
|
try:
|
||||||
|
await self._redis.connect()
|
||||||
|
logger.info("Redis connection established for revalidation manager")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to connect to Redis: {e}")
|
||||||
|
|
||||||
self.task = asyncio.create_task(self.revalidate_cache())
|
self.task = asyncio.create_task(self.revalidate_cache())
|
||||||
|
|
||||||
async def revalidate_cache(self):
|
async def revalidate_cache(self):
|
||||||
@@ -30,24 +50,113 @@ class CacheRevalidationManager:
|
|||||||
|
|
||||||
async def process_revalidation(self):
|
async def process_revalidation(self):
|
||||||
"""Обновление кэша для всех сущностей, требующих ревалидации."""
|
"""Обновление кэша для всех сущностей, требующих ревалидации."""
|
||||||
|
# Проверяем соединение с Redis
|
||||||
|
if not self._redis._client:
|
||||||
|
return # Выходим из метода, если не удалось подключиться
|
||||||
|
|
||||||
async with self.lock:
|
async with self.lock:
|
||||||
# Ревалидация кэша авторов
|
# Ревалидация кэша авторов
|
||||||
for author_id in self.items_to_revalidate["authors"]:
|
if self.items_to_revalidate["authors"]:
|
||||||
author = await get_cached_author(author_id, get_with_stat)
|
logger.debug(f"Revalidating {len(self.items_to_revalidate['authors'])} authors")
|
||||||
if author:
|
for author_id in self.items_to_revalidate["authors"]:
|
||||||
await cache_author(author)
|
if author_id == "all":
|
||||||
self.items_to_revalidate["authors"].clear()
|
await invalidate_cache_by_prefix("authors")
|
||||||
|
break
|
||||||
|
author = await get_cached_author(author_id, get_with_stat)
|
||||||
|
if author:
|
||||||
|
await cache_author(author)
|
||||||
|
self.items_to_revalidate["authors"].clear()
|
||||||
|
|
||||||
# Ревалидация кэша тем
|
# Ревалидация кэша тем
|
||||||
for topic_id in self.items_to_revalidate["topics"]:
|
if self.items_to_revalidate["topics"]:
|
||||||
topic = await get_cached_topic(topic_id)
|
logger.debug(f"Revalidating {len(self.items_to_revalidate['topics'])} topics")
|
||||||
if topic:
|
for topic_id in self.items_to_revalidate["topics"]:
|
||||||
await cache_topic(topic)
|
if topic_id == "all":
|
||||||
self.items_to_revalidate["topics"].clear()
|
await invalidate_cache_by_prefix("topics")
|
||||||
|
break
|
||||||
|
topic = await get_cached_topic(topic_id)
|
||||||
|
if topic:
|
||||||
|
await cache_topic(topic)
|
||||||
|
self.items_to_revalidate["topics"].clear()
|
||||||
|
|
||||||
|
# Ревалидация шаутов (публикаций)
|
||||||
|
if self.items_to_revalidate["shouts"]:
|
||||||
|
shouts_count = len(self.items_to_revalidate["shouts"])
|
||||||
|
logger.debug(f"Revalidating {shouts_count} shouts")
|
||||||
|
|
||||||
|
# Проверяем наличие специального флага 'all'
|
||||||
|
if "all" in self.items_to_revalidate["shouts"]:
|
||||||
|
await invalidate_cache_by_prefix("shouts")
|
||||||
|
# Если элементов много, но не 'all', используем специфический подход
|
||||||
|
elif shouts_count > self.MAX_BATCH_SIZE:
|
||||||
|
# Инвалидируем только collections keys, которые затрагивают много сущностей
|
||||||
|
collection_keys = await asyncio.create_task(self._redis.execute("KEYS", "shouts:*"))
|
||||||
|
if collection_keys:
|
||||||
|
await self._redis.execute("DEL", *collection_keys)
|
||||||
|
logger.debug(f"Удалено {len(collection_keys)} коллекционных ключей шаутов")
|
||||||
|
|
||||||
|
# Обновляем кеш каждого конкретного шаута
|
||||||
|
for shout_id in self.items_to_revalidate["shouts"]:
|
||||||
|
if shout_id != "all":
|
||||||
|
# Точечная инвалидация для каждого shout_id
|
||||||
|
specific_keys = [f"shout:id:{shout_id}"]
|
||||||
|
for key in specific_keys:
|
||||||
|
await self._redis.execute("DEL", key)
|
||||||
|
logger.debug(f"Удален ключ кеша {key}")
|
||||||
|
else:
|
||||||
|
# Если элементов немного, обрабатываем каждый
|
||||||
|
for shout_id in self.items_to_revalidate["shouts"]:
|
||||||
|
if shout_id != "all":
|
||||||
|
# Точечная инвалидация для каждого shout_id
|
||||||
|
specific_keys = [f"shout:id:{shout_id}"]
|
||||||
|
for key in specific_keys:
|
||||||
|
await self._redis.execute("DEL", key)
|
||||||
|
logger.debug(f"Удален ключ кеша {key}")
|
||||||
|
|
||||||
|
self.items_to_revalidate["shouts"].clear()
|
||||||
|
|
||||||
|
# Аналогично для реакций - точечная инвалидация
|
||||||
|
if self.items_to_revalidate["reactions"]:
|
||||||
|
reactions_count = len(self.items_to_revalidate["reactions"])
|
||||||
|
logger.debug(f"Revalidating {reactions_count} reactions")
|
||||||
|
|
||||||
|
if "all" in self.items_to_revalidate["reactions"]:
|
||||||
|
await invalidate_cache_by_prefix("reactions")
|
||||||
|
elif reactions_count > self.MAX_BATCH_SIZE:
|
||||||
|
# Инвалидируем только collections keys для реакций
|
||||||
|
collection_keys = await asyncio.create_task(self._redis.execute("KEYS", "reactions:*"))
|
||||||
|
if collection_keys:
|
||||||
|
await self._redis.execute("DEL", *collection_keys)
|
||||||
|
logger.debug(f"Удалено {len(collection_keys)} коллекционных ключей реакций")
|
||||||
|
|
||||||
|
# Точечная инвалидация для каждой реакции
|
||||||
|
for reaction_id in self.items_to_revalidate["reactions"]:
|
||||||
|
if reaction_id != "all":
|
||||||
|
specific_keys = [f"reaction:id:{reaction_id}"]
|
||||||
|
for key in specific_keys:
|
||||||
|
await self._redis.execute("DEL", key)
|
||||||
|
logger.debug(f"Удален ключ кеша {key}")
|
||||||
|
else:
|
||||||
|
# Точечная инвалидация для каждой реакции
|
||||||
|
for reaction_id in self.items_to_revalidate["reactions"]:
|
||||||
|
if reaction_id != "all":
|
||||||
|
specific_keys = [f"reaction:id:{reaction_id}"]
|
||||||
|
for key in specific_keys:
|
||||||
|
await self._redis.execute("DEL", key)
|
||||||
|
logger.debug(f"Удален ключ кеша {key}")
|
||||||
|
|
||||||
|
self.items_to_revalidate["reactions"].clear()
|
||||||
|
|
||||||
def mark_for_revalidation(self, entity_id, entity_type):
|
def mark_for_revalidation(self, entity_id, entity_type):
|
||||||
"""Отметить сущность для ревалидации."""
|
"""Отметить сущность для ревалидации."""
|
||||||
self.items_to_revalidate[entity_type].add(entity_id)
|
if entity_id and entity_type:
|
||||||
|
self.items_to_revalidate[entity_type].add(entity_id)
|
||||||
|
|
||||||
|
def invalidate_all(self, entity_type):
|
||||||
|
"""Пометить для инвалидации все элементы указанного типа."""
|
||||||
|
logger.debug(f"Marking all {entity_type} for invalidation")
|
||||||
|
# Особый флаг для полной инвалидации
|
||||||
|
self.items_to_revalidate[entity_type].add("all")
|
||||||
|
|
||||||
async def stop(self):
|
async def stop(self):
|
||||||
"""Остановка фонового воркера."""
|
"""Остановка фонового воркера."""
|
||||||
@@ -60,4 +169,4 @@ class CacheRevalidationManager:
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
revalidation_manager = CacheRevalidationManager(interval=300) # Ревалидация каждые 5 минут
|
revalidation_manager = CacheRevalidationManager()
|
||||||
|
295
docs/caching.md
Normal file
295
docs/caching.md
Normal file
@@ -0,0 +1,295 @@
|
|||||||
|
# Система кеширования Discours
|
||||||
|
|
||||||
|
## Общее описание
|
||||||
|
|
||||||
|
Система кеширования Discours - это комплексное решение для повышения производительности платформы. Она использует Redis для хранения часто запрашиваемых данных и уменьшения нагрузки на основную базу данных.
|
||||||
|
|
||||||
|
Кеширование реализовано как многоуровневая система, состоящая из нескольких модулей:
|
||||||
|
|
||||||
|
- `cache.py` - основной модуль с функциями кеширования
|
||||||
|
- `revalidator.py` - асинхронный менеджер ревалидации кеша
|
||||||
|
- `triggers.py` - триггеры событий SQLAlchemy для автоматической ревалидации
|
||||||
|
- `precache.py` - предварительное кеширование данных при старте приложения
|
||||||
|
|
||||||
|
## Ключевые компоненты
|
||||||
|
|
||||||
|
### 1. Форматы ключей кеша
|
||||||
|
|
||||||
|
Система поддерживает несколько форматов ключей для обеспечения совместимости и удобства использования:
|
||||||
|
|
||||||
|
- **Ключи сущностей**: `entity:property:value` (например, `author:id:123`)
|
||||||
|
- **Ключи коллекций**: `entity:collection:params` (например, `authors:stats:limit=10:offset=0`)
|
||||||
|
- **Специальные ключи**: для обратной совместимости (например, `topic_shouts_123`)
|
||||||
|
|
||||||
|
Все стандартные форматы ключей хранятся в словаре `CACHE_KEYS`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
CACHE_KEYS = {
|
||||||
|
"TOPIC_ID": "topic:id:{}",
|
||||||
|
"TOPIC_SLUG": "topic:slug:{}",
|
||||||
|
"AUTHOR_ID": "author:id:{}",
|
||||||
|
# и другие...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Основные функции кеширования
|
||||||
|
|
||||||
|
#### Структура ключей
|
||||||
|
|
||||||
|
Вместо генерации ключей через вспомогательные функции, система следует строгим конвенциям формирования ключей:
|
||||||
|
|
||||||
|
1. **Ключи для отдельных сущностей** строятся по шаблону:
|
||||||
|
```
|
||||||
|
entity:property:value
|
||||||
|
```
|
||||||
|
Например:
|
||||||
|
- `topic:id:123` - тема с ID 123
|
||||||
|
- `author:slug:john-doe` - автор со слагом "john-doe"
|
||||||
|
- `shout:id:456` - публикация с ID 456
|
||||||
|
|
||||||
|
2. **Ключи для коллекций** строятся по шаблону:
|
||||||
|
```
|
||||||
|
entity:collection[:filter1=value1:filter2=value2:...]
|
||||||
|
```
|
||||||
|
Например:
|
||||||
|
- `topics:all:basic` - базовый список всех тем
|
||||||
|
- `authors:stats:limit=10:offset=0:sort=name` - отсортированный список авторов с пагинацией
|
||||||
|
- `shouts:feed:limit=20:community=1` - лента публикаций с фильтром по сообществу
|
||||||
|
|
||||||
|
3. **Специальные форматы ключей** для обратной совместимости:
|
||||||
|
```
|
||||||
|
entity_action_id
|
||||||
|
```
|
||||||
|
Например:
|
||||||
|
- `topic_shouts_123` - публикации для темы с ID 123
|
||||||
|
|
||||||
|
Во всех модулях системы разработчики должны явно формировать ключи в соответствии с этими конвенциями, что обеспечивает единообразие и предсказуемость кеширования.
|
||||||
|
|
||||||
|
#### Работа с данными в кеше
|
||||||
|
|
||||||
|
```python
|
||||||
|
async def cache_data(key, data, ttl=None)
|
||||||
|
async def get_cached_data(key)
|
||||||
|
```
|
||||||
|
|
||||||
|
Эти функции предоставляют универсальный интерфейс для сохранения и получения данных из кеша. Они напрямую используют Redis через вызовы `redis.execute()`.
|
||||||
|
|
||||||
|
#### Высокоуровневое кеширование запросов
|
||||||
|
|
||||||
|
```python
|
||||||
|
async def cached_query(cache_key, query_func, ttl=None, force_refresh=False, **query_params)
|
||||||
|
```
|
||||||
|
|
||||||
|
Функция `cached_query` объединяет получение данных из кеша и выполнение запроса в случае отсутствия данных в кеше. Это основная функция, которую следует использовать в резолверах для кеширования результатов запросов.
|
||||||
|
|
||||||
|
### 3. Кеширование сущностей
|
||||||
|
|
||||||
|
Для основных типов сущностей реализованы специальные функции:
|
||||||
|
|
||||||
|
```python
|
||||||
|
async def cache_topic(topic: dict)
|
||||||
|
async def cache_author(author: dict)
|
||||||
|
async def get_cached_topic(topic_id: int)
|
||||||
|
async def get_cached_author(author_id: int, get_with_stat)
|
||||||
|
```
|
||||||
|
|
||||||
|
Эти функции упрощают работу с часто используемыми типами данных и обеспечивают единообразный подход к их кешированию.
|
||||||
|
|
||||||
|
### 4. Работа со связями
|
||||||
|
|
||||||
|
Для работы со связями между сущностями предназначены функции:
|
||||||
|
|
||||||
|
```python
|
||||||
|
async def cache_follows(follower_id, entity_type, entity_id, is_insert=True)
|
||||||
|
async def get_cached_topic_followers(topic_id)
|
||||||
|
async def get_cached_author_followers(author_id)
|
||||||
|
async def get_cached_follower_topics(author_id)
|
||||||
|
```
|
||||||
|
|
||||||
|
Они позволяют эффективно кешировать и получать информацию о подписках, связях между авторами, темами и публикациями.
|
||||||
|
|
||||||
|
## Система инвалидации кеша
|
||||||
|
|
||||||
|
### 1. Прямая инвалидация
|
||||||
|
|
||||||
|
Система поддерживает два типа инвалидации кеша:
|
||||||
|
|
||||||
|
#### 1.1. Инвалидация по префиксу
|
||||||
|
|
||||||
|
```python
|
||||||
|
async def invalidate_cache_by_prefix(prefix)
|
||||||
|
```
|
||||||
|
|
||||||
|
Позволяет инвалидировать все ключи кеша, начинающиеся с указанного префикса. Используется в резолверах для инвалидации группы кешей при массовых изменениях.
|
||||||
|
|
||||||
|
#### 1.2. Точечная инвалидация
|
||||||
|
|
||||||
|
```python
|
||||||
|
async def invalidate_authors_cache(author_id=None)
|
||||||
|
async def invalidate_topics_cache(topic_id=None)
|
||||||
|
```
|
||||||
|
|
||||||
|
Эти функции позволяют инвалидировать кеш только для конкретной сущности, что снижает нагрузку на Redis и предотвращает ненужную потерю кешированных данных. Если ID сущности не указан, используется инвалидация по префиксу.
|
||||||
|
|
||||||
|
Примеры использования точечной инвалидации:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Инвалидация кеша только для автора с ID 123
|
||||||
|
await invalidate_authors_cache(123)
|
||||||
|
|
||||||
|
# Инвалидация кеша только для темы с ID 456
|
||||||
|
await invalidate_topics_cache(456)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Отложенная инвалидация
|
||||||
|
|
||||||
|
Модуль `revalidator.py` реализует систему отложенной инвалидации кеша через класс `CacheRevalidationManager`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
class CacheRevalidationManager:
|
||||||
|
def __init__(self, interval=CACHE_REVALIDATION_INTERVAL):
|
||||||
|
# ...
|
||||||
|
self._redis = redis # Прямая ссылка на сервис Redis
|
||||||
|
|
||||||
|
async def start(self):
|
||||||
|
# Проверка и установка соединения с Redis
|
||||||
|
# ...
|
||||||
|
|
||||||
|
async def process_revalidation(self):
|
||||||
|
# Обработка элементов для ревалидации
|
||||||
|
# ...
|
||||||
|
|
||||||
|
def mark_for_revalidation(self, entity_id, entity_type):
|
||||||
|
# Добавляет сущность в очередь на ревалидацию
|
||||||
|
# ...
|
||||||
|
```
|
||||||
|
|
||||||
|
Менеджер ревалидации работает как асинхронный фоновый процесс, который периодически (по умолчанию каждые 5 минут) проверяет наличие сущностей для ревалидации.
|
||||||
|
|
||||||
|
**Взаимодействие с Redis:**
|
||||||
|
- CacheRevalidationManager хранит прямую ссылку на сервис Redis через атрибут `_redis`
|
||||||
|
- При запуске проверяется наличие соединения с Redis и при необходимости устанавливается новое
|
||||||
|
- Включена автоматическая проверка соединения перед каждой операцией ревалидации
|
||||||
|
- Система самостоятельно восстанавливает соединение при его потере
|
||||||
|
|
||||||
|
**Особенности реализации:**
|
||||||
|
- Для авторов и тем используется поштучная ревалидация каждой записи
|
||||||
|
- Для шаутов и реакций используется батчевая обработка, с порогом в 10 элементов
|
||||||
|
- При достижении порога система переключается на инвалидацию коллекций вместо поштучной обработки
|
||||||
|
- Специальный флаг `all` позволяет запустить полную инвалидацию всех записей типа
|
||||||
|
|
||||||
|
### 3. Автоматическая инвалидация через триггеры
|
||||||
|
|
||||||
|
Модуль `triggers.py` регистрирует обработчики событий SQLAlchemy, которые автоматически отмечают сущности для ревалидации при изменении данных в базе:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def events_register():
|
||||||
|
event.listen(Author, "after_update", mark_for_revalidation)
|
||||||
|
event.listen(Topic, "after_update", mark_for_revalidation)
|
||||||
|
# и другие...
|
||||||
|
```
|
||||||
|
|
||||||
|
Триггеры имеют следующие особенности:
|
||||||
|
- Реагируют на события вставки, обновления и удаления
|
||||||
|
- Отмечают затронутые сущности для отложенной ревалидации
|
||||||
|
- Учитывают связи между сущностями (например, при изменении темы обновляются связанные шауты)
|
||||||
|
|
||||||
|
## Предварительное кеширование
|
||||||
|
|
||||||
|
Модуль `precache.py` реализует предварительное кеширование часто используемых данных при старте приложения:
|
||||||
|
|
||||||
|
```python
|
||||||
|
async def precache_data():
|
||||||
|
# ...
|
||||||
|
```
|
||||||
|
|
||||||
|
Эта функция выполняется при запуске приложения и заполняет кеш данными, которые будут часто запрашиваться пользователями.
|
||||||
|
|
||||||
|
## Примеры использования
|
||||||
|
|
||||||
|
### Простое кеширование результата запроса
|
||||||
|
|
||||||
|
```python
|
||||||
|
async def get_topics_with_stats(limit=10, offset=0, by="title"):
|
||||||
|
# Формирование ключа кеша по конвенции
|
||||||
|
cache_key = f"topics:stats:limit={limit}:offset={offset}:sort={by}"
|
||||||
|
|
||||||
|
cached_data = await get_cached_data(cache_key)
|
||||||
|
if cached_data:
|
||||||
|
return cached_data
|
||||||
|
|
||||||
|
# Выполнение запроса к базе данных
|
||||||
|
result = ... # логика получения данных
|
||||||
|
|
||||||
|
await cache_data(cache_key, result, ttl=300)
|
||||||
|
return result
|
||||||
|
```
|
||||||
|
|
||||||
|
### Использование обобщенной функции cached_query
|
||||||
|
|
||||||
|
```python
|
||||||
|
async def get_topics_with_stats(limit=10, offset=0, by="title"):
|
||||||
|
async def fetch_data(limit, offset, by):
|
||||||
|
# Логика получения данных
|
||||||
|
return result
|
||||||
|
|
||||||
|
# Формирование ключа кеша по конвенции
|
||||||
|
cache_key = f"topics:stats:limit={limit}:offset={offset}:sort={by}"
|
||||||
|
|
||||||
|
return await cached_query(
|
||||||
|
cache_key,
|
||||||
|
fetch_data,
|
||||||
|
ttl=300,
|
||||||
|
limit=limit,
|
||||||
|
offset=offset,
|
||||||
|
by=by
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Точечная инвалидация кеша при изменении данных
|
||||||
|
|
||||||
|
```python
|
||||||
|
async def update_topic(topic_id, new_data):
|
||||||
|
# Обновление данных в базе
|
||||||
|
# ...
|
||||||
|
|
||||||
|
# Точечная инвалидация кеша только для измененной темы
|
||||||
|
await invalidate_topics_cache(topic_id)
|
||||||
|
|
||||||
|
return updated_topic
|
||||||
|
```
|
||||||
|
|
||||||
|
## Отладка и мониторинг
|
||||||
|
|
||||||
|
Система кеширования использует логгер для отслеживания операций:
|
||||||
|
|
||||||
|
```python
|
||||||
|
logger.debug(f"Данные получены из кеша по ключу {key}")
|
||||||
|
logger.debug(f"Удалено {len(keys)} ключей кеша с префиксом {prefix}")
|
||||||
|
logger.error(f"Ошибка при инвалидации кеша: {e}")
|
||||||
|
```
|
||||||
|
|
||||||
|
Это позволяет отслеживать работу кеша и выявлять возможные проблемы на ранних стадиях.
|
||||||
|
|
||||||
|
## Рекомендации по использованию
|
||||||
|
|
||||||
|
1. **Следуйте конвенциям формирования ключей** - это критически важно для консистентности и предсказуемости кеша.
|
||||||
|
2. **Не создавайте собственные форматы ключей** - используйте существующие шаблоны для обеспечения единообразия.
|
||||||
|
3. **Не забывайте об инвалидации** - всегда инвалидируйте кеш при изменении данных.
|
||||||
|
4. **Используйте точечную инвалидацию** - вместо инвалидации по префиксу для снижения нагрузки на Redis.
|
||||||
|
5. **Устанавливайте разумные TTL** - используйте разные значения TTL в зависимости от частоты изменения данных.
|
||||||
|
6. **Не кешируйте большие объемы данных** - кешируйте только то, что действительно необходимо для повышения производительности.
|
||||||
|
|
||||||
|
## Технические детали реализации
|
||||||
|
|
||||||
|
- **Сериализация данных**: используется `orjson` для эффективной сериализации и десериализации данных.
|
||||||
|
- **Форматирование даты и времени**: для корректной работы с датами используется `CustomJSONEncoder`.
|
||||||
|
- **Асинхронность**: все операции кеширования выполняются асинхронно для минимального влияния на производительность API.
|
||||||
|
- **Прямое взаимодействие с Redis**: все операции выполняются через прямые вызовы `redis.execute()` с обработкой ошибок.
|
||||||
|
- **Батчевая обработка**: для массовых операций используется пороговое значение, после которого применяются оптимизированные стратегии.
|
||||||
|
|
||||||
|
## Известные ограничения
|
||||||
|
|
||||||
|
1. **Согласованность данных** - система не гарантирует абсолютную согласованность данных в кеше и базе данных.
|
||||||
|
2. **Память** - необходимо следить за объемом данных в кеше, чтобы избежать проблем с памятью Redis.
|
||||||
|
3. **Производительность Redis** - при большом количестве операций с кешем может стать узким местом.
|
165
docs/comments-pagination.md
Normal file
165
docs/comments-pagination.md
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
# Пагинация комментариев
|
||||||
|
|
||||||
|
## Обзор
|
||||||
|
|
||||||
|
Реализована система пагинации комментариев по веткам, которая позволяет эффективно загружать и отображать вложенные ветки обсуждений. Основные преимущества:
|
||||||
|
|
||||||
|
1. Загрузка только необходимых комментариев, а не всего дерева
|
||||||
|
2. Снижение нагрузки на сервер и клиент
|
||||||
|
3. Возможность эффективной навигации по большим обсуждениям
|
||||||
|
4. Предзагрузка первых N ответов для улучшения UX
|
||||||
|
|
||||||
|
## API для иерархической загрузки комментариев
|
||||||
|
|
||||||
|
### GraphQL запрос `load_comments_branch`
|
||||||
|
|
||||||
|
```graphql
|
||||||
|
query LoadCommentsBranch(
|
||||||
|
$shout: Int!,
|
||||||
|
$parentId: Int,
|
||||||
|
$limit: Int,
|
||||||
|
$offset: Int,
|
||||||
|
$sort: ReactionSort,
|
||||||
|
$childrenLimit: Int,
|
||||||
|
$childrenOffset: Int
|
||||||
|
) {
|
||||||
|
load_comments_branch(
|
||||||
|
shout: $shout,
|
||||||
|
parent_id: $parentId,
|
||||||
|
limit: $limit,
|
||||||
|
offset: $offset,
|
||||||
|
sort: $sort,
|
||||||
|
children_limit: $childrenLimit,
|
||||||
|
children_offset: $childrenOffset
|
||||||
|
) {
|
||||||
|
id
|
||||||
|
body
|
||||||
|
created_at
|
||||||
|
created_by {
|
||||||
|
id
|
||||||
|
name
|
||||||
|
slug
|
||||||
|
pic
|
||||||
|
}
|
||||||
|
kind
|
||||||
|
reply_to
|
||||||
|
stat {
|
||||||
|
rating
|
||||||
|
comments_count
|
||||||
|
}
|
||||||
|
first_replies {
|
||||||
|
id
|
||||||
|
body
|
||||||
|
created_at
|
||||||
|
created_by {
|
||||||
|
id
|
||||||
|
name
|
||||||
|
slug
|
||||||
|
pic
|
||||||
|
}
|
||||||
|
kind
|
||||||
|
reply_to
|
||||||
|
stat {
|
||||||
|
rating
|
||||||
|
comments_count
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Параметры запроса
|
||||||
|
|
||||||
|
| Параметр | Тип | По умолчанию | Описание |
|
||||||
|
|----------|-----|--------------|----------|
|
||||||
|
| shout | Int! | - | ID статьи, к которой относятся комментарии |
|
||||||
|
| parent_id | Int | null | ID родительского комментария. Если null, загружаются корневые комментарии |
|
||||||
|
| limit | Int | 10 | Максимальное количество комментариев для загрузки |
|
||||||
|
| offset | Int | 0 | Смещение для пагинации |
|
||||||
|
| sort | ReactionSort | newest | Порядок сортировки: newest, oldest, like |
|
||||||
|
| children_limit | Int | 3 | Максимальное количество дочерних комментариев для каждого родительского |
|
||||||
|
| children_offset | Int | 0 | Смещение для пагинации дочерних комментариев |
|
||||||
|
|
||||||
|
### Поля в ответе
|
||||||
|
|
||||||
|
Каждый комментарий содержит следующие основные поля:
|
||||||
|
|
||||||
|
- `id`: ID комментария
|
||||||
|
- `body`: Текст комментария
|
||||||
|
- `created_at`: Время создания
|
||||||
|
- `created_by`: Информация об авторе
|
||||||
|
- `kind`: Тип реакции (COMMENT)
|
||||||
|
- `reply_to`: ID родительского комментария (null для корневых)
|
||||||
|
- `first_replies`: Первые N дочерних комментариев
|
||||||
|
- `stat`: Статистика комментария, включающая:
|
||||||
|
- `comments_count`: Количество ответов на комментарий
|
||||||
|
- `rating`: Рейтинг комментария
|
||||||
|
|
||||||
|
## Примеры использования
|
||||||
|
|
||||||
|
### Загрузка корневых комментариев с первыми ответами
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const { data } = await client.query({
|
||||||
|
query: LOAD_COMMENTS_BRANCH,
|
||||||
|
variables: {
|
||||||
|
shout: 222,
|
||||||
|
limit: 10,
|
||||||
|
offset: 0,
|
||||||
|
sort: "newest",
|
||||||
|
childrenLimit: 3
|
||||||
|
}
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Загрузка ответов на конкретный комментарий
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const { data } = await client.query({
|
||||||
|
query: LOAD_COMMENTS_BRANCH,
|
||||||
|
variables: {
|
||||||
|
shout: 222,
|
||||||
|
parentId: 123, // ID комментария, для которого загружаем ответы
|
||||||
|
limit: 10,
|
||||||
|
offset: 0,
|
||||||
|
sort: "oldest" // Сортируем ответы от старых к новым
|
||||||
|
}
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Пагинация дочерних комментариев
|
||||||
|
|
||||||
|
Для загрузки дополнительных ответов на комментарий:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const { data } = await client.query({
|
||||||
|
query: LOAD_COMMENTS_BRANCH,
|
||||||
|
variables: {
|
||||||
|
shout: 222,
|
||||||
|
parentId: 123,
|
||||||
|
limit: 10,
|
||||||
|
offset: 0,
|
||||||
|
childrenLimit: 5,
|
||||||
|
childrenOffset: 3 // Пропускаем первые 3 комментария (уже загруженные)
|
||||||
|
}
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Рекомендации по клиентской реализации
|
||||||
|
|
||||||
|
1. Для эффективной работы со сложными ветками обсуждений рекомендуется:
|
||||||
|
|
||||||
|
- Сначала загружать только корневые комментарии с первыми N ответами
|
||||||
|
- При наличии дополнительных ответов (когда `stat.comments_count > first_replies.length`)
|
||||||
|
добавить кнопку "Показать все ответы"
|
||||||
|
- При нажатии на кнопку загружать дополнительные ответы с помощью запроса с указанным `parentId`
|
||||||
|
|
||||||
|
2. Для сортировки:
|
||||||
|
- По умолчанию использовать `newest` для отображения свежих обсуждений
|
||||||
|
- Предусмотреть переключатель сортировки для всего дерева комментариев
|
||||||
|
- При изменении сортировки перезагружать данные с новым параметром `sort`
|
||||||
|
|
||||||
|
3. Для улучшения производительности:
|
||||||
|
- Кешировать результаты запросов на клиенте
|
||||||
|
- Использовать оптимистичные обновления при добавлении/редактировании комментариев
|
||||||
|
- При необходимости загружать комментарии порциями (ленивая загрузка)
|
@@ -6,14 +6,20 @@
|
|||||||
|
|
||||||
## Мультидоменная авторизация
|
## Мультидоменная авторизация
|
||||||
|
|
||||||
- Поддержка авторизации для разных доменов:
|
- Поддержка авторизации для разных доменов
|
||||||
- *.dscrs.site (включая testing.dscrs.site)
|
|
||||||
- localhost[:port]
|
|
||||||
- testingdiscoursio-git-*-discoursio.vercel.app
|
|
||||||
- *.discours.io
|
|
||||||
- Автоматическое определение сервера авторизации
|
- Автоматическое определение сервера авторизации
|
||||||
- Корректная обработка CORS для всех поддерживаемых доменов
|
- Корректная обработка CORS для всех поддерживаемых доменов
|
||||||
|
|
||||||
|
## Система кеширования
|
||||||
|
|
||||||
|
- Redis используется в качестве основного механизма кеширования
|
||||||
|
- Поддержка как синхронных, так и асинхронных функций в декораторе cache_on_arguments
|
||||||
|
- Автоматическая сериализация/десериализация данных в JSON с использованием CustomJSONEncoder
|
||||||
|
- Резервная сериализация через pickle для сложных объектов
|
||||||
|
- Генерация уникальных ключей кеша на основе сигнатуры функции и переданных аргументов
|
||||||
|
- Настраиваемое время жизни кеша (TTL)
|
||||||
|
- Возможность ручной инвалидации кеша для конкретных функций и аргументов
|
||||||
|
|
||||||
## Webhooks
|
## Webhooks
|
||||||
|
|
||||||
- Автоматическая регистрация вебхука для события user.login
|
- Автоматическая регистрация вебхука для события user.login
|
||||||
@@ -25,11 +31,18 @@
|
|||||||
|
|
||||||
## CORS Configuration
|
## CORS Configuration
|
||||||
|
|
||||||
- Поддерживаются домены:
|
|
||||||
- *.dscrs.site (включая testing.dscrs.site, core.dscrs.site)
|
|
||||||
- *.discours.io (включая testing.discours.io)
|
|
||||||
- localhost (включая порты)
|
|
||||||
- Поддерживаемые методы: GET, POST, OPTIONS
|
- Поддерживаемые методы: GET, POST, OPTIONS
|
||||||
- Настроена поддержка credentials
|
- Настроена поддержка credentials
|
||||||
- Разрешенные заголовки: Authorization, Content-Type, X-Requested-With, DNT, Cache-Control
|
- Разрешенные заголовки: Authorization, Content-Type, X-Requested-With, DNT, Cache-Control
|
||||||
- Настроено кэширование preflight-ответов на 20 дней (1728000 секунд)
|
- Настроено кэширование preflight-ответов на 20 дней (1728000 секунд)
|
||||||
|
|
||||||
|
## Пагинация комментариев по веткам
|
||||||
|
|
||||||
|
- Эффективная загрузка комментариев с учетом их иерархической структуры
|
||||||
|
- Отдельный запрос `load_comments_branch` для оптимизированной загрузки ветки комментариев
|
||||||
|
- Возможность загрузки корневых комментариев статьи с первыми ответами на них
|
||||||
|
- Гибкая пагинация как для корневых, так и для дочерних комментариев
|
||||||
|
- Использование поля `stat.comments_count` для отображения количества ответов на комментарий
|
||||||
|
- Добавление специального поля `first_replies` для хранения первых ответов на комментарий
|
||||||
|
- Поддержка различных методов сортировки (новые, старые, популярные)
|
||||||
|
- Оптимизированные SQL запросы для минимизации нагрузки на базу данных
|
15
main.py
15
main.py
@@ -17,7 +17,6 @@ from cache.revalidator import revalidation_manager
|
|||||||
from services.exception import ExceptionHandlerMiddleware
|
from services.exception import ExceptionHandlerMiddleware
|
||||||
from services.redis import redis
|
from services.redis import redis
|
||||||
from services.schema import create_all_tables, resolvers
|
from services.schema import create_all_tables, resolvers
|
||||||
#from services.search import search_service
|
|
||||||
from services.search import search_service, initialize_search_index
|
from services.search import search_service, initialize_search_index
|
||||||
from services.viewed import ViewedStorage
|
from services.viewed import ViewedStorage
|
||||||
from services.webhook import WebhookEndpoint, create_webhook_endpoint
|
from services.webhook import WebhookEndpoint, create_webhook_endpoint
|
||||||
@@ -43,6 +42,15 @@ async def check_search_service():
|
|||||||
else:
|
else:
|
||||||
print(f"[INFO] Search service is available: {info}")
|
print(f"[INFO] Search service is available: {info}")
|
||||||
|
|
||||||
|
# Helper to run precache with timeout and catch errors
|
||||||
|
async def precache_with_timeout():
|
||||||
|
try:
|
||||||
|
await asyncio.wait_for(precache_data(), timeout=60)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
print("[precache] Precache timed out after 60 seconds")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[precache] Error during precache: {e}")
|
||||||
|
|
||||||
|
|
||||||
# indexing DB data
|
# indexing DB data
|
||||||
# async def indexing():
|
# async def indexing():
|
||||||
@@ -53,9 +61,12 @@ async def lifespan(_app):
|
|||||||
try:
|
try:
|
||||||
print("[lifespan] Starting application initialization")
|
print("[lifespan] Starting application initialization")
|
||||||
create_all_tables()
|
create_all_tables()
|
||||||
|
|
||||||
|
# schedule precaching in background with timeout and error handling
|
||||||
|
asyncio.create_task(precache_with_timeout())
|
||||||
|
|
||||||
await asyncio.gather(
|
await asyncio.gather(
|
||||||
redis.connect(),
|
redis.connect(),
|
||||||
precache_data(),
|
|
||||||
ViewedStorage.init(),
|
ViewedStorage.init(),
|
||||||
create_webhook_endpoint(),
|
create_webhook_endpoint(),
|
||||||
check_search_service(),
|
check_search_service(),
|
||||||
|
@@ -1,6 +1,6 @@
|
|||||||
import time
|
import time
|
||||||
|
|
||||||
from sqlalchemy import JSON, Boolean, Column, ForeignKey, Integer, String
|
from sqlalchemy import JSON, Boolean, Column, ForeignKey, Index, Integer, String
|
||||||
|
|
||||||
from services.db import Base
|
from services.db import Base
|
||||||
|
|
||||||
@@ -8,6 +8,15 @@ from services.db import Base
|
|||||||
|
|
||||||
|
|
||||||
class AuthorRating(Base):
|
class AuthorRating(Base):
|
||||||
|
"""
|
||||||
|
Рейтинг автора от другого автора.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
rater (int): ID оценивающего автора
|
||||||
|
author (int): ID оцениваемого автора
|
||||||
|
plus (bool): Положительная/отрицательная оценка
|
||||||
|
"""
|
||||||
|
|
||||||
__tablename__ = "author_rating"
|
__tablename__ = "author_rating"
|
||||||
|
|
||||||
id = None # type: ignore
|
id = None # type: ignore
|
||||||
@@ -15,8 +24,26 @@ class AuthorRating(Base):
|
|||||||
author = Column(ForeignKey("author.id"), primary_key=True)
|
author = Column(ForeignKey("author.id"), primary_key=True)
|
||||||
plus = Column(Boolean)
|
plus = Column(Boolean)
|
||||||
|
|
||||||
|
# Определяем индексы
|
||||||
|
__table_args__ = (
|
||||||
|
# Индекс для быстрого поиска всех оценок конкретного автора
|
||||||
|
Index("idx_author_rating_author", "author"),
|
||||||
|
# Индекс для быстрого поиска всех оценок, оставленных конкретным автором
|
||||||
|
Index("idx_author_rating_rater", "rater"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class AuthorFollower(Base):
|
class AuthorFollower(Base):
|
||||||
|
"""
|
||||||
|
Подписка одного автора на другого.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
follower (int): ID подписчика
|
||||||
|
author (int): ID автора, на которого подписываются
|
||||||
|
created_at (int): Время создания подписки
|
||||||
|
auto (bool): Признак автоматической подписки
|
||||||
|
"""
|
||||||
|
|
||||||
__tablename__ = "author_follower"
|
__tablename__ = "author_follower"
|
||||||
|
|
||||||
id = None # type: ignore
|
id = None # type: ignore
|
||||||
@@ -25,16 +52,57 @@ class AuthorFollower(Base):
|
|||||||
created_at = Column(Integer, nullable=False, default=lambda: int(time.time()))
|
created_at = Column(Integer, nullable=False, default=lambda: int(time.time()))
|
||||||
auto = Column(Boolean, nullable=False, default=False)
|
auto = Column(Boolean, nullable=False, default=False)
|
||||||
|
|
||||||
|
# Определяем индексы
|
||||||
|
__table_args__ = (
|
||||||
|
# Индекс для быстрого поиска всех подписчиков автора
|
||||||
|
Index("idx_author_follower_author", "author"),
|
||||||
|
# Индекс для быстрого поиска всех авторов, на которых подписан конкретный автор
|
||||||
|
Index("idx_author_follower_follower", "follower"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class AuthorBookmark(Base):
|
class AuthorBookmark(Base):
|
||||||
|
"""
|
||||||
|
Закладка автора на публикацию.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
author (int): ID автора
|
||||||
|
shout (int): ID публикации
|
||||||
|
"""
|
||||||
|
|
||||||
__tablename__ = "author_bookmark"
|
__tablename__ = "author_bookmark"
|
||||||
|
|
||||||
id = None # type: ignore
|
id = None # type: ignore
|
||||||
author = Column(ForeignKey("author.id"), primary_key=True)
|
author = Column(ForeignKey("author.id"), primary_key=True)
|
||||||
shout = Column(ForeignKey("shout.id"), primary_key=True)
|
shout = Column(ForeignKey("shout.id"), primary_key=True)
|
||||||
|
|
||||||
|
# Определяем индексы
|
||||||
|
__table_args__ = (
|
||||||
|
# Индекс для быстрого поиска всех закладок автора
|
||||||
|
Index("idx_author_bookmark_author", "author"),
|
||||||
|
# Индекс для быстрого поиска всех авторов, добавивших публикацию в закладки
|
||||||
|
Index("idx_author_bookmark_shout", "shout"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class Author(Base):
|
class Author(Base):
|
||||||
|
"""
|
||||||
|
Модель автора в системе.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
user (str): Идентификатор пользователя в системе авторизации
|
||||||
|
name (str): Отображаемое имя
|
||||||
|
slug (str): Уникальный строковый идентификатор
|
||||||
|
bio (str): Краткая биография/статус
|
||||||
|
about (str): Полное описание
|
||||||
|
pic (str): URL изображения профиля
|
||||||
|
links (dict): Ссылки на социальные сети и сайты
|
||||||
|
created_at (int): Время создания профиля
|
||||||
|
last_seen (int): Время последнего посещения
|
||||||
|
updated_at (int): Время последнего обновления
|
||||||
|
deleted_at (int): Время удаления (если профиль удален)
|
||||||
|
"""
|
||||||
|
|
||||||
__tablename__ = "author"
|
__tablename__ = "author"
|
||||||
|
|
||||||
user = Column(String) # unbounded link with authorizer's User type
|
user = Column(String) # unbounded link with authorizer's User type
|
||||||
@@ -53,3 +121,17 @@ class Author(Base):
|
|||||||
# search_vector = Column(
|
# search_vector = Column(
|
||||||
# TSVectorType("name", "slug", "bio", "about", regconfig="pg_catalog.russian")
|
# TSVectorType("name", "slug", "bio", "about", regconfig="pg_catalog.russian")
|
||||||
# )
|
# )
|
||||||
|
|
||||||
|
# Определяем индексы
|
||||||
|
__table_args__ = (
|
||||||
|
# Индекс для быстрого поиска по slug
|
||||||
|
Index("idx_author_slug", "slug"),
|
||||||
|
# Индекс для быстрого поиска по идентификатору пользователя
|
||||||
|
Index("idx_author_user", "user"),
|
||||||
|
# Индекс для фильтрации неудаленных авторов
|
||||||
|
Index("idx_author_deleted_at", "deleted_at", postgresql_where=deleted_at.is_(None)),
|
||||||
|
# Индекс для сортировки по времени создания (для новых авторов)
|
||||||
|
Index("idx_author_created_at", "created_at"),
|
||||||
|
# Индекс для сортировки по времени последнего посещения
|
||||||
|
Index("idx_author_last_seen", "last_seen"),
|
||||||
|
)
|
||||||
|
63
orm/draft.py
63
orm/draft.py
@@ -6,6 +6,7 @@ from sqlalchemy.orm import relationship
|
|||||||
from orm.author import Author
|
from orm.author import Author
|
||||||
from orm.topic import Topic
|
from orm.topic import Topic
|
||||||
from services.db import Base
|
from services.db import Base
|
||||||
|
from orm.shout import Shout
|
||||||
|
|
||||||
|
|
||||||
class DraftTopic(Base):
|
class DraftTopic(Base):
|
||||||
@@ -26,11 +27,14 @@ class DraftAuthor(Base):
|
|||||||
caption = Column(String, nullable=True, default="")
|
caption = Column(String, nullable=True, default="")
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
class Draft(Base):
|
class Draft(Base):
|
||||||
__tablename__ = "draft"
|
__tablename__ = "draft"
|
||||||
# required
|
# required
|
||||||
created_at: int = Column(Integer, nullable=False, default=lambda: int(time.time()))
|
created_at: int = Column(Integer, nullable=False, default=lambda: int(time.time()))
|
||||||
created_by: int = Column(ForeignKey("author.id"), nullable=False)
|
# Колонки для связей с автором
|
||||||
|
created_by: int = Column("created_by", ForeignKey("author.id"), nullable=False)
|
||||||
|
community: int = Column("community", ForeignKey("community.id"), nullable=False, default=1)
|
||||||
|
|
||||||
# optional
|
# optional
|
||||||
layout: str = Column(String, nullable=True, default="article")
|
layout: str = Column(String, nullable=True, default="article")
|
||||||
@@ -38,7 +42,6 @@ class Draft(Base):
|
|||||||
title: str = Column(String, nullable=True)
|
title: str = Column(String, nullable=True)
|
||||||
subtitle: str | None = Column(String, nullable=True)
|
subtitle: str | None = Column(String, nullable=True)
|
||||||
lead: str | None = Column(String, nullable=True)
|
lead: str | None = Column(String, nullable=True)
|
||||||
description: str | None = Column(String, nullable=True)
|
|
||||||
body: str = Column(String, nullable=False, comment="Body")
|
body: str = Column(String, nullable=False, comment="Body")
|
||||||
media: dict | None = Column(JSON, nullable=True)
|
media: dict | None = Column(JSON, nullable=True)
|
||||||
cover: str | None = Column(String, nullable=True, comment="Cover image url")
|
cover: str | None = Column(String, nullable=True, comment="Cover image url")
|
||||||
@@ -49,7 +52,55 @@ class Draft(Base):
|
|||||||
# auto
|
# auto
|
||||||
updated_at: int | None = Column(Integer, nullable=True, index=True)
|
updated_at: int | None = Column(Integer, nullable=True, index=True)
|
||||||
deleted_at: int | None = Column(Integer, nullable=True, index=True)
|
deleted_at: int | None = Column(Integer, nullable=True, index=True)
|
||||||
updated_by: int | None = Column(ForeignKey("author.id"), nullable=True)
|
updated_by: int | None = Column("updated_by", ForeignKey("author.id"), nullable=True)
|
||||||
deleted_by: int | None = Column(ForeignKey("author.id"), nullable=True)
|
deleted_by: int | None = Column("deleted_by", ForeignKey("author.id"), nullable=True)
|
||||||
authors = relationship(Author, secondary="draft_author")
|
|
||||||
topics = relationship(Topic, secondary="draft_topic")
|
# --- Relationships ---
|
||||||
|
# Только many-to-many связи через вспомогательные таблицы
|
||||||
|
authors = relationship(Author, secondary="draft_author", lazy="select")
|
||||||
|
topics = relationship(Topic, secondary="draft_topic", lazy="select")
|
||||||
|
|
||||||
|
# Связь с Community (если нужна как объект, а не ID)
|
||||||
|
# community = relationship("Community", foreign_keys=[community_id], lazy="joined")
|
||||||
|
# Пока оставляем community_id как ID
|
||||||
|
|
||||||
|
# Связь с публикацией (один-к-одному или один-к-нулю)
|
||||||
|
# Загружается через joinedload в резолвере
|
||||||
|
publication = relationship(
|
||||||
|
"Shout",
|
||||||
|
primaryjoin="Draft.id == Shout.draft",
|
||||||
|
foreign_keys="Shout.draft",
|
||||||
|
uselist=False,
|
||||||
|
lazy="noload", # Не грузим по умолчанию, только через options
|
||||||
|
viewonly=True # Указываем, что это связь только для чтения
|
||||||
|
)
|
||||||
|
|
||||||
|
def dict(self):
|
||||||
|
"""
|
||||||
|
Сериализует объект Draft в словарь.
|
||||||
|
Гарантирует, что поля topics и authors всегда будут списками.
|
||||||
|
"""
|
||||||
|
return {
|
||||||
|
"id": self.id,
|
||||||
|
"created_at": self.created_at,
|
||||||
|
"created_by": self.created_by,
|
||||||
|
"community": self.community,
|
||||||
|
"layout": self.layout,
|
||||||
|
"slug": self.slug,
|
||||||
|
"title": self.title,
|
||||||
|
"subtitle": self.subtitle,
|
||||||
|
"lead": self.lead,
|
||||||
|
"body": self.body,
|
||||||
|
"media": self.media or [],
|
||||||
|
"cover": self.cover,
|
||||||
|
"cover_caption": self.cover_caption,
|
||||||
|
"lang": self.lang,
|
||||||
|
"seo": self.seo,
|
||||||
|
"updated_at": self.updated_at,
|
||||||
|
"deleted_at": self.deleted_at,
|
||||||
|
"updated_by": self.updated_by,
|
||||||
|
"deleted_by": self.deleted_by,
|
||||||
|
# Гарантируем, что topics и authors всегда будут списками
|
||||||
|
"topics": [topic.dict() for topic in (self.topics or [])],
|
||||||
|
"authors": [author.dict() for author in (self.authors or [])]
|
||||||
|
}
|
82
orm/shout.py
82
orm/shout.py
@@ -1,6 +1,6 @@
|
|||||||
import time
|
import time
|
||||||
|
|
||||||
from sqlalchemy import JSON, Boolean, Column, ForeignKey, Integer, String
|
from sqlalchemy import JSON, Boolean, Column, ForeignKey, Index, Integer, String
|
||||||
from sqlalchemy.orm import relationship
|
from sqlalchemy.orm import relationship
|
||||||
|
|
||||||
from orm.author import Author
|
from orm.author import Author
|
||||||
@@ -10,6 +10,15 @@ from services.db import Base
|
|||||||
|
|
||||||
|
|
||||||
class ShoutTopic(Base):
|
class ShoutTopic(Base):
|
||||||
|
"""
|
||||||
|
Связь между публикацией и темой.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
shout (int): ID публикации
|
||||||
|
topic (int): ID темы
|
||||||
|
main (bool): Признак основной темы
|
||||||
|
"""
|
||||||
|
|
||||||
__tablename__ = "shout_topic"
|
__tablename__ = "shout_topic"
|
||||||
|
|
||||||
id = None # type: ignore
|
id = None # type: ignore
|
||||||
@@ -17,6 +26,12 @@ class ShoutTopic(Base):
|
|||||||
topic = Column(ForeignKey("topic.id"), primary_key=True, index=True)
|
topic = Column(ForeignKey("topic.id"), primary_key=True, index=True)
|
||||||
main = Column(Boolean, nullable=True)
|
main = Column(Boolean, nullable=True)
|
||||||
|
|
||||||
|
# Определяем дополнительные индексы
|
||||||
|
__table_args__ = (
|
||||||
|
# Оптимизированный составной индекс для запросов, которые ищут публикации по теме
|
||||||
|
Index("idx_shout_topic_topic_shout", "topic", "shout"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class ShoutReactionsFollower(Base):
|
class ShoutReactionsFollower(Base):
|
||||||
__tablename__ = "shout_reactions_followers"
|
__tablename__ = "shout_reactions_followers"
|
||||||
@@ -30,6 +45,15 @@ class ShoutReactionsFollower(Base):
|
|||||||
|
|
||||||
|
|
||||||
class ShoutAuthor(Base):
|
class ShoutAuthor(Base):
|
||||||
|
"""
|
||||||
|
Связь между публикацией и автором.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
shout (int): ID публикации
|
||||||
|
author (int): ID автора
|
||||||
|
caption (str): Подпись автора
|
||||||
|
"""
|
||||||
|
|
||||||
__tablename__ = "shout_author"
|
__tablename__ = "shout_author"
|
||||||
|
|
||||||
id = None # type: ignore
|
id = None # type: ignore
|
||||||
@@ -37,8 +61,46 @@ class ShoutAuthor(Base):
|
|||||||
author = Column(ForeignKey("author.id"), primary_key=True, index=True)
|
author = Column(ForeignKey("author.id"), primary_key=True, index=True)
|
||||||
caption = Column(String, nullable=True, default="")
|
caption = Column(String, nullable=True, default="")
|
||||||
|
|
||||||
|
# Определяем дополнительные индексы
|
||||||
|
__table_args__ = (
|
||||||
|
# Оптимизированный индекс для запросов, которые ищут публикации по автору
|
||||||
|
Index("idx_shout_author_author_shout", "author", "shout"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class Shout(Base):
|
class Shout(Base):
|
||||||
|
"""
|
||||||
|
Публикация в системе.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
body (str)
|
||||||
|
slug (str)
|
||||||
|
cover (str) : "Cover image url"
|
||||||
|
cover_caption (str) : "Cover image alt caption"
|
||||||
|
lead (str)
|
||||||
|
title (str)
|
||||||
|
subtitle (str)
|
||||||
|
layout (str)
|
||||||
|
media (dict)
|
||||||
|
authors (list[Author])
|
||||||
|
topics (list[Topic])
|
||||||
|
reactions (list[Reaction])
|
||||||
|
lang (str)
|
||||||
|
version_of (int)
|
||||||
|
oid (str)
|
||||||
|
seo (str) : JSON
|
||||||
|
draft (int)
|
||||||
|
created_at (int)
|
||||||
|
updated_at (int)
|
||||||
|
published_at (int)
|
||||||
|
featured_at (int)
|
||||||
|
deleted_at (int)
|
||||||
|
created_by (int)
|
||||||
|
updated_by (int)
|
||||||
|
deleted_by (int)
|
||||||
|
community (int)
|
||||||
|
"""
|
||||||
|
|
||||||
__tablename__ = "shout"
|
__tablename__ = "shout"
|
||||||
|
|
||||||
created_at: int = Column(Integer, nullable=False, default=lambda: int(time.time()))
|
created_at: int = Column(Integer, nullable=False, default=lambda: int(time.time()))
|
||||||
@@ -57,7 +119,6 @@ class Shout(Base):
|
|||||||
cover: str | None = Column(String, nullable=True, comment="Cover image url")
|
cover: str | None = Column(String, nullable=True, comment="Cover image url")
|
||||||
cover_caption: str | None = Column(String, nullable=True, comment="Cover image alt caption")
|
cover_caption: str | None = Column(String, nullable=True, comment="Cover image alt caption")
|
||||||
lead: str | None = Column(String, nullable=True)
|
lead: str | None = Column(String, nullable=True)
|
||||||
description: str | None = Column(String, nullable=True)
|
|
||||||
title: str = Column(String, nullable=False)
|
title: str = Column(String, nullable=False)
|
||||||
subtitle: str | None = Column(String, nullable=True)
|
subtitle: str | None = Column(String, nullable=True)
|
||||||
layout: str = Column(String, nullable=False, default="article")
|
layout: str = Column(String, nullable=False, default="article")
|
||||||
@@ -74,3 +135,20 @@ class Shout(Base):
|
|||||||
seo: str | None = Column(String, nullable=True) # JSON
|
seo: str | None = Column(String, nullable=True) # JSON
|
||||||
|
|
||||||
draft: int | None = Column(ForeignKey("draft.id"), nullable=True)
|
draft: int | None = Column(ForeignKey("draft.id"), nullable=True)
|
||||||
|
|
||||||
|
# Определяем индексы
|
||||||
|
__table_args__ = (
|
||||||
|
# Индекс для быстрого поиска неудаленных публикаций
|
||||||
|
Index("idx_shout_deleted_at", "deleted_at", postgresql_where=deleted_at.is_(None)),
|
||||||
|
# Индекс для быстрой фильтрации по community
|
||||||
|
Index("idx_shout_community", "community"),
|
||||||
|
# Индекс для быстрого поиска по slug
|
||||||
|
Index("idx_shout_slug", "slug"),
|
||||||
|
# Составной индекс для фильтрации опубликованных неудаленных публикаций
|
||||||
|
Index(
|
||||||
|
"idx_shout_published_deleted",
|
||||||
|
"published_at",
|
||||||
|
"deleted_at",
|
||||||
|
postgresql_where=published_at.is_not(None) & deleted_at.is_(None),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
42
orm/topic.py
42
orm/topic.py
@@ -1,11 +1,21 @@
|
|||||||
import time
|
import time
|
||||||
|
|
||||||
from sqlalchemy import JSON, Boolean, Column, ForeignKey, Integer, String
|
from sqlalchemy import JSON, Boolean, Column, ForeignKey, Index, Integer, String
|
||||||
|
|
||||||
from services.db import Base
|
from services.db import Base
|
||||||
|
|
||||||
|
|
||||||
class TopicFollower(Base):
|
class TopicFollower(Base):
|
||||||
|
"""
|
||||||
|
Связь между топиком и его подписчиком.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
follower (int): ID подписчика
|
||||||
|
topic (int): ID топика
|
||||||
|
created_at (int): Время создания связи
|
||||||
|
auto (bool): Автоматическая подписка
|
||||||
|
"""
|
||||||
|
|
||||||
__tablename__ = "topic_followers"
|
__tablename__ = "topic_followers"
|
||||||
|
|
||||||
id = None # type: ignore
|
id = None # type: ignore
|
||||||
@@ -14,8 +24,29 @@ class TopicFollower(Base):
|
|||||||
created_at = Column(Integer, nullable=False, default=int(time.time()))
|
created_at = Column(Integer, nullable=False, default=int(time.time()))
|
||||||
auto = Column(Boolean, nullable=False, default=False)
|
auto = Column(Boolean, nullable=False, default=False)
|
||||||
|
|
||||||
|
# Определяем индексы
|
||||||
|
__table_args__ = (
|
||||||
|
# Индекс для быстрого поиска всех подписчиков топика
|
||||||
|
Index("idx_topic_followers_topic", "topic"),
|
||||||
|
# Индекс для быстрого поиска всех топиков, на которые подписан автор
|
||||||
|
Index("idx_topic_followers_follower", "follower"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class Topic(Base):
|
class Topic(Base):
|
||||||
|
"""
|
||||||
|
Модель топика (темы) публикаций.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
slug (str): Уникальный строковый идентификатор темы
|
||||||
|
title (str): Название темы
|
||||||
|
body (str): Описание темы
|
||||||
|
pic (str): URL изображения темы
|
||||||
|
community (int): ID сообщества
|
||||||
|
oid (str): Старый ID
|
||||||
|
parent_ids (list): IDs родительских тем
|
||||||
|
"""
|
||||||
|
|
||||||
__tablename__ = "topic"
|
__tablename__ = "topic"
|
||||||
|
|
||||||
slug = Column(String, unique=True)
|
slug = Column(String, unique=True)
|
||||||
@@ -24,5 +55,12 @@ class Topic(Base):
|
|||||||
pic = Column(String, nullable=True, comment="Picture")
|
pic = Column(String, nullable=True, comment="Picture")
|
||||||
community = Column(ForeignKey("community.id"), default=1)
|
community = Column(ForeignKey("community.id"), default=1)
|
||||||
oid = Column(String, nullable=True, comment="Old ID")
|
oid = Column(String, nullable=True, comment="Old ID")
|
||||||
|
|
||||||
parent_ids = Column(JSON, nullable=True, comment="Parent Topic IDs")
|
parent_ids = Column(JSON, nullable=True, comment="Parent Topic IDs")
|
||||||
|
|
||||||
|
# Определяем индексы
|
||||||
|
__table_args__ = (
|
||||||
|
# Индекс для быстрого поиска по slug
|
||||||
|
Index("idx_topic_slug", "slug"),
|
||||||
|
# Индекс для быстрого поиска по сообществу
|
||||||
|
Index("idx_topic_community", "community"),
|
||||||
|
)
|
||||||
|
6
requirements.dev.txt
Normal file
6
requirements.dev.txt
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
fakeredis
|
||||||
|
pytest
|
||||||
|
pytest-asyncio
|
||||||
|
pytest-cov
|
||||||
|
mypy
|
||||||
|
ruff
|
@@ -2,13 +2,10 @@
|
|||||||
bcrypt
|
bcrypt
|
||||||
authlib
|
authlib
|
||||||
passlib
|
passlib
|
||||||
|
|
||||||
google-analytics-data
|
|
||||||
dogpile-cache
|
|
||||||
opensearch-py
|
opensearch-py
|
||||||
|
google-analytics-data
|
||||||
colorlog
|
colorlog
|
||||||
psycopg2-binary
|
psycopg2-binary
|
||||||
dogpile-cache
|
|
||||||
httpx
|
httpx
|
||||||
redis[hiredis]
|
redis[hiredis]
|
||||||
sentry-sdk[starlette,sqlalchemy]
|
sentry-sdk[starlette,sqlalchemy]
|
||||||
@@ -20,10 +17,6 @@ granian
|
|||||||
# NLP and search
|
# NLP and search
|
||||||
httpx
|
httpx
|
||||||
|
|
||||||
|
orjson
|
||||||
pydantic
|
pydantic
|
||||||
fakeredis
|
trafilatura
|
||||||
pytest
|
|
||||||
pytest-asyncio
|
|
||||||
pytest-cov
|
|
||||||
mypy
|
|
||||||
ruff
|
|
@@ -8,6 +8,7 @@ from resolvers.author import ( # search_authors,
|
|||||||
get_author_id,
|
get_author_id,
|
||||||
get_authors_all,
|
get_authors_all,
|
||||||
load_authors_by,
|
load_authors_by,
|
||||||
|
load_authors_search,
|
||||||
update_author,
|
update_author,
|
||||||
)
|
)
|
||||||
from resolvers.community import get_communities_all, get_community
|
from resolvers.community import get_communities_all, get_community
|
||||||
@@ -16,9 +17,11 @@ from resolvers.draft import (
|
|||||||
delete_draft,
|
delete_draft,
|
||||||
load_drafts,
|
load_drafts,
|
||||||
publish_draft,
|
publish_draft,
|
||||||
unpublish_draft,
|
|
||||||
update_draft,
|
update_draft,
|
||||||
)
|
)
|
||||||
|
from resolvers.editor import (
|
||||||
|
unpublish_shout,
|
||||||
|
)
|
||||||
from resolvers.feed import (
|
from resolvers.feed import (
|
||||||
load_shouts_coauthored,
|
load_shouts_coauthored,
|
||||||
load_shouts_discussed,
|
load_shouts_discussed,
|
||||||
@@ -37,6 +40,7 @@ from resolvers.reaction import (
|
|||||||
create_reaction,
|
create_reaction,
|
||||||
delete_reaction,
|
delete_reaction,
|
||||||
load_comment_ratings,
|
load_comment_ratings,
|
||||||
|
load_comments_branch,
|
||||||
load_reactions_by,
|
load_reactions_by,
|
||||||
load_shout_comments,
|
load_shout_comments,
|
||||||
load_shout_ratings,
|
load_shout_ratings,
|
||||||
@@ -70,6 +74,7 @@ __all__ = [
|
|||||||
"get_author_follows_authors",
|
"get_author_follows_authors",
|
||||||
"get_authors_all",
|
"get_authors_all",
|
||||||
"load_authors_by",
|
"load_authors_by",
|
||||||
|
"load_authors_search",
|
||||||
"update_author",
|
"update_author",
|
||||||
## "search_authors",
|
## "search_authors",
|
||||||
# community
|
# community
|
||||||
@@ -107,6 +112,7 @@ __all__ = [
|
|||||||
"load_shout_comments",
|
"load_shout_comments",
|
||||||
"load_shout_ratings",
|
"load_shout_ratings",
|
||||||
"load_comment_ratings",
|
"load_comment_ratings",
|
||||||
|
"load_comments_branch",
|
||||||
# notifier
|
# notifier
|
||||||
"load_notifications",
|
"load_notifications",
|
||||||
"notifications_seen_thread",
|
"notifications_seen_thread",
|
||||||
|
@@ -1,25 +1,197 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import time
|
import time
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
from sqlalchemy import desc, select, text
|
from sqlalchemy import select, text
|
||||||
|
|
||||||
from cache.cache import (
|
from cache.cache import (
|
||||||
cache_author,
|
cache_author,
|
||||||
|
cached_query,
|
||||||
get_cached_author,
|
get_cached_author,
|
||||||
get_cached_author_by_user_id,
|
get_cached_author_by_user_id,
|
||||||
get_cached_author_followers,
|
get_cached_author_followers,
|
||||||
get_cached_follower_authors,
|
get_cached_follower_authors,
|
||||||
get_cached_follower_topics,
|
get_cached_follower_topics,
|
||||||
|
invalidate_cache_by_prefix,
|
||||||
)
|
)
|
||||||
from orm.author import Author
|
from orm.author import Author
|
||||||
from orm.shout import ShoutAuthor, ShoutTopic
|
|
||||||
from orm.topic import Topic
|
|
||||||
from resolvers.stat import get_with_stat
|
from resolvers.stat import get_with_stat
|
||||||
from services.auth import login_required
|
from services.auth import login_required
|
||||||
from services.db import local_session
|
from services.db import local_session
|
||||||
|
from services.redis import redis
|
||||||
from services.schema import mutation, query
|
from services.schema import mutation, query
|
||||||
|
from services.search import search_service
|
||||||
from utils.logger import root_logger as logger
|
from utils.logger import root_logger as logger
|
||||||
|
|
||||||
|
DEFAULT_COMMUNITIES = [1]
|
||||||
|
|
||||||
|
|
||||||
|
# Вспомогательная функция для получения всех авторов без статистики
|
||||||
|
async def get_all_authors():
|
||||||
|
"""
|
||||||
|
Получает всех авторов без статистики.
|
||||||
|
Используется для случаев, когда нужен полный список авторов без дополнительной информации.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
list: Список всех авторов без статистики
|
||||||
|
"""
|
||||||
|
cache_key = "authors:all:basic"
|
||||||
|
|
||||||
|
# Функция для получения всех авторов из БД
|
||||||
|
async def fetch_all_authors():
|
||||||
|
logger.debug("Получаем список всех авторов из БД и кешируем результат")
|
||||||
|
|
||||||
|
with local_session() as session:
|
||||||
|
# Запрос на получение базовой информации об авторах
|
||||||
|
authors_query = select(Author).where(Author.deleted_at.is_(None))
|
||||||
|
authors = session.execute(authors_query).scalars().all()
|
||||||
|
|
||||||
|
# Преобразуем авторов в словари
|
||||||
|
return [author.dict() for author in authors]
|
||||||
|
|
||||||
|
# Используем универсальную функцию для кеширования запросов
|
||||||
|
return await cached_query(cache_key, fetch_all_authors)
|
||||||
|
|
||||||
|
|
||||||
|
# Вспомогательная функция для получения авторов со статистикой с пагинацией
|
||||||
|
async def get_authors_with_stats(limit=50, offset=0, by: Optional[str] = None):
|
||||||
|
"""
|
||||||
|
Получает авторов со статистикой с пагинацией.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
limit: Максимальное количество возвращаемых авторов
|
||||||
|
offset: Смещение для пагинации
|
||||||
|
by: Опциональный параметр сортировки (new/active)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
list: Список авторов с их статистикой
|
||||||
|
"""
|
||||||
|
# Формируем ключ кеша с помощью универсальной функции
|
||||||
|
cache_key = f"authors:stats:limit={limit}:offset={offset}"
|
||||||
|
|
||||||
|
# Функция для получения авторов из БД
|
||||||
|
async def fetch_authors_with_stats():
|
||||||
|
logger.debug(f"Выполняем запрос на получение авторов со статистикой: limit={limit}, offset={offset}, by={by}")
|
||||||
|
|
||||||
|
with local_session() as session:
|
||||||
|
# Базовый запрос для получения авторов
|
||||||
|
base_query = select(Author).where(Author.deleted_at.is_(None))
|
||||||
|
|
||||||
|
# Применяем сортировку
|
||||||
|
if by:
|
||||||
|
if isinstance(by, dict):
|
||||||
|
# Обработка словаря параметров сортировки
|
||||||
|
from sqlalchemy import asc, desc
|
||||||
|
|
||||||
|
for field, direction in by.items():
|
||||||
|
column = getattr(Author, field, None)
|
||||||
|
if column:
|
||||||
|
if direction.lower() == "desc":
|
||||||
|
base_query = base_query.order_by(desc(column))
|
||||||
|
else:
|
||||||
|
base_query = base_query.order_by(column)
|
||||||
|
elif by == "new":
|
||||||
|
base_query = base_query.order_by(desc(Author.created_at))
|
||||||
|
elif by == "active":
|
||||||
|
base_query = base_query.order_by(desc(Author.last_seen))
|
||||||
|
else:
|
||||||
|
# По умолчанию сортируем по времени создания
|
||||||
|
base_query = base_query.order_by(desc(Author.created_at))
|
||||||
|
else:
|
||||||
|
base_query = base_query.order_by(desc(Author.created_at))
|
||||||
|
|
||||||
|
# Применяем лимит и смещение
|
||||||
|
base_query = base_query.limit(limit).offset(offset)
|
||||||
|
|
||||||
|
# Получаем авторов
|
||||||
|
authors = session.execute(base_query).scalars().all()
|
||||||
|
author_ids = [author.id for author in authors]
|
||||||
|
|
||||||
|
if not author_ids:
|
||||||
|
return []
|
||||||
|
|
||||||
|
# Оптимизированный запрос для получения статистики по публикациям для авторов
|
||||||
|
shouts_stats_query = f"""
|
||||||
|
SELECT sa.author, COUNT(DISTINCT s.id) as shouts_count
|
||||||
|
FROM shout_author sa
|
||||||
|
JOIN shout s ON sa.shout = s.id AND s.deleted_at IS NULL AND s.published_at IS NOT NULL
|
||||||
|
WHERE sa.author IN ({",".join(map(str, author_ids))})
|
||||||
|
GROUP BY sa.author
|
||||||
|
"""
|
||||||
|
shouts_stats = {row[0]: row[1] for row in session.execute(text(shouts_stats_query))}
|
||||||
|
|
||||||
|
# Запрос на получение статистики по подписчикам для авторов
|
||||||
|
followers_stats_query = f"""
|
||||||
|
SELECT author, COUNT(DISTINCT follower) as followers_count
|
||||||
|
FROM author_follower
|
||||||
|
WHERE author IN ({",".join(map(str, author_ids))})
|
||||||
|
GROUP BY author
|
||||||
|
"""
|
||||||
|
followers_stats = {row[0]: row[1] for row in session.execute(text(followers_stats_query))}
|
||||||
|
|
||||||
|
# Формируем результат с добавлением статистики
|
||||||
|
result = []
|
||||||
|
for author in authors:
|
||||||
|
author_dict = author.dict()
|
||||||
|
author_dict["stat"] = {
|
||||||
|
"shouts": shouts_stats.get(author.id, 0),
|
||||||
|
"followers": followers_stats.get(author.id, 0),
|
||||||
|
}
|
||||||
|
result.append(author_dict)
|
||||||
|
|
||||||
|
# Кешируем каждого автора отдельно для использования в других функциях
|
||||||
|
await cache_author(author_dict)
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
# Используем универсальную функцию для кеширования запросов
|
||||||
|
return await cached_query(cache_key, fetch_authors_with_stats)
|
||||||
|
|
||||||
|
|
||||||
|
# Функция для инвалидации кеша авторов
|
||||||
|
async def invalidate_authors_cache(author_id=None):
|
||||||
|
"""
|
||||||
|
Инвалидирует кеши авторов при изменении данных.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
author_id: Опциональный ID автора для точечной инвалидации.
|
||||||
|
Если не указан, инвалидируются все кеши авторов.
|
||||||
|
"""
|
||||||
|
if author_id:
|
||||||
|
# Точечная инвалидация конкретного автора
|
||||||
|
logger.debug(f"Инвалидация кеша для автора #{author_id}")
|
||||||
|
specific_keys = [
|
||||||
|
f"author:id:{author_id}",
|
||||||
|
f"author:followers:{author_id}",
|
||||||
|
f"author:follows-authors:{author_id}",
|
||||||
|
f"author:follows-topics:{author_id}",
|
||||||
|
f"author:follows-shouts:{author_id}",
|
||||||
|
]
|
||||||
|
|
||||||
|
# Получаем user_id автора, если есть
|
||||||
|
with local_session() as session:
|
||||||
|
author = session.query(Author).filter(Author.id == author_id).first()
|
||||||
|
if author and author.user:
|
||||||
|
specific_keys.append(f"author:user:{author.user.strip()}")
|
||||||
|
|
||||||
|
# Удаляем конкретные ключи
|
||||||
|
for key in specific_keys:
|
||||||
|
try:
|
||||||
|
await redis.execute("DEL", key)
|
||||||
|
logger.debug(f"Удален ключ кеша {key}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Ошибка при удалении ключа {key}: {e}")
|
||||||
|
|
||||||
|
# Также ищем и удаляем ключи коллекций, содержащих данные об этом авторе
|
||||||
|
collection_keys = await redis.execute("KEYS", "authors:stats:*")
|
||||||
|
if collection_keys:
|
||||||
|
await redis.execute("DEL", *collection_keys)
|
||||||
|
logger.debug(f"Удалено {len(collection_keys)} коллекционных ключей авторов")
|
||||||
|
else:
|
||||||
|
# Общая инвалидация всех кешей авторов
|
||||||
|
logger.debug("Полная инвалидация кеша авторов")
|
||||||
|
await invalidate_cache_by_prefix("authors")
|
||||||
|
|
||||||
|
|
||||||
@mutation.field("update_author")
|
@mutation.field("update_author")
|
||||||
@login_required
|
@login_required
|
||||||
@@ -51,10 +223,14 @@ async def update_author(_, info, profile):
|
|||||||
|
|
||||||
|
|
||||||
@query.field("get_authors_all")
|
@query.field("get_authors_all")
|
||||||
def get_authors_all(_, _info):
|
async def get_authors_all(_, _info):
|
||||||
with local_session() as session:
|
"""
|
||||||
authors = session.query(Author).all()
|
Получает список всех авторов без статистики.
|
||||||
return authors
|
|
||||||
|
Returns:
|
||||||
|
list: Список всех авторов
|
||||||
|
"""
|
||||||
|
return await get_all_authors()
|
||||||
|
|
||||||
|
|
||||||
@query.field("get_author")
|
@query.field("get_author")
|
||||||
@@ -105,145 +281,145 @@ async def get_author_id(_, _info, user: str):
|
|||||||
asyncio.create_task(cache_author(author_dict))
|
asyncio.create_task(cache_author(author_dict))
|
||||||
return author_with_stat
|
return author_with_stat
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
import traceback
|
logger.error(f"Error getting author: {exc}")
|
||||||
|
return None
|
||||||
traceback.print_exc()
|
|
||||||
logger.error(exc)
|
|
||||||
|
|
||||||
|
|
||||||
@query.field("load_authors_by")
|
@query.field("load_authors_by")
|
||||||
async def load_authors_by(_, _info, by, limit, offset):
|
async def load_authors_by(_, _info, by, limit, offset):
|
||||||
logger.debug(f"loading authors by {by}")
|
"""
|
||||||
authors_query = select(Author)
|
Загружает авторов по заданному критерию с пагинацией.
|
||||||
|
|
||||||
if by.get("slug"):
|
Args:
|
||||||
authors_query = authors_query.filter(Author.slug.ilike(f"%{by['slug']}%"))
|
by: Критерий сортировки авторов (new/active)
|
||||||
elif by.get("name"):
|
limit: Максимальное количество возвращаемых авторов
|
||||||
authors_query = authors_query.filter(Author.name.ilike(f"%{by['name']}%"))
|
offset: Смещение для пагинации
|
||||||
elif by.get("topic"):
|
|
||||||
authors_query = (
|
|
||||||
authors_query.join(ShoutAuthor) # Первое соединение ShoutAuthor
|
|
||||||
.join(ShoutTopic, ShoutAuthor.shout == ShoutTopic.shout)
|
|
||||||
.join(Topic, ShoutTopic.topic == Topic.id)
|
|
||||||
.filter(Topic.slug == str(by["topic"]))
|
|
||||||
)
|
|
||||||
|
|
||||||
if by.get("last_seen"): # в unix time
|
Returns:
|
||||||
before = int(time.time()) - by["last_seen"]
|
list: Список авторов с учетом критерия
|
||||||
authors_query = authors_query.filter(Author.last_seen > before)
|
"""
|
||||||
elif by.get("created_at"): # в unix time
|
# Используем оптимизированную функцию для получения авторов
|
||||||
before = int(time.time()) - by["created_at"]
|
return await get_authors_with_stats(limit, offset, by)
|
||||||
authors_query = authors_query.filter(Author.created_at > before)
|
|
||||||
|
|
||||||
authors_query = authors_query.limit(limit).offset(offset)
|
|
||||||
|
|
||||||
|
@query.field("load_authors_search")
|
||||||
|
async def load_authors_search(_, info, text: str, limit: int = 10, offset: int = 0):
|
||||||
|
"""
|
||||||
|
Resolver for searching authors by text. Works with txt-ai search endpony.
|
||||||
|
Args:
|
||||||
|
text: Search text
|
||||||
|
limit: Maximum number of authors to return
|
||||||
|
offset: Offset for pagination
|
||||||
|
Returns:
|
||||||
|
list: List of authors matching the search criteria
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Get author IDs from search engine (already sorted by relevance)
|
||||||
|
search_results = await search_service.search_authors(text, limit, offset)
|
||||||
|
|
||||||
|
if not search_results:
|
||||||
|
return []
|
||||||
|
|
||||||
|
author_ids = [result.get("id") for result in search_results if result.get("id")]
|
||||||
|
if not author_ids:
|
||||||
|
return []
|
||||||
|
|
||||||
|
# Fetch full author objects from DB
|
||||||
with local_session() as session:
|
with local_session() as session:
|
||||||
authors_nostat = session.execute(authors_query).all()
|
# Simple query to get authors by IDs - no need for stats here
|
||||||
authors = []
|
authors_query = select(Author).filter(Author.id.in_(author_ids))
|
||||||
for a in authors_nostat:
|
db_authors = session.execute(authors_query).scalars().all()
|
||||||
if isinstance(a, Author):
|
|
||||||
author_dict = await get_cached_author(a.id, get_with_stat)
|
|
||||||
if author_dict and isinstance(author_dict.get("shouts"), int):
|
|
||||||
authors.append(author_dict)
|
|
||||||
|
|
||||||
# order
|
if not db_authors:
|
||||||
order = by.get("order")
|
return []
|
||||||
if order in ["shouts", "followers"]:
|
|
||||||
authors_query = authors_query.order_by(desc(text(f"{order}_stat")))
|
|
||||||
|
|
||||||
# group by
|
# Create a dictionary for quick lookup
|
||||||
authors = get_with_stat(authors_query)
|
authors_dict = {str(author.id): author for author in db_authors}
|
||||||
return authors or []
|
|
||||||
|
# Keep the order from search results (maintains the relevance sorting)
|
||||||
|
ordered_authors = [authors_dict[author_id] for author_id in author_ids if author_id in authors_dict]
|
||||||
|
|
||||||
|
return ordered_authors
|
||||||
|
|
||||||
|
|
||||||
def get_author_id_from(slug="", user=None, author_id=None):
|
def get_author_id_from(slug="", user=None, author_id=None):
|
||||||
if not slug and not user and not author_id:
|
try:
|
||||||
raise ValueError("One of slug, user, or author_id must be provided")
|
author_id = None
|
||||||
|
if author_id:
|
||||||
author_query = select(Author.id)
|
return author_id
|
||||||
if user:
|
with local_session() as session:
|
||||||
author_query = author_query.filter(Author.user == user)
|
author = None
|
||||||
elif slug:
|
if slug:
|
||||||
author_query = author_query.filter(Author.slug == slug)
|
author = session.query(Author).filter(Author.slug == slug).first()
|
||||||
elif author_id:
|
if author:
|
||||||
author_query = author_query.filter(Author.id == author_id)
|
author_id = author.id
|
||||||
|
return author_id
|
||||||
with local_session() as session:
|
if user:
|
||||||
author_id_result = session.execute(author_query).first()
|
author = session.query(Author).filter(Author.user == user).first()
|
||||||
author_id = author_id_result[0] if author_id_result else None
|
if author:
|
||||||
|
author_id = author.id
|
||||||
if not author_id:
|
except Exception as exc:
|
||||||
raise ValueError("Author not found")
|
logger.error(exc)
|
||||||
|
|
||||||
return author_id
|
return author_id
|
||||||
|
|
||||||
|
|
||||||
@query.field("get_author_follows")
|
@query.field("get_author_follows")
|
||||||
async def get_author_follows(_, _info, slug="", user=None, author_id=0):
|
async def get_author_follows(_, _info, slug="", user=None, author_id=0):
|
||||||
try:
|
logger.debug(f"getting follows for @{slug}")
|
||||||
author_id = get_author_id_from(slug, user, author_id)
|
author_id = get_author_id_from(slug=slug, user=user, author_id=author_id)
|
||||||
|
if not author_id:
|
||||||
|
return {}
|
||||||
|
|
||||||
if bool(author_id):
|
followed_authors = await get_cached_follower_authors(author_id)
|
||||||
logger.debug(f"getting {author_id} follows authors")
|
followed_topics = await get_cached_follower_topics(author_id)
|
||||||
authors = await get_cached_follower_authors(author_id)
|
|
||||||
topics = await get_cached_follower_topics(author_id)
|
|
||||||
return {
|
|
||||||
"topics": topics,
|
|
||||||
"authors": authors,
|
|
||||||
"communities": [{"id": 1, "name": "Дискурс", "slug": "discours", "pic": ""}],
|
|
||||||
}
|
|
||||||
except Exception:
|
|
||||||
import traceback
|
|
||||||
|
|
||||||
traceback.print_exc()
|
# TODO: Get followed communities too
|
||||||
return {"error": "Author not found"}
|
return {
|
||||||
|
"authors": followed_authors,
|
||||||
|
"topics": followed_topics,
|
||||||
|
"communities": DEFAULT_COMMUNITIES,
|
||||||
|
"shouts": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@query.field("get_author_follows_topics")
|
@query.field("get_author_follows_topics")
|
||||||
async def get_author_follows_topics(_, _info, slug="", user=None, author_id=None):
|
async def get_author_follows_topics(_, _info, slug="", user=None, author_id=None):
|
||||||
try:
|
logger.debug(f"getting followed topics for @{slug}")
|
||||||
follower_id = get_author_id_from(slug, user, author_id)
|
author_id = get_author_id_from(slug=slug, user=user, author_id=author_id)
|
||||||
topics = await get_cached_follower_topics(follower_id)
|
if not author_id:
|
||||||
return topics
|
return []
|
||||||
except Exception:
|
followed_topics = await get_cached_follower_topics(author_id)
|
||||||
import traceback
|
return followed_topics
|
||||||
|
|
||||||
traceback.print_exc()
|
|
||||||
|
|
||||||
|
|
||||||
@query.field("get_author_follows_authors")
|
@query.field("get_author_follows_authors")
|
||||||
async def get_author_follows_authors(_, _info, slug="", user=None, author_id=None):
|
async def get_author_follows_authors(_, _info, slug="", user=None, author_id=None):
|
||||||
try:
|
logger.debug(f"getting followed authors for @{slug}")
|
||||||
follower_id = get_author_id_from(slug, user, author_id)
|
author_id = get_author_id_from(slug=slug, user=user, author_id=author_id)
|
||||||
return await get_cached_follower_authors(follower_id)
|
if not author_id:
|
||||||
except Exception:
|
return []
|
||||||
import traceback
|
followed_authors = await get_cached_follower_authors(author_id)
|
||||||
|
return followed_authors
|
||||||
traceback.print_exc()
|
|
||||||
|
|
||||||
|
|
||||||
def create_author(user_id: str, slug: str, name: str = ""):
|
def create_author(user_id: str, slug: str, name: str = ""):
|
||||||
|
author = Author()
|
||||||
|
author.user = user_id # Связь с user_id из системы авторизации
|
||||||
|
author.slug = slug # Идентификатор из системы авторизации
|
||||||
|
author.created_at = author.updated_at = int(time.time())
|
||||||
|
author.name = name or slug # если не указано
|
||||||
|
|
||||||
with local_session() as session:
|
with local_session() as session:
|
||||||
try:
|
session.add(author)
|
||||||
author = None
|
session.commit()
|
||||||
if user_id:
|
return author
|
||||||
author = session.query(Author).filter(Author.user == user_id).first()
|
|
||||||
elif slug:
|
|
||||||
author = session.query(Author).filter(Author.slug == slug).first()
|
|
||||||
if not author:
|
|
||||||
new_author = Author(user=user_id, slug=slug, name=name)
|
|
||||||
session.add(new_author)
|
|
||||||
session.commit()
|
|
||||||
logger.info(f"author created by webhook {new_author.dict()}")
|
|
||||||
except Exception as exc:
|
|
||||||
logger.debug(exc)
|
|
||||||
|
|
||||||
|
|
||||||
@query.field("get_author_followers")
|
@query.field("get_author_followers")
|
||||||
async def get_author_followers(_, _info, slug: str = "", user: str = "", author_id: int = 0):
|
async def get_author_followers(_, _info, slug: str = "", user: str = "", author_id: int = 0):
|
||||||
logger.debug(f"getting followers for @{slug}")
|
logger.debug(f"getting followers for author @{slug} or ID:{author_id}")
|
||||||
author_id = get_author_id_from(slug=slug, user=user, author_id=author_id)
|
author_id = get_author_id_from(slug=slug, user=user, author_id=author_id)
|
||||||
followers = []
|
if not author_id:
|
||||||
if author_id:
|
return []
|
||||||
followers = await get_cached_author_followers(author_id)
|
followers = await get_cached_author_followers(author_id)
|
||||||
return followers
|
return followers
|
||||||
|
@@ -1,6 +1,6 @@
|
|||||||
import time
|
import time
|
||||||
|
import trafilatura
|
||||||
from sqlalchemy.sql import and_
|
from sqlalchemy.orm import joinedload
|
||||||
|
|
||||||
from cache.cache import (
|
from cache.cache import (
|
||||||
cache_author,
|
cache_author,
|
||||||
@@ -10,7 +10,7 @@ from cache.cache import (
|
|||||||
invalidate_shouts_cache,
|
invalidate_shouts_cache,
|
||||||
)
|
)
|
||||||
from orm.author import Author
|
from orm.author import Author
|
||||||
from orm.draft import Draft
|
from orm.draft import Draft, DraftAuthor, DraftTopic
|
||||||
from orm.shout import Shout, ShoutAuthor, ShoutTopic
|
from orm.shout import Shout, ShoutAuthor, ShoutTopic
|
||||||
from orm.topic import Topic
|
from orm.topic import Topic
|
||||||
from services.auth import login_required
|
from services.auth import login_required
|
||||||
@@ -18,35 +18,70 @@ from services.db import local_session
|
|||||||
from services.notify import notify_shout
|
from services.notify import notify_shout
|
||||||
from services.schema import mutation, query
|
from services.schema import mutation, query
|
||||||
from services.search import search_service
|
from services.search import search_service
|
||||||
|
from utils.html_wrapper import wrap_html_fragment
|
||||||
from utils.logger import root_logger as logger
|
from utils.logger import root_logger as logger
|
||||||
|
|
||||||
|
|
||||||
def create_shout_from_draft(session, draft, author_id):
|
def create_shout_from_draft(session, draft, author_id):
|
||||||
|
"""
|
||||||
|
Создаёт новый объект публикации (Shout) на основе черновика.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session: SQLAlchemy сессия (не используется, для совместимости)
|
||||||
|
draft (Draft): Объект черновика
|
||||||
|
author_id (int): ID автора публикации
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Shout: Новый объект публикации (не сохранённый в базе)
|
||||||
|
|
||||||
|
Пример:
|
||||||
|
>>> from orm.draft import Draft
|
||||||
|
>>> draft = Draft(id=1, title='Заголовок', body='Текст', slug='slug', created_by=1)
|
||||||
|
>>> shout = create_shout_from_draft(None, draft, 1)
|
||||||
|
>>> shout.title
|
||||||
|
'Заголовок'
|
||||||
|
>>> shout.body
|
||||||
|
'Текст'
|
||||||
|
>>> shout.created_by
|
||||||
|
1
|
||||||
|
"""
|
||||||
# Создаем новую публикацию
|
# Создаем новую публикацию
|
||||||
shout = Shout(
|
shout = Shout(
|
||||||
body=draft.body,
|
body=draft.body or "",
|
||||||
slug=draft.slug,
|
slug=draft.slug,
|
||||||
cover=draft.cover,
|
cover=draft.cover,
|
||||||
cover_caption=draft.cover_caption,
|
cover_caption=draft.cover_caption,
|
||||||
lead=draft.lead,
|
lead=draft.lead,
|
||||||
description=draft.description,
|
title=draft.title or "",
|
||||||
title=draft.title,
|
|
||||||
subtitle=draft.subtitle,
|
subtitle=draft.subtitle,
|
||||||
layout=draft.layout,
|
layout=draft.layout or "article",
|
||||||
media=draft.media,
|
media=draft.media or [],
|
||||||
lang=draft.lang,
|
lang=draft.lang or "ru",
|
||||||
seo=draft.seo,
|
seo=draft.seo,
|
||||||
created_by=author_id,
|
created_by=author_id,
|
||||||
community=draft.community,
|
community=draft.community,
|
||||||
draft=draft.id,
|
draft=draft.id,
|
||||||
deleted_at=None,
|
deleted_at=None,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Инициализируем пустые массивы для связей
|
||||||
|
shout.topics = []
|
||||||
|
shout.authors = []
|
||||||
|
|
||||||
return shout
|
return shout
|
||||||
|
|
||||||
|
|
||||||
@query.field("load_drafts")
|
@query.field("load_drafts")
|
||||||
@login_required
|
@login_required
|
||||||
async def load_drafts(_, info):
|
async def load_drafts(_, info):
|
||||||
|
"""
|
||||||
|
Загружает все черновики, доступные текущему пользователю.
|
||||||
|
|
||||||
|
Предварительно загружает связанные объекты (topics, authors, publication),
|
||||||
|
чтобы избежать ошибок с отсоединенными объектами при сериализации.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict: Список черновиков или сообщение об ошибке
|
||||||
|
"""
|
||||||
user_id = info.context.get("user_id")
|
user_id = info.context.get("user_id")
|
||||||
author_dict = info.context.get("author", {})
|
author_dict = info.context.get("author", {})
|
||||||
author_id = author_dict.get("id")
|
author_id = author_dict.get("id")
|
||||||
@@ -54,9 +89,44 @@ async def load_drafts(_, info):
|
|||||||
if not user_id or not author_id:
|
if not user_id or not author_id:
|
||||||
return {"error": "User ID and author ID are required"}
|
return {"error": "User ID and author ID are required"}
|
||||||
|
|
||||||
with local_session() as session:
|
try:
|
||||||
drafts = session.query(Draft).filter(Draft.authors.any(Author.id == author_id)).all()
|
with local_session() as session:
|
||||||
return {"drafts": drafts}
|
# Предзагружаем authors, topics и связанную publication
|
||||||
|
drafts_query = (
|
||||||
|
session.query(Draft)
|
||||||
|
.options(
|
||||||
|
joinedload(Draft.topics),
|
||||||
|
joinedload(Draft.authors),
|
||||||
|
joinedload(Draft.publication) # Загружаем связанную публикацию
|
||||||
|
)
|
||||||
|
.filter(Draft.authors.any(Author.id == author_id))
|
||||||
|
)
|
||||||
|
drafts = drafts_query.all()
|
||||||
|
|
||||||
|
# Преобразуем объекты в словари, пока они в контексте сессии
|
||||||
|
drafts_data = []
|
||||||
|
for draft in drafts:
|
||||||
|
draft_dict = draft.dict()
|
||||||
|
# Всегда возвращаем массив для topics, даже если он пустой
|
||||||
|
draft_dict["topics"] = [topic.dict() for topic in (draft.topics or [])]
|
||||||
|
draft_dict["authors"] = [author.dict() for author in (draft.authors or [])]
|
||||||
|
|
||||||
|
# Добавляем информацию о публикации, если она есть
|
||||||
|
if draft.publication:
|
||||||
|
draft_dict["publication"] = {
|
||||||
|
"id": draft.publication.id,
|
||||||
|
"slug": draft.publication.slug,
|
||||||
|
"published_at": draft.publication.published_at
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
draft_dict["publication"] = None
|
||||||
|
|
||||||
|
drafts_data.append(draft_dict)
|
||||||
|
|
||||||
|
return {"drafts": drafts_data}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to load drafts: {e}", exc_info=True)
|
||||||
|
return {"error": f"Failed to load drafts: {str(e)}"}
|
||||||
|
|
||||||
|
|
||||||
@mutation.field("create_draft")
|
@mutation.field("create_draft")
|
||||||
@@ -100,46 +170,157 @@ async def create_draft(_, info, draft_input):
|
|||||||
if "title" not in draft_input or not draft_input["title"]:
|
if "title" not in draft_input or not draft_input["title"]:
|
||||||
draft_input["title"] = "" # Пустая строка вместо NULL
|
draft_input["title"] = "" # Пустая строка вместо NULL
|
||||||
|
|
||||||
|
# Проверяем slug - он должен быть или не пустым, или не передаваться вообще
|
||||||
|
if "slug" in draft_input and (draft_input["slug"] is None or draft_input["slug"] == ""):
|
||||||
|
# При создании черновика удаляем пустой slug из входных данных
|
||||||
|
del draft_input["slug"]
|
||||||
|
|
||||||
try:
|
try:
|
||||||
with local_session() as session:
|
with local_session() as session:
|
||||||
# Remove id from input if present since it's auto-generated
|
# Remove id from input if present since it's auto-generated
|
||||||
if "id" in draft_input:
|
if "id" in draft_input:
|
||||||
del draft_input["id"]
|
del draft_input["id"]
|
||||||
|
|
||||||
# Добавляем текущее время создания
|
# Добавляем текущее время создания и ID автора
|
||||||
draft_input["created_at"] = int(time.time())
|
draft_input["created_at"] = int(time.time())
|
||||||
|
draft_input["created_by"] = author_id
|
||||||
draft = Draft(created_by=author_id, **draft_input)
|
draft = Draft(**draft_input)
|
||||||
session.add(draft)
|
session.add(draft)
|
||||||
|
session.flush()
|
||||||
|
|
||||||
|
# Добавляем создателя как автора
|
||||||
|
da = DraftAuthor(shout=draft.id, author=author_id)
|
||||||
|
session.add(da)
|
||||||
|
|
||||||
session.commit()
|
session.commit()
|
||||||
return {"draft": draft}
|
return {"draft": draft}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to create draft: {e}", exc_info=True)
|
logger.error(f"Failed to create draft: {e}", exc_info=True)
|
||||||
return {"error": f"Failed to create draft: {str(e)}"}
|
return {"error": f"Failed to create draft: {str(e)}"}
|
||||||
|
|
||||||
|
def generate_teaser(body, limit=300):
|
||||||
|
body_html = wrap_html_fragment(body)
|
||||||
|
body_text = trafilatura.extract(body_html, include_comments=False, include_tables=False)
|
||||||
|
body_teaser = ". ".join(body_text[:limit].split(". ")[:-1])
|
||||||
|
return body_teaser
|
||||||
|
|
||||||
|
|
||||||
@mutation.field("update_draft")
|
@mutation.field("update_draft")
|
||||||
@login_required
|
@login_required
|
||||||
async def update_draft(_, info, draft_input):
|
async def update_draft(_, info, draft_id: int, draft_input):
|
||||||
|
"""Обновляет черновик публикации.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
draft_id: ID черновика для обновления
|
||||||
|
draft_input: Данные для обновления черновика согласно схеме DraftInput:
|
||||||
|
- layout: String
|
||||||
|
- author_ids: [Int!]
|
||||||
|
- topic_ids: [Int!]
|
||||||
|
- main_topic_id: Int
|
||||||
|
- media: [MediaItemInput]
|
||||||
|
- lead: String
|
||||||
|
- subtitle: String
|
||||||
|
- lang: String
|
||||||
|
- seo: String
|
||||||
|
- body: String
|
||||||
|
- title: String
|
||||||
|
- slug: String
|
||||||
|
- cover: String
|
||||||
|
- cover_caption: String
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict: Обновленный черновик или сообщение об ошибке
|
||||||
|
"""
|
||||||
user_id = info.context.get("user_id")
|
user_id = info.context.get("user_id")
|
||||||
author_dict = info.context.get("author", {})
|
author_dict = info.context.get("author", {})
|
||||||
author_id = author_dict.get("id")
|
author_id = author_dict.get("id")
|
||||||
draft_id = draft_input.get("id")
|
|
||||||
if not draft_id:
|
|
||||||
return {"error": "Draft ID is required"}
|
|
||||||
if not user_id or not author_id:
|
if not user_id or not author_id:
|
||||||
return {"error": "Author ID are required"}
|
return {"error": "Author ID are required"}
|
||||||
|
|
||||||
with local_session() as session:
|
try:
|
||||||
draft = session.query(Draft).filter(Draft.id == draft_id).first()
|
with local_session() as session:
|
||||||
del draft_input["id"]
|
draft = session.query(Draft).filter(Draft.id == draft_id).first()
|
||||||
Draft.update(draft, {**draft_input})
|
if not draft:
|
||||||
if not draft:
|
return {"error": "Draft not found"}
|
||||||
return {"error": "Draft not found"}
|
|
||||||
|
|
||||||
draft.updated_at = int(time.time())
|
# Фильтруем входные данные, оставляя только разрешенные поля
|
||||||
session.commit()
|
allowed_fields = {
|
||||||
return {"draft": draft}
|
"layout", "author_ids", "topic_ids", "main_topic_id",
|
||||||
|
"media", "lead", "subtitle", "lang", "seo", "body",
|
||||||
|
"title", "slug", "cover", "cover_caption"
|
||||||
|
}
|
||||||
|
filtered_input = {k: v for k, v in draft_input.items() if k in allowed_fields}
|
||||||
|
|
||||||
|
# Проверяем slug
|
||||||
|
if "slug" in filtered_input and not filtered_input["slug"]:
|
||||||
|
del filtered_input["slug"]
|
||||||
|
|
||||||
|
# Обновляем связи с авторами если переданы
|
||||||
|
if "author_ids" in filtered_input:
|
||||||
|
author_ids = filtered_input.pop("author_ids")
|
||||||
|
if author_ids:
|
||||||
|
# Очищаем текущие связи
|
||||||
|
session.query(DraftAuthor).filter(DraftAuthor.shout == draft_id).delete()
|
||||||
|
# Добавляем новые связи
|
||||||
|
for aid in author_ids:
|
||||||
|
da = DraftAuthor(shout=draft_id, author=aid)
|
||||||
|
session.add(da)
|
||||||
|
|
||||||
|
# Обновляем связи с темами если переданы
|
||||||
|
if "topic_ids" in filtered_input:
|
||||||
|
topic_ids = filtered_input.pop("topic_ids")
|
||||||
|
main_topic_id = filtered_input.pop("main_topic_id", None)
|
||||||
|
if topic_ids:
|
||||||
|
# Очищаем текущие связи
|
||||||
|
session.query(DraftTopic).filter(DraftTopic.shout == draft_id).delete()
|
||||||
|
# Добавляем новые связи
|
||||||
|
for tid in topic_ids:
|
||||||
|
dt = DraftTopic(
|
||||||
|
shout=draft_id,
|
||||||
|
topic=tid,
|
||||||
|
main=(tid == main_topic_id) if main_topic_id else False
|
||||||
|
)
|
||||||
|
session.add(dt)
|
||||||
|
|
||||||
|
# Генерируем SEO если не предоставлено
|
||||||
|
if "seo" not in filtered_input and not draft.seo:
|
||||||
|
body_src = filtered_input.get("body", draft.body)
|
||||||
|
lead_src = filtered_input.get("lead", draft.lead)
|
||||||
|
body_html = wrap_html_fragment(body_src)
|
||||||
|
lead_html = wrap_html_fragment(lead_src)
|
||||||
|
|
||||||
|
try:
|
||||||
|
body_text = trafilatura.extract(body_html, include_comments=False, include_tables=False) if body_src else None
|
||||||
|
lead_text = trafilatura.extract(lead_html, include_comments=False, include_tables=False) if lead_src else None
|
||||||
|
|
||||||
|
body_teaser = generate_teaser(body_text, 300) if body_text else ""
|
||||||
|
filtered_input["seo"] = lead_text if lead_text else body_teaser
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed to generate SEO for draft {draft_id}: {e}")
|
||||||
|
|
||||||
|
# Обновляем основные поля черновика
|
||||||
|
for key, value in filtered_input.items():
|
||||||
|
setattr(draft, key, value)
|
||||||
|
|
||||||
|
# Обновляем метаданные
|
||||||
|
draft.updated_at = int(time.time())
|
||||||
|
draft.updated_by = author_id
|
||||||
|
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
# Преобразуем объект в словарь для ответа
|
||||||
|
draft_dict = draft.dict()
|
||||||
|
draft_dict["topics"] = [topic.dict() for topic in draft.topics]
|
||||||
|
draft_dict["authors"] = [author.dict() for author in draft.authors]
|
||||||
|
# Добавляем объект автора в updated_by
|
||||||
|
draft_dict["updated_by"] = author_dict
|
||||||
|
|
||||||
|
return {"draft": draft_dict}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to update draft: {e}", exc_info=True)
|
||||||
|
return {"error": f"Failed to update draft: {str(e)}"}
|
||||||
|
|
||||||
|
|
||||||
@mutation.field("delete_draft")
|
@mutation.field("delete_draft")
|
||||||
@@ -159,183 +340,136 @@ async def delete_draft(_, info, draft_id: int):
|
|||||||
return {"draft": draft}
|
return {"draft": draft}
|
||||||
|
|
||||||
|
|
||||||
|
def validate_html_content(html_content: str) -> tuple[bool, str]:
|
||||||
|
"""
|
||||||
|
Проверяет валидность HTML контента через trafilatura.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
html_content: HTML строка для проверки
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
tuple[bool, str]: (валидность, сообщение об ошибке)
|
||||||
|
|
||||||
|
Example:
|
||||||
|
>>> is_valid, error = validate_html_content("<p>Valid HTML</p>")
|
||||||
|
>>> is_valid
|
||||||
|
True
|
||||||
|
>>> error
|
||||||
|
''
|
||||||
|
>>> is_valid, error = validate_html_content("Invalid < HTML")
|
||||||
|
>>> is_valid
|
||||||
|
False
|
||||||
|
>>> 'Invalid HTML' in error
|
||||||
|
True
|
||||||
|
"""
|
||||||
|
if not html_content or not html_content.strip():
|
||||||
|
return False, "Content is empty"
|
||||||
|
|
||||||
|
try:
|
||||||
|
html_content = wrap_html_fragment(html_content)
|
||||||
|
extracted = trafilatura.extract(html_content)
|
||||||
|
if not extracted:
|
||||||
|
return False, "Invalid HTML structure or empty content"
|
||||||
|
return True, ""
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"HTML validation error: {e}", exc_info=True)
|
||||||
|
return False, f"Invalid HTML content: {str(e)}"
|
||||||
|
|
||||||
|
|
||||||
@mutation.field("publish_draft")
|
@mutation.field("publish_draft")
|
||||||
@login_required
|
@login_required
|
||||||
async def publish_draft(_, info, draft_id: int):
|
async def publish_draft(_, info, draft_id: int):
|
||||||
user_id = info.context.get("user_id")
|
"""
|
||||||
author_dict = info.context.get("author", {})
|
Публикует черновик, создавая новый Shout или обновляя существующий.
|
||||||
author_id = author_dict.get("id")
|
|
||||||
if not user_id or not author_id:
|
|
||||||
return {"error": "User ID and author ID are required"}
|
|
||||||
|
|
||||||
with local_session() as session:
|
|
||||||
draft = session.query(Draft).filter(Draft.id == draft_id).first()
|
|
||||||
if not draft:
|
|
||||||
return {"error": "Draft not found"}
|
|
||||||
shout = create_shout_from_draft(session, draft, author_id)
|
|
||||||
session.add(shout)
|
|
||||||
session.commit()
|
|
||||||
return {"shout": shout, "draft": draft}
|
|
||||||
|
|
||||||
|
|
||||||
@mutation.field("unpublish_draft")
|
|
||||||
@login_required
|
|
||||||
async def unpublish_draft(_, info, draft_id: int):
|
|
||||||
user_id = info.context.get("user_id")
|
|
||||||
author_dict = info.context.get("author", {})
|
|
||||||
author_id = author_dict.get("id")
|
|
||||||
if not user_id or not author_id:
|
|
||||||
return {"error": "User ID and author ID are required"}
|
|
||||||
|
|
||||||
with local_session() as session:
|
|
||||||
draft = session.query(Draft).filter(Draft.id == draft_id).first()
|
|
||||||
if not draft:
|
|
||||||
return {"error": "Draft not found"}
|
|
||||||
shout = session.query(Shout).filter(Shout.draft == draft.id).first()
|
|
||||||
if shout:
|
|
||||||
shout.published_at = None
|
|
||||||
session.commit()
|
|
||||||
return {"shout": shout, "draft": draft}
|
|
||||||
return {"error": "Failed to unpublish draft"}
|
|
||||||
|
|
||||||
|
|
||||||
@mutation.field("publish_shout")
|
|
||||||
@login_required
|
|
||||||
async def publish_shout(_, info, shout_id: int):
|
|
||||||
"""Publish draft as a shout or update existing shout.
|
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
shout_id: ID существующей публикации или 0 для новой
|
draft_id (int): ID черновика для публикации
|
||||||
draft: Объект черновика (опционально)
|
|
||||||
|
Returns:
|
||||||
|
dict: Результат публикации с shout или сообщением об ошибке
|
||||||
"""
|
"""
|
||||||
user_id = info.context.get("user_id")
|
user_id = info.context.get("user_id")
|
||||||
author_dict = info.context.get("author", {})
|
author_dict = info.context.get("author", {})
|
||||||
author_id = author_dict.get("id")
|
author_id = author_dict.get("id")
|
||||||
now = int(time.time())
|
|
||||||
if not user_id or not author_id:
|
if not user_id or not author_id:
|
||||||
return {"error": "User ID and author ID are required"}
|
return {"error": "Author ID is required"}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
with local_session() as session:
|
with local_session() as session:
|
||||||
shout = session.query(Shout).filter(Shout.id == shout_id).first()
|
# Загружаем черновик со всеми связями
|
||||||
if not shout:
|
draft = (
|
||||||
return {"error": "Shout not found"}
|
session.query(Draft)
|
||||||
was_published = shout.published_at is not None
|
.options(
|
||||||
draft = session.query(Draft).where(Draft.id == shout.draft).first()
|
joinedload(Draft.topics),
|
||||||
|
joinedload(Draft.authors),
|
||||||
|
joinedload(Draft.publication)
|
||||||
|
)
|
||||||
|
.filter(Draft.id == draft_id)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
|
||||||
if not draft:
|
if not draft:
|
||||||
return {"error": "Draft not found"}
|
return {"error": "Draft not found"}
|
||||||
# Находим черновик если не передан
|
|
||||||
|
|
||||||
if not shout:
|
# Проверка валидности HTML в body
|
||||||
shout = create_shout_from_draft(session, draft, author_id)
|
is_valid, error = validate_html_content(draft.body)
|
||||||
else:
|
if not is_valid:
|
||||||
|
return {"error": f"Cannot publish draft: {error}"}
|
||||||
|
|
||||||
|
# Проверяем, есть ли уже публикация для этого черновика
|
||||||
|
if draft.publication:
|
||||||
|
shout = draft.publication
|
||||||
# Обновляем существующую публикацию
|
# Обновляем существующую публикацию
|
||||||
shout.draft = draft.id
|
for field in ["body", "title", "subtitle", "lead", "cover", "cover_caption", "media", "lang", "seo"]:
|
||||||
shout.created_by = author_id
|
if hasattr(draft, field):
|
||||||
shout.title = draft.title
|
setattr(shout, field, getattr(draft, field))
|
||||||
shout.subtitle = draft.subtitle
|
shout.updated_at = int(time.time())
|
||||||
shout.body = draft.body
|
shout.updated_by = author_id
|
||||||
shout.cover = draft.cover
|
else:
|
||||||
shout.cover_caption = draft.cover_caption
|
# Создаем новую публикацию
|
||||||
shout.lead = draft.lead
|
shout = create_shout_from_draft(session, draft, author_id)
|
||||||
shout.description = draft.description
|
now = int(time.time())
|
||||||
shout.layout = draft.layout
|
shout.created_at = now
|
||||||
shout.media = draft.media
|
shout.published_at = now
|
||||||
shout.lang = draft.lang
|
session.add(shout)
|
||||||
shout.seo = draft.seo
|
session.flush() # Получаем ID нового шаута
|
||||||
|
|
||||||
draft.updated_at = now
|
# Очищаем существующие связи
|
||||||
shout.updated_at = now
|
session.query(ShoutAuthor).filter(ShoutAuthor.shout == shout.id).delete()
|
||||||
|
session.query(ShoutTopic).filter(ShoutTopic.shout == shout.id).delete()
|
||||||
|
|
||||||
# Устанавливаем published_at только если была ранее снята с публикации
|
# Добавляем авторов
|
||||||
if not was_published:
|
for author in (draft.authors or []):
|
||||||
shout.published_at = now
|
sa = ShoutAuthor(shout=shout.id, author=author.id)
|
||||||
|
|
||||||
# Обрабатываем связи с авторами
|
|
||||||
if (
|
|
||||||
not session.query(ShoutAuthor)
|
|
||||||
.filter(and_(ShoutAuthor.shout == shout.id, ShoutAuthor.author == author_id))
|
|
||||||
.first()
|
|
||||||
):
|
|
||||||
sa = ShoutAuthor(shout=shout.id, author=author_id)
|
|
||||||
session.add(sa)
|
session.add(sa)
|
||||||
|
|
||||||
# Обрабатываем темы
|
# Добавляем темы
|
||||||
if draft.topics:
|
for topic in (draft.topics or []):
|
||||||
for topic in draft.topics:
|
st = ShoutTopic(
|
||||||
st = ShoutTopic(
|
topic=topic.id,
|
||||||
topic=topic.id, shout=shout.id, main=topic.main if hasattr(topic, "main") else False
|
shout=shout.id,
|
||||||
)
|
main=topic.main if hasattr(topic, "main") else False
|
||||||
session.add(st)
|
)
|
||||||
|
session.add(st)
|
||||||
session.add(shout)
|
|
||||||
session.add(draft)
|
|
||||||
session.flush()
|
|
||||||
|
|
||||||
# Инвалидируем кэш только если это новая публикация или была снята с публикации
|
|
||||||
if not was_published:
|
|
||||||
cache_keys = ["feed", f"author_{author_id}", "random_top", "unrated"]
|
|
||||||
|
|
||||||
# Добавляем ключи для тем
|
|
||||||
for topic in shout.topics:
|
|
||||||
cache_keys.append(f"topic_{topic.id}")
|
|
||||||
cache_keys.append(f"topic_shouts_{topic.id}")
|
|
||||||
await cache_by_id(Topic, topic.id, cache_topic)
|
|
||||||
|
|
||||||
# Инвалидируем кэш
|
|
||||||
await invalidate_shouts_cache(cache_keys)
|
|
||||||
await invalidate_shout_related_cache(shout, author_id)
|
|
||||||
|
|
||||||
# Обновляем кэш авторов
|
|
||||||
for author in shout.authors:
|
|
||||||
await cache_by_id(Author, author.id, cache_author)
|
|
||||||
|
|
||||||
# Отправляем уведомление о публикации
|
|
||||||
await notify_shout(shout.dict(), "published")
|
|
||||||
|
|
||||||
# Обновляем поисковый индекс
|
|
||||||
search_service.index(shout)
|
|
||||||
else:
|
|
||||||
# Для уже опубликованных материалов просто отправляем уведомление об обновлении
|
|
||||||
await notify_shout(shout.dict(), "update")
|
|
||||||
|
|
||||||
session.commit()
|
session.commit()
|
||||||
|
|
||||||
|
# Инвалидируем кеш
|
||||||
|
invalidate_shouts_cache()
|
||||||
|
invalidate_shout_related_cache(shout.id)
|
||||||
|
|
||||||
|
# Уведомляем о публикации
|
||||||
|
await notify_shout(shout.id)
|
||||||
|
|
||||||
|
# Обновляем поисковый индекс
|
||||||
|
search_service.index_shout(shout)
|
||||||
|
|
||||||
|
logger.info(f"Successfully published shout #{shout.id} from draft #{draft_id}")
|
||||||
|
logger.debug(f"Shout data: {shout.dict()}")
|
||||||
|
|
||||||
return {"shout": shout}
|
return {"shout": shout}
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to publish shout: {e}", exc_info=True)
|
logger.error(f"Failed to publish draft {draft_id}: {e}", exc_info=True)
|
||||||
if "session" in locals():
|
return {"error": f"Failed to publish draft: {str(e)}"}
|
||||||
session.rollback()
|
|
||||||
return {"error": f"Failed to publish shout: {str(e)}"}
|
|
||||||
|
|
||||||
|
|
||||||
@mutation.field("unpublish_shout")
|
|
||||||
@login_required
|
|
||||||
async def unpublish_shout(_, info, shout_id: int):
|
|
||||||
"""Unpublish a shout.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
shout_id: The ID of the shout to unpublish
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
dict: The unpublished shout or an error message
|
|
||||||
"""
|
|
||||||
author_dict = info.context.get("author", {})
|
|
||||||
author_id = author_dict.get("id")
|
|
||||||
if not author_id:
|
|
||||||
return {"error": "Author ID is required"}
|
|
||||||
|
|
||||||
shout = None
|
|
||||||
with local_session() as session:
|
|
||||||
try:
|
|
||||||
shout = session.query(Shout).filter(Shout.id == shout_id).first()
|
|
||||||
shout.published_at = None
|
|
||||||
session.commit()
|
|
||||||
invalidate_shout_related_cache(shout)
|
|
||||||
invalidate_shouts_cache()
|
|
||||||
|
|
||||||
except Exception:
|
|
||||||
session.rollback()
|
|
||||||
return {"error": "Failed to unpublish shout"}
|
|
||||||
|
|
||||||
return {"shout": shout}
|
|
||||||
|
@@ -1,8 +1,9 @@
|
|||||||
import json
|
|
||||||
import time
|
import time
|
||||||
|
|
||||||
|
import orjson
|
||||||
|
import trafilatura
|
||||||
from sqlalchemy import and_, desc, select
|
from sqlalchemy import and_, desc, select
|
||||||
from sqlalchemy.orm import joinedload
|
from sqlalchemy.orm import joinedload, selectinload
|
||||||
from sqlalchemy.sql.functions import coalesce
|
from sqlalchemy.sql.functions import coalesce
|
||||||
|
|
||||||
from cache.cache import (
|
from cache.cache import (
|
||||||
@@ -12,6 +13,7 @@ from cache.cache import (
|
|||||||
invalidate_shouts_cache,
|
invalidate_shouts_cache,
|
||||||
)
|
)
|
||||||
from orm.author import Author
|
from orm.author import Author
|
||||||
|
from orm.draft import Draft
|
||||||
from orm.shout import Shout, ShoutAuthor, ShoutTopic
|
from orm.shout import Shout, ShoutAuthor, ShoutTopic
|
||||||
from orm.topic import Topic
|
from orm.topic import Topic
|
||||||
from resolvers.follower import follow, unfollow
|
from resolvers.follower import follow, unfollow
|
||||||
@@ -19,8 +21,9 @@ from resolvers.stat import get_with_stat
|
|||||||
from services.auth import login_required
|
from services.auth import login_required
|
||||||
from services.db import local_session
|
from services.db import local_session
|
||||||
from services.notify import notify_shout
|
from services.notify import notify_shout
|
||||||
from services.schema import query
|
from services.schema import mutation, query
|
||||||
from services.search import search_service
|
from services.search import search_service
|
||||||
|
from utils.html_wrapper import wrap_html_fragment
|
||||||
from utils.logger import root_logger as logger
|
from utils.logger import root_logger as logger
|
||||||
|
|
||||||
|
|
||||||
@@ -106,7 +109,7 @@ async def get_my_shout(_, info, shout_id: int):
|
|||||||
if hasattr(shout, "media") and shout.media:
|
if hasattr(shout, "media") and shout.media:
|
||||||
if isinstance(shout.media, str):
|
if isinstance(shout.media, str):
|
||||||
try:
|
try:
|
||||||
shout.media = json.loads(shout.media)
|
shout.media = orjson.loads(shout.media)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error parsing shout media: {e}")
|
logger.error(f"Error parsing shout media: {e}")
|
||||||
shout.media = []
|
shout.media = []
|
||||||
@@ -176,9 +179,18 @@ async def create_shout(_, info, inp):
|
|||||||
|
|
||||||
logger.info(f"Creating shout with input: {inp}")
|
logger.info(f"Creating shout with input: {inp}")
|
||||||
# Создаем публикацию без topics
|
# Создаем публикацию без topics
|
||||||
|
body = inp.get("body", "")
|
||||||
|
lead = inp.get("lead", "")
|
||||||
|
body_html = wrap_html_fragment(body)
|
||||||
|
lead_html = wrap_html_fragment(lead)
|
||||||
|
body_text = trafilatura.extract(body_html)
|
||||||
|
lead_text = trafilatura.extract(lead_html)
|
||||||
|
seo = inp.get("seo", lead_text.strip() or body_text.strip()[:300].split(". ")[:-1].join(". "))
|
||||||
new_shout = Shout(
|
new_shout = Shout(
|
||||||
slug=slug,
|
slug=slug,
|
||||||
body=inp.get("body", ""),
|
body=body,
|
||||||
|
seo=seo,
|
||||||
|
lead=lead,
|
||||||
layout=inp.get("layout", "article"),
|
layout=inp.get("layout", "article"),
|
||||||
title=inp.get("title", ""),
|
title=inp.get("title", ""),
|
||||||
created_by=author_id,
|
created_by=author_id,
|
||||||
@@ -380,7 +392,7 @@ def patch_topics(session, shout, topics_input):
|
|||||||
# @login_required
|
# @login_required
|
||||||
async def update_shout(_, info, shout_id: int, shout_input=None, publish=False):
|
async def update_shout(_, info, shout_id: int, shout_input=None, publish=False):
|
||||||
logger.info(f"Starting update_shout with id={shout_id}, publish={publish}")
|
logger.info(f"Starting update_shout with id={shout_id}, publish={publish}")
|
||||||
logger.debug(f"Full shout_input: {shout_input}")
|
logger.debug(f"Full shout_input: {shout_input}") # DraftInput
|
||||||
|
|
||||||
user_id = info.context.get("user_id")
|
user_id = info.context.get("user_id")
|
||||||
roles = info.context.get("roles", [])
|
roles = info.context.get("roles", [])
|
||||||
@@ -637,39 +649,178 @@ def get_main_topic(topics):
|
|||||||
"""Get the main topic from a list of ShoutTopic objects."""
|
"""Get the main topic from a list of ShoutTopic objects."""
|
||||||
logger.info(f"Starting get_main_topic with {len(topics) if topics else 0} topics")
|
logger.info(f"Starting get_main_topic with {len(topics) if topics else 0} topics")
|
||||||
logger.debug(
|
logger.debug(
|
||||||
f"Topics data: {[(t.topic.slug if t.topic else 'no-topic', t.main) for t in topics] if topics else []}"
|
f"Topics data: {[(t.slug, getattr(t, 'main', False)) for t in topics] if topics else []}"
|
||||||
)
|
)
|
||||||
|
|
||||||
if not topics:
|
if not topics:
|
||||||
logger.warning("No topics provided to get_main_topic")
|
logger.warning("No topics provided to get_main_topic")
|
||||||
return {"id": 0, "title": "no topic", "slug": "notopic", "is_main": True}
|
return {"id": 0, "title": "no topic", "slug": "notopic", "is_main": True}
|
||||||
|
|
||||||
# Find first main topic in original order
|
# Проверяем, является ли topics списком объектов ShoutTopic или Topic
|
||||||
main_topic_rel = next((st for st in topics if st.main), None)
|
if hasattr(topics[0], 'topic') and topics[0].topic:
|
||||||
logger.debug(
|
# Для ShoutTopic объектов (старый формат)
|
||||||
f"Found main topic relation: {main_topic_rel.topic.slug if main_topic_rel and main_topic_rel.topic else None}"
|
# Find first main topic in original order
|
||||||
)
|
main_topic_rel = next((st for st in topics if getattr(st, 'main', False)), None)
|
||||||
|
logger.debug(
|
||||||
|
f"Found main topic relation: {main_topic_rel.topic.slug if main_topic_rel and main_topic_rel.topic else None}"
|
||||||
|
)
|
||||||
|
|
||||||
if main_topic_rel and main_topic_rel.topic:
|
if main_topic_rel and main_topic_rel.topic:
|
||||||
result = {
|
result = {
|
||||||
"slug": main_topic_rel.topic.slug,
|
"slug": main_topic_rel.topic.slug,
|
||||||
"title": main_topic_rel.topic.title,
|
"title": main_topic_rel.topic.title,
|
||||||
"id": main_topic_rel.topic.id,
|
"id": main_topic_rel.topic.id,
|
||||||
"is_main": True,
|
"is_main": True,
|
||||||
}
|
}
|
||||||
logger.info(f"Returning main topic: {result}")
|
logger.info(f"Returning main topic: {result}")
|
||||||
return result
|
return result
|
||||||
|
|
||||||
# If no main found but topics exist, return first
|
# If no main found but topics exist, return first
|
||||||
if topics and topics[0].topic:
|
if topics and topics[0].topic:
|
||||||
logger.info(f"No main topic found, using first topic: {topics[0].topic.slug}")
|
logger.info(f"No main topic found, using first topic: {topics[0].topic.slug}")
|
||||||
result = {
|
result = {
|
||||||
"slug": topics[0].topic.slug,
|
"slug": topics[0].topic.slug,
|
||||||
"title": topics[0].topic.title,
|
"title": topics[0].topic.title,
|
||||||
"id": topics[0].topic.id,
|
"id": topics[0].topic.id,
|
||||||
"is_main": True,
|
"is_main": True,
|
||||||
}
|
}
|
||||||
return result
|
return result
|
||||||
|
else:
|
||||||
|
# Для Topic объектов (новый формат из selectinload)
|
||||||
|
# После смены на selectinload у нас просто список Topic объектов
|
||||||
|
if topics:
|
||||||
|
logger.info(f"Using first topic as main: {topics[0].slug}")
|
||||||
|
result = {
|
||||||
|
"slug": topics[0].slug,
|
||||||
|
"title": topics[0].title,
|
||||||
|
"id": topics[0].id,
|
||||||
|
"is_main": True,
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
|
||||||
logger.warning("No valid topics found, returning default")
|
logger.warning("No valid topics found, returning default")
|
||||||
return {"slug": "notopic", "title": "no topic", "id": 0, "is_main": True}
|
return {"slug": "notopic", "title": "no topic", "id": 0, "is_main": True}
|
||||||
|
|
||||||
|
@mutation.field("unpublish_shout")
|
||||||
|
@login_required
|
||||||
|
async def unpublish_shout(_, info, shout_id: int):
|
||||||
|
"""Снимает публикацию (shout) с публикации.
|
||||||
|
|
||||||
|
Предзагружает связанный черновик (draft) и его авторов/темы, чтобы избежать
|
||||||
|
ошибок при последующем доступе к ним в GraphQL.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
shout_id: ID публикации для снятия с публикации
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict: Снятая с публикации публикация или сообщение об ошибке
|
||||||
|
"""
|
||||||
|
author_dict = info.context.get("author", {})
|
||||||
|
author_id = author_dict.get("id")
|
||||||
|
if not author_id:
|
||||||
|
# В идеале нужна проверка прав, имеет ли автор право снимать публикацию
|
||||||
|
return {"error": "Author ID is required"}
|
||||||
|
|
||||||
|
shout = None
|
||||||
|
with local_session() as session:
|
||||||
|
try:
|
||||||
|
# Загружаем Shout со всеми связями для правильного формирования ответа
|
||||||
|
shout = (
|
||||||
|
session.query(Shout)
|
||||||
|
.options(
|
||||||
|
joinedload(Shout.authors),
|
||||||
|
selectinload(Shout.topics)
|
||||||
|
)
|
||||||
|
.filter(Shout.id == shout_id)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
|
||||||
|
if not shout:
|
||||||
|
logger.warning(f"Shout not found for unpublish: ID {shout_id}")
|
||||||
|
return {"error": "Shout not found"}
|
||||||
|
|
||||||
|
# Если у публикации есть связанный черновик, загружаем его с relationships
|
||||||
|
if shout.draft:
|
||||||
|
# Отдельно загружаем черновик с его связями
|
||||||
|
draft = (
|
||||||
|
session.query(Draft)
|
||||||
|
.options(
|
||||||
|
selectinload(Draft.authors),
|
||||||
|
selectinload(Draft.topics)
|
||||||
|
)
|
||||||
|
.filter(Draft.id == shout.draft)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
|
||||||
|
# Связываем черновик с публикацией вручную для доступа через API
|
||||||
|
if draft:
|
||||||
|
shout.draft_obj = draft
|
||||||
|
|
||||||
|
# TODO: Добавить проверку прав доступа, если необходимо
|
||||||
|
# if author_id not in [a.id for a in shout.authors]: # Требует selectinload(Shout.authors) выше
|
||||||
|
# logger.warning(f"Author {author_id} denied unpublishing shout {shout_id}")
|
||||||
|
# return {"error": "Access denied"}
|
||||||
|
|
||||||
|
# Запоминаем старый slug и id для формирования поля publication
|
||||||
|
shout_slug = shout.slug
|
||||||
|
shout_id_for_publication = shout.id
|
||||||
|
|
||||||
|
# Снимаем с публикации (устанавливаем published_at в None)
|
||||||
|
shout.published_at = None
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
# Формируем полноценный словарь для ответа
|
||||||
|
shout_dict = shout.dict()
|
||||||
|
|
||||||
|
# Добавляем связанные данные
|
||||||
|
shout_dict["topics"] = (
|
||||||
|
[
|
||||||
|
{"id": topic.id, "slug": topic.slug, "title": topic.title}
|
||||||
|
for topic in shout.topics
|
||||||
|
]
|
||||||
|
if shout.topics
|
||||||
|
else []
|
||||||
|
)
|
||||||
|
|
||||||
|
# Добавляем main_topic
|
||||||
|
shout_dict["main_topic"] = get_main_topic(shout.topics)
|
||||||
|
|
||||||
|
# Добавляем авторов
|
||||||
|
shout_dict["authors"] = (
|
||||||
|
[
|
||||||
|
{"id": author.id, "name": author.name, "slug": author.slug}
|
||||||
|
for author in shout.authors
|
||||||
|
]
|
||||||
|
if shout.authors
|
||||||
|
else []
|
||||||
|
)
|
||||||
|
|
||||||
|
# Важно! Обновляем поле publication, отражая состояние "снят с публикации"
|
||||||
|
shout_dict["publication"] = {
|
||||||
|
"id": shout_id_for_publication,
|
||||||
|
"slug": shout_slug,
|
||||||
|
"published_at": None # Ключевое изменение - устанавливаем published_at в None
|
||||||
|
}
|
||||||
|
|
||||||
|
# Инвалидация кэша
|
||||||
|
try:
|
||||||
|
cache_keys = [
|
||||||
|
"feed", # лента
|
||||||
|
f"author_{author_id}", # публикации автора
|
||||||
|
"random_top", # случайные топовые
|
||||||
|
"unrated", # неоцененные
|
||||||
|
]
|
||||||
|
await invalidate_shout_related_cache(shout, author_id)
|
||||||
|
await invalidate_shouts_cache(cache_keys)
|
||||||
|
logger.info(f"Cache invalidated after unpublishing shout {shout_id}")
|
||||||
|
except Exception as cache_err:
|
||||||
|
logger.error(f"Failed to invalidate cache for unpublish shout {shout_id}: {cache_err}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
session.rollback()
|
||||||
|
logger.error(f"Failed to unpublish shout {shout_id}: {e}", exc_info=True)
|
||||||
|
return {"error": f"Failed to unpublish shout: {str(e)}"}
|
||||||
|
|
||||||
|
# Возвращаем сформированный словарь вместо объекта
|
||||||
|
logger.info(f"Shout {shout_id} unpublished successfully by author {author_id}")
|
||||||
|
return {"shout": shout_dict}
|
@@ -1,7 +1,7 @@
|
|||||||
import json
|
|
||||||
import time
|
import time
|
||||||
from typing import List, Tuple
|
from typing import List, Tuple
|
||||||
|
|
||||||
|
import orjson
|
||||||
from sqlalchemy import and_, select
|
from sqlalchemy import and_, select
|
||||||
from sqlalchemy.exc import SQLAlchemyError
|
from sqlalchemy.exc import SQLAlchemyError
|
||||||
from sqlalchemy.orm import aliased
|
from sqlalchemy.orm import aliased
|
||||||
@@ -115,7 +115,7 @@ def get_notifications_grouped(author_id: int, after: int = 0, limit: int = 10, o
|
|||||||
if (groups_amount + offset) >= limit:
|
if (groups_amount + offset) >= limit:
|
||||||
break
|
break
|
||||||
|
|
||||||
payload = json.loads(str(notification.payload))
|
payload = orjson.loads(str(notification.payload))
|
||||||
|
|
||||||
if str(notification.entity) == NotificationEntity.SHOUT.value:
|
if str(notification.entity) == NotificationEntity.SHOUT.value:
|
||||||
shout = payload
|
shout = payload
|
||||||
@@ -177,7 +177,7 @@ def get_notifications_grouped(author_id: int, after: int = 0, limit: int = 10, o
|
|||||||
|
|
||||||
elif str(notification.entity) == "follower":
|
elif str(notification.entity) == "follower":
|
||||||
thread_id = "followers"
|
thread_id = "followers"
|
||||||
follower = json.loads(payload)
|
follower = orjson.loads(payload)
|
||||||
group = groups_by_thread.get(thread_id)
|
group = groups_by_thread.get(thread_id)
|
||||||
if group:
|
if group:
|
||||||
if str(notification.action) == "follow":
|
if str(notification.action) == "follow":
|
||||||
@@ -293,11 +293,11 @@ async def notifications_seen_thread(_, info, thread: str, after: int):
|
|||||||
)
|
)
|
||||||
exclude = set()
|
exclude = set()
|
||||||
for nr in removed_reaction_notifications:
|
for nr in removed_reaction_notifications:
|
||||||
reaction = json.loads(str(nr.payload))
|
reaction = orjson.loads(str(nr.payload))
|
||||||
reaction_id = reaction.get("id")
|
reaction_id = reaction.get("id")
|
||||||
exclude.add(reaction_id)
|
exclude.add(reaction_id)
|
||||||
for n in new_reaction_notifications:
|
for n in new_reaction_notifications:
|
||||||
reaction = json.loads(str(n.payload))
|
reaction = orjson.loads(str(n.payload))
|
||||||
reaction_id = reaction.get("id")
|
reaction_id = reaction.get("id")
|
||||||
if (
|
if (
|
||||||
reaction_id not in exclude
|
reaction_id not in exclude
|
||||||
|
@@ -67,50 +67,58 @@ def add_reaction_stat_columns(q):
|
|||||||
return q
|
return q
|
||||||
|
|
||||||
|
|
||||||
def get_reactions_with_stat(q, limit, offset):
|
def get_reactions_with_stat(q, limit=10, offset=0):
|
||||||
"""
|
"""
|
||||||
Execute the reaction query and retrieve reactions with statistics.
|
Execute the reaction query and retrieve reactions with statistics.
|
||||||
|
|
||||||
:param q: Query with reactions and statistics.
|
:param q: Query with reactions and statistics.
|
||||||
:param limit: Number of reactions to load.
|
:param limit: Number of reactions to load.
|
||||||
:param offset: Pagination offset.
|
:param offset: Pagination offset.
|
||||||
:return: List of reactions.
|
:return: List of reactions as dictionaries.
|
||||||
|
|
||||||
|
>>> get_reactions_with_stat(q, 10, 0) # doctest: +SKIP
|
||||||
|
[{'id': 1, 'body': 'Текст комментария', 'stat': {'rating': 5, 'comments_count': 3}, ...}]
|
||||||
"""
|
"""
|
||||||
q = q.limit(limit).offset(offset)
|
q = q.limit(limit).offset(offset)
|
||||||
reactions = []
|
reactions = []
|
||||||
|
|
||||||
with local_session() as session:
|
with local_session() as session:
|
||||||
result_rows = session.execute(q)
|
result_rows = session.execute(q)
|
||||||
for reaction, author, shout, commented_stat, rating_stat in result_rows:
|
for reaction, author, shout, comments_count, rating_stat in result_rows:
|
||||||
# Пропускаем реакции с отсутствующими shout или author
|
# Пропускаем реакции с отсутствующими shout или author
|
||||||
if not shout or not author:
|
if not shout or not author:
|
||||||
logger.error(f"Пропущена реакция из-за отсутствия shout или author: {reaction.dict()}")
|
logger.error(f"Пропущена реакция из-за отсутствия shout или author: {reaction.dict()}")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
reaction.created_by = author.dict()
|
# Преобразуем Reaction в словарь для доступа по ключу
|
||||||
reaction.shout = shout.dict()
|
reaction_dict = reaction.dict()
|
||||||
reaction.stat = {"rating": rating_stat, "comments": commented_stat}
|
reaction_dict["created_by"] = author.dict()
|
||||||
reactions.append(reaction)
|
reaction_dict["shout"] = shout.dict()
|
||||||
|
reaction_dict["stat"] = {"rating": rating_stat, "comments_count": comments_count}
|
||||||
|
reactions.append(reaction_dict)
|
||||||
|
|
||||||
return reactions
|
return reactions
|
||||||
|
|
||||||
|
|
||||||
def is_featured_author(session, author_id) -> bool:
|
def is_featured_author(session, author_id) -> bool:
|
||||||
"""
|
"""
|
||||||
Check if an author has at least one featured article.
|
Check if an author has at least one non-deleted featured article.
|
||||||
|
|
||||||
:param session: Database session.
|
:param session: Database session.
|
||||||
:param author_id: Author ID.
|
:param author_id: Author ID.
|
||||||
:return: True if the author has a featured article, else False.
|
:return: True if the author has a featured article, else False.
|
||||||
"""
|
"""
|
||||||
return session.query(
|
return session.query(
|
||||||
session.query(Shout).where(Shout.authors.any(id=author_id)).filter(Shout.featured_at.is_not(None)).exists()
|
session.query(Shout)
|
||||||
|
.where(Shout.authors.any(id=author_id))
|
||||||
|
.filter(Shout.featured_at.is_not(None), Shout.deleted_at.is_(None))
|
||||||
|
.exists()
|
||||||
).scalar()
|
).scalar()
|
||||||
|
|
||||||
|
|
||||||
def check_to_feature(session, approver_id, reaction) -> bool:
|
def check_to_feature(session, approver_id, reaction) -> bool:
|
||||||
"""
|
"""
|
||||||
Make a shout featured if it receives more than 4 votes.
|
Make a shout featured if it receives more than 4 votes from authors.
|
||||||
|
|
||||||
:param session: Database session.
|
:param session: Database session.
|
||||||
:param approver_id: Approver author ID.
|
:param approver_id: Approver author ID.
|
||||||
@@ -118,46 +126,78 @@ def check_to_feature(session, approver_id, reaction) -> bool:
|
|||||||
:return: True if shout should be featured, else False.
|
:return: True if shout should be featured, else False.
|
||||||
"""
|
"""
|
||||||
if not reaction.reply_to and is_positive(reaction.kind):
|
if not reaction.reply_to and is_positive(reaction.kind):
|
||||||
approvers = {approver_id}
|
# Проверяем, не содержит ли пост более 20% дизлайков
|
||||||
# Count the number of approvers
|
# Если да, то не должен быть featured независимо от количества лайков
|
||||||
|
if check_to_unfeature(session, reaction):
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Собираем всех авторов, поставивших лайк
|
||||||
|
author_approvers = set()
|
||||||
reacted_readers = (
|
reacted_readers = (
|
||||||
session.query(Reaction.created_by)
|
session.query(Reaction.created_by)
|
||||||
.filter(Reaction.shout == reaction.shout, is_positive(Reaction.kind), Reaction.deleted_at.is_(None))
|
.filter(
|
||||||
|
Reaction.shout == reaction.shout,
|
||||||
|
is_positive(Reaction.kind),
|
||||||
|
# Рейтинги (LIKE, DISLIKE) физически удаляются, поэтому фильтр deleted_at не нужен
|
||||||
|
)
|
||||||
.distinct()
|
.distinct()
|
||||||
|
.all()
|
||||||
)
|
)
|
||||||
|
|
||||||
for reader_id in reacted_readers:
|
# Добавляем текущего одобряющего
|
||||||
|
approver = session.query(Author).filter(Author.id == approver_id).first()
|
||||||
|
if approver and is_featured_author(session, approver_id):
|
||||||
|
author_approvers.add(approver_id)
|
||||||
|
|
||||||
|
# Проверяем, есть ли у реагировавших авторов featured публикации
|
||||||
|
for (reader_id,) in reacted_readers:
|
||||||
if is_featured_author(session, reader_id):
|
if is_featured_author(session, reader_id):
|
||||||
approvers.add(reader_id)
|
author_approvers.add(reader_id)
|
||||||
return len(approvers) > 4
|
|
||||||
|
# Публикация становится featured при наличии более 4 лайков от авторов
|
||||||
|
logger.debug(f"Публикация {reaction.shout} имеет {len(author_approvers)} лайков от авторов")
|
||||||
|
return len(author_approvers) > 4
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
def check_to_unfeature(session, rejecter_id, reaction) -> bool:
|
def check_to_unfeature(session, reaction) -> bool:
|
||||||
"""
|
"""
|
||||||
Unfeature a shout if 20% of reactions are negative.
|
Unfeature a shout if 20% of reactions are negative.
|
||||||
|
|
||||||
:param session: Database session.
|
:param session: Database session.
|
||||||
:param rejecter_id: Rejecter author ID.
|
|
||||||
:param reaction: Reaction object.
|
:param reaction: Reaction object.
|
||||||
:return: True if shout should be unfeatured, else False.
|
:return: True if shout should be unfeatured, else False.
|
||||||
"""
|
"""
|
||||||
if not reaction.reply_to and is_negative(reaction.kind):
|
if not reaction.reply_to:
|
||||||
|
# Проверяем соотношение дизлайков, даже если текущая реакция не дизлайк
|
||||||
total_reactions = (
|
total_reactions = (
|
||||||
session.query(Reaction)
|
session.query(Reaction)
|
||||||
.filter(
|
.filter(
|
||||||
Reaction.shout == reaction.shout, Reaction.kind.in_(RATING_REACTIONS), Reaction.deleted_at.is_(None)
|
Reaction.shout == reaction.shout,
|
||||||
|
Reaction.reply_to.is_(None),
|
||||||
|
Reaction.kind.in_(RATING_REACTIONS),
|
||||||
|
# Рейтинги физически удаляются при удалении, поэтому фильтр deleted_at не нужен
|
||||||
)
|
)
|
||||||
.count()
|
.count()
|
||||||
)
|
)
|
||||||
|
|
||||||
negative_reactions = (
|
negative_reactions = (
|
||||||
session.query(Reaction)
|
session.query(Reaction)
|
||||||
.filter(Reaction.shout == reaction.shout, is_negative(Reaction.kind), Reaction.deleted_at.is_(None))
|
.filter(
|
||||||
|
Reaction.shout == reaction.shout,
|
||||||
|
is_negative(Reaction.kind),
|
||||||
|
Reaction.reply_to.is_(None),
|
||||||
|
# Рейтинги физически удаляются при удалении, поэтому фильтр deleted_at не нужен
|
||||||
|
)
|
||||||
.count()
|
.count()
|
||||||
)
|
)
|
||||||
|
|
||||||
return total_reactions > 0 and (negative_reactions / total_reactions) >= 0.2
|
# Проверяем, составляют ли отрицательные реакции 20% или более от всех реакций
|
||||||
|
negative_ratio = negative_reactions / total_reactions if total_reactions > 0 else 0
|
||||||
|
logger.debug(
|
||||||
|
f"Публикация {reaction.shout}: {negative_reactions}/{total_reactions} отрицательных реакций ({negative_ratio:.2%})"
|
||||||
|
)
|
||||||
|
return total_reactions > 0 and negative_ratio >= 0.2
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
@@ -196,8 +236,8 @@ async def _create_reaction(session, shout_id: int, is_author: bool, author_id: i
|
|||||||
Create a new reaction and perform related actions such as updating counters and notification.
|
Create a new reaction and perform related actions such as updating counters and notification.
|
||||||
|
|
||||||
:param session: Database session.
|
:param session: Database session.
|
||||||
:param info: GraphQL context info.
|
:param shout_id: Shout ID.
|
||||||
:param shout: Shout object.
|
:param is_author: Flag indicating if the user is the author of the shout.
|
||||||
:param author_id: Author ID.
|
:param author_id: Author ID.
|
||||||
:param reaction: Dictionary with reaction data.
|
:param reaction: Dictionary with reaction data.
|
||||||
:return: Dictionary with created reaction data.
|
:return: Dictionary with created reaction data.
|
||||||
@@ -217,10 +257,14 @@ async def _create_reaction(session, shout_id: int, is_author: bool, author_id: i
|
|||||||
|
|
||||||
# Handle rating
|
# Handle rating
|
||||||
if r.kind in RATING_REACTIONS:
|
if r.kind in RATING_REACTIONS:
|
||||||
if check_to_unfeature(session, author_id, r):
|
# Проверяем сначала условие для unfeature (дизлайки имеют приоритет)
|
||||||
|
if check_to_unfeature(session, r):
|
||||||
set_unfeatured(session, shout_id)
|
set_unfeatured(session, shout_id)
|
||||||
|
logger.info(f"Публикация {shout_id} потеряла статус featured из-за высокого процента дизлайков")
|
||||||
|
# Только если не было unfeature, проверяем условие для feature
|
||||||
elif check_to_feature(session, author_id, r):
|
elif check_to_feature(session, author_id, r):
|
||||||
await set_featured(session, shout_id)
|
await set_featured(session, shout_id)
|
||||||
|
logger.info(f"Публикация {shout_id} получила статус featured благодаря лайкам от авторов")
|
||||||
|
|
||||||
# Notify creation
|
# Notify creation
|
||||||
await notify_reaction(rdict, "create")
|
await notify_reaction(rdict, "create")
|
||||||
@@ -354,7 +398,7 @@ async def update_reaction(_, info, reaction):
|
|||||||
|
|
||||||
result = session.execute(reaction_query).unique().first()
|
result = session.execute(reaction_query).unique().first()
|
||||||
if result:
|
if result:
|
||||||
r, author, shout, commented_stat, rating_stat = result
|
r, author, _shout, comments_count, rating_stat = result
|
||||||
if not r or not author:
|
if not r or not author:
|
||||||
return {"error": "Invalid reaction ID or unauthorized"}
|
return {"error": "Invalid reaction ID or unauthorized"}
|
||||||
|
|
||||||
@@ -369,7 +413,7 @@ async def update_reaction(_, info, reaction):
|
|||||||
session.commit()
|
session.commit()
|
||||||
|
|
||||||
r.stat = {
|
r.stat = {
|
||||||
"commented": commented_stat,
|
"comments_count": comments_count,
|
||||||
"rating": rating_stat,
|
"rating": rating_stat,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -406,15 +450,24 @@ async def delete_reaction(_, info, reaction_id: int):
|
|||||||
if r.created_by != author_id and "editor" not in roles:
|
if r.created_by != author_id and "editor" not in roles:
|
||||||
return {"error": "Access denied"}
|
return {"error": "Access denied"}
|
||||||
|
|
||||||
logger.debug(f"{user_id} user removing his #{reaction_id} reaction")
|
|
||||||
reaction_dict = r.dict()
|
|
||||||
session.delete(r)
|
|
||||||
session.commit()
|
|
||||||
|
|
||||||
# Update author stat
|
|
||||||
if r.kind == ReactionKind.COMMENT.value:
|
if r.kind == ReactionKind.COMMENT.value:
|
||||||
|
r.deleted_at = int(time.time())
|
||||||
update_author_stat(author.id)
|
update_author_stat(author.id)
|
||||||
|
session.add(r)
|
||||||
|
session.commit()
|
||||||
|
elif r.kind == ReactionKind.PROPOSE.value:
|
||||||
|
r.deleted_at = int(time.time())
|
||||||
|
session.add(r)
|
||||||
|
session.commit()
|
||||||
|
# TODO: add more reaction types here
|
||||||
|
else:
|
||||||
|
logger.debug(f"{user_id} user removing his #{reaction_id} reaction")
|
||||||
|
session.delete(r)
|
||||||
|
session.commit()
|
||||||
|
if check_to_unfeature(session, r):
|
||||||
|
set_unfeatured(session, r.shout)
|
||||||
|
|
||||||
|
reaction_dict = r.dict()
|
||||||
await notify_reaction(reaction_dict, "delete")
|
await notify_reaction(reaction_dict, "delete")
|
||||||
|
|
||||||
return {"error": None, "reaction": reaction_dict}
|
return {"error": None, "reaction": reaction_dict}
|
||||||
@@ -435,11 +488,15 @@ def apply_reaction_filters(by, q):
|
|||||||
if shout_slug:
|
if shout_slug:
|
||||||
q = q.filter(Shout.slug == shout_slug)
|
q = q.filter(Shout.slug == shout_slug)
|
||||||
|
|
||||||
|
shout_id = by.get("shout_id")
|
||||||
|
if shout_id:
|
||||||
|
q = q.filter(Shout.id == shout_id)
|
||||||
|
|
||||||
shouts = by.get("shouts")
|
shouts = by.get("shouts")
|
||||||
if shouts:
|
if shouts:
|
||||||
q = q.filter(Shout.slug.in_(shouts))
|
q = q.filter(Shout.slug.in_(shouts))
|
||||||
|
|
||||||
created_by = by.get("created_by")
|
created_by = by.get("created_by", by.get("author_id"))
|
||||||
if created_by:
|
if created_by:
|
||||||
q = q.filter(Author.id == created_by)
|
q = q.filter(Author.id == created_by)
|
||||||
|
|
||||||
@@ -485,7 +542,9 @@ async def load_reactions_by(_, _info, by, limit=50, offset=0):
|
|||||||
# Add statistics and apply filters
|
# Add statistics and apply filters
|
||||||
q = add_reaction_stat_columns(q)
|
q = add_reaction_stat_columns(q)
|
||||||
q = apply_reaction_filters(by, q)
|
q = apply_reaction_filters(by, q)
|
||||||
q = q.where(Reaction.deleted_at.is_(None))
|
|
||||||
|
# Include reactions with deleted_at for building comment trees
|
||||||
|
# q = q.where(Reaction.deleted_at.is_(None))
|
||||||
|
|
||||||
# Group and sort
|
# Group and sort
|
||||||
q = q.group_by(Reaction.id, Author.id, Shout.id)
|
q = q.group_by(Reaction.id, Author.id, Shout.id)
|
||||||
@@ -562,24 +621,22 @@ async def load_shout_comments(_, info, shout: int, limit=50, offset=0):
|
|||||||
@query.field("load_comment_ratings")
|
@query.field("load_comment_ratings")
|
||||||
async def load_comment_ratings(_, info, comment: int, limit=50, offset=0):
|
async def load_comment_ratings(_, info, comment: int, limit=50, offset=0):
|
||||||
"""
|
"""
|
||||||
Load ratings for a specified comment with pagination and statistics.
|
Load ratings for a specified comment with pagination.
|
||||||
|
|
||||||
:param info: GraphQL context info.
|
:param info: GraphQL context info.
|
||||||
:param comment: Comment ID.
|
:param comment: Comment ID.
|
||||||
:param limit: Number of ratings to load.
|
:param limit: Number of ratings to load.
|
||||||
:param offset: Pagination offset.
|
:param offset: Pagination offset.
|
||||||
:return: List of reactions.
|
:return: List of ratings.
|
||||||
"""
|
"""
|
||||||
q = query_reactions()
|
q = query_reactions()
|
||||||
|
|
||||||
q = add_reaction_stat_columns(q)
|
|
||||||
|
|
||||||
# Filter, group, sort, limit, offset
|
# Filter, group, sort, limit, offset
|
||||||
q = q.filter(
|
q = q.filter(
|
||||||
and_(
|
and_(
|
||||||
Reaction.deleted_at.is_(None),
|
Reaction.deleted_at.is_(None),
|
||||||
Reaction.reply_to == comment,
|
Reaction.reply_to == comment,
|
||||||
Reaction.kind == ReactionKind.COMMENT.value,
|
Reaction.kind.in_(RATING_REACTIONS),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
q = q.group_by(Reaction.id, Author.id, Shout.id)
|
q = q.group_by(Reaction.id, Author.id, Shout.id)
|
||||||
@@ -587,3 +644,187 @@ async def load_comment_ratings(_, info, comment: int, limit=50, offset=0):
|
|||||||
|
|
||||||
# Retrieve and return reactions
|
# Retrieve and return reactions
|
||||||
return get_reactions_with_stat(q, limit, offset)
|
return get_reactions_with_stat(q, limit, offset)
|
||||||
|
|
||||||
|
|
||||||
|
@query.field("load_comments_branch")
|
||||||
|
async def load_comments_branch(
|
||||||
|
_,
|
||||||
|
_info,
|
||||||
|
shout: int,
|
||||||
|
parent_id: int | None = None,
|
||||||
|
limit=10,
|
||||||
|
offset=0,
|
||||||
|
sort="newest",
|
||||||
|
children_limit=3,
|
||||||
|
children_offset=0,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Загружает иерархические комментарии с возможностью пагинации корневых и дочерних.
|
||||||
|
|
||||||
|
:param info: GraphQL context info.
|
||||||
|
:param shout: ID статьи.
|
||||||
|
:param parent_id: ID родительского комментария (None для корневых).
|
||||||
|
:param limit: Количество комментариев для загрузки.
|
||||||
|
:param offset: Смещение для пагинации.
|
||||||
|
:param sort: Порядок сортировки ('newest', 'oldest', 'like').
|
||||||
|
:param children_limit: Максимальное количество дочерних комментариев.
|
||||||
|
:param children_offset: Смещение для дочерних комментариев.
|
||||||
|
:return: Список комментариев с дочерними.
|
||||||
|
"""
|
||||||
|
# Создаем базовый запрос
|
||||||
|
q = query_reactions()
|
||||||
|
q = add_reaction_stat_columns(q)
|
||||||
|
|
||||||
|
# Фильтруем по статье и типу (комментарии)
|
||||||
|
q = q.filter(
|
||||||
|
and_(
|
||||||
|
Reaction.deleted_at.is_(None),
|
||||||
|
Reaction.shout == shout,
|
||||||
|
Reaction.kind == ReactionKind.COMMENT.value,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Фильтруем по родительскому ID
|
||||||
|
if parent_id is None:
|
||||||
|
# Загружаем только корневые комментарии
|
||||||
|
q = q.filter(Reaction.reply_to.is_(None))
|
||||||
|
else:
|
||||||
|
# Загружаем только прямые ответы на указанный комментарий
|
||||||
|
q = q.filter(Reaction.reply_to == parent_id)
|
||||||
|
|
||||||
|
# Сортировка и группировка
|
||||||
|
q = q.group_by(Reaction.id, Author.id, Shout.id)
|
||||||
|
|
||||||
|
# Определяем сортировку
|
||||||
|
order_by_stmt = None
|
||||||
|
if sort.lower() == "oldest":
|
||||||
|
order_by_stmt = asc(Reaction.created_at)
|
||||||
|
elif sort.lower() == "like":
|
||||||
|
order_by_stmt = desc("rating_stat")
|
||||||
|
else: # "newest" по умолчанию
|
||||||
|
order_by_stmt = desc(Reaction.created_at)
|
||||||
|
|
||||||
|
q = q.order_by(order_by_stmt)
|
||||||
|
|
||||||
|
# Выполняем запрос для получения комментариев
|
||||||
|
comments = get_reactions_with_stat(q, limit, offset)
|
||||||
|
|
||||||
|
# Если комментарии найдены, загружаем дочерние и количество ответов
|
||||||
|
if comments:
|
||||||
|
# Загружаем количество ответов для каждого комментария
|
||||||
|
await load_replies_count(comments)
|
||||||
|
|
||||||
|
# Загружаем дочерние комментарии
|
||||||
|
await load_first_replies(comments, children_limit, children_offset, sort)
|
||||||
|
|
||||||
|
return comments
|
||||||
|
|
||||||
|
|
||||||
|
async def load_replies_count(comments):
|
||||||
|
"""
|
||||||
|
Загружает количество ответов для списка комментариев и обновляет поле stat.comments_count.
|
||||||
|
|
||||||
|
:param comments: Список комментариев, для которых нужно загрузить количество ответов.
|
||||||
|
"""
|
||||||
|
if not comments:
|
||||||
|
return
|
||||||
|
|
||||||
|
comment_ids = [comment["id"] for comment in comments]
|
||||||
|
|
||||||
|
# Запрос для подсчета количества ответов
|
||||||
|
q = (
|
||||||
|
select(Reaction.reply_to.label("parent_id"), func.count().label("count"))
|
||||||
|
.where(
|
||||||
|
and_(
|
||||||
|
Reaction.reply_to.in_(comment_ids),
|
||||||
|
Reaction.deleted_at.is_(None),
|
||||||
|
Reaction.kind == ReactionKind.COMMENT.value,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.group_by(Reaction.reply_to)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Выполняем запрос
|
||||||
|
with local_session() as session:
|
||||||
|
result = session.execute(q).fetchall()
|
||||||
|
|
||||||
|
# Создаем словарь {parent_id: count}
|
||||||
|
replies_count = {row[0]: row[1] for row in result}
|
||||||
|
|
||||||
|
# Добавляем значения в комментарии
|
||||||
|
for comment in comments:
|
||||||
|
if "stat" not in comment:
|
||||||
|
comment["stat"] = {}
|
||||||
|
|
||||||
|
# Обновляем счетчик комментариев в stat
|
||||||
|
comment["stat"]["comments_count"] = replies_count.get(comment["id"], 0)
|
||||||
|
|
||||||
|
|
||||||
|
async def load_first_replies(comments, limit, offset, sort="newest"):
|
||||||
|
"""
|
||||||
|
Загружает первые N ответов для каждого комментария.
|
||||||
|
|
||||||
|
:param comments: Список комментариев, для которых нужно загрузить ответы.
|
||||||
|
:param limit: Максимальное количество ответов для каждого комментария.
|
||||||
|
:param offset: Смещение для пагинации дочерних комментариев.
|
||||||
|
:param sort: Порядок сортировки ответов.
|
||||||
|
"""
|
||||||
|
if not comments or limit <= 0:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Собираем ID комментариев
|
||||||
|
comment_ids = [comment["id"] for comment in comments]
|
||||||
|
|
||||||
|
# Базовый запрос для загрузки ответов
|
||||||
|
q = query_reactions()
|
||||||
|
q = add_reaction_stat_columns(q)
|
||||||
|
|
||||||
|
# Фильтрация: только ответы на указанные комментарии
|
||||||
|
q = q.filter(
|
||||||
|
and_(
|
||||||
|
Reaction.reply_to.in_(comment_ids),
|
||||||
|
Reaction.deleted_at.is_(None),
|
||||||
|
Reaction.kind == ReactionKind.COMMENT.value,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Группировка
|
||||||
|
q = q.group_by(Reaction.id, Author.id, Shout.id)
|
||||||
|
|
||||||
|
# Определяем сортировку
|
||||||
|
order_by_stmt = None
|
||||||
|
if sort.lower() == "oldest":
|
||||||
|
order_by_stmt = asc(Reaction.created_at)
|
||||||
|
elif sort.lower() == "like":
|
||||||
|
order_by_stmt = desc("rating_stat")
|
||||||
|
else: # "newest" по умолчанию
|
||||||
|
order_by_stmt = desc(Reaction.created_at)
|
||||||
|
|
||||||
|
q = q.order_by(order_by_stmt, Reaction.reply_to)
|
||||||
|
|
||||||
|
# Выполняем запрос - указываем limit для неограниченного количества ответов
|
||||||
|
# но не более 100 на родительский комментарий
|
||||||
|
replies = get_reactions_with_stat(q, limit=100, offset=0)
|
||||||
|
|
||||||
|
# Группируем ответы по родительским ID
|
||||||
|
replies_by_parent = {}
|
||||||
|
for reply in replies:
|
||||||
|
parent_id = reply.get("reply_to")
|
||||||
|
if parent_id not in replies_by_parent:
|
||||||
|
replies_by_parent[parent_id] = []
|
||||||
|
replies_by_parent[parent_id].append(reply)
|
||||||
|
|
||||||
|
# Добавляем ответы к соответствующим комментариям с учетом смещения и лимита
|
||||||
|
for comment in comments:
|
||||||
|
comment_id = comment["id"]
|
||||||
|
if comment_id in replies_by_parent:
|
||||||
|
parent_replies = replies_by_parent[comment_id]
|
||||||
|
# Применяем смещение и лимит
|
||||||
|
comment["first_replies"] = parent_replies[offset : offset + limit]
|
||||||
|
else:
|
||||||
|
comment["first_replies"] = []
|
||||||
|
|
||||||
|
# Загружаем количество ответов для дочерних комментариев
|
||||||
|
all_replies = [reply for replies in replies_by_parent.values() for reply in replies]
|
||||||
|
if all_replies:
|
||||||
|
await load_replies_count(all_replies)
|
||||||
|
@@ -1,5 +1,4 @@
|
|||||||
import json
|
import orjson
|
||||||
|
|
||||||
from graphql import GraphQLResolveInfo
|
from graphql import GraphQLResolveInfo
|
||||||
from sqlalchemy import and_, nulls_last, text
|
from sqlalchemy import and_, nulls_last, text
|
||||||
from sqlalchemy.orm import aliased
|
from sqlalchemy.orm import aliased
|
||||||
@@ -11,7 +10,7 @@ from orm.shout import Shout, ShoutAuthor, ShoutTopic
|
|||||||
from orm.topic import Topic
|
from orm.topic import Topic
|
||||||
from services.db import json_array_builder, json_builder, local_session
|
from services.db import json_array_builder, json_builder, local_session
|
||||||
from services.schema import query
|
from services.schema import query
|
||||||
from services.search import search_text
|
from services.search import search_text, get_search_count
|
||||||
from services.viewed import ViewedStorage
|
from services.viewed import ViewedStorage
|
||||||
from utils.logger import root_logger as logger
|
from utils.logger import root_logger as logger
|
||||||
|
|
||||||
@@ -188,12 +187,10 @@ def get_shouts_with_links(info, q, limit=20, offset=0):
|
|||||||
"""
|
"""
|
||||||
shouts = []
|
shouts = []
|
||||||
try:
|
try:
|
||||||
# logger.info(f"Starting get_shouts_with_links with limit={limit}, offset={offset}")
|
|
||||||
q = q.limit(limit).offset(offset)
|
q = q.limit(limit).offset(offset)
|
||||||
|
|
||||||
with local_session() as session:
|
with local_session() as session:
|
||||||
shouts_result = session.execute(q).all()
|
shouts_result = session.execute(q).all()
|
||||||
# logger.info(f"Got {len(shouts_result) if shouts_result else 0} shouts from query")
|
|
||||||
|
|
||||||
if not shouts_result:
|
if not shouts_result:
|
||||||
logger.warning("No shouts found in query result")
|
logger.warning("No shouts found in query result")
|
||||||
@@ -204,7 +201,6 @@ def get_shouts_with_links(info, q, limit=20, offset=0):
|
|||||||
shout = None
|
shout = None
|
||||||
if hasattr(row, "Shout"):
|
if hasattr(row, "Shout"):
|
||||||
shout = row.Shout
|
shout = row.Shout
|
||||||
# logger.debug(f"Processing shout#{shout.id} at index {idx}")
|
|
||||||
if shout:
|
if shout:
|
||||||
shout_id = int(f"{shout.id}")
|
shout_id = int(f"{shout.id}")
|
||||||
shout_dict = shout.dict()
|
shout_dict = shout.dict()
|
||||||
@@ -222,30 +218,26 @@ def get_shouts_with_links(info, q, limit=20, offset=0):
|
|||||||
if has_field(info, "stat"):
|
if has_field(info, "stat"):
|
||||||
stat = {}
|
stat = {}
|
||||||
if isinstance(row.stat, str):
|
if isinstance(row.stat, str):
|
||||||
stat = json.loads(row.stat)
|
stat = orjson.loads(row.stat)
|
||||||
elif isinstance(row.stat, dict):
|
elif isinstance(row.stat, dict):
|
||||||
stat = row.stat
|
stat = row.stat
|
||||||
viewed = ViewedStorage.get_shout(shout_id=shout_id) or 0
|
viewed = ViewedStorage.get_shout(shout_id=shout_id) or 0
|
||||||
shout_dict["stat"] = {**stat, "viewed": viewed, "commented": stat.get("comments_count", 0)}
|
shout_dict["stat"] = {**stat, "viewed": viewed}
|
||||||
|
|
||||||
# Обработка main_topic и topics
|
# Обработка main_topic и topics
|
||||||
topics = None
|
topics = None
|
||||||
if has_field(info, "topics") and hasattr(row, "topics"):
|
if has_field(info, "topics") and hasattr(row, "topics"):
|
||||||
topics = json.loads(row.topics) if isinstance(row.topics, str) else row.topics
|
topics = orjson.loads(row.topics) if isinstance(row.topics, str) else row.topics
|
||||||
# logger.debug(f"Shout#{shout_id} topics: {topics}")
|
|
||||||
shout_dict["topics"] = topics
|
shout_dict["topics"] = topics
|
||||||
|
|
||||||
if has_field(info, "main_topic"):
|
if has_field(info, "main_topic"):
|
||||||
main_topic = None
|
main_topic = None
|
||||||
if hasattr(row, "main_topic"):
|
if hasattr(row, "main_topic"):
|
||||||
# logger.debug(f"Raw main_topic for shout#{shout_id}: {row.main_topic}")
|
|
||||||
main_topic = (
|
main_topic = (
|
||||||
json.loads(row.main_topic) if isinstance(row.main_topic, str) else row.main_topic
|
orjson.loads(row.main_topic) if isinstance(row.main_topic, str) else row.main_topic
|
||||||
)
|
)
|
||||||
# logger.debug(f"Parsed main_topic for shout#{shout_id}: {main_topic}")
|
|
||||||
|
|
||||||
if not main_topic and topics and len(topics) > 0:
|
if not main_topic and topics and len(topics) > 0:
|
||||||
# logger.info(f"No main_topic found for shout#{shout_id}, using first topic from list")
|
|
||||||
main_topic = {
|
main_topic = {
|
||||||
"id": topics[0]["id"],
|
"id": topics[0]["id"],
|
||||||
"title": topics[0]["title"],
|
"title": topics[0]["title"],
|
||||||
@@ -253,14 +245,12 @@ def get_shouts_with_links(info, q, limit=20, offset=0):
|
|||||||
"is_main": True,
|
"is_main": True,
|
||||||
}
|
}
|
||||||
elif not main_topic:
|
elif not main_topic:
|
||||||
logger.debug(f"No main_topic and no topics found for shout#{shout_id}")
|
|
||||||
main_topic = {"id": 0, "title": "no topic", "slug": "notopic", "is_main": True}
|
main_topic = {"id": 0, "title": "no topic", "slug": "notopic", "is_main": True}
|
||||||
shout_dict["main_topic"] = main_topic
|
shout_dict["main_topic"] = main_topic
|
||||||
logger.debug(f"Final main_topic for shout#{shout_id}: {main_topic}")
|
|
||||||
|
|
||||||
if has_field(info, "authors") and hasattr(row, "authors"):
|
if has_field(info, "authors") and hasattr(row, "authors"):
|
||||||
shout_dict["authors"] = (
|
shout_dict["authors"] = (
|
||||||
json.loads(row.authors) if isinstance(row.authors, str) else row.authors
|
orjson.loads(row.authors) if isinstance(row.authors, str) else row.authors
|
||||||
)
|
)
|
||||||
|
|
||||||
if has_field(info, "media") and shout.media:
|
if has_field(info, "media") and shout.media:
|
||||||
@@ -268,8 +258,8 @@ def get_shouts_with_links(info, q, limit=20, offset=0):
|
|||||||
media_data = shout.media
|
media_data = shout.media
|
||||||
if isinstance(media_data, str):
|
if isinstance(media_data, str):
|
||||||
try:
|
try:
|
||||||
media_data = json.loads(media_data)
|
media_data = orjson.loads(media_data)
|
||||||
except json.JSONDecodeError:
|
except orjson.JSONDecodeError:
|
||||||
media_data = []
|
media_data = []
|
||||||
shout_dict["media"] = [media_data] if isinstance(media_data, dict) else media_data
|
shout_dict["media"] = [media_data] if isinstance(media_data, dict) else media_data
|
||||||
|
|
||||||
@@ -283,7 +273,6 @@ def get_shouts_with_links(info, q, limit=20, offset=0):
|
|||||||
logger.error(f"Fatal error in get_shouts_with_links: {e}", exc_info=True)
|
logger.error(f"Fatal error in get_shouts_with_links: {e}", exc_info=True)
|
||||||
raise
|
raise
|
||||||
finally:
|
finally:
|
||||||
logger.info(f"Returning {len(shouts)} shouts from get_shouts_with_links")
|
|
||||||
return shouts
|
return shouts
|
||||||
|
|
||||||
|
|
||||||
@@ -402,41 +391,49 @@ async def load_shouts_search(_, info, text, options):
|
|||||||
"""
|
"""
|
||||||
limit = options.get("limit", 10)
|
limit = options.get("limit", 10)
|
||||||
offset = options.get("offset", 0)
|
offset = options.get("offset", 0)
|
||||||
|
|
||||||
if isinstance(text, str) and len(text) > 2:
|
if isinstance(text, str) and len(text) > 2:
|
||||||
|
# Get search results with pagination
|
||||||
results = await search_text(text, limit, offset)
|
results = await search_text(text, limit, offset)
|
||||||
scores = {}
|
|
||||||
hits_ids = []
|
|
||||||
for sr in results:
|
|
||||||
shout_id = sr.get("id")
|
|
||||||
if shout_id:
|
|
||||||
shout_id = str(shout_id)
|
|
||||||
scores[shout_id] = sr.get("score")
|
|
||||||
hits_ids.append(shout_id)
|
|
||||||
|
|
||||||
""" q = (
|
if not results:
|
||||||
query_with_stat(info)
|
logger.info(f"No search results found for '{text}'")
|
||||||
if has_field(info, "stat")
|
return []
|
||||||
else select(Shout).filter(and_(Shout.published_at.is_not(None), Shout.deleted_at.is_(None)))
|
|
||||||
) """
|
# Extract IDs in the order from the search engine
|
||||||
|
hits_ids = [str(sr.get("id")) for sr in results if sr.get("id")]
|
||||||
|
|
||||||
|
# Query DB for only the IDs in the current page
|
||||||
q = query_with_stat(info)
|
q = query_with_stat(info)
|
||||||
|
|
||||||
q = q.filter(Shout.id.in_(hits_ids))
|
q = q.filter(Shout.id.in_(hits_ids))
|
||||||
q = apply_filters(q, options)
|
q = apply_filters(q, options.get("filters", {}))
|
||||||
|
|
||||||
# added this to join topics
|
shouts = get_shouts_with_links(info, q, len(hits_ids), 0)
|
||||||
topic_join = aliased(ShoutTopic)
|
|
||||||
topic = aliased(Topic)
|
|
||||||
q = q.outerjoin(topic_join, topic_join.shout == Shout.id)
|
|
||||||
q = q.outerjoin(topic, topic.id == topic_join.topic)
|
|
||||||
|
|
||||||
shouts = get_shouts_with_links(info, q, limit, offset)
|
# Reorder shouts to match the order from hits_ids
|
||||||
for shout in shouts:
|
shouts_dict = {str(shout['id']): shout for shout in shouts}
|
||||||
shout["score"] = scores[f"{shout['id']}"]
|
ordered_shouts = [shouts_dict[shout_id] for shout_id in hits_ids if shout_id in shouts_dict]
|
||||||
shouts.sort(key=lambda x: x["score"], reverse=True)
|
|
||||||
return shouts
|
return ordered_shouts
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
@query.field("get_search_results_count")
|
||||||
|
async def get_search_results_count(_, info, text):
|
||||||
|
"""
|
||||||
|
Returns the total count of search results for a search query.
|
||||||
|
|
||||||
|
:param _: Root query object (unused)
|
||||||
|
:param info: GraphQL context information
|
||||||
|
:param text: Search query text
|
||||||
|
:return: Total count of results
|
||||||
|
"""
|
||||||
|
if isinstance(text, str) and len(text) > 2:
|
||||||
|
count = await get_search_count(text)
|
||||||
|
return {"count": count}
|
||||||
|
return {"count": 0}
|
||||||
|
|
||||||
|
|
||||||
@query.field("load_shouts_unrated")
|
@query.field("load_shouts_unrated")
|
||||||
async def load_shouts_unrated(_, info, options):
|
async def load_shouts_unrated(_, info, options):
|
||||||
"""
|
"""
|
||||||
|
@@ -1,44 +1,249 @@
|
|||||||
from sqlalchemy import select
|
from sqlalchemy import desc, select, text
|
||||||
|
|
||||||
from cache.cache import (
|
from cache.cache import (
|
||||||
|
cache_topic,
|
||||||
|
cached_query,
|
||||||
get_cached_topic_authors,
|
get_cached_topic_authors,
|
||||||
get_cached_topic_by_slug,
|
get_cached_topic_by_slug,
|
||||||
get_cached_topic_followers,
|
get_cached_topic_followers,
|
||||||
|
invalidate_cache_by_prefix,
|
||||||
)
|
)
|
||||||
from cache.memorycache import cache_region
|
|
||||||
from orm.author import Author
|
from orm.author import Author
|
||||||
from orm.topic import Topic
|
from orm.topic import Topic
|
||||||
|
from orm.reaction import ReactionKind
|
||||||
from resolvers.stat import get_with_stat
|
from resolvers.stat import get_with_stat
|
||||||
from services.auth import login_required
|
from services.auth import login_required
|
||||||
from services.db import local_session
|
from services.db import local_session
|
||||||
|
from services.redis import redis
|
||||||
from services.schema import mutation, query
|
from services.schema import mutation, query
|
||||||
from utils.logger import root_logger as logger
|
from utils.logger import root_logger as logger
|
||||||
|
|
||||||
|
|
||||||
|
# Вспомогательная функция для получения всех тем без статистики
|
||||||
|
async def get_all_topics():
|
||||||
|
"""
|
||||||
|
Получает все темы без статистики.
|
||||||
|
Используется для случаев, когда нужен полный список тем без дополнительной информации.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
list: Список всех тем без статистики
|
||||||
|
"""
|
||||||
|
cache_key = "topics:all:basic"
|
||||||
|
|
||||||
|
# Функция для получения всех тем из БД
|
||||||
|
async def fetch_all_topics():
|
||||||
|
logger.debug("Получаем список всех тем из БД и кешируем результат")
|
||||||
|
|
||||||
|
with local_session() as session:
|
||||||
|
# Запрос на получение базовой информации о темах
|
||||||
|
topics_query = select(Topic)
|
||||||
|
topics = session.execute(topics_query).scalars().all()
|
||||||
|
|
||||||
|
# Преобразуем темы в словари
|
||||||
|
return [topic.dict() for topic in topics]
|
||||||
|
|
||||||
|
# Используем универсальную функцию для кеширования запросов
|
||||||
|
return await cached_query(cache_key, fetch_all_topics)
|
||||||
|
|
||||||
|
|
||||||
|
# Вспомогательная функция для получения тем со статистикой с пагинацией
|
||||||
|
async def get_topics_with_stats(limit=100, offset=0, community_id=None, by=None):
|
||||||
|
"""
|
||||||
|
Получает темы со статистикой с пагинацией.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
limit: Максимальное количество возвращаемых тем
|
||||||
|
offset: Смещение для пагинации
|
||||||
|
community_id: Опциональный ID сообщества для фильтрации
|
||||||
|
by: Опциональный параметр сортировки
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
list: Список тем с их статистикой
|
||||||
|
"""
|
||||||
|
# Формируем ключ кеша с помощью универсальной функции
|
||||||
|
cache_key = f"topics:stats:limit={limit}:offset={offset}:community_id={community_id}"
|
||||||
|
|
||||||
|
# Функция для получения тем из БД
|
||||||
|
async def fetch_topics_with_stats():
|
||||||
|
logger.debug(f"Выполняем запрос на получение тем со статистикой: limit={limit}, offset={offset}")
|
||||||
|
|
||||||
|
with local_session() as session:
|
||||||
|
# Базовый запрос для получения тем
|
||||||
|
base_query = select(Topic)
|
||||||
|
|
||||||
|
# Добавляем фильтр по сообществу, если указан
|
||||||
|
if community_id:
|
||||||
|
base_query = base_query.where(Topic.community == community_id)
|
||||||
|
|
||||||
|
# Применяем сортировку на основе параметра by
|
||||||
|
if by:
|
||||||
|
if isinstance(by, dict):
|
||||||
|
# Обработка словаря параметров сортировки
|
||||||
|
for field, direction in by.items():
|
||||||
|
column = getattr(Topic, field, None)
|
||||||
|
if column:
|
||||||
|
if direction.lower() == "desc":
|
||||||
|
base_query = base_query.order_by(desc(column))
|
||||||
|
else:
|
||||||
|
base_query = base_query.order_by(column)
|
||||||
|
elif by == "popular":
|
||||||
|
# Сортировка по популярности (количеству публикаций)
|
||||||
|
# Примечание: это требует дополнительного запроса или подзапроса
|
||||||
|
base_query = base_query.order_by(
|
||||||
|
desc(Topic.id)
|
||||||
|
) # Временно, нужно заменить на proper implementation
|
||||||
|
else:
|
||||||
|
# По умолчанию сортируем по ID в обратном порядке
|
||||||
|
base_query = base_query.order_by(desc(Topic.id))
|
||||||
|
else:
|
||||||
|
# По умолчанию сортируем по ID в обратном порядке
|
||||||
|
base_query = base_query.order_by(desc(Topic.id))
|
||||||
|
|
||||||
|
# Применяем лимит и смещение
|
||||||
|
base_query = base_query.limit(limit).offset(offset)
|
||||||
|
|
||||||
|
# Получаем темы
|
||||||
|
topics = session.execute(base_query).scalars().all()
|
||||||
|
topic_ids = [topic.id for topic in topics]
|
||||||
|
|
||||||
|
if not topic_ids:
|
||||||
|
return []
|
||||||
|
|
||||||
|
# Запрос на получение статистики по публикациям для выбранных тем
|
||||||
|
shouts_stats_query = f"""
|
||||||
|
SELECT st.topic, COUNT(DISTINCT s.id) as shouts_count
|
||||||
|
FROM shout_topic st
|
||||||
|
JOIN shout s ON st.shout = s.id AND s.deleted_at IS NULL AND s.published_at IS NOT NULL
|
||||||
|
WHERE st.topic IN ({",".join(map(str, topic_ids))})
|
||||||
|
GROUP BY st.topic
|
||||||
|
"""
|
||||||
|
shouts_stats = {row[0]: row[1] for row in session.execute(text(shouts_stats_query))}
|
||||||
|
|
||||||
|
# Запрос на получение статистики по подписчикам для выбранных тем
|
||||||
|
followers_stats_query = f"""
|
||||||
|
SELECT topic, COUNT(DISTINCT follower) as followers_count
|
||||||
|
FROM topic_followers tf
|
||||||
|
WHERE topic IN ({",".join(map(str, topic_ids))})
|
||||||
|
GROUP BY topic
|
||||||
|
"""
|
||||||
|
followers_stats = {row[0]: row[1] for row in session.execute(text(followers_stats_query))}
|
||||||
|
|
||||||
|
# Запрос на получение статистики авторов для выбранных тем
|
||||||
|
authors_stats_query = f"""
|
||||||
|
SELECT st.topic, COUNT(DISTINCT sa.author) as authors_count
|
||||||
|
FROM shout_topic st
|
||||||
|
JOIN shout s ON st.shout = s.id AND s.deleted_at IS NULL AND s.published_at IS NOT NULL
|
||||||
|
JOIN shout_author sa ON sa.shout = s.id
|
||||||
|
WHERE st.topic IN ({",".join(map(str, topic_ids))})
|
||||||
|
GROUP BY st.topic
|
||||||
|
"""
|
||||||
|
authors_stats = {row[0]: row[1] for row in session.execute(text(authors_stats_query))}
|
||||||
|
|
||||||
|
# Запрос на получение статистики комментариев для выбранных тем
|
||||||
|
comments_stats_query = f"""
|
||||||
|
SELECT st.topic, COUNT(DISTINCT r.id) as comments_count
|
||||||
|
FROM shout_topic st
|
||||||
|
JOIN shout s ON st.shout = s.id AND s.deleted_at IS NULL AND s.published_at IS NOT NULL
|
||||||
|
JOIN reaction r ON r.shout = s.id AND r.kind = '{ReactionKind.COMMENT.value}' AND r.deleted_at IS NULL
|
||||||
|
JOIN author a ON r.created_by = a.id AND a.deleted_at IS NULL
|
||||||
|
WHERE st.topic IN ({",".join(map(str, topic_ids))})
|
||||||
|
GROUP BY st.topic
|
||||||
|
"""
|
||||||
|
comments_stats = {row[0]: row[1] for row in session.execute(text(comments_stats_query))}
|
||||||
|
|
||||||
|
# Формируем результат с добавлением статистики
|
||||||
|
result = []
|
||||||
|
for topic in topics:
|
||||||
|
topic_dict = topic.dict()
|
||||||
|
topic_dict["stat"] = {
|
||||||
|
"shouts": shouts_stats.get(topic.id, 0),
|
||||||
|
"followers": followers_stats.get(topic.id, 0),
|
||||||
|
"authors": authors_stats.get(topic.id, 0),
|
||||||
|
"comments": comments_stats.get(topic.id, 0),
|
||||||
|
}
|
||||||
|
result.append(topic_dict)
|
||||||
|
|
||||||
|
# Кешируем каждую тему отдельно для использования в других функциях
|
||||||
|
await cache_topic(topic_dict)
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
# Используем универсальную функцию для кеширования запросов
|
||||||
|
return await cached_query(cache_key, fetch_topics_with_stats)
|
||||||
|
|
||||||
|
|
||||||
|
# Функция для инвалидации кеша тем
|
||||||
|
async def invalidate_topics_cache(topic_id=None):
|
||||||
|
"""
|
||||||
|
Инвалидирует кеши тем при изменении данных.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
topic_id: Опциональный ID темы для точечной инвалидации.
|
||||||
|
Если не указан, инвалидируются все кеши тем.
|
||||||
|
"""
|
||||||
|
if topic_id:
|
||||||
|
# Точечная инвалидация конкретной темы
|
||||||
|
logger.debug(f"Инвалидация кеша для темы #{topic_id}")
|
||||||
|
specific_keys = [
|
||||||
|
f"topic:id:{topic_id}",
|
||||||
|
f"topic:authors:{topic_id}",
|
||||||
|
f"topic:followers:{topic_id}",
|
||||||
|
f"topic_shouts_{topic_id}",
|
||||||
|
]
|
||||||
|
|
||||||
|
# Получаем slug темы, если есть
|
||||||
|
with local_session() as session:
|
||||||
|
topic = session.query(Topic).filter(Topic.id == topic_id).first()
|
||||||
|
if topic and topic.slug:
|
||||||
|
specific_keys.append(f"topic:slug:{topic.slug}")
|
||||||
|
|
||||||
|
# Удаляем конкретные ключи
|
||||||
|
for key in specific_keys:
|
||||||
|
try:
|
||||||
|
await redis.execute("DEL", key)
|
||||||
|
logger.debug(f"Удален ключ кеша {key}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Ошибка при удалении ключа {key}: {e}")
|
||||||
|
|
||||||
|
# Также ищем и удаляем ключи коллекций, содержащих данные об этой теме
|
||||||
|
collection_keys = await redis.execute("KEYS", "topics:stats:*")
|
||||||
|
if collection_keys:
|
||||||
|
await redis.execute("DEL", *collection_keys)
|
||||||
|
logger.debug(f"Удалено {len(collection_keys)} коллекционных ключей тем")
|
||||||
|
else:
|
||||||
|
# Общая инвалидация всех кешей тем
|
||||||
|
logger.debug("Полная инвалидация кеша тем")
|
||||||
|
await invalidate_cache_by_prefix("topics")
|
||||||
|
|
||||||
|
|
||||||
# Запрос на получение всех тем
|
# Запрос на получение всех тем
|
||||||
@query.field("get_topics_all")
|
@query.field("get_topics_all")
|
||||||
def get_topics_all(_, _info):
|
async def get_topics_all(_, _info):
|
||||||
cache_key = "get_topics_all" # Ключ для кеша
|
"""
|
||||||
|
Получает список всех тем без статистики.
|
||||||
|
|
||||||
@cache_region.cache_on_arguments(cache_key)
|
Returns:
|
||||||
def _get_topics_all():
|
list: Список всех тем
|
||||||
topics_query = select(Topic)
|
"""
|
||||||
return get_with_stat(topics_query) # Получение тем с учетом статистики
|
return await get_all_topics()
|
||||||
|
|
||||||
return _get_topics_all()
|
|
||||||
|
|
||||||
|
|
||||||
# Запрос на получение тем по сообществу
|
# Запрос на получение тем по сообществу
|
||||||
@query.field("get_topics_by_community")
|
@query.field("get_topics_by_community")
|
||||||
def get_topics_by_community(_, _info, community_id: int):
|
async def get_topics_by_community(_, _info, community_id: int, limit=100, offset=0, by=None):
|
||||||
cache_key = f"get_topics_by_community_{community_id}" # Ключ для кеша
|
"""
|
||||||
|
Получает список тем, принадлежащих указанному сообществу с пагинацией и статистикой.
|
||||||
|
|
||||||
@cache_region.cache_on_arguments(cache_key)
|
Args:
|
||||||
def _get_topics_by_community():
|
community_id: ID сообщества
|
||||||
topics_by_community_query = select(Topic).where(Topic.community == community_id)
|
limit: Максимальное количество возвращаемых тем
|
||||||
return get_with_stat(topics_by_community_query)
|
offset: Смещение для пагинации
|
||||||
|
by: Опциональные параметры сортировки
|
||||||
|
|
||||||
return _get_topics_by_community()
|
Returns:
|
||||||
|
list: Список тем с их статистикой
|
||||||
|
"""
|
||||||
|
return await get_topics_with_stats(limit, offset, community_id, by)
|
||||||
|
|
||||||
|
|
||||||
# Запрос на получение тем по автору
|
# Запрос на получение тем по автору
|
||||||
@@ -74,6 +279,9 @@ async def create_topic(_, _info, topic_input):
|
|||||||
session.add(new_topic)
|
session.add(new_topic)
|
||||||
session.commit()
|
session.commit()
|
||||||
|
|
||||||
|
# Инвалидируем кеш всех тем
|
||||||
|
await invalidate_topics_cache()
|
||||||
|
|
||||||
return {"topic": new_topic}
|
return {"topic": new_topic}
|
||||||
|
|
||||||
|
|
||||||
@@ -87,10 +295,19 @@ async def update_topic(_, _info, topic_input):
|
|||||||
if not topic:
|
if not topic:
|
||||||
return {"error": "topic not found"}
|
return {"error": "topic not found"}
|
||||||
else:
|
else:
|
||||||
|
old_slug = topic.slug
|
||||||
Topic.update(topic, topic_input)
|
Topic.update(topic, topic_input)
|
||||||
session.add(topic)
|
session.add(topic)
|
||||||
session.commit()
|
session.commit()
|
||||||
|
|
||||||
|
# Инвалидируем кеш только для этой конкретной темы
|
||||||
|
await invalidate_topics_cache(topic.id)
|
||||||
|
|
||||||
|
# Если slug изменился, удаляем старый ключ
|
||||||
|
if old_slug != topic.slug:
|
||||||
|
await redis.execute("DEL", f"topic:slug:{old_slug}")
|
||||||
|
logger.debug(f"Удален ключ кеша для старого slug: {old_slug}")
|
||||||
|
|
||||||
return {"topic": topic}
|
return {"topic": topic}
|
||||||
|
|
||||||
|
|
||||||
@@ -111,6 +328,11 @@ async def delete_topic(_, info, slug: str):
|
|||||||
session.delete(t)
|
session.delete(t)
|
||||||
session.commit()
|
session.commit()
|
||||||
|
|
||||||
|
# Инвалидируем кеш всех тем и конкретной темы
|
||||||
|
await invalidate_topics_cache()
|
||||||
|
await redis.execute("DEL", f"topic:slug:{slug}")
|
||||||
|
await redis.execute("DEL", f"topic:id:{t.id}")
|
||||||
|
|
||||||
return {}
|
return {}
|
||||||
return {"error": "access denied"}
|
return {"error": "access denied"}
|
||||||
|
|
||||||
|
@@ -33,7 +33,6 @@ input DraftInput {
|
|||||||
main_topic_id: Int # Changed from main_topic: Topic
|
main_topic_id: Int # Changed from main_topic: Topic
|
||||||
media: [MediaItemInput] # Changed to use MediaItemInput
|
media: [MediaItemInput] # Changed to use MediaItemInput
|
||||||
lead: String
|
lead: String
|
||||||
description: String
|
|
||||||
subtitle: String
|
subtitle: String
|
||||||
lang: String
|
lang: String
|
||||||
seo: String
|
seo: String
|
||||||
@@ -93,12 +92,14 @@ input LoadShoutsOptions {
|
|||||||
|
|
||||||
input ReactionBy {
|
input ReactionBy {
|
||||||
shout: String
|
shout: String
|
||||||
|
shout_id: Int
|
||||||
shouts: [String]
|
shouts: [String]
|
||||||
search: String
|
search: String
|
||||||
kinds: [ReactionKind]
|
kinds: [ReactionKind]
|
||||||
reply_to: Int # filter
|
reply_to: Int # filter
|
||||||
topic: String
|
topic: String
|
||||||
created_by: Int
|
created_by: Int
|
||||||
|
author_id: Int
|
||||||
author: String
|
author: String
|
||||||
after: Int
|
after: Int
|
||||||
sort: ReactionSort # sort
|
sort: ReactionSort # sort
|
||||||
|
@@ -4,7 +4,7 @@ type Query {
|
|||||||
get_author_id(user: String!): Author
|
get_author_id(user: String!): Author
|
||||||
get_authors_all: [Author]
|
get_authors_all: [Author]
|
||||||
load_authors_by(by: AuthorsBy!, limit: Int, offset: Int): [Author]
|
load_authors_by(by: AuthorsBy!, limit: Int, offset: Int): [Author]
|
||||||
# search_authors(what: String!): [Author]
|
load_authors_search(text: String!, limit: Int, offset: Int): [Author!] # Search for authors by name or bio
|
||||||
|
|
||||||
# community
|
# community
|
||||||
get_community: Community
|
get_community: Community
|
||||||
@@ -26,10 +26,14 @@ type Query {
|
|||||||
load_shout_ratings(shout: Int!, limit: Int, offset: Int): [Reaction]
|
load_shout_ratings(shout: Int!, limit: Int, offset: Int): [Reaction]
|
||||||
load_comment_ratings(comment: Int!, limit: Int, offset: Int): [Reaction]
|
load_comment_ratings(comment: Int!, limit: Int, offset: Int): [Reaction]
|
||||||
|
|
||||||
|
# branched comments pagination
|
||||||
|
load_comments_branch(shout: Int!, parent_id: Int, limit: Int, offset: Int, sort: ReactionSort, children_limit: Int, children_offset: Int): [Reaction]
|
||||||
|
|
||||||
# reader
|
# reader
|
||||||
get_shout(slug: String, shout_id: Int): Shout
|
get_shout(slug: String, shout_id: Int): Shout
|
||||||
load_shouts_by(options: LoadShoutsOptions): [Shout]
|
load_shouts_by(options: LoadShoutsOptions): [Shout]
|
||||||
load_shouts_search(text: String!, options: LoadShoutsOptions): [SearchResult]
|
load_shouts_search(text: String!, options: LoadShoutsOptions): [SearchResult]
|
||||||
|
get_search_results_count(text: String!): CountResult!
|
||||||
load_shouts_bookmarked(options: LoadShoutsOptions): [Shout]
|
load_shouts_bookmarked(options: LoadShoutsOptions): [Shout]
|
||||||
|
|
||||||
# rating
|
# rating
|
||||||
@@ -57,7 +61,7 @@ type Query {
|
|||||||
get_topic(slug: String!): Topic
|
get_topic(slug: String!): Topic
|
||||||
get_topics_all: [Topic]
|
get_topics_all: [Topic]
|
||||||
get_topics_by_author(slug: String, user: String, author_id: Int): [Topic]
|
get_topics_by_author(slug: String, user: String, author_id: Int): [Topic]
|
||||||
get_topics_by_community(slug: String, community_id: Int): [Topic]
|
get_topics_by_community(community_id: Int!, limit: Int, offset: Int): [Topic]
|
||||||
|
|
||||||
# notifier
|
# notifier
|
||||||
load_notifications(after: Int!, limit: Int, offset: Int): NotificationsResult!
|
load_notifications(after: Int!, limit: Int, offset: Int): NotificationsResult!
|
||||||
|
@@ -55,6 +55,7 @@ type Reaction {
|
|||||||
stat: Stat
|
stat: Stat
|
||||||
oid: String
|
oid: String
|
||||||
# old_thread: String
|
# old_thread: String
|
||||||
|
first_replies: [Reaction]
|
||||||
}
|
}
|
||||||
|
|
||||||
type MediaItem {
|
type MediaItem {
|
||||||
@@ -79,7 +80,6 @@ type Shout {
|
|||||||
layout: String!
|
layout: String!
|
||||||
|
|
||||||
lead: String
|
lead: String
|
||||||
description: String
|
|
||||||
subtitle: String
|
subtitle: String
|
||||||
lang: String
|
lang: String
|
||||||
cover: String
|
cover: String
|
||||||
@@ -99,6 +99,7 @@ type Shout {
|
|||||||
featured_at: Int
|
featured_at: Int
|
||||||
deleted_at: Int
|
deleted_at: Int
|
||||||
|
|
||||||
|
seo: String # generated if not set
|
||||||
version_of: Shout # TODO: use version_of somewhere
|
version_of: Shout # TODO: use version_of somewhere
|
||||||
draft: Draft
|
draft: Draft
|
||||||
media: [MediaItem]
|
media: [MediaItem]
|
||||||
@@ -106,17 +107,22 @@ type Shout {
|
|||||||
score: Float
|
score: Float
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type PublicationInfo {
|
||||||
|
id: Int!
|
||||||
|
slug: String!
|
||||||
|
published_at: Int
|
||||||
|
}
|
||||||
|
|
||||||
type Draft {
|
type Draft {
|
||||||
id: Int!
|
id: Int!
|
||||||
created_at: Int!
|
created_at: Int!
|
||||||
created_by: Author!
|
created_by: Author!
|
||||||
|
community: Community!
|
||||||
layout: String
|
layout: String
|
||||||
slug: String
|
slug: String
|
||||||
title: String
|
title: String
|
||||||
subtitle: String
|
subtitle: String
|
||||||
lead: String
|
lead: String
|
||||||
description: String
|
|
||||||
body: String
|
body: String
|
||||||
media: [MediaItem]
|
media: [MediaItem]
|
||||||
cover: String
|
cover: String
|
||||||
@@ -129,14 +135,14 @@ type Draft {
|
|||||||
deleted_at: Int
|
deleted_at: Int
|
||||||
updated_by: Author
|
updated_by: Author
|
||||||
deleted_by: Author
|
deleted_by: Author
|
||||||
authors: [Author]
|
authors: [Author]!
|
||||||
topics: [Topic]
|
topics: [Topic]!
|
||||||
|
publication: PublicationInfo
|
||||||
}
|
}
|
||||||
|
|
||||||
type Stat {
|
type Stat {
|
||||||
rating: Int
|
rating: Int
|
||||||
commented: Int
|
comments_count: Int
|
||||||
viewed: Int
|
viewed: Int
|
||||||
last_commented_at: Int
|
last_commented_at: Int
|
||||||
}
|
}
|
||||||
@@ -275,3 +281,7 @@ type MyRateComment {
|
|||||||
my_rate: ReactionKind
|
my_rate: ReactionKind
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type CountResult {
|
||||||
|
count: Int!
|
||||||
|
}
|
||||||
|
|
||||||
|
34
server.py
34
server.py
@@ -1,34 +0,0 @@
|
|||||||
import sys
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from granian.constants import Interfaces
|
|
||||||
from granian.log import LogLevels
|
|
||||||
from granian.server import Server
|
|
||||||
|
|
||||||
from settings import PORT
|
|
||||||
from utils.logger import root_logger as logger
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
logger.info("started")
|
|
||||||
try:
|
|
||||||
|
|
||||||
granian_instance = Server(
|
|
||||||
"main:app",
|
|
||||||
address="0.0.0.0",
|
|
||||||
port=PORT,
|
|
||||||
interface=Interfaces.ASGI,
|
|
||||||
workers=1,
|
|
||||||
websockets=False,
|
|
||||||
log_level=LogLevels.debug,
|
|
||||||
backlog=2048,
|
|
||||||
)
|
|
||||||
|
|
||||||
if "dev" in sys.argv:
|
|
||||||
logger.info("dev mode, building ssl context")
|
|
||||||
granian_instance.build_ssl_context(cert=Path("localhost.pem"), key=Path("localhost-key.pem"), password=None)
|
|
||||||
granian_instance.serve()
|
|
||||||
except Exception as error:
|
|
||||||
logger.error(error, exc_info=True)
|
|
||||||
raise
|
|
||||||
finally:
|
|
||||||
logger.info("stopped")
|
|
@@ -1,23 +1,25 @@
|
|||||||
import json
|
|
||||||
import math
|
import math
|
||||||
import time
|
import time
|
||||||
import traceback
|
import traceback
|
||||||
import warnings
|
import warnings
|
||||||
from typing import Any, Callable, Dict, TypeVar
|
from typing import Any, Callable, Dict, TypeVar
|
||||||
|
|
||||||
|
import orjson
|
||||||
import sqlalchemy
|
import sqlalchemy
|
||||||
from sqlalchemy import (
|
from sqlalchemy import (
|
||||||
JSON,
|
JSON,
|
||||||
Column,
|
Column,
|
||||||
Engine,
|
Engine,
|
||||||
|
Index,
|
||||||
Integer,
|
Integer,
|
||||||
create_engine,
|
create_engine,
|
||||||
event,
|
event,
|
||||||
exc,
|
exc,
|
||||||
func,
|
func,
|
||||||
inspect,
|
inspect,
|
||||||
|
text,
|
||||||
)
|
)
|
||||||
from sqlalchemy.orm import Session, configure_mappers, declarative_base
|
from sqlalchemy.orm import Session, configure_mappers, declarative_base, joinedload
|
||||||
from sqlalchemy.sql.schema import Table
|
from sqlalchemy.sql.schema import Table
|
||||||
|
|
||||||
from settings import DB_URL
|
from settings import DB_URL
|
||||||
@@ -56,6 +58,82 @@ def create_table_if_not_exists(engine, table):
|
|||||||
logger.info(f"Table '{table.__tablename__}' ok.")
|
logger.info(f"Table '{table.__tablename__}' ok.")
|
||||||
|
|
||||||
|
|
||||||
|
def sync_indexes():
|
||||||
|
"""
|
||||||
|
Синхронизирует индексы в БД с индексами, определенными в моделях SQLAlchemy.
|
||||||
|
Создает недостающие индексы, если они определены в моделях, но отсутствуют в БД.
|
||||||
|
|
||||||
|
Использует pg_catalog для PostgreSQL для получения списка существующих индексов.
|
||||||
|
"""
|
||||||
|
if not DB_URL.startswith("postgres"):
|
||||||
|
logger.warning("Функция sync_indexes поддерживается только для PostgreSQL.")
|
||||||
|
return
|
||||||
|
|
||||||
|
logger.info("Начинаем синхронизацию индексов в базе данных...")
|
||||||
|
|
||||||
|
# Получаем все существующие индексы в БД
|
||||||
|
with local_session() as session:
|
||||||
|
existing_indexes_query = text("""
|
||||||
|
SELECT
|
||||||
|
t.relname AS table_name,
|
||||||
|
i.relname AS index_name
|
||||||
|
FROM
|
||||||
|
pg_catalog.pg_class i
|
||||||
|
JOIN
|
||||||
|
pg_catalog.pg_index ix ON ix.indexrelid = i.oid
|
||||||
|
JOIN
|
||||||
|
pg_catalog.pg_class t ON t.oid = ix.indrelid
|
||||||
|
JOIN
|
||||||
|
pg_catalog.pg_namespace n ON n.oid = i.relnamespace
|
||||||
|
WHERE
|
||||||
|
i.relkind = 'i'
|
||||||
|
AND n.nspname = 'public'
|
||||||
|
AND t.relkind = 'r'
|
||||||
|
ORDER BY
|
||||||
|
t.relname, i.relname;
|
||||||
|
""")
|
||||||
|
|
||||||
|
existing_indexes = {row[1].lower() for row in session.execute(existing_indexes_query)}
|
||||||
|
logger.debug(f"Найдено {len(existing_indexes)} существующих индексов в БД")
|
||||||
|
|
||||||
|
# Проверяем каждую модель и её индексы
|
||||||
|
for _model_name, model_class in REGISTRY.items():
|
||||||
|
if hasattr(model_class, "__table__") and hasattr(model_class, "__table_args__"):
|
||||||
|
table_args = model_class.__table_args__
|
||||||
|
|
||||||
|
# Если table_args - это кортеж, ищем в нём объекты Index
|
||||||
|
if isinstance(table_args, tuple):
|
||||||
|
for arg in table_args:
|
||||||
|
if isinstance(arg, Index):
|
||||||
|
index_name = arg.name.lower()
|
||||||
|
|
||||||
|
# Проверяем, существует ли индекс в БД
|
||||||
|
if index_name not in existing_indexes:
|
||||||
|
logger.info(
|
||||||
|
f"Создаем отсутствующий индекс {index_name} для таблицы {model_class.__tablename__}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Создаем индекс если он отсутствует
|
||||||
|
try:
|
||||||
|
arg.create(engine)
|
||||||
|
logger.info(f"Индекс {index_name} успешно создан")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Ошибка при создании индекса {index_name}: {e}")
|
||||||
|
else:
|
||||||
|
logger.debug(f"Индекс {index_name} уже существует")
|
||||||
|
|
||||||
|
# Анализируем таблицы для оптимизации запросов
|
||||||
|
for model_name, model_class in REGISTRY.items():
|
||||||
|
if hasattr(model_class, "__tablename__"):
|
||||||
|
try:
|
||||||
|
session.execute(text(f"ANALYZE {model_class.__tablename__}"))
|
||||||
|
logger.debug(f"Таблица {model_class.__tablename__} проанализирована")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Ошибка при анализе таблицы {model_class.__tablename__}: {e}")
|
||||||
|
|
||||||
|
logger.info("Синхронизация индексов завершена.")
|
||||||
|
|
||||||
|
|
||||||
# noinspection PyUnusedLocal
|
# noinspection PyUnusedLocal
|
||||||
def local_session(src=""):
|
def local_session(src=""):
|
||||||
return Session(bind=engine, expire_on_commit=False)
|
return Session(bind=engine, expire_on_commit=False)
|
||||||
@@ -84,8 +162,8 @@ class Base(declarative_base()):
|
|||||||
# Check if the value is JSON and decode it if necessary
|
# Check if the value is JSON and decode it if necessary
|
||||||
if isinstance(value, (str, bytes)) and isinstance(self.__table__.columns[column_name].type, JSON):
|
if isinstance(value, (str, bytes)) and isinstance(self.__table__.columns[column_name].type, JSON):
|
||||||
try:
|
try:
|
||||||
data[column_name] = json.loads(value)
|
data[column_name] = orjson.loads(value)
|
||||||
except (TypeError, json.JSONDecodeError) as e:
|
except (TypeError, orjson.JSONDecodeError) as e:
|
||||||
logger.error(f"Error decoding JSON for column '{column_name}': {e}")
|
logger.error(f"Error decoding JSON for column '{column_name}': {e}")
|
||||||
data[column_name] = value
|
data[column_name] = value
|
||||||
else:
|
else:
|
||||||
@@ -182,8 +260,11 @@ def get_json_builder():
|
|||||||
# Используем их в коде
|
# Используем их в коде
|
||||||
json_builder, json_array_builder, json_cast = get_json_builder()
|
json_builder, json_array_builder, json_cast = get_json_builder()
|
||||||
|
|
||||||
|
# Fetch all shouts, with authors preloaded
|
||||||
|
# This function is used for search indexing
|
||||||
|
|
||||||
async def fetch_all_shouts(session=None):
|
async def fetch_all_shouts(session=None):
|
||||||
"""Fetch all published shouts for search indexing"""
|
"""Fetch all published shouts for search indexing with authors preloaded"""
|
||||||
from orm.shout import Shout
|
from orm.shout import Shout
|
||||||
|
|
||||||
close_session = False
|
close_session = False
|
||||||
@@ -192,8 +273,10 @@ async def fetch_all_shouts(session=None):
|
|||||||
close_session = True
|
close_session = True
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Fetch only published and non-deleted shouts
|
# Fetch only published and non-deleted shouts with authors preloaded
|
||||||
query = session.query(Shout).filter(
|
query = session.query(Shout).options(
|
||||||
|
joinedload(Shout.authors)
|
||||||
|
).filter(
|
||||||
Shout.published_at.is_not(None),
|
Shout.published_at.is_not(None),
|
||||||
Shout.deleted_at.is_(None)
|
Shout.deleted_at.is_(None)
|
||||||
)
|
)
|
||||||
|
@@ -1,4 +1,4 @@
|
|||||||
import json
|
import orjson
|
||||||
|
|
||||||
from orm.notification import Notification
|
from orm.notification import Notification
|
||||||
from services.db import local_session
|
from services.db import local_session
|
||||||
@@ -18,7 +18,7 @@ async def notify_reaction(reaction, action: str = "create"):
|
|||||||
data = {"payload": reaction, "action": action}
|
data = {"payload": reaction, "action": action}
|
||||||
try:
|
try:
|
||||||
save_notification(action, channel_name, data.get("payload"))
|
save_notification(action, channel_name, data.get("payload"))
|
||||||
await redis.publish(channel_name, json.dumps(data))
|
await redis.publish(channel_name, orjson.dumps(data))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to publish to channel {channel_name}: {e}")
|
logger.error(f"Failed to publish to channel {channel_name}: {e}")
|
||||||
|
|
||||||
@@ -28,7 +28,7 @@ async def notify_shout(shout, action: str = "update"):
|
|||||||
data = {"payload": shout, "action": action}
|
data = {"payload": shout, "action": action}
|
||||||
try:
|
try:
|
||||||
save_notification(action, channel_name, data.get("payload"))
|
save_notification(action, channel_name, data.get("payload"))
|
||||||
await redis.publish(channel_name, json.dumps(data))
|
await redis.publish(channel_name, orjson.dumps(data))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to publish to channel {channel_name}: {e}")
|
logger.error(f"Failed to publish to channel {channel_name}: {e}")
|
||||||
|
|
||||||
@@ -43,7 +43,7 @@ async def notify_follower(follower: dict, author_id: int, action: str = "follow"
|
|||||||
save_notification(action, channel_name, data.get("payload"))
|
save_notification(action, channel_name, data.get("payload"))
|
||||||
|
|
||||||
# Convert data to JSON string
|
# Convert data to JSON string
|
||||||
json_data = json.dumps(data)
|
json_data = orjson.dumps(data)
|
||||||
|
|
||||||
# Ensure the data is not empty before publishing
|
# Ensure the data is not empty before publishing
|
||||||
if json_data:
|
if json_data:
|
||||||
@@ -53,3 +53,66 @@ async def notify_follower(follower: dict, author_id: int, action: str = "follow"
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
# Log the error and re-raise it
|
# Log the error and re-raise it
|
||||||
logger.error(f"Failed to publish to channel {channel_name}: {e}")
|
logger.error(f"Failed to publish to channel {channel_name}: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
async def notify_draft(draft_data, action: str = "publish"):
|
||||||
|
"""
|
||||||
|
Отправляет уведомление о публикации или обновлении черновика.
|
||||||
|
|
||||||
|
Функция гарантирует, что данные черновика сериализуются корректно, включая
|
||||||
|
связанные атрибуты (topics, authors).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
draft_data (dict): Словарь с данными черновика. Должен содержать минимум id и title
|
||||||
|
action (str, optional): Действие ("publish", "update"). По умолчанию "publish"
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
None
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
>>> draft = {"id": 1, "title": "Тестовый черновик", "slug": "test-draft"}
|
||||||
|
>>> await notify_draft(draft, "publish")
|
||||||
|
"""
|
||||||
|
channel_name = "draft"
|
||||||
|
try:
|
||||||
|
# Убеждаемся, что все необходимые данные присутствуют
|
||||||
|
# и объект не требует доступа к отсоединенным атрибутам
|
||||||
|
if isinstance(draft_data, dict):
|
||||||
|
draft_payload = draft_data
|
||||||
|
else:
|
||||||
|
# Если это ORM объект, преобразуем его в словарь с нужными атрибутами
|
||||||
|
draft_payload = {
|
||||||
|
"id": getattr(draft_data, "id", None),
|
||||||
|
"slug": getattr(draft_data, "slug", None),
|
||||||
|
"title": getattr(draft_data, "title", None),
|
||||||
|
"subtitle": getattr(draft_data, "subtitle", None),
|
||||||
|
"media": getattr(draft_data, "media", None),
|
||||||
|
"created_at": getattr(draft_data, "created_at", None),
|
||||||
|
"updated_at": getattr(draft_data, "updated_at", None)
|
||||||
|
}
|
||||||
|
|
||||||
|
# Если переданы связанные атрибуты, добавим их
|
||||||
|
if hasattr(draft_data, "topics") and draft_data.topics is not None:
|
||||||
|
draft_payload["topics"] = [
|
||||||
|
{"id": t.id, "name": t.name, "slug": t.slug}
|
||||||
|
for t in draft_data.topics
|
||||||
|
]
|
||||||
|
|
||||||
|
if hasattr(draft_data, "authors") and draft_data.authors is not None:
|
||||||
|
draft_payload["authors"] = [
|
||||||
|
{"id": a.id, "name": a.name, "slug": a.slug, "pic": getattr(a, "pic", None)}
|
||||||
|
for a in draft_data.authors
|
||||||
|
]
|
||||||
|
|
||||||
|
data = {"payload": draft_payload, "action": action}
|
||||||
|
|
||||||
|
# Сохраняем уведомление
|
||||||
|
save_notification(action, channel_name, data.get("payload"))
|
||||||
|
|
||||||
|
# Публикуем в Redis
|
||||||
|
json_data = orjson.dumps(data)
|
||||||
|
if json_data:
|
||||||
|
await redis.publish(channel_name, json_data)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to publish to channel {channel_name}: {e}")
|
||||||
|
@@ -1,14 +1,15 @@
|
|||||||
from asyncio.log import logger
|
from asyncio.log import logger
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
from ariadne import MutationType, QueryType
|
from ariadne import MutationType, ObjectType, QueryType
|
||||||
|
|
||||||
from services.db import create_table_if_not_exists, local_session
|
from services.db import create_table_if_not_exists, local_session
|
||||||
from settings import AUTH_URL
|
from settings import AUTH_URL
|
||||||
|
|
||||||
query = QueryType()
|
query = QueryType()
|
||||||
mutation = MutationType()
|
mutation = MutationType()
|
||||||
resolvers = [query, mutation]
|
type_draft = ObjectType("Draft")
|
||||||
|
resolvers = [query, mutation, type_draft]
|
||||||
|
|
||||||
|
|
||||||
async def request_graphql_data(gql, url=AUTH_URL, headers=None):
|
async def request_graphql_data(gql, url=AUTH_URL, headers=None):
|
||||||
@@ -28,12 +29,19 @@ async def request_graphql_data(gql, url=AUTH_URL, headers=None):
|
|||||||
async with httpx.AsyncClient() as client:
|
async with httpx.AsyncClient() as client:
|
||||||
response = await client.post(url, json=gql, headers=headers)
|
response = await client.post(url, json=gql, headers=headers)
|
||||||
if response.status_code == 200:
|
if response.status_code == 200:
|
||||||
data = response.json()
|
# Check if the response has content before parsing
|
||||||
errors = data.get("errors")
|
if response.content and len(response.content.strip()) > 0:
|
||||||
if errors:
|
try:
|
||||||
logger.error(f"{url} response: {data}")
|
data = response.json()
|
||||||
|
errors = data.get("errors")
|
||||||
|
if errors:
|
||||||
|
logger.error(f"{url} response: {data}")
|
||||||
|
else:
|
||||||
|
return data
|
||||||
|
except Exception as json_err:
|
||||||
|
logger.error(f"JSON decode error: {json_err}, Response content: {response.text[:100]}")
|
||||||
else:
|
else:
|
||||||
return data
|
logger.error(f"{url}: Response is empty")
|
||||||
else:
|
else:
|
||||||
logger.error(f"{url}: {response.status_code} {response.text}")
|
logger.error(f"{url}: {response.status_code} {response.text}")
|
||||||
except Exception as _e:
|
except Exception as _e:
|
||||||
|
1068
services/search.py
1068
services/search.py
File diff suppressed because it is too large
Load Diff
@@ -1,9 +1,8 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import json
|
|
||||||
import os
|
import os
|
||||||
import time
|
import time
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from typing import Dict
|
from typing import Dict, Optional
|
||||||
|
|
||||||
# ga
|
# ga
|
||||||
from google.analytics.data_v1beta import BetaAnalyticsDataClient
|
from google.analytics.data_v1beta import BetaAnalyticsDataClient
|
||||||
@@ -19,33 +18,39 @@ from orm.author import Author
|
|||||||
from orm.shout import Shout, ShoutAuthor, ShoutTopic
|
from orm.shout import Shout, ShoutAuthor, ShoutTopic
|
||||||
from orm.topic import Topic
|
from orm.topic import Topic
|
||||||
from services.db import local_session
|
from services.db import local_session
|
||||||
|
from services.redis import redis
|
||||||
from utils.logger import root_logger as logger
|
from utils.logger import root_logger as logger
|
||||||
|
|
||||||
GOOGLE_KEYFILE_PATH = os.environ.get("GOOGLE_KEYFILE_PATH", "/dump/google-service.json")
|
GOOGLE_KEYFILE_PATH = os.environ.get("GOOGLE_KEYFILE_PATH", "/dump/google-service.json")
|
||||||
GOOGLE_PROPERTY_ID = os.environ.get("GOOGLE_PROPERTY_ID", "")
|
GOOGLE_PROPERTY_ID = os.environ.get("GOOGLE_PROPERTY_ID", "")
|
||||||
VIEWS_FILEPATH = "/dump/views.json"
|
|
||||||
|
|
||||||
|
|
||||||
class ViewedStorage:
|
class ViewedStorage:
|
||||||
|
"""
|
||||||
|
Класс для хранения и доступа к данным о просмотрах.
|
||||||
|
Использует Redis в качестве основного хранилища и Google Analytics для сбора новых данных.
|
||||||
|
"""
|
||||||
|
|
||||||
lock = asyncio.Lock()
|
lock = asyncio.Lock()
|
||||||
precounted_by_slug = {}
|
|
||||||
views_by_shout = {}
|
views_by_shout = {}
|
||||||
shouts_by_topic = {}
|
shouts_by_topic = {}
|
||||||
shouts_by_author = {}
|
shouts_by_author = {}
|
||||||
views = None
|
views = None
|
||||||
period = 60 * 60 # каждый час
|
period = 60 * 60 # каждый час
|
||||||
analytics_client: BetaAnalyticsDataClient | None = None
|
analytics_client: Optional[BetaAnalyticsDataClient] = None
|
||||||
auth_result = None
|
auth_result = None
|
||||||
running = False
|
running = False
|
||||||
|
redis_views_key = None
|
||||||
|
last_update_timestamp = 0
|
||||||
start_date = datetime.now().strftime("%Y-%m-%d")
|
start_date = datetime.now().strftime("%Y-%m-%d")
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def init():
|
async def init():
|
||||||
"""Подключение к клиенту Google Analytics с использованием аутентификации"""
|
"""Подключение к клиенту Google Analytics и загрузка данных о просмотрах из Redis"""
|
||||||
self = ViewedStorage
|
self = ViewedStorage
|
||||||
async with self.lock:
|
async with self.lock:
|
||||||
# Загрузка предварительно подсчитанных просмотров из файла JSON
|
# Загрузка предварительно подсчитанных просмотров из Redis
|
||||||
self.load_precounted_views()
|
await self.load_views_from_redis()
|
||||||
|
|
||||||
os.environ.setdefault("GOOGLE_APPLICATION_CREDENTIALS", GOOGLE_KEYFILE_PATH)
|
os.environ.setdefault("GOOGLE_APPLICATION_CREDENTIALS", GOOGLE_KEYFILE_PATH)
|
||||||
if GOOGLE_KEYFILE_PATH and os.path.isfile(GOOGLE_KEYFILE_PATH):
|
if GOOGLE_KEYFILE_PATH and os.path.isfile(GOOGLE_KEYFILE_PATH):
|
||||||
@@ -61,40 +66,54 @@ class ViewedStorage:
|
|||||||
self.running = False
|
self.running = False
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def load_precounted_views():
|
async def load_views_from_redis():
|
||||||
"""Загрузка предварительно подсчитанных просмотров из файла JSON"""
|
"""Загрузка предварительно подсчитанных просмотров из Redis"""
|
||||||
self = ViewedStorage
|
self = ViewedStorage
|
||||||
viewfile_path = VIEWS_FILEPATH
|
|
||||||
if not os.path.exists(viewfile_path):
|
|
||||||
viewfile_path = os.path.join(os.path.curdir, "views.json")
|
|
||||||
if not os.path.exists(viewfile_path):
|
|
||||||
logger.warning(" * views.json not found")
|
|
||||||
return
|
|
||||||
|
|
||||||
logger.info(f" * loading views from {viewfile_path}")
|
# Подключаемся к Redis если соединение не установлено
|
||||||
try:
|
if not redis._client:
|
||||||
start_date_int = os.path.getmtime(viewfile_path)
|
await redis.connect()
|
||||||
start_date_str = datetime.fromtimestamp(start_date_int).strftime("%Y-%m-%d")
|
|
||||||
self.start_date = start_date_str
|
# Получаем список всех ключей migrated_views_* и находим самый последний
|
||||||
|
keys = await redis.execute("KEYS", "migrated_views_*")
|
||||||
|
if not keys:
|
||||||
|
logger.warning(" * No migrated_views keys found in Redis")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Фильтруем только ключи timestamp формата (исключаем migrated_views_slugs)
|
||||||
|
timestamp_keys = [k for k in keys if k != "migrated_views_slugs"]
|
||||||
|
if not timestamp_keys:
|
||||||
|
logger.warning(" * No migrated_views timestamp keys found in Redis")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Сортируем по времени создания (в названии ключа) и берем последний
|
||||||
|
timestamp_keys.sort()
|
||||||
|
latest_key = timestamp_keys[-1]
|
||||||
|
self.redis_views_key = latest_key
|
||||||
|
|
||||||
|
# Получаем метку времени создания для установки start_date
|
||||||
|
timestamp = await redis.execute("HGET", latest_key, "_timestamp")
|
||||||
|
if timestamp:
|
||||||
|
self.last_update_timestamp = int(timestamp)
|
||||||
|
timestamp_dt = datetime.fromtimestamp(int(timestamp))
|
||||||
|
self.start_date = timestamp_dt.strftime("%Y-%m-%d")
|
||||||
|
|
||||||
|
# Если данные сегодняшние, считаем их актуальными
|
||||||
now_date = datetime.now().strftime("%Y-%m-%d")
|
now_date = datetime.now().strftime("%Y-%m-%d")
|
||||||
|
|
||||||
if now_date == self.start_date:
|
if now_date == self.start_date:
|
||||||
logger.info(" * views data is up to date!")
|
logger.info(" * Views data is up to date!")
|
||||||
else:
|
else:
|
||||||
logger.warn(f" * {viewfile_path} is too old: {self.start_date}")
|
logger.warning(f" * Views data is from {self.start_date}, may need update")
|
||||||
|
|
||||||
with open(viewfile_path, "r") as file:
|
# Выводим информацию о количестве загруженных записей
|
||||||
precounted_views = json.load(file)
|
total_entries = await redis.execute("HGET", latest_key, "_total")
|
||||||
self.precounted_by_slug.update(precounted_views)
|
if total_entries:
|
||||||
logger.info(f" * {len(precounted_views)} shouts with views was loaded.")
|
logger.info(f" * {total_entries} shouts with views loaded from Redis key: {latest_key}")
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"precounted views loading error: {e}")
|
|
||||||
|
|
||||||
# noinspection PyTypeChecker
|
# noinspection PyTypeChecker
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def update_pages():
|
async def update_pages():
|
||||||
"""Запрос всех страниц от Google Analytics, отсортрованных по количеству просмотров"""
|
"""Запрос всех страниц от Google Analytics, отсортированных по количеству просмотров"""
|
||||||
self = ViewedStorage
|
self = ViewedStorage
|
||||||
logger.info(" ⎧ views update from Google Analytics ---")
|
logger.info(" ⎧ views update from Google Analytics ---")
|
||||||
if self.running:
|
if self.running:
|
||||||
@@ -139,15 +158,40 @@ class ViewedStorage:
|
|||||||
self.running = False
|
self.running = False
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def get_shout(shout_slug="", shout_id=0) -> int:
|
async def get_shout(shout_slug="", shout_id=0) -> int:
|
||||||
"""Получение метрики просмотров shout по slug или id."""
|
"""
|
||||||
|
Получение метрики просмотров shout по slug или id.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
shout_slug: Slug публикации
|
||||||
|
shout_id: ID публикации
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
int: Количество просмотров
|
||||||
|
"""
|
||||||
self = ViewedStorage
|
self = ViewedStorage
|
||||||
|
|
||||||
|
# Получаем данные из Redis для новой схемы хранения
|
||||||
|
if not redis._client:
|
||||||
|
await redis.connect()
|
||||||
|
|
||||||
fresh_views = self.views_by_shout.get(shout_slug, 0)
|
fresh_views = self.views_by_shout.get(shout_slug, 0)
|
||||||
precounted_views = self.precounted_by_slug.get(shout_slug, 0)
|
|
||||||
return fresh_views + precounted_views
|
# Если есть id, пытаемся получить данные из Redis по ключу migrated_views_<timestamp>
|
||||||
|
if shout_id and self.redis_views_key:
|
||||||
|
precounted_views = await redis.execute("HGET", self.redis_views_key, str(shout_id))
|
||||||
|
if precounted_views:
|
||||||
|
return fresh_views + int(precounted_views)
|
||||||
|
|
||||||
|
# Если нет id или данных, пытаемся получить по slug из отдельного хеша
|
||||||
|
precounted_views = await redis.execute("HGET", "migrated_views_slugs", shout_slug)
|
||||||
|
if precounted_views:
|
||||||
|
return fresh_views + int(precounted_views)
|
||||||
|
|
||||||
|
return fresh_views
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def get_shout_media(shout_slug) -> Dict[str, int]:
|
async def get_shout_media(shout_slug) -> Dict[str, int]:
|
||||||
"""Получение метрики воспроизведения shout по slug."""
|
"""Получение метрики воспроизведения shout по slug."""
|
||||||
self = ViewedStorage
|
self = ViewedStorage
|
||||||
|
|
||||||
@@ -156,23 +200,29 @@ class ViewedStorage:
|
|||||||
return self.views_by_shout.get(shout_slug, 0)
|
return self.views_by_shout.get(shout_slug, 0)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def get_topic(topic_slug) -> int:
|
async def get_topic(topic_slug) -> int:
|
||||||
"""Получение суммарного значения просмотров темы."""
|
"""Получение суммарного значения просмотров темы."""
|
||||||
self = ViewedStorage
|
self = ViewedStorage
|
||||||
return sum(self.views_by_shout.get(shout_slug, 0) for shout_slug in self.shouts_by_topic.get(topic_slug, []))
|
views_count = 0
|
||||||
|
for shout_slug in self.shouts_by_topic.get(topic_slug, []):
|
||||||
|
views_count += await self.get_shout(shout_slug=shout_slug)
|
||||||
|
return views_count
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def get_author(author_slug) -> int:
|
async def get_author(author_slug) -> int:
|
||||||
"""Получение суммарного значения просмотров автора."""
|
"""Получение суммарного значения просмотров автора."""
|
||||||
self = ViewedStorage
|
self = ViewedStorage
|
||||||
return sum(self.views_by_shout.get(shout_slug, 0) for shout_slug in self.shouts_by_author.get(author_slug, []))
|
views_count = 0
|
||||||
|
for shout_slug in self.shouts_by_author.get(author_slug, []):
|
||||||
|
views_count += await self.get_shout(shout_slug=shout_slug)
|
||||||
|
return views_count
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def update_topics(shout_slug):
|
def update_topics(shout_slug):
|
||||||
"""Обновление счетчиков темы по slug shout"""
|
"""Обновление счетчиков темы по slug shout"""
|
||||||
self = ViewedStorage
|
self = ViewedStorage
|
||||||
with local_session() as session:
|
with local_session() as session:
|
||||||
# Определение вспомогательной функции для избежа<EFBFBD><EFBFBD>ия повторения кода
|
# Определение вспомогательной функции для избежания повторения кода
|
||||||
def update_groups(dictionary, key, value):
|
def update_groups(dictionary, key, value):
|
||||||
dictionary[key] = list(set(dictionary.get(key, []) + [value]))
|
dictionary[key] = list(set(dictionary.get(key, []) + [value]))
|
||||||
|
|
||||||
|
16
settings.py
16
settings.py
@@ -1,18 +1,24 @@
|
|||||||
import sys
|
import sys
|
||||||
from os import environ
|
from os import environ
|
||||||
|
|
||||||
PORT = 8000
|
MODE = "development" if "dev" in sys.argv else "production"
|
||||||
|
DEV_SERVER_PID_FILE_NAME = "dev-server.pid"
|
||||||
|
|
||||||
|
PORT = environ.get("PORT") or 8000
|
||||||
|
|
||||||
|
# storages
|
||||||
DB_URL = (
|
DB_URL = (
|
||||||
environ.get("DATABASE_URL", "").replace("postgres://", "postgresql://")
|
environ.get("DATABASE_URL", "").replace("postgres://", "postgresql://")
|
||||||
or environ.get("DB_URL", "").replace("postgres://", "postgresql://")
|
or environ.get("DB_URL", "").replace("postgres://", "postgresql://")
|
||||||
or "sqlite:///discoursio.db"
|
or "sqlite:///discoursio.db"
|
||||||
)
|
)
|
||||||
REDIS_URL = environ.get("REDIS_URL") or "redis://127.0.0.1"
|
REDIS_URL = environ.get("REDIS_URL") or "redis://127.0.0.1"
|
||||||
AUTH_URL = environ.get("AUTH_URL") or ""
|
|
||||||
GLITCHTIP_DSN = environ.get("GLITCHTIP_DSN")
|
|
||||||
DEV_SERVER_PID_FILE_NAME = "dev-server.pid"
|
|
||||||
MODE = "development" if "dev" in sys.argv else "production"
|
|
||||||
|
|
||||||
|
# debug
|
||||||
|
GLITCHTIP_DSN = environ.get("GLITCHTIP_DSN")
|
||||||
|
|
||||||
|
# authorizer.dev
|
||||||
|
AUTH_URL = environ.get("AUTH_URL") or "https://auth.discours.io/graphql"
|
||||||
ADMIN_SECRET = environ.get("AUTH_SECRET") or "nothing"
|
ADMIN_SECRET = environ.get("AUTH_SECRET") or "nothing"
|
||||||
WEBHOOK_SECRET = environ.get("WEBHOOK_SECRET") or "nothing-else"
|
WEBHOOK_SECRET = environ.get("WEBHOOK_SECRET") or "nothing-else"
|
||||||
|
|
||||||
|
1
utils/__init__.py
Normal file
1
utils/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
|
@@ -1,9 +1,28 @@
|
|||||||
import json
|
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
|
from json import JSONEncoder
|
||||||
|
|
||||||
|
|
||||||
class CustomJSONEncoder(json.JSONEncoder):
|
class CustomJSONEncoder(JSONEncoder):
|
||||||
|
"""
|
||||||
|
Расширенный JSON энкодер с поддержкой сериализации объектов SQLAlchemy.
|
||||||
|
|
||||||
|
Примеры:
|
||||||
|
>>> import json
|
||||||
|
>>> from decimal import Decimal
|
||||||
|
>>> from orm.topic import Topic
|
||||||
|
>>> json.dumps(Decimal("10.50"), cls=CustomJSONEncoder)
|
||||||
|
'"10.50"'
|
||||||
|
>>> topic = Topic(id=1, slug="test")
|
||||||
|
>>> json.dumps(topic, cls=CustomJSONEncoder)
|
||||||
|
'{"id": 1, "slug": "test", ...}'
|
||||||
|
"""
|
||||||
|
|
||||||
def default(self, obj):
|
def default(self, obj):
|
||||||
if isinstance(obj, Decimal):
|
if isinstance(obj, Decimal):
|
||||||
return str(obj)
|
return str(obj)
|
||||||
|
|
||||||
|
# Проверяем, есть ли у объекта метод dict() (как у моделей SQLAlchemy)
|
||||||
|
if hasattr(obj, "dict") and callable(obj.dict):
|
||||||
|
return obj.dict()
|
||||||
|
|
||||||
return super().default(obj)
|
return super().default(obj)
|
||||||
|
38
utils/html_wrapper.py
Normal file
38
utils/html_wrapper.py
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
"""
|
||||||
|
Модуль для обработки HTML-фрагментов
|
||||||
|
"""
|
||||||
|
|
||||||
|
def wrap_html_fragment(fragment: str) -> str:
|
||||||
|
"""
|
||||||
|
Оборачивает HTML-фрагмент в полную HTML-структуру для корректной обработки.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
fragment: HTML-фрагмент для обработки
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
str: Полный HTML-документ
|
||||||
|
|
||||||
|
Example:
|
||||||
|
>>> wrap_html_fragment("<p>Текст параграфа</p>")
|
||||||
|
'<!DOCTYPE html><html><head><meta charset="utf-8"></head><body><p>Текст параграфа</p></body></html>'
|
||||||
|
"""
|
||||||
|
if not fragment or not fragment.strip():
|
||||||
|
return fragment
|
||||||
|
|
||||||
|
# Проверяем, является ли контент полным HTML-документом
|
||||||
|
is_full_html = fragment.strip().startswith('<!DOCTYPE') or fragment.strip().startswith('<html')
|
||||||
|
|
||||||
|
# Если это фрагмент, оборачиваем его в полный HTML-документ
|
||||||
|
if not is_full_html:
|
||||||
|
return f"""<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title></title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
{fragment}
|
||||||
|
</body>
|
||||||
|
</html>"""
|
||||||
|
|
||||||
|
return fragment
|
@@ -13,7 +13,7 @@ def filter(record: logging.LogRecord):
|
|||||||
record.emoji = (
|
record.emoji = (
|
||||||
"🔍"
|
"🔍"
|
||||||
if record.levelno == logging.DEBUG
|
if record.levelno == logging.DEBUG
|
||||||
else "🖊️"
|
else "ℹ︎"
|
||||||
if record.levelno == logging.INFO
|
if record.levelno == logging.INFO
|
||||||
else "🚧"
|
else "🚧"
|
||||||
if record.levelno == logging.WARNING
|
if record.levelno == logging.WARNING
|
||||||
|
Reference in New Issue
Block a user