Commits

luke  committed 2e2eef8

[project @ 321]
Moved everything to trunk dir

  • Participants

Comments (0)

Files changed (165)

+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE Project SYSTEM "Project-3.7.dtd">
+<!-- Project file for project cciw -->
+<!-- Saved: 2006-06-21, 23:23:23 -->
+<!-- Copyright (C) 2006 ,  -->
+<Project version="3.7">
+  <ProgLanguage mixed="0">Python</ProgLanguage>
+  <UIType>Qt</UIType>
+  <Description></Description>
+  <Version>0.1</Version>
+  <Author></Author>
+  <Email></Email>
+  <Sources>
+    <Source>
+      <Dir>cciw</Dir>
+      <Dir>cciwmain</Dir>
+      <Dir>views</Dir>
+      <Name>services.py</Name>
+    </Source>
+    <Source>
+      <Dir>cciw</Dir>
+      <Dir>cciwmain</Dir>
+      <Dir>views</Dir>
+      <Name>misc.py</Name>
+    </Source>
+    <Source>
+      <Name>devel.py</Name>
+    </Source>
+    <Source>
+      <Dir>cciw</Dir>
+      <Dir>cciwmain</Dir>
+      <Dir>views</Dir>
+      <Name>members.py</Name>
+    </Source>
+    <Source>
+      <Dir>cciw</Dir>
+      <Dir>cciwmain</Dir>
+      <Dir>views</Dir>
+      <Name>__init__.py</Name>
+    </Source>
+    <Source>
+      <Dir>cciw</Dir>
+      <Dir>cciwmain</Dir>
+      <Dir>views</Dir>
+      <Name>camps.py</Name>
+    </Source>
+    <Source>
+      <Dir>cciw</Dir>
+      <Dir>cciwmain</Dir>
+      <Dir>views</Dir>
+      <Name>htmlchunk.py</Name>
+    </Source>
+    <Source>
+      <Dir>cciw</Dir>
+      <Dir>cciwmain</Dir>
+      <Dir>views</Dir>
+      <Name>forums.py</Name>
+    </Source>
+    <Source>
+      <Dir>cciw</Dir>
+      <Dir>cciwmain</Dir>
+      <Dir>views</Dir>
+      <Name>tagging.py</Name>
+    </Source>
+    <Source>
+      <Dir>cciw</Dir>
+      <Dir>cciwmain</Dir>
+      <Dir>views</Dir>
+      <Name>memberadmin.py</Name>
+    </Source>
+    <Source>
+      <Dir>cciw</Dir>
+      <Dir>middleware</Dir>
+      <Name>threadlocals.py</Name>
+    </Source>
+    <Source>
+      <Dir>cciw</Dir>
+      <Dir>middleware</Dir>
+      <Name>__init__.py</Name>
+    </Source>
+    <Source>
+      <Dir>cciw</Dir>
+      <Dir>officers</Dir>
+      <Name>__init__.py</Name>
+    </Source>
+    <Source>
+      <Dir>cciw</Dir>
+      <Dir>officers</Dir>
+      <Name>views.py</Name>
+    </Source>
+    <Source>
+      <Dir>cciw</Dir>
+      <Dir>officers</Dir>
+      <Name>models.py</Name>
+    </Source>
+    <Source>
+      <Dir>cciw</Dir>
+      <Dir>officers</Dir>
+      <Name>urls.py</Name>
+    </Source>
+    <Source>
+      <Dir>cciw</Dir>
+      <Dir>officers</Dir>
+      <Name>hooks.py</Name>
+    </Source>
+    <Source>
+      <Dir>cciw</Dir>
+      <Dir>officers</Dir>
+      <Name>signals.py</Name>
+    </Source>
+    <Source>
+      <Dir>cciw</Dir>
+      <Dir>officers</Dir>
+      <Dir>templatetags</Dir>
+      <Name>__init__.py</Name>
+    </Source>
+    <Source>
+      <Dir>cciw</Dir>
+      <Dir>officers</Dir>
+      <Dir>templatetags</Dir>
+      <Name>application_admin.py</Name>
+    </Source>
+    <Source>
+      <Dir>lukeplant_me_uk</Dir>
+      <Name>dist.py</Name>
+    </Source>
+    <Source>
+      <Dir>lukeplant_me_uk</Dir>
+      <Name>__init__.py</Name>
+    </Source>
+    <Source>
+      <Dir>lukeplant_me_uk</Dir>
+      <Dir>setup</Dir>
+      <Dir>djangovalidator</Dir>
+      <Name>setup.py</Name>
+    </Source>
+    <Source>
+      <Dir>lukeplant_me_uk</Dir>
+      <Dir>django</Dir>
+      <Name>__init__.py</Name>
+    </Source>
+    <Source>
+      <Dir>lukeplant_me_uk</Dir>
+      <Dir>django</Dir>
+      <Dir>tagging</Dir>
+      <Name>models.py</Name>
+    </Source>
+    <Source>
+      <Dir>lukeplant_me_uk</Dir>
+      <Dir>django</Dir>
+      <Dir>tagging</Dir>
+      <Name>__init__.py</Name>
+    </Source>
+    <Source>
+      <Dir>lukeplant_me_uk</Dir>
+      <Dir>django</Dir>
+      <Dir>tagging</Dir>
+      <Name>fields.py</Name>
+    </Source>
+    <Source>
+      <Dir>lukeplant_me_uk</Dir>
+      <Dir>django</Dir>
+      <Dir>tagging</Dir>
+      <Name>utils.py</Name>
+    </Source>
+    <Source>
+      <Dir>lukeplant_me_uk</Dir>
+      <Dir>django</Dir>
+      <Dir>tagging</Dir>
+      <Name>views.py</Name>
+    </Source>
+    <Source>
+      <Dir>lukeplant_me_uk</Dir>
+      <Dir>django</Dir>
+      <Dir>tagging</Dir>
+      <Dir>templatetags</Dir>
+      <Name>tagging.py</Name>
+    </Source>
+    <Source>
+      <Dir>lukeplant_me_uk</Dir>
+      <Dir>django</Dir>
+      <Dir>tagging</Dir>
+      <Dir>templatetags</Dir>
+      <Name>__init__.py</Name>
+    </Source>
+    <Source>
+      <Dir>lukeplant_me_uk</Dir>
+      <Dir>django</Dir>
+      <Dir>validator</Dir>
+      <Name>urls.py</Name>
+    </Source>
+    <Source>
+      <Dir>lukeplant_me_uk</Dir>
+      <Dir>django</Dir>
+      <Dir>validator</Dir>
+      <Name>views.py</Name>
+    </Source>
+    <Source>
+      <Dir>lukeplant_me_uk</Dir>
+      <Dir>django</Dir>
+      <Dir>validator</Dir>
+      <Name>models.py</Name>
+    </Source>
+    <Source>
+      <Dir>lukeplant_me_uk</Dir>
+      <Dir>django</Dir>
+      <Dir>validator</Dir>
+      <Name>__init__.py</Name>
+    </Source>
+    <Source>
+      <Dir>lukeplant_me_uk</Dir>
+      <Dir>django</Dir>
+      <Dir>validator</Dir>
+      <Name>middleware.py</Name>
+    </Source>
+    <Source>
+      <Dir>cciw</Dir>
+      <Name>__init__.py</Name>
+    </Source>
+    <Source>
+      <Dir>cciw</Dir>
+      <Name>settings.py</Name>
+    </Source>
+    <Source>
+      <Dir>cciw</Dir>
+      <Name>urls.py</Name>
+    </Source>
+    <Source>
+      <Dir>cciw</Dir>
+      <Name>settings_postgres.py</Name>
+    </Source>
+    <Source>
+      <Dir>cciw</Dir>
+      <Name>settings_mysql.py</Name>
+    </Source>
+    <Source>
+      <Dir>cciw</Dir>
+      <Name>settings_calvin.py</Name>
+    </Source>
+    <Source>
+      <Dir>cciw</Dir>
+      <Name>settings_common.py</Name>
+    </Source>
+    <Source>
+      <Dir>cciw</Dir>
+      <Name>settings_sqlite.py</Name>
+    </Source>
+    <Source>
+      <Dir>cciw</Dir>
+      <Dir>cciwmain</Dir>
+      <Name>__init__.py</Name>
+    </Source>
+    <Source>
+      <Dir>cciw</Dir>
+      <Dir>cciwmain</Dir>
+      <Name>common.py</Name>
+    </Source>
+    <Source>
+      <Dir>cciw</Dir>
+      <Dir>cciwmain</Dir>
+      <Name>utils.py</Name>
+    </Source>
+    <Source>
+      <Dir>cciw</Dir>
+      <Dir>cciwmain</Dir>
+      <Name>urls.py</Name>
+    </Source>
+    <Source>
+      <Dir>cciw</Dir>
+      <Dir>cciwmain</Dir>
+      <Name>feeds.py</Name>
+    </Source>
+    <Source>
+      <Dir>cciw</Dir>
+      <Dir>cciwmain</Dir>
+      <Name>decorators.py</Name>
+    </Source>
+    <Source>
+      <Dir>cciw</Dir>
+      <Dir>cciwmain</Dir>
+      <Name>imageutils.py</Name>
+    </Source>
+    <Source>
+      <Dir>cciw</Dir>
+      <Dir>cciwmain</Dir>
+      <Dir>models</Dir>
+      <Name>sitecontent.py</Name>
+    </Source>
+    <Source>
+      <Dir>cciw</Dir>
+      <Dir>cciwmain</Dir>
+      <Dir>models</Dir>
+      <Name>forums.py</Name>
+    </Source>
+    <Source>
+      <Dir>cciw</Dir>
+      <Dir>cciwmain</Dir>
+      <Dir>models</Dir>
+      <Name>camps.py</Name>
+    </Source>
+    <Source>
+      <Dir>cciw</Dir>
+      <Dir>cciwmain</Dir>
+      <Dir>models</Dir>
+      <Name>__init__.py</Name>
+    </Source>
+    <Source>
+      <Dir>cciw</Dir>
+      <Dir>cciwmain</Dir>
+      <Dir>models</Dir>
+      <Name>members.py</Name>
+    </Source>
+    <Source>
+      <Dir>cciw</Dir>
+      <Dir>cciwmain</Dir>
+      <Dir>models</Dir>
+      <Name>polls.py</Name>
+    </Source>
+    <Source>
+      <Dir>cciw</Dir>
+      <Dir>cciwmain</Dir>
+      <Dir>templatetags</Dir>
+      <Name>standardpage.py</Name>
+    </Source>
+    <Source>
+      <Dir>cciw</Dir>
+      <Dir>cciwmain</Dir>
+      <Dir>templatetags</Dir>
+      <Name>__init__.py</Name>
+    </Source>
+    <Source>
+      <Dir>cciw</Dir>
+      <Dir>cciwmain</Dir>
+      <Dir>templatetags</Dir>
+      <Name>view_extras.py</Name>
+    </Source>
+    <Source>
+      <Dir>cciw</Dir>
+      <Dir>cciwmain</Dir>
+      <Dir>templatetags</Dir>
+      <Name>bbcode.py</Name>
+    </Source>
+    <Source>
+      <Dir>cciw</Dir>
+      <Dir>cciwmain</Dir>
+      <Dir>templatetags</Dir>
+      <Name>forums.py</Name>
+    </Source>
+    <Source>
+      <Dir>cciw</Dir>
+      <Dir>cciwmain</Dir>
+      <Dir>templatetags</Dir>
+      <Name>test_bbcode.py</Name>
+    </Source>
+    <Source>
+      <Dir>migrate</Dir>
+      <Name>mail_duplicated_users.py</Name>
+    </Source>
+    <Source>
+      <Dir>migrate</Dir>
+      <Name>django_migrate.py</Name>
+    </Source>
+    <Source>
+      <Dir>migrate</Dir>
+      <Name>migrate_html.py</Name>
+    </Source>
+    <Source>
+      <Dir>migrate</Dir>
+      <Name>deduplicate_email.py</Name>
+    </Source>
+    <Source>
+      <Dir>migrate</Dir>
+      <Name>html_and_links_to_python.py</Name>
+    </Source>
+    <Source>
+      <Name>changed_users.py</Name>
+    </Source>
+    <Source>
+      <Dir>migrate</Dir>
+      <Name>changed_users.py</Name>
+    </Source>
+    <Source>
+      <Dir>migrate</Dir>
+      <Name>changed_users-2006-06-17-22:35.py</Name>
+    </Source>
+  </Sources>
+  <Forms>
+  </Forms>
+  <Translations>
+  </Translations>
+  <Interfaces>
+  </Interfaces>
+  <Others>
+  </Others>
+  <Vcs>
+    <VcsType>Subversion</VcsType>
+    <VcsOptions>{'status': [''], 'log': [''], 'global': [''], 'update': [''], 'remove': [''], 'add': [''], 'tag': [''], 'export': [''], 'diff': [''], 'commit': [''], 'checkout': [''], 'history': ['']}</VcsOptions>
+    <VcsOtherData>{'standardLayout': 1}</VcsOtherData>
+  </Vcs>
+</Project>
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE Tasks SYSTEM "Tasks-3.7.dtd">
+<!-- Tasks file for project cciw -->
+<!-- Saved: 2006-06-21, 23:23:23 -->
+<Tasks version="3.7">
+  <Task priority="1" completed="0">
+    <Description>TODO: do this in flatten_data...</Description>
+    <Created>2006-06-16, 18:53:24</Created>
+    <Resource>
+      <Filename>
+        <Dir>cciw</Dir>
+        <Dir>officers</Dir>
+        <Name>views.py</Name>
+      </Filename>
+      <Linenumber>208</Linenumber>
+    </Resource>
+  </Task>
+</Tasks>

