+
+
+ 1.6
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/.project b/.project
new file mode 100644
index 0000000..8e56b00
--- /dev/null
+++ b/.project
@@ -0,0 +1,23 @@
+
+
+ osqa
+
+
+
+
+
+ org.eclipse.wst.jsdt.core.javascriptValidator
+
+
+
+
+ org.python.pydev.PyDevBuilder
+
+
+
+
+
+ org.python.pydev.pythonNature
+ org.eclipse.wst.jsdt.core.jsNature
+
+
diff --git a/.pydevproject b/.pydevproject
new file mode 100644
index 0000000..f7f3fd1
--- /dev/null
+++ b/.pydevproject
@@ -0,0 +1,10 @@
+
+
+
+
+Default
+python 2.6
+
+/osqa
+
+
diff --git a/HOW_TO_DEBUG b/HOW_TO_DEBUG
new file mode 100644
index 0000000..ba36198
--- /dev/null
+++ b/HOW_TO_DEBUG
@@ -0,0 +1,39 @@
+1) LOGGING
+Please remember that log files may contain plaintext passwords, etc.
+
+Please do not add print statements - at least do not commit them to git
+because in some environments printing to stdout causes errors
+
+Instead use python logging this way:
+--------------------------------
+#somewere on top of file
+import logging
+
+#anywhere below
+logging.debug('this maybe works')
+logging.error('have big error!')
+#or even
+logging.debug('') #this will add time, line number, function and file record
+#sometimes useful record for call tracing on its own
+#etc - take a look at http://docs.python.org/library/logging.html
+-------------------------------
+
+in OSQA logging is currently set up in settings_local.py.dist
+please update it if you need - in older revs logging strings have less info
+
+messages of interest can be grepped out of the log file by module/file/function name
+e.g. to take out all django_authopenid logs run:
+>grep 'osqa\/django_authopenid' log/django.osqa.log | sed 's/^.*MSG: //'
+in the example above 'sed' call truncates out a long prefix
+and makes output look more meaningful
+
+2) DJANGO DEBUG TOOLBAR
+osqa works with django debug toolbar
+if debugging under apache server, check
+that debug toolbar media is loaded correctly
+if toolbar is enabled but you do not see it, possibly some Alias statement
+in apache config is wrong in your VirtualHost or elsewhere
+
+3) If you discover new debugging techniques, please add here.
+Possible areas to improve - at this point there is no SQL query logging,
+as well as request data and http header.
diff --git a/INSTALL b/INSTALL
new file mode 100644
index 0000000..f70b3ec
--- /dev/null
+++ b/INSTALL
@@ -0,0 +1,314 @@
+CONTENTS
+------------------
+A. PREREQUISITES
+B. INSTALLATION
+ 1. Settings file
+ 2. Database
+ 3. Running OSQA in the development server
+ 4. Installation under Apache/WSGI
+ 5. Full text search
+ 6. Email subscriptions
+ 7. Sitemap
+ 8. Miscellaneous
+C. CONFIGURATION PARAMETERS (settings_local.py)
+D. CUSTOMIZATION
+
+
+A. PREREQUISITES
+-----------------------------------------------
+0. We recommend you to use python-setuptools to install pre-requirement libraries.
+If you haven't installed it, please try to install it first.
+e.g, sudo apt-get install python-setuptools
+
+1. Python2.5/2.6, MySQL, Django v1.0/1.1
+Note: email subscription sender job requires Django 1.1, everything else works with 1.0
+Make sure mysql for python provider has been installed.
+sudo easy_install mysql-python
+
+2. Python-openid v2.2
+http://openidenabled.com/python-openid/
+sudo easy_install python-openid
+
+4. html5lib
+http://code.google.com/p/html5lib/
+Used for HTML sanitizer
+sudo easy_install html5lib
+
+5. Markdown2
+http://code.google.com/p/python-markdown2/
+sudo easy_install markdown2
+
+6. Django Debug Toolbar
+http://github.com/robhudson/django-debug-toolbar/tree/master
+
+7. djangosphinx (optional - for full text questions+answer+tag)
+http://github.com/dcramer/django-sphinx/tree/master/djangosphinx
+
+8. sphinx search engine (optional, works together with djangosphinx)
+http://sphinxsearch.com/downloads.html
+
+9. recaptcha_django
+http://code.google.com/p/recaptcha-django/
+
+10. python recaptcha module
+http://code.google.com/p/recaptcha/
+Notice that you will need to register with recaptcha.net and receive
+recaptcha public and private keys that need to be saved in your
+settings_local.py file
+
+NOTES: django_authopenid is included into OSQA code
+and is significantly modified. http://code.google.com/p/django-authopenid/
+no need to install this library
+
+B. INSTALLATION
+-----------------------------------------------
+0. Make sure you have all above python libraries installed.
+
+ make osqa installation server-readable on Linux command might be:
+ chown -R yourlogin:apache /path/to/OSQA
+
+ directories templates/upfiles and log must be server writable
+
+ on Linux type chmod
+ chmod -R g+w /path/to/OSQA/upfiles
+ chmod -R g+w /path/to/log
+
+ above it is assumed that webserver runs under group named "apache"
+
+1. Settings file
+
+Copy settings_local.py.dist to settings_local.py and
+update all your settings. Check settings.py and update
+it as well if necessory.
+Section C explains configuration paramaters.
+
+2. Database
+
+Prepare your database by using the same database/account
+configuration from above.
+e.g,
+create database osqa DEFAULT CHARACTER SET UTF8 COLLATE utf8_general_ci;
+grant all on osqa.* to 'osqa'@'localhost';
+And then run "python manage.py syncdb" to synchronize your database.
+
+3. Running OSQA on the development server
+
+Run "python manage.py runserver" to startup django
+development environment.
+(Under Linux you can use command "python manage.py runserver `hostname -i`:8000",
+where you can use any other available number for the port)
+
+you might want to have DEBUG=True in the beginning of settings.py
+when using the test server
+
+4. Installation under Apache/WSGI
+
+4.1 Prepare wsgi script
+
+Make a file readable by your webserver with the following content:
+
+---------
+import os
+import sys
+
+sys.path.insert(0,'/one/level/above') #insert to make sure that forum will be found
+sys.path.append('/one/level/above/OSQA') #maybe this is not necessary
+os.environ['DJANGO_SETTINGS_MODULE'] = 'OSQA.settings'
+import django.core.handlers.wsgi
+application = django.core.handlers.wsgi.WSGIHandler()
+-----------
+
+insert method is used for path because if the forum directory name
+is by accident the same as some other python module
+you wull see strange errors - forum won't be found even though
+it's in the python path. for example using name "test" is
+not a good idea - as there is a module with such name
+
+
+4.2 Configure webserver
+Settings below are not perfect but may be a good starting point
+
+---------
+WSGISocketPrefix /path/to/socket/sock #must be readable and writable by apache
+WSGIPythonHome /usr/local #must be readable by apache
+WSGIPythonEggs /var/python/eggs #must be readable and writable by apache
+
+#NOTE: all urs below will need to be adjusted if
+#settings.FORUM_SCRIPT_ALIAS !='' (e.g. = 'forum/')
+#this allows "rooting" forum at http://example.com/forum, if you like
+
+ ServerAdmin forum@example.com
+ DocumentRoot /path/to/osqa-site
+ ServerName example.com
+
+ #run mod_wsgi process for django in daemon mode
+ #this allows avoiding confused timezone settings when
+ #another application runs in the same virtual host
+ WSGIDaemonProcess OSQA
+ WSGIProcessGroup OSQA
+
+ #force all content to be served as static files
+ #otherwise django will be crunching images through itself wasting time
+ Alias /m/ /path/to/osqa-site/forum/skins/
+ Alias /upfiles/ /path/to/osqa-site/forum/upfiles/
+
+ Order deny,allow
+ Allow from all
+
+
+ #this is your wsgi script described in the prev section
+ WSGIScriptAlias / /path/to/osqa-site/osqa.wsgi
+
+ #this will force admin interface to work only
+ #through https (optional)
+ #"nimda" is the secret spelling of "admin" ;)
+
+ RewriteEngine on
+ RewriteRule /nimda(.*)$ https://example.com/nimda$1 [L,R=301]
+
+ CustomLog /var/log/httpd/OSQA/access_log common
+ ErrorLog /var/log/httpd/OSQA/error_log
+
+#(optional) run admin interface under https
+
+ ServerAdmin forum@example.com
+ DocumentRoot /path/to/osqa-site
+ ServerName example.com
+ SSLEngine on
+ SSLCertificateFile /path/to/ssl-certificate/server.crt
+ SSLCertificateKeyFile /path/to/ssl-certificate/server.key
+ WSGIScriptAlias / /path/to/osqa-site/osqa.wsgi
+ CustomLog /var/log/httpd/OSQA/access_log common
+ ErrorLog /var/log/httpd/OSQA/error_log
+ DirectoryIndex index.html
+
+-------------
+
+5. Full text search (using sphinx search)
+
+ Currently full text search works only with sphinx search engine
+ And builtin PostgreSQL (postgres only >= 8.3???)
+
+ 5.1 Instructions for Sphinx search setup
+ Sphinx at this time supports only MySQL and PostgreSQL databases
+ to enable this, install sphinx search engine and djangosphinx
+
+ configure sphinx, sample configuration can be found in
+ sphinx/sphinx.conf file usually goes somewhere in /etc tree
+
+ build osqa index first time manually
+
+ % indexer --config /path/to/sphinx.conf --index osqa
+
+ setup cron job to rebuild index periodically with command
+ your crontab entry may be something like
+
+ 0 9,15,21 * * * /usr/local/bin/indexer --config /etc/sphinx/sphinx.conf --all --rotate >/dev/null 2>&1
+ adjust it as necessary this one will reindex three times a day at 9am 3pm and 9pm
+
+ if your forum grows very big ( good luck with that :) you'll
+ need to two search indices one diff index and one main
+ please refer to online sphinx search documentation for the information
+ on the subject http://sphinxsearch.com/docs/
+
+ in settings_local.py set
+ USE_SPHINX_SEARCH=True
+ adjust other settings that have SPHINX_* prefix accordingly
+ remember that there must be trailing comma in parentheses for
+ SHPINX_SEARCH_INDICES tuple - particlarly with just one item!
+
+ in settings.py look for INSTALLED_APPS
+ and uncomment #'djangosphinx',
+
+
+6. Email subscriptions
+
+ This function at the moment requires Django 1.1
+
+ edit paths in the file cron/send_email_alerts
+ set up a cron job to call cron/send_email_alerts once or twice a day
+ subscription sender may be tested manually in shell
+ by calling cron/send_email_alerts
+
+7. Sitemap
+Sitemap will be available at /sitemap.xml
+e.g yoursite.com/forum/sitemap.xml
+
+google will be pinged each time question, answer or
+comment is saved or a question deleted
+
+for this to be useful - do register you sitemap with Google at
+https://www.google.com/webmasters/tools/
+
+8. Miscellaneous
+
+There are some demo scripts under sql_scripts folder,
+including badges and test accounts for CNProg.com. You
+don't need them to run your sample.
+
+C. CONFIGURATION PARAMETERS
+
+#the only parameter that needs to be touched in settings.py is
+DEBUG=False #set to True to enable debug mode
+
+#all forum parameters are set in file settings_local.py
+
+LOG_FILENAME = 'osqa.log' #where logging messages should go
+DATABASE_NAME = 'osqa' # Or path to database file if using sqlite3.
+DATABASE_USER = '' # Not used with sqlite3.
+DATABASE_PASSWORD = '' # Not used with sqlite3.
+DATABASE_ENGINE = 'mysql' #mysql, etc
+SERVER_EMAIL = ''
+DEFAULT_FROM_EMAIL = ''
+EMAIL_HOST_USER = ''
+EMAIL_HOST_PASSWORD = '' #not necessary if mailserver is run on local machine
+EMAIL_SUBJECT_PREFIX = '[OSQA] '
+EMAIL_HOST='osqa.com'
+EMAIL_PORT='25'
+EMAIL_USE_TLS=False
+TIME_ZONE = 'America/Tijuana'
+APP_TITLE = u'OSQA Q&A Forum' #title of your forum
+APP_KEYWORDS = u'OSQA,forum,community' #keywords for search engines
+APP_DESCRIPTION = u'Ask and answer questions.' #site description for searche engines
+APP_INTRO = u'
'
+ if num_moot > 0:
+ text += ''
+ text += ungettext('There is also one question which was recently '\
+ +'updated but you might not have seen its latest version.',
+ 'There are also %(num)d more questions which were recently updated '\
+ +'but you might not have seen their latest version.',num_moot) \
+ % {'num':num_moot,}
+ text += _('Perhaps you could look up previously sent forum reminders in your mailbox.')
+ text += '
'
+
+ link = url_prefix + user.get_profile_url() + '?sort=email_subscriptions'
+ text += _('go to %(link)s to change frequency of email updates or %(email)s administrator') \
+ % {'link':link, 'email':settings.ADMINS[0][1]}
+ msg = EmailMessage(subject, text, settings.DEFAULT_FROM_EMAIL, [user.email])
+ msg.content_subtype = 'html'
+ msg.send()
diff --git a/forum/management/commands/subscribe_everyone.py b/forum/management/commands/subscribe_everyone.py
new file mode 100644
index 0000000..c79528f
--- /dev/null
+++ b/forum/management/commands/subscribe_everyone.py
@@ -0,0 +1,32 @@
+from django.core.management.base import NoArgsCommand
+from django.db import connection
+from django.db.models import Q, F
+from forum.models import *
+from django.core.mail import EmailMessage
+from django.utils.translation import ugettext as _
+from django.utils.translation import ungettext
+import datetime
+from django.conf import settings
+
+class Command(NoArgsCommand):
+ def handle_noargs(self,**options):
+ try:
+ try:
+ self.subscribe_everyone()
+ except Exception, e:
+ print e
+ finally:
+ connection.close()
+
+ def subscribe_everyone(self):
+
+ feed_type_info = EmailFeedSetting.FEED_TYPES
+ for user in User.objects.all():
+ for feed_type in feed_type_info:
+ try:
+ feed_setting = EmailFeedSetting.objects.get(subscriber=user,feed_type = feed_type[0])
+ except EmailFeedSetting.DoesNotExist:
+ feed_setting = EmailFeedSetting(subscriber=user,feed_type=feed_type[0])
+ feed_setting.frequency = 'w'
+ feed_setting.reported_at = None
+ feed_setting.save()
diff --git a/forum/middleware/__init__.py b/forum/middleware/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/forum/middleware/anon_user.py b/forum/middleware/anon_user.py
new file mode 100644
index 0000000..866734d
--- /dev/null
+++ b/forum/middleware/anon_user.py
@@ -0,0 +1,35 @@
+from django.http import HttpResponseRedirect
+from forum.utils.forms import get_next_url
+from django.utils.translation import ugettext as _
+from forum.user_messages import create_message, get_and_delete_messages
+from django.conf import settings
+from django.core.urlresolvers import reverse
+import logging
+
+class AnonymousMessageManager(object):
+ def __init__(self,request):
+ self.request = request
+ def create(self,message=''):
+ create_message(self.request,message)
+ def get_and_delete(self):
+ messages = get_and_delete_messages(self.request)
+ return messages
+
+def dummy_deepcopy(*arg):
+ """this is necessary to prevent deepcopy() on anonymous user object
+ that now contains reference to request, which cannot be deepcopied
+ """
+ return None
+
+class ConnectToSessionMessagesMiddleware(object):
+ def process_request(self, request):
+ if not request.user.is_authenticated():
+ request.user.__deepcopy__ = dummy_deepcopy #plug on deepcopy which may be called by django db "driver"
+ request.user.message_set = AnonymousMessageManager(request) #here request is linked to anon user
+ request.user.get_and_delete_messages = request.user.message_set.get_and_delete
+
+ #also set the first greeting one time per session only
+ if 'greeting_set' not in request.session:
+ request.session['greeting_set'] = True
+ msg = _('First time here? Check out the FAQ!') % reverse('faq')
+ request.user.message_set.create(message=msg)
diff --git a/forum/middleware/cancel.py b/forum/middleware/cancel.py
new file mode 100644
index 0000000..15a4371
--- /dev/null
+++ b/forum/middleware/cancel.py
@@ -0,0 +1,15 @@
+from django.http import HttpResponseRedirect
+from forum.utils.forms import get_next_url
+import logging
+class CancelActionMiddleware(object):
+ def process_view(self, request, view_func, view_args, view_kwargs):
+ if 'cancel' in request.REQUEST:
+ #todo use session messages for the anonymous users
+ try:
+ msg = getattr(view_func,'CANCEL_MESSAGE')
+ except AttributeError:
+ msg = 'action canceled'
+ request.user.message_set.create(message=msg)
+ return HttpResponseRedirect(get_next_url(request))
+ else:
+ return None
diff --git a/forum/middleware/pagesize.py b/forum/middleware/pagesize.py
new file mode 100644
index 0000000..f6e6fcf
--- /dev/null
+++ b/forum/middleware/pagesize.py
@@ -0,0 +1,33 @@
+# used in questions
+QUESTIONS_PAGE_SIZE = 10
+class QuestionsPageSizeMiddleware(object):
+ def process_request(self, request):
+ # Set flag to False by default. If it is equal to True, then need to be saved.
+ pagesize_changed = False
+ # get pagesize from session, if failed then get default value
+ user_page_size = request.session.get("pagesize", QUESTIONS_PAGE_SIZE)
+ # set pagesize equal to logon user specified value in database
+ if request.user.is_authenticated() and request.user.questions_per_page > 0:
+ user_page_size = request.user.questions_per_page
+
+ try:
+ # get new pagesize from UI selection
+ pagesize = int(request.GET.get('pagesize', user_page_size))
+ if pagesize <> user_page_size:
+ pagesize_changed = True
+
+ except ValueError:
+ pagesize = user_page_size
+
+ # save this pagesize to user database
+ if pagesize_changed:
+ if request.user.is_authenticated():
+ user = request.user
+ user.questions_per_page = pagesize
+ user.save()
+ # put pagesize into session
+ request.session["pagesize"] = pagesize
+
+ def process_exception(self,request,exception):
+ import logging
+ logging.debug('have exception %s' % str(exception))
diff --git a/forum/models/__init__.py b/forum/models/__init__.py
new file mode 100755
index 0000000..12a0239
--- /dev/null
+++ b/forum/models/__init__.py
@@ -0,0 +1,343 @@
+from question import Question ,QuestionRevision, QuestionView, AnonymousQuestion, FavoriteQuestion
+from answer import Answer, AnonymousAnswer, AnswerRevision
+from tag import Tag, MarkedTag
+from meta import Vote, Comment, FlaggedItem
+from user import Activity, AnonymousEmail, EmailFeedSetting, AuthKeyUserAssociation
+from repute import Badge, Award, Repute
+
+from base import *
+
+# User extend properties
+QUESTIONS_PER_PAGE_CHOICES = (
+ (10, u'10'),
+ (30, u'30'),
+ (50, u'50'),
+)
+
+def user_is_username_taken(cls,username):
+ try:
+ cls.objects.get(username=username)
+ return True
+ except cls.MultipleObjectsReturned:
+ return True
+ except cls.DoesNotExist:
+ return False
+
+def user_get_q_sel_email_feed_frequency(self):
+ #print 'looking for frequency for user %s' % self
+ try:
+ feed_setting = EmailFeedSetting.objects.get(subscriber=self,feed_type='q_sel')
+ except Exception, e:
+ #print 'have error %s' % e.message
+ raise e
+ #print 'have freq=%s' % feed_setting.frequency
+ return feed_setting.frequency
+
+User.add_to_class('is_approved', models.BooleanField(default=False))
+User.add_to_class('email_isvalid', models.BooleanField(default=False))
+User.add_to_class('email_key', models.CharField(max_length=32, null=True))
+User.add_to_class('reputation', models.PositiveIntegerField(default=1))
+User.add_to_class('gravatar', models.CharField(max_length=32))
+
+#User.add_to_class('favorite_questions',
+# models.ManyToManyField(Question, through=FavoriteQuestion,
+# related_name='favorited_by'))
+
+#User.add_to_class('badges', models.ManyToManyField(Badge, through=Award,
+# related_name='awarded_to'))
+User.add_to_class('gold', models.SmallIntegerField(default=0))
+User.add_to_class('silver', models.SmallIntegerField(default=0))
+User.add_to_class('bronze', models.SmallIntegerField(default=0))
+User.add_to_class('questions_per_page',
+ models.SmallIntegerField(choices=QUESTIONS_PER_PAGE_CHOICES, default=10))
+User.add_to_class('last_seen',
+ models.DateTimeField(default=datetime.datetime.now))
+User.add_to_class('real_name', models.CharField(max_length=100, blank=True))
+User.add_to_class('website', models.URLField(max_length=200, blank=True))
+User.add_to_class('location', models.CharField(max_length=100, blank=True))
+User.add_to_class('date_of_birth', models.DateField(null=True, blank=True))
+User.add_to_class('about', models.TextField(blank=True))
+User.add_to_class('is_username_taken',classmethod(user_is_username_taken))
+User.add_to_class('get_q_sel_email_feed_frequency',user_get_q_sel_email_feed_frequency)
+User.add_to_class('hide_ignored_questions', models.BooleanField(default=False))
+User.add_to_class('tag_filter_setting',
+ models.CharField(
+ max_length=16,
+ choices=TAG_EMAIL_FILTER_CHOICES,
+ default='ignored'
+ )
+ )
+
+# custom signal
+tags_updated = django.dispatch.Signal(providing_args=["question"])
+edit_question_or_answer = django.dispatch.Signal(providing_args=["instance", "modified_by"])
+delete_post_or_answer = django.dispatch.Signal(providing_args=["instance", "deleted_by"])
+mark_offensive = django.dispatch.Signal(providing_args=["instance", "mark_by"])
+user_updated = django.dispatch.Signal(providing_args=["instance", "updated_by"])
+user_logged_in = django.dispatch.Signal(providing_args=["session"])
+
+
+def get_messages(self):
+ messages = []
+ for m in self.message_set.all():
+ messages.append(m.message)
+ return messages
+
+def delete_messages(self):
+ self.message_set.all().delete()
+
+def get_profile_url(self):
+ """Returns the URL for this User's profile."""
+ return '%s%s/' % (reverse('user', args=[self.id]), slugify(self.username))
+
+def get_profile_link(self):
+ profile_link = u'%s' % (self.get_profile_url(),self.username)
+ logging.debug('in get profile link %s' % profile_link)
+ return mark_safe(profile_link)
+
+User.add_to_class('get_profile_url', get_profile_url)
+User.add_to_class('get_profile_link', get_profile_link)
+User.add_to_class('get_messages', get_messages)
+User.add_to_class('delete_messages', delete_messages)
+
+def calculate_gravatar_hash(instance, **kwargs):
+ """Calculates a User's gravatar hash from their email address."""
+ if kwargs.get('raw', False):
+ return
+ instance.gravatar = hashlib.md5(instance.email).hexdigest()
+
+def record_ask_event(instance, created, **kwargs):
+ if created:
+ activity = Activity(user=instance.author, active_at=instance.added_at, content_object=instance, activity_type=TYPE_ACTIVITY_ASK_QUESTION)
+ activity.save()
+
+def record_answer_event(instance, created, **kwargs):
+ if created:
+ activity = Activity(user=instance.author, active_at=instance.added_at, content_object=instance, activity_type=TYPE_ACTIVITY_ANSWER)
+ activity.save()
+
+def record_comment_event(instance, created, **kwargs):
+ if created:
+ from django.contrib.contenttypes.models import ContentType
+ question_type = ContentType.objects.get_for_model(Question)
+ question_type_id = question_type.id
+ if (instance.content_type_id == question_type_id):
+ type = TYPE_ACTIVITY_COMMENT_QUESTION
+ else:
+ type = TYPE_ACTIVITY_COMMENT_ANSWER
+ activity = Activity(user=instance.user, active_at=instance.added_at, content_object=instance, activity_type=type)
+ activity.save()
+
+def record_revision_question_event(instance, created, **kwargs):
+ if created and instance.revision <> 1:
+ activity = Activity(user=instance.author, active_at=instance.revised_at, content_object=instance, activity_type=TYPE_ACTIVITY_UPDATE_QUESTION)
+ activity.save()
+
+def record_revision_answer_event(instance, created, **kwargs):
+ if created and instance.revision <> 1:
+ activity = Activity(user=instance.author, active_at=instance.revised_at, content_object=instance, activity_type=TYPE_ACTIVITY_UPDATE_ANSWER)
+ activity.save()
+
+def record_award_event(instance, created, **kwargs):
+ """
+ After we awarded a badge to user, we need to record this activity and notify user.
+ We also recaculate awarded_count of this badge and user information.
+ """
+ if created:
+ activity = Activity(user=instance.user, active_at=instance.awarded_at, content_object=instance,
+ activity_type=TYPE_ACTIVITY_PRIZE)
+ activity.save()
+
+ instance.badge.awarded_count += 1
+ instance.badge.save()
+
+ if instance.badge.type == Badge.GOLD:
+ instance.user.gold += 1
+ if instance.badge.type == Badge.SILVER:
+ instance.user.silver += 1
+ if instance.badge.type == Badge.BRONZE:
+ instance.user.bronze += 1
+ instance.user.save()
+
+def notify_award_message(instance, created, **kwargs):
+ """
+ Notify users when they have been awarded badges by using Django message.
+ """
+ if created:
+ user = instance.user
+ user.message_set.create(message=u"Congratulations, you have received a badge '%s'" % instance.badge.name)
+
+def record_answer_accepted(instance, created, **kwargs):
+ """
+ when answer is accepted, we record this for question author - who accepted it.
+ """
+ if not created and instance.accepted:
+ activity = Activity(user=instance.question.author, active_at=datetime.datetime.now(), \
+ content_object=instance, activity_type=TYPE_ACTIVITY_MARK_ANSWER)
+ activity.save()
+
+def update_last_seen(instance, created, **kwargs):
+ """
+ when user has activities, we update 'last_seen' time stamp for him
+ """
+ user = instance.user
+ user.last_seen = datetime.datetime.now()
+ user.save()
+
+def record_vote(instance, created, **kwargs):
+ """
+ when user have voted
+ """
+ if created:
+ if instance.vote == 1:
+ vote_type = TYPE_ACTIVITY_VOTE_UP
+ else:
+ vote_type = TYPE_ACTIVITY_VOTE_DOWN
+
+ activity = Activity(user=instance.user, active_at=instance.voted_at, content_object=instance, activity_type=vote_type)
+ activity.save()
+
+def record_cancel_vote(instance, **kwargs):
+ """
+ when user canceled vote, the vote will be deleted.
+ """
+ activity = Activity(user=instance.user, active_at=datetime.datetime.now(), content_object=instance, activity_type=TYPE_ACTIVITY_CANCEL_VOTE)
+ activity.save()
+
+def record_delete_question(instance, delete_by, **kwargs):
+ """
+ when user deleted the question
+ """
+ if instance.__class__ == "Question":
+ activity_type = TYPE_ACTIVITY_DELETE_QUESTION
+ else:
+ activity_type = TYPE_ACTIVITY_DELETE_ANSWER
+
+ activity = Activity(user=delete_by, active_at=datetime.datetime.now(), content_object=instance, activity_type=activity_type)
+ activity.save()
+
+def record_mark_offensive(instance, mark_by, **kwargs):
+ activity = Activity(user=mark_by, active_at=datetime.datetime.now(), content_object=instance, activity_type=TYPE_ACTIVITY_MARK_OFFENSIVE)
+ activity.save()
+
+def record_update_tags(question, **kwargs):
+ """
+ when user updated tags of the question
+ """
+ activity = Activity(user=question.author, active_at=datetime.datetime.now(), content_object=question, activity_type=TYPE_ACTIVITY_UPDATE_TAGS)
+ activity.save()
+
+def record_favorite_question(instance, created, **kwargs):
+ """
+ when user add the question in him favorite questions list.
+ """
+ if created:
+ activity = Activity(user=instance.user, active_at=datetime.datetime.now(), content_object=instance, activity_type=TYPE_ACTIVITY_FAVORITE)
+ activity.save()
+
+def record_user_full_updated(instance, **kwargs):
+ activity = Activity(user=instance, active_at=datetime.datetime.now(), content_object=instance, activity_type=TYPE_ACTIVITY_USER_FULL_UPDATED)
+ activity.save()
+
+def post_stored_anonymous_content(sender,user,session_key,signal,*args,**kwargs):
+ aq_list = AnonymousQuestion.objects.filter(session_key = session_key)
+ aa_list = AnonymousAnswer.objects.filter(session_key = session_key)
+ import settings
+ if settings.EMAIL_VALIDATION == 'on':#add user to the record
+ for aq in aq_list:
+ aq.author = user
+ aq.save()
+ for aa in aa_list:
+ aa.author = user
+ aa.save()
+ #maybe add pending posts message?
+ else: #just publish the questions
+ for aq in aq_list:
+ aq.publish(user)
+ for aa in aa_list:
+ aa.publish(user)
+
+#signal for User modle save changes
+
+pre_save.connect(calculate_gravatar_hash, sender=User)
+post_save.connect(record_ask_event, sender=Question)
+post_save.connect(record_answer_event, sender=Answer)
+post_save.connect(record_comment_event, sender=Comment)
+post_save.connect(record_revision_question_event, sender=QuestionRevision)
+post_save.connect(record_revision_answer_event, sender=AnswerRevision)
+post_save.connect(record_award_event, sender=Award)
+post_save.connect(notify_award_message, sender=Award)
+post_save.connect(record_answer_accepted, sender=Answer)
+post_save.connect(update_last_seen, sender=Activity)
+post_save.connect(record_vote, sender=Vote)
+post_delete.connect(record_cancel_vote, sender=Vote)
+delete_post_or_answer.connect(record_delete_question, sender=Question)
+delete_post_or_answer.connect(record_delete_question, sender=Answer)
+mark_offensive.connect(record_mark_offensive, sender=Question)
+mark_offensive.connect(record_mark_offensive, sender=Answer)
+tags_updated.connect(record_update_tags, sender=Question)
+post_save.connect(record_favorite_question, sender=FavoriteQuestion)
+user_updated.connect(record_user_full_updated, sender=User)
+user_logged_in.connect(post_stored_anonymous_content)
+
+Question = Question
+QuestionRevision = QuestionRevision
+QuestionView = QuestionView
+FavoriteQuestion = FavoriteQuestion
+AnonymousQuestion = AnonymousQuestion
+
+Answer = Answer
+AnswerRevision = AnswerRevision
+AnonymousAnswer = AnonymousAnswer
+
+Tag = Tag
+Comment = Comment
+Vote = Vote
+FlaggedItem = FlaggedItem
+MarkedTag = MarkedTag
+
+Badge = Badge
+Award = Award
+Repute = Repute
+
+Activity = Activity
+EmailFeedSetting = EmailFeedSetting
+AnonymousEmail = AnonymousEmail
+AuthKeyUserAssociation = AuthKeyUserAssociation
+
+__all__ = [
+ 'Question',
+ 'QuestionRevision',
+ 'QuestionView',
+ 'FavoriteQuestion',
+ 'AnonymousQuestion',
+
+ 'Answer',
+ 'AnswerRevision',
+ 'AnonymousAnswer',
+
+ 'Tag',
+ 'Comment',
+ 'Vote',
+ 'FlaggedItem',
+ 'MarkedTag',
+
+ 'Badge',
+ 'Award',
+ 'Repute',
+
+ 'Activity',
+ 'EmailFeedSetting',
+ 'AnonymousEmail',
+ 'AuthKeyUserAssociation',
+
+ 'User'
+ ]
+
+
+from forum.modules import get_modules_script_classes
+
+for k, v in get_modules_script_classes('models', models.Model).items():
+ if not k in __all__:
+ __all__.append(k)
+ exec "%s = v" % k
\ No newline at end of file
diff --git a/forum/models/answer.py b/forum/models/answer.py
new file mode 100755
index 0000000..14199de
--- /dev/null
+++ b/forum/models/answer.py
@@ -0,0 +1,134 @@
+from base import *
+
+from question import Question
+
+class AnswerManager(models.Manager):
+ @staticmethod
+ def create_new(cls, question=None, author=None, added_at=None, wiki=False, text='', email_notify=False):
+ answer = Answer(
+ question = question,
+ author = author,
+ added_at = added_at,
+ wiki = wiki,
+ html = text
+ )
+ if answer.wiki:
+ answer.last_edited_by = answer.author
+ answer.last_edited_at = added_at
+ answer.wikified_at = added_at
+
+ answer.save()
+
+ #update question data
+ question.last_activity_at = added_at
+ question.last_activity_by = author
+ question.save()
+ Question.objects.update_answer_count(question)
+
+ AnswerRevision.objects.create(
+ answer = answer,
+ revision = 1,
+ author = author,
+ revised_at = added_at,
+ summary = CONST['default_version'],
+ text = text
+ )
+
+ #set notification/delete
+ if email_notify:
+ if author not in question.followed_by.all():
+ question.followed_by.add(author)
+ else:
+ #not sure if this is necessary. ajax should take care of this...
+ try:
+ question.followed_by.remove(author)
+ except:
+ pass
+
+ #GET_ANSWERS_FROM_USER_QUESTIONS = u'SELECT answer.* FROM answer INNER JOIN question ON answer.question_id = question.id WHERE question.author_id =%s AND answer.author_id <> %s'
+ def get_answers_from_question(self, question, user=None):
+ """
+ Retrieves visibile answers for the given question. Delete answers
+ are only visibile to the person who deleted them.
+ """
+
+ if user is None or not user.is_authenticated():
+ return self.filter(question=question, deleted=False)
+ else:
+ return self.filter(models.Q(question=question),
+ models.Q(deleted=False) | models.Q(deleted_by=user))
+
+ #todo: I think this method is not being used anymore, I'll just comment it for now
+ #def get_answers_from_questions(self, user_id):
+ # """
+ # Retrieves visibile answers for the given question. Which are not included own answers
+ # """
+ # cursor = connection.cursor()
+ # cursor.execute(self.GET_ANSWERS_FROM_USER_QUESTIONS, [user_id, user_id])
+ # return cursor.fetchall()
+
+class Answer(Content, DeletableContent):
+ question = models.ForeignKey('Question', related_name='answers')
+ accepted = models.BooleanField(default=False)
+ accepted_at = models.DateTimeField(null=True, blank=True)
+
+ objects = AnswerManager()
+
+ class Meta(Content.Meta):
+ db_table = u'answer'
+
+ def get_user_vote(self, user):
+ if user.__class__.__name__ == "AnonymousUser":
+ return None
+
+ votes = self.votes.filter(user=user)
+ if votes and votes.count() > 0:
+ return votes[0]
+ else:
+ return None
+
+ def get_latest_revision(self):
+ return self.revisions.all()[0]
+
+ def get_question_title(self):
+ return self.question.title
+
+ def get_absolute_url(self):
+ return '%s%s#%s' % (reverse('question', args=[self.question.id]), django_urlquote(slugify(self.question.title)), self.id)
+
+ def __unicode__(self):
+ return self.html
+
+
+class AnswerRevision(ContentRevision):
+ """A revision of an Answer."""
+ answer = models.ForeignKey('Answer', related_name='revisions')
+
+ def get_absolute_url(self):
+ return reverse('answer_revisions', kwargs={'id':self.answer.id})
+
+ def get_question_title(self):
+ return self.answer.question.title
+
+ class Meta(ContentRevision.Meta):
+ db_table = u'answer_revision'
+ ordering = ('-revision',)
+
+ def save(self, **kwargs):
+ """Looks up the next available revision number if not set."""
+ if not self.revision:
+ self.revision = AnswerRevision.objects.filter(
+ answer=self.answer).values_list('revision',
+ flat=True)[0] + 1
+ super(AnswerRevision, self).save(**kwargs)
+
+class AnonymousAnswer(AnonymousContent):
+ question = models.ForeignKey('Question', related_name='anonymous_answers')
+
+ def publish(self,user):
+ added_at = datetime.datetime.now()
+ #print user.id
+ AnswerManager.create_new(question=self.question,wiki=self.wiki,
+ added_at=added_at,text=self.text,
+ author=user)
+ self.delete()
diff --git a/forum/models/base.py b/forum/models/base.py
new file mode 100755
index 0000000..2c28a47
--- /dev/null
+++ b/forum/models/base.py
@@ -0,0 +1,139 @@
+import datetime
+import hashlib
+from urllib import quote_plus, urlencode
+from django.db import models, IntegrityError, connection, transaction
+from django.utils.http import urlquote as django_urlquote
+from django.utils.html import strip_tags
+from django.core.urlresolvers import reverse
+from django.contrib.auth.models import User
+from django.contrib.contenttypes import generic
+from django.contrib.contenttypes.models import ContentType
+from django.template.defaultfilters import slugify
+from django.db.models.signals import post_delete, post_save, pre_save
+from django.utils.translation import ugettext as _
+from django.utils.safestring import mark_safe
+from django.contrib.sitemaps import ping_google
+import django.dispatch
+from django.conf import settings
+import logging
+
+if settings.USE_SPHINX_SEARCH == True:
+ from djangosphinx.models import SphinxSearch
+
+from forum.const import *
+
+class MetaContent(models.Model):
+ """
+ Base class for Vote, Comment and FlaggedItem
+ """
+ content_type = models.ForeignKey(ContentType)
+ object_id = models.PositiveIntegerField()
+ content_object = generic.GenericForeignKey('content_type', 'object_id')
+ user = models.ForeignKey(User, related_name='%(class)ss')
+
+ class Meta:
+ abstract = True
+ app_label = 'forum'
+
+
+class DeletableContent(models.Model):
+ deleted = models.BooleanField(default=False)
+ deleted_at = models.DateTimeField(null=True, blank=True)
+ deleted_by = models.ForeignKey(User, null=True, blank=True, related_name='deleted_%(class)ss')
+
+ class Meta:
+ abstract = True
+ app_label = 'forum'
+
+
+class ContentRevision(models.Model):
+ """
+ Base class for QuestionRevision and AnswerRevision
+ """
+ revision = models.PositiveIntegerField()
+ author = models.ForeignKey(User, related_name='%(class)ss')
+ revised_at = models.DateTimeField()
+ summary = models.CharField(max_length=300, blank=True)
+ text = models.TextField()
+
+ class Meta:
+ abstract = True
+ app_label = 'forum'
+
+
+class AnonymousContent(models.Model):
+ """
+ Base class for AnonymousQuestion and AnonymousAnswer
+ """
+ session_key = models.CharField(max_length=40) #session id for anonymous questions
+ wiki = models.BooleanField(default=False)
+ added_at = models.DateTimeField(default=datetime.datetime.now)
+ ip_addr = models.IPAddressField(max_length=21) #allow high port numbers
+ author = models.ForeignKey(User,null=True)
+ text = models.TextField()
+ summary = models.CharField(max_length=180)
+
+ class Meta:
+ abstract = True
+ app_label = 'forum'
+
+
+from meta import Comment, Vote, FlaggedItem
+
+class Content(models.Model):
+ """
+ Base class for Question and Answer
+ """
+ author = models.ForeignKey(User, related_name='%(class)ss')
+ added_at = models.DateTimeField(default=datetime.datetime.now)
+
+ wiki = models.BooleanField(default=False)
+ wikified_at = models.DateTimeField(null=True, blank=True)
+
+ locked = models.BooleanField(default=False)
+ locked_by = models.ForeignKey(User, null=True, blank=True, related_name='locked_%(class)ss')
+ locked_at = models.DateTimeField(null=True, blank=True)
+
+ score = models.IntegerField(default=0)
+ vote_up_count = models.IntegerField(default=0)
+ vote_down_count = models.IntegerField(default=0)
+
+ comment_count = models.PositiveIntegerField(default=0)
+ offensive_flag_count = models.SmallIntegerField(default=0)
+
+ last_edited_at = models.DateTimeField(null=True, blank=True)
+ last_edited_by = models.ForeignKey(User, null=True, blank=True, related_name='last_edited_%(class)ss')
+
+ html = models.TextField()
+ comments = generic.GenericRelation(Comment)
+ votes = generic.GenericRelation(Vote)
+ flagged_items = generic.GenericRelation(FlaggedItem)
+
+ class Meta:
+ abstract = True
+ app_label = 'forum'
+
+ def save(self,**kwargs):
+ super(Content,self).save(**kwargs)
+ try:
+ ping_google()
+ except Exception:
+ logging.debug('problem pinging google did you register you sitemap with google?')
+
+ def get_object_comments(self):
+ comments = self.comments.all().order_by('id')
+ return comments
+
+ def post_get_last_update_info(self):
+ when = self.added_at
+ who = self.author
+ if self.last_edited_at and self.last_edited_at > when:
+ when = self.last_edited_at
+ who = self.last_edited_by
+ comments = self.comments.all()
+ if len(comments) > 0:
+ for c in comments:
+ if c.added_at > when:
+ when = c.added_at
+ who = c.user
+ return when, who
\ No newline at end of file
diff --git a/forum/models/meta.py b/forum/models/meta.py
new file mode 100755
index 0000000..3dfd3e8
--- /dev/null
+++ b/forum/models/meta.py
@@ -0,0 +1,89 @@
+from base import *
+
+class VoteManager(models.Manager):
+ def get_up_vote_count_from_user(self, user):
+ if user is not None:
+ return self.filter(user=user, vote=1).count()
+ else:
+ return 0
+
+ def get_down_vote_count_from_user(self, user):
+ if user is not None:
+ return self.filter(user=user, vote=-1).count()
+ else:
+ return 0
+
+ def get_votes_count_today_from_user(self, user):
+ if user is not None:
+ today = datetime.date.today()
+ return self.filter(user=user, voted_at__range=(today, today + datetime.timedelta(1))).count()
+
+ else:
+ return 0
+
+
+class Vote(MetaContent):
+ VOTE_UP = +1
+ VOTE_DOWN = -1
+ VOTE_CHOICES = (
+ (VOTE_UP, u'Up'),
+ (VOTE_DOWN, u'Down'),
+ )
+
+ vote = models.SmallIntegerField(choices=VOTE_CHOICES)
+ voted_at = models.DateTimeField(default=datetime.datetime.now)
+
+ objects = VoteManager()
+
+ class Meta(MetaContent.Meta):
+ unique_together = ('content_type', 'object_id', 'user')
+ db_table = u'vote'
+
+ def __unicode__(self):
+ return '[%s] voted at %s: %s' %(self.user, self.voted_at, self.vote)
+
+ def is_upvote(self):
+ return self.vote == self.VOTE_UP
+
+ def is_downvote(self):
+ return self.vote == self.VOTE_DOWN
+
+
+class FlaggedItemManager(models.Manager):
+ def get_flagged_items_count_today(self, user):
+ if user is not None:
+ today = datetime.date.today()
+ return self.filter(user=user, flagged_at__range=(today, today + datetime.timedelta(1))).count()
+ else:
+ return 0
+
+class FlaggedItem(MetaContent):
+ """A flag on a Question or Answer indicating offensive content."""
+ flagged_at = models.DateTimeField(default=datetime.datetime.now)
+
+ objects = FlaggedItemManager()
+
+ class Meta(MetaContent.Meta):
+ unique_together = ('content_type', 'object_id', 'user')
+ db_table = u'flagged_item'
+
+ def __unicode__(self):
+ return '[%s] flagged at %s' %(self.user, self.flagged_at)
+
+class Comment(MetaContent):
+ comment = models.CharField(max_length=300)
+ added_at = models.DateTimeField(default=datetime.datetime.now)
+
+ class Meta(MetaContent.Meta):
+ ordering = ('-added_at',)
+ db_table = u'comment'
+
+ def save(self,**kwargs):
+ super(Comment,self).save(**kwargs)
+ try:
+ ping_google()
+ except Exception:
+ logging.debug('problem pinging google did you register you sitemap with google?')
+
+ def __unicode__(self):
+ return self.comment
\ No newline at end of file
diff --git a/forum/models/question.py b/forum/models/question.py
new file mode 100755
index 0000000..f916e65
--- /dev/null
+++ b/forum/models/question.py
@@ -0,0 +1,336 @@
+from base import *
+from tag import Tag
+
+class QuestionManager(models.Manager):
+ @staticmethod
+ def create_new(cls, title=None,author=None,added_at=None, wiki=False,tagnames=None,summary=None, text=None):
+ question = Question(
+ title = title,
+ author = author,
+ added_at = added_at,
+ last_activity_at = added_at,
+ last_activity_by = author,
+ wiki = wiki,
+ tagnames = tagnames,
+ html = text,
+ summary = summary
+ )
+ if question.wiki:
+ question.last_edited_by = question.author
+ question.last_edited_at = added_at
+ question.wikified_at = added_at
+
+ question.save()
+
+ # create the first revision
+ QuestionRevision.objects.create(
+ question = question,
+ revision = 1,
+ title = question.title,
+ author = author,
+ revised_at = added_at,
+ tagnames = question.tagnames,
+ summary = CONST['default_version'],
+ text = text
+ )
+ return question
+
+ def update_tags(self, question, tagnames, user):
+ """
+ Updates Tag associations for a question to match the given
+ tagname string.
+
+ Returns ``True`` if tag usage counts were updated as a result,
+ ``False`` otherwise.
+ """
+
+ current_tags = list(question.tags.all())
+ current_tagnames = set(t.name for t in current_tags)
+ updated_tagnames = set(t for t in tagnames.split(' ') if t)
+ modified_tags = []
+
+ removed_tags = [t for t in current_tags
+ if t.name not in updated_tagnames]
+ if removed_tags:
+ modified_tags.extend(removed_tags)
+ question.tags.remove(*removed_tags)
+
+ added_tagnames = updated_tagnames - current_tagnames
+ if added_tagnames:
+ added_tags = Tag.objects.get_or_create_multiple(added_tagnames,
+ user)
+ modified_tags.extend(added_tags)
+ question.tags.add(*added_tags)
+
+ if modified_tags:
+ Tag.objects.update_use_counts(modified_tags)
+ return True
+
+ return False
+
+ def update_answer_count(self, question):
+ """
+ Executes an UPDATE query to update denormalised data with the
+ number of answers the given question has.
+ """
+
+ # for some reasons, this Answer class failed to be imported,
+ # although we have imported all classes from models on top.
+ from answer import Answer
+ self.filter(id=question.id).update(
+ answer_count=Answer.objects.get_answers_from_question(question).filter(deleted=False).count())
+
+ def update_view_count(self, question):
+ """
+ update counter+1 when user browse question page
+ """
+ self.filter(id=question.id).update(view_count = question.view_count + 1)
+
+ def update_favorite_count(self, question):
+ """
+ update favourite_count for given question
+ """
+ self.filter(id=question.id).update(favourite_count = FavoriteQuestion.objects.filter(question=question).count())
+
+ def get_similar_questions(self, question):
+ """
+ Get 10 similar questions for given one.
+ This will search the same tag list for give question(by exactly same string) first.
+ Questions with the individual tags will be added to list if above questions are not full.
+ """
+ #print datetime.datetime.now()
+ questions = list(self.filter(tagnames = question.tagnames, deleted=False).all())
+
+ tags_list = question.tags.all()
+ for tag in tags_list:
+ extend_questions = self.filter(tags__id = tag.id, deleted=False)[:50]
+ for item in extend_questions:
+ if item not in questions and len(questions) < 10:
+ questions.append(item)
+
+ #print datetime.datetime.now()
+ return questions
+
+class Question(Content, DeletableContent):
+ title = models.CharField(max_length=300)
+ tags = models.ManyToManyField('Tag', related_name='questions')
+ answer_accepted = models.BooleanField(default=False)
+ closed = models.BooleanField(default=False)
+ closed_by = models.ForeignKey(User, null=True, blank=True, related_name='closed_questions')
+ closed_at = models.DateTimeField(null=True, blank=True)
+ close_reason = models.SmallIntegerField(choices=CLOSE_REASONS, null=True, blank=True)
+ followed_by = models.ManyToManyField(User, related_name='followed_questions')
+
+ # Denormalised data
+ answer_count = models.PositiveIntegerField(default=0)
+ view_count = models.PositiveIntegerField(default=0)
+ favourite_count = models.PositiveIntegerField(default=0)
+ last_activity_at = models.DateTimeField(default=datetime.datetime.now)
+ last_activity_by = models.ForeignKey(User, related_name='last_active_in_questions')
+ tagnames = models.CharField(max_length=125)
+ summary = models.CharField(max_length=180)
+
+ favorited_by = models.ManyToManyField(User, through='FavoriteQuestion', related_name='favorite_questions')
+
+ objects = QuestionManager()
+
+ class Meta(Content.Meta):
+ db_table = u'question'
+
+ def delete(self):
+ super(Question, self).delete()
+ try:
+ ping_google()
+ except Exception:
+ logging.debug('problem pinging google did you register you sitemap with google?')
+
+ def save(self, **kwargs):
+ """
+ Overridden to manually manage addition of tags when the object
+ is first saved.
+
+ This is required as we're using ``tagnames`` as the sole means of
+ adding and editing tags.
+ """
+ initial_addition = (self.id is None)
+
+ super(Question, self).save(**kwargs)
+
+ if initial_addition:
+ tags = Tag.objects.get_or_create_multiple(self.tagname_list(),
+ self.author)
+ self.tags.add(*tags)
+ Tag.objects.update_use_counts(tags)
+
+ def tagname_list(self):
+ """Creates a list of Tag names from the ``tagnames`` attribute."""
+ return [name for name in self.tagnames.split(u' ')]
+
+ def tagname_meta_generator(self):
+ return u','.join([unicode(tag) for tag in self.tagname_list()])
+
+ def get_absolute_url(self):
+ return '%s%s' % (reverse('question', args=[self.id]), django_urlquote(slugify(self.title)))
+
+ def has_favorite_by_user(self, user):
+ if not user.is_authenticated():
+ return False
+
+ return FavoriteQuestion.objects.filter(question=self, user=user).count() > 0
+
+ def get_answer_count_by_user(self, user_id):
+ from answer import Answer
+ query_set = Answer.objects.filter(author__id=user_id)
+ return query_set.filter(question=self).count()
+
+ def get_question_title(self):
+ if self.closed:
+ attr = CONST['closed']
+ elif self.deleted:
+ attr = CONST['deleted']
+ else:
+ attr = None
+ if attr is not None:
+ return u'%s %s' % (self.title, attr)
+ else:
+ return self.title
+
+ def get_revision_url(self):
+ return reverse('question_revisions', args=[self.id])
+
+ def get_latest_revision(self):
+ return self.revisions.all()[0]
+
+ def get_last_update_info(self):
+ when, who = self.post_get_last_update_info()
+
+ answers = self.answers.all()
+ if len(answers) > 0:
+ for a in answers:
+ a_when, a_who = a.post_get_last_update_info()
+ if a_when > when:
+ when = a_when
+ who = a_who
+
+ return when, who
+
+ def get_update_summary(self,last_reported_at=None,recipient_email=''):
+ edited = False
+ if self.last_edited_at and self.last_edited_at > last_reported_at:
+ if self.last_edited_by.email != recipient_email:
+ edited = True
+ comments = []
+ for comment in self.comments.all():
+ if comment.added_at > last_reported_at and comment.user.email != recipient_email:
+ comments.append(comment)
+ new_answers = []
+ answer_comments = []
+ modified_answers = []
+ commented_answers = []
+ import sets
+ commented_answers = sets.Set([])
+ for answer in self.answers.all():
+ if (answer.added_at > last_reported_at and answer.author.email != recipient_email):
+ new_answers.append(answer)
+ if (answer.last_edited_at
+ and answer.last_edited_at > last_reported_at
+ and answer.last_edited_by.email != recipient_email):
+ modified_answers.append(answer)
+ for comment in answer.comments.all():
+ if comment.added_at > last_reported_at and comment.user.email != recipient_email:
+ commented_answers.add(answer)
+ answer_comments.append(comment)
+
+ #create the report
+ if edited or new_answers or modified_answers or answer_comments:
+ out = []
+ if edited:
+ out.append(_('%(author)s modified the question') % {'author':self.last_edited_by.username})
+ if new_answers:
+ names = sets.Set(map(lambda x: x.author.username,new_answers))
+ people = ', '.join(names)
+ out.append(_('%(people)s posted %(new_answer_count)s new answers') \
+ % {'new_answer_count':len(new_answers),'people':people})
+ if comments:
+ names = sets.Set(map(lambda x: x.user.username,comments))
+ people = ', '.join(names)
+ out.append(_('%(people)s commented the question') % {'people':people})
+ if answer_comments:
+ names = sets.Set(map(lambda x: x.user.username,answer_comments))
+ people = ', '.join(names)
+ if len(commented_answers) > 1:
+ out.append(_('%(people)s commented answers') % {'people':people})
+ else:
+ out.append(_('%(people)s commented an answer') % {'people':people})
+ url = settings.APP_URL + self.get_absolute_url()
+ retval = '%s: \n' % (url,self.title)
+ out = map(lambda x: '
' + x + '
',out)
+ retval += '
' + '\n'.join(out) + '
\n'
+ return retval
+ else:
+ return None
+
+ def __unicode__(self):
+ return self.title
+
+
+class QuestionView(models.Model):
+ question = models.ForeignKey(Question, related_name='viewed')
+ who = models.ForeignKey(User, related_name='question_views')
+ when = models.DateTimeField()
+
+ class Meta:
+ app_label = 'forum'
+
+class FavoriteQuestion(models.Model):
+ """A favorite Question of a User."""
+ question = models.ForeignKey(Question)
+ user = models.ForeignKey(User, related_name='user_favorite_questions')
+ added_at = models.DateTimeField(default=datetime.datetime.now)
+
+ class Meta:
+ app_label = 'forum'
+ db_table = u'favorite_question'
+ def __unicode__(self):
+ return '[%s] favorited at %s' %(self.user, self.added_at)
+
+class QuestionRevision(ContentRevision):
+ """A revision of a Question."""
+ question = models.ForeignKey(Question, related_name='revisions')
+ title = models.CharField(max_length=300)
+ tagnames = models.CharField(max_length=125)
+
+ class Meta(ContentRevision.Meta):
+ db_table = u'question_revision'
+ ordering = ('-revision',)
+
+ def get_question_title(self):
+ return self.question.title
+
+ def get_absolute_url(self):
+ #print 'in QuestionRevision.get_absolute_url()'
+ return reverse('question_revisions', args=[self.question.id])
+
+ def save(self, **kwargs):
+ """Looks up the next available revision number."""
+ if not self.revision:
+ self.revision = QuestionRevision.objects.filter(
+ question=self.question).values_list('revision',
+ flat=True)[0] + 1
+ super(QuestionRevision, self).save(**kwargs)
+
+ def __unicode__(self):
+ return u'revision %s of %s' % (self.revision, self.title)
+
+class AnonymousQuestion(AnonymousContent):
+ title = models.CharField(max_length=300)
+ tagnames = models.CharField(max_length=125)
+
+ def publish(self,user):
+ added_at = datetime.datetime.now()
+ QuestionManager.create_new(title=self.title, author=user, added_at=added_at,
+ wiki=self.wiki, tagnames=self.tagnames,
+ summary=self.summary, text=self.text)
+ self.delete()
+
+from answer import Answer, AnswerManager
diff --git a/forum/models/repute.py b/forum/models/repute.py
new file mode 100755
index 0000000..a47ce47
--- /dev/null
+++ b/forum/models/repute.py
@@ -0,0 +1,109 @@
+from base import *
+
+from django.utils.translation import ugettext as _
+
+class Badge(models.Model):
+ """Awarded for notable actions performed on the site by Users."""
+ GOLD = 1
+ SILVER = 2
+ BRONZE = 3
+ TYPE_CHOICES = (
+ (GOLD, _('gold')),
+ (SILVER, _('silver')),
+ (BRONZE, _('bronze')),
+ )
+
+ name = models.CharField(max_length=50)
+ type = models.SmallIntegerField(choices=TYPE_CHOICES)
+ slug = models.SlugField(max_length=50, blank=True)
+ description = models.CharField(max_length=300)
+ multiple = models.BooleanField(default=False)
+ # Denormalised data
+ awarded_count = models.PositiveIntegerField(default=0)
+
+ awarded_to = models.ManyToManyField(User, through='Award', related_name='badges')
+
+ class Meta:
+ app_label = 'forum'
+ db_table = u'badge'
+ ordering = ('name',)
+ unique_together = ('name', 'type')
+
+ def __unicode__(self):
+ return u'%s: %s' % (self.get_type_display(), self.name)
+
+ def save(self, **kwargs):
+ if not self.slug:
+ self.slug = self.name#slugify(self.name)
+ super(Badge, self).save(**kwargs)
+
+ def get_absolute_url(self):
+ return '%s%s/' % (reverse('badge', args=[self.id]), self.slug)
+
+class AwardManager(models.Manager):
+ def get_recent_awards(self):
+ awards = super(AwardManager, self).extra(
+ select={'badge_id': 'badge.id', 'badge_name':'badge.name',
+ 'badge_description': 'badge.description', 'badge_type': 'badge.type',
+ 'user_id': 'auth_user.id', 'user_name': 'auth_user.username'
+ },
+ tables=['award', 'badge', 'auth_user'],
+ order_by=['-awarded_at'],
+ where=['auth_user.id=award.user_id AND badge_id=badge.id'],
+ ).values('badge_id', 'badge_name', 'badge_description', 'badge_type', 'user_id', 'user_name')
+ return awards
+
+class Award(models.Model):
+ """The awarding of a Badge to a User."""
+ user = models.ForeignKey(User, related_name='award_user')
+ badge = models.ForeignKey('Badge', related_name='award_badge')
+ content_type = models.ForeignKey(ContentType)
+ object_id = models.PositiveIntegerField()
+ content_object = generic.GenericForeignKey('content_type', 'object_id')
+ awarded_at = models.DateTimeField(default=datetime.datetime.now)
+ notified = models.BooleanField(default=False)
+
+ objects = AwardManager()
+
+ def __unicode__(self):
+ return u'[%s] is awarded a badge [%s] at %s' % (self.user.username, self.badge.name, self.awarded_at)
+
+ class Meta:
+ app_label = 'forum'
+ db_table = u'award'
+
+class ReputeManager(models.Manager):
+ def get_reputation_by_upvoted_today(self, user):
+ """
+ For one user in one day, he can only earn rep till certain score (ep. +200)
+ by upvoted(also substracted from upvoted canceled). This is because we need
+ to prohibit gaming system by upvoting/cancel again and again.
+ """
+ if user is not None:
+ today = datetime.date.today()
+ sums = self.filter(models.Q(reputation_type=1) | models.Q(reputation_type=-8),
+ user=user, reputed_at__range=(today, today + datetime.timedelta(1))). \
+ agregate(models.Sum('positive'), models.Sum('negative'))
+
+ return sums['positive__sum'] + sums['negative__sum']
+ else:
+ return 0
+
+class Repute(models.Model):
+ """The reputation histories for user"""
+ user = models.ForeignKey(User)
+ positive = models.SmallIntegerField(default=0)
+ negative = models.SmallIntegerField(default=0)
+ question = models.ForeignKey('Question')
+ reputed_at = models.DateTimeField(default=datetime.datetime.now)
+ reputation_type = models.SmallIntegerField(choices=TYPE_REPUTATION)
+ reputation = models.IntegerField(default=1)
+
+ objects = ReputeManager()
+
+ def __unicode__(self):
+ return u'[%s]\' reputation changed at %s' % (self.user.username, self.reputed_at)
+
+ class Meta:
+ app_label = 'forum'
+ db_table = u'repute'
diff --git a/forum/models/tag.py b/forum/models/tag.py
new file mode 100755
index 0000000..28b9e57
--- /dev/null
+++ b/forum/models/tag.py
@@ -0,0 +1,85 @@
+from base import *
+
+from django.utils.translation import ugettext as _
+
+class TagManager(models.Manager):
+ UPDATE_USED_COUNTS_QUERY = (
+ 'UPDATE tag '
+ 'SET used_count = ('
+ 'SELECT COUNT(*) FROM question_tags '
+ 'INNER JOIN question ON question_id=question.id '
+ 'WHERE tag_id = tag.id AND question.deleted=False'
+ ') '
+ 'WHERE id IN (%s)')
+
+ def get_valid_tags(self, page_size):
+ tags = self.all().filter(deleted=False).exclude(used_count=0).order_by("-id")[:page_size]
+ return tags
+
+ def get_or_create_multiple(self, names, user):
+ """
+ Fetches a list of Tags with the given names, creating any Tags
+ which don't exist when necesssary.
+ """
+ tags = list(self.filter(name__in=names))
+ #Set all these tag visible
+ for tag in tags:
+ if tag.deleted:
+ tag.deleted = False
+ tag.deleted_by = None
+ tag.deleted_at = None
+ tag.save()
+
+ if len(tags) < len(names):
+ existing_names = set(tag.name for tag in tags)
+ new_names = [name for name in names if name not in existing_names]
+ tags.extend([self.create(name=name, created_by=user)
+ for name in new_names if self.filter(name=name).count() == 0 and len(name.strip()) > 0])
+
+ return tags
+
+ def update_use_counts(self, tags):
+ """Updates the given Tags with their current use counts."""
+ if not tags:
+ return
+ cursor = connection.cursor()
+ query = self.UPDATE_USED_COUNTS_QUERY % ','.join(['%s'] * len(tags))
+ cursor.execute(query, [tag.id for tag in tags])
+ transaction.commit_unless_managed()
+
+ def get_tags_by_questions(self, questions):
+ question_ids = []
+ for question in questions:
+ question_ids.append(question.id)
+
+ question_ids_str = ','.join([str(id) for id in question_ids])
+ related_tags = self.extra(
+ tables=['tag', 'question_tags'],
+ where=["tag.id = question_tags.tag_id AND question_tags.question_id IN (" + question_ids_str + ")"]
+ ).distinct()
+
+ return related_tags
+
+class Tag(DeletableContent):
+ name = models.CharField(max_length=255, unique=True)
+ created_by = models.ForeignKey(User, related_name='created_tags')
+ # Denormalised data
+ used_count = models.PositiveIntegerField(default=0)
+
+ objects = TagManager()
+
+ class Meta(DeletableContent.Meta):
+ db_table = u'tag'
+ ordering = ('-used_count', 'name')
+
+ def __unicode__(self):
+ return self.name
+
+class MarkedTag(models.Model):
+ TAG_MARK_REASONS = (('good',_('interesting')),('bad',_('ignored')))
+ tag = models.ForeignKey('Tag', related_name='user_selections')
+ user = models.ForeignKey(User, related_name='tag_selections')
+ reason = models.CharField(max_length=16, choices=TAG_MARK_REASONS)
+
+ class Meta:
+ app_label = 'forum'
\ No newline at end of file
diff --git a/forum/models/user.py b/forum/models/user.py
new file mode 100755
index 0000000..9502416
--- /dev/null
+++ b/forum/models/user.py
@@ -0,0 +1,77 @@
+from base import *
+
+from django.utils.translation import ugettext as _
+
+class Activity(models.Model):
+ """
+ We keep some history data for user activities
+ """
+ user = models.ForeignKey(User)
+ activity_type = models.SmallIntegerField(choices=TYPE_ACTIVITY)
+ active_at = models.DateTimeField(default=datetime.datetime.now)
+ content_type = models.ForeignKey(ContentType)
+ object_id = models.PositiveIntegerField()
+ content_object = generic.GenericForeignKey('content_type', 'object_id')
+ is_auditted = models.BooleanField(default=False)
+
+ def __unicode__(self):
+ return u'[%s] was active at %s' % (self.user.username, self.active_at)
+
+ class Meta:
+ app_label = 'forum'
+ db_table = u'activity'
+
+class EmailFeedSetting(models.Model):
+ DELTA_TABLE = {
+ 'w':datetime.timedelta(7),
+ 'd':datetime.timedelta(1),
+ 'n':datetime.timedelta(-1),
+ }
+ FEED_TYPES = (
+ ('q_all',_('Entire forum')),
+ ('q_ask',_('Questions that I asked')),
+ ('q_ans',_('Questions that I answered')),
+ ('q_sel',_('Individually selected questions')),
+ )
+ UPDATE_FREQUENCY = (
+ ('w',_('Weekly')),
+ ('d',_('Daily')),
+ ('n',_('No email')),
+ )
+ subscriber = models.ForeignKey(User)
+ feed_type = models.CharField(max_length=16,choices=FEED_TYPES)
+ frequency = models.CharField(max_length=8,choices=UPDATE_FREQUENCY,default='n')
+ added_at = models.DateTimeField(auto_now_add=True)
+ reported_at = models.DateTimeField(null=True)
+
+ def save(self,*args,**kwargs):
+ type = self.feed_type
+ subscriber = self.subscriber
+ similar = self.__class__.objects.filter(feed_type=type,subscriber=subscriber).exclude(pk=self.id)
+ if len(similar) > 0:
+ raise IntegrityError('email feed setting already exists')
+ super(EmailFeedSetting,self).save(*args,**kwargs)
+
+ class Meta:
+ app_label = 'forum'
+
+class AnonymousEmail(models.Model):
+ #validation key, if used
+ key = models.CharField(max_length=32)
+ email = models.EmailField(null=False,unique=True)
+ isvalid = models.BooleanField(default=False)
+
+ class Meta:
+ app_label = 'forum'
+
+
+class AuthKeyUserAssociation(models.Model):
+ key = models.CharField(max_length=256,null=False,unique=True)
+ provider = models.CharField(max_length=64)
+ user = models.ForeignKey(User)
+ added_at = models.DateTimeField(default=datetime.datetime.now)
+
+ class Meta:
+ app_label = 'forum'
+
+
\ No newline at end of file
diff --git a/forum/modules.py b/forum/modules.py
new file mode 100755
index 0000000..9c07233
--- /dev/null
+++ b/forum/modules.py
@@ -0,0 +1,79 @@
+import os
+import types
+import re
+
+from django.template import Template, TemplateDoesNotExist
+
+MODULES_PACKAGE = 'forum_modules'
+
+MODULES_FOLDER = os.path.join(os.path.dirname(__file__), '../' + MODULES_PACKAGE)
+
+MODULE_LIST = [
+ __import__('forum_modules.%s' % f, globals(), locals(), ['forum_modules'])
+ for f in os.listdir(MODULES_FOLDER)
+ if os.path.isdir(os.path.join(MODULES_FOLDER, f)) and
+ os.path.exists(os.path.join(MODULES_FOLDER, "%s/__init__.py" % f)) and
+ not os.path.exists(os.path.join(MODULES_FOLDER, "%s/DISABLED" % f))
+]
+
+def get_modules_script(script_name):
+ all = []
+
+ for m in MODULE_LIST:
+ try:
+ all.append(__import__('%s.%s' % (m.__name__, script_name), globals(), locals(), [m.__name__]))
+ except Exception, e:
+ #print script_name + ":" + str(e)
+ pass
+
+ return all
+
+def get_modules_script_classes(script_name, base_class):
+ scripts = get_modules_script(script_name)
+ all_classes = {}
+
+ for script in scripts:
+ all_classes.update(dict([
+ (n, c) for (n, c) in [(n, getattr(script, n)) for n in dir(script)]
+ if isinstance(c, (type, types.ClassType)) and issubclass(c, base_class)
+ ]))
+
+ return all_classes
+
+def get_all_handlers(name):
+ handler_files = get_modules_script('handlers')
+
+ return [
+ h for h in [
+ getattr(f, name) for f in handler_files
+ if hasattr(f, name)
+ ]
+
+ if callable(h)
+ ]
+
+def get_handler(name, default):
+ all = get_all_handlers(name)
+ print(len(all))
+ return len(all) and all[0] or default
+
+module_template_re = re.compile('^modules\/(\w+)\/(.*)$')
+
+def module_templates_loader(name, dirs=None):
+ result = module_template_re.search(name)
+
+ if result is not None:
+ file_name = os.path.join(MODULES_FOLDER, result.group(1), 'templates', result.group(2))
+
+ if os.path.exists(file_name):
+ try:
+ f = open(file_name, 'r')
+ source = f.read()
+ f.close()
+ return (source, file_name)
+ except:
+ pass
+
+ raise TemplateDoesNotExist, name
+
+module_templates_loader.is_usable = True
\ No newline at end of file
diff --git a/forum/sitemap.py b/forum/sitemap.py
new file mode 100644
index 0000000..c0c60b5
--- /dev/null
+++ b/forum/sitemap.py
@@ -0,0 +1,14 @@
+from django.contrib.sitemaps import Sitemap
+from forum.models import Question
+
+class QuestionsSitemap(Sitemap):
+ changefreq = 'daily'
+ priority = 0.5
+ def items(self):
+ return Question.objects.exclude(deleted=True)
+
+ def lastmod(self, obj):
+ return obj.last_activity_at
+
+ def location(self, obj):
+ return obj.get_absolute_url()
diff --git a/forum/skins/README b/forum/skins/README
new file mode 100644
index 0000000..5565fa8
--- /dev/null
+++ b/forum/skins/README
@@ -0,0 +1,22 @@
+this directory contains available skins
+
+1) default - default skin with templates
+2) common - this directory is to media directory common to all or many templates
+
+to create a new skin just create another directory under skins/
+and start populating it with the directory structure as in
+default/templates - templates must be named the same way
+
+NO NEED TO CREATE ALL TEMPLATES/MEDIA FILES AT ONCE
+
+templates are resolved in the following way:
+* check in skin named as in settings.OSQA_DEFAULT_SKIN
+* then skin named 'default'
+
+media is resolved with one extra option
+* settings.OSQA_DEFAULT_SKIN
+* 'default'
+* 'common'
+
+media does not have to be composed of files named the same way as in default skin
+whatever media you link to from your templates - will be in operation
diff --git a/forum/skins/__init__.py b/forum/skins/__init__.py
new file mode 100644
index 0000000..be6bd4f
--- /dev/null
+++ b/forum/skins/__init__.py
@@ -0,0 +1,57 @@
+from django.conf import settings
+from django.template import loader
+from django.template.loaders import filesystem
+from django.http import HttpResponse
+import os.path
+import logging
+
+#module for skinning osqa
+#at this point skin can be changed only in settings file
+#via OSQA_DEFAULT_SKIN variable
+
+#note - Django template loaders use method django.utils._os.safe_join
+#to work on unicode file paths
+#here it is ignored because it is assumed that we won't use unicode paths
+
+def load_template_source(name, dirs=None):
+ try:
+ tname = os.path.join(settings.OSQA_DEFAULT_SKIN,'templates',name)
+ return filesystem.load_template_source(tname,dirs)
+ except:
+ tname = os.path.join('default','templates',name)
+ return filesystem.load_template_source(tname,dirs)
+load_template_source.is_usable = True
+
+def find_media_source(url):
+ """returns url prefixed with the skin name
+ of the first skin that contains the file
+ directories are searched in this order:
+ settings.OSQA_DEFAULT_SKIN, then 'default', then 'commmon'
+ if file is not found - returns None
+ and logs an error message
+ """
+ while url[0] == '/': url = url[1:]
+ d = os.path.dirname
+ n = os.path.normpath
+ j = os.path.join
+ f = os.path.isfile
+ skins = n(j(d(d(__file__)),'skins'))
+ try:
+ media = os.path.join(skins, settings.OSQA_DEFAULT_SKIN, url)
+ assert(f(media))
+ use_skin = settings.OSQA_DEFAULT_SKIN
+ except:
+ try:
+ media = j(skins, 'default', url)
+ assert(f(media))
+ use_skin = 'default'
+ except:
+ media = j(skins, 'common', url)
+ try:
+ assert(f(media))
+ use_skin = 'common'
+ except:
+ logging.error('could not find media for %s' % url)
+ use_skin = ''
+ return None
+ return use_skin + '/' + url
diff --git a/forum/skins/common/media/README b/forum/skins/common/media/README
new file mode 100644
index 0000000..3376e75
--- /dev/null
+++ b/forum/skins/common/media/README
@@ -0,0 +1 @@
+directory for media common to all or many templates
diff --git a/forum/skins/default/media/images/blue-up-arrow-h18px.png b/forum/skins/default/media/images/blue-up-arrow-h18px.png
new file mode 100644
index 0000000000000000000000000000000000000000..e1f29e86334ce72d2d28989a133571d7bf53a94e
GIT binary patch
literal 593
zcmV-X0{6iqad+$V|KR(Q{q;Ok;ua!jjQtuI1z7)O(
zySE1i5k-TnUMhU^!Fth&L`=(4hnCB&mew0>atrF^~@9muPo$nmJ5BNLwb>CS+
z;4aaxR`*WD1Hfmk^?b25_^vW#;gMP0a=;-lw8uKYF+TucXlk2wNsBS@}pFD
ztaavBN$@-;DyO5Dqi0!d9a~=U7;O0Ayiuq>#Jyi<@Hb@35~1ra$v5dmgDzqB>^+=uOM7b5+>fY}+kwy1R`cV__*wFuQ!Bi@#VE~BJd
f7UnWh{5Sjn&bP0xmAf5500000NkvXXu0mjfU!M$-
literal 0
HcmV?d00001
diff --git a/forum/skins/default/media/images/box-arrow.gif b/forum/skins/default/media/images/box-arrow.gif
new file mode 100644
index 0000000000000000000000000000000000000000..89dcf5b3dd40fac0e6afb0b1a7ff899a059f923f
GIT binary patch
literal 69
zcmZ?wbhEHbWM@!dXkcJ?_wL=lfBzJJvM@6+Ff!;c00Bsbfk~#Pf92`7{EJz*0#eqW
WoE7iinDij`X5pe+YFj-S8LR>Nkr%B1
literal 0
HcmV?d00001
diff --git a/forum/skins/default/media/images/bullet_green.gif b/forum/skins/default/media/images/bullet_green.gif
new file mode 100644
index 0000000000000000000000000000000000000000..fa530910f9dc11fadaa2314f72bd98f29df39daf
GIT binary patch
literal 64
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
D3>(Ui
literal 0
HcmV?d00001
diff --git a/forum/skins/default/media/images/close-small-dark.png b/forum/skins/default/media/images/close-small-dark.png
new file mode 100644
index 0000000000000000000000000000000000000000..280c1fc74e47c0e7d1c68d6f356eb22eeba7a2de
GIT binary patch
literal 226
zcmeAS@N?(olHy`uVBq!ia0vp^d?3ui3?$#C89V|~1_3@Hu3NY7ShRS_jG43c?mKYe
z;M6!dxb8-^+kP+MX_sAsp9}6ArM>V3CjzZ7^A^)I5Q~
t!yzC*Q0L$Zl?Eqn$0Lca5`Jx9W=NmTc;}pLNdZtBgQu&X%Q~loCIHsxKFr1QCT0q{1{MYeDE)e-c@Ne1ia=5ZCX&{{Hy&_tW!N_ZKfc
zpObUM#pTWAs~3xk4w#ty`t$F>lBGXhy}DLgdpr1Q#zqRd1{MYe;g^zIfZEtgJbhi+Z*Z~+S_nO6;N1-r
z;wE)e-c@Ne1ia=5Z4(qXDwR1Wb5`Fd-okU
zaq{%lYd1c8`uyw9U!Xizkhgxu4WI-^NswRge+Xc>*St3pD5mS_;uunKD_NtRfsJRE
z&YnFcvL4forc1XpAK{H{IrvG{>z#&4h|Ds+W&FE$GCZx(SSr0JY6i#_Pgg&ebxsLQ
E08gA+X#fBK
literal 0
HcmV?d00001
diff --git a/forum/skins/default/media/images/dash.gif b/forum/skins/default/media/images/dash.gif
new file mode 100644
index 0000000000000000000000000000000000000000..d1ddc507fe00bd654fce38ac8552793aa18c9966
GIT binary patch
literal 44
wcmZ?wbhEHbWMN=rXkcLY|NsBAY10&cvM{hS{AbW%00NK<0~50k11p0y023w&(*OVf
literal 0
HcmV?d00001
diff --git a/forum/skins/default/media/images/djangomade124x25_grey.gif b/forum/skins/default/media/images/djangomade124x25_grey.gif
new file mode 100644
index 0000000000000000000000000000000000000000..d34bb311615b1378a672a828c7a7916490cd882b
GIT binary patch
literal 2035
zcmcJOSyz(>0zk8Y5oHk(5d(s_FbFhAqNt4L16&YNCyID%iyA#v9i~-V)T#$7C{?Rq
zJuXmNkX;Zk3t36XzAs-E$W8(YK|~TDELE}gFw-9~_w_#9r@MFGo{ViLN+D5@_t6k?
zub_>}Y9`ZKyBS?Q+zuw^CB28p=9m6l=^h>P&(87W>M^fRs?m>(j#(W;9ErkUvGq!o
zR>z=aV9@3qVhSZRzIguQM;cEA4PH>{`)!UPyVLdOr%&oW6O}KX^iRvR21>7B_K!bg
zpuy#NEl_HdeI}XK;2M6dGg;OB7NH7sx`ycjiOOJXqcVp_-!Mh8P9}%M;d7*l9dn^o^Yc8JiXo6#2L{zfi^DzK#p>;|ITX6Se!BxS
zS=nOw+`D&V4qvF$^jqyzo~TDG>y;|lVi{d1<;qn?yF=A))|stRQ2&y_<|)*2U7tjw
zV~V8YUSW?!VYCi#Byx?(%9SZ)x_-xyo5JBc-5#D?H9GEn-bvP4>_a1?Mw^2xQ-LN+
zBe7ec(5U;2FS?oT(KkAyXb
zH`<+SiGsov4vvg6M6xClb-?9rrO;i&BX+lk$mV$`rZ`gN>v8YfiAm6G<101ow4OKP
zUbVqQ?BTjSuXPq%Yd5pISJ261cQQEjo#Yk@y{?1ASAq*s-`cubDGW-lki_O|%{D4etT9^j7JEC5N#je{GL^~hpbMnVVNY!b
z@p%VHZ?SpC-cHTTm>h#mB$`;Qbq9D5VJ`EU_H44c4@Fu%*g{g$2m}
zjre&46atBcB>!Fb`w0kg0fIg2-$QN?-a;-U9a~C|Sx_l#$$
zbyds`pGKe0LOZIh;hTXQH~7?2Jo0
zAKTtHp2*){xDdLxDX)5Bq$d9S)rRYTt~R1BZu8%arq9oL7S0X{cVZ83Md|CWuc5Ww
zgHJrm4n{311;Egli>`(nMilUQIqb4)5Q?cl#}=HzL03S&F5J5ZvF3I&1iAmphxib7
z=~(%y)r?(&s;zbaOiB%VcyqB)a3LW*6`!N2T5$*vtPDAu4Lr&i{1t{hUIbJv$v&)4
zc!CfB!4I<5zFha<24-Hg2z@rDMTRf