This commit is contained in:
parent
41395eb7c6
commit
ab65fd4fd8
|
@ -48,9 +48,9 @@ Backend service providing GraphQL API for content management system with reactio
|
||||||
|
|
||||||
## 🛠️ Tech Stack
|
## 🛠️ Tech Stack
|
||||||
|
|
||||||
**Core:** Python 3.12 • GraphQL • PostgreSQL • Redis • txtai
|
**Core:** Python 3.12 • GraphQL • PostgreSQL • SQLAlchemy • JWT • Redis • txtai
|
||||||
**Server:** Starlette • Granian • Nginx
|
**Server:** Starlette • Granian • Nginx
|
||||||
**Tools:** SQLAlchemy • JWT • Pytest • Ruff
|
**Tools:** Pytest • MyPy • Ruff
|
||||||
**Deploy:** Dokku • Gitea • Glitchtip
|
**Deploy:** Dokku • Gitea • Glitchtip
|
||||||
|
|
||||||
## 🔧 Development
|
## 🔧 Development
|
||||||
|
|
|
@ -346,7 +346,11 @@ async def oauth_callback(request: Any) -> JSONResponse | RedirectResponse:
|
||||||
"provider": provider,
|
"provider": provider,
|
||||||
"profile": profile,
|
"profile": profile,
|
||||||
},
|
},
|
||||||
username=author.name,
|
username=author.name
|
||||||
|
if isinstance(author.name, str)
|
||||||
|
else str(author.name)
|
||||||
|
if author.name is not None
|
||||||
|
else None,
|
||||||
device_info={
|
device_info={
|
||||||
"user_agent": request.headers.get("user-agent"),
|
"user_agent": request.headers.get("user-agent"),
|
||||||
"ip": request.client.host if hasattr(request, "client") else None,
|
"ip": request.client.host if hasattr(request, "client") else None,
|
||||||
|
@ -357,9 +361,11 @@ async def oauth_callback(request: Any) -> JSONResponse | RedirectResponse:
|
||||||
state = request.query_params.get("state")
|
state = request.query_params.get("state")
|
||||||
state_data = await get_oauth_state(state) if state else None
|
state_data = await get_oauth_state(state) if state else None
|
||||||
redirect_uri = state_data.get("redirect_uri") if state_data else FRONTEND_URL
|
redirect_uri = state_data.get("redirect_uri") if state_data else FRONTEND_URL
|
||||||
|
if not isinstance(redirect_uri, str) or not redirect_uri:
|
||||||
|
redirect_uri = FRONTEND_URL
|
||||||
|
|
||||||
# Создаем ответ с редиректом
|
# Создаем ответ с редиректом
|
||||||
response = RedirectResponse(url=redirect_uri)
|
response = RedirectResponse(url=str(redirect_uri))
|
||||||
|
|
||||||
# Устанавливаем cookie с сессией
|
# Устанавливаем cookie с сессией
|
||||||
response.set_cookie(
|
response.set_cookie(
|
||||||
|
|
|
@ -17,10 +17,11 @@ export const ADMIN_GET_SHOUTS_QUERY: string =
|
||||||
cover
|
cover
|
||||||
cover_caption
|
cover_caption
|
||||||
media {
|
media {
|
||||||
type
|
|
||||||
url
|
url
|
||||||
|
title
|
||||||
body
|
body
|
||||||
caption
|
source
|
||||||
|
pic
|
||||||
}
|
}
|
||||||
seo
|
seo
|
||||||
created_at
|
created_at
|
||||||
|
@ -132,24 +133,22 @@ export const GET_COMMUNITIES_QUERY: string =
|
||||||
gql`
|
gql`
|
||||||
query GetCommunities {
|
query GetCommunities {
|
||||||
get_communities_all {
|
get_communities_all {
|
||||||
communities {
|
id
|
||||||
|
slug
|
||||||
|
name
|
||||||
|
desc
|
||||||
|
pic
|
||||||
|
created_at
|
||||||
|
created_by {
|
||||||
id
|
id
|
||||||
slug
|
|
||||||
name
|
name
|
||||||
desc
|
email
|
||||||
pic
|
slug
|
||||||
created_at
|
}
|
||||||
created_by {
|
stat {
|
||||||
id
|
shouts
|
||||||
name
|
followers
|
||||||
email
|
authors
|
||||||
slug
|
|
||||||
}
|
|
||||||
stat {
|
|
||||||
shouts
|
|
||||||
followers
|
|
||||||
authors
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -159,23 +158,21 @@ export const GET_TOPICS_QUERY: string =
|
||||||
gql`
|
gql`
|
||||||
query GetTopics {
|
query GetTopics {
|
||||||
get_topics_all {
|
get_topics_all {
|
||||||
topics {
|
id
|
||||||
id
|
slug
|
||||||
slug
|
title
|
||||||
title
|
body
|
||||||
body
|
pic
|
||||||
pic
|
community
|
||||||
community
|
parent_ids
|
||||||
parent_ids
|
stat {
|
||||||
stat {
|
shouts
|
||||||
shouts
|
followers
|
||||||
followers
|
authors
|
||||||
authors
|
comments
|
||||||
comments
|
|
||||||
}
|
|
||||||
oid
|
|
||||||
is_main
|
|
||||||
}
|
}
|
||||||
|
oid
|
||||||
|
is_main
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
`.loc?.source.body || ''
|
`.loc?.source.body || ''
|
||||||
|
@ -184,21 +181,19 @@ export const GET_COLLECTIONS_QUERY: string =
|
||||||
gql`
|
gql`
|
||||||
query GetCollections {
|
query GetCollections {
|
||||||
get_collections_all {
|
get_collections_all {
|
||||||
collections {
|
id
|
||||||
|
slug
|
||||||
|
title
|
||||||
|
desc
|
||||||
|
pic
|
||||||
|
amount
|
||||||
|
published_at
|
||||||
|
created_at
|
||||||
|
created_by {
|
||||||
id
|
id
|
||||||
|
name
|
||||||
|
email
|
||||||
slug
|
slug
|
||||||
title
|
|
||||||
desc
|
|
||||||
pic
|
|
||||||
amount
|
|
||||||
published_at
|
|
||||||
created_at
|
|
||||||
created_by {
|
|
||||||
id
|
|
||||||
name
|
|
||||||
email
|
|
||||||
slug
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -59,7 +59,10 @@ async def admin_get_users(
|
||||||
|
|
||||||
# Вычисляем информацию о пагинации
|
# Вычисляем информацию о пагинации
|
||||||
per_page = limit
|
per_page = limit
|
||||||
total_pages = ceil(total_count / per_page)
|
if total_count is None or per_page in (None, 0):
|
||||||
|
total_pages = 1
|
||||||
|
else:
|
||||||
|
total_pages = ceil(total_count / per_page)
|
||||||
current_page = (offset // per_page) + 1 if per_page > 0 else 1
|
current_page = (offset // per_page) + 1 if per_page > 0 else 1
|
||||||
|
|
||||||
# Применяем пагинацию
|
# Применяем пагинацию
|
||||||
|
@ -420,7 +423,10 @@ async def admin_get_shouts(
|
||||||
|
|
||||||
# Вычисляем информацию о пагинации
|
# Вычисляем информацию о пагинации
|
||||||
per_page = limit
|
per_page = limit
|
||||||
total_pages = ceil(total_count / per_page)
|
if total_count is None or per_page in (None, 0):
|
||||||
|
total_pages = 1
|
||||||
|
else:
|
||||||
|
total_pages = ceil(total_count / per_page)
|
||||||
current_page = (offset // per_page) + 1 if per_page > 0 else 1
|
current_page = (offset // per_page) + 1 if per_page > 0 else 1
|
||||||
|
|
||||||
# Применяем пагинацию и сортировку (новые сверху)
|
# Применяем пагинацию и сортировку (новые сверху)
|
||||||
|
@ -430,12 +436,20 @@ async def admin_get_shouts(
|
||||||
if status == "all":
|
if status == "all":
|
||||||
# Для статуса "all" используем простой запрос без статистики
|
# Для статуса "all" используем простой запрос без статистики
|
||||||
q = q.limit(limit).offset(offset)
|
q = q.limit(limit).offset(offset)
|
||||||
shouts_result = session.execute(q).unique().all()
|
shouts_result: list[Any] = session.execute(q).unique().all()
|
||||||
shouts_data = []
|
shouts_data = []
|
||||||
|
|
||||||
for row in shouts_result:
|
for row in shouts_result:
|
||||||
# Get the Shout object from the row
|
# Get the Shout object from the row
|
||||||
shout = row[0] if isinstance(row, tuple) else row.Shout if hasattr(row, "Shout") else row
|
if isinstance(row, tuple):
|
||||||
|
shout = row[0]
|
||||||
|
elif hasattr(row, "Shout"):
|
||||||
|
shout = row.Shout
|
||||||
|
elif isinstance(row, dict) and "id" in row:
|
||||||
|
shout = row
|
||||||
|
else:
|
||||||
|
shout = row
|
||||||
|
|
||||||
# Обрабатываем поле media
|
# Обрабатываем поле media
|
||||||
media_data = []
|
media_data = []
|
||||||
if hasattr(shout, "media") and shout.media:
|
if hasattr(shout, "media") and shout.media:
|
||||||
|
@ -452,49 +466,90 @@ async def admin_get_shouts(
|
||||||
media_data = [shout.media]
|
media_data = [shout.media]
|
||||||
|
|
||||||
shout_dict = {
|
shout_dict = {
|
||||||
"id": shout.id,
|
"id": getattr(shout, "id", None) if not isinstance(shout, dict) else shout.get("id"),
|
||||||
"title": shout.title,
|
"title": getattr(shout, "title", None) if not isinstance(shout, dict) else shout.get("title"),
|
||||||
"slug": shout.slug,
|
"slug": getattr(shout, "slug", None) if not isinstance(shout, dict) else shout.get("slug"),
|
||||||
"body": shout.body,
|
"body": getattr(shout, "body", None) if not isinstance(shout, dict) else shout.get("body"),
|
||||||
"lead": shout.lead,
|
"lead": getattr(shout, "lead", None) if not isinstance(shout, dict) else shout.get("lead"),
|
||||||
"subtitle": shout.subtitle,
|
"subtitle": getattr(shout, "subtitle", None)
|
||||||
"layout": shout.layout,
|
if not isinstance(shout, dict)
|
||||||
"lang": shout.lang,
|
else shout.get("subtitle"),
|
||||||
"cover": shout.cover,
|
"layout": getattr(shout, "layout", None)
|
||||||
"cover_caption": shout.cover_caption,
|
if not isinstance(shout, dict)
|
||||||
|
else shout.get("layout"),
|
||||||
|
"lang": getattr(shout, "lang", None) if not isinstance(shout, dict) else shout.get("lang"),
|
||||||
|
"cover": getattr(shout, "cover", None) if not isinstance(shout, dict) else shout.get("cover"),
|
||||||
|
"cover_caption": getattr(shout, "cover_caption", None)
|
||||||
|
if not isinstance(shout, dict)
|
||||||
|
else shout.get("cover_caption"),
|
||||||
"media": media_data,
|
"media": media_data,
|
||||||
"seo": shout.seo,
|
"seo": getattr(shout, "seo", None) if not isinstance(shout, dict) else shout.get("seo"),
|
||||||
"created_at": shout.created_at,
|
"created_at": getattr(shout, "created_at", None)
|
||||||
"updated_at": shout.updated_at,
|
if not isinstance(shout, dict)
|
||||||
"published_at": shout.published_at,
|
else shout.get("created_at"),
|
||||||
"featured_at": shout.featured_at,
|
"updated_at": getattr(shout, "updated_at", None)
|
||||||
"deleted_at": shout.deleted_at,
|
if not isinstance(shout, dict)
|
||||||
|
else shout.get("updated_at"),
|
||||||
|
"published_at": getattr(shout, "published_at", None)
|
||||||
|
if not isinstance(shout, dict)
|
||||||
|
else shout.get("published_at"),
|
||||||
|
"featured_at": getattr(shout, "featured_at", None)
|
||||||
|
if not isinstance(shout, dict)
|
||||||
|
else shout.get("featured_at"),
|
||||||
|
"deleted_at": getattr(shout, "deleted_at", None)
|
||||||
|
if not isinstance(shout, dict)
|
||||||
|
else shout.get("deleted_at"),
|
||||||
"created_by": {
|
"created_by": {
|
||||||
"id": shout.created_by,
|
"id": getattr(shout, "created_by", None)
|
||||||
|
if not isinstance(shout, dict)
|
||||||
|
else shout.get("created_by"),
|
||||||
"email": "unknown", # Заполним при необходимости
|
"email": "unknown", # Заполним при необходимости
|
||||||
"name": "unknown",
|
"name": "unknown",
|
||||||
},
|
},
|
||||||
"updated_by": None, # Заполним при необходимости
|
"updated_by": None, # Заполним при необходимости
|
||||||
"deleted_by": None, # Заполним при необходимости
|
"deleted_by": None, # Заполним при необходимости
|
||||||
"community": {
|
"community": {
|
||||||
"id": shout.community,
|
"id": getattr(shout, "community", None)
|
||||||
|
if not isinstance(shout, dict)
|
||||||
|
else shout.get("community"),
|
||||||
"name": "unknown", # Заполним при необходимости
|
"name": "unknown", # Заполним при необходимости
|
||||||
},
|
},
|
||||||
"authors": [
|
"authors": [
|
||||||
{"id": author.id, "email": author.email, "name": author.name, "slug": author.slug}
|
{
|
||||||
for author in (shout.authors or [])
|
"id": getattr(author, "id", None),
|
||||||
|
"email": getattr(author, "email", None),
|
||||||
|
"name": getattr(author, "name", None),
|
||||||
|
"slug": getattr(author, "slug", None),
|
||||||
|
}
|
||||||
|
for author in (
|
||||||
|
getattr(shout, "authors", [])
|
||||||
|
if not isinstance(shout, dict)
|
||||||
|
else shout.get("authors", [])
|
||||||
|
)
|
||||||
],
|
],
|
||||||
"topics": [
|
"topics": [
|
||||||
{"id": topic.id, "title": topic.title, "slug": topic.slug} for topic in (shout.topics or [])
|
{
|
||||||
|
"id": getattr(topic, "id", None),
|
||||||
|
"title": getattr(topic, "title", None),
|
||||||
|
"slug": getattr(topic, "slug", None),
|
||||||
|
}
|
||||||
|
for topic in (
|
||||||
|
getattr(shout, "topics", []) if not isinstance(shout, dict) else shout.get("topics", [])
|
||||||
|
)
|
||||||
],
|
],
|
||||||
"version_of": shout.version_of,
|
"version_of": getattr(shout, "version_of", None)
|
||||||
"draft": shout.draft,
|
if not isinstance(shout, dict)
|
||||||
|
else shout.get("version_of"),
|
||||||
|
"draft": getattr(shout, "draft", None) if not isinstance(shout, dict) else shout.get("draft"),
|
||||||
"stat": None, # Заполним при необходимости
|
"stat": None, # Заполним при необходимости
|
||||||
}
|
}
|
||||||
shouts_data.append(shout_dict)
|
shouts_data.append(shout_dict)
|
||||||
else:
|
else:
|
||||||
# Используем существующую функцию для получения публикаций со статистикой
|
# Используем существующую функцию для получения публикаций со статистикой
|
||||||
shouts_data = get_shouts_with_links(info, q, limit, offset)
|
shouts_result = get_shouts_with_links(info, q, limit, offset)
|
||||||
|
shouts_data = [
|
||||||
|
s.dict() if hasattr(s, "dict") else dict(s) if hasattr(s, "_mapping") else s for s in shouts_result
|
||||||
|
]
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"shouts": shouts_data,
|
"shouts": shouts_data,
|
||||||
|
@ -702,7 +757,10 @@ async def admin_get_invites(
|
||||||
|
|
||||||
# Вычисляем информацию о пагинации
|
# Вычисляем информацию о пагинации
|
||||||
per_page = limit
|
per_page = limit
|
||||||
total_pages = ceil(total_count / per_page)
|
if total_count is None or per_page in (None, 0):
|
||||||
|
total_pages = 1
|
||||||
|
else:
|
||||||
|
total_pages = ceil(total_count / per_page)
|
||||||
current_page = (offset // per_page) + 1 if per_page > 0 else 1
|
current_page = (offset // per_page) + 1 if per_page > 0 else 1
|
||||||
|
|
||||||
# Применяем пагинацию и сортировку (по ID приглашающего, затем автора, затем публикации)
|
# Применяем пагинацию и сортировку (по ID приглашающего, затем автора, затем публикации)
|
||||||
|
|
|
@ -143,7 +143,7 @@ async def get_authors_with_stats(
|
||||||
default_sort_applied = True
|
default_sort_applied = True
|
||||||
else:
|
else:
|
||||||
# If order is not a stats field, treat it as a regular field
|
# If order is not a stats field, treat it as a regular field
|
||||||
column = getattr(Author, order_value, None)
|
column = getattr(Author, order_value or "", "")
|
||||||
if column:
|
if column:
|
||||||
base_query = base_query.order_by(sql_desc(column))
|
base_query = base_query.order_by(sql_desc(column))
|
||||||
logger.debug(f"Applying sorting by column: {order_value}")
|
logger.debug(f"Applying sorting by column: {order_value}")
|
||||||
|
@ -153,9 +153,11 @@ async def get_authors_with_stats(
|
||||||
else:
|
else:
|
||||||
# Regular sorting by fields
|
# Regular sorting by fields
|
||||||
for field, direction in by.items():
|
for field, direction in by.items():
|
||||||
|
if field is None:
|
||||||
|
continue
|
||||||
column = getattr(Author, field, None)
|
column = getattr(Author, field, None)
|
||||||
if column:
|
if column:
|
||||||
if direction.lower() == "desc":
|
if isinstance(direction, str) and direction.lower() == "desc":
|
||||||
base_query = base_query.order_by(sql_desc(column))
|
base_query = base_query.order_by(sql_desc(column))
|
||||||
else:
|
else:
|
||||||
base_query = base_query.order_by(column)
|
base_query = base_query.order_by(column)
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
from typing import Any
|
from typing import Any, Optional
|
||||||
|
|
||||||
from graphql import GraphQLResolveInfo
|
from graphql import GraphQLResolveInfo
|
||||||
|
|
||||||
|
@ -211,8 +211,8 @@ async def delete_collection(_: None, info: GraphQLResolveInfo, slug: str) -> dic
|
||||||
|
|
||||||
|
|
||||||
@type_collection.field("created_by")
|
@type_collection.field("created_by")
|
||||||
def resolve_collection_created_by(obj: Collection, *_: Any) -> Author:
|
def resolve_collection_created_by(obj: Collection, *_: Any) -> Optional[Author]:
|
||||||
"""Резолвер для поля created_by коллекции"""
|
"""Резолвер для поля created_by коллекции (может вернуть None)"""
|
||||||
with local_session() as session:
|
with local_session() as session:
|
||||||
if hasattr(obj, "created_by_author") and obj.created_by_author:
|
if hasattr(obj, "created_by_author") and obj.created_by_author:
|
||||||
return obj.created_by_author
|
return obj.created_by_author
|
||||||
|
|
18
settings.py
18
settings.py
|
@ -51,6 +51,22 @@ OAUTH_CLIENTS = {
|
||||||
"id": os.getenv("FACEBOOK_CLIENT_ID", ""),
|
"id": os.getenv("FACEBOOK_CLIENT_ID", ""),
|
||||||
"key": os.getenv("FACEBOOK_CLIENT_SECRET", ""),
|
"key": os.getenv("FACEBOOK_CLIENT_SECRET", ""),
|
||||||
},
|
},
|
||||||
|
"X": {
|
||||||
|
"id": os.getenv("X_CLIENT_ID", ""),
|
||||||
|
"key": os.getenv("X_CLIENT_SECRET", ""),
|
||||||
|
},
|
||||||
|
"YANDEX": {
|
||||||
|
"id": os.getenv("YANDEX_CLIENT_ID", ""),
|
||||||
|
"key": os.getenv("YANDEX_CLIENT_SECRET", ""),
|
||||||
|
},
|
||||||
|
"VK": {
|
||||||
|
"id": os.getenv("VK_CLIENT_ID", ""),
|
||||||
|
"key": os.getenv("VK_CLIENT_SECRET", ""),
|
||||||
|
},
|
||||||
|
"TELEGRAM": {
|
||||||
|
"id": os.getenv("TELEGRAM_CLIENT_ID", ""),
|
||||||
|
"key": os.getenv("TELEGRAM_CLIENT_SECRET", ""),
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
# Настройки базы данных
|
# Настройки базы данных
|
||||||
|
@ -62,7 +78,7 @@ JWT_ACCESS_TOKEN_EXPIRE_MINUTES = 30
|
||||||
JWT_REFRESH_TOKEN_EXPIRE_DAYS = 30
|
JWT_REFRESH_TOKEN_EXPIRE_DAYS = 30
|
||||||
|
|
||||||
# Настройки для HTTP cookies (используется в auth middleware)
|
# Настройки для HTTP cookies (используется в auth middleware)
|
||||||
SESSION_COOKIE_NAME = "auth_token"
|
SESSION_COOKIE_NAME = "session_token"
|
||||||
SESSION_COOKIE_SECURE = True # Включаем для HTTPS
|
SESSION_COOKIE_SECURE = True # Включаем для HTTPS
|
||||||
SESSION_COOKIE_HTTPONLY = True
|
SESSION_COOKIE_HTTPONLY = True
|
||||||
SESSION_COOKIE_SAMESITE: Literal["lax", "strict", "none"] = "lax"
|
SESSION_COOKIE_SAMESITE: Literal["lax", "strict", "none"] = "lax"
|
||||||
|
|
Loading…
Reference in New Issue
Block a user