File cciw/__init__.py

Empty file added.

File cciw/cciwmain/__init__.py

+__all__ = ['common']

File cciw/cciwmain/common.py

+from django.conf import settings
+import cciw.cciwmain.models
+from cciw.cciwmain.templatetags import view_extras
+from django.http import HttpResponseRedirect
+import cciw.middleware.threadlocals as threadlocals
+import datetime
+import urllib
+       
+def standard_extra_context(extra_dict=None, title=None):
+    """
+    Gets the 'extra_dict' dictionary used for all pages
+    """
+    Member = cciw.cciwmain.models.Member
+    if extra_dict is None: 
+        extra_dict = {}
+        
+    if title is None:
+        title = "Christian Camps in Wales"
+    
+    extra_dict['title'] = title
+    extra_dict['thisyear'] = get_thisyear()
+    extra_dict['misc'] = {
+        'logged_in_members': 
+            Member.objects.filter(last_seen__gte=datetime.datetime.now() \
+                                           - datetime.timedelta(minutes=3)).count(),
+    }
+    
+    return extra_dict
+
+_thisyear = None
+_thisyear_timestamp = None
+
+def get_thisyear():
+    """
+    Get the year the website is currently on.  The website year is
+    equal to the year of the last camp in the database, or the year 
+    afterwards if that camp is in the past.
+    """
+    global _thisyear, _thisyear_timestamp
+    if _thisyear is None or _thisyear_timestamp is None \
+        or (datetime.datetime.now() - _thisyear_timestamp).seconds > 3600:
+        from cciw.cciwmain.models import Camp
+        lastcamp = Camp.objects.order_by('-end_date')[0]
+        if lastcamp.end_date <= datetime.date.today():
+            _thisyear = lastcamp.year + 1
+        else:
+            _thisyear = lastcamp.year
+        _thisyear_timestamp = datetime.datetime.now()
+    return _thisyear
+
+def standard_subs(value):
+    """Standard substitutions made on HTML content"""
+    return value.replace('{{thisyear}}', str(get_thisyear()))\
+                .replace('{{media}}', settings.CCIW_MEDIA_URL)
+
+def get_order_option(order_options, request, default_order_by):
+    """Get the order_by parameter from the request, if the request 
+    contains any of the specified ordering parameters in the query string.
+    
+    order_options is a dict of query string params and the corresponding lookup argument.  
+    
+    default_order_by is value to use for if there is no matching
+    order query string.
+    """
+
+    order_request = request.GET.get('order', None)
+    try:
+        order_by = order_options[order_request]
+    except:
+        order_by = default_order_by
+    return order_by
+
+def create_breadcrumb(links):
+    return " :: ".join(links)
+
+def standard_processor(request):
+    """Processor that does standard processing of request
+    that we need for all pages."""
+    MenuLink = cciw.cciwmain.models.MenuLink
+    context = {}
+    context['homepage'] = (request.path == "/")
+    # TODO - filter on 'visible' attribute
+    links = MenuLink.objects.filter(parent_item__isnull=True)
+    for l in links:
+        l.title = standard_subs(l.title)
+        l.isCurrentPage = False
+        l.isCurrentSection = False
+        if l.url == request.path:
+            l.isCurrentPage = True
+        elif request.path.startswith(l.url) and l.url != '/':
+            l.isCurrentSection = True
+    
+    context['menulinks'] = links
+    context['current_member'] = threadlocals.get_current_member()
+    context['pagevars'] = {
+        'media_root_url': settings.CCIW_MEDIA_URL,
+        'style_sheet_url': settings.CCIW_MEDIA_URL + 'style.css',
+    }
+
+    context.update(view_extras.get_view_extras_context(request))
+    
+    return context
+
+
+
+

