diff options
51 files changed, 1242 insertions, 277 deletions
diff --git a/askbot/deps/django_authopenid/backends.py b/askbot/deps/django_authopenid/backends.py index f41427b9..1e8626ac 100644 --- a/askbot/deps/django_authopenid/backends.py +++ b/askbot/deps/django_authopenid/backends.py @@ -119,15 +119,17 @@ class AuthBackend(object): assoc.openid_url = username + '@' + provider_name#has to be this way for external pw logins elif method == 'openid': - provider_name = util.get_provider_name(openid_url) try: - assoc = UserAssociation.objects.get( - openid_url = openid_url, - provider_name = provider_name - ) + assoc = UserAssociation.objects.get(openid_url=openid_url) user = assoc.user except UserAssociation.DoesNotExist: return None + except UserAssociation.MultipleObjectsReturned: + logging.critical( + 'duplicate openid url in the database!!! %s' % openid_url + ) + return None + elif method == 'email': #with this method we do no use user association diff --git a/askbot/deps/django_authopenid/views.py b/askbot/deps/django_authopenid/views.py index e5c7df6c..040d837c 100644 --- a/askbot/deps/django_authopenid/views.py +++ b/askbot/deps/django_authopenid/views.py @@ -810,14 +810,34 @@ def finalize_generic_signin( if request.user.is_authenticated(): #this branch is for adding a new association if user is None: - #register new association - UserAssociation( - user = request.user, - provider_name = login_provider_name, - openid_url = user_identifier, - last_used_timestamp = datetime.datetime.now() - ).save() - return HttpResponseRedirect(redirect_url) + try: + #see if currently logged in user has login with the given provider + assoc = UserAssociation.objects.get( + user=request.user, + provider_name=login_provider_name + ) + logging.critical('switching account or open id changed???') + #did openid url change? or we are dealing with a brand new open id? + message1 = _( + 'If you are trying to sign in to another account, ' + 'please sign out first.' + ) + request.user.message_set.create(message=message1) + message2 = _( + 'Otherwise, please report the incident ' + 'to the site administrator.' + ) + request.user.message_set.create(message=message2) + return HttpResponseRedirect(redirect_url) + except UserAssociation.DoesNotExist: + #register new association + UserAssociation( + user=request.user, + provider_name=login_provider_name, + openid_url=user_identifier, + last_used_timestamp=datetime.datetime.now() + ).save() + return HttpResponseRedirect(redirect_url) elif user != request.user: #prevent theft of account by another pre-existing user diff --git a/askbot/doc/source/changelog.rst b/askbot/doc/source/changelog.rst index d055bf5e..82f46e32 100644 --- a/askbot/doc/source/changelog.rst +++ b/askbot/doc/source/changelog.rst @@ -3,6 +3,9 @@ Changes in Askbot Development version ------------------- +* Added setting `NOTIFICATION_DELAY_TIME` to use with enabled celery daemon (Adolfo) +* Added setting `ASKBOT_INTERNAL_IPS` - to allow anonymous access to + closed sites from dedicated IP addresses (Evgeny) * Moved default skin from `askbot/skins/default` to simply `askbot` (Evgeny) * Repost comment as answer (Adolfo) * Question list widget (Adolfo) diff --git a/askbot/doc/source/intranet-setup.rst b/askbot/doc/source/intranet-setup.rst index 224ffb89..2711b376 100644 --- a/askbot/doc/source/intranet-setup.rst +++ b/askbot/doc/source/intranet-setup.rst @@ -12,3 +12,10 @@ Please change the following settings in your ``settings.py`` file:: In addition, in the "live settings": * disable gravatar in "settings->User settings" + +If you would like to password/protect your site +(achievable via "access control settings" -> "allow only registered users..."), +and at the same time be able to have some dedicated service +to read your site without authentication, add +IP addresses of that service to a tuple ``ASKBOT_INTERNAL_IPS`` +in your ``settings.py`` file. diff --git a/askbot/mail/__init__.py b/askbot/mail/__init__.py index 2d314dbc..74aa27e9 100644 --- a/askbot/mail/__init__.py +++ b/askbot/mail/__init__.py @@ -17,6 +17,7 @@ from askbot import const from askbot.conf import settings as askbot_settings from askbot.utils import url_utils from askbot.utils.file_utils import store_file +from askbot.utils.html import absolutize_urls from bs4 import BeautifulSoup #todo: maybe send_mail functions belong to models @@ -116,6 +117,7 @@ def send_mail( if raise_on_failure is True, exceptions.EmailNotSent is raised """ + body_text = absolutize_urls(body_text) try: assert(subject_line is not None) subject_line = prefix_the_subject_line(subject_line) @@ -143,6 +145,7 @@ def mail_moderators( ): """sends email to forum moderators and admins """ + body_text = absolutize_urls(body_text) from django.db.models import Q from askbot.models import User recipient_list = User.objects.filter( diff --git a/askbot/mail/lamson_handlers.py b/askbot/mail/lamson_handlers.py index 59d707c7..da09eec2 100644 --- a/askbot/mail/lamson_handlers.py +++ b/askbot/mail/lamson_handlers.py @@ -11,7 +11,6 @@ from askbot import mail from askbot.conf import settings as askbot_settings from askbot.skins.loaders import get_template - #we might end up needing to use something like this #to distinguish the reply text from the quoted original message """ @@ -66,7 +65,7 @@ def is_inline_attachment(part): def format_attachment(part): """takes message part and turns it into SimpleUploadedFile object""" - att_info = get_attachment_info(part) + att_info = get_attachment_info(part) name = att_info.get('filename', None) content_type = get_content_type(part) return SimpleUploadedFile(name, part.body, content_type) @@ -127,10 +126,11 @@ def process_reply(func): """processes forwarding rules, and run the handler in the case of error, send a bounce email """ + try: for rule in django_settings.LAMSON_FORWARD: if re.match(rule['pattern'], message.base['to']): - relay = Relay(host=rule['host'], + relay = Relay(host=rule['host'], port=rule['port'], debug=1) relay.deliver(message) return @@ -138,6 +138,7 @@ def process_reply(func): pass error = None + try: reply_address = ReplyAddress.objects.get( address = address, @@ -145,10 +146,18 @@ def process_reply(func): ) #here is the business part of this function + parts = get_parts(message) + for part_type, content in parts: + if part_type == 'body': + print '===============================' + print 'message :', content + break + else: + continue func( from_address = message.From, subject_line = message['Subject'], - parts = get_parts(message), + parts = parts, reply_address_object = reply_address ) @@ -169,7 +178,7 @@ def process_reply(func): subject_line = "Error posting your reply", body_text = body_text, recipient_list = [message.From], - ) + ) return wrapped @@ -265,7 +274,7 @@ def PROCESS( """handler to process the emailed message and make a post to askbot based on the contents of the email, including the text body and the file attachments""" - #1) get actual email content + #1) get actual email content # todo: factor this out into the process_reply decorator reply_code = reply_address_object.address body_text, stored_files, signature = mail.process_parts(parts, reply_code) diff --git a/askbot/management/commands/askbot_add_test_content.py b/askbot/management/commands/askbot_add_test_content.py index 888f7df0..7867fcec 100644 --- a/askbot/management/commands/askbot_add_test_content.py +++ b/askbot/management/commands/askbot_add_test_content.py @@ -72,12 +72,19 @@ class Command(NoArgsCommand): "Create the users and return an array of created users" users = [] - #add admin with the same password + #add admin with the same password - this user will be admin automatically admin = User.objects.create_user('admin', 'admin@example.com') admin.set_password('admin') + admin.save() self.print_if_verbose("Created User 'admin'") users.append(admin) + #this user will have regular privileges, because it's second + joe = User.objects.create_user('joe', 'joe@example.com') + joe.set_password('joe') + joe.save() + self.print_if_verbose("Created User 'joe'") + # Keeping the created users in array - we will iterate over them # several times, we don't want querying the model each and every time. for i in range(NUM_USERS): diff --git a/askbot/media/js/group_messaging.js b/askbot/media/js/group_messaging.js index 08dd305a..2522f72e 100644 --- a/askbot/media/js/group_messaging.js +++ b/askbot/media/js/group_messaging.js @@ -37,9 +37,6 @@ var MessageComposer = function() { }; inherits(MessageComposer, HideableWidget); -MessageComposer.prototype.send = function() { -}; - MessageComposer.prototype.onAfterCancel = function(handler) { if (handler) { this._onAfterCancel = handler; @@ -48,11 +45,27 @@ MessageComposer.prototype.onAfterCancel = function(handler) { } }; -MessageComposer.prototype.onAfterSend = function(handler) { - if (handler) { - this._onAfterSend = handler; - } else { - return this._onAfterSend(); +/** override these two + * @param {object} data - the response data + * these functions will run after .send() receives + * the response + */ +MessageComposer.prototype.onSendSuccessInternal = function(data) {}; +MessageComposer.prototype.onSendErrorInternal = function(data) {}; + +MessageComposer.prototype.onSendSuccess = function(callback) { + if (callback) { + this._onSendSuccess = callback; + } else if (this._onSendSuccess) { + this._onSendSuccess(); + } +}; + +MessageComposer.prototype.onSendError = function(callback) { + if (callback) { + this._onSendError = callback; + } else if (this._onSendError) { + this._onSendError(); } }; @@ -105,7 +118,15 @@ MessageComposer.prototype.send = function() { url: url, data: data, cache: false, - success: function() { me.onAfterSend(); } + success: function(data) { + if (data['success']) { + me.onSendSuccessInternal(data); + me.onSendSuccess(); + } else { + me.onSendErrorInternal(data); + me.onSendError(); + } + } }); }; @@ -130,7 +151,6 @@ MessageComposer.prototype.createDom = function() { var sendBtn = this.makeButton( gettext('send'), function() { - debugger; if (me.dataIsValid()){ me.send(); } @@ -149,6 +169,82 @@ MessageComposer.prototype.createDom = function() { }; +var ReplyMessageComposer = function() { + MessageComposer.call(this); +}; +inherits(ReplyMessageComposer, MessageComposer); + +ReplyMessageComposer.prototype.setParent = function(elem) { + this._parent = elem; +}; + +ReplyMessageComposer.prototype.onSendSuccessInternal = function(data) { + var message = new Message(); + message.decorate($(data['html'])); + this._parent.addMessage(message); +}; + +/** + * @constructor + * same as message composer, but initially + * hidden and presented by a "reply" link + */ +var ReplyComposer = function() { + HideableWidget.call(this); +}; +inherits(ReplyComposer, HideableWidget); + +ReplyComposer.prototype.open = function() { + this._opener.hide(); + this._editor.show(); +}; + +ReplyComposer.prototype.close = function() { + this._opener.show(); + this._editor.hide(); +} + +ReplyComposer.prototype.setSendUrl = function(url) { + this._sendUrl = url; +}; + +ReplyComposer.prototype.setPostData = function(data) { + this._editor.setPostData(data); +}; + +ReplyComposer.prototype.setThread = function(thread) { + this._thread = thread; +}; + +ReplyComposer.prototype.addMessage = function(message) { + this._thread.addMessage(message); +}; + +ReplyComposer.prototype.createDom = function() { + this._element = this.makeElement('div'); + this._element.addClass('reply-composer'); + var opener = this.makeElement('a'); + opener.html(gettext('Reply')); + this._opener = opener; + this._element.append(opener); + + var editor = new ReplyMessageComposer(); + editor.setSendUrl(this._sendUrl); + editor.setParent(this); + editor.onSendSuccess(function() { + editor.cancel(); + notify.show(gettext('message sent'), true); + }); + this._editor = editor; + this._element.append(editor.getElement()); + + var me = this; + setupButtonEventHandlers(opener, function() { me.open() }); + editor.onAfterCancel(function() { me.close() }); + this.hide(); +}; + + /** * @constructor */ @@ -167,21 +263,33 @@ NewThreadComposer.prototype.onAfterShow = function() { this._toInput.focus(); }; +NewThreadComposer.prototype.onSendErrorInternal = function(data) { + var missingUsers = data['missing_users'] + if (missingUsers) { + var errorTpl = ngettext( + 'user {{str}} does not exist', + 'users {{str}} do not exist', + missingUsers.length + ) + error = errorTpl.replace('{{str}}', joinAsPhrase(missingUsers)); + this._toInputError.html(error); + } +}; + NewThreadComposer.prototype.getInputData = function() { var data = NewThreadComposer.superClass_.getInputData.call(this); - data['to_username'] = $.trim(this._toInput.val()); + data['to_usernames'] = $.trim(this._toInput.val()); return data; }; NewThreadComposer.prototype.dataIsValid = function() { var superIsValid = NewThreadComposer.superClass_.dataIsValid.call(this); var to = $.trim(this._toInput.val()); - var meIsValid = true; if (to === '') { - meIsValid = false; this._toInputError.html(gettext('required')); + return false; } - return superIsValid && meIsValid; + return superIsValid; }; NewThreadComposer.prototype.createDom = function() { @@ -197,7 +305,7 @@ NewThreadComposer.prototype.createDom = function() { var usersAc = new AutoCompleter({ url: '/get-users-info/',//askbot['urls']['get_users_info'], - preloadData: true, + preloadData: false, minChars: 1, useCache: true, matchInside: true, @@ -218,6 +326,19 @@ NewThreadComposer.prototype.createDom = function() { label.after(error); }; +var ThreadHeading = function() { + SimpleControl.call(this); +}; +inherits(ThreadHeading, SimpleControl); + +ThreadHeading.prototype.getId = function() { + return this._id; +}; + +ThreadHeading.prototype.decorate = function(element) { + this._element = element; + this._id = element.data('threadId'); +}; /** * @constructor @@ -227,6 +348,40 @@ var ThreadsList = function() { }; inherits(ThreadsList, HideableWidget); +ThreadsList.prototype.setMessageCenter = function(ctr) { + this._messageCenter = ctr; +}; + +ThreadsList.prototype.getOpenThreadHandler = function(threadId) { + var messageCenter = this._messageCenter; + return function() { + messageCenter.openThread(threadId); + }; +}; + +ThreadsList.prototype.setHTML = function(html) { + $.each(this._threads, function(idx, thread) { + thread.dispose(); + }); + this._element.html(html); + this.decorate(this._element); +}; + +ThreadsList.prototype.decorate = function(element) { + this._element = element; + var headingElements = element.find('tr.thread-heading'); + var me = this; + var threads = []; + $.each(headingElements, function(idx, headingElement) { + var heading = new ThreadHeading(); + heading.decorate($(headingElement)); + var threadId = heading.getId(); + heading.setHandler(me.getOpenThreadHandler(threadId)); + threads.push(heading); + }); + this._threads = threads; +} + /** * @constructor @@ -236,14 +391,178 @@ var Message = function() { }; inherits(Message, Widget); +Message.prototype.getId = function() { + return this._id; +}; + +Message.prototype.decorate = function(element) { + this._element = element; + this._id = element.data('messageId'); +}; + /** * @constructor */ -var Thread = function() { +var ThreadContainer = function() { HideableWidget.call(this); }; -inherits(Thread, HideableWidget); +inherits(ThreadContainer, HideableWidget); + +ThreadContainer.prototype.show = function() { + ThreadContainer.superClass_.show.call(this); + this._editor.close(); + this._editor.show(); +}; + +ThreadContainer.prototype.hide = function() { + ThreadContainer.superClass_.hide.call(this); + this._editor.close(); + this._editor.hide(); +}; + +/** + * sets html content part of the thread + * and re-decorates it + */ +ThreadContainer.prototype.setContent = function(html) { + if (this._thread) { + this._thread.dispose(); + } + var thread = new Thread(); + thread.decorate($(html)); + this._thread = thread; + this._contentElement.empty(); + this._contentElement.append(thread.getElement()); + var postData = {parent_id: thread.getLastMessageId()}; + this._editor.setPostData(postData); + this._editor.setThread(thread); +}; + +ThreadContainer.prototype.setReplyUrl = function(url) { + this._replyUrl = url; +}; + +ThreadContainer.prototype.createDom = function() { + this._element = this.makeElement('div'); + var content = this.makeElement('div'); + this._contentElement = content; + this._element.append(content); + + var editor = new ReplyComposer(); + editor.setSendUrl(this._replyUrl); + this._element.append(editor.getElement()); + this._editor = editor; +}; + + +/** + * @constructor + */ +var Thread = function() { + WrappedElement.call(this); +}; +inherits(Thread, WrappedElement); + +Thread.prototype.getLastMessageId = function() { + return this._messages.slice(-1)[0].getId(); +}; + +Thread.prototype.dispose = function() { + $.each(this._messages, function(idx, message) { + message.dispose() + }); + Thread.superClass_.dispose.call(this); +}; + +Thread.prototype.addMessage = function(message) { + var li = this.makeElement('li'); + this._element.append(li); + li.append(message.getElement()); +}; + +Thread.prototype.decorate = function(element) { + this._element = element; + var messages = []; + $.each(element.find('.message'), function(idx, item) { + var message = new Message(); + message.decorate($(item)); + messages.push(message); + }); + this._messages = messages; +}; + + +/** + * @constructor + */ +var Sender = function() { + SimpleControl.call(this); +}; +inherits(Sender, SimpleControl); + +Sender.prototype.getId = function() { + return this._id; +}; + +Sender.prototype.select = function() { + this._element.addClass('selected'); +}; + +Sender.prototype.unselect = function() { + this._element.removeClass('selected'); +}; + +Sender.prototype.decorate = function(element) { + Sender.superClass_.decorate.call(this, element); + this._id = element.data('senderId'); +}; + + +/** + * @constructor + * list of senders in the first column of inbox + */ +var SendersList = function() { + WrappedElement.call(this); + this._messageCenter = undefined; +}; +inherits(SendersList, WrappedElement); + +SendersList.prototype.setMessageCenter = function(ctr) { + this._messageCenter = ctr; +}; + +SendersList.prototype.getSenders = function() { + return this._senders; +}; + +SendersList.prototype.getSenderSelectHandler = function(sender) { + var messageCenter = this._messageCenter; + var me = this; + return function() { + $.map(me.getSenders(), function(s){ s.unselect() }); + sender.select(); + messageCenter.loadThreadsForSender(sender.getId()); + }; +}; + +SendersList.prototype.decorate = function(element) { + this._element = element; + var senders = []; + $.each(element.find('a'), function(idx, item) { + var sender = new Sender(); + sender.decorate($(item)); + senders.push(sender); + }); + + this._senders = senders; + + var me = this; + $.each(senders, function(idx, sender) { + sender.setHandler(me.getSenderSelectHandler(sender)); + }); +}; /** @@ -257,25 +576,79 @@ inherits(MessageCenter, Widget); MessageCenter.prototype.setState = function(state) { this._editor.hide(); this._threadsList.hide(); - //this._thread.hide(); + this._threadContainer.hide(); if (state === 'compose') { this._editor.show(); } else if (state === 'show-list') { this._threadsList.show(); } else if (state === 'show-thread') { - this._thread.show(); + this._threadContainer.show(); } }; +MessageCenter.prototype.openThread = function(threadId) { + var url = this._urls['getThreads'] + threadId + '/'; + var me = this; + var threadContainer = this._threadContainer; + $.ajax({ + type: 'GET', + dataType: 'json', + url: url, + cache: false, + success: function(data) { + if (data['success']) { + threadContainer.setContent(data['html']); + me.setState('show-thread'); + } + } + }); +}; + +MessageCenter.prototype.loadThreadsForSender = function(senderId) { + var threadsList = this._threadsList; + var url = this._urls['getThreads']; + me = this; + $.ajax({ + type: 'GET', + dataType: 'json', + url: url, + cache: false, + data: {sender_id: senderId}, + success: function(data) { + if (data['success']) { + threadsList.setHTML(data['html']); + me.setState('show-list'); + } + } + }); +}; + MessageCenter.prototype.decorate = function(element) { this._element = element; this._firstCol = element.find('.first-col'); this._secondCol = element.find('.second-col'); + + this._urls = { + getThreads: element.data('getThreadsUrl'), + getThreadDetails: element.data('getThreadDetailsUrl'), + reply: element.data('replyUrl') + }; + //read sender list + var senders = new SendersList(); + senders.setMessageCenter(this); + senders.decorate($('.senders-list')); + this._sendersList = senders; //read message list var threads = new ThreadsList(); + threads.setMessageCenter(this); threads.decorate($('.threads-list')); this._threadsList = threads; + //add empty thread container + var threadContainer = new ThreadContainer(); + this._threadContainer = threadContainer; + threadContainer.setReplyUrl(this._urls['reply']); + this._secondCol.append(threadContainer.getElement()); var me = this; //create editor @@ -283,8 +656,8 @@ MessageCenter.prototype.decorate = function(element) { this._secondCol.append(editor.getElement()); editor.setSendUrl(element.data('createThreadUrl')); editor.onAfterCancel(function() { me.setState('show-list') }); - editor.onAfterSend(function() { - me.setState('show-list'); + editor.onSendSuccess(function() { + editor.cancel(); notify.show(gettext('message sent'), true); }); this._editor = editor; diff --git a/askbot/media/js/tinymce/plugins/askbot_attachment/editor_plugin.js b/askbot/media/js/tinymce/plugins/askbot_attachment/editor_plugin.js index 717a4716..d1ef13b4 100644 --- a/askbot/media/js/tinymce/plugins/askbot_attachment/editor_plugin.js +++ b/askbot/media/js/tinymce/plugins/askbot_attachment/editor_plugin.js @@ -14,7 +14,7 @@ if (description) {
content = content + '" title="' + description;
}
- content = content + '"/>';
+ content = content + '">file attached</a>';
tinyMCE.activeEditor.focus();
if (document.selection) {
diff --git a/askbot/media/js/utils.js b/askbot/media/js/utils.js index dfec46cc..1dddc07d 100644 --- a/askbot/media/js/utils.js +++ b/askbot/media/js/utils.js @@ -29,17 +29,40 @@ var animateHashes = function(){ } }; -var getUniqueWords = function(value){ - var words = $.trim(value).split(/\s+/); +var getUniqueValues = function(values) { var uniques = new Object(); var out = new Array(); - $.each(words, function(idx, item){ - if (!(item in uniques)){ - uniques[item] = 1; - out.push(item); + $.each(values, function(idx, value){ + if (!(value in uniques)){ + uniques[value] = 1; + out.push(value); }; }); return out; +} + +var getUniqueWords = function(value){ + var words = $.trim(value).split(/\s+/); + return getUniqueValues(words); +}; + +/** + * comma-joins items and uses "and' + * between the last and penultimate items + * @param {Array<string>} values + * @return {string} + */ +var joinAsPhrase = function(values) { + var count = values.length; + if (count === 0) { + return ''; + } else if (count === 1) { + return values[0]; + } else { + var last = values.pop(); + var prev = values.pop(); + return values.join(', ') + prev + gettext('and') + last; + } }; var showMessage = function(element, msg, where) { @@ -2286,14 +2309,10 @@ AutoCompleter.prototype.activateNow = function() { }; AutoCompleter.prototype.fetchData = function(value) { - if (this.options.data) { - this.filterAndShowResults(this.options.data, value); - } else { - var self = this; - this.fetchRemoteData(value, function(remoteData) { - self.filterAndShowResults(remoteData, value); - }); - } + var self = this; + this.fetchRemoteData(value, function(remoteData) { + self.filterAndShowResults(remoteData, value); + }); }; AutoCompleter.prototype.fetchRemoteData = function(filter, callback) { diff --git a/askbot/media/style/style.less b/askbot/media/style/style.less index 053d4307..607261ce 100644 --- a/askbot/media/style/style.less +++ b/askbot/media/style/style.less @@ -123,6 +123,10 @@ blockquote { background-color: #F5F5F5; } +html { + overflow-y: scroll; +} + /* http://pathfindersoftware.com/2007/09/developers-note-2/ */ * html .clearfix, * html .paginator { diff --git a/askbot/middleware/forum_mode.py b/askbot/middleware/forum_mode.py index 7f1e29b1..d593a6f2 100644 --- a/askbot/middleware/forum_mode.py +++ b/askbot/middleware/forum_mode.py @@ -45,6 +45,10 @@ class ForumModeMiddleware(object): and request.user.is_anonymous()): resolver_match = ResolverMatch(resolve(request.path)) + internal_ips = getattr(settings, 'ASKBOT_INTERNAL_IPS', None) + if internal_ips and request.META['REMOTE_ADDR'] in internal_ips: + return None + if is_view_allowed(resolver_match.func): return diff --git a/askbot/migrations/0137_create_groups_from_relevant_tags.py b/askbot/migrations/0137_create_groups_from_relevant_tags.py index 8d9a55d2..0150fcbc 100644 --- a/askbot/migrations/0137_create_groups_from_relevant_tags.py +++ b/askbot/migrations/0137_create_groups_from_relevant_tags.py @@ -54,7 +54,7 @@ class Migration(DataMigration): from django.db import connection cursor = connection.cursor() cursor.execute( - 'DROP TRIGGER group_membership_tsv_update_trigger ' + 'DROP TRIGGER IF EXISTS group_membership_tsv_update_trigger ' 'ON askbot_groupmembership' ) diff --git a/askbot/models/__init__.py b/askbot/models/__init__.py index 6b571c92..c16087f7 100644 --- a/askbot/models/__init__.py +++ b/askbot/models/__init__.py @@ -15,6 +15,8 @@ import hashlib import logging import urllib import uuid +from celery import states +from celery.task import task from django.core.urlresolvers import reverse, NoReverseMatch from django.db.models import signals as django_signals from django.template import Context @@ -1754,7 +1756,7 @@ def user_create_post_reject_reason( added_at = timestamp, text = details ) - details.parse_and_save(author = self) + details.parse_and_save(author=self) details.add_revision( author = self, revised_at = timestamp, @@ -3031,16 +3033,14 @@ def get_reply_to_addresses(user, post): return primary_addr, secondary_addr #todo: action +@task() def send_instant_notifications_about_activity_in_post( update_activity = None, post = None, recipients = None, ): - """ - function called when posts are updated - newly mentioned users are carried through to reduce - database hits - """ + #reload object from the database + post = Post.objects.get(id=post.id) if post.is_approved() is False: return @@ -3071,9 +3071,8 @@ def send_instant_notifications_about_activity_in_post( else: log_id = None - #send email for all recipients - for user in recipients: + for user in recipients: if user.is_blocked(): continue diff --git a/askbot/models/post.py b/askbot/models/post.py index 72b1b680..8b8ff76a 100644 --- a/askbot/models/post.py +++ b/askbot/models/post.py @@ -16,6 +16,7 @@ from django.utils.translation import ugettext as _ from django.utils.translation import ungettext from django.utils.http import urlquote as django_urlquote from django.core import exceptions as django_exceptions +from django.core import cache from django.core.exceptions import ValidationError from django.core.urlresolvers import reverse from django.contrib.contenttypes.models import ContentType @@ -225,7 +226,18 @@ class PostManager(BaseQuerySetManager): is_private = is_private or \ (thread and thread.requires_response_moderation(author)) - post.parse_and_save(author=author, is_private=is_private) + parse_results = post.parse_and_save(author=author, is_private=is_private) + + from askbot.models import signals + signals.post_updated.send( + post=post, + updated_by=author, + newly_mentioned_users=parse_results['newly_mentioned_users'], + timestamp=added_at, + created=True, + diff=parse_results['diff'], + sender=post.__class__ + ) post.add_revision( author = author, @@ -529,19 +541,6 @@ class Post(models.Model): timestamp = self.get_time_of_last_edit() - #todo: this is handled in signal because models for posts - #are too spread out - from askbot.models import signals - signals.post_updated.send( - post = self, - updated_by = author, - newly_mentioned_users = newly_mentioned_users, - timestamp = timestamp, - created = created, - diff = diff, - sender = self.__class__ - ) - try: from askbot.conf import settings as askbot_settings if askbot_settings.GOOGLE_SITEMAP_CODE != '': @@ -549,6 +548,8 @@ class Post(models.Model): except Exception: logging.debug('cannot ping google - did you register with them?') + return {'diff': diff, 'newly_mentioned_users': newly_mentioned_users} + def is_question(self): return self.post_type == 'question' @@ -653,11 +654,18 @@ class Post(models.Model): notify_sets['for_email'] = \ [u for u in notify_sets['for_email'] if u.is_administrator()] + if not settings.CELERY_ALWAYS_EAGER: + cache_key = 'instant-notification-%d-%d' % (self.thread.id, updated_by.id) + if cache.cache.get(cache_key): + return + cache.cache.set(cache_key, True, settings.NOTIFICATION_DELAY_TIME) + from askbot.models import send_instant_notifications_about_activity_in_post - send_instant_notifications_about_activity_in_post( - update_activity=update_activity, - post=self, - recipients=notify_sets['for_email'], + send_instant_notifications_about_activity_in_post.apply_async(( + update_activity, + self, + notify_sets['for_email']), + countdown = settings.NOTIFICATION_DELAY_TIME ) def make_private(self, user, group_id=None): @@ -834,7 +842,7 @@ class Post(models.Model): return filtered_candidates def format_for_email( - self, quote_level = 0, is_leaf_post = False, format = None + self, quote_level=0, is_leaf_post=False, format=None ): """format post for the output in email, if quote_level > 0, the post will be indented that number of times @@ -1626,7 +1634,19 @@ class Post(models.Model): by_email = by_email ) - self.parse_and_save(author=edited_by, is_private=is_private) + parse_results = self.parse_and_save(author=edited_by, is_private=is_private) + + from askbot.models import signals + signals.post_updated.send( + post=self, + updated_by=edited_by, + newly_mentioned_users=parse_results['newly_mentioned_users'], + timestamp=edited_at, + created=False, + diff=parse_results['diff'], + sender=self.__class__ + ) + def _answer__apply_edit( self, diff --git a/askbot/models/question.py b/askbot/models/question.py index a4b3233a..6b233188 100644 --- a/askbot/models/question.py +++ b/askbot/models/question.py @@ -133,7 +133,8 @@ class ThreadManager(BaseQuerySetManager): question.wikified_at = added_at #this is kind of bad, but we save assign privacy groups to posts and thread - question.parse_and_save(author = author, is_private = is_private) + #this call is rather heavy, we should split into several functions + parse_results = question.parse_and_save(author=author, is_private=is_private) revision = question.add_revision( author=author, @@ -155,7 +156,19 @@ class ThreadManager(BaseQuerySetManager): thread.make_public() # INFO: Question has to be saved before update_tags() is called - thread.update_tags(tagnames = tagnames, user = author, timestamp = added_at) + thread.update_tags(tagnames=tagnames, user=author, timestamp=added_at) + + #todo: this is handled in signal because models for posts + #are too spread out + signals.post_updated.send( + post=question, + updated_by=author, + newly_mentioned_users=parse_results['newly_mentioned_users'], + timestamp=added_at, + created=True, + diff=parse_results['diff'], + sender=question.__class__ + ) return thread @@ -246,16 +259,19 @@ class ThreadManager(BaseQuerySetManager): if askbot_settings.TAG_SEARCH_INPUT_ENABLED: #todo: this may be gone or disabled per option #"tag_search_box_enabled" - existing_tags = set( - Tag.objects.filter( - name__in = tags - ).values_list( - 'name', - flat = True - ) - ) - - non_existing_tags = set(tags) - existing_tags + existing_tags = set() + non_existing_tags = set() + #we're using a one-by-one tag retreival, b/c + #we want to take advantage of case-insensitive search indexes + #in postgresql, plus it is most likely that there will be + #only one or two search tags anyway + for tag in tags: + try: + tag_record = Tag.objects.get(name__iexact=tag) + existing_tags.add(tag_record.name) + except Tag.DoesNotExist: + non_existing_tags.add(tag) + meta_data['non_existing_tags'] = list(non_existing_tags) tags = existing_tags else: diff --git a/askbot/models/user.py b/askbot/models/user.py index bdb94100..39bb8ea9 100644 --- a/askbot/models/user.py +++ b/askbot/models/user.py @@ -271,6 +271,13 @@ class EmailFeedSetting(models.Model): 'q_sel': 'n', 'm_and_c': 'n' } + MAX_EMAIL_SCHEDULE = { + 'q_ask': 'i', + 'q_ans': 'i', + 'q_all': 'i', + 'q_sel': 'i', + 'm_and_c': 'i' + } FEED_TYPE_CHOICES = ( ('q_all',_('Entire forum')), ('q_ask',_('Questions that I asked')), diff --git a/askbot/setup_templates/settings.py b/askbot/setup_templates/settings.py index 8577281c..3d24fc5e 100644 --- a/askbot/setup_templates/settings.py +++ b/askbot/setup_templates/settings.py @@ -260,3 +260,6 @@ TINYMCE_DEFAULT_CONFIG = { 'theme_advanced_statusbar_location': 'bottom', 'height': '250' } + +#delayed notifications, time in seconds, 15 mins by default +NOTIFICATION_DELAY_TIME = 60 * 15 diff --git a/askbot/setup_templates/settings.py.mustache b/askbot/setup_templates/settings.py.mustache index 6a40969d..a800edec 100644 --- a/askbot/setup_templates/settings.py.mustache +++ b/askbot/setup_templates/settings.py.mustache @@ -262,3 +262,6 @@ TINYMCE_DEFAULT_CONFIG = { 'width': '723', 'height': '250' } + +#delayed notifications, time in seconds, 15 mins by default +NOTIFICATION_DELAY_TIME = 60 * 15 diff --git a/askbot/setup_templates/tinymce_sample_config.py b/askbot/setup_templates/tinymce_sample_config.py index ac49da68..11085212 100644 --- a/askbot/setup_templates/tinymce_sample_config.py +++ b/askbot/setup_templates/tinymce_sample_config.py @@ -3,6 +3,7 @@ TINYMCE_SPELLCHECKER = False TINYMCE_JS_ROOT = os.path.join(STATIC_ROOT, 'default/media/js/tinymce/') TINYMCE_URL = STATIC_URL + 'default/media/js/tinymce/' TINYMCE_DEFAULT_CONFIG = { + 'convert_urls': False, 'plugins': 'askbot_imageuploader,askbot_attachment', 'theme': 'advanced', 'content_css': STATIC_URL + 'default/media/style/tinymce/content.css', diff --git a/askbot/startup_procedures.py b/askbot/startup_procedures.py index a33f8047..087fc957 100644 --- a/askbot/startup_procedures.py +++ b/askbot/startup_procedures.py @@ -227,6 +227,25 @@ def test_celery(): """ broker_backend = getattr(django_settings, 'BROKER_BACKEND', None) broker_transport = getattr(django_settings, 'BROKER_TRANSPORT', None) + delay_time = getattr(django_settings, 'NOTIFICATION_DELAY_TIME', None) + delay_setting_info = 'The delay is in seconds - used to throttle ' + \ + 'instant notifications note that this delay will work only if ' + \ + 'celery daemon is running Please search about ' + \ + '"celery daemon setup" for details' + + if delay_time is None: + raise AskbotConfigError( + '\nPlease add to your settings.py\n' + \ + 'NOTIFICATION_DELAY_TIME = 60*15\n' + \ + delay_setting_info + ) + else: + if not isinstance(delay_time, int): + raise AskbotConfigError( + '\nNOTIFICATION_DELAY_TIME setting must have a numeric value\n' + \ + delay_setting_info + ) + if broker_backend is None: if broker_transport is None: @@ -579,6 +598,31 @@ def test_tinymce(): compressor_on = getattr(django_settings, 'TINYMCE_COMPRESSOR', False) if compressor_on is False: errors.append('add line: TINYMCE_COMPRESSOR = True') + #todo: add pointer to instructions on how to debug tinymce: + #1) add ('tiny_mce', os.path.join(ASKBOT_ROOT, 'media/js/tinymce')), + # to STATIFILES_DIRS + #2) add this to the main urlconf: + # ( + # r'^m/(?P<path>.*)$', + # 'django.views.static.serve', + # {'document_root': static_root} + # ), + #3) set `TINYMCE_COMPRESSOR = False` + #4) set DEBUG = False + #then - tinymce compressing will be disabled and it will + #be possible to debug custom tinymce plugins that are used with askbot + + + config = getattr(django_settings, 'TINYMCE_DEFAULT_CONFIG', None) + if config: + if 'convert_urls' in config: + if config['convert_urls'] is not False: + message = "set 'convert_urls':False in TINYMCE_DEFAULT_CONFIG" + errors.append(message) + else: + message = "add to TINYMCE_DEFAULT_CONFIG\n'convert_urls': False," + errors.append(message) + #check js root setting - before version 0.7.44 we used to have #"common" skin and after we combined it into the default @@ -642,7 +686,7 @@ def run_startup_tests(): test_middleware() test_celery() #test_csrf_cookie_domain() - test_tinymce() + #test_tinymce() test_staticfiles() test_new_skins() test_longerusername() diff --git a/askbot/tasks.py b/askbot/tasks.py index 4aa11798..650b7aeb 100644 --- a/askbot/tasks.py +++ b/askbot/tasks.py @@ -19,6 +19,8 @@ That is the reason for having two types of methods here: """ import sys import traceback +import logging +import uuid from django.contrib.contenttypes.models import ContentType from django.template import Context @@ -29,6 +31,8 @@ from askbot import const from askbot import mail from askbot.models import Post, Thread, User, ReplyAddress from askbot.models.badges import award_badges_signal +from askbot.models import get_reply_to_addresses, format_instant_notification_email +from askbot import exceptions as askbot_exceptions # TODO: Make exceptions raised inside record_post_update_celery_task() ... # ... propagate upwards to test runner, if only CELERY_ALWAYS_EAGER = True @@ -94,7 +98,7 @@ def notify_author_of_published_revision_celery_task(revision): def record_post_update_celery_task( post_id, post_content_type_id, - newly_mentioned_user_id_list = None, + newly_mentioned_user_id_list = None, updated_by_id = None, timestamp = None, created = False, @@ -138,7 +142,7 @@ def record_question_visit( update_view_count = False): """celery task which records question visit by a person updates view counter, if necessary, - and awards the badges associated with the + and awards the badges associated with the question visit """ #1) maybe update the view count diff --git a/askbot/templates/base.html b/askbot/templates/base.html index eaf2261d..63d7115f 100644 --- a/askbot/templates/base.html +++ b/askbot/templates/base.html @@ -2,7 +2,14 @@ <html xmlns="http://www.w3.org/1999/xhtml"> <head> <title>{% block title %}{% endblock %} - {{ settings.APP_TITLE|escape }}</title> - {% include "meta/html_head_meta.html" %} + <meta http-equiv="Content-Type" content="text/html; charset=UTF-8" /> + {% block meta_description %} + <meta name="description" content="{{settings.APP_DESCRIPTION|escape}}" /> + {% endblock %} + <meta name="keywords" content="{%block keywords%}{%endblock%},{{settings.APP_KEYWORDS|escape}}" /> + {% if settings.GOOGLE_SITEMAP_CODE %} + <meta name="google-site-verification" content="{{settings.GOOGLE_SITEMAP_CODE}}" /> + {% endif %} <link rel="shortcut icon" href="{{ settings.SITE_FAVICON|media }}" /> {% block before_css %}{% endblock %} {% include "meta/html_head_stylesheets.html" %} diff --git a/askbot/templates/email/macros.html b/askbot/templates/email/macros.html index f1b06fc8..125705e2 100644 --- a/askbot/templates/email/macros.html +++ b/askbot/templates/email/macros.html @@ -70,7 +70,7 @@ {% endif %} </p> {% endif %} - {{ post.html|absolutize_urls }} + {{ post.html }} {{ end_quote(quote_level) }} {% endspaceless %} {% endmacro %} diff --git a/askbot/templates/email/notify_admins_about_new_tags.html b/askbot/templates/email/notify_admins_about_new_tags.html index dd37bb34..9aaaffc2 100644 --- a/askbot/templates/email/notify_admins_about_new_tags.html +++ b/askbot/templates/email/notify_admins_about_new_tags.html @@ -1,18 +1,8 @@ -{% extends "email/base_mail.html"%} -{%block headline%} - {%trans %}Hello{%endtrans%} -{% endblock %} - -{%block content %} - <p>{% trans %}Please have a look at these new tags {{tags|join(', ')}}, - created by {{user.username}}.{% endtrans %}</p> - <p>{% trans %}If you decide not to use the tags, please reply to this email - to the user {% endtrans %}<p> - <p>{%trans%}Otherwise, please <a href="{{ thread_url }}">visit the question</a> - and apply the tags{%endtrans%}</p> - <p>{%trans%}Thank you.{%endtrans%}</p> -{% endblock %} - -{%block footer %} -{% include "email/footer.html" %} -{% endblock %} +<p>Hello,</p> +<p>Please have a look at these new tags {{tags|join(', ')}}, +created by {{user.username}}.</p> +<p>If you decide not to use the tags, please reply to this email +to the user<p> +<p>Otherwise, please <a href="{{ thread_url }}">visit the question</a> +and apply the tags</p> +<p>Thank you.</p> diff --git a/askbot/templates/email/reply_by_email_error.html b/askbot/templates/email/reply_by_email_error.html index 618e061a..53648184 100644 --- a/askbot/templates/email/reply_by_email_error.html +++ b/askbot/templates/email/reply_by_email_error.html @@ -1,11 +1,4 @@ -{% extends "email/base_mail.html"%} -{%block headline%}{% trans %}Oops, there was an error.{% endblock %} -{%block content %} {% trans %} <p>The system was unable to process your message successfully, the reason being:<p> {% endtrans %} {{error}} -{% endblock %} -{%block footer %} -{% include "email/footer.html" %} -{% endblock %} diff --git a/askbot/templates/group_messaging/home.html b/askbot/templates/group_messaging/home.html index b6733624..d5d34a37 100644 --- a/askbot/templates/group_messaging/home.html +++ b/askbot/templates/group_messaging/home.html @@ -1,5 +1,7 @@ <div class="group-messaging" data-create-thread-url="{% url create_thread %}" + data-get-threads-url="{% url get_threads %}" + data-reply-url="{% url post_reply %}" > <div class="first-col"> <button class="submit compose">{% trans %}compose{% endtrans %}</button> diff --git a/askbot/templates/group_messaging/macros.html b/askbot/templates/group_messaging/macros.html new file mode 100644 index 00000000..7fbe4434 --- /dev/null +++ b/askbot/templates/group_messaging/macros.html @@ -0,0 +1,16 @@ +{%- macro message(post, visitor) -%} +<div class="message" data-message-id="{{ post.id }}"> + <p class="header"> + {% if post.sender == visitor %} + {% trans date=post.sent_at %}You wrote on {{ date }}:{% endtrans %} + {% else %} + {% trans user=post.sender.username, + date=post.sent_at + %}{{ user }} wrote on {{ date }}:{% endtrans %} + {% endif %} + </p> + <div class="content"> + {{ post.html|safe }} + </div> +</div> +{%- endmacro -%} diff --git a/askbot/templates/group_messaging/senders_list.html b/askbot/templates/group_messaging/senders_list.html index 43f8ea28..687cacd6 100644 --- a/askbot/templates/group_messaging/senders_list.html +++ b/askbot/templates/group_messaging/senders_list.html @@ -1,9 +1,14 @@ +{#<ul class="mailboxes"> + <li><a class="inbox selected">{% trans %}Inbox{% endtrans %}</a></li> + <li><a class="sent">{% trans %}Sent{% endtrans %}</a></li> + <li><a class="trash">{% trans %}Trash{% endtrans %}</a></li> +</ul>#} {% if senders %} <ul class="senders-list"> -{% for sender in senders %} - <li>{% trans %}Senders:{% endtrans %}</li> - <li><a data-sender-id="-1">{% trans %}all{% endtrans %}</a></li> - <li><a data-sender-id="{{ sender.id }}">{{ sender.username|escape }}</a></li> -{% endfor %} + <li>{% trans %}Messages by sender:{% endtrans %}</li> + <li><a class="selected" data-sender-id="-1">{% trans %}all users{% endtrans %}</a></li> + {% for sender in senders %} + <li><a data-sender-id="{{ sender.id }}">{{ sender.username|escape }}</a></li> + {% endfor %} </ul> {% endif %} diff --git a/askbot/templates/group_messaging/stored_message.html b/askbot/templates/group_messaging/stored_message.html new file mode 100644 index 00000000..1bf31368 --- /dev/null +++ b/askbot/templates/group_messaging/stored_message.html @@ -0,0 +1,2 @@ +{% from "group_messaging/macros.html" import message %} +{{ message(post, user) }} diff --git a/askbot/templates/group_messaging/thread_details.html b/askbot/templates/group_messaging/thread_details.html new file mode 100644 index 00000000..969479d8 --- /dev/null +++ b/askbot/templates/group_messaging/thread_details.html @@ -0,0 +1,7 @@ +{% from "group_messaging/macros.html" import message %} +<ul class="thread" data-thread-id="{{ root_message.id }}"> + <li>{{ message(root_message, request.user) }}</li> + {% for response in responses %} + <li>{{ message(response, request.user) }}</li> + {% endfor %} +</ul> diff --git a/askbot/templates/group_messaging/threads_list.html b/askbot/templates/group_messaging/threads_list.html index 164867a1..8469198c 100644 --- a/askbot/templates/group_messaging/threads_list.html +++ b/askbot/templates/group_messaging/threads_list.html @@ -1,13 +1,18 @@ -<ul class="threads-list"> +<table class="threads-list"> {% if threads %} {% for thread in threads %} - <li> - <a data-thread-id="{{ thread.id }}"> - {{ thread.headline|escape }} - </a> - </li> + {% set thread_data = threads_data[thread.id] %} + <tr class="thread-heading {{ thread_data['status'] }}" + data-thread-id="{{ thread.id }}" + > + <td class="senders">{{ thread_data['senders_info']|escape }}</td> + <td class="subject">{{ thread.headline|escape }}</td> + <td class="timestamp">{{ thread.last_active_at|timesince }}</td> + </tr> {% endfor %} {% else %} - <li class="empty">{% trans %}there are no messages yet...{% endtrans %}</li> + <tr> + <td class="empty" colspan="3">{% trans %}there are no messages yet...{% endtrans %}<td> + </tr> {% endif %} -</ul> +</table> diff --git a/askbot/templates/main_page/tag_search.html b/askbot/templates/main_page/tag_search.html index 0d81bf4e..45f12b2f 100644 --- a/askbot/templates/main_page/tag_search.html +++ b/askbot/templates/main_page/tag_search.html @@ -1,6 +1,5 @@ <div id="tagSearch" class="box"> <h2>{% trans %}Tag search{% endtrans %}</h2> - <label for="ab-tag-search">{% trans %}Please note that tag search is case sensitive!{% endtrans %}</label> <div class="inputs"> <input id="ab-tag-search" autocomplete="off" type="text"/> <input id="ab-tag-search-add" type="submit" value="{% trans %}search{% endtrans %}"/> diff --git a/askbot/templates/meta/html_head_meta.html b/askbot/templates/meta/html_head_meta.html deleted file mode 100644 index 352ffb53..00000000 --- a/askbot/templates/meta/html_head_meta.html +++ /dev/null @@ -1,8 +0,0 @@ -<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" /> -{% block meta_description %} -<meta name="description" content="{{settings.APP_DESCRIPTION|escape}}" /> -{% endblock %} -<meta name="keywords" content="{%block keywords%}{%endblock%},{{settings.APP_KEYWORDS|escape}}" /> -{% if settings.GOOGLE_SITEMAP_CODE %} -<meta name="google-site-verification" content="{{settings.GOOGLE_SITEMAP_CODE}}" /> -{% endif %} diff --git a/askbot/templates/user_inbox/base.html b/askbot/templates/user_inbox/base.html index 890cb0f7..8beababc 100644 --- a/askbot/templates/user_inbox/base.html +++ b/askbot/templates/user_inbox/base.html @@ -13,10 +13,10 @@ <div id="re_sections"> {% trans %}Sections:{% endtrans %} {% set sep = joiner('|') %} - {{ sep() }} + {#{ sep() }} <a href="{{request.user.get_absolute_url()}}?sort=inbox§ion=messages" {% if inbox_section == 'messages' %}class="on"{% endif %} - >{% trans %}messages{% endtrans %}</a> + >{% trans %}messages{% endtrans %}</a>#} {% if re_count > 0 %}{{ sep() }} <a href="{{request.user.get_absolute_url()}}?sort=inbox§ion=forum" {% if inbox_section == 'forum' %}class="on"{% endif %} diff --git a/askbot/templates/user_inbox/messages.html b/askbot/templates/user_inbox/messages.html index 8bb64c4a..5108d15e 100644 --- a/askbot/templates/user_inbox/messages.html +++ b/askbot/templates/user_inbox/messages.html @@ -6,20 +6,49 @@ .group-messaging { padding-top: 25px; } - .group-messaging ul { + ul.senders-list { padding: 0px; + margin-left: 1em; + margin-top: 0.5em; } - .group-messaging li { + .senders-list li { + height: 1.5em; + vertical-align: center; list-style-type: none; list-style-position: outside; } - li.empty { + .senders-list .selected { + font-weight: bold; + } + table.threads-list { + width: 100%; + } + .threads-list tr { + height: 2em; + } + .threads-list td { + vertical-align: center; + } + .threads-list tr.new { + font-weight: bold; + color: #777; + } + .threads-list tr:hover { + background-color: #eff5f6; + } + td.empty { line-height: 30px; vertical-align: middle; background: #eee; padding-left: 320px; margin: 0px; } + td.senders { + padding-left: 10px; + } + td.timestamp { + width: 180px; + } button.compose { width: 150px; } @@ -31,12 +60,16 @@ width: 150px; } .second-col { - width: 810px; + width: 790px; + margin-left: 20px; } .message-composer { - padding: 0 0 10px 25px; + padding-bottom: 10px; margin-top: -25px; } + .reply-composer .message-composer { + margin-top: 0; + } .message-composer input.recipients, .message-composer textarea { width: 400px; diff --git a/askbot/templatetags/extra_filters_jinja.py b/askbot/templatetags/extra_filters_jinja.py index 62a41895..146de6d1 100644 --- a/askbot/templatetags/extra_filters_jinja.py +++ b/askbot/templatetags/extra_filters_jinja.py @@ -14,6 +14,7 @@ from askbot import exceptions as askbot_exceptions from askbot.conf import settings as askbot_settings from django.conf import settings as django_settings from askbot.skins import utils as skin_utils +from askbot.utils.html import absolutize_urls from askbot.utils import functions from askbot.utils import url_utils from askbot.utils.slug import slugify @@ -24,19 +25,7 @@ from django_countries import settings as countries_settings register = coffin_template.Library() -@register.filter -def absolutize_urls(text): - url_re1 = re.compile(r'(?P<prefix><img[^<]+src=)"(?P<url>/[^"]+)"', re.I) - url_re2 = re.compile(r"(?P<prefix><img[^<]+src=)'(?P<url>/[^']+)'", re.I) - url_re3 = re.compile(r'(?P<prefix><a[^<]+href=)"(?P<url>/[^"]+)"', re.I) - url_re4 = re.compile(r"(?P<prefix><a[^<]+href=)'(?P<url>/[^']+)'", re.I) - img_replacement = '\g<prefix>"%s\g<url>" style="max-width:500px;"' % askbot_settings.APP_URL - replacement = '\g<prefix>"%s\g<url>"' % askbot_settings.APP_URL - text = url_re1.sub(img_replacement, text) - text = url_re2.sub(img_replacement, text) - text = url_re3.sub(replacement, text) - return url_re4.sub(replacement, text) - +absolutize_urls = register.filter(absolutize_urls) TIMEZONE_STR = pytz.timezone( django_settings.TIME_ZONE diff --git a/askbot/tests/__init__.py b/askbot/tests/__init__.py index 8a018ea1..fcef288b 100644 --- a/askbot/tests/__init__.py +++ b/askbot/tests/__init__.py @@ -10,7 +10,6 @@ from askbot.tests.management_command_tests import * from askbot.tests.search_state_tests import * from askbot.tests.form_tests import * from askbot.tests.follow_tests import * -from askbot.tests.templatefilter_tests import * from askbot.tests.markup_test import * from askbot.tests.post_model_tests import * from askbot.tests.thread_model_tests import * diff --git a/askbot/tests/db_api_tests.py b/askbot/tests/db_api_tests.py index ec4210e8..65b0a950 100644 --- a/askbot/tests/db_api_tests.py +++ b/askbot/tests/db_api_tests.py @@ -166,10 +166,7 @@ class DBApiTests(AskbotTestCase): count = models.Tag.objects.filter(name='one-tag').count() self.assertEquals(count, 0) - @with_settings({ - 'MAX_TAG_LENGTH': 200, - 'MAX_TAGS_PER_POST': 50 - }) + @with_settings(MAX_TAG_LENGTH=200, MAX_TAGS_PER_POST=50) def test_retag_tags_too_long_raises(self): tags = "aoaoesuouooeueooeuoaeuoeou aostoeuoaethoeastn oasoeoa nuhoasut oaeeots aoshootuheotuoehao asaoetoeatuoasu o aoeuethut aoaoe uou uoetu uouuou ao aouosutoeh" question = self.post_question(user=self.user) diff --git a/askbot/tests/email_alert_tests.py b/askbot/tests/email_alert_tests.py index 1937da6f..f4ae052b 100644 --- a/askbot/tests/email_alert_tests.py +++ b/askbot/tests/email_alert_tests.py @@ -1,6 +1,7 @@ +import bs4 +import copy import datetime import functools -import copy import time from django.conf import settings as django_settings from django.core import management @@ -10,6 +11,7 @@ from django.core.urlresolvers import reverse from django.test import TestCase from django.test.client import Client from askbot.tests import utils +from askbot.tests.utils import with_settings from askbot import models from askbot import mail from askbot.conf import settings as askbot_settings @@ -954,6 +956,29 @@ class EmailFeedSettingTests(utils.AskbotTestCase): data_after = TO_JSON(self.get_user_feeds()) self.assertEquals(data_before, data_after) + +class EmailAlertTestsWithGroupsEnabled(utils.AskbotTestCase): + + def setUp(self): + self.backup = askbot_settings.GROUPS_ENABLED + askbot_settings.update('GROUPS_ENABLED', True) + + def tearDown(self): + askbot_settings.update('GROUPS_ENABLED', self.backup) + + @with_settings(MIN_REP_TO_TRIGGER_EMAIL=1) + def test_notification_for_global_group_works(self): + sender = self.create_user('sender') + recipient = self.create_user( + 'recipient', + notification_schedule=models.EmailFeedSetting.MAX_EMAIL_SCHEDULE + ) + self.post_question(user=sender) + outbox = django.core.mail.outbox + self.assertEqual(len(outbox), 1) + self.assertEqual(outbox[0].recipients(), [recipient.email]) + + class PostApprovalTests(utils.AskbotTestCase): """test notifications sent to authors when their posts are approved or published""" @@ -1011,6 +1036,38 @@ class PostApprovalTests(utils.AskbotTestCase): #self.assertEquals(outbox[1].recipients(), [u1.email,])#approval +class AbsolutizeUrlsInEmailsTests(utils.AskbotTestCase): + @with_settings(MIN_REP_TO_TRIGGER_EMAIL=1, APP_URL='http://example.com/') + def test_urls_are_absolute(self): + u1 = self.create_user('u1') + max_email = models.EmailFeedSetting.MAX_EMAIL_SCHEDULE + u2 = self.create_user('u2', notification_schedule=max_email) + text = '<a href="/index.html">home</a>' + \ + '<img alt="an image" src=\'/img.png\'><a href="https://example.com"><img src="/img.png"/></a>' + question = self.post_question(user=u1, body_text=text) + outbox = django.core.mail.outbox + html_message = outbox[0].alternatives[0][0] + content_type = outbox[0].alternatives[0][1] + self.assertEqual(content_type, 'text/html') + + soup = bs4.BeautifulSoup(html_message) + links = soup.find_all('a') + url_bits = {} + for link in links: + url_bits[link.attrs['href'][:4]] = 1 + + self.assertEqual(len(url_bits.keys()), 1) + self.assertEqual(url_bits.keys()[0], 'http') + + images = soup.find_all('img') + url_bits = {} + for img in images: + url_bits[img.attrs['src'][:4]] = 1 + + self.assertEqual(len(url_bits.keys()), 1) + self.assertEqual(url_bits.keys()[0], 'http') + + class MailMessagesTests(utils.AskbotTestCase): def test_ask_for_signature(self): from askbot.mail import messages diff --git a/askbot/tests/page_load_tests.py b/askbot/tests/page_load_tests.py index 0f102975..3805d012 100644 --- a/askbot/tests/page_load_tests.py +++ b/askbot/tests/page_load_tests.py @@ -9,6 +9,7 @@ from django.utils import simplejson import coffin import coffin.template +from bs4 import BeautifulSoup from askbot import models from askbot.utils.slug import slugify @@ -151,14 +152,14 @@ class PageLoadTestCase(AskbotTestCase): def test_ask_page_allowed_anonymous(self): self.proto_test_ask_page(True, 200) - @with_settings({'GROUPS_ENABLED': False}) + @with_settings(GROUPS_ENABLED=False) def test_api_get_questions_groups_disabled(self): data = {'query': 'Question'} response = self.client.get(reverse('api_get_questions'), data) data = simplejson.loads(response.content) self.assertTrue(len(data) > 1) - @with_settings({'GROUPS_ENABLED': True}) + @with_settings(GROUPS_ENABLED=True) def test_api_get_questions_groups_enabled(self): group = models.Group(name='secret group', openness=models.Group.OPEN) @@ -442,7 +443,7 @@ class PageLoadTestCase(AskbotTestCase): @skipIf('askbot.middleware.forum_mode.ForumModeMiddleware' \ not in settings.MIDDLEWARE_CLASSES, 'no ForumModeMiddleware set') - @with_settings({'ASKBOT_CLOSED_FORUM_MODE': True}) + @with_settings(ASKBOT_CLOSED_FORUM_MODE=True) def test_non_user_urls_in_closed_forum_mode(self): self.proto_test_non_user_urls(status_code=302) @@ -513,7 +514,7 @@ class PageLoadTestCase(AskbotTestCase): @skipIf('askbot.middleware.forum_mode.ForumModeMiddleware' \ not in settings.MIDDLEWARE_CLASSES, 'no ForumModeMiddleware set') - @with_settings({'ASKBOT_CLOSED_FORUM_MODE': True}) + @with_settings(ASKBOT_CLOSED_FORUM_MODE=True) def test_user_urls_in_closed_forum_mode(self): self.proto_test_user_urls(status_code=302) @@ -549,11 +550,11 @@ class PageLoadTestCase(AskbotTestCase): template='user_inbox/responses_and_flags.html', ) - @with_settings({'GROUPS_ENABLED': True}) + @with_settings(GROUPS_ENABLED=True) def test_user_page_with_groups_enabled(self): self.try_url('users', status_code=302) - @with_settings({'GROUPS_ENABLED': False}) + @with_settings(GROUPS_ENABLED=False) def test_user_page_with_groups_disabled(self): self.try_url('users', status_code=200) @@ -568,6 +569,17 @@ class AvatarTests(AskbotTestCase): ) +class QuestionViewTests(AskbotTestCase): + def test_meta_description_has_question_summary(self): + user = self.create_user('user') + text = 'this is a question' + question = self.post_question(user=user, body_text=text) + response = self.client.get(question.get_absolute_url()) + soup = BeautifulSoup(response.content) + meta_descr = soup.find_all('meta', attrs={'name': 'description'})[0] + self.assertTrue(text in meta_descr.attrs['content']) + + class QuestionPageRedirectTests(AskbotTestCase): def setUp(self): diff --git a/askbot/tests/templatefilter_tests.py b/askbot/tests/templatefilter_tests.py deleted file mode 100644 index 090be956..00000000 --- a/askbot/tests/templatefilter_tests.py +++ /dev/null @@ -1,23 +0,0 @@ -from unittest import TestCase -from askbot.templatetags import extra_filters_jinja as filters -from askbot.conf import settings as askbot_settings - -class AbsolutizeUrlsTests(TestCase): - def setUp(self): - askbot_settings.update('APP_URL', 'http://example.com') - def test_absolutize_image_urls(self): - text = """<img class="junk" src="/some.gif"> <IMG SRC='/some.png'>""" - #jinja register.filter decorator works in a weird way - output = filters.absolutize_urls[0](text) - self.assertEqual( - output, - '<img class="junk" src="http://example.com/some.gif" style="max-width:500px;"> <IMG SRC="http://example.com/some.png" style="max-width:500px;">' - ) - def test_absolutize_anchor_urls(self): - text = """<a class="junk" href="/something">link</a> <A HREF='/something'>link</A>""" - #jinja register.filter decorator works in a weird way - output = filters.absolutize_urls[0](text) - self.assertEqual( - output, - '<a class="junk" href="http://example.com/something">link</a> <A HREF="http://example.com/something">link</A>' - ) diff --git a/askbot/tests/utils.py b/askbot/tests/utils.py index 1cd174c1..ee3cd37e 100644 --- a/askbot/tests/utils.py +++ b/askbot/tests/utils.py @@ -4,7 +4,7 @@ from django.test import TestCase from functools import wraps from askbot import models -def with_settings(settings_dict): +def with_settings(**settings_dict): """a decorator that will run function with settings then apply previous settings and return the result of the function. diff --git a/askbot/tests/utils_tests.py b/askbot/tests/utils_tests.py index 7f252b69..c8526ad1 100644 --- a/askbot/tests/utils_tests.py +++ b/askbot/tests/utils_tests.py @@ -1,5 +1,8 @@ from django.test import TestCase +from askbot.tests.utils import with_settings from askbot.utils.url_utils import urls_equal +from askbot.utils.html import absolutize_urls +from askbot.conf import settings as askbot_settings class UrlUtilsTests(TestCase): @@ -15,3 +18,40 @@ class UrlUtilsTests(TestCase): self.assertTrue(e('http://cnn.com/path', 'http://cnn.com/path/', True)) self.assertFalse(e('http://cnn.com/path', 'http://cnn.com/path/')) + +class HTMLUtilsTests(TestCase): + """tests for :mod:`askbot.utils.html` module""" + + @with_settings(APP_URL='http://example.com') + def test_absolutize_urls(self): + text = """<img class="junk" src="/some.gif"> <img class="junk" src="/cat.gif"> <IMG SRC='/some.png'>""" + #jinja register.filter decorator works in a weird way + self.assertEqual( + absolutize_urls(text), + '<img class="junk" src="http://example.com/some.gif" style="max-width:500px;"> <img class="junk" src="http://example.com/cat.gif" style="max-width:500px;"> <IMG SRC="http://example.com/some.png" style="max-width:500px;">' + ) + + text = """<a class="junk" href="/something">link</a> <A HREF='/something'>link</A>""" + #jinja register.filter decorator works in a weird way + self.assertEqual( + absolutize_urls(text), + '<a class="junk" href="http://example.com/something">link</a> <A HREF="http://example.com/something">link</A>' + ) + + text = '<img src="/upfiles/13487900323638005.png" alt="" />' + self.assertEqual( + absolutize_urls(text), + '<img src="http://example.com/upfiles/13487900323638005.png" style="max-width:500px;" alt="" />' + ) + + text = 'ohaouhaosthoanstoahuaou<br /><img src="/upfiles/13487906221942257.png" alt="" /><img class="gravatar" title="Evgeny4" src="http://kp-dev.askbot.com/avatar/render_primary/5/32/" alt="Evgeny4 gravatar image" width="32" height="32" />' + self.assertEqual( + absolutize_urls(text), + 'ohaouhaosthoanstoahuaou<br /><img src="http://example.com/upfiles/13487906221942257.png" style="max-width:500px;" alt="" /><img class="gravatar" title="Evgeny4" src="http://kp-dev.askbot.com/avatar/render_primary/5/32/" alt="Evgeny4 gravatar image" width="32" height="32" />' + ) + + text = '<a href="/upfiles/13487909784287052.png"><img src="/upfiles/13487909942351405.png" alt="" /></a><img src="http://i2.cdn.turner.com/cnn/dam/assets/120927033530-ryder-cup-captains-wall-4-tease.jpg" alt="" width="160" height="90" border="0" />and some text<br />aouaosutoaehut' + self.assertEqual( + absolutize_urls(text), + '<a href="http://example.com/upfiles/13487909784287052.png"><img src="http://example.com/upfiles/13487909942351405.png" style="max-width:500px;" alt="" /></a><img src="http://i2.cdn.turner.com/cnn/dam/assets/120927033530-ryder-cup-captains-wall-4-tease.jpg" alt="" width="160" height="90" border="0" />and some text<br />aouaosutoaehut' + ) diff --git a/askbot/utils/html.py b/askbot/utils/html.py index 44e3f1df..49eddee2 100644 --- a/askbot/utils/html.py +++ b/askbot/utils/html.py @@ -6,6 +6,7 @@ import htmlentitydefs from urlparse import urlparse from django.core.urlresolvers import reverse from django.utils.html import escape +from askbot.conf import settings as askbot_settings class HTMLSanitizerMixin(sanitizer.HTMLSanitizerMixin): acceptable_elements = ('a', 'abbr', 'acronym', 'address', 'b', 'big', @@ -43,6 +44,23 @@ class HTMLSanitizer(tokenizer.HTMLTokenizer, HTMLSanitizerMixin): if token: yield token +def absolutize_urls(html): + """turns relative urls in <img> and <a> tags to absolute, + starting with the ``askbot_settings.APP_URL``""" + #temporal fix for bad regex with wysiwyg editor + url_re1 = re.compile(r'(?P<prefix><img[^<]+src=)"(?P<url>/[^"]+)"', re.I) + url_re2 = re.compile(r"(?P<prefix><img[^<]+src=)'(?P<url>/[^']+)'", re.I) + url_re3 = re.compile(r'(?P<prefix><a[^<]+href=)"(?P<url>/[^"]+)"', re.I) + url_re4 = re.compile(r"(?P<prefix><a[^<]+href=)'(?P<url>/[^']+)'", re.I) + img_replacement = '\g<prefix>"%s/\g<url>" style="max-width:500px;"' % askbot_settings.APP_URL + replacement = '\g<prefix>"%s\g<url>"' % askbot_settings.APP_URL + html = url_re1.sub(img_replacement, html) + html= url_re2.sub(img_replacement, html) + html = url_re3.sub(replacement, html) + #temporal fix for bad regex with wysiwyg editor + return url_re4.sub(replacement, html).replace('%s//' % askbot_settings.APP_URL, + '%s/' % askbot_settings.APP_URL) + def sanitize_html(html): """Sanitizes an HTML fragment.""" p = html5lib.HTMLParser(tokenizer=HTMLSanitizer, diff --git a/askbot/views/commands.py b/askbot/views/commands.py index 2ab15c35..f05cc9e2 100644 --- a/askbot/views/commands.py +++ b/askbot/views/commands.py @@ -13,7 +13,11 @@ from django.core import exceptions #from django.core.management import call_command from django.core.urlresolvers import reverse from django.contrib.auth.decorators import login_required -from django.http import HttpResponse, HttpResponseRedirect, Http404, HttpResponseBadRequest +from django.http import Http404 +from django.http import HttpResponse +from django.http import HttpResponseBadRequest +from django.http import HttpResponseRedirect +from django.http import HttpResponseForbidden from django.forms import ValidationError, IntegerField, CharField from django.shortcuts import get_object_or_404 from django.views.decorators import csrf @@ -1196,30 +1200,26 @@ def save_draft_answer(request): draft.save() @decorators.get_only -@decorators.admins_only def get_users_info(request): """retuns list of user names and email addresses of "fake" users - so that admins can post on their behalf""" - #user_info_list = models.User.objects.filter( - # is_fake=True - # ).values_list( - # 'username', - # 'email' - # ) - user_info_list = models.User.objects.values_list( - 'username', - 'email' - ) - - result_list = list() - for user_info in user_info_list: - username = user_info[0] - email = user_info[1] - result_list.append('%s|%s' % (username, email)) - - output = '\n'.join(result_list) - return HttpResponse(output, mimetype = 'text/plain') + if request.user.is_anonymous(): + return HttpResponseForbidden() + + query = request.GET['q'] + limit = IntegerField().clean(request.GET['limit']) + + users = models.User.objects + user_info_list = users.filter(username__istartswith=query) + + if request.user.is_administrator_or_moderator(): + user_info_list = user_info_list.values_list('username', 'email') + else: + user_info_list = user_info_list.values_list('username') + + result_list = ['|'.join(info) for info in user_info_list[:limit]] + return HttpResponse('\n'.join(result_list), mimetype = 'text/plain') @csrf.csrf_protect def share_question_with_group(request): diff --git a/group_messaging/migrations/0002_auto__add_lastvisittime__add_unique_lastvisittime_user_message__add_fi.py b/group_messaging/migrations/0002_auto__add_lastvisittime__add_unique_lastvisittime_user_message__add_fi.py new file mode 100644 index 00000000..5e92ef2b --- /dev/null +++ b/group_messaging/migrations/0002_auto__add_lastvisittime__add_unique_lastvisittime_user_message__add_fi.py @@ -0,0 +1,142 @@ +# -*- coding: utf-8 -*- +import datetime +from south.db import db +from south.v2 import SchemaMigration +from django.db import models + + +class Migration(SchemaMigration): + + def forwards(self, orm): + # Adding model 'LastVisitTime' + db.create_table('group_messaging_lastvisittime', ( + ('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)), + ('user', self.gf('django.db.models.fields.related.ForeignKey')(to=orm['auth.User'])), + ('message', self.gf('django.db.models.fields.related.ForeignKey')(to=orm['group_messaging.Message'])), + ('at', self.gf('django.db.models.fields.DateTimeField')(auto_now_add=True, blank=True)), + )) + db.send_create_signal('group_messaging', ['LastVisitTime']) + + # Adding unique constraint on 'LastVisitTime', fields ['user', 'message'] + db.create_unique('group_messaging_lastvisittime', ['user_id', 'message_id']) + + # Adding field 'Message.senders_info' + db.add_column('group_messaging_message', 'senders_info', + self.gf('django.db.models.fields.CharField')(default='', max_length=64), + keep_default=False) + + def backwards(self, orm): + # Removing unique constraint on 'LastVisitTime', fields ['user', 'message'] + db.delete_unique('group_messaging_lastvisittime', ['user_id', 'message_id']) + + # Deleting model 'LastVisitTime' + db.delete_table('group_messaging_lastvisittime') + + # Deleting field 'Message.senders_info' + db.delete_column('group_messaging_message', 'senders_info') + + models = { + 'auth.group': { + 'Meta': {'object_name': 'Group'}, + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '80'}), + 'permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}) + }, + 'auth.permission': { + 'Meta': {'ordering': "('content_type__app_label', 'content_type__model', 'codename')", 'unique_together': "(('content_type', 'codename'),)", 'object_name': 'Permission'}, + 'codename': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '50'}) + }, + 'auth.user': { + 'Meta': {'object_name': 'User'}, + 'about': ('django.db.models.fields.TextField', [], {'blank': 'True'}), + 'avatar_type': ('django.db.models.fields.CharField', [], {'default': "'n'", 'max_length': '1'}), + 'bronze': ('django.db.models.fields.SmallIntegerField', [], {'default': '0'}), + 'consecutive_days_visit_count': ('django.db.models.fields.IntegerField', [], {'default': '0'}), + 'country': ('django_countries.fields.CountryField', [], {'max_length': '2', 'blank': 'True'}), + 'date_joined': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), + 'date_of_birth': ('django.db.models.fields.DateField', [], {'null': 'True', 'blank': 'True'}), + 'display_tag_filter_strategy': ('django.db.models.fields.SmallIntegerField', [], {'default': '0'}), + 'email': ('django.db.models.fields.EmailField', [], {'max_length': '75', 'blank': 'True'}), + 'email_isvalid': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'email_key': ('django.db.models.fields.CharField', [], {'max_length': '32', 'null': 'True'}), + 'email_signature': ('django.db.models.fields.TextField', [], {'blank': 'True'}), + 'email_tag_filter_strategy': ('django.db.models.fields.SmallIntegerField', [], {'default': '1'}), + 'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), + 'gold': ('django.db.models.fields.SmallIntegerField', [], {'default': '0'}), + 'gravatar': ('django.db.models.fields.CharField', [], {'max_length': '32'}), + 'groups': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Group']", 'symmetrical': 'False', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'ignored_tags': ('django.db.models.fields.TextField', [], {'blank': 'True'}), + 'interesting_tags': ('django.db.models.fields.TextField', [], {'blank': 'True'}), + 'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), + 'is_fake': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'is_superuser': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'last_login': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), + 'last_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), + 'last_seen': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), + 'location': ('django.db.models.fields.CharField', [], {'max_length': '100', 'blank': 'True'}), + 'new_response_count': ('django.db.models.fields.IntegerField', [], {'default': '0'}), + 'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}), + 'questions_per_page': ('django.db.models.fields.SmallIntegerField', [], {'default': '10'}), + 'real_name': ('django.db.models.fields.CharField', [], {'max_length': '100', 'blank': 'True'}), + 'reputation': ('django.db.models.fields.PositiveIntegerField', [], {'default': '1'}), + 'seen_response_count': ('django.db.models.fields.IntegerField', [], {'default': '0'}), + 'show_country': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'show_marked_tags': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), + 'silver': ('django.db.models.fields.SmallIntegerField', [], {'default': '0'}), + 'status': ('django.db.models.fields.CharField', [], {'default': "'w'", 'max_length': '2'}), + 'subscribed_tags': ('django.db.models.fields.TextField', [], {'blank': 'True'}), + 'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}), + 'username': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '255'}), + 'website': ('django.db.models.fields.URLField', [], {'max_length': '200', 'blank': 'True'}) + }, + 'contenttypes.contenttype': { + 'Meta': {'ordering': "('name',)", 'unique_together': "(('app_label', 'model'),)", 'object_name': 'ContentType', 'db_table': "'django_content_type'"}, + 'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '100'}) + }, + 'group_messaging.lastvisittime': { + 'Meta': {'unique_together': "(('user', 'message'),)", 'object_name': 'LastVisitTime'}, + 'at': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'message': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['group_messaging.Message']"}), + 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}) + }, + 'group_messaging.message': { + 'Meta': {'object_name': 'Message'}, + 'active_until': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'blank': 'True'}), + 'headline': ('django.db.models.fields.CharField', [], {'max_length': '80'}), + 'html': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'last_active_at': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}), + 'message_type': ('django.db.models.fields.SmallIntegerField', [], {'default': '0'}), + 'parent': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'children'", 'null': 'True', 'to': "orm['group_messaging.Message']"}), + 'recipients': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Group']", 'symmetrical': 'False'}), + 'root': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'descendants'", 'null': 'True', 'to': "orm['group_messaging.Message']"}), + 'sender': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'sent_messages'", 'to': "orm['auth.User']"}), + 'senders_info': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '64'}), + 'sent_at': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}), + 'text': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}) + }, + 'group_messaging.messagememo': { + 'Meta': {'unique_together': "(('user', 'message'),)", 'object_name': 'MessageMemo'}, + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'message': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['group_messaging.Message']"}), + 'status': ('django.db.models.fields.SmallIntegerField', [], {'default': '0'}), + 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}) + }, + 'group_messaging.senderlist': { + 'Meta': {'object_name': 'SenderList'}, + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'recipient': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.Group']", 'unique': 'True'}), + 'senders': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.User']", 'symmetrical': 'False'}) + } + } + + complete_apps = ['group_messaging']
\ No newline at end of file diff --git a/group_messaging/models.py b/group_messaging/models.py index 838134e7..62f720cf 100644 --- a/group_messaging/models.py +++ b/group_messaging/models.py @@ -1,14 +1,26 @@ +"""models for the ``group_messaging`` app +""" +import datetime from django.db import models from django.contrib.auth.models import Group from django.contrib.auth.models import User MAX_TITLE_LENGTH = 80 +MAX_SENDERS_INFO_LENGTH = 64 #dummy parse message function parse_message = lambda v: v +GROUP_NAME_TPL = '_personal_%s' + def get_personal_group_by_user_id(user_id): - return Group.objects.get(name='_personal_%s' % user_id) + return Group.objects.get(name=GROUP_NAME_TPL % user_id) + + +def get_personal_groups_for_users(users): + """for a given list of users return their personal groups""" + group_names = [(GROUP_NAME_TPL % user.id) for user in users] + return Group.objects.filter(name__in=group_names) def get_personal_group(user): @@ -18,11 +30,23 @@ def get_personal_group(user): def create_personal_group(user): """creates a personal group for the user""" - group = Group(name='_personal_%s' % user.id) + group = Group(name=GROUP_NAME_TPL % user.id) group.save() return group +class LastVisitTime(models.Model): + """just remembers when a user has + last visited a given thread + """ + user = models.ForeignKey(User) + message = models.ForeignKey('Message') + at = models.DateTimeField(auto_now_add=True) + + class Meta: + unique_together = ('user', 'message') + + class SenderListManager(models.Manager): """model manager for the :class:`SenderList`""" @@ -61,7 +85,6 @@ class MessageMemo(models.Model): STATUS_CHOICES = ( (SEEN, 'seen'), (ARCHIVED, 'archived') - ) user = models.ForeignKey(User) message = models.ForeignKey('Message') @@ -99,13 +122,24 @@ class MessageManager(models.Manager): headline = kwargs.get('headline', kwargs['text']) kwargs['headline'] = headline[:MAX_TITLE_LENGTH] kwargs['html'] = parse_message(kwargs['text']) - return super(MessageManager, self).create(**kwargs) + + message = super(MessageManager, self).create(**kwargs) + #creator of message saw it by definition + #crate a "seen" memo for the sender, because we + #don't want to inform the user about his/her own post + sender = kwargs['sender'] + MessageMemo.objects.create( + message=message, user=sender, status=MessageMemo.SEEN + ) + return message + def create_thread(self, sender=None, recipients=None, text=None): """creates a stored message and adds recipients""" message = self.create( message_type=Message.STORED, sender=sender, + senders_info=sender.username, text=text, ) message.add_recipients(recipients) @@ -123,10 +157,13 @@ class MessageManager(models.Manager): recipients = set(parent.recipients.all()) senders_group = get_personal_group(parent.sender) recipients.add(senders_group) - message.add_recipients(recipients, ignore_user=sender) + message.add_recipients(recipients) #add author of the parent as a recipient to parent - #but make sure to mute the message - parent.add_recipients([senders_group], ignore_user=parent.sender) + parent.add_recipients([senders_group]) + #mark last active timestamp for the root message + #so that we know that this thread was most recently + #updated + message.update_root_info() return message @@ -150,47 +187,63 @@ class Message(models.Model): ) sender = models.ForeignKey(User, related_name='sent_messages') + + senders_info = models.CharField( + max_length=MAX_SENDERS_INFO_LENGTH, + default='' + )#comma-separated list of a few names + recipients = models.ManyToManyField(Group) + root = models.ForeignKey( 'self', null=True, blank=True, related_name='descendants' ) + parent = models.ForeignKey( 'self', null=True, blank=True, related_name='children' ) + headline = models.CharField(max_length=MAX_TITLE_LENGTH) + text = models.TextField( null=True, blank=True, help_text='source text for the message, e.g. in markdown format' ) + html = models.TextField( null=True, blank=True, help_text='rendered html of the message' ) + sent_at = models.DateTimeField(auto_now_add=True) last_active_at = models.DateTimeField(auto_now_add=True) active_until = models.DateTimeField(blank=True, null=True) objects = MessageManager() - def add_recipients(self, recipients, ignore_user=None): + def add_recipients(self, recipients): """adds recipients to the message and updates the sender lists for all recipients todo: sender lists may be updated in a lazy way - per user - - `ignore_user` parameter is used to mark a specific user - as not needing to receive a message as new, even if that - user is a member of any of the recipient groups """ - if ignore_user: - #crate a "seen" memo for the sender, because we - #don't want to inform the user about his/her own post - MessageMemo.objects.create( - message=self, user=self.sender, status=MessageMemo.SEEN - ) - self.recipients.add(*recipients) for recipient in recipients: sender_list, created = SenderList.objects.get_or_create(recipient=recipient) sender_list.senders.add(self.sender) + + def update_root_info(self): + """Update the last active at timestamp and + the contributors info, if relevant. + Root object will be saved to the database. + """ + self.root.last_active_at = datetime.datetime.now() + senders_names = self.root.senders_info.split(',') + + if self.sender.username in senders_names: + senders_names.remove(self.sender.username) + senders_names.insert(0, self.sender.username) + + self.root.senders_info = (','.join(senders_names))[:64] + self.root.save() diff --git a/group_messaging/tests.py b/group_messaging/tests.py index 80f6f792..c8401dc1 100644 --- a/group_messaging/tests.py +++ b/group_messaging/tests.py @@ -51,11 +51,19 @@ class ModelTests(TestCase): #sender_group = get_personal_group(self.sender) #maybe add this too expected_recipients = set([recipient_group]) self.assertEqual(recipients, expected_recipients) - self.assertRaises( - MessageMemo.DoesNotExist, - MessageMemo.objects.get, - message=message - ) + #self.assertRaises( + # MessageMemo.DoesNotExist, + # MessageMemo.objects.get, + # message=message + #) + #make sure that the original senders memo to the root + #message is marke ad seen + memos = MessageMemo.objects.filter( + message=message, + user=self.sender + ) + self.assertEquals(memos.count(), 1) + self.assertEqual(memos[0].status, MessageMemo.SEEN) def test_get_senders_for_user(self): """this time send thread to a real group test that @@ -77,11 +85,13 @@ class ModelTests(TestCase): parent=root_message ) self.assertEqual(response.message_type, Message.STORED) + #assert that there is only one "seen" memo for the response memos = MessageMemo.objects.filter(message=response) self.assertEqual(memos.count(), 1) self.assertEqual(memos[0].user, self.recipient) self.assertEqual(memos[0].status, MessageMemo.SEEN) + #assert that recipients are the two people who are part of #this conversation recipients = set(response.recipients.all()) diff --git a/group_messaging/urls.py b/group_messaging/urls.py index eb033751..30002bf3 100644 --- a/group_messaging/urls.py +++ b/group_messaging/urls.py @@ -10,6 +10,11 @@ urlpatterns = patterns('', name='get_threads' ), url( + '^threads/(?P<thread_id>\d+)/$', + views.ThreadDetails().as_view(), + name='thread_details' + ), + url( '^threads/create/$', views.NewThread().as_view(), name='create_thread' @@ -18,5 +23,10 @@ urlpatterns = patterns('', '^senders/$', views.SendersList().as_view(), name='get_senders' + ), + url( + '^post-reply/$', + views.PostReply().as_view(), + name='post_reply' ) ) diff --git a/group_messaging/views.py b/group_messaging/views.py index 9d324d62..289961ff 100644 --- a/group_messaging/views.py +++ b/group_messaging/views.py @@ -8,7 +8,10 @@ in order to render messages within the page. Notice that :mod:`urls` module decorates all these functions and turns them into complete views """ +import copy +import datetime from coffin.template.loader import get_template +from django.contrib.auth.models import User from django.forms import IntegerField from django.http import HttpResponse from django.http import HttpResponseNotAllowed @@ -16,7 +19,9 @@ from django.http import HttpResponseForbidden from django.utils import simplejson from group_messaging.models import Message from group_messaging.models import SenderList +from group_messaging.models import LastVisitTime from group_messaging.models import get_personal_group_by_user_id +from group_messaging.models import get_personal_groups_for_users class InboxView(object): """custom class-based view @@ -35,9 +40,9 @@ class InboxView(object): """ if template_name is None: template_name = self.template_name - template = get_template(self.template_name) + template = get_template(template_name) html = template.render(context) - json = simplejson.dumps({'html': html}) + json = simplejson.dumps({'html': html, 'success': True}) return HttpResponse(json, mimetype='application/json') @@ -78,8 +83,7 @@ class InboxView(object): class NewThread(InboxView): """view for creation of new thread""" - template_name = 'create_thread.html'# contains new thread form - http_method_list = ('GET', 'POST') + http_method_list = ('POST',) def post(self, request): """creates a new thread on behalf of the user @@ -87,18 +91,32 @@ class NewThread(InboxView): need to go back to the thread listing view whose content should be cached in the client' """ - username = IntegerField().clean(request.POST['to_username']) - user = User.objects.get(username=username) - recipient = get_personal_group_by_user_id(user.id) - Message.objects.create_thread( - sender=request.user, - recipients=[recipient], - text=request.POST['text'] - ) - return HttpResponse('', mimetype='application/json') - - -class NewResponse(InboxView): + usernames = request.POST['to_usernames'] + usernames = map(lambda v: v.strip(), usernames.split(',')) + users = User.objects.filter(username__in=usernames) + + missing = copy.copy(usernames) + for user in users: + if user.username in missing: + missing.remove(user.username) + + result = dict() + if missing: + result['success'] = False + result['missing_users'] = missing + else: + recipients = get_personal_groups_for_users(users) + message = Message.objects.create_thread( + sender=request.user, + recipients=recipients, + text=request.POST['text'] + ) + result['success'] = True + result['message_id'] = message.id + return HttpResponse(simplejson.dumps(result), mimetype='application/json') + + +class PostReply(InboxView): """view to create a new response""" http_method_list = ('POST',) @@ -110,25 +128,63 @@ class NewResponse(InboxView): text=request.POST['text'], parent=parent ) + last_visit = LastVisitTime.objects.get( + message=message.root, + user=request.user + ) + last_visit.at = datetime.datetime.now() + last_visit.save() return self.render_to_response( - {'message': message}, template_name='stored_message.htmtl' + {'post': message, 'user': request.user}, + template_name='group_messaging/stored_message.html' ) class ThreadsList(InboxView): """shows list of threads for a given user""" - template_name = 'threads_list.html' + template_name = 'group_messaging/threads_list.html' http_method_list = ('GET',) def get_context(self, request): """returns thread list data""" + #get threads and the last visit time threads = Message.objects.get_threads_for_user(request.user) - threads = threads.values('id', 'headline') - return {'threads': threads} + + sender_id = IntegerField().clean(request.GET.get('sender_id', '-1')) + if sender_id != -1: + threads = threads.filter(sender__id=sender_id) + + #for each thread we need to know if there is something + #unread for the user - to mark "new" threads as bold + threads_data = dict() + for thread in threads: + thread_data = dict() + #determine status + thread_data['status'] = 'new' + #determine the senders info + senders_names = thread.senders_info.split(',') + if request.user.username in senders_names: + senders_names.remove(request.user.username) + thread_data['senders_info'] = ', '.join(senders_names) + thread_data['thread'] = thread + threads_data[thread.id] = thread_data + + last_visit_times = LastVisitTime.objects.filter( + user=request.user, + message__in=threads + ) + for last_visit in last_visit_times: + thread_data = threads_data[last_visit.message_id] + if thread_data['thread'].last_active_at <= last_visit.at: + thread_data['status'] = 'seen' + + #after we have all the data - update the last visit time + last_visit_times.update(at=datetime.datetime.now()) + return {'threads': threads, 'threads_data': threads_data} class SendersList(InboxView): """shows list of senders for a user""" - template_name = 'senders_list.html' + template_name = 'group_messaging/senders_list.html' http_method_names = ('GET',) def get_context(self, request): @@ -140,13 +196,19 @@ class SendersList(InboxView): class ThreadDetails(InboxView): """shows entire thread in the unfolded form""" - template_name = 'thread_details.html' + template_name = 'group_messaging/thread_details.html' http_method_names = ('GET',) - def get_context(self, request): + def get_context(self, request, thread_id=None): """shows individual thread""" - thread_id = IntegerField().clean(request.GET['thread_id']) #todo: assert that current thread is the root - messages = Message.objects.filter(root__id=thread_id) - messages = messages.values('html') - return {'messages': messages} + root = Message.objects.get(id=thread_id) + responses = Message.objects.filter(root__id=thread_id) + last_visit, created = LastVisitTime.objects.get_or_create( + message=root, + user=request.user + ) + if created is False: + last_visit.at = datetime.datetime.now() + last_visit.save() + return {'root_message': root, 'responses': responses, 'request': request} |