This commit is contained in:
parent
5cfde98c22
commit
6c95b0575a
16
CHANGELOG.md
16
CHANGELOG.md
|
@ -6,6 +6,22 @@
|
|||
- Исправлена ошибка в функции `authenticate` в файле `auth/internal.py` - неправильное создание объекта `AuthState` и использование `TokenManager` вместо прямого создания `SessionTokenManager`
|
||||
- Исправлена ошибка в функции `admin_get_invites` в файле `resolvers/admin.py` - добавлено значение по умолчанию для поля `slug` в объектах `Author`, чтобы избежать ошибки "Cannot return null for non-nullable field Author.slug"
|
||||
|
||||
### Улучшения админ-панели для приглашений
|
||||
|
||||
- **ОБНОВЛЕНО**: Управление приглашениями в админ-панели:
|
||||
- **Удалена возможность создания приглашений**: Приглашения теперь создаются только через основной интерфейс пользователями
|
||||
- **Удалена возможность редактирования приглашений**: Статусы приглашений изменяются автоматически при принятии/отклонении
|
||||
- **Добавлено пакетное удаление**: Возможность выбрать несколько приглашений с помощью чекбоксов и удалить их одним действием
|
||||
- **Чекбоксы для выбора**: Добавлены чекбоксы для каждого приглашения и опция "Выбрать все"
|
||||
- **Кнопка пакетного удаления**: Появляется только когда выбрано хотя бы одно приглашение
|
||||
- **Счетчик выбранных**: Отображает количество выбранных для удаления приглашений
|
||||
- **Подтверждение удаления**: Модальное окно с запросом подтверждения перед пакетным удалением
|
||||
|
||||
- **Серверная часть**:
|
||||
- **Новая GraphQL мутация**: `adminDeleteInvitesBatch` для пакетного удаления приглашений
|
||||
- **Оптимизированная обработка**: Удаление нескольких приглашений в рамках одной транзакции
|
||||
- **Обработка ошибок**: Детальное логирование и возврат информации о количестве успешно удаленных приглашений
|
||||
|
||||
### Новая функциональность CRUD приглашений
|
||||
|
||||
- **НОВОЕ**: Полноценное управление приглашениями в админ-панели:
|
||||
|
|
|
@ -128,3 +128,12 @@ export const ADMIN_DELETE_INVITE_MUTATION = `
|
|||
}
|
||||
}
|
||||
`
|
||||
|
||||
export const ADMIN_DELETE_INVITES_BATCH_MUTATION = `
|
||||
mutation AdminDeleteInvitesBatch($invites: [AdminInviteIdInput!]!) {
|
||||
adminDeleteInvitesBatch(invites: $invites) {
|
||||
success
|
||||
error
|
||||
}
|
||||
}
|
||||
`
|
||||
|
|
|
@ -1,11 +1,9 @@
|
|||
import { Component, createSignal, For, onMount, Show } from 'solid-js'
|
||||
import {
|
||||
ADMIN_CREATE_INVITE_MUTATION,
|
||||
ADMIN_DELETE_INVITE_MUTATION,
|
||||
ADMIN_UPDATE_INVITE_MUTATION
|
||||
ADMIN_DELETE_INVITES_BATCH_MUTATION
|
||||
} from '../graphql/mutations'
|
||||
import { ADMIN_GET_INVITES_QUERY } from '../graphql/queries'
|
||||
import InviteEditModal from '../modals/InviteEditModal'
|
||||
import styles from '../styles/Table.module.css'
|
||||
import Button from '../ui/Button'
|
||||
import Modal from '../ui/Modal'
|
||||
|
@ -60,15 +58,18 @@ const InvitesRoute: Component<InvitesRouteProps> = (props) => {
|
|||
totalPages: 1
|
||||
})
|
||||
|
||||
const [editModal, setEditModal] = createSignal<{ show: boolean; invite: Invite | null }>({
|
||||
show: false,
|
||||
invite: null
|
||||
})
|
||||
// Состояние для выбранных приглашений
|
||||
const [selectedInvites, setSelectedInvites] = createSignal<Record<string, boolean>>({})
|
||||
const [selectAll, setSelectAll] = createSignal(false)
|
||||
|
||||
// Состояние для модального окна подтверждения удаления
|
||||
const [deleteModal, setDeleteModal] = createSignal<{ show: boolean; invite: Invite | null }>({
|
||||
show: false,
|
||||
invite: null
|
||||
})
|
||||
const [createModal, setCreateModal] = createSignal<{ show: boolean }>({
|
||||
|
||||
// Состояние для модального окна подтверждения пакетного удаления
|
||||
const [batchDeleteModal, setBatchDeleteModal] = createSignal<{ show: boolean }>({
|
||||
show: false
|
||||
})
|
||||
|
||||
|
@ -116,6 +117,10 @@ const InvitesRoute: Component<InvitesRouteProps> = (props) => {
|
|||
total: data.total || 0,
|
||||
totalPages: data.totalPages || 1
|
||||
})
|
||||
|
||||
// Сбрасываем выбранные приглашения при загрузке новых данных
|
||||
setSelectedInvites({})
|
||||
setSelectAll(false)
|
||||
} catch (error) {
|
||||
props.onError(`Ошибка загрузки приглашений: ${(error as Error).message}`)
|
||||
} finally {
|
||||
|
@ -161,66 +166,6 @@ const InvitesRoute: Component<InvitesRouteProps> = (props) => {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Открывает модалку создания
|
||||
*/
|
||||
const openCreateModal = () => {
|
||||
setCreateModal({ show: true })
|
||||
}
|
||||
|
||||
/**
|
||||
* Открывает модалку редактирования
|
||||
*/
|
||||
const openEditModal = (invite: Invite) => {
|
||||
setEditModal({ show: true, invite })
|
||||
}
|
||||
|
||||
/**
|
||||
* Обрабатывает сохранение приглашения (создание или обновление)
|
||||
*/
|
||||
const handleSaveInvite = async (inviteData: Partial<Invite>) => {
|
||||
try {
|
||||
const isCreating = !editModal().invite && createModal().show
|
||||
const mutation = isCreating ? ADMIN_CREATE_INVITE_MUTATION : ADMIN_UPDATE_INVITE_MUTATION
|
||||
|
||||
// Получаем токен авторизации из localStorage или cookie
|
||||
const authToken = localStorage.getItem('auth_token') || getAuthTokenFromCookie()
|
||||
console.log(`[InvitesRoute] Сохранение приглашения, токен: ${authToken ? 'найден' : 'не найден'}`)
|
||||
|
||||
const response = await fetch('/graphql', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: authToken ? `Bearer ${authToken}` : ''
|
||||
},
|
||||
body: JSON.stringify({
|
||||
query: mutation,
|
||||
variables: { invite: inviteData }
|
||||
})
|
||||
})
|
||||
|
||||
const result = await response.json()
|
||||
|
||||
if (result.errors) {
|
||||
throw new Error(result.errors[0].message)
|
||||
}
|
||||
|
||||
const resultData = isCreating ? result.data.adminCreateInvite : result.data.adminUpdateInvite
|
||||
if (!resultData.success) {
|
||||
throw new Error(resultData.error || 'Неизвестная ошибка')
|
||||
}
|
||||
|
||||
props.onSuccess(isCreating ? 'Приглашение успешно создано' : 'Приглашение успешно обновлено')
|
||||
setCreateModal({ show: false })
|
||||
setEditModal({ show: false, invite: null })
|
||||
await loadInvites(pagination().page)
|
||||
} catch (error) {
|
||||
props.onError(
|
||||
`Ошибка ${createModal().show ? 'создания' : 'обновления'} приглашения: ${(error as Error).message}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Удаляет приглашение
|
||||
*/
|
||||
|
@ -264,6 +209,104 @@ const InvitesRoute: Component<InvitesRouteProps> = (props) => {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Пакетное удаление выбранных приглашений
|
||||
*/
|
||||
const deleteSelectedInvites = async () => {
|
||||
try {
|
||||
const selected = selectedInvites()
|
||||
const invitesToDelete = invites().filter(invite => {
|
||||
const key = `${invite.inviter_id}-${invite.author_id}-${invite.shout_id}`
|
||||
return selected[key]
|
||||
})
|
||||
|
||||
if (invitesToDelete.length === 0) {
|
||||
props.onError('Не выбрано ни одного приглашения для удаления')
|
||||
return
|
||||
}
|
||||
|
||||
// Получаем токен авторизации из localStorage или cookie
|
||||
const authToken = localStorage.getItem('auth_token') || getAuthTokenFromCookie()
|
||||
console.log(`[InvitesRoute] Пакетное удаление приглашений, токен: ${authToken ? 'найден' : 'не найден'}`)
|
||||
|
||||
const response = await fetch('/graphql', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: authToken ? `Bearer ${authToken}` : ''
|
||||
},
|
||||
body: JSON.stringify({
|
||||
query: ADMIN_DELETE_INVITES_BATCH_MUTATION,
|
||||
variables: {
|
||||
invites: invitesToDelete.map(invite => ({
|
||||
inviter_id: invite.inviter_id,
|
||||
author_id: invite.author_id,
|
||||
shout_id: invite.shout_id
|
||||
}))
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
const result = await response.json()
|
||||
|
||||
if (result.errors) {
|
||||
throw new Error(result.errors[0].message)
|
||||
}
|
||||
|
||||
const deleteResult = result.data.adminDeleteInvitesBatch
|
||||
|
||||
if (!deleteResult.success) {
|
||||
throw new Error(deleteResult.error || 'Неизвестная ошибка')
|
||||
}
|
||||
|
||||
props.onSuccess(`Успешно удалено ${invitesToDelete.length} приглашений`)
|
||||
setBatchDeleteModal({ show: false })
|
||||
setSelectedInvites({})
|
||||
setSelectAll(false)
|
||||
await loadInvites(pagination().page)
|
||||
} catch (error) {
|
||||
props.onError(`Ошибка пакетного удаления приглашений: ${(error as Error).message}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Обработчик выбора/снятия выбора с приглашения
|
||||
*/
|
||||
const handleSelectInvite = (invite: Invite, checked: boolean) => {
|
||||
const key = `${invite.inviter_id}-${invite.author_id}-${invite.shout_id}`
|
||||
setSelectedInvites(prev => ({ ...prev, [key]: checked }))
|
||||
|
||||
// Если снимаем выбор с элемента, то снимаем и "выбрать все"
|
||||
if (!checked && selectAll()) {
|
||||
setSelectAll(false)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Обработчик выбора/снятия выбора со всех приглашений
|
||||
*/
|
||||
const handleSelectAll = (checked: boolean) => {
|
||||
setSelectAll(checked)
|
||||
|
||||
const newSelected: Record<string, boolean> = {}
|
||||
if (checked) {
|
||||
// Выбираем все приглашения на текущей странице
|
||||
invites().forEach(invite => {
|
||||
const key = `${invite.inviter_id}-${invite.author_id}-${invite.shout_id}`
|
||||
newSelected[key] = true
|
||||
})
|
||||
}
|
||||
|
||||
setSelectedInvites(newSelected)
|
||||
}
|
||||
|
||||
/**
|
||||
* Получает количество выбранных приглашений
|
||||
*/
|
||||
const getSelectedCount = () => {
|
||||
return Object.values(selectedInvites()).filter(Boolean).length
|
||||
}
|
||||
|
||||
// Загружаем приглашения при монтировании компонента
|
||||
onMount(() => {
|
||||
void loadInvites()
|
||||
|
@ -300,12 +343,40 @@ const InvitesRoute: Component<InvitesRouteProps> = (props) => {
|
|||
{loading() ? 'Загрузка...' : 'Обновить'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Button variant="primary" onClick={openCreateModal}>
|
||||
Создать приглашение
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Панель пакетных действий */}
|
||||
<Show when={!loading() && invites().length > 0}>
|
||||
<div class={styles['batch-actions']}>
|
||||
<div class={styles['select-all-container']}>
|
||||
<input
|
||||
type="checkbox"
|
||||
id="select-all"
|
||||
checked={selectAll()}
|
||||
onChange={(e) => handleSelectAll(e.target.checked)}
|
||||
class={styles.checkbox}
|
||||
/>
|
||||
<label for="select-all" class={styles['select-all-label']}>
|
||||
Выбрать все
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<Show when={getSelectedCount() > 0}>
|
||||
<div class={styles['selected-count']}>
|
||||
Выбрано: {getSelectedCount()}
|
||||
</div>
|
||||
|
||||
<button
|
||||
class={styles['batch-delete-button']}
|
||||
onClick={() => setBatchDeleteModal({ show: true })}
|
||||
title="Удалить выбранные приглашения"
|
||||
>
|
||||
Удалить выбранные
|
||||
</button>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={loading()}>
|
||||
<div class={styles.loading}>Загрузка приглашений...</div>
|
||||
</Show>
|
||||
|
@ -319,6 +390,7 @@ const InvitesRoute: Component<InvitesRouteProps> = (props) => {
|
|||
<table class={styles.table}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th class={styles['checkbox-column']}></th>
|
||||
<th>Приглашающий</th>
|
||||
<th>Приглашаемый</th>
|
||||
<th>Публикация</th>
|
||||
|
@ -330,12 +402,20 @@ const InvitesRoute: Component<InvitesRouteProps> = (props) => {
|
|||
<For each={invites()}>
|
||||
{(invite) => {
|
||||
const statusDisplay = getStatusDisplay(invite.status)
|
||||
const inviteKey = `${invite.inviter_id}-${invite.author_id}-${invite.shout_id}`
|
||||
const isSelected = selectedInvites()[inviteKey] || false
|
||||
|
||||
return (
|
||||
<tr
|
||||
class={styles.clickableRow}
|
||||
onClick={() => openEditModal(invite)}
|
||||
title="Нажмите для редактирования"
|
||||
>
|
||||
<tr>
|
||||
<td class={styles['checkbox-column']}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isSelected}
|
||||
onChange={(e) => handleSelectInvite(invite, e.target.checked)}
|
||||
class={styles.checkbox}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<div>
|
||||
<strong>{invite.inviter.name || 'Без имени'}</strong>
|
||||
|
@ -365,10 +445,7 @@ const InvitesRoute: Component<InvitesRouteProps> = (props) => {
|
|||
<td>
|
||||
<button
|
||||
class={styles.deleteButton}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setDeleteModal({ show: true, invite })
|
||||
}}
|
||||
onClick={() => setDeleteModal({ show: true, invite })}
|
||||
title="Удалить приглашение"
|
||||
>
|
||||
×
|
||||
|
@ -391,21 +468,6 @@ const InvitesRoute: Component<InvitesRouteProps> = (props) => {
|
|||
/>
|
||||
</Show>
|
||||
|
||||
{/* Модальные окна */}
|
||||
<InviteEditModal
|
||||
isOpen={createModal().show}
|
||||
invite={null}
|
||||
onClose={() => setCreateModal({ show: false })}
|
||||
onSave={handleSaveInvite}
|
||||
/>
|
||||
|
||||
<InviteEditModal
|
||||
isOpen={editModal().show}
|
||||
invite={editModal().invite}
|
||||
onClose={() => setEditModal({ show: false, invite: null })}
|
||||
onSave={handleSaveInvite}
|
||||
/>
|
||||
|
||||
{/* Модальное окно подтверждения удаления */}
|
||||
<Modal
|
||||
isOpen={deleteModal().show}
|
||||
|
@ -433,6 +495,33 @@ const InvitesRoute: Component<InvitesRouteProps> = (props) => {
|
|||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{/* Модальное окно подтверждения пакетного удаления */}
|
||||
<Modal
|
||||
isOpen={batchDeleteModal().show}
|
||||
onClose={() => setBatchDeleteModal({ show: false })}
|
||||
title="Подтверждение пакетного удаления"
|
||||
size="small"
|
||||
>
|
||||
<div class={styles.deleteConfirmation}>
|
||||
<p>
|
||||
Вы действительно хотите удалить <strong>{getSelectedCount()}</strong> выбранных приглашений?
|
||||
<br />
|
||||
Это действие нельзя отменить.
|
||||
</p>
|
||||
<div class={styles.modalActions}>
|
||||
<Button variant="secondary" onClick={() => setBatchDeleteModal({ show: false })}>
|
||||
Отмена
|
||||
</Button>
|
||||
<Button
|
||||
variant="danger"
|
||||
onClick={deleteSelectedInvites}
|
||||
>
|
||||
Удалить выбранные
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -207,3 +207,65 @@
|
|||
.delete-button:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
/* Стили для чекбоксов и пакетного удаления */
|
||||
.checkbox-column {
|
||||
width: 40px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.checkbox {
|
||||
cursor: pointer;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
.batch-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.selected-count {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.select-all-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-right: 15px;
|
||||
}
|
||||
|
||||
.select-all-label {
|
||||
margin-left: 5px;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Кнопка пакетного удаления */
|
||||
.batch-delete-button {
|
||||
background-color: #dc3545;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 6px 12px;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.batch-delete-button:hover {
|
||||
background-color: #c82333;
|
||||
}
|
||||
|
||||
.batch-delete-button:disabled {
|
||||
background-color: #e9a8ae;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
|
|
@ -991,3 +991,71 @@ async def admin_delete_invite(
|
|||
logger.error(f"Ошибка при удалении приглашения: {e!s}")
|
||||
msg = f"Не удалось удалить приглашение: {e!s}"
|
||||
raise GraphQLError(msg) from e
|
||||
|
||||
|
||||
@mutation.field("adminDeleteInvitesBatch")
|
||||
@admin_auth_required
|
||||
async def admin_delete_invites_batch(
|
||||
_: None, _info: GraphQLResolveInfo, invites: list[dict[str, Any]]
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Пакетное удаление приглашений
|
||||
|
||||
Args:
|
||||
_info: Контекст GraphQL запроса
|
||||
invites: Список приглашений для удаления (каждое содержит inviter_id, author_id, shout_id)
|
||||
|
||||
Returns:
|
||||
Результат операции
|
||||
"""
|
||||
try:
|
||||
if not invites:
|
||||
return {"success": False, "error": "Список приглашений для удаления пуст"}
|
||||
|
||||
deleted_count = 0
|
||||
errors = []
|
||||
|
||||
with local_session() as session:
|
||||
for invite_data in invites:
|
||||
inviter_id = invite_data.get("inviter_id")
|
||||
author_id = invite_data.get("author_id")
|
||||
shout_id = invite_data.get("shout_id")
|
||||
|
||||
if not all([inviter_id, author_id, shout_id]):
|
||||
errors.append(f"Неполные данные для приглашения: {invite_data}")
|
||||
continue
|
||||
|
||||
# Находим приглашение для удаления
|
||||
invite = (
|
||||
session.query(Invite)
|
||||
.filter(
|
||||
Invite.inviter_id == inviter_id,
|
||||
Invite.author_id == author_id,
|
||||
Invite.shout_id == shout_id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
|
||||
if not invite:
|
||||
errors.append(f"Приглашение с ID {inviter_id}-{author_id}-{shout_id} не найдено")
|
||||
continue
|
||||
|
||||
# Удаляем приглашение
|
||||
session.delete(invite)
|
||||
deleted_count += 1
|
||||
|
||||
# Сохраняем все изменения за раз
|
||||
if deleted_count > 0:
|
||||
session.commit()
|
||||
logger.info(f"Пакетное удаление: удалено {deleted_count} приглашений")
|
||||
|
||||
if errors:
|
||||
error_message = f"Удалено {deleted_count} из {len(invites)} приглашений. Ошибки: {', '.join(errors)}"
|
||||
return {"success": deleted_count > 0, "error": error_message}
|
||||
|
||||
return {"success": True, "error": None}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при пакетном удалении приглашений: {e!s}")
|
||||
msg = f"Не удалось удалить приглашения: {e!s}"
|
||||
raise GraphQLError(msg) from e
|
||||
|
|
|
@ -141,6 +141,13 @@ input AdminInviteUpdateInput {
|
|||
status: InviteStatus!
|
||||
}
|
||||
|
||||
# Входной тип для идентификации приглашения при пакетном удалении
|
||||
input AdminInviteIdInput {
|
||||
inviter_id: Int!
|
||||
author_id: Int!
|
||||
shout_id: Int!
|
||||
}
|
||||
|
||||
extend type Query {
|
||||
getEnvVariables: [EnvSection!]!
|
||||
# Запросы для управления пользователями
|
||||
|
@ -166,4 +173,5 @@ extend type Mutation {
|
|||
adminCreateInvite(invite: AdminInviteUpdateInput!): OperationResult!
|
||||
adminUpdateInvite(invite: AdminInviteUpdateInput!): OperationResult!
|
||||
adminDeleteInvite(inviter_id: Int!, author_id: Int!, shout_id: Int!): OperationResult!
|
||||
adminDeleteInvitesBatch(invites: [AdminInviteIdInput!]!): OperationResult!
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue
Block a user