diff --git a/overseerr-api.yml b/overseerr-api.yml index 6a387a6b6..cf019068b 100644 --- a/overseerr-api.yml +++ b/overseerr-api.yml @@ -1944,6 +1944,11 @@ components: properties: id: type: string + AutoApprovalRule: + type: object + properties: + id: + type: string securitySchemes: cookieAuth: type: apiKey @@ -7042,6 +7047,21 @@ paths: application/json: schema: $ref: '#/components/schemas/OverrideRule' + /autoApprovalRule: + get: + summary: 'Get autoapproval rules' + description: 'Returns a list of all autoapproval rules with their conditions' + tags: + - 'autoapprovalrule' + responses: + '200': + description: 'Values were successfully created' + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/AutoApprovalRule' security: - cookieAuth: [] - apiKey: [] diff --git a/server/entity/AutoApproval/AutoApprovalSpecificationBase.ts b/server/entity/AutoApproval/AutoApprovalSpecificationBase.ts new file mode 100644 index 000000000..e614e3f48 --- /dev/null +++ b/server/entity/AutoApproval/AutoApprovalSpecificationBase.ts @@ -0,0 +1,11 @@ +class AutoApprovalSpecificationBase { + public implementationName: string; + public comparator: string; + public value: unknown; + + isSatisfiedBy(): boolean { + return false; + } +} + +export default AutoApprovalSpecificationBase; diff --git a/server/entity/AutoApproval/GenreSpecification.tsx b/server/entity/AutoApproval/GenreSpecification.tsx new file mode 100644 index 000000000..d0084fc5f --- /dev/null +++ b/server/entity/AutoApproval/GenreSpecification.tsx @@ -0,0 +1,19 @@ +import AutoApprovalSpecificationBase from '@server/entity/AutoApproval/AutoApprovalSpecificationBase'; + +class GenreSpecification extends AutoApprovalSpecificationBase { + public implementationName = 'genre'; + public isSatisfiedBy(): boolean { + return false; + } + + public value: string; + + public comparator = 'is'; + public constructor(comparator?: string, value?: string) { + super(); + this.comparator = comparator ?? 'is'; + this.value = value ?? ''; + } +} + +export default GenreSpecification; diff --git a/server/entity/AutoApproval/ReleaseYearSpecification.tsx b/server/entity/AutoApproval/ReleaseYearSpecification.tsx new file mode 100644 index 000000000..f4ac5355a --- /dev/null +++ b/server/entity/AutoApproval/ReleaseYearSpecification.tsx @@ -0,0 +1,43 @@ +import { AutoApprovalSpecificationBase } from '@server/entity/AutoApproval/AutoApprovalSpecificationBase'; + +class ReleaseYearSpecification extends AutoApprovalSpecificationBase { + public implementationName = 'releaseyear'; + public comparator = 'equals'; + public value: string; + public options = [ + { value: 'equals', text: '=' }, + { value: 'equalsnot', text: '!=' }, + { value: 'greaterthan', text: '>' }, + { value: 'lessthan', text: '<' }, + ]; + public optionsContent; + constructor(comparison: string, value?: string) { + super({}); + this.comparator = comparison; + this.value = value ?? ''; + this.optionsContent = this.options.map((option) => ( + + )); + } + public Component = () => { + return ( + <> + + + + ); + }; +} + +export default ReleaseYearSpecification; diff --git a/server/entity/AutoApproval/UserSpecification.ts b/server/entity/AutoApproval/UserSpecification.ts new file mode 100644 index 000000000..9d1d06b65 --- /dev/null +++ b/server/entity/AutoApproval/UserSpecification.ts @@ -0,0 +1,17 @@ +import AutoApprovalSpecificationBase from './AutoApprovalSpecificationBase'; + +class UserSpecification extends AutoApprovalSpecificationBase { + public implementationName = 'user'; + public isSatisfiedBy(): boolean { + return false; + } + public value: number; + public comparator: string; + public constructor(comparator?: string, value?: number) { + super(); + this.comparator = comparator ?? 'is'; + this.value = value ?? 0; + } +} + +export default UserSpecification; diff --git a/server/lib/autoapproval.ts b/server/lib/autoapproval.ts new file mode 100644 index 000000000..572d2c481 --- /dev/null +++ b/server/lib/autoapproval.ts @@ -0,0 +1,5 @@ +import type AutoApprovalSpecificationBase from '@server/entity/AutoApproval/AutoApprovalSpecificationBase'; +export interface AutoApprovalRule { + name: string; + conditions: AutoApprovalSpecificationBase[]; +} diff --git a/server/routes/autoApprovalRule.ts b/server/routes/autoApprovalRule.ts new file mode 100644 index 000000000..0b8863712 --- /dev/null +++ b/server/routes/autoApprovalRule.ts @@ -0,0 +1,27 @@ +import GenreSpecification from '@server/entity/AutoApproval/GenreSpecification'; +import UserSpecification from '@server/entity/AutoApproval/UserSpecification'; +import type { AutoApprovalRule } from '@server/lib/autoapproval'; +import { isAuthenticated } from '@server/middleware/auth'; +import { Router } from 'express'; + +const autoApprovalRuleRoutes = Router(); + +autoApprovalRuleRoutes.get('/', isAuthenticated(), async (req, res, next) => { + try { + const data = [ + { + name: 'Test Rule', + conditions: [ + new UserSpecification('is', 1), + new GenreSpecification('is', '16,14'), + ], + }, + ]; + + return res.status(200).json(data as AutoApprovalRule[]); + } catch (e) { + next({ status: 404, message: e.message }); + } +}); + +export default autoApprovalRuleRoutes; diff --git a/server/routes/index.ts b/server/routes/index.ts index f064e6031..07d8ea63b 100644 --- a/server/routes/index.ts +++ b/server/routes/index.ts @@ -15,6 +15,7 @@ import { checkUser, isAuthenticated } from '@server/middleware/auth'; import { mapWatchProviderDetails } from '@server/models/common'; import { mapProductionCompany } from '@server/models/Movie'; import { mapNetwork } from '@server/models/Tv'; +import autoApprovalRuleRoutes from '@server/routes/autoApprovalRule'; import overrideRuleRoutes from '@server/routes/overrideRule'; import settingsRoutes from '@server/routes/settings'; import watchlistRoutes from '@server/routes/watchlist'; @@ -166,6 +167,11 @@ router.use( isAuthenticated(Permission.ADMIN), overrideRuleRoutes ); +router.use( + '/autoApprovalRule', + isAuthenticated(Permission.ADMIN), + autoApprovalRuleRoutes +); router.get('/regions', isAuthenticated(), async (req, res, next) => { const tmdb = new TheMovieDb(); diff --git a/src/components/Layout/Sidebar/index.tsx b/src/components/Layout/Sidebar/index.tsx index a947e2626..a26f3a77d 100644 --- a/src/components/Layout/Sidebar/index.tsx +++ b/src/components/Layout/Sidebar/index.tsx @@ -29,6 +29,7 @@ export const menuMessages = defineMessages('components.Layout.Sidebar', { blacklist: 'Blacklist', issues: 'Issues', users: 'Users', + autoapproval: 'Auto Approval', settings: 'Settings', }); diff --git a/src/components/Settings/SettingsAutoApproval/RuleModal/Specifications/GenreSpecificationItem.tsx b/src/components/Settings/SettingsAutoApproval/RuleModal/Specifications/GenreSpecificationItem.tsx new file mode 100644 index 000000000..667f22fa3 --- /dev/null +++ b/src/components/Settings/SettingsAutoApproval/RuleModal/Specifications/GenreSpecificationItem.tsx @@ -0,0 +1,47 @@ +import Table from '@app/components/Common/Table'; +import { GenreSelector } from '@app/components/Selector'; + +interface GenreSpecificationItemProps { + currentValue: string; + isMovie: boolean; + comparator: string; +} +export default function GenreSpecificationItem({ + currentValue, + isMovie, + comparator, +}: GenreSpecificationItemProps) { + const comparatorOptions = [ + { label: 'is', value: 'is' }, + { label: 'is not', value: 'isnot' }, + { label: 'contains', value: 'contains' }, + { label: 'does not contain', value: 'containsnot' }, + ]; + const comparatorItems = comparatorOptions.map((item) => ( + + )); + return ( + <> + + + + + { + return; + }} + /> + + + ); +} diff --git a/src/components/Settings/SettingsAutoApproval/RuleModal/Specifications/UserSpecification.tsx b/src/components/Settings/SettingsAutoApproval/RuleModal/Specifications/UserSpecification.tsx new file mode 100644 index 000000000..841136d6f --- /dev/null +++ b/src/components/Settings/SettingsAutoApproval/RuleModal/Specifications/UserSpecification.tsx @@ -0,0 +1,42 @@ +import Table from '@app/components/Common/Table'; +import { UserSelector } from '@app/components/Selector'; + +interface UserSpecificationItemProps { + currentValue: number; + comparator: string; +} + +export default function UserSpecificationItem({ + currentValue, + comparator, +}: UserSpecificationItemProps) { + const comparatorItems = [ + { label: 'is', value: 'is' }, + { label: 'is not', value: 'isnot' }, + { label: 'contains', value: 'contains' }, + { label: 'does not contain', value: 'containsnot' }, + ].map((item) => ); + return ( + <> + + + + + { + return; + }} + /> + + + ); +} diff --git a/src/components/Settings/SettingsAutoApproval/RuleModal/index.tsx b/src/components/Settings/SettingsAutoApproval/RuleModal/index.tsx new file mode 100644 index 000000000..f386a006a --- /dev/null +++ b/src/components/Settings/SettingsAutoApproval/RuleModal/index.tsx @@ -0,0 +1,145 @@ +import Button from '@app/components/Common/Button'; +import Modal from '@app/components/Common/Modal'; +import Table from '@app/components/Common/Table'; +import { Transition } from '@headlessui/react'; +import { PlusIcon } from '@heroicons/react/24/solid'; +import type AutoApprovalSpecificationBase from '@server/entity/AutoApproval/AutoApprovalSpecificationBase'; +import type { AutoApprovalRule } from '@server/lib/autoapproval'; +import { useState } from 'react'; +import GenreSpecificationItem from './Specifications/GenreSpecificationItem'; +import UserSpecificationItem from './Specifications/UserSpecification'; + +interface RuleModalProps { + approvalRule: AutoApprovalRule | null; + onClose: () => void; + onSave: () => void; +} + +const ReleaseYearCondition = (comparison = 'equals') => { + const options = [ + { value: 'equals', text: '=' }, + { value: 'greaterthan', text: '>' }, + { value: 'lessthan', text: '<' }, + ]; + const optionsContent = options.map((option) => ( + + )); + return ( + + ); +}; + +const ConditionItem = (condition: AutoApprovalSpecificationBase) => { + const [implementation, setImplementation] = useState( + condition.implementationName + ); + return ( + + + + + + { + { + genre: ( + + ), + user: ( + + ), + 'release-year': ReleaseYearCondition('is'), + }[implementation] + } + + + ); +}; + +const RuleModal = ({ onClose, approvalRule }: RuleModalProps) => { + const conditionsList = approvalRule?.conditions.map((condition) => + ConditionItem(condition) + ); + + return ( + + +
+

