mirror of https://github.com/zulip/zulip.git
remote data: Send RealmAuditLog data.
This commit is contained in:
parent
b86142089b
commit
360cd7f147
|
@ -264,6 +264,7 @@ python_rules = RuleList(
|
||||||
'bad_lines': ['a =b', 'asdf =42']},
|
'bad_lines': ['a =b', 'asdf =42']},
|
||||||
{'pattern': r'":\w[^"]*$',
|
{'pattern': r'":\w[^"]*$',
|
||||||
'description': 'Missing whitespace after ":"',
|
'description': 'Missing whitespace after ":"',
|
||||||
|
'exclude': set(['zerver/tests/test_push_notifications.py']),
|
||||||
'good_lines': ['"foo": bar', '"some:string:with:colons"'],
|
'good_lines': ['"foo": bar', '"some:string:with:colons"'],
|
||||||
'bad_lines': ['"foo":bar', '"foo":1']},
|
'bad_lines': ['"foo":bar', '"foo":1']},
|
||||||
{'pattern': r"':\w[^']*$",
|
{'pattern': r"':\w[^']*$",
|
||||||
|
|
|
@ -12,6 +12,7 @@ from analytics.models import InstallationCount, RealmCount
|
||||||
from version import ZULIP_VERSION
|
from version import ZULIP_VERSION
|
||||||
from zerver.lib.exceptions import JsonableError
|
from zerver.lib.exceptions import JsonableError
|
||||||
from zerver.lib.export import floatify_datetime_fields
|
from zerver.lib.export import floatify_datetime_fields
|
||||||
|
from zerver.models import RealmAuditLog
|
||||||
|
|
||||||
class PushNotificationBouncerException(Exception):
|
class PushNotificationBouncerException(Exception):
|
||||||
pass
|
pass
|
||||||
|
@ -91,24 +92,34 @@ def send_json_to_push_bouncer(method: str, endpoint: str, post_data: Dict[str, A
|
||||||
extra_headers={"Content-type": "application/json"},
|
extra_headers={"Content-type": "application/json"},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
REALMAUDITLOG_PUSHED_FIELDS = ['id', 'realm', 'event_time', 'backfilled', 'extra_data', 'event_type']
|
||||||
|
|
||||||
def build_analytics_data(realm_count_query: Any,
|
def build_analytics_data(realm_count_query: Any,
|
||||||
installation_count_query: Any) -> Tuple[List[Dict[str, Any]],
|
installation_count_query: Any,
|
||||||
|
realmauditlog_query: Any) -> Tuple[List[Dict[str, Any]],
|
||||||
|
List[Dict[str, Any]],
|
||||||
List[Dict[str, Any]]]:
|
List[Dict[str, Any]]]:
|
||||||
# We limit the batch size on the client side to avoid OOM kills timeouts, etc.
|
# We limit the batch size on the client side to avoid OOM kills timeouts, etc.
|
||||||
MAX_CLIENT_BATCH_SIZE = 10000
|
MAX_CLIENT_BATCH_SIZE = 10000
|
||||||
data = {}
|
data = {}
|
||||||
data['analytics_realmcount'] = [
|
data['analytics_realmcount'] = [
|
||||||
model_to_dict(realm_count) for realm_count in
|
model_to_dict(row) for row in
|
||||||
realm_count_query.order_by("id")[0:MAX_CLIENT_BATCH_SIZE]
|
realm_count_query.order_by("id")[0:MAX_CLIENT_BATCH_SIZE]
|
||||||
]
|
]
|
||||||
data['analytics_installationcount'] = [
|
data['analytics_installationcount'] = [
|
||||||
model_to_dict(count) for count in
|
model_to_dict(row) for row in
|
||||||
installation_count_query.order_by("id")[0:MAX_CLIENT_BATCH_SIZE]
|
installation_count_query.order_by("id")[0:MAX_CLIENT_BATCH_SIZE]
|
||||||
]
|
]
|
||||||
|
data['zerver_realmauditlog'] = [
|
||||||
|
model_to_dict(row, fields=REALMAUDITLOG_PUSHED_FIELDS) for row in
|
||||||
|
realmauditlog_query.order_by("id")[0:MAX_CLIENT_BATCH_SIZE]
|
||||||
|
]
|
||||||
|
|
||||||
floatify_datetime_fields(data, 'analytics_realmcount')
|
floatify_datetime_fields(data, 'analytics_realmcount')
|
||||||
floatify_datetime_fields(data, 'analytics_installationcount')
|
floatify_datetime_fields(data, 'analytics_installationcount')
|
||||||
return (data['analytics_realmcount'], data['analytics_installationcount'])
|
floatify_datetime_fields(data, 'zerver_realmauditlog')
|
||||||
|
return (data['analytics_realmcount'], data['analytics_installationcount'],
|
||||||
|
data['zerver_realmauditlog'])
|
||||||
|
|
||||||
def send_analytics_to_remote_server() -> None:
|
def send_analytics_to_remote_server() -> None:
|
||||||
# first, check what's latest
|
# first, check what's latest
|
||||||
|
@ -118,19 +129,24 @@ def send_analytics_to_remote_server() -> None:
|
||||||
|
|
||||||
last_acked_realm_count_id = result['last_realm_count_id']
|
last_acked_realm_count_id = result['last_realm_count_id']
|
||||||
last_acked_installation_count_id = result['last_installation_count_id']
|
last_acked_installation_count_id = result['last_installation_count_id']
|
||||||
|
last_acked_realmauditlog_id = result['last_realmauditlog_id']
|
||||||
|
|
||||||
(realm_count_data, installation_count_data) = build_analytics_data(
|
(realm_count_data, installation_count_data, realmauditlog_data) = build_analytics_data(
|
||||||
realm_count_query=RealmCount.objects.filter(
|
realm_count_query=RealmCount.objects.filter(
|
||||||
id__gt=last_acked_realm_count_id),
|
id__gt=last_acked_realm_count_id),
|
||||||
installation_count_query=InstallationCount.objects.filter(
|
installation_count_query=InstallationCount.objects.filter(
|
||||||
id__gt=last_acked_installation_count_id))
|
id__gt=last_acked_installation_count_id),
|
||||||
|
realmauditlog_query=RealmAuditLog.objects.filter(
|
||||||
|
event_type__in=RealmAuditLog.SYNCED_BILLING_EVENTS,
|
||||||
|
id__gt=last_acked_realmauditlog_id))
|
||||||
|
|
||||||
if len(realm_count_data) == 0 and len(installation_count_data) == 0:
|
if len(realm_count_data) + len(installation_count_data) + len(realmauditlog_data) == 0:
|
||||||
return
|
return
|
||||||
|
|
||||||
request = {
|
request = {
|
||||||
'realm_counts': ujson.dumps(realm_count_data),
|
'realm_counts': ujson.dumps(realm_count_data),
|
||||||
'installation_counts': ujson.dumps(installation_count_data),
|
'installation_counts': ujson.dumps(installation_count_data),
|
||||||
|
'realmauditlog_rows': ujson.dumps(realmauditlog_data),
|
||||||
'version': ujson.dumps(ZULIP_VERSION),
|
'version': ujson.dumps(ZULIP_VERSION),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -2541,32 +2541,8 @@ EMAIL_TYPES = {
|
||||||
'invitation_reminder': ScheduledEmail.INVITATION_REMINDER,
|
'invitation_reminder': ScheduledEmail.INVITATION_REMINDER,
|
||||||
}
|
}
|
||||||
|
|
||||||
class RealmAuditLog(models.Model):
|
class AbstractRealmAuditLog(models.Model):
|
||||||
"""
|
"""Defines fields common to RealmAuditLog and RemoteRealmAuditLog."""
|
||||||
RealmAuditLog tracks important changes to users, streams, and
|
|
||||||
realms in Zulip. It is intended to support both
|
|
||||||
debugging/introspection (e.g. determining when a user's left a
|
|
||||||
given stream?) as well as help with some database migrations where
|
|
||||||
we might be able to do a better data backfill with it. Here are a
|
|
||||||
few key details about how this works:
|
|
||||||
|
|
||||||
* acting_user is the user who initiated the state change
|
|
||||||
* modified_user (if present) is the user being modified
|
|
||||||
* modified_stream (if present) is the stream being modified
|
|
||||||
|
|
||||||
For example:
|
|
||||||
* When a user subscribes another user to a stream, modified_user,
|
|
||||||
acting_user, and modified_stream will all be present and different.
|
|
||||||
* When an administrator changes an organization's realm icon,
|
|
||||||
acting_user is that administrator and both modified_user and
|
|
||||||
modified_stream will be None.
|
|
||||||
"""
|
|
||||||
realm = models.ForeignKey(Realm, on_delete=CASCADE) # type: Realm
|
|
||||||
acting_user = models.ForeignKey(UserProfile, null=True, related_name='+', on_delete=CASCADE) # type: Optional[UserProfile]
|
|
||||||
modified_user = models.ForeignKey(UserProfile, null=True, related_name='+', on_delete=CASCADE) # type: Optional[UserProfile]
|
|
||||||
modified_stream = models.ForeignKey(Stream, null=True, on_delete=CASCADE) # type: Optional[Stream]
|
|
||||||
event_last_message_id = models.IntegerField(null=True) # type: Optional[int]
|
|
||||||
|
|
||||||
event_time = models.DateTimeField(db_index=True) # type: datetime.datetime
|
event_time = models.DateTimeField(db_index=True) # type: datetime.datetime
|
||||||
# If True, event_time is an overestimate of the true time. Can be used
|
# If True, event_time is an overestimate of the true time. Can be used
|
||||||
# by migrations when introducing a new event_type.
|
# by migrations when introducing a new event_type.
|
||||||
|
@ -2627,6 +2603,35 @@ class RealmAuditLog(models.Model):
|
||||||
USER_CREATED, USER_ACTIVATED, USER_DEACTIVATED, USER_REACTIVATED, USER_ROLE_CHANGED,
|
USER_CREATED, USER_ACTIVATED, USER_DEACTIVATED, USER_REACTIVATED, USER_ROLE_CHANGED,
|
||||||
REALM_DEACTIVATED, REALM_REACTIVATED]
|
REALM_DEACTIVATED, REALM_REACTIVATED]
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
abstract = True
|
||||||
|
|
||||||
|
class RealmAuditLog(AbstractRealmAuditLog):
|
||||||
|
"""
|
||||||
|
RealmAuditLog tracks important changes to users, streams, and
|
||||||
|
realms in Zulip. It is intended to support both
|
||||||
|
debugging/introspection (e.g. determining when a user's left a
|
||||||
|
given stream?) as well as help with some database migrations where
|
||||||
|
we might be able to do a better data backfill with it. Here are a
|
||||||
|
few key details about how this works:
|
||||||
|
|
||||||
|
* acting_user is the user who initiated the state change
|
||||||
|
* modified_user (if present) is the user being modified
|
||||||
|
* modified_stream (if present) is the stream being modified
|
||||||
|
|
||||||
|
For example:
|
||||||
|
* When a user subscribes another user to a stream, modified_user,
|
||||||
|
acting_user, and modified_stream will all be present and different.
|
||||||
|
* When an administrator changes an organization's realm icon,
|
||||||
|
acting_user is that administrator and both modified_user and
|
||||||
|
modified_stream will be None.
|
||||||
|
"""
|
||||||
|
realm = models.ForeignKey(Realm, on_delete=CASCADE) # type: Realm
|
||||||
|
acting_user = models.ForeignKey(UserProfile, null=True, related_name='+', on_delete=CASCADE) # type: Optional[UserProfile]
|
||||||
|
modified_user = models.ForeignKey(UserProfile, null=True, related_name='+', on_delete=CASCADE) # type: Optional[UserProfile]
|
||||||
|
modified_stream = models.ForeignKey(Stream, null=True, on_delete=CASCADE) # type: Optional[Stream]
|
||||||
|
event_last_message_id = models.IntegerField(null=True) # type: Optional[int]
|
||||||
|
|
||||||
def __str__(self) -> str:
|
def __str__(self) -> str:
|
||||||
if self.modified_user is not None:
|
if self.modified_user is not None:
|
||||||
return "<RealmAuditLog: %s %s %s %s>" % (
|
return "<RealmAuditLog: %s %s %s %s>" % (
|
||||||
|
|
|
@ -33,6 +33,7 @@ from zerver.models import (
|
||||||
get_realm,
|
get_realm,
|
||||||
get_stream,
|
get_stream,
|
||||||
Recipient,
|
Recipient,
|
||||||
|
RealmAuditLog,
|
||||||
Stream,
|
Stream,
|
||||||
Subscription,
|
Subscription,
|
||||||
)
|
)
|
||||||
|
@ -67,7 +68,7 @@ from zerver.lib.test_classes import (
|
||||||
)
|
)
|
||||||
|
|
||||||
from zilencer.models import RemoteZulipServer, RemotePushDeviceToken, \
|
from zilencer.models import RemoteZulipServer, RemotePushDeviceToken, \
|
||||||
RemoteRealmCount, RemoteInstallationCount
|
RemoteRealmCount, RemoteInstallationCount, RemoteRealmAuditLog
|
||||||
from django.utils.timezone import now
|
from django.utils.timezone import now
|
||||||
|
|
||||||
ZERVER_DIR = os.path.dirname(os.path.dirname(__file__))
|
ZERVER_DIR = os.path.dirname(os.path.dirname(__file__))
|
||||||
|
@ -319,6 +320,23 @@ class AnalyticsBouncerTest(BouncerTestCase):
|
||||||
user = self.example_user('hamlet')
|
user = self.example_user('hamlet')
|
||||||
end_time = self.TIME_ZERO
|
end_time = self.TIME_ZERO
|
||||||
|
|
||||||
|
# Send any existing data over, so that we can start the test with a "clean" slate
|
||||||
|
audit_log_max_id = RealmAuditLog.objects.all().order_by('id').last().id
|
||||||
|
send_analytics_to_remote_server()
|
||||||
|
self.assertEqual(mock_request.call_count, 2)
|
||||||
|
remote_audit_log_count = RemoteRealmAuditLog.objects.count()
|
||||||
|
self.assertEqual(RemoteRealmCount.objects.count(), 0)
|
||||||
|
self.assertEqual(RemoteInstallationCount.objects.count(), 0)
|
||||||
|
|
||||||
|
def check_counts(mock_request_call_count: int, remote_realm_count: int,
|
||||||
|
remote_installation_count: int, remote_realm_audit_log: int) -> None:
|
||||||
|
self.assertEqual(mock_request.call_count, mock_request_call_count)
|
||||||
|
self.assertEqual(RemoteRealmCount.objects.count(), remote_realm_count)
|
||||||
|
self.assertEqual(RemoteInstallationCount.objects.count(), remote_installation_count)
|
||||||
|
self.assertEqual(RemoteRealmAuditLog.objects.count(),
|
||||||
|
remote_audit_log_count + remote_realm_audit_log)
|
||||||
|
|
||||||
|
# Create some rows we'll send to remote server
|
||||||
realm_stat = LoggingCountStat('invites_sent::day', RealmCount, CountStat.DAY)
|
realm_stat = LoggingCountStat('invites_sent::day', RealmCount, CountStat.DAY)
|
||||||
RealmCount.objects.create(
|
RealmCount.objects.create(
|
||||||
realm=user.realm, property=realm_stat.property, end_time=end_time, value=5)
|
realm=user.realm, property=realm_stat.property, end_time=end_time, value=5)
|
||||||
|
@ -327,48 +345,63 @@ class AnalyticsBouncerTest(BouncerTestCase):
|
||||||
# We set a subgroup here to work around:
|
# We set a subgroup here to work around:
|
||||||
# https://github.com/zulip/zulip/issues/12362
|
# https://github.com/zulip/zulip/issues/12362
|
||||||
subgroup="test_subgroup")
|
subgroup="test_subgroup")
|
||||||
|
# Event type in SYNCED_BILLING_EVENTS -- should be included
|
||||||
|
RealmAuditLog.objects.create(
|
||||||
|
realm=user.realm, modified_user=user, event_type=RealmAuditLog.USER_CREATED,
|
||||||
|
event_time=end_time, extra_data='data')
|
||||||
|
# Event type not in SYNCED_BILLING_EVENTS -- should not be included
|
||||||
|
RealmAuditLog.objects.create(
|
||||||
|
realm=user.realm, modified_user=user, event_type=RealmAuditLog.REALM_LOGO_CHANGED,
|
||||||
|
event_time=end_time, extra_data='data')
|
||||||
self.assertEqual(RealmCount.objects.count(), 1)
|
self.assertEqual(RealmCount.objects.count(), 1)
|
||||||
self.assertEqual(InstallationCount.objects.count(), 1)
|
self.assertEqual(InstallationCount.objects.count(), 1)
|
||||||
|
self.assertEqual(RealmAuditLog.objects.filter(id__gt=audit_log_max_id).count(), 2)
|
||||||
|
|
||||||
self.assertEqual(RemoteRealmCount.objects.count(), 0)
|
|
||||||
self.assertEqual(RemoteInstallationCount.objects.count(), 0)
|
|
||||||
send_analytics_to_remote_server()
|
send_analytics_to_remote_server()
|
||||||
self.assertEqual(mock_request.call_count, 2)
|
check_counts(4, 1, 1, 1)
|
||||||
self.assertEqual(RemoteRealmCount.objects.count(), 1)
|
|
||||||
self.assertEqual(RemoteInstallationCount.objects.count(), 1)
|
|
||||||
send_analytics_to_remote_server()
|
|
||||||
self.assertEqual(mock_request.call_count, 3)
|
|
||||||
self.assertEqual(RemoteRealmCount.objects.count(), 1)
|
|
||||||
self.assertEqual(RemoteInstallationCount.objects.count(), 1)
|
|
||||||
|
|
||||||
|
# Test having no new rows
|
||||||
|
send_analytics_to_remote_server()
|
||||||
|
check_counts(5, 1, 1, 1)
|
||||||
|
|
||||||
|
# Test only having new RealmCount rows
|
||||||
RealmCount.objects.create(
|
RealmCount.objects.create(
|
||||||
realm=user.realm, property=realm_stat.property, end_time=end_time + datetime.timedelta(days=1), value=6)
|
realm=user.realm, property=realm_stat.property, end_time=end_time + datetime.timedelta(days=1), value=6)
|
||||||
RealmCount.objects.create(
|
RealmCount.objects.create(
|
||||||
realm=user.realm, property=realm_stat.property, end_time=end_time + datetime.timedelta(days=2), value=9)
|
realm=user.realm, property=realm_stat.property, end_time=end_time + datetime.timedelta(days=2), value=9)
|
||||||
self.assertEqual(RemoteRealmCount.objects.count(), 1)
|
|
||||||
self.assertEqual(mock_request.call_count, 3)
|
|
||||||
send_analytics_to_remote_server()
|
send_analytics_to_remote_server()
|
||||||
self.assertEqual(mock_request.call_count, 5)
|
check_counts(7, 3, 1, 1)
|
||||||
self.assertEqual(RemoteRealmCount.objects.count(), 3)
|
|
||||||
self.assertEqual(RemoteInstallationCount.objects.count(), 1)
|
|
||||||
|
|
||||||
|
# Test only having new InstallationCount rows
|
||||||
InstallationCount.objects.create(
|
InstallationCount.objects.create(
|
||||||
property=realm_stat.property, end_time=end_time + datetime.timedelta(days=1), value=6)
|
property=realm_stat.property, end_time=end_time + datetime.timedelta(days=1), value=6)
|
||||||
InstallationCount.objects.create(
|
|
||||||
property=realm_stat.property, end_time=end_time + datetime.timedelta(days=2), value=9)
|
|
||||||
send_analytics_to_remote_server()
|
send_analytics_to_remote_server()
|
||||||
self.assertEqual(mock_request.call_count, 7)
|
check_counts(9, 3, 2, 1)
|
||||||
self.assertEqual(RemoteRealmCount.objects.count(), 3)
|
|
||||||
self.assertEqual(RemoteInstallationCount.objects.count(), 3)
|
# Test only having new RealmAuditLog rows
|
||||||
|
# Non-synced event
|
||||||
|
RealmAuditLog.objects.create(
|
||||||
|
realm=user.realm, modified_user=user, event_type=RealmAuditLog.REALM_LOGO_CHANGED,
|
||||||
|
event_time=end_time, extra_data='data')
|
||||||
|
send_analytics_to_remote_server()
|
||||||
|
check_counts(10, 3, 2, 1)
|
||||||
|
# Synced event
|
||||||
|
RealmAuditLog.objects.create(
|
||||||
|
realm=user.realm, modified_user=user, event_type=RealmAuditLog.USER_REACTIVATED,
|
||||||
|
event_time=end_time, extra_data='data')
|
||||||
|
send_analytics_to_remote_server()
|
||||||
|
check_counts(12, 3, 2, 2)
|
||||||
|
|
||||||
(realm_count_data,
|
(realm_count_data,
|
||||||
installation_count_data) = build_analytics_data(RealmCount.objects.all(),
|
installation_count_data,
|
||||||
InstallationCount.objects.all())
|
realmauditlog_data) = build_analytics_data(RealmCount.objects.all(),
|
||||||
|
InstallationCount.objects.all(),
|
||||||
|
RealmAuditLog.objects.all())
|
||||||
result = self.api_post(self.server_uuid,
|
result = self.api_post(self.server_uuid,
|
||||||
'/api/v1/remotes/server/analytics',
|
'/api/v1/remotes/server/analytics',
|
||||||
{'realm_counts': ujson.dumps(realm_count_data),
|
{'realm_counts': ujson.dumps(realm_count_data),
|
||||||
'installation_counts': ujson.dumps(installation_count_data)},
|
'installation_counts': ujson.dumps(installation_count_data),
|
||||||
|
'realmauditlog_rows': ujson.dumps(realmauditlog_data)},
|
||||||
subdomain="")
|
subdomain="")
|
||||||
self.assert_json_error(result, "Data is out of order.")
|
self.assert_json_error(result, "Data is out of order.")
|
||||||
|
|
||||||
|
@ -381,7 +414,8 @@ class AnalyticsBouncerTest(BouncerTestCase):
|
||||||
self.server_uuid,
|
self.server_uuid,
|
||||||
'/api/v1/remotes/server/analytics',
|
'/api/v1/remotes/server/analytics',
|
||||||
{'realm_counts': ujson.dumps(realm_count_data),
|
{'realm_counts': ujson.dumps(realm_count_data),
|
||||||
'installation_counts': ujson.dumps(installation_count_data)},
|
'installation_counts': ujson.dumps(installation_count_data),
|
||||||
|
'realmauditlog_rows': ujson.dumps(realmauditlog_data)},
|
||||||
subdomain="")
|
subdomain="")
|
||||||
self.assert_json_error(result, "Invalid data.")
|
self.assert_json_error(result, "Invalid data.")
|
||||||
|
|
||||||
|
@ -407,6 +441,73 @@ class AnalyticsBouncerTest(BouncerTestCase):
|
||||||
log_warning.assert_called_once()
|
log_warning.assert_called_once()
|
||||||
self.assertEqual(RemoteRealmCount.objects.count(), 0)
|
self.assertEqual(RemoteRealmCount.objects.count(), 0)
|
||||||
|
|
||||||
|
# Servers on Zulip 2.0.6 and earlier only send realm_counts and installation_counts data,
|
||||||
|
# and don't send realmauditlog_rows. Make sure that continues to work.
|
||||||
|
@override_settings(PUSH_NOTIFICATION_BOUNCER_URL='https://push.zulip.org.example.com')
|
||||||
|
@mock.patch('zerver.lib.push_notifications.requests.request')
|
||||||
|
def test_old_two_table_format(self, mock_request: Any) -> None:
|
||||||
|
mock_request.side_effect = self.bounce_request
|
||||||
|
# Send fixture generated with Zulip 2.0 code
|
||||||
|
send_to_push_bouncer('POST', 'server/analytics', {
|
||||||
|
'realm_counts': '[{"id":1,"property":"invites_sent::day","subgroup":null,"end_time":574300800.0,"value":5,"realm":2}]', # lint:ignore
|
||||||
|
'installation_counts': '[]',
|
||||||
|
'version': '"2.0.6+git"'})
|
||||||
|
self.assertEqual(mock_request.call_count, 1)
|
||||||
|
self.assertEqual(RemoteRealmCount.objects.count(), 1)
|
||||||
|
self.assertEqual(RemoteInstallationCount.objects.count(), 0)
|
||||||
|
self.assertEqual(RemoteRealmAuditLog.objects.count(), 0)
|
||||||
|
|
||||||
|
# Make sure we aren't sending data we don't mean to, even if we don't store it.
|
||||||
|
@override_settings(PUSH_NOTIFICATION_BOUNCER_URL='https://push.zulip.org.example.com')
|
||||||
|
@mock.patch('zerver.lib.push_notifications.requests.request')
|
||||||
|
def test_only_sending_intended_realmauditlog_data(self, mock_request: Any) -> None:
|
||||||
|
mock_request.side_effect = self.bounce_request
|
||||||
|
user = self.example_user('hamlet')
|
||||||
|
# Event type in SYNCED_BILLING_EVENTS -- should be included
|
||||||
|
RealmAuditLog.objects.create(
|
||||||
|
realm=user.realm, modified_user=user, event_type=RealmAuditLog.USER_REACTIVATED,
|
||||||
|
event_time=self.TIME_ZERO, extra_data='data')
|
||||||
|
# Event type not in SYNCED_BILLING_EVENTS -- should not be included
|
||||||
|
RealmAuditLog.objects.create(
|
||||||
|
realm=user.realm, modified_user=user, event_type=RealmAuditLog.REALM_LOGO_CHANGED,
|
||||||
|
event_time=self.TIME_ZERO, extra_data='data')
|
||||||
|
|
||||||
|
def check_for_unwanted_data(*args: Any) -> Any:
|
||||||
|
if check_for_unwanted_data.first_call: # type: ignore
|
||||||
|
check_for_unwanted_data.first_call = False # type: ignore
|
||||||
|
else:
|
||||||
|
# Test that we're respecting SYNCED_BILLING_EVENTS
|
||||||
|
self.assertIn('"event_type":{}'.format(RealmAuditLog.USER_REACTIVATED), str(args))
|
||||||
|
self.assertNotIn('"event_type":{}'.format(RealmAuditLog.REALM_LOGO_CHANGED), str(args))
|
||||||
|
# Test that we're respecting REALMAUDITLOG_PUSHED_FIELDS
|
||||||
|
self.assertIn('backfilled', str(args))
|
||||||
|
self.assertNotIn('modified_user', str(args))
|
||||||
|
return send_to_push_bouncer(*args)
|
||||||
|
|
||||||
|
# send_analytics_to_remote_server calls send_to_push_bouncer twice.
|
||||||
|
# We need to distinguish the first and second calls.
|
||||||
|
check_for_unwanted_data.first_call = True # type: ignore
|
||||||
|
with mock.patch('zerver.lib.remote_server.send_to_push_bouncer',
|
||||||
|
side_effect=check_for_unwanted_data):
|
||||||
|
send_analytics_to_remote_server()
|
||||||
|
|
||||||
|
@override_settings(PUSH_NOTIFICATION_BOUNCER_URL='https://push.zulip.org.example.com')
|
||||||
|
@mock.patch('zerver.lib.push_notifications.requests.request')
|
||||||
|
def test_realmauditlog_data_mapping(self, mock_request: Any) -> None:
|
||||||
|
mock_request.side_effect = self.bounce_request
|
||||||
|
user = self.example_user('hamlet')
|
||||||
|
log_entry = RealmAuditLog.objects.create(
|
||||||
|
realm=user.realm, modified_user=user, backfilled=True,
|
||||||
|
event_type=RealmAuditLog.USER_REACTIVATED, event_time=self.TIME_ZERO, extra_data='data')
|
||||||
|
send_analytics_to_remote_server()
|
||||||
|
remote_log_entry = RemoteRealmAuditLog.objects.order_by('id').last()
|
||||||
|
self.assertEqual(remote_log_entry.server.uuid, self.server_uuid)
|
||||||
|
self.assertEqual(remote_log_entry.remote_id, log_entry.id)
|
||||||
|
self.assertEqual(remote_log_entry.event_time, self.TIME_ZERO)
|
||||||
|
self.assertEqual(remote_log_entry.backfilled, True)
|
||||||
|
self.assertEqual(remote_log_entry.extra_data, 'data')
|
||||||
|
self.assertEqual(remote_log_entry.event_type, RealmAuditLog.USER_REACTIVATED)
|
||||||
|
|
||||||
class PushNotificationTest(BouncerTestCase):
|
class PushNotificationTest(BouncerTestCase):
|
||||||
def setUp(self) -> None:
|
def setUp(self) -> None:
|
||||||
super().setUp()
|
super().setUp()
|
||||||
|
|
|
@ -0,0 +1,29 @@
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# Generated by Django 1.11.24 on 2019-10-03 00:10
|
||||||
|
from __future__ import unicode_literals
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
import django.db.models.deletion
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('zilencer', '0017_installationcount_indexes'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='RemoteRealmAuditLog',
|
||||||
|
fields=[
|
||||||
|
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('realm_id', models.IntegerField(db_index=True)),
|
||||||
|
('remote_id', models.IntegerField(db_index=True)),
|
||||||
|
('event_time', models.DateTimeField(db_index=True)),
|
||||||
|
('backfilled', models.BooleanField(default=False)),
|
||||||
|
('extra_data', models.TextField(null=True)),
|
||||||
|
('event_type', models.PositiveSmallIntegerField()),
|
||||||
|
('server', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='zilencer.RemoteZulipServer')),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
]
|
|
@ -2,7 +2,7 @@ import datetime
|
||||||
|
|
||||||
from django.db import models
|
from django.db import models
|
||||||
|
|
||||||
from zerver.models import AbstractPushDeviceToken
|
from zerver.models import AbstractPushDeviceToken, AbstractRealmAuditLog
|
||||||
from analytics.models import BaseCount
|
from analytics.models import BaseCount
|
||||||
|
|
||||||
def get_remote_server_by_uuid(uuid: str) -> 'RemoteZulipServer':
|
def get_remote_server_by_uuid(uuid: str) -> 'RemoteZulipServer':
|
||||||
|
@ -35,6 +35,19 @@ class RemotePushDeviceToken(AbstractPushDeviceToken):
|
||||||
def __str__(self) -> str:
|
def __str__(self) -> str:
|
||||||
return "<RemotePushDeviceToken %s %s>" % (self.server, self.user_id)
|
return "<RemotePushDeviceToken %s %s>" % (self.server, self.user_id)
|
||||||
|
|
||||||
|
class RemoteRealmAuditLog(AbstractRealmAuditLog):
|
||||||
|
"""Synced audit data from a remote Zulip server, used primarily for
|
||||||
|
billing. See RealmAuditLog and AbstractRealmAuditLog for details.
|
||||||
|
"""
|
||||||
|
server = models.ForeignKey(RemoteZulipServer, on_delete=models.CASCADE) # type: RemoteZulipServer
|
||||||
|
realm_id = models.IntegerField(db_index=True) # type: int
|
||||||
|
# The remote_id field lets us deduplicate data from the remote server
|
||||||
|
remote_id = models.IntegerField(db_index=True) # type: int
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
return "<RemoteRealmAuditLog: %s %s %s %s>" % (
|
||||||
|
self.server, self.event_type, self.event_time, self.id)
|
||||||
|
|
||||||
class RemoteInstallationCount(BaseCount):
|
class RemoteInstallationCount(BaseCount):
|
||||||
server = models.ForeignKey(RemoteZulipServer, on_delete=models.CASCADE) # type: RemoteZulipServer
|
server = models.ForeignKey(RemoteZulipServer, on_delete=models.CASCADE) # type: RemoteZulipServer
|
||||||
# The remote_id field lets us deduplicate data from the remote server
|
# The remote_id field lets us deduplicate data from the remote server
|
||||||
|
|
|
@ -19,7 +19,7 @@ v1_api_and_json_patterns = [
|
||||||
# Push signup doesn't use the REST API, since there's no auth.
|
# Push signup doesn't use the REST API, since there's no auth.
|
||||||
url('^remotes/server/register$', zilencer.views.register_remote_server),
|
url('^remotes/server/register$', zilencer.views.register_remote_server),
|
||||||
|
|
||||||
# For receiving InstallationCount data and similar analytics.
|
# For receiving table data used in analytics and billing
|
||||||
url('^remotes/server/analytics$', rest_dispatch,
|
url('^remotes/server/analytics$', rest_dispatch,
|
||||||
{'POST': 'zilencer.views.remote_server_post_analytics'}),
|
{'POST': 'zilencer.views.remote_server_post_analytics'}),
|
||||||
url('^remotes/server/analytics/status$', rest_dispatch,
|
url('^remotes/server/analytics/status$', rest_dispatch,
|
||||||
|
|
|
@ -20,11 +20,11 @@ from zerver.lib.request import REQ, has_request_variables
|
||||||
from zerver.lib.response import json_error, json_success
|
from zerver.lib.response import json_error, json_success
|
||||||
from zerver.lib.validator import check_int, check_string, \
|
from zerver.lib.validator import check_int, check_string, \
|
||||||
check_capped_string, check_string_fixed_length, check_float, check_none_or, \
|
check_capped_string, check_string_fixed_length, check_float, check_none_or, \
|
||||||
check_dict_only, check_list
|
check_dict_only, check_list, check_bool
|
||||||
from zerver.models import UserProfile
|
from zerver.models import UserProfile
|
||||||
from zerver.views.push_notifications import validate_token
|
from zerver.views.push_notifications import validate_token
|
||||||
from zilencer.models import RemotePushDeviceToken, RemoteZulipServer, \
|
from zilencer.models import RemotePushDeviceToken, RemoteZulipServer, \
|
||||||
RemoteRealmCount, RemoteInstallationCount
|
RemoteRealmCount, RemoteInstallationCount, RemoteRealmAuditLog
|
||||||
|
|
||||||
def validate_entity(entity: Union[UserProfile, RemoteZulipServer]) -> RemoteZulipServer:
|
def validate_entity(entity: Union[UserProfile, RemoteZulipServer]) -> RemoteZulipServer:
|
||||||
if not isinstance(entity, RemoteZulipServer):
|
if not isinstance(entity, RemoteZulipServer):
|
||||||
|
@ -192,11 +192,22 @@ def remote_server_post_analytics(request: HttpRequest,
|
||||||
('end_time', check_float),
|
('end_time', check_float),
|
||||||
('subgroup', check_none_or(check_string)),
|
('subgroup', check_none_or(check_string)),
|
||||||
('value', check_int),
|
('value', check_int),
|
||||||
])))) -> HttpResponse:
|
]))),
|
||||||
|
realmauditlog_rows: Optional[List[Dict[str, Any]]]=REQ(
|
||||||
|
validator=check_list(check_dict_only([
|
||||||
|
('id', check_int),
|
||||||
|
('realm', check_int),
|
||||||
|
('event_time', check_float),
|
||||||
|
('backfilled', check_bool),
|
||||||
|
('extra_data', check_none_or(check_string)),
|
||||||
|
('event_type', check_int),
|
||||||
|
])), default=None)) -> HttpResponse:
|
||||||
server = validate_entity(entity)
|
server = validate_entity(entity)
|
||||||
|
|
||||||
validate_incoming_table_data(server, RemoteRealmCount, realm_counts, True)
|
validate_incoming_table_data(server, RemoteRealmCount, realm_counts, True)
|
||||||
validate_incoming_table_data(server, RemoteInstallationCount, installation_counts, True)
|
validate_incoming_table_data(server, RemoteInstallationCount, installation_counts, True)
|
||||||
|
if realmauditlog_rows is not None:
|
||||||
|
validate_incoming_table_data(server, RemoteRealmAuditLog, realmauditlog_rows)
|
||||||
|
|
||||||
row_objects = [RemoteRealmCount(
|
row_objects = [RemoteRealmCount(
|
||||||
property=row['property'],
|
property=row['property'],
|
||||||
|
@ -217,6 +228,17 @@ def remote_server_post_analytics(request: HttpRequest,
|
||||||
value=row['value']) for row in installation_counts]
|
value=row['value']) for row in installation_counts]
|
||||||
batch_create_table_data(server, RemoteInstallationCount, row_objects)
|
batch_create_table_data(server, RemoteInstallationCount, row_objects)
|
||||||
|
|
||||||
|
if realmauditlog_rows is not None:
|
||||||
|
row_objects = [RemoteRealmAuditLog(
|
||||||
|
realm_id=row['realm'],
|
||||||
|
remote_id=row['id'],
|
||||||
|
server=server,
|
||||||
|
event_time=datetime.datetime.fromtimestamp(row['event_time'], tz=timezone_utc),
|
||||||
|
backfilled=row['backfilled'],
|
||||||
|
extra_data=row['extra_data'],
|
||||||
|
event_type=row['event_type']) for row in realmauditlog_rows]
|
||||||
|
batch_create_table_data(server, RemoteRealmAuditLog, row_objects)
|
||||||
|
|
||||||
return json_success()
|
return json_success()
|
||||||
|
|
||||||
def get_last_id_from_server(server: RemoteZulipServer, model: Any) -> int:
|
def get_last_id_from_server(server: RemoteZulipServer, model: Any) -> int:
|
||||||
|
@ -234,5 +256,7 @@ def remote_server_check_analytics(request: HttpRequest,
|
||||||
'last_realm_count_id': get_last_id_from_server(server, RemoteRealmCount),
|
'last_realm_count_id': get_last_id_from_server(server, RemoteRealmCount),
|
||||||
'last_installation_count_id': get_last_id_from_server(
|
'last_installation_count_id': get_last_id_from_server(
|
||||||
server, RemoteInstallationCount),
|
server, RemoteInstallationCount),
|
||||||
|
'last_realmauditlog_id': get_last_id_from_server(
|
||||||
|
server, RemoteRealmAuditLog),
|
||||||
}
|
}
|
||||||
return json_success(result)
|
return json_success(result)
|
||||||
|
|
Loading…
Reference in New Issue