0.5.9-collections-crud+spa-fix
All checks were successful
Deploy on push / deploy (push) Successful in 6s

This commit is contained in:
Untone 2025-06-30 21:46:53 +03:00
parent 952b294345
commit 1e2c85e56a
14 changed files with 913 additions and 8 deletions

View File

@ -1,5 +1,49 @@
# Changelog # Changelog
## [0.5.9] - 2025-06-30
### Новая функциональность CRUD коллекций
- **НОВОЕ**: Полноценное управление коллекциями в админ-панели:
- **Новая вкладка "Коллекции"**: Отдельная секция в админ-панели для управления коллекциями
- **Полная CRUD функциональность**: Создание, редактирование, удаление коллекций
- **Подробная таблица**: ID, название, slug, описание, создатель, количество публикаций, даты создания и публикации
- **Клик для редактирования**: Нажатие на строку открывает модалку редактирования коллекции
- **Удаление с подтверждением**: Тонкая кнопка "×" для удаления с модальным окном подтверждения
- **Кнопка создания**: Возможность создания новых коллекций прямо из интерфейса
- **Серверная часть**:
- **GraphQL схема**: Новые queries, mutations и input types для коллекций
- **Резолверы**: Полный набор резолверов для CRUD операций (create_collection, update_collection, delete_collection, get_collections_all)
- **Авторизация**: Требуется роль editor или admin для создания/редактирования/удаления коллекций
- **Валидация прав**: Создатель коллекции или admin/editor могут редактировать коллекции
- **Cascading delete**: При удалении коллекции удаляются все связи с публикациями
- **Подсчет публикаций**: Автоматический подсчет количества публикаций в коллекции
- **Архитектурные улучшения**:
- **Модель Collection**: Добавлен relationship для created_by_author
- **Базы данных**: Включены таблицы Collection и ShoutCollection в создание схемы
- **Type safety**: Полная типизация для TypeScript в админ-панели
- **Переиспользование паттернов**: Следование существующим паттернам для единообразия
### Исправления SPA роутинга
- **КРИТИЧНО ИСПРАВЛЕНО**: Проблема с роутингом админ-панели:
- **Проблема**: Переходы на `/login`, `/admin` и другие маршруты возвращали "Not Found" вместо корректного отображения SPA
- **Причина**: Сервер искал физические файлы для каждого маршрута вместо делегирования клиентскому роутеру
- **Решение**:
- Добавлен SPA fallback обработчик `spa_handler()` в `main.py`
- Все неизвестные GET маршруты теперь возвращают `index.html`
- Клиентский роутер SolidJS получает управление и корректно обрабатывает маршрутизацию
- Разделены статические ресурсы (`/assets`) и SPA маршруты
- **Результат**: Админ-панель корректно работает на всех маршрутах (`/`, `/login`, `/admin`, `/admin/collections`)
- **Архитектурные улучшения**:
- **Правильное разделение обязанностей**: Сервер обслуживает API и статику, клиент управляет роутингом
- **Добавлен FileResponse импорт**: Для корректной отдачи HTML файлов
- **Оптимизированная конфигурация маршрутов**: Четкое разделение между API, статикой и SPA fallback
- **Совместимость с SolidJS Router**: Полная поддержка клиентского роутинга
## [0.5.8] - 2025-06-30 ## [0.5.8] - 2025-06-30
### Улучшения интерфейса публикаций ### Улучшения интерфейса публикаций

28
main.py
View File

