diff --git a/packages/api-client/lib/openapi/api.ts b/packages/api-client/lib/openapi/api.ts index 00c3d5699..6e9353747 100644 --- a/packages/api-client/lib/openapi/api.ts +++ b/packages/api-client/lib/openapi/api.ts @@ -431,55 +431,6 @@ export interface ApiServerModelsTortoiseModelsScheduledTaskScheduledTaskSchedule */ at?: string | null; } -/** - * - * @export - * @interface ApiServerModelsTortoiseModelsTasksTaskFavoriteLeaf - */ -export interface ApiServerModelsTortoiseModelsTasksTaskFavoriteLeaf { - /** - * - * @type {string} - * @memberof ApiServerModelsTortoiseModelsTasksTaskFavoriteLeaf - */ - id: string; - /** - * - * @type {string} - * @memberof ApiServerModelsTortoiseModelsTasksTaskFavoriteLeaf - */ - name: string; - /** - * - * @type {string} - * @memberof ApiServerModelsTortoiseModelsTasksTaskFavoriteLeaf - */ - unix_millis_earliest_start_time?: string | null; - /** - * - * @type {any} - * @memberof ApiServerModelsTortoiseModelsTasksTaskFavoriteLeaf - */ - priority?: any; - /** - * - * @type {string} - * @memberof ApiServerModelsTortoiseModelsTasksTaskFavoriteLeaf - */ - category: string; - /** - * - * @type {any} - * @memberof ApiServerModelsTortoiseModelsTasksTaskFavoriteLeaf - */ - description?: any; - /** - * - * @type {string} - * @memberof ApiServerModelsTortoiseModelsTasksTaskFavoriteLeaf - */ - user: string; -} /** * Which agent (robot) is the task assigned to * @export @@ -2444,6 +2395,56 @@ export interface Task { */ description_schema?: object; } +/** + * This label is to be populated by any frontend during a task dispatch, by being added to TaskRequest.labels, which in turn populates TaskState.booking.labels, and can be used to display relevant information needed for any frontends. + * @export + * @interface TaskBookingLabel + */ +export interface TaskBookingLabel { + /** + * + * @type {TaskBookingLabelDescription} + * @memberof TaskBookingLabel + */ + description: TaskBookingLabelDescription; +} +/** + * This description holds several fields that could be useful for frontend dashboards when dispatching a task, to then be identified or rendered accordingly back on the same frontend. + * @export + * @interface TaskBookingLabelDescription + */ +export interface TaskBookingLabelDescription { + /** + * + * @type {string} + * @memberof TaskBookingLabelDescription + */ + task_definition_id: string; + /** + * + * @type {number} + * @memberof TaskBookingLabelDescription + */ + unix_millis_warn_time?: number; + /** + * + * @type {string} + * @memberof TaskBookingLabelDescription + */ + pickup?: string; + /** + * + * @type {string} + * @memberof TaskBookingLabelDescription + */ + destination?: string; + /** + * + * @type {string} + * @memberof TaskBookingLabelDescription + */ + cart_id?: string; +} /** * Response to a request to cancel a task * @export @@ -2626,51 +2627,57 @@ export interface TaskEventLog { /** * * @export - * @interface TaskFavoritePydantic + * @interface TaskFavorite */ -export interface TaskFavoritePydantic { +export interface TaskFavorite { /** * * @type {string} - * @memberof TaskFavoritePydantic + * @memberof TaskFavorite */ id: string; /** * * @type {string} - * @memberof TaskFavoritePydantic + * @memberof TaskFavorite */ name: string; /** * * @type {number} - * @memberof TaskFavoritePydantic + * @memberof TaskFavorite */ unix_millis_earliest_start_time: number; /** * * @type {object} - * @memberof TaskFavoritePydantic + * @memberof TaskFavorite */ priority?: object; /** * * @type {string} - * @memberof TaskFavoritePydantic + * @memberof TaskFavorite */ category: string; /** * * @type {object} - * @memberof TaskFavoritePydantic + * @memberof TaskFavorite */ description?: object; /** * * @type {string} - * @memberof TaskFavoritePydantic + * @memberof TaskFavorite */ user: string; + /** + * + * @type {string} + * @memberof TaskFavorite + */ + task_definition_id: string; } /** * @@ -8686,6 +8693,47 @@ export const TasksApiAxiosParamCreator = function (configuration?: Configuration options: localVarRequestOptions, }; }, + /** + * + * @summary Get Task Booking Label + * @param {string} taskId task_id + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + getTaskBookingLabelTasksTaskIdBookingLabelGet: async ( + taskId: string, + options: AxiosRequestConfig = {}, + ): Promise => { + // verify required parameter 'taskId' is not null or undefined + assertParamExists('getTaskBookingLabelTasksTaskIdBookingLabelGet', 'taskId', taskId); + const localVarPath = `/tasks/{task_id}/booking_label`.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: 'GET', ...baseOptions, ...options }; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + setSearchParams(localVarUrlObj, localVarQueryParameter); + let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = { + ...localVarHeaderParameter, + ...headersFromBaseOptions, + ...options.headers, + }; + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, /** * Available in socket.io * @summary Get Task Log @@ -8965,20 +9013,16 @@ export const TasksApiAxiosParamCreator = function (configuration?: Configuration /** * * @summary Post Favorite Task - * @param {TaskFavoritePydantic} taskFavoritePydantic + * @param {TaskFavorite} taskFavorite * @param {*} [options] Override http request option. * @throws {RequiredError} */ postFavoriteTaskFavoriteTasksPost: async ( - taskFavoritePydantic: TaskFavoritePydantic, + taskFavorite: TaskFavorite, options: AxiosRequestConfig = {}, ): Promise => { - // verify required parameter 'taskFavoritePydantic' is not null or undefined - assertParamExists( - 'postFavoriteTaskFavoriteTasksPost', - 'taskFavoritePydantic', - taskFavoritePydantic, - ); + // verify required parameter 'taskFavorite' is not null or undefined + assertParamExists('postFavoriteTaskFavoriteTasksPost', 'taskFavorite', taskFavorite); const localVarPath = `/favorite_tasks`; // use dummy base URL string because the URL constructor only accepts absolute URLs. const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); @@ -9001,7 +9045,7 @@ export const TasksApiAxiosParamCreator = function (configuration?: Configuration ...options.headers, }; localVarRequestOptions.data = serializeDataIfNeeded( - taskFavoritePydantic, + taskFavorite, localVarRequestOptions, configuration, ); @@ -9734,9 +9778,7 @@ export const TasksApiFp = function (configuration?: Configuration) { */ async getFavoritesTasksFavoriteTasksGet( options?: AxiosRequestConfig, - ): Promise< - (axios?: AxiosInstance, basePath?: string) => AxiosPromise> - > { + ): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise>> { const localVarAxiosArgs = await localVarAxiosParamCreator.getFavoritesTasksFavoriteTasksGet( options, ); @@ -9796,6 +9838,24 @@ export const TasksApiFp = function (configuration?: Configuration) { ); return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); }, + /** + * + * @summary Get Task Booking Label + * @param {string} taskId task_id + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async getTaskBookingLabelTasksTaskIdBookingLabelGet( + taskId: string, + options?: AxiosRequestConfig, + ): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = + await localVarAxiosParamCreator.getTaskBookingLabelTasksTaskIdBookingLabelGet( + taskId, + options, + ); + return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); + }, /** * Available in socket.io * @summary Get Task Log @@ -9908,21 +9968,16 @@ export const TasksApiFp = function (configuration?: Configuration) { /** * * @summary Post Favorite Task - * @param {TaskFavoritePydantic} taskFavoritePydantic + * @param {TaskFavorite} taskFavorite * @param {*} [options] Override http request option. * @throws {RequiredError} */ async postFavoriteTaskFavoriteTasksPost( - taskFavoritePydantic: TaskFavoritePydantic, + taskFavorite: TaskFavorite, options?: AxiosRequestConfig, - ): Promise< - ( - axios?: AxiosInstance, - basePath?: string, - ) => AxiosPromise - > { + ): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { const localVarAxiosArgs = await localVarAxiosParamCreator.postFavoriteTaskFavoriteTasksPost( - taskFavoritePydantic, + taskFavorite, options, ); return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); @@ -10253,7 +10308,7 @@ export const TasksApiFactory = function ( * @param {*} [options] Override http request option. * @throws {RequiredError} */ - getFavoritesTasksFavoriteTasksGet(options?: any): AxiosPromise> { + getFavoritesTasksFavoriteTasksGet(options?: any): AxiosPromise> { return localVarFp .getFavoritesTasksFavoriteTasksGet(options) .then((request) => request(axios, basePath)); @@ -10303,6 +10358,21 @@ export const TasksApiFactory = function ( ) .then((request) => request(axios, basePath)); }, + /** + * + * @summary Get Task Booking Label + * @param {string} taskId task_id + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + getTaskBookingLabelTasksTaskIdBookingLabelGet( + taskId: string, + options?: any, + ): AxiosPromise { + return localVarFp + .getTaskBookingLabelTasksTaskIdBookingLabelGet(taskId, options) + .then((request) => request(axios, basePath)); + }, /** * Available in socket.io * @summary Get Task Log @@ -10392,16 +10462,16 @@ export const TasksApiFactory = function ( /** * * @summary Post Favorite Task - * @param {TaskFavoritePydantic} taskFavoritePydantic + * @param {TaskFavorite} taskFavorite * @param {*} [options] Override http request option. * @throws {RequiredError} */ postFavoriteTaskFavoriteTasksPost( - taskFavoritePydantic: TaskFavoritePydantic, + taskFavorite: TaskFavorite, options?: any, - ): AxiosPromise { + ): AxiosPromise { return localVarFp - .postFavoriteTaskFavoriteTasksPost(taskFavoritePydantic, options) + .postFavoriteTaskFavoriteTasksPost(taskFavorite, options) .then((request) => request(axios, basePath)); }, /** @@ -10744,6 +10814,23 @@ export class TasksApi extends BaseAPI { .then((request) => request(this.axios, this.basePath)); } + /** + * + * @summary Get Task Booking Label + * @param {string} taskId task_id + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof TasksApi + */ + public getTaskBookingLabelTasksTaskIdBookingLabelGet( + taskId: string, + options?: AxiosRequestConfig, + ) { + return TasksApiFp(this.configuration) + .getTaskBookingLabelTasksTaskIdBookingLabelGet(taskId, options) + .then((request) => request(this.axios, this.basePath)); + } + /** * Available in socket.io * @summary Get Task Log @@ -10845,17 +10932,17 @@ export class TasksApi extends BaseAPI { /** * * @summary Post Favorite Task - * @param {TaskFavoritePydantic} taskFavoritePydantic + * @param {TaskFavorite} taskFavorite * @param {*} [options] Override http request option. * @throws {RequiredError} * @memberof TasksApi */ public postFavoriteTaskFavoriteTasksPost( - taskFavoritePydantic: TaskFavoritePydantic, + taskFavorite: TaskFavorite, options?: AxiosRequestConfig, ) { return TasksApiFp(this.configuration) - .postFavoriteTaskFavoriteTasksPost(taskFavoritePydantic, options) + .postFavoriteTaskFavoriteTasksPost(taskFavorite, 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 59be35401..d44ff17a7 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: 'fd45675f94b75df6845303db4f45276d0998f3e6', + rmfServer: '98741b14ceca74208ca98e4bb0c3ca9e41ca1e3c', openapiGenerator: '6.2.1', }; diff --git a/packages/api-client/schema/index.ts b/packages/api-client/schema/index.ts index 911a5de48..37ea1f1b5 100644 --- a/packages/api-client/schema/index.ts +++ b/packages/api-client/schema/index.ts @@ -917,6 +917,36 @@ export default { }, }, }, + '/tasks/{task_id}/booking_label': { + get: { + tags: ['Tasks'], + summary: 'Get Task Booking Label', + operationId: 'get_task_booking_label_tasks__task_id__booking_label_get', + parameters: [ + { + description: 'task_id', + required: true, + schema: { title: 'Task Id', type: 'string', description: 'task_id' }, + name: 'task_id', + in: 'path', + }, + ], + responses: { + '200': { + description: 'Successful Response', + content: { + 'application/json': { schema: { $ref: '#/components/schemas/TaskBookingLabel' } }, + }, + }, + '422': { + description: 'Validation Error', + content: { + 'application/json': { schema: { $ref: '#/components/schemas/HTTPValidationError' } }, + }, + }, + }, + }, + }, '/tasks/{task_id}/log': { get: { tags: ['Tasks'], @@ -1556,7 +1586,7 @@ export default { schema: { title: 'Response Get Favorites Tasks Favorite Tasks Get', type: 'array', - items: { $ref: '#/components/schemas/TaskFavoritePydantic' }, + items: { $ref: '#/components/schemas/TaskFavorite' }, }, }, }, @@ -1569,20 +1599,14 @@ export default { operationId: 'post_favorite_task_favorite_tasks_post', requestBody: { content: { - 'application/json': { schema: { $ref: '#/components/schemas/TaskFavoritePydantic' } }, + 'application/json': { schema: { $ref: '#/components/schemas/TaskFavorite' } }, }, required: true, }, responses: { '200': { description: 'Successful Response', - content: { - 'application/json': { - schema: { - $ref: '#/components/schemas/api_server.models.tortoise_models.tasks.TaskFavorite.leaf', - }, - }, - }, + content: { 'application/json': { schema: {} } }, }, '422': { description: 'Validation Error', @@ -3647,6 +3671,28 @@ export default { }, }, }, + TaskBookingLabel: { + title: 'TaskBookingLabel', + required: ['description'], + type: 'object', + properties: { description: { $ref: '#/components/schemas/TaskBookingLabelDescription' } }, + description: + 'This label is to be populated by any frontend during a task dispatch, by\nbeing added to TaskRequest.labels, which in turn populates\nTaskState.booking.labels, and can be used to display relevant information\nneeded for any frontends.', + }, + TaskBookingLabelDescription: { + title: 'TaskBookingLabelDescription', + required: ['task_definition_id'], + type: 'object', + properties: { + task_definition_id: { title: 'Task Definition Id', type: 'string' }, + unix_millis_warn_time: { title: 'Unix Millis Warn Time', type: 'integer' }, + pickup: { title: 'Pickup', type: 'string' }, + destination: { title: 'Destination', type: 'string' }, + cart_id: { title: 'Cart Id', type: 'string' }, + }, + description: + 'This description holds several fields that could be useful for frontend\ndashboards when dispatching a task, to then be identified or rendered\naccordingly back on the same frontend.', + }, TaskCancelResponse: { title: 'TaskCancelResponse', allOf: [{ $ref: '#/components/schemas/SimpleResponse' }], @@ -3730,9 +3776,16 @@ export default { }, additionalProperties: false, }, - TaskFavoritePydantic: { - title: 'TaskFavoritePydantic', - required: ['id', 'name', 'unix_millis_earliest_start_time', 'category', 'user'], + TaskFavorite: { + title: 'TaskFavorite', + required: [ + 'id', + 'name', + 'unix_millis_earliest_start_time', + 'category', + 'user', + 'task_definition_id', + ], type: 'object', properties: { id: { title: 'Id', type: 'string' }, @@ -3745,6 +3798,7 @@ export default { category: { title: 'Category', type: 'string' }, description: { title: 'Description', type: 'object' }, user: { title: 'User', type: 'string' }, + task_definition_id: { title: 'Task Definition Id', type: 'string' }, }, }, TaskInterruptionRequest: { @@ -4259,26 +4313,6 @@ export default { $ref: '#/components/schemas/api_server.models.tortoise_models.scheduled_task.ScheduledTask', }, }, - 'api_server.models.tortoise_models.tasks.TaskFavorite.leaf': { - title: 'TaskFavorite', - required: ['id', 'name', 'category', 'user'], - type: 'object', - properties: { - id: { title: 'Id', maxLength: 255, type: 'string' }, - name: { title: 'Name', maxLength: 255, type: 'string' }, - unix_millis_earliest_start_time: { - title: 'Unix Millis Earliest Start Time', - type: 'string', - format: 'date-time', - nullable: true, - }, - priority: { title: 'Priority' }, - category: { title: 'Category', maxLength: 255, type: 'string' }, - description: { title: 'Description' }, - user: { title: 'User', maxLength: 255, type: 'string' }, - }, - additionalProperties: false, - }, api_server__models__delivery_alerts__DeliveryAlert__Category: { title: 'Category', enum: ['missing', 'wrong', 'obstructed', 'cancelled'], diff --git a/packages/api-server/api_server/authenticator.py b/packages/api-server/api_server/authenticator.py index 75240c997..2580e9607 100644 --- a/packages/api-server/api_server/authenticator.py +++ b/packages/api-server/api_server/authenticator.py @@ -70,7 +70,7 @@ async def verify_token(self, token: Optional[str]) -> User: user = await self._get_user(claims) return user - except jwt.DecodeError as e: + except jwt.InvalidTokenError as e: raise AuthenticationError(str(e)) from e def fastapi_dep(self) -> Callable[..., Union[Coroutine[Any, Any, User], User]]: diff --git a/packages/api-server/api_server/models/__init__.py b/packages/api-server/api_server/models/__init__.py index 5e85265c4..df99f189e 100644 --- a/packages/api-server/api_server/models/__init__.py +++ b/packages/api-server/api_server/models/__init__.py @@ -51,4 +51,6 @@ from .rmf_api.task_state_update import TaskStateUpdate from .rmf_api.undo_skip_phase_request import UndoPhaseSkipRequest from .rmf_api.undo_skip_phase_response import UndoPhaseSkipResponse +from .task_booking_label import * +from .task_favorite import * from .user import * diff --git a/packages/api-server/api_server/models/task_booking_label.py b/packages/api-server/api_server/models/task_booking_label.py new file mode 100644 index 000000000..1e59d77ab --- /dev/null +++ b/packages/api-server/api_server/models/task_booking_label.py @@ -0,0 +1,46 @@ +from typing import Optional + +import pydantic +from pydantic import BaseModel + +# NOTE: This label model needs to exactly match the fields that are defined and +# populated by the dashboard. Any changes to either side will require syncing. + + +class TaskBookingLabelDescription(BaseModel): + """ + This description holds several fields that could be useful for frontend + dashboards when dispatching a task, to then be identified or rendered + accordingly back on the same frontend. + """ + + task_definition_id: str + unix_millis_warn_time: Optional[int] + pickup: Optional[str] + destination: Optional[str] + cart_id: Optional[str] + + @staticmethod + def from_json_string(json_str: str) -> Optional["TaskBookingLabelDescription"]: + try: + return TaskBookingLabelDescription.parse_raw(json_str) + except pydantic.error_wrappers.ValidationError: + return None + + +class TaskBookingLabel(BaseModel): + """ + This label is to be populated by any frontend during a task dispatch, by + being added to TaskRequest.labels, which in turn populates + TaskState.booking.labels, and can be used to display relevant information + needed for any frontends. + """ + + description: TaskBookingLabelDescription + + @staticmethod + def from_json_string(json_str: str) -> Optional["TaskBookingLabel"]: + try: + return TaskBookingLabel.parse_raw(json_str) + except pydantic.error_wrappers.ValidationError: + return None diff --git a/packages/api-server/api_server/models/task_favorite.py b/packages/api-server/api_server/models/task_favorite.py new file mode 100644 index 000000000..174dee4f9 --- /dev/null +++ b/packages/api-server/api_server/models/task_favorite.py @@ -0,0 +1,14 @@ +from typing import Dict + +from pydantic import BaseModel + + +class TaskFavorite(BaseModel): + id: str + name: str + unix_millis_earliest_start_time: int + priority: Dict | None + category: str + description: Dict | None + user: str + task_definition_id: str diff --git a/packages/api-server/api_server/models/tortoise_models/__init__.py b/packages/api-server/api_server/models/tortoise_models/__init__.py index 301bed861..384dd978d 100644 --- a/packages/api-server/api_server/models/tortoise_models/__init__.py +++ b/packages/api-server/api_server/models/tortoise_models/__init__.py @@ -26,7 +26,7 @@ TaskEventLogPhasesEventsLog, TaskEventLogPhasesLog, TaskFavorite, - TaskFavoritePydantic, + TaskLabel, TaskRequest, TaskState, ) diff --git a/packages/api-server/api_server/models/tortoise_models/tasks.py b/packages/api-server/api_server/models/tortoise_models/tasks.py index 660ae0a41..6e3f3e665 100644 --- a/packages/api-server/api_server/models/tortoise_models/tasks.py +++ b/packages/api-server/api_server/models/tortoise_models/tasks.py @@ -1,8 +1,8 @@ -from tortoise.contrib.pydantic.creator import pydantic_model_creator from tortoise.fields import ( BigIntField, CharField, DatetimeField, + FloatField, ForeignKeyField, ForeignKeyRelation, JSONField, @@ -31,6 +31,15 @@ class TaskState(Model): unix_millis_warn_time = DatetimeField(null=True, index=True) pickup = CharField(255, null=True, index=True) destination = CharField(255, null=True, index=True) + labels = ReverseRelation["TaskLabel"] + + +class TaskLabel(Model): + state = ForeignKeyField("models.TaskState", null=True, related_name="labels") + label_name = CharField(255, null=False, index=True) + label_value_str = CharField(255, null=True, index=True) + label_value_num = BigIntField(null=True, index=True) + label_value_float = FloatField(null=True, index=True) class TaskEventLog(Model): @@ -81,16 +90,4 @@ class TaskFavorite(Model): category = CharField(255, null=False, index=True) description = JSONField() user = CharField(255, null=False, index=True) - - -TaskFavoritePydantic = pydantic_model_creator(TaskFavorite) - - -# class TaskPath(Model): -# task_id = CharField(255, null=False, index=True) - - -# class TaskLocationCheckIn(Model): -# task_id = CharField(255, null=False, index=True) -# unix_millis_check_in_time = BigIntField(null=False, index=True) -# location = CharField(255, null=False, index=True) + task_definition_id = CharField(255, null=True, index=True) diff --git a/packages/api-server/api_server/repositories/tasks.py b/packages/api-server/api_server/repositories/tasks.py index 5b8794e28..c857abfea 100644 --- a/packages/api-server/api_server/repositories/tasks.py +++ b/packages/api-server/api_server/repositories/tasks.py @@ -14,6 +14,7 @@ LogEntry, Pagination, Phases, + TaskBookingLabel, TaskEventLog, TaskRequest, TaskState, @@ -37,98 +38,6 @@ def __init__( self.user = user self.logger = logger - def parse_pickup(self, task_request: TaskRequest) -> Optional[str]: - # patrol - if task_request.category.lower() == "patrol": - return None - - # custom deliveries - supportedDeliveries = [ - "delivery_pickup", - "delivery_sequential_lot_pickup", - "delivery_area_pickup", - ] - if ( - "category" not in task_request.description - or task_request.description["category"] not in supportedDeliveries - ): - return None - - category = task_request.description["category"] - try: - perform_action_description = task_request.description["phases"][0][ - "activity" - ]["description"]["activities"][1]["description"]["description"] - if category == "delivery_pickup": - return perform_action_description["pickup_lot"] - return perform_action_description["pickup_zone"] - except Exception as e: # pylint: disable=W0703 - self.logger.error( - f"Failed to parse pickup for task of category [{category}] [{e}]" - ) - return None - - def parse_destination( - self, task_state: TaskState, task_request: TaskRequest - ) -> Optional[str]: - # patrol - try: - if ( - task_request.category.lower() == "patrol" - and task_request.description["places"] is not None - and len(task_request.description["places"]) > 0 - ): - return task_request.description["places"][-1] - except Exception as e: # pylint: disable=W0703 - self.logger.error(f"Failed to parse destination for patrol [{e}]") - return None - - # custom deliveries - supportedDeliveries = [ - "delivery_pickup", - "delivery_sequential_lot_pickup", - "delivery_area_pickup", - ] - if ( - "category" not in task_request.description - or task_request.description["category"] not in supportedDeliveries - ): - return None - - category = task_request.description["category"] - try: - destination = task_request.description["phases"][1]["activity"][ - "description" - ]["activities"][0]["description"] - return destination - except Exception as e: # pylint: disable=W0703 - self.logger.error( - f"Failed to parse destination from task request of category [{category}] [{e}]" - ) - - # automated tasks that can only be parsed with state - if task_state.category is not None and task_state.category == "Charge Battery": - try: - if ( - task_state.phases is None - or "1" not in task_state.phases - or task_state.phases["1"].events is None - or "1" not in task_state.phases["1"].events - or task_state.phases["1"].events["1"].name is None - ): - raise ValueError - - charge_event_name = task_state.phases["1"].events["1"].name - charge_place_split = charge_event_name.split("[place:")[1] - charge_place = charge_place_split.split("]")[0] - return charge_place - except Exception as e: # pylint: disable=W0703 - self.logger.error( - f"Failed to parse charging point from task state of id [{task_state.booking.id}] [{e}]" - ) - return None - return None - async def save_task_request( self, task_state: TaskState, task_request: TaskRequest ) -> None: @@ -136,19 +45,6 @@ async def save_task_request( {"request": task_request.json()}, id_=task_state.booking.id ) - # Add pickup and destination to task state model for filter and sort - pickup = self.parse_pickup(task_request) - destination = self.parse_destination(task_state, task_request) - db_task_state = await DbTaskState.get_or_none(id_=task_state.booking.id) - if db_task_state is not None: - db_task_state.update_from_dict( - { - "pickup": pickup, - "destination": destination, - } - ) - await db_task_state.save() - async def get_task_request(self, task_id: str) -> Optional[TaskRequest]: result = await DbTaskRequest.get_or_none(id_=task_id) if result is None: @@ -183,13 +79,8 @@ async def save_task_state(self, task_state: TaskState) -> None: else None, } - if task_state.unix_millis_warn_time is not None: - task_state_dict["unix_millis_warn_time"] = datetime.fromtimestamp( - task_state.unix_millis_warn_time / 1000 - ) - try: - await ttm.TaskState.update_or_create( + state, created = await ttm.TaskState.update_or_create( task_state_dict, id_=task_state.booking.id ) except Exception as e: # pylint: disable=W0703 @@ -206,6 +97,44 @@ async def save_task_state(self, task_state: TaskState) -> None: self.logger.error( f"Failed to save task state of id [{task_state.booking.id}] [{e}]" ) + return + + # Since this is updating an existing task state, we are done + if not created: + return + + # Labels are created and saved when a new task state is first received + labels = task_state.booking.labels + booking_label = None + if labels is not None: + for l in labels: + validated_booking_label = TaskBookingLabel.from_json_string(l) + if validated_booking_label is not None: + booking_label = validated_booking_label + break + if booking_label is None: + return + + # Here we generate the labels required for server-side sorting and + # filtering. + if booking_label.description.pickup is not None: + await ttm.TaskLabel.create( + state=state, + label_name="pickup", + label_value_str=booking_label.description.pickup, + ) + if booking_label.description.destination is not None: + await ttm.TaskLabel.create( + state=state, + label_name="destination", + label_value_str=booking_label.description.destination, + ) + if booking_label.description.unix_millis_warn_time is not None: + await ttm.TaskLabel.create( + state=state, + label_name="unix_millis_warn_time", + label_value_num=booking_label.description.unix_millis_warn_time, + ) async def query_task_states( self, query: QuerySet[DbTaskState], pagination: Optional[Pagination] = None diff --git a/packages/api-server/api_server/routes/tasks/favorite_tasks.py b/packages/api-server/api_server/routes/tasks/favorite_tasks.py index d3dcabef5..66b069a18 100644 --- a/packages/api-server/api_server/routes/tasks/favorite_tasks.py +++ b/packages/api-server/api_server/routes/tasks/favorite_tasks.py @@ -1,59 +1,51 @@ import uuid from datetime import datetime -from typing import Dict, List +from typing import List from fastapi import Depends, HTTPException -from pydantic import BaseModel from tortoise.exceptions import IntegrityError from api_server.authenticator import user_dep from api_server.fast_io import FastIORouter -from api_server.models import User +from api_server.models import TaskFavorite, User from api_server.models import tortoise_models as ttm router = FastIORouter(tags=["Tasks"]) -class TaskFavoritePydantic(BaseModel): - id: str - name: str - unix_millis_earliest_start_time: int - priority: Dict | None - category: str - description: Dict | None - user: str - - -@router.post("", response_model=ttm.TaskFavoritePydantic) +@router.post("") async def post_favorite_task( - request: TaskFavoritePydantic, + favorite_task: TaskFavorite, user: User = Depends(user_dep), ): try: await ttm.TaskFavorite.update_or_create( { - "name": request.name, + "name": favorite_task.name, "unix_millis_earliest_start_time": datetime.fromtimestamp( - request.unix_millis_earliest_start_time / 1000 + favorite_task.unix_millis_earliest_start_time / 1000 ), - "priority": request.priority if request.priority else None, - "category": request.category, - "description": request.description if request.description else None, + "priority": favorite_task.priority if favorite_task.priority else None, + "category": favorite_task.category, + "description": favorite_task.description + if favorite_task.description + else None, "user": user.username, + "task_definition_id": favorite_task.task_definition_id, }, - id=request.id if request.id != "" else uuid.uuid4(), + id=favorite_task.id if favorite_task.id != "" else uuid.uuid4(), ) except IntegrityError as e: raise HTTPException(422, str(e)) from e -@router.get("", response_model=List[TaskFavoritePydantic]) +@router.get("", response_model=List[TaskFavorite]) async def get_favorites_tasks( user: User = Depends(user_dep), ): favorites_tasks = await ttm.TaskFavorite.filter(user=user.username) return [ - TaskFavoritePydantic( + TaskFavorite( id=favorite_task.id, name=favorite_task.name, unix_millis_earliest_start_time=int( @@ -65,6 +57,7 @@ async def get_favorites_tasks( if favorite_task.description else None, user=user.username, + task_definition_id=favorite_task.task_definition_id, ) for favorite_task in favorites_tasks ] diff --git a/packages/api-server/api_server/routes/tasks/tasks.py b/packages/api-server/api_server/routes/tasks/tasks.py index 8220b5058..65ec2488c 100644 --- a/packages/api-server/api_server/routes/tasks/tasks.py +++ b/packages/api-server/api_server/routes/tasks/tasks.py @@ -128,10 +128,6 @@ async def query_task_states( filters["unix_millis_request_time__lte"] = request_time_between[1] if requester is not None: filters["requester__in"] = requester.split(",") - if pickup is not None: - filters["pickup__in"] = pickup.split(",") - if destination is not None: - filters["destination__in"] = destination.split(",") if assigned_to is not None: filters["assigned_to__in"] = assigned_to.split(",") if start_time_between is not None: @@ -148,6 +144,31 @@ async def query_task_states( continue filters["status__in"].append(mdl.Status(status_string)) + # NOTE: in order to perform filtering based on the values in labels, a + # filter on the label_name will need to be applied as well as a filter on + # the label_value. + if pickup is not None: + filters["labels__label_name"] = "pickup" + filters["labels__label_value_str__in"] = pickup.split(",") + if destination is not None: + filters["labels__label_name"] = "destination" + filters["labels__label_value_str__in"] = destination.split(",") + + # NOTE: In order to perform sorting based on the values in labels, a filter + # on the label_name has to be performed first. A side-effect of this would + # be that states that do not contain this field will not be returned. + if pagination.order_by is not None: + labels_fields = ["pickup", "destination"] + new_order = pagination.order_by + for field in labels_fields: + if field in pagination.order_by: + filters["labels__label_name"] = field + new_order = pagination.order_by.replace( + field, "labels__label_value_str" + ) + break + pagination.order_by = new_order + return await task_repo.query_task_states(DbTaskState.filter(**filters), pagination) @@ -178,6 +199,26 @@ async def sub_task_state(req: SubscriptionRequest, task_id: str): return obs +@router.get("/{task_id}/booking_label", response_model=mdl.TaskBookingLabel) +async def get_task_booking_label( + task_repo: TaskRepository = Depends(TaskRepository), + task_id: str = Path(..., description="task_id"), +): + state = await task_repo.get_task_state(task_id) + if state is None: + raise HTTPException(status_code=404) + + if state.booking.labels is not None: + for label in state.booking.labels: + if len(label) == 0: + continue + + booking_label = mdl.TaskBookingLabel.from_json_string(label) + if booking_label is not None: + return booking_label + raise HTTPException(status_code=404) + + @router.get("/{task_id}/log", response_model=mdl.TaskEventLog) async def get_task_log( task_repo: TaskRepository = Depends(TaskRepository), diff --git a/packages/api-server/api_server/routes/tasks/test_tasks.py b/packages/api-server/api_server/routes/tasks/test_tasks.py index 5dee13e0a..ffa78e9db 100644 --- a/packages/api-server/api_server/routes/tasks/test_tasks.py +++ b/packages/api-server/api_server/routes/tasks/test_tasks.py @@ -3,7 +3,12 @@ from api_server import models as mdl from api_server.rmf_io import tasks_service -from api_server.test import AppFixture, make_task_log, make_task_state +from api_server.test import ( + AppFixture, + make_task_booking_label, + make_task_log, + make_task_state, +) class TestTasksRoute(AppFixture): @@ -51,6 +56,14 @@ def test_sub_task_state(self): state = next(gen) self.assertEqual(task_id, state.booking.id) # type: ignore + def test_get_task_booking_label(self): + resp = self.client.get(f"/tasks/{self.task_states[0].booking.id}/booking_label") + self.assertEqual(200, resp.status_code) + self.assertEqual( + make_task_booking_label(), + mdl.TaskBookingLabel(**resp.json()), + ) + def test_get_task_log(self): resp = self.client.get( f"/tasks/{self.task_logs[0].task_id}/log?between=0,1636388414500" diff --git a/packages/api-server/api_server/test/test_data.py b/packages/api-server/api_server/test/test_data.py index a287e73e2..cf0f693d2 100644 --- a/packages/api-server/api_server/test/test_data.py +++ b/packages/api-server/api_server/test/test_data.py @@ -23,10 +23,12 @@ Lift, LiftState, RobotState, + TaskBookingLabel, + TaskBookingLabelDescription, TaskEventLog, + TaskFavorite, TaskState, ) -from api_server.models import tortoise_models as ttm def make_door(name: str = "test_door") -> Door: @@ -130,6 +132,18 @@ def make_fleet_log() -> FleetLog: return FleetLog(name=str(uuid4()), log=[], robots={}) +def make_task_booking_label() -> TaskBookingLabel: + return TaskBookingLabel( + description=TaskBookingLabelDescription( + task_definition_id="multi-delivery", + unix_millis_warn_time=1636388400000, + pickup="Kitchen", + destination="room_203", + cart_id="soda", + ) + ) + + def make_task_state(task_id: str = "test_task") -> TaskState: # from https://raw.githubusercontent.com/open-rmf/rmf_api_msgs/960b286d9849fc716a3043b8e1f5fb341bdf5778/rmf_api_msgs/samples/task_state/multi_dropoff_delivery.json sample_task = json.loads( @@ -429,12 +443,19 @@ def make_task_state(task_id: str = "test_task") -> TaskState: """ ) sample_task["booking"]["id"] = task_id + + booking_labels = [ + "dummy_label_1", + "dummy_label_2", + make_task_booking_label().json(), + ] + sample_task["booking"]["labels"] = booking_labels return TaskState(**sample_task) def make_task_favorite( favorite_task_id: str = "default_id", -) -> ttm.TaskFavoritePydantic: +) -> TaskFavorite: sample_favorite_task = json.loads( """ { @@ -458,12 +479,13 @@ def make_task_favorite( "payload":"" } }, - "user":"stub" + "user":"stub", + "task_definition_id": "delivery" } """ ) sample_favorite_task["id"] = favorite_task_id - return ttm.TaskFavoritePydantic(**sample_favorite_task) + return TaskFavorite(**sample_favorite_task) def make_task_log(task_id: str) -> TaskEventLog: diff --git a/packages/api-server/api_server/test/test_fixtures.py b/packages/api-server/api_server/test/test_fixtures.py index 2280702de..32a2e6d2f 100644 --- a/packages/api-server/api_server/test/test_fixtures.py +++ b/packages/api-server/api_server/test/test_fixtures.py @@ -176,6 +176,7 @@ def post_favorite_task(self): "category": "clean", "description": {"type": "", "zone": ""}, "user": "", + "task_definition_id": "", } return self.client.post( "/favorite_tasks", diff --git a/packages/api-server/migrations/migrate_db_912.py b/packages/api-server/migrations/migrate_db_912.py new file mode 100644 index 000000000..c4923eaa3 --- /dev/null +++ b/packages/api-server/migrations/migrate_db_912.py @@ -0,0 +1,291 @@ +import asyncio +import os +from typing import Optional + +from tortoise import Tortoise + +import api_server.models.tortoise_models as ttm +from api_server.app_config import app_config, load_config +from api_server.models import ( + TaskBookingLabel, + TaskBookingLabelDescription, + TaskRequest, + TaskState, +) + +# NOTE: This script is for migrating TaskState and ScheduledTask in an existing +# database to work with https://github.com/open-rmf/rmf-web/pull/912. +# Before migration: +# - Pickup, destination, cart ID, task definition id information will be +# unavailable on the Task Queue Table on the dashboard, as we no longer gather +# those fields from the TaskRequest. +# - TaskState database model contains optional CharFields for pickup and +# destination, to facilitate server-side sorting and filtering. +# After migration: +# - Dashboard will behave the same as before #912, however it is no longer +# dependent on TaskRequest to fill out those fields. It gathers those fields +# from the json string in TaskState.booking.labels. +# - In the database, we create a new generic key-value pair model, that allow +# us to encode all this information and tie them to a task state, and be used +# for sorting and filtering, using reverse relations, as opposed to fully +# filled out columns for TaskState. +# This script performs the following: +# - Construct TaskBookingLabel from its TaskRequest if it is available. +# - Update the respective TaskState.data json TaskState.booking.labels field +# with the newly constructed TaskBookingLabel json string. +# - Update the requests in ScheduledTask to use labels too +# - TaskFavorite will not be migrated as the database model was not able to +# support it until rmf-web#912 + + +app_config = load_config( + os.environ.get( + "RMF_API_SERVER_CONFIG", + f"{os.path.dirname(__file__)}/../../default_config.py", + ) +) + + +def parse_task_definition_id(task_request: TaskRequest) -> Optional[str]: + """ + Although not IDs per-se, these names are used to identify which task + definition to use when rendering on the dashboard. While these IDs are + going to be static, in the next update, the underlying display name will be + configurable at build time. + """ + name = None + if task_request.category.lower() == "patrol": + name = "Patrol" + elif task_request.description and task_request.description["category"]: + name = task_request.description["category"] + return name + + +def parse_pickup(task_request: TaskRequest) -> Optional[str]: + # patrol + if task_request.category.lower() == "patrol": + return None + + # custom deliveries + supportedDeliveries = [ + "delivery_pickup", + "delivery_sequential_lot_pickup", + "delivery_area_pickup", + ] + if ( + "category" not in task_request.description + or task_request.description["category"] not in supportedDeliveries + ): + return None + + category = task_request.description["category"] + try: + perform_action_description = task_request.description["phases"][0]["activity"][ + "description" + ]["activities"][1]["description"]["description"] + if category == "delivery_pickup": + return perform_action_description["pickup_lot"] + return perform_action_description["pickup_zone"] + except Exception as e: # pylint: disable=W0703 + print(f"Failed to parse pickup for task of category {category}") + return None + + +def parse_destination(task_request: TaskRequest) -> Optional[str]: + # patrol + try: + if ( + task_request.category.lower() == "patrol" + and task_request.description["places"] is not None + and len(task_request.description["places"]) > 0 + ): + return task_request.description["places"][-1] + except Exception as e: # pylint: disable=W0703 + print("Failed to parse destination for patrol") + return None + + # custom deliveries + supportedDeliveries = [ + "delivery_pickup", + "delivery_sequential_lot_pickup", + "delivery_area_pickup", + ] + if ( + "category" not in task_request.description + or task_request.description["category"] not in supportedDeliveries + ): + return None + + category = task_request.description["category"] + try: + destination = task_request.description["phases"][1]["activity"]["description"][ + "activities" + ][0]["description"] + return destination + except Exception as e: # pylint: disable=W0703 + print(f"Failed to parse destination from task request of category {category}") + return None + + +def parse_cart_id(task_request: TaskRequest) -> Optional[str]: + # patrol + if task_request.category.lower() == "patrol": + return None + + # custom deliveries + supportedDeliveries = [ + "delivery_pickup", + "delivery_sequential_lot_pickup", + "delivery_area_pickup", + ] + if ( + "category" not in task_request.description + or task_request.description["category"] not in supportedDeliveries + ): + return None + + category = task_request.description["category"] + try: + perform_action_description = task_request.description["phases"][0]["activity"][ + "description" + ]["activities"][1]["description"]["description"] + return perform_action_description["cart_id"] + except Exception as e: # pylint: disable=W0703 + print(f"Failed to parse cart ID for task of category {category}") + return None + + +async def migrate(): + await Tortoise.init( + db_url=app_config.db_url, + modules={"models": ["api_server.models.tortoise_models"]}, + ) + await Tortoise.generate_schemas() + + # Acquire all existing TaskStates + states = await ttm.TaskState.all() + print(f"Migrating {len(states)} TaskState models") + + # Migrate each TaskState + skipped_task_states_count = 0 + failed_task_states_count = 0 + for state in states: + state_model = TaskState(**state.data) + task_id = state_model.booking.id + + # If the request is not available we skip migrating this TaskState + request = await ttm.TaskRequest.get_or_none(id_=task_id) + if request is None: + skipped_task_states_count += 1 + continue + request_model = TaskRequest(**request.request) + + # Construct TaskBookingLabel based on TaskRequest + pickup = parse_pickup(request_model) + destination = parse_destination(request_model) + + # If the task definition id could not be parsed, we skip migrating this + # TaskState + task_definition_id = parse_task_definition_id(request_model) + if task_definition_id is None: + failed_task_states_count += 1 + continue + + label_description = TaskBookingLabelDescription( + task_definition_id=task_definition_id, + unix_millis_warn_time=None, + pickup=pickup, + destination=destination, + cart_id=parse_cart_id(request_model), + ) + label = TaskBookingLabel(description=label_description) + # print(label) + + # Update data json + if state_model.booking.labels is None: + state_model.booking.labels = [ + label.json(exclude_none=True, separators=(",", ":")) + ] + else: + state_model.booking.labels.append( + label.json(exclude_none=True, separators=(",", ":")) + ) + # print(state_model) + + if pickup is not None: + await ttm.TaskLabel.create( + state=state, label_name="pickup", label_value_str=pickup + ) + if destination is not None: + await ttm.TaskLabel.create( + state=state, label_name="destination", label_value_str=destination + ) + + state.update_from_dict( + {"data": state_model.json(exclude_none=True, separators=(",", ":"))} + ) + await state.save() + + # Acquire all ScheduledTask + scheduled_tasks = await ttm.ScheduledTask.all() + print(f"Migrating {len(scheduled_tasks)} ScheduledTask models") + + # Migrate each ScheduledTask + failed_scheduled_task_count = 0 + for scheduled_task in scheduled_tasks: + scheduled_task_model = await ttm.ScheduledTaskPydantic.from_tortoise_orm( + scheduled_task + ) + task_request = TaskRequest( + **scheduled_task_model.task_request # pyright: ignore[reportGeneralTypeIssues] + ) + # print(task_request) + + task_definition_id = parse_task_definition_id(task_request) + if task_definition_id is None: + failed_scheduled_task_count += 1 + continue + + # Construct TaskBookingLabel based on TaskRequest + pickup = parse_pickup(task_request) + destination = parse_destination(task_request) + label_description = TaskBookingLabelDescription( + task_definition_id=task_definition_id, + unix_millis_warn_time=None, + pickup=pickup, + destination=destination, + cart_id=parse_cart_id(task_request), + ) + label = TaskBookingLabel(description=label_description) + # print(label) + + # Update TaskRequest + if task_request.labels is None: + task_request.labels = [label.json(exclude_none=True, separators=(",", ":"))] + else: + task_request.labels.append( + label.json(exclude_none=True, separators=(",", ":")) + ) + # print(task_request) + + # Update ScheduledTask + scheduled_task.update_from_dict( + { + "task_request": task_request.json( + exclude_none=True, separators=(",", ":") + ) + } + ) + await scheduled_task.save() + + await Tortoise.close_connections() + + +def main(): + print("Migration started") + asyncio.run(migrate()) + print("Migration done") + + +if __name__ == "__main__": + main() diff --git a/packages/dashboard/package.json b/packages/dashboard/package.json index f18b2cc6b..4464f6499 100644 --- a/packages/dashboard/package.json +++ b/packages/dashboard/package.json @@ -59,7 +59,6 @@ "@types/react-router": "^5.1.20", "@types/react-router-dom": "^5.3.3", "@types/three": "^0.156.0", - "ajv": "^8.10.0", "api-client": "workspace:*", "axios": "^0.21.1", "date-fns": "^2.21.3", diff --git a/packages/dashboard/src/components/appbar.tsx b/packages/dashboard/src/components/appbar.tsx index f4e03e15c..76a3f5224 100644 --- a/packages/dashboard/src/components/appbar.tsx +++ b/packages/dashboard/src/components/appbar.tsx @@ -38,7 +38,7 @@ import { import { ApiServerModelsTortoiseModelsAlertsAlertLeaf as Alert, FireAlarmTriggerState, - TaskFavoritePydantic as TaskFavorite, + TaskFavorite, TaskRequest, } from 'api-client'; import React from 'react'; @@ -615,7 +615,6 @@ export const AppBar = React.memo(({ extraToolbarItems }: AppBarProps): React.Rea cleaningZones={cleaningZoneNames} pickupZones={resourceManager?.pickupZones} cartIds={resourceManager?.cartIds} - emergencyLots={resourceManager?.emergencyLots} pickupPoints={pickupPoints} dropoffPoints={dropoffPoints} favoritesTasks={favoritesTasks} diff --git a/packages/dashboard/src/components/tasks/task-summary.tsx b/packages/dashboard/src/components/tasks/task-summary.tsx index 458bfa766..288ed81ed 100644 --- a/packages/dashboard/src/components/tasks/task-summary.tsx +++ b/packages/dashboard/src/components/tasks/task-summary.tsx @@ -14,8 +14,12 @@ import DialogActions from '@mui/material/DialogActions'; import DialogContent from '@mui/material/DialogContent'; import DialogTitle from '@mui/material/DialogTitle'; import { makeStyles, createStyles } from '@mui/styles'; -import { ApiServerModelsRmfApiTaskStateStatus as Status, TaskRequest, TaskState } from 'api-client'; -import { base, parseCartId, parseCategory, parseDestination, parsePickup } from 'react-components'; +import { + ApiServerModelsRmfApiTaskStateStatus as Status, + TaskBookingLabel, + TaskState, +} from 'api-client'; +import { base, getTaskBookingLabelFromTaskState } from 'react-components'; import { TaskInspector } from './task-inspector'; import { RmfAppContext } from '../rmf-app'; import { TaskCancelButton } from './task-cancellation'; @@ -69,7 +73,6 @@ const setTaskDialogColor = (taskStatus: Status | undefined) => { export interface TaskSummaryProps { onClose: () => void; task?: TaskState; - request?: TaskRequest; } export const TaskSummary = React.memo((props: TaskSummaryProps) => { @@ -77,10 +80,11 @@ export const TaskSummary = React.memo((props: TaskSummaryProps) => { const classes = useStyles(); const rmf = React.useContext(RmfAppContext); - const { onClose, task, request } = props; + const { onClose, task } = props; const [openTaskDetailsLogs, setOpenTaskDetailsLogs] = React.useState(false); const [taskState, setTaskState] = React.useState(null); + const [label, setLabel] = React.useState(null); const [isOpen, setIsOpen] = React.useState(true); const taskProgress = React.useMemo(() => { @@ -106,9 +110,15 @@ export const TaskSummary = React.memo((props: TaskSummaryProps) => { if (!rmf || !task) { return; } - const sub = rmf - .getTaskStateObs(task.booking.id) - .subscribe((subscribedTask) => setTaskState(subscribedTask)); + const sub = rmf.getTaskStateObs(task.booking.id).subscribe((subscribedTask) => { + const requestLabel = getTaskBookingLabelFromTaskState(subscribedTask); + if (requestLabel) { + setLabel(requestLabel); + } else { + setLabel(null); + } + setTaskState(subscribedTask); + }); return () => sub.unsubscribe(); }, [rmf, task]); @@ -119,20 +129,20 @@ export const TaskSummary = React.memo((props: TaskSummaryProps) => { value: taskState ? taskState.booking.id : 'n/a.', }, { - title: 'Category', - value: parseCategory(task, request), + title: 'Task definition ID', + value: label?.description.task_definition_id ?? 'n/a', }, { title: 'Pickup', - value: parsePickup(request), + value: label?.description.pickup ?? 'n/a', }, { title: 'Cart ID', - value: parseCartId(request), + value: label?.description.cart_id ?? 'n/a', }, { title: 'Dropoff', - value: parseDestination(task, request), + value: label?.description.destination ?? 'n/a', }, { title: 'Est. end time', diff --git a/packages/dashboard/src/components/tasks/tasks-app.tsx b/packages/dashboard/src/components/tasks/tasks-app.tsx index 8cc49fc06..2716e99cc 100644 --- a/packages/dashboard/src/components/tasks/tasks-app.tsx +++ b/packages/dashboard/src/components/tasks/tasks-app.tsx @@ -95,7 +95,6 @@ export const TasksApp = React.memo( const [tasksState, setTasksState] = React.useState({ isLoading: true, data: [], - requests: {}, total: 0, page: 1, pageSize: 10, @@ -282,32 +281,6 @@ export const TasksApp = React.memo( return allTasks; }; - const getPastMonthTaskRequests = async (tasks: TaskState[]) => { - if (!rmf) { - return {}; - } - - const taskRequestMap: Record = {}; - const allTaskIds: string[] = tasks.map((task) => task.booking.id); - const queriesRequired = Math.ceil(allTaskIds.length / QueryLimit); - for (let i = 0; i < queriesRequired; i++) { - const endingIndex = Math.min(allTaskIds.length, (i + 1) * QueryLimit); - const taskIds = allTaskIds.slice(i * QueryLimit, endingIndex); - const taskIdsQuery = taskIds.join(','); - const taskRequests = (await rmf.tasksApi.queryTaskRequestsTasksRequestsGet(taskIdsQuery)) - .data; - - let requestIndex = 0; - for (const id of taskIds) { - if (requestIndex < taskRequests.length && taskRequests[requestIndex]) { - taskRequestMap[id] = taskRequests[requestIndex]; - } - ++requestIndex; - } - } - return taskRequestMap; - }; - const exportTasksToCsv = async (minimal: boolean) => { AppEvents.loadingBackdrop.next(true); const now = new Date(); @@ -319,11 +292,7 @@ export const TasksApp = React.memo( return; } if (minimal) { - // FIXME: Task requests are currently required for parsing pickup and - // destination information. Once we start using TaskState.Booking.Labels - // to encode these fields, we can skip querying for task requests. - const pastMonthTaskRequests = await getPastMonthTaskRequests(pastMonthTasks); - exportCsvMinimal(now, pastMonthTasks, pastMonthTaskRequests); + exportCsvMinimal(now, pastMonthTasks); } else { exportCsvFull(now, pastMonthTasks); } @@ -481,7 +450,6 @@ export const TasksApp = React.memo( setOpenTaskSummary(false)} task={selectedTask ?? undefined} - request={selectedTask ? tasksState.requests[selectedTask.booking.id] : undefined} /> )} {children} diff --git a/packages/dashboard/src/components/tasks/utils.ts b/packages/dashboard/src/components/tasks/utils.ts index 93d74a9f5..2b165ac2e 100644 --- a/packages/dashboard/src/components/tasks/utils.ts +++ b/packages/dashboard/src/components/tasks/utils.ts @@ -1,7 +1,6 @@ import { PostScheduledTaskRequest, TaskRequest, TaskState } from 'api-client'; -import { Schedule, parsePickup, parseDestination } from 'react-components'; +import { ajv, getTaskBookingLabelFromTaskState, Schedule } from 'react-components'; import schema from 'api-client/dist/schema'; -import { ajv } from '../utils'; export function parseTasksFile(contents: string): TaskRequest[] { const obj = JSON.parse(contents) as unknown[]; @@ -47,11 +46,7 @@ export function exportCsvFull(timestamp: Date, allTasks: TaskState[]) { }); } -export function exportCsvMinimal( - timestamp: Date, - allTasks: TaskState[], - taskRequestMap: Record, -) { +export function exportCsvMinimal(timestamp: Date, allTasks: TaskState[]) { const columnSeparator = ';'; const rowSeparator = '\n'; let csvContent = `sep=${columnSeparator}` + rowSeparator; @@ -67,7 +62,8 @@ export function exportCsvMinimal( ]; csvContent += keys.join(columnSeparator) + rowSeparator; allTasks.forEach((task) => { - const request: TaskRequest | undefined = taskRequestMap[task.booking.id]; + let requestLabel = getTaskBookingLabelFromTaskState(task); + const values = [ // Date task.booking.unix_millis_request_time @@ -76,9 +72,11 @@ export function exportCsvMinimal( // Requester task.booking.requester ? task.booking.requester : 'n/a', // Pickup - parsePickup(request), + requestLabel && requestLabel.description.pickup ? requestLabel.description.pickup : 'n/a', // Destination - parseDestination(task, request), + requestLabel && requestLabel.description.destination + ? requestLabel.description.destination + : 'n/a', // Robot task.assigned_to ? task.assigned_to.name : 'n/a', // Start Time diff --git a/packages/dashboard/src/components/utils.ts b/packages/dashboard/src/components/utils.ts index cdb1d6cb4..ac280c665 100644 --- a/packages/dashboard/src/components/utils.ts +++ b/packages/dashboard/src/components/utils.ts @@ -1,13 +1,5 @@ -import Ajv from 'ajv'; -import schema from 'api-client/schema'; import { AxiosError } from 'axios'; export function getApiErrorMessage(error: unknown): string { return (error as AxiosError).response?.data.detail || ''; } - -export const ajv = new Ajv(); - -Object.entries(schema.components.schemas).forEach(([k, v]) => { - ajv.addSchema(v, `#/components/schemas/${k}`); -}); diff --git a/packages/dashboard/src/managers/resource-manager.ts b/packages/dashboard/src/managers/resource-manager.ts index 2f872d855..4227326a4 100644 --- a/packages/dashboard/src/managers/resource-manager.ts +++ b/packages/dashboard/src/managers/resource-manager.ts @@ -18,7 +18,6 @@ export interface ResourceConfigurationsType { attributionPrefix?: string; cartIds?: string[]; loggedInDisplayLevel?: string; - emergencyLots?: string[]; } export default class ResourceManager { @@ -33,7 +32,6 @@ export default class ResourceManager { attributionPrefix?: string; cartIds?: string[]; loggedInDisplayLevel?: string; - emergencyLots?: string[]; /** * Gets the default resource manager using the embedded resource file (aka "assets/resources/main.json"). @@ -73,7 +71,6 @@ export default class ResourceManager { this.attributionPrefix = resources.attributionPrefix || 'OSRC-SG'; this.cartIds = resources.cartIds || []; this.loggedInDisplayLevel = resources.loggedInDisplayLevel; - this.emergencyLots = resources.emergencyLots || []; } } diff --git a/packages/react-components/lib/tasks/create-task.tsx b/packages/react-components/lib/tasks/create-task.tsx index 1aedab997..fc62722c0 100644 --- a/packages/react-components/lib/tasks/create-task.tsx +++ b/packages/react-components/lib/tasks/create-task.tsx @@ -38,11 +38,90 @@ import { useTheme, } from '@mui/material'; import { DatePicker, TimePicker, DateTimePicker } from '@mui/x-date-pickers'; -import type { TaskFavoritePydantic as TaskFavorite, TaskRequest } from 'api-client'; +import type { TaskBookingLabel, TaskFavorite, TaskRequest } from 'api-client'; import React from 'react'; import { Loading } from '..'; import { ConfirmationDialog, ConfirmationDialogProps } from '../confirmation-dialog'; import { PositiveIntField } from '../form-inputs'; +import { serializeTaskBookingLabel } from './task-booking-label-utils'; + +interface TaskDefinition { + task_definition_id: string; + task_display_name: string; +} + +// If no task definition id is found in a past task (scheduled or favorite) +const DefaultTaskDefinitionId = 'custom_compose'; + +// FIXME: This is the order of the task type dropdown, and will be migrated out +// as a build-time configuration in a subsequent patch. +const SupportedTaskDefinitions: TaskDefinition[] = [ + { + task_definition_id: 'delivery_pickup', + task_display_name: 'Delivery - 1:1', + }, + { + task_definition_id: 'delivery_sequential_lot_pickup', + task_display_name: 'Delivery - Sequential lot pick up', + }, + { + task_definition_id: 'delivery_area_pickup', + task_display_name: 'Delivery - Area pick up', + }, + { + task_definition_id: 'patrol', + task_display_name: 'Patrol', + }, + { + task_definition_id: 'custom_compose', + task_display_name: 'Custom Compose Task', + }, +]; + +function makeDeliveryTaskBookingLabel(task_description: DeliveryTaskDescription): TaskBookingLabel { + const pickupDescription = + task_description.phases[0].activity.description.activities[1].description.description; + return { + description: { + task_definition_id: task_description.category, + pickup: pickupDescription.pickup_lot, + destination: task_description.phases[1].activity.description.activities[0].description, + cart_id: pickupDescription.cart_id, + }, + }; +} + +function makeDeliveryCustomTaskBookingLabel( + task_description: DeliveryCustomTaskDescription, +): TaskBookingLabel { + const pickupDescription = + task_description.phases[0].activity.description.activities[1].description.description; + return { + description: { + task_definition_id: task_description.category, + pickup: pickupDescription.pickup_zone, + destination: task_description.phases[1].activity.description.activities[0].description, + cart_id: pickupDescription.cart_id, + }, + }; +} + +function makePatrolTaskBookingLabel(task_description: PatrolTaskDescription): TaskBookingLabel { + return { + description: { + task_definition_id: 'patrol', + destination: task_description.places[task_description.places.length - 1], + }, + }; +} + +function makeCustomComposeTaskBookingLabel(): TaskBookingLabel { + return { + description: { + task_definition_id: 'custom_compose', + }, + }; +} // A bunch of manually defined descriptions to avoid using `any`. export interface PatrolTaskDescription { @@ -1208,6 +1287,7 @@ const defaultFavoriteTask = (): TaskFavorite => { unix_millis_earliest_start_time: 0, priority: { type: 'binary', value: 0 }, user: '', + task_definition_id: DefaultTaskDefinitionId, }; }; @@ -1222,7 +1302,6 @@ export interface CreateTaskFormProps patrolWaypoints?: string[]; pickupZones?: string[]; cartIds?: string[]; - emergencyLots?: string[]; pickupPoints?: Record; dropoffPoints?: Record; favoritesTasks?: TaskFavorite[]; @@ -1249,7 +1328,6 @@ export function CreateTaskForm({ patrolWaypoints = [], pickupZones = [], cartIds = [], - emergencyLots = [], pickupPoints = {}, dropoffPoints = {}, favoritesTasks = [], @@ -1283,7 +1361,9 @@ export function CreateTaskForm({ const [favoriteTaskTitleError, setFavoriteTaskTitleError] = React.useState(false); const [savingFavoriteTask, setSavingFavoriteTask] = React.useState(false); - const [taskType, setTaskType] = React.useState(undefined); + const [taskDefinitionId, setTaskDefinitionId] = React.useState( + SupportedTaskDefinitions[0].task_definition_id, + ); const [taskRequest, setTaskRequest] = React.useState( () => requestTask ?? defaultTask(), ); @@ -1366,9 +1446,7 @@ export function CreateTaskForm({ return ( { - handleCustomComposeTaskDescriptionChange(desc); - }} + onChange={(desc) => handleCustomComposeTaskDescriptionChange(desc)} allowSubmit={allowSubmit} /> ); @@ -1387,6 +1465,8 @@ export function CreateTaskForm({ desc.phases[0].activity.description.activities[1].description.category = taskRequest.description.category; handleTaskDescriptionChange('compose', desc); + const pickupPerformAction = + desc.phases[0].activity.description.activities[1].description.description; }} allowSubmit={allowSubmit} /> @@ -1404,6 +1484,8 @@ export function CreateTaskForm({ desc.phases[0].activity.description.activities[1].description.category = taskRequest.description.category; handleTaskDescriptionChange('compose', desc); + const pickupPerformAction = + desc.phases[0].activity.description.activities[1].description.description; }} allowSubmit={allowSubmit} /> @@ -1414,7 +1496,7 @@ export function CreateTaskForm({ }; const handleTaskTypeChange = (ev: React.ChangeEvent) => { const newType = ev.target.value; - setTaskType(newType); + setTaskDefinitionId(newType); if (newType === 'custom_compose') { taskRequest.category = 'custom_compose'; @@ -1449,44 +1531,7 @@ export function CreateTaskForm({ request.requester = requester; request.unix_millis_request_time = Date.now(); - if ( - taskType === 'delivery_pickup' || - taskType === 'delivery_sequential_lot_pickup' || - taskType === 'delivery_area_pickup' - ) { - const goToOneOfThePlaces: GoToOneOfThePlacesActivity = { - category: 'go_to_place', - description: { - one_of: emergencyLots.map((placeName) => { - return { - waypoint: placeName, - }; - }), - constraints: [ - { - category: 'prefer_same_map', - description: '', - }, - ], - }, - }; - - // FIXME: there should not be any statically defined duration estimates as - // it makes assumptions of the deployments. - const deliveryDropoff: DropoffActivity = { - category: 'perform_action', - description: { - unix_millis_action_duration_estimate: 60000, - category: 'delivery_dropoff', - description: {}, - }, - }; - const onCancelDropoff: OnCancelDropoff = { - category: 'sequence', - description: [goToOneOfThePlaces, deliveryDropoff], - }; - request.description.phases[1].on_cancel = [onCancelDropoff]; - } else if (taskType === 'custom_compose') { + if (taskDefinitionId === 'custom_compose') { try { const obj = JSON.parse(request.description); request.category = 'compose'; @@ -1498,6 +1543,42 @@ export function CreateTaskForm({ } } + // Generate booking label for each task + try { + let requestBookingLabel: TaskBookingLabel | null = null; + switch (taskDefinitionId) { + case 'delivery_pickup': + requestBookingLabel = makeDeliveryTaskBookingLabel(request.description); + break; + case 'delivery_sequential_lot_pickup': + case 'delivery_area_pickup': + requestBookingLabel = makeDeliveryCustomTaskBookingLabel(request.description); + break; + case 'patrol': + requestBookingLabel = makePatrolTaskBookingLabel(request.description); + break; + case 'custom_compose': + requestBookingLabel = makeCustomComposeTaskBookingLabel(); + break; + } + + if (!requestBookingLabel) { + const error = Error( + `Failed to generate booking label for task request of definition ID: ${taskDefinitionId}`, + ); + onFail && onFail(error, [request]); + return; + } + + const labelString = serializeTaskBookingLabel(requestBookingLabel); + if (labelString) { + request.labels = [labelString]; + } + console.log(`labels: ${request.labels}`); + } catch (e) { + console.error('Failed to generate string for task request label'); + } + try { setSubmitting(true); await submitTasks([request], scheduling ? schedule : null); @@ -1543,7 +1624,11 @@ export function CreateTaskForm({ } try { setSavingFavoriteTask(true); - await submitFavoriteTask(favoriteTaskBuffer); + + const favoriteTask = favoriteTaskBuffer; + favoriteTask.task_definition_id = taskDefinitionId ?? DefaultTaskDefinitionId; + + await submitFavoriteTask(favoriteTask); setSavingFavoriteTask(false); onSuccessFavoriteTask && onSuccessFavoriteTask( @@ -1623,6 +1708,7 @@ export function CreateTaskForm({ unix_millis_earliest_start_time: 0, priority: favoriteTask.priority, }); + setTaskDefinitionId(favoriteTask.task_definition_id); }} /> ); @@ -1659,39 +1745,14 @@ export function CreateTaskForm({ }} InputLabelProps={{ style: { fontSize: isScreenHeightLessThan800 ? 16 : 20 } }} > - - Delivery - 1:1 - - - Delivery - Sequential lot pick up - - - Delivery - Area pick up - - - Patrol - - Custom Compose Task + {SupportedTaskDefinitions.map((taskDefinition) => ( + + {taskDefinition.task_display_name} + + ))} @@ -1782,7 +1843,7 @@ export function CreateTaskForm({ setOpenFavoriteDialog(true); }} style={{ marginTop: theme.spacing(2), marginBottom: theme.spacing(2) }} - disabled={taskType === 'custom_compose'} + disabled={taskDefinitionId === 'custom_compose'} > {callToUpdateFavoriteTask ? `Confirm edits` : 'Save as a favorite task'} diff --git a/packages/react-components/lib/tasks/index.ts b/packages/react-components/lib/tasks/index.ts index 6cc17baaf..2214ea0ea 100644 --- a/packages/react-components/lib/tasks/index.ts +++ b/packages/react-components/lib/tasks/index.ts @@ -1,6 +1,7 @@ export * from './create-task'; export * from './task-info'; export * from './task-logs'; +export * from './task-booking-label-utils'; export * from './task-table'; export * from './task-timeline'; export * from './task-table-datagrid'; diff --git a/packages/react-components/lib/tasks/task-booking-label-utils.tsx b/packages/react-components/lib/tasks/task-booking-label-utils.tsx new file mode 100644 index 000000000..bfd4b0150 --- /dev/null +++ b/packages/react-components/lib/tasks/task-booking-label-utils.tsx @@ -0,0 +1,46 @@ +import { ajv } from '../utils/schema-utils'; +import schema from 'api-client/dist/schema'; +import type { TaskBookingLabel, TaskState } from 'api-client'; + +const validateTaskBookingLabel = ajv.compile(schema.components.schemas.TaskBookingLabel); + +export function serializeTaskBookingLabel(label: TaskBookingLabel): string { + return JSON.stringify(label); +} + +export function getTaskBookingLabelFromJsonString( + jsonString: string, +): TaskBookingLabel | undefined { + try { + // Validate first before parsing again into the interface + const validated = validateTaskBookingLabel(JSON.parse(jsonString)); + if (validated) { + const parsedLabel: TaskBookingLabel = JSON.parse(jsonString); + return parsedLabel; + } + } catch (e) { + console.error(`Failed to parse TaskBookingLabel: ${(e as Error).message}`); + return undefined; + } + + console.error(`Failed to validate TaskBookingLabel`); + return undefined; +} + +export function getTaskBookingLabelFromTaskState(taskState: TaskState): TaskBookingLabel | null { + let requestLabel: TaskBookingLabel | null = null; + if (taskState.booking.labels) { + for (const label of taskState.booking.labels) { + try { + const parsedLabel = getTaskBookingLabelFromJsonString(label); + if (parsedLabel) { + requestLabel = parsedLabel; + break; + } + } catch (e) { + continue; + } + } + } + return requestLabel; +} diff --git a/packages/react-components/lib/tasks/task-table-datagrid.tsx b/packages/react-components/lib/tasks/task-table-datagrid.tsx index a5fde005f..d63c3cc12 100644 --- a/packages/react-components/lib/tasks/task-table-datagrid.tsx +++ b/packages/react-components/lib/tasks/task-table-datagrid.tsx @@ -13,9 +13,9 @@ import { } from '@mui/x-data-grid'; import { styled, Stack, Typography, Tooltip, useMediaQuery, SxProps, Theme } from '@mui/material'; import * as React from 'react'; -import { TaskState, TaskRequest, ApiServerModelsRmfApiTaskStateStatus as Status } from 'api-client'; +import { TaskState, ApiServerModelsRmfApiTaskStateStatus as Status } from 'api-client'; import { InsertInvitation as ScheduleIcon, Person as UserIcon } from '@mui/icons-material/'; -import { parsePickup, parseDestination } from './utils'; +import { getTaskBookingLabelFromTaskState } from './task-booking-label-utils'; const classes = { taskActiveCell: 'MuiDataGrid-cell-active-cell', @@ -57,7 +57,6 @@ const StyledDataGrid = styled(DataGrid)(({ theme }) => ({ export interface Tasks { isLoading: boolean; data: TaskState[]; - requests: Record; total: number; page: number; pageSize: number; @@ -185,8 +184,11 @@ export function TaskDataGridTable({ width: 150, editable: false, valueGetter: (params: GridValueGetterParams) => { - const request: TaskRequest | undefined = tasks.requests[params.row.booking.id]; - return parsePickup(request); + const requestLabel = getTaskBookingLabelFromTaskState(params.row); + if (requestLabel && requestLabel.description.pickup) { + return requestLabel.description.pickup; + } + return 'n/a'; }, flex: 1, filterOperators: getMinimalStringFilterOperators, @@ -198,8 +200,11 @@ export function TaskDataGridTable({ width: 150, editable: false, valueGetter: (params: GridValueGetterParams) => { - const request: TaskRequest | undefined = tasks.requests[params.row.booking.id]; - return parseDestination(params.row, request); + const requestLabel = getTaskBookingLabelFromTaskState(params.row); + if (requestLabel && requestLabel.description.destination) { + return requestLabel.description.destination; + } + return 'n/a'; }, flex: 1, filterOperators: getMinimalStringFilterOperators, diff --git a/packages/react-components/lib/utils/index.ts b/packages/react-components/lib/utils/index.ts index 237b1f493..754cf481a 100644 --- a/packages/react-components/lib/utils/index.ts +++ b/packages/react-components/lib/utils/index.ts @@ -2,3 +2,4 @@ export * from './geometry'; export * from './health'; export * from './item-table'; export * from './misc'; +export * from './schema-utils'; diff --git a/packages/react-components/lib/utils/schema-utils.ts b/packages/react-components/lib/utils/schema-utils.ts new file mode 100644 index 000000000..e91d91097 --- /dev/null +++ b/packages/react-components/lib/utils/schema-utils.ts @@ -0,0 +1,8 @@ +import Ajv from 'ajv'; +import schema from 'api-client/schema'; + +export const ajv = new Ajv(); + +Object.entries(schema.components.schemas).forEach(([k, v]) => { + ajv.addSchema(v, `#/components/schemas/${k}`); +}); diff --git a/packages/react-components/package.json b/packages/react-components/package.json index 7b0787731..5707be2b3 100644 --- a/packages/react-components/package.json +++ b/packages/react-components/package.json @@ -35,6 +35,7 @@ "@types/react-leaflet": "^2.5.2", "@types/shallowequal": "^1.1.1", "@types/three": "^0.156.0", + "ajv": "^8.10.0", "api-client": "workspace:*", "clsx": "^1.1.1", "crc": "^3.8.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8f179d8f8..f9942c907 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -150,9 +150,6 @@ importers: '@types/three': specifier: ^0.156.0 version: 0.156.0 - ajv: - specifier: ^8.10.0 - version: 8.11.0 api-client: specifier: workspace:* version: link:../api-client @@ -388,6 +385,9 @@ importers: '@types/three': specifier: ^0.156.0 version: 0.156.0 + ajv: + specifier: ^8.10.0 + version: 8.11.0 api-client: specifier: workspace:* version: link:../api-client