invites-fix2
All checks were successful
Deploy on push / deploy (push) Successful in 7s

This commit is contained in:
Untone 2025-06-30 23:37:21 +03:00
parent 5cfde98c22
commit 6c95b0575a
6 changed files with 348 additions and 96 deletions

View File

@ -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 приглашений
- **НОВОЕ**: Полноценное управление приглашениями в админ-панели:

View File

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

View File

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

View File

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

View File

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

View File

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