@ -9,7 +9,7 @@ from starlette.applications import Starlette
from starlette.middleware import Middleware from starlette.middleware import Middleware
from starlette.middleware.cors import CORSMiddleware from starlette.middleware.cors import CORSMiddleware
from starlette.requests import Request from starlette.requests import Request
from starlette.responses import JSONResponse, Response from starlette.responses import FileResponse, JSONResponse, Response
from starlette.routing import Mount, Route from starlette.routing import Mount, Route
from starlette.staticfiles import StaticFiles from starlette.staticfiles import StaticFiles
@ -108,6 +108,25 @@ async def graphql_handler(request: Request) -> Response:
return JSONResponse({"error": str(e)}, status_code=500) return JSONResponse({"error": str(e)}, status_code=500)
async def spa_handler(request: Request) -> Response:
"""
Обработчик для SPA (Single Page Application) fallback.
Возвращает index.html для всех маршрутов, которые не найдены,
чтобы клиентский роутер (SolidJS) мог обработать маршрутинг.
Args:
request: Starlette Request объект
Returns:
FileResponse: ответ с содержимым index.html
"""
index_path = DIST_DIR / "index.html"
if index_path.exists():
return FileResponse(index_path, media_type="text/html")
return JSONResponse({"error": "Admin panel not built"}, status_code=404)
async def shutdown() -> None: async def shutdown() -> None:
"""Остановка сервера и освобождение ресурсов""" """Остановка сервера и освобождение ресурсов"""
logger.info("Остановка сервера") logger.info("Остановка сервера")
@ -232,7 +251,12 @@ app = Starlette(
# OAuth маршруты # OAuth маршруты
Route("/oauth/{provider}", oauth_login, methods=["GET"]), Route("/oauth/{provider}", oauth_login, methods=["GET"]),
Route("/oauth/{provider}/callback", oauth_callback, methods=["GET"]), Route("/oauth/{provider}/callback", oauth_callback, methods=["GET"]),
Mount("/", app=StaticFiles(directory=str(DIST_DIR), html=True)), # Статические файлы (CSS, JS, изображения)
Mount("/assets", app=StaticFiles(directory=str(DIST_DIR / "assets"))),
# Корневой маршрут для админ-панели
Route("/", spa_handler, methods=["GET"]),
# SPA fallback для всех остальных маршрутов
Route("/{path:path}", spa_handler, methods=["GET"]),
], ],
middleware=middleware, # Используем единый список middleware middleware=middleware, # Используем единый список middleware
lifespan=lifespan, lifespan=lifespan,

View File

@ -1,6 +1,7 @@
import time import time
from sqlalchemy import Column, ForeignKey, Integer, String from sqlalchemy import Column, ForeignKey, Integer, String
from sqlalchemy.orm import relationship
from services.db import BaseModel as Base from services.db import BaseModel as Base
@ -8,7 +9,6 @@ from services.db import BaseModel as Base
class ShoutCollection(Base): class ShoutCollection(Base):
__tablename__ = "shout_collection" __tablename__ = "shout_collection"
id = None # type: ignore
shout = Column(ForeignKey("shout.id"), primary_key=True) shout = Column(ForeignKey("shout.id"), primary_key=True)
collection = Column(ForeignKey("collection.id"), primary_key=True) collection = Column(ForeignKey("collection.id"), primary_key=True)
@ -23,3 +23,5 @@ class Collection(Base):
created_at = Column(Integer, default=lambda: int(time.time())) created_at = Column(Integer, default=lambda: int(time.time()))
created_by = Column(ForeignKey("author.id"), comment="Created By") created_by = Column(ForeignKey("author.id"), comment="Created By")
published_at = Column(Integer, default=lambda: int(time.time())) published_at = Column(Integer, default=lambda: int(time.time()))
created_by_author = relationship("Author", foreign_keys=[created_by])

View File