File cciw/cciwmain/decorators.py

+from django.http import HttpResponseRedirect, HttpResponseForbidden
+from django import template
+from django.conf import settings
+from django.shortcuts import render_to_response
+
+from cciw.middleware.threadlocals import get_current_member, set_current_member
+from cciw.cciwmain.models import Permission, Member
+from cciw.cciwmain.common import standard_extra_context
+
+import urllib
+import base64, datetime, md5
+import cPickle as pickle
+
+def login_redirect(path):
+    """Returns a URL for logging in and then redirecting to the supplied path"""
+    qs = urllib.urlencode({'redirect': path})
+    return '%s?%s' % ('/login/', qs)
+
+def member_required(func):
+    """Decorator for a view function that redirects to a login
+     screen if the user isn't logged in."""
+    def _check(request, *args, **kwargs):
+        if get_current_member() is None:
+            return HttpResponseRedirect(login_redirect(request.get_full_path()))
+        else:
+            return func(request, *args, **kwargs)
+    return _check
+
+
+LOGIN_FORM_KEY = 'this_is_the_login_form'
+ERROR_MESSAGE = "Please enter a correct username and password. Note that both fields are case-sensitive."
+
+def _display_login_form(request, error_message=''):
+    if request.POST and request.POST.has_key('post_data'):
+        # User has failed login BUT has previously saved post data.
+        post_data = request.POST['post_data']
+    elif request.POST:
+        # User's session must have expired; save their post data.
+        post_data = _encode_post_data(request.POST)
+    else:
+        post_data = _encode_post_data({})
+    
+    c = template.RequestContext(request, standard_extra_context(title="Login"))
+    return render_to_response('cciw/members/login.html', {
+        'app_path': request.path,
+        'post_data': post_data,
+        'error_message': error_message
+    }, context_instance=c)
+
+    
+
+def _encode_post_data(post_data):
+    pickled = pickle.dumps(post_data)
+    pickled_md5 = md5.new(pickled + settings.SECRET_KEY).hexdigest()
+    return base64.encodestring(pickled + pickled_md5)
+
+def _decode_post_data(encoded_data):
+    encoded_data = base64.decodestring(encoded_data)
+    pickled, tamper_check = encoded_data[:-32], encoded_data[-32:]
+    if md5.new(pickled + settings.SECRET_KEY).hexdigest() != tamper_check:
+        from django.core.exceptions import SuspiciousOperation
+        raise SuspiciousOperation, "User may have tampered with session cookie."
+    return pickle.loads(pickled)
+
+def member_required_for_post(view_func):
+    """
+    Decorator for views that checks if data was POSTed back and 
+    if so requires the user to log in.
+    """
+    def _checklogin(request, *args, **kwargs):
+        if not request.POST:
+            return view_func(request, *args, **kwargs)
+    
+        if get_current_member() is not None:
+            # The user is valid. Continue to the page.
+            if request.POST.has_key('post_data'):
+                # User must have re-authenticated through a different window
+                # or tab.
+                request.POST = _decode_post_data(request.POST['post_data'])
+            return view_func(request, *args, **kwargs)
+
+        # If this isn't already the login page, display it.
+        if not request.POST.has_key(LOGIN_FORM_KEY):
+            if request.POST:
+                message = _("Please log in again, because your session has expired. Don't worry: Your submission has been saved.")
+            else:
+                message = ""
+            return _display_login_form(request, message)
+
+        # Check the password.
+        user_name = request.POST.get('user_name', '')
+        try:
+            member = Member.objects.get(user_name=user_name)
+        except Member.DoesNotExist:
+            return _display_login_form(request, ERROR_MESSAGE)
+
+        # The member data is correct; log in the member in and continue.
+        else:
+            if member.check_password(request.POST.get('password', '')):
+                request.session['member_id'] = member.user_name
+                member.last_seen = datetime.datetime.now()
+                member.save()
+                set_current_member(member)
+                
+                if request.POST.has_key('post_data'):
+                    post_data = _decode_post_data(request.POST['post_data'])
+                    if post_data and not post_data.has_key(LOGIN_FORM_KEY):
+                        # overwrite request.POST with the saved post_data, and continue
+                        request.POST = post_data
+                    return view_func(request, *args, **kwargs)
+            else:
+                return _display_login_form(request, ERROR_MESSAGE)
+
+    return _checklogin
+
+def same_member_required(member_name_arg):
+    """Returns a decorator for a view that only allows the specified
+    member to view the page.
+    
+    member_name_arg sepcifies the argument to the wrapped function 
+    that contains the users name. It is either an integer for a positional 
+    argument or a string for a keyword argument."""
+    
+    def _dec(func):
+        def _check(request, *args, **kwargs):
+            if isinstance(member_name_arg, int):
+                # positional argument, but out by one
+                # due to the request arg
+                user_name = args[member_name_arg-1]
+            else:
+                user_name = kwargs[member_name_arg]
+            cur_member = get_current_member()
+            if cur_member is None or \
+                (cur_member.user_name != user_name):
+                return HttpResponseForbidden('<h1>Access denied</h1>')
+            return func(request, *args, **kwargs)
+        return _check
+    return _dec

File cciw/cciwmain/feeds.py

