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
};
}