diff options
-rw-r--r-- | askbot/management/commands/merge_users.py | 8 | ||||
-rw-r--r-- | askbot/migrations/0106_update_postgres_full_text_setup.py | 4 | ||||
-rw-r--r-- | askbot/migrations/0107_added_db_indexes.py | 280 | ||||
-rw-r--r-- | askbot/models/base.py | 7 | ||||
-rw-r--r-- | askbot/models/post.py | 260 | ||||
-rw-r--r-- | askbot/models/question.py | 103 | ||||
-rw-r--r-- | askbot/models/tag.py | 37 | ||||
-rw-r--r-- | askbot/search/postgresql/thread_and_post_models_01162012.plsql | 1 | ||||
-rw-r--r-- | askbot/search/state_manager.py | 49 | ||||
-rw-r--r-- | askbot/skins/default/templates/main_page/questions_loop.html | 6 | ||||
-rw-r--r-- | askbot/skins/default/templates/widgets/question_summary.html | 6 | ||||
-rw-r--r-- | askbot/tests/page_load_tests.py | 169 | ||||
-rw-r--r-- | askbot/tests/post_model_tests.py | 166 | ||||
-rw-r--r-- | askbot/tests/search_state_tests.py | 120 | ||||
-rw-r--r-- | askbot/views/readers.py | 36 |
15 files changed, 790 insertions, 462 deletions
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/0106_update_postgres_full_text_setup.py b/askbot/migrations/0106_update_postgres_full_text_setup.py index 0f940b96..b0b0c668 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,9 @@ class Migration(DataMigration): def forwards(self, orm): "Write your forwards methods here." + if 'test' in sys.argv: + return # Somehow this migration fails when called from test runner + 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/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..6cd9f7eb 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 @@ -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..a112830d 100644 --- a/askbot/models/question.py +++ b/askbot/models/question.py @@ -1,5 +1,6 @@ import datetime import operator +from askbot.utils import mysql from django.conf import settings from django.db import models @@ -128,7 +129,7 @@ class ThreadManager(models.Manager): ) - 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,7 +138,8 @@ 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 = {} @@ -148,7 +150,7 @@ class ThreadManager(models.Manager): 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 +186,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 +193,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 +213,61 @@ 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): + 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 @@ -301,7 +314,11 @@ class Thread(models.Model): app_label = 'askbot' def _question_post(self): - return Post.objects.get(post_type='question', thread=self) + 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() 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..29e6484e 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): diff --git a/askbot/skins/default/templates/main_page/questions_loop.html b/askbot/skins/default/templates/main_page/questions_loop.html index 7e924e63..6d83032c 100644 --- a/askbot/skins/default/templates/main_page/questions_loop.html +++ b/askbot/skins/default/templates/main_page/questions_loop.html @@ -1,9 +1,9 @@ {% 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)}} {% 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..6ab59de5 100644 --- a/askbot/skins/default/templates/widgets/question_summary.html +++ b/askbot/skins/default/templates/widgets/question_summary.html @@ -46,12 +46,12 @@ {% 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..1a56f951 100644 --- a/askbot/tests/page_load_tests.py +++ b/askbot/tests/page_load_tests.py @@ -1,18 +1,19 @@ -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 + 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 +37,41 @@ 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 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: @@ -170,76 +190,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..824a4a8f 100644 --- a/askbot/tests/post_model_tests.py +++ b/askbot/tests/post_model_tests.py @@ -1,8 +1,11 @@ import datetime +from operator import attrgetter +from askbot.search.state_manager import SearchState +from django.contrib.auth.models import User 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 class PostModelTests(AskbotTestCase): @@ -111,9 +114,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 +138,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 +152,151 @@ 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) + + 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/readers.py b/askbot/views/readers.py index 1d592ab6..88d2bf7d 100644 --- a/askbot/views/readers.py +++ b/askbot/views/readers.py @@ -10,10 +10,13 @@ import datetime import logging import urllib import operator +from django.contrib.auth.models import User from django.shortcuts import get_object_or_404 from django.http import HttpResponseRedirect, HttpResponse, Http404, HttpResponseNotAllowed from django.core.paginator import Paginator, EmptyPage, InvalidPage from django.template import Context +from django.template.base import Template +from django.template.context import RequestContext from django.utils import simplejson from django.utils.translation import ugettext as _ from django.utils.translation import ungettext @@ -31,6 +34,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 @@ -72,24 +76,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 +108,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 +152,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 +165,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 +193,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, |