+from django.contrib.syndication import feeds
+from django.http import Http404, HttpResponse
+from cciw.cciwmain.models import Member, Topic, Post, NewsItem
+from django.contrib.sites.models import Site
+from django.utils.feedgenerator import Atom1Feed
+from cciw.cciwmain.utils import get_member_href, get_current_domain
+
+MEMBER_FEED_MAX_ITEMS = 20
+NEWS_FEED_MAX_ITEMS = 20
+POST_FEED_MAX_ITEMS = 20
+TOPIC_FEED_MAX_ITEMS = 20
+PHOTO_FEED_MAX_ITEMS = 20
+TAG_FEED_MAX_ITEMS = 20
+
+# My extensions to django's feed class:
+#  - items() uses self.query_set
+#  - feed class stores the template name for convenience
+
+# Part of this is borrowed from django.contrib.syndication.views
+def handle_feed_request(request, feed_class, query_set=None, param=None):
+    """If the request has 'format=atom' in the query string,
+    create a feed and return it, otherwise return None."""
+    
+    if request.GET.get('format', None) != 'atom':
+        return None
+
+    template_name = feed_class.template_name
+    feed_inst = feed_class(template_name, request.path + "?format=atom")
+    if query_set is not None:
+        # The Feed subclass may or may not use this query_set
+        # If it is a CCIWFeed it will.
+        feed_inst.query_set = query_set
+
+    if not hasattr(feed_inst, 'link'):
+        # Default: atom feed is at same location
+        # as HTML page, but with different query parameters
+        feed_inst.link = request.path
+
+    try:
+        feedgen = feed_inst.get_feed(param)
+    except feeds.FeedDoesNotExist:
+        raise Http404, "Invalid feed parameters: %r." % param
+
+    response = HttpResponse(mimetype=feedgen.mime_type)
+    feedgen.write(response, 'utf-8')
+    return response
+    
+def add_domain(url):
+    """Adds the domain onto the beginning of a URL"""
+    return feeds.add_domain(get_current_domain(), url)
+
+class CCIWFeed(feeds.Feed):
+    feed_type = Atom1Feed
+    def items(self):
+        return self.modify_query(self.query_set)
+
+class MemberFeed(CCIWFeed):
+    template_name = 'members'
+    title = "New CCIW Members"
+    description = "New members of the Christian Camps in Wales message boards."
+
+    def modify_query(self, query_set):
+        return  query_set.order_by('-date_joined')[:MEMBER_FEED_MAX_ITEMS]
+
+class PostFeed(CCIWFeed):
+    template_name = 'posts'
+    title = "CCIW message boards posts"
+
+    def modify_query(self, query_set):
+        return query_set.order_by('-posted_at')[:POST_FEED_MAX_ITEMS]
+
+    def item_author_name(self, post):
+        return post.posted_by_id
+
+    def item_author_link(self, post):
+        return add_domain(get_member_href(post.posted_by_id))
+
+    def item_pubdate(self, post):
+        return post.posted_at
+
+def member_post_feed(member):
+    """Returns a Feed class suitable for the posts
+    of a specific member."""
+    class MemberPostFeed(PostFeed):
+        title = "CCIW - Posts by %s" % member.user_name
+    return MemberPostFeed
+
+def topic_post_feed(topic):
+    """Returns a Feed class suitable for the posts
+    in a specific topic."""
+    class TopicPostFeed(PostFeed):
+        title = "CCIW - Posts on topic \"%s\"" % topic.subject
+    return TopicPostFeed
+
+def photo_post_feed(photo):
+    """Returns a Feed classs suitable for the posts in a specific photo."""
+    class PhotoPostFeed(PostFeed):
+        title = "CCIW - Posts on photo %s" % str(photo)
+    return PhotoPostFeed
+
+class TopicFeed(CCIWFeed):
+    template_name = 'topics'
+    title = 'CCIW - message board topics'
+
+    def modify_query(self, query_set):
+        return query_set.order_by('-created_at')[:TOPIC_FEED_MAX_ITEMS]
+        
+    def item_author_name(self, topic):
+        return topic.started_by_id
+        
+    def item_author_link(self, topic):
+        return add_domain(get_member_href(topic.started_by_id))
+        
+    def item_pubdate(self, topic):
+        return topic.created_at
+
+def forum_topic_feed(forum):
+    """Returns a Feed class suitable for topics of a specific forum."""
+    class ForumTopicFeed(TopicFeed):
+        title = "CCIW - new topics in %s" % forum.nice_name()
+    return ForumTopicFeed
+
+class PhotoFeed(CCIWFeed):
+    template_name = 'photos'
+    title = 'CCIW photos'
+    
+    def modify_query(self, query_set):
+        return query_set.order_by('-created_at')[:PHOTO_FEED_MAX_ITEMS]
+    
+    def item_pubdate(self, photo):
+        return photo.created_at
+
+def gallery_photo_feed(gallery_name):
+    class GalleryPhotoFeed(PhotoFeed):
+        title = gallery_name
+    return GalleryPhotoFeed
+
+class TagFeed(CCIWFeed):
+    template_name = 'tags'
+    title = 'CCIW - recent tags'
+    
+    def modify_query(self, query_set):
+        return query_set.order_by('-added')[:TAG_FEED_MAX_ITEMS]
+        
+    def item_author_name(self, tag):
+        return tag.creator_id
+        
+    def item_author_link(self, tag):
+        return add_domain(get_member_href(tag.creator_id))
+    
+    def item_pubdate(self, tag):
+        return tag.added
+        
+    def item_link(self, tag):
+        return add_domain("/tag_targets/%s/%s/%s/%s/" % (tag.target_ct.name, tag.target_id, tag.text, tag.id))
+
+def text_tag_feed(text):
+    class TextTagFeed(TagFeed):
+        title = 'CCIW - items tagged "%s"' % text
+    return TextTagFeed
+
+def member_tag_feed(member):
+    """Gets a tag feed for a specific member."""
+    class MemberTagFeed(TagFeed):
+        title = "CCIW - tags by %s" % member.user_name
+    return MemberTagFeed
+
+def member_tag_text_feed(member, text):
+    """Gets a tag feed for a member for a specific text value."""
+    class MemberTagTextFeed(TagFeed):
+        title = "CCIW - '%s' tags by %s" % (text, member.user_name)
+    return MemberTagTextFeed
+
+def target_tag_feed(target):
+    """Gets a tag feed for a specifc target object."""
+    class TargetTagFeed(TagFeed):
+        title = "CCIW - tags for %s" % target
+    return TargetTagFeed

File cciw/cciwmain/imageutils.py

+"""
+Utilities for manipulating images
+"""
+
+# Using 'convert', because it's installed on my hosting and dev box,
+# and it's easier than getting 
+
+from django.conf import settings
+import shutil
+import os
+import ImageFile
+import glob
+
+def parse_image(filename):
+    fp = open(filename, "rb")
+    p = ImageFile.Parser()
+    
+    while 1:
+        s = fp.read(1024)
+        if not s:
+            break
+        p.feed(s)
+    
+    im = p.close()
+    return im
+
+
+class ValidationError(Exception):
+    pass
+
+def safe_del(filename):
+    try:
+        os.unlink(filename)
+    except OSError:
+        pass # don't care if we couldn't delete for some reason
+
+
+def fix_member_icon(filename, username):
+    try:
+        img = parse_image(filename)
+    except IOError:
+        safe_del(filename)
+        raise ValidationError("The image format was not recognised.")
+    
+    if img.size is None:
+        safe_del(filename)
+        raise ValidationError("The image format was not recognised.")
+    
+    if img.size[0] > settings.MEMBER_ICON_MAX_SIZE or \
+       img.size[1] > settings.MEMBER_ICON_MAX_SIZE:
+        # For now, just complain
+        safe_del(filename)
+        raise ValidationError("The image was bigger than %s by %s." % \
+            (settings.MEMBER_ICON_MAX_SIZE, settings.MEMBER_ICON_MAX_SIZE))
+        
+        # Scale to fit - TODO
+        #factor = max(img.size[0], img.size[1])/float(settings.MEMBER_ICON_MAX_SIZE)
+        #new_width, new_height = size[0]/factor, size[1]/factor
+    
+    # Give the icon a predictable name, with the same extension it had before.
+    # We refer to it in views without its extension, and use content negotiation
+    # to get the right one.
+    # This means we can just we only need the primary key (the username) of 
+    # the Member object to calculate this URL, saving on *lots* of db queries.
+    
+    ext = filename.split('.')[-1]
+    # Remove existing variants
+    for f in glob.glob(settings.MEDIA_ROOT + settings.MEMBER_ICON_PATH + username + ".*"):
+        os.unlink(f)
+
+    shutil.move(filename, settings.MEDIA_ROOT + settings.MEMBER_ICON_PATH + username + "." + ext)

File cciw/cciwmain/models/__init__.py

+from members import Permission, Member, Award, PersonalAward, Message
+from camps import Site, Person, Camp
+from forums import Forum, NewsItem, Topic, Gallery, Photo, Post
+from polls import Poll, PollOption, VoteInfo
+from sitecontent import MenuLink, HtmlChunk
+
+# TODO - work out where to put this:
+from lukeplant_me_uk.django.tagging.fields import add_tagging_fields
+from lukeplant_me_uk.django.tagging.utils import register_mappers, register_renderer
+import cciw.cciwmain.utils
+from django.template.defaultfilters import escape
+
+register_mappers(Post, str, int)
+register_mappers(Topic, str, int)
+register_mappers(Photo, str, int)
+register_mappers(Member, str, str)
+add_tagging_fields(creator_model=Member, creator_attrname='all_tags')
+add_tagging_fields(creator_model=Member, creator_attrname='post_tags', target_model=Post, target_attrname='tags')
+add_tagging_fields(creator_model=Member, creator_attrname='topic_tags', target_model=Topic, target_attrname='tags')
+add_tagging_fields(creator_model=Member, creator_attrname='photo_tags', target_model=Photo, target_attrname='tags')
+add_tagging_fields(creator_model=Member, creator_attrname='member_tags', target_model=Post, target_attrname='tags')
+
+def render_post(post):
+    return '<a href="%s">Post by %s: %s...</a>' % \
+        (post.get_absolute_url(), post.posted_by_id, escape(cciw.cciwmain.utils.get_extract(post.message, 30)))
+
+def render_topic(topic):
+    return '<a href="%s">Topic: %s...</a>' % \
+        (topic.get_absolute_url(), escape(cciw.cciwmain.utils.get_extract(topic.subject, 30)))
+
+def render_photo(photo):
+    return '<a href="%s">Photo: %s</a>' % \
+        (photo.get_absolute_url(), photo.id)
+
+register_renderer(Member, Member.get_link)
+register_renderer(Post, render_post)
+register_renderer(Topic, render_topic)
+register_renderer(Photo, render_photo)

