From: hernani Date: Mon, 1 Mar 2010 16:55:04 +0000 (+0000) Subject: Initial commit X-Git-Tag: live~1069 X-Git-Url: https://git.openstreetmap.org./osqa.git/commitdiff_plain/a9eef437702d5df7a2f97010e6798c689371808c Initial commit git-svn-id: http://svn.osqa.net/svnroot/osqa/trunk@4 0cfe37f9-358a-4d5e-be75-b63607b5c754 --- diff --git a/.idea/ant.xml b/.idea/ant.xml new file mode 100755 index 0000000..db0112b --- /dev/null +++ b/.idea/ant.xml @@ -0,0 +1,7 @@ + + + + + + + diff --git a/.idea/compiler.xml b/.idea/compiler.xml new file mode 100755 index 0000000..b9a1798 --- /dev/null +++ b/.idea/compiler.xml @@ -0,0 +1,24 @@ + + + + + + diff --git a/.idea/copyright/profiles_settings.xml b/.idea/copyright/profiles_settings.xml new file mode 100755 index 0000000..b385f01 --- /dev/null +++ b/.idea/copyright/profiles_settings.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/.idea/encodings.xml b/.idea/encodings.xml new file mode 100755 index 0000000..fa0a5f1 --- /dev/null +++ b/.idea/encodings.xml @@ -0,0 +1,7 @@ + + + + + + + diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100755 index 0000000..44294f2 --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,52 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100755 index 0000000..9a64d92 --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/.idea/uiDesigner.xml b/.idea/uiDesigner.xml new file mode 100755 index 0000000..1e7cce4 --- /dev/null +++ b/.idea/uiDesigner.xml @@ -0,0 +1,125 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100755 index 0000000..3848996 --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,7 @@ + + + + + + + diff --git a/.idea/workspace.xml b/.idea/workspace.xml new file mode 100755 index 0000000..250800c --- /dev/null +++ b/.idea/workspace.xml @@ -0,0 +1,654 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + localhost + 5050 + + + + + + + + + + + 1266534782032 + 1266534782032 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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'

Ask and answer questions, make the world better!

