user_groups: Add `creator` and `date_created` field in user groups.

This commit introduced 'creator' and 'date_created'
fields in user groups, allowing users to view who
created the groups and when.

Both fields can be null for groups without creator data.
This commit is contained in:
sujal shah 2024-06-04 16:06:52 +05:30 committed by Tim Abbott
parent 3b69f2e5d9
commit 614caf111e
20 changed files with 251 additions and 6 deletions

View File

@ -20,6 +20,13 @@ format used by the Zulip server that they are interacting with.
## Changes in Zulip 10.0
**Feature level 292**
* [`POST /register`](/api/register-queue), [`GET
/events`](/api/get-events), [`GET
/user_groups`](/api/get-user-groups): Added `creator_id` and
`date_created` fields to user groups objects.
**Feature level 291**
* `PATCH /realm`, [`GET /events`](/api/get-events),

View File

@ -34,8 +34,7 @@ DESKTOP_WARNING_VERSION = "5.9.3"
# new level means in api_docs/changelog.md, as well as "**Changes**"
# entries in the endpoint's documentation in `zulip.yaml`.
API_FEATURE_LEVEL = 291 # Last bumped for can_delete_own_message_group
API_FEATURE_LEVEL = 292 # Last bumped for `namedusergroup_creator_date_created`.
# Bump the minor PROVISION_VERSION to indicate that folks should provision
# only when going from an old version of the code to a newer version. Bump

View File