File cciw/cciwmain/models/camps.py

+from django.db import models
+from django.contrib.auth.models import User
+
+class Site(models.Model):
+    short_name = models.CharField("Short name", maxlength="25", blank=False, unique=True)
+    slug_name = models.SlugField("Machine name", maxlength="25", blank=True, unique=True)
+    long_name = models.CharField("Long name", maxlength="50", blank=False)
+    info = models.TextField("Description (HTML)")
+    
+    def __str__(self):
+        return self.short_name
+        
+    def get_absolute_url(self):
+        return "/sites/" + self.slug_name
+    
+    def save(self):
+        from django.template.defaultfilters import slugify
+        self.slug_name = slugify(self.short_name)
+        super(Site, self).save()
+    
+    class Meta:
+        app_label = "cciwmain"
+        pass
+    
+    class Admin:
+        fields = (
+            (None, {'fields': ('short_name', 'long_name', 'info')}),
+        )
+        
+class Person(models.Model):
+    name = models.CharField("Name", maxlength=40)
+    info = models.TextField("Information (Plain text)", 
+                        blank=True)
+    user = models.ForeignKey(User, verbose_name="Associated admin user", null=True, blank=True)
+
+    def __str__(self):
+        return self.name
+
+    class Meta:
+        ordering = ('name',)
+        verbose_name_plural = 'people'
+        app_label = "cciwmain"
+        
+    class Admin:
+        pass
+
+CAMP_AGES = (
+    ('Jnr','Junior'),
+    ('Snr','Senior')
+)
+
+class Camp(models.Model):
+    year = models.PositiveSmallIntegerField("year")
+    number = models.PositiveSmallIntegerField("number")
+    age = models.CharField("age", blank=False, maxlength=3,
+                        choices=CAMP_AGES)
+    start_date = models.DateField("start date")
+    end_date = models.DateField("end date")
+    previous_camp = models.ForeignKey("self", 
+        related_name="next_camps", 
+        verbose_name="previous camp",
+        null=True, blank=True)
+    chaplain = models.ForeignKey(Person, 
+        related_name="camp_as_chaplain", 
+        verbose_name="chaplain", 
+        null=True, blank=True)
+    leaders = models.ManyToManyField(Person, 
+        related_name="camp_as_leader", 
+        verbose_name="leaders",
+        null=True, blank=True, filter_interface=models.HORIZONTAL)
+    site = models.ForeignKey(Site)
+    online_applications = models.BooleanField("Accepts online applications from officers.")
+    
+    def __str__(self):
+        leaders = list(self.leaders.all())
+        try:
+            leaders.append(self.chaplain)
+        except Person.DoesNotExist:
+            pass
+        if len(leaders) > 0:
+            leadertext = " (" + ", ".join(str(l) for l in leaders) + ")"
+        else:
+            leadertext = ""
+        return str(self.year) + "-" + str(self.number) + leadertext
+    
+    @property
+    def nice_name(self):
+        return "Camp " + str(self.number) + ", year " + str(self.year)
+
+    def get_link(self):
+        return "<a href='" + self.get_absolute_url() + "'>" + self.nice_name + '</a>'
+
+    def get_absolute_url(self):
+        return "/camps/" + str(self.year) + "/" + str(self.number) + "/"
+
+    class Meta:
+        app_label = "cciwmain"
+
+    class Admin:
+        fields = (
+            (None, {'fields': ('year', 'number', 'age', 'start_date', 'end_date', 
+                               'chaplain', 'leaders', 'site', 'previous_camp', 'online_applications') 
+                    }
+            ),
+        )
+        ordering = ['-year','number']
+        list_filter = ('age', 'site', 'online_applications')

File cciw/cciwmain/models/forums.py

