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
|
||||
|
||||
## [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
|
||||
|
||||
### Улучшения интерфейса публикаций
|
||||
|
|
28
main.py
28
main.py
|
@ -9,7 +9,7 @@ from starlette.applications import Starlette
|
|||
from starlette.middleware import Middleware
|
||||
from starlette.middleware.cors import CORSMiddleware
|
||||
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.staticfiles import StaticFiles
|
||||
|
||||
|
@ -108,6 +108,25 @@ async def graphql_handler(request: Request) -> Response:
|
|||
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:
|
||||
"""Остановка сервера и освобождение ресурсов"""
|
||||
logger.info("Остановка сервера")
|
||||
|
@ -232,7 +251,12 @@ app = Starlette(
|
|||
# OAuth маршруты
|
||||
Route("/oauth/{provider}", oauth_login, 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
|
||||
lifespan=lifespan,
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
import time
|
||||
|
||||
from sqlalchemy import Column, ForeignKey, Integer, String
|
||||
from sqlalchemy.orm import relationship
|
||||
|
||||
from services.db import BaseModel as Base
|
||||
|
||||
|
@ -8,7 +9,6 @@ from services.db import BaseModel as Base
|
|||
class ShoutCollection(Base):
|
||||
__tablename__ = "shout_collection"
|
||||
|
||||
id = None # type: ignore
|
||||
shout = Column(ForeignKey("shout.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_by = Column(ForeignKey("author.id"), comment="Created By")
|
||||
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",
|
||||
"version": "0.5.8",
|
||||
"version": "0.5.9",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
|
|
@ -9,6 +9,7 @@ import publyLogo from './assets/publy.svg?url'
|
|||
import { logout } from './context/auth'
|
||||
// Прямой импорт компонентов вместо ленивой загрузки
|
||||
import AuthorsRoute from './routes/authors'
|
||||
import CollectionsRoute from './routes/collections'
|
||||
import CommunitiesRoute from './routes/communities'
|
||||
import EnvRoute from './routes/env'
|
||||
import ShoutsRoute from './routes/shouts'
|
||||
|
@ -133,6 +134,12 @@ const AdminPage: Component<AdminPageProps> = (props) => {
|
|||
>
|
||||
Сообщества
|
||||
</Button>
|
||||
<Button
|
||||
variant={activeTab() === 'collections' ? 'primary' : 'secondary'}
|
||||
onClick={() => navigate('/admin/collections')}
|
||||
>
|
||||
Коллекции
|
||||
</Button>
|
||||
<Button
|
||||
variant={activeTab() === 'env' ? 'primary' : 'secondary'}
|
||||
onClick={() => navigate('/admin/env')}
|
||||
|
@ -168,6 +175,10 @@ const AdminPage: Component<AdminPageProps> = (props) => {
|
|||
<CommunitiesRoute onError={handleError} onSuccess={handleSuccess} />
|
||||
</Show>
|
||||
|
||||
<Show when={activeTab() === 'collections'}>
|
||||
<CollectionsRoute onError={handleError} onSuccess={handleSuccess} />
|
||||
</Show>
|
||||
|
||||
<Show when={activeTab() === 'env'}>
|
||||
<EnvRoute onError={handleError} onSuccess={handleSuccess} />
|
||||
</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 || ''
|
||||
|
||||
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,
|
||||
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.draft import (
|
||||
create_draft,
|
||||
|
@ -100,6 +101,9 @@ __all__ = [
|
|||
"get_author_follows_authors",
|
||||
"get_author_follows_topics",
|
||||
"get_authors_all",
|
||||
"get_collection",
|
||||
"get_collections_all",
|
||||
"get_collections_by_author",
|
||||
"get_communities_all",
|
||||
# "search_authors",
|
||||
# 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
|
||||
}
|
||||
|
||||
input CollectionInput {
|
||||
id: Int
|
||||
slug: String!
|
||||
title: String!
|
||||
desc: String
|
||||
pic: String
|
||||
}
|
||||
|
||||
# Auth inputs
|
||||
input LoginCredentials {
|
||||
email: String!
|
||||
|
|
|
@ -64,4 +64,9 @@ type Mutation {
|
|||
create_community(community_input: CommunityInput!): CommonResult!
|
||||
update_community(community_input: CommunityInput!): 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_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
|
||||
get_shout_followers(slug: String, shout_id: Int): [Author]
|
||||
get_topic_followers(slug: String): [Author]
|
||||
|
|
|
@ -9,13 +9,14 @@ query = QueryType()
|
|||
mutation = MutationType()
|
||||
type_draft = ObjectType("Draft")
|
||||
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:
|
||||
"""Create all database tables in the correct order."""
|
||||
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 = [
|
||||
|
@ -43,8 +44,8 @@ def create_all_tables() -> None:
|
|||
AuthorBookmark, # Зависит от Author
|
||||
notification.Notification, # Зависит от Author
|
||||
notification.NotificationSeen, # Зависит от Notification
|
||||
# collection.Collection,
|
||||
# collection.ShoutCollection,
|
||||
collection.Collection, # Зависит от Author
|
||||
collection.ShoutCollection, # Зависит от Collection и Shout
|
||||
# invite.Invite
|
||||
]
|
||||
|
||||
|
|
Loading…
Reference in New Issue
Block a user