summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorEvgeny Fadeev <evgeny.fadeev@gmail.com>2010-07-08 00:46:36 -0400
committerEvgeny Fadeev <evgeny.fadeev@gmail.com>2010-07-08 00:46:36 -0400
commit50be11dfcff81e38c065a247c1a6ee786d40df2d (patch)
tree539337171c75d0a696c1684ccc01973bdd6a2167
parentc2ac593c9d1b9799e6f9a3a57a76941cd3d2fc6b (diff)
downloadaskbot-50be11dfcff81e38c065a247c1a6ee786d40df2d.tar.gz
askbot-50be11dfcff81e38c065a247c1a6ee786d40df2d.tar.bz2
askbot-50be11dfcff81e38c065a247c1a6ee786d40df2d.zip
added some tests for email alerts
-rw-r--r--askbot/doc/INSTALL248
-rw-r--r--askbot/management/commands/send_email_alerts.py109
-rw-r--r--askbot/management/commands/subscribe_everyone.py3
-rw-r--r--askbot/models/__init__.py129
-rw-r--r--askbot/models/answer.py11
-rw-r--r--askbot/models/user.py55
-rw-r--r--askbot/tests.py265
-rw-r--r--askbot/views/readers.py40
-rw-r--r--setup.py2
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 <http://www.codegood.com/archives/4>`_ 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
-<VirtualHost ...your ip...:80>
- 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/
- <Directory /path/to/askbot-site/forum/skins>
- Order deny,allow
- Allow from all
- </Directory>
-
- #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" ;)
- <Location "/nimda">
- RewriteEngine on
- RewriteRule /nimda(.*)$ https://example.com/nimda$1 [L,R=301]
- </Location>
- CustomLog /var/log/httpd/askbot/access_log common
- ErrorLog /var/log/httpd/askbot/error_log
-</VirtualHost>
-#(optional) run admin interface under https
-<VirtualHost ..your ip..:443>
- 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
-</VirtualHost>
-
-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 /<settings.FORUM\_SCRIPT\_ALIAS>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 <evgeny.fadeev@gmail.com>
"""
+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',