@ -1,6 +1,6 @@
{ {
"name": "publy-panel", "name": "publy-panel",
"version": "0.5.8", "version": "0.5.9",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",

View File

@ -9,6 +9,7 @@ import publyLogo from './assets/publy.svg?url'
import { logout } from './context/auth' import { logout } from './context/auth'
// Прямой импорт компонентов вместо ленивой загрузки // Прямой импорт компонентов вместо ленивой загрузки
import AuthorsRoute from './routes/authors' import AuthorsRoute from './routes/authors'
import CollectionsRoute from './routes/collections'
import CommunitiesRoute from './routes/communities' import CommunitiesRoute from './routes/communities'
import EnvRoute from './routes/env' import EnvRoute from './routes/env'
import ShoutsRoute from './routes/shouts' import ShoutsRoute from './routes/shouts'
@ -133,6 +134,12 @@ const AdminPage: Component<AdminPageProps> = (props) => {
> >
Сообщества Сообщества
</Button> </Button>
<Button
variant={activeTab() === 'collections' ? 'primary' : 'secondary'}
onClick={() => navigate('/admin/collections')}
>
Коллекции
</Button>
<Button <Button
variant={activeTab() === 'env' ? 'primary' : 'secondary'} variant={activeTab() === 'env' ? 'primary' : 'secondary'}
onClick={() => navigate('/admin/env')} onClick={() => navigate('/admin/env')}
@ -168,6 +175,10 @@ const AdminPage: Component<AdminPageProps> = (props) => {
<CommunitiesRoute onError={handleError} onSuccess={handleSuccess} /> <CommunitiesRoute onError={handleError} onSuccess={handleSuccess} />
</Show> </Show>
<Show when={activeTab() === 'collections'}>
<CollectionsRoute onError={handleError} onSuccess={handleSuccess} />
</Show>
<Show when={activeTab() === 'env'}> <Show when={activeTab() === 'env'}>
<EnvRoute onError={handleError} onSuccess={handleSuccess} /> <EnvRoute onError={handleError} onSuccess={handleSuccess} />
</Show> </Show>

View File

@ -61,3 +61,27 @@ export const DELETE_COMMUNITY_MUTATION = `
} }
} }
` `
export const CREATE_COLLECTION_MUTATION = `
mutation CreateCollection($collection_input: CollectionInput!) {
create_collection(collection_input: $collection_input) {
error
}
}
`
export const UPDATE_COLLECTION_MUTATION = `
mutation UpdateCollection($collection_input: CollectionInput!) {
update_collection(collection_input: $collection_input) {
error
}
}
`
export const DELETE_COLLECTION_MUTATION = `
mutation DeleteCollection($slug: String!) {
delete_collection(slug: $slug) {
error
}
}
`

View File

@ -154,3 +154,24 @@ export const GET_TOPICS_QUERY: string =
} }
} }
`.loc?.source.body || '' `.loc?.source.body || ''
export const GET_COLLECTIONS_QUERY: string =
gql`
query GetCollections {
get_collections_all {
id
slug
title
desc
pic
amount
created_at
published_at
created_by {
id
name
email
}
}
}
`.loc?.source.body || ''

View File

@ -0,0 +1,522 @@
import { Component, createSignal, For, onMount, Show } from 'solid-js'
import {
CREATE_COLLECTION_MUTATION,
DELETE_COLLECTION_MUTATION,
UPDATE_COLLECTION_MUTATION
} from '../graphql/mutations'
import { GET_COLLECTIONS_QUERY } from '../graphql/queries'
import styles from '../styles/Table.module.css'
import Button from '../ui/Button'
import Modal from '../ui/Modal'
/**
* Интерфейс для коллекции
*/
interface Collection {
id: number
slug: string
title: string
desc?: string
pic: string
amount: number
created_at: number
published_at?: number
created_by: {
id: number
name: string
email: string
}
}
interface CollectionsRouteProps {
onError: (error: string) => void
onSuccess: (message: string) => void
}
/**
* Компонент для управления коллекциями
*/
const CollectionsRoute: Component<CollectionsRouteProps> = (props) => {
const [collections, setCollections] = createSignal<Collection[]>([])
const [loading, setLoading] = createSignal(false)
const [editModal, setEditModal] = createSignal<{ show: boolean; collection: Collection | null }>({
show: false,
collection: null
})
const [deleteModal, setDeleteModal] = createSignal<{ show: boolean; collection: Collection | null }>({
show: false,
collection: null
})
const [createModal, setCreateModal] = createSignal(false)
// Форма для редактирования/создания
const [formData, setFormData] = createSignal({
slug: '',
title: '',
desc: '',
pic: ''
})
/**
* Загружает список всех коллекций
*/
const loadCollections = async () => {
setLoading(true)
try {
const response = await fetch('/graphql', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
query: GET_COLLECTIONS_QUERY
})
})
const result = await response.json()
if (result.errors) {
throw new Error(result.errors[0].message)
}
setCollections(result.data.get_collections_all || [])
} catch (error) {
props.onError(`Ошибка загрузки коллекций: ${(error as Error).message}`)
} finally {
setLoading(false)
}
}
/**
* Форматирует дату
*/
const formatDate = (timestamp: number): string => {
return new Date(timestamp * 1000).toLocaleDateString('ru-RU')
}
/**
* Открывает модалку редактирования
*/
const openEditModal = (collection: Collection) => {
setFormData({
slug: collection.slug,
title: collection.title,
desc: collection.desc || '',
pic: collection.pic
})
setEditModal({ show: true, collection })
}
/**
* Открывает модалку создания
*/
const openCreateModal = () => {
setFormData({
slug: '',
title: '',
desc: '',
pic: ''
})
setCreateModal(true)
}
/**
* Создает новую коллекцию
*/
const createCollection = async () => {
try {
const response = await fetch('/graphql', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
query: CREATE_COLLECTION_MUTATION,
variables: { collection_input: formData() }
})
})
const result = await response.json()
if (result.errors) {
throw new Error(result.errors[0].message)
}
if (result.data.create_collection.error) {
throw new Error(result.data.create_collection.error)
}
props.onSuccess('Коллекция успешно создана')
setCreateModal(false)
await loadCollections()
} catch (error) {
props.onError(`Ошибка создания коллекции: ${(error as Error).message}`)
}
}
/**
* Обновляет коллекцию
*/
const updateCollection = async () => {
try {
const response = await fetch('/graphql', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
query: UPDATE_COLLECTION_MUTATION,
variables: { collection_input: formData() }
})
})
const result = await response.json()
if (result.errors) {
throw new Error(result.errors[0].message)
}
if (result.data.update_collection.error) {
throw new Error(result.data.update_collection.error)
}
props.onSuccess('Коллекция успешно обновлена')
setEditModal({ show: false, collection: null })
await loadCollections()
} catch (error) {
props.onError(`Ошибка обновления коллекции: ${(error as Error).message}`)
}
}
/**
* Удаляет коллекцию
*/
const deleteCollection = async (slug: string) => {
try {
const response = await fetch('/graphql', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
query: DELETE_COLLECTION_MUTATION,
variables: { slug }
})
})
const result = await response.json()
if (result.errors) {
throw new Error(result.errors[0].message)
}
if (result.data.delete_collection.error) {
throw new Error(result.data.delete_collection.error)
}
props.onSuccess('Коллекция успешно удалена')
setDeleteModal({ show: false, collection: null })
await loadCollections()
} catch (error) {
props.onError(`Ошибка удаления коллекции: ${(error as Error).message}`)
}
}
// Загружаем коллекции при монтировании компонента
onMount(() => {
void loadCollections()
})
return (
<div class={styles.container}>
<div class={styles.header}>
<h2>Управление коллекциями</h2>
<div style={{ display: 'flex', gap: '10px' }}>
<Button onClick={openCreateModal} variant="primary">
Создать коллекцию
</Button>
<Button onClick={loadCollections} disabled={loading()}>
{loading() ? 'Загрузка...' : 'Обновить'}
</Button>
</div>
</div>
<Show
when={!loading()}
fallback={
<div class="loading-screen">
<div class="loading-spinner" />
<div>Загрузка коллекций...</div>
</div>
}
>
<table class={styles.table}>
<thead>
<tr>
<th>ID</th>
<th>Название</th>
<th>Slug</th>
<th>Описание</th>
<th>Создатель</th>
<th>Публикации</th>
<th>Создано</th>
<th>Опубликовано</th>
<th>Действия</th>
</tr>
</thead>
<tbody>
<For each={collections()}>
{(collection) => (
<tr
onClick={() => openEditModal(collection)}
style={{ cursor: 'pointer' }}
class={styles['clickable-row']}
>
<td>{collection.id}</td>
<td>{collection.title}</td>
<td>{collection.slug}</td>
<td>
<div
style={{
'max-width': '200px',
overflow: 'hidden',
'text-overflow': 'ellipsis',
'white-space': 'nowrap'
}}
title={collection.desc}
>
{collection.desc || '—'}
</div>
</td>
<td>{collection.created_by.name || collection.created_by.email}</td>
<td>{collection.amount}</td>
<td>{formatDate(collection.created_at)}</td>
<td>{collection.published_at ? formatDate(collection.published_at) : '—'}</td>
<td onClick={(e) => e.stopPropagation()}>
<button
onClick={(e) => {
e.stopPropagation()
setDeleteModal({ show: true, collection })
}}
class={styles['delete-button']}
title="Удалить коллекцию"
aria-label="Удалить коллекцию"
>
×
</button>
</td>
</tr>
)}
</For>
</tbody>
</table>
</Show>
{/* Модальное окно создания */}
<Modal isOpen={createModal()} onClose={() => setCreateModal(false)} title="Создание новой коллекции">
<div style={{ padding: '20px' }}>
<div style={{ 'margin-bottom': '16px' }}>
<label style={{ display: 'block', 'margin-bottom': '4px', 'font-weight': 'bold' }}>
Slug <span style={{ color: 'red' }}>*</span>
</label>
<input
type="text"
value={formData().slug}
onInput={(e) => setFormData((prev) => ({ ...prev, slug: e.target.value }))}
style={{
width: '100%',
padding: '8px',
border: '1px solid #ddd',
'border-radius': '4px'
}}
required
placeholder="my-collection"
/>
</div>
<div style={{ 'margin-bottom': '16px' }}>
<label style={{ display: 'block', 'margin-bottom': '4px', 'font-weight': 'bold' }}>
Название <span style={{ color: 'red' }}>*</span>
</label>
<input
type="text"
value={formData().title}
onInput={(e) => setFormData((prev) => ({ ...prev, title: e.target.value }))}
style={{
width: '100%',
padding: '8px',
border: '1px solid #ddd',
'border-radius': '4px'
}}
required
placeholder="Моя коллекция"
/>
</div>
<div style={{ 'margin-bottom': '16px' }}>
<label style={{ display: 'block', 'margin-bottom': '4px', 'font-weight': 'bold' }}>
Описание
</label>
<textarea
value={formData().desc}
onInput={(e) => setFormData((prev) => ({ ...prev, desc: e.target.value }))}
style={{
width: '100%',
padding: '8px',
border: '1px solid #ddd',
'border-radius': '4px',
'min-height': '80px',
resize: 'vertical'
}}
placeholder="Описание коллекции..."
/>
</div>
<div style={{ 'margin-bottom': '16px' }}>
<label style={{ display: 'block', 'margin-bottom': '4px', 'font-weight': 'bold' }}>
Картинка (URL)
</label>
<input
type="text"
value={formData().pic}
onInput={(e) => setFormData((prev) => ({ ...prev, pic: e.target.value }))}
style={{
width: '100%',
padding: '8px',
border: '1px solid #ddd',
'border-radius': '4px'
}}
placeholder="https://example.com/image.jpg"
/>
</div>
<div class={styles['modal-actions']}>
<Button variant="secondary" onClick={() => setCreateModal(false)}>
Отмена
</Button>
<Button variant="primary" onClick={createCollection}>
Создать
</Button>
</div>
</div>
</Modal>
{/* Модальное окно редактирования */}
<Modal
isOpen={editModal().show}
onClose={() => setEditModal({ show: false, collection: null })}
title={`Редактирование коллекции: ${editModal().collection?.title || ''}`}
>
<div style={{ padding: '20px' }}>
<div style={{ 'margin-bottom': '16px' }}>
<label style={{ display: 'block', 'margin-bottom': '4px', 'font-weight': 'bold' }}>Slug</label>
<input
type="text"
value={formData().slug}
onInput={(e) => setFormData((prev) => ({ ...prev, slug: e.target.value }))}
style={{
width: '100%',
padding: '8px',
border: '1px solid #ddd',
'border-radius': '4px'
}}
required
/>
</div>
<div style={{ 'margin-bottom': '16px' }}>
<label style={{ display: 'block', 'margin-bottom': '4px', 'font-weight': 'bold' }}>
Название
</label>
<input
type="text"
value={formData().title}
onInput={(e) => setFormData((prev) => ({ ...prev, title: e.target.value }))}
style={{
width: '100%',
padding: '8px',
border: '1px solid #ddd',
'border-radius': '4px'
}}
/>
</div>
<div style={{ 'margin-bottom': '16px' }}>
<label style={{ display: 'block', 'margin-bottom': '4px', 'font-weight': 'bold' }}>
Описание
</label>
<textarea
value={formData().desc}
onInput={(e) => setFormData((prev) => ({ ...prev, desc: e.target.value }))}
style={{
width: '100%',
padding: '8px',
border: '1px solid #ddd',
'border-radius': '4px',
'min-height': '80px',
resize: 'vertical'
}}
placeholder="Описание коллекции..."
/>
</div>
<div style={{ 'margin-bottom': '16px' }}>
<label style={{ display: 'block', 'margin-bottom': '4px', 'font-weight': 'bold' }}>
Картинка (URL)
</label>
<input
type="text"
value={formData().pic}
onInput={(e) => setFormData((prev) => ({ ...prev, pic: e.target.value }))}
style={{
width: '100%',
padding: '8px',
border: '1px solid #ddd',
'border-radius': '4px'
}}
placeholder="https://example.com/image.jpg"
/>
</div>
<div class={styles['modal-actions']}>
<Button variant="secondary" onClick={() => setEditModal({ show: false, collection: null })}>
Отмена
</Button>
<Button variant="primary" onClick={updateCollection}>
Сохранить
</Button>
</div>
</div>
</Modal>
{/* Модальное окно подтверждения удаления */}
<Modal
isOpen={deleteModal().show}
onClose={() => setDeleteModal({ show: false, collection: null })}
title="Подтверждение удаления"
>
<div>
<p>
Вы уверены, что хотите удалить коллекцию "<strong>{deleteModal().collection?.title}</strong>"?
</p>
<p class={styles['warning-text']}>
Это действие нельзя отменить. Все связи с публикациями будут удалены.
</p>
<div class={styles['modal-actions']}>
<Button variant="secondary" onClick={() => setDeleteModal({ show: false, collection: null })}>
Отмена
</Button>
<Button
variant="danger"
onClick={() => deleteModal().collection && deleteCollection(deleteModal().collection!.slug)}
>
Удалить
</Button>
</div>
</div>
</Modal>
</div>
)
}
export default CollectionsRoute

View File

@ -21,6 +21,7 @@ from resolvers.author import ( # search_authors,
load_authors_search, load_authors_search,
update_author, update_author,
) )
from resolvers.collection import get_collection, get_collections_all, get_collections_by_author
from resolvers.community import get_communities_all, get_community from resolvers.community import get_communities_all, get_community
from resolvers.draft import ( from resolvers.draft import (
create_draft, create_draft,
@ -100,6 +101,9 @@ __all__ = [
"get_author_follows_authors", "get_author_follows_authors",
"get_author_follows_topics", "get_author_follows_topics",
"get_authors_all", "get_authors_all",
"get_collection",
"get_collections_all",
"get_collections_by_author",
"get_communities_all", "get_communities_all",
# "search_authors", # "search_authors",
# community # community

234
resolvers/collection.py Normal file
View File

@ -0,0 +1,234 @@
from typing import Any
from graphql import GraphQLResolveInfo
from auth.decorators import editor_or_admin_required
from auth.orm import Author
from orm.collection import Collection, ShoutCollection
from services.db import local_session
from services.schema import mutation, query, type_collection
@query.field("get_collections_all")
async def get_collections_all(_: None, _info: GraphQLResolveInfo) -> list[Collection]:
"""Получает все коллекции"""
from sqlalchemy.orm import joinedload
with local_session() as session:
# Загружаем коллекции с проверкой существования авторов
collections = (
session.query(Collection)
.options(joinedload(Collection.created_by_author))
.join(
Author,
Collection.created_by == Author.id, # INNER JOIN - исключает коллекции без авторов
)
.filter(
Collection.created_by.isnot(None), # Дополнительная проверка
Author.id.isnot(None), # Проверяем что автор существует
)
.all()
)
# Дополнительная проверка валидности данных
valid_collections = []
for collection in collections:
if (
collection.created_by
and hasattr(collection, "created_by_author")
and collection.created_by_author
and collection.created_by_author.id
):
valid_collections.append(collection)
else:
from utils.logger import root_logger as logger
logger.warning(f"Исключена коллекция {collection.id} ({collection.slug}) - проблемы с автором")
return valid_collections
@query.field("get_collection")
async def get_collection(_: None, _info: GraphQLResolveInfo, slug: str) -> Collection | None:
"""Получает коллекцию по slug"""
q = local_session().query(Collection).where(Collection.slug == slug)
return q.first()
@query.field("get_collections_by_author")
async def get_collections_by_author(
_: None, _info: GraphQLResolveInfo, slug: str = "", user: str = "", author_id: int = 0
) -> list[Collection]:
"""Получает коллекции автора"""
with local_session() as session:
q = session.query(Collection)
if slug:
author = session.query(Author).where(Author.slug == slug).first()
if author:
q = q.where(Collection.created_by == author.id)
elif user:
author = session.query(Author).where(Author.id == user).first()
if author:
q = q.where(Collection.created_by == author.id)
elif author_id:
q = q.where(Collection.created_by == author_id)
return q.all()
@mutation.field("create_collection")
@editor_or_admin_required
async def create_collection(_: None, info: GraphQLResolveInfo, collection_input: dict[str, Any]) -> dict[str, Any]:
"""Создает новую коллекцию"""
# Получаем author_id из контекста через декоратор авторизации
request = info.context.get("request")
author_id = None
if hasattr(request, "auth") and request.auth and hasattr(request.auth, "author_id"):
author_id = request.auth.author_id
elif hasattr(request, "scope") and "auth" in request.scope:
auth_info = request.scope.get("auth", {})
if isinstance(auth_info, dict):
author_id = auth_info.get("author_id")
elif hasattr(auth_info, "author_id"):
author_id = auth_info.author_id
if not author_id:
return {"error": "Не удалось определить автора"}
try:
with local_session() as session:
# Исключаем created_by из входных данных - он всегда из токена
filtered_input = {k: v for k, v in collection_input.items() if k != "created_by"}
# Создаем новую коллекцию с обязательным created_by из токена
new_collection = Collection(created_by=author_id, **filtered_input)
session.add(new_collection)
session.commit()
return {"error": None}
except Exception as e:
return {"error": f"Ошибка создания коллекции: {e!s}"}
@mutation.field("update_collection")
@editor_or_admin_required
async def update_collection(_: None, info: GraphQLResolveInfo, collection_input: dict[str, Any]) -> dict[str, Any]:
"""Обновляет существующую коллекцию"""
# Получаем author_id из контекста через декоратор авторизации
request = info.context.get("request")
author_id = None
if hasattr(request, "auth") and request.auth and hasattr(request.auth, "author_id"):
author_id = request.auth.author_id
elif hasattr(request, "scope") and "auth" in request.scope:
auth_info = request.scope.get("auth", {})
if isinstance(auth_info, dict):
author_id = auth_info.get("author_id")
elif hasattr(auth_info, "author_id"):
author_id = auth_info.author_id
if not author_id:
return {"error": "Не удалось определить автора"}
slug = collection_input.get("slug")
if not slug:
return {"error": "Не указан slug коллекции"}
try:
with local_session() as session:
# Находим коллекцию для обновления
collection = session.query(Collection).filter(Collection.slug == slug).first()
if not collection:
return {"error": "Коллекция не найдена"}
# Проверяем права на редактирование (создатель или админ/редактор)
with local_session() as auth_session:
author = auth_session.query(Author).filter(Author.id == author_id).first()
user_roles = [role.id for role in author.roles] if author and author.roles else []
# Разрешаем редактирование если пользователь - создатель или имеет роль admin/editor
if collection.created_by != author_id and "admin" not in user_roles and "editor" not in user_roles:
return {"error": "Недостаточно прав для редактирования этой коллекции"}
# Обновляем поля коллекции
for key, value in collection_input.items():
# Исключаем изменение created_by - создатель не может быть изменен
if hasattr(collection, key) and key not in ["slug", "created_by"]:
setattr(collection, key, value)
session.commit()
return {"error": None}
except Exception as e:
return {"error": f"Ошибка обновления коллекции: {e!s}"}
@mutation.field("delete_collection")
@editor_or_admin_required
async def delete_collection(_: None, info: GraphQLResolveInfo, slug: str) -> dict[str, Any]:
"""Удаляет коллекцию"""
# Получаем author_id из контекста через декоратор авторизации
request = info.context.get("request")
author_id = None
if hasattr(request, "auth") and request.auth and hasattr(request.auth, "author_id"):
author_id = request.auth.author_id
elif hasattr(request, "scope") and "auth" in request.scope:
auth_info = request.scope.get("auth", {})
if isinstance(auth_info, dict):
author_id = auth_info.get("author_id")
elif hasattr(auth_info, "author_id"):
author_id = auth_info.author_id
if not author_id:
return {"error": "Не удалось определить автора"}
try:
with local_session() as session:
# Находим коллекцию для удаления
collection = session.query(Collection).filter(Collection.slug == slug).first()
if not collection:
return {"error": "Коллекция не найдена"}
# Проверяем права на удаление (создатель или админ/редактор)
with local_session() as auth_session:
author = auth_session.query(Author).filter(Author.id == author_id).first()
user_roles = [role.id for role in author.roles] if author and author.roles else []
# Разрешаем удаление если пользователь - создатель или имеет роль admin/editor
if collection.created_by != author_id and "admin" not in user_roles and "editor" not in user_roles:
return {"error": "Недостаточно прав для удаления этой коллекции"}
# Удаляем связи с публикациями
session.query(ShoutCollection).filter(ShoutCollection.collection == collection.id).delete()
# Удаляем коллекцию
session.delete(collection)
session.commit()
return {"error": None}
except Exception as e:
return {"error": f"Ошибка удаления коллекции: {e!s}"}
@type_collection.field("created_by")
def resolve_collection_created_by(obj: Collection, *_: Any) -> Author:
"""Резолвер для поля created_by коллекции"""
with local_session() as session:
if hasattr(obj, "created_by_author") and obj.created_by_author:
return obj.created_by_author
author = session.query(Author).filter(Author.id == obj.created_by).first()
if not author:
from utils.logger import root_logger as logger
logger.warning(f"Автор с ID {obj.created_by} не найден для коллекции {obj.id}")
return author
@type_collection.field("amount")
def resolve_collection_amount(obj: Collection, *_: Any) -> int:
"""Резолвер для количества публикаций в коллекции"""
with local_session() as session:
count = session.query(ShoutCollection).filter(ShoutCollection.collection == obj.id).count()
return count

View File

@ -120,6 +120,14 @@ input CommunityInput {
pic: String pic: String
} }
input CollectionInput {
id: Int
slug: String!
title: String!
desc: String
pic: String
}
# Auth inputs # Auth inputs
input LoginCredentials { input LoginCredentials {
email: String! email: String!

View File

@ -64,4 +64,9 @@ type Mutation {
create_community(community_input: CommunityInput!): CommonResult! create_community(community_input: CommunityInput!): CommonResult!
update_community(community_input: CommunityInput!): CommonResult! update_community(community_input: CommunityInput!): CommonResult!
delete_community(slug: String!): CommonResult! delete_community(slug: String!): CommonResult!
# collection
create_collection(collection_input: CollectionInput!): CommonResult!
update_collection(collection_input: CollectionInput!): CommonResult!
delete_collection(slug: String!): CommonResult!
} }

View File

@ -18,6 +18,11 @@ type Query {
get_communities_all: [Community] get_communities_all: [Community]
get_communities_by_author(slug: String, user: String, author_id: Int): [Community] get_communities_by_author(slug: String, user: String, author_id: Int): [Community]
# collection
get_collection(slug: String!): Collection
get_collections_all: [Collection]
get_collections_by_author(slug: String, user: String, author_id: Int): [Collection]
# follower # follower
get_shout_followers(slug: String, shout_id: Int): [Author] get_shout_followers(slug: String, shout_id: Int): [Author]
get_topic_followers(slug: String): [Author] get_topic_followers(slug: String): [Author]

View File

@ -9,13 +9,14 @@ query = QueryType()
mutation = MutationType() mutation = MutationType()
type_draft = ObjectType("Draft") type_draft = ObjectType("Draft")
type_community = ObjectType("Community") type_community = ObjectType("Community")
resolvers: List[SchemaBindable] = [query, mutation, type_draft, type_community] type_collection = ObjectType("Collection")
resolvers: List[SchemaBindable] = [query, mutation, type_draft, type_community, type_collection]
def create_all_tables() -> None: def create_all_tables() -> None:
"""Create all database tables in the correct order.""" """Create all database tables in the correct order."""
from auth.orm import Author, AuthorBookmark, AuthorFollower, AuthorRating from auth.orm import Author, AuthorBookmark, AuthorFollower, AuthorRating
from orm import community, draft, notification, reaction, shout, topic from orm import collection, community, draft, notification, reaction, shout, topic
# Порядок важен - сначала таблицы без внешних ключей, затем зависимые таблицы # Порядок важен - сначала таблицы без внешних ключей, затем зависимые таблицы
models_in_order = [ models_in_order = [
@ -43,8 +44,8 @@ def create_all_tables() -> None:
AuthorBookmark, # Зависит от Author AuthorBookmark, # Зависит от Author
notification.Notification, # Зависит от Author notification.Notification, # Зависит от Author
notification.NotificationSeen, # Зависит от Notification notification.NotificationSeen, # Зависит от Notification
# collection.Collection, collection.Collection, # Зависит от Author
# collection.ShoutCollection, collection.ShoutCollection, # Зависит от Collection и Shout
# invite.Invite # invite.Invite
] ]