@ -129,6 +129,8 @@ export const realm_emoji_map_schema = z.record(server_emoji_schema);
export const user_group_schema = z.object({
description: z.string(),
id: z.number(),
creator_id: z.number().nullable(),
date_created: z.number().nullable(),
name: z.string(),
members: z.array(z.number()),
is_system_group: z.boolean(),

View File

@ -24,6 +24,8 @@ import * as settings_components from "./settings_components";
import * as settings_data from "./settings_data";
import * as settings_org from "./settings_org";
import {current_user, realm} from "./state_data";
import * as stream_data from "./stream_data";
import * as timerender from "./timerender";
import * as ui_report from "./ui_report";
import * as user_group_components from "./user_group_components";
import * as user_group_create from "./user_group_create";
@ -333,6 +335,13 @@ function update_toggler_for_group_setting() {
export function show_settings_for(group) {
const html = render_user_group_settings({
group,
// We get timestamp in seconds from the API but timerender needs milliseconds.
date_created_string: timerender.get_localized_date_or_time_for_format(
new Date(group.date_created * 1000),
"dayofyear_year",
),
creator: stream_data.maybe_get_creator_details(group.creator_id),
is_creator: group.creator_id === current_user.user_id,
is_member: user_groups.is_direct_member_of(people.my_current_user_id(), group.id),
});

View File

@ -45,6 +45,8 @@ export function add(user_group_raw: UserGroupRaw): void {
description: user_group_raw.description,
id: user_group_raw.id,
name: user_group_raw.name,
creator_id: user_group_raw.creator_id,
date_created: user_group_raw.date_created,
members: new Set(user_group_raw.members),
is_system_group: user_group_raw.is_system_group,
direct_subgroup_ids: new Set(user_group_raw.direct_subgroup_ids),

View File

@ -950,8 +950,10 @@ h4.user_group_setting_subsection_title {
}
}
.stream_details_box {
> .stream_details_subsection {
.stream_details_box,
.group_detail_box {
> .stream_details_subsection,
> .group_details_box_subsection {
margin: 0 0 10px; /* mimic paragraph spacing */
}

View File

@ -43,6 +43,21 @@
{{> group_permissions can_mention_group_widget_name="can_mention_group" can_manage_group_widget_name="can_manage_group"}}
</div>
<div class="group_detail_box">
<div class="user_group_details_box_header">
<h3 class="user_group_setting_subsection_title">
{{t "User group details" }}
</h3>
</div>
<div class="creator_details group_details_box_subsection">
{{> ../creator_details }}
</div>
<div class="group_details_box_subsection">
{{t "User group ID"}}<br/>
{{group.id}}
</div>
</div>
</div>
<div class="group_member_settings group_setting_section" data-group-section="members">

View File

@ -424,6 +424,8 @@ const harry_item = user_item(harry);
const hamletcharacters = user_group_item({
name: "hamletcharacters",
id: 1,
creator_id: null,
date_created: 1596710000,
description: "Characters of Hamlet",
members: new Set([100, 104]),
is_system_group: false,
@ -435,6 +437,8 @@ const hamletcharacters = user_group_item({
const backend = user_group_item({
name: "Backend",
id: 2,
creator_id: null,
date_created: 1596710000,
description: "Backend team",
members: new Set([101]),
is_system_group: false,
@ -446,6 +450,8 @@ const backend = user_group_item({
const call_center = user_group_item({
name: "Call Center",
id: 3,
creator_id: null,
date_created: 1596710000,
description: "folks working in support",
members: new Set([102]),
is_system_group: false,

View File

@ -809,6 +809,8 @@ exports.fixtures = {
group: {
id: 555,
name: "Mobile",
creator_id: null,
date_created: fake_now,
description: "mobile folks",
members: [1],
is_system_group: false,

View File

@ -13,6 +13,8 @@ run_test("user_groups", () => {
const students = {
description: "Students group",
name: "Students",
creator_id: null,
date_created: 1596710000,
id: 0,
members: new Set([1, 2]),
is_system_group: false,
@ -32,6 +34,8 @@ run_test("user_groups", () => {
const admins = {
name: "Admins",
description: "foo",
creator_id: null,
date_created: 1596710000,
id: 1,
members: new Set([3]),
is_system_group: false,

View File

@ -8,6 +8,7 @@ from django.utils.timezone import now as timezone_now
from django.utils.translation import gettext as _
from zerver.lib.exceptions import JsonableError
from zerver.lib.timestamp import datetime_to_timestamp
from zerver.lib.user_groups import (
AnonymousSettingGroupDict,
get_group_setting_value_for_api,
@ -53,6 +54,7 @@ def create_user_group_in_database(
description=description,
is_system_group=is_system_group,
realm_for_sharding=realm,
creator=acting_user,
)
for setting_name, setting_value in group_settings_map.items():
@ -170,11 +172,17 @@ def do_send_create_user_group_event(
members: list[UserProfile],
direct_subgroups: Sequence[UserGroup] = [],
) -> None:
creator_id = user_group.creator_id
assert user_group.date_created is not None
date_created = datetime_to_timestamp(user_group.date_created)
event = dict(
type="user_group",
op="add",
group=dict(
name=user_group.name,
creator_id=creator_id,
date_created=date_created,
members=[member.id for member in members],
description=user_group.description,
id=user_group.id,

View File

@ -1812,6 +1812,8 @@ group_type = DictType(
required_keys=[
("id", int),
("name", str),
("creator_id", OptionalType(int)),
("date_created", OptionalType(int)),
("members", ListType(int)),
("direct_subgroup_ids", ListType(int)),
("description", str),

View File

@ -301,6 +301,7 @@ DATE_FIELDS: dict[TableName, list[Field]] = {
"zerver_realm": ["date_created"],
"zerver_scheduledmessage": ["scheduled_timestamp"],
"zerver_stream": ["date_created"],
"zerver_namedusergroup": ["date_created"],
"zerver_useractivityinterval": ["start", "end"],
"zerver_useractivity": ["last_visit"],
"zerver_onboardingstep": ["timestamp"],

View File

@ -754,13 +754,15 @@ def bulk_import_named_user_groups(data: TableData) -> None:
group["can_manage_group_id"],
group["can_mention_group_id"],
group["deactivated"],
group["creator_id"],
group["date_created"],
)
for group in data["zerver_namedusergroup"]
]
query = SQL(
"""
INSERT INTO zerver_namedusergroup (usergroup_ptr_id, realm_id, name, description, is_system_group, can_manage_group_id, can_mention_group_id, deactivated)
INSERT INTO zerver_namedusergroup (usergroup_ptr_id, realm_id, name, description, is_system_group, can_manage_group_id, can_mention_group_id, deactivated, creator_id, date_created)
VALUES %s
"""
)
@ -1197,6 +1199,10 @@ def do_import_realm(import_dir: Path, subdomain: str, processes: int = 1) -> Rea
bulk_import_model(data, UserGroup)
if "zerver_namedusergroup" in data:
re_map_foreign_keys(
data, "zerver_namedusergroup", "creator", related_table="user_profile"
)
fix_datetime_fields(data, "zerver_namedusergroup")
re_map_foreign_keys(
data, "zerver_namedusergroup", "usergroup_ptr", related_table="usergroup"
)

View File

@ -17,6 +17,7 @@ from zerver.lib.exceptions import (
PreviousSettingValueMismatchedError,
SystemGroupRequiredError,
)
from zerver.lib.timestamp import datetime_to_timestamp
from zerver.lib.types import GroupPermissionSetting, ServerSupportedPermissionSettings
from zerver.models import (
GroupGroupMembership,
@ -50,6 +51,8 @@ class UserGroupDict(TypedDict):
description: str
members: list[int]
direct_subgroup_ids: list[int]
creator_id: int | None
date_created: int | None
is_system_group: bool
can_manage_group: int | AnonymousSettingGroupDict
can_mention_group: int | AnonymousSettingGroupDict
@ -516,9 +519,19 @@ def user_groups_in_realm_serialized(
if user_group.id in group_subgroups:
direct_subgroup_ids = group_subgroups[user_group.id]
creator_id = user_group.creator_id
date_created = (
datetime_to_timestamp(user_group.date_created)
if user_group.date_created is not None
else None
)
group_dicts[user_group.id] = dict(
id=user_group.id,
name=user_group.name,
creator_id=creator_id,
date_created=date_created,
description=user_group.description,
members=direct_member_ids,
direct_subgroup_ids=direct_subgroup_ids,

View File

@ -0,0 +1,31 @@
# Generated by Django 5.0.8 on 2024-08-31 08:09
import django.db.models.deletion
import django.utils.timezone
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("zerver", "0582_remove_realm_delete_own_message_policy"),
]
operations = [
migrations.AddField(
model_name="namedusergroup",
name="creator",
field=models.ForeignKey(
db_column="creator_id",
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="+",
to=settings.AUTH_USER_MODEL,
),
),
migrations.AddField(
model_name="namedusergroup",
name="date_created",
field=models.DateTimeField(default=django.utils.timezone.now, null=True),
),
]

View File

@ -0,0 +1,53 @@
# Generated by Django 5.0.8 on 2024-08-31 08:09
from django.db import migrations
from django.db.backends.base.schema import BaseDatabaseSchemaEditor
from django.db.migrations.state import StateApps
def backfill_creator_id_and_date_created_from_realm_audit_log(
apps: StateApps, schema_editor: BaseDatabaseSchemaEditor
) -> None:
RealmAuditLog = apps.get_model("zerver", "RealmAuditLog")
RealmAuditLog.USER_GROUP_CREATED = 701
NamedUserGroup = apps.get_model("zerver", "NamedUserGroup")
user_group_creator_updates = []
for audit_log_entry in RealmAuditLog.objects.select_related("modified_user_group").filter(
event_type=RealmAuditLog.USER_GROUP_CREATED,
acting_user_id__isnull=False,
):
assert audit_log_entry.modified_user_group is not None
user_group = audit_log_entry.modified_user_group
user_group.creator_id = audit_log_entry.acting_user_id
user_group_creator_updates.append(user_group)
NamedUserGroup.objects.bulk_update(user_group_creator_updates, ["creator_id"], batch_size=1000)
user_group_date_created_updates = []
for audit_log_entry in RealmAuditLog.objects.select_related("modified_user_group").filter(
event_type=RealmAuditLog.USER_GROUP_CREATED,
event_time__isnull=False,
):
assert audit_log_entry.modified_user_group is not None
user_group = audit_log_entry.modified_user_group
user_group.date_created = audit_log_entry.event_time
user_group_date_created_updates.append(user_group)
NamedUserGroup.objects.bulk_update(
user_group_date_created_updates, ["date_created"], batch_size=1000
)
class Migration(migrations.Migration):
dependencies = [
("zerver", "0583_namedusergroup_creator_namedusergroup_date_created"),
]
operations = [
migrations.RunPython(
backfill_creator_id_and_date_created_from_realm_audit_log,
reverse_code=migrations.RunPython.noop,
elidable=True,
),
]

View File

@ -1,5 +1,6 @@
from django.db import models
from django.db.models import CASCADE
from django.utils.timezone import now as timezone_now
from django_cte import CTEManager
from zerver.lib.types import GroupPermissionSetting
@ -52,6 +53,10 @@ class NamedUserGroup(UserGroup): # type: ignore[django-manager-missing] # djang
)
name = models.CharField(max_length=MAX_NAME_LENGTH, db_column="name")
description = models.TextField(default="", db_column="description")
date_created = models.DateTimeField(default=timezone_now, null=True)
creator = models.ForeignKey(
UserProfile, null=True, on_delete=models.SET_NULL, related_name="+", db_column="creator_id"
)
is_system_group = models.BooleanField(default=False, db_column="is_system_group")
can_manage_group = models.ForeignKey(UserGroup, on_delete=models.RESTRICT, related_name="+")

View File

@ -3148,6 +3148,8 @@ paths:
{
"name": "backend",
"members": [12],
"creator_id": 9,
"date_created": 1717484476,
"description": "Backend team",
"id": 2,
"is_system_group": false,
@ -20026,6 +20028,34 @@ paths:
type: integer
description: |
The user group's integer ID.
date_created:
type: integer
nullable: true
description: |
The UNIX timestamp for when the user group was created, in UTC seconds.
A `null` value means the user group has no recorded date, which is often
because the group predates the metadata being tracked starting in Zulip 8.0,
or because it was created via a data import tool
or [management command][management-commands].
**Changes**: New in Zulip 10.0 (feature level 292).
[management-commands]: https://zulip.readthedocs.io/en/latest/production/management-commands.html
creator_id:
type: integer
nullable: true
description: |
The ID of the user who created this user group.
A `null` value means the user group has no recorded creator, which is often
because the group predates the metadata being tracked starting in Zulip 8.0,
or because it was created via a data import tool
or [management command][management-commands].
**Changes**: New in Zulip 10.0 (feature level 292).
[management-commands]: https://zulip.readthedocs.io/en/latest/production/management-commands.html
members:
type: array
description: |
@ -20107,6 +20137,8 @@ paths:
{
"description": "Owners of this organization",
"id": 1,
"creator_id": null,
"date_created": null,
"name": "role:owners",
"members": [1],
"direct_subgroup_ids": [],
@ -20117,6 +20149,8 @@ paths:
{
"description": "Administrators of this organization, including owners",
"id": 2,
"creator_id": null,
"date_created": null,
"name": "role:administrators",
"members": [2],
"direct_subgroup_ids": [1],
@ -20127,6 +20161,8 @@ paths:
{
"description": "Characters of Hamlet",
"id": 3,
"creator_id": null,
"date_created": 1717484476,
"name": "hamletcharacters",
"members": [3, 4],
"direct_subgroup_ids": [],
@ -21269,6 +21305,30 @@ components:
type: string
description: |
The name of the user group.
date_created:
type: integer
nullable: true
description: |
The UNIX timestamp for when the user group was created, in UTC seconds.
A `null` value means the user group has no recorded date, which is often
because the user group is very old, or because it was created via a data
import tool or [management command][management-commands].
**Changes**: New in Zulip 10.0 (feature level 292).
[management-commands]: https://zulip.readthedocs.io/en/latest/production/management-commands.html
creator_id:
type: integer
nullable: true
description: |
The ID of the user who created this user group.
A `null` value means the user group has no recorded creator, which is often
because the user group is very old, or because it was created via a data
import tool or [management command][management-commands].
**Changes**: New in Zulip 10.0 (feature level 292).
[management-commands]: https://zulip.readthedocs.io/en/latest/production/management-commands.html
description:
type: string
description: |

View File

@ -33,6 +33,7 @@ from zerver.lib.mention import silent_mention_syntax_for_user
from zerver.lib.streams import ensure_stream
from zerver.lib.test_classes import ZulipTestCase
from zerver.lib.test_helpers import most_recent_usermessage
from zerver.lib.timestamp import datetime_to_timestamp
from zerver.lib.user_groups import (
AnonymousSettingGroupDict,
get_direct_user_groups,
@ -80,13 +81,16 @@ class UserGroupTestCase(ZulipTestCase):
def test_user_groups_in_realm_serialized(self) -> None:
realm = get_realm("zulip")
user = self.example_user("iago")
user_group = NamedUserGroup.objects.filter(realm=realm).first()
assert user_group is not None
empty_user_group = check_add_user_group(realm, "newgroup", [], acting_user=None)
empty_user_group = check_add_user_group(realm, "newgroup", [], acting_user=user)
user_groups = user_groups_in_realm_serialized(realm, allow_deactivated=False)
self.assert_length(user_groups, 10)
self.assertEqual(user_groups[0]["id"], user_group.id)
self.assertEqual(user_groups[0]["creator_id"], user_group.creator_id)
self.assertEqual(user_groups[0]["date_created"], user_group.date_created)
self.assertEqual(user_groups[0]["name"], SystemGroups.NOBODY)
self.assertEqual(user_groups[0]["description"], "Nobody")
self.assertEqual(user_groups[0]["members"], [])
@ -100,6 +104,8 @@ class UserGroupTestCase(ZulipTestCase):
"user_profile_id", flat=True
)
self.assertEqual(user_groups[1]["id"], owners_system_group.id)
self.assertEqual(user_groups[1]["creator_id"], owners_system_group.creator_id)
self.assertEqual(user_groups[1]["date_created"], owners_system_group.date_created)
self.assertEqual(user_groups[1]["name"], SystemGroups.OWNERS)
self.assertEqual(user_groups[1]["description"], "Owners of this organization")
self.assertEqual(set(user_groups[1]["members"]), set(membership))
@ -119,6 +125,11 @@ class UserGroupTestCase(ZulipTestCase):
name=SystemGroups.EVERYONE, realm=realm, is_system_group=True
)
self.assertEqual(user_groups[9]["id"], empty_user_group.id)
self.assertEqual(user_groups[9]["creator_id"], empty_user_group.creator_id)
assert empty_user_group.date_created is not None
self.assertEqual(
user_groups[9]["date_created"], datetime_to_timestamp(empty_user_group.date_created)
)
self.assertEqual(user_groups[9]["name"], "newgroup")
self.assertEqual(user_groups[9]["description"], "")
self.assertEqual(user_groups[9]["members"], [])
@ -143,6 +154,13 @@ class UserGroupTestCase(ZulipTestCase):
)
user_groups = user_groups_in_realm_serialized(realm, allow_deactivated=False)
self.assertEqual(user_groups[10]["id"], new_user_group.id)
self.assertEqual(user_groups[10]["creator_id"], new_user_group.creator_id)
new_user_group_date_created = (
datetime_to_timestamp(new_user_group.date_created)
if new_user_group.date_created is not None
else None
)
self.assertEqual(user_groups[10]["date_created"], new_user_group_date_created)
self.assertEqual(user_groups[10]["name"], "newgroup2")
self.assertEqual(user_groups[10]["description"], "")
self.assertEqual(user_groups[10]["members"], [othello.id])