diff --git a/jupyter_scheduler/models.py b/jupyter_scheduler/models.py index d6319f6f8..01b41186e 100644 --- a/jupyter_scheduler/models.py +++ b/jupyter_scheduler/models.py @@ -1,8 +1,8 @@ import os from enum import Enum -from typing import Dict, List, Optional, Union +from typing import Any, Dict, List, Optional, Type, Union -from pydantic import BaseModel, root_validator +from pydantic import BaseModel, root_validator, validator Tags = List[str] EnvironmentParameterValues = Union[int, float, bool, str] @@ -11,6 +11,65 @@ SCHEDULE_RE = "" +class NotificationEvent(str, Enum): + """ + Enum that represents events triggering notifications. Implementers can extend + this enum to include additional notification events as needed. + + Attributes: + SUCCESS (str): Sent when a job completes successfully. + FAILURE (str): Sent on job failure. + STOPPED (str): Sent when a job is manually stopped. + """ + + SUCCESS = "Success" + FAILURE = "Failure" + STOPPED = "Stopped" + + +class NotificationsConfig(BaseModel): + """Represents configuration for notifications. + + Attributes: + send_to (List[str]): A list of symbols (e.g., email addresses) to which notifications should be sent. + events (List[NotificationEvent]): A list of events that should trigger the sending of notifications. + include_output (bool): A flag indicating whether a output should be included in the notification. Default is False. + """ + + send_to: List[str] = [] + events: List[NotificationEvent] = [] + include_output: bool = False + + class Config: + orm_mode = True + + @validator("send_to") + def validate_send_to(cls, v): + if len(v) > 100: + raise ValueError("Too many 'Send to' addressee identifiers. Maximum allowed is 100.") + return v + + @validator("send_to", each_item=True) + def validate_send_to_items(cls, v): + if len(v) > 100: + raise ValueError( + "Each 'Send to' addressee identifier should be at most 100 characters long." + ) + return v + + @validator("events") + def validate_events(cls, v): + if len(v) > 100: + raise ValueError("Too many notification events. Maximum allowed is 100.") + return v + + @validator("events", each_item=True) + def validate_events_items(cls, v): + if len(v.value) > 100: + raise ValueError("Each notification event should be at most 100 characters long.") + return v + + class RuntimeEnvironment(BaseModel): """Defines a runtime context where job execution will happen. For example, conda @@ -26,6 +85,8 @@ class RuntimeEnvironment(BaseModel): compute_types: Optional[List[str]] default_compute_type: Optional[str] # Should be a member of the compute_types list utc_only: Optional[bool] + notifications_enabled: bool = False + notification_events: List[Type[NotificationEvent]] = [] def __str__(self): return self.json() @@ -85,6 +146,7 @@ class CreateJob(BaseModel): name: str output_filename_template: Optional[str] = OUTPUT_FILENAME_TEMPLATE compute_type: Optional[str] = None + notifications_config: Optional[NotificationsConfig] = None @root_validator def compute_input_filename(cls, values) -> Dict: @@ -145,6 +207,7 @@ class DescribeJob(BaseModel): status: Status = Status.CREATED status_message: Optional[str] = None downloaded: bool = False + notifications_config: Optional[NotificationsConfig] = None class Config: orm_mode = True @@ -209,6 +272,7 @@ class CreateJobDefinition(BaseModel): compute_type: Optional[str] = None schedule: Optional[str] = None timezone: Optional[str] = None + notifications_config: Optional[NotificationsConfig] = None @root_validator def compute_input_filename(cls, values) -> Dict: @@ -234,6 +298,7 @@ class DescribeJobDefinition(BaseModel): create_time: int update_time: int active: bool + notifications_config: Optional[NotificationsConfig] = None class Config: orm_mode = True @@ -253,6 +318,7 @@ class UpdateJobDefinition(BaseModel): active: Optional[bool] = None compute_type: Optional[str] = None input_uri: Optional[str] = None + notifications_config: Optional[NotificationsConfig] = None class ListJobDefinitionsQuery(BaseModel): diff --git a/jupyter_scheduler/orm.py b/jupyter_scheduler/orm.py index 0a4a214f3..88325792a 100644 --- a/jupyter_scheduler/orm.py +++ b/jupyter_scheduler/orm.py @@ -4,8 +4,14 @@ from uuid import uuid4 import sqlalchemy.types as types -from sqlalchemy import Boolean, Column, Integer, String, create_engine -from sqlalchemy.orm import declarative_base, declarative_mixin, registry, sessionmaker +from sqlalchemy import Boolean, Column, ForeignKey, Integer, String, create_engine +from sqlalchemy.orm import ( + declarative_base, + declarative_mixin, + registry, + relationship, + sessionmaker, +) from jupyter_scheduler.models import EmailNotifications, Status from jupyter_scheduler.utils import get_utc_timestamp @@ -67,6 +73,14 @@ def process_result_value(self, value, dialect): mapper_registry = registry() +class NotificationsConfigTable(Base): + __tablename__ = "notifications_config" + id = Column(String(36), primary_key=True, default=generate_uuid) + include_output = Column(Boolean, default=False) + send_to = Column(JsonType, nullable=False) + events = Column(JsonType, nullable=False) + + @declarative_mixin class CommonColumns: runtime_environment_name = Column(String(256), nullable=False) @@ -98,6 +112,8 @@ class Job(CommonColumns, Base): url = Column(String(256), default=generate_jobs_url) pid = Column(Integer) idempotency_token = Column(String(256)) + notifications_config_id = Column(String(36), ForeignKey("notifications_config.id")) + notifications_config = relationship("NotificationsConfigTable", lazy="joined") class JobDefinition(CommonColumns, Base): @@ -108,6 +124,8 @@ class JobDefinition(CommonColumns, Base): url = Column(String(256), default=generate_job_definitions_url) create_time = Column(Integer, default=get_utc_timestamp) active = Column(Boolean, default=True) + notifications_config_id = Column(String(36), ForeignKey("notifications_config.id")) + notifications_config = relationship("NotificationsConfigTable", lazy="joined") def create_tables(db_url, drop_tables=False): diff --git a/jupyter_scheduler/scheduler.py b/jupyter_scheduler/scheduler.py index cbe2acb8f..2a8385930 100644 --- a/jupyter_scheduler/scheduler.py +++ b/jupyter_scheduler/scheduler.py @@ -38,7 +38,12 @@ UpdateJob, UpdateJobDefinition, ) -from jupyter_scheduler.orm import Job, JobDefinition, create_session +from jupyter_scheduler.orm import ( + Job, + JobDefinition, + NotificationsConfigTable, + create_session, +) from jupyter_scheduler.utils import create_output_directory, create_output_filename @@ -396,7 +401,17 @@ def create_job(self, model: CreateJob) -> str: if not model.output_formats: model.output_formats = [] - job = Job(**model.dict(exclude_none=True, exclude={"input_uri"})) + orm_notifications_config = None + if model.notifications_config: + orm_notifications_config = NotificationsConfigTable( + **model.notifications_config.dict() + ) + session.add(orm_notifications_config) + + job_data = model.dict(exclude={"input_uri", "notifications_config"}) + job_data["notifications_config"] = orm_notifications_config + + job = Job(**job_data) session.add(job) session.commit() @@ -534,7 +549,20 @@ def create_job_definition(self, model: CreateJobDefinition) -> str: if not self.file_exists(model.input_uri): raise InputUriError(model.input_uri) - job_definition = JobDefinition(**model.dict(exclude_none=True, exclude={"input_uri"})) + orm_notifications_config = None + if model.notifications_config: + orm_notifications_config = NotificationsConfigTable( + **model.notifications_config.dict() + ) + session.add(orm_notifications_config) + session.flush() + + job_definition_data = model.dict( + exclude={"input_uri", "notifications_config"}, exclude_none=True + ) + job_definition = JobDefinition( + **job_definition_data, notifications_config=orm_notifications_config + ) session.add(job_definition) session.commit() diff --git a/src/components/notifications-config-detail.tsx b/src/components/notifications-config-detail.tsx new file mode 100644 index 000000000..c38cac5cd --- /dev/null +++ b/src/components/notifications-config-detail.tsx @@ -0,0 +1,45 @@ +import React from 'react'; + +import { Card, CardContent, Stack, FormLabel } from '@mui/material'; +import { useTranslator } from '../hooks'; +import { Scheduler } from '../handler'; +import { LabeledValue } from './labeled-value'; + +type INotificationsConfigDetailProps = { + notificationsConfig: Scheduler.INotificationsConfig; +}; + +export function NotificationsConfigDetail( + props: INotificationsConfigDetailProps +): JSX.Element { + const trans = useTranslator('jupyterlab'); + const sendTo = props.notificationsConfig.send_to.join(', '); + const events = props.notificationsConfig.events.join(', '); + + return ( + + + + {trans.__('Notifications Settings')} + + + + + + + + + ); +} diff --git a/src/components/notifications-config.tsx b/src/components/notifications-config.tsx new file mode 100644 index 000000000..503041b45 --- /dev/null +++ b/src/components/notifications-config.tsx @@ -0,0 +1,205 @@ +import React, { useState } from 'react'; + +import { Cluster } from './cluster'; +import { + Checkbox, + Chip, + FormControl, + FormControlLabel, + InputLabel, + MenuItem, + Switch, + TextField +} from '@mui/material'; +import { NotificationsConfigModel } from '../model'; +import Select, { SelectChangeEvent } from '@mui/material/Select'; +import { Stack } from './stack'; +import { useTranslator } from '../hooks'; + +type NotificationsConfigProps = { + notificationEvents: string[]; + id: string; + notificationsConfig: NotificationsConfigModel; + notificationsConfigChange: ( + newConfig: Partial + ) => void; +}; + +export function NotificationsConfig( + props: NotificationsConfigProps +): JSX.Element | null { + const trans = useTranslator('jupyterlab'); + const [sendToInput, setSendToInput] = useState( + props.notificationsConfig.sendTo?.join(', ') ?? '' + ); + + function enableNotificationChange(e: React.ChangeEvent) { + props.notificationsConfigChange({ + ...props.notificationsConfig, + enableNotification: e.target.checked + }); + } + + function selectChange(e: SelectChangeEvent) { + const newEvent = e.target.value; + if (!props.notificationsConfig.selectedEvents?.includes(newEvent)) { + const updatedEvents = [ + ...(props.notificationsConfig.selectedEvents ?? []), + newEvent + ]; + props.notificationsConfigChange({ + ...props.notificationsConfig, + selectedEvents: updatedEvents + }); + } + } + + function sendToChange(e: React.ChangeEvent) { + setSendToInput(e.target.value); + } + + function blur() { + const sendToArray = sendToInput + .split(',') + .map(email => email.trim()) + .filter(email => email); + props.notificationsConfigChange({ + ...props.notificationsConfig, + sendTo: sendToArray + }); + } + + function keyDown(e: React.KeyboardEvent) { + if (e.key === 'Enter') { + e.preventDefault(); + blur(); + } + } + + function includeOutputChange(event: React.ChangeEvent) { + const updatedValue = event.target.checked; + props.notificationsConfigChange({ + ...props.notificationsConfig, + includeOutput: updatedValue + }); + } + + function deleteSelectedEvent(eventToDelete: string) { + const updatedEvents = props.notificationsConfig.selectedEvents?.filter( + event => event !== eventToDelete + ); + + props.notificationsConfigChange({ + ...props.notificationsConfig, + selectedEvents: updatedEvents + }); + } + + if (!props.notificationEvents.length) { + return null; + } + + return ( + + {trans.__('Notifications Settings')} + + } + label={trans.__('Enable notifications')} + /> + + !props.notificationsConfig.selectedEvents?.includes(e) + )} + onChange={selectChange} + disabled={!props.notificationsConfig.enableNotification} + /> + + + } + label={trans.__('Include output')} + /> + + ); +} + +type NotificationEventsSelectProps = { + id: string; + value: string[]; + onChange: (e: SelectChangeEvent) => void; + disabled: boolean; +}; + +function NotificationEventsSelect(props: NotificationEventsSelectProps) { + const trans = useTranslator('jupyterlab'); + const label = trans.__('Notification Events'); + const labelId = `${props.id}-label`; + + return ( + + + {label} + + + + ); +} + +type SelectedEventsChipsProps = { + value: string[]; + onChange: (eventToDelete: string) => void; + disabled: boolean; +}; + +function SelectedEventsChips(props: SelectedEventsChipsProps) { + return ( + + {props.value.map(e => ( + props.onChange(e)} + disabled={props.disabled} + /> + ))} + + ); +} diff --git a/src/handler.ts b/src/handler.ts index c852fab85..f198effe5 100644 --- a/src/handler.ts +++ b/src/handler.ts @@ -363,6 +363,7 @@ export namespace Scheduler { compute_type?: string; schedule?: string; timezone?: string; + notifications_config?: INotificationsConfig; } export interface IUpdateJobDefinition { @@ -371,6 +372,7 @@ export namespace Scheduler { timezone?: string; active?: boolean; input_uri?: string; + notifications_config?: INotificationsConfig; } export interface IDescribeJobDefinition { @@ -389,6 +391,7 @@ export namespace Scheduler { create_time: number; update_time: number; active: boolean; + notifications_config?: INotificationsConfig; } export interface IEmailNotifications { @@ -398,6 +401,12 @@ export namespace Scheduler { no_alert_for_skipped_rows: boolean; } + export interface INotificationsConfig { + send_to: string[]; + events: string[]; + include_output: boolean; + } + export interface ICreateJob { name: string; input_uri: string; @@ -415,6 +424,7 @@ export namespace Scheduler { output_filename_template?: string; output_formats?: string[]; compute_type?: string; + notifications_config?: INotificationsConfig; } export interface ICreateJobFromDefinition { @@ -463,6 +473,7 @@ export namespace Scheduler { start_time?: number; end_time?: number; downloaded: boolean; + notifications_config?: INotificationsConfig; } export interface ICreateJobResponse { @@ -521,6 +532,8 @@ export namespace Scheduler { compute_types: string[] | null; default_compute_type: string | null; utc_only?: boolean; + notifications_enabled: boolean; + notification_events: string[]; } export interface IOutputFormat { diff --git a/src/mainviews/create-job.tsx b/src/mainviews/create-job.tsx index 20096e38d..e7fe725a8 100644 --- a/src/mainviews/create-job.tsx +++ b/src/mainviews/create-job.tsx @@ -18,7 +18,13 @@ import { import { ParametersPicker } from '../components/parameters-picker'; import { Scheduler, SchedulerService } from '../handler'; import { useEventLogger, useTranslator } from '../hooks'; -import { ICreateJobModel, IJobParameter, JobsView } from '../model'; +import { + ICreateJobModel, + IJobParameter, + NotificationsConfigModel, + JobsView, + emptyNotificationsConfigModel +} from '../model'; import { Scheduler as SchedulerTokens } from '../tokens'; import { NameError } from '../util/job-name-validation'; @@ -41,6 +47,7 @@ import { } from '@mui/material'; import { Box, Stack } from '@mui/system'; +import { NotificationsConfig } from '../components/notifications-config'; export interface ICreateJobProps { model: ICreateJobModel; @@ -94,6 +101,7 @@ export function CreateJob(props: ICreateJobProps): JSX.Element { useState({}); const api = useMemo(() => new SchedulerService({}), []); + const emptyNotifConfigModel = emptyNotificationsConfigModel(); // Retrieve the environment list once. useEffect(() => { @@ -121,12 +129,14 @@ export function CreateJob(props: ICreateJobProps): JSX.Element { envList[0].name )?.map(format => format.name); - props.handleModelChange({ + const newModel = { ...props.model, environment: envList[0].name, computeType: newComputeType, outputFormats: outputFormats - }); + }; + + props.handleModelChange(newModel); } }; @@ -159,6 +169,9 @@ export function CreateJob(props: ICreateJobProps): JSX.Element { ? 'UTC' : Intl.DateTimeFormat().resolvedOptions().timeZone }); + if (currEnv.notifications_enabled && !props.model.notificationsConfig) { + notificationsConfigChange(emptyNotifConfigModel); + } } prevEnvName.current = props.model.environment; @@ -326,6 +339,14 @@ export function CreateJob(props: ICreateJobProps): JSX.Element { jobOptions.parameters = serializeParameters(props.model.parameters); } + if (props.model.notificationsConfig?.enableNotification) { + jobOptions.notifications_config = { + send_to: props.model.notificationsConfig.sendTo ?? [], + events: props.model.notificationsConfig.selectedEvents ?? [], + include_output: props.model.notificationsConfig.includeOutput ?? false + }; + } + props.handleModelChange({ ...props.model, createError: undefined, @@ -373,6 +394,14 @@ export function CreateJob(props: ICreateJobProps): JSX.Element { ); } + if (props.model.notificationsConfig?.enableNotification) { + jobDefinitionOptions.notifications_config = { + send_to: props.model.notificationsConfig.sendTo ?? [], + events: props.model.notificationsConfig.selectedEvents ?? [], + include_output: props.model.notificationsConfig.includeOutput ?? false + }; + } + props.handleModelChange({ ...props.model, createError: undefined, @@ -398,6 +427,18 @@ export function CreateJob(props: ICreateJobProps): JSX.Element { }); }; + function notificationsConfigChange( + updatedConfig: Partial + ) { + const newModel = { + ...props.model, + notificationsConfig: { + ...updatedConfig + } + }; + props.handleModelChange(newModel); + } + const removeParameter = (idx: number) => { const newParams = props.model.parameters || []; newParams.splice(idx, 1); @@ -515,6 +556,18 @@ export function CreateJob(props: ICreateJobProps): JSX.Element { environment={props.model.environment} value={props.model.computeType} /> + {envsByName[props.model.environment]?.notifications_enabled && ( + + )} + )} {AdvancedOptions} ); diff --git a/src/mainviews/detail-view/job-detail.tsx b/src/mainviews/detail-view/job-detail.tsx index 240cecd5d..3064ecf38 100644 --- a/src/mainviews/detail-view/job-detail.tsx +++ b/src/mainviews/detail-view/job-detail.tsx @@ -1,18 +1,5 @@ import React, { useCallback, useState } from 'react'; -import { JupyterFrontEnd } from '@jupyterlab/application'; - -import { ButtonBar } from '../../components/button-bar'; -import { - ConfirmDialogDeleteButton, - ConfirmDialogStopButton -} from '../../components/confirm-dialog-buttons'; -import { JobFileLink } from '../../components/job-file-link'; -import { Scheduler, SchedulerService } from '../../handler'; -import { useEventLogger, useTranslator } from '../../hooks'; -import { ICreateJobModel, IJobDetailModel, JobsView } from '../../model'; -import { Scheduler as SchedulerTokens } from '../../tokens'; - import { Alert, Button, @@ -23,7 +10,23 @@ import { TextField, TextFieldProps } from '@mui/material'; +import { ButtonBar } from '../../components/button-bar'; import { CommandIDs } from '../..'; +import { + ConfirmDialogDeleteButton, + ConfirmDialogStopButton +} from '../../components/confirm-dialog-buttons'; +import { ICreateJobModel, IJobDetailModel, JobsView } from '../../model'; +import { + ILabeledValueProps, + LabeledValue +} from '../../components/labeled-value'; +import { JobFileLink } from '../../components/job-file-link'; +import { JupyterFrontEnd } from '@jupyterlab/application'; +import { NotificationsConfigDetail } from '../../components/notifications-config-detail'; +import { Scheduler, SchedulerService } from '../../handler'; +import { Scheduler as SchedulerTokens } from '../../tokens'; +import { useEventLogger, useTranslator } from '../../hooks'; export const TextFieldStyled = (props: TextFieldProps): JSX.Element => ( ( /> ); -import { - ILabeledValueProps, - LabeledValue -} from '../../components/labeled-value'; - export interface IJobDetailProps { app: JupyterFrontEnd; model: IJobDetailModel | null; @@ -354,6 +352,11 @@ export function JobDetail(props: IJobDetailProps): JSX.Element { {JobButtonBar} {CoreOptions} {Parameters} + {props.model.notificationsConfig && ( + + )} {AdvancedOptions} ); diff --git a/src/model.ts b/src/model.ts index a3b5c6fe2..13f26657b 100644 --- a/src/model.ts +++ b/src/model.ts @@ -74,6 +74,24 @@ export type ModelWithScheduleFields = { scheduleMinute: string; }; +export type NotificationsConfigModel = { + sendTo?: string[]; + includeOutput?: boolean; + enableNotification?: boolean; + availableEvents?: string[]; + selectedEvents?: string[]; +}; + +export function emptyNotificationsConfigModel(): NotificationsConfigModel { + return { + sendTo: [], + includeOutput: false, + enableNotification: true, + availableEvents: [], + selectedEvents: [] + }; +} + export interface ICreateJobModel extends ModelWithScheduleFields, PartialJSONObject { @@ -99,6 +117,7 @@ export interface ICreateJobModel tags?: string[]; // Is the create button disabled due to a submission in progress? createInProgress?: boolean; + notificationsConfig?: NotificationsConfigModel; } export const defaultScheduleFields: ModelWithScheduleFields = { @@ -310,6 +329,7 @@ export interface IJobDetailModel { outputPrefix?: string; job_files: Scheduler.IJobFile[]; downloaded: boolean; + notificationsConfig?: Scheduler.INotificationsConfig; } export interface IJobDefinitionModel { @@ -336,6 +356,7 @@ export interface IJobDefinitionModel { startTime?: number; endTime?: number; outputPrefix?: string; + notificationsConfig?: Scheduler.INotificationsConfig; } const convertParameters = (parameters: { @@ -384,7 +405,8 @@ export function convertDescribeJobtoJobDetail( updateTime: describeJob.update_time, startTime: describeJob.start_time, endTime: describeJob.end_time, - downloaded: describeJob.downloaded + downloaded: describeJob.downloaded, + notificationsConfig: describeJob.notifications_config }; } @@ -412,7 +434,8 @@ export function convertDescribeDefinitiontoDefinition( createTime: describeDefinition.create_time, updateTime: describeDefinition.update_time, schedule: describeDefinition.schedule, - timezone: describeDefinition.timezone + timezone: describeDefinition.timezone, + notificationsConfig: describeDefinition.notifications_config }; }