' #slogan that goes to front page in logged out mode +APP_COPYRIGHT = '' #copyright message + +#if you set FORUM_SCRIPT_ALIAS= 'forum/' +#then OSQA will run at url http://example.com/forum +#FORUM_SCRIPT_ALIAS cannot have leading slash, otherwise it can be set to anything +FORUM_SCRIPT_ALIAS = '' #no leading slash, default = '' empty string + +LANGUAGE_CODE = 'en' #forum language (see language instructions on the wiki) +EMAIL_VALIDATION = 'off' #string - on|off +MIN_USERNAME_LENGTH = 1 +EMAIL_UNIQUE = False #if True, email addresses must be unique in all accounts +APP_URL = 'http://osqa.com' #used by email notif system and RSS +GOOGLE_SITEMAP_CODE = '' #code for google site crawler (look up google webmaster tools) +GOOGLE_ANALYTICS_KEY = '' #key to enable google analytics on this site +BOOKS_ON = False #if True - books tab will be on +WIKI_ON = True #if False - community wiki feature is disabled + +#experimental - allow password login through external site +#must implement django_authopenid/external_login.py +#included prototype external_login works with Mediawiki +USE_EXTERNAL_LEGACY_LOGIN = True #if false OSQA uses it's own login/password +EXTERNAL_LEGACY_LOGIN_HOST = 'login.osqa.com' +EXTERNAL_LEGACY_LOGIN_PORT = 80 +EXTERNAL_LEGACY_LOGIN_PROVIDER_NAME = 'OSQA' + +FEEDBACK_SITE_URL = None #None or url +LOGIN_URL = '/%s%s%s' % (FORUM_SCRIPT_ALIAS,'account/','signin/') + +DJANGO_VERSION = 1.1 #must be either 1.0 or 1.1 +RESOURCE_REVISION=4 #increment when you update media files - clients will be forced to load new version + +D. Customization + +Other than settings_local.py the following will most likely need customization: +* locale/*/django.po - language files that may also contain your site-specific messages + if you want to start with english messages file - look for words like "forum" and + "OSQA" in the msgstr lines +* templates/header.html and templates/footer.html may contain extra links +* templates/about.html - a place to explain for is your forum for +* templates/faq.html - put answers to users frequent questions +* templates/content/style/style.css - modify style sheet to add disctinctive look to your forum diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..803781c --- /dev/null +++ b/LICENSE @@ -0,0 +1,14 @@ +Copyright (C) 2009. Chen Gang + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . diff --git a/PENDING b/PENDING new file mode 100644 index 0000000..2931303 --- /dev/null +++ b/PENDING @@ -0,0 +1,28 @@ +There are two kinds of things that can be done: +refactorings (think of jogging in the morning, going to a spa, well make the code better :) +new features (go to law school, get a job, do something real) +Just a joke - pick yourself a task and work on it. + +==Refactoring== +* validate HTML +* set up loading of default settings from inside the /forum dir +* automatic dependency checking for modules +* propose how to rename directory forum --> osqa + without breaking things and keeping name of the project root + named the same way - osqa + +==New features== +Whoever wants - pick a feature from the WISH_LIST +add it here and start working on it +If you are not starting immediately - leave it on the wishlist :) + +==Notes== +1)after this is done most new suggested features + may be worked on easily since most of them + only require editing view functions and templates + + However, anyone can work on new features anyway - you'll + just have to probably copy-paste your code into + the branch undergoing refactoring which involves + splitting the files. Auto merging across split points + is harder or impossible. diff --git a/README b/README new file mode 100644 index 0000000..2a209b7 --- /dev/null +++ b/README @@ -0,0 +1,6 @@ +This is OSQA project - open source Q&A system + +Demo site is http://osqa.net + +OSQA is based on code of CNPROG, originally created by Mike Chen and Sailing Cai. + diff --git a/ROADMAP.rst b/ROADMAP.rst new file mode 100644 index 0000000..42f2e8c --- /dev/null +++ b/ROADMAP.rst @@ -0,0 +1,32 @@ +This document is a map for our activities down the road - therefore ROADMAP. +ROADMAP does not specify deadlines - those belong to the PENDING file + +Intro +========= +ROADMAP aims to streamline activities of the OSQA open source project and +to minimize ad-hoc approaches of "big-picture" level. + +With one exception: under extreme time pressure improvised approaches are perfectly acceptable. + +Items in this document must be discussed in public via dev@osqa.net + +Architecture +============= + +Sub-systems +----------------- +* authentication system +* Q&A system + +Authentication system +------------------------- +* MUST authenticate people visiting the website via web browsers. +* Upon successful authentication must associates the visitor with + his/her Django system user account +* MUST allow multiple methods of authentication to the same account +* MUST support a method to recover lost authentication link by email +* MAY offer an option to "soft-validate" user's email (send a link + with a special key, so that user clicks and we know that email is valid) + "soft" - meaning that lack of validation won't block people + from using the site + diff --git a/WISH_LIST b/WISH_LIST new file mode 100644 index 0000000..6b10687 --- /dev/null +++ b/WISH_LIST @@ -0,0 +1,10 @@ +* The wonder bar (integrated the search / ask functionality) +* The authentication system ??? +* allow multiple logins to the same account +* more advanced templating/skinning system +* per-tag email subscriptions +* view for personalized news on the site +* a little flag popping when there are news +* drill-down mode for navigation by tags +* improved admin console +* sort out mess with profile - currently we patch django User diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/context.py b/context.py new file mode 100644 index 0000000..9e22550 --- /dev/null +++ b/context.py @@ -0,0 +1,47 @@ +from django.conf import settings +def application_settings(context): + my_settings = { + 'APP_TITLE' : settings.APP_TITLE, + 'APP_SHORT_NAME' : settings.APP_SHORT_NAME, + 'APP_URL' : settings.APP_URL, + 'APP_KEYWORDS' : settings.APP_KEYWORDS, + 'APP_DESCRIPTION' : settings.APP_DESCRIPTION, + 'APP_INTRO' : settings.APP_INTRO, + 'EMAIL_VALIDATION': settings.EMAIL_VALIDATION, + 'LANGUAGE_CODE': settings.LANGUAGE_CODE, + 'GOOGLE_SITEMAP_CODE':settings.GOOGLE_SITEMAP_CODE, + 'GOOGLE_ANALYTICS_KEY':settings.GOOGLE_ANALYTICS_KEY, + 'BOOKS_ON':settings.BOOKS_ON, + 'WIKI_ON':settings.WIKI_ON, + 'USE_EXTERNAL_LEGACY_LOGIN':settings.USE_EXTERNAL_LEGACY_LOGIN, + 'RESOURCE_REVISION':settings.RESOURCE_REVISION, + 'USE_SPHINX_SEARCH':settings.USE_SPHINX_SEARCH, + 'OSQA_SKIN':settings.OSQA_DEFAULT_SKIN, + } + return {'settings':my_settings} + +def auth_processor(request): + """ + Returns context variables required by apps that use Django's authentication + system. + + If there is no 'user' attribute in the request, uses AnonymousUser (from + django.contrib.auth). + """ + if hasattr(request, 'user'): + user = request.user + if user.is_authenticated(): + messages = user.message_set.all() + else: + messages = None + else: + from django.contrib.auth.models import AnonymousUser + user = AnonymousUser() + messages = None + + from django.core.context_processors import PermWrapper + return { + 'user': user, + 'messages': messages, + 'perms': PermWrapper(user), + } diff --git a/cron/send_email_alerts b/cron/send_email_alerts new file mode 100644 index 0000000..6358b59 --- /dev/null +++ b/cron/send_email_alerts @@ -0,0 +1,4 @@ +PYTHONPATH=/path/to/dir/above/forum +export PYTHONPATH +APP_ROOT=$PYTHONPATH/nmr-forum2 +/path/to/python $APP_ROOT/manage.py send_email_alerts diff --git a/dos2unix.sh b/dos2unix.sh new file mode 100644 index 0000000..2864426 --- /dev/null +++ b/dos2unix.sh @@ -0,0 +1,12 @@ +#please take care not to dos2unix anything in your .git directory +#because that will probably break your repo +dos2unix `find . -name '*.py'` +dos2unix `find . -name '*.po'` +dos2unix `find . -name '*.js'` +dos2unix `find . -name '*.css'` +dos2unix `find . -name '*.txt'` +dos2unix `find ./sphinx -type f` +dos2unix `find ./cron -type f` +dos2unix settings_local.py.dist +dos2unix README +dos2unix INSTALL diff --git a/forum/__init__.py b/forum/__init__.py new file mode 100644 index 0000000..85cd5d2 --- /dev/null +++ b/forum/__init__.py @@ -0,0 +1 @@ +__all__ = ['admin','auth','const','feed','forms','managers','models','sitemap','urls','views'] diff --git a/forum/admin.py b/forum/admin.py new file mode 100644 index 0000000..88643b9 --- /dev/null +++ b/forum/admin.py @@ -0,0 +1,74 @@ +# -*- coding: utf-8 -*- + +from django.contrib import admin +from models import * + + +class AnonymousQuestionAdmin(admin.ModelAdmin): + """AnonymousQuestion admin class""" + +class QuestionAdmin(admin.ModelAdmin): + """Question admin class""" + +class TagAdmin(admin.ModelAdmin): + """Tag admin class""" + +class Answerdmin(admin.ModelAdmin): + """Answer admin class""" + +class CommentAdmin(admin.ModelAdmin): + """ admin class""" + +class VoteAdmin(admin.ModelAdmin): + """ admin class""" + +class FlaggedItemAdmin(admin.ModelAdmin): + """ admin class""" + +class FavoriteQuestionAdmin(admin.ModelAdmin): + """ admin class""" + +class QuestionRevisionAdmin(admin.ModelAdmin): + """ admin class""" + +class AnswerRevisionAdmin(admin.ModelAdmin): + """ admin class""" + +class AwardAdmin(admin.ModelAdmin): + """ admin class""" + +class BadgeAdmin(admin.ModelAdmin): + """ admin class""" + +class ReputeAdmin(admin.ModelAdmin): + """ admin class""" + +class ActivityAdmin(admin.ModelAdmin): + """ admin class""" + +#class BookAdmin(admin.ModelAdmin): +# """ admin class""" + +#class BookAuthorInfoAdmin(admin.ModelAdmin): +# """ admin class""" + +#class BookAuthorRssAdmin(admin.ModelAdmin): +# """ admin class""" + + +admin.site.register(Question, QuestionAdmin) +admin.site.register(Tag, TagAdmin) +admin.site.register(Answer, Answerdmin) +admin.site.register(Comment, CommentAdmin) +admin.site.register(Vote, VoteAdmin) +admin.site.register(FlaggedItem, FlaggedItemAdmin) +admin.site.register(FavoriteQuestion, FavoriteQuestionAdmin) +admin.site.register(QuestionRevision, QuestionRevisionAdmin) +admin.site.register(AnswerRevision, AnswerRevisionAdmin) +admin.site.register(Badge, BadgeAdmin) +admin.site.register(Award, AwardAdmin) +admin.site.register(Repute, ReputeAdmin) +admin.site.register(Activity, ActivityAdmin) +#admin.site.register(Book, BookAdmin) +#admin.site.register(BookAuthorInfo, BookAuthorInfoAdmin) +#admin.site.register(BookAuthorRss, BookAuthorRssAdmin) diff --git a/forum/auth.py b/forum/auth.py new file mode 100644 index 0000000..3533b9c --- /dev/null +++ b/forum/auth.py @@ -0,0 +1,498 @@ +""" +Authorisation related functions. + +The actions a User is authorised to perform are dependent on their reputation +and superuser status. +""" +import datetime +from django.contrib.contenttypes.models import ContentType +from django.utils.translation import ugettext as _ +from django.db import transaction +from models import Repute +from models import Question +from models import Answer +from const import TYPE_REPUTATION +import logging +question_type = ContentType.objects.get_for_model(Question) +answer_type = ContentType.objects.get_for_model(Answer) + +VOTE_UP = 15 +FLAG_OFFENSIVE = 15 +POST_IMAGES = 15 +LEAVE_COMMENTS = 50 +UPLOAD_FILES = 60 +VOTE_DOWN = 100 +CLOSE_OWN_QUESTIONS = 250 +RETAG_OTHER_QUESTIONS = 500 +REOPEN_OWN_QUESTIONS = 500 +EDIT_COMMUNITY_WIKI_POSTS = 750 +EDIT_OTHER_POSTS = 2000 +DELETE_COMMENTS = 2000 +VIEW_OFFENSIVE_FLAGS = 2000 +DISABLE_URL_NOFOLLOW = 2000 +CLOSE_OTHER_QUESTIONS = 3000 +LOCK_POSTS = 4000 + +VOTE_RULES = { + 'scope_votes_per_user_per_day' : 30, # how many votes of one user has everyday + 'scope_flags_per_user_per_day' : 5, # how many times user can flag posts everyday + 'scope_warn_votes_left' : 10, # start when to warn user how many votes left + 'scope_deny_unvote_days' : 1, # if 1 days passed, user can't cancel votes. + 'scope_flags_invisible_main_page' : 3, # post doesn't show on main page if has more than 3 offensive flags + 'scope_flags_delete_post' : 5, # post will be deleted if it has more than 5 offensive flags +} + +REPUTATION_RULES = { + 'initial_score' : 1, + 'scope_per_day_by_upvotes' : 200, + 'gain_by_upvoted' : 10, + 'gain_by_answer_accepted' : 15, + 'gain_by_accepting_answer' : 2, + 'gain_by_downvote_canceled' : 2, + 'gain_by_canceling_downvote' : 1, + 'lose_by_canceling_accepted_answer' : -2, + 'lose_by_accepted_answer_cancled' : -15, + 'lose_by_downvoted' : -2, + 'lose_by_flagged' : -2, + 'lose_by_downvoting' : -1, + 'lose_by_flagged_lastrevision_3_times': -30, + 'lose_by_flagged_lastrevision_5_times': -100, + 'lose_by_upvote_canceled' : -10, +} + +def can_moderate_users(user): + return user.is_superuser + +def can_vote_up(user): + """Determines if a User can vote Questions and Answers up.""" + return user.is_authenticated() and ( + user.reputation >= VOTE_UP or + user.is_superuser) + +def can_flag_offensive(user): + """Determines if a User can flag Questions and Answers as offensive.""" + return user.is_authenticated() and ( + user.reputation >= FLAG_OFFENSIVE or + user.is_superuser) + +def can_add_comments(user,subject): + """Determines if a User can add comments to Questions and Answers.""" + if user.is_authenticated(): + if user.id == subject.author.id: + return True + if user.reputation >= LEAVE_COMMENTS: + return True + if user.is_superuser: + return True + if isinstance(subject,Answer) and subject.question.author.id == user.id: + return True + return False + +def can_vote_down(user): + """Determines if a User can vote Questions and Answers down.""" + return user.is_authenticated() and ( + user.reputation >= VOTE_DOWN or + user.is_superuser) + +def can_retag_questions(user): + """Determines if a User can retag Questions.""" + return user.is_authenticated() and ( + RETAG_OTHER_QUESTIONS <= user.reputation < EDIT_OTHER_POSTS or + user.is_superuser) + +def can_edit_post(user, post): + """Determines if a User can edit the given Question or Answer.""" + return user.is_authenticated() and ( + user.id == post.author_id or + (post.wiki and user.reputation >= EDIT_COMMUNITY_WIKI_POSTS) or + user.reputation >= EDIT_OTHER_POSTS or + user.is_superuser) + +def can_delete_comment(user, comment): + """Determines if a User can delete the given Comment.""" + return user.is_authenticated() and ( + user.id == comment.user_id or + user.reputation >= DELETE_COMMENTS or + user.is_superuser) + +def can_view_offensive_flags(user): + """Determines if a User can view offensive flag counts.""" + return user.is_authenticated() and ( + user.reputation >= VIEW_OFFENSIVE_FLAGS or + user.is_superuser) + +def can_close_question(user, question): + """Determines if a User can close the given Question.""" + return user.is_authenticated() and ( + (user.id == question.author_id and + user.reputation >= CLOSE_OWN_QUESTIONS) or + user.reputation >= CLOSE_OTHER_QUESTIONS or + user.is_superuser) + +def can_lock_posts(user): + """Determines if a User can lock Questions or Answers.""" + return user.is_authenticated() and ( + user.reputation >= LOCK_POSTS or + user.is_superuser) + +def can_follow_url(user): + """Determines if the URL link can be followed by Google search engine.""" + return user.reputation >= DISABLE_URL_NOFOLLOW + +def can_accept_answer(user, question, answer): + return (user.is_authenticated() and + question.author != answer.author and + question.author == user) or user.is_superuser + +# now only support to reopen own question except superuser +def can_reopen_question(user, question): + return (user.is_authenticated() and + user.id == question.author_id and + user.reputation >= REOPEN_OWN_QUESTIONS) or user.is_superuser + +def can_delete_post(user, post): + if user.is_superuser: + return True + elif user.is_authenticated() and user == post.author: + if isinstance(post,Answer): + return True + elif isinstance(post,Question): + answers = post.answers.all() + for answer in answers: + if user != answer.author and answer.deleted == False: + return False + return True + else: + return False + else: + return False + +def can_view_deleted_post(user, post): + return user.is_superuser + +# user preferences view permissions +def is_user_self(request_user, target_user): + return (request_user.is_authenticated() and request_user == target_user) + +def can_view_user_votes(request_user, target_user): + return (request_user.is_authenticated() and request_user == target_user) + +def can_view_user_preferences(request_user, target_user): + return (request_user.is_authenticated() and request_user == target_user) + +def can_view_user_edit(request_user, target_user): + return (request_user.is_authenticated() and request_user == target_user) + +def can_upload_files(request_user): + return (request_user.is_authenticated() and request_user.reputation >= UPLOAD_FILES) or \ + request_user.is_superuser + +########################################### +## actions and reputation changes event +########################################### +def calculate_reputation(origin, offset): + result = int(origin) + int(offset) + if (result > 0): + return result + else: + return 1 + +@transaction.commit_on_success +def onFlaggedItem(item, post, user): + + item.save() + post.offensive_flag_count = post.offensive_flag_count + 1 + post.save() + + post.author.reputation = calculate_reputation(post.author.reputation, + int(REPUTATION_RULES['lose_by_flagged'])) + post.author.save() + + question = post + if ContentType.objects.get_for_model(post) == answer_type: + question = post.question + + reputation = Repute(user=post.author, + negative=int(REPUTATION_RULES['lose_by_flagged']), + question=question, reputed_at=datetime.datetime.now(), + reputation_type=-4, + reputation=post.author.reputation) + reputation.save() + + #todo: These should be updated to work on same revisions. + if post.offensive_flag_count == VOTE_RULES['scope_flags_invisible_main_page'] : + post.author.reputation = calculate_reputation(post.author.reputation, + int(REPUTATION_RULES['lose_by_flagged_lastrevision_3_times'])) + post.author.save() + + reputation = Repute(user=post.author, + negative=int(REPUTATION_RULES['lose_by_flagged_lastrevision_3_times']), + question=question, + reputed_at=datetime.datetime.now(), + reputation_type=-6, + reputation=post.author.reputation) + reputation.save() + + elif post.offensive_flag_count == VOTE_RULES['scope_flags_delete_post']: + post.author.reputation = calculate_reputation(post.author.reputation, + int(REPUTATION_RULES['lose_by_flagged_lastrevision_5_times'])) + post.author.save() + + reputation = Repute(user=post.author, + negative=int(REPUTATION_RULES['lose_by_flagged_lastrevision_5_times']), + question=question, + reputed_at=datetime.datetime.now(), + reputation_type=-7, + reputation=post.author.reputation) + reputation.save() + + post.deleted = True + #post.deleted_at = datetime.datetime.now() + #post.deleted_by = Admin + post.save() + + +@transaction.commit_on_success +def onAnswerAccept(answer, user): + answer.accepted = True + answer.accepted_at = datetime.datetime.now() + answer.question.answer_accepted = True + answer.save() + answer.question.save() + + answer.author.reputation = calculate_reputation(answer.author.reputation, + int(REPUTATION_RULES['gain_by_answer_accepted'])) + answer.author.save() + reputation = Repute(user=answer.author, + positive=int(REPUTATION_RULES['gain_by_answer_accepted']), + question=answer.question, + reputed_at=datetime.datetime.now(), + reputation_type=2, + reputation=answer.author.reputation) + reputation.save() + + user.reputation = calculate_reputation(user.reputation, + int(REPUTATION_RULES['gain_by_accepting_answer'])) + user.save() + reputation = Repute(user=user, + positive=int(REPUTATION_RULES['gain_by_accepting_answer']), + question=answer.question, + reputed_at=datetime.datetime.now(), + reputation_type=3, + reputation=user.reputation) + reputation.save() + +@transaction.commit_on_success +def onAnswerAcceptCanceled(answer, user): + answer.accepted = False + answer.accepted_at = None + answer.question.answer_accepted = False + answer.save() + answer.question.save() + + answer.author.reputation = calculate_reputation(answer.author.reputation, + int(REPUTATION_RULES['lose_by_accepted_answer_cancled'])) + answer.author.save() + reputation = Repute(user=answer.author, + negative=int(REPUTATION_RULES['lose_by_accepted_answer_cancled']), + question=answer.question, + reputed_at=datetime.datetime.now(), + reputation_type=-2, + reputation=answer.author.reputation) + reputation.save() + + user.reputation = calculate_reputation(user.reputation, + int(REPUTATION_RULES['lose_by_canceling_accepted_answer'])) + user.save() + reputation = Repute(user=user, + negative=int(REPUTATION_RULES['lose_by_canceling_accepted_answer']), + question=answer.question, + reputed_at=datetime.datetime.now(), + reputation_type=-1, + reputation=user.reputation) + reputation.save() + +@transaction.commit_on_success +def onUpVoted(vote, post, user): + vote.save() + + post.vote_up_count = int(post.vote_up_count) + 1 + post.score = int(post.score) + 1 + post.save() + + if not post.wiki: + author = post.author + if Repute.objects.get_reputation_by_upvoted_today(author) < int(REPUTATION_RULES['scope_per_day_by_upvotes']): + author.reputation = calculate_reputation(author.reputation, + int(REPUTATION_RULES['gain_by_upvoted'])) + author.save() + + question = post + if ContentType.objects.get_for_model(post) == answer_type: + question = post.question + + reputation = Repute(user=author, + positive=int(REPUTATION_RULES['gain_by_upvoted']), + question=question, + reputed_at=datetime.datetime.now(), + reputation_type=1, + reputation=author.reputation) + reputation.save() + +@transaction.commit_on_success +def onUpVotedCanceled(vote, post, user): + vote.delete() + + post.vote_up_count = int(post.vote_up_count) - 1 + if post.vote_up_count < 0: + post.vote_up_count = 0 + post.score = int(post.score) - 1 + post.save() + + if not post.wiki: + author = post.author + author.reputation = calculate_reputation(author.reputation, + int(REPUTATION_RULES['lose_by_upvote_canceled'])) + author.save() + + question = post + if ContentType.objects.get_for_model(post) == answer_type: + question = post.question + + reputation = Repute(user=author, + negative=int(REPUTATION_RULES['lose_by_upvote_canceled']), + question=question, + reputed_at=datetime.datetime.now(), + reputation_type=-8, + reputation=author.reputation) + reputation.save() + +@transaction.commit_on_success +def onDownVoted(vote, post, user): + vote.save() + + post.vote_down_count = int(post.vote_down_count) + 1 + post.score = int(post.score) - 1 + post.save() + + if not post.wiki: + author = post.author + author.reputation = calculate_reputation(author.reputation, + int(REPUTATION_RULES['lose_by_downvoted'])) + author.save() + + question = post + if ContentType.objects.get_for_model(post) == answer_type: + question = post.question + + reputation = Repute(user=author, + negative=int(REPUTATION_RULES['lose_by_downvoted']), + question=question, + reputed_at=datetime.datetime.now(), + reputation_type=-3, + reputation=author.reputation) + reputation.save() + + user.reputation = calculate_reputation(user.reputation, + int(REPUTATION_RULES['lose_by_downvoting'])) + user.save() + + reputation = Repute(user=user, + negative=int(REPUTATION_RULES['lose_by_downvoting']), + question=question, + reputed_at=datetime.datetime.now(), + reputation_type=-5, + reputation=user.reputation) + reputation.save() + +@transaction.commit_on_success +def onDownVotedCanceled(vote, post, user): + vote.delete() + + post.vote_down_count = int(post.vote_down_count) - 1 + if post.vote_down_count < 0: + post.vote_down_count = 0 + post.score = post.score + 1 + post.save() + + if not post.wiki: + author = post.author + author.reputation = calculate_reputation(author.reputation, + int(REPUTATION_RULES['gain_by_downvote_canceled'])) + author.save() + + question = post + if ContentType.objects.get_for_model(post) == answer_type: + question = post.question + + reputation = Repute(user=author, + positive=int(REPUTATION_RULES['gain_by_downvote_canceled']), + question=question, + reputed_at=datetime.datetime.now(), + reputation_type=4, + reputation=author.reputation) + reputation.save() + + user.reputation = calculate_reputation(user.reputation, + int(REPUTATION_RULES['gain_by_canceling_downvote'])) + user.save() + + reputation = Repute(user=user, + positive=int(REPUTATION_RULES['gain_by_canceling_downvote']), + question=question, + reputed_at=datetime.datetime.now(), + reputation_type=5, + reputation=user.reputation) + reputation.save() + +def onDeleteCanceled(post, user): + post.deleted = False + post.deleted_by = None + post.deleted_at = None + post.save() + logging.debug('now restoring something') + if isinstance(post,Answer): + logging.debug('updated answer count on undelete, have %d' % post.question.answer_count) + Question.objects.update_answer_count(post.question) + elif isinstance(post,Question): + for tag in list(post.tags.all()): + if tag.used_count == 1 and tag.deleted: + tag.deleted = False + tag.deleted_by = None + tag.deleted_at = None + tag.save() + +def onDeleted(post, user): + post.deleted = True + post.deleted_by = user + post.deleted_at = datetime.datetime.now() + post.save() + + if isinstance(post, Question): + for tag in list(post.tags.all()): + if tag.used_count == 1: + tag.deleted = True + tag.deleted_by = user + tag.deleted_at = datetime.datetime.now() + else: + tag.used_count = tag.used_count - 1 + tag.save() + + answers = post.answers.all() + if user == post.author: + if len(answers) > 0: + msg = _('Your question and all of it\'s answers have been deleted') + else: + msg = _('Your question has been deleted') + else: + if len(answers) > 0: + msg = _('The question and all of it\'s answers have been deleted') + else: + msg = _('The question has been deleted') + user.message_set.create(message=msg) + logging.debug('posted a message %s' % msg) + for answer in answers: + onDeleted(answer, user) + elif isinstance(post, Answer): + Question.objects.update_answer_count(post.question) + logging.debug('updated answer count to %d' % post.question.answer_count) diff --git a/forum/authentication/__init__.py b/forum/authentication/__init__.py new file mode 100755 index 0000000..e83ba87 --- /dev/null +++ b/forum/authentication/__init__.py @@ -0,0 +1,27 @@ +import re +from forum.modules import get_modules_script_classes +from forum.authentication.base import AuthenticationConsumer, ConsumerTemplateContext + +class ConsumerAndContext(): + def __init__(self, id, consumer, context): + self.id = id + self.consumer = consumer() + + context.id = id + self.context = context + +consumers = dict([ + (re.sub('AuthConsumer$', '', name).lower(), cls) for name, cls + in get_modules_script_classes('authentication', AuthenticationConsumer).items() + if not re.search('AbstractAuthConsumer$', name) + ]) + +contexts = dict([ + (re.sub('AuthContext$', '', name).lower(), cls) for name, cls + in get_modules_script_classes('authentication', ConsumerTemplateContext).items() + ]) + +AUTH_PROVIDERS = dict([ + (name, ConsumerAndContext(name, consumers[name], contexts[name])) for name in consumers.keys() + if name in contexts + ]) \ No newline at end of file diff --git a/forum/authentication/base.py b/forum/authentication/base.py new file mode 100755 index 0000000..995f7c9 --- /dev/null +++ b/forum/authentication/base.py @@ -0,0 +1,40 @@ + +class AuthenticationConsumer(object): + + def prepare_authentication_request(self, request, redirect_to): + raise NotImplementedError() + + def process_authentication_request(self, response): + raise NotImplementedError() + + def get_user_data(self, key): + raise NotImplementedError() + + +class ConsumerTemplateContext(object): + """ + Class that provides information about a certain authentication provider context in the signin page. + + class attributes: + + mode - one of BIGICON, SMALLICON, FORM + + human_name - the human readable name of the provider + + extra_js - some providers require us to load extra javascript on the signin page for them to work, + this is the place to add those files in the form of a list + + extra_css - same as extra_js but for css files + """ + mode = '' + weight = 500 + human_name = '' + extra_js = [] + extra_css = [] + show_to_logged_in_user = True + +class InvalidAuthentication(Exception): + def __init__(self, message): + self.message = message + + \ No newline at end of file diff --git a/forum/authentication/forms.py b/forum/authentication/forms.py new file mode 100755 index 0000000..0484134 --- /dev/null +++ b/forum/authentication/forms.py @@ -0,0 +1,31 @@ +from forum.utils.forms import NextUrlField, UserNameField, UserEmailField +from forum.models import EmailFeedSetting, Question +from django.contrib.contenttypes.models import ContentType +from django.utils.translation import ugettext as _ +from django import forms +from forum.forms import EditUserEmailFeedsForm +import logging + +class SimpleRegistrationForm(forms.Form): + next = NextUrlField() + username = UserNameField() + email = UserEmailField() + + +class SimpleEmailSubscribeForm(forms.Form): + SIMPLE_SUBSCRIBE_CHOICES = ( + ('y',_('okay, let\'s try!')), + ('n',_('no OSQA community email please, thanks')) + ) + subscribe = forms.ChoiceField(widget=forms.widgets.RadioSelect(), \ + error_messages={'required':_('please choose one of the options above')}, + choices=SIMPLE_SUBSCRIBE_CHOICES) + + def save(self,user=None): + EFF = EditUserEmailFeedsForm + if self.cleaned_data['subscribe'] == 'y': + email_settings_form = EFF() + logging.debug('%s wants to subscribe' % user.username) + else: + email_settings_form = EFF(initial=EFF.NO_EMAIL_INITIAL) + email_settings_form.save(user,save_unbound=True) diff --git a/forum/const.py b/forum/const.py new file mode 100644 index 0000000..76fd4a2 --- /dev/null +++ b/forum/const.py @@ -0,0 +1,92 @@ +# encoding:utf-8 +from django.utils.translation import ugettext as _ +""" +All constants could be used in other modules +For reasons that models, views can't have unicode text in this project, all unicode text go here. +""" +CLOSE_REASONS = ( + (1, _('duplicate question')), + (2, _('question is off-topic or not relevant')), + (3, _('too subjective and argumentative')), + (4, _('is not an answer to the question')), + (5, _('the question is answered, right answer was accepted')), + (6, _('problem is not reproducible or outdated')), + #(7, u'太局部、本地化的问题',) + (7, _('question contains offensive inappropriate, or malicious remarks')), + (8, _('spam or advertising')), +) + +TYPE_REPUTATION = ( + (1, 'gain_by_upvoted'), + (2, 'gain_by_answer_accepted'), + (3, 'gain_by_accepting_answer'), + (4, 'gain_by_downvote_canceled'), + (5, 'gain_by_canceling_downvote'), + (-1, 'lose_by_canceling_accepted_answer'), + (-2, 'lose_by_accepted_answer_cancled'), + (-3, 'lose_by_downvoted'), + (-4, 'lose_by_flagged'), + (-5, 'lose_by_downvoting'), + (-6, 'lose_by_flagged_lastrevision_3_times'), + (-7, 'lose_by_flagged_lastrevision_5_times'), + (-8, 'lose_by_upvote_canceled'), +) + +TYPE_ACTIVITY_ASK_QUESTION=1 +TYPE_ACTIVITY_ANSWER=2 +TYPE_ACTIVITY_COMMENT_QUESTION=3 +TYPE_ACTIVITY_COMMENT_ANSWER=4 +TYPE_ACTIVITY_UPDATE_QUESTION=5 +TYPE_ACTIVITY_UPDATE_ANSWER=6 +TYPE_ACTIVITY_PRIZE=7 +TYPE_ACTIVITY_MARK_ANSWER=8 +TYPE_ACTIVITY_VOTE_UP=9 +TYPE_ACTIVITY_VOTE_DOWN=10 +TYPE_ACTIVITY_CANCEL_VOTE=11 +TYPE_ACTIVITY_DELETE_QUESTION=12 +TYPE_ACTIVITY_DELETE_ANSWER=13 +TYPE_ACTIVITY_MARK_OFFENSIVE=14 +TYPE_ACTIVITY_UPDATE_TAGS=15 +TYPE_ACTIVITY_FAVORITE=16 +TYPE_ACTIVITY_USER_FULL_UPDATED = 17 +TYPE_ACTIVITY_QUESTION_EMAIL_UPDATE_SENT = 18 +#TYPE_ACTIVITY_EDIT_QUESTION=17 +#TYPE_ACTIVITY_EDIT_ANSWER=18 + +TYPE_ACTIVITY = ( + (TYPE_ACTIVITY_ASK_QUESTION, _('question')), + (TYPE_ACTIVITY_ANSWER, _('answer')), + (TYPE_ACTIVITY_COMMENT_QUESTION, _('commented question')), + (TYPE_ACTIVITY_COMMENT_ANSWER, _('commented answer')), + (TYPE_ACTIVITY_UPDATE_QUESTION, _('edited question')), + (TYPE_ACTIVITY_UPDATE_ANSWER, _('edited answer')), + (TYPE_ACTIVITY_PRIZE, _('received award')), + (TYPE_ACTIVITY_MARK_ANSWER, _('marked best answer')), + (TYPE_ACTIVITY_VOTE_UP, _('upvoted')), + (TYPE_ACTIVITY_VOTE_DOWN, _('downvoted')), + (TYPE_ACTIVITY_CANCEL_VOTE, _('canceled vote')), + (TYPE_ACTIVITY_DELETE_QUESTION, _('deleted question')), + (TYPE_ACTIVITY_DELETE_ANSWER, _('deleted answer')), + (TYPE_ACTIVITY_MARK_OFFENSIVE, _('marked offensive')), + (TYPE_ACTIVITY_UPDATE_TAGS, _('updated tags')), + (TYPE_ACTIVITY_FAVORITE, _('selected favorite')), + (TYPE_ACTIVITY_USER_FULL_UPDATED, _('completed user profile')), + (TYPE_ACTIVITY_QUESTION_EMAIL_UPDATE_SENT, _('email update sent to user')), +) + +TYPE_RESPONSE = { + 'QUESTION_ANSWERED' : 'question_answered', + 'QUESTION_COMMENTED': 'question_commented', + 'ANSWER_COMMENTED' : 'answer_commented', + 'ANSWER_ACCEPTED' : 'answer_accepted', +} + +CONST = { + 'closed' : _('[closed]'), + 'deleted' : _('[deleted]'), + 'default_version' : _('initial version'), + 'retagged' : _('retagged'), +} + +#how to filter questions by tags in email digests? +TAG_EMAIL_FILTER_CHOICES = (('ignored', _('exclude ignored tags')),('interesting',_('allow only selected tags'))) diff --git a/forum/feed.py b/forum/feed.py new file mode 100644 index 0000000..e4b929e --- /dev/null +++ b/forum/feed.py @@ -0,0 +1,43 @@ +#!/usr/bin/env python +#encoding:utf-8 +#------------------------------------------------------------------------------- +# Name: Syndication feed class for subsribtion +# Purpose: +# +# Author: Mike +# +# Created: 29/01/2009 +# Copyright: (c) CNPROG.COM 2009 +# Licence: GPL V2 +#------------------------------------------------------------------------------- +from django.contrib.syndication.feeds import Feed, FeedDoesNotExist +from django.utils.translation import ugettext as _ +from models import Question +from django.conf import settings +class RssLastestQuestionsFeed(Feed): + title = settings.APP_TITLE + _(' - ')+ _('latest questions') + link = settings.APP_URL #+ '/' + _('question/') + description = settings.APP_DESCRIPTION + #ttl = 10 + copyright = settings.APP_COPYRIGHT + + def item_link(self, item): + return self.link + item.get_absolute_url() + + def item_author_name(self, item): + return item.author.username + + def item_author_link(self, item): + return item.author.get_profile_url() + + def item_pubdate(self, item): + return item.added_at + + def items(self, item): + return Question.objects.filter(deleted=False).order_by('-last_activity_at')[:30] + +def main(): + pass + +if __name__ == '__main__': + main() diff --git a/forum/forms.py b/forum/forms.py new file mode 100644 index 0000000..c157aa4 --- /dev/null +++ b/forum/forms.py @@ -0,0 +1,359 @@ +import re +from datetime import date +from django import forms +from models import * +from const import * +from django.utils.translation import ugettext as _ +from django.contrib.auth.models import User +from django.contrib.contenttypes.models import ContentType +from django.utils.safestring import mark_safe +from forum.utils.forms import NextUrlField, UserNameField, SetPasswordForm +from recaptcha_django import ReCaptchaField +from django.conf import settings +import logging + +class TitleField(forms.CharField): + def __init__(self, *args, **kwargs): + super(TitleField, self).__init__(*args, **kwargs) + self.required = True + self.widget = forms.TextInput(attrs={'size' : 70, 'autocomplete' : 'off'}) + self.max_length = 255 + self.label = _('title') + self.help_text = _('please enter a descriptive title for your question') + self.initial = '' + + def clean(self, value): + if len(value) < 10: + raise forms.ValidationError(_('title must be > 10 characters')) + + return value + +class EditorField(forms.CharField): + def __init__(self, *args, **kwargs): + super(EditorField, self).__init__(*args, **kwargs) + self.required = True + self.widget = forms.Textarea(attrs={'id':'editor'}) + self.label = _('content') + self.help_text = u'' + self.initial = '' + + def clean(self, value): + if len(value) < 10: + raise forms.ValidationError(_('question content must be > 10 characters')) + + return value + +class TagNamesField(forms.CharField): + def __init__(self, *args, **kwargs): + super(TagNamesField, self).__init__(*args, **kwargs) + self.required = True + self.widget = forms.TextInput(attrs={'size' : 50, 'autocomplete' : 'off'}) + self.max_length = 255 + self.label = _('tags') + #self.help_text = _('please use space to separate tags (this enables autocomplete feature)') + self.help_text = _('Tags are short keywords, with no spaces within. Up to five tags can be used.') + self.initial = '' + + def clean(self, value): + value = super(TagNamesField, self).clean(value) + data = value.strip() + if len(data) < 1: + raise forms.ValidationError(_('tags are required')) + + split_re = re.compile(r'[ ,]+') + list = split_re.split(data) + list_temp = [] + if len(list) > 5: + raise forms.ValidationError(_('please use 5 tags or less')) + for tag in list: + if len(tag) > 20: + raise forms.ValidationError(_('tags must be shorter than 20 characters')) + #take tag regex from settings + tagname_re = re.compile(r'[a-z0-9]+') + if not tagname_re.match(tag): + raise forms.ValidationError(_('please use following characters in tags: letters \'a-z\', numbers, and characters \'.-_#\'')) + # only keep one same tag + if tag not in list_temp and len(tag.strip()) > 0: + list_temp.append(tag) + return u' '.join(list_temp) + +class WikiField(forms.BooleanField): + def __init__(self, *args, **kwargs): + super(WikiField, self).__init__(*args, **kwargs) + self.required = False + self.label = _('community wiki') + self.help_text = _('if you choose community wiki option, the question and answer do not generate points and name of author will not be shown') + def clean(self,value): + return value and settings.WIKI_ON + +class EmailNotifyField(forms.BooleanField): + def __init__(self, *args, **kwargs): + super(EmailNotifyField, self).__init__(*args, **kwargs) + self.required = False + self.widget.attrs['class'] = 'nomargin' + +class SummaryField(forms.CharField): + def __init__(self, *args, **kwargs): + super(SummaryField, self).__init__(*args, **kwargs) + self.required = False + self.widget = forms.TextInput(attrs={'size' : 50, 'autocomplete' : 'off'}) + self.max_length = 300 + self.label = _('update summary:') + self.help_text = _('enter a brief summary of your revision (e.g. fixed spelling, grammar, improved style, this field is optional)') + +class ModerateUserForm(forms.ModelForm): + is_approved = forms.BooleanField(label=_("Automatically accept user's contributions for the email updates"), + required=False) + + def clean_is_approved(self): + if 'is_approved' not in self.cleaned_data: + self.cleaned_data['is_approved'] = False + return self.cleaned_data['is_approved'] + + class Meta: + model = User + fields = ('is_approved',) + +class NotARobotForm(forms.Form): + recaptcha = ReCaptchaField() + +class FeedbackForm(forms.Form): + name = forms.CharField(label=_('Your name:'), required=False) + email = forms.EmailField(label=_('Email (not shared with anyone):'), required=False) + message = forms.CharField(label=_('Your message:'), max_length=800,widget=forms.Textarea(attrs={'cols':60})) + next = NextUrlField() + +class AskForm(forms.Form): + title = TitleField() + text = EditorField() + tags = TagNamesField() + wiki = WikiField() + + openid = forms.CharField(required=False, max_length=255, widget=forms.TextInput(attrs={'size' : 40, 'class':'openid-input'})) + user = forms.CharField(required=False, max_length=255, widget=forms.TextInput(attrs={'size' : 35})) + email = forms.CharField(required=False, max_length=255, widget=forms.TextInput(attrs={'size' : 35})) + +class AnswerForm(forms.Form): + text = EditorField() + wiki = WikiField() + openid = forms.CharField(required=False, max_length=255, widget=forms.TextInput(attrs={'size' : 40, 'class':'openid-input'})) + user = forms.CharField(required=False, max_length=255, widget=forms.TextInput(attrs={'size' : 35})) + email = forms.CharField(required=False, max_length=255, widget=forms.TextInput(attrs={'size' : 35})) + email_notify = EmailNotifyField() + def __init__(self, question, user, *args, **kwargs): + super(AnswerForm, self).__init__(*args, **kwargs) + self.fields['email_notify'].widget.attrs['id'] = 'question-subscribe-updates'; + if question.wiki and settings.WIKI_ON: + self.fields['wiki'].initial = True + if user.is_authenticated(): + if user in question.followed_by.all(): + self.fields['email_notify'].initial = True + return + self.fields['email_notify'].initial = False + + +class CloseForm(forms.Form): + reason = forms.ChoiceField(choices=CLOSE_REASONS) + +class RetagQuestionForm(forms.Form): + tags = TagNamesField() + # initialize the default values + def __init__(self, question, *args, **kwargs): + super(RetagQuestionForm, self).__init__(*args, **kwargs) + self.fields['tags'].initial = question.tagnames + +class RevisionForm(forms.Form): + """ + Lists revisions of a Question or Answer + """ + revision = forms.ChoiceField(widget=forms.Select(attrs={'style' : 'width:520px'})) + + def __init__(self, post, latest_revision, *args, **kwargs): + super(RevisionForm, self).__init__(*args, **kwargs) + revisions = post.revisions.all().values_list( + 'revision', 'author__username', 'revised_at', 'summary') + date_format = '%c' + self.fields['revision'].choices = [ + (r[0], u'%s - %s (%s) %s' % (r[0], r[1], r[2].strftime(date_format), r[3])) + for r in revisions] + self.fields['revision'].initial = latest_revision.revision + +class EditQuestionForm(forms.Form): + title = TitleField() + text = EditorField() + tags = TagNamesField() + summary = SummaryField() + + def __init__(self, question, revision, *args, **kwargs): + super(EditQuestionForm, self).__init__(*args, **kwargs) + self.fields['title'].initial = revision.title + self.fields['text'].initial = revision.text + self.fields['tags'].initial = revision.tagnames + # Once wiki mode is enabled, it can't be disabled + if not question.wiki: + self.fields['wiki'] = WikiField() + +class EditAnswerForm(forms.Form): + text = EditorField() + summary = SummaryField() + + def __init__(self, answer, revision, *args, **kwargs): + super(EditAnswerForm, self).__init__(*args, **kwargs) + self.fields['text'].initial = revision.text + +class EditUserForm(forms.Form): + email = forms.EmailField(label=u'Email', help_text=_('this email does not have to be linked to gravatar'), required=True, max_length=255, widget=forms.TextInput(attrs={'size' : 35})) + if settings.EDITABLE_SCREEN_NAME: + username = UserNameField(label=_('Screen name')) + realname = forms.CharField(label=_('Real name'), required=False, max_length=255, widget=forms.TextInput(attrs={'size' : 35})) + website = forms.URLField(label=_('Website'), required=False, max_length=255, widget=forms.TextInput(attrs={'size' : 35})) + city = forms.CharField(label=_('Location'), required=False, max_length=255, widget=forms.TextInput(attrs={'size' : 35})) + birthday = forms.DateField(label=_('Date of birth'), help_text=_('will not be shown, used to calculate age, format: YYYY-MM-DD'), required=False, widget=forms.TextInput(attrs={'size' : 35})) + about = forms.CharField(label=_('Profile'), required=False, widget=forms.Textarea(attrs={'cols' : 60})) + + def __init__(self, user, *args, **kwargs): + super(EditUserForm, self).__init__(*args, **kwargs) + logging.debug('initializing the form') + if settings.EDITABLE_SCREEN_NAME: + self.fields['username'].initial = user.username + self.fields['username'].user_instance = user + self.fields['email'].initial = user.email + self.fields['realname'].initial = user.real_name + self.fields['website'].initial = user.website + self.fields['city'].initial = user.location + + if user.date_of_birth is not None: + self.fields['birthday'].initial = user.date_of_birth + else: + self.fields['birthday'].initial = '1990-01-01' + self.fields['about'].initial = user.about + self.user = user + + def clean_email(self): + """For security reason one unique email in database""" + if self.user.email != self.cleaned_data['email']: + #todo dry it, there is a similar thing in openidauth + if settings.EMAIL_UNIQUE == True: + if 'email' in self.cleaned_data: + try: + user = User.objects.get(email = self.cleaned_data['email']) + except User.DoesNotExist: + return self.cleaned_data['email'] + except User.MultipleObjectsReturned: + raise forms.ValidationError(_('this email has already been registered, please use another one')) + raise forms.ValidationError(_('this email has already been registered, please use another one')) + return self.cleaned_data['email'] + +class TagFilterSelectionForm(forms.ModelForm): + tag_filter_setting = forms.ChoiceField(choices=TAG_EMAIL_FILTER_CHOICES, #imported from forum/const.py + initial='ignored', + label=_('Choose email tag filter'), + widget=forms.RadioSelect) + class Meta: + model = User + fields = ('tag_filter_setting',) + + def save(self): + before = self.instance.tag_filter_setting + super(TagFilterSelectionForm, self).save() + after = self.instance.tag_filter_setting #User.objects.get(pk=self.instance.id).tag_filter_setting + if before != after: + return True + return False + + +class ChangePasswordForm(SetPasswordForm): + """ change password form """ + oldpw = forms.CharField(widget=forms.PasswordInput(attrs={'class':'required'}), + label=mark_safe(_('Current password'))) + + def __init__(self, data=None, user=None, *args, **kwargs): + if user is None: + raise TypeError("Keyword argument 'user' must be supplied") + super(ChangePasswordForm, self).__init__(data, *args, **kwargs) + self.user = user + + def clean_oldpw(self): + """ test old password """ + if not self.user.check_password(self.cleaned_data['oldpw']): + raise forms.ValidationError(_("Old password is incorrect. \ + Please enter the correct password.")) + return self.cleaned_data['oldpw'] + +class EditUserEmailFeedsForm(forms.Form): + WN = (('w',_('weekly')),('n',_('no email'))) + DWN = (('d',_('daily')),('w',_('weekly')),('n',_('no email'))) + FORM_TO_MODEL_MAP = { + 'all_questions':'q_all', + 'asked_by_me':'q_ask', + 'answered_by_me':'q_ans', + 'individually_selected':'q_sel', + } + NO_EMAIL_INITIAL = { + 'all_questions':'n', + 'asked_by_me':'n', + 'answered_by_me':'n', + 'individually_selected':'n', + } + asked_by_me = forms.ChoiceField(choices=DWN,initial='w', + widget=forms.RadioSelect, + label=_('Asked by me')) + answered_by_me = forms.ChoiceField(choices=DWN,initial='w', + widget=forms.RadioSelect, + label=_('Answered by me')) + individually_selected = forms.ChoiceField(choices=DWN,initial='w', + widget=forms.RadioSelect, + label=_('Individually selected')) + all_questions = forms.ChoiceField(choices=DWN,initial='w', + widget=forms.RadioSelect, + label=_('Entire forum (tag filtered)'),) + + def set_initial_values(self,user=None): + KEY_MAP = dict([(v,k) for k,v in self.FORM_TO_MODEL_MAP.iteritems()]) + if user != None: + settings = EmailFeedSetting.objects.filter(subscriber=user) + initial_values = {} + for setting in settings: + feed_type = setting.feed_type + form_field = KEY_MAP[feed_type] + frequency = setting.frequency + initial_values[form_field] = frequency + self.initial = initial_values + return self + + def reset(self): + self.cleaned_data['all_questions'] = 'n' + self.cleaned_data['asked_by_me'] = 'n' + self.cleaned_data['answered_by_me'] = 'n' + self.cleaned_data['individually_selected'] = 'n' + self.initial = self.NO_EMAIL_INITIAL + return self + + def save(self,user,save_unbound=False): + """ + with save_unbound==True will bypass form validation and save initial values + """ + changed = False + for form_field, feed_type in self.FORM_TO_MODEL_MAP.items(): + s, created = EmailFeedSetting.objects.get_or_create(subscriber=user,\ + feed_type=feed_type) + if save_unbound: + #just save initial values instead + if form_field in self.initial: + new_value = self.initial[form_field] + else: + new_value = self.fields[form_field].initial + else: + new_value = self.cleaned_data[form_field] + if s.frequency != new_value: + s.frequency = new_value + s.save() + changed = True + else: + if created: + s.save() + if form_field == 'individually_selected': + feed_type = ContentType.objects.get_for_model(Question) + user.followed_questions.clear() + return changed + diff --git a/forum/management/__init__.py b/forum/management/__init__.py new file mode 100644 index 0000000..8266592 --- /dev/null +++ b/forum/management/__init__.py @@ -0,0 +1,3 @@ +from forum.modules import get_modules_script + +get_modules_script('management') \ No newline at end of file diff --git a/forum/management/commands/__init__.py b/forum/management/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/forum/management/commands/base_command.py b/forum/management/commands/base_command.py new file mode 100644 index 0000000..c073bf7 --- /dev/null +++ b/forum/management/commands/base_command.py @@ -0,0 +1,35 @@ +#!/usr/bin/env python +#encoding:utf-8 +#------------------------------------------------------------------------------- +# Name: Award badges command +# Purpose: This is a command file croning in background process regularly to +# query database and award badges for user's special acitivities. +# +# Author: Mike, Sailing +# +# Created: 22/01/2009 +# Copyright: (c) Mike 2009 +# Licence: GPL V2 +#------------------------------------------------------------------------------- + +from datetime import datetime, date +from django.core.management.base import NoArgsCommand +from django.db import connection +from django.shortcuts import get_object_or_404 +from django.contrib.contenttypes.models import ContentType + +from forum.models import * +from forum.const import * + +class BaseCommand(NoArgsCommand): + def update_activities_auditted(self, cursor, activity_ids): + # update processed rows to auditted + if len(activity_ids): + query = "UPDATE activity SET is_auditted = 1 WHERE id in (%s)"\ + % ','.join('%s' % item for item in activity_ids) + cursor.execute(query) + + + + + diff --git a/forum/management/commands/clean_award_badges.py b/forum/management/commands/clean_award_badges.py new file mode 100644 index 0000000..117e3a5 --- /dev/null +++ b/forum/management/commands/clean_award_badges.py @@ -0,0 +1,59 @@ +#------------------------------------------------------------------------------- +# Name: Award badges command +# Purpose: This is a command file croning in background process regularly to +# query database and award badges for user's special acitivities. +# +# Author: Mike +# +# Created: 18/01/2009 +# Copyright: (c) Mike 2009 +# Licence: GPL V2 +#------------------------------------------------------------------------------- +#!/usr/bin/env python +#encoding:utf-8 +from django.core.management.base import NoArgsCommand +from django.db import connection +from django.shortcuts import get_object_or_404 +from django.contrib.contenttypes.models import ContentType + +from forum.models import * + +class Command(NoArgsCommand): + def handle_noargs(self, **options): + try: + try: + self.clean_awards() + except Exception, e: + print e + finally: + connection.close() + + def clean_awards(self): + Award.objects.all().delete() + + award_type =ContentType.objects.get_for_model(Award) + Activity.objects.filter(content_type=award_type).delete() + + for user in User.objects.all(): + user.gold = 0 + user.silver = 0 + user.bronze = 0 + user.save() + + for badge in Badge.objects.all(): + badge.awarded_count = 0 + badge.save() + + query = "UPDATE activity SET is_auditted = 0" + cursor = connection.cursor() + try: + cursor.execute(query) + finally: + cursor.close() + connection.close() + +def main(): + pass + +if __name__ == '__main__': + main() \ No newline at end of file diff --git a/forum/management/commands/message_to_everyone.py b/forum/management/commands/message_to_everyone.py new file mode 100644 index 0000000..c020c17 --- /dev/null +++ b/forum/management/commands/message_to_everyone.py @@ -0,0 +1,12 @@ +from django.core.management.base import NoArgsCommand +from django.contrib.auth.models import User +import sys + +class Command(NoArgsCommand): + def handle_noargs(self, **options): + msg = None + if msg == None: + print 'to run this command, please first edit the file %s' % __file__ + sys.exit(1) + for u in User.objects.all(): + u.message_set.create(message = msg % u.username) diff --git a/forum/management/commands/multi_award_badges.py b/forum/management/commands/multi_award_badges.py new file mode 100644 index 0000000..6b330cf --- /dev/null +++ b/forum/management/commands/multi_award_badges.py @@ -0,0 +1,348 @@ +#!/usr/bin/env python +#encoding:utf-8 +#------------------------------------------------------------------------------- +# Name: Award badges command +# Purpose: This is a command file croning in background process regularly to +# query database and award badges for user's special acitivities. +# +# Author: Mike, Sailing +# +# Created: 22/01/2009 +# Copyright: (c) Mike 2009 +# Licence: GPL V2 +#------------------------------------------------------------------------------- + +from datetime import datetime, date +from django.core.management.base import NoArgsCommand +from django.db import connection +from django.shortcuts import get_object_or_404 +from django.contrib.contenttypes.models import ContentType + +from forum.models import * +from forum.const import * +from base_command import BaseCommand +""" +(1, '炼狱法师', 3, '炼狱法师', '删除自己有3个以上赞成票的帖子', 1, 0), +(2, '压力白领', 3, '压力白领', '删除自己有3个以上反对票的帖子', 1, 0), +(3, '优秀回答', 3, '优秀回答', '回答好评10次以上', 1, 0), +(4, '优秀问题', 3, '优秀问题', '问题好评10次以上', 1, 0), +(5, '评论家', 3, '评论家', '评论10次以上', 0, 0), +(6, '流行问题', 3, '流行问题', '问题的浏览量超过1000人次', 1, 0), +(7, '巡逻兵', 3, '巡逻兵', '第一次标记垃圾帖子', 0, 0), +(8, '清洁工', 3, '清洁工', '第一次撤销投票', 0, 0), +(9, '批评家', 3, '批评家', '第一次反对票', 0, 0), +(10, '小编', 3, '小编', '第一次编辑更新', 0, 0), +(11, '村长', 3, '村长', '第一次重新标签', 0, 0), +(12, '学者', 3, '学者', '第一次标记答案', 0, 0), +(13, '学生', 3, '学生', '第一次提问并且有一次以上赞成票', 0, 0), +(14, '支持者', 3, '支持者', '第一次赞成票', 0, 0), +(15, '教师', 3, '教师', '第一次回答问题并且得到一个以上赞成票', 0, 0), +(16, '自传作者', 3, '自传作者', '完整填写用户资料所有选项', 0, 0), +(17, '自学成才', 3, '自学成才', '回答自己的问题并且有3个以上赞成票', 1, 0), +(18, '最有价值回答', 1, '最有价值回答', '回答超过100次赞成票', 1, 0), +(19, '最有价值问题', 1, '最有价值问题', '问题超过100次赞成票', 1, 0), +(20, '万人迷', 1, '万人迷', '问题被100人以上收藏', 1, 0), +(21, '著名问题', 1, '著名问题', '问题的浏览量超过10000人次', 1, 0), +(22, 'alpha用户', 2, 'alpha用户', '内测期间的活跃用户', 0, 0), +(23, '极好回答', 2, '极好回答', '回答超过25次赞成票', 1, 0), +(24, '极好问题', 2, '极好问题', '问题超过25次赞成票', 1, 0), +(25, '受欢迎问题', 2, '受欢迎问题', '问题被25人以上收藏', 1, 0), +(26, '优秀市民', 2, '优秀市民', '投票300次以上', 0, 0), +(27, '编辑主任', 2, '编辑主任', '编辑了100个帖子', 0, 0), +(28, '通才', 2, '通才', '在多个标签领域活跃', 0, 0), +(29, '专家', 2, '专家', '在一个标签领域活跃出众', 0, 0), +(30, '老鸟', 2, '老鸟', '活跃超过一年的用户', 0, 0), +(31, '最受关注问题', 2, '最受关注问题', '问题的浏览量超过2500人次', 1, 0), +(32, '学问家', 2, '学问家', '第一次回答被投赞成票10次以上', 0, 0), +(33, 'beta用户', 2, 'beta用户', 'beta期间活跃参与', 0, 0), +(34, '导师', 2, '导师', '被指定为最佳答案并且赞成票40以上', 1, 0), +(35, '巫师', 2, '巫师', '在提问60天之后回答并且赞成票5次以上', 1, 0), +(36, '分类专家', 2, '分类专家', '创建的标签被50个以上问题使用', 1, 0); + + +TYPE_ACTIVITY_ASK_QUESTION=1 +TYPE_ACTIVITY_ANSWER=2 +TYPE_ACTIVITY_COMMENT_QUESTION=3 +TYPE_ACTIVITY_COMMENT_ANSWER=4 +TYPE_ACTIVITY_UPDATE_QUESTION=5 +TYPE_ACTIVITY_UPDATE_ANSWER=6 +TYPE_ACTIVITY_PRIZE=7 +TYPE_ACTIVITY_MARK_ANSWER=8 +TYPE_ACTIVITY_VOTE_UP=9 +TYPE_ACTIVITY_VOTE_DOWN=10 +TYPE_ACTIVITY_CANCEL_VOTE=11 +TYPE_ACTIVITY_DELETE_QUESTION=12 +TYPE_ACTIVITY_DELETE_ANSWER=13 +TYPE_ACTIVITY_MARK_OFFENSIVE=14 +TYPE_ACTIVITY_UPDATE_TAGS=15 +TYPE_ACTIVITY_FAVORITE=16 +TYPE_ACTIVITY_USER_FULL_UPDATED = 17 +""" + +class Command(BaseCommand): + def handle_noargs(self, **options): + try: + try: + self.delete_question_be_voted_up_3() + self.delete_answer_be_voted_up_3() + self.delete_question_be_vote_down_3() + self.delete_answer_be_voted_down_3() + self.answer_be_voted_up_10() + self.question_be_voted_up_10() + self.question_view_1000() + self.answer_self_question_be_voted_up_3() + self.answer_be_voted_up_100() + self.question_be_voted_up_100() + self.question_be_favorited_100() + self.question_view_10000() + self.answer_be_voted_up_25() + self.question_be_voted_up_25() + self.question_be_favorited_25() + self.question_view_2500() + self.answer_be_accepted_and_voted_up_40() + self.question_be_answered_after_60_days_and_be_voted_up_5() + self.created_tag_be_used_in_question_50() + except Exception, e: + print e + finally: + connection.close() + + def delete_question_be_voted_up_3(self): + """ + (1, '炼狱法师', 3, '炼狱法师', '删除自己有3个以上赞成票的帖子', 1, 0), + """ + query = "SELECT act.id, act.user_id, act.object_id FROM activity act, question q WHERE act.object_id = q.id AND\ + act.activity_type = %s AND\ + q.vote_up_count >=3 AND \ + act.is_auditted = 0" % (TYPE_ACTIVITY_DELETE_QUESTION) + self.__process_activities_badge(query, 1, Question) + + def delete_answer_be_voted_up_3(self): + """ + (1, '炼狱法师', 3, '炼狱法师', '删除自己有3个以上赞成票的帖子', 1, 0), + """ + query = "SELECT act.id, act.user_id, act.object_id FROM activity act, answer an WHERE act.object_id = an.id AND\ + act.activity_type = %s AND\ + an.vote_up_count >=3 AND \ + act.is_auditted = 0" % (TYPE_ACTIVITY_DELETE_ANSWER) + self.__process_activities_badge(query, 1, Answer) + + def delete_question_be_vote_down_3(self): + """ + (2, '压力白领', 3, '压力白领', '删除自己有3个以上反对票的帖子', 1, 0), + """ + query = "SELECT act.id, act.user_id, act.object_id FROM activity act, question q WHERE act.object_id = q.id AND\ + act.activity_type = %s AND\ + q.vote_down_count >=3 AND \ + act.is_auditted = 0" % (TYPE_ACTIVITY_DELETE_QUESTION) + content_type = ContentType.objects.get_for_model(Question) + self.__process_activities_badge(query, 2, Question) + + def delete_answer_be_voted_down_3(self): + """ + (2, '压力白领', 3, '压力白领', '删除自己有3个以上反对票的帖子', 1, 0), + """ + query = "SELECT act.id, act.user_id, act.object_id FROM activity act, answer an WHERE act.object_id = an.id AND\ + act.activity_type = %s AND\ + an.vote_down_count >=3 AND \ + act.is_auditted = 0" % (TYPE_ACTIVITY_DELETE_ANSWER) + self.__process_activities_badge(query, 2, Answer) + + def answer_be_voted_up_10(self): + """ + (3, '优秀回答', 3, '优秀回答', '回答好评10次以上', 1, 0), + """ + query = "SELECT act.id, act.user_id, act.object_id FROM \ + activity act, answer a WHERE act.object_id = a.id AND\ + act.activity_type = %s AND \ + a.vote_up_count >= 10 AND\ + act.is_auditted = 0" % (TYPE_ACTIVITY_ANSWER) + self.__process_activities_badge(query, 3, Answer) + + def question_be_voted_up_10(self): + """ + (4, '优秀问题', 3, '优秀问题', '问题好评10次以上', 1, 0), + """ + query = "SELECT act.id, act.user_id, act.object_id FROM \ + activity act, question q WHERE act.object_id = q.id AND\ + act.activity_type = %s AND \ + q.vote_up_count >= 10 AND\ + act.is_auditted = 0" % (TYPE_ACTIVITY_ASK_QUESTION) + self.__process_activities_badge(query, 4, Question) + + def question_view_1000(self): + """ + (6, '流行问题', 3, '流行问题', '问题的浏览量超过1000人次', 1, 0), + """ + query = "SELECT act.id, act.user_id, act.object_id FROM \ + activity act, question q WHERE act.activity_type = %s AND\ + act.object_id = q.id AND \ + q.view_count >= 1000 AND\ + act.object_id NOT IN \ + (SELECT object_id FROM award WHERE award.badge_id = %s)" % (TYPE_ACTIVITY_ASK_QUESTION, 6) + self.__process_activities_badge(query, 6, Question, False) + + def answer_self_question_be_voted_up_3(self): + """ + (17, '自学成才', 3, '自学成才', '回答自己的问题并且有3个以上赞成票', 1, 0), + """ + query = "SELECT act.id, act.user_id, act.object_id FROM \ + activity act, answer an WHERE act.activity_type = %s AND\ + act.object_id = an.id AND\ + an.vote_up_count >= 3 AND\ + act.user_id = (SELECT user_id FROM question q WHERE q.id = an.question_id) AND\ + act.object_id NOT IN \ + (SELECT object_id FROM award WHERE award.badge_id = %s)" % (TYPE_ACTIVITY_ANSWER, 17) + self.__process_activities_badge(query, 17, Question, False) + + def answer_be_voted_up_100(self): + """ + (18, '最有价值回答', 1, '最有价值回答', '回答超过100次赞成票', 1, 0), + """ + query = "SELECT an.id, an.author_id FROM answer an WHERE an.vote_up_count >= 100 AND an.id NOT IN \ + (SELECT object_id FROM award WHERE award.badge_id = %s)" % (18) + + self.__process_badge(query, 18, Answer) + + def question_be_voted_up_100(self): + """ + (19, '最有价值问题', 1, '最有价值问题', '问题超过100次赞成票', 1, 0), + """ + query = "SELECT q.id, q.author_id FROM question q WHERE q.vote_up_count >= 100 AND q.id NOT IN \ + (SELECT object_id FROM award WHERE award.badge_id = %s)" % (19) + + self.__process_badge(query, 19, Question) + + def question_be_favorited_100(self): + """ + (20, '万人迷', 1, '万人迷', '问题被100人以上收藏', 1, 0), + """ + query = "SELECT q.id, q.author_id FROM question q WHERE q.favourite_count >= 100 AND q.id NOT IN \ + (SELECT object_id FROM award WHERE award.badge_id = %s)" % (20) + + self.__process_badge(query, 20, Question) + + def question_view_10000(self): + """ + (21, '著名问题', 1, '著名问题', '问题的浏览量超过10000人次', 1, 0), + """ + query = "SELECT q.id, q.author_id FROM question q WHERE q.view_count >= 10000 AND q.id NOT IN \ + (SELECT object_id FROM award WHERE award.badge_id = %s)" % (21) + + self.__process_badge(query, 21, Question) + + def answer_be_voted_up_25(self): + """ + (23, '极好回答', 2, '极好回答', '回答超过25次赞成票', 1, 0), + """ + query = "SELECT a.id, a.author_id FROM answer a WHERE a.vote_up_count >= 25 AND a.id NOT IN \ + (SELECT object_id FROM award WHERE award.badge_id = %s)" % (23) + + self.__process_badge(query, 23, Answer) + + def question_be_voted_up_25(self): + """ + (24, '极好问题', 2, '极好问题', '问题超过25次赞成票', 1, 0), + """ + query = "SELECT q.id, q.author_id FROM question q WHERE q.vote_up_count >= 25 AND q.id NOT IN \ + (SELECT object_id FROM award WHERE award.badge_id = %s)" % (24) + + self.__process_badge(query, 24, Question) + + def question_be_favorited_25(self): + """ + (25, '受欢迎问题', 2, '受欢迎问题', '问题被25人以上收藏', 1, 0), + """ + query = "SELECT q.id, q.author_id FROM question q WHERE q.favourite_count >= 25 AND q.id NOT IN \ + (SELECT object_id FROM award WHERE award.badge_id = %s)" % (25) + + self.__process_badge(query, 25, Question) + + def question_view_2500(self): + """ + (31, '最受关注问题', 2, '最受关注问题', '问题的浏览量超过2500人次', 1, 0), + """ + query = "SELECT q.id, q.author_id FROM question q WHERE q.view_count >= 2500 AND q.id NOT IN \ + (SELECT object_id FROM award WHERE award.badge_id = %s)" % (31) + + self.__process_badge(query, 31, Question) + + def answer_be_accepted_and_voted_up_40(self): + """ + (34, '导师', 2, '导师', '被指定为最佳答案并且赞成票40以上', 1, 0), + """ + query = "SELECT a.id, a.author_id FROM answer a WHERE a.vote_up_count >= 40 AND\ + a.accepted = 1 AND\ + a.id NOT IN \ + (SELECT object_id FROM award WHERE award.badge_id = %s)" % (34) + + self.__process_badge(query, 34, Answer) + + def question_be_answered_after_60_days_and_be_voted_up_5(self): + """ + (35, '巫师', 2, '巫师', '在提问60天之后回答并且赞成票5次以上', 1, 0), + """ + query = "SELECT a.id, a.author_id FROM question q, answer a WHERE q.id = a.question_id AND\ + DATEDIFF(a.added_at, q.added_at) >= 60 AND\ + a.vote_up_count >= 5 AND \ + a.id NOT IN \ + (SELECT object_id FROM award WHERE award.badge_id = %s)" % (35) + + self.__process_badge(query, 35, Answer) + + def created_tag_be_used_in_question_50(self): + """ + (36, '分类专家', 2, '分类专家', '创建的标签被50个以上问题使用', 1, 0); + """ + query = "SELECT t.id, t.created_by_id FROM tag t, auth_user u WHERE t.created_by_id = u.id AND \ + t. used_count >= 50 AND \ + t.id NOT IN \ + (SELECT object_id FROM award WHERE award.badge_id = %s)" % (36) + + self.__process_badge(query, 36, Tag) + + def __process_activities_badge(self, query, badge, content_object, update_auditted=True): + content_type = ContentType.objects.get_for_model(content_object) + + cursor = connection.cursor() + try: + cursor.execute(query) + rows = cursor.fetchall() + + if update_auditted: + activity_ids = [] + badge = get_object_or_404(Badge, id=badge) + for row in rows: + activity_id = row[0] + user_id = row[1] + object_id = row[2] + + user = get_object_or_404(User, id=user_id) + award = Award(user=user, badge=badge, content_type=content_type, object_id=object_id) + award.save() + + if update_auditted: + activity_ids.append(activity_id) + + if update_auditted: + self.update_activities_auditted(cursor, activity_ids) + finally: + cursor.close() + + def __process_badge(self, query, badge, content_object): + content_type = ContentType.objects.get_for_model(Answer) + cursor = connection.cursor() + try: + cursor.execute(query) + rows = cursor.fetchall() + + badge = get_object_or_404(Badge, id=badge) + for row in rows: + object_id = row[0] + user_id = row[1] + + user = get_object_or_404(User, id=user_id) + award = Award(user=user, badge=badge, content_type=content_type, object_id=object_id) + award.save() + finally: + cursor.close() diff --git a/forum/management/commands/once_award_badges.py b/forum/management/commands/once_award_badges.py new file mode 100644 index 0000000..8c91334 --- /dev/null +++ b/forum/management/commands/once_award_badges.py @@ -0,0 +1,350 @@ +#!/usr/bin/env python +#encoding:utf-8 +#------------------------------------------------------------------------------- +# Name: Award badges command +# Purpose: This is a command file croning in background process regularly to +# query database and award badges for user's special acitivities. +# +# Author: Mike, Sailing +# +# Created: 18/01/2009 +# Copyright: (c) Mike 2009 +# Licence: GPL V2 +#------------------------------------------------------------------------------- + +from datetime import datetime, date +from django.db import connection +from django.shortcuts import get_object_or_404 +from django.contrib.contenttypes.models import ContentType + +from forum.models import * +from forum.const import * +from base_command import BaseCommand +""" +(1, '炼狱法师', 3, '炼狱法师', '删除自己有3个以上赞成票的帖子', 1, 0), +(2, '压力白领', 3, '压力白领', '删除自己有3个以上反对票的帖子', 1, 0), +(3, '优秀回答', 3, '优秀回答', '回答好评10次以上', 1, 0), +(4, '优秀问题', 3, '优秀问题', '问题好评10次以上', 1, 0), +(5, '评论家', 3, '评论家', '评论10次以上', 0, 0), +(6, '流行问题', 3, '流行问题', '问题的浏览量超过1000人次', 1, 0), +(7, '巡逻兵', 3, '巡逻兵', '第一次标记垃圾帖子', 0, 0), +(8, '清洁工', 3, '清洁工', '第一次撤销投票', 0, 0), +(9, '批评家', 3, '批评家', '第一次反对票', 0, 0), +(10, '小编', 3, '小编', '第一次编辑更新', 0, 0), +(11, '村长', 3, '村长', '第一次重新标签', 0, 0), +(12, '学者', 3, '学者', '第一次标记答案', 0, 0), +(13, '学生', 3, '学生', '第一次提问并且有一次以上赞成票', 0, 0), +(14, '支持者', 3, '支持者', '第一次赞成票', 0, 0), +(15, '教师', 3, '教师', '第一次回答问题并且得到一个以上赞成票', 0, 0), +(16, '自传作者', 3, '自传作者', '完整填写用户资料所有选项', 0, 0), +(17, '自学成才', 3, '自学成才', '回答自己的问题并且有3个以上赞成票', 1, 0), +(18, '最有价值回答', 1, '最有价值回答', '回答超过100次赞成票', 1, 0), +(19, '最有价值问题', 1, '最有价值问题', '问题超过100次赞成票', 1, 0), +(20, '万人迷', 1, '万人迷', '问题被100人以上收藏', 1, 0), +(21, '著名问题', 1, '著名问题', '问题的浏览量超过10000人次', 1, 0), +(22, 'alpha用户', 2, 'alpha用户', '内测期间的活跃用户', 0, 0), +(23, '极好回答', 2, '极好回答', '回答超过25次赞成票', 1, 0), +(24, '极好问题', 2, '极好问题', '问题超过25次赞成票', 1, 0), +(25, '受欢迎问题', 2, '受欢迎问题', '问题被25人以上收藏', 1, 0), +(26, '优秀市民', 2, '优秀市民', '投票300次以上', 0, 0), +(27, '编辑主任', 2, '编辑主任', '编辑了100个帖子', 0, 0), +(28, '通才', 2, '通才', '在多个标签领域活跃', 0, 0), +(29, '专家', 2, '专家', '在一个标签领域活跃出众', 0, 0), +(30, '老鸟', 2, '老鸟', '活跃超过一年的用户', 0, 0), +(31, '最受关注问题', 2, '最受关注问题', '问题的浏览量超过2500人次', 1, 0), +(32, '学问家', 2, '学问家', '第一次回答被投赞成票10次以上', 0, 0), +(33, 'beta用户', 2, 'beta用户', 'beta期间活跃参与', 0, 0), +(34, '导师', 2, '导师', '被指定为最佳答案并且赞成票40以上', 1, 0), +(35, '巫师', 2, '巫师', '在提问60天之后回答并且赞成票5次以上', 1, 0), +(36, '分类专家', 2, '分类专家', '创建的标签被50个以上问题使用', 1, 0); + + +TYPE_ACTIVITY_ASK_QUESTION=1 +TYPE_ACTIVITY_ANSWER=2 +TYPE_ACTIVITY_COMMENT_QUESTION=3 +TYPE_ACTIVITY_COMMENT_ANSWER=4 +TYPE_ACTIVITY_UPDATE_QUESTION=5 +TYPE_ACTIVITY_UPDATE_ANSWER=6 +TYPE_ACTIVITY_PRIZE=7 +TYPE_ACTIVITY_MARK_ANSWER=8 +TYPE_ACTIVITY_VOTE_UP=9 +TYPE_ACTIVITY_VOTE_DOWN=10 +TYPE_ACTIVITY_CANCEL_VOTE=11 +TYPE_ACTIVITY_DELETE_QUESTION=12 +TYPE_ACTIVITY_DELETE_ANSWER=13 +TYPE_ACTIVITY_MARK_OFFENSIVE=14 +TYPE_ACTIVITY_UPDATE_TAGS=15 +TYPE_ACTIVITY_FAVORITE=16 +TYPE_ACTIVITY_USER_FULL_UPDATED = 17 +""" + +BADGE_AWARD_TYPE_FIRST = { + TYPE_ACTIVITY_MARK_OFFENSIVE : 7, + TYPE_ACTIVITY_CANCEL_VOTE: 8, + TYPE_ACTIVITY_VOTE_DOWN : 9, + TYPE_ACTIVITY_UPDATE_QUESTION : 10, + TYPE_ACTIVITY_UPDATE_ANSWER : 10, + TYPE_ACTIVITY_UPDATE_TAGS : 11, + TYPE_ACTIVITY_MARK_ANSWER : 12, + TYPE_ACTIVITY_VOTE_UP : 14, + TYPE_ACTIVITY_USER_FULL_UPDATED: 16 + +} + +class Command(BaseCommand): + def handle_noargs(self, **options): + try: + try: + self.alpha_user() + self.beta_user() + self.first_type_award() + self.first_ask_be_voted() + self.first_answer_be_voted() + self.first_answer_be_voted_10() + self.vote_count_300() + self.edit_count_100() + self.comment_count_10() + except Exception, e: + print e + finally: + connection.close() + + def alpha_user(self): + """ + Before Jan 25, 2009(Chinese New Year Eve and enter into Beta for CNProg), every registered user + will be awarded the "Alpha" badge if he has any activities. + """ + alpha_end_date = date(2009, 1, 25) + if date.today() < alpha_end_date: + badge = get_object_or_404(Badge, id=22) + for user in User.objects.all(): + award = Award.objects.filter(user=user, badge=badge) + if award and not badge.multiple: + continue + activities = Activity.objects.filter(user=user) + if len(activities) > 0: + new_award = Award(user=user, badge=badge) + new_award.save() + + def beta_user(self): + """ + Before Feb 25, 2009, every registered user + will be awarded the "Beta" badge if he has any activities. + """ + beta_end_date = date(2009, 2, 25) + if date.today() < beta_end_date: + badge = get_object_or_404(Badge, id=33) + for user in User.objects.all(): + award = Award.objects.filter(user=user, badge=badge) + if award and not badge.multiple: + continue + activities = Activity.objects.filter(user=user) + if len(activities) > 0: + new_award = Award(user=user, badge=badge) + new_award.save() + + def first_type_award(self): + """ + This will award below badges for users first behaviors: + + (7, '巡逻兵', 3, '巡逻兵', '第一次标记垃圾帖子', 0, 0), + (8, '清洁工', 3, '清洁工', '第一次撤销投票', 0, 0), + (9, '批评家', 3, '批评家', '第一次反对票', 0, 0), + (10, '小编', 3, '小编', '第一次编辑更新', 0, 0), + (11, '村长', 3, '村长', '第一次重新标签', 0, 0), + (12, '学者', 3, '学者', '第一次标记答案', 0, 0), + (14, '支持者', 3, '支持者', '第一次赞成票', 0, 0), + (16, '自传作者', 3, '自传作者', '完整填写用户资料所有选项', 0, 0), + """ + activity_types = ','.join('%s' % item for item in BADGE_AWARD_TYPE_FIRST.keys()) + # ORDER BY user_id, activity_type + query = "SELECT id, user_id, activity_type, content_type_id, object_id FROM activity WHERE is_auditted = 0 AND activity_type IN (%s) ORDER BY user_id, activity_type" % activity_types + + cursor = connection.cursor() + try: + cursor.execute(query) + rows = cursor.fetchall() + # collect activity_id in current process + activity_ids = [] + last_user_id = 0 + last_activity_type = 0 + for row in rows: + activity_ids.append(row[0]) + user_id = row[1] + activity_type = row[2] + content_type_id = row[3] + object_id = row[4] + + # if the user and activity are same as the last, continue + if user_id == last_user_id and activity_type == last_activity_type: + continue; + + user = get_object_or_404(User, id=user_id) + badge = get_object_or_404(Badge, id=BADGE_AWARD_TYPE_FIRST[activity_type]) + content_type = get_object_or_404(ContentType, id=content_type_id) + + count = Award.objects.filter(user=user, badge=badge).count() + if count and not badge.multiple: + continue + else: + # new award + award = Award(user=user, badge=badge, content_type=content_type, object_id=object_id) + award.save() + + # set the current user_id and activity_type to last + last_user_id = user_id + last_activity_type = activity_type + + # update processed rows to auditted + self.update_activities_auditted(cursor, activity_ids) + finally: + cursor.close() + + def first_ask_be_voted(self): + """ + For user asked question and got first upvote, we award him following badge: + + (13, '学生', 3, '学生', '第一次提问并且有一次以上赞成票', 0, 0), + """ + query = "SELECT act.user_id, q.vote_up_count, act.object_id FROM " \ + "activity act, question q WHERE act.activity_type = %s AND " \ + "act.object_id = q.id AND " \ + "act.user_id NOT IN (SELECT distinct user_id FROM award WHERE badge_id = %s)" % (TYPE_ACTIVITY_ASK_QUESTION, 13) + cursor = connection.cursor() + try: + cursor.execute(query) + rows = cursor.fetchall() + + badge = get_object_or_404(Badge, id=13) + content_type = ContentType.objects.get_for_model(Question) + awarded_users = [] + for row in rows: + user_id = row[0] + vote_up_count = row[1] + object_id = row[2] + if vote_up_count > 0 and user_id not in awarded_users: + user = get_object_or_404(User, id=user_id) + award = Award(user=user, badge=badge, content_type=content_type, object_id=object_id) + award.save() + awarded_users.append(user_id) + finally: + cursor.close() + + def first_answer_be_voted(self): + """ + When user answerd questions and got first upvote, we award him following badge: + + (15, '教师', 3, '教师', '第一次回答问题并且得到一个以上赞成票', 0, 0), + """ + query = "SELECT act.user_id, a.vote_up_count, act.object_id FROM " \ + "activity act, answer a WHERE act.activity_type = %s AND " \ + "act.object_id = a.id AND " \ + "act.user_id NOT IN (SELECT distinct user_id FROM award WHERE badge_id = %s)" % (TYPE_ACTIVITY_ANSWER, 15) + cursor = connection.cursor() + try: + cursor.execute(query) + rows = cursor.fetchall() + + awarded_users = [] + badge = get_object_or_404(Badge, id=15) + content_type = ContentType.objects.get_for_model(Answer) + for row in rows: + user_id = row[0] + vote_up_count = row[1] + object_id = row[2] + if vote_up_count > 0 and user_id not in awarded_users: + user = get_object_or_404(User, id=user_id) + award = Award(user=user, badge=badge, content_type=content_type, object_id=object_id) + award.save() + awarded_users.append(user_id) + finally: + cursor.close() + + def first_answer_be_voted_10(self): + """ + (32, '学问家', 2, '学问家', '第一次回答被投赞成票10次以上', 0, 0) + """ + query = "SELECT act.user_id, act.object_id FROM " \ + "activity act, answer a WHERE act.object_id = a.id AND " \ + "act.activity_type = %s AND " \ + "a.vote_up_count >= 10 AND " \ + "act.user_id NOT IN (SELECT user_id FROM award WHERE badge_id = %s)" % (TYPE_ACTIVITY_ANSWER, 32) + cursor = connection.cursor() + try: + cursor.execute(query) + rows = cursor.fetchall() + + awarded_users = [] + badge = get_object_or_404(Badge, id=32) + content_type = ContentType.objects.get_for_model(Answer) + for row in rows: + user_id = row[0] + if user_id not in awarded_users: + user = get_object_or_404(User, id=user_id) + object_id = row[1] + award = Award(user=user, badge=badge, content_type=content_type, object_id=object_id) + award.save() + awarded_users.append(user_id) + finally: + cursor.close() + + def vote_count_300(self): + """ + (26, '优秀市民', 2, '优秀市民', '投票300次以上', 0, 0) + """ + query = "SELECT count(*) vote_count, user_id FROM activity WHERE " \ + "activity_type = %s OR " \ + "activity_type = %s AND " \ + "user_id NOT IN (SELECT user_id FROM award WHERE badge_id = %s) " \ + "GROUP BY user_id HAVING vote_count >= 300" % (TYPE_ACTIVITY_VOTE_UP, TYPE_ACTIVITY_VOTE_DOWN, 26) + + self.__award_for_count_num(query, 26) + + def edit_count_100(self): + """ + (27, '编辑主任', 2, '编辑主任', '编辑了100个帖子', 0, 0) + """ + query = "SELECT count(*) vote_count, user_id FROM activity WHERE " \ + "activity_type = %s OR " \ + "activity_type = %s AND " \ + "user_id NOT IN (SELECT user_id FROM award WHERE badge_id = %s) " \ + "GROUP BY user_id HAVING vote_count >= 100" % (TYPE_ACTIVITY_UPDATE_QUESTION, TYPE_ACTIVITY_UPDATE_ANSWER, 27) + + self.__award_for_count_num(query, 27) + + def comment_count_10(self): + """ + (5, '评论家', 3, '评论家', '评论10次以上', 0, 0), + """ + query = "SELECT count(*) vote_count, user_id FROM activity WHERE " \ + "activity_type = %s OR " \ + "activity_type = %s AND " \ + "user_id NOT IN (SELECT user_id FROM award WHERE badge_id = %s) " \ + "GROUP BY user_id HAVING vote_count >= 10" % (TYPE_ACTIVITY_COMMENT_QUESTION, TYPE_ACTIVITY_COMMENT_ANSWER, 5) + self.__award_for_count_num(query, 5) + + def __award_for_count_num(self, query, badge): + cursor = connection.cursor() + try: + cursor.execute(query) + rows = cursor.fetchall() + + awarded_users = [] + badge = get_object_or_404(Badge, id=badge) + for row in rows: + vote_count = row[0] + user_id = row[1] + + if user_id not in awarded_users: + user = get_object_or_404(User, id=user_id) + award = Award(user=user, badge=badge) + award.save() + awarded_users.append(user_id) + finally: + cursor.close() + +def main(): + pass + +if __name__ == '__main__': + main() diff --git a/forum/management/commands/sample_command.py b/forum/management/commands/sample_command.py new file mode 100644 index 0000000..55e6723 --- /dev/null +++ b/forum/management/commands/sample_command.py @@ -0,0 +1,7 @@ +from django.core.management.base import NoArgsCommand +from forum.models import Comment + +class Command(NoArgsCommand): + def handle_noargs(self, **options): + objs = Comment.objects.all() + print objs \ No newline at end of file diff --git a/forum/management/commands/send_email_alerts.py b/forum/management/commands/send_email_alerts.py new file mode 100644 index 0000000..26eb779 --- /dev/null +++ b/forum/management/commands/send_email_alerts.py @@ -0,0 +1,192 @@ +from django.core.management.base import NoArgsCommand +from django.db import connection +from django.db.models import Q, F +from forum.models import * +from forum import const +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 +import logging +from forum.utils.odict import OrderedDict + +class Command(NoArgsCommand): + def handle_noargs(self,**options): + try: + try: + self.send_email_alerts() + except Exception, e: + print e + finally: + connection.close() + + def get_updated_questions_for_user(self,user): + q_sel = None + q_ask = None + q_ans = None + q_all = None + now = datetime.datetime.now() + Q_set1 = Question.objects.exclude( + last_activity_by=user, + ).exclude( + last_activity_at__lt=user.date_joined + ).filter( + Q(viewed__who=user,viewed__when__lt=F('last_activity_at')) | \ + ~Q(viewed__who=user) + ).exclude( + deleted=True + ).exclude( + closed=True + ) + + user_feeds = EmailFeedSetting.objects.filter(subscriber=user).exclude(frequency='n') + for feed in user_feeds: + cutoff_time = now - EmailFeedSetting.DELTA_TABLE[feed.frequency] + if feed.reported_at == None or feed.reported_at <= cutoff_time: + Q_set = Q_set1.exclude(last_activity_at__gt=cutoff_time)#report these excluded later + feed.reported_at = now + feed.save()#may not actually report anything, depending on filters below + if feed.feed_type == 'q_sel': + q_sel = Q_set.filter(followed_by=user) + q_sel.cutoff_time = cutoff_time #store cutoff time per query set + elif feed.feed_type == 'q_ask': + q_ask = Q_set.filter(author=user) + q_ask.cutoff_time = cutoff_time + elif feed.feed_type == 'q_ans': + q_ans = Q_set.filter(answers__author=user) + q_ans.cutoff_time = cutoff_time + elif feed.feed_type == 'q_all': + if user.tag_filter_setting == 'ignored': + ignored_tags = Tag.objects.filter(user_selections__reason='bad',user_selections__user=user) + q_all = Q_set.exclude( tags__in=ignored_tags ) + else: + selected_tags = Tag.objects.filter(user_selections__reason='good',user_selections__user=user) + q_all = Q_set.filter( tags__in=selected_tags ) + q_all.cutoff_time = cutoff_time + #build list in this order + q_list = OrderedDict() + def extend_question_list(src, dst): + """src is a query set with questions + or an empty list + dst - is an ordered dictionary + """ + if src is None: + return #will not do anything if subscription of this type is not used + cutoff_time = src.cutoff_time + for q in src: + if q in dst: + if cutoff_time < dst[q]['cutoff_time']: + dst[q]['cutoff_time'] = cutoff_time + else: + #initialise a questions metadata dictionary to use for email reporting + dst[q] = {'cutoff_time':cutoff_time} + + extend_question_list(q_sel, q_list) + extend_question_list(q_ask, q_list) + extend_question_list(q_ans, q_list) + extend_question_list(q_all, q_list) + + ctype = ContentType.objects.get_for_model(Question) + EMAIL_UPDATE_ACTIVITY = const.TYPE_ACTIVITY_QUESTION_EMAIL_UPDATE_SENT + for q, meta_data in q_list.items(): + #todo use Activity, but first start keeping more Activity records + #act = Activity.objects.filter(content_type=ctype, object_id=q.id) + #because currently activity is not fully recorded to through + #revision records to see what kind modifications were done on + #the questions and answers + try: + update_info = Activity.objects.get(content_type=ctype, + object_id=q.id, + activity_type=EMAIL_UPDATE_ACTIVITY) + emailed_at = update_info.active_at + except Activity.DoesNotExist: + update_info = Activity(user=user, content_object=q, activity_type=EMAIL_UPDATE_ACTIVITY) + emailed_at = datetime.datetime(1970,1,1)#long time ago + except Activity.MultipleObjectsReturned: + raise Exception('server error - multiple question email activities found per user-question pair') + + q_rev = QuestionRevision.objects.filter(question=q,\ + revised_at__lt=cutoff_time,\ + revised_at__gt=emailed_at) + q_rev = q_rev.exclude(author=user) + meta_data['q_rev'] = len(q_rev) + if len(q_rev) > 0 and q.added_at == q_rev[0].revised_at: + meta_data['q_rev'] = 0 + meta_data['new_q'] = True + else: + meta_data['new_q'] = False + + new_ans = Answer.objects.filter(question=q,\ + added_at__lt=cutoff_time,\ + added_at__gt=emailed_at) + new_ans = new_ans.exclude(author=user) + meta_data['new_ans'] = len(new_ans) + ans_rev = AnswerRevision.objects.filter(answer__question=q,\ + revised_at__lt=cutoff_time,\ + revised_at__gt=emailed_at) + ans_rev = ans_rev.exclude(author=user) + meta_data['ans_rev'] = len(ans_rev) + if len(q_rev) == 0 and len(new_ans) == 0 and len(ans_rev) == 0: + meta_data['nothing_new'] = True + else: + meta_data['nothing_new'] = False + update_info.active_at = now + update_info.save() #save question email update activity + return q_list + + def __action_count(self,string,number,output): + if number > 0: + output.append(_(string) % {'num':number}) + + def send_email_alerts(self): + + #todo: move this to template + for user in User.objects.all(): + q_list = self.get_updated_questions_for_user(user) + num_q = 0 + num_moot = 0 + for meta_data in q_list.values(): + if meta_data['nothing_new'] == False: + num_q += 1 + else: + num_moot += 1 + if num_q > 0: + url_prefix = settings.APP_URL + subject = _('email update message subject') + print 'have %d updated questions for %s' % (num_q, user.username) + text = ungettext('%(name)s, this is an update message header for a question', + '%(name)s, this is an update message header for %(num)d questions',num_q) \ + % {'num':num_q, 'name':user.username} + + text += '
    ' + for q, meta_data in q_list.items(): + act_list = [] + if meta_data['nothing_new']: + continue + else: + if meta_data['new_q']: + act_list.append(_('new question')) + self.__action_count('%(num)d rev', meta_data['q_rev'],act_list) + self.__action_count('%(num)d ans', meta_data['new_ans'],act_list) + self.__action_count('%(num)d ans rev',meta_data['ans_rev'],act_list) + act_token = ', '.join(act_list) + text += '
  • %s (%s)
  • ' \ + % (url_prefix + q.get_absolute_url(), q.title, act_token) + text += '
