mirror of
https://github.com/sct/overseerr.git
synced 2025-12-28 00:54:03 +01:00
feat(requests): add request quotas (#1277)
* feat(quotas): rebased * feat: add getQuota() method to User entity * feat(ui): add default quota setting options * feat: user quota settings * feat: quota display in request modals * fix: only show user quotas on own profile or with manage users permission * feat: add request progress circles to profile page * feat: add migration * fix: add missing restricted field to api schema * fix: dont show auto approve message for movie request when restricted * fix(lang): change enable checkbox langauge to "enable override" Co-authored-by: Jakob Ankarhem <jakob.ankarhem@outlook.com> Co-authored-by: TheCatLady <52870424+TheCatLady@users.noreply.github.com>
This commit is contained in:
@@ -8,6 +8,7 @@ import {
|
||||
MediaStatus,
|
||||
} from '../../../server/constants/media';
|
||||
import { MediaRequest } from '../../../server/entity/MediaRequest';
|
||||
import { QuotaResponse } from '../../../server/interfaces/api/userInterfaces';
|
||||
import { Permission } from '../../../server/lib/permissions';
|
||||
import { MovieDetails } from '../../../server/models/Movie';
|
||||
import DownloadIcon from '../../assets/download.svg';
|
||||
@@ -16,9 +17,10 @@ import globalMessages from '../../i18n/globalMessages';
|
||||
import Alert from '../Common/Alert';
|
||||
import Modal from '../Common/Modal';
|
||||
import AdvancedRequester, { RequestOverrides } from './AdvancedRequester';
|
||||
import QuotaDisplay from './QuotaDisplay';
|
||||
|
||||
const messages = defineMessages({
|
||||
requestadmin: 'Your request will be immediately approved.',
|
||||
requestadmin: 'Your request will be approved automatically.',
|
||||
cancelrequest:
|
||||
'This will remove your request. Are you sure you want to continue?',
|
||||
requestSuccess: '<strong>{title}</strong> requested successfully!',
|
||||
@@ -37,7 +39,6 @@ const messages = defineMessages({
|
||||
request4kfrom: 'There is currently a pending 4K request from {username}.',
|
||||
errorediting: 'Something went wrong while editing the request.',
|
||||
requestedited: 'Request edited.',
|
||||
autoapproval: 'Automatic Approval',
|
||||
requesterror: 'Something went wrong while submitting the request.',
|
||||
});
|
||||
|
||||
@@ -69,6 +70,9 @@ const MovieRequestModal: React.FC<RequestModalProps> = ({
|
||||
});
|
||||
const intl = useIntl();
|
||||
const { user, hasPermission } = useUser();
|
||||
const { data: quota } = useSWR<QuotaResponse>(
|
||||
user ? `/api/v1/user/${requestOverrides?.user?.id ?? user.id}/quota` : null
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (onUpdating) {
|
||||
@@ -260,13 +264,22 @@ const MovieRequestModal: React.FC<RequestModalProps> = ({
|
||||
);
|
||||
}
|
||||
|
||||
const hasAutoApprove = hasPermission(
|
||||
[
|
||||
Permission.MANAGE_REQUESTS,
|
||||
is4k ? Permission.AUTO_APPROVE_4K : Permission.AUTO_APPROVE,
|
||||
is4k ? Permission.AUTO_APPROVE_4K_MOVIE : Permission.AUTO_APPROVE_MOVIE,
|
||||
],
|
||||
{ type: 'or' }
|
||||
);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
loading={!data && !error}
|
||||
loading={(!data && !error) || !quota}
|
||||
backgroundClickable
|
||||
onCancel={onCancel}
|
||||
onOk={sendRequest}
|
||||
okDisabled={isUpdating}
|
||||
okDisabled={isUpdating || quota?.movie.restricted}
|
||||
title={intl.formatMessage(
|
||||
is4k ? messages.request4ktitle : messages.requesttitle,
|
||||
{ title: data?.title }
|
||||
@@ -279,20 +292,24 @@ const MovieRequestModal: React.FC<RequestModalProps> = ({
|
||||
okButtonType={'primary'}
|
||||
iconSvg={<DownloadIcon className="w-6 h-6" />}
|
||||
>
|
||||
{(hasPermission(Permission.MANAGE_REQUESTS) ||
|
||||
hasPermission(
|
||||
is4k ? Permission.AUTO_APPROVE_4K : Permission.AUTO_APPROVE
|
||||
) ||
|
||||
hasPermission(
|
||||
is4k
|
||||
? Permission.AUTO_APPROVE_4K_MOVIE
|
||||
: Permission.AUTO_APPROVE_MOVIE
|
||||
)) && (
|
||||
<p className="mt-6">
|
||||
<Alert title={intl.formatMessage(messages.autoapproval)} type="info">
|
||||
{intl.formatMessage(messages.requestadmin)}
|
||||
</Alert>
|
||||
</p>
|
||||
{hasAutoApprove && !quota?.movie.restricted && (
|
||||
<div className="mt-6">
|
||||
<Alert
|
||||
title={intl.formatMessage(messages.requestadmin)}
|
||||
type="info"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{(quota?.movie.limit ?? 0) > 0 && (
|
||||
<QuotaDisplay
|
||||
mediaType="movie"
|
||||
quota={quota?.movie}
|
||||
userOverride={
|
||||
requestOverrides?.user && requestOverrides.user.id !== user?.id
|
||||
? requestOverrides?.user?.id
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{(hasPermission(Permission.REQUEST_ADVANCED) ||
|
||||
hasPermission(Permission.MANAGE_REQUESTS)) && (
|
||||
|
||||
173
src/components/RequestModal/QuotaDisplay/index.tsx
Normal file
173
src/components/RequestModal/QuotaDisplay/index.tsx
Normal file
@@ -0,0 +1,173 @@
|
||||
import Link from 'next/link';
|
||||
import React, { useState } from 'react';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
import { QuotaStatus } from '../../../../server/interfaces/api/userInterfaces';
|
||||
import ProgressCircle from '../../Common/ProgressCircle';
|
||||
|
||||
const messages = defineMessages({
|
||||
requestsremaining:
|
||||
'{remaining, plural, =0 {No} other {<strong>#</strong>}} {type} {remaining, plural, one {requests} other {requests}} remaining',
|
||||
movielimit: '{limit, plural, one {movie} other {movies}}',
|
||||
seasonlimit: '{limit, plural, one {season} other {seasons}}',
|
||||
allowedRequests:
|
||||
'You are allowed to request <strong>{limit}</strong> {type} every <strong>{days}</strong> days.',
|
||||
allowedRequestsUser:
|
||||
'This user is allowed to request <strong>{limit}</strong> {type} every <strong>{days}</strong> days.',
|
||||
quotaLink:
|
||||
'You can view a summary of your request limits on your <ProfileLink>profile page</ProfileLink>.',
|
||||
quotaLinkUser:
|
||||
"You can view a summary of this user's request limits on their <ProfileLink>profile page</ProfileLink>.",
|
||||
movie: 'movie',
|
||||
season: 'season',
|
||||
notenoughseasonrequests: 'Not enough season requests remaining',
|
||||
requiredquota:
|
||||
'You need to have at least <strong>{seasons}</strong> {seasons, plural, one {season request} other {season requests}} remaining in order to submit a request for this series.',
|
||||
});
|
||||
|
||||
interface QuotaDisplayProps {
|
||||
quota?: QuotaStatus;
|
||||
mediaType: 'movie' | 'tv';
|
||||
userOverride?: number | null;
|
||||
remaining?: number;
|
||||
overLimit?: number;
|
||||
}
|
||||
|
||||
const QuotaDisplay: React.FC<QuotaDisplayProps> = ({
|
||||
quota,
|
||||
mediaType,
|
||||
userOverride,
|
||||
remaining,
|
||||
overLimit,
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
const [showDetails, setShowDetails] = useState(false);
|
||||
return (
|
||||
<div
|
||||
className="flex flex-col p-4 my-4 bg-gray-800 rounded-md"
|
||||
onClick={() => setShowDetails((s) => !s)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
setShowDetails((s) => !s);
|
||||
}
|
||||
}}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<ProgressCircle
|
||||
className="w-8 h-8"
|
||||
progress={Math.max(
|
||||
0,
|
||||
Math.round(
|
||||
((remaining ?? quota?.remaining ?? 0) / (quota?.limit ?? 1)) * 100
|
||||
)
|
||||
)}
|
||||
useHeatLevel
|
||||
/>
|
||||
<div
|
||||
className={`flex items-end ${
|
||||
Math.max(0, remaining ?? quota?.remaining ?? 0) === 0 ||
|
||||
quota?.restricted
|
||||
? 'text-red-500'
|
||||
: ''
|
||||
}`}
|
||||
>
|
||||
<div className="ml-2 text-lg">
|
||||
{overLimit !== undefined
|
||||
? intl.formatMessage(messages.notenoughseasonrequests)
|
||||
: intl.formatMessage(messages.requestsremaining, {
|
||||
remaining: Math.max(0, remaining ?? quota?.remaining ?? 0),
|
||||
type: intl.formatMessage(
|
||||
mediaType === 'movie' ? messages.movie : messages.season
|
||||
),
|
||||
strong: function strong(msg) {
|
||||
return <span className="font-bold">{msg}</span>;
|
||||
},
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end flex-1">
|
||||
{showDetails ? (
|
||||
<svg
|
||||
className="w-6 h-6"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M14.707 12.707a1 1 0 01-1.414 0L10 9.414l-3.293 3.293a1 1 0 01-1.414-1.414l4-4a1 1 0 011.414 0l4 4a1 1 0 010 1.414z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
) : (
|
||||
<svg
|
||||
className="w-6 h-6"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{showDetails && (
|
||||
<div className="mt-4">
|
||||
{overLimit !== undefined && (
|
||||
<div className="mb-2">
|
||||
{intl.formatMessage(messages.requiredquota, {
|
||||
seasons: overLimit,
|
||||
strong: function strong(msg) {
|
||||
return <span className="font-bold">{msg}</span>;
|
||||
},
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
{intl.formatMessage(
|
||||
userOverride
|
||||
? messages.allowedRequestsUser
|
||||
: messages.allowedRequests,
|
||||
{
|
||||
limit: quota?.limit,
|
||||
days: quota?.days,
|
||||
type: intl.formatMessage(
|
||||
mediaType === 'movie'
|
||||
? messages.movielimit
|
||||
: messages.seasonlimit,
|
||||
{ limit: quota?.limit }
|
||||
),
|
||||
strong: function strong(msg) {
|
||||
return <span className="font-bold">{msg}</span>;
|
||||
},
|
||||
}
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-2">
|
||||
{intl.formatMessage(
|
||||
userOverride ? messages.quotaLinkUser : messages.quotaLink,
|
||||
{
|
||||
ProfileLink: function ProfileLink(msg) {
|
||||
return (
|
||||
<Link
|
||||
href={userOverride ? `/user/${userOverride}` : '/profile'}
|
||||
>
|
||||
<a className="text-white hover:underline">{msg}</a>
|
||||
</Link>
|
||||
);
|
||||
},
|
||||
}
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default QuotaDisplay;
|
||||
@@ -1,28 +1,30 @@
|
||||
import React, { useState } from 'react';
|
||||
import Modal from '../Common/Modal';
|
||||
import { useUser } from '../../hooks/useUser';
|
||||
import { Permission } from '../../../server/lib/permissions';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
import { MediaRequest } from '../../../server/entity/MediaRequest';
|
||||
import useSWR from 'swr';
|
||||
import { useToasts } from 'react-toast-notifications';
|
||||
import { ANIME_KEYWORD_ID } from '../../../server/api/themoviedb/constants';
|
||||
import axios from 'axios';
|
||||
import React, { useState } from 'react';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
import { useToasts } from 'react-toast-notifications';
|
||||
import useSWR from 'swr';
|
||||
import { ANIME_KEYWORD_ID } from '../../../server/api/themoviedb/constants';
|
||||
import {
|
||||
MediaStatus,
|
||||
MediaRequestStatus,
|
||||
MediaStatus,
|
||||
} from '../../../server/constants/media';
|
||||
import { TvDetails } from '../../../server/models/Tv';
|
||||
import Badge from '../Common/Badge';
|
||||
import globalMessages from '../../i18n/globalMessages';
|
||||
import { MediaRequest } from '../../../server/entity/MediaRequest';
|
||||
import SeasonRequest from '../../../server/entity/SeasonRequest';
|
||||
import Alert from '../Common/Alert';
|
||||
import AdvancedRequester, { RequestOverrides } from './AdvancedRequester';
|
||||
import SearchByNameModal from './SearchByNameModal';
|
||||
import { QuotaResponse } from '../../../server/interfaces/api/userInterfaces';
|
||||
import { Permission } from '../../../server/lib/permissions';
|
||||
import { TvDetails } from '../../../server/models/Tv';
|
||||
import useSettings from '../../hooks/useSettings';
|
||||
import { useUser } from '../../hooks/useUser';
|
||||
import globalMessages from '../../i18n/globalMessages';
|
||||
import Alert from '../Common/Alert';
|
||||
import Badge from '../Common/Badge';
|
||||
import Modal from '../Common/Modal';
|
||||
import AdvancedRequester, { RequestOverrides } from './AdvancedRequester';
|
||||
import QuotaDisplay from './QuotaDisplay';
|
||||
import SearchByNameModal from './SearchByNameModal';
|
||||
|
||||
const messages = defineMessages({
|
||||
requestadmin: 'Your request will be immediately approved.',
|
||||
requestadmin: 'Your request will be approved automatically.',
|
||||
cancelrequest:
|
||||
'This will remove your request. Are you sure you want to continue?',
|
||||
requestSuccess: '<strong>{title}</strong> requested successfully!',
|
||||
@@ -79,13 +81,19 @@ const TvRequestModal: React.FC<RequestModalProps> = ({
|
||||
editRequest ? editingSeasons : []
|
||||
);
|
||||
const intl = useIntl();
|
||||
const { hasPermission } = useUser();
|
||||
const { user, hasPermission } = useUser();
|
||||
const [searchModal, setSearchModal] = useState<{
|
||||
show: boolean;
|
||||
}>({
|
||||
show: true,
|
||||
});
|
||||
const [tvdbId, setTvdbId] = useState<number | undefined>(undefined);
|
||||
const { data: quota } = useSWR<QuotaResponse>(
|
||||
user ? `/api/v1/user/${requestOverrides?.user?.id ?? user.id}/quota` : null
|
||||
);
|
||||
|
||||
const currentlyRemaining =
|
||||
(quota?.tv.remaining ?? 0) - selectedSeasons.length;
|
||||
|
||||
const updateRequest = async () => {
|
||||
if (!editRequest) {
|
||||
@@ -246,6 +254,15 @@ const TvRequestModal: React.FC<RequestModalProps> = ({
|
||||
return;
|
||||
}
|
||||
|
||||
// If there are no more remaining requests available, block toggle
|
||||
if (
|
||||
quota?.tv.limit &&
|
||||
currentlyRemaining <= 0 &&
|
||||
!isSelectedSeason(seasonNumber)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedSeasons.includes(seasonNumber)) {
|
||||
setSelectedSeasons((seasons) =>
|
||||
seasons.filter((sn) => sn !== seasonNumber)
|
||||
@@ -255,20 +272,25 @@ const TvRequestModal: React.FC<RequestModalProps> = ({
|
||||
}
|
||||
};
|
||||
|
||||
const unrequestedSeasons = getAllSeasons().filter(
|
||||
(season) => !getAllRequestedSeasons().includes(season)
|
||||
);
|
||||
|
||||
const toggleAllSeasons = (): void => {
|
||||
// If the user has a quota and not enough requests for all seasons, block toggleAllSeasons
|
||||
if (
|
||||
quota?.tv.limit &&
|
||||
(quota?.tv.remaining ?? 0) < unrequestedSeasons.length
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
data &&
|
||||
selectedSeasons.length >= 0 &&
|
||||
selectedSeasons.length <
|
||||
getAllSeasons().filter(
|
||||
(season) => !getAllRequestedSeasons().includes(season)
|
||||
).length
|
||||
selectedSeasons.length < unrequestedSeasons.length
|
||||
) {
|
||||
setSelectedSeasons(
|
||||
getAllSeasons().filter(
|
||||
(season) => !getAllRequestedSeasons().includes(season)
|
||||
)
|
||||
);
|
||||
setSelectedSeasons(unrequestedSeasons);
|
||||
} else {
|
||||
setSelectedSeasons([]);
|
||||
}
|
||||
@@ -352,6 +374,9 @@ const TvRequestModal: React.FC<RequestModalProps> = ({
|
||||
okDisabled={
|
||||
editRequest
|
||||
? false
|
||||
: !settings.currentSettings.partialRequestsEnabled &&
|
||||
unrequestedSeasons.length > (quota?.tv.limit ?? 0)
|
||||
? true
|
||||
: getAllRequestedSeasons().length >= getAllSeasons().length ||
|
||||
(settings.currentSettings.partialRequestsEnabled &&
|
||||
selectedSeasons.length === 0)
|
||||
@@ -393,17 +418,43 @@ const TvRequestModal: React.FC<RequestModalProps> = ({
|
||||
],
|
||||
{ type: 'or' }
|
||||
) &&
|
||||
!(
|
||||
quota?.tv.limit &&
|
||||
!settings.currentSettings.partialRequestsEnabled &&
|
||||
unrequestedSeasons.length > (quota?.tv.limit ?? 0)
|
||||
) &&
|
||||
getAllRequestedSeasons().length < getAllSeasons().length &&
|
||||
!editRequest && (
|
||||
<p className="mt-6">
|
||||
<Alert
|
||||
title={intl.formatMessage(messages.autoapproval)}
|
||||
title={intl.formatMessage(messages.requestadmin)}
|
||||
type="info"
|
||||
>
|
||||
{intl.formatMessage(messages.requestadmin)}
|
||||
</Alert>
|
||||
/>
|
||||
</p>
|
||||
)}
|
||||
{(quota?.movie.limit ?? 0) > 0 && (
|
||||
<QuotaDisplay
|
||||
mediaType="tv"
|
||||
quota={quota?.tv}
|
||||
remaining={
|
||||
!settings.currentSettings.partialRequestsEnabled &&
|
||||
unrequestedSeasons.length > (quota?.tv.limit ?? 0)
|
||||
? 0
|
||||
: currentlyRemaining
|
||||
}
|
||||
userOverride={
|
||||
requestOverrides?.user && requestOverrides.user.id !== user?.id
|
||||
? requestOverrides?.user?.id
|
||||
: undefined
|
||||
}
|
||||
overLimit={
|
||||
!settings.currentSettings.partialRequestsEnabled &&
|
||||
unrequestedSeasons.length > (quota?.tv.limit ?? 0)
|
||||
? unrequestedSeasons.length
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<div className="flex flex-col">
|
||||
<div className="-mx-4 sm:mx-0">
|
||||
<div className="inline-block min-w-full py-2 align-middle">
|
||||
@@ -427,7 +478,13 @@ const TvRequestModal: React.FC<RequestModalProps> = ({
|
||||
toggleAllSeasons();
|
||||
}
|
||||
}}
|
||||
className="relative inline-flex items-center justify-center flex-shrink-0 w-10 h-5 pt-2 cursor-pointer focus:outline-none"
|
||||
className={`relative inline-flex items-center justify-center flex-shrink-0 w-10 h-5 pt-2 cursor-pointer focus:outline-none ${
|
||||
quota?.tv.remaining &&
|
||||
quota.tv.limit &&
|
||||
quota.tv.remaining < unrequestedSeasons.length
|
||||
? 'opacity-50'
|
||||
: ''
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
@@ -494,6 +551,9 @@ const TvRequestModal: React.FC<RequestModalProps> = ({
|
||||
}}
|
||||
className={`pt-2 relative inline-flex items-center justify-center flex-shrink-0 h-5 w-10 cursor-pointer focus:outline-none ${
|
||||
mediaSeason ||
|
||||
(quota?.tv.limit &&
|
||||
currentlyRemaining <= 0 &&
|
||||
!isSelectedSeason(season.seasonNumber)) ||
|
||||
(!!seasonRequest &&
|
||||
!editingSeasons.includes(season.seasonNumber))
|
||||
? 'opacity-50'
|
||||
|
||||
Reference in New Issue
Block a user