This is a modal

+
+ + + + Condition + + + + + {conditionsList} + + +
+ +
+
+ + +
+
+
+ ); +}; + +export default RuleModal; diff --git a/src/components/Settings/SettingsAutoApproval/SettingsAutoApproval/index.tsx b/src/components/Settings/SettingsAutoApproval/SettingsAutoApproval/index.tsx new file mode 100644 index 000000000..cfd081fe0 --- /dev/null +++ b/src/components/Settings/SettingsAutoApproval/SettingsAutoApproval/index.tsx @@ -0,0 +1,202 @@ +import Badge from '@app/components/Common/Badge'; +import Button from '@app/components/Common/Button'; +import Header from '@app/components/Common/Header'; +import RuleModal from '@app/components/Settings/SettingsAutoApproval/RuleModal'; +import { PencilIcon, PlusIcon, TrashIcon } from '@heroicons/react/24/solid'; +import type { AutoApprovalRule } from '@server/lib/autoapproval'; +import { useState } from 'react'; +import { defineMessages } from 'react-intl'; + +export const messages = defineMessages('components.ApprovalRuleList', { + autoapprovalrules: 'Auto Approval Rules', + addRule: 'Add Approval Rule', +}); + +interface ApprovalRuleInstanceProps { + name: string; + currentRule: AutoApprovalRule; + onEdit: () => void; +} + +const ApprovalRuleInstance = ({ + name, + currentRule, + onEdit, +}: ApprovalRuleInstanceProps) => { + const comparisonNames = new Map([ + ['equals', '='], + ['greaterthan', '>'], + ['lessthan', '<'], + ['is', 'is'], + ['isnot', 'is not'], + ['contains', 'contains'], + ['does not contain'], + ]); + const valueNames: string[] = ['Action', 'Comedy', 'Documentary', 'Romance']; + const conditionBadges = currentRule.conditions.map((condition) => ( + + {condition.implementation} {comparisonNames.get(condition.comparisonType)}{' '} + {condition.value > 1000 ? condition.value : valueNames[condition.value]} + + )); + + return ( +
  • +
    +
    +
    +

    + + {name} + +

    +
    +

    + {conditionBadges} +

    +
    +
    +
    +
    +
    + +
    +
    + +
    +
    +
    +
  • + ); +}; + +const SettingsAutoApproval = () => { + const movieRuleData = [ + { + name: 'Test Rule', + currentRule: { + name: 'Test Rule', + conditions: [ + { implementation: 'genre', comparisonType: 'is', value: 0 }, + { + implementation: 'release-year', + comparisonType: 'lessthan', + value: 2020, + }, + { implementation: 'genre', comparisonType: 'isnot', value: 2 }, + { + implementation: 'genre', + comparisonType: 'contains', + value: [1, 4], + }, + ], + }, + }, + ]; + const [editRuleModal, setEditRuleModal] = useState<{ + open: boolean; + approvalRule: AutoApprovalRule | null; + }>({ + open: false, + approvalRule: null, + }); + return ( +
    +
    +
    +
    Auto Approval Rules
    +
    + {editRuleModal.open && ( + + setEditRuleModal({ open: false, approvalRule: null }) + } + onSave={() => { + setEditRuleModal({ open: false, approvalRule: null }); + }} + /> + )} +

    Movie Auto-approval rules

    +
    +
      + {movieRuleData.map((rule) => ( + + setEditRuleModal({ + open: true, + approvalRule: rule.currentRule, + }) + } + currentRule={{ + name: rule.name, + conditions: rule.currentRule.conditions, + }} + /> + ))} +
    • +
      + +
      +
    • +
    +
    +

    Series Auto-approval rules

    +
    +
      +
    • +
      + +
      +
    • +
    +
    +
    +
    + ); +}; + +export default SettingsAutoApproval; diff --git a/src/components/Settings/SettingsAutoApproval/index.tsx b/src/components/Settings/SettingsAutoApproval/index.tsx new file mode 100644 index 000000000..bb7cafcd1 --- /dev/null +++ b/src/components/Settings/SettingsAutoApproval/index.tsx @@ -0,0 +1,193 @@ +import Badge from '@app/components/Common/Badge'; +import Button from '@app/components/Common/Button'; +import Header from '@app/components/Common/Header'; +import RuleModal from '@app/components/Settings/SettingsAutoApproval/RuleModal'; +import defineMessages from '@app/utils/defineMessages'; +import { PencilIcon, PlusIcon, TrashIcon } from '@heroicons/react/24/solid'; +import type { AutoApprovalRule } from '@server/lib/autoapproval'; +import { useState } from 'react'; +import useSWR from 'swr'; + +export const messages = defineMessages('components.ApprovalRuleList', { + autoapprovalrules: 'Auto Approval Rules', + addRule: 'Add Approval Rule', +}); + +interface ApprovalRuleInstanceProps { + name: string; + currentRule: AutoApprovalRule; + onEdit: () => void; +} + +const ApprovalRuleInstance = ({ + name, + currentRule, + onEdit, +}: ApprovalRuleInstanceProps) => { + const comparisonNames = new Map([ + ['equals', '='], + ['greaterthan', '>'], + ['lessthan', '<'], + ['is', 'is'], + ['isnot', 'is not'], + ['contains', 'contains'], + ['does not contain'], + ]); + const conditionBadges = currentRule.conditions.map((condition) => ( + + <> + {condition.implementationName}{' '} + {comparisonNames.get(condition.comparator)} {condition.value} + + + )); + + return ( +
  • +
    +
    +
    +

    + + {name} + +

    +
    +

    + {conditionBadges} +

    +
    +
    +
    +
    +
    + +
    +
    + +
    +
    +
    +
  • + ); +}; + +const SettingsAutoApproval = () => { + const { data: mockData } = useSWR( + '/api/v1/autoApprovalRule' + ); + const movieRuleData = mockData; + const [editRuleModal, setEditRuleModal] = useState<{ + open: boolean; + approvalRule: AutoApprovalRule | null; + }>({ + open: false, + approvalRule: null, + }); + return ( +
    +
    +
    +
    Auto Approval Rules
    +
    + {editRuleModal.open && ( + + setEditRuleModal({ open: false, approvalRule: null }) + } + onSave={() => { + setEditRuleModal({ open: false, approvalRule: null }); + }} + /> + )} +

    Movie Auto-approval rules

    +
    +
      + {movieRuleData && + movieRuleData.map((rule) => ( + + setEditRuleModal({ + open: true, + approvalRule: rule, + }) + } + currentRule={{ + name: rule.name, + conditions: rule.conditions, + }} + /> + ))} +
    • +
      + +
      +
    • +
    +
    +

    Series Auto-approval rules

    +
    +
      +
    • +
      + +
      +
    • +
    +
    +
    +
    + ); +}; + +export default SettingsAutoApproval; diff --git a/src/components/Settings/SettingsLayout.tsx b/src/components/Settings/SettingsLayout.tsx index 6336bad01..82bf45247 100644 --- a/src/components/Settings/SettingsLayout.tsx +++ b/src/components/Settings/SettingsLayout.tsx @@ -14,6 +14,7 @@ const messages = defineMessages('components.Settings', { menuJellyfinSettings: '{mediaServerName}', menuServices: 'Services', menuNotifications: 'Notifications', + menuAutoApproval: 'Auto Approval', menuLogs: 'Logs', menuJobs: 'Jobs & Cache', menuAbout: 'About', @@ -58,6 +59,11 @@ const SettingsLayout = ({ children }: SettingsLayoutProps) => { route: '/settings/notifications/email', regex: /^\/settings\/notifications/, }, + { + text: intl.formatMessage(messages.menuAutoApproval), + route: '/settings/autoapproval', + regex: /^\/settings\/autoapproval/, + }, { text: intl.formatMessage(messages.menuLogs), route: '/settings/logs', diff --git a/src/i18n/locale/en.json b/src/i18n/locale/en.json index 3fce7abd7..ad60d235a 100644 --- a/src/i18n/locale/en.json +++ b/src/i18n/locale/en.json @@ -1064,6 +1064,7 @@ "components.Settings.menuJobs": "Jobs & Cache", "components.Settings.menuLogs": "Logs", "components.Settings.menuNotifications": "Notifications", + "components.Settings.menuAutoApproval": "Auto Approval", "components.Settings.menuPlexSettings": "Plex", "components.Settings.menuServices": "Services", "components.Settings.menuUsers": "Users", diff --git a/src/pages/settings/autoapproval.tsx b/src/pages/settings/autoapproval.tsx new file mode 100644 index 000000000..6e1f5e461 --- /dev/null +++ b/src/pages/settings/autoapproval.tsx @@ -0,0 +1,16 @@ +import SettingsAutoApproval from '@app/components/Settings/SettingsAutoApproval'; +import SettingsLayout from '@app/components/Settings/SettingsLayout'; +import useRouteGuard from '@app/hooks/useRouteGuard'; +import { Permission } from '@app/hooks/useUser'; +import type { NextPage } from 'next'; + +const SettingsAutoApprovalPage: NextPage = () => { + useRouteGuard(Permission.ADMIN); + return ( + + + + ); +}; + +export default SettingsAutoApprovalPage;