diff --git a/bundles/processing/App.tsx b/bundles/processing/App.tsx index a41255343..cc7276dd6 100644 --- a/bundles/processing/App.tsx +++ b/bundles/processing/App.tsx @@ -1,14 +1,17 @@ import * as React from 'react'; +import { useQuery } from 'react-query'; import { Route, Switch } from 'react-router'; import { Accent, AppContainer, Theme } from '@spyrothon/sparx'; import { useConstants } from '@common/Constants'; import { usePermission } from '@public/api/helpers/auth'; +import APIClient from '@public/apiv2/APIClient'; import { setAPIRoot } from '@public/apiv2/HTTPUtils'; import { ProcessingSocket } from '@public/apiv2/sockets/ProcessingSocket'; +import { loadMe } from './modules/auth/AuthStore'; import { loadDonations } from './modules/donations/DonationsStore'; -import useProcessingStore from './modules/processing/ProcessingStore'; +import { loadProcessActions } from './modules/processing/ProcessActionsStore'; import * as Theming from './modules/theming/Theming'; import ProcessDonations from './pages/ProcessDonations'; import ReadDonations from './pages/ReadDonations'; @@ -18,7 +21,6 @@ import '@spyrothon/sparx/style.css'; export default function App() { const canChangeDonations = usePermission('tracker.change_donation'); - const { processDonation } = useProcessingStore(); const { theme, accent } = Theming.useThemeStore(); const { APIV2_ROOT } = useConstants(); @@ -27,23 +29,31 @@ export default function App() { setAPIRoot(APIV2_ROOT); }, [APIV2_ROOT]); + useQuery('auth.me', () => APIClient.getMe(), { + onSuccess: me => loadMe(me), + staleTime: 5 * 60 * 1000, + }); + React.useEffect(() => { const unsubActions = ProcessingSocket.on('processing_action', event => { loadDonations([event.donation]); - if (event.action !== 'unprocessed') { - processDonation(event.donation, event.action, false); - } + loadProcessActions([event.action]); }); const unsubNewDonations = ProcessingSocket.on('donation_received', event => { loadDonations([event.donation]); }); + const unsubUpdatedDonations = ProcessingSocket.on('donation_updated', event => { + loadDonations([event.donation]); + }); + return () => { unsubActions(); unsubNewDonations(); + unsubUpdatedDonations(); }; - }, [processDonation]); + }, []); return ( diff --git a/bundles/processing/modules/layout/SidebarLayout.tsx b/bundles/processing/modules/layout/SidebarLayout.tsx index e9ac9b69c..423c5a4a4 100644 --- a/bundles/processing/modules/layout/SidebarLayout.tsx +++ b/bundles/processing/modules/layout/SidebarLayout.tsx @@ -48,7 +48,7 @@ export default function SidebarLayout(props: SidebarLayoutProps) { return (
- + {sidebar} diff --git a/bundles/processing/modules/processing/ActionHistoryModal.mod.css b/bundles/processing/modules/processing/ActionHistoryModal.mod.css new file mode 100644 index 000000000..a350c93d2 --- /dev/null +++ b/bundles/processing/modules/processing/ActionHistoryModal.mod.css @@ -0,0 +1,16 @@ +.container { + min-width: 560px; + width: 60vw; + height: 80vh; + overflow-y: scroll; +} + +.comment { + position: relative; + overflow-wrap: anywhere; + white-space: pre-wrap; +} + +.action { + width: 100%; +} diff --git a/bundles/processing/modules/processing/ActionHistoryModal.tsx b/bundles/processing/modules/processing/ActionHistoryModal.tsx new file mode 100644 index 000000000..2dac1a7de --- /dev/null +++ b/bundles/processing/modules/processing/ActionHistoryModal.tsx @@ -0,0 +1,130 @@ +import * as React from 'react'; +import { useMutation, useQuery } from 'react-query'; +import { Anchor, Button, Card, Checkbox, Header, openModal, Stack, Text, useTooltip } from '@spyrothon/sparx'; + +import APIClient from '@public/apiv2/APIClient'; +import { DonationProcessAction } from '@public/apiv2/APITypes'; +import * as CurrencyUtils from '@public/util/currency'; +import Undo from '@uikit/icons/Undo'; + +import { AdminRoutes, useAdminRoute } from '../../Routes'; +import { useMe } from '../auth/AuthStore'; +import { useDonation } from '../donations/DonationsStore'; +import RelativeTime from '../time/RelativeTime'; +import { loadProcessActions, useOwnProcessActions } from './ProcessActionsStore'; + +import styles from './ActionHistoryModal.mod.css'; + +export function ActionHistoryModalButton() { + function handleOpen() { + openModal(() => ); + } + + return ; +} + +function ActionEntry({ action }: { action: DonationProcessAction }) { + const storedDonation = useDonation(action.donation_id); + const donation = action.donation ?? storedDonation; + const hasDonation = donation != null; + const isUndo = action.originating_action != null; + + const donationLink = useAdminRoute(AdminRoutes.DONATION(action.donation_id)); + const amount = CurrencyUtils.asCurrency(donation.amount); + const timestamp = React.useMemo(() => new Date(action.occurred_at), [action.occurred_at]); + + const [undoTooltipProps] = useTooltip(`Reset to ${action.from_state}`); + const undo = useMutation(() => APIClient.undoProcessAction(action.id)); + + return ( + + +
+ + {isUndo ? ( + + Undo {action.from_state} to {action.to_state} + + ) : ( + {action.to_state} + )} + {' · '} + + + + + #{action.donation_id} + + {' · '} + {hasDonation ? ( + <> + {amount} from {donation.donor_name} + + ) : ( + 'Donation info not available' + )} + + {donation?.comment != null ? ( + + {donation.comment.slice(0, 500)} + {donation.comment.length > 500 ? '...' : null} + + ) : null} +
+ {!isUndo ? ( + + ) : null} +
+
+ ); +} + +export default function ActionHistoryModal() { + const me = useMe(); + // -1 should be an invalid user id, meaning we'll get back an empty history + // until Me has loaded. + const history = useOwnProcessActions(me?.id ?? -1); + + const [showUndos, setShowUndos] = React.useState(true); + + const { data: initialHistory } = useQuery(`processing.action-history`, () => APIClient.getProcessActionHistory(), { + // This data comes in over the socket as the user interacts with the page. + // This initial fetch is just to back-populate data when they open the modal, + // So it only needs to run once-ish. + cacheTime: 60 * 60 * 1000, + staleTime: 60 * 60 * 1000, + refetchOnWindowFocus: false, + }); + React.useEffect(() => { + if (initialHistory == null) return; + + loadProcessActions(initialHistory); + }, [initialHistory]); + + return ( + + + Action History + setShowUndos(event.target.checked)} + label="Show Undos" + /> + + + {history.map(action => { + if (!showUndos && action.originating_action != null) return null; + return ; + })} + + + ); +} diff --git a/bundles/processing/modules/processing/ActionLog.mod.css b/bundles/processing/modules/processing/ActionLog.mod.css index 10012d742..903065745 100644 --- a/bundles/processing/modules/processing/ActionLog.mod.css +++ b/bundles/processing/modules/processing/ActionLog.mod.css @@ -1,26 +1,12 @@ .action { - display: flex; - width: 100%; - margin: 8px 0; - transform-origin: top; - - &:hover, - &:focus { - background-color: var(--background-hover-alt); - - & .undoButton { - display: block; - } - } -} - -.info { - flex: 1 1 auto; + padding: 8px 0; + border-radius: var(--radius-normal); } .actionEnter { opacity: 0; transform: scaleY(0); + transform-origin: top; } .actionEnterActive { @@ -28,6 +14,7 @@ transform: scaleY(1); transform-origin: top; transition: 160ms ease-out; + transition-delay: 50ms; } .actionExit { diff --git a/bundles/processing/modules/processing/ActionLog.tsx b/bundles/processing/modules/processing/ActionLog.tsx index e5619fbc6..d2ba54897 100644 --- a/bundles/processing/modules/processing/ActionLog.tsx +++ b/bundles/processing/modules/processing/ActionLog.tsx @@ -1,97 +1,112 @@ import * as React from 'react'; -import { useMutation } from 'react-query'; +import { useQuery } from 'react-query'; import { CSSTransition, TransitionGroup } from 'react-transition-group'; -import { Anchor, Button, Header, Text } from '@spyrothon/sparx'; +import { Anchor, Button, Header, Stack, Text } from '@spyrothon/sparx'; import APIClient from '@public/apiv2/APIClient'; -import { Donation } from '@public/apiv2/APITypes'; +import { Donation, DonationProcessAction } from '@public/apiv2/APITypes'; import * as CurrencyUtils from '@public/util/currency'; import Undo from '@uikit/icons/Undo'; import { AdminRoutes, useAdminRoute } from '../../Routes'; -import { loadDonations, useDonation } from '../donations/DonationsStore'; -import useProcessingStore, { HistoryAction } from '../processing/ProcessingStore'; +import { useMe } from '../auth/AuthStore'; +import { useDonation } from '../donations/DonationsStore'; +import RelativeTime from '../time/RelativeTime'; +import { loadProcessActions, useOwnProcessActions } from './ProcessActionsStore'; import styles from './ActionLog.mod.css'; -const timeFormatter = new Intl.RelativeTimeFormat('en', { style: 'narrow' }); - -function getRelativeTime(timestamp: number, now: number = Date.now()) { - const diff = -(now - timestamp) / 1000; - if (diff > -5) { - return 'just now'; - } else if (diff <= -3600) { - return timeFormatter.format(Math.round(diff / 3600), 'hours'); - } else if (diff <= -60) { - return timeFormatter.format(Math.round(diff / 60), 'minutes'); - } else { - return timeFormatter.format(Math.round(diff), 'seconds'); - } +interface NoDonationActionRow { + action: DonationProcessAction; } -function ActionEntry({ action }: { action: HistoryAction }) { - const donationLink = useAdminRoute(AdminRoutes.DONATION(action.donationId)); - const donation = useDonation(action.donationId); - - const store = useProcessingStore(); - const unprocess = useMutation( - (donationId: number) => { - return APIClient.unprocessDonation(`${donationId}`); - }, - { - onSuccess: (donation: Donation) => { - loadDonations([donation]); - store.undoAction(action.id); - }, - }, - ); +function NoDonationActionRow(props: NoDonationActionRow) { + const { action } = props; + + return null; +} +interface DonationActionRow { + action: DonationProcessAction; + donation: Donation; +} + +function DonationActionRow(props: DonationActionRow) { + const { action, donation } = props; + + const donationLink = useAdminRoute(AdminRoutes.DONATION(action.donation_id)); const amount = CurrencyUtils.asCurrency(donation.amount); + const timestamp = React.useMemo(() => new Date(action.occurred_at), [action.occurred_at]); return ( -
+
- - {amount} from {donation.donor_name} + + + #{action.donation_id} + + {' · '} + {action.to_state} + {' · '} + - #{donation.id} - {' – '} - {action.label} - {' – '} - {getRelativeTime(action.timestamp)} + {donation != null ? ( + <> + {amount} from {donation.donor_name} + + ) : ( + 'Donation info not available' + )}
-
+
); } +function ActionEntry({ action }: { action: DonationProcessAction }) { + const storedDonation = useDonation(action.donation_id); + const donation = action.donation ?? storedDonation; + const hasDonation = donation != null || storedDonation; + + if (!hasDonation) { + return ; + } + + return ; +} + export default function ActionLog() { - const history = useProcessingStore(state => state.actionHistory.slice(0, 20)); + const me = useMe(); + // -1 should be an invalid user id, meaning we'll get back an empty history + // until Me has loaded. + const history = useOwnProcessActions(me?.id ?? -1); - // This keeps the live timers relatively up-to-date. - const [, forceUpdate] = React.useState({}); + const { data: initialHistory } = useQuery(`processing.action-history`, () => APIClient.getProcessActionHistory(), { + staleTime: 60 * 60 * 1000, + }); React.useEffect(() => { - const interval = setInterval(() => forceUpdate({}), Math.random() * 4000 + 6000); - return () => clearInterval(interval); - }, []); + if (initialHistory == null) return; + + loadProcessActions(initialHistory); + }, [initialHistory]); return ( -
+
Action History
- {history.map(action => ( + {history.slice(0, 10).map(action => ( ; +} + +const useProcessActionsStore = create()(() => ({ + actions: {}, +})); + +export default useProcessActionsStore; + +export function loadProcessActions(actions: DonationProcessAction[]) { + useProcessActionsStore.setState(state => { + const newActions = { ...state.actions }; + for (const action of actions) { + newActions[action.id] = action; + } + return { actions: newActions }; + }); +} + +export function useProcessAction(actionId: number) { + const actions = useProcessActionsStore(state => state.actions); + return actions[actionId]; +} + +export function useOwnProcessActions(userId: number) { + const actions = useProcessActionsStore(state => state.actions); + return React.useMemo( + () => + Object.values(actions) + .filter(action => action.actor.id === userId) + .sort((a, b) => b.occurred_at.localeCompare(a.occurred_at)), + [actions, userId], + ); +} diff --git a/bundles/processing/modules/processing/ProcessingDonationRow.tsx b/bundles/processing/modules/processing/ProcessingDonationRow.tsx index e491562cf..45a8f4420 100644 --- a/bundles/processing/modules/processing/ProcessingDonationRow.tsx +++ b/bundles/processing/modules/processing/ProcessingDonationRow.tsx @@ -19,14 +19,11 @@ import DonationRow from '../donations/DonationRow'; import ModCommentModal from '../donations/ModCommentModal'; import ModCommentTooltip from '../donations/ModCommentTooltip'; import MutationButton from '../processing/MutationButton'; -import useProcessingStore from '../processing/ProcessingStore'; -function useDonationMutation(mutation: (donationId: number) => Promise, actionLabel: string) { - const store = useProcessingStore(); +function useDonationMutation(mutation: (donationId: number) => Promise) { return useMutation(mutation, { onSuccess: (donation: Donation) => { loadDonations([donation]); - store.processDonation(donation, actionLabel); }, }); } @@ -34,19 +31,15 @@ function useDonationMutation(mutation: (donationId: number) => Promise interface ProcessingActionsProps { donation: Donation; action: (donationId: string) => Promise; - actionName: string; actionLabel: string; } function ProcessingActions(props: ProcessingActionsProps) { - const { donation, action, actionName, actionLabel } = props; + const { donation, action, actionLabel } = props; - const mutation = useDonationMutation((donationId: number) => action(`${donationId}`), actionName); - const approve = useDonationMutation( - (donationId: number) => APIClient.approveDonationComment(`${donationId}`), - 'Approved', - ); - const deny = useDonationMutation((donationId: number) => APIClient.denyDonationComment(`${donationId}`), 'Blocked'); + const mutation = useDonationMutation((donationId: number) => action(`${donationId}`)); + const approve = useDonationMutation((donationId: number) => APIClient.approveDonationComment(`${donationId}`)); + const deny = useDonationMutation((donationId: number) => APIClient.denyDonationComment(`${donationId}`)); function handleEditModComment() { openModal(props => ); @@ -71,12 +64,11 @@ function ProcessingActions(props: ProcessingActionsProps) { interface ProcessingDonationRowProps { donation: Donation; action: (donationId: string) => Promise; - actionName: string; actionLabel: string; } export default function ProcessingDonationRow(props: ProcessingDonationRowProps) { - const { donation, action, actionName, actionLabel } = props; + const { donation, action, actionLabel } = props; const timestamp = TimeUtils.parseTimestamp(donation.timereceived); const donationLink = useAdminRoute(AdminRoutes.DONATION(donation.id)); @@ -115,7 +107,7 @@ export default function ProcessingDonationRow(props: ProcessingDonationRowProps) } function renderActions() { - return ; + return ; } return ( diff --git a/bundles/processing/modules/processing/ProcessingStore.tsx b/bundles/processing/modules/processing/ProcessingStore.tsx index 2838e6ff2..0c0147dc1 100644 --- a/bundles/processing/modules/processing/ProcessingStore.tsx +++ b/bundles/processing/modules/processing/ProcessingStore.tsx @@ -1,17 +1,6 @@ import create from 'zustand'; import { persist } from 'zustand/middleware'; -import { Donation } from '@public/apiv2/APITypes'; - -let nextId = 0; - -export interface HistoryAction { - id: number; - label: string; - donationId: number; - timestamp: number; -} - export type ProcessingMode = 'flag' | 'confirm' | 'onestep'; interface ProcessingStoreState { @@ -19,7 +8,6 @@ interface ProcessingStoreState { * The partition to use when browsing donations. */ partition: number; - actionHistory: HistoryAction[]; /** * The total number of partitions currently in use, used as a maximum bound for `partition`. */ @@ -32,32 +20,14 @@ interface ProcessingStoreState { */ processingMode: ProcessingMode; setProcessingMode: (processingMode: ProcessingMode) => void; - processDonation(donation: Donation, action: string, log?: boolean): void; - undoAction(actionId: number): void; } const useProcessingStore = create()( persist( set => ({ - actionHistory: [] as HistoryAction[], partition: 0, partitionCount: 1, processingMode: 'flag', - processDonation(donation: Donation, action: string, log = true) { - set(state => { - return { - actionHistory: log - ? [ - { id: nextId++, label: action, donationId: donation.id, timestamp: Date.now() }, - ...state.actionHistory, - ] - : state.actionHistory, - }; - }); - }, - undoAction(actionId: number) { - set(state => ({ actionHistory: state.actionHistory.filter(({ id }) => id !== actionId) })); - }, setPartition(partition) { set({ partition }); }, @@ -68,7 +38,7 @@ const useProcessingStore = create()( set(state => { if (processingMode === state.processingMode) return state; - return { processingMode, unprocessed: new Set(), actionHistory: [] }; + return { processingMode, unprocessed: new Set() }; }); }, }), diff --git a/bundles/processing/modules/processing/ProcessingTypes.tsx b/bundles/processing/modules/processing/ProcessingTypes.tsx index 178e30af5..bd8f307af 100644 --- a/bundles/processing/modules/processing/ProcessingTypes.tsx +++ b/bundles/processing/modules/processing/ProcessingTypes.tsx @@ -6,6 +6,5 @@ export interface ProcessDefinition { donationState: DonationState; fetch: (eventId: string) => Promise; action: (donationId: string) => Promise; - actionName: string; actionLabel: string; } diff --git a/bundles/processing/modules/time/RelativeTime.tsx b/bundles/processing/modules/time/RelativeTime.tsx index 2931c9d84..b9ee52bda 100644 --- a/bundles/processing/modules/time/RelativeTime.tsx +++ b/bundles/processing/modules/time/RelativeTime.tsx @@ -22,12 +22,17 @@ interface RelativeTimeProps { time: Date; now?: Date; formatter?: Intl.RelativeTimeFormat; + /** + * Override the user's preferences and always show a relative timestamp. + */ + forceRelative?: boolean; } export default function RelativeTime(props: RelativeTimeProps) { - const { time, now, formatter = timeFormatter } = props; + const { time, now, formatter = timeFormatter, forceRelative = false } = props; - const useRelativeTimestamps = useUserPreferencesStore(state => state.useRelativeTimestamps); + const userWantsRelative = useUserPreferencesStore(state => state.useRelativeTimestamps); + const useRelativeTimestamps = forceRelative || userWantsRelative; const diff = -((now ?? new Date()).getTime() - time.getTime()) / 1000; const [timeString, setTimeString] = React.useState(() => getRelativeTimeString(diff, formatter)); diff --git a/bundles/processing/pages/ProcessDonations.tsx b/bundles/processing/pages/ProcessDonations.tsx index 01f4cc298..a523346a0 100644 --- a/bundles/processing/pages/ProcessDonations.tsx +++ b/bundles/processing/pages/ProcessDonations.tsx @@ -7,11 +7,12 @@ import { usePermission } from '@public/api/helpers/auth'; import APIClient from '@public/apiv2/APIClient'; import type { Donation, Event } from '@public/apiv2/APITypes'; +import { ActionHistoryModalButton } from '@processing/modules/processing/ActionHistoryModal'; + import DonationList from '../modules/donations/DonationList'; import { loadDonations, useDonationsInState } from '../modules/donations/DonationsStore'; import SearchKeywordsInput from '../modules/donations/SearchKeywordsInput'; import SidebarLayout from '../modules/layout/SidebarLayout'; -import ActionLog from '../modules/processing/ActionLog'; import ConnectionStatus from '../modules/processing/ConnectionStatus'; import ProcessingDonationRow from '../modules/processing/ProcessingDonationRow'; import ProcessingModeSelector from '../modules/processing/ProcessingModeSelector'; @@ -24,21 +25,18 @@ const PROCESSES: Record = { donationState: 'unprocessed', fetch: (eventId: string) => APIClient.getUnprocessedDonations(eventId), action: (donationId: string) => APIClient.flagDonation(donationId), - actionName: 'Sent to Head', actionLabel: 'Send to Head', }, confirm: { donationState: 'flagged', fetch: (eventId: string) => APIClient.getFlaggedDonations(eventId), action: (donationId: string) => APIClient.sendDonationToReader(donationId), - actionName: 'Sent to Reader', actionLabel: 'Send to Reader', }, onestep: { donationState: 'unprocessed', fetch: (eventId: string) => APIClient.getUnprocessedDonations(eventId), action: (donationId: string) => APIClient.sendDonationToReader(donationId), - actionName: 'Sent to Reader', actionLabel: 'Send to Reader', }, }; @@ -79,7 +77,7 @@ function Sidebar(props: SidebarProps) { - + ); } @@ -107,12 +105,7 @@ export default function ProcessDonations() { const renderDonationRow = React.useCallback( (donation: Donation) => ( - + ), [process], ); diff --git a/bundles/processing/pages/ReadDonations.tsx b/bundles/processing/pages/ReadDonations.tsx index 1c97de93e..c137946a3 100644 --- a/bundles/processing/pages/ReadDonations.tsx +++ b/bundles/processing/pages/ReadDonations.tsx @@ -9,6 +9,7 @@ import Plus from '@uikit/icons/Plus'; import DonationDropTarget from '@processing/modules/donations/DonationDropTarget'; import DonationList from '@processing/modules/donations/DonationList'; +import { ActionHistoryModalButton } from '@processing/modules/processing/ActionHistoryModal'; import FilterGroupTab, { FilterGroupTabDropTarget } from '@processing/modules/reading/FilterGroupTab'; import ReadingDonationRow from '@processing/modules/reading/ReadingDonationRow'; import { FILTER_ITEMS, FilterGroupTabItem, GroupTabItem } from '@processing/modules/reading/ReadingTypes'; @@ -127,6 +128,7 @@ function Sidebar(props: SidebarProps) { ))} + ); } diff --git a/bundles/public/apiv2/APIClient.tsx b/bundles/public/apiv2/APIClient.tsx index 76af55340..0666a62e6 100644 --- a/bundles/public/apiv2/APIClient.tsx +++ b/bundles/public/apiv2/APIClient.tsx @@ -1,11 +1,13 @@ import * as donations from './routes/donations'; import * as events from './routes/events'; import * as me from './routes/me'; +import * as processActions from './routes/process_actions'; const client = { ...donations, ...events, ...me, + ...processActions, }; export default client; diff --git a/bundles/public/apiv2/APITypes.tsx b/bundles/public/apiv2/APITypes.tsx index 25f037ac5..c8e057b2d 100644 --- a/bundles/public/apiv2/APITypes.tsx +++ b/bundles/public/apiv2/APITypes.tsx @@ -27,6 +27,28 @@ export type DonationBid = { bid_name: string; }; +export type DonationProcessState = + | 'unprocessed' + | 'flagged' + | 'ready' + | 'read' + | 'approved' + | 'ignored' + | 'denied' + | 'unknown'; + +export type DonationProcessAction = { + type: 'donationprocessaction'; + id: number; + actor: User; + donation?: Donation; + donation_id: number; + from_state: DonationProcessState; + to_state: DonationProcessState; + occurred_at: string; + originating_action?: DonationProcessAction; +}; + export type Event = { type: 'event'; id: number; @@ -39,8 +61,15 @@ export type Event = { }; export type Me = { + id: number; username: string; superuser: boolean; staff: boolean; permissions: string[]; }; + +export type User = { + type: 'user'; + id: number; + username: string; +}; diff --git a/bundles/public/apiv2/Endpoints.tsx b/bundles/public/apiv2/Endpoints.tsx index c2bc1f7b3..36b13c237 100644 --- a/bundles/public/apiv2/Endpoints.tsx +++ b/bundles/public/apiv2/Endpoints.tsx @@ -20,6 +20,8 @@ const Endpoints = { EVENTS: `events/`, EVENT: (eventId: string) => `events/${eventId}/`, ME: `me/`, + PROCESS_ACTIONS: `process_actions/`, + PROCESS_ACTIONS_UNDO: (actionId: number) => `process_actions/${actionId}/undo/`, }; export default Endpoints; diff --git a/bundles/public/apiv2/routes/process_actions.tsx b/bundles/public/apiv2/routes/process_actions.tsx new file mode 100644 index 000000000..8e357c853 --- /dev/null +++ b/bundles/public/apiv2/routes/process_actions.tsx @@ -0,0 +1,13 @@ +import type { DonationProcessAction } from '../APITypes'; +import Endpoints from '../Endpoints'; +import HTTPUtils from '../HTTPUtils'; + +export async function getProcessActionHistory() { + const response = await HTTPUtils.get(Endpoints.PROCESS_ACTIONS); + return response.data; +} + +export async function undoProcessAction(actionId: number) { + const response = await HTTPUtils.post(Endpoints.PROCESS_ACTIONS_UNDO(actionId)); + return response.data; +} diff --git a/bundles/public/apiv2/sockets/ProcessingSocket.tsx b/bundles/public/apiv2/sockets/ProcessingSocket.tsx index 701c8d04a..786619854 100644 --- a/bundles/public/apiv2/sockets/ProcessingSocket.tsx +++ b/bundles/public/apiv2/sockets/ProcessingSocket.tsx @@ -1,8 +1,12 @@ import SturdyWebsocket from 'sturdy-websocket'; -import { Donation } from '../APITypes'; +import { Donation, DonationProcessAction } from '../APITypes'; -export type ProcessingSocketEventType = 'connection_changed' | 'processing_action' | 'donation_received'; +export type ProcessingSocketEventType = + | 'connection_changed' + | 'processing_action' + | 'donation_updated' + | 'donation_received'; export type ProcessingActionType = | 'unprocessed' @@ -15,9 +19,12 @@ export type ProcessingActionType = interface ProcessingActionEvent { type: 'processing_action'; - action: ProcessingActionType; - actor_name: string; - actorId: number; + action: DonationProcessAction; + donation: Donation; +} + +interface DonationUpdatedEvent { + type: 'donation_updated'; donation: Donation; } @@ -31,10 +38,15 @@ interface ConnectionChangedEvent { isConnected: boolean; } -export type ProcessingEvent = ProcessingActionEvent | DonationReceivedEvent | ConnectionChangedEvent; +export type ProcessingEvent = + | ProcessingActionEvent + | DonationUpdatedEvent + | DonationReceivedEvent + | ConnectionChangedEvent; type ProcessingEventMap = { processing_action: ProcessingActionEvent; + donation_updated: DonationUpdatedEvent; donation_received: DonationReceivedEvent; connection_changed: ConnectionChangedEvent; }; diff --git a/tests/apiv2/test_donations.py b/tests/apiv2/test_donations.py index e4ceb9a26..5c8377d8f 100644 --- a/tests/apiv2/test_donations.py +++ b/tests/apiv2/test_donations.py @@ -207,45 +207,6 @@ def test_flagged_does_not_return_processed_donations(self): self.assertEqual(len(response.data), 0) - ### - # /unprocess - ### - - def test_unprocess_fails_without_login(self): - self.client.force_authenticate(user=None) - response = self.client.post(f'/tracker/api/v2/donations/1234/unprocess/') - self.assertEquals(response.status_code, 403) - - def test_unprocess_fails_without_change_donation_permission(self): - user = User.objects.create() - self.client.force_authenticate(user=user) - - response = self.client.post(f'/tracker/api/v2/donations/1234/unprocess/') - self.assertEquals(response.status_code, 403) - - def test_unprocess_resets_donation_state(self): - donation = self.generate_donations(self.event, count=1, state='approved')[0] - - response = self.client.post( - f'/tracker/api/v2/donations/{donation.pk}/unprocess/' - ) - - returned = response.data - self.assertEqual(returned['commentstate'], 'PENDING') - self.assertEqual(returned['readstate'], 'PENDING') - saved = Donation.objects.get(pk=donation.pk) - self.assertEqual(saved.commentstate, 'PENDING') - self.assertEqual(saved.readstate, 'PENDING') - - def test_unprocess_logs_changes(self): - donation = self.generate_donations(self.event, count=1, state='approved')[0] - - self.client.post(f'/tracker/api/v2/donations/{donation.pk}/unprocess/') - - self.assertLogEntry( - 'donation', donation.pk, CHANGE, DONATION_CHANGE_LOG_MESSAGES['unprocessed'] - ) - ### # /approve_comment ### diff --git a/tests/apiv2/test_process_actions.py b/tests/apiv2/test_process_actions.py new file mode 100644 index 000000000..c8843286a --- /dev/null +++ b/tests/apiv2/test_process_actions.py @@ -0,0 +1,116 @@ +from random import Random +from typing import Optional + +from django.contrib.auth.models import Permission, User +from django.utils import timezone +from rest_framework.test import APIClient + +from tests.randgen import generate_donation, generate_donor +from tracker.api.serializers import DonationProcessActionSerializer +from tracker.models.donation import DonationProcessAction, DonationProcessState + +from ..util import APITestCase + +rand = Random() + + +class TestProcessActions(APITestCase): + def setUp(self): + super(TestProcessActions, self).setUp() + self.client = APIClient() + self.donor = generate_donor(rand) + self.donor.save() + self.donation = generate_donation( + rand, donor=self.donor, commentstate='PENDING', readstate='PENDING' + ) + self.donation.save() + self.user.user_permissions.add( + Permission.objects.get(codename='change_donation'), + ) + + def _generate_action( + self, + *, + actor: User, + from_state: DonationProcessState, + to_state: DonationProcessState, + originating_action: Optional[DonationProcessAction] = None, + ): + return DonationProcessAction.objects.create( + actor=actor, + donation=self.donation, + from_state=from_state, + to_state=to_state, + occurred_at=timezone.now(), + originating_action=originating_action, + ) + + def test_list_actions(self): + self.client.force_authenticate(user=self.user) + + response = self.client.get('/tracker/api/v2/process_actions/') + self.assertEqual(response.data, []) + + action = self._generate_action( + actor=self.user, + from_state=DonationProcessState.UNPROCESSED, + to_state=DonationProcessState.FLAGGED, + ) + serialized_action = DonationProcessActionSerializer(action).data + response = self.client.get('/tracker/api/v2/process_actions/') + self.assertEqual(response.data, [serialized_action]) + + def test_list_actions_cannot_see_other_users(self): + self._generate_action( + actor=self.super_user, + from_state=DonationProcessState.UNPROCESSED, + to_state=DonationProcessState.FLAGGED, + ) + + self.client.force_authenticate(user=self.user) + response = self.client.get('/tracker/api/v2/process_actions/') + self.assertEqual(response.data, []) + + def test_list_actions_super_user_can_see_all(self): + action = self._generate_action( + actor=self.super_user, + from_state=DonationProcessState.UNPROCESSED, + to_state=DonationProcessState.FLAGGED, + ) + + self.client.force_authenticate(user=self.super_user) + serialized_action = DonationProcessActionSerializer(action).data + response = self.client.get('/tracker/api/v2/process_actions/') + self.assertEqual(response.data, [serialized_action]) + + def test_list_actions_requires_permissions(self): + response = self.client.get('/tracker/api/v2/process_actions/') + self.assertEqual(response.status_code, 403) + + def test_undo(self): + action = self._generate_action( + actor=self.user, + from_state=DonationProcessState.UNPROCESSED, + to_state=DonationProcessState.FLAGGED, + ) + self.client.force_authenticate(user=self.user) + response = self.client.post( + f'/tracker/api/v2/process_actions/{action.id}/undo/' + ) + undo_action = DonationProcessAction.objects.get(originating_action=action) + self.assertEqual(undo_action.to_state, DonationProcessState.UNPROCESSED) + + serialized_undo = DonationProcessActionSerializer(undo_action).data + self.assertEqual(response.data, serialized_undo) + + def test_undo_cannot_undo_anothers_action(self): + action = self._generate_action( + actor=self.super_user, + from_state=DonationProcessState.UNPROCESSED, + to_state=DonationProcessState.FLAGGED, + ) + self.client.force_authenticate(user=self.user) + response = self.client.post( + f'/tracker/api/v2/process_actions/{action.id}/undo/' + ) + self.assertEqual(response.status_code, 403) diff --git a/tests/apiv2/test_serializers.py b/tests/apiv2/test_serializers.py index ba713a3d9..c7ec2874c 100644 --- a/tests/apiv2/test_serializers.py +++ b/tests/apiv2/test_serializers.py @@ -1,21 +1,24 @@ import random +from datetime import datetime +from django.contrib.auth.models import User from django.test import TransactionTestCase from tests.randgen import generate_donation, generate_donor, generate_event -from tracker.api.serializers import DonationSerializer +from tracker.api.serializers import DonationProcessActionSerializer, DonationSerializer +from tracker.models.donation import DonationProcessAction, DonationProcessState +rand = random.Random() -class TestDonationSerializer(TransactionTestCase): - rand = random.Random() +class TestDonationSerializer(TransactionTestCase): def setUp(self): super(TestDonationSerializer, self).setUp() - self.event = generate_event(self.rand) + self.event = generate_event(rand) self.event.save() - self.donor = generate_donor(self.rand) + self.donor = generate_donor(rand) self.donor.save() - self.donation = generate_donation(self.rand, event=self.event, donor=self.donor) + self.donation = generate_donation(rand, event=self.event, donor=self.donor) self.donation.save() def test_includes_all_public_fields(self): @@ -51,3 +54,61 @@ def test_includes_modcomment_with_permission(self): self.donation, with_permissions=('tracker.change_donation',) ).data self.assertIn('modcomment', serialized_donation) + + +class TestDonationProcessActionSerializer(TransactionTestCase): + def setUp(self): + super(TestDonationProcessActionSerializer, self).setUp() + self.event = generate_event(rand) + self.event.save() + self.donor = generate_donor(rand) + self.donor.save() + self.donation = generate_donation(rand, event=self.event, donor=self.donor) + self.donation.save() + self.user = User.objects.create(username='test user') + self.user.save() + self.action = DonationProcessAction( + actor=self.user, + donation=self.donation, + from_state=DonationProcessState.UNPROCESSED, + to_state=DonationProcessState.FLAGGED, + occurred_at=datetime.now(), + ) + + def test_includes_all_public_fields(self): + expected_fields = [ + 'type', + 'id', + 'actor', + 'donation_id', + 'from_state', + 'to_state', + 'occurred_at', + ] + + serialized = DonationProcessActionSerializer(self.action).data + for field in expected_fields: + self.assertIn(field, serialized) + + def test_includes_donation_by_default(self): + serialized = DonationProcessActionSerializer(self.action).data + self.assertIn('donation', serialized) + self.assertIn('donation_id', serialized) + + def test_removes_donation_when_not_requested(self): + serialized = DonationProcessActionSerializer( + self.action, with_donation=False + ).data + self.assertNotIn('donation', serialized) + # donation_id stays for referencing without the full object + self.assertIn('donation_id', serialized) + + def test_includes_originating_action_by_default(self): + serialized = DonationProcessActionSerializer(self.action).data + self.assertIn('originating_action', serialized) + + def test_removes_originating_action_when_not_requested(self): + serialized = DonationProcessActionSerializer( + self.action, with_originating_action=False + ).data + self.assertNotIn('originating_action', serialized) diff --git a/tests/test_consumers.py b/tests/test_consumers.py index 53ecc91dd..88bd766df 100644 --- a/tests/test_consumers.py +++ b/tests/test_consumers.py @@ -10,10 +10,10 @@ from django.test import SimpleTestCase, TransactionTestCase from tracker import eventutil, models -from tracker.api.serializers import DonationSerializer -from tracker.api.views.donations import DonationProcessingActionTypes +from tracker.api.serializers import DonationProcessActionSerializer, DonationSerializer from tracker.consumers import DonationConsumer, PingConsumer from tracker.consumers.processing import ProcessingConsumer, broadcast_processing_action +from tracker.models.donation import DonationProcessAction, DonationProcessState from .util import today_noon @@ -119,9 +119,16 @@ def setUp(self): event=self.event, transactionstate='COMPLETED', ) + self.action = DonationProcessAction.objects.create( + actor=self.user, + donation=self.donation, + from_state=DonationProcessState.UNPROCESSED, + to_state=DonationProcessState.FLAGGED, + ) self.serialized_donation = DonationSerializer( self.donation, with_permissions=('tracker.change_donation',) ).data + self.serialized_action = DonationProcessActionSerializer(self.action).data def tearDown(self): self.donation.delete() @@ -162,15 +169,11 @@ async def test_processing_actions_get_broadcasted(self): connected, subprotocol = await communicator.connect() self.assertTrue(connected, 'Could not connect') - await sync_to_async(broadcast_processing_action)( - self.user, self.donation, DonationProcessingActionTypes.FLAGGED - ) + await sync_to_async(broadcast_processing_action)(self.donation, self.action) result = json.loads(await communicator.receive_from()) expected = { 'type': 'processing_action', - 'actor_name': self.user.username, - 'actor_id': self.user.id, 'donation': self.serialized_donation, - 'action': DonationProcessingActionTypes.FLAGGED, + 'action': self.serialized_action, } self.assertEqual(result, expected) diff --git a/tracker/analytics/events.py b/tracker/analytics/events.py index 180c3d532..eb2b64eaf 100644 --- a/tracker/analytics/events.py +++ b/tracker/analytics/events.py @@ -13,7 +13,7 @@ class AnalyticsEventTypes(str, enum.Enum): DONATION_COMMENT_DENIED = 'donation_comment_denied' DONATION_COMMENT_FLAGGED = 'donation_comment_flagged' DONATION_COMMENT_SENT_TO_READER = 'donation_comment_sent_to_reader' - DONATION_COMMENT_UNPROCESSED = 'donation_comment_unprocessed' + DONATION_COMMENT_UNDONE = 'donation_comment_undone' DONATION_COMMENT_AUTOMOD_DENIED = 'donation_comment_automod_denied' DONATION_COMMENT_PINNED = 'donation_comment_pinned' DONATION_COMMENT_UNPINNED = 'donation_comment_unpinned' diff --git a/tracker/api/serializers.py b/tracker/api/serializers.py index df1f85f93..ff641062c 100644 --- a/tracker/api/serializers.py +++ b/tracker/api/serializers.py @@ -2,10 +2,11 @@ import logging +from django.contrib.auth.models import User from rest_framework import serializers from tracker.models.bid import DonationBid -from tracker.models.donation import Donation +from tracker.models.donation import Donation, DonationProcessAction from tracker.models.event import Event, Headset, Runner, SpeedRun log = logging.getLogger(__name__) @@ -27,6 +28,14 @@ def to_representation(self, obj): return obj.__class__.__name__.lower() +class UserSerializer(serializers.ModelSerializer): + type = ClassNameField() + + class Meta: + model = User + fields = ('type', 'id', 'username') + + class DonationBidSerializer(serializers.ModelSerializer): type = ClassNameField() bid_name = serializers.SerializerMethodField() @@ -44,7 +53,7 @@ class DonationSerializer(serializers.ModelSerializer): donor_name = serializers.SerializerMethodField() bids = DonationBidSerializer(many=True, read_only=True) - def __init__(self, instance, *, with_permissions=None, **kwargs): + def __init__(self, instance=None, *, with_permissions=None, **kwargs): self.permissions = with_permissions or [] super().__init__(instance, **kwargs) @@ -85,6 +94,62 @@ def get_donor_name(self, donation: Donation): return '(Anonymous)' +class DonationProcessActionSerializer(serializers.ModelSerializer): + type = ClassNameField() + actor = UserSerializer() + donation = DonationSerializer() + donation_id = serializers.SerializerMethodField() + originating_action = serializers.SerializerMethodField() + + def __init__( + self, + instance=None, + *, + with_donation: bool = True, + with_originating_action: bool = True, + **kwargs, + ): + self.with_donation = with_donation + self.with_originating_action = with_originating_action + super().__init__(instance, **kwargs) + + class Meta: + model = DonationProcessAction + fields = ( + 'type', + 'id', + 'actor', + 'donation', + 'donation_id', + 'from_state', + 'to_state', + 'occurred_at', + 'originating_action', + ) + + def get_fields(self): + fields = super().get_fields() + if not self.with_donation: + del fields['donation'] + if not self.with_originating_action: + del fields['originating_action'] + + return fields + + def get_originating_action(self, obj): + if obj.originating_action is None: + return None + + return DonationProcessActionSerializer( + obj.originating_action, + with_donation=False, + with_originating_action=False, + ).data + + def get_donation_id(self, obj): + return obj.donation.id + + class EventSerializer(serializers.ModelSerializer): type = ClassNameField() timezone = serializers.SerializerMethodField() diff --git a/tracker/api/urls.py b/tracker/api/urls.py index de8b7fc0c..df62fce96 100644 --- a/tracker/api/urls.py +++ b/tracker/api/urls.py @@ -4,7 +4,7 @@ from rest_framework import routers from tracker.api import views -from tracker.api.views import donations, me +from tracker.api.views import donations, me, process_actions # routers generate URLs based on the view sets, so that we don't need to do a bunch of stuff by hand router = routers.DefaultRouter() @@ -12,6 +12,9 @@ router.register(r'runners', views.RunnerViewSet) router.register(r'runs', views.SpeedRunViewSet) router.register(r'donations', donations.DonationViewSet, basename='donations') +router.register( + r'process_actions', process_actions.ProcessActionViewSet, basename='process_actions' +) router.register(r'me', me.MeViewSet, basename='me') # use the router-generated URLs, and also link to the browsable API diff --git a/tracker/api/views/donations.py b/tracker/api/views/donations.py index c84526ce3..0cabb50fa 100644 --- a/tracker/api/views/donations.py +++ b/tracker/api/views/donations.py @@ -1,6 +1,6 @@ import enum from contextlib import contextmanager -from typing import Callable +from typing import Callable, Optional from django.db.models import Q from django.shortcuts import get_object_or_404 @@ -12,16 +12,23 @@ from tracker.analytics import AnalyticsEventTypes, analytics from tracker.api.permissions import tracker_permission from tracker.api.serializers import DonationSerializer -from tracker.consumers.processing import broadcast_processing_action -from tracker.models import Donation +from tracker.consumers.processing import ( + broadcast_donation_update_to_processors, + broadcast_processing_action, +) +from tracker.models import Donation, DonationProcessAction +from tracker.models.donation import DonationProcessState CanChangeDonation = tracker_permission('tracker.change_donation') CanSendToReader = tracker_permission('tracker.send_to_reader') CanViewComments = tracker_permission('tracker.view_comments') +# NOTE: This is distinct from DonationProcessState, as it includes non-state +# actions like pinning and editing mod comments. It represents the action that +# was taken versus the actual state of a donation. class DonationProcessingActionTypes(str, enum.Enum): - UNPROCESSED = 'unprocessed' + UNDONE = 'undone' APPROVED = 'approved' DENIED = 'denied' FLAGGED = 'flagged' @@ -34,7 +41,7 @@ class DonationProcessingActionTypes(str, enum.Enum): DONATION_CHANGE_LOG_MESSAGES = { - DonationProcessingActionTypes.UNPROCESSED: 'reset donation comment processing status', + DonationProcessingActionTypes.UNDONE: 'reset donation comment processing status', DonationProcessingActionTypes.APPROVED: 'approved donation comment', DonationProcessingActionTypes.DENIED: 'denied donation comment', DonationProcessingActionTypes.FLAGGED: 'flagged donation to head', @@ -47,7 +54,7 @@ class DonationProcessingActionTypes(str, enum.Enum): } DONATION_ACTION_ANALYTICS_EVENTS = { - DonationProcessingActionTypes.UNPROCESSED: AnalyticsEventTypes.DONATION_COMMENT_UNPROCESSED, + DonationProcessingActionTypes.UNDONE: AnalyticsEventTypes.DONATION_COMMENT_UNDONE, DonationProcessingActionTypes.APPROVED: AnalyticsEventTypes.DONATION_COMMENT_APPROVED, DonationProcessingActionTypes.DENIED: AnalyticsEventTypes.DONATION_COMMENT_DENIED, DonationProcessingActionTypes.FLAGGED: AnalyticsEventTypes.DONATION_COMMENT_FLAGGED, @@ -77,25 +84,61 @@ def _get_donation_analytics_fields(donation: Donation): } -def _track_donation_processing_event( - action: DonationProcessingActionTypes, - donation: Donation, - request, -): - # Add to local event audit log - logutil.change(request, donation, DONATION_CHANGE_LOG_MESSAGES[action]) - - # Track event to analytics database - analytics.track( - DONATION_ACTION_ANALYTICS_EVENTS[action], - { - **_get_donation_analytics_fields(donation), - 'user_id': request.user.pk, - }, - ) +def _get_donation_state_from_read_state( + donation: Donation, default: DonationProcessState +) -> str: + if donation.readstate == 'READ': + return DonationProcessState.READ + if donation.readstate == 'READY': + return DonationProcessState.READY + elif donation.readstate == 'FLAGGED': + return DonationProcessState.FLAGGED + elif donation.readstate == 'IGNORED': + return DonationProcessState.IGNORED + + return default - # Announce the action to all other processors - broadcast_processing_action(request.user, donation, action) + +def get_donation_state(donation: Donation) -> str: + if donation.commentstate == 'APPROVED': + return _get_donation_state_from_read_state( + donation, DonationProcessState.UNKNOWN + ) + elif donation.commentstate == 'ABSENT' or donation.commentstate == 'PENDING': + return _get_donation_state_from_read_state( + donation, DonationProcessState.UNPROCESSED + ) + elif donation.commentstate == 'DENIED': + return DonationProcessState.DENIED + else: + return DonationProcessState.UNKNOWN + + +def _set_donation_to_processing_state(donation: Donation, state: DonationProcessState): + if state == DonationProcessState.UNPROCESSED: + donation.commentstate = 'PENDING' + donation.readstate = 'PENDING' + elif state == DonationProcessState.FLAGGED: + donation.commentstate = 'APPROVED' + donation.readstate = 'FLAGGED' + elif state == DonationProcessState.READY: + donation.commentstate = 'APPROVED' + donation.readstate = 'READY' + elif state == DonationProcessState.READ: + donation.commentstate = 'APPROVED' + donation.readstate = 'READ' + elif state == DonationProcessState.IGNORED: + donation.commentstate = 'APPROVED' + donation.readstate = 'IGNORED' + elif state == DonationProcessState.APPROVED: + donation.commentstate = 'APPROVED' + donation.readstate = 'IGNORED' + elif state == DonationProcessState.DENIED: + donation.commentstate = 'DENIED' + donation.readstate = 'IGNORED' + elif state == DonationProcessState.UNKNOWN: + # Donations cannot be set to an unknown state + pass class DonationChangeManager: @@ -103,14 +146,73 @@ def __init__(self, request, pk: str, get_serializer: Callable): self.request = request self.pk = pk self.get_serializer = get_serializer + self._donation = None + self.action_record = None + + @property + def donation(self): + if self._donation is None: + self._donation = get_object_or_404(Donation, pk=self.pk) + + return self._donation @contextmanager - def change_donation(self, action: DonationProcessingActionTypes): - self.donation = get_object_or_404(Donation, pk=self.pk) + def change_donation( + self, action: DonationProcessingActionTypes, broadcast_update: bool = True + ): + """ + Perform a generic change to the donation. State changes should use + `change_donation_state` instead. + If `broadcast_update` is True, a `donation_update` event will be + broadcasted to all connected processors. + """ yield self.donation self.donation.save() - _track_donation_processing_event( - action=action, request=self.request, donation=self.donation + + self._track(action=action) + + if broadcast_update: + broadcast_donation_update_to_processors(self.donation) + + def change_donation_state( + self, + action: DonationProcessingActionTypes, + to_state: DonationProcessState, + originating_action: Optional[DonationProcessAction] = None, + ): + """ + Change the processing state of the donation to the given `to_state`. The + change will be recorded as a DonationProcessAction and broadcasted to all + connected processors. + """ + from_state = get_donation_state(self.donation) + with self.change_donation(action, broadcast_update=False) as donation: + _set_donation_to_processing_state(donation, to_state) + + self.action_record = DonationProcessAction.objects.create( + actor=self.request.user, + donation=donation, + from_state=from_state, + to_state=to_state, + originating_action=originating_action, + ) + + # Announce the action to all other processors + broadcast_processing_action(self.donation, self.action_record) + + def _track(self, action: DonationProcessingActionTypes): + # Add to local event audit log + logutil.change( + self.request, self.donation, DONATION_CHANGE_LOG_MESSAGES[action] + ) + + # Track event to analytics database + analytics.track( + DONATION_ACTION_ANALYTICS_EVENTS[action], + { + **_get_donation_analytics_fields(self.donation), + 'user_id': self.request.user.pk, + }, ) def response(self): @@ -210,20 +312,6 @@ def unread(self, _request): serializer = self.get_serializer(donations, many=True) return Response(serializer.data) - @action(detail=True, methods=['post'], permission_classes=[CanChangeDonation]) - def unprocess(self, request, pk): - """ - Reset the comment and read states for the donation. - """ - manager = DonationChangeManager(request, pk, self.get_serializer) - with manager.change_donation( - action=DonationProcessingActionTypes.UNPROCESSED - ) as donation: - donation.commentstate = 'PENDING' - donation.readstate = 'PENDING' - - return manager.response() - @action(detail=True, methods=['post'], permission_classes=[CanChangeDonation]) def approve_comment(self, request, pk): """ @@ -231,12 +319,10 @@ def approve_comment(self, request, pk): be read. """ manager = DonationChangeManager(request, pk, self.get_serializer) - with manager.change_donation( - action=DonationProcessingActionTypes.APPROVED - ) as donation: - donation.commentstate = 'APPROVED' - donation.readstate = 'IGNORED' - + manager.change_donation_state( + action=DonationProcessingActionTypes.APPROVED, + to_state=DonationProcessState.APPROVED, + ) return manager.response() @action(detail=True, methods=['post'], permission_classes=[CanChangeDonation]) @@ -245,12 +331,10 @@ def deny_comment(self, request, pk): Mark the comment for the donation as explicitly denied and ignored. """ manager = DonationChangeManager(request, pk, self.get_serializer) - with manager.change_donation( - action=DonationProcessingActionTypes.DENIED - ) as donation: - donation.commentstate = 'DENIED' - donation.readstate = 'IGNORED' - + manager.change_donation_state( + action=DonationProcessingActionTypes.DENIED, + to_state=DonationProcessState.DENIED, + ) return manager.response() @action(detail=True, methods=['post'], permission_classes=[CanChangeDonation]) @@ -261,12 +345,10 @@ def flag(self, request, pk): is using two step screening. """ manager = DonationChangeManager(request, pk, self.get_serializer) - with manager.change_donation( - action=DonationProcessingActionTypes.FLAGGED - ) as donation: - donation.commentstate = 'APPROVED' - donation.readstate = 'FLAGGED' - + manager.change_donation_state( + action=DonationProcessingActionTypes.FLAGGED, + to_state=DonationProcessState.FLAGGED, + ) return manager.response() @action( @@ -279,12 +361,10 @@ def send_to_reader(self, request, pk): Mark the donation as approved and send it directly to the reader. """ manager = DonationChangeManager(request, pk, self.get_serializer) - with manager.change_donation( - action=DonationProcessingActionTypes.SENT_TO_READER - ) as donation: - donation.commentstate = 'APPROVED' - donation.readstate = 'READY' - + manager.change_donation_state( + action=DonationProcessingActionTypes.SENT_TO_READER, + to_state=DonationProcessState.READY, + ) return manager.response() @action(detail=True, methods=['post'], permission_classes=[CanChangeDonation]) @@ -319,11 +399,10 @@ def read(self, request, pk): Mark the donation as read, completing the donation's lifecycle. """ manager = DonationChangeManager(request, pk, self.get_serializer) - with manager.change_donation( - action=DonationProcessingActionTypes.READ - ) as donation: - donation.readstate = 'READ' - + manager.change_donation_state( + action=DonationProcessingActionTypes.READ, + to_state=DonationProcessState.READ, + ) return manager.response() @action(detail=True, methods=['post'], permission_classes=[CanChangeDonation]) @@ -332,11 +411,10 @@ def ignore(self, request, pk): Mark the donation as ignored, completing the donation's lifecycle. """ manager = DonationChangeManager(request, pk, self.get_serializer) - with manager.change_donation( - action=DonationProcessingActionTypes.IGNORED - ) as donation: - donation.readstate = 'IGNORED' - + manager.change_donation_state( + action=DonationProcessingActionTypes.IGNORED, + to_state=DonationProcessState.IGNORED, + ) return manager.response() @action(detail=True, methods=['post'], permission_classes=[CanChangeDonation]) diff --git a/tracker/api/views/me.py b/tracker/api/views/me.py index 4bd841367..5b85ed932 100644 --- a/tracker/api/views/me.py +++ b/tracker/api/views/me.py @@ -3,6 +3,7 @@ class MeSerializer(serializers.Serializer): + id = serializers.IntegerField() username = serializers.CharField() superuser = serializers.BooleanField() staff = serializers.BooleanField() @@ -20,6 +21,7 @@ def list(self, request): me = MeSerializer( { + 'id': request.user.id, 'username': request.user.username, 'superuser': request.user.is_superuser, 'staff': request.user.is_staff, diff --git a/tracker/api/views/process_actions.py b/tracker/api/views/process_actions.py new file mode 100644 index 000000000..3808479d7 --- /dev/null +++ b/tracker/api/views/process_actions.py @@ -0,0 +1,69 @@ +from django.shortcuts import get_object_or_404 +from rest_framework import viewsets +from rest_framework.decorators import action +from rest_framework.exceptions import PermissionDenied +from rest_framework.response import Response + +from tracker import settings +from tracker.api.permissions import tracker_permission +from tracker.api.serializers import DonationProcessActionSerializer, DonationSerializer +from tracker.api.views.donations import ( + DonationChangeManager, + DonationProcessingActionTypes, +) +from tracker.models import DonationProcessAction + +CanChangeDonation = tracker_permission('tracker.change_donation') +CanViewAllProcessActions = tracker_permission('tracker.view_all_process_actions') + + +class ProcessActionViewSet(viewsets.GenericViewSet): + queryset = DonationProcessAction.objects + serializer_class = DonationProcessActionSerializer + permission_classes = [CanChangeDonation] + + def list(self, request): + """ + Return a list of the most recent DonationProcessActions for the event. + If the requesting user has permissions to see processing actions from + all users, they will all be returned. Otherwise, only actions performed + by the user will be returned. The maximum number of actions returned is + determined by TRACKER_PAGINATION_LIMIT. + """ + limit = settings.TRACKER_PAGINATION_LIMIT + actions = self.get_queryset().prefetch_related( + 'actor', 'donation', 'originating_action' + ) + + # If the user cannot see all process actions, filter down to just theirs. + if not CanViewAllProcessActions().has_permission(request, self): + actions = actions.filter(actor=request.user) + + actions = actions[0:limit] + serializer = self.get_serializer(actions, many=True) + return Response(serializer.data) + + @action(detail=True, methods=['post']) + def undo(self, request, pk): + """ + Reverses the given action on the target donation, so long as the current + state of the donation matches the action's `to_state`. If `force` is + as a body param, the action will be reversed regardless of the current + state. + """ + action = get_object_or_404(DonationProcessAction, pk=pk) + + # Ensure the requesting user can affect this action. + if ( + action.actor != request.user + and not CanViewAllProcessActions().has_permission(request, self) + ): + raise PermissionDenied() + + manager = DonationChangeManager(request, action.donation.pk, DonationSerializer) + manager.change_donation_state( + action=DonationProcessingActionTypes.UNDONE, + to_state=action.from_state, + originating_action=action, + ) + return Response(self.get_serializer(manager.action_record).data) diff --git a/tracker/consumers/processing.py b/tracker/consumers/processing.py index bb1c03aee..eacbf462d 100644 --- a/tracker/consumers/processing.py +++ b/tracker/consumers/processing.py @@ -3,8 +3,8 @@ from channels.layers import get_channel_layer from django.contrib.auth import get_user_model -from tracker.api.serializers import DonationSerializer -from tracker.models import Donation +from tracker.api.serializers import DonationProcessActionSerializer, DonationSerializer +from tracker.models import Donation, DonationProcessAction PROCESSING_GROUP_NAME = 'processing' @@ -30,6 +30,10 @@ async def donation_received(self, event): payload = event['payload'] await self.send_json({'type': 'donation_received', **payload}) + async def donation_updated(self, event): + payload = event['payload'] + await self.send_json({'type': 'donation_updated', **payload}) + User = get_user_model() @@ -40,16 +44,30 @@ def _serialize_donation(donation: Donation): ).data -def broadcast_processing_action(user: User, donation: Donation, action: str): +def _serialize_action(action: DonationProcessAction): + return DonationProcessActionSerializer(action).data + + +def broadcast_processing_action(donation: Donation, action: DonationProcessAction): async_to_sync(get_channel_layer().group_send)( PROCESSING_GROUP_NAME, { 'type': 'processing_action', 'payload': { - 'actor_name': user.username, - 'actor_id': user.pk, + 'action': _serialize_action(action), + 'donation': _serialize_donation(donation), + }, + }, + ) + + +def broadcast_donation_update_to_processors(donation: Donation): + async_to_sync(get_channel_layer().group_send)( + PROCESSING_GROUP_NAME, + { + 'type': 'donation_updated', + 'payload': { 'donation': _serialize_donation(donation), - 'action': action, }, }, ) diff --git a/tracker/migrations/0030_donationprocessaction.py b/tracker/migrations/0030_donationprocessaction.py new file mode 100644 index 000000000..19be2a0c4 --- /dev/null +++ b/tracker/migrations/0030_donationprocessaction.py @@ -0,0 +1,163 @@ +# Generated by Django 4.1.6 on 2023-04-29 17:03 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import django.utils.timezone +import tracker.models.donation + + +class Migration(migrations.Migration): + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('tracker', '0029_headset_merge_case'), + ] + + operations = [ + migrations.CreateModel( + name='DonationProcessAction', + fields=[ + ( + 'id', + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name='ID', + ), + ), + ( + 'from_state', + models.CharField( + choices=[ + ( + tracker.models.donation.DonationProcessState[ + 'UNPROCESSED' + ], + 'No processing has happened on the donation yet', + ), + ( + tracker.models.donation.DonationProcessState['FLAGGED'], + 'The donation has been flagged for head donations to process', + ), + ( + tracker.models.donation.DonationProcessState['READY'], + 'The donation is ready for the reader to process', + ), + ( + tracker.models.donation.DonationProcessState['READ'], + 'The donation comment was read by the reader', + ), + ( + tracker.models.donation.DonationProcessState['IGNORED'], + 'The donation comment was ignored by the reader', + ), + ( + tracker.models.donation.DonationProcessState[ + 'APPROVED' + ], + 'The donation was approved but not sent for reading', + ), + ( + tracker.models.donation.DonationProcessState['DENIED'], + 'The donation comment was rejected', + ), + ( + tracker.models.donation.DonationProcessState['UNKNOWN'], + 'The donation is in an unknown or corrupted processing state', + ), + ], + max_length=32, + ), + ), + ( + 'to_state', + models.CharField( + choices=[ + ( + tracker.models.donation.DonationProcessState[ + 'UNPROCESSED' + ], + 'No processing has happened on the donation yet', + ), + ( + tracker.models.donation.DonationProcessState['FLAGGED'], + 'The donation has been flagged for head donations to process', + ), + ( + tracker.models.donation.DonationProcessState['READY'], + 'The donation is ready for the reader to process', + ), + ( + tracker.models.donation.DonationProcessState['READ'], + 'The donation comment was read by the reader', + ), + ( + tracker.models.donation.DonationProcessState['IGNORED'], + 'The donation comment was ignored by the reader', + ), + ( + tracker.models.donation.DonationProcessState[ + 'APPROVED' + ], + 'The donation was approved but not sent for reading', + ), + ( + tracker.models.donation.DonationProcessState['DENIED'], + 'The donation comment was rejected', + ), + ( + tracker.models.donation.DonationProcessState['UNKNOWN'], + 'The donation is in an unknown or corrupted processing state', + ), + ], + max_length=32, + ), + ), + ( + 'occurred_at', + models.DateTimeField( + db_index=True, default=django.utils.timezone.now + ), + ), + ( + 'actor', + models.ForeignKey( + blank=True, + help_text='Who performed the processing action', + null=True, + on_delete=django.db.models.deletion.SET_NULL, + to=settings.AUTH_USER_MODEL, + ), + ), + ( + 'donation', + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to='tracker.donation', + ), + ), + ( + 'originating_action', + models.ForeignKey( + help_text='Original action that this action is undoing/modifying/etc.', + null=True, + on_delete=django.db.models.deletion.DO_NOTHING, + to='tracker.donationprocessaction', + ), + ), + ], + options={ + 'permissions': ( + ( + 'delete_all_process_actions', + 'Can delete donation processing action history', + ), + ( + 'view_all_donation_actions', + 'Can view donation processing actions performed by any user', + ), + ), + }, + ), + ] diff --git a/tracker/models/__init__.py b/tracker/models/__init__.py index 0e7c52dd9..d8c8b2e16 100644 --- a/tracker/models/__init__.py +++ b/tracker/models/__init__.py @@ -1,6 +1,12 @@ from tracker.models.bid import Bid, BidSuggestion, DonationBid from tracker.models.country import Country, CountryRegion -from tracker.models.donation import Donation, Donor, DonorCache, Milestone +from tracker.models.donation import ( + Donation, + DonationProcessAction, + Donor, + DonorCache, + Milestone, +) from tracker.models.event import ( Event, Headset, @@ -28,6 +34,7 @@ 'DonationBid', 'BidSuggestion', 'Donation', + 'DonationProcessAction', 'Donor', 'DonorCache', 'Milestone', diff --git a/tracker/models/donation.py b/tracker/models/donation.py index 4d5bba818..b9d4f36c1 100644 --- a/tracker/models/donation.py +++ b/tracker/models/donation.py @@ -1,4 +1,5 @@ import datetime +import enum import logging import random import time @@ -42,6 +43,41 @@ ('de', 'German'), ) + +class DonationProcessState(str, enum.Enum): + UNPROCESSED = 'unprocessed' + FLAGGED = 'flagged' + READY = 'ready' + READ = 'read' + IGNORED = 'ignored' + APPROVED = 'approved' + DENIED = 'denied' + UNKNOWN = 'unknown' + + +DonationProcessActionChoices = ( + ( + DonationProcessState.UNPROCESSED, + 'No processing has happened on the donation yet', + ), + ( + DonationProcessState.FLAGGED, + 'The donation has been flagged for head donations to process', + ), + (DonationProcessState.READY, 'The donation is ready for the reader to process'), + (DonationProcessState.READ, 'The donation comment was read by the reader'), + (DonationProcessState.IGNORED, 'The donation comment was ignored by the reader'), + ( + DonationProcessState.APPROVED, + 'The donation was approved but not sent for reading', + ), + (DonationProcessState.DENIED, 'The donation comment was rejected'), + ( + DonationProcessState.UNKNOWN, + 'The donation is in an unknown or corrupted processing state', + ), +) + logger = logging.getLogger(__name__) @@ -591,3 +627,40 @@ class Meta: app_label = 'tracker' ordering = ('event', 'amount') unique_together = ('event', 'amount') + + +class DonationProcessAction(models.Model): + actor = models.ForeignKey( + User, + help_text='Who performed the processing action', + null=True, + blank=True, + on_delete=models.SET_NULL, + ) + donation = models.ForeignKey('tracker.Donation', on_delete=models.CASCADE) + from_state = models.CharField( + max_length=32, null=False, blank=False, choices=DonationProcessActionChoices + ) + to_state = models.CharField( + max_length=32, null=False, blank=False, choices=DonationProcessActionChoices + ) + occurred_at = models.DateTimeField(default=timezone.now, db_index=True) + originating_action = models.ForeignKey( + 'tracker.DonationProcessAction', + null=True, + on_delete=models.DO_NOTHING, + help_text='Original action that this action is undoing/modifying/etc.', + ) + + class Meta: + app_label = 'tracker' + permissions = ( + ( + 'delete_all_process_actions', + 'Can delete donation processing action history', + ), + ( + 'view_all_process_actions', + 'Can view donation processing actions performed by any user', + ), + )