feat: merging groups (#23966)
Before Width: | Height: | Size: 92 KiB After Width: | Height: | Size: 91 KiB |
Before Width: | Height: | Size: 92 KiB After Width: | Height: | Size: 94 KiB |
Before Width: | Height: | Size: 39 KiB After Width: | Height: | Size: 39 KiB |
Before Width: | Height: | Size: 40 KiB After Width: | Height: | Size: 40 KiB |
Before Width: | Height: | Size: 174 KiB After Width: | Height: | Size: 174 KiB |
Before Width: | Height: | Size: 245 KiB After Width: | Height: | Size: 223 KiB |
Before Width: | Height: | Size: 245 KiB After Width: | Height: | Size: 223 KiB |
@ -673,6 +673,10 @@ class ApiRequest {
|
||||
return this.errorTracking(teamId).addPathComponent(fingerprint)
|
||||
}
|
||||
|
||||
public errorTrackingMerge(fingerprint: ErrorTrackingGroup['fingerprint']): ApiRequest {
|
||||
return this.errorTrackingGroup(fingerprint).addPathComponent('merge')
|
||||
}
|
||||
|
||||
// # Warehouse
|
||||
public dataWarehouseTables(teamId?: TeamType['id']): ApiRequest {
|
||||
return this.projectsDetail(teamId).addPathComponent('warehouse_tables')
|
||||
@ -1720,6 +1724,15 @@ const api = {
|
||||
): Promise<ErrorTrackingGroup> {
|
||||
return await new ApiRequest().errorTrackingGroup(fingerprint).update({ data })
|
||||
},
|
||||
|
||||
async merge(
|
||||
primaryFingerprint: ErrorTrackingGroup['fingerprint'],
|
||||
mergingFingerprints: ErrorTrackingGroup['fingerprint'][]
|
||||
): Promise<{ content: string }> {
|
||||
return await new ApiRequest()
|
||||
.errorTrackingMerge(primaryFingerprint)
|
||||
.create({ data: { merging_fingerprints: mergingFingerprints } })
|
||||
},
|
||||
},
|
||||
|
||||
recordings: {
|
||||
|
@ -1,17 +1,19 @@
|
||||
import { LemonSelect } from '@posthog/lemon-ui'
|
||||
import { LemonButton, LemonSelect } from '@posthog/lemon-ui'
|
||||
import { useActions, useValues } from 'kea'
|
||||
import { DateFilter } from 'lib/components/DateFilter/DateFilter'
|
||||
|
||||
import { errorTrackingDataLogic } from './errorTrackingDataLogic'
|
||||
import { errorTrackingLogic } from './errorTrackingLogic'
|
||||
import { errorTrackingSceneLogic } from './errorTrackingSceneLogic'
|
||||
|
||||
export const ErrorTrackingActions = ({ showOrder = true }: { showOrder?: boolean }): JSX.Element => {
|
||||
const { dateRange } = useValues(errorTrackingLogic)
|
||||
const { setDateRange } = useActions(errorTrackingLogic)
|
||||
const { order } = useValues(errorTrackingSceneLogic)
|
||||
const { setOrder } = useActions(errorTrackingSceneLogic)
|
||||
const { order, selectedRows } = useValues(errorTrackingSceneLogic)
|
||||
const { setOrder, setSelectedRows } = useActions(errorTrackingSceneLogic)
|
||||
const { mergeGroups } = useActions(errorTrackingDataLogic)
|
||||
|
||||
return (
|
||||
return selectedRows.length === 0 ? (
|
||||
<div className="flex gap-4">
|
||||
<div className="flex items-center gap-1">
|
||||
<span>Date range:</span>
|
||||
@ -58,5 +60,23 @@ export const ErrorTrackingActions = ({ showOrder = true }: { showOrder?: boolean
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex space-x-1">
|
||||
<LemonButton type="secondary" size="small" onClick={() => setSelectedRows([])}>
|
||||
Unselect all
|
||||
</LemonButton>
|
||||
{selectedRows.length > 1 && (
|
||||
<LemonButton
|
||||
type="secondary"
|
||||
size="small"
|
||||
onClick={() => {
|
||||
mergeGroups(selectedRows)
|
||||
setSelectedRows([])
|
||||
}}
|
||||
>
|
||||
Merge
|
||||
</LemonButton>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
@ -1,6 +1,7 @@
|
||||
import { TZLabel } from '@posthog/apps-common'
|
||||
import { IconPerson } from '@posthog/icons'
|
||||
import { LemonButton, LemonDivider, LemonSegmentedButton, ProfilePicture } from '@posthog/lemon-ui'
|
||||
import { LemonButton, LemonCheckbox, LemonDivider, LemonSegmentedButton, ProfilePicture } from '@posthog/lemon-ui'
|
||||
import clsx from 'clsx'
|
||||
import { BindLogic, useActions, useValues } from 'kea'
|
||||
import { MemberSelect } from 'lib/components/MemberSelect'
|
||||
import { LemonTableLink } from 'lib/lemon-ui/LemonTable/LemonTableLink'
|
||||
@ -80,10 +81,26 @@ const CustomVolumeColumnHeader: QueryContextColumnTitleComponent = ({ columnName
|
||||
}
|
||||
|
||||
const CustomGroupTitleColumn: QueryContextColumnComponent = (props) => {
|
||||
const { selectedRows } = useValues(errorTrackingSceneLogic)
|
||||
const { setSelectedRows } = useActions(errorTrackingSceneLogic)
|
||||
|
||||
const record = props.record as ErrorTrackingGroup
|
||||
|
||||
const checked = selectedRows.includes(record.fingerprint)
|
||||
|
||||
return (
|
||||
<div className="flex items-start space-x-1.5 group">
|
||||
<LemonCheckbox
|
||||
className={clsx('pt-1 group-hover:visible', !checked && 'invisible')}
|
||||
checked={checked}
|
||||
onChange={(checked) => {
|
||||
setSelectedRows(
|
||||
checked
|
||||
? [...selectedRows, record.fingerprint]
|
||||
: selectedRows.filter((r) => r != record.fingerprint)
|
||||
)
|
||||
}}
|
||||
/>
|
||||
<LemonTableLink
|
||||
title={record.fingerprint}
|
||||
description={
|
||||
|
@ -5,6 +5,7 @@ import { dataNodeLogic, DataNodeLogicProps } from '~/queries/nodes/DataNode/data
|
||||
import { ErrorTrackingGroup } from '~/queries/schema'
|
||||
|
||||
import type { errorTrackingDataLogicType } from './errorTrackingDataLogicType'
|
||||
import { mergeGroups } from './utils'
|
||||
|
||||
export interface ErrorTrackingDataLogicProps {
|
||||
query: DataNodeLogicProps['query']
|
||||
@ -21,6 +22,7 @@ export const errorTrackingDataLogic = kea<errorTrackingDataLogicType>([
|
||||
})),
|
||||
|
||||
actions({
|
||||
mergeGroups: (fingerprints: string[]) => ({ fingerprints }),
|
||||
assignGroup: (recordIndex: number, assigneeId: number | null) => ({
|
||||
recordIndex,
|
||||
assigneeId,
|
||||
@ -28,6 +30,30 @@ export const errorTrackingDataLogic = kea<errorTrackingDataLogicType>([
|
||||
}),
|
||||
|
||||
listeners(({ values, actions }) => ({
|
||||
mergeGroups: async ({ fingerprints }) => {
|
||||
const results = values.response?.results as ErrorTrackingGroup[]
|
||||
|
||||
const groups = results.filter((g) => fingerprints.includes(g.fingerprint))
|
||||
const primaryGroup = groups.shift()
|
||||
|
||||
if (primaryGroup && groups.length > 0) {
|
||||
const mergingFingerprints = groups.map((g) => g.fingerprint)
|
||||
const mergedGroup = mergeGroups(primaryGroup, groups)
|
||||
|
||||
// optimistically update local results
|
||||
actions.setResponse({
|
||||
...values.response,
|
||||
results: results
|
||||
// remove merged groups
|
||||
.filter((group) => !mergingFingerprints.includes(group.fingerprint))
|
||||
.map((group) =>
|
||||
// replace primary group
|
||||
mergedGroup.fingerprint === group.fingerprint ? mergedGroup : group
|
||||
),
|
||||
})
|
||||
await api.errorTracking.merge(primaryGroup?.fingerprint, mergingFingerprints)
|
||||
}
|
||||
},
|
||||
assignGroup: async ({ recordIndex, assigneeId }) => {
|
||||
const response = values.response
|
||||
if (response) {
|
||||
|
@ -1,4 +1,5 @@
|
||||
import { actions, connect, kea, path, reducers, selectors } from 'kea'
|
||||
import { subscriptions } from 'kea-subscriptions'
|
||||
|
||||
import { DataTableNode, ErrorTrackingQuery } from '~/queries/schema'
|
||||
|
||||
@ -15,6 +16,7 @@ export const errorTrackingSceneLogic = kea<errorTrackingSceneLogicType>([
|
||||
|
||||
actions({
|
||||
setOrder: (order: ErrorTrackingQuery['order']) => ({ order }),
|
||||
setSelectedRows: (selectedRows: string[]) => ({ selectedRows }),
|
||||
}),
|
||||
reducers({
|
||||
order: [
|
||||
@ -24,6 +26,12 @@ export const errorTrackingSceneLogic = kea<errorTrackingSceneLogicType>([
|
||||
setOrder: (_, { order }) => order,
|
||||
},
|
||||
],
|
||||
selectedRows: [
|
||||
[] as string[],
|
||||
{
|
||||
setSelectedRows: (_, { selectedRows }) => selectedRows,
|
||||
},
|
||||
],
|
||||
}),
|
||||
|
||||
selectors({
|
||||
@ -39,4 +47,8 @@ export const errorTrackingSceneLogic = kea<errorTrackingSceneLogicType>([
|
||||
}),
|
||||
],
|
||||
}),
|
||||
|
||||
subscriptions(({ actions }) => ({
|
||||
query: () => actions.setSelectedRows([]),
|
||||
})),
|
||||
])
|
||||
|
157
frontend/src/scenes/error-tracking/utils.test.ts
Normal file
@ -0,0 +1,157 @@
|
||||
import { ErrorTrackingGroup } from '~/queries/schema'
|
||||
|
||||
import { mergeGroups } from './utils'
|
||||
|
||||
describe('mergeGroups', () => {
|
||||
it('arbitrary values', async () => {
|
||||
const primaryGroup: ErrorTrackingGroup = {
|
||||
assignee: 400,
|
||||
description: 'This is the original description',
|
||||
fingerprint: 'Fingerprint',
|
||||
first_seen: '2024-07-22T13:15:07.074000Z',
|
||||
last_seen: '2024-07-20T13:15:50.186000Z',
|
||||
merged_fingerprints: ['ExistingFingerprint'],
|
||||
occurrences: 250,
|
||||
sessions: 100,
|
||||
status: 'active',
|
||||
users: 50,
|
||||
volume: [
|
||||
'__hx_tag',
|
||||
'Sparkline',
|
||||
'data',
|
||||
[10, 5, 10, 20, 50],
|
||||
'labels',
|
||||
[
|
||||
'25 Jun, 2024 00:00 (UTC)',
|
||||
'26 Jun, 2024 00:00 (UTC)',
|
||||
'27 Jun, 2024 00:00 (UTC)',
|
||||
'28 Jun, 2024 00:00 (UTC)',
|
||||
'29 Jun, 2024 00:00 (UTC)',
|
||||
],
|
||||
],
|
||||
}
|
||||
|
||||
const mergingGroups: ErrorTrackingGroup[] = [
|
||||
{
|
||||
assignee: 100,
|
||||
description: 'This is another description',
|
||||
fingerprint: 'Fingerprint2',
|
||||
first_seen: '2024-07-21T13:15:07.074000Z',
|
||||
last_seen: '2024-07-20T13:15:50.186000Z',
|
||||
merged_fingerprints: ['NestedFingerprint'],
|
||||
occurrences: 10,
|
||||
sessions: 5,
|
||||
status: 'active',
|
||||
users: 1,
|
||||
volume: [
|
||||
'__hx_tag',
|
||||
'Sparkline',
|
||||
'data',
|
||||
[1, 1, 2, 1, 2],
|
||||
'labels',
|
||||
[
|
||||
'25 Jun, 2024 00:00 (UTC)',
|
||||
'26 Jun, 2024 00:00 (UTC)',
|
||||
'27 Jun, 2024 00:00 (UTC)',
|
||||
'28 Jun, 2024 00:00 (UTC)',
|
||||
'29 Jun, 2024 00:00 (UTC)',
|
||||
],
|
||||
],
|
||||
},
|
||||
{
|
||||
assignee: 400,
|
||||
description: 'This is another description',
|
||||
fingerprint: 'Fingerprint3',
|
||||
first_seen: '2024-07-21T13:15:07.074000Z',
|
||||
last_seen: '2024-07-22T13:15:50.186000Z',
|
||||
merged_fingerprints: [],
|
||||
occurrences: 1,
|
||||
sessions: 1,
|
||||
status: 'active',
|
||||
users: 1,
|
||||
volume: [
|
||||
'__hx_tag',
|
||||
'Sparkline',
|
||||
'data',
|
||||
[5, 10, 2, 3, 5],
|
||||
'labels',
|
||||
[
|
||||
'25 Jun, 2024 00:00 (UTC)',
|
||||
'26 Jun, 2024 00:00 (UTC)',
|
||||
'27 Jun, 2024 00:00 (UTC)',
|
||||
'28 Jun, 2024 00:00 (UTC)',
|
||||
'29 Jun, 2024 00:00 (UTC)',
|
||||
],
|
||||
],
|
||||
},
|
||||
{
|
||||
assignee: null,
|
||||
description: 'This is another description',
|
||||
fingerprint: 'Fingerprint4',
|
||||
first_seen: '2023-07-22T13:15:07.074000Z',
|
||||
last_seen: '2024-07-22T13:15:50.186000Z',
|
||||
merged_fingerprints: [],
|
||||
occurrences: 1000,
|
||||
sessions: 500,
|
||||
status: 'active',
|
||||
users: 50,
|
||||
volume: [
|
||||
'__hx_tag',
|
||||
'Sparkline',
|
||||
'data',
|
||||
[10, 100, 200, 300, 700],
|
||||
'labels',
|
||||
[
|
||||
'25 Jun, 2024 00:00 (UTC)',
|
||||
'26 Jun, 2024 00:00 (UTC)',
|
||||
'27 Jun, 2024 00:00 (UTC)',
|
||||
'28 Jun, 2024 00:00 (UTC)',
|
||||
'29 Jun, 2024 00:00 (UTC)',
|
||||
],
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const mergedGroup = mergeGroups(primaryGroup, mergingGroups)
|
||||
|
||||
expect(mergedGroup).toEqual({
|
||||
// retains values from primary group
|
||||
assignee: 400,
|
||||
description: 'This is the original description',
|
||||
fingerprint: 'Fingerprint',
|
||||
status: 'active',
|
||||
// earliest first_seen
|
||||
first_seen: '2023-07-22T13:15:07.074Z',
|
||||
// latest last_seen
|
||||
last_seen: '2024-07-22T13:15:50.186Z',
|
||||
// retains previously merged_fingerprints
|
||||
// adds new fingerprints AND their nested fingerprints
|
||||
merged_fingerprints: [
|
||||
'ExistingFingerprint',
|
||||
'Fingerprint2',
|
||||
'NestedFingerprint',
|
||||
'Fingerprint3',
|
||||
'Fingerprint4',
|
||||
],
|
||||
// sums counts
|
||||
occurrences: 1261,
|
||||
sessions: 606,
|
||||
users: 102,
|
||||
// sums volumes
|
||||
volume: [
|
||||
'__hx_tag',
|
||||
'Sparkline',
|
||||
'data',
|
||||
[26, 116, 214, 324, 757],
|
||||
'labels',
|
||||
[
|
||||
'25 Jun, 2024 00:00 (UTC)',
|
||||
'26 Jun, 2024 00:00 (UTC)',
|
||||
'27 Jun, 2024 00:00 (UTC)',
|
||||
'28 Jun, 2024 00:00 (UTC)',
|
||||
'29 Jun, 2024 00:00 (UTC)',
|
||||
],
|
||||
],
|
||||
})
|
||||
})
|
||||
})
|
48
frontend/src/scenes/error-tracking/utils.ts
Normal file
@ -0,0 +1,48 @@
|
||||
import { dayjs } from 'lib/dayjs'
|
||||
|
||||
import { ErrorTrackingGroup } from '~/queries/schema'
|
||||
|
||||
export const mergeGroups = (
|
||||
primaryGroup: ErrorTrackingGroup,
|
||||
mergingGroups: ErrorTrackingGroup[]
|
||||
): ErrorTrackingGroup => {
|
||||
const mergingFingerprints = mergingGroups.flatMap((g) => [g.fingerprint, ...g.merged_fingerprints])
|
||||
|
||||
const mergedFingerprints = [...primaryGroup.merged_fingerprints]
|
||||
mergedFingerprints.push(...mergingFingerprints)
|
||||
|
||||
const sum = (value: 'occurrences' | 'users' | 'sessions'): number => {
|
||||
return mergingGroups.reduce((sum, g) => sum + g[value], primaryGroup[value])
|
||||
}
|
||||
|
||||
const [firstSeen, lastSeen] = mergingGroups.reduce(
|
||||
(res, g) => {
|
||||
const firstSeen = dayjs(g.first_seen)
|
||||
const lastSeen = dayjs(g.last_seen)
|
||||
return [res[0].isAfter(firstSeen) ? firstSeen : res[0], res[1].isBefore(lastSeen) ? lastSeen : res[1]]
|
||||
},
|
||||
[dayjs(primaryGroup.first_seen), dayjs(primaryGroup.last_seen)]
|
||||
)
|
||||
|
||||
const volume = primaryGroup.volume
|
||||
|
||||
if (volume) {
|
||||
const dataIndex = 3
|
||||
const data = mergingGroups.reduce(
|
||||
(sum: number[], g) => g.volume[dataIndex].map((num: number, idx: number) => num + sum[idx]),
|
||||
primaryGroup.volume[dataIndex]
|
||||
)
|
||||
volume.splice(dataIndex, 1, data)
|
||||
}
|
||||
|
||||
return {
|
||||
...primaryGroup,
|
||||
merged_fingerprints: mergedFingerprints,
|
||||
occurrences: sum('occurrences'),
|
||||
sessions: sum('sessions'),
|
||||
users: sum('users'),
|
||||
first_seen: firstSeen.toISOString(),
|
||||
last_seen: lastSeen.toISOString(),
|
||||
volume: volume,
|
||||
}
|
||||
}
|
@ -4,6 +4,8 @@ from rest_framework import serializers, viewsets
|
||||
from posthog.api.forbid_destroy_model import ForbidDestroyModel
|
||||
from posthog.api.routing import TeamAndOrgViewSetMixin
|
||||
from posthog.models.error_tracking import ErrorTrackingGroup
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.response import Response
|
||||
|
||||
|
||||
class ErrorTrackingGroupSerializer(serializers.ModelSerializer):
|
||||
@ -21,3 +23,10 @@ class ErrorTrackingGroupViewSet(TeamAndOrgViewSetMixin, ForbidDestroyModel, view
|
||||
fingerprint = self.kwargs["pk"]
|
||||
group, _ = queryset.get_or_create(fingerprint=fingerprint, team=self.team)
|
||||
return group
|
||||
|
||||
@action(methods=["POST"], detail=True)
|
||||
def merge(self, request, **kwargs):
|
||||
group: ErrorTrackingGroup = self.get_object()
|
||||
merging_fingerprints: list[str] = request.data.get("merging_fingerprints", [])
|
||||
group.merge(merging_fingerprints)
|
||||
return Response({"success": True})
|
||||
|
@ -37,3 +37,29 @@ class TestErrorTracking(APIBaseTest):
|
||||
)
|
||||
group.refresh_from_db()
|
||||
self.assertEqual(group.fingerprint, "CustomFingerprint")
|
||||
|
||||
def test_merging_of_an_existing_group(self):
|
||||
fingerprint = "CustomFingerprint"
|
||||
merging_fingerprints = ["NewFingerprint"]
|
||||
group = ErrorTrackingGroup.objects.create(fingerprint=fingerprint, team=self.team)
|
||||
|
||||
self.client.post(
|
||||
f"/api/projects/{self.team.id}/error_tracking/{fingerprint}/merge",
|
||||
data={"merging_fingerprints": merging_fingerprints},
|
||||
)
|
||||
|
||||
group.refresh_from_db()
|
||||
self.assertEqual(group.merged_fingerprints, merging_fingerprints)
|
||||
|
||||
def test_merging_when_no_group_exists(self):
|
||||
fingerprint = "CustomFingerprint"
|
||||
merging_fingerprints = ["NewFingerprint"]
|
||||
|
||||
self.assertEqual(ErrorTrackingGroup.objects.count(), 0)
|
||||
self.client.post(
|
||||
f"/api/projects/{self.team.id}/error_tracking/{fingerprint}/merge",
|
||||
data={"merging_fingerprints": merging_fingerprints},
|
||||
)
|
||||
self.assertEqual(ErrorTrackingGroup.objects.count(), 1)
|
||||
groups = ErrorTrackingGroup.objects.only("merged_fingerprints")
|
||||
self.assertEqual(groups[0].merged_fingerprints, merging_fingerprints)
|
||||
|
@ -36,17 +36,17 @@ class ErrorTrackingGroup(UUIDModel):
|
||||
return queryset.filter(query)
|
||||
|
||||
@transaction.atomic
|
||||
def merge(self, groups: list["ErrorTrackingGroup"]) -> None:
|
||||
if not groups:
|
||||
def merge(self, fingerprints: list[str]) -> None:
|
||||
if not fingerprints:
|
||||
return
|
||||
|
||||
merged_fingerprints = set(self.merged_fingerprints)
|
||||
for group in groups:
|
||||
fingerprints = [group.fingerprint, *group.merged_fingerprints]
|
||||
merged_fingerprints |= set(fingerprints)
|
||||
merged_fingerprints.update(fingerprints)
|
||||
|
||||
merging_groups = ErrorTrackingGroup.objects.filter(team=self.team, fingerprint__in=fingerprints)
|
||||
for group in merging_groups:
|
||||
merged_fingerprints |= set(group.merged_fingerprints)
|
||||
|
||||
merging_groups.delete()
|
||||
self.merged_fingerprints = list(merged_fingerprints)
|
||||
self.save()
|
||||
|
||||
for group in groups:
|
||||
group.delete()
|
||||
|
@ -22,18 +22,18 @@ class TestErrorTracking(BaseTest):
|
||||
)
|
||||
|
||||
matching_groups = ErrorTrackingGroup.objects.filter(fingerprint__in=["first_error", "second_error"])
|
||||
assert len(matching_groups) == 2
|
||||
assert matching_groups.count() == 2
|
||||
|
||||
matching_groups = ErrorTrackingGroup.objects.filter(merged_fingerprints__contains=["previously_merged"])
|
||||
assert len(matching_groups) == 1
|
||||
assert matching_groups.count() == 1
|
||||
|
||||
matching_groups = ErrorTrackingGroup.filter_fingerprints(
|
||||
queryset=ErrorTrackingGroup.objects, fingerprints=["first_error", "previously_merged"]
|
||||
)
|
||||
assert len(matching_groups) == 2
|
||||
assert matching_groups.count() == 2
|
||||
|
||||
def test_merge(self):
|
||||
root_group = ErrorTrackingGroup.objects.create(
|
||||
primary_group = ErrorTrackingGroup.objects.create(
|
||||
status="active",
|
||||
team=self.team,
|
||||
fingerprint="a_fingerprint",
|
||||
@ -49,14 +49,16 @@ class TestErrorTracking(BaseTest):
|
||||
merged_fingerprints=["merged_fingerprint"],
|
||||
)
|
||||
|
||||
root_group.merge([merge_group_1, merge_group_2])
|
||||
merging_fingerprints = [merge_group_1.fingerprint, merge_group_2.fingerprint, "no_group_fingerprint"]
|
||||
primary_group.merge(merging_fingerprints)
|
||||
|
||||
assert sorted(root_group.merged_fingerprints) == [
|
||||
assert sorted(primary_group.merged_fingerprints) == [
|
||||
"already_merged_fingerprint",
|
||||
"another_fingerprint",
|
||||
"merged_fingerprint",
|
||||
"new_fingerprint",
|
||||
"no_group_fingerprint",
|
||||
]
|
||||
|
||||
# deletes the old groups
|
||||
assert len(ErrorTrackingGroup.objects.filter(fingerprint="new_fingerprint")) == 0
|
||||
assert ErrorTrackingGroup.objects.count() == 1
|
||||
|