diff --git a/beanstalk_worker/__init__.py b/beanstalk_worker/__init__.py index 6c3156be8f..7eca8441df 100644 --- a/beanstalk_worker/__init__.py +++ b/beanstalk_worker/__init__.py @@ -1,11 +1,8 @@ -import traceback - from functools import wraps from logging import getLogger import sentry_sdk -from django.utils import timezone from lazy_services import LazyService # type: ignore @@ -21,7 +18,8 @@ def inner_task(func): @wraps(func) def wrapper(*args, **kwargs): - from iaso.models.base import ERRORED, RUNNING, KilledException, Project, Task + from iaso.models.base import RUNNING, KilledException, Project + from iaso.models.task import Task immediate = kwargs.pop("_immediate", False) # if true, we need to run the task now, we are a worker if immediate: @@ -39,19 +37,7 @@ def wrapper(*args, **kwargs): # If it was interrupted in the middle of a transaction the new status was not saved so save it again the_task.save() except Exception as e: - the_task.status = ERRORED - the_task.ended_at = timezone.now() - the_task.result = { - "result": ERRORED, - "message": str(e), - "stack_trace": traceback.format_exc(), - "last_progress_message": the_task.progress_message, - } - the_task.progress_message = e.message if hasattr(e, "message") else str(e) - # Extra debug info - if hasattr(e, "extra"): - the_task.result["extra"] = e.extra - the_task.save() + the_task.report_failure(e) logger.exception(f"Error when running task {the_task.id}: {the_task}") sentry_sdk.capture_exception(e) return the_task diff --git a/beanstalk_worker/management/commands/run_task.py b/beanstalk_worker/management/commands/run_task.py index 6ac09dbe5b..f3271a17f2 100644 --- a/beanstalk_worker/management/commands/run_task.py +++ b/beanstalk_worker/management/commands/run_task.py @@ -3,7 +3,7 @@ from django.core.management.base import BaseCommand from beanstalk_worker import task_service -from iaso.models.base import Task +from iaso.models.task import Task class Command(BaseCommand): diff --git a/beanstalk_worker/services.py b/beanstalk_worker/services.py index b1203ef390..013fffd5a3 100644 --- a/beanstalk_worker/services.py +++ b/beanstalk_worker/services.py @@ -12,7 +12,8 @@ from django.db import connection from django.utils import timezone -from iaso.models.base import KILLED, QUEUED, RUNNING, Task +from iaso.models.base import KILLED, QUEUED, RUNNING +from iaso.models.task import Task logger = getLogger(__name__) diff --git a/beanstalk_worker/views.py b/beanstalk_worker/views.py index c4f6c19af5..2143148193 100644 --- a/beanstalk_worker/views.py +++ b/beanstalk_worker/views.py @@ -10,7 +10,8 @@ from django.utils import timezone from django.views.decorators.csrf import csrf_exempt -from iaso.models.base import QUEUED, RUNNING, Task +from iaso.models.base import QUEUED, RUNNING +from iaso.models.task import Task from . import task_service diff --git a/entrypoint.sh b/entrypoint.sh index 716c5003a8..bf7cdfd519 100755 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -100,6 +100,10 @@ case "$1" in python "${@:2}" ;; * ) - show_help + if [[ $2 == /opt/.pycharm_helpers/* ]]; then + ${@} + else + show_help + fi ;; esac diff --git a/hat/assets/js/apps/Iaso/domains/app/translations/en.json b/hat/assets/js/apps/Iaso/domains/app/translations/en.json index ca36c59c29..a9f604e0b4 100644 --- a/hat/assets/js/apps/Iaso/domains/app/translations/en.json +++ b/hat/assets/js/apps/Iaso/domains/app/translations/en.json @@ -813,6 +813,7 @@ "iaso.label.submissionsLocations": "Submissions locations", "iaso.label.submitter": "Submitter", "iaso.label.submitterTeam": "Submitter team", + "iaso.label.task": "Task", "iaso.label.tasks": "Tasks", "iaso.label.team": "Team", "iaso.label.teams": "Teams", @@ -1513,6 +1514,7 @@ "iaso.tasks.last_launch_by": "Last launch:", "iaso.tasks.message": "Message", "iaso.tasks.name": "Name", + "iaso.tasks.no_logs_to_show": "No logs to show.", "iaso.tasks.polioNotificationImport.details": "Polio Notifications Import Details", "iaso.tasks.polioNotificationImport.errors": "{count} error(s). The following data couldn't be imported.", "iaso.tasks.progress": "Progress", diff --git a/hat/assets/js/apps/Iaso/domains/app/translations/es.json b/hat/assets/js/apps/Iaso/domains/app/translations/es.json index 4e91120658..5cba2f3864 100644 --- a/hat/assets/js/apps/Iaso/domains/app/translations/es.json +++ b/hat/assets/js/apps/Iaso/domains/app/translations/es.json @@ -767,6 +767,7 @@ "iaso.label.submissionsLocations": "Submissions locations", "iaso.label.submitter": "Submitter", "iaso.label.submitterTeam": "Submitter team", + "iaso.label.tasks": "Task", "iaso.label.tasks": "Tasks", "iaso.label.team": "Team", "iaso.label.teams": "Teams", @@ -1213,5 +1214,6 @@ "iaso.projets.featureflag.mobile_check_ou_update": "Móvil: Advertir al usuario cuando las unidades organizativas han sido actualizadas", "iaso.projets.featureflag.mobile_entity_limited_search": "Móvil: Limitar búsqueda de entidades", "iaso.projets.featureflag.mobile_entity_no_creation": "Móvil: El usuario no puede crear una entidad", - "iaso.snackBar.deleteEntityTypeError": "Se ha producido un error al eliminar un tipo de entidad" + "iaso.snackBar.deleteEntityTypeError": "Se ha producido un error al eliminar un tipo de entidad", + "iaso.tasks.no_logs_to_show": "No hay registros para mostrar." } \ No newline at end of file diff --git a/hat/assets/js/apps/Iaso/domains/app/translations/fr.json b/hat/assets/js/apps/Iaso/domains/app/translations/fr.json index 3bdbe3e935..890d9fcd6c 100644 --- a/hat/assets/js/apps/Iaso/domains/app/translations/fr.json +++ b/hat/assets/js/apps/Iaso/domains/app/translations/fr.json @@ -813,6 +813,7 @@ "iaso.label.submissionsLocations": "Emplacements des soumissions", "iaso.label.submitter": "Soumis par", "iaso.label.submitterTeam": "Equipe d'origine", + "iaso.label.task": "Tâche", "iaso.label.tasks": "Tâches", "iaso.label.team": "Equipe", "iaso.label.teams": "Equipes", @@ -1514,6 +1515,7 @@ "iaso.tasks.last_launch_by": "Dernière exécution :", "iaso.tasks.message": "Message", "iaso.tasks.name": "Nom", + "iaso.tasks.no_logs_to_show": "Aucun journal à afficher.", "iaso.tasks.polioNotificationImport.details": "Détails de l'importation des notifications Polio", "iaso.tasks.polioNotificationImport.errors": "{count} erreur(s). L'import des données suivantes a échoué.", "iaso.tasks.progress": "Avancement", diff --git a/hat/assets/js/apps/Iaso/domains/tasks/components/ActionCell.tsx b/hat/assets/js/apps/Iaso/domains/tasks/components/ActionCell.tsx new file mode 100644 index 0000000000..5ce42758af --- /dev/null +++ b/hat/assets/js/apps/Iaso/domains/tasks/components/ActionCell.tsx @@ -0,0 +1,62 @@ +import React, { FunctionComponent } from 'react'; +import ReplayIcon from '@mui/icons-material/Replay'; +import { IconButton, useSafeIntl } from 'bluesquare-components'; +import { NotificationImportDetailModal } from 'Iaso/domains/tasks/components/NotificationImportDetailModal'; +import { TaskLogsModal } from 'Iaso/domains/tasks/components/TaskLogsModal'; +import { useKillTask, useRelaunchTask } from 'Iaso/domains/tasks/hooks/api'; +import MESSAGES from 'Iaso/domains/tasks/messages'; +import { Task } from 'Iaso/domains/tasks/types'; +import { userHasPermission } from 'Iaso/domains/users/utils'; +import { POLIO_NOTIFICATIONS } from 'Iaso/utils/permissions'; +import { useCurrentUser } from 'Iaso/utils/usersUtils'; + +export type Props = { + task: Task; +}; + +export const TaskActionCell: FunctionComponent = ({ task }) => { + const { formatMessage } = useSafeIntl(); + const hasPolioNotificationsPerm = userHasPermission( + POLIO_NOTIFICATIONS, + useCurrentUser(), + ); + const { mutateAsync: killTaskAction } = useKillTask(); + const { mutateAsync: relaunchTaskAction } = useRelaunchTask(); + return ( +
+ {['QUEUED', 'RUNNING', 'UNKNOWN'].includes(task.status) && + !task.should_be_killed && ( + + killTaskAction({ + id: task.id, + should_be_killed: true, + }) + } + icon="stop" + tooltipMessage={MESSAGES.killTask} + /> + )} + {task.status === 'ERRORED' && ( + + relaunchTaskAction({ + id: task.id, + }) + } + overrideIcon={ReplayIcon} + tooltipMessage={MESSAGES.relaunch} + /> + )} + {task.should_be_killed && + task.status === 'RUNNING' && + formatMessage(MESSAGES.killSignalSent)} + {hasPolioNotificationsPerm && + ['SUCCESS', 'ERRORED'].includes(task.status) && + task.name === 'create_polio_notifications_async' && ( + + )} + +
+ ); +}; diff --git a/hat/assets/js/apps/Iaso/domains/tasks/components/StatusCell.tsx b/hat/assets/js/apps/Iaso/domains/tasks/components/StatusCell.tsx new file mode 100644 index 0000000000..c07d183f68 --- /dev/null +++ b/hat/assets/js/apps/Iaso/domains/tasks/components/StatusCell.tsx @@ -0,0 +1,73 @@ +import React, { FunctionComponent } from 'react'; +import { Chip } from '@mui/material'; +import { useSafeIntl } from 'bluesquare-components'; +import MESSAGES from 'Iaso/domains/tasks/messages'; +import { Task } from 'Iaso/domains/tasks/types'; +import { SxStyles } from 'Iaso/types/general'; + +export type Props = { + task: Task; +}; + +const getTranslatedStatusMessage = ( + formatMessage: (record: Record) => string, + status: string, +): string => { + // Return untranslated status if not translation available + return MESSAGES[status.toLowerCase()] + ? formatMessage(MESSAGES[status.toLowerCase()]) + : status; +}; + +const getStatusColor = ( + status: string, +): 'info' | 'success' | 'error' | 'warning' => { + if (['QUEUED', 'RUNNING'].includes(status)) { + return 'info'; + } + if (['EXPORTED', 'SUCCESS'].includes(status)) { + return 'success'; + } + if (status === 'ERRORED') { + return 'error'; + } + return 'warning'; +}; + +const safePercent = (a: number, b: number): string => { + if (b === 0) { + return ''; + } + const percent = 100 * (a / b); + return `${percent.toFixed(2)}%`; +}; + +const styles: SxStyles = { + chip: { + color: 'white', + }, +}; + +export const StatusCell: FunctionComponent = ({ task }) => { + const { formatMessage } = useSafeIntl(); + return ( + + {task.status === 'RUNNING' && task.end_value > 0 ? ( + `${task.progress_value}/${task.end_value} (${safePercent( + task.progress_value, + task.end_value, + )})` + ) : ( + + )} + + ); +}; diff --git a/hat/assets/js/apps/Iaso/domains/tasks/components/TaskBaseInfo.tsx b/hat/assets/js/apps/Iaso/domains/tasks/components/TaskBaseInfo.tsx new file mode 100644 index 0000000000..d5897712c1 --- /dev/null +++ b/hat/assets/js/apps/Iaso/domains/tasks/components/TaskBaseInfo.tsx @@ -0,0 +1,73 @@ +import React, { FunctionComponent, ReactNode } from 'react'; +import { Table, TableBody, TableRow, TableCell } from '@mui/material'; +import { TablePropsSizeOverrides } from '@mui/material/Table/Table'; +import { makeStyles } from '@mui/styles'; +import { OverridableStringUnion } from '@mui/types/esm'; +import { useSafeIntl } from 'bluesquare-components'; + +import moment from 'moment'; +import { StatusCell } from 'Iaso/domains/tasks/components/StatusCell'; +import getDisplayName from 'Iaso/utils/usersUtils'; +import MESSAGES from '../messages'; + +import { Task } from '../types'; + +const useStyles = makeStyles(theme => ({ + leftCell: { + // @ts-ignore + borderRight: `1px solid ${theme.palette.ligthGray.border}`, + fontWeight: 'bold', + }, +})); + +type RowProps = { + label: string; + value?: string | ReactNode; +}; + +const Row: FunctionComponent = ({ label, value }) => { + const classes = useStyles(); + return ( + + {label} + {value} + + ); +}; + +type Props = { + task: Task; + size?: OverridableStringUnion<'small' | 'medium', TablePropsSizeOverrides>; +}; +export const TaskBaseInfo: FunctionComponent = ({ task, size }) => { + const { formatMessage } = useSafeIntl(); + return ( + + + + + + + {task.ended_at && ( + + )} + : ''} + /> + +
+ ); +}; diff --git a/hat/assets/js/apps/Iaso/domains/tasks/components/TaskDetails.tsx b/hat/assets/js/apps/Iaso/domains/tasks/components/TaskDetails.tsx index 3898f22dcc..27381d391a 100644 --- a/hat/assets/js/apps/Iaso/domains/tasks/components/TaskDetails.tsx +++ b/hat/assets/js/apps/Iaso/domains/tasks/components/TaskDetails.tsx @@ -1,8 +1,8 @@ -import { defineMessages } from 'react-intl'; import React, { FunctionComponent } from 'react'; -import { useQuery } from 'react-query'; import { Button, Container } from '@mui/material'; import { useSafeIntl } from 'bluesquare-components'; +import { defineMessages } from 'react-intl'; +import { useQuery } from 'react-query'; // @ts-ignore import { Task } from 'Iaso/domains/tasks/types'; // @ts-ignore @@ -25,7 +25,7 @@ const MESSAGES = defineMessages({ }); type Props = { - task: Task; + task: Task; }; const TaskDetails: FunctionComponent = ({ task }) => { diff --git a/hat/assets/js/apps/Iaso/domains/tasks/components/TaskLogMessages.tsx b/hat/assets/js/apps/Iaso/domains/tasks/components/TaskLogMessages.tsx new file mode 100644 index 0000000000..0289148726 --- /dev/null +++ b/hat/assets/js/apps/Iaso/domains/tasks/components/TaskLogMessages.tsx @@ -0,0 +1,40 @@ +import React, { FunctionComponent } from 'react'; +import { Grid } from '@mui/material'; +import moment from 'moment'; +import { TaskLog } from 'Iaso/domains/tasks/types'; + +export type Props = { + messages: TaskLog[]; +}; + +export const TaskLogMessages: FunctionComponent = ({ messages }) => { + return ( + <> + {messages.map(log => { + return ( + + + {moment.unix(log.created_at).format('LTS')} + + +
+                                {log.message}
+                            
+
+
+ ); + })} + + ); +}; diff --git a/hat/assets/js/apps/Iaso/domains/tasks/components/TaskLogsModal.tsx b/hat/assets/js/apps/Iaso/domains/tasks/components/TaskLogsModal.tsx new file mode 100644 index 0000000000..b03d51d107 --- /dev/null +++ b/hat/assets/js/apps/Iaso/domains/tasks/components/TaskLogsModal.tsx @@ -0,0 +1,102 @@ +import React, { FunctionComponent, useEffect, useRef, useState } from 'react'; +import { Receipt } from '@mui/icons-material'; +import { Box, CircularProgress, IconButton } from '@mui/material'; +import { AlertModal, makeFullModal, useSafeIntl } from 'bluesquare-components'; +import { useQueryClient } from 'react-query'; +import WidgetPaper from 'Iaso/components/papers/WidgetPaperComponent'; +import { TaskBaseInfo } from 'Iaso/domains/tasks/components/TaskBaseInfo'; +import { TaskLogMessages } from 'Iaso/domains/tasks/components/TaskLogMessages'; +import { useGetLogs, useGetTask } from 'Iaso/domains/tasks/hooks/api'; +import { Task } from 'Iaso/domains/tasks/types'; +import MESSAGES from '../messages'; + +export type Props = { + task: Task; + isOpen: boolean; + closeDialog: () => void; +}; + +const TaskLogsModal: FunctionComponent = ({ + task, + isOpen, + closeDialog, +}) => { + const queryClient = useQueryClient(); + const messagesEndRef = useRef(null); + const [isRunning, setRunning] = useState(false); + const { data, isFetching } = useGetLogs(task.id, isRunning); + const { formatMessage } = useSafeIntl(); + useEffect(() => { + setRunning(['RUNNING', 'QUEUED'].includes(data?.status ?? task.status)); + if (task.status != data?.status) { + queryClient.invalidateQueries(['tasks']).then(); + } + }, [data, task, setRunning, queryClient]); + const { data: fetchedTask } = useGetTask(task.id); + + useEffect(() => { + messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); + }, [data, messagesEndRef]); + return ( + + + + + + {!isRunning && (data == null || data?.logs.length == 0) && ( +

+ {formatMessage(MESSAGES.noLogsToShow)} +

+ )} + {data?.logs && } +
+ + {(isFetching || isRunning) && ( + + + + )} + + ); +}; + +type IconButtonProps = { + onClick: () => void; +}; + +const Icon: FunctionComponent = ({ onClick }) => { + return ( + + + + ); +}; + +const modalWithIconButton = makeFullModal(TaskLogsModal, Icon); + +export { modalWithIconButton as TaskLogsModal }; diff --git a/hat/assets/js/apps/Iaso/domains/tasks/config.tsx b/hat/assets/js/apps/Iaso/domains/tasks/config.tsx index 0a4ff899fd..7e837c6279 100644 --- a/hat/assets/js/apps/Iaso/domains/tasks/config.tsx +++ b/hat/assets/js/apps/Iaso/domains/tasks/config.tsx @@ -1,61 +1,19 @@ import React, { useMemo } from 'react'; -import { Chip } from '@mui/material'; -import ReplayIcon from '@mui/icons-material/Replay'; import { - IconButton, displayDateFromTimestamp, Expander, Column, useSafeIntl, } from 'bluesquare-components'; -import { UseMutateAsyncFunction } from 'react-query'; +import { DateTimeCell } from 'Iaso/components/Cells/DateTimeCell'; +import { TaskActionCell } from 'Iaso/domains/tasks/components/ActionCell'; +import { getDisplayName } from 'Iaso/utils/usersUtils'; +import { StatusCell } from './components/StatusCell'; import MESSAGES from './messages'; -import { DateTimeCell } from '../../components/Cells/DateTimeCell'; -import { NotificationImportDetailModal } from './components/NotificationImportDetailModal'; -import { SxStyles } from '../../types/general'; -import { getDisplayName } from '../../utils/usersUtils'; - -const getTranslatedStatusMessage = (formatMessage, status) => { - // Return untranslated status if not translation available - return MESSAGES[status.toLowerCase()] - ? formatMessage(MESSAGES[status.toLowerCase()]) - : status; -}; - -const getStatusColor = status => { - if (['QUEUED', 'RUNNING'].includes(status)) { - return 'info'; - } - if (['EXPORTED', 'SUCCESS'].includes(status)) { - return 'success'; - } - if (status === 'ERRORED') { - return 'error'; - } - return 'warning'; -}; - -const safePercent = (a, b) => { - if (b === 0) { - return ''; - } - const percent = 100 * (a / b); - return `${percent.toFixed(2)}%`; -}; - -const styles: SxStyles = { - chip: { - color: 'white', - }, -}; type TaskColumn = Partial & { expander?: boolean; Expander?: any }; -export const useTasksTableColumns = ( - killTaskAction: UseMutateAsyncFunction, - relaunchTaskAction: UseMutateAsyncFunction, - hasPolioNotificationsPerm: boolean, -): TaskColumn[] => { +export const useTasksTableColumns = (): TaskColumn[] => { const { formatMessage } = useSafeIntl(); return useMemo( () => [ @@ -69,29 +27,7 @@ export const useTasksTableColumns = ( sortable: true, accessor: 'status', Cell: settings => { - return ( - - {settings.value === 'RUNNING' && - settings.row.original.end_value > 0 ? ( - `${settings.row.original.progress_value}/${ - settings.row.original.end_value - } (${safePercent( - settings.row.original.progress_value, - settings.row.original.end_value, - )})` - ) : ( - - )} - - ); + return ; }, }, { @@ -186,49 +122,7 @@ export const useTasksTableColumns = ( sortable: false, width: 150, Cell: settings => ( -
- {['QUEUED', 'RUNNING', 'UNKNOWN'].includes( - settings.row.original.status, - ) === true && - settings.row.original.should_be_killed === - false && ( - - killTaskAction({ - id: settings.row.original.id, - should_be_killed: true, - }) - } - icon="stop" - tooltipMessage={MESSAGES.killTask} - /> - )} - {settings.row.original.status === 'ERRORED' && ( - - relaunchTaskAction({ - id: settings.row.original.id, - }) - } - overrideIcon={ReplayIcon} - tooltipMessage={MESSAGES.relaunch} - /> - )} - {settings.row.original.should_be_killed === true && - settings.row.original.status === 'RUNNING' && - formatMessage(MESSAGES.killSignalSent)} - {hasPolioNotificationsPerm && - ['SUCCESS', 'ERRORED'].includes( - settings.row.original.status, - ) && - settings.row.original.name === - 'create_polio_notifications_async' && ( - - )} -
+ ), }, { @@ -238,11 +132,6 @@ export const useTasksTableColumns = ( Expander, }, ], - [ - formatMessage, - hasPolioNotificationsPerm, - killTaskAction, - relaunchTaskAction, - ], + [formatMessage], ); }; diff --git a/hat/assets/js/apps/Iaso/domains/tasks/hooks/api.ts b/hat/assets/js/apps/Iaso/domains/tasks/hooks/api.ts index 5a291621bf..a7714beea0 100644 --- a/hat/assets/js/apps/Iaso/domains/tasks/hooks/api.ts +++ b/hat/assets/js/apps/Iaso/domains/tasks/hooks/api.ts @@ -1,10 +1,11 @@ -import { UseQueryResult } from 'react-query'; +import { UseMutationResult, UseQueryResult } from 'react-query'; -import { DropdownOptionsWithOriginal } from '../../../types/utils'; -import { getRequest } from '../../../libs/Api'; -import { useSnackQuery } from '../../../libs/apiHooks'; +import MESSAGES from 'Iaso/domains/tasks/messages'; +import { getRequest, patchRequest } from 'Iaso/libs/Api'; +import { useSnackMutation, useSnackQuery } from 'Iaso/libs/apiHooks'; +import { DropdownOptionsWithOriginal } from 'Iaso/types/utils'; -import { PolioNotificationImport } from '../types'; +import { PolioNotificationImport, Task, TaskLogApiResponse } from '../types'; export const useGetPolioNotificationImport = ( polioNotificationImportId: number | string | undefined, @@ -44,3 +45,48 @@ export const useGetTaskTypes = (): UseQueryResult< }, }); }; + +export const useKillTask = (): UseMutationResult => + useSnackMutation( + (task: Task) => patchRequest(`/api/tasks/${task.id}/`, task), + MESSAGES.patchTaskSuccess, + MESSAGES.patchTaskError, + ['tasks'], + ); + +export const useRelaunchTask = (): UseMutationResult => + useSnackMutation( + (task: Task) => + patchRequest(`/api/tasks/${task.id}/relaunch/`, task), + MESSAGES.patchTaskSuccess, + MESSAGES.patchTaskError, + ['tasks'], + ); + +export const useGetLogs = ( + taskId: number, + autoRefresh: boolean, +): UseQueryResult => { + return useSnackQuery({ + queryKey: ['tasks'], + queryFn: () => getRequest(`/api/tasks/${taskId}/logs/`), + options: { + retry: false, + keepPreviousData: true, + refetchInterval: autoRefresh ? 1000 : false, + }, + }); +}; + +export const useGetTask = ( + taskId: number, +): UseQueryResult, Error> => { + return useSnackQuery({ + queryKey: ['tasks', taskId], + queryFn: () => getRequest(`/api/tasks/${taskId}/`), + options: { + retry: false, + keepPreviousData: true, + }, + }); +}; diff --git a/hat/assets/js/apps/Iaso/domains/tasks/index.tsx b/hat/assets/js/apps/Iaso/domains/tasks/index.tsx index 74c44b7e49..718b6a8e71 100644 --- a/hat/assets/js/apps/Iaso/domains/tasks/index.tsx +++ b/hat/assets/js/apps/Iaso/domains/tasks/index.tsx @@ -1,24 +1,21 @@ import React from 'react'; +import Autorenew from '@mui/icons-material/Autorenew'; import { Box, Button } from '@mui/material'; import { makeStyles } from '@mui/styles'; -import Autorenew from '@mui/icons-material/Autorenew'; import { commonStyles, useSafeIntl } from 'bluesquare-components'; -import { getRequest, patchRequest } from 'Iaso/libs/Api'; -import { useSnackMutation, useSnackQuery } from 'Iaso/libs/apiHooks'; import TopBar from 'Iaso/components/nav/TopBarComponent'; -import { baseUrls } from 'Iaso/constants/urls'; import { TableWithDeepLink } from 'Iaso/components/tables/TableWithDeepLink'; +import { baseUrls } from 'Iaso/constants/urls'; import { TaskDetails } from 'Iaso/domains/tasks/components/TaskDetails'; +import { getRequest } from 'Iaso/libs/Api'; +import { useSnackQuery } from 'Iaso/libs/apiHooks'; +import { makeUrlWithParams } from 'Iaso/libs/utils'; +import { useParamsObject } from 'Iaso/routing/hooks/useParamsObject'; import { TaskFilters } from './components/Filters'; -import MESSAGES from './messages'; -import { POLIO_NOTIFICATIONS } from '../../utils/permissions'; -import { useCurrentUser } from '../../utils/usersUtils'; -import { useParamsObject } from '../../routing/hooks/useParamsObject'; -import { userHasPermission } from '../users/utils'; import { useTasksTableColumns } from './config'; +import MESSAGES from './messages'; import { Task, TaskParams } from './types'; -import { makeUrlWithParams } from '../../libs/utils'; const baseUrl = baseUrls.tasks; @@ -31,21 +28,6 @@ const Tasks = () => { const classes: Record = useStyles(); const params = useParamsObject(baseUrl) as unknown as TaskParams; - const { mutateAsync: killTaskAction } = useSnackMutation( - (task: Task) => patchRequest(`/api/tasks/${task.id}/`, task), - MESSAGES.patchTaskSuccess, - MESSAGES.patchTaskError, - ['tasks'], - ); - - const { mutateAsync: relaunchTaskAction } = useSnackMutation( - (task: Task) => - patchRequest(`/api/tasks/${task.id}/relaunch/`, task), - MESSAGES.patchTaskSuccess, - MESSAGES.patchTaskError, - ['tasks'], - ); - const urlParams = { limit: params.pageSize ? params.pageSize : 10, order: params.order, @@ -67,16 +49,7 @@ const Tasks = () => { MESSAGES.fetchTasksError, ); - const hasPolioNotificationsPerm = userHasPermission( - POLIO_NOTIFICATIONS, - useCurrentUser(), - ); - - const columns = useTasksTableColumns( - killTaskAction, - relaunchTaskAction, - hasPolioNotificationsPerm, - ); + const columns = useTasksTableColumns(); return ( <> @@ -98,6 +71,8 @@ const Tasks = () => { it.id} + expanded={{}} data={data?.tasks ?? []} pages={data?.pages} count={data?.count} diff --git a/hat/assets/js/apps/Iaso/domains/tasks/messages.js b/hat/assets/js/apps/Iaso/domains/tasks/messages.js index 9268d59961..e174ab85b5 100644 --- a/hat/assets/js/apps/Iaso/domains/tasks/messages.js +++ b/hat/assets/js/apps/Iaso/domains/tasks/messages.js @@ -45,6 +45,10 @@ const MESSAGES = defineMessages({ defaultMessage: 'Date de fin', id: 'iaso.tasks.timeEnd', }, + task: { + defaultMessage: 'Task', + id: 'iaso.label.task', + }, tasks: { defaultMessage: 'Tasks', id: 'iaso.label.tasks', @@ -126,6 +130,10 @@ const MESSAGES = defineMessages({ defaultMessage: 'Type', id: 'iaso.tasks.type', }, + noLogsToShow: { + defaultMessage: 'No logs to show.', + id: 'iaso.tasks.no_logs_to_show', + }, }); export default MESSAGES; diff --git a/hat/assets/js/apps/Iaso/domains/tasks/types.ts b/hat/assets/js/apps/Iaso/domains/tasks/types.ts index edc72fe87b..905d7d5aea 100644 --- a/hat/assets/js/apps/Iaso/domains/tasks/types.ts +++ b/hat/assets/js/apps/Iaso/domains/tasks/types.ts @@ -1,5 +1,5 @@ -import { Nullable } from '../../types/utils'; import { UrlParams } from 'bluesquare-components'; +import { Nullable } from '../../types/utils'; export type TaskStatus = | 'RUNNING' @@ -18,6 +18,7 @@ type User = { export type Task = { id: number; + name: string; created_at: number; // date started_at: number; // date ended_at: Nullable; // date @@ -31,6 +32,16 @@ export type Task = { polio_notification_import_id?: number; }; +export type TaskLog = { + message: string; + created_at: number; // date +}; + +export type TaskLogApiResponse = { + status: TaskStatus; + logs: TaskLog[]; +}; + export type TaskApiResponse = { task: Task; }; diff --git a/iaso/admin/base.py b/iaso/admin/base.py index 7a5575ec19..b2cadf9128 100644 --- a/iaso/admin/base.py +++ b/iaso/admin/base.py @@ -77,6 +77,7 @@ StorageLogEntry, StoragePassword, Task, + TaskLog, TenantUser, UserRole, Workflow, @@ -559,6 +560,13 @@ def get_queryset(self, request): return super().get_queryset(request).prefetch_related("launcher") +@admin.register(TaskLog) +class TaskLogAdmin(admin.ModelAdmin): + list_display = ("task", "created_at", "message") + list_filter = ["task"] + readonly_fields = ["created_at"] + + @admin.register(SourceVersion) @admin_attr_decorator class SourceVersionAdmin(admin.ModelAdmin): diff --git a/iaso/api/deduplication/algos/base.py b/iaso/api/deduplication/algos/base.py index c34138279e..76bff6d802 100644 --- a/iaso/api/deduplication/algos/base.py +++ b/iaso/api/deduplication/algos/base.py @@ -1,7 +1,7 @@ from abc import ABC, abstractmethod from typing import List -from iaso.models.base import Task +from iaso.models.task import Task from ..common import PotentialDuplicate diff --git a/iaso/api/deduplication/algos/finalize.py b/iaso/api/deduplication/algos/finalize.py index 1d9d5ee23b..d6c2d989b0 100644 --- a/iaso/api/deduplication/algos/finalize.py +++ b/iaso/api/deduplication/algos/finalize.py @@ -5,7 +5,7 @@ from iaso.api.deduplication.common import PotentialDuplicate from iaso.models import EntityDuplicate -from iaso.models.base import Task +from iaso.models.task import Task def create_entity_duplicates(task: Task, potential_duplicates: List[PotentialDuplicate]) -> None: diff --git a/iaso/api/deduplication/algos/levenshtein.py b/iaso/api/deduplication/algos/levenshtein.py index f6299a7faf..61fdc847b7 100644 --- a/iaso/api/deduplication/algos/levenshtein.py +++ b/iaso/api/deduplication/algos/levenshtein.py @@ -2,7 +2,7 @@ from django.db import connection -from iaso.models.base import Task +from iaso.models.task import Task from ..common import PotentialDuplicate from .base import DeduplicationAlgorithm diff --git a/iaso/api/openhexa/views.py b/iaso/api/openhexa/views.py index b0d5b2ecb8..0f9bce76c4 100644 --- a/iaso/api/openhexa/views.py +++ b/iaso/api/openhexa/views.py @@ -18,8 +18,9 @@ TaskUpdateSerializer, ) from iaso.api.tasks.views import ExternalTaskModelViewSet -from iaso.models.base import RUNNING, Task +from iaso.models.base import RUNNING from iaso.models.json_config import Config +from iaso.models.task import Task logger = logging.getLogger(__name__) diff --git a/iaso/api/payments/serializers.py b/iaso/api/payments/serializers.py index 4b249aabdf..df8fb10ff4 100644 --- a/iaso/api/payments/serializers.py +++ b/iaso/api/payments/serializers.py @@ -6,8 +6,8 @@ from iaso.api.payments.filters.potential_payments import filter_by_dates, filter_by_forms, filter_by_parent from iaso.api.payments.pagination import PaymentPagination from iaso.models import OrgUnitChangeRequest, Payment, PaymentLot, PotentialPayment -from iaso.models.base import Task from iaso.models.payments import PaymentStatuses +from iaso.models.task import Task from ..common import TimestampField diff --git a/iaso/api/tasks/serializers.py b/iaso/api/tasks/serializers.py index 8a8a905c1d..855ec5613f 100644 --- a/iaso/api/tasks/serializers.py +++ b/iaso/api/tasks/serializers.py @@ -5,7 +5,8 @@ from rest_framework import serializers from iaso.api.common import TimestampField, UserSerializer -from iaso.models.base import ERRORED, KILLED, RUNNING, SUCCESS, Task +from iaso.models.base import ERRORED, KILLED, RUNNING, SUCCESS +from iaso.models.task import Task, TaskLog class TaskSerializer(serializers.ModelSerializer): @@ -14,7 +15,7 @@ class TaskSerializer(serializers.ModelSerializer): class Meta: model = Task - # Do not include the params, it can contains sensitive information such as passwords + # Do not include the params, it can contain sensitive information such as passwords fields = [ "id", "created_at", @@ -133,3 +134,13 @@ def create(self, validated_data): ) task.save() return task + + +class TaskLogSerializer(serializers.Serializer): + created_at = TimestampField() + message = serializers.CharField() + + class Meta: + model = TaskLog + fields = ["created_at", "message"] + read_only_fields = ["created_at", "message"] diff --git a/iaso/api/tasks/views.py b/iaso/api/tasks/views.py index 234b7b718a..55142889ce 100644 --- a/iaso/api/tasks/views.py +++ b/iaso/api/tasks/views.py @@ -17,12 +17,14 @@ TaskTypeFilterBackend, UsersFilterBackend, ) -from iaso.models.base import ERRORED, QUEUED, RUNNING, SKIPPED, Task +from iaso.models.base import ERRORED, QUEUED, RUNNING, SKIPPED from iaso.models.json_config import Config +from iaso.models.task import Task from iaso.permissions.core_permissions import CORE_DATA_TASKS_PERMISSION from iaso.utils.s3_client import generate_presigned_url_from_s3 -from .serializers import ExternalTaskPostSerializer, ExternalTaskSerializer, TaskSerializer +from ...models import TaskLog +from .serializers import ExternalTaskPostSerializer, ExternalTaskSerializer, TaskLogSerializer, TaskSerializer task_service = LazyService("BACKGROUND_TASK_SERVICE") @@ -103,6 +105,18 @@ def generate_presigned_url(self, request, pk=None): {"presigned_url": "Could not create a presigned URL, are you sure the task generated a file?"} ) + @action(detail=True, methods=["get"], url_path="logs") + def get_logs(self, request, pk=None): + task = get_object_or_404(Task, pk=pk) + + logs = TaskLog.objects.filter(task=task) + serializer = TaskLogSerializer(logs, many=True, context=self.get_serializer_context()) + response = { + "status": task.status, + "logs": serializer.data, + } + return Response(response) + @action(detail=True, methods=["patch"], url_path="relaunch") def relaunch(self, request, pk): task = get_object_or_404(Task, pk=pk) diff --git a/iaso/migrations/0346_tasklog.py b/iaso/migrations/0346_tasklog.py new file mode 100644 index 0000000000..9bde9fbafc --- /dev/null +++ b/iaso/migrations/0346_tasklog.py @@ -0,0 +1,23 @@ +# Generated by Django 4.2.24 on 2025-10-01 09:11 + +import django.db.models.deletion + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("iaso", "0345_planning_pipeline_uuids"), + ] + + operations = [ + migrations.CreateModel( + name="TaskLog", + fields=[ + ("id", models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("message", models.TextField()), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("task", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to="iaso.task")), + ], + ), + ] diff --git a/iaso/models/__init__.py b/iaso/models/__init__.py index c6f9a7c55a..d3c4a56f0d 100644 --- a/iaso/models/__init__.py +++ b/iaso/models/__init__.py @@ -18,6 +18,7 @@ from .project import Project from .reports import Report, ReportVersion from .storage import StorageDevice, StorageLogEntry, StoragePassword +from .task import Task, TaskLog from .tenant_users import TenantUser from .workflow import Workflow, WorkflowChange, WorkflowFollowup, WorkflowVersion @@ -51,6 +52,7 @@ "InstanceLock", "InstanceFile", "InstanceQuerySet", + "KilledException", "MetricType", "MetricValue", "OrgUnit", @@ -68,9 +70,19 @@ "Report", "ReportVersion", "SourceVersion", + "STATUS_TYPE_CHOICES", + "QUEUED", + "RUNNING", + "ERRORED", + "EXPORTED", + "SUCCESS", + "SKIPPED", + "KILLED", "StorageDevice", "StorageLogEntry", "StoragePassword", + "Task", + "TaskLog", "Team", "TenantUser", "Workflow", diff --git a/iaso/models/base.py b/iaso/models/base.py index 557af30dd9..5b0a0b7013 100644 --- a/iaso/models/base.py +++ b/iaso/models/base.py @@ -10,7 +10,6 @@ from django.core.validators import MinLengthValidator from django.db import models from django.db.models import Q -from django.utils import timezone from django.utils.functional import cached_property from django.utils.text import slugify from django.utils.translation import gettext_lazy as _ @@ -260,126 +259,6 @@ def as_list(self): } -class Task(models.Model): - """Represents an asynchronous function that will be run by a background worker for things like a data import""" - - created_at = models.DateTimeField(auto_now_add=True) - started_at = models.DateTimeField(null=True, blank=True) - ended_at = models.DateTimeField(null=True, blank=True) - progress_value = models.IntegerField(default=0) - end_value = models.IntegerField(default=0) - account = models.ForeignKey(Account, on_delete=models.CASCADE) - created_by = models.ForeignKey(User, null=True, blank=True, on_delete=models.SET_NULL, related_name="created_tasks") - launcher = models.ForeignKey(User, null=True, blank=True, on_delete=models.SET_NULL) - result = models.JSONField(null=True, blank=True) - status = models.CharField(choices=STATUS_TYPE_CHOICES, max_length=40, default=QUEUED) - name = models.TextField() - params = models.JSONField(null=True, blank=True) - queue_answer = models.JSONField(null=True, blank=True) - progress_message = models.TextField(null=True, blank=True) - should_be_killed = models.BooleanField(default=False) - external = models.BooleanField(default=False) - - class Meta: - ordering = ["-created_at"] - indexes = [ - models.Index(fields=["created_at"]), - models.Index(fields=["name"]), - models.Index(fields=["status"]), - ] - - def __str__(self): - return "%s - %s - %s -%s" % ( - self.name, - self.created_by, - self.status, - self.created_at, - ) - - def as_dict(self): - return { - "id": self.id, - "created_at": self.created_at.timestamp() if self.created_at else None, - "started_at": self.started_at.timestamp() if self.started_at else None, - "ended_at": self.ended_at.timestamp() if self.ended_at else None, - "params": self.params, - "result": self.result, - "status": self.status, - "created_by": ( - self.created_by.iaso_profile.as_short_dict() - if self.created_by and self.created_by.iaso_profile - else None - ), - "launcher": ( - self.launcher.iaso_profile.as_short_dict() if self.launcher and self.launcher.iaso_profile else None - ), - "progress_value": self.progress_value, - "end_value": self.end_value, - "name": self.name, - "queue_answer": self.queue_answer, - "progress_message": self.progress_message, - "should_be_killed": self.should_be_killed, - } - - def stop_if_killed(self): - self.refresh_from_db() - if self.should_be_killed: - logger.warning(f"Stopping Task {self} as it as been marked for kill") - self.status = KILLED - self.ended_at = timezone.now() - self.result = {"result": KILLED, "message": "Killed"} - self.save() - - def report_progress_and_stop_if_killed( - self, progress_value=None, progress_message=None, end_value=None, prepend_progress=False - ): - """Save progress and check if we have been killed - We use a separate transaction, so we can report the progress even from a transaction, see services.py - """ - logger.info(f"Task {self} reported {progress_message}") - self.refresh_from_db() - if self.should_be_killed: - self.stop_if_killed() - raise KilledException("Killed by user") - - if progress_value: - self.progress_value = progress_value - if progress_message: - if prepend_progress: - self.progress_message = ( - progress_message + "\n" + self.progress_message if self.progress_message else progress_message - ) - else: - self.progress_message = progress_message - if end_value: - self.end_value = end_value - self.save() - - def report_success_with_result(self, message=None, result_data=None): - logger.info(f"Task {self} reported success with message {message}") - self.progress_message = message - self.status = SUCCESS - self.ended_at = timezone.now() - self.result = {"result": SUCCESS, "data": result_data} - self.save() - - def report_success(self, message=None): - logger.info(f"Task {self} reported success with message {message}") - self.progress_message = message - self.status = SUCCESS - self.ended_at = timezone.now() - self.result = {"result": SUCCESS, "message": message} - self.save() - - def terminate_with_error(self, message=None, exception=None): - self.refresh_from_db() - logger.error(f"Task {self} ended in error %s", message, exc_info=exception) - self.status = ERRORED - self.ended_at = timezone.now() - self.result = {"result": ERRORED, "message": message if message else "Error"} - self.save() - - class Link(models.Model): destination = models.ForeignKey( "OrgUnit", diff --git a/iaso/models/task.py b/iaso/models/task.py new file mode 100644 index 0000000000..66add611a9 --- /dev/null +++ b/iaso/models/task.py @@ -0,0 +1,184 @@ +import traceback + +from logging import getLogger +from typing import Optional + +from django.contrib.auth.models import User +from django.db import models +from django.utils import timezone + +from iaso.models import ( + ERRORED, + KILLED, + QUEUED, + STATUS_TYPE_CHOICES, + SUCCESS, + Account, + KilledException, +) + + +logger = getLogger(__name__) + + +class Task(models.Model): + """Represents an asynchronous function that will be run by a background worker for things like a data import""" + + created_at = models.DateTimeField(auto_now_add=True) + started_at = models.DateTimeField(null=True, blank=True) + ended_at = models.DateTimeField(null=True, blank=True) + progress_value = models.IntegerField(default=0) + end_value = models.IntegerField(default=0) + account = models.ForeignKey(Account, on_delete=models.CASCADE) + created_by = models.ForeignKey(User, null=True, blank=True, on_delete=models.SET_NULL, related_name="created_tasks") + launcher = models.ForeignKey(User, null=True, blank=True, on_delete=models.SET_NULL) + result = models.JSONField(null=True, blank=True) + status = models.CharField(choices=STATUS_TYPE_CHOICES, max_length=40, default=QUEUED) + name = models.TextField() + params = models.JSONField(null=True, blank=True) + queue_answer = models.JSONField(null=True, blank=True) + progress_message = models.TextField(null=True, blank=True) + should_be_killed = models.BooleanField(default=False) + external = models.BooleanField(default=False) + + class Meta: + ordering = ["-created_at"] + indexes = [ + models.Index(fields=["created_at"]), + models.Index(fields=["name"]), + models.Index(fields=["status"]), + ] + + def __str__(self): + return "%s - %s - %s -%s" % ( + self.name, + self.created_by, + self.status, + self.created_at, + ) + + def as_dict(self): + return { + "id": self.id, + "created_at": self.created_at.timestamp() if self.created_at else None, + "started_at": self.started_at.timestamp() if self.started_at else None, + "ended_at": self.ended_at.timestamp() if self.ended_at else None, + "params": self.params, + "result": self.result, + "status": self.status, + "created_by": ( + self.created_by.iaso_profile.as_short_dict() + if self.created_by and self.created_by.iaso_profile + else None + ), + "launcher": ( + self.launcher.iaso_profile.as_short_dict() if self.launcher and self.launcher.iaso_profile else None + ), + "progress_value": self.progress_value, + "end_value": self.end_value, + "name": self.name, + "queue_answer": self.queue_answer, + "progress_message": self.progress_message, + "should_be_killed": self.should_be_killed, + } + + def stop_if_killed(self): + self.refresh_from_db() + if self.should_be_killed: + logger.warning(f"Stopping Task {self} as it as been marked for kill") + self.status = KILLED + self.ended_at = timezone.now() + self.result = {"result": KILLED, "message": "Killed"} + self.save() + + def report_progress_and_stop_if_killed( + self, + progress_value: Optional[int] = None, + progress_message: Optional[str] = None, + end_value: Optional[int] = None, + prepend_progress=False, + ): + """Save progress and check if we have been killed + We use a separate transaction, so we can report the progress even from a transaction, see services.py + """ + logger.info(f"Task {self} reported {progress_message}") + self.refresh_from_db() + if self.should_be_killed: + self.stop_if_killed() + raise KilledException("Killed by user") + + if progress_value: + self.progress_value = progress_value + if progress_message: + if prepend_progress: + self.progress_message = ( + progress_message + "\n" + self.progress_message if self.progress_message else progress_message + ) + else: + self.progress_message = progress_message + if end_value: + self.end_value = end_value + self.create_log_entry_if_needed(progress_message) + self.save() + + def report_success_with_result(self, message: Optional[str] = None, result_data=None): + logger.info(f"Task {self} reported success with message {message}") + self.progress_message = message + self.status = SUCCESS + self.ended_at = timezone.now() + self.result = {"result": SUCCESS, "data": result_data} + self.create_log_entry_if_needed(message) + self.save() + + def report_success(self, message: Optional[str] = None): + logger.info(f"Task {self} reported success with message {message}") + self.progress_message = message + self.status = SUCCESS + self.ended_at = timezone.now() + self.result = {"result": SUCCESS, "message": message} + self.create_log_entry_if_needed(message) + self.save() + + def report_failure(self, e: Exception): + self.status = ERRORED + self.ended_at = timezone.now() + self.result = { + "result": ERRORED, + "message": str(e), + "stack_trace": traceback.format_exc(), + "last_progress_message": self.progress_message, + } + self.progress_message = e.message if hasattr(e, "message") else str(e) + # Extra debug info + if hasattr(e, "extra"): + self.result["extra"] = e.extra + + self.create_log_entry_if_needed(self.progress_message) + self.save() + + def terminate_with_error(self, message: Optional[str] = None, exception=None): + self.refresh_from_db() + logger.error(f"Task {self} ended in error %s", message, exc_info=exception) + self.status = ERRORED + self.ended_at = timezone.now() + self.result = {"result": ERRORED, "message": message if message else "Error"} + self.create_log_entry_if_needed(message) + self.save() + + def create_log_entry_if_needed(self, message: Optional[str]): + if message: + TaskLog.objects.create(task=self, message=message) + + +class TaskLog(models.Model): + """Tasks are updated on progress/success/error to set `progress_message` but previous messages are lost. + This is the history of all the received messages.""" + + task = models.ForeignKey(Task, on_delete=models.CASCADE) + message = models.TextField(null=False, blank=False) + created_at = models.DateTimeField(auto_now_add=True) + + def save(self, *args, **kwargs): + if self.id: + raise ValueError("Cannot update a TaskLog") + super().save(*args, **kwargs) diff --git a/iaso/tests/api/test_tasks.py b/iaso/tests/api/test_tasks.py index d549c6f634..1a73e0a60e 100644 --- a/iaso/tests/api/test_tasks.py +++ b/iaso/tests/api/test_tasks.py @@ -334,3 +334,53 @@ def test_tasks_filtering(self): ) task_ids = [t["id"] for t in response.json()["tasks"]] self.assertEqual(task_ids, [task_5.id]) + + def test_logs_not_found(self): + self.client.force_authenticate(self.johnny) + response = self.client.get("/api/tasks/100000/logs/") + self.assertEqual(response.status_code, 404) + + def test_logs_not_authenticated(self): + task = m.Task.objects.create( + progress_value=1, + end_value=1, + account=self.account, + created_by=self.johnny, + status=SUCCESS, + name="The best task", + ) + response = self.client.get(f"/api/tasks/{task.id}/logs/") + self.assertEqual(response.status_code, 401) + + def test_logs_authenticated(self): + task = m.Task.objects.create( + progress_value=1, + end_value=1, + account=self.account, + created_by=self.johnny, + status=SUCCESS, + name="The best task", + ) + task2 = m.Task.objects.create( + progress_value=1, + end_value=1, + account=self.account, + created_by=self.johnny, + status=SUCCESS, + name="The best task", + ) + + m.TaskLog.objects.create(task=task, message="We have the best task.") + m.TaskLog.objects.create(task=task, message="Simply the best task.") + m.TaskLog.objects.create(task=task, message="You can't believe how good this task is.") + + m.TaskLog.objects.create(task=task2, message="We have the worst task.") + m.TaskLog.objects.create(task=task2, message="Simply the worst task.") + self.client.force_authenticate(self.johnny) + response = self.client.get(f"/api/tasks/{task.id}/logs/") + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json()["status"], SUCCESS) + self.assertEqual(len(response.json()["logs"]), 3) + self.assertEqual(response.json()["logs"][0]["message"], "We have the best task.") + self.assertEqual(response.json()["logs"][1]["message"], "Simply the best task.") + self.assertEqual(response.json()["logs"][2]["message"], "You can't believe how good this task is.") diff --git a/iaso/tests/tasks/test_reference_instance_bulk_link.py b/iaso/tests/tasks/test_reference_instance_bulk_link.py index f52de3f64f..d33e9e3545 100644 --- a/iaso/tests/tasks/test_reference_instance_bulk_link.py +++ b/iaso/tests/tasks/test_reference_instance_bulk_link.py @@ -181,6 +181,10 @@ def test_instance_ids_wrong_account(self): self.assertEqual( self.not_linked_org_unit.reference_instances.filter(id=self.reference_instance_not_linked.id).count(), 0 ) + logs = m.TaskLog.objects.filter(task=task).all() + self.assertEqual(len(logs), 2) + self.assertEqual(logs[0].message, "Searching for Instances for link or unlink to/from Org unit") + self.assertEqual(logs[1].message, "No matching instances found") def test_multiple_updates_same_org_unit(self): """POST /api/tasks/create/instancereferencebulklink/ with instances that target the same orgunit""" @@ -215,6 +219,10 @@ def test_multiple_updates_same_org_unit(self): duplicate_reference_instance, ]: self.assertIn(str(instance.org_unit_id), result) + logs = m.TaskLog.objects.filter(task=task).all() + self.assertEqual(len(logs), 2) + self.assertEqual(logs[0].message, "Searching for Instances for link or unlink to/from Org unit") + self.assertEqual(logs[1].message, result) def test_warning_when_instance_is_not_reference(self): """POST /api/tasks/create/instancereferencebulklink/ with instances which are not reference""" @@ -345,6 +353,12 @@ def test_linking_select_all_with_filters(self): # they were filtered out self.assertNotIn(instance_filtered_out_by_user.id, reference_instances) self.assertNotIn(instance_filtered_out_by_org_unit_type.id, reference_instances) + logs = m.TaskLog.objects.filter(task=task).all() + self.assertEqual(len(logs), 4) + self.assertEqual(logs[0].message, "Searching for Instances for link or unlink to/from Org unit") + self.assertIn("sec, processed 0 instances", logs[1].message) + self.assertIn("sec, processed 1 instances", logs[2].message) + self.assertEqual(logs[3].message, result) def test_task_kill(self): """Launch the task and then kill it diff --git a/iaso/utils/powerbi.py b/iaso/utils/powerbi.py index 67e86d0277..fbc4177fd9 100644 --- a/iaso/utils/powerbi.py +++ b/iaso/utils/powerbi.py @@ -9,7 +9,8 @@ from django.shortcuts import get_object_or_404 from iaso.api.tasks.views import ExternalTaskModelViewSet -from iaso.models.base import RUNNING, SUCCESS, Task +from iaso.models.base import RUNNING, SUCCESS +from iaso.models.task import Task SP_AUTH_URL = "https://login.microsoftonline.com/{tenant_id}/oauth2/token" diff --git a/plugins/polio/models/base.py b/plugins/polio/models/base.py index 8b536633a6..0757ae3b94 100644 --- a/plugins/polio/models/base.py +++ b/plugins/polio/models/base.py @@ -32,10 +32,11 @@ from beanstalk_worker import task_decorator from iaso.models import Group, OrgUnit -from iaso.models.base import Account, Task +from iaso.models.base import Account from iaso.models.entity import UserNotAuthError from iaso.models.microplanning import Team from iaso.models.project import Project +from iaso.models.task import Task from iaso.utils import slugify_underscore from iaso.utils.models.soft_deletable import ( DefaultSoftDeletableManager, diff --git a/plugins/polio/tasks/api/refresh_lqas_im_data.py b/plugins/polio/tasks/api/refresh_lqas_im_data.py index 5d5ef3468b..8b079a0edb 100644 --- a/plugins/polio/tasks/api/refresh_lqas_im_data.py +++ b/plugins/polio/tasks/api/refresh_lqas_im_data.py @@ -7,8 +7,9 @@ from iaso.api.common import HasPermission from iaso.api.tasks.serializers import ExternalTaskPostSerializer, ExternalTaskSerializer, TaskSerializer from iaso.api.tasks.views import ExternalTaskModelViewSet -from iaso.models.base import ERRORED, RUNNING, SUCCESS, Task +from iaso.models.base import ERRORED, RUNNING, SUCCESS from iaso.models.org_unit import OrgUnit +from iaso.models.task import Task from plugins.polio.permissions import POLIO_CONFIG_PERMISSION, POLIO_PERMISSION diff --git a/plugins/polio/tasks/api/refresh_preparedness_dashboard_data.py b/plugins/polio/tasks/api/refresh_preparedness_dashboard_data.py index b1fa7d2172..f13b8b090c 100644 --- a/plugins/polio/tasks/api/refresh_preparedness_dashboard_data.py +++ b/plugins/polio/tasks/api/refresh_preparedness_dashboard_data.py @@ -4,7 +4,8 @@ from iaso.api.tasks.serializers import TaskSerializer from iaso.api.tasks.views import ExternalTaskModelViewSet -from iaso.models.base import RUNNING, Task +from iaso.models.base import RUNNING +from iaso.models.task import Task PREPAREDNESS_TASK_NAME = "Refresh Preparedness dashboard data" diff --git a/plugins/polio/tasks/api/refresh_vrf_dashboard_data.py b/plugins/polio/tasks/api/refresh_vrf_dashboard_data.py index 369d77248d..0745edc3a0 100644 --- a/plugins/polio/tasks/api/refresh_vrf_dashboard_data.py +++ b/plugins/polio/tasks/api/refresh_vrf_dashboard_data.py @@ -4,7 +4,8 @@ from iaso.api.tasks.serializers import TaskSerializer from iaso.api.tasks.views import ExternalTaskModelViewSet -from iaso.models.base import RUNNING, Task +from iaso.models.base import RUNNING +from iaso.models.task import Task VRF_TASK_NAME = "Refresh VRF dashboard data"