+from django.db import models
+from members import *
+from polls import *
+from datetime import datetime
+from django.contrib.auth.models import User
+import re
+from django.conf import settings
+import cciw.middleware.threadlocals as threadlocals
+from django.template.defaultfilters import escape
+
+# regex used to match forums that belong to camps
+_camp_forum_re = re.compile('^' + settings.CAMP_FORUM_RE + '$')
+
+class Forum(models.Model):
+    open = models.BooleanField("Open", default=True)
+    location = models.CharField("Location/path", db_index=True, unique=True, maxlength=50)
+    
+    def get_absolute_url(self):
+        return '/' + self.location
+    
+    def __str__(self):
+        return self.location
+    
+    def nice_name(self):
+        m = _camp_forum_re.match(self.location)
+        if m:
+            captures = m.groupdict()
+            number = captures['number']
+            if number == 'all':
+                return "forum for all camps, year %s" % captures['year']
+            else:
+                return "forum for camp %s, year %s" % (number, captures['year'])
+        else:
+            return "forum at %s" % self.location
+
+    class Meta:
+        app_label = "cciwmain"   
+        
+    class Admin:
+        pass
+
+class NewsItem(models.Model):
+    created_by = models.ForeignKey(Member, related_name="news_items_created")
+    created_at = models.DateTimeField("Posted")
+    summary = models.TextField("Summary or short item, (bbcode)")
+    full_item = models.TextField("Full post (HTML)", blank=True)
+    subject = models.CharField("Subject", maxlength=100)
+    
+    def __str__(self):
+        return self.subject
+
+    @staticmethod
+    def create_item(member, subject, short_item):
+        """Create a news item with the correct defaults for a member."""
+        return NewsItem(created_by=member,
+                        created_at=datetime.now(),
+                        summary=short_item,
+                        full_item="",
+                        subject=subject)
+
+    class Meta:
+        app_label = "cciwmain"
+        
+    class Admin:
+        pass
+
+class UserSpecificTopics(models.Manager):
+    def get_query_set(self):
+        queryset = super(UserSpecificTopics, self).get_query_set()
+        user = threadlocals.get_current_user()
+        if user is None or user.is_anonymous() or \
+            not user.has_perm('cciwmain.edit_topic'):
+            # Non-moderator user
+            member = threadlocals.get_current_member()
+            if member is not None:
+                # include hidden topics by that user
+                return (queryset.filter(started_by=member.user_name) | queryset.filter(hidden=False))
+            else:
+                return queryset.filter(hidden=False)
+        else:
+            return queryset
+
+class Topic(models.Model):
+    subject = models.CharField("Subject", maxlength=240)
+    started_by = models.ForeignKey(Member, related_name="topics_started",
+        verbose_name="started by")
+    created_at = models.DateTimeField("Started", null=True)
+    open = models.BooleanField("Open")
+    hidden = models.BooleanField("Hidden", default=False)
+    approved = models.BooleanField("Approved", null=True, blank=True)
+    checked_by = models.ForeignKey(User,
+        null=True, blank=True, related_name="topics_checked",
+        verbose_name="checked by")
+    needs_approval = models.BooleanField("Needs approval", default=False)
+    news_item = models.ForeignKey(NewsItem, null=True, blank=True,
+        related_name="topics") # optional news item
+    poll = models.ForeignKey(Poll, null=True, blank=True,
+        related_name="topics") # optional topic
+    forum = models.ForeignKey(Forum, related_name="topics")
+
+    # De-normalised fields needed for performance and simplicity in templates:
+    last_post_at = models.DateTimeField("Last post at", 
+        null=True, blank=True) 
+    last_post_by = models.ForeignKey(Member, verbose_name="Last post by",
+        null=True, blank=True, related_name='topics_with_last_post') 
+    # since we need 'last_post_by', may as well have this too:
+    post_count = models.PositiveSmallIntegerField("Number of posts", default=0) 
+    
+    # Managers:
+    objects = UserSpecificTopics()
+    all_objects = models.Manager()
+
+    def __str__(self):
+        return  "Topic: " + self.subject
+        
+    def get_absolute_url(self):
+        return self.forum.get_absolute_url() + str(self.id) + '/'
+    
+    def get_link(self):
+        return '<a href="' + self.get_absolute_url() + '">' + escape(self.subject) + '</a>'
+
+    @staticmethod
+    def create_topic(member, subject, forum):
+        """Create a topic with the correct defaults for a member"""
+        return Topic(started_by=member,
+                     subject=subject,
+                     forum=forum,
+                     created_at=datetime.now(),
+                     hidden=(member.moderated == Member.MODERATE_ALL),
+                     needs_approval=(member.moderated == Member.MODERATE_ALL),
+                     open=True)
+
+    class Admin:
+        list_display = ('subject', 'started_by', 'created_at')
+        search_fields = ('subject',)
+    
+    class Meta:
+        app_label = "cciwmain"
+        ordering = ('-started_by',)
+
+class Gallery(models.Model):
+    location = models.CharField("Location/URL", maxlength=50)
+    needs_approval = models.BooleanField("Photos need approval", default=False)
+
+    def __str__(self):
+        return self.location
+        
+    def get_absolute_url(self):
+        return '/' + self.location
+        
+    class Meta:
+        app_label = "cciwmain"
+        verbose_name_plural = "Galleries"
+        ordering = ('-location',)
+        
+    class Admin:
+        pass
+
+class UserSpecificPhotos(models.Manager):
+    def get_query_set(self):
+        queryset = super(UserSpecificPhotos, self).get_query_set()
+        user = threadlocals.get_current_user()
+        if user is None or user.is_anonymous() or \
+            not user.has_perm('cciwmain.edit_topic'):
+            # Non-moderator user
+            return queryset.filter(hidden=False)
+        else:
+            return queryset
+
+class Photo(models.Model):
+    created_at = models.DateTimeField("Started", null=True)
+    open = models.BooleanField("Open")
+    hidden = models.BooleanField("Hidden")
+    filename = models.CharField("Filename", maxlength=50)
+    description = models.CharField("Description", blank=True, maxlength=100)
+    gallery = models.ForeignKey(Gallery,
+        verbose_name="gallery",
+        related_name="photo")
+    checked_by = models.ForeignKey(User,
+        null=True, blank=True, related_name="photos_checked")
+    approved = models.BooleanField("Approved", null=True, blank=True)
+    needs_approval = models.BooleanField("Needs approval", default=False)
+
+    # De-normalised fields needed for performance and simplicity in templates:
+    last_post_at = models.DateTimeField("Last post at", 
+        null=True, blank=True) 
+    last_post_by = models.ForeignKey(Member, verbose_name="Last post by",
+        null=True, blank=True, related_name='photos_with_last_post')
+    # since we need 'last_post_by', may as well have this too:
+    post_count = models.PositiveSmallIntegerField("Number of posts", default=0)
+
+    # managers
+    objects = UserSpecificPhotos()
+    all_objects = models.Manager()
+
+    def __str__(self):
+        return "Photo: " + self.filename
+
+    def get_absolute_url(self):
+        return self.gallery.get_absolute_url() + str(self.id) + '/'
+
+    class Meta:
+        app_label = "cciwmain"
+        
+    class Admin:
+        pass
+
+class UserSpecificPosts(models.Manager):
+    def get_query_set(self):
+        """Return a filtered version of the queryset,
+        appropriate for the current member/user."""
+        queryset = super(UserSpecificPosts, self).get_query_set()
+        user = threadlocals.get_current_user()
+        if user is None or user.is_anonymous() or \
+            not user.has_perm('cciwmain.edit_post'):
+            # Non-moderator user
+            
+            member = threadlocals.get_current_member()
+            if member is not None:
+                # include hidden posts by that user
+                return (queryset.filter(posted_by=member.user_name) | queryset.filter(hidden=False))
+            else:
+                return queryset.filter(hidden=False)
+        else:
+            return queryset
+
+class Post(models.Model):
+    posted_by = models.ForeignKey(Member, 
+        related_name="posts")
+    subject = models.CharField("Subject", maxlength=240, blank=True) # deprecated, supports legacy boards
+    message = models.TextField("Message")
+    posted_at = models.DateTimeField("Posted at", null=True)
+    hidden = models.BooleanField("Hidden", default=False)
+    approved = models.BooleanField("Approved", null=True)
+    checked_by = models.ForeignKey(User,
+        verbose_name="checked by",
+        null=True, blank=True, related_name="checked_post")
+    needs_approval = models.BooleanField("Needs approval", default=False)
+    photo = models.ForeignKey(Photo, related_name="posts",
+        null=True, blank=True)
+    topic = models.ForeignKey(Topic, related_name="posts",
+        null=True, blank=True)
+
+    # Managers
+    objects = UserSpecificPosts()
+    all_objects = models.Manager()
+
+
+    def __str__(self):
+        return "Post [" + str(self.id) + "]:  " + self.message[:30]
+
+    def updateParent(self, parent):
+        "Update the cached info in the parent topic/photo"
+        # Both types of parent, photos and topics,
+        # are covered by this sub since they deliberately have the same
+        # interface for this bit.
+        post_count = parent.posts.count()
+        changed = False
+        if (parent.last_post_at is None and not self.posted_at is None) or \
+            (not parent.last_post_at is None and not self.posted_at is None \
+            and self.posted_at > parent.last_post_at):
+            parent.last_post_at = self.posted_at
+            changed = True
+        if parent.last_post_by_id is None or \
+            parent.last_post_by_id != self.posted_by_id:
+            parent.last_post_by_id = self.posted_by_id
+            changed = True
+        if post_count > parent.post_count:
+            parent.post_count = post_count
+            changed = True
+        if changed:
+            parent.save()
+                
+    def save(self):
+        super(Post, self).save()
+        # Update parent topic/photo
+        
+        if self.topic_id is not None:
+            self.updateParent(self.topic)
+            
+        if self.photo_id is not None:
+            self.updateParent(self.photo)
+
+    def get_absolute_url(self):
+        """Returns the absolute URL of the post that is always correct.  
+        (This does a redirect to a URL that depends on the member viewing the page)"""
+        return "/posts/%s/" % self.id
+
+    def get_forum_url(self):
+        """Gets the URL for the post in the context of its forum."""
+        # Some posts are not visible to some users.  In a forum
+        # thread, however, posts are always displayed in pages
+        # of N posts, so the page a post is on depends on who is
+        # looking at it.  This function takes this into account
+        # and gives the correct URL.  This is important for the case
+        # or feed readers that won't in general be logged in as the
+        # the user when they fetch the feed that may have absolute 
+        # URLs in it.
+        # Also it's useful in case we change the paging.
+        if self.topic_id is not None:
+            thread = self.topic
+        elif self.photo_id is not None:
+            thread = self.photo
+        # Post ordering is by id (for compatibility with legacy data)
+        # The following uses the default manager so has permissions
+        # built in.
+        posts = thread.posts.filter(id__lt=self.id)
+        previous_posts = posts.count()
+        page = int(previous_posts/settings.FORUM_PAGINATE_POSTS_BY) + 1
+        return "%s?page=%s#id%s" % (thread.get_absolute_url(), page, self.id)
+
+    @staticmethod
+    def create_post(member, message, topic=None, photo=None):
+        """Creates a post with the correct defaults for a member."""
+        post = Post(posted_by=member,
+                    subject='',
+                    message=message,
+                    topic=topic,
+                    photo=photo,
+                    hidden=(member.moderated == Member.MODERATE_ALL),
+                    needs_approval=(member.moderated == Member.MODERATE_ALL),
+                    posted_at=datetime.now())
+        return post
+        
+        
+    class Meta:
+        app_label = "cciwmain"
+        # Order by the autoincrement id, rather than  posted_at, because
+        # this matches the old system (in the old system editing a post 
+        # would also cause its posted_at date to change, but not it's order,
+        # and data for the original post date/time is now lost)
+        ordering = ('id',) 
+
+    class Admin:
+        list_display = ('__str__', 'posted_by', 'posted_at')
+        search_fields = ('message',)
+        

File cciw/cciwmain/models/members.py

