├── .gitignore
├── LICENSE.txt
├── MANIFEST.in
├── README.rst
├── TODO.txt
├── apache
├── django.wsgi
└── httpd.conf
├── forum
├── __init__.py
├── admin.py
├── app_settings.py
├── auth.py
├── bin
│ └── create-test-data.py
├── fixtures
│ └── testdata.json
├── formatters
│ ├── __init__.py
│ └── emoticons.py
├── forms.py
├── manage.py
├── models.py
├── moderation.py
├── redis_connection.py
├── sessions
│ ├── __init__.py
│ └── redis_session_backend.py
├── settings.py
├── static
│ └── forum
│ │ ├── css
│ │ ├── ie.css
│ │ └── style.css
│ │ ├── img
│ │ ├── djangosite.gif
│ │ ├── emoticons
│ │ │ ├── angry.gif
│ │ │ ├── blink.gif
│ │ │ ├── grin.gif
│ │ │ ├── huh.gif
│ │ │ ├── lol.gif
│ │ │ ├── ohmy.gif
│ │ │ ├── ph34r.gif
│ │ │ ├── rolleyes.gif
│ │ │ ├── sad.gif
│ │ │ ├── smile.gif
│ │ │ ├── tongue.gif
│ │ │ ├── unsure.gif
│ │ │ ├── wacko.gif
│ │ │ ├── wink.gif
│ │ │ └── wub.gif
│ │ ├── icon_alert.gif
│ │ ├── icon_exclamation.gif
│ │ ├── icon_lock.gif
│ │ ├── new_posts.gif
│ │ ├── no_new_posts.gif
│ │ └── unread_post.gif
│ │ └── js
│ │ ├── DOMBuilder.dom.min.js
│ │ ├── Paginator.js
│ │ ├── PostForm.js
│ │ ├── Stalk.js
│ │ ├── Topic.js
│ │ └── jquery-1.2.1.pack.js
├── templates
│ ├── 404.html
│ ├── 500.html
│ ├── base_forum.html
│ ├── forum
│ │ ├── add_forum.html
│ │ ├── add_reply.html
│ │ ├── add_section.html
│ │ ├── add_topic.html
│ │ ├── base.html
│ │ ├── delete_forum.html
│ │ ├── delete_post.html
│ │ ├── delete_section.html
│ │ ├── delete_topic.html
│ │ ├── edit_forum.html
│ │ ├── edit_post.html
│ │ ├── edit_section.html
│ │ ├── edit_topic.html
│ │ ├── edit_user_forum_profile.html
│ │ ├── edit_user_forum_settings.html
│ │ ├── forum_detail.html
│ │ ├── forum_index.html
│ │ ├── help
│ │ │ ├── basic_formatting_quick.html
│ │ │ ├── bbcode_formatting_quick.html
│ │ │ ├── emoticons.html
│ │ │ └── markdown_formatting_quick.html
│ │ ├── new_posts.html
│ │ ├── pagination.html
│ │ ├── permission_denied.html
│ │ ├── search.html
│ │ ├── search_results.html
│ │ ├── section_detail.html
│ │ ├── stalk_users.html
│ │ ├── topic_detail.html
│ │ ├── topic_post_summary.html
│ │ ├── user_profile.html
│ │ └── user_topics.html
│ └── registration
│ │ ├── activate.html
│ │ ├── activation_email.txt
│ │ ├── activation_email_subject.txt
│ │ ├── login.html
│ │ ├── logout.html
│ │ ├── password_change_done.html
│ │ ├── password_change_form.html
│ │ ├── password_reset_done.html
│ │ ├── password_reset_email.html
│ │ ├── password_reset_form.html
│ │ ├── registration_complete.html
│ │ └── registration_form.html
├── templatetags
│ ├── __init__.py
│ └── forum_tags.py
├── tests
│ ├── __init__.py
│ ├── auth.py
│ └── models.py
├── urls.py
├── utils
│ ├── __init__.py
│ ├── dates.py
│ └── models.py
└── views.py
├── package.json
├── pip-requirements.txt
├── setup.py
└── stalk.js
/.gitignore:
--------------------------------------------------------------------------------
1 | *.pyc
2 | *.egg-info
3 | MANIFEST
4 | build/
5 | dist/
6 | forum/database.db
7 | node_modules
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/MANIFEST.in:
--------------------------------------------------------------------------------
1 | include MANIFEST.in
2 | recursive-include tests *
--------------------------------------------------------------------------------
/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.
--------------------------------------------------------------------------------
/TODO.txt:
--------------------------------------------------------------------------------
1 | ======================
2 | Django Forum TODO List
3 | ======================
4 |
5 | :author: Jonathan Buchanan
6 |
7 | Top Priority
8 | ============
9 |
10 | *Dirty hacks and other broken windows.*
11 |
12 | Make It So, Number One
13 | ======================
14 |
15 | *Features, fixes and tasks which are ready to be implementated or performed as
16 | and when the time can be found.*
17 |
18 | - Non-standalone mode issues which will need to be resolved:
19 |
20 | - Provide a means of performing certain URL lookups based on whether we're in
21 | standalone mode or not. There's already a ``LOGIN_URL`` setting which we
22 | could use instead of django-registration's ``auth_login`` named URL, but
23 | what about the logout and register URLs?
24 | - I imagine it will be likely that the forum's standalone password
25 | change/reset forms will be used in favour of the admin application's any
26 | time a project is using the application directory template loader (as we
27 | require it to) - it might be necessary to add moving of the standalone
28 | registration templates into the ``/forum/templates/`` directory as one of
29 | the installation steps for using the forum in standalone mode.
30 |
31 | - Create full help templates for the provided post formatters.
32 | - Give the BBCode formatter some love - we're currently using the default set of
33 | tags it provides. Decide which we really need, which are missing and implement
34 | them if need be.
35 | - Post control for moderators - move post to another topic, merge all posts from
36 | one topic into another.
37 | - User control for moderators.
38 |
39 | *Testing.*
40 |
41 | - Write test client tests.
42 | - Testing in non-standalone mode - create a project which itself uses
43 | django-registration to ensure that the forum's set of templates don't take
44 | precedence. If the order of ``INSTALLED_APPS`` is important in this case,
45 | document it in the installation docs.
46 |
47 | For Future Consideration
48 | ========================
49 |
50 | *Features which require a bit more consideration - as in does the application
51 | really need the added complexity they bring? It has a fairly simple structure
52 | at the moment.*
53 |
54 | *Boring stuff which most forums have:*
55 |
56 | - Subforums.
57 | - Tracking which posts were replied to / using this information to offer a
58 | threaded topic view.
59 | - Poll topics.
60 |
61 | *Possibly less boring stuff:*
62 |
63 | - Up/down voting *everywhere*, starting with users, topics and posts, each of
64 | which should have a configurable lower boundary in user profiles, scores under
65 | which would result in topics, posts or everything from given users being
66 | hidden. Let consensus be your ignore list, if you're mad enough.
67 | - Forum Types, which could affect how everything in a particular forum works.
68 | Examples:
69 |
70 | - Help Forums could have the initial post in each topic displayed on every
71 | page, with some kind of kudos point system in place, where topic starters
72 | can award points for useful answers. Uses of kudos points:
73 |
74 | 1. Total points earned could be displayed in place of Postcounts in user
75 | profiles, and they would actually *mean* something.
76 | 2. An option to view only the starting post and posts which got kudos points
77 | if you just want to see what the useful answers were.
78 |
79 | - Progressive enhancement with JavaScript:
80 |
81 | - Hijack topic and forum pagination.
82 | - Hijack posting of new replies to work on the same page.
83 | - Periodic fetching of new posts when on the last page of a topic.
84 | - Adding a new control to edit replies in place, with preview.
85 |
--------------------------------------------------------------------------------
/apache/django.wsgi:
--------------------------------------------------------------------------------
1 | import os
2 | import sys
3 |
4 | activate_this = 'C:/virtualenvs/forum/Scripts/activate_this.py'
5 | execfile(activate_this, dict(__file__=activate_this))
6 |
7 | os.environ['DJANGO_SETTINGS_MODULE'] = 'forum.settings'
8 |
9 | import django.core.handlers.wsgi
10 | application = django.core.handlers.wsgi.WSGIHandler()
11 |
--------------------------------------------------------------------------------
/apache/httpd.conf:
--------------------------------------------------------------------------------
1 | WSGIPythonHome C:/virtualenvs/forum/
2 |
3 | NameVirtualHost *:81
4 |
5 |
6 | ServerName insin.forum
7 | ServerAlias insin.forum
8 |
9 | WSGIScriptAlias / C:/virtualenvs/forum/src/forum/apache/django.wsgi
10 |
11 | Order allow,deny
12 | Allow from all
13 |
14 |
15 | Alias /media/forum/ C:/virtualenvs/forum/src/forum/forum/media/
16 |
17 | Order allow,deny
18 | Allow from all
19 |
20 |
21 | Alias /admin_media/ C:/virtualenvs/forum/Lib/site-packages/django/contrib/admin/media/
22 |
23 | Order allow,deny
24 | Allow from all
25 |
26 |
--------------------------------------------------------------------------------
/forum/__init__.py:
--------------------------------------------------------------------------------
1 | VERSION = (0, 1, 0, 'alpha', 0)
2 |
3 | def get_version():
4 | version = '%s.%s' % (VERSION[0], VERSION[1])
5 | if VERSION[2]:
6 | version = '%s.%s' % (version, VERSION[2])
7 | if VERSION[3:] == ('alpha', 0):
8 | version = '%s pre-alpha' % version
9 | else:
10 | if VERSION[3] != 'final':
11 | version = '%s %s %s' % (version, VERSION[3], VERSION[4])
12 | return version
13 |
--------------------------------------------------------------------------------
/forum/admin.py:
--------------------------------------------------------------------------------
1 | from django.contrib import admin
2 |
3 | from forum.models import ForumProfile, Section, Forum, Topic, Post, Search
4 |
5 | DENORMALISED_DATA_NOTICE = 'You shouldn\'t need to edit this data manually.'
6 |
7 | class ForumProfileAdmin(admin.ModelAdmin):
8 | list_display = ('user', 'group', 'title', 'location',
9 | 'post_count')
10 | list_filter = ('group',)
11 | fieldsets = (
12 | (None, {
13 | 'fields': ('user', 'group', 'title', 'location', 'avatar',
14 | 'website'),
15 | }),
16 | ('Board settings', {
17 | 'fields': ('timezone', 'topics_per_page', 'posts_per_page',
18 | 'auto_fast_reply'),
19 | }),
20 | ('Denormalised data', {
21 | 'classes': ('collapse',),
22 | 'description': DENORMALISED_DATA_NOTICE,
23 | 'fields': ('post_count',),
24 | }),
25 | )
26 |
27 | class SectionAdmin(admin.ModelAdmin):
28 | list_display = ('name', 'order')
29 |
30 | class ForumAdmin(admin.ModelAdmin):
31 | list_display = ('name', 'section', 'description', 'order',
32 | 'topic_count', 'locked', 'hidden')
33 | list_filter = ('section',)
34 | fieldsets = (
35 | (None, {
36 | 'fields': ('name', 'section', 'description', 'order'),
37 | }),
38 | ('Administration', {
39 | 'fields': ('locked', 'hidden'),
40 | }),
41 | ('Denormalised data', {
42 | 'classes': ('collapse',),
43 | 'description': DENORMALISED_DATA_NOTICE,
44 | 'fields': ('topic_count', 'last_post_at', 'last_topic_id',
45 | 'last_topic_title','last_user_id', 'last_username'),
46 | }),
47 | )
48 | search_fields = ('name',)
49 |
50 | class TopicAdmin(admin.ModelAdmin):
51 | list_display = ('title', 'forum', 'user', 'started_at', 'post_count',
52 | 'metapost_count', 'last_post_at', 'locked', 'pinned',
53 | 'hidden')
54 | list_filter = ('forum', 'locked', 'pinned', 'hidden')
55 | fieldsets = (
56 | (None, {
57 | 'fields': ('title', 'forum', 'user', 'description'),
58 | }),
59 | ('Administration', {
60 | 'fields': ('pinned', 'locked', 'hidden'),
61 | }),
62 | ('Denormalised data', {
63 | 'classes': ('collapse',),
64 | 'description': DENORMALISED_DATA_NOTICE,
65 | 'fields': ('post_count', 'metapost_count', 'last_post_at',
66 | 'last_user_id', 'last_username'),
67 | }),
68 | )
69 | search_fields = ('title',)
70 |
71 | class PostAdmin(admin.ModelAdmin):
72 | list_display = ('__unicode__', 'user', 'topic', 'meta', 'posted_at',
73 | 'edited_at', 'user_ip')
74 | list_filter = ('meta',)
75 | fieldsets = (
76 | (None, {
77 | 'fields': ('user', 'topic', 'body', 'meta', 'emoticons'),
78 | }),
79 | ('Denormalised data', {
80 | 'classes': ('collapse',),
81 | 'description': DENORMALISED_DATA_NOTICE,
82 | 'fields': ('num_in_topic',),
83 | }),
84 | )
85 | search_fields = ('body',)
86 |
87 | class SearchAdmin(admin.ModelAdmin):
88 | list_display = ('type', 'user', 'searched_at')
89 | list_filter = ('type',)
90 |
91 | admin.site.register(ForumProfile, ForumProfileAdmin)
92 | admin.site.register(Section, SectionAdmin)
93 | admin.site.register(Forum, ForumAdmin)
94 | admin.site.register(Topic, TopicAdmin)
95 | admin.site.register(Post, PostAdmin)
96 | admin.site.register(Search, SearchAdmin)
97 |
--------------------------------------------------------------------------------
/forum/app_settings.py:
--------------------------------------------------------------------------------
1 | """
2 | Convenience module for access of application-specific settings, which
3 | enforces default settings when the main settings module does not contain
4 | the appropriate settings.
5 | """
6 | from django.conf import settings
7 |
8 | STANDALONE = getattr(settings, 'FORUM_STANDALONE', False)
9 | DEFAULT_POSTS_PER_PAGE = getattr(settings, 'FORUM_DEFAULT_POSTS_PER_PAGE', 20)
10 | DEFAULT_TOPICS_PER_PAGE = getattr(settings, 'FORUM_DEFAULT_TOPICS_PER_PAGE', 30)
11 | POST_FORMATTER = getattr(settings, 'FORUM_POST_FORMATTER', 'forum.formatters.PostFormatter')
12 | MAX_AVATAR_FILESIZE = getattr(settings, 'FORUM_MAX_AVATAR_FILESIZE', 512 * 1024)
13 | ALLOWED_AVATAR_FORMATS = getattr(settings, 'FORUM_ALLOWED_AVATAR_FORMATS', ('GIF', 'JPEG', 'PNG'))
14 | MAX_AVATAR_DIMENSIONS = getattr(settings, 'FORUM_MAX_AVATAR_DIMENSIONS', (64, 64))
15 | FORCE_AVATAR_DIMENSIONS = getattr(settings, 'FORUM_FORCE_AVATAR_DIMENSIONS', True)
16 |
17 | EMOTICONS = getattr(settings, 'FORUM_EMOTICONS', {
18 | ':angry:': 'angry.gif',
19 | ':blink:': 'blink.gif',
20 | ':D': 'grin.gif',
21 | ':huh:': 'huh.gif',
22 | ':lol:': 'lol.gif',
23 | ':o': 'ohmy.gif',
24 | ':ph34r:': 'ph34r.gif',
25 | ':rolleyes:': 'rolleyes.gif',
26 | ':(': 'sad.gif',
27 | ':)': 'smile.gif',
28 | ':p': 'tongue.gif',
29 | ':unsure:': 'unsure.gif',
30 | ':wacko:': 'wacko.gif',
31 | ';)': 'wink.gif',
32 | ':wub:': 'wub.gif',
33 | })
34 |
35 | USE_REDIS = getattr(settings, 'FORUM_USE_REDIS', False)
36 | REDIS_HOST = getattr(settings, 'FORUM_REDIS_HOST', 'localhost')
37 | REDIS_PORT = getattr(settings, 'FORUM_REDIS_PORT', 6379)
38 | REDIS_DB = getattr(settings, 'FORUM_REDIS_DB', 0)
39 |
40 | USE_NODEJS = getattr(settings, 'FORUM_USE_NODEJS', False)
41 |
--------------------------------------------------------------------------------
/forum/auth.py:
--------------------------------------------------------------------------------
1 | """
2 | Utility functions related to forum user permissions.
3 | """
4 | from forum.models import ForumProfile
5 |
6 | def is_admin(user):
7 | """
8 | Shortcut so we don't have to paste this incantation everywhere.
9 |
10 | Also provides a single point of change should we ever modify how
11 | user permissions are determined.
12 | """
13 | return ForumProfile.objects.get_for_user(user).is_admin()
14 |
15 | def is_moderator(user):
16 | """
17 | Shortcut so we don't have to paste this incantation everywhere.
18 |
19 | Also provides a single point of change should we ever modify how
20 | user permissions are determined.
21 | """
22 | return ForumProfile.objects.get_for_user(user).is_moderator()
23 |
24 | def user_can_edit_post(user, post, topic=None):
25 | """
26 | Returns ``True`` if the given User can edit the given Post,
27 | ``False`` otherwise.
28 |
29 | If the Post's Topic is also given, its ``locked`` status will be
30 | taken into account when determining permissions.
31 | """
32 | if topic and topic.locked:
33 | return is_moderator(user)
34 | else:
35 | return user.id == post.user_id or is_moderator(user)
36 |
37 | def user_can_edit_topic(user, topic):
38 | """
39 | Returns ``True`` if the given User can edit the given Topic,
40 | ``False`` otherwise.
41 | """
42 | if topic.locked:
43 | return is_moderator(user)
44 | else:
45 | return user.id == topic.user_id or is_moderator(user)
46 |
47 | def user_can_edit_user_profile(user, user_to_edit):
48 | """
49 | Returns ``True`` if the given User can edit the given User's
50 | profile, ``False`` otherwise.
51 | """
52 | return user.id == user_to_edit.id or is_moderator(user)
53 |
54 | def user_can_view_search_results(user, search):
55 | """
56 | Returns ``True`` if the given User can view the given search results,
57 | ``False`` otherwise.
58 | """
59 | return user.id == search.user_id or is_moderator(user)
60 |
--------------------------------------------------------------------------------
/forum/bin/create-test-data.py:
--------------------------------------------------------------------------------
1 | """
2 | Uses the ORM to create test data.
3 | """
4 | import datetime
5 | import os
6 | import random
7 |
8 | os.environ['DJANGO_SETTINGS_MODULE'] = 'forum.settings'
9 |
10 | from django.contrib.auth.models import User
11 | from django.db import transaction
12 |
13 | from forum.models import Forum, ForumProfile, Post, Section, Topic
14 |
15 | @transaction.commit_on_success
16 | def create_test_data():
17 | # 3 Users
18 | admin = User.objects.create_user('admin', 'a@a.com', 'admin')
19 | admin.first_name = 'Admin'
20 | admin.last_name = 'User'
21 | admin.is_staff = True
22 | admin.is_superuser = True
23 | admin.save()
24 | ForumProfile.objects.create(user=admin, group=ForumProfile.ADMIN_GROUP)
25 |
26 | moderator = User.objects.create_user('moderator', 'm@m.com', 'moderator')
27 | moderator.first_name = 'Moderator'
28 | moderator.last_name = 'User'
29 | moderator.save()
30 | ForumProfile.objects.create(user=moderator, group=ForumProfile.MODERATOR_GROUP)
31 |
32 | user = User.objects.create_user('user', 'u@u.com', 'user')
33 | user.first_name = 'Test'
34 | user.last_name = 'User'
35 | user.save()
36 | ForumProfile.objects.create(user=user, group=ForumProfile.USER_GROUP)
37 |
38 | users = [admin, moderator, user]
39 |
40 | # 3 Sections
41 | sections = [Section.objects.create(name='Section %s' % i, order=i) \
42 | for i in xrange(1, 4)]
43 |
44 | # 3 Forums per Section
45 | forums = []
46 | for section in sections:
47 | forums += [section.forums.create(name='Forum %s' % i, order=i) \
48 | for i in xrange(1, 4)]
49 |
50 | # 3 Topics per Forum
51 | topics = []
52 | for forum in forums:
53 | topics += [forum.topics.create(user=users[i-1], title='Topic %s' % i) \
54 | for i in xrange(1, 4)]
55 |
56 | # 3 Posts per Topic
57 | for topic in topics:
58 | for i in xrange(1, 4):
59 | topic.posts.create(user=topic.user, body='Post %s' % i)
60 |
61 | # 3 Metaposts per Topic
62 | for topic in topics:
63 | for i in xrange(1, 4):
64 | topic.posts.create(user=topic.user, meta=True, body='Metapost %s' % i)
65 |
66 | if __name__ == '__main__':
67 | create_test_data()
68 |
--------------------------------------------------------------------------------
/forum/formatters/__init__.py:
--------------------------------------------------------------------------------
1 | import re
2 |
3 | from django.conf import settings
4 | from django.utils.html import escape, linebreaks, urlize
5 | from django.utils.text import normalize_newlines, wrap
6 |
7 | from forum.formatters.emoticons import Emoticons
8 |
9 | quote_post_re = re.compile(r'^', re.MULTILINE)
10 |
11 | class PostFormatter(object):
12 | """
13 | Base post formatting object.
14 |
15 | If used as a post formatter itself, performs basic formatting,
16 | preserving linebreaks and converting URLs to links.
17 | """
18 | QUICK_HELP_TEMPLATE = 'forum/help/basic_formatting_quick.html'
19 | FULL_HELP_TEMPLATE = 'forum/help/basic_formatting.html'
20 |
21 | def __init__(self, emoticons=None):
22 | if emoticons is None: emoticons = {}
23 | self.emoticon_processor = Emoticons(emoticons,
24 | base_url='%sforum/img/emoticons/' % settings.STATIC_URL)
25 |
26 | def format_post(self, body, process_emoticons=True):
27 | """
28 | Formats the given post body, replacing emoticon symbols with
29 | images if ``emoticons`` is ``True``.
30 | """
31 | if process_emoticons:
32 | return self.emoticon_processor.process(self.format_post_body(body))
33 | else:
34 | return self.format_post_body(body)
35 |
36 | def format_post_body(self, body):
37 | """
38 | Formats the given raw post body as HTML.
39 | """
40 | return linebreaks(urlize(escape(body.strip())))
41 |
42 | def quote_post(self, post):
43 | """
44 | Returns a raw post body which quotes the given Post.
45 | """
46 | return u'%s wrote:\n\n%s\n\n' % (
47 | escape(post.user.username),
48 | quote_post_re.sub('> ', wrap(normalize_newlines(post.body), 80)),
49 | )
50 |
51 | class MarkdownFormatter(PostFormatter):
52 | """
53 | Post formatter which uses Markdown to format posts.
54 | """
55 | QUICK_HELP_TEMPLATE = 'forum/help/markdown_formatting_quick.html'
56 | FULL_HELP_TEMPLATE = 'forum/help/markdown_formatting.html'
57 |
58 | def __init__(self, *args, **kwargs):
59 | super(MarkdownFormatter, self).__init__(*args, **kwargs)
60 | from markdown2 import Markdown
61 | self.md = Markdown(safe_mode='escape')
62 |
63 | def format_post_body(self, body):
64 | """
65 | Formats the given raw post body as HTML using Markdown
66 | formatting.
67 | """
68 | self.md.reset()
69 | return self.md.convert(body).strip()
70 |
71 | def quote_post(self, post):
72 | """
73 | Returns a raw post body which quotes the given Post using
74 | Markdown syntax.
75 | """
76 | return u'**%s** [wrote](%s "View quoted post"):\n\n%s\n\n' % (
77 | escape(post.user.username),
78 | post.get_absolute_url(),
79 | quote_post_re.sub('> ', post.body),
80 | )
81 |
82 | class BBCodeFormatter(PostFormatter):
83 | """
84 | Post formatter which uses BBCode syntax to format posts.
85 | """
86 | QUICK_HELP_TEMPLATE = 'forum/help/bbcode_formatting_quick.html'
87 | FULL_HELP_TEMPLATE = 'forum/help/bbcode_formatting.html'
88 |
89 | def __init__(self, *args, **kwargs):
90 | super(BBCodeFormatter, self).__init__(*args, **kwargs)
91 | import postmarkup
92 | self.pm = postmarkup.create()
93 |
94 | def format_post_body(self, body):
95 | """
96 | Formats the given raw post body as HTML using BBCode formatting.
97 | """
98 | return self.pm(body).strip()
99 |
100 | def quote_post(self, post):
101 | """
102 | Returns a raw post body which quotes the given Post using BBCode
103 | syntax.
104 | """
105 | return u'[quote="%s"]%s[/quote]' % (escape(post.user.username), post.body)
106 |
107 | def get_post_formatter():
108 | """
109 | Creates a post formatting object as specified by current settings.
110 | """
111 | from django.core import exceptions
112 | from forum import app_settings
113 | try:
114 | dot = app_settings.POST_FORMATTER.rindex('.')
115 | except ValueError:
116 | raise exceptions.ImproperlyConfigured, '%s isn\'t a post formatting module' % app_settings.POST_FORMATTER
117 | modulename, classname = app_settings.POST_FORMATTER[:dot], app_settings.POST_FORMATTER[dot+1:]
118 | try:
119 | mod = __import__(modulename, {}, {}, [''])
120 | except ImportError, e:
121 | raise exceptions.ImproperlyConfigured, 'Error importing post formatting module %s: "%s"' % (modulename, e)
122 | try:
123 | formatter_class = getattr(mod, classname)
124 | except AttributeError:
125 | raise exceptions.ImproperlyConfigured, 'Post formatting module "%s" does not define a "%s" class' % (modulename, classname)
126 | return formatter_class(emoticons=app_settings.EMOTICONS)
127 |
128 | # For convenience, make a single instance of the currently specified post
129 | # formatter available for reuse.
130 | post_formatter = get_post_formatter()
131 |
--------------------------------------------------------------------------------
/forum/formatters/emoticons.py:
--------------------------------------------------------------------------------
1 | """
2 | Emoticon Replacement
3 | ====================
4 |
5 | Converts emoticon symbols to images, with the symbols set as their
6 | ``alt`` text.
7 |
8 | Basic usage::
9 |
10 | >>> em = Emoticons({':p': 'tongue.gif'})
11 | >>> em.process(u'Cheeky :p')
12 | u'Cheeky '
13 |
14 | Example showing usage of all arguments::
15 |
16 | >>> em = Emoticons({':p': 'cheeky.png'},
17 | ... base_url='http://localhost/', xhtml=True)
18 | >>> em.process(u'Cheeky :p')
19 | u'Cheeky '
20 |
21 | Other tests::
22 |
23 | >>> em = Emoticons({})
24 | >>> em.process(u'Cheeky :p')
25 | u'Cheeky :p'
26 |
27 | """
28 | import re
29 |
30 | class Emoticons:
31 | """
32 | Replacement of multiple string pairs in one go based on
33 | http://effbot.org/zone/python-replace.htm
34 | """
35 | def __init__ (self, emoticons, base_url='', xhtml=False):
36 | """
37 | emoticons
38 | A dict mapping emoticon symbols to image filenames.
39 |
40 | base_url
41 | The base URL to be prepended to image filenames when
42 | generating image URLs.
43 |
44 | xhtml
45 | If ``True``, a closing slash will be added to image tags.
46 | """
47 | self.emoticons = dict(
48 | [(k, '' % (base_url, v, k,
49 | xhtml and ' /' or '')) \
50 | for k, v in emoticons.items()])
51 | keys = emoticons.keys()
52 | keys.sort() # lexical order
53 | keys.reverse() # use longest match first
54 | self.pattern = re.compile('|'.join([re.escape(key) for key in keys]))
55 |
56 | def process(self, text):
57 | """
58 | Returns a version of the given text with emoticon symbols
59 | replaced with HTML for their image equivalents.
60 |
61 | text
62 | The text to be processed.
63 | """
64 | def repl(match, get=self.emoticons.get):
65 | item = match.group(0)
66 | return get(item, item)
67 | return self.pattern.sub(repl, text)
68 |
69 | if __name__ == '__main__':
70 | import doctest
71 | doctest.testmod()
72 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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/moderation.py:
--------------------------------------------------------------------------------
1 | """
2 | Functions which perform moderation tasks - this can involve making
3 | multiple, complex changes to the items being moderated.
4 | """
5 | from forum.models import Post
6 |
7 | def _update_num_in_topic(post, topic):
8 | """
9 | Updates the ``num_in_topic`` field for Posts in the given Topic
10 | affected by the given Post having its ``meta`` flag changed.
11 | """
12 | # Decrement num_in_topic for the post's current meta type
13 | Post.objects.update_num_in_topic(topic, post.num_in_topic, increment=False,
14 | meta=not post.meta)
15 | try:
16 | # Find the first prior post of the new meta type by post time -
17 | # fall back on id if times are equal.
18 | previous_post = \
19 | (topic.posts.filter(meta=post.meta, posted_at__lt=post.posted_at) | \
20 | topic.posts.filter(meta=post.meta, posted_at=post.posted_at, pk__lt=post.pk)) \
21 | .order_by('-posted_at', '-id')[0]
22 | post.num_in_topic = previous_post.num_in_topic + 1
23 | increment_from = previous_post.num_in_topic
24 | except IndexError:
25 | # There is no existing, earlier post - make this the first
26 | post.num_in_topic = 1
27 | increment_from = 0
28 | # Increment num_in_topic for the post's new meta type
29 | Post.objects.update_num_in_topic(topic, increment_from, increment=True,
30 | meta=post.meta)
31 | # Save the post to update its meta and num_in_topic attributes
32 | post.save()
33 |
34 | def make_post_not_meta(post, topic, forum):
35 | """
36 | Performs changes required to turn a metapost into a regular post.
37 | """
38 | # If this becomes the new last post in its topic, the topic will need
39 | # its last post details updated.
40 | is_last_in_topic = False
41 | if post.posted_at > topic.last_post_at:
42 | is_last_in_topic = True
43 | # If this becomes the new last post in its forum, the forum will need
44 | # its last post details updated.
45 | is_last_in_forum = False
46 | if post.posted_at > forum.last_post_at:
47 | is_last_in_forum = True
48 | # Update num_in_topic for all affected posts
49 | _update_num_in_topic(post, topic)
50 | # Make any changes required to the topic and forum
51 | if is_last_in_topic:
52 | topic.set_last_post(post)
53 | else:
54 | topic.update_post_count(meta=False)
55 | topic.update_post_count(meta=True)
56 | if is_last_in_forum:
57 | forum.set_last_post(post)
58 |
59 | def make_post_meta(post, topic, forum):
60 | """
61 | Performs changes required to turn a regular post into a metapost.
62 | """
63 | # If this was the last post in its topic, the topic will need its
64 | # last post details updated.
65 | was_last_in_topic = False
66 | if topic.last_post_at == post.posted_at and \
67 | topic.last_user_id == post.user_id:
68 | was_last_in_topic = True
69 | # If this was the last post in its forum, the forum will need its
70 | # latest post details updated.
71 | was_last_in_forum = False
72 | if forum.last_topic_id == topic.pk and \
73 | forum.last_post_at == post.posted_at and \
74 | forum.last_user_id == post.user_id:
75 | was_last_in_forum = True
76 | # Update num_in_topic for all affected posts
77 | _update_num_in_topic(post, topic)
78 | # Make any changes required to the topic and forum
79 | if was_last_in_topic:
80 | topic.set_last_post()
81 | else:
82 | topic.update_post_count(meta=False)
83 | topic.update_post_count(meta=True)
84 | if was_last_in_forum:
85 | forum.set_last_post()
86 |
--------------------------------------------------------------------------------
/forum/redis_connection.py:
--------------------------------------------------------------------------------
1 | """
2 | Connection and forum API for real-time tracking using Redis.
3 | """
4 | import datetime
5 | import time
6 |
7 | from django.core import signals
8 | from django.utils.html import escape
9 |
10 | import redis
11 | from forum import app_settings
12 |
13 | r = redis.StrictRedis(app_settings.REDIS_HOST,
14 | app_settings.REDIS_PORT,
15 | app_settings.REDIS_DB)
16 |
17 | TOPIC_ViEWS = 't:%s:v'
18 | TOPIC_TRACKER = 'u:%s:t:%s'
19 | ACTIVE_USERS = 'au'
20 | USER_USERNAME = 'u:%s:un'
21 | USER_LAST_SEEN = 'u:%s:s'
22 | USER_DOING = 'u:%s:d'
23 |
24 | def increment_view_count(topic):
25 | """Increments the view count for a Topic."""
26 | r.incr(TOPIC_ViEWS % topic.pk)
27 |
28 | def get_view_counts(topic_ids):
29 | """Yields viewcounts for the given Topics."""
30 | for view_count in r.mget([TOPIC_ViEWS % id for id in topic_ids]):
31 | if view_count:
32 | yield int(view_count)
33 | else:
34 | yield 0
35 |
36 | def update_last_read_time(user, topic):
37 | """
38 | Sets the last read time for a User in the given Topic, expiring in a
39 | fortnight.
40 | """
41 | key = TOPIC_TRACKER % (user.pk, topic.pk)
42 | last_read = datetime.datetime.now()
43 | expire_at = last_read + datetime.timedelta(days=14)
44 | r.set(key, int(time.mktime(last_read.timetuple())))
45 | r.expireat(key, int(time.mktime(expire_at.timetuple())))
46 |
47 | def get_last_read_time(user, topic_id):
48 | """Gets the last read time for a User in the given Topic."""
49 | last_read = r.get(TOPIC_TRACKER % (user.pk, topic_id))
50 | if last_read:
51 | return datetime.datetime.fromtimestamp(int(last_read))
52 | return None
53 |
54 | def get_last_read_times(user, topics):
55 | """Gets last read times for a User in the given Topics."""
56 | for last_read in r.mget([TOPIC_TRACKER % (user.pk, t.pk)
57 | for t in topics]):
58 | if last_read:
59 | yield datetime.datetime.fromtimestamp(int(last_read))
60 | else:
61 | yield None
62 |
63 | def seen_user(user, doing, item=None):
64 | """
65 | Stores what a User was doing when they were last seen and updates
66 | their last seen time in the active users sorted set.
67 | """
68 | last_seen = int(time.mktime(datetime.datetime.now().timetuple()))
69 | r.zadd(ACTIVE_USERS, last_seen, user.pk)
70 | r.setnx(USER_USERNAME % user.pk, user.username)
71 | r.set(USER_LAST_SEEN % user.pk, last_seen)
72 | if item:
73 | doing = '%s %s' % (
74 | doing, item.get_absolute_url(), escape(str(item)))
75 | r.set(USER_DOING % user.pk, doing)
76 |
77 | def get_active_users(minutes_ago=30):
78 | """
79 | Yields active Users in the last ``minutes_ago`` minutes, returning
80 | 2-tuples of (user_detail_dict, last_seen_time) in most-to-least recent
81 | order by time.
82 | """
83 | since = datetime.datetime.now() - datetime.timedelta(minutes=minutes_ago)
84 | since_time = int(time.mktime(since.timetuple()))
85 | for user_id, last_seen in reversed(r.zrangebyscore(ACTIVE_USERS, since_time,
86 | 'inf', withscores=True)):
87 | yield (
88 | {'id': int(user_id), 'username': r.get(USER_USERNAME % user_id)},
89 | datetime.datetime.fromtimestamp(int(last_seen)),
90 | )
91 |
92 | def get_last_seen(user):
93 | """
94 | Returns a 2-tuple of (last_seen, doing), where doing may contain HTML
95 | linking to the relevant place.
96 | """
97 | last_seen = r.get(USER_LAST_SEEN % user.pk)
98 | if last_seen:
99 | last_seen = datetime.datetime.fromtimestamp(int(last_seen))
100 | else:
101 | last_seen = user.date_joined
102 | doing = r.get(USER_DOING % user.pk)
103 | return last_seen, doing
104 |
--------------------------------------------------------------------------------
/forum/sessions/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/insin/forum/59b35bd102da3bdeb0d6bc104de77572158992b3/forum/sessions/__init__.py
--------------------------------------------------------------------------------
/forum/sessions/redis_session_backend.py:
--------------------------------------------------------------------------------
1 | from django.contrib.sessions.backends.base import SessionBase, CreateError
2 |
3 | from forum.redis_connection import r
4 |
5 | class SessionStore(SessionBase):
6 | """
7 | Implements a Redis session store.
8 | """
9 | def load(self):
10 | session_data = r.get('session:%s' % self.session_key)
11 | if session_data is not None:
12 | return self.decode(session_data)
13 | self.create()
14 | return {}
15 |
16 | def exists(self, session_key):
17 | if r.exists('session:%s' % session_key):
18 | return True
19 | return False
20 |
21 | def create(self):
22 | while True:
23 | self.session_key = self._get_new_session_key()
24 | try:
25 | self.save(must_create=True)
26 | except CreateError:
27 | continue
28 | self.modified = True
29 | return
30 |
31 | def save(self, must_create=False):
32 | key = 'session:%s' % self.session_key
33 | if must_create and r.exists(key):
34 | raise CreateError
35 | r.set(key, self.encode(self._get_session(no_load=must_create)))
36 | r.expire(key, self.get_expiry_age())
37 |
38 | def delete(self, session_key=None):
39 | if session_key is None:
40 | if self._session_key is None:
41 | return
42 | session_key = self._session_key
43 | r.delete('session:%s' % session_key)
44 |
--------------------------------------------------------------------------------
/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/css/ie.css:
--------------------------------------------------------------------------------
1 | /* Invoke the dark, proprietary art of hasLayout */
2 | #userbar, div.tools, div.post, .module h2 { zoom: 1; }
3 |
4 | div.postbody { padding-bottom: 1em; }
--------------------------------------------------------------------------------
/forum/static/forum/css/style.css:
--------------------------------------------------------------------------------
1 | body { font-family: "Lucida Grande", Tahoma, Arial, Verdana, sans-serif; font-size: 12px; margin: 0; padding: 1em; background-color: #fff; color: #000; }
2 | a img { border: none; }
3 | blockquote { border-left: 2px solid #595979; margin-left: 1em; padding-left: 6px; }
4 | img { vertical-align: text-bottom; }
5 | .clickable { cursor: pointer; }
6 |
7 | /* Tables */
8 | table { border-collapse: collapse; width: 100%; }
9 | td, th { padding: 4px 6px; border: 1px solid #999; text-align: left; }
10 | th { font-weight: bold; }
11 | td p.description { margin: 4px 0 0 0; }
12 | td.icon, td.posts, td.views { text-align: center; }
13 | td.last-post { white-space: nowrap; }
14 | th.forum-topics { text-align: center; }
15 | a.unread { margin-right: 4px; }
16 | a.unread img { vertical-align: middle; }
17 |
18 | /* Common formatting */
19 | .odd { background-color: #e9e9f9; }
20 | .even { background-color: #e3e3f3; }
21 | .module { border: 1px solid #ccc; margin-bottom: 1em; background-color: #fff; }
22 | .module-body { padding: 4px 8px; }
23 | .no-margin { margin: 0; }
24 | .module h2, .module caption { overflow: hidden; margin: 0; padding: 2px 8px 3px 8px; font-size: 1em; text-align: left; font-weight: bold; background: #595979 url("../img/module-bg.gif") top left repeat-x; color: #fff; border-bottom: 0; }
25 | .module h2 a { color: #fff; }
26 | .module h2 span.title { float: left; }
27 | .module h2 span.controls { float: right; }
28 | .module h2 span.controls a { color: #ff0; }
29 | .module h2 span.separator { display: none; }
30 | .module h3 { margin: 0 0 8px 0; }
31 | .module h3 span { font-weight: normal; }
32 | .module p.message { text-align: center; margin: 1em; }
33 | .clear { clear: both; }
34 |
35 | /* Navigation elements */
36 | #userbar { padding: 4px 6px; overflow: hidden; border: 1px solid #ccc; background: #e9e9f9 url("../img/module-bg.gif") top left repeat-x; }
37 | #authenticated { float: left; }
38 | #authenticated a.user { font-weight: bold; }
39 | #user-tools { float: right; }
40 | #breadcrumbs { padding: 6px 0; }
41 | p.description { font-style: italic; }
42 |
43 | div.tools { overflow: hidden; padding: 4px 0; }
44 | div.tools .actions { float: right; }
45 | div.paginator { float: left; }
46 |
47 | h1 { margin: .25em 0 .5em 0; }
48 |
49 | /* Posts */
50 | div.post { overflow: hidden; padding: 0 10px; }
51 | div.postbody { float: left; width: 76%; clear: both; padding-top: 6px; }
52 | div.postbody ul.post-actions { float: right; margin: 0; padding: 0; }
53 | div.postbody ul.post-actions li { list-style-type: none; float: left; margin-left: 10px; }
54 | p.author { margin: 0 0 .5em 0; }
55 | div.profile { border-left: 1px solid #fff; float: right; width: 22%; margin: 8px 0; }
56 | div.profile dl { margin-top: 0; margin-bottom: 0; }
57 | div.profile dd, div.profile dt { margin-left: 8px; }
58 | div.profile dd.postcount { margin-top: 1em; }
59 |
60 | /* Forms */
61 | fieldset { padding: 0; margin-left: 0; margin-right: 0; }
62 | .form-row { height: 100%; overflow: hidden; padding: 8px 12px; border-bottom: 1px solid #eee; border-right: 1px solid #eee; }
63 | .form-row img, .form-row input { vertical-align: middle; }
64 | .aligned label { padding: 0 1em 3px 0; float: left; width: 9em; }
65 | .checkbox-row label, label.checkbox, .radio-list label { pading: 0 0 3px 0; float: none; width: auto; }
66 | .radio-list ul { list-style-type: none; margin: 0; padding: 0; }
67 | .radio-list li { padding: 4px 0; margin: 0; }
68 | .form-field { float: left; }
69 | .avatar-field input, .avatar-field img { vertical-align: top; }
70 | .post-preview .body { padding: 0 12px; }
71 | #fast-reply { width: 70%; margin: 1em auto 0 auto; }
72 | .fast-reply-field { padding: 8px 12px; }
73 | .fast-reply-field textarea { width: 98.5%; margin: 0; padding: 3px; }
74 | #fast-reply .buttons { text-align: center; }
75 | #fast-reply .checkbox-row { text-align: center; padding-top: 0; }
76 | .post-body-field { margin-right: 1em; margin-bottom: .5em; }
77 | .quick-help { float: left; }
78 | .quick-help table { width: auto; }
79 | .quick-help ul, .quick-help li { margin: 0 0 0 1em; padding: 0; }
80 | .quick-help blockquote { margin: 0 0 0 2em; }
81 | .emoticon-help { float: left; }
82 | .emoticon-help table { width: auto; }
83 | .emoticon-help th, .emoticon-help td { text-align: center; }
84 | ul.errorlist { margin: 0; padding: 0; }
85 | ul.errorlist li { padding: 4px 8px 4px 25px; margin: 0 0 8px 0; border: 1px solid #f00; color: #fff; background: #f00 url("../img/icon_alert.gif") 5px .3em no-repeat; }
86 |
87 | .col-l { float: left; width: 30%; }
88 | .forum-profile th, .forum-settings th { width: 30%; }
89 | .col-r { float: right; width: 70%; }
90 | .col-r .last-topics-started { margin-left: 1em; }
91 |
92 | #active-users p { margin: 0; }
93 |
94 | #footer { clear: both; margin-top: 1em; padding: 4px 0; }
95 |
96 | #debug { clear: both; }
--------------------------------------------------------------------------------
/forum/static/forum/img/djangosite.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/insin/forum/59b35bd102da3bdeb0d6bc104de77572158992b3/forum/static/forum/img/djangosite.gif
--------------------------------------------------------------------------------
/forum/static/forum/img/emoticons/angry.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/insin/forum/59b35bd102da3bdeb0d6bc104de77572158992b3/forum/static/forum/img/emoticons/angry.gif
--------------------------------------------------------------------------------
/forum/static/forum/img/emoticons/blink.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/insin/forum/59b35bd102da3bdeb0d6bc104de77572158992b3/forum/static/forum/img/emoticons/blink.gif
--------------------------------------------------------------------------------
/forum/static/forum/img/emoticons/grin.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/insin/forum/59b35bd102da3bdeb0d6bc104de77572158992b3/forum/static/forum/img/emoticons/grin.gif
--------------------------------------------------------------------------------
/forum/static/forum/img/emoticons/huh.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/insin/forum/59b35bd102da3bdeb0d6bc104de77572158992b3/forum/static/forum/img/emoticons/huh.gif
--------------------------------------------------------------------------------
/forum/static/forum/img/emoticons/lol.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/insin/forum/59b35bd102da3bdeb0d6bc104de77572158992b3/forum/static/forum/img/emoticons/lol.gif
--------------------------------------------------------------------------------
/forum/static/forum/img/emoticons/ohmy.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/insin/forum/59b35bd102da3bdeb0d6bc104de77572158992b3/forum/static/forum/img/emoticons/ohmy.gif
--------------------------------------------------------------------------------
/forum/static/forum/img/emoticons/ph34r.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/insin/forum/59b35bd102da3bdeb0d6bc104de77572158992b3/forum/static/forum/img/emoticons/ph34r.gif
--------------------------------------------------------------------------------
/forum/static/forum/img/emoticons/rolleyes.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/insin/forum/59b35bd102da3bdeb0d6bc104de77572158992b3/forum/static/forum/img/emoticons/rolleyes.gif
--------------------------------------------------------------------------------
/forum/static/forum/img/emoticons/sad.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/insin/forum/59b35bd102da3bdeb0d6bc104de77572158992b3/forum/static/forum/img/emoticons/sad.gif
--------------------------------------------------------------------------------
/forum/static/forum/img/emoticons/smile.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/insin/forum/59b35bd102da3bdeb0d6bc104de77572158992b3/forum/static/forum/img/emoticons/smile.gif
--------------------------------------------------------------------------------
/forum/static/forum/img/emoticons/tongue.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/insin/forum/59b35bd102da3bdeb0d6bc104de77572158992b3/forum/static/forum/img/emoticons/tongue.gif
--------------------------------------------------------------------------------
/forum/static/forum/img/emoticons/unsure.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/insin/forum/59b35bd102da3bdeb0d6bc104de77572158992b3/forum/static/forum/img/emoticons/unsure.gif
--------------------------------------------------------------------------------
/forum/static/forum/img/emoticons/wacko.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/insin/forum/59b35bd102da3bdeb0d6bc104de77572158992b3/forum/static/forum/img/emoticons/wacko.gif
--------------------------------------------------------------------------------
/forum/static/forum/img/emoticons/wink.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/insin/forum/59b35bd102da3bdeb0d6bc104de77572158992b3/forum/static/forum/img/emoticons/wink.gif
--------------------------------------------------------------------------------
/forum/static/forum/img/emoticons/wub.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/insin/forum/59b35bd102da3bdeb0d6bc104de77572158992b3/forum/static/forum/img/emoticons/wub.gif
--------------------------------------------------------------------------------
/forum/static/forum/img/icon_alert.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/insin/forum/59b35bd102da3bdeb0d6bc104de77572158992b3/forum/static/forum/img/icon_alert.gif
--------------------------------------------------------------------------------
/forum/static/forum/img/icon_exclamation.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/insin/forum/59b35bd102da3bdeb0d6bc104de77572158992b3/forum/static/forum/img/icon_exclamation.gif
--------------------------------------------------------------------------------
/forum/static/forum/img/icon_lock.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/insin/forum/59b35bd102da3bdeb0d6bc104de77572158992b3/forum/static/forum/img/icon_lock.gif
--------------------------------------------------------------------------------
/forum/static/forum/img/new_posts.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/insin/forum/59b35bd102da3bdeb0d6bc104de77572158992b3/forum/static/forum/img/new_posts.gif
--------------------------------------------------------------------------------
/forum/static/forum/img/no_new_posts.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/insin/forum/59b35bd102da3bdeb0d6bc104de77572158992b3/forum/static/forum/img/no_new_posts.gif
--------------------------------------------------------------------------------
/forum/static/forum/img/unread_post.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/insin/forum/59b35bd102da3bdeb0d6bc104de77572158992b3/forum/static/forum/img/unread_post.gif
--------------------------------------------------------------------------------
/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 && page <= pages) {
7 | window.location.href = '?page=' + page
8 | }
9 | }
10 | }
11 | }
12 |
--------------------------------------------------------------------------------
/forum/static/forum/js/PostForm.js:
--------------------------------------------------------------------------------
1 | $(function() {
2 | if ($('#emoticons img').size() > 0) {
3 | $('#emoticons img').addClass('clickable').click(function() {
4 | document.forms[0].elements['body'].value += this.alt + ' '
5 | })
6 | }
7 | })
8 |
--------------------------------------------------------------------------------
/forum/static/forum/js/Stalk.js:
--------------------------------------------------------------------------------
1 | var dom = DOMBuilder.dom
2 |
3 | function update() {
4 | $.getJSON('http://localhost:8001/?callback=?', function(data) {
5 | $body = $('#stalkUsers')
6 | $body.empty()
7 | $.each(data, function(i, user) {
8 | $body.append(
9 | dom.TR({'class': (i % 2 == 0 ? 'odd' : 'even')}
10 | , dom.TD(dom.A({href: '/user/' + user.id}, user.username))
11 | , dom.TD(new Date(user.seen * 1000).toString().substring(0, 24))
12 | , dom.TD({innerHTML: user.doing})
13 | )
14 | )
15 | })
16 | setTimeout(update, 5000)
17 | })
18 | }
19 |
20 | $(function() {
21 | update()
22 | })
--------------------------------------------------------------------------------
/forum/static/forum/js/Topic.js:
--------------------------------------------------------------------------------
1 | // Create Fast Reply controls
2 | if (createFastReplyControls)
3 | $(function() {
4 | if ($('#topic-actions-bottom').size() > 0) {
5 | $('#topic-actions-bottom')
6 | .prepend(document.createTextNode(' | '))
7 | .prepend(
8 | $('Fast Reply').click(function() {
9 | $("#fast-reply").toggle()
10 | })
11 | )
12 | }
13 |
14 | if ($("#fast-reply-buttons").size() > 0) {
15 | $("#fast-reply-buttons").append(
16 | $('').click(function() {
17 | $("#fast-reply").toggle()
18 | })
19 | )
20 | }
21 | })
22 |
--------------------------------------------------------------------------------
/forum/templates/404.html:
--------------------------------------------------------------------------------
1 | {% extends "base_forum.html" %}
2 | {% block title %}Page not found{% endblock %}
3 | {% block breadcrumbs %}
4 | Forums
5 | » Page not found
6 | {% endblock %}
7 | {% block content_title %}