diff --git a/CHANGELOG.md b/CHANGELOG.md index 6bf4a58c..47d19610 100644 --- a/CHANGELOG.md +++ b/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 приглашений - **НОВОЕ**: Полноценное управление приглашениями в админ-панели: diff --git a/panel/graphql/mutations.ts b/panel/graphql/mutations.ts index 8864d3b1..0550bdcd 100644 --- a/panel/graphql/mutations.ts +++ b/panel/graphql/mutations.ts @@ -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 + } + } +` diff --git a/panel/routes/invites.tsx b/panel/routes/invites.tsx index b460eaf8..e6cac224 100644 --- a/panel/routes/invites.tsx +++ b/panel/routes/invites.tsx @@ -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 = (props) => { totalPages: 1 }) - const [editModal, setEditModal] = createSignal<{ show: boolean; invite: Invite | null }>({ - show: false, - invite: null - }) + // Состояние для выбранных приглашений + const [selectedInvites, setSelectedInvites] = createSignal>({}) + 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 = (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 = (props) => { } } - /** - * Открывает модалку создания - */ - const openCreateModal = () => { - setCreateModal({ show: true }) - } - - /** - * Открывает модалку редактирования - */ - const openEditModal = (invite: Invite) => { - setEditModal({ show: true, invite }) - } - - /** - * Обрабатывает сохранение приглашения (создание или обновление) - */ - const handleSaveInvite = async (inviteData: Partial) => { - 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 = (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 = {} + 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 = (props) => { {loading() ? 'Загрузка...' : 'Обновить'} - - + {/* Панель пакетных действий */} + 0}> +
+
+ handleSelectAll(e.target.checked)} + class={styles.checkbox} + /> + +
+ + 0}> +
+ Выбрано: {getSelectedCount()} +
+ + +
+
+
+
Загрузка приглашений...
@@ -319,6 +390,7 @@ const InvitesRoute: Component = (props) => { + @@ -330,12 +402,20 @@ const InvitesRoute: Component = (props) => { {(invite) => { const statusDisplay = getStatusDisplay(invite.status) + const inviteKey = `${invite.inviter_id}-${invite.author_id}-${invite.shout_id}` + const isSelected = selectedInvites()[inviteKey] || false + return ( - openEditModal(invite)} - title="Нажмите для редактирования" - > + +
Приглашающий Приглашаемый Публикация
+ handleSelectInvite(invite, e.target.checked)} + class={styles.checkbox} + onClick={(e) => e.stopPropagation()} + /> +
{invite.inviter.name || 'Без имени'} @@ -365,10 +445,7 @@ const InvitesRoute: Component = (props) => {
+ + + + ) } diff --git a/panel/styles/Table.module.css b/panel/styles/Table.module.css index 23b13da2..65b4d705 100644 --- a/panel/styles/Table.module.css +++ b/panel/styles/Table.module.css @@ -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; +} diff --git a/resolvers/admin.py b/resolvers/admin.py index db244c78..8d91ce2d 100644 --- a/resolvers/admin.py +++ b/resolvers/admin.py @@ -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 diff --git a/schema/admin.graphql b/schema/admin.graphql index e45e23fc..d04fe438 100644 --- a/schema/admin.graphql +++ b/schema/admin.graphql @@ -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! }