diff --git a/frontend/src/App/State/SettingsAppState.ts b/frontend/src/App/State/SettingsAppState.ts index b8e6f4954..bf35580a1 100644 --- a/frontend/src/App/State/SettingsAppState.ts +++ b/frontend/src/App/State/SettingsAppState.ts @@ -14,6 +14,7 @@ import ImportListOptionsSettings from 'typings/ImportListOptionsSettings'; import Indexer from 'typings/Indexer'; import IndexerFlag from 'typings/IndexerFlag'; import Notification from 'typings/Notification'; +import NotificationTemplate from 'typings/Settings/NotificationTemplate'; import QualityProfile from 'typings/QualityProfile'; import General from 'typings/Settings/General'; import NamingConfig from 'typings/Settings/NamingConfig'; @@ -55,6 +56,12 @@ export interface NotificationAppState extends AppSectionState, AppSectionDeleteState {} +export interface NotificationTemplateAppState + extends AppSectionState, + AppSectionSaveState { + pendingChanges: Partial; +} + export interface QualityProfilesAppState extends AppSectionState, AppSectionItemSchemaState {} @@ -101,6 +108,7 @@ interface SettingsAppState { naming: NamingAppState; namingExamples: NamingExamplesAppState; notifications: NotificationAppState; + notificationTemplates: NotificationTemplateAppState; qualityProfiles: QualityProfilesAppState; releaseProfiles: ReleaseProfilesAppState; ui: UiSettingsAppState; diff --git a/frontend/src/Components/Form/FormInputGroup.tsx b/frontend/src/Components/Form/FormInputGroup.tsx index 98c6e586a..e00fac3d8 100644 --- a/frontend/src/Components/Form/FormInputGroup.tsx +++ b/frontend/src/Components/Form/FormInputGroup.tsx @@ -20,6 +20,7 @@ import EnhancedSelectInput from './Select/EnhancedSelectInput'; import IndexerFlagsSelectInput from './Select/IndexerFlagsSelectInput'; import IndexerSelectInput from './Select/IndexerSelectInput'; import LanguageSelectInput from './Select/LanguageSelectInput'; +import NotificationTemplateSelectInput from './Select/NotificationTemplateSelectInput'; import MonitorEpisodesSelectInput from './Select/MonitorEpisodesSelectInput'; import MonitorNewItemsSelectInput from './Select/MonitorNewItemsSelectInput'; import ProviderDataSelectInput from './Select/ProviderOptionSelectInput'; @@ -82,6 +83,9 @@ function getComponent(type: InputType) { case inputTypes.INDEXER_FLAGS_SELECT: return IndexerFlagsSelectInput; + case inputTypes.NOTIFICATION_TEMPLATE_SELECT: + return NotificationTemplateSelectInput; + case inputTypes.DOWNLOAD_CLIENT_SELECT: return DownloadClientSelectInput; diff --git a/frontend/src/Components/Form/Select/NotificationTemplateSelectInput.tsx b/frontend/src/Components/Form/Select/NotificationTemplateSelectInput.tsx new file mode 100644 index 000000000..b7092c07b --- /dev/null +++ b/frontend/src/Components/Form/Select/NotificationTemplateSelectInput.tsx @@ -0,0 +1,81 @@ +import React, { useEffect } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { createSelector } from 'reselect'; +import AppState from 'App/State/AppState'; +import { fetchNotificationTemplates } from 'Store/Actions/settingsActions'; +import { EnhancedSelectInputChanged } from 'typings/inputs'; +import sortByProp from 'Utilities/Array/sortByProp'; +import translate from 'Utilities/String/translate'; +import EnhancedSelectInput from './EnhancedSelectInput'; + +function createNotificationTemplatesSelector(includeAny: boolean) { + return createSelector( + (state: AppState) => state.settings.notificationTemplates, + (notificationTemplates) => { + const { isFetching, isPopulated, error, items } = notificationTemplates; + + const values = items.sort(sortByProp('name')).map((notificationTemplate) => { + return { + key: notificationTemplate.id, + value: notificationTemplate.name, + }; + }); + + if (includeAny) { + values.unshift({ + key: 0, + value: `(${translate('Fallback')})`, + }); + } + + return { + isFetching, + isPopulated, + error, + values, + }; + } + ); +} + +interface NotificationTemplateSelectInputConnectorProps { + name: string; + value: number; + includeAny?: boolean; + values: object[]; + onChange: (change: EnhancedSelectInputChanged) => void; +} + +function NotificationTemplateSelectInput({ + name, + value, + includeAny = false, + onChange, +}: NotificationTemplateSelectInputConnectorProps) { + const dispatch = useDispatch(); + const { isFetching, isPopulated, values } = useSelector( + createNotificationTemplatesSelector(includeAny) + ); + + useEffect(() => { + if (!isPopulated) { + dispatch(fetchNotificationTemplates()); + } + }, [isPopulated, dispatch]); + + return ( + + ); +} + +NotificationTemplateSelectInput.defaultProps = { + includeAny: false, +}; + +export default NotificationTemplateSelectInput; diff --git a/frontend/src/Helpers/Props/inputTypes.ts b/frontend/src/Helpers/Props/inputTypes.ts index a0c4c817c..3b0654067 100644 --- a/frontend/src/Helpers/Props/inputTypes.ts +++ b/frontend/src/Helpers/Props/inputTypes.ts @@ -14,6 +14,7 @@ export const QUALITY_PROFILE_SELECT = 'qualityProfileSelect'; export const INDEXER_SELECT = 'indexerSelect'; export const INDEXER_FLAGS_SELECT = 'indexerFlagsSelect'; export const LANGUAGE_SELECT = 'languageSelect'; +export const NOTIFICATION_TEMPLATE_SELECT = 'notificationTemplateSelect'; export const DOWNLOAD_CLIENT_SELECT = 'downloadClientSelect'; export const ROOT_FOLDER_SELECT = 'rootFolderSelect'; export const SELECT = 'select'; @@ -42,6 +43,7 @@ export const all = [ PATH, QUALITY_PROFILE_SELECT, INDEXER_SELECT, + NOTIFICATION_TEMPLATE_SELECT, DOWNLOAD_CLIENT_SELECT, ROOT_FOLDER_SELECT, LANGUAGE_SELECT, @@ -75,6 +77,7 @@ export type InputType = | 'indexerSelect' | 'indexerFlagsSelect' | 'languageSelect' + | 'notificationTemplateSelect' | 'downloadClientSelect' | 'rootFolderSelect' | 'select' diff --git a/frontend/src/Settings/Notifications/NotificationSettings.js b/frontend/src/Settings/Notifications/NotificationSettings.js index 991624463..ed445222e 100644 --- a/frontend/src/Settings/Notifications/NotificationSettings.js +++ b/frontend/src/Settings/Notifications/NotificationSettings.js @@ -4,6 +4,7 @@ import PageContentBody from 'Components/Page/PageContentBody'; import SettingsToolbarConnector from 'Settings/SettingsToolbarConnector'; import translate from 'Utilities/String/translate'; import NotificationsConnector from './Notifications/NotificationsConnector'; +import NotificationTemplates from './NotificationTemplates/NotificationTemplates'; function NotificationSettings() { return ( @@ -14,6 +15,7 @@ function NotificationSettings() { + ); diff --git a/frontend/src/Settings/Notifications/NotificationTemplates/EditNotificationTemplateModal.tsx b/frontend/src/Settings/Notifications/NotificationTemplates/EditNotificationTemplateModal.tsx new file mode 100644 index 000000000..2b2fb3f7b --- /dev/null +++ b/frontend/src/Settings/Notifications/NotificationTemplates/EditNotificationTemplateModal.tsx @@ -0,0 +1,41 @@ +import React, { useCallback } from 'react'; +import { useDispatch } from 'react-redux'; +import Modal from 'Components/Modal/Modal'; +import { sizes } from 'Helpers/Props'; +import { clearPendingChanges } from 'Store/Actions/baseActions'; +import EditNotificationTemplateModalContent from './EditNotificationTemplateModalContent'; + +interface EditNotificationTemplateModalProps { + id?: number; + isOpen: boolean; + onModalClose: () => void; + onDeleteNotificationTemplatePress?: () => void; +} + +function EditNotificationTemplateModal({ + isOpen, + onModalClose, + ...otherProps +}: EditNotificationTemplateModalProps) { + const dispatch = useDispatch(); + + const handleModalClose = useCallback(() => { + dispatch( + clearPendingChanges({ + section: 'settings.notificationTemplates', + }) + ); + onModalClose(); + }, [dispatch, onModalClose]); + + return ( + + + + ); +} + +export default EditNotificationTemplateModal; diff --git a/frontend/src/Settings/Notifications/NotificationTemplates/EditNotificationTemplateModalContent.css b/frontend/src/Settings/Notifications/NotificationTemplates/EditNotificationTemplateModalContent.css new file mode 100644 index 000000000..a6ef7e3c0 --- /dev/null +++ b/frontend/src/Settings/Notifications/NotificationTemplates/EditNotificationTemplateModalContent.css @@ -0,0 +1,11 @@ +.deleteButton { + composes: button from '~Components/Link/Button.css'; + + margin-right: auto; +} + +.tagInternalInput { + composes: internalInput from '~Components/Form/Tag/TagInput.css'; + + flex: 0 0 100%; +} diff --git a/frontend/src/Settings/Notifications/NotificationTemplates/EditNotificationTemplateModalContent.css.d.ts b/frontend/src/Settings/Notifications/NotificationTemplates/EditNotificationTemplateModalContent.css.d.ts new file mode 100644 index 000000000..930ca0cb3 --- /dev/null +++ b/frontend/src/Settings/Notifications/NotificationTemplates/EditNotificationTemplateModalContent.css.d.ts @@ -0,0 +1,8 @@ +// This file is automatically generated. +// Please do not change this file! +interface CssExports { + 'deleteButton': string; + 'tagInternalInput': string; +} +export const cssExports: CssExports; +export default cssExports; diff --git a/frontend/src/Settings/Notifications/NotificationTemplates/EditNotificationTemplateModalContent.tsx b/frontend/src/Settings/Notifications/NotificationTemplates/EditNotificationTemplateModalContent.tsx new file mode 100644 index 000000000..b7519acfb --- /dev/null +++ b/frontend/src/Settings/Notifications/NotificationTemplates/EditNotificationTemplateModalContent.tsx @@ -0,0 +1,342 @@ +import React, { useCallback, useEffect } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { createSelector } from 'reselect'; +import AppState from 'App/State/AppState'; +import Form from 'Components/Form/Form'; +import FormGroup from 'Components/Form/FormGroup'; +import FormInputGroup from 'Components/Form/FormInputGroup'; +import FormLabel from 'Components/Form/FormLabel'; +import Button from 'Components/Link/Button'; +import SpinnerErrorButton from 'Components/Link/SpinnerErrorButton'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalFooter from 'Components/Modal/ModalFooter'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import usePrevious from 'Helpers/Hooks/usePrevious'; +import { inputTypes, kinds } from 'Helpers/Props'; +import { + saveNotificationTemplate, + setNotificationTemplateValue, +} from 'Store/Actions/Settings/notificationTemplates'; +import selectSettings from 'Store/Selectors/selectSettings'; +import NotificationTemplate from 'typings/Settings/NotificationTemplate'; +import translate from 'Utilities/String/translate'; +import styles from './EditNotificationTemplateModalContent.css'; + +const newNotificationTemplate: NotificationTemplate = { + id: 0, + name: '', + title: '', + body: '', + onGrab: true, + onDownload: true, + onUpgrade: true, + onImportComplete: true, + onRename: false, + onSeriesAdd: true, + onSeriesDelete: false, + onEpisodeFileDelete: false, + onEpisodeFileDeleteForUpgrade: false, + onHealthIssue: false, + onHealthRestored: false, + onApplicationUpdate: false, + onManualInteractionRequired: false +}; + +function createNotificationTemplateSelector(id?: number) { + return createSelector( + (state: AppState) => state.settings.notificationTemplates, + (notificationTemplates) => { + const { items, isFetching, error, isSaving, saveError, pendingChanges } = + notificationTemplates; + + const mapping = id ? items.find((i) => i.id === id)! : newNotificationTemplate; + const settings = selectSettings( + mapping, + pendingChanges, + saveError + ); + + return { + id, + isFetching, + error, + isSaving, + saveError, + item: settings.settings, + ...settings, + }; + } + ); +} + +interface EditNotificationTemplateModalContentProps { + id?: number; + onModalClose: () => void; + onDeleteNotificationTemplatePress?: () => void; +} + +function EditNotificationTemplateModalContent({ + id, + onModalClose, + onDeleteNotificationTemplatePress, +}: EditNotificationTemplateModalContentProps) { + const { item, isFetching, isSaving, error, saveError, ...otherProps } = + useSelector(createNotificationTemplateSelector(id)); + + const { + name, + title, + body, + onGrab, + onDownload, + onUpgrade, + onImportComplete, + onRename, + onSeriesAdd, + onSeriesDelete, + onEpisodeFileDelete, + onEpisodeFileDeleteForUpgrade, + onHealthIssue, + onHealthRestored, + onApplicationUpdate, + onManualInteractionRequired + } = item; + + const dispatch = useDispatch(); + const previousIsSaving = usePrevious(isSaving); + + useEffect(() => { + if (!id) { + Object.entries(newNotificationTemplate).forEach(([name, value]) => { + // @ts-expect-error 'setNotificationTemplateValue' isn't typed yet + dispatch(setNotificationTemplateValue({ name, value })); + }); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + useEffect(() => { + if (previousIsSaving && !isSaving && !saveError) { + onModalClose(); + } + }, [previousIsSaving, isSaving, saveError, onModalClose]); + + const handleSavePress = useCallback(() => { + dispatch(saveNotificationTemplate({ id })); + }, [dispatch, id]); + + const onInputChange = useCallback( + (payload: { name: string; value: string | number | boolean }) => { + // @ts-expect-error 'setNotificationTemplateValue' isn't typed yet + dispatch(setNotificationTemplateValue(payload)); + }, + [dispatch] + ); + + return ( + + + {id ? translate('EditNotificationTemplate') : translate('AddNotificationTemplate')} + + + +
+ + {translate('Name')} + + + + + + {translate('Title')} + + + + + + {translate('Body')} + + + + + + {translate('NotificationTriggers')} +
+
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+
+
+
+
+ + {id ? ( + + ) : null} + + + + + {translate('Save')} + + +
+ ); +} + +export default EditNotificationTemplateModalContent; diff --git a/frontend/src/Settings/Notifications/NotificationTemplates/NotificationTemplateItem.css b/frontend/src/Settings/Notifications/NotificationTemplates/NotificationTemplateItem.css new file mode 100644 index 000000000..a6305106c --- /dev/null +++ b/frontend/src/Settings/Notifications/NotificationTemplates/NotificationTemplateItem.css @@ -0,0 +1,25 @@ +.notificationTemplate { + composes: card from '~Components/Card.css'; + + width: 290px; +} + +.enabled { + display: flex; + flex-wrap: wrap; + margin-top: 5px; +} + +.name { + @add-mixin truncate; + + margin-bottom: 20px; + font-weight: 300; + font-size: 24px; +} + +.label { + composes: label from '~Components/Label.css'; + + max-width: 100%; +} diff --git a/frontend/src/Settings/Notifications/NotificationTemplates/NotificationTemplateItem.css.d.ts b/frontend/src/Settings/Notifications/NotificationTemplates/NotificationTemplateItem.css.d.ts new file mode 100644 index 000000000..b48a3ee11 --- /dev/null +++ b/frontend/src/Settings/Notifications/NotificationTemplates/NotificationTemplateItem.css.d.ts @@ -0,0 +1,10 @@ +// This file is automatically generated. +// Please do not change this file! +interface CssExports { + 'enabled': string; + 'label': string; + 'name': string; + 'notificationTemplate': string; +} +export const cssExports: CssExports; +export default cssExports; diff --git a/frontend/src/Settings/Notifications/NotificationTemplates/NotificationTemplateItem.tsx b/frontend/src/Settings/Notifications/NotificationTemplates/NotificationTemplateItem.tsx new file mode 100644 index 000000000..d887799a8 --- /dev/null +++ b/frontend/src/Settings/Notifications/NotificationTemplates/NotificationTemplateItem.tsx @@ -0,0 +1,190 @@ +import React, { useCallback } from 'react'; +import { useDispatch } from 'react-redux'; +import Card from 'Components/Card'; +import Label from 'Components/Label'; +import ConfirmModal from 'Components/Modal/ConfirmModal'; +import useModalOpenState from 'Helpers/Hooks/useModalOpenState'; +import { kinds } from 'Helpers/Props'; +import { deleteNotificationTemplate } from 'Store/Actions/Settings/notificationTemplates'; +import NotificationTemplate from 'typings/Settings/NotificationTemplate'; +import translate from 'Utilities/String/translate'; +import EditNotificationTemplateModal from './EditNotificationTemplateModal'; +import styles from './NotificationTemplateItem.css'; + +interface NotificationTemplateProps extends NotificationTemplate { + title: string; + body: string; + onGrab: boolean; + onDownload: boolean; + onUpgrade: boolean; + onImportComplete: boolean; + onRename: boolean; + onSeriesAdd: boolean; + onSeriesDelete: boolean; + onEpisodeFileDelete: boolean; + onEpisodeFileDeleteForUpgrade: boolean; + onHealthIssue: boolean; + onHealthRestored: boolean; + onApplicationUpdate: boolean; + onManualInteractionRequired: boolean; +} + +function NotificationTemplateItem(props: NotificationTemplateProps) { + const { + id, + name, + onGrab, + onDownload, + onUpgrade, + onImportComplete, + onRename, + onSeriesAdd, + onSeriesDelete, + onEpisodeFileDelete, + onEpisodeFileDeleteForUpgrade, + onHealthIssue, + onHealthRestored, + onApplicationUpdate, + onManualInteractionRequired + } = props; + + const dispatch = useDispatch(); + + const [ + isEditNotificationTemplateModalOpen, + setEditNotificationTemplateModalOpen, + setEditNotificationTemplateModalClosed, + ] = useModalOpenState(false); + + const [ + isDeleteNotificationTemplateModalOpen, + setDeleteNotificationTemplateModalOpen, + setDeleteNotificationTemplateModalClosed, + ] = useModalOpenState(false); + + const handleDeletePress = useCallback(() => { + dispatch(deleteNotificationTemplate({ id })); + }, [id, dispatch]); + + return ( + + {name ?
{name}
: null} + { + onGrab ? + : + null + } + { + onDownload ? + : + null + } + { + onUpgrade ? + : + null + } + { + onImportComplete ? + : + null + } + { + onRename ? + : + null + } + { + onSeriesAdd ? + : + null + } + { + onSeriesDelete ? + : + null + } + { + onEpisodeFileDelete ? + : + null + } + { + onEpisodeFileDeleteForUpgrade ? + : + null + } + { + onHealthIssue ? + : + null + } + { + onHealthRestored ? + : + null + } + { + onApplicationUpdate ? + : + null + } + { + onManualInteractionRequired ? + : + null + } + + + + +
+ ); +} + +export default NotificationTemplateItem; diff --git a/frontend/src/Settings/Notifications/NotificationTemplates/NotificationTemplates.css b/frontend/src/Settings/Notifications/NotificationTemplates/NotificationTemplates.css new file mode 100644 index 000000000..e08755df5 --- /dev/null +++ b/frontend/src/Settings/Notifications/NotificationTemplates/NotificationTemplates.css @@ -0,0 +1,19 @@ +.notificationTemplates { + display: flex; + flex-wrap: wrap; +} + +.addNotificationTemplate { + composes: notificationTemplate from '~./NotificationTemplateItem.css'; + background-color: var(--cardAlternateBackgroundColor); + color: var(--gray); + text-align: center; +} + +.center { + display: inline-block; + padding: 5px 20px 0; + border: 1px solid var(--borderColor); + border-radius: 4px; + background-color: var(--cardCenterBackgroundColor); +} diff --git a/frontend/src/Settings/Notifications/NotificationTemplates/NotificationTemplates.css.d.ts b/frontend/src/Settings/Notifications/NotificationTemplates/NotificationTemplates.css.d.ts new file mode 100644 index 000000000..db148e9b2 --- /dev/null +++ b/frontend/src/Settings/Notifications/NotificationTemplates/NotificationTemplates.css.d.ts @@ -0,0 +1,9 @@ +// This file is automatically generated. +// Please do not change this file! +interface CssExports { + 'addNotificationTemplate': string; + 'center': string; + 'notificationTemplates': string; +} +export const cssExports: CssExports; +export default cssExports; diff --git a/frontend/src/Settings/Notifications/NotificationTemplates/NotificationTemplates.tsx b/frontend/src/Settings/Notifications/NotificationTemplates/NotificationTemplates.tsx new file mode 100644 index 000000000..1aa995219 --- /dev/null +++ b/frontend/src/Settings/Notifications/NotificationTemplates/NotificationTemplates.tsx @@ -0,0 +1,70 @@ +import React, { useEffect } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { NotificationTemplateAppState } from 'App/State/SettingsAppState'; +import Card from 'Components/Card'; +import FieldSet from 'Components/FieldSet'; +import Icon from 'Components/Icon'; +import PageSectionContent from 'Components/Page/PageSectionContent'; +import useModalOpenState from 'Helpers/Hooks/useModalOpenState'; +import { icons } from 'Helpers/Props'; +import { fetchNotificationTemplates } from 'Store/Actions/Settings/notificationTemplates'; +import createClientSideCollectionSelector from 'Store/Selectors/createClientSideCollectionSelector'; +import translate from 'Utilities/String/translate'; +import EditNotificationTemplateModal from './EditNotificationTemplateModal'; +import NotificationTemplateItem from './NotificationTemplateItem'; +import styles from './NotificationTemplates.css'; + +function NotificationTemplates() { + const { items, isFetching, isPopulated, error }: NotificationTemplateAppState = + useSelector(createClientSideCollectionSelector('settings.notificationTemplates')); + + const dispatch = useDispatch(); + + const [ + isAddNotificationTemplateModalOpen, + setAddNotificationTemplateModalOpen, + setAddNotificationTemplateModalClosed, + ] = useModalOpenState(false); + + useEffect(() => { + dispatch(fetchNotificationTemplates()); + }, [dispatch]); + + return ( +
+ +
+ {items.map((item) => { + return ( + + ); + })} + + +
+ +
+
+
+ + +
+
+ ); +} + +export default NotificationTemplates; diff --git a/frontend/src/Settings/Notifications/Notifications/EditNotificationModalContent.js b/frontend/src/Settings/Notifications/Notifications/EditNotificationModalContent.js index f52655289..d16bcc614 100644 --- a/frontend/src/Settings/Notifications/Notifications/EditNotificationModalContent.js +++ b/frontend/src/Settings/Notifications/Notifications/EditNotificationModalContent.js @@ -44,7 +44,8 @@ function EditNotificationModalContent(props) { name, tags, fields, - message + message, + notificationTemplateId } = item; return ( @@ -95,6 +96,23 @@ function EditNotificationModalContent(props) { onInputChange={onInputChange} /> + { + item.implementationName === 'Email' ? + + {translate('NotificationTemplate')} + + + : + null + } + {translate('Tags')} diff --git a/frontend/src/Settings/Notifications/Notifications/Notification.js b/frontend/src/Settings/Notifications/Notifications/Notification.js index b2d0b29b5..279e4b55b 100644 --- a/frontend/src/Settings/Notifications/Notifications/Notification.js +++ b/frontend/src/Settings/Notifications/Notifications/Notification.js @@ -266,6 +266,7 @@ Notification.propTypes = { supportsOnHealthRestored: PropTypes.bool.isRequired, supportsOnApplicationUpdate: PropTypes.bool.isRequired, supportsOnManualInteractionRequired: PropTypes.bool.isRequired, + notificationTemplateId: PropTypes.number.isRequired, tags: PropTypes.arrayOf(PropTypes.number).isRequired, tagList: PropTypes.arrayOf(PropTypes.object).isRequired, onConfirmDeleteNotification: PropTypes.func.isRequired diff --git a/frontend/src/Store/Actions/Settings/notificationTemplates.js b/frontend/src/Store/Actions/Settings/notificationTemplates.js new file mode 100644 index 000000000..4e066e789 --- /dev/null +++ b/frontend/src/Store/Actions/Settings/notificationTemplates.js @@ -0,0 +1,71 @@ +import { createAction } from 'redux-actions'; +import createFetchHandler from 'Store/Actions/Creators/createFetchHandler'; +import createRemoveItemHandler from 'Store/Actions/Creators/createRemoveItemHandler'; +import createSaveProviderHandler from 'Store/Actions/Creators/createSaveProviderHandler'; +import createSetSettingValueReducer from 'Store/Actions/Creators/Reducers/createSetSettingValueReducer'; +import { createThunk } from 'Store/thunks'; + +// +// Variables + +const section = 'settings.notificationTemplates'; + +// +// Actions Types + +export const FETCH_NOTIFICATION_TEMPLATES = 'settings/notificationTemplates/fetchNotificationTemplates'; +export const SAVE_NOTIFICATION_TEMPLATE = 'settings/notificationTemplates/saveNotificationTemplate'; +export const DELETE_NOTIFICATION_TEMPLATE = 'settings/notificationTemplates/deleteNotificationTemplate'; +export const SET_NOTIFICATION_TEMPLATE_VALUE = 'settings/notificationTemplates/setNotificationTemplateValue'; + +// +// Action Creators + +export const fetchNotificationTemplates = createThunk(FETCH_NOTIFICATION_TEMPLATES); +export const saveNotificationTemplate = createThunk(SAVE_NOTIFICATION_TEMPLATE); +export const deleteNotificationTemplate = createThunk(DELETE_NOTIFICATION_TEMPLATE); + +export const setNotificationTemplateValue = createAction(SET_NOTIFICATION_TEMPLATE_VALUE, (payload) => { + return { + section, + ...payload + }; +}); + +// +// Details + +export default { + + // + // State + + defaultState: { + isFetching: false, + isPopulated: false, + error: null, + isSaving: false, + saveError: null, + items: [], + pendingChanges: {} + }, + + // + // Action Handlers + + actionHandlers: { + [FETCH_NOTIFICATION_TEMPLATES]: createFetchHandler(section, '/notificationtemplate'), + + [SAVE_NOTIFICATION_TEMPLATE]: createSaveProviderHandler(section, '/notificationtemplate'), + + [DELETE_NOTIFICATION_TEMPLATE]: createRemoveItemHandler(section, '/notificationtemplate') + }, + + // + // Reducers + + reducers: { + [SET_NOTIFICATION_TEMPLATE_VALUE]: createSetSettingValueReducer(section) + } + +}; diff --git a/frontend/src/Store/Actions/settingsActions.js b/frontend/src/Store/Actions/settingsActions.js index 440f20000..2adc6babe 100644 --- a/frontend/src/Store/Actions/settingsActions.js +++ b/frontend/src/Store/Actions/settingsActions.js @@ -21,6 +21,7 @@ import metadata from './Settings/metadata'; import naming from './Settings/naming'; import namingExamples from './Settings/namingExamples'; import notifications from './Settings/notifications'; +import notificationTemplates from './Settings/notificationTemplates'; import qualityDefinitions from './Settings/qualityDefinitions'; import qualityProfiles from './Settings/qualityProfiles'; import releaseProfiles from './Settings/releaseProfiles'; @@ -47,6 +48,7 @@ export * from './Settings/metadata'; export * from './Settings/naming'; export * from './Settings/namingExamples'; export * from './Settings/notifications'; +export * from './Settings/notificationTemplates'; export * from './Settings/qualityDefinitions'; export * from './Settings/qualityProfiles'; export * from './Settings/releaseProfiles'; @@ -83,6 +85,7 @@ export const defaultState = { naming: naming.defaultState, namingExamples: namingExamples.defaultState, notifications: notifications.defaultState, + notificationTemplates: notificationTemplates.defaultState, qualityDefinitions: qualityDefinitions.defaultState, qualityProfiles: qualityProfiles.defaultState, releaseProfiles: releaseProfiles.defaultState, @@ -129,6 +132,7 @@ export const actionHandlers = handleThunks({ ...naming.actionHandlers, ...namingExamples.actionHandlers, ...notifications.actionHandlers, + ...notificationTemplates.actionHandlers, ...qualityDefinitions.actionHandlers, ...qualityProfiles.actionHandlers, ...releaseProfiles.actionHandlers, @@ -165,6 +169,7 @@ export const reducers = createHandleActions({ ...naming.reducers, ...namingExamples.reducers, ...notifications.reducers, + ...notificationTemplates.reducers, ...qualityDefinitions.reducers, ...qualityProfiles.reducers, ...releaseProfiles.reducers, diff --git a/frontend/src/typings/Settings/NotificationTemplate.ts b/frontend/src/typings/Settings/NotificationTemplate.ts new file mode 100644 index 000000000..f6bcea33b --- /dev/null +++ b/frontend/src/typings/Settings/NotificationTemplate.ts @@ -0,0 +1,22 @@ +import ModelBase from 'App/ModelBase'; + +interface NotificationTemplate extends ModelBase { + name: string; + title: string; + body: string; + onGrab: boolean; + onDownload: boolean; + onUpgrade: boolean; + onImportComplete: boolean; + onRename: boolean; + onSeriesAdd: boolean; + onSeriesDelete: boolean; + onEpisodeFileDelete: boolean; + onEpisodeFileDeleteForUpgrade: boolean; + onHealthIssue: boolean; + onHealthRestored: boolean; + onApplicationUpdate: boolean; + onManualInteractionRequired: boolean; +} + +export default NotificationTemplate; diff --git a/src/NzbDrone.Core/Datastore/Migration/216_add_notification_template.cs b/src/NzbDrone.Core/Datastore/Migration/216_add_notification_template.cs new file mode 100644 index 000000000..0427c80d2 --- /dev/null +++ b/src/NzbDrone.Core/Datastore/Migration/216_add_notification_template.cs @@ -0,0 +1,100 @@ +using System.Data; +using FluentMigrator; +using NzbDrone.Core.Datastore.Migration.Framework; + +namespace NzbDrone.Core.Datastore.Migration +{ + [Migration(216)] + public class add_notification_template : NzbDroneMigrationBase + { + protected override void MainDbUpgrade() + { + Create.TableForModel("NotificationTemplates") + .WithColumn("Name").AsString().NotNullable().Unique() + .WithColumn("Title").AsString().NotNullable() + .WithColumn("Body").AsString().NotNullable() + .WithColumn("OnGrab").AsBoolean().WithDefaultValue(true) + .WithColumn("OnDownload").AsBoolean().WithDefaultValue(true) + .WithColumn("OnUpgrade").AsBoolean().WithDefaultValue(true) + .WithColumn("OnImportComplete").AsBoolean().WithDefaultValue(true) + .WithColumn("OnRename").AsBoolean().WithDefaultValue(false) + .WithColumn("OnSeriesAdd").AsBoolean().WithDefaultValue(true) + .WithColumn("OnSeriesDelete").AsBoolean().WithDefaultValue(false) + .WithColumn("OnEpisodeFileDelete").AsBoolean().WithDefaultValue(false) + .WithColumn("OnEpisodeFileDeleteForUpgrade").AsBoolean().WithDefaultValue(false) + .WithColumn("OnHealthIssue").AsBoolean().WithDefaultValue(false) + .WithColumn("OnHealthRestored").AsBoolean().WithDefaultValue(false) + .WithColumn("OnApplicationUpdate").AsBoolean().WithDefaultValue(false) + .WithColumn("OnManualInteractionRequired").AsBoolean().WithDefaultValue(false); + + Alter.Table("Notifications").AddColumn("NotificationTemplateId").AsInt32().WithDefaultValue(0); + + Execute.WithConnection(CreateDefaultHtmlTemplate); + Execute.WithConnection(UpdateEmailConnections); + } + + private void CreateDefaultHtmlTemplate(IDbConnection conn, IDbTransaction tran) + { + var name = "Email template"; + var title = "Sonarr - {{ if grab_message }}Episode Grabbed{{ else if series_add_message }}Series Added{{ else }}{{fallback_title}}{{ end }}"; + var body = @" + + + Sonarr Notification + + + {{ if grab_message }} + {{ series = grab_message.series }} +

{{grab_message.episode.parsed_episode_info.series_title}} - {{grab_message.episode.parsed_episode_info.release_title}} sent to queue.

+ {{ else if series_add_message }} + {{ series = series_add_message.series }} + {{ else }} +

{{fallback_body}}

+ {{ end }} + {{ if series }} +

{{series.title}}

+

{{series.overview}}

+ {{- for image in series.images }} + {{ if image.cover_type == ""Banner"" }} + + {{ end }} + {{- end }} + {{ end }} +
+

Metadata is provided by theTVDB

+
+ +"; + + using (var updateCmd = conn.CreateCommand()) + { + updateCmd.Transaction = tran; + updateCmd.CommandText = "INSERT INTO \"NotificationTemplates\" (\"Name\", \"Title\", \"Body\") VALUES (?, ?, ?)"; + updateCmd.AddParameter(name); + updateCmd.AddParameter(title); + updateCmd.AddParameter(body); + + updateCmd.ExecuteNonQuery(); + } + } + + private void UpdateEmailConnections(IDbConnection conn, IDbTransaction tran) + { + using (var selectCmd = conn.CreateCommand()) + { + selectCmd.Transaction = tran; + selectCmd.CommandText = "SELECT \"Id\" from \"NotificationTemplates\" DESC LIMIT 1"; + var id = selectCmd.ExecuteReader().Read(); + + using (var updateCmd = conn.CreateCommand()) + { + updateCmd.Transaction = tran; + updateCmd.CommandText = "UPDATE \"Notifications\" SET \"NotificationTemplateId\" = ? WHERE \"Implementation\" = 'Email' and \"NotificationTemplateId\" = 0"; + updateCmd.AddParameter(id); + + updateCmd.ExecuteNonQuery(); + } + } + } + } +} diff --git a/src/NzbDrone.Core/Datastore/TableMapping.cs b/src/NzbDrone.Core/Datastore/TableMapping.cs index 0704099d7..f7f2ad995 100644 --- a/src/NzbDrone.Core/Datastore/TableMapping.cs +++ b/src/NzbDrone.Core/Datastore/TableMapping.cs @@ -28,6 +28,7 @@ using NzbDrone.Core.MediaFiles; using NzbDrone.Core.Messaging.Commands; using NzbDrone.Core.Notifications; +using NzbDrone.Core.Notifications.NotificationTemplates; using NzbDrone.Core.Organizer; using NzbDrone.Core.Parser.Model; using NzbDrone.Core.Profiles; @@ -161,6 +162,7 @@ public static void Map() Mapper.Entity("DownloadClientStatus").RegisterModel(); Mapper.Entity("ImportListStatus").RegisterModel(); Mapper.Entity("NotificationStatus").RegisterModel(); + Mapper.Entity("NotificationTemplates").RegisterModel(); Mapper.Entity("CustomFilters").RegisterModel(); diff --git a/src/NzbDrone.Core/Localization/Core/en.json b/src/NzbDrone.Core/Localization/Core/en.json index c7a7c1fdc..d349ee305 100644 --- a/src/NzbDrone.Core/Localization/Core/en.json +++ b/src/NzbDrone.Core/Localization/Core/en.json @@ -44,6 +44,7 @@ "AddNewSeriesSearchForCutoffUnmetEpisodes": "Start search for cutoff unmet episodes", "AddNewSeriesSearchForMissingEpisodes": "Start search for missing episodes", "AddNotificationError": "Unable to add a new notification, please try again.", + "AddNotificationTemplate": "Add Notification Template", "AddQualityProfile": "Add Quality Profile", "AddQualityProfileError": "Unable to add a new quality profile, please try again.", "AddReleaseProfile": "Add Release Profile", @@ -166,6 +167,7 @@ "BlocklistRelease": "Blocklist Release", "BlocklistReleaseHelpText": "Blocks this release from being redownloaded by {appName} via RSS or Automatic Search", "BlocklistReleases": "Blocklist Releases", + "Body": "Body", "Branch": "Branch", "BranchUpdate": "Branch to use to update {appName}", "BranchUpdateMechanism": "Branch used by external update mechanism", @@ -361,6 +363,8 @@ "DeleteIndexerMessageText": "Are you sure you want to delete the indexer '{name}'?", "DeleteNotification": "Delete Notification", "DeleteNotificationMessageText": "Are you sure you want to delete the notification '{name}'?", + "DeleteNotificationTemplate": "Delete Notification Template", + "DeleteNotificationTemplateMessageText": "Are you sure you want to delete the notification template '{name}'?", "DeleteQualityProfile": "Delete Quality Profile", "DeleteQualityProfileMessageText": "Are you sure you want to delete the quality profile '{name}'?", "DeleteReleaseProfile": "Delete Release Profile", @@ -595,6 +599,7 @@ "EditIndexerImplementation": "Edit Indexer - {implementationName}", "EditListExclusion": "Edit List Exclusion", "EditMetadata": "Edit {metadataType} Metadata", + "EditNotificationTemplate": "Edit Notification Template", "EditQualityProfile": "Edit Quality Profile", "EditReleaseProfile": "Edit Release Profile", "EditRemotePathMapping": "Edit Remote Path Mapping", @@ -1501,6 +1506,12 @@ "NotificationsValidationUnableToConnectToService": "Unable to connect to {serviceName}", "NotificationsValidationUnableToSendTestMessage": "Unable to send test message: {exceptionMessage}", "NotificationsValidationUnableToSendTestMessageApiResponse": "Unable to send test message. Response from API: {error}", + "NotificationTemplateBodyHelpText": "The notification body supports template placeholders", + "NotificationNotificationTemplateHelpText": "Use text from selected template for notification", + "NotificationTemplate": "Notification Template", + "NotificationTemplates": "Notification Templates", + "NotificationTemplatesLoadError": "Unable to load Notification Templates", + "NotificationTemplateTitleHelpText": "The notification title supports template placeholders", "NzbgetHistoryItemMessage": "PAR Status: {parStatus} - Unpack Status: {unpackStatus} - Move Status: {moveStatus} - Script Status: {scriptStatus} - Delete Status: {deleteStatus} - Mark Status: {markStatus}", "Ok": "Ok", "OnApplicationUpdate": "On Application Update", @@ -1967,6 +1978,7 @@ "Status": "Status", "StopSelecting": "Stop Selecting", "Style": "Style", + "Subject": "Subject", "SubtitleLanguages": "Subtitle Languages", "Sunday": "Sunday", "SupportedAutoTaggingProperties": "{appName} supports the follow properties for auto tagging rules", diff --git a/src/NzbDrone.Core/Notifications/Email/Email.cs b/src/NzbDrone.Core/Notifications/Email/Email.cs index 7ade7dd99..dd29d62c9 100644 --- a/src/NzbDrone.Core/Notifications/Email/Email.cs +++ b/src/NzbDrone.Core/Notifications/Email/Email.cs @@ -9,6 +9,7 @@ using NzbDrone.Common.Extensions; using NzbDrone.Common.Http.Dispatchers; using NzbDrone.Core.Localization; +using NzbDrone.Core.Notifications.NotificationTemplates; namespace NzbDrone.Core.Notifications.Email { @@ -16,14 +17,16 @@ public class Email : NotificationBase { private readonly ICertificateValidationService _certificateValidationService; private readonly ILocalizationService _localizationService; + private readonly INotificationTemplateService _notificationTemplateService; private readonly Logger _logger; public override string Name => _localizationService.GetLocalizedString("NotificationsEmailSettingsName"); - public Email(ICertificateValidationService certificateValidationService, ILocalizationService localizationService, Logger logger) + public Email(ICertificateValidationService certificateValidationService, ILocalizationService localizationService, INotificationTemplateService notificationTemplateService, Logger logger) { _certificateValidationService = certificateValidationService; _localizationService = localizationService; + _notificationTemplateService = notificationTemplateService; _logger = logger; } @@ -33,66 +36,98 @@ public override void OnGrab(GrabMessage grabMessage) { var body = $"{grabMessage.Message} sent to queue."; - SendEmail(Settings, EPISODE_GRABBED_TITLE_BRANDED, body); + var notificationTemplateId = ((NotificationDefinition)this.Definition).NotificationTemplateId; + var processedNotificationTemplate = _notificationTemplateService.processNotificationTemplate(notificationTemplateId, grabMessage, EPISODE_GRABBED_TITLE_BRANDED, body); + + SendEmail(Settings, processedNotificationTemplate.Title, processedNotificationTemplate.Body, true); } public override void OnDownload(DownloadMessage message) { var body = $"{message.Message} Downloaded and sorted."; - SendEmail(Settings, EPISODE_DOWNLOADED_TITLE_BRANDED, body); + var notificationTemplateId = ((NotificationDefinition)this.Definition).NotificationTemplateId; + var processedNotificationTemplate = _notificationTemplateService.processNotificationTemplate(notificationTemplateId, message, EPISODE_DOWNLOADED_TITLE_BRANDED, body); + + SendEmail(Settings, processedNotificationTemplate.Title, processedNotificationTemplate.Body, true); } public override void OnImportComplete(ImportCompleteMessage message) { var body = $"All expected episode files in {message.Message} downloaded and sorted."; - SendEmail(Settings, IMPORT_COMPLETE_TITLE, body); + var notificationTemplateId = ((NotificationDefinition)this.Definition).NotificationTemplateId; + var processedNotificationTemplate = _notificationTemplateService.processNotificationTemplate(notificationTemplateId, message, IMPORT_COMPLETE_TITLE, body); + + SendEmail(Settings, processedNotificationTemplate.Title, processedNotificationTemplate.Body, true); } public override void OnEpisodeFileDelete(EpisodeDeleteMessage deleteMessage) { var body = $"{deleteMessage.Message} deleted."; - SendEmail(Settings, EPISODE_DELETED_TITLE_BRANDED, body); + var notificationTemplateId = ((NotificationDefinition)this.Definition).NotificationTemplateId; + var processedNotificationTemplate = _notificationTemplateService.processNotificationTemplate(notificationTemplateId, deleteMessage, EPISODE_DELETED_TITLE_BRANDED, body); + + SendEmail(Settings, processedNotificationTemplate.Title, processedNotificationTemplate.Body, true); } public override void OnSeriesAdd(SeriesAddMessage message) { var body = $"{message.Message}"; - SendEmail(Settings, SERIES_ADDED_TITLE_BRANDED, body); + var notificationTemplateId = ((NotificationDefinition)this.Definition).NotificationTemplateId; + var processedNotificationTemplate = _notificationTemplateService.processNotificationTemplate(notificationTemplateId, message, SERIES_ADDED_TITLE_BRANDED, body); + + SendEmail(Settings, processedNotificationTemplate.Title, processedNotificationTemplate.Body, true); } public override void OnSeriesDelete(SeriesDeleteMessage deleteMessage) { - var body = $"{deleteMessage.Message}"; + var body = $"{deleteMessage.Message}."; - SendEmail(Settings, SERIES_DELETED_TITLE_BRANDED, body); + var notificationTemplateId = ((NotificationDefinition)this.Definition).NotificationTemplateId; + var processedNotificationTemplate = _notificationTemplateService.processNotificationTemplate(notificationTemplateId, deleteMessage, SERIES_DELETED_TITLE_BRANDED, body); + + SendEmail(Settings, processedNotificationTemplate.Title, processedNotificationTemplate.Body, true); } public override void OnHealthIssue(HealthCheck.HealthCheck message) { - SendEmail(Settings, HEALTH_ISSUE_TITLE_BRANDED, message.Message); + var notificationTemplateId = ((NotificationDefinition)this.Definition).NotificationTemplateId; + var processedNotificationTemplate = _notificationTemplateService.processNotificationTemplate(notificationTemplateId, message, HEALTH_ISSUE_TITLE_BRANDED, message.Message); + + SendEmail(Settings, processedNotificationTemplate.Title, processedNotificationTemplate.Body, true); } public override void OnHealthRestored(HealthCheck.HealthCheck previousMessage) { - SendEmail(Settings, HEALTH_RESTORED_TITLE_BRANDED, $"The following issue is now resolved: {previousMessage.Message}"); + var body = $"The following issue is now resolved: {previousMessage.Message}"; + + var notificationTemplateId = ((NotificationDefinition)this.Definition).NotificationTemplateId; + var processedNotificationTemplate = _notificationTemplateService.processNotificationTemplate(notificationTemplateId, previousMessage, HEALTH_RESTORED_TITLE_BRANDED, body); + + SendEmail(Settings, processedNotificationTemplate.Title, processedNotificationTemplate.Body, true); } public override void OnApplicationUpdate(ApplicationUpdateMessage updateMessage) { var body = $"{updateMessage.Message}"; - SendEmail(Settings, APPLICATION_UPDATE_TITLE_BRANDED, body); + var notificationTemplateId = ((NotificationDefinition)this.Definition).NotificationTemplateId; + var processedNotificationTemplate = _notificationTemplateService.processNotificationTemplate(notificationTemplateId, updateMessage, APPLICATION_UPDATE_TITLE_BRANDED, body); + + SendEmail(Settings, processedNotificationTemplate.Title, processedNotificationTemplate.Body, true); } public override void OnManualInteractionRequired(ManualInteractionRequiredMessage message) { var body = $"{message.Message} requires manual interaction."; - SendEmail(Settings, MANUAL_INTERACTION_REQUIRED_TITLE_BRANDED, body); + var notificationTemplateId = ((NotificationDefinition)this.Definition).NotificationTemplateId; + var processedNotificationTemplate = _notificationTemplateService.processNotificationTemplate(notificationTemplateId, message, MANUAL_INTERACTION_REQUIRED_TITLE_BRANDED, body); + + SendEmail(Settings, processedNotificationTemplate.Title, processedNotificationTemplate.Body, true); } public override ValidationResult Test() diff --git a/src/NzbDrone.Core/Notifications/NotificationDefinition.cs b/src/NzbDrone.Core/Notifications/NotificationDefinition.cs index 6ca3cf6a0..d0e460adc 100644 --- a/src/NzbDrone.Core/Notifications/NotificationDefinition.cs +++ b/src/NzbDrone.Core/Notifications/NotificationDefinition.cs @@ -22,6 +22,7 @@ public class NotificationDefinition : ProviderDefinition, IEquatable { void UpdateSettings(NotificationDefinition model); + void removeNotificationTemplate(int notificationTemplateId); } public class NotificationRepository : ProviderRepository, INotificationRepository @@ -20,5 +21,19 @@ public void UpdateSettings(NotificationDefinition model) { SetFields(model, m => m.Settings); } + + public void removeNotificationTemplate(int notificationTemplateId) + { + var models = All(); + + foreach (var model in models) + { + if (model.NotificationTemplateId == notificationTemplateId) + { + model.NotificationTemplateId = 0; + Update(model); + } + } + } } } diff --git a/src/NzbDrone.Core/Notifications/NotificationTemplates/NotificationTemplate.cs b/src/NzbDrone.Core/Notifications/NotificationTemplates/NotificationTemplate.cs new file mode 100644 index 000000000..9d015e5a4 --- /dev/null +++ b/src/NzbDrone.Core/Notifications/NotificationTemplates/NotificationTemplate.cs @@ -0,0 +1,109 @@ +using System; +using NzbDrone.Core.Datastore; + +namespace NzbDrone.Core.Notifications.NotificationTemplates +{ + public class NotificationTemplate : ModelBase, IEquatable + { + public NotificationTemplate() + { + } + + public NotificationTemplate( + string name, + string title, + string body, + bool onGrab, + bool onDownload, + bool onUpgrade, + bool onImportComplete, + bool onRename, + bool onSeriesAdd, + bool onSeriesDelete, + bool onEpisodeFileDelete, + bool onEpisodeFileDeleteForUpgrade, + bool onHealthIssue, + bool onHealthRestored, + bool onApplicationUpdate, + bool onManualInteractionRequired) + { + Name = name; + Title = title; + Body = body; + OnGrab = onGrab; + OnDownload = onDownload; + OnUpgrade = onUpgrade; + OnImportComplete = onImportComplete; + OnRename = onRename; + OnSeriesAdd = onSeriesAdd; + OnSeriesDelete = onSeriesDelete; + OnEpisodeFileDelete = onEpisodeFileDelete; + OnEpisodeFileDeleteForUpgrade = onEpisodeFileDeleteForUpgrade; + OnHealthIssue = onHealthIssue; + OnHealthRestored = onHealthRestored; + OnApplicationUpdate = onApplicationUpdate; + OnManualInteractionRequired = onManualInteractionRequired; + } + + public string Name { get; set; } + public string Title { get; set; } + public string Body { get; set; } + public bool OnGrab { get; set; } + public bool OnDownload { get; set; } + public bool OnUpgrade { get; set; } + public bool OnImportComplete { get; set; } + public bool OnRename { get; set; } + public bool OnSeriesAdd { get; set; } + public bool OnSeriesDelete { get; set; } + public bool OnEpisodeFileDelete { get; set; } + public bool OnEpisodeFileDeleteForUpgrade { get; set; } + public bool OnHealthIssue { get; set; } + public bool OnHealthRestored { get; set; } + public bool OnApplicationUpdate { get; set; } + public bool OnManualInteractionRequired { get; set; } + public bool Equals(NotificationTemplate other) + { + if (other is null) + { + return false; + } + + if (ReferenceEquals(this, other)) + { + return true; + } + + return Equals(Id, other.Id); + } + + public override bool Equals(object obj) + { + if (obj is null) + { + return false; + } + + if (ReferenceEquals(this, obj)) + { + return true; + } + + if (obj.GetType() != GetType()) + { + return false; + } + + return Equals((NotificationTemplate)obj); + } + + public override string ToString() + { + return Name; + } + + public override int GetHashCode() + { + return Id; + } + } +} diff --git a/src/NzbDrone.Core/Notifications/NotificationTemplates/NotificationTemplateParameters.cs b/src/NzbDrone.Core/Notifications/NotificationTemplates/NotificationTemplateParameters.cs new file mode 100644 index 000000000..3d893d3d0 --- /dev/null +++ b/src/NzbDrone.Core/Notifications/NotificationTemplates/NotificationTemplateParameters.cs @@ -0,0 +1,21 @@ +namespace NzbDrone.Core.Notifications.NotificationTemplates +{ + public class NotificationTemplateParameters + { + public NotificationTemplateParameters() + { + } + + public string FallbackTitle { get; set; } + public string FallbackBody { get; set; } + public GrabMessage GrabMessage { get; set; } + public SeriesAddMessage SeriesAddMessage { get; set; } + public EpisodeDeleteMessage EpisodeDeleteMessage { get; set; } + public SeriesDeleteMessage SeriesDeleteMessage { get; set; } + public ImportCompleteMessage ImportCompleteMessage { get; set; } + public DownloadMessage DownloadMessage { get; set; } + public HealthCheck.HealthCheck HealthCheck { get; set; } + public ApplicationUpdateMessage ApplicationUpdateMessage { get; set; } + public ManualInteractionRequiredMessage ManualInteractionRequiredMessage { get; set; } + } +} diff --git a/src/NzbDrone.Core/Notifications/NotificationTemplates/NotificationTemplateRepository.cs b/src/NzbDrone.Core/Notifications/NotificationTemplates/NotificationTemplateRepository.cs new file mode 100644 index 000000000..ed9992478 --- /dev/null +++ b/src/NzbDrone.Core/Notifications/NotificationTemplates/NotificationTemplateRepository.cs @@ -0,0 +1,17 @@ +using NzbDrone.Core.Datastore; +using NzbDrone.Core.Messaging.Events; + +namespace NzbDrone.Core.Notifications.NotificationTemplates +{ + public interface INotificationTemplateRepository : IBasicRepository + { + } + + public class NotificationTemplateRepository : BasicRepository, INotificationTemplateRepository + { + public NotificationTemplateRepository(IMainDatabase database, IEventAggregator eventAggregator) + : base(database, eventAggregator) + { + } + } +} diff --git a/src/NzbDrone.Core/Notifications/NotificationTemplates/NotificationTemplateService.cs b/src/NzbDrone.Core/Notifications/NotificationTemplates/NotificationTemplateService.cs new file mode 100644 index 000000000..db829892e --- /dev/null +++ b/src/NzbDrone.Core/Notifications/NotificationTemplates/NotificationTemplateService.cs @@ -0,0 +1,236 @@ +using System.Collections.Generic; +using System.Linq; +using NzbDrone.Common.Cache; +using NzbDrone.Core.Messaging.Events; +using Scriban; + +namespace NzbDrone.Core.Notifications.NotificationTemplates +{ + public interface INotificationTemplateService + { + void Update(NotificationTemplate notificationTemplate); + NotificationTemplate Insert(NotificationTemplate notificationTemplate); + List All(); + NotificationTemplate GetById(int id); + void Delete(int id); + NotificationTemplate processNotificationTemplate(int notificationTemplateId, GrabMessage grabMessage, string fallbackTitle, string fallbackBody); + NotificationTemplate processNotificationTemplate(int notificationTemplateId, SeriesAddMessage message, string fallbackTitle, string fallbackBody); + NotificationTemplate processNotificationTemplate(int notificationTemplateId, EpisodeDeleteMessage deleteMessage, string fallbackTitle, string fallbackBody); + NotificationTemplate processNotificationTemplate(int notificationTemplateId, SeriesDeleteMessage deleteMessage, string fallbackTitle, string fallbackBody); + NotificationTemplate processNotificationTemplate(int notificationTemplateId, ImportCompleteMessage message, string fallbackTitle, string fallbackBody); + NotificationTemplate processNotificationTemplate(int notificationTemplateId, DownloadMessage message, string fallbackTitle, string fallbackBody); + NotificationTemplate processNotificationTemplate(int notificationTemplateId, HealthCheck.HealthCheck message, string fallbackTitle, string fallbackBody); + NotificationTemplate processNotificationTemplate(int notificationTemplateId, ApplicationUpdateMessage updateMessage, string fallbackTitle, string fallbackBody); + NotificationTemplate processNotificationTemplate(int notificationTemplateId, ManualInteractionRequiredMessage message, string fallbackTitle, string fallbackBody); + } + + public class NotificationTemplateService : INotificationTemplateService + { + private readonly INotificationTemplateRepository _templateRepository; + private readonly IEventAggregator _eventAggregator; + private readonly ICached> _cache; + private readonly INotificationRepository _notificationRepository; + + public NotificationTemplateService(INotificationTemplateRepository templateRepository, + ICacheManager cacheManager, + IEventAggregator eventAggregator, + INotificationRepository notificationRepository) + { + _templateRepository = templateRepository; + _eventAggregator = eventAggregator; + _cache = cacheManager.GetCache>(typeof(NotificationTemplate), "templates"); + _notificationRepository = notificationRepository; + } + + private Dictionary AllDictionary() + { + return _cache.Get("all", () => _templateRepository.All().ToDictionary(m => m.Id)); + } + + public List All() + { + return AllDictionary().Values.ToList(); + } + + public NotificationTemplate GetById(int id) + { + return AllDictionary()[id]; + } + + public void Update(NotificationTemplate notificationTemplate) + { + _templateRepository.Update(notificationTemplate); + _cache.Clear(); + } + + public void Update(List notificationTemplate) + { + _templateRepository.UpdateMany(notificationTemplate); + _cache.Clear(); + } + + public NotificationTemplate Insert(NotificationTemplate notificationTemplate) + { + var result = _templateRepository.Insert(notificationTemplate); + _cache.Clear(); + + return result; + } + + public void Delete(int id) + { + _notificationRepository.removeNotificationTemplate(id); + _templateRepository.Delete(id); + _cache.Clear(); + } + + public void Delete(List ids) + { + foreach (var id in ids) + { + _notificationRepository.removeNotificationTemplate(id); + _templateRepository.Delete(id); + } + + _cache.Clear(); + } + + NotificationTemplate INotificationTemplateService.processNotificationTemplate(int notificationTemplateId, GrabMessage grabMessage, string fallbackTitle, string fallbackBody) + { + var templateParams = new NotificationTemplateParameters + { + FallbackTitle = fallbackTitle, + FallbackBody = fallbackBody, + GrabMessage = grabMessage + }; + return this.ProcessNotificationTemplate(notificationTemplateId, templateParams); + } + + NotificationTemplate INotificationTemplateService.processNotificationTemplate(int notificationTemplateId, SeriesAddMessage message, string fallbackTitle, string fallbackBody) + { + var templateParams = new NotificationTemplateParameters + { + FallbackTitle = fallbackTitle, + FallbackBody = fallbackBody, + SeriesAddMessage = message + }; + return this.ProcessNotificationTemplate(notificationTemplateId, templateParams); + } + + NotificationTemplate INotificationTemplateService.processNotificationTemplate(int notificationTemplateId, EpisodeDeleteMessage deleteMessage, string fallbackTitle, string fallbackBody) + { + var templateParams = new NotificationTemplateParameters + { + FallbackTitle = fallbackTitle, + FallbackBody = fallbackBody, + EpisodeDeleteMessage = deleteMessage + }; + return this.ProcessNotificationTemplate(notificationTemplateId, templateParams); + } + + NotificationTemplate INotificationTemplateService.processNotificationTemplate(int notificationTemplateId, SeriesDeleteMessage deleteMessage, string fallbackTitle, string fallbackBody) + { + var templateParams = new NotificationTemplateParameters + { + FallbackTitle = fallbackTitle, + FallbackBody = fallbackBody, + SeriesDeleteMessage = deleteMessage + }; + return this.ProcessNotificationTemplate(notificationTemplateId, templateParams); + } + + NotificationTemplate INotificationTemplateService.processNotificationTemplate(int notificationTemplateId, ImportCompleteMessage message, string fallbackTitle, string fallbackBody) + { + var templateParams = new NotificationTemplateParameters + { + FallbackTitle = fallbackTitle, + FallbackBody = fallbackBody, + ImportCompleteMessage = message + }; + return this.ProcessNotificationTemplate(notificationTemplateId, templateParams); + } + + NotificationTemplate INotificationTemplateService.processNotificationTemplate(int notificationTemplateId, DownloadMessage message, string fallbackTitle, string fallbackBody) + { + var templateParams = new NotificationTemplateParameters + { + FallbackTitle = fallbackTitle, + FallbackBody = fallbackBody, + DownloadMessage = message + }; + return this.ProcessNotificationTemplate(notificationTemplateId, templateParams); + } + + NotificationTemplate INotificationTemplateService.processNotificationTemplate(int notificationTemplateId, HealthCheck.HealthCheck message, string fallbackTitle, string fallbackBody) + { + var templateParams = new NotificationTemplateParameters + { + FallbackTitle = fallbackTitle, + FallbackBody = fallbackBody, + HealthCheck = message + }; + return this.ProcessNotificationTemplate(notificationTemplateId, templateParams); + } + + NotificationTemplate INotificationTemplateService.processNotificationTemplate(int notificationTemplateId, ApplicationUpdateMessage updateMessage, string fallbackTitle, string fallbackBody) + { + var templateParams = new NotificationTemplateParameters + { + FallbackTitle = fallbackTitle, + FallbackBody = fallbackBody, + ApplicationUpdateMessage = updateMessage + }; + return this.ProcessNotificationTemplate(notificationTemplateId, templateParams); + } + + NotificationTemplate INotificationTemplateService.processNotificationTemplate(int notificationTemplateId, ManualInteractionRequiredMessage message, string fallbackTitle, string fallbackBody) + { + var templateParams = new NotificationTemplateParameters + { + FallbackTitle = fallbackTitle, + FallbackBody = fallbackBody, + ManualInteractionRequiredMessage = message + }; + return this.ProcessNotificationTemplate(notificationTemplateId, templateParams); + } + + private NotificationTemplate ProcessNotificationTemplate(int notificationTemplateId, NotificationTemplateParameters templateParams) + { + var processedNotificationTemplate = new NotificationTemplate(); + processedNotificationTemplate.Title = templateParams.FallbackTitle; + processedNotificationTemplate.Body = templateParams.FallbackBody; + + if (notificationTemplateId > 0) + { + var notificationTemplate = _templateRepository.Find(notificationTemplateId); + if (notificationTemplate != null && ( + (templateParams.GrabMessage != null && notificationTemplate.OnGrab) + || (templateParams.SeriesAddMessage != null && notificationTemplate.OnSeriesAdd) + || (templateParams.EpisodeDeleteMessage != null && notificationTemplate.OnEpisodeFileDelete) + || (templateParams.SeriesDeleteMessage != null && notificationTemplate.OnSeriesDelete) + || (templateParams.ImportCompleteMessage != null && notificationTemplate.OnImportComplete) + || (templateParams.DownloadMessage != null && notificationTemplate.OnDownload) + || (templateParams.HealthCheck != null && (notificationTemplate.OnHealthIssue || notificationTemplate.OnHealthRestored)) + || (templateParams.ApplicationUpdateMessage != null && notificationTemplate.OnApplicationUpdate) + || (templateParams.ManualInteractionRequiredMessage != null && notificationTemplate.OnManualInteractionRequired))) + { + if (!string.IsNullOrEmpty(notificationTemplate.Title)) + { + var tpl = Template.Parse(notificationTemplate.Title); + processedNotificationTemplate.Title = tpl.Render(templateParams); + } + + if (!string.IsNullOrEmpty(notificationTemplate.Body)) + { + var tpl = Template.Parse(notificationTemplate.Body); + processedNotificationTemplate.Body = tpl.Render(templateParams); + } + + return processedNotificationTemplate; + } + } + + return processedNotificationTemplate; + } + } +} diff --git a/src/NzbDrone.Core/Sonarr.Core.csproj b/src/NzbDrone.Core/Sonarr.Core.csproj index be297e838..ce8ae9cb4 100644 --- a/src/NzbDrone.Core/Sonarr.Core.csproj +++ b/src/NzbDrone.Core/Sonarr.Core.csproj @@ -27,6 +27,7 @@ + diff --git a/src/Sonarr.Api.V3/NotificationTemplates/NotificationTemplateController.cs b/src/Sonarr.Api.V3/NotificationTemplates/NotificationTemplateController.cs new file mode 100644 index 000000000..e54a796bb --- /dev/null +++ b/src/Sonarr.Api.V3/NotificationTemplates/NotificationTemplateController.cs @@ -0,0 +1,85 @@ +using System.Collections.Generic; +using System.Linq; +using FluentValidation; +using FluentValidation.Results; +using Microsoft.AspNetCore.Mvc; +using NzbDrone.Core.Notifications.NotificationTemplates; +using NzbDrone.Core.Validation; +using Sonarr.Http; +using Sonarr.Http.REST; +using Sonarr.Http.REST.Attributes; + +namespace Sonarr.Api.V3.NotificationTemplates +{ + [V3ApiController] + public class NotificationTemplateController : RestController + { + private readonly INotificationTemplateService _templateService; + + public NotificationTemplateController(INotificationTemplateService templateService) + { + _templateService = templateService; + + SharedValidator.RuleFor(c => c.Name).NotEmpty(); + SharedValidator.RuleFor(c => c.Name) + .Must((v, c) => !_templateService.All().Any(f => f.Name == c && f.Id != v.Id)).WithMessage("Must be unique."); + } + + protected override NotificationTemplateResource GetResourceById(int id) + { + return _templateService.GetById(id).ToResource(); + } + + [HttpGet] + [Produces("application/json")] + public List GetAll() + { + return _templateService.All().ToResource(); + } + + [RestPostById] + [Consumes("application/json")] + public ActionResult Create([FromBody] NotificationTemplateResource notificationTemplateResource) + { + var model = notificationTemplateResource.ToModel(); + + Validate(model); + + return Created(_templateService.Insert(model).Id); + } + + [RestPutById] + [Consumes("application/json")] + public ActionResult Update([FromBody] NotificationTemplateResource resource) + { + var model = resource.ToModel(); + + Validate(model); + + _templateService.Update(model); + + return Accepted(model.Id); + } + + [RestDeleteById] + public void DeleteFormat(int id) + { + _templateService.Delete(id); + } + + private void Validate(NotificationTemplate notificationTemplate) + { + // TODO + } + + private void VerifyValidationResult(ValidationResult validationResult) + { + var result = new NzbDroneValidationResult(validationResult.Errors); + + if (!result.IsValid) + { + throw new ValidationException(result.Errors); + } + } + } +} diff --git a/src/Sonarr.Api.V3/NotificationTemplates/NotificationTemplateResource.cs b/src/Sonarr.Api.V3/NotificationTemplates/NotificationTemplateResource.cs new file mode 100644 index 000000000..ba58b4e9a --- /dev/null +++ b/src/Sonarr.Api.V3/NotificationTemplates/NotificationTemplateResource.cs @@ -0,0 +1,89 @@ +using System.Collections.Generic; +using System.Linq; +using System.Text.Json.Serialization; +using NzbDrone.Core.Notifications.NotificationTemplates; +using Sonarr.Http.REST; + +namespace Sonarr.Api.V3.NotificationTemplates +{ + public class NotificationTemplateResource : RestResource + { + [JsonIgnore(Condition = JsonIgnoreCondition.Never)] + public override int Id { get; set; } + public string Name { get; set; } + public string Title { get; set; } + public string Body { get; set; } + public bool OnGrab { get; set; } + public bool OnDownload { get; set; } + public bool OnUpgrade { get; set; } + public bool OnImportComplete { get; set; } + public bool OnRename { get; set; } + public bool OnSeriesAdd { get; set; } + public bool OnSeriesDelete { get; set; } + public bool OnEpisodeFileDelete { get; set; } + public bool OnEpisodeFileDeleteForUpgrade { get; set; } + public bool OnHealthIssue { get; set; } + public bool IncludeHealthWarnings { get; set; } + public bool OnHealthRestored { get; set; } + public bool OnApplicationUpdate { get; set; } + public bool OnManualInteractionRequired { get; set; } + } + + public static class NotificationTemplateResourceMapper + { + public static NotificationTemplateResource ToResource(this NotificationTemplate model) + { + var resource = new NotificationTemplateResource + { + Id = model.Id, + Name = model.Name, + Title = model.Title, + Body = model.Body, + OnGrab = model.OnGrab, + OnDownload = model.OnDownload, + OnUpgrade = model.OnUpgrade, + OnImportComplete = model.OnImportComplete, + OnRename = model.OnRename, + OnSeriesAdd = model.OnSeriesAdd, + OnSeriesDelete = model.OnSeriesDelete, + OnEpisodeFileDelete = model.OnEpisodeFileDelete, + OnEpisodeFileDeleteForUpgrade = model.OnEpisodeFileDeleteForUpgrade, + OnHealthIssue = model.OnHealthIssue, + OnHealthRestored = model.OnHealthRestored, + OnApplicationUpdate = model.OnApplicationUpdate, + OnManualInteractionRequired = model.OnManualInteractionRequired + }; + + return resource; + } + + public static List ToResource(this IEnumerable models) + { + return models.Select(m => m.ToResource()).ToList(); + } + + public static NotificationTemplate ToModel(this NotificationTemplateResource resource) + { + return new NotificationTemplate + { + Id = resource.Id, + Name = resource.Name, + Title = resource.Title, + Body = resource.Body, + OnGrab = resource.OnGrab, + OnDownload = resource.OnDownload, + OnUpgrade = resource.OnUpgrade, + OnImportComplete = resource.OnImportComplete, + OnRename = resource.OnRename, + OnSeriesAdd = resource.OnSeriesAdd, + OnSeriesDelete = resource.OnSeriesDelete, + OnEpisodeFileDelete = resource.OnEpisodeFileDelete, + OnEpisodeFileDeleteForUpgrade = resource.OnEpisodeFileDeleteForUpgrade, + OnHealthIssue = resource.OnHealthIssue, + OnHealthRestored = resource.OnHealthRestored, + OnApplicationUpdate = resource.OnApplicationUpdate, + OnManualInteractionRequired = resource.OnManualInteractionRequired + }; + } + } +} diff --git a/src/Sonarr.Api.V3/Notifications/NotificationResource.cs b/src/Sonarr.Api.V3/Notifications/NotificationResource.cs index 3ada87e92..cae853e1c 100644 --- a/src/Sonarr.Api.V3/Notifications/NotificationResource.cs +++ b/src/Sonarr.Api.V3/Notifications/NotificationResource.cs @@ -32,6 +32,7 @@ public class NotificationResource : ProviderResource public bool SupportsOnHealthRestored { get; set; } public bool SupportsOnApplicationUpdate { get; set; } public bool SupportsOnManualInteractionRequired { get; set; } + public int NotificationTemplateId { get; set; } public string TestCommand { get; set; } } @@ -73,6 +74,7 @@ public override NotificationResource ToResource(NotificationDefinition definitio resource.SupportsOnHealthRestored = definition.SupportsOnHealthRestored; resource.SupportsOnApplicationUpdate = definition.SupportsOnApplicationUpdate; resource.SupportsOnManualInteractionRequired = definition.SupportsOnManualInteractionRequired; + resource.NotificationTemplateId = definition.NotificationTemplateId; return resource; } @@ -113,6 +115,7 @@ public override NotificationDefinition ToModel(NotificationResource resource, No definition.SupportsOnHealthRestored = resource.SupportsOnHealthRestored; definition.SupportsOnApplicationUpdate = resource.SupportsOnApplicationUpdate; definition.SupportsOnManualInteractionRequired = resource.SupportsOnManualInteractionRequired; + definition.NotificationTemplateId = resource.NotificationTemplateId; return definition; }