From 50be11dfcff81e38c065a247c1a6ee786d40df2d Mon Sep 17 00:00:00 2001 From: Evgeny Fadeev Date: Thu, 8 Jul 2010 00:46:36 -0400 Subject: added some tests for email alerts --- askbot/doc/INSTALL | 248 +-------------------- askbot/management/commands/send_email_alerts.py | 109 ++++++---- askbot/management/commands/subscribe_everyone.py | 3 +- askbot/models/__init__.py | 129 +++++++++++ askbot/models/answer.py | 11 +- askbot/models/user.py | 55 ++--- askbot/tests.py | 265 ++++++++++++++++++++++- askbot/views/readers.py | 40 +--- setup.py | 2 +- 9 files changed, 496 insertions(+), 366 deletions(-) diff --git a/askbot/doc/INSTALL b/askbot/doc/INSTALL index a49dd364..a24ad236 100644 --- a/askbot/doc/INSTALL +++ b/askbot/doc/INSTALL @@ -8,250 +8,6 @@ Code of Askbot grew out of CNPROG_ project originally written by If you have any questions installing or tweaking askbot - please do not hesitate to ask at the forum_ or email to admin@askbot.org. -Prerequisites -====================== -To install and run Askbot the following are required: +Online documentation is available at: http://askbot.org/doc/index.html -* Python_ version 2.4 - 2.6 (Version 3 is not supported) -* MySQL_ version 5 - -For the production deployment you will also need a webserver capable to run -python web applications (see section Deployment_). - -Installation Instructions -=========================== - -To simplify future deployment, please make sure to use the same python -interpreter for the installation and testing as the one assigned -(or will be assigned) to the webserver. - -If you already have `easy_install`_ on your system, then type:: - easy_install askbot - -If you are using the easy\_install tool, make sure that it was also -originally installed with the python interpreter mentioned above, -otherwise use the second method: - -Download the latest version of askbot_, unzip and untar the archive and run:: - python setup.py install - -If you are planning to use askbot on Windows, please install -`mysql-python windows binary package `_ manually. - -Chances are that steps above will complete your installation. If so, then -proceed to the Configuration_ section. Below are extra installation notes -that cover some special cases. - -To install in non-standard locations add parameter --prefix=/path/to/some/dir to both commands. - -Askbot depends on about a dozen other packages. Normally those dependencies will be -automatically resolved. However, if something does not go well - e.g. -some dependency package site is not accessible, please -download and install some of those things -( -django-1.1.2_, -django-debug-toolbar_, -South_, -recaptcha-client_, -markdown2_, -html5lib_, -python-openid_, -django-keyedcache_, -django-threaded-multihost_, -mysql-python_ -) manually. - -If any of the provided links -do not work please try to look up those packages or notify askbot maintainers at admin@askbot.org. - -.. _Configuration: -Configuration -==================== - -type:: - startforum - -and answer questions. - -The startforum script will attempt to create necessary directories -and copy files. - -If you are creating a new Django project, you will need to edit file - -In the case you are adding askbot to an existing Django project, you will need to -merge askbot files settings.py_ and urls.py_ into your project files manually. - -Within settings.py, at the very minimum you will need to provide correct values to:: - DATABASE_NAME = '' - DATABASE_USER = '' - DATABASE_PASSWORD = '' - -within single quotes - login credential to your mysql database. Assuming that -the database exists, you can now install the tables by running:: - python manage.py syncdb - python manage.py migrate forum - -now run the development sever:: - python manage.py runserver `hostname -i`:8000 #or use some other port number > 1024 - -`hostname -i` is a Unix command returning the IP address of your system, you can also type -the IP manually or replace it with localhost if you are installing askbot -on a local machine. - -Your basic installation is now complete. Many settings can be -changed at runtime by following url /settings. - -If you choose to host a real website, please read -section Deployment_. For advice on hosting Askbot, please take -a look at section Hosting_. - -.. _Deployment: -Deployment -============== -Webserver process must be able to write to the following locations:: - /path/to/django-project/log/ - /path/to/django-project/askbot/upfiles - -If you know user name or the group name under which the webserver runs, -you can make those directories writable by setting the permissons -accordingly: - -For example, if you are using Linux installation of apache webserver running under -group name 'apache' you could do the following:: - - chown -R yourlogin:apache /path/to/askbot-site - chmod -R g+w /path/to/askbot-site/forum/upfiles - chmod -R g+w /path/to/askbot-site/log - -If your account somehow limits you from running such commands - please consult your -system administrator. - -Installation under Apache/mod\_wsgi -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -Apache/mod\_wsgi combination is the only type of deployment described in this -document at the moment. mod_wsgi_ is currently the most resource efficient -apache handler for the Python web applications. - -The main wsgi script is in the file django.wsgi_ -it does not need to be modified - -4.1 Configure webserver -Settings below are not perfect but may be a good starting point - ---------- -WSGISocketPrefix /path/to/socket/sock #must be readable and writable by apache -WSGIPythonHome /usr/local #must be readable by apache -WSGIPythonEggs /var/python/eggs #must be readable and writable by apache - -#NOTE: all urs below will need to be adjusted if -#settings.FORUM_SCRIPT_ALIAS !='' (e.g. = 'forum/') -#this allows "rooting" forum at http://example.com/forum, if you like - - ServerAdmin forum@example.com - DocumentRoot /path/to/askbot-site - ServerName example.com - - #run mod_wsgi process for django in daemon mode - #this allows avoiding confused timezone settings when - #another application runs in the same virtual host - WSGIDaemonProcess askbot - WSGIProcessGroup askbot - - #force all content to be served as static files - #otherwise django will be crunching images through itself wasting time - Alias /m/ /path/to/askbot-site/forum/skins/ - Alias /upfiles/ /path/to/askbot-site/forum/upfiles/ - - Order deny,allow - Allow from all - - - #this is your wsgi script described in the prev section - WSGIScriptAlias / /path/to/askbot-site/django.wsgi - - #this will force admin interface to work only - #through https (optional) - #"nimda" is the secret spelling of "admin" ;) - - RewriteEngine on - RewriteRule /nimda(.*)$ https://example.com/nimda$1 [L,R=301] - - CustomLog /var/log/httpd/askbot/access_log common - ErrorLog /var/log/httpd/askbot/error_log - -#(optional) run admin interface under https - - ServerAdmin forum@example.com - DocumentRoot /path/to/askbot-site - ServerName example.com - SSLEngine on - SSLCertificateFile /path/to/ssl-certificate/server.crt - SSLCertificateKeyFile /path/to/ssl-certificate/server.key - WSGIScriptAlias / /path/to/askbot-site/django.wsgi - CustomLog /var/log/httpd/askbot/access_log common - ErrorLog /var/log/httpd/askbot/error_log - DirectoryIndex index.html - - -Database configuration -~~~~~~~~~~~~~~~~~~~~~~~~~~~ -Database can be prepared via your hosting control panel, if available or -can be created manually (provided that you have a mysql account with -a sufficient privilege) - -The relevant MySQL the commands are:: - create database askbot DEFAULT CHARACTER SET UTF8 COLLATE utf8_general_ci; - grant all privileges on dbname.* to dbuser@localhost identified by 'dbpassword'; - -where dbname, dbuser and dbpassword should be replaced with the real values. -MySQL will create a user with those credentials if it does not yet exist. - -Automation of maintenance jobs -=============================== - -There are routine tasks that should be performed periodically -from the command line. They can be automated via cron_ jobs - -File askbot_cron_job_ has a sample script that can be run say hourly - -The script currently does two things: (1) sends delayed email alerts and -(2) awards badges. These two actions can be separated into two separate jobs, -if necessary - -Sitemap registration -======================= -Sitemap to your forum will be available at url /sitemap.xml -e.g yoursite.com/forum/sitemap.xml or yoursite.com/sitemap.xml - -Google will be pinged each time question, answer or -comment is saved or a question deleted. - -If you register you sitemap through `Google Webmasters Tools`_ Google -will have be indexing your site more efficiently. - -.. _`Google Webmasters Tools`: https://www.google.com/webmasters/tools/ -.. _Python: http://www.python.org/download/ -.. _MySQL: http://www.mysql.com/downloads/mysql/#downloads -.. _YahooAnswers: http://answers.yahoo.com/ -.. _StackOverflow: http://stackoverflow.com/ -.. _CNPROG: http://cnprog.com -.. _askbot: http://pypi.python.org/pypi/askbot -.. _django-1.1.2: http://www.djangoproject.com/download/1.1.2/tarball/ -.. _django-debug-toolbar: http://github.com/robhudson/django-debug-toolbar -.. _South: http://www.aeracode.org/releases/south/ -.. _recaptcha-client: http://code.google.com/p/django-recaptcha/ -.. _markdown2: http://code.google.com/p/python-markdown2/ -.. _html5lib: http://code.google.com/p/html5lib/ -.. _python-openid: http://github.com/openid/python-openid -.. _django-keyedcache: http://bitbucket.org/bkroeze/django-keyedcache/src -.. _django-threaded-multihost: http://bitbucket.org/bkroeze/django-threaded-multihost/src -.. _mysql-python-win: -.. _mysql-python: http://sourceforge.net/projects/mysql-python/ -.. _settings.py: http://github.com/ASKBOT/askbot-devel/blob/master/askbot/setup_templates/settings.py -.. _urls.py: http://github.com/ASKBOT/askbot-devel/blob/master/askbot/setup_templates/urls.py -.. _cron: http://www.unixgeeks.org/security/newbie/unix/cron-1.html -.. _mod_wsgi: http://code.google.com/p/modwsgi/ -.. _django.wsgi: http://github.com/ASKBOT/askbot-devel/blob/master/askbot/setup_templates/django.wsgi -.. _forum: http://askbot.org -.. _askbot_cron_job: http://github.com/ASKBOT/askbot-devel/blob/master/askbot/cron/askbot_cron_job +Also there is an - offline copy of the documentation in askbot/doc/source diff --git a/askbot/management/commands/send_email_alerts.py b/askbot/management/commands/send_email_alerts.py index 94474963..172bd0b1 100644 --- a/askbot/management/commands/send_email_alerts.py +++ b/askbot/management/commands/send_email_alerts.py @@ -24,19 +24,27 @@ def get_all_origin_posts(mentions): return list(origin_posts) #todo: refactor this as class -def extend_question_list(src, dst, limit=False, add_mention=False): +def extend_question_list( + src, dst, cutoff_time = None, + limit=False, add_mention=False, + add_comment = False + ): """src is a query set with questions - or None - dst - is an ordered dictionary - update reporting cutoff time for each question - to the latest value to be more permissive about updates + or None + dst - is an ordered dictionary + update reporting cutoff time for each question + to the latest value to be more permissive about updates """ if src is None:#is not QuerySet return #will not do anything if subscription of this type is not used if limit and len(dst.keys()) >= askbot_settings.MAX_ALERTS_PER_EMAIL: return - cutoff_time = src.cutoff_time#todo: this limits use of function to query sets - #but sometimes we have a list on the input (like in the case of comment) + if cutoff_time is None: + if hasattr(src, 'cutoff_time'): + cutoff_time = src.cutoff_time + else: + raise ValueError('cutoff_time is a mandatory parameter') + for q in src: if q in dst: meta_data = dst[q] @@ -54,6 +62,11 @@ def extend_question_list(src, dst, limit=False, add_mention=False): meta_data['mentions'] += 1 else: meta_data['mentions'] = 1 + if add_comment: + if 'comments' in meta_data: + meta_data['comments'] += 1 + else: + meta_data['comments'] = 1 def format_action_count(string, number, output): if number > 0: @@ -105,9 +118,6 @@ class Command(NoArgsCommand): q_all_A = None q_all_B = None - q_m_and_c_A = None#mentions and post comments - q_m_and_c_B = None - #base question query set for this user #basic things - not deleted, not closed, not too old #not last edited by the same user @@ -138,24 +148,30 @@ class Command(NoArgsCommand): ) ) + #shorten variables for convenience + Q_set_A = not_seen_qs + Q_set_B = seen_before_last_mod_qs + for feed in user_feeds: + if feed.feed_type == 'm_and_c': + #alerts on mentions and comments are processed separately + #because comments to questions do not trigger change of last_updated + #this may be changed in the future though, see + #http://askbot.org/en/question/96/ + continue + #each group of updates represented by the corresponding #query set has it's own cutoff time #that cutoff time is computed for each user individually #and stored as a parameter "cutoff_time" - # + #we won't send email for a given question if an email has been #sent after that cutoff_time if feed.should_send_now(): - if DEBUG_THIS_COMMAND == False: feed.mark_reported_now() cutoff_time = feed.get_previous_report_cutoff_time() - #shorten variables for convenience - Q_set_A = not_seen_qs - Q_set_B = seen_before_last_mod_qs - if feed.feed_type == 'q_sel': q_sel_A = Q_set_A.filter(followed_by=user) q_sel_A.cutoff_time = cutoff_time #store cutoff time per query set @@ -204,7 +220,7 @@ class Command(NoArgsCommand): #build ordered list questions for the email report q_list = SortedDict() - #todo: refactor q_list into a separate class + #todo: refactor q_list into a separate class? extend_question_list(q_sel_A, q_list) extend_question_list(q_sel_B, q_list) @@ -214,37 +230,34 @@ class Command(NoArgsCommand): #mention responses could be collected in the loop above, but #it is inconvenient, because feed_type m_and_c bundles the two #also we collect metadata for these here - if user_feeds.exists(feed_type='m_and_c'): + try: feed = user_feeds.get(feed_type='m_and_c') - if feed.should_report_now(): + if feed.should_send_now(): cutoff_time = feed.get_previous_report_cutoff_time() comments = Comment.objects.filter( - added_at__gt = cutoff_time, - user__ne = user, + added_at__lt = cutoff_time, + ).exclude( + user = user ) q_commented = list() + i = 0 for c in comments: post = c.content_object if post.author != user: continue + else: + i += 1 #skip is post was seen by the user after #the comment posting time + q_commented.append(post.get_origin_post()) - if isinstance(post, Question): - q_commented.append(post) - elif isinstance(post, Answer): - q_commented.append(post.question) - - for q in q_commented: - if q in q_list: - meta_data = q_list[q] - if meta_data['cutoff_time'] < cutoff_time: - meta_data['cutoff_time'] = cutoff_time - if 'comments' in meta_data: - meta_data['comments'] += 1 - else: - meta_data['comments'] = 1 + extend_question_list( + q_commented, + q_list, + cutoff_time = cutoff_time, + add_comment = True + ) mentions = Activity.objects.get_mentions( mentioned_at__gt = cutoff_time, @@ -261,6 +274,8 @@ class Command(NoArgsCommand): q_mentions_B = Q_set_B.filter(id__in = q_mentions_id) q_mentions_B.cutoff_time = cutoff_time extend_question_list(q_mentions_B, q_list, add_mention=True) + except EmailFeedSetting.DoesNotExist: + pass if user.tag_filter_setting == 'interesting': extend_question_list(q_all_A, q_list) @@ -355,8 +370,11 @@ class Command(NoArgsCommand): ans_rev = ans_rev.exclude(author=user) meta_data['ans_rev'] = len(ans_rev) + comments = meta_data.get('comments', 0) + mentions = meta_data.get('mentions', 0) + #finally skip question if there are no news indeed - if len(q_rev) + len(new_ans) + len(ans_rev) == 0: + if len(q_rev) + len(new_ans) + len(ans_rev) + comments + mentions == 0: meta_data['skip'] = True else: meta_data['skip'] = False @@ -386,7 +404,8 @@ class Command(NoArgsCommand): if num_q > 0: url_prefix = askbot_settings.APP_URL subject = _('email update message subject') - print 'have %d updated questions for %s' % (num_q, user.username) + #todo: send this to special log + #print 'have %d updated questions for %s' % (num_q, user.username) text = ungettext('%(name)s, this is an update message header for %(num)d question', '%(name)s, this is an update message header for %(num)d questions',num_q) \ % {'num':num_q, 'name':user.username} @@ -463,15 +482,11 @@ class Command(NoArgsCommand): text += _('go to %(email_settings_link)s to change frequency of email updates or %(admin_email)s administrator') \ % {'email_settings_link':link, 'admin_email':settings.ADMINS[0][1]} if DEBUG_THIS_COMMAND == False: - msg = EmailMessage(subject, text, settings.DEFAULT_FROM_EMAIL, [user.email]) + msg = EmailMessage( + subject, + text, + settings.DEFAULT_FROM_EMAIL, + [user.email] + ) msg.content_subtype = 'html' msg.send() - else: - msg2 = EmailMessage( - subject, - text, - settings.DEFAULT_FROM_EMAIL, - ['your@email.com'] - ) - msg2.content_subtype = 'html' - msg2.send() diff --git a/askbot/management/commands/subscribe_everyone.py b/askbot/management/commands/subscribe_everyone.py index d45d33ec..039fc1e3 100644 --- a/askbot/management/commands/subscribe_everyone.py +++ b/askbot/management/commands/subscribe_everyone.py @@ -15,9 +15,8 @@ class Command(NoArgsCommand): def subscribe_everyone(self): - feed_type_info = EmailFeedSetting.FEED_TYPES for user in User.objects.all(): - for feed_type in feed_type_info: + for feed_type in EmailFeedSetting.FEED_TYPES: try: feed_setting = EmailFeedSetting.objects.get( subscriber=user, diff --git a/askbot/models/__init__.py b/askbot/models/__init__.py index 7ed64cd4..b68c5da2 100644 --- a/askbot/models/__init__.py +++ b/askbot/models/__init__.py @@ -59,6 +59,131 @@ User.add_to_class('tag_filter_setting', ) User.add_to_class('response_count', models.IntegerField(default=0)) +def user_post_comment( + self, + parent_post = None, + body_text = None, + timestamp = None, + ): + """post a comment on behalf of the user + to parent_post + """ + if body_text is None: + raise ValueError('body_text is required to post comment') + if parent_post is None: + raise ValueError('parent_post is required to post question') + if timestamp is None: + timestamp = datetime.datetime.now() + comment = parent_post.add_comment( + user = self, + comment = body_text, + added_at = timestamp, + ) + #print comment + #print 'comment id is %s' % comment.id + #print len(Comment.objects.all()) + return comment + +def user_post_question( + self, + title = None, + body_text = None, + tags = None, + wiki = False, + timestamp = None + ): + if title is None: + raise ValueError('Title is required to post question') + if body_text is None: + raise ValueError('Text body is required to post question') + if tags is None: + raise ValueError('Tags are required to post question') + if timestamp is None: + timestamp = datetime.datetime.now() + + question = Question.objects.create_new( + author = self, + title = title, + text = body_text, + tagnames = tags, + added_at = timestamp, + wiki = wiki + ) + return question + +def user_post_answer( + self, + question = None, + body_text = None, + follow = False, + timestamp = None + ): + if not isinstance(question, Question): + raise TypeError('question argument must be provided') + if body_text is None: + raise ValueError('Body text is required to post answer') + if timestamp is None: + timestamp = datetime.datetime.now() + answer = Answer.objects.create_new( + question = question, + author = self, + text = body_text, + added_at = timestamp, + email_notify = follow + ) + return answer + +def user_visit_question(self, question = None, timestamp = None): + """create a QuestionView record + on behalf of the user represented by the self object + and mark it as taking place at timestamp time + + and remove pending on-screen notifications about anything in + the post - question, answer or comments + """ + if not isinstance(question, Question): + raise TypeError('question type expected') + if timestamp is None: + timestamp = datetime.datetime.now() + + ACTIVITY_TYPES = const.RESPONSE_ACTIVITY_TYPES_FOR_DISPLAY + ACTIVITY_TYPES += (const.TYPE_ACTIVITY_MENTION,) + response_activities = Activity.objects.filter( + receiving_users = self, + activity_type__in = ACTIVITY_TYPES, + ) + try: + question_view = QuestionView.objects.get( + who = self, + question = question + ) + response_activities = response_activities.filter( + active_at__gt = question_view.when + ) + except QuestionView.DoesNotExist: + question_view = QuestionView( + who = self, + question = question + ) + question_view.when = timestamp + question_view.save() + + #filter response activities (already directed to the qurrent user + #as per the query in the beginning of this if branch) + #that refer to the children of the currently + #viewed question and clear them for the current user + for activity in response_activities: + post = activity.content_object + if hasattr(post, 'get_origin_post'): + if question == post.get_origin_post(): + activity.receiving_users.remove(self) + self.decrement_response_count() + else: + logging.critical( + 'activity content object has no get_origin_post method' + ) + self.save() + def user_is_username_taken(cls,username): try: cls.objects.get(username=username) @@ -250,6 +375,10 @@ User.add_to_class( user_get_q_sel_email_feed_frequency ) User.add_to_class('get_absolute_url', user_get_absolute_url) +User.add_to_class('post_question', user_post_question) +User.add_to_class('post_answer', user_post_answer) +User.add_to_class('post_comment', user_post_comment) +User.add_to_class('visit_question', user_visit_question) User.add_to_class('upvote', upvote) User.add_to_class('downvote', downvote) User.add_to_class('accept_answer', accept_answer) diff --git a/askbot/models/answer.py b/askbot/models/answer.py index 22951417..c0107fee 100644 --- a/askbot/models/answer.py +++ b/askbot/models/answer.py @@ -12,7 +12,16 @@ from askbot import const class AnswerManager(models.Manager): - def create_new(self, question=None, author=None, added_at=None, wiki=False, text='', email_notify=False): + def create_new( + self, + question=None, + author=None, + added_at=None, + wiki=False, + text='', + email_notify=False + ): + answer = Answer( question = question, author = author, diff --git a/askbot/models/user.py b/askbot/models/user.py index 40445d70..31e3839e 100644 --- a/askbot/models/user.py +++ b/askbot/models/user.py @@ -165,13 +165,30 @@ class Activity(models.Model): return self.content_object.get_absolute_url() class EmailFeedSetting(models.Model): + #definitions of delays before notification for each type of notification frequency DELTA_TABLE = { 'i':datetime.timedelta(-1),#instant emails are processed separately 'd':datetime.timedelta(1), 'w':datetime.timedelta(7), 'n':datetime.timedelta(-1), } + #definitions of feed schedule types FEED_TYPES = ( + 'q_ask', #questions that user asks + 'q_all', #enture forum, tag filtered + 'q_ans', #questions that user answers + 'q_sel', #questions that user decides to follow + 'm_and_c' #comments and mentions of user anywhere + ) + #email delivery schedule when no email is sent at all + NO_EMAIL_SCHEDULE = { + 'q_ask': 'n', + 'q_ans': 'n', + 'q_all': 'n', + 'q_sel': 'n', + 'm_and_c': 'n' + } + FEED_TYPE_CHOICES = ( ('q_all',_('Entire askbot')), ('q_ask',_('Questions that I asked')), ('q_ans',_('Questions that I answered')), @@ -187,7 +204,7 @@ class EmailFeedSetting(models.Model): subscriber = models.ForeignKey(User, related_name='notification_subscriptions') - feed_type = models.CharField(max_length=16,choices=FEED_TYPES) + feed_type = models.CharField(max_length=16, choices=FEED_TYPE_CHOICES) frequency = models.CharField( max_length=8, choices=const.NOTIFICATION_DELIVERY_SCHEDULE_CHOICES, @@ -196,31 +213,17 @@ class EmailFeedSetting(models.Model): added_at = models.DateTimeField(auto_now_add=True) reported_at = models.DateTimeField(null=True) - #functions for rich comparison - #PRECEDENCE = ('i','d','w','n')#the greater ones are first - #def __eq__(self, other): - # return self.id == other.id - -# def __eq__(self, other): -# return self.id != other.id - -# def __gt__(self, other): -# return PRECEDENCE.index(self.frequency) < PRECEDENCE.index(other.frequency) - -# def __lt__(self, other): -# return PRECEDENCE.index(self.frequency) > PRECEDENCE.index(other.frequency) - -# def __gte__(self, other): -# if self.__eq__(other): -# return True -# else: -# return self.__gt__(other) - -# def __lte__(self, other): -# if self.__eq__(other): -# return True -# else: -# return self.__lt__(other) + def __str__(self): + if self.reported_at is None: + reported_at = "'not yet'" + else: + reported_at = '%s' % self.reported_at.strftime('%d/%m/%y %H:%M') + return 'Email feed for %s type=%s, frequency=%s, reported_at=%s' % ( + self.subscriber, + self.feed_type, + self.frequency, + reported_at + ) def save(self,*args,**kwargs): type = self.feed_type diff --git a/askbot/tests.py b/askbot/tests.py index 10584ed1..45317942 100644 --- a/askbot/tests.py +++ b/askbot/tests.py @@ -7,23 +7,38 @@ .. automodule:: tests .. moduleauthor:: Evgeny Fadeev """ +import copy import datetime import time +import django.core.mail +from django.conf import settings as django_settings from django.test import TestCase from django.template import defaultfilters +from django.core import management from django.core.urlresolvers import reverse from askbot.models import User, Question, Answer, Activity from askbot.models import EmailFeedSetting from askbot import const -def create_user(username = None, email = None): +def create_user( + username = None, + email = None, + notification_schedule = None, + date_joined = None + ): """Creates a user and sets default update subscription settings""" user = User.objects.create_user(username, email) - for feed_type in EmailFeedSetting.FEED_TYPES: + if date_joined is not None: + user.date_joined = date_joined + user.save() + if notification_schedule == None: + notification_schedule = EmailFeedSetting.NO_EMAIL_SCHEDULE + + for feed_type, frequency in notification_schedule.items(): feed = EmailFeedSetting( - feed_type = feed_type[0], - frequency = 'n', + feed_type = feed_type, + frequency = frequency, subscriber = user ) feed.save() @@ -36,7 +51,247 @@ def get_re_notif_after(timestamp): ) return notifications -class UpdateNotificationTests(TestCase): +class EmailAlertTests(TestCase): + """Base class for testing delayed Email notifications + that are triggered by the send_email_alerts + command + + this class tests cases where target user has no subscriptions + that is all subscriptions off + + subclasses should redefine initial data via the static + class member this class tests cases where target user has no subscriptions + that is all subscriptions off + + this class also defines a few utility methods that do + not run any tests themselves + + class variables: + + * notification_schedule + * setup_timestamp + * visit_timestamp + * expected_results + + should be set in subclasses to reuse testing code + """ + + def send_alerts(self): + """runs the send_email_alerts management command + and makes a shortcut access to the outbox + """ + #make sure tha we are not sending email for real + #this setting must be present in settings.py + assert( + django_settings.EMAIL_BACKEND == 'django.core.mail.backends.locmem.EmailBackend' + ) + management.call_command('send_email_alerts') + + def setUp(self): + """generic pre-test setup method: + + * creates user who is to post stuff + * creates user who is targeted for this update + * subclass must subscribe receiving user + with frequency that is to be tested + in addition making to any other specific setup + manipulations + """ + #empty email subscription schedule + #no email is sent + self.notification_schedule = copy.deepcopy(EmailFeedSetting.NO_EMAIL_SCHEDULE) + #timestamp to use for the setup + #functions + self.setup_timestamp = datetime.datetime.now() + + #must call this after setting up the notification schedule + #and only in this class, any subclasses + #must call this setUp() first thing + #before setting their own notification schedule + #and after that - setUpUsers() + self.setUpUsers() + + #timestamp to use for the question visit + #by the target user + #if this timestamp is None then there will be no visit + #otherwise question will be visited by the target user + #at that time + self.visit_timestamp = None + + #dictionary to hols expected results for each test + #actual data@is initialized in the code just before the function + #or in the body of the subclass + self.expected_results = dict() + + #fill out expected result for each test + self.expected_results['q_ask'] = { 'message_count': 0, } + self.expected_results['question_comment'] = { 'message_count': 0, } + + def setUpUsers(self): + self.other_user = create_user( + username = 'other', + email = 'other@domain.com', + date_joined = self.setup_timestamp + ) + self.target_user = create_user( + username = 'target', + email = 'target@domain.com', + notification_schedule = self.notification_schedule, + date_joined = self.setup_timestamp + ) + + def post_comment( + self, + author = None, + parent_post = None, + body_text = 'dummy test comment', + timestamp = None + ): + """posts and returns a comment to parent post, uses + now timestamp if not given, dummy body_text + author is required + """ + comment = author.post_comment( + parent_post = parent_post, + body_text = body_text, + timestamp = timestamp, + ) + return comment + + def post_question( + self, + author = None, + timestamp = None, + title = 'test question title', + body_text = 'test question body', + tags = 'test', + ): + """post a question with dummy content + and return it + """ + return author.post_question( + title = 'test question', + body_text = 'test question body', + tags = 'test', + timestamp = timestamp + ) + + def maybe_visit_question(self, user = None): + """visits question on behalf of a given user and applies + a timestamp set in the class attribute ``visit_timestamp`` + + if ``visit_timestamp`` is None, then visit is skipped + + parameter ``user`` is optional if not given, the visit will occur + on behalf of the user stored in the class attribute ``target_user`` + """ + if self.visit_timestamp: + if user is None: + user = self.target_user + + user.visit_post( + question = question, + timestamp = self.visit_timestamp + ) + + def post_answer( + self, + question = None, + author = None, + body_text = 'test answer body', + timestamp = None + ): + """post answer with dummy content and return it + """ + return author.post_answer( + question = question, + body_text = body_text, + timestamp = timestamp + ) + + def check_results(self, test_key = None): + if test_key is None: + raise ValueError('test_key parameter is required') + expected = self.expected_results[test_key] + outbox = django.core.mail.outbox + self.assertEqual(len(outbox), expected['message_count']) + if expected['message_count'] > 0: + if len(outbox) > 0: + self.assertEqual( + outbox[0].recipients()[0], + self.target_user.email + ) + + def test_question_comment(self): + """target user posts question other user posts a comment + target user does or does not receive email notification + depending on the setup parameters + + in the base class user does not receive a notification + """ + question = self.post_question( + author = self.target_user, + timestamp = self.setup_timestamp, + ) + self.post_comment( + author = self.other_user, + parent_post = question, + timestamp = self.setup_timestamp + ) + self.maybe_visit_question(question) + self.send_alerts() + self.check_results('question_comment') + + def test_q_ask(self): + """target user posts question + other user answer the question + """ + question = self.post_question( + author = self.target_user, + timestamp = self.setup_timestamp, + ) + answer = self.post_answer( + question = question, + author = self.other_user, + timestamp = self.setup_timestamp + datetime.timedelta(1) + ) + self.maybe_visit_question(question) + self.send_alerts() + self.check_results('q_ask') + +class WeeklyQAskEmailAlertTests(EmailAlertTests): + def setUp(self): + self.notification_schedule = copy.deepcopy(EmailFeedSetting.NO_EMAIL_SCHEDULE) + self.notification_schedule['q_ask'] = 'w' + self.setup_timestamp = datetime.datetime.now() - datetime.timedelta(14) + + self.setUpUsers() #must call create_users after super.setUp() and schedule + + self.visit_timestamp = None + + self.expected_results = dict() + self.expected_results['q_ask'] = {'message_count': 1} + self.expected_results['question_comment'] = {'message_count': 0} + +class WeeklyMentionsAndCommentsEmailAlertTests(EmailAlertTests): + def setUp(self): + self.notification_schedule = copy.deepcopy(EmailFeedSetting.NO_EMAIL_SCHEDULE) + self.notification_schedule['m_and_c'] = 'w' + self.setup_timestamp = datetime.datetime.now() - datetime.timedelta(14) + + self.setUpUsers() + + self.visit_timestamp = None + + self.expected_results = dict() + self.expected_results['q_ask'] = {'message_count': 0} + self.expected_results['question_comment'] = {'message_count': 1} + +class OnScreenUpdateNotificationTests(TestCase): + """Test update notifications that are displayed on + screen in the user profile responses view + and "the red envelope" + """ def reset_response_counts(self): self.reload_users() diff --git a/askbot/views/readers.py b/askbot/views/readers.py index 2177179a..706584bc 100644 --- a/askbot/views/readers.py +++ b/askbot/views/readers.py @@ -344,47 +344,11 @@ def question(request, id):#refactor - long subroutine. display question body, an question.view_count += 1 question.save() - #2) question view count per user + #2) question view count per user and clear response displays if request.user.is_authenticated(): #get response notifications - ACTIVITY_TYPES = const.RESPONSE_ACTIVITY_TYPES_FOR_DISPLAY - ACTIVITY_TYPES += (const.TYPE_ACTIVITY_MENTION,) - response_activities = Activity.objects.filter( - receiving_users = request.user, - activity_type__in = ACTIVITY_TYPES, - ) - try: - question_view = QuestionView.objects.get( - who=request.user, - question=question - ) - response_activities = response_activities.filter( - active_at__gt = question_view.when - ) - except QuestionView.DoesNotExist: - question_view = QuestionView( - who=request.user, - question=question - ) - question_view.when = datetime.datetime.now() - question_view.save() - - #filter response activities (already directed to the qurrent user - #as per the query in the beginning of this if branch) - #that refer to the children of the currently - #viewed question and clear them for the current user - for activity in response_activities: - post = activity.content_object - if hasattr(post, 'get_origin_post'): - if question == post.get_origin_post(): - activity.receiving_users.remove(request.user) - request.user.decrement_response_count() - request.user.save() - else: - logging.critical( - 'activity content object has no get_origin_post method' - ) + request.user.visit_question() return render_to_response('question.html', { 'view_name': 'question', diff --git a/setup.py b/setup.py index 7be009ac..44af2679 100644 --- a/setup.py +++ b/setup.py @@ -20,7 +20,7 @@ if sys.platform not in WIN_PLATFORMS: setup( name = "askbot", - version = "0.6.5", + version = "0.6.6", description = 'Question and Answer forum, like StackOverflow, written in python and Django', packages = find_packages(), author = 'Evgeny.Fadeev', -- cgit v1.2.3-1-g7c22