0.5.9-collections-crud+spa-fix
All checks were successful
Deploy on push / deploy (push) Successful in 6s
All checks were successful
Deploy on push / deploy (push) Successful in 6s
This commit is contained in:
parent
952b294345
commit
1e2c85e56a
44
CHANGELOG.md
44
CHANGELOG.md
|
@ -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
28
main.py
|
@ -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,
|
||||||
|
|
|
@ -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])
|
||||||
|
|
|
@ -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",
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
|
@ -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 || ''
|
||||||
|
|
522
panel/routes/collections.tsx
Normal file
522
panel/routes/collections.tsx
Normal 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
|
|
@ -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
234
resolvers/collection.py
Normal 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
|
|
@ -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!
|
||||||
|
|
|
@ -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!
|
||||||
}
|
}
|
||||||
|
|
|
@ -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]
|
||||||
|
|
|
@ -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
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
Loading…
Reference in New Issue
Block a user