schema-fix
All checks were successful
Deploy on push / deploy (push) Successful in 6s

This commit is contained in:
Untone 2025-06-30 22:43:32 +03:00
parent 41395eb7c6
commit ab65fd4fd8
7 changed files with 163 additions and 86 deletions

View File

@ -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

View File

@ -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(

View File

@ -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,7 +133,6 @@ export const GET_COMMUNITIES_QUERY: string =
gql` gql`
query GetCommunities { query GetCommunities {
get_communities_all { get_communities_all {
communities {
id id
slug slug
name name
@ -152,14 +152,12 @@ export const GET_COMMUNITIES_QUERY: string =
} }
} }
} }
}
`.loc?.source.body || '' `.loc?.source.body || ''
export const GET_TOPICS_QUERY: string = 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
@ -177,14 +175,12 @@ export const GET_TOPICS_QUERY: string =
is_main is_main
} }
} }
}
`.loc?.source.body || '' `.loc?.source.body || ''
export const GET_COLLECTIONS_QUERY: string = export const GET_COLLECTIONS_QUERY: string =
gql` gql`
query GetCollections { query GetCollections {
get_collections_all { get_collections_all {
collections {
id id
slug slug
title title
@ -201,7 +197,6 @@ export const GET_COLLECTIONS_QUERY: string =
} }
} }
} }
}
`.loc?.source.body || '' `.loc?.source.body || ''
export const ADMIN_GET_INVITES_QUERY: string = export const ADMIN_GET_INVITES_QUERY: string =

View File

@ -59,6 +59,9 @@ async def admin_get_users(
# Вычисляем информацию о пагинации # Вычисляем информацию о пагинации
per_page = limit per_page = limit
if total_count is None or per_page in (None, 0):
total_pages = 1
else:
total_pages = ceil(total_count / per_page) 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,6 +423,9 @@ async def admin_get_shouts(
# Вычисляем информацию о пагинации # Вычисляем информацию о пагинации
per_page = limit per_page = limit
if total_count is None or per_page in (None, 0):
total_pages = 1
else:
total_pages = ceil(total_count / per_page) 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,6 +757,9 @@ async def admin_get_invites(
# Вычисляем информацию о пагинации # Вычисляем информацию о пагинации
per_page = limit per_page = limit
if total_count is None or per_page in (None, 0):
total_pages = 1
else:
total_pages = ceil(total_count / per_page) 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

View File

@ -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)

View File

@ -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

View File

@ -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"