diff --git a/packages/api-client/lib/openapi/api.ts b/packages/api-client/lib/openapi/api.ts index b509ca3a2..4d1f47daa 100644 --- a/packages/api-client/lib/openapi/api.ts +++ b/packages/api-client/lib/openapi/api.ts @@ -8431,6 +8431,69 @@ export const TasksApiAxiosParamCreator = function (configuration?: Configuration ...options.headers, }; + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, + /** + * + * @summary Update Schedule Task + * @param {number} taskId + * @param {PostScheduledTaskRequest} postScheduledTaskRequest + * @param {string} [exceptDate] + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + updateScheduleTaskScheduledTasksTaskIdUpdatePost: async ( + taskId: number, + postScheduledTaskRequest: PostScheduledTaskRequest, + exceptDate?: string, + options: AxiosRequestConfig = {}, + ): Promise => { + // verify required parameter 'taskId' is not null or undefined + assertParamExists('updateScheduleTaskScheduledTasksTaskIdUpdatePost', 'taskId', taskId); + // verify required parameter 'postScheduledTaskRequest' is not null or undefined + assertParamExists( + 'updateScheduleTaskScheduledTasksTaskIdUpdatePost', + 'postScheduledTaskRequest', + postScheduledTaskRequest, + ); + const localVarPath = `/scheduled_tasks/{task_id}/update`.replace( + `{${'task_id'}}`, + encodeURIComponent(String(taskId)), + ); + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { method: 'POST', ...baseOptions, ...options }; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + if (exceptDate !== undefined) { + localVarQueryParameter['except_date'] = + (exceptDate as any) instanceof Date ? (exceptDate as any).toISOString() : exceptDate; + } + + localVarHeaderParameter['Content-Type'] = 'application/json'; + + setSearchParams(localVarUrlObj, localVarQueryParameter); + let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = { + ...localVarHeaderParameter, + ...headersFromBaseOptions, + ...options.headers, + }; + localVarRequestOptions.data = serializeDataIfNeeded( + postScheduledTaskRequest, + localVarRequestOptions, + configuration, + ); + return { url: toPathString(localVarUrlObj), options: localVarRequestOptions, @@ -8891,6 +8954,35 @@ export const TasksApiFp = function (configuration?: Configuration) { ); return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); }, + /** + * + * @summary Update Schedule Task + * @param {number} taskId + * @param {PostScheduledTaskRequest} postScheduledTaskRequest + * @param {string} [exceptDate] + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async updateScheduleTaskScheduledTasksTaskIdUpdatePost( + taskId: number, + postScheduledTaskRequest: PostScheduledTaskRequest, + exceptDate?: string, + options?: AxiosRequestConfig, + ): Promise< + ( + axios?: AxiosInstance, + basePath?: string, + ) => AxiosPromise + > { + const localVarAxiosArgs = + await localVarAxiosParamCreator.updateScheduleTaskScheduledTasksTaskIdUpdatePost( + taskId, + postScheduledTaskRequest, + exceptDate, + options, + ); + return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); + }, }; }; @@ -9271,6 +9363,30 @@ export const TasksApiFactory = function ( ) .then((request) => request(axios, basePath)); }, + /** + * + * @summary Update Schedule Task + * @param {number} taskId + * @param {PostScheduledTaskRequest} postScheduledTaskRequest + * @param {string} [exceptDate] + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + updateScheduleTaskScheduledTasksTaskIdUpdatePost( + taskId: number, + postScheduledTaskRequest: PostScheduledTaskRequest, + exceptDate?: string, + options?: any, + ): AxiosPromise { + return localVarFp + .updateScheduleTaskScheduledTasksTaskIdUpdatePost( + taskId, + postScheduledTaskRequest, + exceptDate, + options, + ) + .then((request) => request(axios, basePath)); + }, }; }; @@ -9680,4 +9796,30 @@ export class TasksApi extends BaseAPI { ) .then((request) => request(this.axios, this.basePath)); } + + /** + * + * @summary Update Schedule Task + * @param {number} taskId + * @param {PostScheduledTaskRequest} postScheduledTaskRequest + * @param {string} [exceptDate] + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof TasksApi + */ + public updateScheduleTaskScheduledTasksTaskIdUpdatePost( + taskId: number, + postScheduledTaskRequest: PostScheduledTaskRequest, + exceptDate?: string, + options?: AxiosRequestConfig, + ) { + return TasksApiFp(this.configuration) + .updateScheduleTaskScheduledTasksTaskIdUpdatePost( + taskId, + postScheduledTaskRequest, + exceptDate, + options, + ) + .then((request) => request(this.axios, this.basePath)); + } } diff --git a/packages/api-client/lib/version.ts b/packages/api-client/lib/version.ts index 091417ba6..115944cbc 100644 --- a/packages/api-client/lib/version.ts +++ b/packages/api-client/lib/version.ts @@ -3,6 +3,6 @@ import { version as rmfModelVer } from 'rmf-models'; export const version = { rmfModels: rmfModelVer, - rmfServer: 'c8c43e395caae7ccc858008b7663d30914b2cc62', + rmfServer: '60cfa28fb26433cb33254ba1522fc00466fe7146', openapiGenerator: '6.2.1', }; diff --git a/packages/api-client/schema/index.ts b/packages/api-client/schema/index.ts index 0d7a8e22a..259fbfb4d 100644 --- a/packages/api-client/schema/index.ts +++ b/packages/api-client/schema/index.ts @@ -1283,6 +1283,53 @@ export default { }, }, }, + '/scheduled_tasks/{task_id}/update': { + post: { + tags: ['Tasks'], + summary: 'Update Schedule Task', + operationId: 'update_schedule_task_scheduled_tasks__task_id__update_post', + parameters: [ + { + required: true, + schema: { title: 'Task Id', type: 'integer' }, + name: 'task_id', + in: 'path', + }, + { + required: false, + schema: { title: 'Except Date', type: 'string', format: 'date-time' }, + name: 'except_date', + in: 'query', + }, + ], + requestBody: { + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/PostScheduledTaskRequest' }, + }, + }, + required: true, + }, + responses: { + '201': { + description: 'Successful Response', + content: { + 'application/json': { + schema: { + $ref: '#/components/schemas/api_server.models.tortoise_models.scheduled_task.ScheduledTask', + }, + }, + }, + }, + '422': { + description: 'Validation Error', + content: { + 'application/json': { schema: { $ref: '#/components/schemas/HTTPValidationError' } }, + }, + }, + }, + }, + }, '/favorite_tasks': { get: { tags: ['Tasks'], diff --git a/packages/api-server/api_server/routes/tasks/scheduled_tasks.py b/packages/api-server/api_server/routes/tasks/scheduled_tasks.py index 44aee63d3..874618bd1 100644 --- a/packages/api-server/api_server/routes/tasks/scheduled_tasks.py +++ b/packages/api-server/api_server/routes/tasks/scheduled_tasks.py @@ -1,5 +1,6 @@ import asyncio from datetime import datetime +from typing import Optional import schedule import tortoise.transactions @@ -159,6 +160,82 @@ async def del_scheduled_tasks_event( await schedule_task(task, task_repo) +@router.post( + "/{task_id}/update", status_code=201, response_model=ttm.ScheduledTaskPydantic +) +async def update_schedule_task( + task_id: int, + scheduled_task_request: PostScheduledTaskRequest, + except_date: Optional[datetime] = None, + task_repo: TaskRepository = Depends(task_repo_dep), +): + try: + task = await get_scheduled_task(task_id) + if task is None: + raise HTTPException(404) + # If "except_date" is provided, it means a single event is being updated. + # In this case, we perform the following steps: + # 1. Add the "except_date" to the list of exception dates for the task. + # 2. Clear all existing schedules associated with the task. + # 3. Create a new scheduled task with the requested data from the schedule form. + + async with tortoise.transactions.in_transaction(): + if except_date: + task.except_dates.append(datetime_to_date_format(except_date)) + await task.save() + + for sche in task.schedules: + schedule.clear(sche.get_id()) + + await schedule_task(task, task_repo) + + scheduled_task = await ttm.ScheduledTask.create( + task_request=scheduled_task_request.task_request.json( + exclude_none=True + ), + created_by=task.created_by, + ) + schedules = [ + ttm.ScheduledTaskSchedule(scheduled_task=scheduled_task, **x.dict()) + for x in scheduled_task_request.schedules + ] + await ttm.ScheduledTaskSchedule.bulk_create(schedules) + + await schedule_task(scheduled_task, task_repo) + else: + # If "except_date" is not provided, it means the entire series is being updated. + # In this case, we perform the following steps: + # 1. Update the task with the requested data from the schedule form and clear exception dates. + # 2. Clear all existing schedules associated with the task. + # 3. Delete all existing schedules associated with the task. + # 4. Create new schedules based on the requested data. + task.update_from_dict( + { + "task_request": scheduled_task_request.task_request.json( + exclude_none=True + ), + "except_dates": [], + } + ) + + for sche in task.schedules: + schedule.clear(sche.get_id()) + for sche in task.schedules: + await sche.delete() + + await task.save() + schedules = [ + ttm.ScheduledTaskSchedule(scheduled_task=task, **x.dict()) + for x in scheduled_task_request.schedules + ] + + await ttm.ScheduledTaskSchedule.bulk_create(schedules) + + await schedule_task(task, task_repo) + except schedule.ScheduleError as e: + raise HTTPException(422, str(e)) from e + + @router.delete("/{task_id}") async def del_scheduled_tasks(task_id: int): async with tortoise.transactions.in_transaction(): diff --git a/packages/dashboard/src/components/appbar.tsx b/packages/dashboard/src/components/appbar.tsx index 0d3a86f58..39a846ae9 100644 --- a/packages/dashboard/src/components/appbar.tsx +++ b/packages/dashboard/src/components/appbar.tsx @@ -26,7 +26,6 @@ import { } from '@mui/material'; import { ApiServerModelsTortoiseModelsAlertsAlertLeaf as Alert, - PostScheduledTaskRequest, TaskFavoritePydantic as TaskFavorite, TaskRequest, } from 'api-client'; @@ -35,11 +34,9 @@ import { AppBarTab, CreateTaskForm, CreateTaskFormProps, - getPlaces, HeaderBar, LogoButton, NavigationBar, - Schedule, useAsync, } from 'react-components'; import { useNavigate, useLocation } from 'react-router-dom'; @@ -65,6 +62,9 @@ import { RmfAppContext } from './rmf-app'; import { parseTasksFile } from './tasks/utils'; import { Subscription } from 'rxjs'; import { formatDistance } from 'date-fns'; +import { useCreateTaskFormData } from '../hooks/useCreateTaskForm'; +import { toApiSchedule } from './tasks/utils'; +import useGetUsername from '../hooks/useFetchUser'; export type TabValue = 'infrastructure' | 'robots' | 'tasks' | 'custom1' | 'custom2' | 'admin'; @@ -122,28 +122,6 @@ function AppSettings() { ); } -function toApiSchedule(taskRequest: TaskRequest, schedule: Schedule): PostScheduledTaskRequest { - const start = schedule.startOn; - const apiSchedules: PostScheduledTaskRequest['schedules'] = []; - const date = new Date(start); - const start_from = start.toISOString(); - const until = schedule.until?.toISOString(); - const hours = date.getHours().toString().padStart(2, '0'); - const minutes = date.getMinutes().toString().padStart(2, '0'); - const at = `${hours}:${minutes}`; - schedule.days[0] && apiSchedules.push({ period: 'monday', start_from, at, until }); - schedule.days[1] && apiSchedules.push({ period: 'tuesday', start_from, at, until }); - schedule.days[2] && apiSchedules.push({ period: 'wednesday', start_from, at, until }); - schedule.days[3] && apiSchedules.push({ period: 'thursday', start_from, at, until }); - schedule.days[4] && apiSchedules.push({ period: 'friday', start_from, at, until }); - schedule.days[5] && apiSchedules.push({ period: 'saturday', start_from, at, until }); - schedule.days[6] && apiSchedules.push({ period: 'sunday', start_from, at, until }); - return { - task_request: taskRequest, - schedules: apiSchedules, - }; -} - export interface AppBarProps { extraToolbarItems?: React.ReactNode; @@ -167,18 +145,16 @@ export const AppBar = React.memo(({ extraToolbarItems }: AppBarProps): React.Rea const [brandingIconPath, setBrandingIconPath] = React.useState(''); const [settingsAnchor, setSettingsAnchor] = React.useState(null); const [openCreateTaskForm, setOpenCreateTaskForm] = React.useState(false); - const [waypointNames, setWaypointNames] = React.useState([]); - const [cleaningZoneNames, setCleaningZoneNames] = React.useState([]); - const [pickupPoints, setPickupPoints] = React.useState>({}); - const [dropoffPoints, setDropoffPoints] = React.useState>({}); const [favoritesTasks, setFavoritesTasks] = React.useState([]); const [refreshTaskAppCount, setRefreshTaskAppCount] = React.useState(0); - const [username, setUsername] = React.useState(null); const [alertListAnchor, setAlertListAnchor] = React.useState(null); const [unacknowledgedAlertsNum, setUnacknowledgedAlertsNum] = React.useState(0); const [unacknowledgedAlertList, setUnacknowledgedAlertList] = React.useState([]); const curTheme = React.useContext(SettingsContext).themeMode; + const { waypointNames, pickupPoints, dropoffPoints, cleaningZoneNames } = + useCreateTaskFormData(rmf); + const username = useGetUsername(rmf); async function handleLogout(): Promise { try { @@ -188,20 +164,6 @@ export const AppBar = React.memo(({ extraToolbarItems }: AppBarProps): React.Rea } } - React.useEffect(() => { - if (!rmf) { - return; - } - (async () => { - try { - const user = (await rmf.defaultApi.getUserUserGet()).data; - setUsername(user.username); - } catch (e) { - console.log(`error getting username: ${(e as Error).message}`); - } - })(); - }, [rmf]); - React.useEffect(() => { const sub = AppEvents.refreshTaskApp.subscribe({ next: () => setRefreshTaskAppCount((oldValue) => ++oldValue), @@ -222,32 +184,6 @@ export const AppBar = React.memo(({ extraToolbarItems }: AppBarProps): React.Rea } const subs: Subscription[] = []; - subs.push( - rmf.buildingMapObs.subscribe((map) => { - const places = getPlaces(map); - const waypointNames: string[] = []; - const pickupPoints: Record = {}; - const dropoffPoints: Record = {}; - const cleaningZoneNames: string[] = []; - for (const p of places) { - if (p.pickupHandler !== undefined && p.pickupHandler.length !== 0) { - pickupPoints[p.vertex.name] = p.pickupHandler; - } - if (p.dropoffHandler !== undefined && p.dropoffHandler.length !== 0) { - dropoffPoints[p.vertex.name] = p.dropoffHandler; - } - if (p.cleaningZone !== undefined && p.cleaningZone === true) { - cleaningZoneNames.push(p.vertex.name); - } - waypointNames.push(p.vertex.name); - } - - setPickupPoints(pickupPoints); - setDropoffPoints(dropoffPoints); - setCleaningZoneNames(cleaningZoneNames); - setWaypointNames(waypointNames); - }), - ); subs.push( AppEvents.refreshAlert.subscribe({ next: () => { diff --git a/packages/dashboard/src/components/tasks/task-schedule-utils.ts b/packages/dashboard/src/components/tasks/task-schedule-utils.ts new file mode 100644 index 000000000..e5c426d8a --- /dev/null +++ b/packages/dashboard/src/components/tasks/task-schedule-utils.ts @@ -0,0 +1,163 @@ +import { ProcessedEvent } from '@aldabil/react-scheduler/types'; +import { + ApiServerModelsTortoiseModelsScheduledTaskScheduledTask as ScheduledTask, + ApiServerModelsTortoiseModelsScheduledTaskScheduledTaskScheduleLeaf as ApiSchedule, +} from 'api-client'; +import { + addMinutes, + endOfDay, + endOfMinute, + isFriday, + isMonday, + isSaturday, + isSunday, + isThursday, + isTuesday, + isWednesday, + nextFriday, + nextMonday, + nextSaturday, + nextSunday, + nextThursday, + nextTuesday, + nextWednesday, + startOfMinute, +} from 'date-fns'; +import { RecurringDays, Schedule } from 'react-components'; + +/** + * Generates a list of ProcessedEvents to occur within the query start and end, + * based on the provided schedule. + * @param start The start of the query, which is generally 00:00:00 of the first + * day in the calendar view. + * @param end The end of the query, which is generally 23:59:59 of the last day + * in the calendar view. + * @param schedule The current schedule, to be checked if there are any events + * between start and end. + * @param getEventId Callback function to get the event ID. + * @param getEventTitle Callback function to get the event title. + * @returns List of ProcessedEvents to occur within the query start and end. + */ +export const scheduleToEvents = ( + start: Date, + end: Date, + schedule: ApiSchedule, + task: ScheduledTask, + getEventId: () => number, + getEventTitle: () => string, +): ProcessedEvent[] => { + if (!schedule.at) { + console.warn('Unable to convert schedule without [at] to an event'); + return []; + } + const [hours, minutes] = schedule.at.split(':').map((s: string) => Number(s)); + let cur = new Date(start); + cur.setHours(hours); + cur.setMinutes(minutes); + + const scheStartFrom = schedule.start_from ? startOfMinute(new Date(schedule.start_from)) : null; + const scheUntil = schedule.until ? endOfMinute(new Date(schedule.until)) : null; + + let period = 8.64e7; // 1 day + switch (schedule.period) { + case 'day': + break; + case 'monday': + cur = isMonday(cur) ? cur : nextMonday(cur); + period *= 7; + break; + case 'tuesday': + cur = isTuesday(cur) ? cur : nextTuesday(cur); + period *= 7; + break; + case 'wednesday': + cur = isWednesday(cur) ? cur : nextWednesday(cur); + period *= 7; + break; + case 'thursday': + cur = isThursday(cur) ? cur : nextThursday(cur); + period *= 7; + break; + case 'friday': + cur = isFriday(cur) ? cur : nextFriday(cur); + period *= 7; + break; + case 'saturday': + cur = isSaturday(cur) ? cur : nextSaturday(cur); + period *= 7; + break; + case 'sunday': + cur = isSunday(cur) ? cur : nextSunday(cur); + period *= 7; + break; + default: + console.warn(`Unable to convert schedule with period [${schedule.period}] to events`); + return []; + } + + const events: ProcessedEvent[] = []; + while (cur <= end) { + if ( + (scheStartFrom == null || scheStartFrom <= cur) && + (scheUntil == null || scheUntil >= cur) + ) { + if (!task.except_dates.includes(cur.toLocaleDateString())) { + events.push({ + start: cur, + end: addMinutes(cur, 45), + event_id: getEventId(), + title: getEventTitle(), + }); + } + } + + cur = new Date(cur.valueOf() + period); + } + return events; +}; + +export const scheduleWithSelectedDay = (scheduleTask: ApiSchedule[], date: Date): Schedule => { + const daysArray: RecurringDays = [false, false, false, false, false, false, false]; + + const dayIndex = date.getDay(); + // Calculate the adjusted index to match React Scheduler (1 = Monday, ..., 7 = Sunday) + const adjustedIndex = dayIndex === 0 ? 7 : dayIndex; + + daysArray[adjustedIndex - 1] = true; + + return { + startOn: scheduleTask[0].start_from ? new Date(scheduleTask[0].start_from) : new Date(), + days: daysArray, + until: endOfDay(new Date(date.toISOString())), + at: scheduleTask[0].start_from ? new Date(scheduleTask[0].start_from) : new Date(), + }; +}; + +export const apiScheduleToSchedule = (scheduleTask: ApiSchedule[]): Schedule => { + const daysOfWeek = ['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday']; + + const daysArray: RecurringDays = [false, false, false, false, false, false, false]; + + for (const schedule of scheduleTask) { + const dayIndex = daysOfWeek.indexOf(schedule.period.toLowerCase()); + if (dayIndex === -1) { + throw new Error(`Invalid day: ${schedule}`); + } + + daysArray[dayIndex] = true; + } + + return { + startOn: scheduleTask[0].start_from ? new Date(scheduleTask[0].start_from) : new Date(), + days: daysArray, + until: scheduleTask[0].until ? endOfMinute(new Date(scheduleTask[0].until)) : undefined, + at: scheduleTask[0].start_from ? new Date(scheduleTask[0].start_from) : new Date(), + }; +}; + +export const getScheduledTaskTitle = (task: ScheduledTask): string => { + if (!task.task_request || !task.task_request.category) { + return `[${task.id}] Unknown`; + } + return `[${task.id}] ${task.task_request.category}`; +}; diff --git a/packages/dashboard/src/components/tasks/task-schedule.tsx b/packages/dashboard/src/components/tasks/task-schedule.tsx new file mode 100644 index 000000000..306593951 --- /dev/null +++ b/packages/dashboard/src/components/tasks/task-schedule.tsx @@ -0,0 +1,341 @@ +import { Scheduler } from '@aldabil/react-scheduler'; +import { + CellRenderedProps, + ProcessedEvent, + SchedulerHelpers, + SchedulerProps, +} from '@aldabil/react-scheduler/types'; +import { Button } from '@mui/material'; +import { + ApiServerModelsTortoiseModelsScheduledTaskScheduledTask as ScheduledTask, + ApiServerModelsTortoiseModelsScheduledTaskScheduledTaskScheduleLeaf as ApiSchedule, +} from 'api-client'; +import React from 'react'; +import { + ConfirmationDialog, + CreateTaskForm, + CreateTaskFormProps, + EventEditDeletePopup, + Schedule, +} from 'react-components'; +import { useCreateTaskFormData } from '../../hooks/useCreateTaskForm'; +import useGetUsername from '../../hooks/useFetchUser'; +import { AppControllerContext } from '../app-contexts'; +import { AppEvents } from '../app-events'; +import { RmfAppContext } from '../rmf-app'; +import { toApiSchedule } from './utils'; +import { + apiScheduleToSchedule, + getScheduledTaskTitle, + scheduleToEvents, + scheduleWithSelectedDay, +} from './task-schedule-utils'; + +enum EventScopes { + ALL = 'all', + CURRENT = 'current', +} + +interface CustomCalendarEditorProps { + scheduler: SchedulerHelpers; + value: string; + onChange: (event: React.ChangeEvent) => void; +} + +const disablingCellsWithoutEvents = ( + events: ProcessedEvent[], + { start, ...props }: CellRenderedProps, +): React.ReactElement => { + const filteredEvents = events.filter((event) => start.getTime() !== event.start.getTime()); + const disabled = filteredEvents.length > 0 || events.length === 0; + const restProps = disabled ? {} : props; + return ( +