mirror of
				https://github.com/zulip/zulip.git
				synced 2025-10-31 12:03:46 +00:00 
			
		
		
		
	alert_words: Move alert_words from UserProfile to separate model.
Previously, alert words were a JSON list of strings stored in a TextField on user_profile. That hacky model reflected the fact that they were an early prototype feature. This commit migrates from that to a separate table, 'AlertWord'. The new AlertWord has user_profile, word, id and realm(denormalization so we can provide a nice index for fetching all the alert words in a realm). This transition requires moving the logic for flushing the Alert Words caches to their own independent feature. Note that this commit should not be cherry-picked without the following commit, which fixes case-sensitivity issues with Alert Words.
This commit is contained in:
		
				
					committed by
					
						 Tim Abbott
						Tim Abbott
					
				
			
			
				
	
			
			
			
						parent
						
							818776faae
						
					
				
				
					commit
					052368bd3e
				
			| @@ -1,17 +1,17 @@ | ||||
| from django.db.models import Q | ||||
| from zerver.models import UserProfile, Realm | ||||
| from zerver.models import UserProfile, Realm, AlertWord | ||||
| from zerver.lib.cache import cache_with_key, realm_alert_words_cache_key, \ | ||||
|     realm_alert_words_automaton_cache_key | ||||
| import ujson | ||||
| import ahocorasick | ||||
| from typing import Dict, Iterable, List | ||||
|  | ||||
| @cache_with_key(realm_alert_words_cache_key, timeout=3600*24) | ||||
| def alert_words_in_realm(realm: Realm) -> Dict[int, List[str]]: | ||||
|     users_query = UserProfile.objects.filter(realm=realm, is_active=True) | ||||
|     alert_word_data = users_query.filter(~Q(alert_words=ujson.dumps([]))).values('id', 'alert_words') | ||||
|     all_user_words = {elt['id']: ujson.loads(elt['alert_words']) for elt in alert_word_data} | ||||
|     user_ids_with_words = {user_id: w for (user_id, w) in all_user_words.items() if len(w)} | ||||
|     user_ids_and_words = AlertWord.objects.filter( | ||||
|         realm=realm, user_profile__is_active=True).values("user_profile_id", "word") | ||||
|     user_ids_with_words = dict()  # type: Dict[int, List[str]] | ||||
|     for id_and_word in user_ids_and_words: | ||||
|         user_ids_with_words.setdefault(id_and_word["user_profile_id"], []) | ||||
|         user_ids_with_words[id_and_word["user_profile_id"]].append(id_and_word["word"]) | ||||
|     return user_ids_with_words | ||||
|  | ||||
| @cache_with_key(realm_alert_words_automaton_cache_key, timeout=3600*24) | ||||
| @@ -36,26 +36,24 @@ def get_alert_word_automaton(realm: Realm) -> ahocorasick.Automaton: | ||||
|     return alert_word_automaton | ||||
|  | ||||
| def user_alert_words(user_profile: UserProfile) -> List[str]: | ||||
|     return ujson.loads(user_profile.alert_words) | ||||
|     return list(AlertWord.objects.filter(user_profile=user_profile).values_list("word", flat=True)) | ||||
|  | ||||
| def add_user_alert_words(user_profile: UserProfile, alert_words: Iterable[str]) -> List[str]: | ||||
|     words = user_alert_words(user_profile) | ||||
|  | ||||
|     new_words = [w for w in alert_words if w not in words] | ||||
|     words.extend(new_words) | ||||
|  | ||||
|     set_user_alert_words(user_profile, words) | ||||
|     # to avoid duplication of words | ||||
|     new_words = list(set(new_words)) | ||||
|  | ||||
|     return words | ||||
|     AlertWord.objects.bulk_create( | ||||
|         AlertWord(user_profile=user_profile, word=word, realm=user_profile.realm) for word in new_words) | ||||
|  | ||||
|     return words+new_words | ||||
|  | ||||
| def remove_user_alert_words(user_profile: UserProfile, alert_words: Iterable[str]) -> List[str]: | ||||
|     words = user_alert_words(user_profile) | ||||
|     words = [w for w in words if w not in alert_words] | ||||
|     delete_words = [w for w in alert_words if w in words] | ||||
|     AlertWord.objects.filter(user_profile=user_profile, word__in=delete_words).delete() | ||||
|  | ||||
|     set_user_alert_words(user_profile, words) | ||||
|  | ||||
|     return words | ||||
|  | ||||
| def set_user_alert_words(user_profile: UserProfile, alert_words: List[str]) -> None: | ||||
|     user_profile.alert_words = ujson.dumps(alert_words) | ||||
|     user_profile.save(update_fields=['alert_words']) | ||||
|     return user_alert_words(user_profile) | ||||
|   | ||||
| @@ -540,12 +540,6 @@ def flush_user_profile(sender: Any, **kwargs: Any) -> None: | ||||
|     if user_profile.is_bot and changed(kwargs, bot_dict_fields): | ||||
|         cache_delete(bot_dicts_in_realm_cache_key(user_profile.realm)) | ||||
|  | ||||
|     # Invalidate realm-wide alert words cache if any user in the realm has changed | ||||
|     # alert words | ||||
|     if changed(kwargs, ['alert_words']): | ||||
|         cache_delete(realm_alert_words_cache_key(user_profile.realm)) | ||||
|         cache_delete(realm_alert_words_automaton_cache_key(user_profile.realm)) | ||||
|  | ||||
| # Called by models.py to flush various caches whenever we save | ||||
| # a Realm object.  The main tricky thing here is that Realm info is | ||||
| # generally cached indirectly through user_profile objects. | ||||
|   | ||||
| @@ -77,6 +77,7 @@ ALL_ZULIP_TABLES = { | ||||
|     'social_auth_partial', | ||||
|     'social_auth_usersocialauth', | ||||
|     'two_factor_phonedevice', | ||||
|     'zerver_alertword', | ||||
|     'zerver_archivedattachment', | ||||
|     'zerver_archivedattachment_messages', | ||||
|     'zerver_archivedmessage', | ||||
|   | ||||
| @@ -58,7 +58,7 @@ from zerver.models import ( | ||||
| from typing import Any, Dict, List, Optional, Set, Tuple, Sequence | ||||
| from typing_extensions import TypedDict | ||||
|  | ||||
| RealmAlertWords = Dict[int, List[str]] | ||||
| RealmAlertWord = Dict[int, List[str]] | ||||
|  | ||||
| RawUnreadMessagesResult = TypedDict('RawUnreadMessagesResult', { | ||||
|     'pm_dict': Dict[int, Any], | ||||
|   | ||||
							
								
								
									
										27
									
								
								zerver/migrations/0276_alertword.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										27
									
								
								zerver/migrations/0276_alertword.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,27 @@ | ||||
| # -*- coding: utf-8 -*- | ||||
|  | ||||
| from django.conf import settings | ||||
| from django.db import migrations, models | ||||
| import django.db.models.deletion | ||||
|  | ||||
|  | ||||
| class Migration(migrations.Migration): | ||||
|  | ||||
|     dependencies = [ | ||||
|         ('zerver', '0275_remove_userprofile_last_pointer_updater'), | ||||
|     ] | ||||
|  | ||||
|     operations = [ | ||||
|         migrations.CreateModel( | ||||
|             name='AlertWord', | ||||
|             fields=[ | ||||
|                 ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), | ||||
|                 ('word', models.TextField()), | ||||
|                 ('realm', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='zerver.Realm')), | ||||
|                 ('user_profile', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), | ||||
|             ], | ||||
|             options={ | ||||
|                 'unique_together': {('user_profile', 'word')}, | ||||
|             }, | ||||
|         ), | ||||
|     ] | ||||
							
								
								
									
										43
									
								
								zerver/migrations/0277_migrate_alert_word.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										43
									
								
								zerver/migrations/0277_migrate_alert_word.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,43 @@ | ||||
| # -*- coding: utf-8 -*- | ||||
|  | ||||
| from django.db import migrations | ||||
| from django.db.backends.postgresql_psycopg2.schema import DatabaseSchemaEditor | ||||
| from django.db.migrations.state import StateApps | ||||
| import ujson | ||||
| from typing import Dict, List | ||||
|  | ||||
| def move_to_seperate_table(apps: StateApps, schema_editor: DatabaseSchemaEditor) -> None: | ||||
|     UserProfile = apps.get_model('zerver', 'UserProfile') | ||||
|     AlertWord = apps.get_model('zerver', 'AlertWord') | ||||
|  | ||||
|     for user_profile in UserProfile.objects.all(): | ||||
|  | ||||
|         list_of_words = ujson.loads(user_profile.alert_words) | ||||
|         AlertWord.objects.bulk_create( | ||||
|             AlertWord(user_profile=user_profile, word=word, realm=user_profile.realm) for word in list_of_words) | ||||
|  | ||||
| def move_back_to_user_profile(apps: StateApps, schema_editor: DatabaseSchemaEditor) -> None: | ||||
|     AlertWord = apps.get_model('zerver', 'AlertWord') | ||||
|     UserProfile = apps.get_model('zerver', 'UserProfile') | ||||
|  | ||||
|     user_ids_and_words = AlertWord.objects.all().values("user_profile_id", "word") | ||||
|     user_ids_with_words = dict()  # type: Dict[int, List[str]] | ||||
|  | ||||
|     for id_and_word in user_ids_and_words: | ||||
|         user_ids_with_words.setdefault(id_and_word["user_profile_id"], []) | ||||
|         user_ids_with_words[id_and_word["user_profile_id"]].append(id_and_word["word"]) | ||||
|  | ||||
|     for (user_id, words) in user_ids_with_words.items(): | ||||
|         user_profile = UserProfile.objects.get(id=user_id) | ||||
|         user_profile.alert_words = ujson.dumps(words) | ||||
|         user_profile.save(update_fields=['alert_words']) | ||||
|  | ||||
| class Migration(migrations.Migration): | ||||
|  | ||||
|     dependencies = [ | ||||
|         ('zerver', '0276_alertword'), | ||||
|     ] | ||||
|  | ||||
|     operations = [ | ||||
|         migrations.RunPython(move_to_seperate_table, move_back_to_user_profile) | ||||
|     ] | ||||
							
								
								
									
										17
									
								
								zerver/migrations/0278_remove_userprofile_alert_words.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										17
									
								
								zerver/migrations/0278_remove_userprofile_alert_words.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,17 @@ | ||||
| # Generated by Django 2.2.10 on 2020-03-23 20:08 | ||||
|  | ||||
| from django.db import migrations | ||||
|  | ||||
|  | ||||
| class Migration(migrations.Migration): | ||||
|  | ||||
|     dependencies = [ | ||||
|         ('zerver', '0277_migrate_alert_word'), | ||||
|     ] | ||||
|  | ||||
|     operations = [ | ||||
|         migrations.RemoveField( | ||||
|             model_name='userprofile', | ||||
|             name='alert_words', | ||||
|         ), | ||||
|     ] | ||||
| @@ -19,12 +19,13 @@ from zerver.lib.cache import cache_with_key, flush_user_profile, flush_realm, \ | ||||
|     get_stream_cache_key, realm_user_dicts_cache_key, \ | ||||
|     bot_dicts_in_realm_cache_key, realm_user_dict_fields, \ | ||||
|     bot_dict_fields, flush_message, flush_submessage, bot_profile_cache_key, \ | ||||
|     flush_used_upload_space_cache, get_realm_used_upload_space_cache_key | ||||
|     flush_used_upload_space_cache, get_realm_used_upload_space_cache_key, \ | ||||
|     realm_alert_words_cache_key, realm_alert_words_automaton_cache_key | ||||
| from zerver.lib.utils import make_safe_digest, generate_random_token | ||||
| from django.db import transaction | ||||
| from django.utils.timezone import now as timezone_now | ||||
| from zerver.lib.timestamp import datetime_to_timestamp | ||||
| from django.db.models.signals import post_save, post_delete | ||||
| from django.db.models.signals import post_save, post_delete, post_init | ||||
| from django.utils.translation import ugettext_lazy as _ | ||||
| from zerver.lib import cache | ||||
| from zerver.lib.validator import check_int, \ | ||||
| @@ -916,9 +917,6 @@ class UserProfile(AbstractBaseUser, PermissionsMixin): | ||||
|     enable_login_emails: bool = models.BooleanField(default=True) | ||||
|     realm_name_in_notifications: bool = models.BooleanField(default=False) | ||||
|  | ||||
|     # Words that trigger a mention for this user, formatted as a json-serialized list of strings | ||||
|     alert_words: str = models.TextField(default='[]') | ||||
|  | ||||
|     # Used for rate-limiting certain automated messages generated by bots | ||||
|     last_reminder: Optional[datetime.datetime] = models.DateTimeField(default=None, null=True) | ||||
|  | ||||
| @@ -2888,3 +2886,24 @@ def get_fake_email_domain() -> str: | ||||
|         raise InvalidFakeEmailDomain(settings.FAKE_EMAIL_DOMAIN + ' is not a valid domain.') | ||||
|  | ||||
|     return settings.FAKE_EMAIL_DOMAIN | ||||
|  | ||||
| class AlertWord(models.Model): | ||||
|     # Realm isn't necessary, but it's a nice denormalization.  Users | ||||
|     # never move to another realm, so it's static, and having Realm | ||||
|     # here optimizes the main query on this table, which is fetching | ||||
|     # all the alert words in a realm. | ||||
|     realm = models.ForeignKey(Realm, db_index=True, on_delete=CASCADE)  # type: Realm | ||||
|     user_profile = models.ForeignKey(UserProfile, on_delete=CASCADE)  # type: UserProfile | ||||
|     word = models.TextField()   # type: str | ||||
|  | ||||
|     class Meta: | ||||
|         unique_together = ("user_profile", "word") | ||||
|  | ||||
| def flush_alert_word(sender: Any, **kwargs: Any) -> None: | ||||
|     realm = kwargs['instance'].realm | ||||
|     cache_delete(realm_alert_words_cache_key(realm)) | ||||
|     cache_delete(realm_alert_words_automaton_cache_key(realm)) | ||||
|  | ||||
|  | ||||
| post_init.connect(flush_alert_word, sender=AlertWord) | ||||
| post_delete.connect(flush_alert_word, sender=AlertWord) | ||||
|   | ||||
| @@ -3632,10 +3632,10 @@ class FetchQueriesTest(ZulipTestCase): | ||||
|                     client_gravatar=False, | ||||
|                 ) | ||||
|  | ||||
|         self.assert_length(queries, 31) | ||||
|         self.assert_length(queries, 32) | ||||
|  | ||||
|         expected_counts = dict( | ||||
|             alert_words=0, | ||||
|             alert_words=1, | ||||
|             custom_profile_fields=1, | ||||
|             default_streams=1, | ||||
|             default_stream_groups=1, | ||||
|   | ||||
| @@ -249,7 +249,7 @@ class HomeTest(ZulipTestCase): | ||||
|         self.assertEqual(set(result["Cache-Control"].split(", ")), | ||||
|                          {"must-revalidate", "no-store", "no-cache"}) | ||||
|  | ||||
|         self.assert_length(queries, 42) | ||||
|         self.assert_length(queries, 43) | ||||
|         self.assert_length(cache_mock.call_args_list, 5) | ||||
|  | ||||
|         html = result.content.decode('utf-8') | ||||
| @@ -315,7 +315,7 @@ class HomeTest(ZulipTestCase): | ||||
|                 result = self._get_home_page() | ||||
|                 self.assertEqual(result.status_code, 200) | ||||
|                 self.assert_length(cache_mock.call_args_list, 6) | ||||
|             self.assert_length(queries, 40) | ||||
|             self.assert_length(queries, 41) | ||||
|  | ||||
|     @slow("Creates and subscribes 10 users in a loop.  Should use bulk queries.") | ||||
|     def test_num_queries_with_streams(self) -> None: | ||||
| @@ -347,7 +347,7 @@ class HomeTest(ZulipTestCase): | ||||
|         with queries_captured() as queries2: | ||||
|             result = self._get_home_page() | ||||
|  | ||||
|         self.assert_length(queries2, 37) | ||||
|         self.assert_length(queries2, 38) | ||||
|  | ||||
|         # Do a sanity check that our new streams were in the payload. | ||||
|         html = result.content.decode('utf-8') | ||||
|   | ||||
		Reference in New Issue
	
	Block a user