Skip to content

Commit ea74f6a

Browse files
feat: add filters for user data limits and expiration dates
- Extend `get_users` function to include parameters for data limit range (min and max), and expiration date range (expire after and expire before). - Implement logic to filter users based on the new parameters in the database query. - Update the user operation and router to handle the new filters. - Enhance the advance search modal to allow users to input data limit and expiration date filters. - Add corresponding translations for the new filters in multiple languages. - Update the API to parse and handle the new filter parameters. - Implement tests to verify the functionality of the new filters for data limits and expiration dates.
1 parent 2463dbf commit ea74f6a

14 files changed

Lines changed: 671 additions & 15 deletions

File tree

app/db/crud/user.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -286,6 +286,12 @@ async def get_users(
286286
admin: Admin | None = None,
287287
admins: list[str] | None = None,
288288
reset_strategy: DataLimitResetStrategy | list[DataLimitResetStrategy] | None = None,
289+
data_limit_min: int | None = None,
290+
data_limit_max: int | None = None,
291+
expire_after: datetime | None = None,
292+
expire_before: datetime | None = None,
293+
no_data_limit: bool = False,
294+
no_expire: bool = False,
289295
return_with_count: bool = False,
290296
group_ids: list[int] | None = None,
291297
) -> list[User] | tuple[list[User], int]:
@@ -303,6 +309,12 @@ async def get_users(
303309
admin: Admin filter.
304310
admins: List of admin usernames to filter by.
305311
reset_strategy: Reset strategy filter (single strategy or list).
312+
data_limit_min: Minimum user data limit in bytes.
313+
data_limit_max: Maximum user data limit in bytes.
314+
expire_after: Include users whose expire date is on or after this datetime.
315+
expire_before: Include users whose expire date is on or before this datetime.
316+
no_data_limit: Include only users with no data limit set.
317+
no_expire: Include only users with no expire date set.
306318
return_with_count: Whether to return total count.
307319
group_ids: Filter users by their group IDs.
308320
@@ -336,6 +348,20 @@ async def get_users(
336348
filters.append(User.data_limit_reset_strategy.in_(reset_strategy))
337349
else:
338350
filters.append(User.data_limit_reset_strategy == reset_strategy)
351+
if no_data_limit:
352+
filters.append(or_(User.data_limit.is_(None), User.data_limit == 0))
353+
else:
354+
if data_limit_min is not None:
355+
filters.append(and_(User.data_limit.is_not(None), User.data_limit >= data_limit_min))
356+
if data_limit_max is not None:
357+
filters.append(and_(User.data_limit.is_not(None), User.data_limit <= data_limit_max))
358+
if no_expire:
359+
filters.append(User.expire.is_(None))
360+
else:
361+
if expire_after is not None:
362+
filters.append(and_(User.expire.is_not(None), User.expire >= expire_after))
363+
if expire_before is not None:
364+
filters.append(and_(User.expire.is_not(None), User.expire <= expire_before))
339365

340366
if group_ids:
341367
filters.append(User.groups.any(Group.id.in_(group_ids)))

app/operation/user.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -723,6 +723,12 @@ async def get_users(
723723
status: UserStatus | None = None,
724724
sort: str | None = None,
725725
proxy_id: str | None = None,
726+
data_limit_min: int | None = None,
727+
data_limit_max: int | None = None,
728+
expire_after: dt | None = None,
729+
expire_before: dt | None = None,
730+
no_data_limit: bool = False,
731+
no_expire: bool = False,
726732
load_sub: bool = False,
727733
group_ids: list[int] | None = None,
728734
) -> UsersResponse:
@@ -750,6 +756,12 @@ async def get_users(
750756
status=status,
751757
sort=sort_list,
752758
proxy_id=proxy_id,
759+
data_limit_min=data_limit_min,
760+
data_limit_max=data_limit_max,
761+
expire_after=expire_after,
762+
expire_before=expire_before,
763+
no_data_limit=no_data_limit,
764+
no_expire=no_expire,
753765
admins=owner if admin.is_sudo else [admin.username],
754766
return_with_count=True,
755767
group_ids=group_ids,

app/routers/user.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -418,6 +418,12 @@ async def get_users(
418418
status: UserStatus | None = None,
419419
sort: str | None = None,
420420
proxy_id: str | None = None,
421+
data_limit_min: int | None = Query(None, ge=0),
422+
data_limit_max: int | None = Query(None, ge=0),
423+
expire_after: dt | None = Query(None, examples=["2026-01-01T00:00:00+03:30"]),
424+
expire_before: dt | None = Query(None, examples=["2026-01-31T23:59:59+03:30"]),
425+
no_data_limit: bool = False,
426+
no_expire: bool = False,
421427
load_sub: bool = False,
422428
db: AsyncSession = Depends(get_db),
423429
admin: AdminDetails = Depends(get_current),
@@ -435,6 +441,12 @@ async def get_users(
435441
sort=sort,
436442
load_sub=load_sub,
437443
proxy_id=proxy_id,
444+
data_limit_min=data_limit_min,
445+
data_limit_max=data_limit_max,
446+
expire_after=expire_after,
447+
expire_before=expire_before,
448+
no_data_limit=no_data_limit,
449+
no_expire=no_expire,
438450
group_ids=group_ids,
439451
)
440452

dashboard/public/statics/locales/en.json

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2246,6 +2246,19 @@
22462246
"showSelectionCheckboxDescription": "Shows row checkboxes for bulk selection.",
22472247
"byStatus": "Status",
22482248
"statusDescription": "Leave on All to include every status.",
2249+
"dataLimitDescription": "Filter users by data-limit range in gigabytes.",
2250+
"dataLimitMin": "Minimum data limit (GB)",
2251+
"dataLimitMax": "Maximum data limit (GB)",
2252+
"dataLimitMinPlaceholder": "e.g. 10",
2253+
"dataLimitMaxPlaceholder": "e.g. 100",
2254+
"noDataLimit": "Only users with no data limit",
2255+
"noDataLimitDescription": "Shows users whose data limit is unlimited.",
2256+
"expireAfter": "Expire after",
2257+
"expireBefore": "Expire before",
2258+
"expireAfterPlaceholder": "Select start date",
2259+
"expireBeforePlaceholder": "Select end date",
2260+
"noExpire": "Only users with no expire date",
2261+
"noExpireDescription": "Shows users whose account has no expire date.",
22492262
"byAdmin": "Admin",
22502263
"byGroup": "Group",
22512264
"displaySection": "Table display",

dashboard/public/statics/locales/fa.json

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2220,6 +2220,19 @@
22202220
"showSelectionCheckboxDescription": "چک‌باکس سطرها را برای انتخاب گروهی نمایش می‌دهد.",
22212221
"byStatus": "وضعیت",
22222222
"statusDescription": "برای نمایش همه وضعیت‌ها، مقدار را روی همه بگذارید.",
2223+
"dataLimitDescription": "کاربران را بر اساس بازه محدودیت حجم بر حسب گیگابایت فیلتر می‌کند.",
2224+
"dataLimitMin": "حداقل محدودیت حجم (گیگابایت)",
2225+
"dataLimitMax": "حداکثر محدودیت حجم (گیگابایت)",
2226+
"dataLimitMinPlaceholder": "مثلاً 10",
2227+
"dataLimitMaxPlaceholder": "مثلاً 100",
2228+
"noDataLimit": "فقط کاربران بدون محدودیت حجم",
2229+
"noDataLimitDescription": "فقط کاربرانی را نشان می‌دهد که محدودیت حجم ندارند.",
2230+
"expireAfter": "انقضا بعد از",
2231+
"expireBefore": "انقضا قبل از",
2232+
"expireAfterPlaceholder": "تاریخ شروع را انتخاب کنید",
2233+
"expireBeforePlaceholder": "تاریخ پایان را انتخاب کنید",
2234+
"noExpire": "فقط کاربران بدون تاریخ انقضا",
2235+
"noExpireDescription": "فقط کاربرانی را نشان می‌دهد که تاریخ انقضا ندارند.",
22232236
"byAdmin": "مدیر",
22242237
"byGroup": "گروه",
22252238
"displaySection": "نمایش جدول",

dashboard/public/statics/locales/ru.json

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2188,6 +2188,19 @@
21882188
"showSelectionCheckboxDescription": "Показывает чекбоксы строк для массового выбора.",
21892189
"byStatus": "Статус",
21902190
"statusDescription": "Оставьте «Все», чтобы включить все статусы.",
2191+
"dataLimitDescription": "Фильтрует пользователей по диапазону лимита трафика в гигабайтах.",
2192+
"dataLimitMin": "Минимальный лимит трафика (ГБ)",
2193+
"dataLimitMax": "Максимальный лимит трафика (ГБ)",
2194+
"dataLimitMinPlaceholder": "например, 10",
2195+
"dataLimitMaxPlaceholder": "например, 100",
2196+
"noDataLimit": "Только пользователи без лимита трафика",
2197+
"noDataLimitDescription": "Показывает только пользователей с безлимитным трафиком.",
2198+
"expireAfter": "Истекает после",
2199+
"expireBefore": "Истекает до",
2200+
"expireAfterPlaceholder": "Выберите начальную дату",
2201+
"expireBeforePlaceholder": "Выберите конечную дату",
2202+
"noExpire": "Только пользователи без даты истечения",
2203+
"noExpireDescription": "Показывает только пользователей без даты истечения срока.",
21912204
"byAdmin": "Администратор",
21922205
"byGroup": "Группа",
21932206
"displaySection": "Отображение таблицы",

dashboard/public/statics/locales/zh.json

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2244,6 +2244,19 @@
22442244
"showSelectionCheckboxDescription": "显示行复选框以便批量选择。",
22452245
"byStatus": "状态",
22462246
"statusDescription": "保留为“全部”即可包含所有状态。",
2247+
"dataLimitDescription": "按以 GB 为单位的数据上限范围筛选用户。",
2248+
"dataLimitMin": "最小数据上限 (GB)",
2249+
"dataLimitMax": "最大数据上限 (GB)",
2250+
"dataLimitMinPlaceholder": "例如 10",
2251+
"dataLimitMaxPlaceholder": "例如 100",
2252+
"noDataLimit": "仅显示无数据上限的用户",
2253+
"noDataLimitDescription": "只显示数据上限为无限制的用户。",
2254+
"expireAfter": "到期晚于",
2255+
"expireBefore": "到期早于",
2256+
"expireAfterPlaceholder": "选择开始日期",
2257+
"expireBeforePlaceholder": "选择结束日期",
2258+
"noExpire": "仅显示无到期日期的用户",
2259+
"noExpireDescription": "只显示账户没有到期日期的用户。",
22472260
"byAdmin": "管理员",
22482261
"byGroup": "分组",
22492262
"displaySection": "表格显示",

dashboard/src/components/dialogs/advance-search-modal.tsx

Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,9 @@ import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@/
22
import { Badge } from '@/components/ui/badge'
33
import { Button } from '@/components/ui/button'
44
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
5+
import { DatePicker } from '@/components/common/date-picker'
56
import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form'
7+
import { Input } from '@/components/ui/input'
68
import { LoaderButton } from '@/components/ui/loader-button'
79
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
810
import { Switch } from '@/components/ui/switch'
@@ -29,6 +31,8 @@ interface AdvanceSearchModalProps {
2931
export default function AdvanceSearchModal({ isDialogOpen, onOpenChange, form, onSubmit, isSudo, isApplying = false }: AdvanceSearchModalProps) {
3032
const dir = useDirDetection()
3133
const { t } = useTranslation()
34+
const noDataLimitOnly = form.watch('no_data_limit')
35+
const noExpireOnly = form.watch('no_expire')
3236

3337
const { data: groupsData } = useGetGroupsSimple({ all: true })
3438

@@ -167,6 +171,164 @@ export default function AdvanceSearchModal({ isDialogOpen, onOpenChange, form, o
167171
}}
168172
/>
169173

174+
<div className="grid gap-4 sm:grid-cols-2">
175+
<FormField
176+
control={form.control}
177+
name="data_limit_min"
178+
render={({ field }) => (
179+
<FormItem className="w-full">
180+
<FormLabel>{t('advanceSearch.dataLimitMin', { defaultValue: 'Minimum data limit (GB)' })}</FormLabel>
181+
<FormDescription>{t('advanceSearch.dataLimitDescription', { defaultValue: 'Filter users by data-limit range in gigabytes.' })}</FormDescription>
182+
<FormControl>
183+
<Input
184+
type="number"
185+
min="0"
186+
step="any"
187+
inputMode="decimal"
188+
placeholder={t('advanceSearch.dataLimitMinPlaceholder', { defaultValue: 'e.g. 10' })}
189+
value={field.value ?? ''}
190+
disabled={isApplying || noDataLimitOnly}
191+
onChange={event => {
192+
const rawValue = event.target.value
193+
field.onChange(rawValue === '' ? undefined : Number(rawValue))
194+
}}
195+
/>
196+
</FormControl>
197+
<FormMessage />
198+
</FormItem>
199+
)}
200+
/>
201+
202+
<FormField
203+
control={form.control}
204+
name="data_limit_max"
205+
render={({ field }) => (
206+
<FormItem className="w-full">
207+
<FormLabel>{t('advanceSearch.dataLimitMax', { defaultValue: 'Maximum data limit (GB)' })}</FormLabel>
208+
<FormDescription>{t('advanceSearch.dataLimitDescription', { defaultValue: 'Filter users by data-limit range in gigabytes.' })}</FormDescription>
209+
<FormControl>
210+
<Input
211+
type="number"
212+
min="0"
213+
step="any"
214+
inputMode="decimal"
215+
placeholder={t('advanceSearch.dataLimitMaxPlaceholder', { defaultValue: 'e.g. 100' })}
216+
value={field.value ?? ''}
217+
disabled={isApplying || noDataLimitOnly}
218+
onChange={event => {
219+
const rawValue = event.target.value
220+
field.onChange(rawValue === '' ? undefined : Number(rawValue))
221+
}}
222+
/>
223+
</FormControl>
224+
<FormMessage />
225+
</FormItem>
226+
)}
227+
/>
228+
</div>
229+
230+
<FormField
231+
control={form.control}
232+
name="no_data_limit"
233+
render={({ field }) => (
234+
<FormItem className="flex w-full items-start justify-between gap-4 rounded-md border p-4">
235+
<div className="space-y-1">
236+
<FormLabel>{t('advanceSearch.noDataLimit', { defaultValue: 'Only users with no data limit' })}</FormLabel>
237+
<FormDescription>{t('advanceSearch.noDataLimitDescription', { defaultValue: 'Shows users whose data limit is unlimited.' })}</FormDescription>
238+
</div>
239+
<FormControl>
240+
<Switch
241+
checked={field.value}
242+
disabled={isApplying}
243+
onCheckedChange={checked => {
244+
field.onChange(checked)
245+
if (checked) {
246+
form.setValue('data_limit_min', undefined, { shouldDirty: true })
247+
form.setValue('data_limit_max', undefined, { shouldDirty: true })
248+
}
249+
}}
250+
/>
251+
</FormControl>
252+
<FormMessage />
253+
</FormItem>
254+
)}
255+
/>
256+
257+
<div className="grid gap-4 sm:grid-cols-2">
258+
<FormField
259+
control={form.control}
260+
name="expire_after"
261+
render={({ field }) => (
262+
<FormItem className="w-full">
263+
<FormControl>
264+
<div className={cn((isApplying || noExpireOnly) && 'pointer-events-none opacity-60')}>
265+
<DatePicker
266+
mode="single"
267+
date={field.value}
268+
onDateChange={field.onChange}
269+
label={t('advanceSearch.expireAfter', { defaultValue: 'Expire after' })}
270+
placeholder={t('advanceSearch.expireAfterPlaceholder', { defaultValue: 'Select start date' })}
271+
minDate={new Date('1900-01-01')}
272+
className="[&_label]:text-sm"
273+
/>
274+
</div>
275+
</FormControl>
276+
<FormMessage />
277+
</FormItem>
278+
)}
279+
/>
280+
281+
<FormField
282+
control={form.control}
283+
name="expire_before"
284+
render={({ field }) => (
285+
<FormItem className="w-full">
286+
<FormControl>
287+
<div className={cn((isApplying || noExpireOnly) && 'pointer-events-none opacity-60')}>
288+
<DatePicker
289+
mode="single"
290+
date={field.value}
291+
onDateChange={field.onChange}
292+
label={t('advanceSearch.expireBefore', { defaultValue: 'Expire before' })}
293+
placeholder={t('advanceSearch.expireBeforePlaceholder', { defaultValue: 'Select end date' })}
294+
minDate={new Date('1900-01-01')}
295+
className="[&_label]:text-sm"
296+
/>
297+
</div>
298+
</FormControl>
299+
<FormMessage />
300+
</FormItem>
301+
)}
302+
/>
303+
</div>
304+
305+
<FormField
306+
control={form.control}
307+
name="no_expire"
308+
render={({ field }) => (
309+
<FormItem className="flex w-full items-start justify-between gap-4 rounded-md border p-4">
310+
<div className="space-y-1">
311+
<FormLabel>{t('advanceSearch.noExpire', { defaultValue: 'Only users with no expire date' })}</FormLabel>
312+
<FormDescription>{t('advanceSearch.noExpireDescription', { defaultValue: 'Shows users whose account has no expire date.' })}</FormDescription>
313+
</div>
314+
<FormControl>
315+
<Switch
316+
checked={field.value}
317+
disabled={isApplying}
318+
onCheckedChange={checked => {
319+
field.onChange(checked)
320+
if (checked) {
321+
form.setValue('expire_after', undefined, { shouldDirty: true })
322+
form.setValue('expire_before', undefined, { shouldDirty: true })
323+
}
324+
}}
325+
/>
326+
</FormControl>
327+
<FormMessage />
328+
</FormItem>
329+
)}
330+
/>
331+
170332
<FormField
171333
control={form.control}
172334
name="group"

0 commit comments

Comments
 (0)