summaryrefslogtreecommitdiffstats
path: root/group_messaging
diff options
context:
space:
mode:
Diffstat (limited to 'group_messaging')
-rw-r--r--group_messaging/__init__.py14
-rw-r--r--group_messaging/migrations/0001_initial.py177
-rw-r--r--group_messaging/migrations/0002_auto__add_lastvisittime__add_unique_lastvisittime_user_message__add_fi.py142
-rw-r--r--group_messaging/migrations/__init__.py0
-rw-r--r--group_messaging/models.py249
-rw-r--r--group_messaging/tests.py118
-rw-r--r--group_messaging/urls.py32
-rw-r--r--group_messaging/views.py214
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}