diff options
Diffstat (limited to 'group_messaging')
-rw-r--r-- | group_messaging/__init__.py | 14 | ||||
-rw-r--r-- | group_messaging/migrations/0001_initial.py | 177 | ||||
-rw-r--r-- | group_messaging/migrations/0002_auto__add_lastvisittime__add_unique_lastvisittime_user_message__add_fi.py | 142 | ||||
-rw-r--r-- | group_messaging/migrations/__init__.py | 0 | ||||
-rw-r--r-- | group_messaging/models.py | 249 | ||||
-rw-r--r-- | group_messaging/tests.py | 118 | ||||
-rw-r--r-- | group_messaging/urls.py | 32 | ||||
-rw-r--r-- | group_messaging/views.py | 214 |
8 files changed, 946 insertions, 0 deletions
diff --git a/group_messaging/__init__.py b/group_messaging/__init__.py new file mode 100644 index 00000000..642ad5c8 --- /dev/null +++ b/group_messaging/__init__.py @@ -0,0 +1,14 @@ +"""`group_messages` is a django application +which allows users send messages to other users +and groups (instances of :class:`django.contrib.auth.models.Group`) + +The same methods are used are used to send messages +to users as to groups - achieved via special "personal groups". + +By convention - personal groups have names formatted as follows: +_personal_<user id>, for example for the user whose `id == 1`, +the group should be named `'_personal_1'`. + +Only one person must be a member of a personal group and +each user must have such group. +""" diff --git a/group_messaging/migrations/0001_initial.py b/group_messaging/migrations/0001_initial.py new file mode 100644 index 00000000..7d907dc1 --- /dev/null +++ b/group_messaging/migrations/0001_initial.py @@ -0,0 +1,177 @@ +# -*- coding: utf-8 -*- +import datetime +from south.db import db +from south.v2 import SchemaMigration +from django.db import models + + +class Migration(SchemaMigration): + + def forwards(self, orm): + # Adding model 'SenderList' + db.create_table('group_messaging_senderlist', ( + ('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)), + ('recipient', self.gf('django.db.models.fields.related.ForeignKey')(to=orm['auth.Group'], unique=True)), + )) + db.send_create_signal('group_messaging', ['SenderList']) + + # Adding M2M table for field senders on 'SenderList' + db.create_table('group_messaging_senderlist_senders', ( + ('id', models.AutoField(verbose_name='ID', primary_key=True, auto_created=True)), + ('senderlist', models.ForeignKey(orm['group_messaging.senderlist'], null=False)), + ('user', models.ForeignKey(orm['auth.user'], null=False)) + )) + db.create_unique('group_messaging_senderlist_senders', ['senderlist_id', 'user_id']) + + # Adding model 'MessageMemo' + db.create_table('group_messaging_messagememo', ( + ('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)), + ('user', self.gf('django.db.models.fields.related.ForeignKey')(to=orm['auth.User'])), + ('message', self.gf('django.db.models.fields.related.ForeignKey')(to=orm['group_messaging.Message'])), + ('status', self.gf('django.db.models.fields.SmallIntegerField')(default=0)), + )) + db.send_create_signal('group_messaging', ['MessageMemo']) + + # Adding unique constraint on 'MessageMemo', fields ['user', 'message'] + db.create_unique('group_messaging_messagememo', ['user_id', 'message_id']) + + # Adding model 'Message' + db.create_table('group_messaging_message', ( + ('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)), + ('message_type', self.gf('django.db.models.fields.SmallIntegerField')(default=0)), + ('sender', self.gf('django.db.models.fields.related.ForeignKey')(related_name='sent_messages', to=orm['auth.User'])), + ('root', self.gf('django.db.models.fields.related.ForeignKey')(blank=True, related_name='descendants', null=True, to=orm['group_messaging.Message'])), + ('parent', self.gf('django.db.models.fields.related.ForeignKey')(blank=True, related_name='children', null=True, to=orm['group_messaging.Message'])), + ('headline', self.gf('django.db.models.fields.CharField')(max_length=80)), + ('text', self.gf('django.db.models.fields.TextField')(null=True, blank=True)), + ('html', self.gf('django.db.models.fields.TextField')(null=True, blank=True)), + ('sent_at', self.gf('django.db.models.fields.DateTimeField')(auto_now_add=True, blank=True)), + ('last_active_at', self.gf('django.db.models.fields.DateTimeField')(auto_now_add=True, blank=True)), + ('active_until', self.gf('django.db.models.fields.DateTimeField')(null=True, blank=True)), + )) + db.send_create_signal('group_messaging', ['Message']) + + # Adding M2M table for field recipients on 'Message' + db.create_table('group_messaging_message_recipients', ( + ('id', models.AutoField(verbose_name='ID', primary_key=True, auto_created=True)), + ('message', models.ForeignKey(orm['group_messaging.message'], null=False)), + ('group', models.ForeignKey(orm['auth.group'], null=False)) + )) + db.create_unique('group_messaging_message_recipients', ['message_id', 'group_id']) + + def backwards(self, orm): + # Removing unique constraint on 'MessageMemo', fields ['user', 'message'] + db.delete_unique('group_messaging_messagememo', ['user_id', 'message_id']) + + # Deleting model 'SenderList' + db.delete_table('group_messaging_senderlist') + + # Removing M2M table for field senders on 'SenderList' + db.delete_table('group_messaging_senderlist_senders') + + # Deleting model 'MessageMemo' + db.delete_table('group_messaging_messagememo') + + # Deleting model 'Message' + db.delete_table('group_messaging_message') + + # Removing M2M table for field recipients on 'Message' + db.delete_table('group_messaging_message_recipients') + + models = { + 'auth.group': { + 'Meta': {'object_name': 'Group'}, + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '80'}), + 'permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}) + }, + 'auth.permission': { + 'Meta': {'ordering': "('content_type__app_label', 'content_type__model', 'codename')", 'unique_together': "(('content_type', 'codename'),)", 'object_name': 'Permission'}, + 'codename': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '50'}) + }, + 'auth.user': { + 'Meta': {'object_name': 'User'}, + 'about': ('django.db.models.fields.TextField', [], {'blank': 'True'}), + 'avatar_type': ('django.db.models.fields.CharField', [], {'default': "'n'", 'max_length': '1'}), + 'bronze': ('django.db.models.fields.SmallIntegerField', [], {'default': '0'}), + 'consecutive_days_visit_count': ('django.db.models.fields.IntegerField', [], {'default': '0'}), + 'country': ('django_countries.fields.CountryField', [], {'max_length': '2', 'blank': 'True'}), + 'date_joined': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), + 'date_of_birth': ('django.db.models.fields.DateField', [], {'null': 'True', 'blank': 'True'}), + 'display_tag_filter_strategy': ('django.db.models.fields.SmallIntegerField', [], {'default': '0'}), + 'email': ('django.db.models.fields.EmailField', [], {'max_length': '75', 'blank': 'True'}), + 'email_isvalid': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'email_key': ('django.db.models.fields.CharField', [], {'max_length': '32', 'null': 'True'}), + 'email_signature': ('django.db.models.fields.TextField', [], {'blank': 'True'}), + 'email_tag_filter_strategy': ('django.db.models.fields.SmallIntegerField', [], {'default': '1'}), + 'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), + 'gold': ('django.db.models.fields.SmallIntegerField', [], {'default': '0'}), + 'gravatar': ('django.db.models.fields.CharField', [], {'max_length': '32'}), + 'groups': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Group']", 'symmetrical': 'False', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'ignored_tags': ('django.db.models.fields.TextField', [], {'blank': 'True'}), + 'interesting_tags': ('django.db.models.fields.TextField', [], {'blank': 'True'}), + 'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), + 'is_fake': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'is_superuser': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'last_login': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), + 'last_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), + 'last_seen': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), + 'location': ('django.db.models.fields.CharField', [], {'max_length': '100', 'blank': 'True'}), + 'new_response_count': ('django.db.models.fields.IntegerField', [], {'default': '0'}), + 'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}), + 'questions_per_page': ('django.db.models.fields.SmallIntegerField', [], {'default': '10'}), + 'real_name': ('django.db.models.fields.CharField', [], {'max_length': '100', 'blank': 'True'}), + 'reputation': ('django.db.models.fields.PositiveIntegerField', [], {'default': '1'}), + 'seen_response_count': ('django.db.models.fields.IntegerField', [], {'default': '0'}), + 'show_country': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'show_marked_tags': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), + 'silver': ('django.db.models.fields.SmallIntegerField', [], {'default': '0'}), + 'status': ('django.db.models.fields.CharField', [], {'default': "'w'", 'max_length': '2'}), + 'subscribed_tags': ('django.db.models.fields.TextField', [], {'blank': 'True'}), + 'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}), + 'username': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '255'}), + 'website': ('django.db.models.fields.URLField', [], {'max_length': '200', 'blank': 'True'}) + }, + 'contenttypes.contenttype': { + 'Meta': {'ordering': "('name',)", 'unique_together': "(('app_label', 'model'),)", 'object_name': 'ContentType', 'db_table': "'django_content_type'"}, + 'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '100'}) + }, + 'group_messaging.message': { + 'Meta': {'object_name': 'Message'}, + 'active_until': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'blank': 'True'}), + 'headline': ('django.db.models.fields.CharField', [], {'max_length': '80'}), + 'html': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'last_active_at': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}), + 'message_type': ('django.db.models.fields.SmallIntegerField', [], {'default': '0'}), + 'parent': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'children'", 'null': 'True', 'to': "orm['group_messaging.Message']"}), + 'recipients': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Group']", 'symmetrical': 'False'}), + 'root': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'descendants'", 'null': 'True', 'to': "orm['group_messaging.Message']"}), + 'sender': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'sent_messages'", 'to': "orm['auth.User']"}), + 'sent_at': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}), + 'text': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}) + }, + 'group_messaging.messagememo': { + 'Meta': {'unique_together': "(('user', 'message'),)", 'object_name': 'MessageMemo'}, + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'message': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['group_messaging.Message']"}), + 'status': ('django.db.models.fields.SmallIntegerField', [], {'default': '0'}), + 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}) + }, + 'group_messaging.senderlist': { + 'Meta': {'object_name': 'SenderList'}, + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'recipient': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.Group']", 'unique': 'True'}), + 'senders': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.User']", 'symmetrical': 'False'}) + } + } + + complete_apps = ['group_messaging']
\ No newline at end of file diff --git a/group_messaging/migrations/0002_auto__add_lastvisittime__add_unique_lastvisittime_user_message__add_fi.py b/group_messaging/migrations/0002_auto__add_lastvisittime__add_unique_lastvisittime_user_message__add_fi.py new file mode 100644 index 00000000..5e92ef2b --- /dev/null +++ b/group_messaging/migrations/0002_auto__add_lastvisittime__add_unique_lastvisittime_user_message__add_fi.py @@ -0,0 +1,142 @@ +# -*- coding: utf-8 -*- +import datetime +from south.db import db +from south.v2 import SchemaMigration +from django.db import models + + +class Migration(SchemaMigration): + + def forwards(self, orm): + # Adding model 'LastVisitTime' + db.create_table('group_messaging_lastvisittime', ( + ('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)), + ('user', self.gf('django.db.models.fields.related.ForeignKey')(to=orm['auth.User'])), + ('message', self.gf('django.db.models.fields.related.ForeignKey')(to=orm['group_messaging.Message'])), + ('at', self.gf('django.db.models.fields.DateTimeField')(auto_now_add=True, blank=True)), + )) + db.send_create_signal('group_messaging', ['LastVisitTime']) + + # Adding unique constraint on 'LastVisitTime', fields ['user', 'message'] + db.create_unique('group_messaging_lastvisittime', ['user_id', 'message_id']) + + # Adding field 'Message.senders_info' + db.add_column('group_messaging_message', 'senders_info', + self.gf('django.db.models.fields.CharField')(default='', max_length=64), + keep_default=False) + + def backwards(self, orm): + # Removing unique constraint on 'LastVisitTime', fields ['user', 'message'] + db.delete_unique('group_messaging_lastvisittime', ['user_id', 'message_id']) + + # Deleting model 'LastVisitTime' + db.delete_table('group_messaging_lastvisittime') + + # Deleting field 'Message.senders_info' + db.delete_column('group_messaging_message', 'senders_info') + + models = { + 'auth.group': { + 'Meta': {'object_name': 'Group'}, + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '80'}), + 'permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}) + }, + 'auth.permission': { + 'Meta': {'ordering': "('content_type__app_label', 'content_type__model', 'codename')", 'unique_together': "(('content_type', 'codename'),)", 'object_name': 'Permission'}, + 'codename': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '50'}) + }, + 'auth.user': { + 'Meta': {'object_name': 'User'}, + 'about': ('django.db.models.fields.TextField', [], {'blank': 'True'}), + 'avatar_type': ('django.db.models.fields.CharField', [], {'default': "'n'", 'max_length': '1'}), + 'bronze': ('django.db.models.fields.SmallIntegerField', [], {'default': '0'}), + 'consecutive_days_visit_count': ('django.db.models.fields.IntegerField', [], {'default': '0'}), + 'country': ('django_countries.fields.CountryField', [], {'max_length': '2', 'blank': 'True'}), + 'date_joined': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), + 'date_of_birth': ('django.db.models.fields.DateField', [], {'null': 'True', 'blank': 'True'}), + 'display_tag_filter_strategy': ('django.db.models.fields.SmallIntegerField', [], {'default': '0'}), + 'email': ('django.db.models.fields.EmailField', [], {'max_length': '75', 'blank': 'True'}), + 'email_isvalid': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'email_key': ('django.db.models.fields.CharField', [], {'max_length': '32', 'null': 'True'}), + 'email_signature': ('django.db.models.fields.TextField', [], {'blank': 'True'}), + 'email_tag_filter_strategy': ('django.db.models.fields.SmallIntegerField', [], {'default': '1'}), + 'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), + 'gold': ('django.db.models.fields.SmallIntegerField', [], {'default': '0'}), + 'gravatar': ('django.db.models.fields.CharField', [], {'max_length': '32'}), + 'groups': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Group']", 'symmetrical': 'False', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'ignored_tags': ('django.db.models.fields.TextField', [], {'blank': 'True'}), + 'interesting_tags': ('django.db.models.fields.TextField', [], {'blank': 'True'}), + 'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), + 'is_fake': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'is_superuser': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'last_login': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), + 'last_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), + 'last_seen': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), + 'location': ('django.db.models.fields.CharField', [], {'max_length': '100', 'blank': 'True'}), + 'new_response_count': ('django.db.models.fields.IntegerField', [], {'default': '0'}), + 'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}), + 'questions_per_page': ('django.db.models.fields.SmallIntegerField', [], {'default': '10'}), + 'real_name': ('django.db.models.fields.CharField', [], {'max_length': '100', 'blank': 'True'}), + 'reputation': ('django.db.models.fields.PositiveIntegerField', [], {'default': '1'}), + 'seen_response_count': ('django.db.models.fields.IntegerField', [], {'default': '0'}), + 'show_country': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'show_marked_tags': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), + 'silver': ('django.db.models.fields.SmallIntegerField', [], {'default': '0'}), + 'status': ('django.db.models.fields.CharField', [], {'default': "'w'", 'max_length': '2'}), + 'subscribed_tags': ('django.db.models.fields.TextField', [], {'blank': 'True'}), + 'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}), + 'username': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '255'}), + 'website': ('django.db.models.fields.URLField', [], {'max_length': '200', 'blank': 'True'}) + }, + 'contenttypes.contenttype': { + 'Meta': {'ordering': "('name',)", 'unique_together': "(('app_label', 'model'),)", 'object_name': 'ContentType', 'db_table': "'django_content_type'"}, + 'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '100'}) + }, + 'group_messaging.lastvisittime': { + 'Meta': {'unique_together': "(('user', 'message'),)", 'object_name': 'LastVisitTime'}, + 'at': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'message': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['group_messaging.Message']"}), + 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}) + }, + 'group_messaging.message': { + 'Meta': {'object_name': 'Message'}, + 'active_until': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'blank': 'True'}), + 'headline': ('django.db.models.fields.CharField', [], {'max_length': '80'}), + 'html': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'last_active_at': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}), + 'message_type': ('django.db.models.fields.SmallIntegerField', [], {'default': '0'}), + 'parent': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'children'", 'null': 'True', 'to': "orm['group_messaging.Message']"}), + 'recipients': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Group']", 'symmetrical': 'False'}), + 'root': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'descendants'", 'null': 'True', 'to': "orm['group_messaging.Message']"}), + 'sender': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'sent_messages'", 'to': "orm['auth.User']"}), + 'senders_info': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '64'}), + 'sent_at': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}), + 'text': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}) + }, + 'group_messaging.messagememo': { + 'Meta': {'unique_together': "(('user', 'message'),)", 'object_name': 'MessageMemo'}, + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'message': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['group_messaging.Message']"}), + 'status': ('django.db.models.fields.SmallIntegerField', [], {'default': '0'}), + 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}) + }, + 'group_messaging.senderlist': { + 'Meta': {'object_name': 'SenderList'}, + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'recipient': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.Group']", 'unique': 'True'}), + 'senders': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.User']", 'symmetrical': 'False'}) + } + } + + complete_apps = ['group_messaging']
\ No newline at end of file diff --git a/group_messaging/migrations/__init__.py b/group_messaging/migrations/__init__.py new file mode 100644 index 00000000..e69de29b --- /dev/null +++ b/group_messaging/migrations/__init__.py diff --git a/group_messaging/models.py b/group_messaging/models.py new file mode 100644 index 00000000..62f720cf --- /dev/null +++ b/group_messaging/models.py @@ -0,0 +1,249 @@ +"""models for the ``group_messaging`` app +""" +import datetime +from django.db import models +from django.contrib.auth.models import Group +from django.contrib.auth.models import User + +MAX_TITLE_LENGTH = 80 +MAX_SENDERS_INFO_LENGTH = 64 + +#dummy parse message function +parse_message = lambda v: v + +GROUP_NAME_TPL = '_personal_%s' + +def get_personal_group_by_user_id(user_id): + return Group.objects.get(name=GROUP_NAME_TPL % user_id) + + +def get_personal_groups_for_users(users): + """for a given list of users return their personal groups""" + group_names = [(GROUP_NAME_TPL % user.id) for user in users] + return Group.objects.filter(name__in=group_names) + + +def get_personal_group(user): + """returns personal group for the user""" + return get_personal_group_by_user_id(user.id) + + +def create_personal_group(user): + """creates a personal group for the user""" + group = Group(name=GROUP_NAME_TPL % user.id) + group.save() + return group + + +class LastVisitTime(models.Model): + """just remembers when a user has + last visited a given thread + """ + user = models.ForeignKey(User) + message = models.ForeignKey('Message') + at = models.DateTimeField(auto_now_add=True) + + class Meta: + unique_together = ('user', 'message') + + +class SenderListManager(models.Manager): + """model manager for the :class:`SenderList`""" + + def get_senders_for_user(self, user=None): + """returns query set of :class:`User`""" + user_groups = user.groups.all() + lists = self.filter(recipient__in=user_groups) + user_ids = lists.values_list( + 'senders__id', flat=True + ).distinct() + return User.objects.filter(id__in=user_ids) + +class SenderList(models.Model): + """a model to store denormalized data + about who sends messages to any given person + sender list is populated automatically + as new messages are created + """ + recipient = models.ForeignKey(Group, unique=True) + senders = models.ManyToManyField(User) + objects = SenderListManager() + + +class MessageMemo(models.Model): + """A bridge between message recipients and messages + these records are only created when user sees a message. + The idea is that using groups as recipients, we can send + messages to massive numbers of users, without cluttering + the database. + + Instead we'll be creating a "seen" message after user + reads the message. + """ + SEEN = 0 + ARCHIVED = 1 + STATUS_CHOICES = ( + (SEEN, 'seen'), + (ARCHIVED, 'archived') + ) + user = models.ForeignKey(User) + message = models.ForeignKey('Message') + status = models.SmallIntegerField( + choices=STATUS_CHOICES, default=SEEN + ) + + class Meta: + unique_together = ('user', 'message') + + +class MessageManager(models.Manager): + """model manager for the :class:`Message`""" + + def get_threads_for_user(self, user): + user_groups = user.groups.all() + return self.filter( + root=None, + message_type=Message.STORED, + recipients__in=user_groups + ) + + def create(self, **kwargs): + """creates a message""" + root = kwargs.get('root', None) + if root is None: + parent = kwargs.get('parent', None) + if parent: + if parent.root: + root = parent.root + else: + root = parent + kwargs['root'] = root + + headline = kwargs.get('headline', kwargs['text']) + kwargs['headline'] = headline[:MAX_TITLE_LENGTH] + kwargs['html'] = parse_message(kwargs['text']) + + message = super(MessageManager, self).create(**kwargs) + #creator of message saw it by definition + #crate a "seen" memo for the sender, because we + #don't want to inform the user about his/her own post + sender = kwargs['sender'] + MessageMemo.objects.create( + message=message, user=sender, status=MessageMemo.SEEN + ) + return message + + + def create_thread(self, sender=None, recipients=None, text=None): + """creates a stored message and adds recipients""" + message = self.create( + message_type=Message.STORED, + sender=sender, + senders_info=sender.username, + text=text, + ) + message.add_recipients(recipients) + return message + + def create_response(self, sender=None, text=None, parent=None): + message = self.create( + parent=parent, + message_type=Message.STORED, + sender=sender, + text=text, + ) + #recipients are parent's recipients + sender + #creator of response gets memo in the "read" status + recipients = set(parent.recipients.all()) + senders_group = get_personal_group(parent.sender) + recipients.add(senders_group) + message.add_recipients(recipients) + #add author of the parent as a recipient to parent + parent.add_recipients([senders_group]) + #mark last active timestamp for the root message + #so that we know that this thread was most recently + #updated + message.update_root_info() + return message + + +class Message(models.Model): + """the message model allowing users to send + messages to other users and groups, via + personal groups. + """ + STORED = 0 + TEMPORARY = 1 + ONE_TIME = 2 + MESSAGE_TYPE_CHOICES = ( + (STORED, 'email-like message, stored in the inbox'), + (ONE_TIME, 'will be shown just once'), + (TEMPORARY, 'will be shown until certain time') + ) + + message_type = models.SmallIntegerField( + choices=MESSAGE_TYPE_CHOICES, + default=STORED, + ) + + sender = models.ForeignKey(User, related_name='sent_messages') + + senders_info = models.CharField( + max_length=MAX_SENDERS_INFO_LENGTH, + default='' + )#comma-separated list of a few names + + recipients = models.ManyToManyField(Group) + + root = models.ForeignKey( + 'self', null=True, + blank=True, related_name='descendants' + ) + + parent = models.ForeignKey( + 'self', null=True, + blank=True, related_name='children' + ) + + headline = models.CharField(max_length=MAX_TITLE_LENGTH) + + text = models.TextField( + null=True, blank=True, + help_text='source text for the message, e.g. in markdown format' + ) + + html = models.TextField( + null=True, blank=True, + help_text='rendered html of the message' + ) + + sent_at = models.DateTimeField(auto_now_add=True) + last_active_at = models.DateTimeField(auto_now_add=True) + active_until = models.DateTimeField(blank=True, null=True) + + objects = MessageManager() + + def add_recipients(self, recipients): + """adds recipients to the message + and updates the sender lists for all recipients + todo: sender lists may be updated in a lazy way - per user + """ + self.recipients.add(*recipients) + for recipient in recipients: + sender_list, created = SenderList.objects.get_or_create(recipient=recipient) + sender_list.senders.add(self.sender) + + def update_root_info(self): + """Update the last active at timestamp and + the contributors info, if relevant. + Root object will be saved to the database. + """ + self.root.last_active_at = datetime.datetime.now() + senders_names = self.root.senders_info.split(',') + + if self.sender.username in senders_names: + senders_names.remove(self.sender.username) + senders_names.insert(0, self.sender.username) + + self.root.senders_info = (','.join(senders_names))[:64] + self.root.save() diff --git a/group_messaging/tests.py b/group_messaging/tests.py new file mode 100644 index 00000000..c8401dc1 --- /dev/null +++ b/group_messaging/tests.py @@ -0,0 +1,118 @@ +from django.test import TestCase +from django.contrib.auth.models import User, Group +from group_messaging.models import Message +from group_messaging.models import MessageMemo +from group_messaging.models import SenderList +from group_messaging.models import get_personal_group +from group_messaging.models import create_personal_group + +MESSAGE_TEXT = 'test message text' + +def create_user(name): + """creates a user and a personal group, + returns the created user""" + user = User.objects.create_user(name, name + '@example.com') + #note that askbot will take care of three lines below automatically + try: + group = get_personal_group(user) + except Group.DoesNotExist: + group = create_personal_group(user) + group_name = '_personal_%d' % user.id + group, created = Group.objects.get_or_create(name=group_name) + user.groups.add(group) + return user + +class ModelTests(TestCase): + """test cases for the `private_messaging` models""" + + def setUp(self): + self.sender = create_user('sender') + self.recipient = create_user('recipient') + + def create_thread(self, recipients): + return Message.objects.create_thread( + sender=self.sender, recipients=recipients, + text=MESSAGE_TEXT + ) + + def create_thread_for_user(self, user): + group = get_personal_group(user) + return self.create_thread([group]) + + def test_create_thread_for_user(self): + """the basic create thread with one recipient + tests that the recipient is there""" + message = self.create_thread_for_user(self.recipient) + #message type is stored + self.assertEqual(message.message_type, Message.STORED) + #recipient is in the list of recipients + recipients = set(message.recipients.all()) + recipient_group = get_personal_group(self.recipient) + #sender_group = get_personal_group(self.sender) #maybe add this too + expected_recipients = set([recipient_group]) + self.assertEqual(recipients, expected_recipients) + #self.assertRaises( + # MessageMemo.DoesNotExist, + # MessageMemo.objects.get, + # message=message + #) + #make sure that the original senders memo to the root + #message is marke ad seen + memos = MessageMemo.objects.filter( + message=message, + user=self.sender + ) + self.assertEquals(memos.count(), 1) + self.assertEqual(memos[0].status, MessageMemo.SEEN) + + def test_get_senders_for_user(self): + """this time send thread to a real group test that + member of the group has updated the sender list""" + group = Group.objects.create(name='somegroup') + self.recipient.groups.add(group) + message = self.create_thread([group]) + senders = SenderList.objects.get_senders_for_user(self.recipient) + self.assertEqual(set(senders), set([self.sender])) + + def test_create_thread_response(self): + """create a thread with one response, + then load thread for the user + test that only the root message is retrieved""" + root_message = self.create_thread_for_user(self.recipient) + response = Message.objects.create_response( + sender=self.recipient, + text='some response', + parent=root_message + ) + self.assertEqual(response.message_type, Message.STORED) + + #assert that there is only one "seen" memo for the response + memos = MessageMemo.objects.filter(message=response) + self.assertEqual(memos.count(), 1) + self.assertEqual(memos[0].user, self.recipient) + self.assertEqual(memos[0].status, MessageMemo.SEEN) + + #assert that recipients are the two people who are part of + #this conversation + recipients = set(response.recipients.all()) + sender_group = get_personal_group(self.sender) + recipient_group = get_personal_group(self.recipient) + expected_recipients = set([sender_group, recipient_group]) + self.assertEqual(recipients, expected_recipients) + + def test_get_threads_for_user(self): + root_message = self.create_thread_for_user(self.recipient) + threads = set(Message.objects.get_threads_for_user(self.sender)) + self.assertEqual(threads, set([])) + threads = set(Message.objects.get_threads_for_user(self.recipient)) + self.assertEqual(threads, set([root_message])) + + response = Message.objects.create_response( + sender=self.recipient, + text='some response', + parent=root_message + ) + threads = set(Message.objects.get_threads_for_user(self.sender)) + self.assertEqual(threads, set([root_message])) + threads = set(Message.objects.get_threads_for_user(self.recipient)) + self.assertEqual(threads, set([root_message])) diff --git a/group_messaging/urls.py b/group_messaging/urls.py new file mode 100644 index 00000000..30002bf3 --- /dev/null +++ b/group_messaging/urls.py @@ -0,0 +1,32 @@ +"""url configuration for the group_messaging application""" +from django.conf.urls.defaults import patterns +from django.conf.urls.defaults import url +from group_messaging import views + +urlpatterns = patterns('', + url( + '^threads/$', + views.ThreadsList().as_view(), + name='get_threads' + ), + url( + '^threads/(?P<thread_id>\d+)/$', + views.ThreadDetails().as_view(), + name='thread_details' + ), + url( + '^threads/create/$', + views.NewThread().as_view(), + name='create_thread' + ), + url( + '^senders/$', + views.SendersList().as_view(), + name='get_senders' + ), + url( + '^post-reply/$', + views.PostReply().as_view(), + name='post_reply' + ) +) diff --git a/group_messaging/views.py b/group_messaging/views.py new file mode 100644 index 00000000..289961ff --- /dev/null +++ b/group_messaging/views.py @@ -0,0 +1,214 @@ +"""semi-views for the `group_messaging` application +These are not really views - rather context generator +functions, to be used separately, when needed. + +For example, some other application can call these +in order to render messages within the page. + +Notice that :mod:`urls` module decorates all these functions +and turns them into complete views +""" +import copy +import datetime +from coffin.template.loader import get_template +from django.contrib.auth.models import User +from django.forms import IntegerField +from django.http import HttpResponse +from django.http import HttpResponseNotAllowed +from django.http import HttpResponseForbidden +from django.utils import simplejson +from group_messaging.models import Message +from group_messaging.models import SenderList +from group_messaging.models import LastVisitTime +from group_messaging.models import get_personal_group_by_user_id +from group_messaging.models import get_personal_groups_for_users + +class InboxView(object): + """custom class-based view + to be used for pjax use and for generation + of content in the traditional way, where + the only the :method:`get_context` would be used. + """ + template_name = None #used only for the "GET" method + http_method_names = ('GET', 'POST') + + def render_to_response(self, context, template_name=None): + """like a django's shortcut, except will use + template_name from self, if `template_name` is not given. + Also, response is packaged as json with an html fragment + for the pjax consumption + """ + if template_name is None: + template_name = self.template_name + template = get_template(template_name) + html = template.render(context) + json = simplejson.dumps({'html': html, 'success': True}) + return HttpResponse(json, mimetype='application/json') + + + def get(self, request, *args, **kwargs): + """view function for the "GET" method""" + context = self.get_context(request, *args, **kwargs) + return self.render_to_response(context) + + def post(self, request, *args, **kwargs): + """view function for the "POST" method""" + pass + + def dispatch(self, request, *args, **kwargs): + """checks that the current request method is allowed + and calls the corresponding view function""" + if request.method not in self.http_method_names: + return HttpResponseNotAllowed() + view_func = getattr(self, request.method.lower()) + return view_func(request, *args, **kwargs) + + def get_context(self, request, *args, **kwargs): + """Returns the context dictionary for the "get" + method only""" + return {} + + def as_view(self): + """returns the view function - for the urls.py""" + def view_function(request, *args, **kwargs): + """the actual view function""" + if request.user.is_authenticated() and request.is_ajax(): + view_method = getattr(self, request.method.lower()) + return view_method(request, *args, **kwargs) + else: + return HttpResponseForbidden() + + return view_function + + +class NewThread(InboxView): + """view for creation of new thread""" + http_method_list = ('POST',) + + def post(self, request): + """creates a new thread on behalf of the user + response is blank, because on the client side we just + need to go back to the thread listing view whose + content should be cached in the client' + """ + usernames = request.POST['to_usernames'] + usernames = map(lambda v: v.strip(), usernames.split(',')) + users = User.objects.filter(username__in=usernames) + + missing = copy.copy(usernames) + for user in users: + if user.username in missing: + missing.remove(user.username) + + result = dict() + if missing: + result['success'] = False + result['missing_users'] = missing + else: + recipients = get_personal_groups_for_users(users) + message = Message.objects.create_thread( + sender=request.user, + recipients=recipients, + text=request.POST['text'] + ) + result['success'] = True + result['message_id'] = message.id + return HttpResponse(simplejson.dumps(result), mimetype='application/json') + + +class PostReply(InboxView): + """view to create a new response""" + http_method_list = ('POST',) + + def post(self, request): + parent_id = IntegerField().clean(request.POST['parent_id']) + parent = Message.objects.get(id=parent_id) + message = Message.objects.create_response( + sender=request.user, + text=request.POST['text'], + parent=parent + ) + last_visit = LastVisitTime.objects.get( + message=message.root, + user=request.user + ) + last_visit.at = datetime.datetime.now() + last_visit.save() + return self.render_to_response( + {'post': message, 'user': request.user}, + template_name='group_messaging/stored_message.html' + ) + +class ThreadsList(InboxView): + """shows list of threads for a given user""" + template_name = 'group_messaging/threads_list.html' + http_method_list = ('GET',) + + def get_context(self, request): + """returns thread list data""" + #get threads and the last visit time + threads = Message.objects.get_threads_for_user(request.user) + + sender_id = IntegerField().clean(request.GET.get('sender_id', '-1')) + if sender_id != -1: + threads = threads.filter(sender__id=sender_id) + + #for each thread we need to know if there is something + #unread for the user - to mark "new" threads as bold + threads_data = dict() + for thread in threads: + thread_data = dict() + #determine status + thread_data['status'] = 'new' + #determine the senders info + senders_names = thread.senders_info.split(',') + if request.user.username in senders_names: + senders_names.remove(request.user.username) + thread_data['senders_info'] = ', '.join(senders_names) + thread_data['thread'] = thread + threads_data[thread.id] = thread_data + + last_visit_times = LastVisitTime.objects.filter( + user=request.user, + message__in=threads + ) + for last_visit in last_visit_times: + thread_data = threads_data[last_visit.message_id] + if thread_data['thread'].last_active_at <= last_visit.at: + thread_data['status'] = 'seen' + + #after we have all the data - update the last visit time + last_visit_times.update(at=datetime.datetime.now()) + return {'threads': threads, 'threads_data': threads_data} + + +class SendersList(InboxView): + """shows list of senders for a user""" + template_name = 'group_messaging/senders_list.html' + http_method_names = ('GET',) + + def get_context(self, request): + """get data about senders for the user""" + senders = SenderList.objects.get_senders_for_user(request.user) + senders = senders.values('id', 'username') + return {'senders': senders} + + +class ThreadDetails(InboxView): + """shows entire thread in the unfolded form""" + template_name = 'group_messaging/thread_details.html' + http_method_names = ('GET',) + + def get_context(self, request, thread_id=None): + """shows individual thread""" + #todo: assert that current thread is the root + root = Message.objects.get(id=thread_id) + responses = Message.objects.filter(root__id=thread_id) + last_visit, created = LastVisitTime.objects.get_or_create( + message=root, + user=request.user + ) + if created is False: + last_visit.at = datetime.datetime.now() + last_visit.save() + return {'root_message': root, 'responses': responses, 'request': request} |