+from django.db import models
+from django.conf import settings
+from django.core import mail
+from cciw.middleware import threadlocals
+from cciw.cciwmain import utils
+from datetime import datetime
+
+class Permission(models.Model):
+    POLL_CREATOR = 5
+    NEWS_CREATOR = 6
+
+    id = models.PositiveSmallIntegerField("ID", primary_key=True)
+    description = models.CharField("Description", maxlength=40)
+    
+    def __str__(self):
+        return self.description
+    
+    class Meta:
+        ordering = ('id',)
+        app_label = "cciwmain"
+        
+    class Admin:
+        pass
+
+class UserSpecificMembers(models.Manager):
+    def get_query_set(self):
+        user = threadlocals.get_current_user()
+        if user is None or user.is_anonymous() or not user.is_staff or not\
+            user.has_perm('cciwmain.change_member'):
+            return super(UserSpecificMembers, self).get_query_set().filter(hidden=False)
+        else:
+            return super(UserSpecificMembers, self).get_query_set()
+
+class Member(models.Model):
+    """Represents a user of the CCIW message boards."""
+    MESSAGES_NONE = 0
+    MESSAGES_WEBSITE = 1
+    MESSAGES_EMAIL = 2
+    MESSAGES_EMAIL_AND_WEBSITE = 3
+    
+    MODERATE_OFF = 0
+    MODERATE_NOTIFY = 1
+    MODERATE_ALL = 2
+    
+    MESSAGE_OPTIONS = (
+        (MESSAGES_NONE,     "Don't allow messages"),
+        (MESSAGES_WEBSITE,  "Store messages on the website"),
+        (MESSAGES_EMAIL,    "Send messages via email"),
+        (MESSAGES_EMAIL_AND_WEBSITE, "Store messages and send via email")
+    )
+    
+    MODERATE_OPTIONS = (
+        (MODERATE_OFF,      "Off"),
+        (MODERATE_NOTIFY,   "Unmoderated, but notify"),
+        (MODERATE_ALL,      "Fully moderated")
+    )
+
+    user_name   = models.CharField("User name", primary_key=True, maxlength=30)
+    real_name   = models.CharField("Real name", maxlength=30, blank=True)
+    email       = models.EmailField("Email address")
+    password    = models.CharField("Password", maxlength=30)
+    date_joined = models.DateTimeField("Date joined", null=True)
+    last_seen   = models.DateTimeField("Last on website", null=True)
+    show_email  = models.BooleanField("Show email address", default=False)
+    message_option = models.PositiveSmallIntegerField("Message option",
+        choices=MESSAGE_OPTIONS, default=1, radio_admin=True)
+    comments    = models.TextField("Comments", blank=True)
+    moderated   = models.PositiveSmallIntegerField("Moderated", default=0,
+        choices=MODERATE_OPTIONS)
+    hidden      = models.BooleanField("Hidden", default=False)
+    banned      = models.BooleanField("Banned", default=False)
+    permissions = models.ManyToManyField(Permission,
+        verbose_name="permissions", related_name="member_with_permission",
+        blank=True, null=True, filter_interface=models.HORIZONTAL)
+    icon         = models.ImageField("Icon", upload_to=settings.MEMBER_ICON_UPLOAD_PATH, blank=True)
+    dummy_member = models.BooleanField("Dummy member status", default=False) # supports ancient posts in message boards
+    
+    # Managers
+    objects = UserSpecificMembers()
+    all_objects = models.Manager()
+    
+    def __str__(self):
+        return self.user_name
+        
+    def get_absolute_url(self):
+        return "/members/" + self.user_name + "/"
+        
+    def get_link(self):
+        from cciw.cciwmain.utils import get_member_link
+        if self.dummy_member:
+            return self.user_name
+        else:
+            return get_member_link(self.user_name)
+
+    def check_password(self, plaintextPass):
+        """Checks a password is correct"""
+        import crypt
+        return crypt.crypt(plaintextPass, self.password) == self.password
+        
+    def new_messages(self):
+        return self.messages_received.filter(box=Message.MESSAGE_BOX_INBOX).count()
+
+    def saved_messages(self):
+        return self.messages_received.filter(box=Message.MESSAGE_BOX_SAVED).count()
+    
+    def has_perm(self, perm):
+        """Does the member has the specified permission?
+        perm is one of the permission constants in Permission."""
+        return len(self.permissions.filter(pk=perm)) > 0
+    
+    @property
+    def can_add_news(self):
+        return self.has_perm(Permission.NEWS_CREATOR)
+        
+    @property
+    def can_add_poll(self):
+        return self.has_perm(Permission.POLL_CREATOR)
+
+    @staticmethod    
+    def generate_salt():
+        import random, datetime
+        rand64= "./0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
+        random.seed(datetime.datetime.today().microsecond)
+        return rand64[int(random.random()*64)] + rand64[int(random.random()*64)]
+    
+    @staticmethod
+    def encrypt_password(memberPass):
+        import crypt
+        """Encrypt a members password"""
+        # written to maintain compatibility with existing password file
+        return crypt.crypt(memberPass, Member.generate_salt())
+
+    class Meta:
+        ordering = ('user_name',)
+        app_label = "cciwmain"
+        
+    class Admin:
+        search_fields = (
+            'user_name', 'real_name', 'email'
+        )
+        list_display = (
+            'user_name', 'real_name', 'email', 'date_joined'
+        )
+        list_filter = (
+            'dummy_member',
+            'hidden',
+            'banned',
+            'moderated',
+        )
+
+
+class Award(models.Model):
+    name = models.CharField("Award name", maxlength=50)
+    value = models.SmallIntegerField("Value")
+    year = models.PositiveSmallIntegerField("Year")
+    description = models.CharField("Description", maxlength=200)
+    image = models.ImageField("Award image", 
+        upload_to=settings.AWARD_UPLOAD_PATH)
+
+    def __str__(self):
+        return self.name + " " + str(self.year)
+        
+    def nice_name(self):
+        return str(self)
+    
+    def imageurl(self):
+        return settings.CCIW_MEDIA_URL + "images/awards/" + self.image
+        
+    def get_absolute_url(self):
+        from django.template.defaultfilters import slugify
+        return "/awards/#" + slugify(str(self))
+    
+    class Meta:
+        app_label = "cciwmain"
+        ordering = ('-year', 'name',)
+    
+    class Admin:
+        list_display = ('name', 'year')
+    
+class PersonalAward(models.Model):
+    reason = models.CharField("Reason for award", maxlength=200)
+    date_awarded = models.DateField("Date awarded", null=True, blank=True)
+    award = models.ForeignKey(Award,
+        verbose_name="award", 
+        related_name="personal_awards")
+    member = models.ForeignKey(Member,
+        verbose_name="member",
+        related_name="personal_awards")
+
+    def __str__(self):
+        return self.award.name + " to " + self.member.user_name
+
+    class Meta:
+        app_label = "cciwmain"   
+        ordering = ('date_awarded',)
+
+    class Admin:
+        list_display = ('award', 'member','reason', 'date_awarded')
+        list_filter = ('award',)
+        
+class Message(models.Model):
+    MESSAGE_BOX_INBOX = 0
+    MESSAGE_BOX_SAVED = 1
+    
+    MESSAGE_BOXES = (
+        (MESSAGE_BOX_INBOX, "Inbox"),
+        (MESSAGE_BOX_SAVED, "Saved")
+    )
+    from_member = models.ForeignKey(Member,
+        verbose_name="from member",
+        related_name="messages_sent"
+    )
+    to_member = models.ForeignKey(Member, 
+        verbose_name="to member",
+        related_name="messages_received")
+    time = models.DateTimeField("At")
+    text = models.TextField("Message")
+    box = models.PositiveSmallIntegerField("Message box",
+        choices=MESSAGE_BOXES)
+    
+    @staticmethod
+    def send_message(to_member, from_member, text):
+        if to_member.message_option == Member.MESSAGES_NONE:
+            return
+        if to_member.message_option != Member.MESSAGES_EMAIL:
+            msg = Message(to_member=to_member, from_member=from_member,
+                        text=text, time=datetime.now(),
+                        box=Message.MESSAGE_BOX_INBOX)
+            msg.save()
+        if to_member.message_option != Member.MESSAGES_WEBSITE:
+            mail.send_mail("Message on cciw.co.uk",
+"""You have received a message on cciw.co.uk from user %(from)s:
+
+%(message)s
+----
+You can view your inbox here:
+http://%(domain)s/members/%(to)s/messages/inbox/
+
+You can reply here:
+http://%(domain)s/members/%(from)s/messages/
+
+""" % {'from': from_member.user_name, 'to': to_member.user_name,
+        'domain': utils.get_current_domain(), 'message': text},
+        "website@cciw.co.uk", [to_member.email])
+            
+    
+    def __str__(self):
+        return "[" + str(self.id) + "] to " + str(self.to_member)  + " from " + str(self.from_member)
+    
+    class Meta:
+        app_label = "cciwmain"
+        ordering = ('-time',)
+    
+    class Admin:
+        list_display = ('to_member', 'from_member', 'time')

