10 | {% endblock %}
--------------------------------------------------------------------------------
/forum/templates/registration/activation_email.txt:
--------------------------------------------------------------------------------
1 | {% load humanize %}
2 | Someone, hopefully you, signed up for a new account at {{ site.domain|escape }} using this email address. If it was you, and you'd like to activate and use your account, click the link below or copy and paste it into your web browser's address bar:
3 |
4 | {{ site.domain|escape }}accounts/activate/{{ activation_key }}/
5 |
6 | If you didn't request this, you don't need to do anything; you won't receive any more email from us, and the account will expire automatically in {{ expiration_days|apnumber }} days.
7 |
--------------------------------------------------------------------------------
/forum/manage.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python
2 | from django.core.management import execute_manager
3 | try:
4 | import settings # Assumed to be in the same directory.
5 | except ImportError:
6 | import sys
7 | sys.stderr.write("Error: Can't find the file 'settings.py' in the directory containing %r. It appears you've customized things.\nYou'll have to run django-admin.py, passing it your settings module.\n(If the file settings.py does indeed exist, it's causing an ImportError somehow.)\n" % __file__)
8 | sys.exit(1)
9 |
10 | if __name__ == "__main__":
11 | execute_manager(settings)
12 |
--------------------------------------------------------------------------------
/forum/templates/registration/activate.html:
--------------------------------------------------------------------------------
1 | {% extends "forum/base.html" %}
2 | {% block title %}Account activation{% endblock %}
3 | {% block content_title %}
Thanks for signing up! Now you can log in and start contributing!
8 | {% else %}
9 |
Either your activation link was incorrect, or the activation key for your account has expired; activation keys are only valid for {{ expiration_days|apnumber }} days after registration.
Are you sure you want to delete the {{ forum.name }} forum?
4 | {% if topic_count %}
5 |
This will result in the deletion of its {{ topic_count }} topic{{ topic_count|pluralize }} and all {{ topic_count|pluralize:"its,their" }} posts.
6 | {% endif %}
7 |
28 | {% endblock %}
--------------------------------------------------------------------------------
/LICENSE.txt:
--------------------------------------------------------------------------------
1 | Django Forum
2 | ------------
3 |
4 | Copyright (c) 2007, Jonathan Buchanan
5 |
6 | Permission is hereby granted, free of charge, to any person obtaining a copy of
7 | this software and associated documentation files (the "Software"), to deal in
8 | the Software without restriction, including without limitation the rights to
9 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
10 | the Software, and to permit persons to whom the Software is furnished to do so,
11 | subject to the following conditions:
12 |
13 | The above copyright notice and this permission notice shall be included in all
14 | copies or substantial portions of the Software.
15 |
16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
18 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
19 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
20 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
21 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
22 |
--------------------------------------------------------------------------------
/forum/templates/base_forum.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | {% block title %}{{ title }}{% endblock %}
5 |
6 |
7 |
8 |
9 | {% block extrahead %}{% endblock %}
10 |
11 |
12 |
13 |
Fill out the form below (all fields are required) and your account will be created; you'll be sent an email with instructions on how to finish your registration.
92 | {% if user|can_see_post_actions:topic %}Add Reply |{% endif %}
93 | New Topic
94 |
95 | {% endif %}
96 |
97 |
98 | {% if user.is_authenticated and user|can_see_post_actions:topic %}
99 |
100 |
101 | {% csrf_token %}
102 |
112 |
113 |
114 |
115 |
116 |
117 |
118 | {% endif %}
119 | {% endblock %}
--------------------------------------------------------------------------------
/forum/settings.py:
--------------------------------------------------------------------------------
1 | # Django settings for using the forum application as a standalone project.
2 |
3 | import os
4 | DIRNAME = os.path.dirname(__file__)
5 |
6 | DEBUG = True
7 | TEMPLATE_DEBUG = DEBUG
8 |
9 | ADMINS = (
10 | # ('Your Name', 'your_email@domain.com'),
11 | )
12 |
13 | MANAGERS = ADMINS
14 |
15 | INTERNAL_IPS = ('127.0.0.1',)
16 |
17 | DATABASES = {
18 | 'default': {
19 | 'ENGINE': 'django.db.backends.sqlite3', # Add 'postgresql_psycopg2', 'postgresql', 'mysql', 'sqlite3' or 'oracle'.
20 | 'NAME': os.path.join(DIRNAME, 'database.db'), # Or path to database file if using sqlite3.
21 | 'USER': '', # Not used with sqlite3.
22 | 'PASSWORD': '', # Not used with sqlite3.
23 | 'HOST': '', # Set to empty string for localhost. Not used with sqlite3.
24 | 'PORT': '', # Set to empty string for default. Not used with sqlite3.
25 | }
26 | }
27 |
28 | # Local time zone for this installation. Choices can be found here:
29 | # http://en.wikipedia.org/wiki/List_of_tz_zones_by_name
30 | # although not all choices may be available on all operating systems.
31 | # On Unix systems, a value of None will cause Django to use the same
32 | # timezone as the operating system.
33 | # If running in a Windows environment this must be set to the same as your
34 | # system time zone.
35 | TIME_ZONE = 'Europe/Belfast'
36 |
37 | # Language code for this installation. All choices can be found here:
38 | # http://www.i18nguy.com/unicode/language-identifiers.html
39 | LANGUAGE_CODE = 'en-gb'
40 |
41 | SITE_ID = 1
42 |
43 | # If you set this to False, Django will make some optimizations so as not
44 | # to load the internationalization machinery.
45 | USE_I18N = True
46 |
47 | # If you set this to False, Django will not format dates, numbers and
48 | # calendars according to the current locale
49 | USE_L10N = True
50 |
51 | # Absolute filesystem path to the directory that will hold user-uploaded files.
52 | # Example: "/home/media/media.lawrence.com/media/"
53 | MEDIA_ROOT = ''
54 |
55 | # URL that handles the media served from MEDIA_ROOT. Make sure to use a
56 | # trailing slash.
57 | # Examples: "http://media.lawrence.com/media/", "http://example.com/media/"
58 | MEDIA_URL = ''
59 |
60 | # Absolute path to the directory static files should be collected to.
61 | # Don't put anything in this directory yourself; store your static files
62 | # in apps' "static/" subdirectories and in STATICFILES_DIRS.
63 | # Example: "/home/media/media.lawrence.com/static/"
64 | STATIC_ROOT = ''
65 |
66 | # URL prefix for static files.
67 | # Example: "http://media.lawrence.com/static/"
68 | STATIC_URL = '/static/'
69 |
70 | # URL prefix for admin static files -- CSS, JavaScript and images.
71 | # Make sure to use a trailing slash.
72 | # Examples: "http://foo.com/static/admin/", "/static/admin/".
73 | ADMIN_MEDIA_PREFIX = '/static/admin/'
74 |
75 | # Additional locations of static files
76 | STATICFILES_DIRS = (
77 | # Put strings here, like "/home/html/static" or "C:/www/django/static".
78 | # Always use forward slashes, even on Windows.
79 | # Don't forget to use absolute paths, not relative paths.
80 | )
81 |
82 | # List of finder classes that know how to find static files in
83 | # various locations.
84 | STATICFILES_FINDERS = (
85 | 'django.contrib.staticfiles.finders.FileSystemFinder',
86 | 'django.contrib.staticfiles.finders.AppDirectoriesFinder',
87 | # 'django.contrib.staticfiles.finders.DefaultStorageFinder',
88 | )
89 |
90 | # Make this unique, and don't share it with anybody.
91 | SECRET_KEY = '4z-(+=l(wkd)1aj+wn)(r%9684uj2589o&uu_w$ids#ww='
92 |
93 | # List of callables that know how to import templates from various sources.
94 | TEMPLATE_LOADERS = (
95 | 'django.template.loaders.filesystem.Loader',
96 | 'django.template.loaders.app_directories.Loader',
97 | # 'django.template.loaders.eggs.Loader',
98 | )
99 |
100 | MIDDLEWARE_CLASSES = [
101 | 'django.middleware.common.CommonMiddleware',
102 | 'django.contrib.sessions.middleware.SessionMiddleware',
103 | 'django.middleware.csrf.CsrfViewMiddleware',
104 | 'django.contrib.auth.middleware.AuthenticationMiddleware',
105 | 'django.contrib.messages.middleware.MessageMiddleware',
106 | ]
107 |
108 | ROOT_URLCONF = 'forum.urls'
109 |
110 | TEMPLATE_DIRS = (
111 | # Put strings here, like "/home/html/django_templates" or "C:/www/django/templates".
112 | # Always use forward slashes, even on Windows.
113 | # Don't forget to use absolute paths, not relative paths.
114 | os.path.join(DIRNAME, 'templates'),
115 | )
116 |
117 | INSTALLED_APPS = [
118 | 'django.contrib.auth',
119 | 'django.contrib.contenttypes',
120 | 'django.contrib.sessions',
121 | 'django.contrib.sites',
122 | 'django.contrib.messages',
123 | 'django.contrib.staticfiles',
124 | 'django.contrib.admin',
125 | 'django.contrib.humanize',
126 | 'registration',
127 | 'forum',
128 | ]
129 |
130 | # A sample logging configuration. The only tangible logging
131 | # performed by this configuration is to send an email to
132 | # the site admins on every HTTP 500 error.
133 | # See http://docs.djangoproject.com/en/dev/topics/logging for
134 | # more details on how to customize your logging configuration.
135 | LOGGING = {
136 | 'version': 1,
137 | 'disable_existing_loggers': False,
138 | 'handlers': {
139 | 'mail_admins': {
140 | 'level': 'ERROR',
141 | 'class': 'django.utils.log.AdminEmailHandler'
142 | }
143 | },
144 | 'loggers': {
145 | 'django.request': {
146 | 'handlers': ['mail_admins'],
147 | 'level': 'ERROR',
148 | 'propagate': True,
149 | },
150 | }
151 | }
152 |
153 | if DEBUG:
154 | try:
155 | import debug_toolbar
156 | MIDDLEWARE_CLASSES.append('debug_toolbar.middleware.DebugToolbarMiddleware')
157 | INSTALLED_APPS.append('debug_toolbar')
158 | except ImportError:
159 | pass
160 |
161 | # Auth settings
162 | LOGIN_URL = '/accounts/login/'
163 | LOGIN_REDIRECT_URL = '/'
164 |
165 | # Session settings
166 | SESSION_ENGINE = 'forum.sessions.redis_session_backend'
167 |
168 | # Registration settings
169 | ACCOUNT_ACTIVATION_DAYS = 10
170 |
171 | # Forum settings
172 | FORUM_STANDALONE = True
173 | FORUM_USE_REDIS = True
174 | FORUM_USE_NODEJS = True
175 | FORUM_POST_FORMATTER = 'forum.formatters.BBCodeFormatter'
176 |
--------------------------------------------------------------------------------
/forum/static/forum/js/DOMBuilder.dom.min.js:
--------------------------------------------------------------------------------
1 | /**
2 | * DOMBuilder 2.0.1 (modes: dom [default]) - https://github.com/insin/DOMBuilder
3 | * MIT licensed
4 | */
5 | (function(w){function k(a,b){for(var f in b)a[f]=b[f];return a}function o(a){for(var b={},f=0,d=a.length;f",h).html(j).get(0)}else return jQuery("<"+i+">",h).get(0)}:function(){var i={tabindex:"tabIndex"},h={tabindex:"tabIndex",readonly:"readOnly","for":"htmlFor","class":"className",maxlength:"maxLength",
15 | cellspacing:"cellSpacing",cellpadding:"cellPadding",rowspan:"rowSpan",colspan:"colSpan",usemap:"useMap",frameborder:"frameBorder",contenteditable:"contentEditable"},j=function(){var e=o.createElement("div");e.setAttribute("className","t");e.innerHTML='s';var c=e.getElementsByTagName("span")[0],g=o.createElement("input");g.value="t";g.setAttribute("type","radio");return{style:/silver/.test(c.getAttribute("style")),getSetAttribute:e.className!="t",radioValue:g.value==
16 | "t"}}(),l,n=function(e,c,g){c!==!1&&(c=h[g]||g,c in e&&(e[c]=!0),e.setAttribute(g,g.toLowerCase()));return g},k={type:function(e,c){if(!j.radioValue&&c=="radio"&&e.nodeName&&e.nodeName.toUpperCase()=="INPUT"){var g=e.value;e.setAttribute("type",c);if(g)e.value=g;return c}},value:function(e,c,g){if(l&&e.nodeName&&e.nodeName.toUpperCase()=="BUTTON")return l(e,c,g);e.value=c}},r=/^(?:autofocus|autoplay|async|checked|controls|defer|disabled|hidden|loop|multiple|open|readonly|required|scoped|selected)$/i,
17 | t=/\:|^on/;if(!j.getSetAttribute)i=h,l=k.name=k.title=function(e,c,g){if(e=e.getAttributeNode(g))return e.nodeValue=c},k.width=k.height=function(e,c){if(c==="")return e.setAttribute(name,"auto"),c};if(!j.style)k.style=function(e,c){return e.style.cssText=""+c};return function(e,c){var g=o.createElement(e),j,p;q.call(c,"innerHTML")&&(u(g,c.innerHTML),delete c.innerHTML);for(j in c)if(p=c[j],s[j])g["on"+j]=p;else{var m=g,a=j;if("getAttribute"in m){var b=void 0,a=i[a]||a,b=k[a];if(!b)if(r.test(a))b=
18 | n;else if(l&&a!="className"&&(m.nodeName&&m.nodeName.toUpperCase()=="FORM"||t.test(a)))b=l;p!==void 0&&!(b&&b(m,p,a)!==void 0)&&m.setAttribute(a,""+p)}else a=h[a]||a,p!==void 0&&(m[a]=p)}return g}}();k.addMode({name:"dom",createElement:function(i,h,j){var l=q.call(h,"innerHTML"),i=v(i,h);if(!l)for(var l=0,h=j.length,k;l 0 and n <= context['pages']]
69 | show_first = 1 not in page_numbers
70 | show_last = context['pages'] not in page_numbers
71 | return {
72 | 'what': what,
73 | 'hits': context['hits'],
74 | 'page': context['page'],
75 | 'pages': context['pages'],
76 | 'page_numbers': page_numbers,
77 | 'next': context['next'],
78 | 'previous': context['previous'],
79 | 'has_next': context['has_next'],
80 | 'has_previous': context['has_previous'],
81 | 'show_first': show_first,
82 | 'show_first_divider': show_first and page_numbers[0] != 2,
83 | 'show_last': show_last,
84 | 'show_last_divider': show_last and page_numbers[-1] != context['pages'] - 1,
85 | }
86 |
87 | ###########
88 | # Filters #
89 | ###########
90 |
91 | @register.filter
92 | def partition(l, n):
93 | """
94 | Partitions a list into lists of size ``n``.
95 |
96 | From http://www.djangosnippets.org/snippets/6/
97 | """
98 | try:
99 | n = int(n)
100 | thelist = list(l)
101 | except (ValueError, TypeError):
102 | return [l]
103 | lists = [list() for i in range(int(ceil(len(l) / float(n))))]
104 | for i, item in enumerate(l):
105 | lists[i/n].append(item)
106 | return lists
107 |
108 | ##########################
109 | # Authentication Filters #
110 | ##########################
111 |
112 | @register.filter
113 | def can_edit_post(user, post):
114 | return user.is_authenticated() and \
115 | auth.user_can_edit_post(user, post)
116 |
117 | @register.filter
118 | def can_edit_topic(user, topic):
119 | return user.is_authenticated() and \
120 | auth.user_can_edit_topic(user, topic)
121 |
122 | @register.filter
123 | def can_edit_user_profile(user, user_to_edit):
124 | return user.is_authenticated() and \
125 | auth.user_can_edit_user_profile(user, user_to_edit)
126 |
127 | @register.filter
128 | def is_admin(user):
129 | """
130 | Returns ``True`` if the given user has admin permissions,
131 | ``False`` otherwise.
132 | """
133 | return user.is_authenticated() and \
134 | auth.is_admin(user)
135 |
136 | @register.filter
137 | def is_moderator(user):
138 | """
139 | Returns ``True`` if the given user has moderation permissions,
140 | ``False`` otherwise.
141 | """
142 | return user.is_authenticated() and \
143 | auth.is_moderator(user)
144 |
145 | @register.filter
146 | def can_see_post_actions(user, topic):
147 | """
148 | Returns ``True`` if the given User should be able to see the post
149 | action list for posts in the given topic, ``False`` otherwise.
150 |
151 | This function is used as part of ensuring that moderators have
152 | unrestricted access to locked Topics.
153 | """
154 | if user.is_authenticated():
155 | return not topic.locked or auth.is_moderator(user)
156 | else:
157 | return False
158 |
159 | #######################
160 | # Date / Time Filters #
161 | #######################
162 |
163 | @register.filter
164 | def forum_datetime(st, user=None):
165 | """
166 | Formats a general datetime.
167 | """
168 | return mark_safe(format_datetime(st, user, 'M jS Y', 'H:i A', ', '))
169 |
170 | @register.filter
171 | def post_time(posted_at, user=None):
172 | """
173 | Formats a Post time.
174 | """
175 | return mark_safe(format_datetime(posted_at, user, r'\o\n M jS Y', r'\a\t H:i A'))
176 |
177 | @register.filter
178 | def joined_date(date):
179 | """
180 | Formats a joined date.
181 | """
182 | return mark_safe(dateformat.format(date, 'M jS Y'))
183 |
184 | ########################
185 | # Topic / Post Filters #
186 | ########################
187 |
188 | @register.filter
189 | def is_first_post(post):
190 | """
191 | Determines if the given post is the first post in a topic.
192 | """
193 | return post.num_in_topic == 1 and not post.meta
194 |
195 | @register.filter
196 | def topic_status_image(topic):
197 | """
198 | Returns HTML for an image representing a topic's status.
199 | """
200 | if has_new_posts(topic):
201 | src = u'forum/img/new_posts.gif'
202 | description = u'New Posts'
203 | else:
204 | src = u'forum/img/no_new_posts.gif'
205 | description = u'No New Posts'
206 | return mark_safe(u'' % (
207 | urljoin(settings.STATIC_URL, src), description, description))
208 |
209 | @register.filter
210 | def has_new_posts(topic):
211 | """
212 | Returns ``True`` if the given topic has new posts for the current
213 | User, based on the presence and value of a ``last_read`` attribute.
214 | """
215 | if hasattr(topic, 'last_read'):
216 | return topic.last_read is None or topic.last_post_at > topic.last_read
217 | else:
218 | return False
219 |
220 | @register.filter
221 | def topic_pagination(topic, posts_per_page):
222 | """
223 | Creates topic listing page links for the given topic, with the given
224 | number of posts per page.
225 |
226 | Topics with between 2 and 5 pages will have page links displayed for
227 | each page.
228 |
229 | Topics with more than 5 pages will have page links displayed for the
230 | first page and the last 3 pages.
231 | """
232 | hits = (topic.post_count - 1)
233 | if hits < 1:
234 | hits = 0
235 | pages = hits // posts_per_page + 1
236 | if pages < 2:
237 | html = u''
238 | else:
239 | page_link = u'%%s' % \
240 | topic.get_absolute_url()
241 | if pages < 6:
242 | html = u' '.join([page_link % (page, page) \
243 | for page in xrange(1, pages + 1)])
244 | else:
245 | html = u' '.join([page_link % (1 ,1), u'…'] + \
246 | [page_link % (page, page) \
247 | for page in xrange(pages - 2, pages + 1)])
248 | return mark_safe(html)
249 |
--------------------------------------------------------------------------------
/README.rst:
--------------------------------------------------------------------------------
1 | ============
2 | Django Forum
3 | ============
4 |
5 | This is a basic forum application which is usable by itself as a standalone
6 | project and should eventually be usable as a pluggable application in any
7 | Django project.
8 |
9 | .. contents::
10 | :local:
11 | :depth: 2
12 |
13 | Features
14 | ========
15 |
16 | - **Bog-standard layout** - Sections |rarr| Forums |rarr| Topics.
17 |
18 | - **Search** posts and topics - search for keywords by section, forum,
19 | username, post type and date.
20 |
21 | Searching supports quoted phrases and ``+`` and ``-`` modifiers on
22 | keywords and phrases.
23 |
24 | - **Real-time tracking** with `Redis`_ - performing ``UPDATE`` queries on
25 | every single page view to track view counts, active users and user
26 | activity on the forum? No.
27 |
28 | Redis does it in style.
29 |
30 | - **Configurable post formatting** - comes with BBCode and Markdown formatters.
31 |
32 | - **Metaposts** - each topic has regular posts, as you'd expect, and also
33 | metaposts. These are effectively a second thread of conversation for
34 | posts *about* the topic. Why, you say?
35 |
36 | People who want to talk about the topic itself or how it's being
37 | discussed, or just start a good old ding-dong with other users, rather
38 | than taking part in the discussion at hand, have a place to vent instead
39 | of dragging the topic into the realm of the off-topic.
40 |
41 | Moderators have another option other than deleting or hiding posts when
42 | topics start to take a turn for the worse in that direction.
43 |
44 | People who just wanted to read and post in the original topic can
45 | continue to do so *and* still have it out in the metaposts. Win/win
46 | or win/lose - the choice is yours.
47 |
48 | Inspired by my many years with the excellent people of `RLLMUK`_.
49 |
50 | - **Avatar validation** - linked avatars are validated for format, file
51 | size and dimensions.
52 |
53 | Possible Misfeatures
54 | --------------------
55 |
56 | - **Denormalised up the yazoo** - data such as post counts and last post
57 | information are maintained on all affected objects on every write.
58 |
59 | Trading write complexity and ease of maintenance against fewer, more
60 | simple reads, just because.
61 |
62 | - **No signatures** - it's not a bug, it's a feature.
63 |
64 | .. _`RLLMUK`: http://www.rllmukforum.com
65 | .. |rarr| unicode:: 0x2192 .. rightward arrow
66 |
67 | Installation
68 | ============
69 |
70 | Dependencies
71 | ------------
72 |
73 | **Required:**
74 |
75 | - `Python Imaging Library`_ (PIL) is required for validation of user avatars.
76 | - `pytz`_ is required to perform timezone conversions based on the timezone
77 | registered users can choose as part of their forum profile.
78 |
79 | **Required for standalone mode:**
80 |
81 | - `django-registration`_ is used to perform registration and validation of new
82 | users when running as a standalone project - it is assumed that when the forum
83 | is integrated into existing projects, they will already have their own
84 | registration mechanism in place.
85 |
86 | **Required based on settings:**
87 |
88 | - `Redis`_ and `redis-py`_ are required for real-time tracking fun if
89 | ``FORUM_USE_REDIS`` is set to ``True``. For Windows users,
90 | `native Redis binaries`_ are available.
91 | - `python-markdown2`_ is required to use ``forum.formatters.MarkdownFormatter``
92 | to format posts using `Markdown`_ syntax.
93 | - `postmarkup`_ and `Pygments`_ are required to use
94 | ``forum.formatters.BBCodeFormatter`` to format posts using `BBCode`_ syntax.
95 |
96 | **Others:**
97 |
98 | - `Django Debug Toolbar`_ will be used if available, if the forum is in
99 | standalone mode and ``DEBUG`` is set to ``True``.
100 |
101 | .. _`redis-py`: https://github.com/andymccurdy/redis-py
102 | .. _`native redis binaries`: https://github.com/dmajkic/redis/downloads
103 | .. _`Python Imaging Library`: http://www.pythonware.com/products/pil/
104 | .. _`pytz`: http://pytz.sourceforge.net/
105 | .. _`django-registration`: http://code.google.com/p/django-registration/
106 | .. _`Django Debug Toolbar`: http://robhudson.github.com/django-debug-toolbar/
107 | .. _`python-markdown2`: http://code.google.com/p/python-markdown2
108 | .. _`Markdown`: http://daringfireball.net/projects/markdown/
109 | .. _`postmarkup`: http://code.google.com/p/postmarkup/
110 | .. _`BBCode`: http://en.wikipedia.org/wiki/BBCode
111 | .. _`Pygments`: http://pygments.org
112 |
113 | Standalone Mode
114 | ---------------
115 |
116 | At the time of typing, the codebase comes with a complete development
117 | ``settings.py`` module which can be used to run the forum in standalone
118 | mode.
119 |
120 | It's configured to to use Redis for real-time tracking on the forum and
121 | for session management using ``forum.sessions.redis_session_backend``.
122 |
123 | Pluggable Application Mode
124 | --------------------------
125 |
126 | **Note: this mode has not yet been fully developed or tested**
127 |
128 | Add ``'forum'`` to your application's ``INSTALLED_APPS`` setting, then run
129 | ``syncdb`` to create its tables.
130 |
131 | Include the forum's URLConf in your project's main URLConf at whatever URL you
132 | like. For example::
133 |
134 | from django.conf.urls.defaults import *
135 |
136 | urlpatterns = patterns(
137 | (r'^forum/', include('forum.urls')),
138 | )
139 |
140 | The forum application's URLs are decoupled using Django's `named URL patterns`_
141 | feature, so it doesn't mind which URL you mount it at.
142 |
143 | .. _`named URL patterns`: http://www.djangoproject.com/documentation/url_dispatch/#naming-url-patterns
144 |
145 | Settings
146 | ========
147 |
148 | The following settings may be added to your project's settings module to
149 | configure the forum application.
150 |
151 | ``FORUM_STANDALONE``
152 |
153 | *Default:* ``False``
154 |
155 | Whether or not the forum is being used in standalone mode. If set to
156 | ``True``, URL configurations for the django.contrib.admin and
157 | django-registration apps will be included in the application's main
158 | URLConf.
159 |
160 | ``FORUM_USE_REDIS``
161 |
162 | *Default:* ``False``
163 |
164 | Whether or not the forum should use `Redis`_ to track real-time information
165 | such as topic view counts, active users and user locations on the forum.
166 |
167 | If set to ``False``, these details will not be displayed.
168 |
169 | ``FORUM_REDIS_HOST``
170 |
171 | *Default:* ``'localhost'``
172 |
173 | Redis host.
174 |
175 | ``FORUM_REDIS_PORT``
176 |
177 | *Default:* ``6379``
178 |
179 | Redis port.
180 |
181 | ``FORUM_REDIS_DB``
182 |
183 | *Default:* ``0``
184 |
185 | Redis database number, ``0``-``16``.
186 |
187 | ``FORUM_POST_FORMATTER``
188 |
189 | *Default:* ``'forum.formatters.PostFormatter'``
190 |
191 | The Python path to the module to be used to format raw post input. This class
192 | should satisfy the requirements defined below in `Post Formatter Structure`_.
193 |
194 | ``FORUM_DEFAULT_POSTS_PER_PAGE``
195 |
196 | *Default:* ``20``
197 |
198 | The number of posts which are displayed by default on any page where posts are
199 | listed - this applies to registered users who do not choose to override the
200 | number of posts per page and to anonymous users.
201 |
202 | ``FORUM_DEFAULT_TOPICS_PER_PAGE``
203 |
204 | *Default:* ``30``
205 |
206 | The number of topics which are displayed by default on any page where topics are
207 | listed - this applies to registered users who do not choose to override the
208 | number of topics per page and to anonymous users.
209 |
210 | ``FORUM_MAX_AVATAR_FILESIZE``
211 |
212 | *Default:* ``512 * 1024`` (512 kB)
213 |
214 | The maximum allowable filesize for user avatars, specified in bytes. To disable
215 | validation of user avatar filesizes, set this setting to ``None``.
216 |
217 | ``FORUM_ALLOWED_AVATAR_FORMATS``
218 |
219 | *Default:* ``('GIF', 'JPEG', 'PNG')``
220 |
221 | A tuple of allowed image formats for user avatars. To disable validation of user
222 | avatar image formats, set this setting to ``None``.
223 |
224 | ``FORUM_MAX_AVATAR_DIMENSIONS``
225 |
226 | *Default:* ``(64, 64)``
227 |
228 | A two-tuple, (width, height), of maximum allowable dimensions for user avatars.
229 | To disable validation of user avatar dimensions, set this setting to ``None``.
230 |
231 | ``FORUM_FORCE_AVATAR_DIMENSIONS``
232 |
233 | *Default:* ``True``
234 |
235 | Whether or not ```` tags created for user avatars should include ``width``
236 | and ``height`` attributes to force all avatars to be displayed with the
237 | dimensions specified in the ``FORUM_MAX_AVATAR_DIMENSIONS`` setting.
238 |
239 | ``FORUM_EMOTICONS``
240 |
241 | *Default:*
242 |
243 | ::
244 |
245 | {':angry:': 'angry.gif',
246 | ':blink:': 'blink.gif',
247 | ':D': 'grin.gif',
248 | ':huh:': 'huh.gif',
249 | ':lol:': 'lol.gif',
250 | ':o': 'ohmy.gif',
251 | ':ph34r:': 'ph34r.gif',
252 | ':rolleyes:': 'rolleyes.gif',
253 | ':(': 'sad.gif',
254 | ':)': 'smile.gif',
255 | ':p': 'tongue.gif',
256 | ':unsure:': 'unsure.gif',
257 | ':wacko:': 'wacko.gif',
258 | ';)': 'wink.gif',
259 | ':wub:': 'wub.gif'}
260 |
261 | A ``dict`` mapping emoticon symbols to the filenames of images they
262 | should be replaced with when emoticons are enabled while formatting
263 | posts. Images should be placed in media/img/emticons.
264 |
265 | Post Formatters
266 | ===============
267 |
268 | Post formatting classes are responsible for taking raw input entered by forum
269 | users and transforming and escaping it for display, as well as performing any
270 | other operations which are dependent on the post formatting syntax being used.
271 |
272 | The following post formatting classes are bundled with the forum application:
273 |
274 | - ``forum.formatters.PostFormatter``
275 | - ``forum.formatters.MarkdownFormatter``
276 | - ``forum.formatters.BBCodeFormatter``
277 |
278 | Post Formatter Structure
279 | ------------------------
280 |
281 | When creating a custom post formatting class, you should subclass
282 | ``forum.formatters.PostFormatter`` and override the following:
283 |
284 | ``QUICK_HELP_TEMPLATE``
285 |
286 | This class-level attribute should specify the location of a template providing
287 | quick help, suitable for embedding into posting pages.
288 |
289 | ``FULL_HELP_TEMPLATE``
290 |
291 | This class-level attribute should specify the location of a template file
292 | providing detailed help, suitable for embedding in a standalone page.
293 |
294 | ``format_post_body(body)``
295 |
296 | This method should accept raw post text input by the user, returning a version
297 | of it which has been transformed and escaped for display. It is important that
298 | the output of this function has been made safe for direct inclusion in
299 | templates, as no further escaping will be performed.
300 |
301 | For example, given the raw post text::
302 |
303 | [quote]T
304 |
305 | t![/quote]
306 |
307 | ...a BBCode post formatter might return something like::
308 |
309 |
T
310 | <es>
311 | t!
312 |
313 | ``quote_post(post)``
314 |
315 | This method should accept a ``Post`` object and return the raw post text for a
316 | a "quoted" version of the post's content. The ``Post`` object itself is passed,
317 | as opposed to just the raw post text, as the quote may wish to include other
318 | details such as the name of the user who made the post, the time the post was
319 | made at, a link back to the quoted post... and so on.
320 |
321 | Note that the raw post text returned by this function will be escaped when it is
322 | displayed to the user for editing, so to avoid double escaping it should *not*
323 | be escaped by this function.
324 |
325 | For example, given a ``Post`` whose raw ``body`` text is::
326 |
327 | Tt!
328 |
329 | ...a BBCode post formatter might return something like::
330 |
331 | [quote]Tt![/quote]
332 |
333 | .. _`Redis`: http://redis.io
334 |
335 | MIT License
336 | ===========
337 |
338 | Copyright (c) 2011, Jonathan Buchanan
339 |
340 | Permission is hereby granted, free of charge, to any person obtaining a copy of
341 | this software and associated documentation files (the "Software"), to deal in
342 | the Software without restriction, including without limitation the rights to
343 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
344 | the Software, and to permit persons to whom the Software is furnished to do so,
345 | subject to the following conditions:
346 |
347 | The above copyright notice and this permission notice shall be included in all
348 | copies or substantial portions of the Software.
349 |
350 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
351 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
352 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
353 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
354 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
355 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
--------------------------------------------------------------------------------
/forum/forms.py:
--------------------------------------------------------------------------------
1 | import datetime
2 | import operator
3 | import urllib
4 |
5 | from django import forms
6 | from django.db.models.query_utils import Q
7 | from django.forms.models import modelform_factory
8 | from django.template.defaultfilters import filesizeformat
9 | from django.utils.text import capfirst, get_text_list, smart_split
10 |
11 | from forum import app_settings
12 | from forum.models import Forum, ForumProfile, Post, Search, Section, Topic
13 |
14 | # Try to import PIL in either of the two ways it can end up installed.
15 | try:
16 | from PIL import ImageFile as PILImageFile
17 | except ImportError:
18 | import ImageFile as PILImageFile
19 |
20 | class AddSectionForm(forms.Form):
21 | """
22 | Form for adding a new Section - takes and existing Section it should
23 | be inserted before.
24 | """
25 | name = forms.CharField(max_length=100)
26 | section = forms.ChoiceField(required=False)
27 |
28 | def __init__(self, sections, *args, **kwargs):
29 | super(AddSectionForm, self).__init__(*args, **kwargs)
30 | self.sections = sections
31 | self.fields['section'].choices = [('', '----------')] + \
32 | [(section.id, section.name) for section in sections]
33 |
34 | def clean_name(self):
35 | """Validates that the section name is unique."""
36 | for section in self.sections:
37 | if self.cleaned_data['name'] == section.name:
38 | raise forms.ValidationError('A Section with this name already exists.')
39 | return self.cleaned_data['name']
40 |
41 | class EditSectionForm(forms.ModelForm):
42 | """Form for editing a Section."""
43 | class Meta:
44 | model = Section
45 | fields = ('name',)
46 |
47 | def clean_name(self):
48 | """Validates that the section name is unique if it has changed."""
49 | if self.fields['name'].initial != self.cleaned_data['name']:
50 | try:
51 | Section.objects.get(name=self.cleaned_data['name'])
52 | raise forms.ValidationError('A Section with this name already exists.')
53 | except Section.DoesNotExist:
54 | pass
55 | return self.cleaned_data['name']
56 |
57 | class AddForumForm(forms.Form):
58 | """
59 | Form for adding a new Forum - takes an existing Forum it should be
60 | inserted before.
61 | """
62 | name = forms.CharField(max_length=100)
63 | description = forms.CharField(max_length=100, required=False, widget=forms.Textarea())
64 | forum = forms.ChoiceField(required=False)
65 |
66 | def __init__(self, forums, *args, **kwargs):
67 | super(AddForumForm, self).__init__(*args, **kwargs)
68 | self.fields['forum'].choices = [('', '----------')] + \
69 | [(forum.id, forum.name) for forum in forums]
70 |
71 | class EditForumForm(forms.ModelForm):
72 | """Form for editing a Forum."""
73 | class Meta:
74 | model = Forum
75 | fields = ('name', 'description')
76 |
77 | def topic_formfield_callback(field, **kwargs):
78 | """
79 | Callback for Post form field creation.
80 |
81 | Customises the size of the widgets used to edit topic details.
82 | """
83 | if field.name in ['title', 'description']:
84 | formfield = field.formfield(**kwargs)
85 | formfield.widget.attrs['size'] = 50
86 | return formfield
87 | else:
88 | return field.formfield(**kwargs)
89 |
90 | class AddTopicForm(forms.ModelForm):
91 | """Form for adding a new Topic."""
92 | formfield_callback = topic_formfield_callback
93 |
94 | class Meta:
95 | model = Topic
96 | fields = ('title', 'description')
97 |
98 | class EditTopicForm(forms.ModelForm):
99 | """Form for editing a Topic."""
100 | formfield_callback = topic_formfield_callback
101 |
102 | class Meta:
103 | model = Topic
104 | fields = ('title', 'description', 'pinned', 'locked', 'hidden')
105 |
106 | def __init__(self, moderate, *args, **kwargs):
107 | super(EditTopicForm, self).__init__(*args, **kwargs)
108 | if not moderate:
109 | del self.fields['pinned']
110 | del self.fields['locked']
111 | del self.fields['hidden']
112 |
113 | def post_formfield_callback(field, **kwargs):
114 | """
115 | Callback for Post form field creation.
116 |
117 | Customises the widget used to edit posts.
118 | """
119 | if field.name == 'body':
120 | formfield = field.formfield(**kwargs)
121 | formfield.widget.attrs['rows'] = 14
122 | formfield.widget.attrs['cols'] = 70
123 | return formfield
124 | else:
125 | return field.formfield(**kwargs)
126 |
127 | class TopicPostForm(forms.ModelForm):
128 | """Form for the initial Post in a new Topic."""
129 | formfield_callback = post_formfield_callback
130 |
131 | class Meta:
132 | model = Post
133 | fields = ('body', 'emoticons')
134 |
135 | class ReplyForm(forms.ModelForm):
136 | """Form for a reply Post."""
137 | formfield_callback = post_formfield_callback
138 |
139 | class Meta:
140 | model = Post
141 | fields = ('body', 'emoticons', 'meta')
142 |
143 | def __init__(self, meta, *args, **kwargs):
144 | super(ReplyForm, self).__init__(*args, **kwargs)
145 | if not meta:
146 | del self.fields['meta']
147 |
148 | class SearchForm(forms.Form):
149 | """
150 | Criteria for searching Topics or Posts.
151 |
152 | Creates a QuerySet based on selected criteria.
153 | """
154 | SEARCH_ALL_FORUMS = 'A'
155 | SEARCH_IN_SECTION = 'S'
156 | SEARCH_IN_FORUM = 'F'
157 |
158 | SEARCH_ALL_POSTS = 'A'
159 | SEARCH_REGULAR_POSTS = 'R'
160 | SEARCH_METAPOSTS = 'M'
161 | SEARCH_POST_TYPE_CHOICES = (
162 | (SEARCH_ALL_POSTS, 'All Posts'),
163 | (SEARCH_REGULAR_POSTS, 'Regular Posts'),
164 | (SEARCH_METAPOSTS, 'Metaposts'),
165 | )
166 |
167 | SEARCH_FROM_TODAY = 'T'
168 | SEARCH_ANY_DATE = 'A'
169 | SEARCH_FROM_CHOICES = (
170 | (SEARCH_FROM_TODAY, 'Today and...'),
171 | (7, '7 days ago and...'),
172 | (30, '30 days ago and...'),
173 | (60, '60 days ago and...'),
174 | (90, '90 days ago and...'),
175 | (180, '180 days ago and...'),
176 | (365, '365 days ago and...'),
177 | (SEARCH_ANY_DATE, 'Any date'),
178 | )
179 |
180 | SEARCH_OLDER = 'O'
181 | SEARCH_NEWER = 'N'
182 | SEARCH_WHEN_CHOICES = (
183 | (SEARCH_OLDER, 'Older'),
184 | (SEARCH_NEWER, 'Newer'),
185 | )
186 | SEARCH_WHEN_LOOKUP = {
187 | SEARCH_OLDER: 'lte',
188 | SEARCH_NEWER: 'gte',
189 | }
190 |
191 | SORT_DESCENDING = 'D'
192 | SORT_ASCENDING = 'A'
193 | SORT_DIRECTION_CHOICES = (
194 | (SORT_DESCENDING, 'Descending'),
195 | (SORT_ASCENDING, 'Ascending'),
196 | )
197 | SORT_DIRECTION_FLAG = {
198 | SORT_DESCENDING: '-',
199 | SORT_ASCENDING: '',
200 | }
201 |
202 | USERNAME_LOOKUP = {True: '', False: '__icontains'}
203 |
204 | search_type = forms.ChoiceField(choices=Search.TYPE_CHOICES, initial=Search.POST_SEARCH, widget=forms.RadioSelect)
205 | keywords = forms.CharField()
206 | username = forms.CharField(required=False)
207 | exact_username = forms.BooleanField(required=False, initial=True, label='Match exact username')
208 | post_type = forms.ChoiceField(choices=SEARCH_POST_TYPE_CHOICES, initial=SEARCH_ALL_POSTS, widget=forms.RadioSelect)
209 | search_in = forms.MultipleChoiceField(required=False, initial=[SEARCH_ALL_FORUMS])
210 | search_from = forms.ChoiceField(choices=SEARCH_FROM_CHOICES)
211 | search_when = forms.ChoiceField(choices=SEARCH_WHEN_CHOICES, initial=SEARCH_OLDER, widget=forms.RadioSelect)
212 | sort_direction = forms.ChoiceField(choices=SORT_DIRECTION_CHOICES, initial=SORT_DESCENDING, widget=forms.RadioSelect)
213 |
214 | def __init__(self, *args, **kwargs):
215 | super(SearchForm, self).__init__(*args, **kwargs)
216 | choices = [(self.SEARCH_ALL_FORUMS, 'All Forums')]
217 | for section, forums in Section.objects.get_forums_by_section():
218 | choices.append(('%s.%s' % (self.SEARCH_IN_SECTION, section.pk),
219 | section.name))
220 | choices.extend([('%s.%s' % (self.SEARCH_IN_FORUM, forum.pk),
221 | '|-- %s' % forum.name) \
222 | for forum in forums])
223 | self.fields['search_in'].choices = choices
224 | self.fields['search_in'].widget.attrs['size'] = 10
225 |
226 | def clean_keywords(self):
227 | """
228 | Validates that no search keyword is shorter than 3 characters.
229 | """
230 | for keyword in smart_split(self.cleaned_data['keywords']):
231 | keyword_len = len(keyword)
232 | if keyword[0] in ('+', '-'):
233 | keyword_len = keyword_len - 1
234 | elif keyword[0] == '"' and keyword[-1] == '"' or \
235 | keyword[0] == "'" and keyword[-1] == "'":
236 | keyword_len = keyword_len - 2
237 | if keyword_len < 3:
238 | raise forms.ValidationError('Keywords must be a minimun of 3 characters long.')
239 | return self.cleaned_data['keywords']
240 |
241 | def get_queryset(self):
242 | """
243 | Creates a ``QuerySet`` based on the search criteria specified in
244 | this form.
245 |
246 | Returns ``None`` if the form doesn't appear to have been
247 | validated.
248 | """
249 | if not hasattr(self, 'cleaned_data'):
250 | return None
251 |
252 | search_type = self.cleaned_data['search_type']
253 | filters = []
254 |
255 | # Calculate certain lookup values based on criteria
256 | search_in = {}
257 | if len(self.cleaned_data['search_in']) and \
258 | self.SEARCH_ALL_FORUMS not in self.cleaned_data['search_in']:
259 | for item in self.cleaned_data['search_in']:
260 | bits = item.split('.')
261 | search_in.setdefault(bits[0], []).append(bits[1])
262 |
263 | from_date = None
264 | if self.cleaned_data['search_from'] != self.SEARCH_ANY_DATE:
265 | from_date = datetime.date.today()
266 | if self.cleaned_data['search_from'] != self.SEARCH_FROM_TODAY:
267 | days_ago = int(self.cleaned_data['search_from'])
268 | from_date = from_date - datetime.timedelta(days=days_ago)
269 | if self.cleaned_data['search_when'] == self.SEARCH_OLDER:
270 | # Less-than date searches should compare to midnight on
271 | # the following day.
272 | from_date = from_date + datetime.timedelta(days=1)
273 |
274 | # Some lookup fields which change based on the search type
275 | if search_type == Search.POST_SEARCH:
276 | section_lookup = 'topic__forum__section'
277 | forum_lookup = 'topic__forum'
278 | date_lookup = 'posted_at'
279 | text_lookup = 'body'
280 | # Searching should not give the user access to Posts in
281 | # hidden Topics.
282 | filters.append(Q(topic__hidden=False))
283 | else:
284 | section_lookup = 'forum__section'
285 | forum_lookup = 'forum'
286 | date_lookup = 'started_at'
287 | text_lookup = 'title'
288 | # Searching should not give the user access to hidden Topics
289 | filters.append(Q(hidden=False))
290 |
291 | # Create lookup filters
292 | if search_type == Search.POST_SEARCH and \
293 | self.cleaned_data['post_type'] != self.SEARCH_ALL_POSTS:
294 | meta = self.cleaned_data['post_type'] == self.SEARCH_METAPOSTS
295 | filters.append(Q(meta=meta))
296 |
297 | if self.SEARCH_IN_SECTION in search_in and \
298 | self.SEARCH_IN_FORUM in search_in:
299 | filters.append(Q(**{'%s__in' % section_lookup: search_in[self.SEARCH_IN_SECTION]}) | \
300 | Q(**{'%s__in' % forum_lookup: search_in[self.SEARCH_IN_FORUM]}))
301 | elif self.SEARCH_IN_SECTION in search_in:
302 | filters.append(Q(**{'%s__in' % section_lookup: search_in[self.SEARCH_IN_SECTION]}))
303 | elif self.SEARCH_IN_FORUM in search_in:
304 | filters.append(Q(**{'%s__in' % forum_lookup: search_in[self.SEARCH_IN_FORUM]}))
305 |
306 | if from_date is not None:
307 | lookup_type = self.SEARCH_WHEN_LOOKUP[self.cleaned_data['search_when']]
308 | filters.append(Q(**{'%s__%s' % (date_lookup, lookup_type): from_date}))
309 |
310 | if self.cleaned_data['username']:
311 | lookup_type = self.USERNAME_LOOKUP[self.cleaned_data['exact_username']]
312 | filters.append(Q(**{'user__username%s' % lookup_type: \
313 | self.cleaned_data['username']}))
314 |
315 | one_of_filters = []
316 | phrase_filters = []
317 | for keyword in smart_split(self.cleaned_data['keywords']):
318 | if keyword[0] == '+':
319 | filters.append(Q(**{'%s__icontains' % text_lookup: keyword[1:]}))
320 | elif keyword[0] == '-':
321 | filters.append(~Q(**{'%s__icontains' % text_lookup: keyword[1:]}))
322 | elif keyword[0] == '"' and keyword[-1] == '"' or \
323 | keyword[0] == "'" and keyword[-1] == "'":
324 | phrase_filters.append(Q(**{'%s__icontains' % text_lookup: keyword[1:-1]}))
325 | else:
326 | one_of_filters.append(Q(**{'%s__icontains' % text_lookup: keyword}))
327 | if one_of_filters:
328 | filters.append(reduce(operator.or_, one_of_filters))
329 | if phrase_filters:
330 | filters.append(reduce(operator.or_, phrase_filters))
331 |
332 | # Apply filters and perform ordering
333 | if search_type == Search.POST_SEARCH:
334 | qs = Post.objects.all()
335 | else:
336 | qs = Topic.objects.all()
337 | if len(filters):
338 | qs = qs.filter(reduce(operator.and_, filters))
339 | sort_direction_flag = \
340 | self.SORT_DIRECTION_FLAG[self.cleaned_data['sort_direction']]
341 | return qs.order_by('%s%s' % (sort_direction_flag, date_lookup),
342 | '%sid' % sort_direction_flag)
343 |
344 | class ImageURLField(forms.URLField):
345 | """
346 | A URL field specifically for images, which can validate details
347 | about the filesize, dimensions and format of an image at a given
348 | URL.
349 |
350 | Specifying any of the following arguments will result in the
351 | appropriate validation of image details, retrieved from the URL
352 | specified in this field:
353 |
354 | max/min_filesize
355 | An integer specifying an image filesize limit, in bytes.
356 |
357 | max/min_width
358 | An integer specifying an image width limit, in pixels.
359 |
360 | max/min_height
361 | An integer specifying an image height limit, in pixels.
362 |
363 | image_formats
364 | A list of image formats to be accepted, specified as uppercase
365 | strings.
366 |
367 | For a list of valid image formats, see the "Image File Formats"
368 | section of the `Python Imaging Library Handbook`_.
369 |
370 | .. _`Python Imaging Library Handbook`: http://www.pythonware.com/library/pil/handbook/
371 | """
372 | def __init__(self, max_filesize=None, min_filesize=None, max_width=None,
373 | min_width=None, max_height=None, min_height=None, image_formats=None,
374 | *args, **kwargs):
375 | super(ImageURLField, self).__init__(*args, **kwargs)
376 | self.max_filesize, self.min_filesize = max_filesize, min_filesize
377 | self.max_width, self.min_width = max_width, min_width
378 | self.max_height, self.min_height = max_height, min_height
379 | self.image_formats = image_formats
380 | self.validate_image = \
381 | max_filesize is not None or min_filesize is not None or \
382 | max_width is not None or min_width is not None or \
383 | max_height is not None or min_height is not None or \
384 | image_formats is not None
385 |
386 | def validate(self, value):
387 | super(ImageURLField, self).validate(value)
388 |
389 | if value == '' or not self.validate_image:
390 | return
391 |
392 | try:
393 | filesize, dimensions, format = self._get_image_details(value)
394 | if dimensions is None or format is None:
395 | raise forms.ValidationError(
396 | 'Could not retrieve image details from this URL.')
397 | if self.max_filesize is not None and filesize > self.max_filesize:
398 | raise forms.ValidationError(
399 | 'The image at this URL is %s large - it must be at most %s.' % (
400 | filesizeformat(filesize), filesizeformat(self.max_filesize)))
401 | if self.min_filesize is not None and filesize < self.min_filesize:
402 | raise forms.ValidationError(
403 | 'The image at this URL is %s large - it must be at least %s.' % (
404 | filesizeformat(filesize), filesizeformat(self.min_filesize)))
405 | if self.max_width is not None and dimensions[0] > self.max_width:
406 | raise forms.ValidationError(
407 | 'The image at this URL is %s pixels wide - it must be at most %s pixels.' % (
408 | dimensions[0], self.max_width))
409 | if self.min_width is not None and dimensions[0] < self.min_width:
410 | raise forms.ValidationError(
411 | 'The image at this URL is %s pixels wide - it must be at least %s pixels.' % (
412 | dimensions[0], self.min_width))
413 | if self.max_height is not None and dimensions[1] > self.max_height:
414 | raise forms.ValidationError(
415 | 'The image at this URL is %s pixels high - it must be at most %s pixels.' % (
416 | dimensions[1], self.max_height))
417 | if self.min_height is not None and dimensions[1] < self.min_height:
418 | raise forms.ValidationError(
419 | 'The image at this URL is %s pixels high - it must be at least %s pixels.' % (
420 | dimensions[1], self.min_height))
421 | if self.image_formats is not None and format not in self.image_formats:
422 | raise forms.ValidationError(
423 | 'The image at this URL is in %s format - %s %s.' % (
424 | format,
425 | len(self.image_formats) == 1 and 'the only accepted format is' or 'accepted formats are',
426 | get_text_list(self.image_formats)))
427 | except IOError:
428 | raise forms.ValidationError('Could not load an image from this URL.')
429 | return value
430 |
431 | def _get_image_details(self, url):
432 | """
433 | Retrieves details about the image accessible at the given URL,
434 | returning a 3-tuple of (filesize, image dimensions (width,
435 | height) and image format), or (filesize, ``None``, ``None``) if
436 | image details could not be determined.
437 |
438 | The Python Imaging Library is used to parse the image in chunks
439 | in order to determine its dimension and format details without
440 | having to load the entire image into memory.
441 |
442 | Adapted from http://effbot.org/zone/pil-image-size.htm
443 | """
444 | file = urllib.urlopen(url)
445 | filesize = file.headers.get('content-length')
446 | if filesize: filesize = int(filesize)
447 | p = PILImageFile.Parser()
448 | while 1:
449 | data = file.read(1024)
450 | if not data:
451 | break
452 | p.feed(data)
453 | if p.image:
454 | return filesize, p.image.size, p.image.format
455 | break
456 | file.close()
457 | return filesize, None, None
458 |
459 | def forum_profile_formfield_callback(field, **kwargs):
460 | """
461 | Callback for forum profile form field creation.
462 |
463 | Generates an ``ImageURLField`` for the ``avatar`` field and default
464 | fields for all others.
465 | """
466 | if field.name == 'avatar':
467 | args = {
468 | 'verify_exists': field.validators[-1].verify_exists, # TODO Make nice
469 | 'max_length': field.max_length,
470 | 'required': not field.blank,
471 | 'label': capfirst(field.verbose_name),
472 | 'help_text': field.help_text,
473 | }
474 | if app_settings.MAX_AVATAR_FILESIZE is not None:
475 | args['max_filesize'] = app_settings.MAX_AVATAR_FILESIZE
476 | if app_settings.ALLOWED_AVATAR_FORMATS is not None:
477 | args['image_formats'] = app_settings.ALLOWED_AVATAR_FORMATS
478 | if app_settings.MAX_AVATAR_DIMENSIONS is not None:
479 | args['max_width'] = app_settings.MAX_AVATAR_DIMENSIONS[0]
480 | args['max_height'] = app_settings.MAX_AVATAR_DIMENSIONS[1]
481 | args.update(kwargs)
482 | return ImageURLField(**args)
483 | else:
484 | return field.formfield(**kwargs)
485 |
486 | class UserProfileForm(forms.ModelForm):
487 | """Form for editing the user profile fields in a ForumProfile."""
488 | formfield_callback = forum_profile_formfield_callback
489 |
490 | class Meta:
491 | model = ForumProfile
492 | fields = ('title', 'location', 'avatar', 'website')
493 |
494 | def __init__(self, can_edit_title, *args, **kwargs):
495 | super(UserProfileForm, self).__init__(*args, **kwargs)
496 | if not can_edit_title:
497 | del self.fields['title']
498 |
499 | class ForumSettingsForm(forms.ModelForm):
500 | """Form for editing the board setting fields in a ForumProfile."""
501 | class Meta:
502 | model = ForumProfile
503 | fields = ('timezone', 'topics_per_page', 'posts_per_page', 'auto_fast_reply')
504 |
--------------------------------------------------------------------------------