chore: remove user groups (#34296)

This commit is contained in:
David Newell
2025-07-01 11:44:40 +01:00
committed by GitHub
parent 0393254656
commit 400bbd8049
40 changed files with 61 additions and 734 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.4 KiB

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.3 KiB

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 156 KiB

After

Width:  |  Height:  |  Size: 148 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 156 KiB

After

Width:  |  Height:  |  Size: 148 KiB

View File

@@ -148,7 +148,6 @@ import {
SurveyStatsResponse,
TeamType,
UserBasicType,
UserGroup,
UserInterviewType,
UserType,
} from '~/types'
@@ -1080,23 +1079,6 @@ export class ApiRequest {
return this.projectsDetail(teamId).addPathComponent('uploaded_media')
}
// # UserGroups
public userGroups(teamId?: TeamType['id']): ApiRequest {
return this.projectsDetail(teamId).addPathComponent('user_groups')
}
public userGroup(id: UserGroup['id']): ApiRequest {
return this.userGroups().addPathComponent(id)
}
public userGroupAddMember(id: UserGroup['id']): ApiRequest {
return this.userGroup(id).addPathComponent('add')
}
public userGroupRemoveMember(id: UserGroup['id']): ApiRequest {
return this.userGroup(id).addPathComponent('remove')
}
// # Alerts
public alerts(alertId?: AlertType['id'], insightId?: InsightModel['id'], teamId?: TeamType['id']): ApiRequest {
if (alertId) {
@@ -2579,28 +2561,6 @@ const api = {
},
},
userGroups: {
async list(): Promise<{ results: UserGroup[] }> {
return await new ApiRequest().userGroups().get()
},
async delete(id: UserGroup['id']): Promise<void> {
return await new ApiRequest().userGroup(id).delete()
},
async create(name: UserGroup['name']): Promise<UserGroup> {
return await new ApiRequest().userGroups().create({ data: { name } })
},
async addMember(id: UserGroup['id'], userId: UserBasicType['id']): Promise<UserGroup> {
return await new ApiRequest().userGroupAddMember(id).create({ data: { userId } })
},
async removeMember(id: UserGroup['id'], userId: UserBasicType['id']): Promise<UserGroup> {
return await new ApiRequest().userGroupRemoveMember(id).create({ data: { userId } })
},
},
recordings: {
async list(params: RecordingsQuery): Promise<RecordingsQueryResponse> {
return await new ApiRequest().recordings().withQueryString(toParams(params)).get()

View File

@@ -265,7 +265,6 @@ export const FEATURE_FLAGS = {
USER_INTERVIEWS: 'user-interviews', // owner: @Twixes @jurajmajerik
LOGS: 'logs', // owner: @david @frank @olly @ross
CSP_REPORTING: 'mexicspo', // owner @pauldambra @lricoy @robbiec
USER_GROUPS_ENABLED: 'user-groups-enabled', // owner: #team-error-tracking
LLM_OBSERVABILITY_PLAYGROUND: 'llm-observability-playground', // owner: #team-llm-observability @peter-k
USAGE_SPEND_DASHBOARDS: 'usage-spend-dashboards', // owner: @pawel-cebula #team-billing
CDP_HOG_SOURCES: 'cdp-hog-sources', // owner #team-cdp

View File

@@ -205,7 +205,6 @@ export const defaultMocks: Mocks = {
'api/projects/:team_id/surveys': EMPTY_PAGINATED_RESPONSE,
'api/projects/:team_id/surveys/responses_count': {},
'api/environments/:team_id/integrations': EMPTY_PAGINATED_RESPONSE,
'api/projects/:team_id/user_groups': EMPTY_PAGINATED_RESPONSE,
'api/environments/:team_id/error_tracking/assignment_rules': EMPTY_PAGINATED_RESPONSE,
'api/environments/:team_id/error_tracking/grouping_rules': EMPTY_PAGINATED_RESPONSE,
'api/environments/:team_id/error_tracking/suppression_rules': EMPTY_PAGINATED_RESPONSE,

View File

@@ -9071,7 +9071,7 @@
]
},
"type": {
"enum": ["user_group", "user", "role"],
"enum": ["user", "role"],
"type": "string"
}
},

View File

@@ -2010,7 +2010,7 @@ export interface ErrorTrackingQuery extends DataNode<ErrorTrackingQueryResponse>
}
export interface ErrorTrackingIssueAssignee {
type: 'user_group' | 'user' | 'role'
type: 'user' | 'role'
id: integer | string
}

View File

@@ -66,7 +66,6 @@ import {
WebSnippet,
} from './environment/TeamSettings'
import { ProjectAccountFiltersSetting } from './environment/TestAccountFiltersConfig'
import { UserGroups } from './environment/UserGroups'
import { WebhookIntegration } from './environment/WebhookIntegration'
import { Invites } from './organization/Invites'
import { Members } from './organization/Members'
@@ -407,13 +406,6 @@ export const SETTINGS_MAP: SettingSection[] = [
title: 'Exception autocapture',
component: <ExceptionAutocaptureSettings />,
},
{
id: 'error-tracking-user-groups',
title: 'User groups',
description: 'Allow collections of users to be assigned to issues',
component: <UserGroups />,
flag: 'USER_GROUPS_ENABLED',
},
{
id: 'error-tracking-alerting',
title: 'Alerting',

View File

@@ -1,143 +0,0 @@
import { IconEllipsis, IconMinus } from '@posthog/icons'
import {
LemonButton,
LemonMenu,
LemonTable,
LemonTableColumns,
ProfileBubbles,
ProfilePicture,
} from '@posthog/lemon-ui'
import { useActions, useValues } from 'kea'
import { MemberSelect } from 'lib/components/MemberSelect'
import { useEffect } from 'react'
import { UserBasicType, UserGroup } from '~/types'
import { userGroupsLogic } from './userGroupsLogic'
export const UserGroups = (): JSX.Element => {
const { userGroups, userGroupsLoading } = useValues(userGroupsLogic)
const { ensureAllGroupsLoaded, openGroupCreationForm } = useActions(userGroupsLogic)
useEffect(() => {
ensureAllGroupsLoaded()
}, [ensureAllGroupsLoaded])
const columns: LemonTableColumns<UserGroup> = [
{
title: 'Group',
dataIndex: 'name',
key: 'name',
width: 0,
className: 'whitespace-nowrap font-semibold',
},
{
title: 'Members',
key: 'members',
dataIndex: 'members',
render: (_, { members }) => {
return members && members.length ? (
<ProfileBubbles
people={members.map((user) => ({
email: user.email,
name: user.first_name,
title: `${user.first_name} <${user.email}>`,
}))}
/>
) : (
'No members'
)
},
},
{
key: 'actions',
render: (_, item: UserGroup) => <Actions group={item} />,
},
]
return (
<div className="deprecated-space-y-2">
<LemonTable
size="small"
dataSource={userGroups}
loading={userGroupsLoading}
columns={columns}
expandable={{
noIndent: true,
rowExpandable: (record) => record.members.length > 0,
expandedRowRender: (record) => <MembersTable groupId={record.id} members={record.members} />,
}}
/>
<LemonButton onClick={openGroupCreationForm} size="small" type="primary">
Create group
</LemonButton>
</div>
)
}
const Actions = ({ group }: { group: UserGroup }): JSX.Element => {
const { addMember, deleteUserGroup } = useActions(userGroupsLogic)
return (
<div className="flex flex-row justify-end deprecated-space-x-2">
<LemonMenu
items={[
{
label: 'Delete',
status: 'danger',
onClick: () => deleteUserGroup(group.id),
},
]}
>
<LemonButton icon={<IconEllipsis />} size="xsmall" />
</LemonMenu>
<MemberSelect
excludedMembers={group.members.map((m) => m.id)}
onChange={(user) => {
if (user) {
addMember({ id: group.id, user: user })
}
}}
value={null}
allowNone={false}
defaultLabel="Add member"
type="primary"
size="xsmall"
/>
</div>
)
}
const MembersTable = ({ groupId, members }: { groupId: UserGroup['id']; members: UserBasicType[] }): JSX.Element => {
const { removeMember } = useActions(userGroupsLogic)
const columns: LemonTableColumns<UserBasicType> = [
{
title: 'Members',
key: 'name',
render: function ProfilePictureRender(_, member) {
return <ProfilePicture user={member} showName />
},
},
{
key: 'actions',
render: (_, item: UserBasicType) => {
return (
<div className="flex flex-row justify-end">
<LemonButton
onClick={() => removeMember({ id: groupId, user: item })}
icon={<IconMinus />}
type="secondary"
status="danger"
size="xsmall"
>
Remove
</LemonButton>
</div>
)
},
},
]
return <LemonTable size="small" showHeader={false} dataSource={members} columns={columns} embedded />
}

View File

@@ -1,102 +0,0 @@
import { LemonDialog, LemonInput } from '@posthog/lemon-ui'
import Fuse from 'fuse.js'
import { actions, kea, listeners, path, reducers, selectors } from 'kea'
import { loaders } from 'kea-loaders'
import api from 'lib/api'
import { LemonField } from 'lib/lemon-ui/LemonField'
import { UserBasicType, UserGroup } from '~/types'
import type { userGroupsLogicType } from './userGroupsLogicType'
export interface UserGroupsFuse extends Fuse<UserGroup> {}
export const userGroupsLogic = kea<userGroupsLogicType>([
path(['scenes', 'settings', 'environment', 'userGroupsLogic']),
actions({
ensureAllGroupsLoaded: true,
openGroupCreationForm: true,
setSearch: (search) => ({ search }),
}),
reducers({
search: ['', { setSearch: (_, { search }) => search }],
}),
loaders(({ values }) => ({
userGroups: [
[] as UserGroup[],
{
loadUserGroups: async () => {
const response = await api.userGroups.list()
return response.results
},
deleteUserGroup: async (id: string) => {
await api.userGroups.delete(id)
const newValues = [...values.userGroups]
return newValues.filter((v) => v.id !== id)
},
createUserGroup: async (name: string) => {
const response = await api.userGroups.create(name)
return [...values.userGroups, response]
},
addMember: async ({ id, user }: { id: string; user: UserBasicType }) => {
const group = values.userGroups.find((g) => g.id === id)
if (group) {
await api.userGroups.addMember(id, user.id)
group.members = [...group.members, user]
return values.userGroups.map((g) => (g.id === id ? group : g))
}
return values.userGroups
},
removeMember: async ({ id, user }: { id: string; user: UserBasicType }) => {
const group = values.userGroups.find((g) => g.id === id)
if (group) {
await api.userGroups.removeMember(id, user.id)
group.members = group.members.filter((m) => m.id !== user.id)
return values.userGroups.map((g) => (g.id === id ? group : g))
}
return values.userGroups
},
},
],
})),
selectors({
userGroupsFuse: [
(s) => [s.userGroups],
(userGroups): UserGroupsFuse => new Fuse<UserGroup>(userGroups, { keys: ['name'], threshold: 0.3 }),
],
filteredGroups: [
(s) => [s.userGroups, s.userGroupsFuse, s.search],
(userGroups, userGroupsFuse, search): UserGroup[] =>
search ? userGroupsFuse.search(search).map((result) => result.item) : userGroups ?? [],
],
}),
listeners(({ values, actions }) => ({
openGroupCreationForm: () => {
LemonDialog.openForm({
title: 'Create user group',
initialValues: { name: '' },
content: (
<LemonField name="name">
<LemonInput placeholder="Name" autoFocus />
</LemonField>
),
errors: { name: (name) => (!name ? 'You must enter a name' : undefined) },
onSubmit: ({ name }) => actions.createUserGroup(name),
})
},
ensureAllGroupsLoaded: () => {
if (values.userGroupsLoading) {
return
}
if (values.userGroups.length === 0) {
actions.loadUserGroups()
}
},
})),
])

View File

@@ -5499,12 +5499,6 @@ export enum UserRole {
Other = 'other',
}
export type UserGroup = {
id: string
name: string
members: UserBasicType[]
}
export interface CoreMemory {
id: string
text: string

View File

@@ -75,7 +75,6 @@ from . import (
team,
uploaded_media,
user,
user_group,
external_web_analytics,
web_vitals,
)
@@ -637,13 +636,6 @@ environments_router.register(
["team_id"],
)
projects_router.register(
r"user_groups",
user_group.UserGroupViewSet,
"project_user_groups",
["team_id"],
)
projects_router.register(
r"comments",
comments.CommentViewSet,

View File

@@ -61,10 +61,10 @@ class ErrorTrackingIssueAssignmentSerializer(serializers.ModelSerializer):
fields = ["id", "type"]
def get_id(self, obj):
return obj.user_id or obj.user_group_id or obj.role_id
return obj.user_id or obj.role_id
def get_type(self, obj):
return "user_group" if obj.user_group else "role" if obj.role else "user"
return "role" if obj.role else "user"
class ErrorTrackingIssueSerializer(serializers.ModelSerializer):
@@ -276,7 +276,6 @@ def assign_issue(issue: ErrorTrackingIssue, assignee, organization, user, team_i
issue_id=issue.id,
defaults={
"user_id": None if assignee["type"] != "user" else assignee["id"],
"user_group_id": None if assignee["type"] != "user_group" else assignee["id"],
"role_id": None if assignee["type"] != "role" else assignee["id"],
},
)
@@ -574,8 +573,6 @@ class ErrorTrackingAssignmentRuleSerializer(serializers.ModelSerializer):
def get_assignee(self, obj):
if obj.user_id:
return {"type": "user", "id": obj.user_id}
elif obj.user_group_id:
return {"type": "user_group", "id": obj.user_group_id}
elif obj.role_id:
return {"type": "role", "id": obj.role_id}
return None
@@ -601,7 +598,6 @@ class ErrorTrackingAssignmentRuleViewSet(TeamAndOrgViewSetMixin, viewsets.ModelV
if assignee:
assignment_rule.user_id = None if assignee["type"] != "user" else assignee["id"]
assignment_rule.user_group_id = None if assignee["type"] != "user_group" else assignee["id"]
assignment_rule.role_id = None if assignee["type"] != "role" else assignee["id"]
assignment_rule.save()
@@ -627,7 +623,6 @@ class ErrorTrackingAssignmentRuleViewSet(TeamAndOrgViewSetMixin, viewsets.ModelV
bytecode=bytecode,
order_key=0,
user_id=None if assignee["type"] != "user" else assignee["id"],
user_group_id=None if assignee["type"] != "user_group" else assignee["id"],
role_id=None if assignee["type"] != "role" else assignee["id"],
)
@@ -646,8 +641,6 @@ class ErrorTrackingGroupingRuleSerializer(serializers.ModelSerializer):
def get_assignee(self, obj):
if obj.user_id:
return {"type": "user", "id": obj.user_id}
elif obj.user_group_id:
return {"type": "user_group", "id": obj.user_group_id}
elif obj.role_id:
return {"type": "role", "id": obj.role_id}
return None
@@ -674,7 +667,6 @@ class ErrorTrackingGroupingRuleViewSet(TeamAndOrgViewSetMixin, viewsets.ModelVie
if assignee:
grouping_rule.user_id = None if assignee["type"] != "user" else assignee["id"]
grouping_rule.user_group_id = None if assignee["type"] != "user_group" else assignee["id"]
grouping_rule.role_id = None if assignee["type"] != "role" else assignee["id"]
if description:
@@ -701,7 +693,6 @@ class ErrorTrackingGroupingRuleViewSet(TeamAndOrgViewSetMixin, viewsets.ModelVie
bytecode=bytecode,
order_key=0,
user_id=None if (not assignee or assignee["type"] != "user") else assignee["id"],
user_group_id=None if (not assignee or assignee["type"] != "user_group") else assignee["id"],
role_id=None if (not assignee or assignee["type"] != "role") else assignee["id"],
description=description,
)

View File

@@ -1,31 +0,0 @@
from posthog.test.base import APIBaseTest
from posthog.models import UserGroup, UserGroupMembership
class TestUserGroup(APIBaseTest):
def test_group_creation(self):
self.client.post(
f"/api/projects/{self.team.id}/user_groups",
data={"name": "My team"},
)
user_group = UserGroup.objects.get(team=self.team)
self.assertIsNotNone(user_group)
self.assertEqual(user_group.name, "My team")
def test_add_group_members(self):
user_group = UserGroup.objects.create(team=self.team)
self.client.post(
f"/api/projects/{self.team.id}/user_groups/{user_group.id}/add",
data={"userId": self.user.id},
)
self.assertEqual(user_group.members.count(), 1)
def test_remove_group_members(self):
user_group = UserGroup.objects.create(team=self.team)
UserGroupMembership.objects.create(group=user_group, user=self.user)
self.assertEqual(user_group.members.count(), 1)
self.client.post(
f"/api/projects/{self.team.id}/user_groups/{user_group.id}/remove",
data={"userId": self.user.id},
)
self.assertEqual(user_group.members.count(), 0)

View File

@@ -1,42 +0,0 @@
from rest_framework import serializers, viewsets, status
from rest_framework.response import Response
from posthog.api.shared import UserBasicSerializer
from posthog.api.routing import TeamAndOrgViewSetMixin
from posthog.api.utils import action
from posthog.models.user_group import UserGroup, UserGroupMembership
class UserGroupSerializer(serializers.ModelSerializer):
members = UserBasicSerializer(many=True, read_only=True)
class Meta:
model = UserGroup
fields = ["id", "name", "members"]
def create(self, validated_data: dict, *args, **kwargs) -> UserGroup:
return UserGroup.objects.create(
team=self.context["get_team"](),
**validated_data,
)
class UserGroupViewSet(TeamAndOrgViewSetMixin, viewsets.ModelViewSet):
scope_object = "INTERNAL"
queryset = UserGroup.objects.all()
serializer_class = UserGroupSerializer
def safely_get_queryset(self, queryset):
return queryset.filter(team=self.team)
@action(methods=["POST"], detail=True)
def add(self, request, **kwargs):
group = self.get_object()
UserGroupMembership.objects.get_or_create(group=group, user_id=request.data["userId"])
return Response(status=status.HTTP_204_NO_CONTENT)
@action(methods=["POST"], detail=True)
def remove(self, request, **kwargs):
group = self.get_object()
UserGroupMembership.objects.filter(group=group, user_id=request.data["userId"]).delete()
return Response(status=status.HTTP_204_NO_CONTENT)

View File

@@ -1,42 +0,0 @@
from django.core.management.base import BaseCommand
from posthog.models.user_group import UserGroup
from ee.models.rbac.role import Role, RoleMembership
from posthog.models.team import Team
from posthog.models.error_tracking import (
ErrorTrackingIssueAssignment,
ErrorTrackingAssignmentRule,
ErrorTrackingGroupingRule,
)
class Command(BaseCommand):
help = "Migrate all user groups to be roles instead"
def add_arguments(self, parser):
parser.add_argument("--team-id", default=None, type=int, help="Team ID to migrate")
def handle(self, *args, **options):
team_id = options["team_id"]
if team_id:
team_ids = [team_id]
else:
team_ids = list(UserGroup.objects.values_list("team_id", flat=True).distinct())
for id in team_ids:
team = Team.objects.get(id=id)
user_groups = UserGroup.objects.filter(team=team)
for user_group in user_groups:
# create roles for each user group
(role, _) = Role.objects.get_or_create(organization=team.organization, name=user_group.name)
# create memberships for each user
members = user_group.members.all()
memberships = [RoleMembership(user=user, role=role) for user in members]
RoleMembership.objects.bulk_create(memberships, ignore_conflicts=True)
# update references in error tracking models from user_group_id to role_id
ErrorTrackingIssueAssignment.objects.filter(user_group=user_group).update(role=role, user_group=None)
ErrorTrackingAssignmentRule.objects.filter(user_group=user_group).update(role=role, user_group=None)
ErrorTrackingGroupingRule.objects.filter(user_group=user_group).update(role=role, user_group=None)

View File

@@ -1,73 +0,0 @@
from posthog.models.user_group import UserGroup, UserGroupMembership
from ee.models.rbac.role import Role, RoleMembership
from django.core.management import call_command
from posthog.test.base import BaseTest
from posthog.models.error_tracking import (
ErrorTrackingIssue,
ErrorTrackingIssueAssignment,
ErrorTrackingAssignmentRule,
ErrorTrackingGroupingRule,
)
class TestMigrateUserGroupsToRoles(BaseTest):
def test_migrates_group_and_members_to_roles(self):
user_group = UserGroup.objects.create(team=self.team, name="Test group")
UserGroupMembership.objects.create(group=user_group, user=self.user)
issue = ErrorTrackingIssue.objects.create(team=self.team)
issue_assignment = ErrorTrackingIssueAssignment.objects.create(issue=issue, user_group=user_group)
assignment_rule = ErrorTrackingAssignmentRule.objects.create(
team=self.team, user_group=user_group, order_key=0, bytecode={}, filters={}
)
grouping_rule = ErrorTrackingGroupingRule.objects.create(
team=self.team, user_group=user_group, order_key=0, bytecode={}, filters={}
)
assert Role.objects.count() == 0
call_command(
"migrate_user_groups_to_roles",
f"--team-id={str(self.team.pk)}",
)
# creates role
assert Role.objects.count() == 1
role = Role.objects.first()
assert role is not None
assert role.name == user_group.name
# updates member
assert len(role.members.all()) == 1
member = role.members.first()
assert member is not None
assert member == self.user
# update error tracking rules
issue_assignment.refresh_from_db()
assignment_rule.refresh_from_db()
grouping_rule.refresh_from_db()
assert issue_assignment.user_group is None
assert assignment_rule.user_group is None
assert grouping_rule.user_group is None
assert issue_assignment.role == role
assert assignment_rule.role == role
assert grouping_rule.role == role
def test_role_of_same_name_exists(self):
user_group = UserGroup.objects.create(team=self.team, name="Test group")
role = Role.objects.create(organization=self.team.organization, name=user_group.name)
RoleMembership.objects.create(role=role, user=self.user)
assert Role.objects.count() == 1
assert RoleMembership.objects.count() == 1
call_command(
"migrate_user_groups_to_roles",
f"--team-id={str(self.team.pk)}",
)
assert Role.objects.count() == 1
assert RoleMembership.objects.count() == 1

View File

@@ -2,6 +2,7 @@ from django.db import models, transaction
from django.contrib.postgres.fields import ArrayField
from django.conf import settings
from rest_framework.exceptions import ValidationError
from django_deprecate_fields import deprecate_field
from posthog.models.utils import UUIDModel
from ee.models.rbac.role import Role
@@ -86,7 +87,8 @@ class ErrorTrackingExternalReference(UUIDModel):
class ErrorTrackingIssueAssignment(UUIDModel):
issue = models.OneToOneField(ErrorTrackingIssue, on_delete=models.CASCADE, related_name="assignment")
user = models.ForeignKey(User, null=True, on_delete=models.CASCADE)
user_group = models.ForeignKey(UserGroup, null=True, on_delete=models.CASCADE)
# DEPRECATED: issues can only be assigned to users or roles
user_group = deprecate_field(models.ForeignKey(UserGroup, null=True, on_delete=models.CASCADE))
role = models.ForeignKey(Role, null=True, on_delete=models.CASCADE)
created_at = models.DateTimeField(auto_now_add=True)
@@ -178,7 +180,8 @@ class ErrorTrackingSymbolSet(UUIDModel):
class ErrorTrackingAssignmentRule(UUIDModel):
team = models.ForeignKey(Team, on_delete=models.CASCADE)
user = models.ForeignKey(User, null=True, on_delete=models.CASCADE)
user_group = models.ForeignKey(UserGroup, null=True, on_delete=models.CASCADE)
# DEPRECATED: issues can only be assigned to users or roles
user_group = deprecate_field(models.ForeignKey(UserGroup, null=True, on_delete=models.CASCADE))
role = models.ForeignKey(Role, null=True, on_delete=models.CASCADE)
order_key = models.IntegerField(null=False, blank=False)
bytecode = models.JSONField(null=False, blank=False) # The bytecode of the rule
@@ -224,7 +227,8 @@ class ErrorTrackingGroupingRule(UUIDModel):
# in favour of the assignment of the grouping rule. Notably this differs from assignment rules
# in so far as we permit all of these to be null
user = models.ForeignKey(User, null=True, on_delete=models.CASCADE)
user_group = models.ForeignKey(UserGroup, null=True, on_delete=models.CASCADE)
# DEPRECATED: issues can only be assigned to users or roles
user_group = deprecate_field(models.ForeignKey(UserGroup, null=True, on_delete=models.CASCADE))
role = models.ForeignKey(Role, null=True, on_delete=models.CASCADE)
# Users will probably find it convenient to be able to add a short description to grouping rules

View File

@@ -1,21 +0,0 @@
import pytest
from django.db.utils import IntegrityError
from posthog.test.base import BaseTest
from posthog.models.user_group import UserGroup, UserGroupMembership
class TestUserGroup(BaseTest):
def test_user_group_membership_cascade_deletes(self):
group = UserGroup.objects.create(team=self.team)
UserGroupMembership.objects.create(group=group, user=self.user)
assert UserGroupMembership.objects.count() == 1
group.delete()
assert UserGroupMembership.objects.count() == 0
def test_user_group_membership_uniqueness(self):
group = UserGroup.objects.create(team=self.team)
with pytest.raises(IntegrityError):
UserGroupMembership.objects.create(group=group, user=self.user)
UserGroupMembership.objects.create(group=group, user=self.user)

View File

@@ -4,6 +4,10 @@ from posthog.models.team import Team
from posthog.models.user import User
# !!! DEPRECATED !!!
# Please use the ee.Role model instead
class UserGroup(UUIDModel):
team = models.ForeignKey(Team, on_delete=models.CASCADE, related_name="user_groups")
name = models.TextField()

View File

@@ -958,7 +958,6 @@ class ErrorTrackingIssueAggregations(BaseModel):
class Type2(StrEnum):
USER_GROUP = "user_group"
USER = "user"
ROLE = "role"

View File

@@ -461,13 +461,6 @@ def send_error_tracking_issue_assigned(assignment: ErrorTrackingIssueAssignment,
for membership in memberships_to_email
if (membership.user == assignment.user and membership.user != assigner)
]
elif assignment.user_group:
group_users = assignment.user_group.members.all()
memberships_to_email = [
membership
for membership in memberships_to_email
if (membership.user in group_users and membership.user != assigner)
]
elif assignment.role:
role_users = assignment.role.members.all()
memberships_to_email = [

View File

@@ -63,11 +63,14 @@ export const GroupDisplays = ({ sizes }: SizedComponentProps): JSX.Element => {
size={size}
assignee={{
id: '123',
type: 'group',
group: {
type: 'role',
role: {
id: '123',
name: 'Group Name',
name: 'Role Name',
feature_flags_access_level: 37,
members: [],
created_at: '2021-08-02 12:34:56',
created_by: null,
},
}}
/>

View File

@@ -49,15 +49,6 @@ function getIconClassname(size: 'xsmall' | 'small' | 'medium' | 'large' = 'mediu
export const AssigneeIconDisplay = ({ assignee, size }: AssigneeIconDisplayProps): JSX.Element => {
return match(assignee)
.with({ type: 'group' }, ({ group }) => (
// The ideal way would be to use a Lettermark component here
// but there is no way to make it consistent with ProfilePicture at the moment
// TODO: Make sure the size prop are the same between ProfilePicture and Lettermark
<ProfilePicture
user={{ first_name: group.name, last_name: undefined, email: undefined }}
className={getIconClassname(size)}
/>
))
.with({ type: 'role' }, ({ role }) => (
// The ideal way would be to use a Lettermark component here
// but there is no way to make it consistent with ProfilePicture at the moment
@@ -99,7 +90,6 @@ export const AssigneeLabelDisplay = ({
})}
>
{match(assignee)
.with({ type: 'group' }, ({ group }) => group.name)
.with({ type: 'role' }, ({ role }) => role.name)
.with({ type: 'user' }, ({ user }) => fullName(user))
.otherwise(() => placeholder || 'Unassigned')}

View File

@@ -1,7 +1,6 @@
import { IconPlusSmall, IconX } from '@posthog/icons'
import { LemonButton, LemonInput } from '@posthog/lemon-ui'
import { useActions, useValues } from 'kea'
import { useFeatureFlag } from 'lib/hooks/useFeatureFlag'
import { urls } from 'scenes/urls'
import { ErrorTrackingIssue, ErrorTrackingIssueAssignee } from '~/queries/schema/schema-general'
@@ -15,10 +14,8 @@ export interface AssigneeDropdownProps {
}
export function AssigneeDropdown({ assignee, onChange }: AssigneeDropdownProps): JSX.Element {
const { search, filteredGroups, filteredRoles, filteredMembers, userGroupsLoading, rolesLoading, membersLoading } =
useValues(assigneeSelectLogic)
const { search, filteredRoles, filteredMembers, rolesLoading, membersLoading } = useValues(assigneeSelectLogic)
const { setSearch } = useActions(assigneeSelectLogic)
const userGroupsEnabled = useFeatureFlag('USER_GROUPS_ENABLED')
return (
<div className="max-w-100 deprecated-space-y-2 overflow-hidden">
@@ -62,32 +59,6 @@ export function AssigneeDropdown({ assignee, onChange }: AssigneeDropdownProps):
}
/>
{userGroupsEnabled && (
<Section
title="Groups"
loading={userGroupsLoading}
search={!!search}
type="user_group"
items={filteredGroups.map((group) => ({
id: group.id,
type: 'group',
group: group,
}))}
onSelect={onChange}
activeId={assignee?.id}
emptyState={
<LemonButton
fullWidth
size="small"
icon={<IconPlusSmall />}
to={urls.settings('environment-error-tracking', 'user-groups')}
>
<div className="text-secondary">Create user group</div>
</LemonButton>
}
/>
)}
<Section
title="Users"
loading={membersLoading}

View File

@@ -1,11 +1,10 @@
import Fuse from 'fuse.js'
import { actions, connect, kea, listeners, path, props, reducers, selectors } from 'kea'
import { membersLogic } from 'scenes/organization/membersLogic'
import { userGroupsLogic } from 'scenes/settings/environment/userGroupsLogic'
import { rolesLogic } from 'scenes/settings/organization/Permissions/Roles/rolesLogic'
import { ErrorTrackingIssue } from '~/queries/schema/schema-general'
import type { OrganizationMemberType, RoleType, UserGroup } from '~/types'
import type { OrganizationMemberType, RoleType } from '~/types'
import type { assigneeSelectLogicType } from './assigneeSelectLogicType'
@@ -19,19 +18,13 @@ export type UserAssignee = {
user: OrganizationMemberType['user']
}
export type GroupAssignee = {
id: string
type: 'group'
group: UserGroup
}
export type RoleAssignee = {
id: string
type: 'role'
role: RoleType
}
export type Assignee = UserAssignee | GroupAssignee | RoleAssignee | null
export type Assignee = UserAssignee | RoleAssignee | null
export interface RolesFuse extends Fuse<RoleType> {}
@@ -43,17 +36,10 @@ export const assigneeSelectLogic = kea<assigneeSelectLogicType>([
values: [
membersLogic,
['meFirstMembers', 'filteredMembers', 'membersLoading'],
userGroupsLogic,
['userGroups', 'filteredGroups', 'userGroupsLoading'],
rolesLogic,
['roles', 'rolesLoading'],
],
actions: [
membersLogic,
['setSearch as setMembersSearch', 'ensureAllMembersLoaded'],
userGroupsLogic,
['setSearch as setGroupsSearch', 'ensureAllGroupsLoaded'],
],
actions: [membersLogic, ['setSearch as setMembersSearch', 'ensureAllMembersLoaded']],
})),
actions({
@@ -67,20 +53,17 @@ export const assigneeSelectLogic = kea<assigneeSelectLogicType>([
listeners(({ values, actions }) => ({
setSearch: () => {
actions.setGroupsSearch(values.search)
actions.setMembersSearch(values.search)
},
ensureAssigneeTypesLoaded: () => {
actions.ensureAllGroupsLoaded()
actions.ensureAllMembersLoaded()
},
})),
selectors({
loading: [
(s) => [s.membersLoading, s.userGroupsLoading, s.rolesLoading],
(membersLoading, userGroupsLoading, rolesLoading): boolean =>
membersLoading || userGroupsLoading || rolesLoading,
(s) => [s.membersLoading, s.rolesLoading],
(membersLoading, rolesLoading): boolean => membersLoading || rolesLoading,
],
rolesFuse: [
@@ -94,20 +77,11 @@ export const assigneeSelectLogic = kea<assigneeSelectLogicType>([
],
resolveAssignee: [
(s) => [s.userGroups, s.roles, s.meFirstMembers],
(groups, roles, members): ((assignee: ErrorTrackingIssue['assignee']) => Assignee) => {
(s) => [s.roles, s.meFirstMembers],
(roles, members): ((assignee: ErrorTrackingIssue['assignee']) => Assignee) => {
return (assignee: ErrorTrackingIssue['assignee']) => {
if (assignee) {
if (assignee.type === 'user_group') {
const assignedGroup = groups.find((group) => group.id === assignee.id)
return assignedGroup
? {
id: assignedGroup.id,
type: 'group',
group: assignedGroup,
}
: null
} else if (assignee.type === 'role') {
if (assignee.type === 'role') {
const assignedRole = roles.find((role) => role.id === assignee.id)
return assignedRole
? {

View File

@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT id, issue_id, user_id, user_group_id, role_id, created_at FROM posthog_errortrackingissueassignment\n WHERE issue_id = $1\n ",
"query": "\n SELECT id, issue_id, user_id, role_id, created_at FROM posthog_errortrackingissueassignment\n WHERE issue_id = $1\n ",
"describe": {
"columns": [
{
@@ -20,16 +20,11 @@
},
{
"ordinal": 3,
"name": "user_group_id",
"type_info": "Uuid"
},
{
"ordinal": 4,
"name": "role_id",
"type_info": "Uuid"
},
{
"ordinal": 5,
"ordinal": 4,
"name": "created_at",
"type_info": "Timestamptz"
}
@@ -44,9 +39,8 @@
false,
true,
true,
true,
false
]
},
"hash": "cbbb85032e24157ba28500031432cd7344c486957d39f6fa47b4dad80ae0f626"
"hash": "1b96d742b758d18f8722dbcec8783657b78776f03bdaf32104ce7d78cc762790"
}

View File

@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "\n INSERT INTO posthog_errortrackingissueassignment (id, issue_id, user_id, user_group_id, role_id, created_at)\n VALUES ($1, $2, $3, $4, $5, NOW())\n ON CONFLICT (issue_id) DO UPDATE SET issue_id = $2 -- no-op to get a returned row\n RETURNING id, issue_id, user_id, user_group_id, role_id, created_at\n ",
"query": "\n INSERT INTO posthog_errortrackingissueassignment (id, issue_id, user_id, role_id, created_at)\n VALUES ($1, $2, $3, $4, NOW())\n ON CONFLICT (issue_id) DO UPDATE SET issue_id = $2 -- no-op to get a returned row\n RETURNING id, issue_id, user_id, role_id, created_at\n ",
"describe": {
"columns": [
{
@@ -20,16 +20,11 @@
},
{
"ordinal": 3,
"name": "user_group_id",
"type_info": "Uuid"
},
{
"ordinal": 4,
"name": "role_id",
"type_info": "Uuid"
},
{
"ordinal": 5,
"ordinal": 4,
"name": "created_at",
"type_info": "Timestamptz"
}
@@ -39,7 +34,6 @@
"Uuid",
"Uuid",
"Int4",
"Uuid",
"Uuid"
]
},
@@ -48,9 +42,8 @@
false,
true,
true,
true,
false
]
},
"hash": "caf8d15152f706524e4d7e55e9b64fc604838f0b1054c8f3b5a39751ecf5014e"
"hash": "40d9604a9c7d7f3e3fc71211ac44dff2413bc92a43ae9d3c7f6ae92c5a386f18"
}

View File

@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT id, team_id, user_id, user_group_id, role_id, order_key, bytecode, created_at, updated_at\n FROM posthog_errortrackinggroupingrule\n WHERE team_id = $1 AND disabled_data IS NULL\n ",
"query": "\n SELECT id, team_id, user_id, role_id, order_key, bytecode, created_at, updated_at\n FROM posthog_errortrackingassignmentrule\n WHERE team_id = $1 AND disabled_data IS NULL\n ",
"describe": {
"columns": [
{
@@ -20,31 +20,26 @@
},
{
"ordinal": 3,
"name": "user_group_id",
"type_info": "Uuid"
},
{
"ordinal": 4,
"name": "role_id",
"type_info": "Uuid"
},
{
"ordinal": 5,
"ordinal": 4,
"name": "order_key",
"type_info": "Int4"
},
{
"ordinal": 6,
"ordinal": 5,
"name": "bytecode",
"type_info": "Jsonb"
},
{
"ordinal": 7,
"ordinal": 6,
"name": "created_at",
"type_info": "Timestamptz"
},
{
"ordinal": 8,
"ordinal": 7,
"name": "updated_at",
"type_info": "Timestamptz"
}
@@ -59,12 +54,11 @@
false,
true,
true,
true,
false,
false,
false,
false
]
},
"hash": "f8bced8a6b1b63e7311bbd03bf992ac2371808f73d0bc8744282a271f3f2b131"
"hash": "47f0acddabe1cdf727b834a26367a441ed1b740b73c1189692c715c59826ddf3"
}

View File

@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT id, team_id, user_id, user_group_id, role_id, order_key, bytecode, created_at, updated_at\n FROM posthog_errortrackingassignmentrule\n WHERE team_id = $1 AND disabled_data IS NULL\n ",
"query": "\n SELECT id, team_id, user_id, role_id, order_key, bytecode, created_at, updated_at\n FROM posthog_errortrackinggroupingrule\n WHERE team_id = $1 AND disabled_data IS NULL\n ",
"describe": {
"columns": [
{
@@ -20,31 +20,26 @@
},
{
"ordinal": 3,
"name": "user_group_id",
"type_info": "Uuid"
},
{
"ordinal": 4,
"name": "role_id",
"type_info": "Uuid"
},
{
"ordinal": 5,
"ordinal": 4,
"name": "order_key",
"type_info": "Int4"
},
{
"ordinal": 6,
"ordinal": 5,
"name": "bytecode",
"type_info": "Jsonb"
},
{
"ordinal": 7,
"ordinal": 6,
"name": "created_at",
"type_info": "Timestamptz"
},
{
"ordinal": 8,
"ordinal": 7,
"name": "updated_at",
"type_info": "Timestamptz"
}
@@ -59,12 +54,11 @@
false,
true,
true,
true,
false,
false,
false,
false
]
},
"hash": "51eb1534432ea185cf0357375a7e09fd2bb76f3937ce4773a6bda824a8bcf8a3"
"hash": "575b8f4c016f9c61eefc2307ec96840aac8134ea6fd2a78b9a999efde05f6f4d"
}

View File

@@ -1,17 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO posthog_eventproperty (event, property, team_id, project_id) VALUES ($1, $2, $3, $4) ON CONFLICT DO NOTHING",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Varchar",
"Int4",
"Int8"
]
},
"nullable": []
},
"hash": "9e0e25b9966a23792427c27a80888a75efdb8abe195339e0a1676ebed6fc61ef"
}

View File

@@ -1,21 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "\n INSERT INTO posthog_propertydefinition (id, name, type, group_type_index, is_numerical, volume_30_day, query_usage_30_day, team_id, project_id, property_type)\n VALUES ($1, $2, $3, $4, $5, NULL, NULL, $6, $7, $8)\n ON CONFLICT (coalesce(project_id, team_id::bigint), name, type, coalesce(group_type_index, -1))\n DO UPDATE SET property_type=EXCLUDED.property_type WHERE posthog_propertydefinition.property_type IS NULL\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Uuid",
"Varchar",
"Int2",
"Int2",
"Bool",
"Int4",
"Int8",
"Varchar"
]
},
"nullable": []
},
"hash": "b954e5f88405dd706b709c313738872f63a186a0a2f64f1f2c4b2a61dd434fce"
}

View File

@@ -19,26 +19,17 @@ use crate::{error::UnhandledError, issue_resolution::Issue, types::OutputErrProp
#[derive(Debug, Clone)]
pub struct NewAssignment {
pub user_id: Option<i32>,
pub user_group_id: Option<Uuid>,
pub role_id: Option<Uuid>,
}
impl NewAssignment {
// Returns None if this cannot be used to construct a valid assignment, ensuring all
// NewAssignments have at least one of user_id, user_group_id, or role_id set.
pub fn try_new(
user_id: Option<i32>,
user_group_id: Option<Uuid>,
role_id: Option<Uuid>,
) -> Option<Self> {
if user_id.is_none() && user_group_id.is_none() && role_id.is_none() {
// NewAssignments have at least one of user_id or role_id set.
pub fn try_new(user_id: Option<i32>, role_id: Option<Uuid>) -> Option<Self> {
if user_id.is_none() && role_id.is_none() {
None
} else {
Some(NewAssignment {
user_id,
user_group_id,
role_id,
})
Some(NewAssignment { user_id, role_id })
}
}
}
@@ -52,15 +43,14 @@ impl NewAssignment {
let assignment = sqlx::query_as!(
Assignment,
r#"
INSERT INTO posthog_errortrackingissueassignment (id, issue_id, user_id, user_group_id, role_id, created_at)
VALUES ($1, $2, $3, $4, $5, NOW())
INSERT INTO posthog_errortrackingissueassignment (id, issue_id, user_id, role_id, created_at)
VALUES ($1, $2, $3, $4, NOW())
ON CONFLICT (issue_id) DO UPDATE SET issue_id = $2 -- no-op to get a returned row
RETURNING id, issue_id, user_id, user_group_id, role_id, created_at
RETURNING id, issue_id, user_id, role_id, created_at
"#,
Uuid::now_v7(),
issue_id,
self.user_id,
self.user_group_id,
self.role_id,
).fetch_one(conn).await?;
@@ -73,7 +63,6 @@ pub struct Assignment {
pub id: Uuid,
pub issue_id: Uuid,
pub user_id: Option<i32>,
pub user_group_id: Option<Uuid>,
pub role_id: Option<Uuid>,
pub created_at: DateTime<Utc>,
}
@@ -109,7 +98,6 @@ pub struct AssignmentRule {
pub id: Uuid,
pub team_id: TeamId,
pub user_id: Option<i32>,
pub user_group_id: Option<Uuid>,
pub role_id: Option<Uuid>,
pub order_key: i32,
pub bytecode: Value,
@@ -127,7 +115,7 @@ impl AssignmentRule {
sqlx::query_as!(
AssignmentRule,
r#"
SELECT id, team_id, user_id, user_group_id, role_id, order_key, bytecode, created_at, updated_at
SELECT id, team_id, user_id, role_id, order_key, bytecode, created_at, updated_at
FROM posthog_errortrackingassignmentrule
WHERE team_id = $1 AND disabled_data IS NULL
"#,
@@ -207,11 +195,7 @@ impl AssignmentRule {
let step_result = vm.step()?;
match step_result {
StepOutcome::Finished(Value::Bool(true)) => {
return Ok(NewAssignment::try_new(
self.user_id,
self.user_group_id,
self.role_id,
));
return Ok(NewAssignment::try_new(self.user_id, self.role_id));
}
StepOutcome::Finished(Value::Bool(false)) => {
return Ok(None);

View File

@@ -26,7 +26,6 @@ pub struct GroupingRule {
// If a rule has been custom grouped, it might also be auto-assigned
pub user_id: Option<i32>,
pub user_group_id: Option<Uuid>,
pub role_id: Option<Uuid>,
pub order_key: i32,
pub bytecode: Value,
@@ -44,7 +43,7 @@ impl GroupingRule {
sqlx::query_as!(
GroupingRule,
r#"
SELECT id, team_id, user_id, user_group_id, role_id, order_key, bytecode, created_at, updated_at
SELECT id, team_id, user_id, role_id, order_key, bytecode, created_at, updated_at
FROM posthog_errortrackinggroupingrule
WHERE team_id = $1 AND disabled_data IS NULL
"#,
@@ -131,7 +130,7 @@ impl GroupingRule {
}
pub fn assignment(&self) -> Option<NewAssignment> {
NewAssignment::try_new(self.user_id, self.user_group_id, self.role_id)
NewAssignment::try_new(self.user_id, self.role_id)
}
}
@@ -211,7 +210,6 @@ mod test {
id: Uuid::new_v4(),
team_id: 1,
user_id: None,
user_group_id: None,
role_id: None,
order_key: 1,
bytecode: rule_bytecode(),

View File

@@ -182,7 +182,7 @@ impl Issue {
let assignments = sqlx::query_as!(
Assignment,
r#"
SELECT id, issue_id, user_id, user_group_id, role_id, created_at FROM posthog_errortrackingissueassignment
SELECT id, issue_id, user_id, role_id, created_at FROM posthog_errortrackingissueassignment
WHERE issue_id = $1
"#,
self.id

View File

@@ -36,7 +36,6 @@ fn get_test_rule() -> AssignmentRule {
id: Uuid::new_v4(),
team_id: 1,
user_id: Some(1), // This rule assigns the issue to user with ID 1
user_group_id: None,
role_id: None,
order_key: 1,
bytecode: rule_bytecode(),
@@ -125,14 +124,13 @@ async fn test_assignment_processing(db: PgPool) {
assert!(res.is_some());
let res = res.unwrap();
assert_eq!(res.user_id, existing.user_id);
assert_eq!(res.user_group_id, existing.user_group_id);
assert_eq!(res.role_id, existing.role_id);
// Next, change the issue, and put an assignment on the fingerprint. The returned assignment should be the one from
// the fingerprint, rather than the rule, because fingerprint assignments take priority over assignment rules
let mut props_with_fingerprint_assignment = test_props.clone();
let fingerprint_assignment = NewAssignment::try_new(Some(3), None, None).unwrap();
let fingerprint_assignment = NewAssignment::try_new(Some(3), None).unwrap();
props_with_fingerprint_assignment.fingerprint.assignment = Some(fingerprint_assignment);
let mut new_issue = issue.clone();
@@ -150,6 +148,5 @@ async fn test_assignment_processing(db: PgPool) {
assert!(res.is_some());
let res = res.unwrap();
assert_eq!(res.user_id, Some(3));
assert_eq!(res.user_group_id, None);
assert_eq!(res.role_id, None);
}