feat(Activity-logs): Various enhancements (#38582)
Co-authored-by: github-actions <41898282+github-actions[bot]@users.noreply.github.com>
|
Before Width: | Height: | Size: 120 KiB After Width: | Height: | Size: 123 KiB |
|
Before Width: | Height: | Size: 120 KiB After Width: | Height: | Size: 122 KiB |
|
Before Width: | Height: | Size: 190 KiB After Width: | Height: | Size: 192 KiB |
|
Before Width: | Height: | Size: 190 KiB After Width: | Height: | Size: 192 KiB |
|
Before Width: | Height: | Size: 123 KiB After Width: | Height: | Size: 126 KiB |
|
Before Width: | Height: | Size: 123 KiB After Width: | Height: | Size: 126 KiB |
|
Before Width: | Height: | Size: 70 KiB After Width: | Height: | Size: 72 KiB |
|
Before Width: | Height: | Size: 70 KiB After Width: | Height: | Size: 72 KiB |
|
Before Width: | Height: | Size: 233 KiB After Width: | Height: | Size: 235 KiB |
|
Before Width: | Height: | Size: 227 KiB After Width: | Height: | Size: 234 KiB |
|
Before Width: | Height: | Size: 162 KiB After Width: | Height: | Size: 164 KiB |
|
Before Width: | Height: | Size: 162 KiB After Width: | Height: | Size: 164 KiB |
|
Before Width: | Height: | Size: 128 KiB After Width: | Height: | Size: 130 KiB |
|
Before Width: | Height: | Size: 127 KiB After Width: | Height: | Size: 130 KiB |
|
Before Width: | Height: | Size: 106 KiB After Width: | Height: | Size: 109 KiB |
|
Before Width: | Height: | Size: 107 KiB After Width: | Height: | Size: 109 KiB |
|
Before Width: | Height: | Size: 157 KiB After Width: | Height: | Size: 159 KiB |
|
Before Width: | Height: | Size: 157 KiB After Width: | Height: | Size: 160 KiB |
|
Before Width: | Height: | Size: 83 KiB After Width: | Height: | Size: 85 KiB |
|
Before Width: | Height: | Size: 83 KiB After Width: | Height: | Size: 85 KiB |
|
Before Width: | Height: | Size: 284 KiB After Width: | Height: | Size: 285 KiB |
|
Before Width: | Height: | Size: 282 KiB After Width: | Height: | Size: 284 KiB |
|
Before Width: | Height: | Size: 413 KiB After Width: | Height: | Size: 415 KiB |
|
Before Width: | Height: | Size: 411 KiB After Width: | Height: | Size: 413 KiB |
|
Before Width: | Height: | Size: 208 KiB After Width: | Height: | Size: 210 KiB |
|
Before Width: | Height: | Size: 207 KiB After Width: | Height: | Size: 210 KiB |
|
Before Width: | Height: | Size: 134 KiB After Width: | Height: | Size: 137 KiB |
|
Before Width: | Height: | Size: 135 KiB After Width: | Height: | Size: 137 KiB |
|
Before Width: | Height: | Size: 216 KiB After Width: | Height: | Size: 219 KiB |
|
Before Width: | Height: | Size: 216 KiB After Width: | Height: | Size: 219 KiB |
|
Before Width: | Height: | Size: 83 KiB After Width: | Height: | Size: 85 KiB |
|
Before Width: | Height: | Size: 83 KiB After Width: | Height: | Size: 86 KiB |
|
Before Width: | Height: | Size: 129 KiB After Width: | Height: | Size: 131 KiB |
|
Before Width: | Height: | Size: 131 KiB After Width: | Height: | Size: 133 KiB |
|
Before Width: | Height: | Size: 69 KiB After Width: | Height: | Size: 71 KiB |
|
Before Width: | Height: | Size: 69 KiB After Width: | Height: | Size: 72 KiB |
|
Before Width: | Height: | Size: 107 KiB After Width: | Height: | Size: 110 KiB |
|
Before Width: | Height: | Size: 108 KiB After Width: | Height: | Size: 110 KiB |
|
Before Width: | Height: | Size: 135 KiB After Width: | Height: | Size: 137 KiB |
|
Before Width: | Height: | Size: 134 KiB After Width: | Height: | Size: 136 KiB |
|
Before Width: | Height: | Size: 83 KiB After Width: | Height: | Size: 85 KiB |
|
Before Width: | Height: | Size: 83 KiB After Width: | Height: | Size: 85 KiB |
|
Before Width: | Height: | Size: 83 KiB After Width: | Height: | Size: 85 KiB |
|
Before Width: | Height: | Size: 83 KiB After Width: | Height: | Size: 85 KiB |
|
Before Width: | Height: | Size: 130 KiB After Width: | Height: | Size: 132 KiB |
|
Before Width: | Height: | Size: 132 KiB After Width: | Height: | Size: 134 KiB |
|
Before Width: | Height: | Size: 105 KiB After Width: | Height: | Size: 108 KiB |
|
Before Width: | Height: | Size: 106 KiB After Width: | Height: | Size: 108 KiB |
|
Before Width: | Height: | Size: 190 KiB After Width: | Height: | Size: 192 KiB |
|
Before Width: | Height: | Size: 190 KiB After Width: | Height: | Size: 192 KiB |
|
Before Width: | Height: | Size: 77 KiB After Width: | Height: | Size: 80 KiB |
|
Before Width: | Height: | Size: 77 KiB After Width: | Height: | Size: 79 KiB |
|
Before Width: | Height: | Size: 76 KiB After Width: | Height: | Size: 79 KiB |
|
Before Width: | Height: | Size: 76 KiB After Width: | Height: | Size: 78 KiB |
|
Before Width: | Height: | Size: 106 KiB After Width: | Height: | Size: 109 KiB |
|
Before Width: | Height: | Size: 107 KiB After Width: | Height: | Size: 109 KiB |
|
Before Width: | Height: | Size: 284 KiB After Width: | Height: | Size: 285 KiB |
|
Before Width: | Height: | Size: 282 KiB After Width: | Height: | Size: 284 KiB |
|
Before Width: | Height: | Size: 410 KiB After Width: | Height: | Size: 412 KiB |
|
Before Width: | Height: | Size: 408 KiB After Width: | Height: | Size: 410 KiB |
|
Before Width: | Height: | Size: 148 KiB After Width: | Height: | Size: 150 KiB |
|
Before Width: | Height: | Size: 149 KiB After Width: | Height: | Size: 150 KiB |
|
Before Width: | Height: | Size: 91 KiB After Width: | Height: | Size: 93 KiB |
|
Before Width: | Height: | Size: 91 KiB After Width: | Height: | Size: 93 KiB |
|
Before Width: | Height: | Size: 175 KiB After Width: | Height: | Size: 177 KiB |
|
Before Width: | Height: | Size: 175 KiB After Width: | Height: | Size: 178 KiB |
|
Before Width: | Height: | Size: 71 KiB After Width: | Height: | Size: 73 KiB |
|
Before Width: | Height: | Size: 71 KiB After Width: | Height: | Size: 73 KiB |
|
Before Width: | Height: | Size: 86 KiB After Width: | Height: | Size: 89 KiB |
|
Before Width: | Height: | Size: 87 KiB After Width: | Height: | Size: 89 KiB |
|
Before Width: | Height: | Size: 92 KiB After Width: | Height: | Size: 94 KiB |
|
Before Width: | Height: | Size: 92 KiB After Width: | Height: | Size: 95 KiB |
|
Before Width: | Height: | Size: 135 KiB After Width: | Height: | Size: 137 KiB |
|
Before Width: | Height: | Size: 134 KiB After Width: | Height: | Size: 137 KiB |
|
Before Width: | Height: | Size: 134 KiB After Width: | Height: | Size: 136 KiB |
|
Before Width: | Height: | Size: 134 KiB After Width: | Height: | Size: 136 KiB |
|
Before Width: | Height: | Size: 135 KiB After Width: | Height: | Size: 137 KiB |
|
Before Width: | Height: | Size: 134 KiB After Width: | Height: | Size: 137 KiB |
@@ -388,13 +388,6 @@ export const getDefaultTreeData = (): FileSystemImport[] => [
|
||||
iconType: 'data_pipeline_metadata',
|
||||
href: urls.dataPipelines('destinations'),
|
||||
} as FileSystemImport,
|
||||
{
|
||||
path: `Activity logs`,
|
||||
category: 'Activity',
|
||||
iconType: 'team_activity',
|
||||
href: urls.advancedActivityLogs(),
|
||||
flag: FEATURE_FLAGS.ADVANCED_ACTIVITY_LOGS,
|
||||
} as FileSystemImport,
|
||||
]
|
||||
|
||||
export const getDefaultTreeProducts = (): FileSystemImport[] =>
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useValues } from 'kea'
|
||||
import { SkeletonLog } from 'lib/components/ActivityLog/ActivityLog'
|
||||
import { describerFor } from 'lib/components/ActivityLog/activityLogLogic'
|
||||
import { humanize } from 'lib/components/ActivityLog/humanizeActivity'
|
||||
import { WarningHog } from 'lib/components/hedgehogs'
|
||||
import { DetectiveHog } from 'lib/components/hedgehogs'
|
||||
import { PaginationControl, usePagination } from 'lib/lemon-ui/PaginationControl'
|
||||
|
||||
import { AuditLogTableHeader, AuditLogTableRow } from './AuditLogTable'
|
||||
@@ -56,7 +56,7 @@ const AdvancedActivityLogsEmptyState = (): JSX.Element => (
|
||||
data-attr="billing-empty-state"
|
||||
className="flex flex-col border rounded px-4 py-8 items-center text-center mx-auto"
|
||||
>
|
||||
<WarningHog width="100" height="100" className="mb-4" />
|
||||
<DetectiveHog width="100" height="100" className="mb-4" />
|
||||
<h2 className="text-xl leading-tight">We couldn't find any activity logs for your current query.</h2>
|
||||
<p className="text-sm text-balance text-tertiary">
|
||||
Try adjusting your filters or date range to see more results.
|
||||
|
||||
@@ -21,7 +21,7 @@ export const scene: SceneExport = {
|
||||
}
|
||||
|
||||
export function AdvancedActivityLogsScene(): JSX.Element | null {
|
||||
const { isFeatureFlagEnabled, exports, activeTab } = useValues(advancedActivityLogsLogic)
|
||||
const { isFeatureFlagEnabled, activeTab } = useValues(advancedActivityLogsLogic)
|
||||
const { setActiveTab } = useActions(advancedActivityLogsLogic)
|
||||
|
||||
if (!isFeatureFlagEnabled) {
|
||||
@@ -29,12 +29,10 @@ export function AdvancedActivityLogsScene(): JSX.Element | null {
|
||||
return null
|
||||
}
|
||||
|
||||
const hasExports = exports && exports.length > 0
|
||||
|
||||
const tabs = [
|
||||
{
|
||||
key: 'logs',
|
||||
label: 'Activity logs',
|
||||
label: 'Logs',
|
||||
content: (
|
||||
<div className="space-y-4">
|
||||
<AdvancedActivityLogFiltersPanel />
|
||||
@@ -42,24 +40,20 @@ export function AdvancedActivityLogsScene(): JSX.Element | null {
|
||||
</div>
|
||||
),
|
||||
},
|
||||
...(hasExports
|
||||
? [
|
||||
{
|
||||
key: 'exports',
|
||||
label: 'Exports',
|
||||
content: <ExportsList />,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
key: 'exports',
|
||||
label: 'Exports',
|
||||
content: <ExportsList />,
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<SceneContent>
|
||||
<SceneTitleSection
|
||||
name="Advanced activity logs"
|
||||
description="Track all changes and activities in your organization"
|
||||
name="Activity logs"
|
||||
description="Track all changes and activities in your organization with detailed filtering and export capabilities."
|
||||
resourceType={{
|
||||
type: 'activity logs',
|
||||
type: 'team_activity',
|
||||
forceIcon: <IconActivity />,
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useActions, useValues } from 'kea'
|
||||
|
||||
import { IconCollapse, IconExpand, IconInfo } from '@posthog/icons'
|
||||
import { LemonButton, LemonSelect, Tooltip } from '@posthog/lemon-ui'
|
||||
import { LemonBadge, LemonButton, LemonSelect, Tooltip } from '@posthog/lemon-ui'
|
||||
|
||||
import { humanizeActivity, humanizeScope } from 'lib/components/ActivityLog/humanizeActivity'
|
||||
import { AnimatedCollapsible } from 'lib/components/AnimatedCollapsible'
|
||||
@@ -14,7 +14,8 @@ import { DetailFilters } from './DetailFilters'
|
||||
import { advancedActivityLogsLogic } from './advancedActivityLogsLogic'
|
||||
|
||||
export const BasicFiltersTab = (): JSX.Element => {
|
||||
const { filters, availableFilters, showMoreFilters } = useValues(advancedActivityLogsLogic)
|
||||
const { filters, availableFilters, showMoreFilters, activeAdvancedFiltersCount } =
|
||||
useValues(advancedActivityLogsLogic)
|
||||
const { setFilters, setShowMoreFilters } = useActions(advancedActivityLogsLogic)
|
||||
|
||||
return (
|
||||
@@ -30,7 +31,7 @@ export const BasicFiltersTab = (): JSX.Element => {
|
||||
}}
|
||||
placeholder="All time"
|
||||
data-attr="audit-logs-date-filter"
|
||||
className="h-8 flex items-center"
|
||||
className="h-[24px] flex items-center"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -51,7 +52,8 @@ export const BasicFiltersTab = (): JSX.Element => {
|
||||
placeholder="All users"
|
||||
allowCustomValues={false}
|
||||
data-attr="audit-logs-user-filter"
|
||||
className="min-w-50 min-h-10"
|
||||
size="small"
|
||||
className="min-w-50"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -72,7 +74,8 @@ export const BasicFiltersTab = (): JSX.Element => {
|
||||
placeholder="All scopes"
|
||||
allowCustomValues={false}
|
||||
data-attr="audit-logs-scope-filter"
|
||||
className="min-w-50 min-h-10"
|
||||
size="small"
|
||||
className="min-w-50"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -93,20 +96,31 @@ export const BasicFiltersTab = (): JSX.Element => {
|
||||
placeholder="All actions"
|
||||
allowCustomValues={false}
|
||||
data-attr="audit-logs-action-filter"
|
||||
className="min-w-50 min-h-10"
|
||||
size="small"
|
||||
className="min-w-50"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-end justify-end mb-1">
|
||||
<LemonButton
|
||||
type="tertiary"
|
||||
icon={showMoreFilters ? <IconCollapse /> : <IconExpand />}
|
||||
onClick={() => setShowMoreFilters(!showMoreFilters)}
|
||||
data-attr="audit-logs-more-filters-toggle"
|
||||
className="text-muted-alt hover:text-default"
|
||||
>
|
||||
More filters
|
||||
</LemonButton>
|
||||
<div className="relative">
|
||||
<LemonButton
|
||||
type="tertiary"
|
||||
size="small"
|
||||
icon={showMoreFilters ? <IconCollapse /> : <IconExpand />}
|
||||
onClick={() => setShowMoreFilters(!showMoreFilters)}
|
||||
data-attr="audit-logs-more-filters-toggle"
|
||||
className="text-muted-alt hover:text-default"
|
||||
>
|
||||
More filters
|
||||
</LemonButton>
|
||||
{!showMoreFilters && activeAdvancedFiltersCount > 0 && (
|
||||
<LemonBadge
|
||||
content={activeAdvancedFiltersCount.toString()}
|
||||
size="small"
|
||||
className="absolute -top-1 -right-1"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -134,7 +148,8 @@ export const BasicFiltersTab = (): JSX.Element => {
|
||||
]}
|
||||
placeholder="All"
|
||||
data-attr="audit-logs-was-impersonated-filter"
|
||||
className="min-w-50 min-h-10"
|
||||
size="small"
|
||||
className="min-w-50"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -157,7 +172,8 @@ export const BasicFiltersTab = (): JSX.Element => {
|
||||
]}
|
||||
placeholder="All"
|
||||
data-attr="audit-logs-is-system-filter"
|
||||
className="min-w-50 min-h-10"
|
||||
size="small"
|
||||
className="min-w-50"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -178,7 +194,8 @@ export const BasicFiltersTab = (): JSX.Element => {
|
||||
placeholder="Enter item IDs"
|
||||
allowCustomValues={true}
|
||||
data-attr="audit-logs-item-ids-filter"
|
||||
className="min-w-50 min-h-10"
|
||||
size="small"
|
||||
className="min-w-50"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
import { useActions, useValues } from 'kea'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
|
||||
import { IconPlus, IconTrash } from '@posthog/icons'
|
||||
import { LemonButton, LemonInput, LemonInputSelect, LemonSearchableSelect, LemonSelect } from '@posthog/lemon-ui'
|
||||
import { IconInfo, IconPlus, IconTrash } from '@posthog/icons'
|
||||
import {
|
||||
LemonButton,
|
||||
LemonInput,
|
||||
LemonInputSelect,
|
||||
LemonSearchableSelect,
|
||||
LemonSelect,
|
||||
Tooltip,
|
||||
} from '@posthog/lemon-ui'
|
||||
|
||||
import { ActiveDetailFilter, advancedActivityLogsLogic } from './advancedActivityLogsLogic'
|
||||
|
||||
@@ -157,6 +164,7 @@ const DetailFilterRow = ({ filter }: DetailFilterRowProps): JSX.Element => {
|
||||
onChange={handleCustomFieldPathChange}
|
||||
placeholder="Enter custom field path"
|
||||
status={fieldPathError ? 'danger' : undefined}
|
||||
size="small"
|
||||
/>
|
||||
{fieldPathError && <div className="text-xs text-danger mt-1">{fieldPathError}</div>}
|
||||
</div>
|
||||
@@ -166,6 +174,7 @@ const DetailFilterRow = ({ filter }: DetailFilterRowProps): JSX.Element => {
|
||||
onChange={handleFieldChange}
|
||||
options={fieldOptionsForRow}
|
||||
placeholder="Select field"
|
||||
size="small"
|
||||
className="min-w-60"
|
||||
/>
|
||||
)}
|
||||
@@ -178,6 +187,7 @@ const DetailFilterRow = ({ filter }: DetailFilterRowProps): JSX.Element => {
|
||||
{ value: 'contains', label: 'contains' },
|
||||
{ value: 'in', label: 'is one of' },
|
||||
]}
|
||||
size="small"
|
||||
className="min-w-32"
|
||||
/>
|
||||
|
||||
@@ -188,6 +198,7 @@ const DetailFilterRow = ({ filter }: DetailFilterRowProps): JSX.Element => {
|
||||
onChange={handleValueChange}
|
||||
allowCustomValues
|
||||
placeholder="Enter values"
|
||||
size="small"
|
||||
className="min-w-60"
|
||||
/>
|
||||
) : (
|
||||
@@ -195,6 +206,7 @@ const DetailFilterRow = ({ filter }: DetailFilterRowProps): JSX.Element => {
|
||||
value={localValue as string}
|
||||
onChange={handleValueChange}
|
||||
placeholder="Enter value"
|
||||
size="small"
|
||||
className="min-w-60"
|
||||
/>
|
||||
)}
|
||||
@@ -271,8 +283,13 @@ export const DetailFilters = (): JSX.Element => {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="block text-sm font-medium">Detail Filters</label>
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex items-center gap-1">
|
||||
<label className="block text-sm font-medium">Detail filters</label>
|
||||
<Tooltip title="Filter by specific fields within the activity log details field. For example, filter by changes to specific dashboard properties, feature flag variations, or other detailed attributes logged with each activity.">
|
||||
<IconInfo className="w-4 h-4 text-muted-alt cursor-help" />
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
{activeFilters.map((filter) => (
|
||||
@@ -290,9 +307,10 @@ export const DetailFilters = (): JSX.Element => {
|
||||
}
|
||||
}}
|
||||
options={fieldOptions}
|
||||
placeholder="Add detail filter"
|
||||
placeholder="Add filter"
|
||||
searchPlaceholder="Search fields..."
|
||||
icon={<IconPlus />}
|
||||
size="small"
|
||||
className="w-[200px]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useValues } from 'kea'
|
||||
import { IconDownload } from '@posthog/icons'
|
||||
import { LemonButton, LemonTable, Tooltip } from '@posthog/lemon-ui'
|
||||
|
||||
import { DetectiveHog } from 'lib/components/hedgehogs'
|
||||
import { humanFriendlyDetailedTime } from 'lib/utils'
|
||||
|
||||
import {
|
||||
@@ -17,6 +18,15 @@ import { ExportedAsset, advancedActivityLogsLogic } from './advancedActivityLogs
|
||||
export function ExportsList(): JSX.Element {
|
||||
const { exports, exportsLoading } = useValues(advancedActivityLogsLogic)
|
||||
|
||||
if (exportsLoading) {
|
||||
// You could add a skeleton here if desired
|
||||
return <div>Loading exports...</div>
|
||||
}
|
||||
|
||||
if (!exports || exports.length === 0) {
|
||||
return <ExportsEmptyState />
|
||||
}
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: 'Filename',
|
||||
@@ -77,18 +87,31 @@ export function ExportsList(): JSX.Element {
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-lg font-semibold">Your exports</h3>
|
||||
<p className="text-muted text-xs">Refreshed every 5 seconds</p>
|
||||
</div>
|
||||
|
||||
<LemonTable
|
||||
dataSource={exports || []}
|
||||
dataSource={exports}
|
||||
columns={columns}
|
||||
loading={exportsLoading}
|
||||
loading={false}
|
||||
rowKey="id"
|
||||
emptyState="No exports found"
|
||||
footer={
|
||||
<div className="flex items-center justify-end mt-2 mr-2">
|
||||
<p className="text-muted text-xs">Refreshed every 5 seconds</p>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const ExportsEmptyState = (): JSX.Element => (
|
||||
<div
|
||||
data-attr="exports-empty-state"
|
||||
className="flex flex-col border rounded px-4 py-8 items-center text-center mx-auto"
|
||||
>
|
||||
<DetectiveHog width="100" height="100" className="mb-4" />
|
||||
<h2 className="text-xl leading-tight">No exports found</h2>
|
||||
<p className="text-sm text-balance text-tertiary">
|
||||
Exports will appear here when you create them from the Logs tab. Start by applying filters and then click
|
||||
"Export".
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -262,9 +262,31 @@ export const advancedActivityLogsLogic = kea<advancedActivityLogsLogicType>([
|
||||
onForward: () => advancedActivityLogsLogic.actions.setPage((filters.page || 1) + 1),
|
||||
}),
|
||||
],
|
||||
|
||||
activeAdvancedFiltersCount: [
|
||||
(s) => [s.filters],
|
||||
(filters: AdvancedActivityLogFilters): number => {
|
||||
let count = 0
|
||||
|
||||
if (filters.was_impersonated !== undefined) {
|
||||
count++
|
||||
}
|
||||
if (filters.is_system !== undefined) {
|
||||
count++
|
||||
}
|
||||
if (filters.item_ids && filters.item_ids.length > 0) {
|
||||
count++
|
||||
}
|
||||
if (filters.detail_filters && Object.keys(filters.detail_filters).length > 0) {
|
||||
count++
|
||||
}
|
||||
|
||||
return count
|
||||
},
|
||||
],
|
||||
}),
|
||||
|
||||
listeners(({ actions, values }) => ({
|
||||
listeners(({ actions, values, cache }) => ({
|
||||
setFilters: async (_, breakpoint) => {
|
||||
await breakpoint(300)
|
||||
actions.loadAdvancedActivityLogs({})
|
||||
@@ -273,9 +295,29 @@ export const advancedActivityLogsLogic = kea<advancedActivityLogsLogicType>([
|
||||
actions.loadAdvancedActivityLogs({})
|
||||
},
|
||||
clearAllFilters: () => {
|
||||
actions.setActiveFilters([])
|
||||
actions.setShowMoreFilters(false)
|
||||
actions.loadAdvancedActivityLogs({})
|
||||
},
|
||||
|
||||
setActiveTab: ({ tab }) => {
|
||||
if (tab === 'exports') {
|
||||
// Start polling when switching to exports tab
|
||||
actions.loadExports()
|
||||
if (!cache.exportPollingInterval) {
|
||||
cache.exportPollingInterval = setInterval(() => {
|
||||
actions.loadExports()
|
||||
}, 5000)
|
||||
}
|
||||
} else {
|
||||
// Stop polling when switching away from exports tab
|
||||
if (cache.exportPollingInterval) {
|
||||
clearInterval(cache.exportPollingInterval)
|
||||
cache.exportPollingInterval = null
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// Detail filter management
|
||||
addActiveFilter: ({ fieldPath }) => {
|
||||
if (fieldPath === '__add_custom__') {
|
||||
@@ -396,7 +438,6 @@ export const advancedActivityLogsLogic = kea<advancedActivityLogsLogicType>([
|
||||
})
|
||||
|
||||
lemonToast.success(`Export started! Your ${format.toUpperCase()} export is being prepared.`)
|
||||
actions.loadExports()
|
||||
actions.setActiveTab('exports')
|
||||
} catch (error) {
|
||||
console.error('Export failed:', error)
|
||||
@@ -429,7 +470,15 @@ export const advancedActivityLogsLogic = kea<advancedActivityLogsLogicType>([
|
||||
})),
|
||||
|
||||
urlToAction(({ actions }) => ({
|
||||
'/advanced-activity-logs': (_, searchParams) => {
|
||||
'/activity-logs': (_, searchParams) => {
|
||||
const hasUrlParams = Object.keys(searchParams).length > 0
|
||||
|
||||
// If just visiting the page, we want to clear all filters in case the page was previously mounted with filters
|
||||
if (!hasUrlParams) {
|
||||
actions.clearAllFilters()
|
||||
return
|
||||
}
|
||||
|
||||
const urlFilters: Partial<AdvancedActivityLogFilters> = {}
|
||||
|
||||
if (searchParams.start_date) {
|
||||
@@ -476,24 +525,19 @@ export const advancedActivityLogsLogic = kea<advancedActivityLogsLogicType>([
|
||||
urlFilters.page = parseInt(searchParams.page, 10)
|
||||
}
|
||||
|
||||
if (Object.keys(urlFilters).length > 0) {
|
||||
actions.setFilters(urlFilters)
|
||||
}
|
||||
actions.setFilters(urlFilters)
|
||||
},
|
||||
})),
|
||||
|
||||
events(({ actions, cache }) => ({
|
||||
afterMount: () => {
|
||||
actions.loadAvailableFilters()
|
||||
actions.loadExports()
|
||||
|
||||
cache.exportPollingInterval = setInterval(() => {
|
||||
actions.loadExports()
|
||||
}, 5000)
|
||||
actions.loadAdvancedActivityLogs({})
|
||||
},
|
||||
beforeUnmount: () => {
|
||||
if (cache.exportPollingInterval) {
|
||||
clearInterval(cache.exportPollingInterval)
|
||||
cache.exportPollingInterval = null
|
||||
}
|
||||
},
|
||||
})),
|
||||
|
||||
@@ -14,7 +14,6 @@ import { featureFlagLogic } from 'lib/logic/featureFlagLogic'
|
||||
import { capitalizeFirstLetter } from 'lib/utils'
|
||||
import { Annotations } from 'scenes/annotations'
|
||||
import { NewAnnotationButton } from 'scenes/annotations/AnnotationModal'
|
||||
import { AdvancedActivityLogsList } from 'scenes/audit-logs/AdvancedActivityLogsList'
|
||||
import { Comments } from 'scenes/data-management/comments/Comments'
|
||||
import { SceneExport } from 'scenes/sceneTypes'
|
||||
import { urls } from 'scenes/urls'
|
||||
@@ -38,7 +37,6 @@ export enum DataManagementTab {
|
||||
Annotations = 'annotations',
|
||||
Comments = 'comments',
|
||||
History = 'history',
|
||||
ActivityLogs = 'activity-logs',
|
||||
IngestionWarnings = 'warnings',
|
||||
Revenue = 'revenue',
|
||||
MarketingAnalytics = 'marketing-analytics',
|
||||
@@ -126,12 +124,6 @@ const tabs: Record<DataManagementTab, TabConfig> = {
|
||||
),
|
||||
tooltipDocLink: 'https://posthog.com/docs/data#history',
|
||||
},
|
||||
[DataManagementTab.ActivityLogs]: {
|
||||
url: urls.advancedActivityLogs(),
|
||||
label: 'Activity logs',
|
||||
content: <AdvancedActivityLogsList />,
|
||||
flag: FEATURE_FLAGS.ADVANCED_ACTIVITY_LOGS,
|
||||
},
|
||||
[DataManagementTab.Revenue]: {
|
||||
url: urls.revenueSettings(),
|
||||
label: (
|
||||
|
||||
@@ -96,7 +96,7 @@ export const newTabSceneLogic = kea<newTabSceneLogicType>([
|
||||
.filter(({ path }) => path.startsWith('Insight/'))
|
||||
.map((fs) => ({
|
||||
href: fs.href,
|
||||
name: 'new ' + fs.path.substring(8),
|
||||
name: 'New ' + fs.path.substring(8),
|
||||
icon: getIconForFileSystemItem(fs),
|
||||
flag: fs.flag,
|
||||
}))
|
||||
@@ -114,7 +114,7 @@ export const newTabSceneLogic = kea<newTabSceneLogicType>([
|
||||
.filter(({ path }) => !path.startsWith('Insight/') && !path.startsWith('Data/'))
|
||||
.map((fs) => ({
|
||||
href: fs.href,
|
||||
name: 'new ' + fs.path,
|
||||
name: 'New ' + fs.path,
|
||||
icon: getIconForFileSystemItem(fs),
|
||||
flag: fs.flag,
|
||||
}))
|
||||
@@ -143,10 +143,10 @@ export const newTabSceneLogic = kea<newTabSceneLogicType>([
|
||||
{
|
||||
category: 'create-new',
|
||||
types: [
|
||||
{ name: 'new SQL query', icon: <IconDatabase />, href: '/sql' },
|
||||
{ name: 'New SQL query', icon: <IconDatabase />, href: '/sql' },
|
||||
...newInsightItems,
|
||||
...newOtherItems,
|
||||
{ name: 'new Hog program', icon: <IconHogQL />, href: '/debug/hog' },
|
||||
{ name: 'New Hog program', icon: <IconHogQL />, href: '/debug/hog' },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -38,7 +38,7 @@ export const sceneConfigurations: Record<Scene | string, SceneConfig> = {
|
||||
[Scene.AdvancedActivityLogs]: {
|
||||
projectBased: true,
|
||||
organizationBased: false,
|
||||
name: 'Advanced activity logs',
|
||||
name: 'Activity logs',
|
||||
},
|
||||
[Scene.AsyncMigrations]: { instanceLevel: true },
|
||||
[Scene.BillingAuthorizationStatus]: {
|
||||
|
||||
@@ -567,6 +567,14 @@ export const SETTINGS_MAP: SettingSection[] = [
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
level: 'environment',
|
||||
id: 'environment-activity-logs',
|
||||
title: 'Activity logs',
|
||||
flag: 'ADVANCED_ACTIVITY_LOGS',
|
||||
to: urls.advancedActivityLogs(),
|
||||
settings: [],
|
||||
},
|
||||
{
|
||||
level: 'environment',
|
||||
id: 'mcp-server',
|
||||
|
||||
@@ -30,6 +30,7 @@ export type SettingSectionId =
|
||||
| 'environment-crm'
|
||||
| 'environment-max'
|
||||
| 'environment-integrations'
|
||||
| 'environment-activity-logs'
|
||||
| 'environment-access-control'
|
||||
| 'environment-danger-zone'
|
||||
| 'project-details'
|
||||
|
||||
@@ -107,7 +107,7 @@ export const urls = {
|
||||
`/organization/billing${products && products.length ? `?products=${products.join(',')}` : ''}`,
|
||||
organizationBillingSection: (section: BillingSectionId = 'overview'): string =>
|
||||
combineUrl(`/organization/billing/${section}`).url,
|
||||
advancedActivityLogs: (): string => '/advanced-activity-logs',
|
||||
advancedActivityLogs: (): string => '/activity-logs',
|
||||
billingAuthorizationStatus: (): string => `/billing/authorization_status`,
|
||||
// Self-hosted only
|
||||
instanceStatus: (): string => '/instance/status',
|
||||
|
||||
@@ -63,15 +63,9 @@ class AdvancedActivityLogFilterManager:
|
||||
# Array fields like changes[].type need special handling
|
||||
queryset = self._apply_array_field_filter(queryset, field_path, operation, value)
|
||||
else:
|
||||
django_field_path = field_path.replace(".", "__")
|
||||
if operation == "exact":
|
||||
queryset = queryset.filter(**{f"detail__{django_field_path}": value})
|
||||
elif operation == "in":
|
||||
if not isinstance(value, list | tuple):
|
||||
value = [value]
|
||||
queryset = queryset.filter(**{f"detail__{django_field_path}__in": value})
|
||||
elif operation == "contains":
|
||||
queryset = queryset.filter(**{f"detail__{django_field_path}__icontains": value})
|
||||
django_field_path = f"detail__{field_path.replace('.', '__')}"
|
||||
query_condition = self._create_type_insensitive_query(django_field_path, operation, value)
|
||||
queryset = queryset.filter(query_condition)
|
||||
|
||||
return queryset
|
||||
|
||||
@@ -93,8 +87,6 @@ class AdvancedActivityLogFilterManager:
|
||||
def _apply_array_exact_filter(
|
||||
self, queryset: QuerySet[ActivityLog], field_path: str, operation: str, value: Any
|
||||
) -> QuerySet[ActivityLog]:
|
||||
from django.db.models import Q
|
||||
|
||||
parts = field_path.split("[].")
|
||||
if len(parts) < 2:
|
||||
return self._apply_array_contains_filter(queryset, field_path, value)
|
||||
@@ -102,53 +94,11 @@ class AdvancedActivityLogFilterManager:
|
||||
max_indices_to_check = 5 # Check first 5 elements of each array
|
||||
query_conditions = []
|
||||
|
||||
for i in range(max_indices_to_check):
|
||||
django_path = self._build_indexed_path(parts, [i])
|
||||
indexed_paths = self._generate_indexed_paths(parts, field_path, max_indices_to_check)
|
||||
|
||||
if operation == "exact":
|
||||
condition = Q(**{f"detail__{django_path}": value})
|
||||
elif operation == "in":
|
||||
if not isinstance(value, list | tuple):
|
||||
value = [value]
|
||||
condition = Q(**{f"detail__{django_path}__in": value})
|
||||
else:
|
||||
continue
|
||||
|
||||
query_conditions.append(condition)
|
||||
|
||||
# Handle nested arrays like items[].data[].name
|
||||
if field_path.count("[]") > 1:
|
||||
for i in range(max_indices_to_check):
|
||||
for j in range(max_indices_to_check):
|
||||
django_path = self._build_indexed_path(parts, [i, j])
|
||||
|
||||
if operation == "exact":
|
||||
condition = Q(**{f"detail__{django_path}": value})
|
||||
elif operation == "in":
|
||||
if not isinstance(value, list | tuple):
|
||||
value = [value]
|
||||
condition = Q(**{f"detail__{django_path}__in": value})
|
||||
else:
|
||||
continue
|
||||
|
||||
query_conditions.append(condition)
|
||||
|
||||
if field_path.count("[]") > 2:
|
||||
for i in range(max_indices_to_check):
|
||||
for j in range(max_indices_to_check):
|
||||
for k in range(max_indices_to_check):
|
||||
django_path = self._build_indexed_path(parts, [i, j, k])
|
||||
|
||||
if operation == "exact":
|
||||
condition = Q(**{f"detail__{django_path}": value})
|
||||
elif operation == "in":
|
||||
if not isinstance(value, list | tuple):
|
||||
value = [value]
|
||||
condition = Q(**{f"detail__{django_path}__in": value})
|
||||
else:
|
||||
continue
|
||||
|
||||
query_conditions.append(condition)
|
||||
for django_path in indexed_paths:
|
||||
query_condition = self._create_type_insensitive_query(f"detail__{django_path}", operation, value)
|
||||
query_conditions.append(query_condition)
|
||||
|
||||
# Combine all conditions with OR
|
||||
if query_conditions:
|
||||
@@ -159,6 +109,27 @@ class AdvancedActivityLogFilterManager:
|
||||
|
||||
return queryset
|
||||
|
||||
def _generate_indexed_paths(
|
||||
self, parts: list[str], field_path: str, max_indices: int, current_indices: list[int] | None = None
|
||||
) -> list[str]:
|
||||
"""
|
||||
Generate all indexed paths for array field filtering based on nesting depth.
|
||||
"""
|
||||
if current_indices is None:
|
||||
return self._generate_indexed_paths(parts, field_path, max_indices, [])
|
||||
|
||||
remaining_depth = field_path.count("[]") - len(current_indices)
|
||||
|
||||
if remaining_depth == 0:
|
||||
return [self._build_indexed_path(parts, current_indices)]
|
||||
|
||||
paths = []
|
||||
for i in range(max_indices):
|
||||
new_indices = [*current_indices, i]
|
||||
paths.extend(self._generate_indexed_paths(parts, field_path, max_indices, new_indices))
|
||||
|
||||
return paths
|
||||
|
||||
def _build_indexed_path(self, parts: list[str], indices: list[int]) -> str:
|
||||
if not parts or not indices:
|
||||
return ""
|
||||
@@ -200,3 +171,101 @@ class AdvancedActivityLogFilterManager:
|
||||
if filters.get("item_ids"):
|
||||
queryset = queryset.filter(item_id__in=filters["item_ids"])
|
||||
return queryset
|
||||
|
||||
def _get_type_variants(self, value: Any) -> list[Any]:
|
||||
"""
|
||||
Convert a value to its possible type variants for type-insensitive matching.
|
||||
Returns a list of values to try in database queries.
|
||||
"""
|
||||
variants = [value] # Always include the original value
|
||||
|
||||
# If value is a string, try to convert to other types
|
||||
if isinstance(value, str) and value.strip():
|
||||
stripped_value = value.strip()
|
||||
|
||||
# Try integer conversion
|
||||
try:
|
||||
int_val = int(stripped_value)
|
||||
if str(int_val) == stripped_value: # Avoid float-to-int conversion artifacts
|
||||
variants.append(int_val)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
# Try float conversion (only if not already an integer)
|
||||
try:
|
||||
float_val = float(stripped_value)
|
||||
if str(float_val) == stripped_value or (
|
||||
stripped_value.endswith(".0") and str(float_val) == stripped_value[:-2]
|
||||
):
|
||||
variants.append(float_val)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
# Try boolean conversion
|
||||
lower_val = stripped_value.lower()
|
||||
if lower_val in ("true", "1"):
|
||||
variants.append(True)
|
||||
elif lower_val in ("false", "0"):
|
||||
variants.append(False)
|
||||
|
||||
# If value is boolean, add string and numeric representations
|
||||
elif isinstance(value, bool):
|
||||
variants.extend([str(value).lower(), str(value), "1" if value else "0"])
|
||||
# If value is numeric, add string representation
|
||||
elif isinstance(value, int | float):
|
||||
variants.append(str(value))
|
||||
|
||||
# Remove duplicates while preserving order
|
||||
seen = set()
|
||||
unique_variants = []
|
||||
for variant in variants:
|
||||
# Use a tuple representation for hashable comparison
|
||||
key = (type(variant).__name__, variant)
|
||||
if key not in seen:
|
||||
seen.add(key)
|
||||
unique_variants.append(variant)
|
||||
|
||||
return unique_variants
|
||||
|
||||
def _expand_values_with_type_variants(self, value: Any) -> list[Any]:
|
||||
"""
|
||||
Expand a single value or list of values to include all type variants.
|
||||
Handles deduplication automatically.
|
||||
"""
|
||||
if not isinstance(value, list | tuple):
|
||||
value = [value]
|
||||
|
||||
expanded_values = []
|
||||
for v in value:
|
||||
expanded_values.extend(self._get_type_variants(v))
|
||||
|
||||
# Remove duplicates while preserving order
|
||||
seen = set()
|
||||
unique_values = []
|
||||
for val in expanded_values:
|
||||
key = (type(val).__name__, val)
|
||||
if key not in seen:
|
||||
seen.add(key)
|
||||
unique_values.append(val)
|
||||
|
||||
return unique_values
|
||||
|
||||
def _create_type_insensitive_query(self, field_path: str, operation: str, value: Any) -> Q:
|
||||
"""
|
||||
Create a type-insensitive query condition for the given field path and operation.
|
||||
"""
|
||||
if operation == "exact":
|
||||
# Create OR conditions for all type variants
|
||||
type_variants = self._get_type_variants(value)
|
||||
conditions = [Q(**{field_path: variant}) for variant in type_variants]
|
||||
combined_condition = conditions[0]
|
||||
for condition in conditions[1:]:
|
||||
combined_condition |= condition
|
||||
return combined_condition
|
||||
elif operation == "in":
|
||||
unique_values = self._expand_values_with_type_variants(value)
|
||||
return Q(**{f"{field_path}__in": unique_values})
|
||||
elif operation == "contains":
|
||||
return Q(**{f"{field_path}__icontains": value})
|
||||
else:
|
||||
return Q(**{field_path: value})
|
||||
|
||||
242
posthog/api/advanced_activity_logs/test_filters.py
Normal file
@@ -0,0 +1,242 @@
|
||||
from posthog.test.base import BaseTest
|
||||
|
||||
from posthog.models.activity_logging.activity_log import ActivityLog
|
||||
|
||||
from .filters import AdvancedActivityLogFilterManager
|
||||
|
||||
|
||||
class TestAdvancedActivityLogFilterManager(BaseTest):
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.filter_manager = AdvancedActivityLogFilterManager()
|
||||
|
||||
def test_get_type_variants_string_to_numeric(self):
|
||||
variants = self.filter_manager._get_type_variants("42")
|
||||
self.assertIn("42", variants)
|
||||
self.assertIn(42, variants)
|
||||
|
||||
variants = self.filter_manager._get_type_variants("3.14")
|
||||
self.assertIn("3.14", variants)
|
||||
self.assertIn(3.14, variants)
|
||||
|
||||
variants = self.filter_manager._get_type_variants("42.0")
|
||||
self.assertIn("42.0", variants)
|
||||
self.assertIn(42.0, variants)
|
||||
|
||||
def test_get_type_variants_numeric_to_string(self):
|
||||
variants = self.filter_manager._get_type_variants(42)
|
||||
self.assertIn(42, variants)
|
||||
self.assertIn("42", variants)
|
||||
|
||||
variants = self.filter_manager._get_type_variants(3.14)
|
||||
self.assertIn(3.14, variants)
|
||||
self.assertIn("3.14", variants)
|
||||
|
||||
def test_get_type_variants_boolean_conversion(self):
|
||||
variants = self.filter_manager._get_type_variants("true")
|
||||
self.assertIn("true", variants)
|
||||
self.assertIn(True, variants)
|
||||
|
||||
variants = self.filter_manager._get_type_variants("false")
|
||||
self.assertIn("false", variants)
|
||||
self.assertIn(False, variants)
|
||||
|
||||
variants = self.filter_manager._get_type_variants("1")
|
||||
self.assertIn("1", variants)
|
||||
self.assertIn(1, variants)
|
||||
self.assertIn(True, variants)
|
||||
|
||||
variants = self.filter_manager._get_type_variants("0")
|
||||
self.assertIn("0", variants)
|
||||
self.assertIn(0, variants)
|
||||
self.assertIn(False, variants)
|
||||
|
||||
variants = self.filter_manager._get_type_variants(True)
|
||||
self.assertIn(True, variants)
|
||||
self.assertIn("true", variants)
|
||||
self.assertIn("True", variants)
|
||||
self.assertIn("1", variants)
|
||||
|
||||
variants = self.filter_manager._get_type_variants(False)
|
||||
self.assertIn(False, variants)
|
||||
self.assertIn("false", variants)
|
||||
self.assertIn("False", variants)
|
||||
self.assertIn("0", variants)
|
||||
|
||||
def test_get_type_variants_edge_cases(self):
|
||||
variants = self.filter_manager._get_type_variants("hello")
|
||||
self.assertEqual(variants, ["hello"])
|
||||
|
||||
variants = self.filter_manager._get_type_variants("")
|
||||
self.assertEqual(variants, [""])
|
||||
|
||||
variants = self.filter_manager._get_type_variants(" ")
|
||||
self.assertEqual(variants, [" "])
|
||||
|
||||
variants = self.filter_manager._get_type_variants("abc123")
|
||||
self.assertEqual(variants, ["abc123"])
|
||||
|
||||
def test_get_type_variants_no_duplicates(self):
|
||||
variants = self.filter_manager._get_type_variants("1")
|
||||
strings = [v for v in variants if isinstance(v, str)]
|
||||
integers = [v for v in variants if isinstance(v, int) and not isinstance(v, bool)]
|
||||
booleans = [v for v in variants if isinstance(v, bool)]
|
||||
|
||||
self.assertEqual(len([v for v in strings if v == "1"]), 1)
|
||||
self.assertEqual(len([v for v in integers if v == 1]), 1)
|
||||
self.assertEqual(len([v for v in booleans if v is True]), 1)
|
||||
|
||||
def _create_activity_log(self, detail: dict) -> ActivityLog:
|
||||
return ActivityLog.objects.create(
|
||||
organization_id=self.organization.id,
|
||||
team_id=self.team.id,
|
||||
user=self.user,
|
||||
scope="TestScope",
|
||||
activity="updated",
|
||||
item_id="test-item",
|
||||
detail=detail,
|
||||
)
|
||||
|
||||
def test_apply_detail_filters_exact_type_insensitive(self):
|
||||
log1 = self._create_activity_log({"count": 42})
|
||||
log2 = self._create_activity_log({"count": "42"})
|
||||
log3 = self._create_activity_log({"count": 42.0})
|
||||
log4 = self._create_activity_log({"count": "other"})
|
||||
|
||||
queryset = ActivityLog.objects.filter(id__in=[log1.id, log2.id, log3.id, log4.id])
|
||||
|
||||
filtered = self.filter_manager._apply_detail_filters(queryset, {"count": {"operation": "exact", "value": "42"}})
|
||||
result_ids = set(filtered.values_list("id", flat=True))
|
||||
expected_ids = {log1.id, log2.id, log3.id}
|
||||
self.assertEqual(result_ids, expected_ids)
|
||||
|
||||
filtered = self.filter_manager._apply_detail_filters(queryset, {"count": {"operation": "exact", "value": 42}})
|
||||
result_ids = set(filtered.values_list("id", flat=True))
|
||||
expected_ids = {log1.id, log2.id, log3.id}
|
||||
self.assertEqual(result_ids, expected_ids)
|
||||
|
||||
def test_apply_detail_filters_in_type_insensitive(self):
|
||||
log1 = self._create_activity_log({"count": "42"})
|
||||
log2 = self._create_activity_log({"count": 42})
|
||||
log3 = self._create_activity_log({"count": "other"})
|
||||
|
||||
queryset = ActivityLog.objects.filter(id__in=[log1.id, log2.id, log3.id])
|
||||
|
||||
filtered = self.filter_manager._apply_detail_filters(queryset, {"count": {"operation": "in", "value": ["42"]}})
|
||||
result_ids = set(filtered.values_list("id", flat=True))
|
||||
expected_ids = {log1.id, log2.id}
|
||||
|
||||
self.assertEqual(result_ids, expected_ids)
|
||||
|
||||
def test_apply_detail_filters_contains_unchanged(self):
|
||||
log1 = self._create_activity_log({"message": "Error code 404"})
|
||||
log2 = self._create_activity_log({"message": "Success"})
|
||||
|
||||
queryset = ActivityLog.objects.filter(id__in=[log1.id, log2.id])
|
||||
|
||||
filtered = self.filter_manager._apply_detail_filters(
|
||||
queryset, {"message": {"operation": "contains", "value": "Error"}}
|
||||
)
|
||||
result_ids = set(filtered.values_list("id", flat=True))
|
||||
self.assertEqual(result_ids, {log1.id})
|
||||
|
||||
def test_nested_object_type_conversion(self):
|
||||
log1 = self._create_activity_log({"config": {"timeout": 30}})
|
||||
log2 = self._create_activity_log({"config": {"timeout": "30"}})
|
||||
log3 = self._create_activity_log({"config": {"timeout": 60}})
|
||||
|
||||
queryset = ActivityLog.objects.filter(id__in=[log1.id, log2.id, log3.id])
|
||||
|
||||
filtered = self.filter_manager._apply_detail_filters(
|
||||
queryset, {"config.timeout": {"operation": "exact", "value": "30"}}
|
||||
)
|
||||
result_ids = set(filtered.values_list("id", flat=True))
|
||||
expected_ids = {log1.id, log2.id}
|
||||
self.assertEqual(result_ids, expected_ids)
|
||||
|
||||
def test_array_field_type_conversion(self):
|
||||
log1 = self._create_activity_log({"items": [{"id": 1}, {"id": 2}]})
|
||||
log2 = self._create_activity_log({"items": [{"id": "1"}, {"id": "3"}]})
|
||||
log3 = self._create_activity_log({"items": [{"id": "other"}]})
|
||||
|
||||
queryset = ActivityLog.objects.filter(id__in=[log1.id, log2.id, log3.id])
|
||||
|
||||
filtered = self.filter_manager._apply_array_field_filter(queryset, "items[].id", "exact", "1")
|
||||
result_ids = set(filtered.values_list("id", flat=True))
|
||||
expected_ids = {log1.id, log2.id}
|
||||
self.assertEqual(result_ids, expected_ids)
|
||||
|
||||
def test_array_field_in_operation_type_conversion(self):
|
||||
log1 = self._create_activity_log({"tags": [{"priority": 1}, {"priority": 3}]})
|
||||
log2 = self._create_activity_log({"tags": [{"priority": "2"}, {"priority": "1"}]})
|
||||
log3 = self._create_activity_log({"tags": [{"priority": "high"}]})
|
||||
|
||||
queryset = ActivityLog.objects.filter(id__in=[log1.id, log2.id, log3.id])
|
||||
|
||||
filtered = self.filter_manager._apply_array_field_filter(queryset, "tags[].priority", "in", ["1", "2"])
|
||||
result_ids = set(filtered.values_list("id", flat=True))
|
||||
expected_ids = {log1.id, log2.id}
|
||||
self.assertEqual(result_ids, expected_ids)
|
||||
|
||||
def test_deeply_nested_array_fields(self):
|
||||
log1 = self._create_activity_log(
|
||||
{
|
||||
"changes": [
|
||||
{"after": [{"field": {"subarray": [{"value": 42}]}}]},
|
||||
{"after": [{"field": {"subarray": [{"value": "other"}]}}]},
|
||||
]
|
||||
}
|
||||
)
|
||||
log2 = self._create_activity_log({"changes": [{"after": [{"field": {"subarray": [{"value": "42"}]}}]}]})
|
||||
log3 = self._create_activity_log({"changes": [{"after": [{"field": {"subarray": [{"value": "different"}]}}]}]})
|
||||
|
||||
queryset = ActivityLog.objects.filter(id__in=[log1.id, log2.id, log3.id])
|
||||
|
||||
filtered = self.filter_manager._apply_array_field_filter(
|
||||
queryset, "changes[].after[].field.subarray[].value", "exact", "42"
|
||||
)
|
||||
result_ids = set(filtered.values_list("id", flat=True))
|
||||
expected_ids = {log1.id, log2.id}
|
||||
self.assertEqual(result_ids, expected_ids)
|
||||
|
||||
|
||||
class TestTypeConversionIntegration(BaseTest):
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.filter_manager = AdvancedActivityLogFilterManager()
|
||||
|
||||
def test_full_filter_pipeline_with_type_conversion(self):
|
||||
log1 = ActivityLog.objects.create(
|
||||
organization_id=self.organization.id,
|
||||
team_id=self.team.id,
|
||||
user=self.user,
|
||||
scope="Dashboard",
|
||||
activity="updated",
|
||||
item_id="123",
|
||||
detail={"version": 2, "active": True, "name": "Test Dashboard"},
|
||||
)
|
||||
|
||||
log2 = ActivityLog.objects.create(
|
||||
organization_id=self.organization.id,
|
||||
team_id=self.team.id,
|
||||
user=self.user,
|
||||
scope="Dashboard",
|
||||
activity="created",
|
||||
item_id="456",
|
||||
detail={"version": "2", "active": "true", "name": "Another Dashboard"},
|
||||
)
|
||||
|
||||
filters = {
|
||||
"scopes": ["Dashboard"],
|
||||
"detail_filters": {
|
||||
"version": {"operation": "exact", "value": "2"},
|
||||
"active": {"operation": "exact", "value": "true"},
|
||||
},
|
||||
}
|
||||
|
||||
queryset = ActivityLog.objects.filter(id__in=[log1.id, log2.id])
|
||||
filtered = self.filter_manager.apply_filters(queryset, filters)
|
||||
|
||||
result_ids = set(filtered.values_list("id", flat=True))
|
||||
expected_ids = {log1.id, log2.id}
|
||||
self.assertEqual(result_ids, expected_ids)
|
||||