File cciw/cciwmain/models/polls.py

+from django.db import models
+from datetime import datetime
+from members import *
+import operator
+
+VOTING_RULES = (
+    (0, "Unlimited"),
+    (1, "'X' votes per member"),
+    (2, "'X' votes per member per day")
+)
+
+class Poll(models.Model):
+    UNLIMITED = 0
+    X_VOTES_PER_USER = 1
+    X_VOTES_PER_USER_PER_DAY = 2
+
+    title = models.CharField("Title", maxlength=100)
+    intro_text = models.CharField("Intro text", maxlength=400, blank=True)
+    outro_text = models.CharField("Closing text", maxlength=400, blank=True)
+    voting_starts = models.DateTimeField("Voting starts")
+    voting_ends = models.DateTimeField("Voting ends")
+    rules = models.PositiveSmallIntegerField("Rules",
+        choices = VOTING_RULES)
+    rule_parameter = models.PositiveSmallIntegerField("Parameter for rule", 
+        default=1, radio_admin=True)
+    have_vote_info = models.BooleanField("Full vote information available", 
+        default=True)
+    created_by = models.ForeignKey(Member, verbose_name="created by",
+        related_name="polls_created")
+    
+    def __str__(self):
+        return self.title
+    
+    def can_vote(self, member):
+        """Returns true if member can vote on the poll"""
+        if not self.can_anyone_vote():
+            return False
+        if not self.have_vote_info:
+            # Can't calculate this, but it will only happen 
+            # for legacy polls, which are all closed.
+            return True
+        if self.rules == Poll.UNLIMITED:
+            return True
+        queries = [] # queries representing users relevant votes
+        for po in self.poll_options.all():
+            if self.rules == Poll.X_VOTES_PER_USER:
+                queries.append(po.votes.filter(member=member.user_name))
+            elif self.rules == Poll.X_VOTES_PER_USER_PER_DAY:
+                queries.append(po.votes.filter(member=member.user_name, 
+                                                date__gte=datetime.now() - timedelta(1)))
+        # combine them all and do an SQL count.
+        if len(queries) == 0:
+            return False # no options to vote on!
+        count = reduce(operator.or_, queries).count()
+        if count >= self.rule_parameter:
+            return False
+        else:
+            return True
+        
+    def total_votes(self):
+        sum = 0
+        # TODO - use SQL, or caching
+        for option in self.poll_options.all():
+            sum += option.total
+        return sum
+    
+    def can_anyone_vote(self):
+        return (self.voting_ends > datetime.now()) and \
+            (self.voting_starts < datetime.now())
+    
+    def verbose_rules(self):
+        if self.rules == Poll.UNLIMITED:
+            return "Unlimited number of votes."
+        elif self.rules == Poll.X_VOTES_PER_USER:
+            return "%s vote(s) per user." % self.rule_parameter
+        elif self.rules == Poll.X_VOTES_PER_USER_PER_DAY:
+            return "%s vote(s) per user per day." % self.rule_parameter
+        
+    class Meta:
+        app_label = "cciwmain"   
+        ordering = ('title',)
+        
+    class Admin:
+        list_display = ('title', 'created_by', 'voting_starts')
+        
+
+class PollOption(models.Model):
+    text = models.CharField("Option text", maxlength=200, core=True)
+    total = models.PositiveSmallIntegerField("Number of votes", core=True)
+    poll = models.ForeignKey(Poll, verbose_name="Associated poll",
+        related_name="poll_options", edit_inline=True)
+    listorder = models.PositiveSmallIntegerField("Order in list", core=True)
+        
+    def __str__(self):
+        return self.text
+        
+    def percentage(self):
+        """
+        Get the percentage of votes this option got 
+        compared to the total number of votes in the whole. Return
+        'n/a' if total votes = 0
+        """
+        sum = self.poll.total_votes()
+        if sum == 0:
+            return 'n/a'
+        else:
+            if self.total == 0:
+                return '0%'
+            else:
+                return '%.1f' % (float(self.total)/sum*100) + '%'
+                
+    def bar_width(self):
+        sum = self.poll.total_votes()
+        if sum == 0:
+            return 0
+        else:
+            return int(float(self.total)/sum*300)
+
+    class Meta:
+        app_label = "cciwmain"
+        ordering = ('poll', 'listorder',)
+
+    class Admin:
+        list_display = ('text', 'poll')
+
+class VoteInfo(models.Model):
+    poll_option = models.ForeignKey(PollOption, 
+        related_name="votes")
+    member = models.ForeignKey(Member,
+        verbose_name="member",
+        related_name="poll_votes")
+    date = models.DateTimeField("Date")
+
+    def save(self):
+        # Manually update the parent
+        #  - this is the easiest way for vote counts to work
+        #    with legacy polls that don't have VoteInfo objects
+        is_new = (self.id is None)
+        super(VoteInfo, self).save()
+        if is_new:
+            self.poll_option.total += 1
+        self.poll_option.save()
+
+    class Meta:
+        app_label = "cciwmain"
+        

File cciw/cciwmain/models/sitecontent.py

+from django.db import models
+from django.contrib.admin.views.main import quote
+import cciw.cciwmain.common
+import cciw.middleware.threadlocals as threadlocals
+
+class MenuLink(models.Model):
+    title = models.CharField("title", maxlength=50)
+    url = models.CharField("URL", maxlength=100)
+    extra_title = models.CharField("Disambiguation title", maxlength=100, blank=True)
+    listorder = models.SmallIntegerField("order in list")
+    visible = models.BooleanField("Visible", default=True)
+    parent_item = models.ForeignKey("self", null=True, blank=True,
+        verbose_name="Parent item (none = top level)",
+        related_name="child_links")
+
+    def __str__(self):
+        from cciw.cciwmain.common import standard_subs
+        return self.url + " [" +  standard_subs(self.title) + "]"
+    
+    def get_visible_children(self, request):
+        """Gets a list of child menu links that should be visible given the current url"""
+        if request.path == self.url:
+            return self.child_links
+        else:
+            return []
+    
+    class Meta:
+        app_label = "cciwmain"
+        ordering = ('listorder','parent_item')
+        #order_with_respect_to = 'parent_item' # doesn't seem to work
+        
+    class Admin:
+        list_display = ('title', 'url', 'listorder','visible','parent_item')
+
+class HtmlChunk(models.Model):
+    name = models.SlugField("name", primary_key=True, db_index=True)
+    html = models.TextField("HTML")
+    menu_link = models.ForeignKey(MenuLink, verbose_name="Associated URL",
+        null=True, blank=True)
+    page_title = models.CharField("page title (for chunks that are pages)", maxlength=100,
+        blank=True)
+    
+    def __str__(self):
+        return self.name
+        
+    def render(self, request):
+        """Render the HTML chunk as HTML, with replacements
+        made and any member specific adjustments."""
+        html = cciw.cciwmain.common.standard_subs(self.html)
+        user = threadlocals.get_current_user()
+        if user and not user.is_anonymous() and user.is_staff \
+            and user.has_perm('edit_htmlchunk'):
+            html += ("""<div class="editChunkLink">&laquo;
+                        <a href="/admin/cciwmain/htmlchunk/%s/">Edit %s</a> &raquo;
+                        </div>""" % (quote(self.name), self.name))
+        return html
+
+    class Meta:
+        app_label = "cciwmain"   
+        verbose_name = "HTML chunk"
+
+    class Admin:
+        list_display = ('name', 'page_title', 'menu_link')
+