From 5addcec4cd448b4cb8d7ab20cb316e73130b6604 Mon Sep 17 00:00:00 2001 From: Tomasz Zielinski Date: Mon, 2 Jan 2012 22:51:34 +0100 Subject: Fixed the remaining failing tests; Fixed migration 0004 to not raise an exception when applied on an empty (fresh) database --- .../0004_install_full_text_indexes_for_mysql.py | 2 +- askbot/models/meta.py | 254 +-------------------- askbot/models/post.py | 243 +++++++++++++++++--- askbot/models/question.py | 1 + askbot/tasks.py | 40 ++-- askbot/utils/mysql.py | 16 ++ askbot/views/users.py | 6 +- 7 files changed, 265 insertions(+), 297 deletions(-) diff --git a/askbot/migrations/0004_install_full_text_indexes_for_mysql.py b/askbot/migrations/0004_install_full_text_indexes_for_mysql.py index ac8448e5..7c7c52f0 100644 --- a/askbot/migrations/0004_install_full_text_indexes_for_mysql.py +++ b/askbot/migrations/0004_install_full_text_indexes_for_mysql.py @@ -35,7 +35,7 @@ class Migration(DataMigration): and will probably fail otherwise """ if db.backend_name == 'mysql': - if mysql.supports_full_text_search(): + if mysql.supports_full_text_search_migr0004(): #todo: extract column names by introspection question_index_sql = get_create_full_text_index_sql( Q_INDEX_NAME, diff --git a/askbot/models/meta.py b/askbot/models/meta.py index 27d40c22..ff18b8be 100644 --- a/askbot/models/meta.py +++ b/askbot/models/meta.py @@ -1,13 +1,6 @@ import datetime -from django.contrib.contenttypes import generic -from django.contrib.contenttypes.models import ContentType from django.db import models -from django.utils import html as html_utils -from django.utils.translation import ugettext as _ -from askbot import const -from askbot import exceptions -from askbot.models import base -from askbot.models.user import EmailFeedSetting + class VoteManager(models.Manager): def get_up_vote_count_from_user(self, user): @@ -86,248 +79,3 @@ class Vote(models.Model): score_after = self.voted_post.score return score_after - score_before - - -#class Comment(models.Model): -# post_type = 'comment' -# comment = models.CharField(max_length = const.COMMENT_HARD_MAX_LENGTH) -# added_at = models.DateTimeField(default = datetime.datetime.now) -# html = models.CharField(max_length = const.COMMENT_HARD_MAX_LENGTH, default='') -# score = models.IntegerField(default = 0) -# offensive_flag_count = models.IntegerField(default = 0) -# -# user = models.ForeignKey('auth.User', related_name='comments') -# -# content_type = models.ForeignKey(ContentType) -# object_id = models.PositiveIntegerField() -# content_object = generic.GenericForeignKey('content_type', 'object_id') -# -# _urlize = True -# _use_markdown = True -# _escape_html = True -# is_anonymous = False #comments are never anonymous - may change -# -# class Meta: -# ordering = ('-added_at',) -# app_label = 'askbot' -# db_table = u'comment' -# -# #these two are methods -# parse = base.parse_post_text -# parse_and_save = base.parse_and_save_post -# -# def assert_is_visible_to(self, user): -# """raises QuestionHidden or AnswerHidden""" -# try: -# self.content_object.assert_is_visible_to(user) -# except exceptions.QuestionHidden: -# message = _( -# 'Sorry, the comment you are looking for is no ' -# 'longer accessible, because the parent question ' -# 'has been removed' -# ) -# raise exceptions.QuestionHidden(message) -# except exceptions.AnswerHidden: -# message = _( -# 'Sorry, the comment you are looking for is no ' -# 'longer accessible, because the parent answer ' -# 'has been removed' -# ) -# raise exceptions.AnswerHidden(message) -# -# def get_origin_post(self): -# return self.content_object.get_origin_post() -# -# def get_tag_names(self): -# """return tag names of the origin question""" -# return self.get_origin_post().get_tag_names() -# -# def get_page_number(self, answers = None): -# """return page number whithin the page -# where the comment is going to appear -# answers parameter will not be used if the comment belongs -# to a question, otherwise answers list or queryset -# will be used to determine the page number""" -# return self.content_object.get_page_number(answers = answers) -# -# def get_order_number(self): -# return self.content_object.comments.filter( -# added_at__lt = self.added_at -# ).count() + 1 -# -# #todo: maybe remove this wnen post models are unified -# def get_text(self): -# return self.comment -# -# def set_text(self, text): -# self.comment = text -# -# def get_snippet(self): -# """returns an abbreviated snippet of the content -# todo: remove this if comment model unites with Q&A -# """ -# return html_utils.strip_tags(self.html)[:120] + ' ...' -# -# def get_owner(self): -# return self.user -# -# def get_updated_activity_data(self, created = False): -# if self.content_object.post_type == 'question': -# return const.TYPE_ACTIVITY_COMMENT_QUESTION, self -# elif self.content_object.post_type == 'answer': -# return const.TYPE_ACTIVITY_COMMENT_ANSWER, self -# -# def get_response_receivers(self, exclude_list = None): -# """Response receivers are commenters of the -# same post and the authors of the post itself. -# """ -# assert(exclude_list is not None) -# users = set() -# #get authors of parent object and all associated comments -# users.update( -# self.content_object.get_author_list( -# include_comments = True, -# ) -# ) -# users -= set(exclude_list) -# return list(users) -# -# def get_instant_notification_subscribers( -# self, -# potential_subscribers = None, -# mentioned_users = None, -# exclude_list = None -# ): -# """get list of users who want instant notifications about comments -# -# argument potential_subscribers is required as it saves on db hits -# -# Here is the list of people who will receive the notifications: -# -# * mentioned users -# * of response receivers -# (see :meth:`~askbot.models.meta.Comment.get_response_receivers`) - -# those who subscribe for the instant -# updates on comments and @mentions -# * all who follow the question explicitly -# * all global subscribers -# (tag filtered, and subject to personalized settings) -# """ -# #print 'in meta function' -# #print 'potential subscribers: ', potential_subscribers -# -# subscriber_set = set() -# -# if potential_subscribers: -# potential_subscribers = set(potential_subscribers) -# else: -# potential_subscribers = set() -# -# if mentioned_users: -# potential_subscribers.update(mentioned_users) -# -# if potential_subscribers: -# comment_subscribers = EmailFeedSetting.objects.filter_subscribers( -# potential_subscribers = potential_subscribers, -# feed_type = 'm_and_c', -# frequency = 'i' -# ) -# subscriber_set.update(comment_subscribers) -# #print 'comment subscribers: ', comment_subscribers -# -# origin_post = self.get_origin_post() -# # TODO: The line below works only if origin_post is Question ! -# selective_subscribers = origin_post.thread.followed_by.all() -# if selective_subscribers: -# selective_subscribers = EmailFeedSetting.objects.filter_subscribers( -# potential_subscribers = selective_subscribers, -# feed_type = 'q_sel', -# frequency = 'i' -# ) -# for subscriber in selective_subscribers: -# if origin_post.passes_tag_filter_for_user(subscriber): -# subscriber_set.add(subscriber) -# -# subscriber_set.update(selective_subscribers) -# #print 'selective subscribers: ', selective_subscribers -# -# global_subscribers = origin_post.get_global_instant_notification_subscribers() -# #print 'global subscribers: ', global_subscribers -# -# subscriber_set.update(global_subscribers) -# -# #print 'exclude list is: ', exclude_list -# if exclude_list: -# subscriber_set -= set(exclude_list) -# -# #print 'final list of subscribers:', subscriber_set -# -# return list(subscriber_set) -# -# def get_time_of_last_edit(self): -# return self.added_at -# -# def delete(self, **kwargs): -# """deletes comment and concomitant response activity -# records, as well as mention records, while preserving -# integrity or response counts for the users -# """ -# comment_content_type = ContentType.objects.get_for_model(self) -# comment_id = self.id -# -# #todo: implement a custom delete method on these -# #all this should pack into Activity.responses.filter( somehow ).delete() -# activity_types = const.RESPONSE_ACTIVITY_TYPES_FOR_DISPLAY -# activity_types += (const.TYPE_ACTIVITY_MENTION,) -# #todo: not very good import in models of other models -# #todo: potentially a circular import -# from askbot.models.user import Activity -# activities = Activity.objects.filter( -# content_type = comment_content_type, -# object_id = comment_id, -# activity_type__in = activity_types -# ) -# -# recipients = set() -# for activity in activities: -# for user in activity.recipients.all(): -# recipients.add(user) -# -# #activities need to be deleted before the response -# #counts are updated -# activities.delete() -# -# for user in recipients: -# user.update_response_counts() -# -# super(Comment,self).delete(**kwargs) -# -# def get_absolute_url(self): -# origin_post = self.get_origin_post() -# return '%(url)s?comment=%(id)d#comment-%(id)d' % \ -# {'url': origin_post.get_absolute_url(), 'id':self.id} -# -# def get_latest_revision_number(self): -# return 1 -# -# def is_upvoted_by(self, user): -# content_type = ContentType.objects.get_for_model(self) -# what_to_count = { -# 'user': user, -# 'object_id': self.id, -# 'content_type': content_type -# } -# return Vote.objects.filter(**what_to_count).count() > 0 -# -# def is_last(self): -# """True if there are no newer comments on -# the related parent object -# """ -# return Comment.objects.filter( -# added_at__gt = self.added_at, -# object_id = self.object_id, -# content_type = self.content_type -# ).count() == 0 -# -# def __unicode__(self): -# return self.comment diff --git a/askbot/models/post.py b/askbot/models/post.py index 12a0787b..720d7673 100644 --- a/askbot/models/post.py +++ b/askbot/models/post.py @@ -15,6 +15,7 @@ from django.utils.translation import ugettext as _ from django.utils.http import urlquote as django_urlquote from django.core import exceptions as django_exceptions from django.core.exceptions import ValidationError +from django.contrib.contenttypes.models import ContentType import askbot @@ -436,15 +437,13 @@ class Post(models.Model): #but the question body to Post is_anonymous = models.BooleanField(default=False) - _use_markdown = True - _escape_html = False #markdow does the escaping - _urlize = False - objects = PostManager() class Meta: app_label = 'askbot' db_table = 'askbot_post' + ordering = ('-added_at',) # default ordering for comments, for other post types should be overriden per query + def parse_post_text(post): """typically post has a field to store raw source text @@ -462,15 +461,26 @@ class Post(models.Model): removed_mentions - list of mention objects - for removed ones """ + if post.is_answer() or post.is_question(): + _urlize = False + _use_markdown = True + _escape_html = False #markdow does the escaping + elif post.is_comment(): + _urlize = True + _use_markdown = True + _escape_html = True + else: + raise NotImplementedError + text = post.get_text() - if post._escape_html: + if _escape_html: text = cgi.escape(text) - if post._urlize: + if _urlize: text = html.urlize(text) - if post._use_markdown: + if _use_markdown: text = sanitize_html(markup.get_parser().convert(text)) #todo, add markdown parser call conditional on @@ -615,6 +625,11 @@ class Post(models.Model): if no_slug is False: url += django_urlquote(self.slug) return url + elif self.is_comment(): + origin_post = self.get_origin_post() + return '%(url)s?comment=%(id)d#comment-%(id)d' % \ + {'url': origin_post.get_absolute_url(), 'id':self.id} + raise NotImplementedError @@ -623,12 +638,49 @@ class Post(models.Model): # TODO: Restore specialized Comment.delete() functionality! super(Post, self).delete(*args, **kwargs) + def delete(self, **kwargs): + """deletes comment and concomitant response activity + records, as well as mention records, while preserving + integrity or response counts for the users + """ + if self.is_comment(): + #todo: implement a custom delete method on these + #all this should pack into Activity.responses.filter( somehow ).delete() + activity_types = const.RESPONSE_ACTIVITY_TYPES_FOR_DISPLAY + activity_types += (const.TYPE_ACTIVITY_MENTION,) + #todo: not very good import in models of other models + #todo: potentially a circular import + from askbot.models.user import Activity + comment_content_type = ContentType.objects.get_for_model(self) + activities = Activity.objects.filter( + content_type = comment_content_type, + object_id = self.id, + activity_type__in = activity_types + ) + + recipients = set() + for activity in activities: + for user in activity.recipients.all(): + recipients.add(user) + + #activities need to be deleted before the response + #counts are updated + activities.delete() + + for user in recipients: + user.update_response_counts() + + super(Post, self).delete(**kwargs) + + def __unicode__(self): if self.is_question(): return self.thread.title elif self.is_answer(): return self.html + elif self.is_comment(): + return self.text raise NotImplementedError def is_answer(self): @@ -827,7 +879,7 @@ class Post(models.Model): return subscriber_set - def get_instant_notification_subscribers( + def _qa__get_instant_notification_subscribers( self, potential_subscribers = None, mentioned_users = None, @@ -898,18 +950,14 @@ class Post(models.Model): #4) question asked by me (todo: not "edited_by_me" ???) question_author = origin_post.author - if EmailFeedSetting.objects.filter( - subscriber = question_author, - frequency = 'i', - feed_type = 'q_ask' - ): + if EmailFeedSetting.objects.filter(subscriber = question_author, frequency = 'i', feed_type = 'q_ask').exists(): subscriber_set.add(question_author) #4) questions answered by me -make sure is that people #are authors of the answers to this question #todo: replace this with a query set method answer_authors = set() - for answer in origin_post.answers.all(): + for answer in origin_post.thread.posts.get_answers().all(): authors = answer.get_author_list() answer_authors.update(authors) @@ -928,13 +976,106 @@ class Post(models.Model): #print 'final subscriber set is ', subscriber_set return list(subscriber_set) + def _comment__get_instant_notification_subscribers( + self, + potential_subscribers = None, + mentioned_users = None, + exclude_list = None + ): + """get list of users who want instant notifications about comments + + argument potential_subscribers is required as it saves on db hits + + Here is the list of people who will receive the notifications: + + * mentioned users + * of response receivers + (see :meth:`~askbot.models.meta.Comment.get_response_receivers`) - + those who subscribe for the instant + updates on comments and @mentions + * all who follow the question explicitly + * all global subscribers + (tag filtered, and subject to personalized settings) + """ + #print 'in meta function' + #print 'potential subscribers: ', potential_subscribers + + subscriber_set = set() + + if potential_subscribers: + potential_subscribers = set(potential_subscribers) + else: + potential_subscribers = set() + + if mentioned_users: + potential_subscribers.update(mentioned_users) + + if potential_subscribers: + comment_subscribers = EmailFeedSetting.objects.filter_subscribers( + potential_subscribers = potential_subscribers, + feed_type = 'm_and_c', + frequency = 'i' + ) + subscriber_set.update(comment_subscribers) + #print 'comment subscribers: ', comment_subscribers + + origin_post = self.get_origin_post() + # TODO: The line below works only if origin_post is Question ! + selective_subscribers = origin_post.thread.followed_by.all() + if selective_subscribers: + selective_subscribers = EmailFeedSetting.objects.filter_subscribers( + potential_subscribers = selective_subscribers, + feed_type = 'q_sel', + frequency = 'i' + ) + for subscriber in selective_subscribers: + if origin_post.passes_tag_filter_for_user(subscriber): + subscriber_set.add(subscriber) + + subscriber_set.update(selective_subscribers) + #print 'selective subscribers: ', selective_subscribers + + global_subscribers = origin_post.get_global_instant_notification_subscribers() + #print 'global subscribers: ', global_subscribers + + subscriber_set.update(global_subscribers) + + #print 'exclude list is: ', exclude_list + if exclude_list: + subscriber_set -= set(exclude_list) + + #print 'final list of subscribers:', subscriber_set + + return list(subscriber_set) + + def get_instant_notification_subscribers(self, potential_subscribers = None, mentioned_users = None, exclude_list = None): + if self.is_question() or self.is_answer(): + return self._qa__get_instant_notification_subscribers( + potential_subscribers=potential_subscribers, + mentioned_users=mentioned_users, + exclude_list=exclude_list + ) + elif self.is_comment(): + return self._comment__get_instant_notification_subscribers( + potential_subscribers=potential_subscribers, + mentioned_users=mentioned_users, + exclude_list=exclude_list + ) + raise NotImplementedError + def get_latest_revision(self): return self.revisions.all().order_by('-revised_at')[0] def get_latest_revision_number(self): - return self.get_latest_revision().revision + if self.is_comment(): + return 1 + else: + return self.get_latest_revision().revision def get_time_of_last_edit(self): + if self.is_comment(): + return self.added_at + if self.last_edited_at: return self.last_edited_at else: @@ -955,8 +1096,9 @@ class Post(models.Model): if include_comments: authors.update([c.author for c in self.comments.all()]) if recursive: - if hasattr(self, 'answers'): - for a in self.answers.exclude(deleted = True): + if self.is_question(): #hasattr(self, 'answers'): + #for a in self.answers.exclude(deleted = True): + for a in self.thread.posts.get_answers().exclude(deleted = True): authors.update(a.get_author_list( include_comments = include_comments ) ) if exclude_list: authors -= set(exclude_list) @@ -1153,11 +1295,33 @@ class Post(models.Model): except django_exceptions.PermissionDenied: raise exceptions.AnswerHidden(message) + def _comment__assert_is_visible_to(self, user): + """raises QuestionHidden or AnswerHidden""" + try: + self.parent.assert_is_visible_to(user) + except exceptions.QuestionHidden: + message = _( + 'Sorry, the comment you are looking for is no ' + 'longer accessible, because the parent question ' + 'has been removed' + ) + raise exceptions.QuestionHidden(message) + except exceptions.AnswerHidden: + message = _( + 'Sorry, the comment you are looking for is no ' + 'longer accessible, because the parent answer ' + 'has been removed' + ) + raise exceptions.AnswerHidden(message) + + def assert_is_visible_to(self, user): if self.is_question(): return self._question__assert_is_visible_to(user) elif self.is_answer(): return self._answer__assert_is_visible_to(user) + elif self.is_comment(): + return _comment__assert_is_visible_to(user) raise NotImplementedError def get_updated_activity_data(self, created = False): @@ -1175,17 +1339,16 @@ class Post(models.Model): else: latest_revision = self.get_latest_revision() return const.TYPE_ACTIVITY_UPDATE_QUESTION, latest_revision - raise NotImplementedError + elif self.is_comment(): + if self.parent.post_type == 'question': + return const.TYPE_ACTIVITY_COMMENT_QUESTION, self + elif self.parent.post_type == 'answer': + return const.TYPE_ACTIVITY_COMMENT_ANSWER, self - def get_tag_names(self): - if self.is_question(): - """Creates a list of Tag names from the ``tagnames`` attribute.""" - return self.thread.tagnames.split(u' ') - elif self.is_answer(): - """return tag names on the question""" - return self.question.get_tag_names() raise NotImplementedError + def get_tag_names(self): + return self.thread.get_tag_names() def _answer__apply_edit(self, edited_at=None, edited_by=None, text=None, comment=None, wiki=False): @@ -1344,12 +1507,13 @@ class Post(models.Model): include_comments = True ) ) + question = self.thread._question_post() recipients.update( - self.question.get_author_list( + question.get_author_list( include_comments = True ) ) - for answer in self.question.answers.all(): + for answer in question.thread.posts.get_answers().all(): recipients.update(answer.get_author_list()) recipients -= set(exclude_list) @@ -1372,17 +1536,35 @@ class Post(models.Model): ) ) #do not include answer commenters here - for a in self.answers.all(): + for a in self.thread.posts.get_answers().all(): recipients.update(a.get_author_list()) recipients -= set(exclude_list) return recipients + def _comment__get_response_receivers(self, exclude_list = None): + """Response receivers are commenters of the + same post and the authors of the post itself. + """ + assert(exclude_list is not None) + users = set() + #get authors of parent object and all associated comments + users.update( + self.parent.get_author_list( + include_comments = True, + ) + ) + users -= set(exclude_list) + return list(users) + + def get_response_receivers(self, exclude_list = None): if self.is_answer(): return self._answer__get_response_receivers(exclude_list) elif self.is_question(): return self._question__get_response_receivers(exclude_list) + elif self.is_comment(): + return self._comment__get_response_receivers(exclude_list) raise NotImplementedError def get_question_title(self): @@ -1438,6 +1620,11 @@ class Post(models.Model): order_number += 1 return int(order_number/const.ANSWERS_PAGE_SIZE) + 1 + def get_order_number(self): + if not self.is_comment(): + raise NotImplementedError + return self.parent.comments.filter(added_at__lt = self.added_at).count() + 1 + def get_latest_revision(self): return self.revisions.order_by('-revised_at')[0] diff --git a/askbot/models/question.py b/askbot/models/question.py index a3738985..693ef814 100644 --- a/askbot/models/question.py +++ b/askbot/models/question.py @@ -579,6 +579,7 @@ class Thread(models.Model): # class Meta(content.Content.Meta): # db_table = u'question' # +# TODO: Add sphinx_search() to Post model # #if getattr(settings, 'USE_SPHINX_SEARCH', False): # from djangosphinx.models import SphinxSearch diff --git a/askbot/tasks.py b/askbot/tasks.py index 465465ef..fefe99f5 100644 --- a/askbot/tasks.py +++ b/askbot/tasks.py @@ -17,12 +17,19 @@ That is the reason for having two types of methods here: * celery tasks - shells that reconstitute the necessary ORM objects and call the base methods """ +import sys +import traceback + from django.contrib.contenttypes.models import ContentType from celery.decorators import task from askbot.models import Activity from askbot.models import User from askbot.models import send_instant_notifications_about_activity_in_post +# TODO: Make exceptions raised inside record_post_update_celery_task() ... +# ... propagate upwards to test runner, if only CELERY_ALWAYS_EAGER = True +# (i.e. if Celery tasks are not deferred but executed straight away) + @task(ignore_results = True) def record_post_update_celery_task( post_id, @@ -40,15 +47,21 @@ def record_post_update_celery_task( newly_mentioned_users = User.objects.filter( id__in = newly_mentioned_user_id_list ) - - record_post_update( - post = post, - updated_by = updated_by, - newly_mentioned_users = newly_mentioned_users, - timestamp = timestamp, - created = created, - diff = diff - ) + try: + record_post_update( + post = post, + updated_by = updated_by, + newly_mentioned_users = newly_mentioned_users, + timestamp = timestamp, + created = created, + diff = diff + ) + except Exception: + if 'test' in sys.argv: + # HACK: exceptions from Celery job don;t propagate upwards to Django test runner + # so at least le't sprint tracebacks + print >>sys.stderr, traceback.format_exc() + raise def record_post_update( post = None, @@ -73,12 +86,12 @@ def record_post_update( #todo: take into account created == True case (activity_type, update_object) = post.get_updated_activity_data(created) - if post.post_type != 'comment': + if post.is_comment(): + #it's just a comment! + summary = post.text + else: #summary = post.get_latest_revision().summary summary = diff - else: - #it's just a comment! - summary = post.comment update_activity = Activity( user = updated_by, @@ -97,6 +110,7 @@ def record_post_update( recipients = post.get_response_receivers( exclude_list = [updated_by, ] ) + update_activity.add_recipients(recipients) #create new mentions diff --git a/askbot/utils/mysql.py b/askbot/utils/mysql.py index cc13e70f..cf0e71ef 100644 --- a/askbot/utils/mysql.py +++ b/askbot/utils/mysql.py @@ -18,3 +18,19 @@ def supports_full_text_search(): else: SUPPORTS_FTS = False return SUPPORTS_FTS + + +# This is needed to maintain compatibility with the old 0004 migration +# Usually South migrations should be self-contained and shouldn't depend on anything but themselves, +# but 0004 is an unfortunate exception +def supports_full_text_search_migr0004(): + global SUPPORTS_FTS + if SUPPORTS_FTS is None: + cursor = connection.cursor() + cursor.execute("SHOW CREATE TABLE question") # In migration 0004 model forum.Question used db table `question` + data = cursor.fetchone() + if 'ENGINE=MyISAM' in data[1]: + SUPPORTS_FTS = True + else: + SUPPORTS_FTS = False + return SUPPORTS_FTS diff --git a/askbot/views/users.py b/askbot/views/users.py index 60690c11..1c575744 100644 --- a/askbot/views/users.py +++ b/askbot/views/users.py @@ -449,7 +449,8 @@ def user_recent(request, user, context): elif activity.activity_type == const.TYPE_ACTIVITY_COMMENT_QUESTION: cm = activity.content_object - q = cm.content_object + q = cm.parent + assert q.is_question() if not q.deleted: activities.append(Event( time=cm.added_at, @@ -462,7 +463,8 @@ def user_recent(request, user, context): elif activity.activity_type == const.TYPE_ACTIVITY_COMMENT_ANSWER: cm = activity.content_object - ans = cm.content_object + ans = cm.parent + assert ans.is_answer() question = ans.thread._question_post() if not ans.deleted and not question.deleted: activities.append(Event( -- cgit v1.2.3-1-g7c22