' + 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 0000000..e1f29e8 Binary files /dev/null and b/forum/skins/default/media/images/blue-up-arrow-h18px.png differ 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 0000000..89dcf5b Binary files /dev/null and b/forum/skins/default/media/images/box-arrow.gif differ 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 0000000..fa53091 Binary files /dev/null and b/forum/skins/default/media/images/bullet_green.gif differ diff --git a/forum/skins/default/media/images/cc-88x31.png b/forum/skins/default/media/images/cc-88x31.png new file mode 100644 index 0000000..0f2a0f1 Binary files /dev/null and b/forum/skins/default/media/images/cc-88x31.png differ diff --git a/forum/skins/default/media/images/cc-wiki.png b/forum/skins/default/media/images/cc-wiki.png new file mode 100644 index 0000000..3e68053 Binary files /dev/null and b/forum/skins/default/media/images/cc-wiki.png differ 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 0000000..280c1fc Binary files /dev/null and b/forum/skins/default/media/images/close-small-dark.png differ diff --git a/forum/skins/default/media/images/close-small-hover.png b/forum/skins/default/media/images/close-small-hover.png new file mode 100644 index 0000000..7899aec Binary files /dev/null and b/forum/skins/default/media/images/close-small-hover.png differ diff --git a/forum/skins/default/media/images/close-small.png b/forum/skins/default/media/images/close-small.png new file mode 100644 index 0000000..5a99d31 Binary files /dev/null and b/forum/skins/default/media/images/close-small.png differ diff --git a/forum/skins/default/media/images/dash.gif b/forum/skins/default/media/images/dash.gif new file mode 100644 index 0000000..d1ddc50 Binary files /dev/null and b/forum/skins/default/media/images/dash.gif differ 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 0000000..d34bb31 Binary files /dev/null and b/forum/skins/default/media/images/djangomade124x25_grey.gif differ diff --git a/forum/skins/default/media/images/dot-g.gif b/forum/skins/default/media/images/dot-g.gif new file mode 100644 index 0000000..5d6bb28 Binary files /dev/null and b/forum/skins/default/media/images/dot-g.gif differ diff --git a/forum/skins/default/media/images/dot-list.gif b/forum/skins/default/media/images/dot-list.gif new file mode 100644 index 0000000..f6a6b86 Binary files /dev/null and b/forum/skins/default/media/images/dot-list.gif differ diff --git a/forum/skins/default/media/images/edit.png b/forum/skins/default/media/images/edit.png new file mode 100644 index 0000000..dcb09be Binary files /dev/null and b/forum/skins/default/media/images/edit.png differ diff --git a/forum/skins/default/media/images/expander-arrow-hide.gif b/forum/skins/default/media/images/expander-arrow-hide.gif new file mode 100644 index 0000000..feb6a61 Binary files /dev/null and b/forum/skins/default/media/images/expander-arrow-hide.gif differ diff --git a/forum/skins/default/media/images/expander-arrow-show.gif b/forum/skins/default/media/images/expander-arrow-show.gif new file mode 100644 index 0000000..6825c56 Binary files /dev/null and b/forum/skins/default/media/images/expander-arrow-show.gif differ diff --git a/forum/skins/default/media/images/favicon.gif b/forum/skins/default/media/images/favicon.gif new file mode 100644 index 0000000..910c266 Binary files /dev/null and b/forum/skins/default/media/images/favicon.gif differ diff --git a/forum/skins/default/media/images/feed-icon-small.png b/forum/skins/default/media/images/feed-icon-small.png new file mode 100644 index 0000000..b3c949d Binary files /dev/null and b/forum/skins/default/media/images/feed-icon-small.png differ diff --git a/forum/skins/default/media/images/gray-up-arrow-h18px.png b/forum/skins/default/media/images/gray-up-arrow-h18px.png new file mode 100644 index 0000000..7876744 Binary files /dev/null and b/forum/skins/default/media/images/gray-up-arrow-h18px.png differ diff --git a/forum/skins/default/media/images/grippie.png b/forum/skins/default/media/images/grippie.png new file mode 100644 index 0000000..6524d41 Binary files /dev/null and b/forum/skins/default/media/images/grippie.png differ diff --git a/forum/skins/default/media/images/indicator.gif b/forum/skins/default/media/images/indicator.gif new file mode 100644 index 0000000..1c72ebb Binary files /dev/null and b/forum/skins/default/media/images/indicator.gif differ diff --git a/forum/skins/default/media/images/logo.gif b/forum/skins/default/media/images/logo.gif new file mode 100644 index 0000000..ab690de Binary files /dev/null and b/forum/skins/default/media/images/logo.gif differ diff --git a/forum/skins/default/media/images/logo.png b/forum/skins/default/media/images/logo.png new file mode 100644 index 0000000..6a250e3 Binary files /dev/null and b/forum/skins/default/media/images/logo.png differ diff --git a/forum/skins/default/media/images/logo1.png b/forum/skins/default/media/images/logo1.png new file mode 100644 index 0000000..d79a627 Binary files /dev/null and b/forum/skins/default/media/images/logo1.png differ diff --git a/forum/skins/default/media/images/logo2.png b/forum/skins/default/media/images/logo2.png new file mode 100644 index 0000000..bd3cccd Binary files /dev/null and b/forum/skins/default/media/images/logo2.png differ diff --git a/forum/skins/default/media/images/medala.gif b/forum/skins/default/media/images/medala.gif new file mode 100644 index 0000000..93dd1a3 Binary files /dev/null and b/forum/skins/default/media/images/medala.gif differ diff --git a/forum/skins/default/media/images/medala_on.gif b/forum/skins/default/media/images/medala_on.gif new file mode 100644 index 0000000..a18f9e8 Binary files /dev/null and b/forum/skins/default/media/images/medala_on.gif differ diff --git a/forum/skins/default/media/images/new.gif b/forum/skins/default/media/images/new.gif new file mode 100644 index 0000000..8a220b5 Binary files /dev/null and b/forum/skins/default/media/images/new.gif differ diff --git a/forum/skins/default/media/images/nophoto.png b/forum/skins/default/media/images/nophoto.png new file mode 100644 index 0000000..2daf0ff Binary files /dev/null and b/forum/skins/default/media/images/nophoto.png differ diff --git a/forum/skins/default/media/images/openid.gif b/forum/skins/default/media/images/openid.gif new file mode 100644 index 0000000..8540e12 Binary files /dev/null and b/forum/skins/default/media/images/openid.gif differ diff --git a/forum/skins/default/media/images/openid/aol.gif b/forum/skins/default/media/images/openid/aol.gif new file mode 100644 index 0000000..decc4f1 Binary files /dev/null and b/forum/skins/default/media/images/openid/aol.gif differ diff --git a/forum/skins/default/media/images/openid/blogger.ico b/forum/skins/default/media/images/openid/blogger.ico new file mode 100644 index 0000000..1b9730b Binary files /dev/null and b/forum/skins/default/media/images/openid/blogger.ico differ diff --git a/forum/skins/default/media/images/openid/claimid.ico b/forum/skins/default/media/images/openid/claimid.ico new file mode 100644 index 0000000..2b80f49 Binary files /dev/null and b/forum/skins/default/media/images/openid/claimid.ico differ diff --git a/forum/skins/default/media/images/openid/facebook.gif b/forum/skins/default/media/images/openid/facebook.gif new file mode 100644 index 0000000..b997b35 Binary files /dev/null and b/forum/skins/default/media/images/openid/facebook.gif differ diff --git a/forum/skins/default/media/images/openid/flickr.ico b/forum/skins/default/media/images/openid/flickr.ico new file mode 100644 index 0000000..11f6e07 Binary files /dev/null and b/forum/skins/default/media/images/openid/flickr.ico differ diff --git a/forum/skins/default/media/images/openid/google.gif b/forum/skins/default/media/images/openid/google.gif new file mode 100644 index 0000000..1b6cd07 Binary files /dev/null and b/forum/skins/default/media/images/openid/google.gif differ diff --git a/forum/skins/default/media/images/openid/livejournal.ico b/forum/skins/default/media/images/openid/livejournal.ico new file mode 100644 index 0000000..f3d21ec Binary files /dev/null and b/forum/skins/default/media/images/openid/livejournal.ico differ diff --git a/forum/skins/default/media/images/openid/myopenid.ico b/forum/skins/default/media/images/openid/myopenid.ico new file mode 100644 index 0000000..ceb06e6 Binary files /dev/null and b/forum/skins/default/media/images/openid/myopenid.ico differ diff --git a/forum/skins/default/media/images/openid/openid-inputicon.gif b/forum/skins/default/media/images/openid/openid-inputicon.gif new file mode 100644 index 0000000..cde836c Binary files /dev/null and b/forum/skins/default/media/images/openid/openid-inputicon.gif differ diff --git a/forum/skins/default/media/images/openid/openid.gif b/forum/skins/default/media/images/openid/openid.gif new file mode 100644 index 0000000..c718b0e Binary files /dev/null and b/forum/skins/default/media/images/openid/openid.gif differ diff --git a/forum/skins/default/media/images/openid/technorati.ico b/forum/skins/default/media/images/openid/technorati.ico new file mode 100644 index 0000000..fa1083c Binary files /dev/null and b/forum/skins/default/media/images/openid/technorati.ico differ diff --git a/forum/skins/default/media/images/openid/twitter.png b/forum/skins/default/media/images/openid/twitter.png new file mode 100755 index 0000000..9a6552d Binary files /dev/null and b/forum/skins/default/media/images/openid/twitter.png differ diff --git a/forum/skins/default/media/images/openid/verisign.ico b/forum/skins/default/media/images/openid/verisign.ico new file mode 100644 index 0000000..3953af9 Binary files /dev/null and b/forum/skins/default/media/images/openid/verisign.ico differ diff --git a/forum/skins/default/media/images/openid/vidoop.ico b/forum/skins/default/media/images/openid/vidoop.ico new file mode 100644 index 0000000..bbd9a0d Binary files /dev/null and b/forum/skins/default/media/images/openid/vidoop.ico differ diff --git a/forum/skins/default/media/images/openid/wordpress.ico b/forum/skins/default/media/images/openid/wordpress.ico new file mode 100644 index 0000000..31b7d2c Binary files /dev/null and b/forum/skins/default/media/images/openid/wordpress.ico differ diff --git a/forum/skins/default/media/images/openid/yahoo.gif b/forum/skins/default/media/images/openid/yahoo.gif new file mode 100644 index 0000000..0f0eb8e Binary files /dev/null and b/forum/skins/default/media/images/openid/yahoo.gif differ diff --git a/forum/skins/default/media/images/quest-bg.gif b/forum/skins/default/media/images/quest-bg.gif new file mode 100644 index 0000000..b754023 Binary files /dev/null and b/forum/skins/default/media/images/quest-bg.gif differ diff --git a/forum/skins/default/media/images/vote-accepted-on.png b/forum/skins/default/media/images/vote-accepted-on.png new file mode 100644 index 0000000..2026f3b Binary files /dev/null and b/forum/skins/default/media/images/vote-accepted-on.png differ diff --git a/forum/skins/default/media/images/vote-accepted.png b/forum/skins/default/media/images/vote-accepted.png new file mode 100644 index 0000000..ecd1855 Binary files /dev/null and b/forum/skins/default/media/images/vote-accepted.png differ diff --git a/forum/skins/default/media/images/vote-arrow-down-on.png b/forum/skins/default/media/images/vote-arrow-down-on.png new file mode 100644 index 0000000..048dbb4 Binary files /dev/null and b/forum/skins/default/media/images/vote-arrow-down-on.png differ diff --git a/forum/skins/default/media/images/vote-arrow-down.png b/forum/skins/default/media/images/vote-arrow-down.png new file mode 100644 index 0000000..e4fdec0 Binary files /dev/null and b/forum/skins/default/media/images/vote-arrow-down.png differ diff --git a/forum/skins/default/media/images/vote-arrow-up-on.png b/forum/skins/default/media/images/vote-arrow-up-on.png new file mode 100644 index 0000000..56ad0c2 Binary files /dev/null and b/forum/skins/default/media/images/vote-arrow-up-on.png differ diff --git a/forum/skins/default/media/images/vote-arrow-up.png b/forum/skins/default/media/images/vote-arrow-up.png new file mode 100644 index 0000000..6e9a51c Binary files /dev/null and b/forum/skins/default/media/images/vote-arrow-up.png differ diff --git a/forum/skins/default/media/images/vote-favorite-off.png b/forum/skins/default/media/images/vote-favorite-off.png new file mode 100644 index 0000000..c1bef07 Binary files /dev/null and b/forum/skins/default/media/images/vote-favorite-off.png differ diff --git a/forum/skins/default/media/images/vote-favorite-on.png b/forum/skins/default/media/images/vote-favorite-on.png new file mode 100644 index 0000000..1f9c14a Binary files /dev/null and b/forum/skins/default/media/images/vote-favorite-on.png differ diff --git a/forum/skins/default/media/jquery-openid/images/aol.gif b/forum/skins/default/media/jquery-openid/images/aol.gif new file mode 100644 index 0000000..decc4f1 Binary files /dev/null and b/forum/skins/default/media/jquery-openid/images/aol.gif differ diff --git a/forum/skins/default/media/jquery-openid/images/blogger-1.png b/forum/skins/default/media/jquery-openid/images/blogger-1.png new file mode 100644 index 0000000..8b360ea Binary files /dev/null and b/forum/skins/default/media/jquery-openid/images/blogger-1.png differ diff --git a/forum/skins/default/media/jquery-openid/images/blogger.ico b/forum/skins/default/media/jquery-openid/images/blogger.ico new file mode 100644 index 0000000..1b9730b Binary files /dev/null and b/forum/skins/default/media/jquery-openid/images/blogger.ico differ diff --git a/forum/skins/default/media/jquery-openid/images/claimid-0.png b/forum/skins/default/media/jquery-openid/images/claimid-0.png new file mode 100644 index 0000000..4a0ea1b Binary files /dev/null and b/forum/skins/default/media/jquery-openid/images/claimid-0.png differ diff --git a/forum/skins/default/media/jquery-openid/images/claimid.ico b/forum/skins/default/media/jquery-openid/images/claimid.ico new file mode 100644 index 0000000..2b80f49 Binary files /dev/null and b/forum/skins/default/media/jquery-openid/images/claimid.ico differ diff --git a/forum/skins/default/media/jquery-openid/images/facebook.gif b/forum/skins/default/media/jquery-openid/images/facebook.gif new file mode 100644 index 0000000..b997b35 Binary files /dev/null and b/forum/skins/default/media/jquery-openid/images/facebook.gif differ diff --git a/forum/skins/default/media/jquery-openid/images/flickr.ico b/forum/skins/default/media/jquery-openid/images/flickr.ico new file mode 100644 index 0000000..11f6e07 Binary files /dev/null and b/forum/skins/default/media/jquery-openid/images/flickr.ico differ diff --git a/forum/skins/default/media/jquery-openid/images/flickr.png b/forum/skins/default/media/jquery-openid/images/flickr.png new file mode 100644 index 0000000..142405a Binary files /dev/null and b/forum/skins/default/media/jquery-openid/images/flickr.png differ diff --git a/forum/skins/default/media/jquery-openid/images/google.gif b/forum/skins/default/media/jquery-openid/images/google.gif new file mode 100644 index 0000000..1b6cd07 Binary files /dev/null and b/forum/skins/default/media/jquery-openid/images/google.gif differ diff --git a/forum/skins/default/media/jquery-openid/images/livejournal-1.png b/forum/skins/default/media/jquery-openid/images/livejournal-1.png new file mode 100644 index 0000000..e643608 Binary files /dev/null and b/forum/skins/default/media/jquery-openid/images/livejournal-1.png differ diff --git a/forum/skins/default/media/jquery-openid/images/livejournal.ico b/forum/skins/default/media/jquery-openid/images/livejournal.ico new file mode 100644 index 0000000..f3d21ec Binary files /dev/null and b/forum/skins/default/media/jquery-openid/images/livejournal.ico differ diff --git a/forum/skins/default/media/jquery-openid/images/myopenid-2.png b/forum/skins/default/media/jquery-openid/images/myopenid-2.png new file mode 100644 index 0000000..f64fb8e Binary files /dev/null and b/forum/skins/default/media/jquery-openid/images/myopenid-2.png differ diff --git a/forum/skins/default/media/jquery-openid/images/myopenid.ico b/forum/skins/default/media/jquery-openid/images/myopenid.ico new file mode 100644 index 0000000..ceb06e6 Binary files /dev/null and b/forum/skins/default/media/jquery-openid/images/myopenid.ico differ diff --git a/forum/skins/default/media/jquery-openid/images/openid-inputicon.gif b/forum/skins/default/media/jquery-openid/images/openid-inputicon.gif new file mode 100644 index 0000000..cde836c Binary files /dev/null and b/forum/skins/default/media/jquery-openid/images/openid-inputicon.gif differ diff --git a/forum/skins/default/media/jquery-openid/images/openid.gif b/forum/skins/default/media/jquery-openid/images/openid.gif new file mode 100644 index 0000000..c718b0e Binary files /dev/null and b/forum/skins/default/media/jquery-openid/images/openid.gif differ diff --git a/forum/skins/default/media/jquery-openid/images/openidico.png b/forum/skins/default/media/jquery-openid/images/openidico.png new file mode 100644 index 0000000..ab62266 Binary files /dev/null and b/forum/skins/default/media/jquery-openid/images/openidico.png differ diff --git a/forum/skins/default/media/jquery-openid/images/openidico16.png b/forum/skins/default/media/jquery-openid/images/openidico16.png new file mode 100644 index 0000000..ad718ac Binary files /dev/null and b/forum/skins/default/media/jquery-openid/images/openidico16.png differ diff --git a/forum/skins/default/media/jquery-openid/images/technorati-1.png b/forum/skins/default/media/jquery-openid/images/technorati-1.png new file mode 100644 index 0000000..f719524 Binary files /dev/null and b/forum/skins/default/media/jquery-openid/images/technorati-1.png differ diff --git a/forum/skins/default/media/jquery-openid/images/technorati.ico b/forum/skins/default/media/jquery-openid/images/technorati.ico new file mode 100644 index 0000000..fa1083c Binary files /dev/null and b/forum/skins/default/media/jquery-openid/images/technorati.ico differ diff --git a/forum/skins/default/media/jquery-openid/images/verisign-2.png b/forum/skins/default/media/jquery-openid/images/verisign-2.png new file mode 100644 index 0000000..c146700 Binary files /dev/null and b/forum/skins/default/media/jquery-openid/images/verisign-2.png differ diff --git a/forum/skins/default/media/jquery-openid/images/verisign.ico b/forum/skins/default/media/jquery-openid/images/verisign.ico new file mode 100644 index 0000000..3953af9 Binary files /dev/null and b/forum/skins/default/media/jquery-openid/images/verisign.ico differ diff --git a/forum/skins/default/media/jquery-openid/images/vidoop.ico b/forum/skins/default/media/jquery-openid/images/vidoop.ico new file mode 100644 index 0000000..bbd9a0d Binary files /dev/null and b/forum/skins/default/media/jquery-openid/images/vidoop.ico differ diff --git a/forum/skins/default/media/jquery-openid/images/vidoop.png b/forum/skins/default/media/jquery-openid/images/vidoop.png new file mode 100644 index 0000000..032c9e9 Binary files /dev/null and b/forum/skins/default/media/jquery-openid/images/vidoop.png differ diff --git a/forum/skins/default/media/jquery-openid/images/wordpress.ico b/forum/skins/default/media/jquery-openid/images/wordpress.ico new file mode 100644 index 0000000..31b7d2c Binary files /dev/null and b/forum/skins/default/media/jquery-openid/images/wordpress.ico differ diff --git a/forum/skins/default/media/jquery-openid/images/wordpress.png b/forum/skins/default/media/jquery-openid/images/wordpress.png new file mode 100644 index 0000000..ee29f0c Binary files /dev/null and b/forum/skins/default/media/jquery-openid/images/wordpress.png differ diff --git a/forum/skins/default/media/jquery-openid/images/yahoo.gif b/forum/skins/default/media/jquery-openid/images/yahoo.gif new file mode 100644 index 0000000..42adbfa Binary files /dev/null and b/forum/skins/default/media/jquery-openid/images/yahoo.gif differ diff --git a/forum/skins/default/media/jquery-openid/jquery.openid.js b/forum/skins/default/media/jquery-openid/jquery.openid.js new file mode 100644 index 0000000..8d1cd20 --- /dev/null +++ b/forum/skins/default/media/jquery-openid/jquery.openid.js @@ -0,0 +1,111 @@ +//jQuery OpenID Plugin 1.1 Copyright 2009 Jarrett Vance http://jvance.com/pages/jQueryOpenIdPlugin.xhtml +$.fn.openid = function() { + var $this = $(this); + + //name input value - needed for name based OpenID + var $usr = $this.find('input[name=openid_username]'); + + //final url input value + var $id = $this.find('input[name=openid_url]'); + + //beginning and end of name OpenID url (name being the middle) + var $front = $this.find('p:has(input[name=openid_username])>span:eq(0)'); + var $end = $this.find('p:has(input[name=openid_username])>span:eq(1)'); + + //needed for special effects only + var $localfs = $this.find('fieldset:has(input[name=username])'); + var $usrfs = $this.find('fieldset:has(input[name=openid_username])'); + var $idfs = $this.find('fieldset:has(input[name=openid_url])'); + + var submitusr = function() { + if ($usr.val().length < 1) { + $usr.focus(); + return false; + } + $id.val($front.text() + $usr.val() + $end.text()); + return true; + }; + + var submitid = function() { + if ($id.val().length < 1) { + $id.focus(); + return false; + } + return true; + + }; + var local = function() { + var $li = $(this); + $('#openid_form .providers li').removeClass('highlight'); + $li.addClass('highlight'); + $usrfs.hide(); + $idfs.hide(); + $localfs.show(); + $this.unbind('submit').submit(submitid); + return false; + }; + + var direct = function() { + var $li = $(this); + $('#openid_form .providers li').removeClass('highlight'); + $li.addClass('highlight'); + $usrfs.fadeOut('slow'); + $localfs.fadeOut('slow'); + $idfs.fadeOut('slow'); + $id.val($this.find("li.highlight span").text()); + setTimeout(function(){$('#bsignin').click();},1000); + return false; + }; + + var openid = function() { + var $li = $(this); + $('#openid_form .providers li').removeClass('highlight'); + $li.addClass('highlight'); + $usrfs.hide(); + $localfs.hide(); + $idfs.show(); + $id.focus(); + $this.unbind('submit').submit(submitid); + return false; + }; + + var username = function() { + var $li = $(this); + $('#openid_form .providers li').removeClass('highlight'); + $li.addClass('highlight'); + $idfs.hide(); + $localfs.hide(); + $usrfs.show(); + $this.find('#enter_your_what').text($li.attr("title")); + $front.text($li.find("span").text().split("username")[0]); + $end.text("").text($li.find("span").text().split("username")[1]); + $id.focus(); + $this.unbind('submit').submit(submitusr); + return false; + }; + + $this.find('li.local').click(local); + $this.find('li.direct').click(direct); + $this.find('li.openid').click(openid); + $this.find('li.username').click(username); + $id.keypress(function(e) { + if ((e.which && e.which == 13) || (e.keyCode && e.keyCode == 13)) { + return submitid(); + } + }); + $usr.keypress(function(e) { + if ((e.which && e.which == 13) || (e.keyCode && e.keyCode == 13)) { + return submitusr(); + } + }); + $this.find('li span').hide(); + $this.find('li').css('line-height', 0).css('cursor', 'pointer'); + $usrfs.hide(); + $idfs.hide(); + $localfs.hide(); + $this.find('li:eq(0)').click(); + + return this; +}; +// submitting next=%2F&openid_username=&openid_url=http%3A%2F%2Fyahoo.com%2F +// submitting next=%2F&openid_username=&openid_url=http%3A%2F%2Fyahoo.com%2F diff --git a/forum/skins/default/media/jquery-openid/openid.css b/forum/skins/default/media/jquery-openid/openid.css new file mode 100644 index 0000000..1b7aaf8 --- /dev/null +++ b/forum/skins/default/media/jquery-openid/openid.css @@ -0,0 +1,75 @@ +fieldset { border-style:none; } +img {border-style:none;} + +.logo_box {display:inline-block;float:left;width:90px;height:40px;background:white;border:1px solid #dddddd;} +.openid_box img {margin-top:6px;} +.aol_box img {margin-top:6px;} +.yahoo_box img {margin-top:13px;} +.google_box img {margin-top:6px;} +.local_login_box img {margin-top:2px;margin-left:-3px;} + +form.openid ul{ margin:0;padding:0;text-align:center; list-style-type:none; display:block;} +form.openid ul li {float:left; padding:4px;display:inline-block;} +form.openid ul li div {display:inline-block;} +form.openid ul li span {padding:0 1em 0 3px} +form.openid ul li.first_tiny_li {clear:left;} +form.openid fieldset {clear:both;padding:10px 0px 0px 0px;} +form.openid div+fieldset {display:none} +form.openid label {display:block; font-weight:bold;} +input[name=openid_username] {width:8em} +input[name=openid_identifier] {width:18em} +form.openid ul li.highlight { -moz-border-radius:4px; -webkit-border-radius:4px; background-color: #FD6} +form.openid fieldset div { + -moz-border-radius:4px; + -webkit-border-radius:4px; + background: #DCDCDC; + padding:10px; + display:inline-block; + float:left; +} +form.openid p {margin-bottom:4px;} +form.openid fieldset div p {padding:0px;margin:0px;} +form.openid fieldset div p.login {padding:0px;margin:0 0 10px 0;} +form.openid label { + display:inline-block; + font-weight:normal; + width:6em; + text-align:right; +} +#local_login_fs div { + padding-bottom:4px; +} +#local_login_buttons { + text-align:center; + line-height:1.8em; + margin-top:3px; +} +/*form.openid input[type='submit'] {margin-left:1em;}*/ +#openid_username {background:#ffffa0;} +#openid_url {background:#ffffa0;} + +.openid_logo{color:#F7931E;padding:6px 0px 8px 28px; +background: url(images/openidico.png) no-repeat; +} + +#openid_login {float:left; width:30%; margin:2em 1em; text-align:center} +#openid_login div{margin-top:0.5em} + +form.openid ul.errorlist { + border: none; + list-style-position:inside; + list-style-type: disc; + margin-bottom:5px; +} +form.openid ul.errorlist li { + text-align: left; + margin: 5px; + float: none; + color:blue; +} +#openid_small_providers li { + margin-top:4px; +} +#openid_small_providers li.facebook { + margin-top:0px; +} diff --git a/forum/skins/default/media/js/com.cnprog.admin.js b/forum/skins/default/media/js/com.cnprog.admin.js new file mode 100644 index 0000000..39dff48 --- /dev/null +++ b/forum/skins/default/media/js/com.cnprog.admin.js @@ -0,0 +1,13 @@ +$(document).ready( function(){ + var options = { + success: function(a,b){$('.admin #action_status').html($.i18n._('changes saved'));}, + dataType:'json', + timeout:5000, + url: scriptUrl + $.i18n._('moderate-user/') + viewUserID + '/' + }; + var form = $('.admin #moderate_user_form').ajaxForm(options); + var box = $('.admin input#id_is_approved').click(function(){ + $('.admin #action_status').html($.i18n._('sending data...')); + form.ajaxSubmit(options); + }); +}); diff --git a/forum/skins/default/media/js/com.cnprog.editor.js b/forum/skins/default/media/js/com.cnprog.editor.js new file mode 100644 index 0000000..18cc516 --- /dev/null +++ b/forum/skins/default/media/js/com.cnprog.editor.js @@ -0,0 +1,68 @@ +/* + jQuery TextAreaResizer plugin + Created on 17th January 2008 by Ryan O'Dell + Version 1.0.4 +*/(function($){var textarea,staticOffset;var iLastMousePos=0;var iMin=32;var grip;$.fn.TextAreaResizer=function(){return this.each(function(){textarea=$(this).addClass('processed'),staticOffset=null;$(this).wrap('
    ').parent().append($('
    ').bind("mousedown",{el:this},startDrag));var grippie=$('div.grippie',$(this).parent())[0];grippie.style.marginRight=(grippie.offsetWidth-$(this)[0].offsetWidth)+'px'})};function startDrag(e){textarea=$(e.data.el);textarea.blur();iLastMousePos=mousePosition(e).y;staticOffset=textarea.height()-iLastMousePos;textarea.css('opacity',0.25);$(document).mousemove(performDrag).mouseup(endDrag);return false}function performDrag(e){var iThisMousePos=mousePosition(e).y;var iMousePos=staticOffset+iThisMousePos;if(iLastMousePos>=(iThisMousePos)){iMousePos-=5}iLastMousePos=iThisMousePos;iMousePos=Math.max(iMin,iMousePos);textarea.height(iMousePos+'px');if(iMousePos1&&!select.visible()){onChange(0,true);}}).bind("search",function(){var fn=(arguments.length>1)?arguments[1]:null;function findValueCallback(q,data){var result;if(data&&data.length){for(var i=0;i1){v=words.slice(0,words.length-1).join(options.multipleSeparator)+options.multipleSeparator+v;}v+=options.multipleSeparator;}$input.val(v);hideResultsNow();$input.trigger("result",[selected.data,selected.value]);return true;}function onChange(crap,skipPrevCheck){if(lastKeyPressCode==KEY.DEL){select.hide();return;}var currentValue=$input.val();if(!skipPrevCheck&¤tValue==previousValue)return;previousValue=currentValue;currentValue=lastWord(currentValue);if(currentValue.length>=options.minChars){$input.addClass(options.loadingClass);if(!options.matchCase)currentValue=currentValue.toLowerCase();request(currentValue,receiveData,hideResultsNow);}else{stopLoading();select.hide();}};function trimWords(value){if(!value){return[""];}var words=value.split(options.multipleSeparator);var result=[];$.each(words,function(i,value){if($.trim(value))result[i]=$.trim(value);});return result;}function lastWord(value){if(!options.multiple)return value;var words=trimWords(value);return words[words.length-1];}function autoFill(q,sValue){if(options.autoFill&&(lastWord($input.val()).toLowerCase()==q.toLowerCase())&&lastKeyPressCode!=KEY.BACKSPACE){$input.val($input.val()+sValue.substring(lastWord(previousValue).length));$.Autocompleter.Selection(input,previousValue.length,previousValue.length+sValue.length);}};function hideResults(){clearTimeout(timeout);timeout=setTimeout(hideResultsNow,200);};function hideResultsNow(){var wasVisible=select.visible();select.hide();clearTimeout(timeout);stopLoading();if(options.mustMatch){$input.search(function(result){if(!result){if(options.multiple){var words=trimWords($input.val()).slice(0,-1);$input.val(words.join(options.multipleSeparator)+(words.length?options.multipleSeparator:""));}else +$input.val("");}});}if(wasVisible)$.Autocompleter.Selection(input,input.value.length,input.value.length);};function receiveData(q,data){if(data&&data.length&&hasFocus){stopLoading();select.display(data,q);autoFill(q,data[0].value);select.show();}else{hideResultsNow();}};function request(term,success,failure){if(!options.matchCase)term=term.toLowerCase();var data=cache.load(term);if(data&&data.length){success(term,data);}else if((typeof options.url=="string")&&(options.url.length>0)){var extraParams={timestamp:+new Date()};$.each(options.extraParams,function(key,param){extraParams[key]=typeof param=="function"?param():param;});$.ajax({mode:"abort",port:"autocomplete"+input.name,dataType:options.dataType,url:options.url,data:$.extend({q:lastWord(term),limit:options.max},extraParams),success:function(data){var parsed=options.parse&&options.parse(data)||parse(data);cache.add(term,parsed);success(term,parsed);}});}else{select.emptyList();failure(term);}};function parse(data){var parsed=[];var rows=data.split("\n");for(var i=0;i]*)("+term.replace(/([\^\$\(\)\[\]\{\}\*\.\+\?\|\\])/gi,"\\$1")+")(?![^<>]*>)(?![^&;]+;)","gi"),"$1");},scroll:true,scrollHeight:180};$.Autocompleter.Cache=function(options){var data={};var length=0;function matchSubset(s,sub){if(!options.matchCase)s=s.toLowerCase();var i=s.indexOf(sub);if(i==-1)return false;return i==0||options.matchContains;};function add(q,value){if(length>options.cacheLength){flush();}if(!data[q]){length++;}data[q]=value;}function populate(){if(!options.data)return false;var stMatchSets={},nullData=0;if(!options.url)options.cacheLength=1;stMatchSets[""]=[];for(var i=0,ol=options.data.length;i0){var c=data[k];$.each(c,function(i,x){if(matchSubset(x.value,q)){csub.push(x);}});}}return csub;}else +if(data[q]){return data[q];}else +if(options.matchSubset){for(var i=q.length-1;i>=options.minChars;i--){var c=data[q.substr(0,i)];if(c){var csub=[];$.each(c,function(i,x){if(matchSubset(x.value,q)){csub[csub.length]=x;}});return csub;}}}return null;}};};$.Autocompleter.Select=function(options,input,select,config){var CLASSES={ACTIVE:"ac_over"};var listItems,active=-1,data,term="",needsInit=true,element,list;function init(){if(!needsInit)return;element=$("
    ").hide().addClass(options.resultsClass).css("position","absolute").appendTo(document.body);list=$("
      ").appendTo(element).mouseover(function(event){if(target(event).nodeName&&target(event).nodeName.toUpperCase()=='LI'){active=$("li",list).removeClass(CLASSES.ACTIVE).index(target(event));$(target(event)).addClass(CLASSES.ACTIVE);}}).click(function(event){$(target(event)).addClass(CLASSES.ACTIVE);select();input.focus();return false;}).mousedown(function(){config.mouseDownOnSelect=true;}).mouseup(function(){config.mouseDownOnSelect=false;});if(options.width>0)element.css("width",options.width);needsInit=false;}function target(event){var element=event.target;while(element&&element.tagName!="LI")element=element.parentNode;if(!element)return[];return element;}function moveSelect(step){listItems.slice(active,active+1).removeClass(CLASSES.ACTIVE);movePosition(step);var activeItem=listItems.slice(active,active+1).addClass(CLASSES.ACTIVE);if(options.scroll){var offset=0;listItems.slice(0,active).each(function(){offset+=this.offsetHeight;});if((offset+activeItem[0].offsetHeight-list.scrollTop())>list[0].clientHeight){list.scrollTop(offset+activeItem[0].offsetHeight-list.innerHeight());}else if(offset=listItems.size()){active=0;}}function limitNumberOfItems(available){return options.max&&options.max").html(options.highlight(formatted,term)).addClass(i%2==0?"ac_even":"ac_odd").appendTo(list)[0];$.data(li,"ac_data",data[i]);}listItems=list.find("li");if(options.selectFirst){listItems.slice(0,1).addClass(CLASSES.ACTIVE);active=0;}if($.fn.bgiframe)list.bgiframe();}return{display:function(d,q){init();data=d;term=q;fillList();},next:function(){moveSelect(1);},prev:function(){moveSelect(-1);},pageUp:function(){if(active!=0&&active-8<0){moveSelect(-active);}else{moveSelect(-8);}},pageDown:function(){if(active!=listItems.size()-1&&active+8>listItems.size()){moveSelect(listItems.size()-1-active);}else{moveSelect(8);}},hide:function(){element&&element.hide();listItems&&listItems.removeClass(CLASSES.ACTIVE);active=-1;},visible:function(){return element&&element.is(":visible");},current:function(){return this.visible()&&(listItems.filter("."+CLASSES.ACTIVE)[0]||options.selectFirst&&listItems[0]);},show:function(){var offset=$(input).offset();element.css({width:typeof options.width=="string"||options.width>0?options.width:$(input).width(),top:offset.top+input.offsetHeight,left:offset.left}).show();if(options.scroll){list.scrollTop(0);list.css({maxHeight:options.scrollHeight,overflow:'auto'});if($.browser.msie&&typeof document.body.style.maxHeight==="undefined"){var listHeight=0;listItems.each(function(){listHeight+=this.offsetHeight;});var scrollbarsVisible=listHeight>options.scrollHeight;list.css('height',scrollbarsVisible?options.scrollHeight:listHeight);if(!scrollbarsVisible){listItems.width(list.width()-parseInt(listItems.css("padding-left"))-parseInt(listItems.css("padding-right")));}}}},selected:function(){var selected=listItems&&listItems.filter("."+CLASSES.ACTIVE).removeClass(CLASSES.ACTIVE);return selected&&selected.length&&$.data(selected[0],"ac_data");},emptyList:function(){list&&list.empty();},unbind:function(){element&&element.remove();}};};$.Autocompleter.Selection=function(field,start,end){if(field.createTextRange){var selRange=field.createTextRange();selRange.collapse(true);selRange.moveStart("character",start);selRange.moveEnd("character",end);selRange.select();}else if(field.setSelectionRange){field.setSelectionRange(start,end);}else{if(field.selectionStart){field.selectionStart=start;field.selectionEnd=end;}}field.focus();};})(jQuery); +/* + * TypeWatch 2.0 - Original by Denny Ferrassoli / Refactored by Charles Christolini + * Copyright(c) 2007 Denny Ferrassoli - DennyDotNet.com + * Coprright(c) 2008 Charles Christolini - BinaryPie.com + * Dual licensed under the MIT and GPL licenses: + * http://www.opensource.org/licenses/mit-license.php + * http://www.gnu.org/licenses/gpl.html +*/(function(jQuery){jQuery.fn.typeWatch=function(o){var options=jQuery.extend({wait:750,callback:function(){},highlight:true,captureLength:2},o);function checkElement(timer,override){var elTxt=jQuery(timer.el).val();if((elTxt.length>options.captureLength&&elTxt.toUpperCase()!=timer.text)||(override&&elTxt.length>options.captureLength)){timer.text=elTxt.toUpperCase();timer.cb(elTxt)}};function watchElement(elem){if(elem.type.toUpperCase()=="TEXT"||elem.nodeName.toUpperCase()=="TEXTAREA"){var timer={timer:null,text:jQuery(elem).val().toUpperCase(),cb:options.callback,el:elem,wait:options.wait};if(options.highlight){jQuery(elem).focus(function(){this.select()})}var startWatch=function(evt){var timerWait=timer.wait;var overrideBool=false;if(evt.keyCode==13&&this.type.toUpperCase()=="TEXT"){timerWait=1;overrideBool=true}var timerCallbackFx=function(){checkElement(timer,overrideBool)};clearTimeout(timer.timer);timer.timer=setTimeout(timerCallbackFx,timerWait)};jQuery(elem).keydown(startWatch)}};return this.each(function(index){watchElement(this)})}})(jQuery); +/* +Ajax upload +*/jQuery.extend({createUploadIframe:function(d,b){var a="jUploadFrame"+d;if(window.ActiveXObject){var c=document.createElement('