diff options
26 files changed, 1430 insertions, 498 deletions
@@ -27,6 +27,7 @@ tmp/* /manage.py /urls.py /log +/prof load askbot/skins/default/media/js/flot askbot/skins/common/media/js/closure/google-closure diff --git a/askbot/const/__init__.py b/askbot/const/__init__.py index 56ef89cf..0f981dee 100644 --- a/askbot/const/__init__.py +++ b/askbot/const/__init__.py @@ -84,7 +84,8 @@ UNANSWERED_QUESTION_MEANING_CHOICES = ( #correct regexes - plus this must be an anchored regex #to do full string match TAG_CHARS = r'\w+.#-' -TAG_REGEX = r'^[%s]+$' % TAG_CHARS +TAG_REGEX_BARE = r'[%s]+' % TAG_CHARS +TAG_REGEX = r'^%s$' % TAG_REGEX_BARE TAG_SPLIT_REGEX = r'[ ,]+' TAG_SEP = ',' # has to be valid TAG_SPLIT_REGEX char and MUST NOT be in const.TAG_CHARS EMAIL_REGEX = re.compile(r'\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,4}\b', re.I) diff --git a/askbot/deps/django_authopenid/migrations/0002_make_multiple_openids_possible.py b/askbot/deps/django_authopenid/migrations/0002_make_multiple_openids_possible.py index 4e615e65..e5541286 100644 --- a/askbot/deps/django_authopenid/migrations/0002_make_multiple_openids_possible.py +++ b/askbot/deps/django_authopenid/migrations/0002_make_multiple_openids_possible.py @@ -4,6 +4,9 @@ from south.db import db from south.v2 import SchemaMigration from django.db import models +from askbot.migrations import houston_do_we_have_a_problem + + class Migration(SchemaMigration): def forwards(self, orm): @@ -12,6 +15,10 @@ class Migration(SchemaMigration): db.add_column('django_authopenid_userassociation', 'provider_name', self.gf('django.db.models.fields.CharField')(default='unknown', max_length=64), keep_default=False) # Removing unique constraint on 'UserAssociation', fields ['user'] + if houston_do_we_have_a_problem('django_authopenid_userassociation'): + # In MySQL+InnoDB Foreign keys have to have some index on them, + # therefore before deleting the UNIQUE index we have to create an "ordinary" one + db.create_index('django_authopenid_userassociation', ['user_id']) db.delete_unique('django_authopenid_userassociation', ['user_id']) # Adding unique constraint on 'UserAssociation', fields ['provider_name', 'user'] diff --git a/askbot/management/commands/merge_users.py b/askbot/management/commands/merge_users.py index 3c7069e5..9eb76756 100644 --- a/askbot/management/commands/merge_users.py +++ b/askbot/management/commands/merge_users.py @@ -1,6 +1,14 @@ from django.core.management.base import CommandError, BaseCommand from askbot.models import User +# TODO: this command is broken - doesn't take into account UNIQUE constraints +# and therefore causes db errors: +# In SQLite: "Warning: columns feed_type, subscriber_id are not unique" +# In MySQL: "Warning: (1062, "Duplicate entry 'm_and_c-2' for key 'askbot_emailfeedsetting_feed_type_6da6fdcd_uniq'")" +# In PostgreSQL: "Warning: duplicate key value violates unique constraint "askbot_emailfeedsetting_feed_type_6da6fdcd_uniq" +# "DETAIL: Key (feed_type, subscriber_id)=(m_and_c, 619) already exists." +# (followed by series of "current transaction is aborted, commands ignored until end of transaction block" warnings) + class MergeUsersBaseCommand(BaseCommand): args = '<from_user_id> <to_user_id>' help = 'Merge an account and all information from a <user_id> to a <user_id>, deleting the <from_user>' diff --git a/askbot/migrations/0012_delete_some_unused_models.py b/askbot/migrations/0012_delete_some_unused_models.py index 8c0edf8e..3a0888ef 100644 --- a/askbot/migrations/0012_delete_some_unused_models.py +++ b/askbot/migrations/0012_delete_some_unused_models.py @@ -24,15 +24,15 @@ class Migration(SchemaMigration): # Deleting model 'Mention' db.delete_table(u'mention') - # Deleting model 'Book' - db.delete_table(u'book') - # Removing M2M table for field questions on 'Book' db.delete_table('book_question') # Deleting model 'BookAuthorRss' db.delete_table(u'book_author_rss') - + + # Deleting model 'Book' + db.delete_table(u'book') + def backwards(self, orm): diff --git a/askbot/migrations/0101_megadeath_of_q_a_c.py b/askbot/migrations/0101_megadeath_of_q_a_c.py index 7e63a999..2f04b4e4 100644 --- a/askbot/migrations/0101_megadeath_of_q_a_c.py +++ b/askbot/migrations/0101_megadeath_of_q_a_c.py @@ -5,12 +5,22 @@ from south.db import db from south.v2 import SchemaMigration from django.db import models -from askbot.migrations import TERM_YELLOW, TERM_RESET +from askbot.migrations import TERM_YELLOW, TERM_RESET, innodb_ready_rename_column + class Migration(SchemaMigration): def forwards(self, orm): - db.rename_column('askbot_thread', 'accepted_answer_post_id', 'accepted_answer_id') + #db.rename_column('askbot_thread', 'accepted_answer_post_id', 'accepted_answer_id') + innodb_ready_rename_column( + orm=orm, + models=self.__class__.models, + table='askbot_thread', + old_column_name='accepted_answer_post_id', + new_column_name='accepted_answer_id', + app_model='askbot.thread', + new_field_name='accepted_answer' + ) # Deleting model 'Comment' db.delete_table(u'comment') diff --git a/askbot/migrations/0102_rename_post_fields_back_1.py b/askbot/migrations/0102_rename_post_fields_back_1.py index 9d155ddd..9c51aac6 100644 --- a/askbot/migrations/0102_rename_post_fields_back_1.py +++ b/askbot/migrations/0102_rename_post_fields_back_1.py @@ -4,10 +4,23 @@ from south.db import db from south.v2 import SchemaMigration from django.db import models +from askbot.migrations import innodb_ready_rename_column + + class Migration(SchemaMigration): def forwards(self, orm): - db.rename_column('askbot_questionview', 'question_post_id', 'question_id') + #db.rename_column('askbot_questionview', 'question_post_id', 'question_id') + innodb_ready_rename_column( + orm=orm, + models=self.__class__.models, + table='askbot_questionview', + old_column_name='question_post_id', + new_column_name='question_id', + app_model='askbot.questionview', + new_field_name='question' + ) + def backwards(self, orm): diff --git a/askbot/migrations/0103_rename_post_fields_back_2.py b/askbot/migrations/0103_rename_post_fields_back_2.py index 6640ff83..f56e2258 100644 --- a/askbot/migrations/0103_rename_post_fields_back_2.py +++ b/askbot/migrations/0103_rename_post_fields_back_2.py @@ -4,10 +4,23 @@ from south.db import db from south.v2 import SchemaMigration from django.db import models +from askbot.migrations import innodb_ready_rename_column + + class Migration(SchemaMigration): def forwards(self, orm): - db.rename_column(u'activity', 'question_post_id', 'question_id') + #db.rename_column(u'activity', 'question_post_id', 'question_id') + innodb_ready_rename_column( + orm=orm, + models=self.__class__.models, + table='activity', + old_column_name='question_post_id', + new_column_name='question_id', + app_model='askbot.activity', + new_field_name='question' + ) + def backwards(self, orm): diff --git a/askbot/migrations/0104_auto__del_field_repute_question_post__add_field_repute_question.py b/askbot/migrations/0104_auto__del_field_repute_question_post__add_field_repute_question.py index 4044cef1..ed72e1ec 100644 --- a/askbot/migrations/0104_auto__del_field_repute_question_post__add_field_repute_question.py +++ b/askbot/migrations/0104_auto__del_field_repute_question_post__add_field_repute_question.py @@ -4,10 +4,23 @@ from south.db import db from south.v2 import SchemaMigration from django.db import models +from askbot.migrations import innodb_ready_rename_column + + class Migration(SchemaMigration): def forwards(self, orm): - db.rename_column(u'repute', 'question_post_id', 'question_id') + #db.rename_column(u'repute', 'question_post_id', 'question_id') + innodb_ready_rename_column( + orm=orm, + models=self.__class__.models, + table='repute', + old_column_name='question_post_id', + new_column_name='question_id', + app_model='askbot.repute', + new_field_name='question' + ) + def backwards(self, orm): diff --git a/askbot/migrations/0105_restore_anon_ans_q.py b/askbot/migrations/0105_restore_anon_ans_q.py index 05429728..4bf5ca99 100644 --- a/askbot/migrations/0105_restore_anon_ans_q.py +++ b/askbot/migrations/0105_restore_anon_ans_q.py @@ -4,10 +4,23 @@ from south.db import db from south.v2 import SchemaMigration from django.db import models +from askbot.migrations import innodb_ready_rename_column + + class Migration(SchemaMigration): def forwards(self, orm): - db.rename_column('askbot_anonymousanswer', 'question_post_id', 'question_id') + #db.rename_column('askbot_anonymousanswer', 'question_post_id', 'question_id') + innodb_ready_rename_column( + orm=orm, + models=self.__class__.models, + table='askbot_anonymousanswer', + old_column_name='question_post_id', + new_column_name='question_id', + app_model='askbot.anonymousanswer', + new_field_name='question' + ) + def backwards(self, orm): diff --git a/askbot/migrations/0106_update_postgres_full_text_setup.py b/askbot/migrations/0106_update_postgres_full_text_setup.py index 0f940b96..e788879c 100644 --- a/askbot/migrations/0106_update_postgres_full_text_setup.py +++ b/askbot/migrations/0106_update_postgres_full_text_setup.py @@ -1,4 +1,5 @@ # encoding: utf-8 +import sys import askbot from askbot.search.postgresql import setup_full_text_search import datetime @@ -14,6 +15,8 @@ class Migration(DataMigration): def forwards(self, orm): "Write your forwards methods here." + return # TODO: remove me when the SQL is fixed! + if 'postgresql_psycopg2' in askbot.get_database_engine_name(): script_path = os.path.join( askbot.get_install_directory(), diff --git a/askbot/migrations/0107_added_db_indexes.py b/askbot/migrations/0107_added_db_indexes.py new file mode 100644 index 00000000..5893a41f --- /dev/null +++ b/askbot/migrations/0107_added_db_indexes.py @@ -0,0 +1,280 @@ +# encoding: 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 index on 'Post', fields ['post_type'] + db.create_index('askbot_post', ['post_type']) + + # Adding index on 'Post', fields ['deleted'] + db.create_index('askbot_post', ['deleted']) + + + def backwards(self, orm): + + # Removing index on 'Post', fields ['deleted'] + db.delete_index('askbot_post', ['deleted']) + + # Removing index on 'Post', fields ['post_type'] + db.delete_index('askbot_post', ['post_type']) + + + models = { + 'askbot.activity': { + 'Meta': {'object_name': 'Activity', 'db_table': "u'activity'"}, + 'active_at': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), + 'activity_type': ('django.db.models.fields.SmallIntegerField', [], {}), + 'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'is_auditted': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'object_id': ('django.db.models.fields.PositiveIntegerField', [], {}), + 'question': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['askbot.Post']", 'null': 'True'}), + 'receiving_users': ('django.db.models.fields.related.ManyToManyField', [], {'related_name': "'received_activity'", 'symmetrical': 'False', 'to': "orm['auth.User']"}), + 'recipients': ('django.db.models.fields.related.ManyToManyField', [], {'related_name': "'incoming_activity'", 'symmetrical': 'False', 'through': "orm['askbot.ActivityAuditStatus']", 'to': "orm['auth.User']"}), + 'summary': ('django.db.models.fields.TextField', [], {'default': "''"}), + 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}) + }, + 'askbot.activityauditstatus': { + 'Meta': {'unique_together': "(('user', 'activity'),)", 'object_name': 'ActivityAuditStatus'}, + 'activity': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['askbot.Activity']"}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'status': ('django.db.models.fields.SmallIntegerField', [], {'default': '0'}), + 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}) + }, + 'askbot.anonymousanswer': { + 'Meta': {'object_name': 'AnonymousAnswer'}, + 'added_at': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), + 'author': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']", 'null': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'ip_addr': ('django.db.models.fields.IPAddressField', [], {'max_length': '15'}), + 'question': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'anonymous_answers'", 'to': "orm['askbot.Post']"}), + 'session_key': ('django.db.models.fields.CharField', [], {'max_length': '40'}), + 'summary': ('django.db.models.fields.CharField', [], {'max_length': '180'}), + 'text': ('django.db.models.fields.TextField', [], {}), + 'wiki': ('django.db.models.fields.BooleanField', [], {'default': 'False'}) + }, + 'askbot.anonymousquestion': { + 'Meta': {'object_name': 'AnonymousQuestion'}, + 'added_at': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), + 'author': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']", 'null': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'ip_addr': ('django.db.models.fields.IPAddressField', [], {'max_length': '15'}), + 'is_anonymous': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'session_key': ('django.db.models.fields.CharField', [], {'max_length': '40'}), + 'summary': ('django.db.models.fields.CharField', [], {'max_length': '180'}), + 'tagnames': ('django.db.models.fields.CharField', [], {'max_length': '125'}), + 'text': ('django.db.models.fields.TextField', [], {}), + 'title': ('django.db.models.fields.CharField', [], {'max_length': '300'}), + 'wiki': ('django.db.models.fields.BooleanField', [], {'default': 'False'}) + }, + 'askbot.award': { + 'Meta': {'object_name': 'Award', 'db_table': "u'award'"}, + 'awarded_at': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), + 'badge': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'award_badge'", 'to': "orm['askbot.BadgeData']"}), + 'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'notified': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'object_id': ('django.db.models.fields.PositiveIntegerField', [], {}), + 'user': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'award_user'", 'to': "orm['auth.User']"}) + }, + 'askbot.badgedata': { + 'Meta': {'ordering': "('slug',)", 'object_name': 'BadgeData'}, + 'awarded_count': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}), + 'awarded_to': ('django.db.models.fields.related.ManyToManyField', [], {'related_name': "'badges'", 'symmetrical': 'False', 'through': "orm['askbot.Award']", 'to': "orm['auth.User']"}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'slug': ('django.db.models.fields.SlugField', [], {'unique': 'True', 'max_length': '50', 'db_index': 'True'}) + }, + 'askbot.emailfeedsetting': { + 'Meta': {'object_name': 'EmailFeedSetting'}, + 'added_at': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}), + 'feed_type': ('django.db.models.fields.CharField', [], {'max_length': '16'}), + 'frequency': ('django.db.models.fields.CharField', [], {'default': "'n'", 'max_length': '8'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'reported_at': ('django.db.models.fields.DateTimeField', [], {'null': 'True'}), + 'subscriber': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'notification_subscriptions'", 'to': "orm['auth.User']"}) + }, + 'askbot.favoritequestion': { + 'Meta': {'object_name': 'FavoriteQuestion', 'db_table': "u'favorite_question'"}, + 'added_at': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'thread': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['askbot.Thread']"}), + 'user': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'user_favorite_questions'", 'to': "orm['auth.User']"}) + }, + 'askbot.markedtag': { + 'Meta': {'object_name': 'MarkedTag'}, + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'reason': ('django.db.models.fields.CharField', [], {'max_length': '16'}), + 'tag': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'user_selections'", 'to': "orm['askbot.Tag']"}), + 'user': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'tag_selections'", 'to': "orm['auth.User']"}) + }, + 'askbot.post': { + 'Meta': {'object_name': 'Post'}, + 'added_at': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), + 'author': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'posts'", 'to': "orm['auth.User']"}), + 'comment_count': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}), + 'deleted': ('django.db.models.fields.BooleanField', [], {'default': 'False', 'db_index': 'True'}), + 'deleted_at': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'blank': 'True'}), + 'deleted_by': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'deleted_posts'", 'null': 'True', 'to': "orm['auth.User']"}), + 'html': ('django.db.models.fields.TextField', [], {'null': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'is_anonymous': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'last_edited_at': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'blank': 'True'}), + 'last_edited_by': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'last_edited_posts'", 'null': 'True', 'to': "orm['auth.User']"}), + 'locked': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'locked_at': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'blank': 'True'}), + 'locked_by': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'locked_posts'", 'null': 'True', 'to': "orm['auth.User']"}), + 'offensive_flag_count': ('django.db.models.fields.SmallIntegerField', [], {'default': '0'}), + 'old_answer_id': ('django.db.models.fields.PositiveIntegerField', [], {'default': 'None', 'unique': 'True', 'null': 'True', 'blank': 'True'}), + 'old_comment_id': ('django.db.models.fields.PositiveIntegerField', [], {'default': 'None', 'unique': 'True', 'null': 'True', 'blank': 'True'}), + 'old_question_id': ('django.db.models.fields.PositiveIntegerField', [], {'default': 'None', 'unique': 'True', 'null': 'True', 'blank': 'True'}), + 'parent': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'comments'", 'null': 'True', 'to': "orm['askbot.Post']"}), + 'post_type': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}), + 'score': ('django.db.models.fields.IntegerField', [], {'default': '0'}), + 'summary': ('django.db.models.fields.CharField', [], {'max_length': '180'}), + 'text': ('django.db.models.fields.TextField', [], {'null': 'True'}), + 'thread': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'posts'", 'to': "orm['askbot.Thread']"}), + 'vote_down_count': ('django.db.models.fields.IntegerField', [], {'default': '0'}), + 'vote_up_count': ('django.db.models.fields.IntegerField', [], {'default': '0'}), + 'wiki': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'wikified_at': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'blank': 'True'}) + }, + 'askbot.postrevision': { + 'Meta': {'ordering': "('-revision',)", 'unique_together': "(('post', 'revision'),)", 'object_name': 'PostRevision'}, + 'author': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'postrevisions'", 'to': "orm['auth.User']"}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'is_anonymous': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'post': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'revisions'", 'null': 'True', 'to': "orm['askbot.Post']"}), + 'revised_at': ('django.db.models.fields.DateTimeField', [], {}), + 'revision': ('django.db.models.fields.PositiveIntegerField', [], {}), + 'revision_type': ('django.db.models.fields.SmallIntegerField', [], {}), + 'summary': ('django.db.models.fields.CharField', [], {'max_length': '300', 'blank': 'True'}), + 'tagnames': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '125', 'blank': 'True'}), + 'text': ('django.db.models.fields.TextField', [], {}), + 'title': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '300', 'blank': 'True'}) + }, + 'askbot.questionview': { + 'Meta': {'object_name': 'QuestionView'}, + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'question': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'viewed'", 'to': "orm['askbot.Post']"}), + 'when': ('django.db.models.fields.DateTimeField', [], {}), + 'who': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'question_views'", 'to': "orm['auth.User']"}) + }, + 'askbot.repute': { + 'Meta': {'object_name': 'Repute', 'db_table': "u'repute'"}, + 'comment': ('django.db.models.fields.CharField', [], {'max_length': '128', 'null': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'negative': ('django.db.models.fields.SmallIntegerField', [], {'default': '0'}), + 'positive': ('django.db.models.fields.SmallIntegerField', [], {'default': '0'}), + 'question': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['askbot.Post']", 'null': 'True', 'blank': 'True'}), + 'reputation': ('django.db.models.fields.IntegerField', [], {'default': '1'}), + 'reputation_type': ('django.db.models.fields.SmallIntegerField', [], {}), + 'reputed_at': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), + 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}) + }, + 'askbot.tag': { + 'Meta': {'ordering': "('-used_count', 'name')", 'object_name': 'Tag', 'db_table': "u'tag'"}, + 'created_by': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'created_tags'", 'to': "orm['auth.User']"}), + 'deleted': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'deleted_at': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'blank': 'True'}), + 'deleted_by': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'deleted_tags'", 'null': 'True', 'to': "orm['auth.User']"}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '255'}), + 'used_count': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}) + }, + 'askbot.thread': { + 'Meta': {'object_name': 'Thread'}, + 'accepted_answer': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'+'", 'null': 'True', 'to': "orm['askbot.Post']"}), + 'answer_accepted_at': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'blank': 'True'}), + 'answer_count': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}), + 'close_reason': ('django.db.models.fields.SmallIntegerField', [], {'null': 'True', 'blank': 'True'}), + 'closed': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'closed_at': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'blank': 'True'}), + 'closed_by': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']", 'null': 'True', 'blank': 'True'}), + 'favorited_by': ('django.db.models.fields.related.ManyToManyField', [], {'related_name': "'unused_favorite_threads'", 'symmetrical': 'False', 'through': "orm['askbot.FavoriteQuestion']", 'to': "orm['auth.User']"}), + 'favourite_count': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}), + 'followed_by': ('django.db.models.fields.related.ManyToManyField', [], {'related_name': "'followed_threads'", 'symmetrical': 'False', 'to': "orm['auth.User']"}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'last_activity_at': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), + 'last_activity_by': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'unused_last_active_in_threads'", 'to': "orm['auth.User']"}), + 'tagnames': ('django.db.models.fields.CharField', [], {'max_length': '125'}), + 'tags': ('django.db.models.fields.related.ManyToManyField', [], {'related_name': "'threads'", 'symmetrical': 'False', 'to': "orm['askbot.Tag']"}), + 'title': ('django.db.models.fields.CharField', [], {'max_length': '300'}), + 'view_count': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}) + }, + 'askbot.vote': { + 'Meta': {'unique_together': "(('user', 'voted_post'),)", 'object_name': 'Vote', 'db_table': "u'vote'"}, + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'user': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'votes'", 'to': "orm['auth.User']"}), + 'vote': ('django.db.models.fields.SmallIntegerField', [], {}), + 'voted_at': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), + 'voted_post': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'votes'", 'to': "orm['askbot.Post']"}) + }, + '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_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_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'}), + 'silver': ('django.db.models.fields.SmallIntegerField', [], {'default': '0'}), + 'status': ('django.db.models.fields.CharField', [], {'default': "'w'", 'max_length': '2'}), + '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': '30'}), + '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'}) + } + } + + complete_apps = ['askbot'] diff --git a/askbot/migrations/__init__.py b/askbot/migrations/__init__.py index ac6b3d47..86377b7b 100644 --- a/askbot/migrations/__init__.py +++ b/askbot/migrations/__init__.py @@ -1,3 +1,6 @@ +from south.db import db +from south.utils import ask_for_it_by_name + # Terminal ANSI codes for printing colored text: # - http://code.google.com/p/testoob/source/browse/trunk/src/testoob/reporting/colored.py#20 # - http://stackoverflow.com/questions/287871/print-in-terminal-with-colors-using-python @@ -5,3 +8,62 @@ TERM_RED_BOLD = '\x1b[31;01m\x1b[01m' TERM_YELLOW = "\x1b[33;01m" TERM_GREEN = "\x1b[32;06m" TERM_RESET = '\x1b[0m' + +def houston_do_we_have_a_problem(table): + "Checks if we're using MySQL + InnoDB" + if not db.dry_run and db.backend_name == 'mysql': + db_table = [db._get_connection().settings_dict['NAME'], table] + ret = db.execute( + "SELECT TABLE_NAME, ENGINE FROM information_schema.TABLES " + "where TABLE_SCHEMA = %s and TABLE_NAME = %s", + db_table + ) + assert len(ret) == 1 # There HAVE to be info about this table ! + assert len(ret[0]) == 2 + if ret[0][1] == 'InnoDB': + print TERM_YELLOW, "!!!", '.'.join(db_table), "is InnoDB - using workarounds !!!", TERM_RESET + return True + return False + + +def innodb_ready_rename_column(orm, models, table, old_column_name, new_column_name, app_model, new_field_name): + """ + Foreign key renaming which works for InnoDB + More: http://south.aeracode.org/ticket/466 + + Parameters: + - orm: a reference to 'orm' parameter passed to Migration.forwards()/backwards() + - models: reference to Migration.models data structure + - table: e.g. 'askbot_thread' + - old_column_name: e.g. 'question_post_id' + - new_column_name: e.g. 'question_id' + - app_model: e.g. 'askbot.thread' (should be a dict key into 'models') + - new_field_name: e.g. 'question' (usually it's same as new_column_name, only without trailing '_id') + """ + use_workaround = houston_do_we_have_a_problem(table) + + # ditch the foreign key + if use_workaround: + db.delete_foreign_key(table, old_column_name) + + # rename column + db.rename_column(table, old_column_name, new_column_name) + + # restore the foreign key + if not use_workaround: + return + + model_def = models[app_model][new_field_name] + assert model_def[0] == 'django.db.models.fields.related.ForeignKey' + # Copy the dict so that we don't change the original + # (otherwise the dry run would change it for the "normal" run + # and the latter would try to convert str to model once again) + fkey_params = model_def[2].copy() + assert 'to' in fkey_params + assert fkey_params['to'].startswith("orm['") + assert fkey_params['to'].endswith("']") + fkey_params['to'] = orm[fkey_params['to'][5:-2]] # convert "orm['app.models']" string to actual model + field = ask_for_it_by_name(model_def[0])(**fkey_params) + # INFO: ask_for_it_by_name() if equivalent to self.gf() which is usually used in migrations, e.g.: + # db.alter_column('askbot_badgedata', 'slug', self.gf('django.db.models.fields.SlugField')(unique=True, max_length=50)) + db.alter_column(table, new_column_name, field) diff --git a/askbot/models/base.py b/askbot/models/base.py index 5f496d43..0686d50c 100644 --- a/askbot/models/base.py +++ b/askbot/models/base.py @@ -30,6 +30,13 @@ class BaseQuerySetManager(models.Manager): >>> objects = SomeManager() """ def __getattr__(self, attr, *args): + ## The following two lines fix the problem from this ticket: + ## https://code.djangoproject.com/ticket/15062#comment:6 + ## https://code.djangoproject.com/changeset/15220 + ## Queryset.only() seems to suffer from that on some occasions + if attr.startswith('_'): + raise AttributeError + ## try: return getattr(self.__class__, attr, *args) except AttributeError: diff --git a/askbot/models/post.py b/askbot/models/post.py index d4482cf4..5cb9708f 100644 --- a/askbot/models/post.py +++ b/askbot/models/post.py @@ -80,214 +80,6 @@ class PostQuerySet(models.query.QuerySet): # #fallback to dumb title match search # return self.filter(thread__title__icontains=search_query) - # def run_advanced_search( - # self, - # request_user = None, - # search_state = None - # ): - # """all parameters are guaranteed to be clean - # however may not relate to database - in that case - # a relvant filter will be silently dropped - # """ - # #todo: same as for get_by_text_query - goes to Tread - # scope_selector = getattr( - # search_state, - # 'scope', - # const.DEFAULT_POST_SCOPE - # ) - # - # search_query = search_state.query - # tag_selector = search_state.tags - # author_selector = search_state.author - # - # import ipdb; ipdb.set_trace() - # - # sort_method = getattr( - # search_state, - # 'sort', - # const.DEFAULT_POST_SORT_METHOD - # ) - # qs = self.filter(deleted=False)#todo - add a possibility to see deleted questions - # - # #return metadata - # meta_data = {} - # if search_query: - # if search_state.stripped_query: - # qs = qs.get_by_text_query(search_state.stripped_query) - # #a patch for postgres search sort method - # if askbot.conf.should_show_sort_by_relevance(): - # if sort_method == 'relevance-desc': - # qs = qs.extra(order_by = ['-relevance',]) - # if search_state.query_title: - # qs = qs.filter(thread__title__icontains = search_state.query_title) - # if len(search_state.query_tags) > 0: - # qs = qs.filter(thread__tags__name__in = search_state.query_tags) - # if len(search_state.query_users) > 0: - # query_users = list() - # for username in search_state.query_users: - # try: - # user = User.objects.get(username__iexact = username) - # query_users.append(user) - # except User.DoesNotExist: - # pass - # if len(query_users) > 0: - # qs = qs.filter(author__in = query_users) - # - # if tag_selector: - # for tag in tag_selector: - # qs = qs.filter(thread__tags__name = tag) - # - # - # #have to import this at run time, otherwise there - # #a circular import dependency... - # from askbot.conf import settings as askbot_settings - # if scope_selector: - # if scope_selector == 'unanswered': - # qs = qs.filter(thread__closed = False)#do not show closed questions in unanswered section - # if askbot_settings.UNANSWERED_QUESTION_MEANING == 'NO_ANSWERS': - # qs = qs.filter(thread__answer_count=0)#todo: expand for different meanings of this - # elif askbot_settings.UNANSWERED_QUESTION_MEANING == 'NO_ACCEPTED_ANSWERS': - # qs = qs.filter(thread__accepted_answer__isnull=True) #answer_accepted=False - # elif askbot_settings.UNANSWERED_QUESTION_MEANING == 'NO_UPVOTED_ANSWERS': - # raise NotImplementedError() - # else: - # raise Exception('UNANSWERED_QUESTION_MEANING setting is wrong') - # elif scope_selector == 'favorite': - # favorite_filter = models.Q(thread__favorited_by = request_user) - # if 'followit' in settings.INSTALLED_APPS: - # followed_users = request_user.get_followed_users() - # favorite_filter |= models.Q(author__in = followed_users) - # favorite_filter |= models.Q(answers__author__in = followed_users) - # qs = qs.filter(favorite_filter) - # - # #user contributed questions & answers - # if author_selector: - # try: - # #todo maybe support selection by multiple authors - # u = User.objects.get(id=int(author_selector)) - # qs = qs.filter( - # models.Q(author=u, deleted=False) \ - # | models.Q(answers__author=u, answers__deleted=False) - # ) - # meta_data['author_name'] = u.username - # except User.DoesNotExist: - # meta_data['author_name'] = None - # - # #get users tag filters - # ignored_tag_names = None - # if request_user and request_user.is_authenticated(): - # uid_str = str(request_user.id) - # #mark questions tagged with interesting tags - # #a kind of fancy annotation, would be nice to avoid it - # interesting_tags = Tag.objects.filter( - # user_selections__user=request_user, - # user_selections__reason='good' - # ) - # ignored_tags = Tag.objects.filter( - # user_selections__user=request_user, - # user_selections__reason='bad' - # ) - # - # meta_data['interesting_tag_names'] = [tag.name for tag in interesting_tags] - # - # ignored_tag_names = [tag.name for tag in ignored_tags] - # meta_data['ignored_tag_names'] = ignored_tag_names - # - # if interesting_tags or request_user.has_interesting_wildcard_tags(): - # #expensive query - # if request_user.display_tag_filter_strategy == \ - # const.INCLUDE_INTERESTING: - # #filter by interesting tags only - # interesting_tag_filter = models.Q(thread__tags__in = interesting_tags) - # if request_user.has_interesting_wildcard_tags(): - # interesting_wildcards = request_user.interesting_tags.split() - # extra_interesting_tags = Tag.objects.get_by_wildcards( - # interesting_wildcards - # ) - # interesting_tag_filter |= models.Q(thread__tags__in = extra_interesting_tags) - # - # qs = qs.filter(interesting_tag_filter) - # else: - # pass - # #simply annotate interesting questions - ## qs = qs.extra( - ## select = SortedDict([ - ## ( - ## # TODO: [tags] Update this query so that it fetches tags from Thread - ## 'interesting_score', - ## 'SELECT COUNT(1) FROM askbot_markedtag, question_tags ' - ## + 'WHERE askbot_markedtag.user_id = %s ' - ## + 'AND askbot_markedtag.tag_id = question_tags.tag_id ' - ## + 'AND askbot_markedtag.reason = \'good\' ' - ## + 'AND question_tags.question_id = question.id' - ## ), - ## ]), - ## select_params = (uid_str,), - ## ) - # - # # get the list of interesting and ignored tags (interesting_tag_names, ignored_tag_names) = (None, None) - # - # if ignored_tags or request_user.has_ignored_wildcard_tags(): - # if request_user.display_tag_filter_strategy == const.EXCLUDE_IGNORED: - # #exclude ignored tags if the user wants to - # qs = qs.exclude(thread__tags__in=ignored_tags) - # if request_user.has_ignored_wildcard_tags(): - # ignored_wildcards = request_user.ignored_tags.split() - # extra_ignored_tags = Tag.objects.get_by_wildcards( - # ignored_wildcards - # ) - # qs = qs.exclude(thread__tags__in = extra_ignored_tags) - # else: - # pass - ## #annotate questions tagged with ignored tags - ## #expensive query - ## qs = qs.extra( - ## select = SortedDict([ - ## ( - ## 'ignored_score', - ## # TODO: [tags] Update this query so that it fetches tags from Thread - ## 'SELECT COUNT(1) ' - ## + 'FROM askbot_markedtag, question_tags ' - ## + 'WHERE askbot_markedtag.user_id = %s ' - ## + 'AND askbot_markedtag.tag_id = question_tags.tag_id ' - ## + 'AND askbot_markedtag.reason = \'bad\' ' - ## + 'AND question_tags.question_id = question.id' - ## ) - ## ]), - ## select_params = (uid_str, ) - ## ) - # - # if sort_method != 'relevance-desc': - # #relevance sort is set in the extra statement - # #only for postgresql - # orderby = QUESTION_ORDER_BY_MAP[sort_method] - # qs = qs.order_by(orderby) - # - # qs = qs.distinct() - # qs = qs.select_related( - # 'thread__last_activity_by__id', - # 'thread__last_activity_by__username', - # 'thread__last_activity_by__reputation', - # 'thread__last_activity_by__gold', - # 'thread__last_activity_by__silver', - # 'thread__last_activity_by__bronze', - # 'thread__last_activity_by__country', - # 'thread__last_activity_by__show_country', - # ) - # - # related_tags = Tag.objects.get_related_to_search( - # questions = qs, - # search_state = search_state, - # ignored_tag_names = ignored_tag_names - # ) - # if askbot_settings.USE_WILDCARD_TAGS == True \ - # and request_user.is_authenticated() == True: - # tagnames = request_user.interesting_tags - # meta_data['interesting_tag_names'].extend(tagnames.split()) - # tagnames = request_user.ignored_tags - # meta_data['ignored_tag_names'].extend(tagnames.split()) - # return qs, meta_data, related_tags - def added_between(self, start, end): """questions added between ``start`` and ``end`` timestamps""" #todo: goes to thread @@ -382,9 +174,9 @@ class PostManager(BaseQuerySetManager): ) #update thread data - thread.set_last_activity(last_activity_at=added_at, last_activity_by=author) thread.answer_count +=1 thread.save() + thread.set_last_activity(last_activity_at=added_at, last_activity_by=author) # this should be here because it regenerates cached thread summary html #set notification/delete if email_notify: @@ -438,7 +230,7 @@ class PostManager(BaseQuerySetManager): class Post(models.Model): - post_type = models.CharField(max_length=255) + post_type = models.CharField(max_length=255, db_index=True) old_question_id = models.PositiveIntegerField(null=True, blank=True, default=None, unique=True) old_answer_id = models.PositiveIntegerField(null=True, blank=True, default=None, unique=True) @@ -450,7 +242,7 @@ class Post(models.Model): author = models.ForeignKey(User, related_name='posts') added_at = models.DateTimeField(default=datetime.datetime.now) - deleted = models.BooleanField(default=False) + deleted = models.BooleanField(default=False, db_index=True) deleted_at = models.DateTimeField(null=True, blank=True) deleted_by = models.ForeignKey(User, null=True, blank=True, related_name='deleted_posts') @@ -660,8 +452,10 @@ class Post(models.Model): def is_comment(self): return self.post_type == 'comment' - def get_absolute_url(self, no_slug = False, question_post=None): + def get_absolute_url(self, no_slug = False, question_post=None, thread=None): from askbot.utils.slug import slugify + if not hasattr(self, '_thread_cache') and thread: + self._thread_cache = thread if self.is_answer(): if not question_post: question_post = self.thread._question_post() @@ -682,12 +476,6 @@ class Post(models.Model): raise NotImplementedError - - def delete(self, *args, **kwargs): - # WARNING: This is not called for batch deletions so watch out! - # TODO: Restore specialized Comment.delete() functionality! - super(Post, self).delete(*args, **kwargs) - def delete(self, **kwargs): """deletes comment and concomitant response activity records, as well as mention records, while preserving @@ -722,8 +510,6 @@ class Post(models.Model): super(Post, self).delete(**kwargs) - - def __unicode__(self): if self.is_question(): return self.thread.title @@ -733,12 +519,6 @@ class Post(models.Model): return self.text raise NotImplementedError - def is_answer(self): - return self.post_type == 'answer' - - def is_question(self): - return self.post_type == 'question' - def save(self, *args, **kwargs): if self.is_answer() and self.is_anonymous: raise ValueError('Answer cannot be anonymous!') @@ -1091,7 +871,7 @@ class Post(models.Model): raise NotImplementedError def get_latest_revision(self): - return self.revisions.all().order_by('-revised_at')[0] + return self.revisions.order_by('-revised_at')[0] def get_latest_revision_number(self): if self.is_comment(): @@ -1255,22 +1035,6 @@ class Post(models.Model): return new_question - def get_page_number(self, answers = None): - """When question has many answers, answers are - paginated. This function returns number of the page - on which the answer will be shown, using the default - sort order. The result may depend on the visitor.""" - if self.is_question(): - return 1 - elif self.is_answer(): - order_number = 0 - for answer in answers: - if self == answer: - break - order_number += 1 - return int(order_number/const.ANSWERS_PAGE_SIZE) + 1 - raise NotImplementedError - def get_user_vote(self, user): if not self.is_answer(): raise NotImplementedError @@ -1610,15 +1374,6 @@ class Post(models.Model): def accepted(self): if self.is_answer(): - return self.question.thread.accepted_answer == self - raise NotImplementedError - - ##### - ##### - ##### - - def accepted(self): - if self.is_answer(): return self.thread.accepted_answer_id == self.id raise NotImplementedError @@ -1647,9 +1402,6 @@ class Post(models.Model): raise NotImplementedError return self.parent.comments.filter(added_at__lt = self.added_at).count() + 1 - def get_latest_revision(self): - return self.revisions.order_by('-revised_at')[0] - def is_upvoted_by(self, user): from askbot.models.meta import Vote return Vote.objects.filter(user=user, voted_post=self, vote=Vote.VOTE_UP).exists() diff --git a/askbot/models/question.py b/askbot/models/question.py index 6f379491..6c2fa383 100644 --- a/askbot/models/question.py +++ b/askbot/models/question.py @@ -1,10 +1,12 @@ import datetime import operator +import re from django.conf import settings from django.db import models from django.contrib.auth.models import User from django.utils.translation import ugettext as _ +from django.core import cache # import cache, not from cache import cache, to be able to monkey-patch cache.cache in test cases import askbot import askbot.conf @@ -15,6 +17,9 @@ from askbot.models import signals from askbot import const from askbot.utils.lists import LazyList from askbot.utils import mysql +from askbot.skins.loaders import get_template #jinja2 template loading enviroment +from askbot.search.state_manager import DummySearchState + class ThreadManager(models.Manager): def get_tag_summary_from_threads(self, threads): @@ -93,42 +98,43 @@ class ThreadManager(models.Manager): return thread - def get_for_query(self, search_query): + def get_for_query(self, search_query, qs=None): """returns a query set of questions, matching the full text query """ + if not qs: + qs = self.all() # if getattr(settings, 'USE_SPHINX_SEARCH', False): # matching_questions = Question.sphinx_search.query(search_query) # question_ids = [q.id for q in matching_questions] -# return self.filter(posts__post_type='question', posts__deleted=False, posts__self_question_id__in=question_ids) +# return qs.filter(posts__post_type='question', posts__deleted=False, posts__self_question_id__in=question_ids) if askbot.get_database_engine_name().endswith('mysql') \ and mysql.supports_full_text_search(): - return self.filter( + return qs.filter( models.Q(title__search = search_query) | models.Q(tagnames__search = search_query) | models.Q(posts__deleted=False, posts__text__search = search_query) ) elif 'postgresql_psycopg2' in askbot.get_database_engine_name(): - # TODO: !! Fix Postgres search - rank_clause = "ts_rank(text_search_vector, plainto_tsquery(%s))"; + rank_clause = "ts_rank(askbot_thread.text_search_vector, plainto_tsquery(%s))" search_query = '&'.join(search_query.split()) extra_params = (search_query,) extra_kwargs = { 'select': {'relevance': rank_clause}, - 'where': ['text_search_vector @@ plainto_tsquery(%s)'], + 'where': ['askbot_thread.text_search_vector @@ plainto_tsquery(%s)'], 'params': extra_params, 'select_params': extra_params, } - return self.extra(**extra_kwargs) + return qs.extra(**extra_kwargs) else: - return self.filter( + return qs.filter( models.Q(title__icontains=search_query) | models.Q(tagnames__icontains=search_query) | models.Q(posts__deleted=False, posts__text__icontains = search_query) ) - def run_advanced_search(self, request_user, search_state, page_size): # TODO: !! review, fix, and write tests for this + def run_advanced_search(self, request_user, search_state): # TODO: !! review, fix, and write tests for this """ all parameters are guaranteed to be clean however may not relate to database - in that case @@ -137,18 +143,19 @@ class ThreadManager(models.Manager): """ from askbot.conf import settings as askbot_settings # Avoid circular import - qs = self.filter(posts__post_type='question', posts__deleted=False) # TODO: add a possibility to see deleted questions + # TODO: add a possibility to see deleted questions + qs = self.filter(posts__post_type='question', posts__deleted=False) # (***) brings `askbot_post` into the SQL query, see the ordering section below meta_data = {} if search_state.stripped_query: - qs = self.get_for_query(search_state.stripped_query) + qs = self.get_for_query(search_query=search_state.stripped_query, qs=qs) if search_state.query_title: qs = qs.filter(title__icontains = search_state.query_title) if search_state.query_users: query_users = User.objects.filter(username__in=search_state.query_users) if query_users: - qs = qs.filter(posts__post_type='question', posts__author__in=query_users) + qs = qs.filter(posts__post_type='question', posts__author__in=query_users) # TODO: unify with search_state.author ? tags = search_state.unified_tags() for tag in tags: @@ -184,7 +191,6 @@ class ThreadManager(models.Manager): meta_data['author_name'] = u.username #get users tag filters - ignored_tag_names = None if request_user and request_user.is_authenticated(): #mark questions tagged with interesting tags #a kind of fancy annotation, would be nice to avoid it @@ -192,9 +198,7 @@ class ThreadManager(models.Manager): ignored_tags = Tag.objects.filter(user_selections__user=request_user, user_selections__reason='bad') meta_data['interesting_tag_names'] = [tag.name for tag in interesting_tags] - - ignored_tag_names = [tag.name for tag in ignored_tags] - meta_data['ignored_tag_names'] = ignored_tag_names + meta_data['ignored_tag_names'] = [tag.name for tag in ignored_tags] if request_user.display_tag_filter_strategy == const.INCLUDE_INTERESTING and (interesting_tags or request_user.has_interesting_wildcard_tags()): #filter by interesting tags only @@ -214,47 +218,68 @@ class ThreadManager(models.Manager): extra_ignored_tags = Tag.objects.get_by_wildcards(ignored_wildcards) qs = qs.exclude(tags__in = extra_ignored_tags) - ### - # HACK: GO BACK To QUESTIONS, otherwise we cannot sort properly! - thread_ids = qs.values_list('id', flat = True) - qs_thread = qs - qs = Post.objects.filter(post_type='question', thread__id__in=thread_ids) - qs = qs.select_related('thread__last_activity_by') - - if search_state.sort == 'relevance-desc': - # TODO: askbot_thread.relevance is not available here, so we have to work around it. Ideas: - # * convert the whole questions() pipeline to Thread-s - # * ... - #qs = qs.extra(select={'relevance': 'askbot_thread.relevance'}, order_by=['-relevance',]) - pass - else: - QUESTION_ORDER_BY_MAP = { - 'age-desc': '-added_at', - 'age-asc': 'added_at', - 'activity-desc': '-thread__last_activity_at', - 'activity-asc': 'thread__last_activity_at', - 'answers-desc': '-thread__answer_count', - 'answers-asc': 'thread__answer_count', - 'votes-desc': '-score', - 'votes-asc': 'score', - } - orderby = QUESTION_ORDER_BY_MAP[search_state.sort] - qs = qs.order_by(orderby) - - related_tags = Tag.objects.get_related_to_search(questions = qs, page_size = page_size, ignored_tag_names = ignored_tag_names) # TODO: !! - - if askbot_settings.USE_WILDCARD_TAGS and request_user.is_authenticated(): - meta_data['interesting_tag_names'].extend(request_user.interesting_tags.split()) - meta_data['ignored_tag_names'].extend(request_user.ignored_tags.split()) + if askbot_settings.USE_WILDCARD_TAGS: + meta_data['interesting_tag_names'].extend(request_user.interesting_tags.split()) + meta_data['ignored_tag_names'].extend(request_user.ignored_tags.split()) + + QUESTION_ORDER_BY_MAP = { + 'age-desc': '-askbot_post.added_at', + 'age-asc': 'askbot_post.added_at', + 'activity-desc': '-last_activity_at', + 'activity-asc': 'last_activity_at', + 'answers-desc': '-answer_count', + 'answers-asc': 'answer_count', + 'votes-desc': '-askbot_post.score', + 'votes-asc': 'askbot_post.score', + + 'relevance-desc': '-relevance', # special Postgresql-specific ordering, 'relevance' quaso-column is added by get_for_query() + } + orderby = QUESTION_ORDER_BY_MAP[search_state.sort] + qs = qs.extra(order_by=[orderby]) + + # HACK: We add 'ordering_key' column as an alias and order by it, because when distict() is used, + # qs.extra(order_by=[orderby,]) is lost if only `orderby` column is from askbot_post! + # Removing distinct() from the queryset fixes the problem, but we have to use it here. + # UPDATE: Apparently we don't need distinct, the query don't duplicate Thread rows! + # qs = qs.extra(select={'ordering_key': orderby.lstrip('-')}, order_by=['-ordering_key' if orderby.startswith('-') else 'ordering_key']) + # qs = qs.distinct() + + qs = qs.only('id', 'title', 'view_count', 'answer_count', 'last_activity_at', 'last_activity_by', 'closed', 'tagnames', 'accepted_answer') + + #print qs.query + + return qs, meta_data + + def precache_view_data_hack(self, threads): + # TODO: Re-enable this when we have a good test cases to verify that it works properly. + # + # E.g.: - make sure that not precaching give threads never increase # of db queries for the main page + # - make sure that it really works, i.e. stuff for non-cached threads is fetched properly + # Precache data only for non-cached threads - only those will be rendered + #threads = [thread for thread in threads if not thread.summary_html_cached()] + + page_questions = Post.objects.filter(post_type='question', thread__in=[obj.id for obj in threads])\ + .only('id', 'thread', 'score', 'is_anonymous', 'summary', 'post_type', 'deleted') # pick only the used fields + page_question_map = {} + for pq in page_questions: + page_question_map[pq.thread_id] = pq + for thread in threads: + thread._question_cache = page_question_map[thread.id] - qs = qs.distinct() + last_activity_by_users = User.objects.filter(id__in=[obj.last_activity_by_id for obj in threads])\ + .only('id', 'username', 'country', 'show_country') + user_map = {} + for la_user in last_activity_by_users: + user_map[la_user.id] = la_user + for thread in threads: + thread._last_activity_by_cache = user_map[thread.last_activity_by_id] - return qs, meta_data, related_tags #todo: this function is similar to get_response_receivers - profile this function against the other one def get_thread_contributors(self, thread_list): """Returns query set of Thread contributors""" - u_id = Post.objects.filter(post_type__in=['question', 'answer'], thread__in=thread_list).values_list('author', flat=True) + # INFO: Evaluate this query to avoid subquery in the subsequent query below (At least MySQL can be awfully slow on subqueries) + u_id = list(Post.objects.filter(post_type__in=('question', 'answer'), thread__in=thread_list).values_list('author', flat=True)) #todo: this does not belong gere - here we select users with real faces #first and limit the number of users in the result for display @@ -268,6 +293,8 @@ class ThreadManager(models.Manager): class Thread(models.Model): + SUMMARY_CACHE_KEY_TPL = 'thread-question-summary-%d' + title = models.CharField(max_length=300) tags = models.ManyToManyField('Tag', related_name='threads') @@ -300,8 +327,14 @@ class Thread(models.Model): class Meta: app_label = 'askbot' - def _question_post(self): - return Post.objects.get(post_type='question', thread=self) + def _question_post(self, refresh=False): + if refresh and hasattr(self, '_question_cache'): + delattr(self, '_question_cache') + post = getattr(self, '_question_cache', None) + if post: + return post + self._question_cache = Post.objects.get(post_type='question', thread=self) + return self._question_cache def get_absolute_url(self): return self._question_post().get_absolute_url() @@ -318,6 +351,9 @@ class Thread(models.Model): qset = Thread.objects.filter(id=self.id) qset.update(view_count=models.F('view_count') + increment) self.view_count = qset.values('view_count')[0]['view_count'] # get the new view_count back because other pieces of code relies on such behaviour + #################################################################### + self.update_summary_html() # regenerate question/thread summary html + #################################################################### def set_closed_status(self, closed, closed_by, closed_at, close_reason): self.closed = closed @@ -337,6 +373,9 @@ class Thread(models.Model): self.last_activity_at = last_activity_at self.last_activity_by = last_activity_by self.save() + #################################################################### + self.update_summary_html() # regenerate question/thread summary html + #################################################################### def get_tag_names(self): "Creates a list of Tag names from the ``tagnames`` attribute." @@ -512,6 +551,10 @@ class Thread(models.Model): self.tags.add(*added_tags) modified_tags.extend(added_tags) + #################################################################### + self.update_summary_html() # regenerate question/thread summary html + #################################################################### + #if there are any modified tags, update their use counts if modified_tags: Tag.objects.update_use_counts(modified_tags) @@ -576,7 +619,47 @@ class Thread(models.Model): return last_updated_at, last_updated_by - + def get_summary_html(self, search_state): + html = self.get_cached_summary_html() + if not html: + html = self.update_summary_html() + + # use `<<<` and `>>>` because they cannot be confused with user input + # - if user accidentialy types <<<tag-name>>> into question title or body, + # then in html it'll become escaped like this: <<<tag-name>>> + regex = re.compile(r'<<<(%s)>>>' % const.TAG_REGEX_BARE) + + while True: + match = regex.search(html) + if not match: + break + seq = match.group(0) # e.g "<<<my-tag>>>" + tag = match.group(1) # e.g "my-tag" + full_url = search_state.add_tag(tag).full_url() + html = html.replace(seq, full_url) + + return html + + def get_cached_summary_html(self): + return cache.cache.get(self.SUMMARY_CACHE_KEY_TPL % self.id) + + def update_summary_html(self): + context = { + 'thread': self, + 'question': self._question_post(refresh=True), # fetch new question post to make sure we're up-to-date + 'search_state': DummySearchState(), + } + html = get_template('widgets/question_summary.html').render(context) + # INFO: Timeout is set to 30 days: + # * timeout=0/None is not a reliable cross-backend way to set infinite timeout + # * We probably don't need to pollute the cache with threads older than 30 days + # * Additionally, Memcached treats timeouts > 30day as dates (https://code.djangoproject.com/browser/django/tags/releases/1.3/django/core/cache/backends/memcached.py#L36), + # which probably doesn't break anything but if we can stick to 30 days then let's stick to it + cache.cache.set(self.SUMMARY_CACHE_KEY_TPL % self.id, html, timeout=60*60*24*30) + return html + + def summary_html_cached(self): + return cache.cache.has_key(self.SUMMARY_CACHE_KEY_TPL % self.id) #class Question(content.Content): diff --git a/askbot/models/tag.py b/askbot/models/tag.py index 31ac9806..a13de661 100644 --- a/askbot/models/tag.py +++ b/askbot/models/tag.py @@ -66,42 +66,13 @@ class TagQuerySet(models.query.QuerySet): tag_filter |= models.Q(name__startswith = next_tag[:-1]) return self.filter(tag_filter) - def get_related_to_search(self, questions, page_size, ignored_tag_names): - """must return at least tag names, along with use counts - handle several cases to optimize the query performance - """ - - if questions.count() > page_size * 3: - """if we have too many questions or - search query is the most common - just return a list - of top tags""" - cheating = True - tags = Tag.objects.all().order_by('-used_count') - else: - cheating = False - #getting id's is necessary to avoid hitting a heavy query - #on entire selection of questions. We actually want - #the big questions query to hit only the page to be displayed - thread_id_list = questions.values_list('thread_id', flat=True) - tags = self.filter( - threads__id__in = thread_id_list, - ).annotate( - local_used_count=models.Count('id') - ).order_by( - '-local_used_count' - ) - + def get_related_to_search(self, threads, ignored_tag_names): + """Returns at least tag names, along with use counts""" + tags = self.filter(threads__in=threads).annotate(local_used_count=models.Count('id')).order_by('-local_used_count', 'name') if ignored_tag_names: tags = tags.exclude(name__in=ignored_tag_names) - tags = tags.exclude(deleted = True) - - tags = tags[:50]#magic number - if cheating: - for tag in tags: - tag.local_used_count = tag.used_count - - return tags + return list(tags[:50]) class TagManager(BaseQuerySetManager): diff --git a/askbot/search/postgresql/thread_and_post_models_01162012.plsql b/askbot/search/postgresql/thread_and_post_models_01162012.plsql index 7156833b..44d0ea4a 100644 --- a/askbot/search/postgresql/thread_and_post_models_01162012.plsql +++ b/askbot/search/postgresql/thread_and_post_models_01162012.plsql @@ -219,4 +219,5 @@ DROP TRIGGER IF EXISTS post_search_vector_update_trigger on askbot_post; CREATE TRIGGER post_search_vector_update_trigger BEFORE INSERT OR UPDATE ON askbot_post FOR EACH ROW EXECUTE PROCEDURE post_trigger(); +DROP INDEX IF EXISTS askbot_search_idx; CREATE INDEX askbot_search_idx ON askbot_thread USING gin(text_search_vector); diff --git a/askbot/search/state_manager.py b/askbot/search/state_manager.py index 211ce638..232d64e9 100644 --- a/askbot/search/state_manager.py +++ b/askbot/search/state_manager.py @@ -1,14 +1,15 @@ """Search state manager object""" import re +import urllib import copy from django.core import urlresolvers -from django.utils.http import urlquote, urlencode +from django.utils.http import urlencode +from django.utils.encoding import smart_str import askbot import askbot.conf from askbot import const -from askbot.conf import settings as askbot_settings from askbot.utils.functions import strip_plus @@ -122,11 +123,13 @@ class SearchState(object): if self.page == 0: # in case someone likes jokes :) self.page = 1 + self._questions_url = urlresolvers.reverse('questions') + def __str__(self): return self.query_string() def full_url(self): - return urlresolvers.reverse('questions') + self.query_string() + return self._questions_url + self.query_string() def ask_query_string(self): # TODO: test me """returns string to prepopulate title field on the "Ask your question" page""" @@ -158,27 +161,47 @@ class SearchState(object): def query_string(self): lst = [ - 'scope:%s' % self.scope, - 'sort:%s' % self.sort + 'scope:' + self.scope, + 'sort:' + self.sort ] if self.query: - lst.append('query:%s' % urlquote(self.query, safe=self.SAFE_CHARS)) + lst.append('query:' + urllib.quote(smart_str(self.query), safe=self.SAFE_CHARS)) if self.tags: - lst.append('tags:%s' % urlquote(const.TAG_SEP.join(self.tags), safe=self.SAFE_CHARS)) + lst.append('tags:' + urllib.quote(smart_str(const.TAG_SEP.join(self.tags)), safe=self.SAFE_CHARS)) if self.author: - lst.append('author:%d' % self.author) + lst.append('author:' + str(self.author)) if self.page: - lst.append('page:%d' % self.page) + lst.append('page:' + str(self.page)) return '/'.join(lst) + '/' - def deepcopy(self): + def deepcopy(self): # TODO: test me "Used to contruct a new SearchState for manipulation, e.g. for adding/removing tags" - return copy.deepcopy(self) + ss = copy.copy(self) #SearchState.get_empty() + + #ss.scope = self.scope + #ss.sort = self.sort + #ss.query = self.query + if ss.tags is not None: # it's important to test against None, because empty lists should also be cloned! + ss.tags = ss.tags[:] # create a copy + #ss.author = self.author + #ss.page = self.page + + #ss.stripped_query = self.stripped_query + if ss.query_tags: # Here we don't have empty lists, only None + ss.query_tags = ss.query_tags[:] + if ss.query_users: + ss.query_users = ss.query_users[:] + #ss.query_title = self.query_title + + #ss._questions_url = self._questions_url + + return ss def add_tag(self, tag): ss = self.deepcopy() - ss.tags.append(tag) - ss.page = 1 # state change causes page reset + if tag not in ss.tags: + ss.tags.append(tag) + ss.page = 1 # state change causes page reset return ss def remove_author(self): @@ -209,3 +232,11 @@ class SearchState(object): ss = self.deepcopy() ss.page = new_page return ss + + +class DummySearchState(object): # Used for caching question/thread summaries + def add_tag(self, tag): + self.tag = tag + return self + def full_url(self): + return '<<<%s>>>' % self.tag diff --git a/askbot/skins/default/templates/main_page/questions_loop.html b/askbot/skins/default/templates/main_page/questions_loop.html index 7e924e63..6a5e5e3d 100644 --- a/askbot/skins/default/templates/main_page/questions_loop.html +++ b/askbot/skins/default/templates/main_page/questions_loop.html @@ -1,9 +1,10 @@ {% import "macros.html" as macros %} {# cache 0 "questions" questions search_tags scope sort query context.page language_code #} -{% for question_post in questions.object_list %} - {{macros.question_summary(question_post.thread, question_post, search_state=search_state)}} +{% for thread in threads.object_list %} + {# {{macros.question_summary(thread, thread._question_post(), search_state=search_state)}} #} + {{ thread.get_summary_html(search_state=search_state) }} {% endfor %} -{% if questions.object_list|length == 0 %} +{% if threads.object_list|length == 0 %} {% include "main_page/nothing_found.html" %} {% else %} <div class="evenMore"> diff --git a/askbot/skins/default/templates/widgets/question_summary.html b/askbot/skins/default/templates/widgets/question_summary.html index feebd27f..56154847 100644 --- a/askbot/skins/default/templates/widgets/question_summary.html +++ b/askbot/skins/default/templates/widgets/question_summary.html @@ -42,16 +42,17 @@ </div> <div style="clear:both"></div> <div class="userinfo"> - <span class="relativetime" title="{{thread.last_activity_at}}">{{ thread.last_activity_at|diff_date }}</span> + {# We have to kill microseconds below because InnoDB doesn't support them and all kinds of funny things happen in unit tests #} + <span class="relativetime" title="{{thread.last_activity_at.replace(microsecond=0)}}">{{ thread.last_activity_at|diff_date }}</span> {% if question.is_anonymous %} <span class="anonymous">{{ thread.last_activity_by.get_anonymous_name() }}</span> {% else %} - <a href="{% url user_profile thread.last_activity_by.id, thread.last_activity_by.username|slugify %}">{{thread.last_activity_by.username}}</a>{{ user_country_flag(thread.last_activity_by) }} - {#{user_score_and_badge_summary(thread.last_activity_by)}#} + <a href="{% url user_profile thread.last_activity_by.id, thread.last_activity_by.username|slugify %}">{{thread.last_activity_by.username}}</a> {{ user_country_flag(thread.last_activity_by) }} + {#{user_score_and_badge_summary(thread.last_activity_by)}#} {% endif %} </div> </div> - <h2><a title="{{question.summary|escape}}" href="{{ question.get_absolute_url() }}">{{thread.get_title(question)|escape}}</a></h2> + <h2><a title="{{question.summary|escape}}" href="{{ question.get_absolute_url(thread=thread) }}">{{thread.get_title(question)|escape}}</a></h2> {{ tag_list_widget(thread.get_tag_names(), search_state=search_state) }} </div> diff --git a/askbot/tests/page_load_tests.py b/askbot/tests/page_load_tests.py index 16732e99..10bded11 100644 --- a/askbot/tests/page_load_tests.py +++ b/askbot/tests/page_load_tests.py @@ -1,18 +1,21 @@ -from django.test import TestCase +from askbot.search.state_manager import SearchState from django.test import signals -from django.template import defaultfilters from django.conf import settings from django.core.urlresolvers import reverse +from django.core import management +from django.core.cache.backends.dummy import DummyCache +from django.core import cache + import coffin import coffin.template + from askbot import models from askbot.utils.slug import slugify from askbot.deployment import package_utils from askbot.tests.utils import AskbotTestCase from askbot.conf import settings as askbot_settings from askbot.tests.utils import skipIf -import sys -import os + def patch_jinja2(): @@ -36,22 +39,48 @@ if CMAJOR == 0 and CMINOR == 3 and CMICRO < 4: class PageLoadTestCase(AskbotTestCase): - def _fixture_setup(self): - from django.core import management + ############################################# + # + # INFO: We load test data once for all tests in this class (setUpClass + cleanup in tearDownClass) + # + # We also disable (by overriding _fixture_setup/teardown) per-test fixture setup, + # which by default flushes the database for non-transactional db engines like MySQL+MyISAM. + # For transactional engines it only messes with transactions, but to keep things uniform + # for both types of databases we disable it all. + # + @classmethod + def setUpClass(cls): + management.call_command('flush', verbosity=0, interactive=False) management.call_command('askbot_add_test_content', verbosity=0, interactive=False) - super(PageLoadTestCase, self)._fixture_setup() - def _fixture_teardown(self): - super(PageLoadTestCase, self)._fixture_teardown() - from django.core import management + @classmethod + def tearDownClass(self): management.call_command('flush', verbosity=0, interactive=False) + def _fixture_setup(self): + pass + + def _fixture_teardown(self): + pass + + ############################################# + + def setUp(self): + self.old_cache = cache.cache + cache.cache = DummyCache('', {}) # Disable caching (to not interfere with production cache, not sure if that's possible but let's not risk it) + + def tearDown(self): + cache.cache = self.old_cache # Restore caching + def try_url( self, url_name, status_code=200, template=None, kwargs={}, redirect_url=None, follow=False, - data={}): - url = reverse(url_name, kwargs=kwargs) + data={}, plain_url_passed=False): + if plain_url_passed: + url = url_name + else: + url = reverse(url_name, kwargs=kwargs) if status_code == 302: url_info = 'redirecting to LOGIN_URL in closed_mode: %s' % url else: @@ -79,16 +108,19 @@ class PageLoadTestCase(AskbotTestCase): #asuming that there is more than one template template_names = ','.join([t.name for t in r.template]) print 'templates are %s' % template_names - if follow == False: - self.fail( - ('Have issue accessing %s. ' - 'This should not have happened, ' - 'since you are not expecting a redirect ' - 'i.e. follow == False, there should be only ' - 'one template') % url - ) - - self.assertEqual(r.template[0].name, template) + # The following code is no longer relevant because we're using + # additional templates for cached fragments [e.g. thread.get_summary_html()] +# if follow == False: +# self.fail( +# ('Have issue accessing %s. ' +# 'This should not have happened, ' +# 'since you are not expecting a redirect ' +# 'i.e. follow == False, there should be only ' +# 'one template') % url +# ) +# +# self.assertEqual(r.template[0].name, template) + self.assertIn(template, [t.name for t in r.template]) else: raise Exception('unexpected error while runnig test') @@ -99,7 +131,8 @@ class PageLoadTestCase(AskbotTestCase): self.assertEqual(response.status_code, 200) self.failUnless(len(response.redirect_chain) == 1) self.failUnless(response.redirect_chain[0][0].endswith('/questions/')) - self.assertEquals(response.template.name, 'main_page.html') + self.assertTrue(isinstance(response.template, list)) + self.assertIn('main_page.html', [t.name for t in response.template]) def proto_test_ask_page(self, allow_anonymous, status_code): prev_setting = askbot_settings.ALLOW_POSTING_BEFORE_LOGGING_IN @@ -170,76 +203,81 @@ class PageLoadTestCase(AskbotTestCase): ) #todo: test different sort methods and scopes self.try_url( - 'questions', - status_code=status_code, - template='main_page.html' - ) - self.try_url( - 'questions', - status_code=status_code, - data={'start_over':'true'}, - template='main_page.html' - ) + 'questions', + status_code=status_code, + template='main_page.html' + ) self.try_url( - 'questions', - status_code=status_code, - data={'scope':'unanswered'}, - template='main_page.html' - ) + url_name=reverse('questions') + SearchState.get_empty().change_scope('unanswered').query_string(), + plain_url_passed=True, + + status_code=status_code, + template='main_page.html', + ) self.try_url( - 'questions', - status_code=status_code, - data={'scope':'favorite'}, - template='main_page.html' - ) + url_name=reverse('questions') + SearchState.get_empty().change_scope('favorite').query_string(), + plain_url_passed=True, + + status_code=status_code, + template='main_page.html' + ) self.try_url( - 'questions', - status_code=status_code, - data={'scope':'unanswered', 'sort':'age-desc'}, - template='main_page.html' - ) + url_name=reverse('questions') + SearchState.get_empty().change_scope('unanswered').change_sort('age-desc').query_string(), + plain_url_passed=True, + + status_code=status_code, + template='main_page.html' + ) self.try_url( - 'questions', - status_code=status_code, - data={'scope':'unanswered', 'sort':'age-asc'}, - template='main_page.html' - ) + url_name=reverse('questions') + SearchState.get_empty().change_scope('unanswered').change_sort('age-asc').query_string(), + plain_url_passed=True, + + status_code=status_code, + template='main_page.html' + ) self.try_url( - 'questions', - status_code=status_code, - data={'scope':'unanswered', 'sort':'activity-desc'}, - template='main_page.html' - ) + url_name=reverse('questions') + SearchState.get_empty().change_scope('unanswered').change_sort('activity-desc').query_string(), + plain_url_passed=True, + + status_code=status_code, + template='main_page.html' + ) self.try_url( - 'questions', - status_code=status_code, - data={'scope':'unanswered', 'sort':'activity-asc'}, - template='main_page.html' - ) + url_name=reverse('questions') + SearchState.get_empty().change_scope('unanswered').change_sort('activity-asc').query_string(), + plain_url_passed=True, + + status_code=status_code, + template='main_page.html' + ) self.try_url( - 'questions', - status_code=status_code, - data={'sort':'answers-desc'}, - template='main_page.html' - ) + url_name=reverse('questions') + SearchState.get_empty().change_sort('answers-desc').query_string(), + plain_url_passed=True, + + status_code=status_code, + template='main_page.html' + ) self.try_url( - 'questions', - status_code=status_code, - data={'sort':'answers-asc'}, - template='main_page.html' - ) + url_name=reverse('questions') + SearchState.get_empty().change_sort('answers-asc').query_string(), + plain_url_passed=True, + + status_code=status_code, + template='main_page.html' + ) self.try_url( - 'questions', - status_code=status_code, - data={'sort':'votes-desc'}, - template='main_page.html' - ) + url_name=reverse('questions') + SearchState.get_empty().change_sort('votes-desc').query_string(), + plain_url_passed=True, + + status_code=status_code, + template='main_page.html' + ) self.try_url( - 'questions', - status_code=status_code, - data={'sort':'votes-asc'}, - template='main_page.html' - ) + url_name=reverse('questions') + SearchState.get_empty().change_sort('votes-asc').query_string(), + plain_url_passed=True, + + status_code=status_code, + template='main_page.html' + ) + self.try_url( 'question', status_code=status_code, diff --git a/askbot/tests/post_model_tests.py b/askbot/tests/post_model_tests.py index e11d2e81..06bceca1 100644 --- a/askbot/tests/post_model_tests.py +++ b/askbot/tests/post_model_tests.py @@ -1,8 +1,19 @@ +import copy import datetime +from operator import attrgetter +import time +from askbot.search.state_manager import SearchState +from askbot.skins.loaders import get_template +from django.contrib.auth.models import User +from django.core import cache, urlresolvers +from django.core.cache.backends.dummy import DummyCache +from django.core.cache.backends.locmem import LocMemCache from django.core.exceptions import ValidationError from askbot.tests.utils import AskbotTestCase -from askbot.models import Post, PostRevision +from askbot.models import Post, PostRevision, Thread, Tag +from askbot.search.state_manager import DummySearchState +from django.utils import simplejson class PostModelTests(AskbotTestCase): @@ -111,9 +122,9 @@ class PostModelTests(AskbotTestCase): self.user = self.u1 q = self.post_question() - c1 = self.post_comment(parent_post=q) - c2 = q.add_comment(user=self.user, comment='blah blah') - c3 = self.post_comment(parent_post=q) + c1 = self.post_comment(parent_post=q, timestamp=datetime.datetime(2010, 10, 2, 14, 33, 20)) + c2 = q.add_comment(user=self.user, comment='blah blah', added_at=datetime.datetime(2010, 10, 2, 14, 33, 21)) + c3 = self.post_comment(parent_post=q, body_text='blah blah 2', timestamp=datetime.datetime(2010, 10, 2, 14, 33, 22)) Post.objects.precache_comments(for_posts=[q], visitor=self.user) self.assertListEqual([c1, c2, c3], q._cached_comments) @@ -135,9 +146,9 @@ class PostModelTests(AskbotTestCase): self.user = self.u1 q = self.post_question() - c1 = self.post_comment(parent_post=q) - c2 = q.add_comment(user=self.user, comment='blah blah') - c3 = self.post_comment(parent_post=q) + c1 = self.post_comment(parent_post=q, timestamp=datetime.datetime(2010, 10, 2, 14, 33, 20)) + c2 = q.add_comment(user=self.user, comment='blah blah', added_at=datetime.datetime(2010, 10, 2, 14, 33, 21)) + c3 = self.post_comment(parent_post=q, timestamp=datetime.datetime(2010, 10, 2, 14, 33, 22)) Post.objects.precache_comments(for_posts=[q], visitor=self.user) self.assertListEqual([c1, c2, c3], q._cached_comments) @@ -149,4 +160,517 @@ class PostModelTests(AskbotTestCase): Post.objects.precache_comments(for_posts=[q], visitor=self.user) self.assertListEqual([c3, c2, c1], q._cached_comments) - del self.user
\ No newline at end of file + del self.user + + def test_cached_get_absolute_url_1(self): + th = lambda:1 + th.title = 'lala-x-lala' + p = Post(id=3, post_type='question') + p._thread_cache = th # cannot assign non-Thread instance directly + self.assertEqual('/question/3/lala-x-lala', p.get_absolute_url(thread=th)) + self.assertTrue(p._thread_cache is th) + self.assertEqual('/question/3/lala-x-lala', p.get_absolute_url(thread=th)) + + def test_cached_get_absolute_url_2(self): + p = Post(id=3, post_type='question') + th = lambda:1 + th.title = 'lala-x-lala' + self.assertEqual('/question/3/lala-x-lala', p.get_absolute_url(thread=th)) + self.assertTrue(p._thread_cache is th) + self.assertEqual('/question/3/lala-x-lala', p.get_absolute_url(thread=th)) + + +class ThreadTagModelsTests(AskbotTestCase): + + # TODO: Use rich test data like page load test cases ? + + def setUp(self): + self.create_user() + user2 = self.create_user(username='user2') + user3 = self.create_user(username='user3') + self.q1 = self.post_question(tags='tag1 tag2 tag3') + self.q2 = self.post_question(tags='tag3 tag4 tag5') + self.q3 = self.post_question(tags='tag6', user=user2) + self.q4 = self.post_question(tags='tag1 tag2 tag3 tag4 tag5 tag6', user=user3) + + def test_related_tags(self): + tags = Tag.objects.get_related_to_search(threads=[self.q1.thread, self.q2.thread], ignored_tag_names=[]) + self.assertListEqual(['tag3', 'tag1', 'tag2', 'tag4', 'tag5'], [t.name for t in tags]) + self.assertListEqual([2, 1, 1, 1, 1], [t.local_used_count for t in tags]) + self.assertListEqual([3, 2, 2, 2, 2], [t.used_count for t in tags]) + + tags = Tag.objects.get_related_to_search(threads=[self.q1.thread, self.q2.thread], ignored_tag_names=['tag3', 'tag5']) + self.assertListEqual(['tag1', 'tag2', 'tag4'], [t.name for t in tags]) + self.assertListEqual([1, 1, 1], [t.local_used_count for t in tags]) + self.assertListEqual([2, 2, 2], [t.used_count for t in tags]) + + tags = Tag.objects.get_related_to_search(threads=[self.q3.thread], ignored_tag_names=[]) + self.assertListEqual(['tag6'], [t.name for t in tags]) + self.assertListEqual([1], [t.local_used_count for t in tags]) + self.assertListEqual([2], [t.used_count for t in tags]) + + tags = Tag.objects.get_related_to_search(threads=[self.q3.thread], ignored_tag_names=['tag1']) + self.assertListEqual(['tag6'], [t.name for t in tags]) + self.assertListEqual([1], [t.local_used_count for t in tags]) + self.assertListEqual([2], [t.used_count for t in tags]) + + tags = Tag.objects.get_related_to_search(threads=[self.q3.thread], ignored_tag_names=['tag6']) + self.assertListEqual([], [t.name for t in tags]) + + tags = Tag.objects.get_related_to_search(threads=[self.q1.thread, self.q2.thread, self.q4.thread], ignored_tag_names=['tag2']) + self.assertListEqual(['tag3', 'tag1', 'tag4', 'tag5', 'tag6'], [t.name for t in tags]) + self.assertListEqual([3, 2, 2, 2, 1], [t.local_used_count for t in tags]) + self.assertListEqual([3, 2, 2, 2, 2], [t.used_count for t in tags]) + + def test_run_adv_search_1(self): + ss = SearchState.get_empty() + qs, meta_data = Thread.objects.run_advanced_search(request_user=self.user, search_state=ss) + self.assertEqual(4, qs.count()) + + def test_run_adv_search_ANDing_tags(self): + ss = SearchState.get_empty() + qs, meta_data = Thread.objects.run_advanced_search(request_user=self.user, search_state=ss.add_tag('tag1')) + self.assertEqual(2, qs.count()) + + qs, meta_data = Thread.objects.run_advanced_search(request_user=self.user, search_state=ss.add_tag('tag1').add_tag('tag3')) + self.assertEqual(2, qs.count()) + + qs, meta_data = Thread.objects.run_advanced_search(request_user=self.user, search_state=ss.add_tag('tag1').add_tag('tag3').add_tag('tag6')) + self.assertEqual(1, qs.count()) + + ss = SearchState(scope=None, sort=None, query="#tag3", tags='tag1, tag6', author=None, page=None, user_logged_in=None) + qs, meta_data = Thread.objects.run_advanced_search(request_user=self.user, search_state=ss) + self.assertEqual(1, qs.count()) + + def test_run_adv_search_query_author(self): + ss = SearchState(scope=None, sort=None, query="@user", tags=None, author=None, page=None, user_logged_in=None) + qs, meta_data = Thread.objects.run_advanced_search(request_user=self.user, search_state=ss) + self.assertEqual(2, len(qs)) + self.assertEqual(self.q1.thread_id, min(qs[0].id, qs[1].id)) + self.assertEqual(self.q2.thread_id, max(qs[0].id, qs[1].id)) + + ss = SearchState(scope=None, sort=None, query="@user2", tags=None, author=None, page=None, user_logged_in=None) + qs, meta_data = Thread.objects.run_advanced_search(request_user=self.user, search_state=ss) + self.assertEqual(1, len(qs)) + self.assertEqual(self.q3.thread_id, qs[0].id) + + ss = SearchState(scope=None, sort=None, query="@user3", tags=None, author=None, page=None, user_logged_in=None) + qs, meta_data = Thread.objects.run_advanced_search(request_user=self.user, search_state=ss) + self.assertEqual(1, len(qs)) + self.assertEqual(self.q4.thread_id, qs[0].id) + + def test_run_adv_search_url_author(self): + ss = SearchState(scope=None, sort=None, query=None, tags=None, author=self.user.id, page=None, user_logged_in=None) + qs, meta_data = Thread.objects.run_advanced_search(request_user=self.user, search_state=ss) + self.assertEqual(2, len(qs)) + self.assertEqual(self.q1.thread_id, min(qs[0].id, qs[1].id)) + self.assertEqual(self.q2.thread_id, max(qs[0].id, qs[1].id)) + + ss = SearchState(scope=None, sort=None, query=None, tags=None, author=self.user2.id, page=None, user_logged_in=None) + qs, meta_data = Thread.objects.run_advanced_search(request_user=self.user, search_state=ss) + self.assertEqual(1, len(qs)) + self.assertEqual(self.q3.thread_id, qs[0].id) + + ss = SearchState(scope=None, sort=None, query=None, tags=None, author=self.user3.id, page=None, user_logged_in=None) + qs, meta_data = Thread.objects.run_advanced_search(request_user=self.user, search_state=ss) + self.assertEqual(1, len(qs)) + self.assertEqual(self.q4.thread_id, qs[0].id) + + def test_thread_caching_1(self): + ss = SearchState.get_empty() + qs, meta_data = Thread.objects.run_advanced_search(request_user=self.user, search_state=ss) + qs = list(qs) + + for thread in qs: + self.assertIsNone(getattr(thread, '_question_cache', None)) + self.assertIsNone(getattr(thread, '_last_activity_by_cache', None)) + + post = Post.objects.get(post_type='question', thread=thread.id) + self.assertEqual(post, thread._question_post()) + self.assertEqual(post, thread._question_cache) + self.assertTrue(thread._question_post() is thread._question_cache) + + def test_thread_caching_2_precache_view_data_hack(self): + ss = SearchState.get_empty() + qs, meta_data = Thread.objects.run_advanced_search(request_user=self.user, search_state=ss) + qs = list(qs) + + Thread.objects.precache_view_data_hack(threads=qs) + + for thread in qs: + post = Post.objects.get(post_type='question', thread=thread.id) + self.assertEqual(post.id, thread._question_cache.id) # Cannot compare models instances with deferred model instances + self.assertEqual(post.id, thread._question_post().id) + self.assertTrue(thread._question_post() is thread._question_cache) + + user = User.objects.get(id=thread.last_activity_by_id) + self.assertEqual(user.id, thread._last_activity_by_cache.id) + self.assertTrue(thread.last_activity_by is thread._last_activity_by_cache) + + +class ThreadRenderLowLevelCachingTests(AskbotTestCase): + def setUp(self): + self.create_user() + # INFO: title and body_text should contain tag placeholders so that we can check if they stay untouched + # - only real tag placeholders in tag widget should be replaced with search URLs + self.q = self.post_question(title="<<<tag1>>> fake title", body_text="<<<tag2>>> <<<tag3>>> cheating", tags='tag1 tag2 tag3') + + self.old_cache = cache.cache + + def tearDown(self): + cache.cache = self.old_cache # Restore caching + + def test_thread_summary_rendering_dummy_cache(self): + cache.cache = DummyCache('', {}) # Disable caching + + ss = SearchState.get_empty() + thread = self.q.thread + test_html = thread.get_summary_html(search_state=ss) + + context = { + 'thread': thread, + 'question': thread._question_post(), + 'search_state': ss, + } + proper_html = get_template('widgets/question_summary.html').render(context) + self.assertEqual(test_html, proper_html) + + # Make double-check that all tags are included + self.assertTrue(ss.add_tag('tag1').full_url() in test_html) + self.assertTrue(ss.add_tag('tag2').full_url() in test_html) + self.assertTrue(ss.add_tag('tag3').full_url() in test_html) + self.assertFalse(ss.add_tag('mini-mini').full_url() in test_html) + + # Make sure that title and body text are escaped properly. + # This should be obvious at this point, if the above test passes, but why not be explicit + # UPDATE: And voila, these tests catched double-escaping bug in template, where `<` was `&lt;` + # And indeed, post.summary is escaped before saving, in parse_and_save_post() + # UPDATE 2:Weird things happen with question summary (it's double escaped etc., really weird) so + # let's just make sure that there are no tag placeholders left + self.assertTrue('<<<tag1>>> fake title' in proper_html) + #self.assertTrue('<<<tag2>>> <<<tag3>>> cheating' in proper_html) + self.assertFalse('<<<tag1>>>' in proper_html) + self.assertFalse('<<<tag2>>>' in proper_html) + self.assertFalse('<<<tag3>>>' in proper_html) + + ### + + ss = ss.add_tag('mini-mini') + context['search_state'] = ss + test_html = thread.get_summary_html(search_state=ss) + proper_html = get_template('widgets/question_summary.html').render(context) + + self.assertEqual(test_html, proper_html) + + # Make double-check that all tags are included (along with `mini-mini` tag) + self.assertTrue(ss.add_tag('tag1').full_url() in test_html) + self.assertTrue(ss.add_tag('tag2').full_url() in test_html) + self.assertTrue(ss.add_tag('tag3').full_url() in test_html) + + def test_thread_summary_locmem_cache(self): + cache.cache = LocMemCache('', {}) # Enable local caching + + thread = self.q.thread + key = Thread.SUMMARY_CACHE_KEY_TPL % thread.id + + self.assertTrue(thread.summary_html_cached()) + self.assertIsNotNone(thread.get_cached_summary_html()) + + ### + cache.cache.delete(key) # let's start over + + self.assertFalse(thread.summary_html_cached()) + self.assertIsNone(thread.get_cached_summary_html()) + + context = { + 'thread': thread, + 'question': self.q, + 'search_state': DummySearchState(), + } + html = get_template('widgets/question_summary.html').render(context) + filled_html = html.replace('<<<tag1>>>', SearchState.get_empty().add_tag('tag1').full_url())\ + .replace('<<<tag2>>>', SearchState.get_empty().add_tag('tag2').full_url())\ + .replace('<<<tag3>>>', SearchState.get_empty().add_tag('tag3').full_url()) + + self.assertEqual(filled_html, thread.get_summary_html(search_state=SearchState.get_empty())) + self.assertTrue(thread.summary_html_cached()) + self.assertEqual(html, thread.get_cached_summary_html()) + + ### + cache.cache.set(key, 'Test <<<tag1>>>', timeout=100) + + self.assertTrue(thread.summary_html_cached()) + self.assertEqual('Test <<<tag1>>>', thread.get_cached_summary_html()) + self.assertEqual( + 'Test %s' % SearchState.get_empty().add_tag('tag1').full_url(), + thread.get_summary_html(search_state=SearchState.get_empty()) + ) + + ### + cache.cache.set(key, 'TestBBB <<<tag1>>>', timeout=100) + + self.assertTrue(thread.summary_html_cached()) + self.assertEqual('TestBBB <<<tag1>>>', thread.get_cached_summary_html()) + self.assertEqual( + 'TestBBB %s' % SearchState.get_empty().add_tag('tag1').full_url(), + thread.get_summary_html(search_state=SearchState.get_empty()) + ) + + ### + cache.cache.delete(key) + thread.update_summary_html = lambda: "Monkey-patched <<<tag2>>>" + + self.assertFalse(thread.summary_html_cached()) + self.assertIsNone(thread.get_cached_summary_html()) + self.assertEqual( + 'Monkey-patched %s' % SearchState.get_empty().add_tag('tag2').full_url(), + thread.get_summary_html(search_state=SearchState.get_empty()) + ) + + + +class ThreadRenderCacheUpdateTests(AskbotTestCase): + def setUp(self): + self.create_user() + self.user.set_password('pswd') + self.user.save() + assert self.client.login(username=self.user.username, password='pswd') + + self.create_user(username='user2') + self.user2.set_password('pswd') + self.user2.reputation = 10000 + self.user2.save() + + self.old_cache = cache.cache + cache.cache = LocMemCache('', {}) # Enable local caching + + def tearDown(self): + cache.cache = self.old_cache # Restore caching + + def _html_for_question(self, q): + context = { + 'thread': q.thread, + 'question': q, + 'search_state': DummySearchState(), + } + html = get_template('widgets/question_summary.html').render(context) + return html + + def test_post_question(self): + self.assertEqual(0, Post.objects.count()) + response = self.client.post(urlresolvers.reverse('ask'), data={ + 'title': 'test title', + 'text': 'test body text', + 'tags': 'tag1 tag2', + }) + self.assertEqual(1, Post.objects.count()) + question = Post.objects.all()[0] + self.assertRedirects(response=response, expected_url=question.get_absolute_url()) + + self.assertEqual('test title', question.thread.title) + self.assertEqual('test body text', question.text) + self.assertItemsEqual(['tag1', 'tag2'], list(question.thread.tags.values_list('name', flat=True))) + self.assertEqual(0, question.thread.answer_count) + + self.assertTrue(question.thread.summary_html_cached()) # <<< make sure that caching backend is set up properly (i.e. it's not dummy) + html = self._html_for_question(question) + self.assertEqual(html, question.thread.get_cached_summary_html()) + + def test_edit_question(self): + self.assertEqual(0, Post.objects.count()) + question = self.post_question() + + thread = Thread.objects.all()[0] + self.assertEqual(0, thread.answer_count) + self.assertEqual(thread.last_activity_at, question.added_at) + self.assertEqual(thread.last_activity_by, question.author) + + time.sleep(1.5) # compensate for 1-sec time resolution in some databases + + response = self.client.post(urlresolvers.reverse('edit_question', kwargs={'id': question.id}), data={ + 'title': 'edited title', + 'text': 'edited body text', + 'tags': 'tag1 tag2', + 'summary': 'just some edit', + }) + self.assertEqual(1, Post.objects.count()) + question = Post.objects.all()[0] + self.assertRedirects(response=response, expected_url=question.get_absolute_url()) + + thread = question.thread + self.assertEqual(0, thread.answer_count) + self.assertTrue(thread.last_activity_at > question.added_at) + self.assertEqual(thread.last_activity_at, question.last_edited_at) + self.assertEqual(thread.last_activity_by, question.author) + + self.assertTrue(question.thread.summary_html_cached()) # <<< make sure that caching backend is set up properly (i.e. it's not dummy) + html = self._html_for_question(question) + self.assertEqual(html, question.thread.get_cached_summary_html()) + + def test_retag_question(self): + self.assertEqual(0, Post.objects.count()) + question = self.post_question() + response = self.client.post(urlresolvers.reverse('retag_question', kwargs={'id': question.id}), data={ + 'tags': 'tag1 tag2', + }) + self.assertEqual(1, Post.objects.count()) + question = Post.objects.all()[0] + self.assertRedirects(response=response, expected_url=question.get_absolute_url()) + + self.assertItemsEqual(['tag1', 'tag2'], list(question.thread.tags.values_list('name', flat=True))) + + self.assertTrue(question.thread.summary_html_cached()) # <<< make sure that caching backend is set up properly (i.e. it's not dummy) + html = self._html_for_question(question) + self.assertEqual(html, question.thread.get_cached_summary_html()) + + def test_answer_question(self): + self.assertEqual(0, Post.objects.count()) + question = self.post_question() + self.assertEqual(1, Post.objects.count()) + + #thread = question.thread + # get fresh Thread instance so that on MySQL it has timestamps without microseconds + thread = Thread.objects.get(id=question.thread.id) + + self.assertEqual(0, thread.answer_count) + self.assertEqual(thread.last_activity_at, question.added_at) + self.assertEqual(thread.last_activity_by, question.author) + + self.client.logout() + self.client.login(username='user2', password='pswd') + time.sleep(1.5) # compensate for 1-sec time resolution in some databases + response = self.client.post(urlresolvers.reverse('answer', kwargs={'id': question.id}), data={ + 'text': 'answer longer than 10 chars', + }) + self.assertEqual(2, Post.objects.count()) + answer = Post.objects.get_answers()[0] + self.assertRedirects(response=response, expected_url=answer.get_absolute_url()) + + thread = answer.thread + self.assertEqual(1, thread.answer_count) + self.assertEqual(thread.last_activity_at, answer.added_at) + self.assertEqual(thread.last_activity_by, answer.author) + + self.assertTrue(question.added_at < answer.added_at) + self.assertNotEqual(question.author, answer.author) + + self.assertTrue(thread.summary_html_cached()) # <<< make sure that caching backend is set up properly (i.e. it's not dummy) + html = self._html_for_question(thread._question_post()) + self.assertEqual(html, thread.get_cached_summary_html()) + + def test_edit_answer(self): + self.assertEqual(0, Post.objects.count()) + question = self.post_question() + # get fresh question Post instance so that on MySQL it has timestamps without microseconds + question = Post.objects.get(id=question.id) + self.assertEqual(question.thread.last_activity_at, question.added_at) + self.assertEqual(question.thread.last_activity_by, question.author) + + time.sleep(1.5) # compensate for 1-sec time resolution in some databases + question_thread = copy.deepcopy(question.thread) # INFO: in the line below question.thread is touched and it reloads its `last_activity_by` field so we preserve it here + answer = self.post_answer(user=self.user2, question=question) + self.assertEqual(2, Post.objects.count()) + + time.sleep(1.5) # compensate for 1-sec time resolution in some databases + self.client.logout() + self.client.login(username='user2', password='pswd') + response = self.client.post(urlresolvers.reverse('edit_answer', kwargs={'id': answer.id}), data={ + 'text': 'edited body text', + 'summary': 'just some edit', + }) + self.assertRedirects(response=response, expected_url=answer.get_absolute_url()) + + answer = Post.objects.get(id=answer.id) + thread = answer.thread + self.assertEqual(thread.last_activity_at, answer.last_edited_at) + self.assertEqual(thread.last_activity_by, answer.last_edited_by) + self.assertTrue(thread.last_activity_at > question_thread.last_activity_at) + self.assertNotEqual(thread.last_activity_by, question_thread.last_activity_by) + + self.assertTrue(thread.summary_html_cached()) # <<< make sure that caching backend is set up properly (i.e. it's not dummy) + html = self._html_for_question(thread._question_post()) + self.assertEqual(html, thread.get_cached_summary_html()) + + def test_view_count(self): + question = self.post_question() + self.assertEqual(0, question.thread.view_count) + self.assertEqual(0, Thread.objects.all()[0].view_count) + self.client.logout() + # INFO: We need to pass some headers to make question() view believe we're not a robot + self.client.get( + urlresolvers.reverse('question', kwargs={'id': question.id}), + {}, + follow=True, # the first view redirects to the full question url (with slug in it), so we have to follow that redirect + HTTP_ACCEPT_LANGUAGE='en', + HTTP_USER_AGENT='Mozilla Gecko' + ) + thread = Thread.objects.all()[0] + self.assertEqual(1, thread.view_count) + + self.assertTrue(thread.summary_html_cached()) # <<< make sure that caching backend is set up properly (i.e. it's not dummy) + html = self._html_for_question(thread._question_post()) + self.assertEqual(html, thread.get_cached_summary_html()) + + def test_question_upvote_downvote(self): + question = self.post_question() + question.score = 5 + question.vote_up_count = 7 + question.vote_down_count = 2 + question.save() + + self.client.logout() + self.client.login(username='user2', password='pswd') + response = self.client.post(urlresolvers.reverse('vote', kwargs={'id': question.id}), data={'type': '1'}, + HTTP_X_REQUESTED_WITH='XMLHttpRequest') # use AJAX request + self.assertEqual(200, response.status_code) + data = simplejson.loads(response.content) + + self.assertEqual(1, data['success']) + self.assertEqual(6, data['count']) # 6 == question.score(5) + 1 + + thread = Thread.objects.get(id=question.thread.id) + + self.assertTrue(thread.summary_html_cached()) # <<< make sure that caching backend is set up properly (i.e. it's not dummy) + html = self._html_for_question(thread._question_post()) + self.assertEqual(html, thread.get_cached_summary_html()) + + ### + + response = self.client.post(urlresolvers.reverse('vote', kwargs={'id': question.id}), data={'type': '2'}, + HTTP_X_REQUESTED_WITH='XMLHttpRequest') # use AJAX request + self.assertEqual(200, response.status_code) + data = simplejson.loads(response.content) + + self.assertEqual(1, data['success']) + self.assertEqual(5, data['count']) # 6 == question.score(6) - 1 + + thread = Thread.objects.get(id=question.thread.id) + + self.assertTrue(thread.summary_html_cached()) # <<< make sure that caching backend is set up properly (i.e. it's not dummy) + html = self._html_for_question(thread._question_post()) + self.assertEqual(html, thread.get_cached_summary_html()) + + def test_question_accept_answer(self): + question = self.post_question(user=self.user2) + answer = self.post_answer(question=question) + + self.client.logout() + self.client.login(username='user2', password='pswd') + response = self.client.post(urlresolvers.reverse('vote', kwargs={'id': question.id}), data={'type': '0', 'postId': answer.id}, + HTTP_X_REQUESTED_WITH='XMLHttpRequest') # use AJAX request + self.assertEqual(200, response.status_code) + data = simplejson.loads(response.content) + + self.assertEqual(1, data['success']) + + thread = Thread.objects.get(id=question.thread.id) + + self.assertTrue(thread.summary_html_cached()) # <<< make sure that caching backend is set up properly (i.e. it's not dummy) + html = self._html_for_question(thread._question_post()) + self.assertEqual(html, thread.get_cached_summary_html()) + + +# TODO: (in spare time - those cases should pass without changing anything in code but we should have them eventually for completness) +# - Publishing anonymous questions / answers +# - Re-posting question as answer and vice versa +# - Management commands (like post_emailed_questions)
\ No newline at end of file diff --git a/askbot/tests/search_state_tests.py b/askbot/tests/search_state_tests.py index 791ee206..aca989fc 100644 --- a/askbot/tests/search_state_tests.py +++ b/askbot/tests/search_state_tests.py @@ -1,6 +1,7 @@ from askbot.tests.utils import AskbotTestCase from askbot.search.state_manager import SearchState import askbot.conf +from django.core import urlresolvers class SearchStateTests(AskbotTestCase): @@ -56,6 +57,10 @@ class SearchStateTests(AskbotTestCase): 'scope:unanswered/sort:age-desc/query:alfa/tags:miki,mini/author:12/page:2/', ss.query_string() ) + self.assertEqual( + 'scope:unanswered/sort:age-desc/query:alfa/tags:miki,mini/author:12/page:2/', + ss.deepcopy().query_string() + ) def test_edge_cases_1(self): ss = SearchState( @@ -72,6 +77,10 @@ class SearchStateTests(AskbotTestCase): 'scope:all/sort:age-desc/query:alfa/tags:miki,mini/author:12/page:2/', ss.query_string() ) + self.assertEqual( + 'scope:all/sort:age-desc/query:alfa/tags:miki,mini/author:12/page:2/', + ss.deepcopy().query_string() + ) ss = SearchState( scope='favorite', @@ -86,7 +95,10 @@ class SearchStateTests(AskbotTestCase): self.assertEqual( 'scope:favorite/sort:age-desc/query:alfa/tags:miki,mini/author:12/page:2/', ss.query_string() - + ) + self.assertEqual( + 'scope:favorite/sort:age-desc/query:alfa/tags:miki,mini/author:12/page:2/', + ss.deepcopy().query_string() ) def test_edge_cases_2(self): @@ -144,10 +156,10 @@ class SearchStateTests(AskbotTestCase): def test_query_escaping(self): ss = self._ss(query=' alfa miki maki +-%#?= lalala/: ') # query coming from URL is already unescaped - self.assertEqual( - 'scope:all/sort:activity-desc/query:alfa%20miki%20maki%20+-%25%23%3F%3D%20lalala%2F%3A/page:1/', - ss.query_string() - ) + + qs = 'scope:all/sort:activity-desc/query:alfa%20miki%20maki%20+-%25%23%3F%3D%20lalala%2F%3A/page:1/' + self.assertEqual(qs, ss.query_string()) + self.assertEqual(qs, ss.deepcopy().query_string()) def test_tag_escaping(self): ss = self._ss(tags=' aA09_+.-#, miki ') # tag string coming from URL is already unescaped @@ -158,11 +170,15 @@ class SearchStateTests(AskbotTestCase): def test_extract_users(self): ss = self._ss(query='"@anna haha @"maria fernanda" @\'diego maradona\' hehe [user:karl marx] hoho user:\' george bush \'') - self.assertEquals( + self.assertEqual( sorted(ss.query_users), sorted(['anna', 'maria fernanda', 'diego maradona', 'karl marx', 'george bush']) ) - self.assertEquals(ss.stripped_query, '" haha hehe hoho') + self.assertEqual(sorted(ss.query_users), sorted(ss.deepcopy().query_users)) + + self.assertEqual(ss.stripped_query, '" haha hehe hoho') + self.assertEqual(ss.stripped_query, ss.deepcopy().stripped_query) + self.assertEqual( 'scope:all/sort:activity-desc/query:%22%40anna%20haha%20%40%22maria%20fernanda%22%20%40%27diego%20maradona%27%20hehe%20%5Buser%3Akarl%20%20marx%5D%20hoho%20%20user%3A%27%20george%20bush%20%20%27/page:1/', ss.query_string() @@ -170,24 +186,92 @@ class SearchStateTests(AskbotTestCase): def test_extract_tags(self): ss = self._ss(query='#tag1 [tag: tag2] some text [tag3] query') - self.assertEquals(set(ss.query_tags), set(['tag1', 'tag2', 'tag3'])) - self.assertEquals(ss.stripped_query, 'some text query') + self.assertEqual(set(ss.query_tags), set(['tag1', 'tag2', 'tag3'])) + self.assertEqual(ss.stripped_query, 'some text query') + + self.assertFalse(ss.deepcopy().query_tags is ss.query_tags) + self.assertEqual(set(ss.deepcopy().query_tags), set(ss.query_tags)) + self.assertTrue(ss.deepcopy().stripped_query is ss.stripped_query) + self.assertEqual(ss.deepcopy().stripped_query, ss.stripped_query) def test_extract_title1(self): ss = self._ss(query='some text query [title: what is this?]') - self.assertEquals(ss.query_title, 'what is this?') - self.assertEquals(ss.stripped_query, 'some text query') + self.assertEqual(ss.query_title, 'what is this?') + self.assertEqual(ss.stripped_query, 'some text query') def test_extract_title2(self): ss = self._ss(query='some text query title:"what is this?"') - self.assertEquals(ss.query_title, 'what is this?') - self.assertEquals(ss.stripped_query, 'some text query') + self.assertEqual(ss.query_title, 'what is this?') + self.assertEqual(ss.stripped_query, 'some text query') def test_extract_title3(self): ss = self._ss(query='some text query title:\'what is this?\'') - self.assertEquals(ss.query_title, 'what is this?') - self.assertEquals(ss.stripped_query, 'some text query') + self.assertEqual(ss.query_title, 'what is this?') + self.assertEqual(ss.stripped_query, 'some text query') + + def test_deep_copy_1(self): + # deepcopy() is tested in other tests as well, but this is a dedicated test + # just to make sure in one place that everything is ok: + # 1. immutable properties (strings, ints) are just assigned to the copy + # 2. lists are cloned so that change in the copy doesn't affect the original + + ss = SearchState( + scope='unanswered', + sort='votes-desc', + query='hejho #tag1 [tag: tag2] @user @user2 title:"what is this?"', + tags='miki, mini', + author='12', + page='2', + + user_logged_in=False + ) + ss2 = ss.deepcopy() + + self.assertEqual(ss.scope, 'unanswered') + self.assertTrue(ss.scope is ss2.scope) + + self.assertEqual(ss.sort, 'votes-desc') + self.assertTrue(ss.sort is ss2.sort) + + self.assertEqual(ss.query, 'hejho #tag1 [tag: tag2] @user @user2 title:"what is this?"') + self.assertTrue(ss.query is ss2.query) + + self.assertFalse(ss.tags is ss2.tags) + self.assertItemsEqual(ss.tags, ss2.tags) + + self.assertEqual(ss.author, 12) + self.assertTrue(ss.author is ss2.author) + + self.assertEqual(ss.page, 2) + self.assertTrue(ss.page is ss2.page) + + self.assertEqual(ss.stripped_query, 'hejho') + self.assertTrue(ss.stripped_query is ss2.stripped_query) + + self.assertItemsEqual(ss.query_tags, ['tag1', 'tag2']) + self.assertFalse(ss.query_tags is ss2.query_tags) + + self.assertItemsEqual(ss.query_users, ['user', 'user2']) + self.assertFalse(ss.query_users is ss2.query_users) + + self.assertEqual(ss.query_title, 'what is this?') + self.assertTrue(ss.query_title is ss2.query_title) + + self.assertEqual(ss._questions_url, urlresolvers.reverse('questions')) + self.assertTrue(ss._questions_url is ss2._questions_url) + + def test_deep_copy_2(self): + # Regression test: a special case of deepcopy() when `tags` list is empty, + # there was a bug before where this empty list in original and copy pointed + # to the same list object + ss = SearchState.get_empty() + ss2 = ss.deepcopy() + + self.assertFalse(ss.tags is ss2.tags) + self.assertItemsEqual(ss.tags, ss2.tags) + self.assertItemsEqual([], ss2.tags) + + def test_cannot_add_already_added_tag(self): + ss = SearchState.get_empty().add_tag('double').add_tag('double') + self.assertListEqual(['double'], ss.tags) - def test_negative_match(self): - ss = self._ss(query='some query text') - self.assertEquals(ss.stripped_query, 'some query text') diff --git a/askbot/views/commands.py b/askbot/views/commands.py index f596b6f6..7db27ef2 100644 --- a/askbot/views/commands.py +++ b/askbot/views/commands.py @@ -205,6 +205,11 @@ def vote(request, id): response_data['status'] = 1 #cancelation else: request.user.accept_best_answer(answer) + + #################################################################### + answer.thread.update_summary_html() # regenerate question/thread summary html + #################################################################### + else: raise exceptions.PermissionDenied( _('Sorry, but anonymous users cannot accept answers') @@ -235,6 +240,11 @@ def vote(request, id): post = post ) + #################################################################### + if vote_type in ('1', '2'): # up/down-vote question + post.thread.update_summary_html() # regenerate question/thread summary html + #################################################################### + elif vote_type in ['7', '8']: #flag question or answer if vote_type == '7': diff --git a/askbot/views/readers.py b/askbot/views/readers.py index 1d592ab6..cccfce67 100644 --- a/askbot/views/readers.py +++ b/askbot/views/readers.py @@ -31,6 +31,7 @@ from askbot.forms import AnswerForm, ShowQuestionForm from askbot import models from askbot import schedules from askbot.models.badges import award_badges_signal +from askbot.models.tag import Tag from askbot import const from askbot.utils import functions from askbot.utils.decorators import anonymous_forbidden, ajax_only, get_only @@ -38,7 +39,7 @@ from askbot.search.state_manager import SearchState from askbot.templatetags import extra_tags import askbot.conf from askbot.conf import settings as askbot_settings -from askbot.skins.loaders import render_into_skin, get_template#jinja2 template loading enviroment +from askbot.skins.loaders import render_into_skin, get_template #jinja2 template loading enviroment # used in index page #todo: - take these out of const or settings @@ -72,24 +73,27 @@ def questions(request, **kwargs): return HttpResponseNotAllowed(['GET']) search_state = SearchState(user_logged_in=request.user.is_authenticated(), **kwargs) - - ####### - page_size = int(askbot_settings.DEFAULT_QUESTIONS_PAGE_SIZE) - qs, meta_data, related_tags = models.Thread.objects.run_advanced_search(request_user=request.user, search_state=search_state, page_size=page_size) - - tag_list_type = askbot_settings.TAG_LIST_FORMAT - if tag_list_type == 'cloud': #force cloud to sort by name - related_tags = sorted(related_tags, key = operator.attrgetter('name')) + qs, meta_data = models.Thread.objects.run_advanced_search(request_user=request.user, search_state=search_state) paginator = Paginator(qs, page_size) if paginator.num_pages < search_state.page: search_state.page = 1 page = paginator.page(search_state.page) - contributors_threads = models.Thread.objects.filter(id__in=[post.thread_id for post in page.object_list]) - contributors = models.Thread.objects.get_thread_contributors(contributors_threads) + page.object_list = list(page.object_list) # evaluate queryset + + # INFO: Because for the time being we need question posts and thread authors + # down the pipeline, we have to precache them in thread objects + models.Thread.objects.precache_view_data_hack(threads=page.object_list) + + related_tags = Tag.objects.get_related_to_search(threads=page.object_list, ignored_tag_names=meta_data.get('ignored_tag_names', [])) + tag_list_type = askbot_settings.TAG_LIST_FORMAT + if tag_list_type == 'cloud': #force cloud to sort by name + related_tags = sorted(related_tags, key = operator.attrgetter('name')) + + contributors = list(models.Thread.objects.get_thread_contributors(thread_list=page.object_list).only('id', 'username', 'gravatar')) paginator_context = { 'is_paginated' : (paginator.count > page_size), @@ -101,8 +105,8 @@ def questions(request, **kwargs): 'previous': page.previous_page_number(), 'next': page.next_page_number(), - 'base_url' : search_state.query_string(),#todo in T sort=>sort_method - 'page_size' : page_size,#todo in T pagesize -> page_size + 'base_url' : search_state.query_string(), + 'page_size' : page_size, } # We need to pass the rss feed url based @@ -145,7 +149,7 @@ def questions(request, **kwargs): questions_tpl = get_template('main_page/questions_loop.html', request) questions_html = questions_tpl.render(Context({ - 'questions': page, + 'threads': page, 'search_state': search_state, 'reset_method_count': reset_method_count, })) @@ -158,7 +162,6 @@ def questions(request, **kwargs): }, 'paginator': paginator_html, 'question_counter': question_counter, - 'questions': list(), 'faces': [extra_tags.gravatar(contributor, 48) for contributor in contributors], 'feed_url': context_feed_url, 'query_string': search_state.query_string(), @@ -187,7 +190,7 @@ def questions(request, **kwargs): 'page_class': 'main-page', 'page_size': page_size, 'query': search_state.query, - 'questions' : page, + 'threads' : page, 'questions_count' : paginator.count, 'reset_method_count': reset_method_count, 'scope': search_state.scope, @@ -207,6 +210,7 @@ def questions(request, **kwargs): return render_into_skin('main_page.html', template_data, request) + def tags(request):#view showing a listing of available tags - plain list tag_list_type = askbot_settings.TAG_LIST_FORMAT @@ -452,6 +456,7 @@ def question(request, id):#refactor - long subroutine. display question body, an page_objects = objects_list.page(show_page) #count visits + #import ipdb; ipdb.set_trace() if functions.not_a_robot_request(request): #todo: split this out into a subroutine #todo: merge view counts per user and per session |