summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authortoshio <toshio@mm3test.fedoraproject.org>2012-03-12 23:27:56 +0000
committertoshio <toshio@mm3test.fedoraproject.org>2012-03-12 23:27:56 +0000
commit1c4f1cb5a0332a1fcc4d2fa8cf908f029cf23594 (patch)
tree53779390ff498842d2b919da62182fe582d4d0ca
downloadhyperkitty-1c4f1cb5a0332a1fcc4d2fa8cf908f029cf23594.tar.gz
hyperkitty-1c4f1cb5a0332a1fcc4d2fa8cf908f029cf23594.tar.xz
hyperkitty-1c4f1cb5a0332a1fcc4d2fa8cf908f029cf23594.zip
Import the initial version of the hyperkitty archiver
-rw-r--r--README44
-rw-r--r--__init__.py0
-rwxr-xr-ximport-mbox.py64
-rw-r--r--lib/__init__.py14
-rw-r--r--lib/mockup.py167
-rw-r--r--lib/notmuch.py105
-rw-r--r--manage.py14
-rw-r--r--settings.py157
-rw-r--r--static/css/stats.css124
-rw-r--r--static/css/style.css324
-rw-r--r--static/img/discussion.pngbin0 -> 514 bytes
-rw-r--r--static/img/email_bg.pngbin0 -> 541 bytes
-rw-r--r--static/img/like.pngbin0 -> 915 bytes
-rw-r--r--static/img/likealot.pngbin0 -> 912 bytes
-rw-r--r--static/img/neutral.pngbin0 -> 895 bytes
-rw-r--r--static/img/newthread.pngbin0 -> 6161 bytes
-rw-r--r--static/img/notsaved.pngbin0 -> 671 bytes
-rw-r--r--static/img/participant.pngbin0 -> 675 bytes
-rw-r--r--static/img/saved.pngbin0 -> 700 bytes
-rw-r--r--static/img/show_discussion.pngbin0 -> 3368 bytes
-rw-r--r--static/img/youdislike.pngbin0 -> 400 bytes
-rw-r--r--static/img/youlike.pngbin0 -> 498 bytes
-rw-r--r--static/jquery.expander.js382
-rw-r--r--templates/base.html61
-rw-r--r--templates/base2.html63
-rw-r--r--templates/index.html17
-rw-r--r--templates/index2.html14
-rw-r--r--templates/month_view.html71
-rw-r--r--templates/month_view2.html75
-rw-r--r--templates/recent_activities.html102
-rw-r--r--templates/search.html73
-rw-r--r--templates/search2.html77
-rw-r--r--urls.py48
-rw-r--r--views/__init__.py0
-rw-r--r--views/mockup.py151
-rw-r--r--views/pages.py238
36 files changed, 2385 insertions, 0 deletions
diff --git a/README b/README
new file mode 100644
index 0000000..288462d
--- /dev/null
+++ b/README
@@ -0,0 +1,44 @@
+MailMan App
+===========
+
+This application aims at providing an interface to visualize and explore
+mailman archives.
+
+This is a django project.
+
+To get it run:
+- Aggregate the static content into their rightfull place:
+python manage.py collectstatic
+- Start the server:
+python manage.py runserver
+
+To get it running under virtualenv:
+-----------------------------------
+# Create the virtualenv
+virtualenv mailman3
+# Activate the virtualenv
+cd mailman3
+source bin/activate
+# Install django and dependencies
+easy_install django
+easy_install bunch
+# Install notmuch -- these are bindings that come with the notmuch C library
+# The easiest way is probably to install them for your OS vendor and then
+# symlink them into the virtualenv similar to this:
+yum install -y python-notmuch
+# Note: on a multiarch system like Fedora, the directories may be lib64 rather
+# than lib on 64 bit systems
+cd lib/python2.7/site-packages
+ln -s /usr/lib/python2.7/site-packages/notmuch .
+# Note: this is the version of notmuch I tested with; others may work
+ln -s /usr/lib/python2.7/site-packages/notmuch-0.11-py2.7.egg-info .
+
+# put the sources there:
+git clone http://ambre.pingoured.fr/cgit/hyperkitty.git/
+# Start it
+cd hyperkitty
+## Put the static content where it should be
+python manage.py collectstatic
+## Run the server
+python manage.py runserver
+
diff --git a/__init__.py b/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/__init__.py
diff --git a/import-mbox.py b/import-mbox.py
new file mode 100755
index 0000000..7bdf9fa
--- /dev/null
+++ b/import-mbox.py
@@ -0,0 +1,64 @@
+#!/usr/bin/python -tt
+
+# Import a maildir into notmuch.
+# Eventually we may also convert from mbox
+# That will give us a full conversion from mailman2 to mailman3
+#
+# For now use mb2md -s devel.mbox -d /var/tmp/notmuch
+# (The destination dir must be an absolute path)
+
+from base64 import b32encode
+import glob
+import hashlib
+import mailbox
+import os
+import sys
+
+import notmuch
+
+
+def stable_url_id(msg):
+ # Should this be a method or attribute on mailman.email.message instead?
+ message_id = msg.get('message-id')
+ # It is not the archiver's job to ensure the message has a Message-ID.
+ # If this header is missing, there is no permalink.
+ if message_id is None:
+ return None
+ # The angle brackets are not part of the Message-ID. See RFC 2822.
+ if message_id.startswith('<') and message_id.endswith('>'):
+ message_id = message_id[1:-1]
+ else:
+ message_id = message_id.strip()
+ digest = hashlib.sha1(message_id).digest()
+ message_id_hash = b32encode(digest)
+ del msg['x-message-id-hash']
+ msg['X-Message-ID-Hash'] = message_id_hash
+ return message_id_hash
+
+
+maildir = os.path.abspath(sys.argv[1])
+actual_db_dir = os.path.join(maildir, '.notmuch')
+m_box_dir = os.path.join(maildir, 'messages')
+m_box = mailbox.Maildir(m_box_dir, factory=None)
+
+try:
+ if os.access(actual_db_dir, os.W_OK|os.X_OK) and os.path.isdir(actual_db_dir):
+ db = notmuch.Database(maildir,
+ mode=notmuch.Database.MODE.READ_WRITE)
+ else:
+ db = notmuch.Database(maildir, create=True)
+ for message in glob.glob(os.path.abspath(
+ os.path.join(maildir, 'messages', 'cur', '*'))):
+ m_box_msg_key = os.path.basename(message).split(':', 1)[:-1][0]
+ email_file = m_box.get_message(m_box_msg_key)
+ msg, status = db.add_message(message)
+ if status == notmuch.STATUS.SUCCESS:
+ # Add the stable url hash as a tag. This is one of the limitations of
+ # notmuch. In our own database we'd add this as a unique field so it
+ # could be used as a secondary id
+ msgid = stable_url_id(email_file)
+ msg.add_tag('=msgid=%s' % msgid)
+finally:
+ # No db close for notmuch
+ del db
+
diff --git a/lib/__init__.py b/lib/__init__.py
new file mode 100644
index 0000000..aa5a3d9
--- /dev/null
+++ b/lib/__init__.py
@@ -0,0 +1,14 @@
+#-*- coding: utf-8 -*-
+
+from hashlib import md5
+import urllib
+
+
+def gravatar_url(email):
+ '''Return a gravatar url for an email address'''
+ size = 64
+ default = "http://fedoraproject.org/static/images/" + \
+ "fedora_infinity_%ix%i.png" % (size, size)
+ query_string = urllib.urlencode({'s': size, 'd': default})
+ identifier = md5(email).hexdigest()
+ return 'http://www.gravatar.com/avatar/%s?%s' % (identifier, query_string)
diff --git a/lib/mockup.py b/lib/mockup.py
new file mode 100644
index 0000000..6dfe298
--- /dev/null
+++ b/lib/mockup.py
@@ -0,0 +1,167 @@
+#-*- coding: utf-8 -*-
+
+
+class Email(object):
+ """ Email class containing the information needed to store and
+ display email threads.
+ """
+
+ def __init__(self):
+ """ Constructor.
+ Instanciate the default attributes of the object.
+ """
+ self.email_id = ''
+ self.title = ''
+ self.body = ''
+ self.tags = []
+ self.category = 'question'
+ self.category_tag = None
+ self.participants = set(['Pierre-Yves Chibon'])
+ self.answers = []
+ self.liked = 0
+ self.author = ''
+ self.avatar = None
+
+class Author(object):
+ """ Author class containing the information needed to get the top
+ author of the month!
+ """
+
+ def __init__(self):
+ """ Constructor.
+ Instanciate the default attributes of the object.
+ """
+ self.name = None
+ self.kudos = 0
+ self.avatar = None
+
+
+def get_email_tag(tag):
+ threads = generate_random_thread()
+ output = []
+ for email in threads:
+ if tag in email.tags or tag in email.category:
+ output.append(email)
+ elif email.category_tag and tag in email.category_tag:
+ output.append(email)
+ return output
+
+
+def generate_thread_per_category():
+ threads = generate_random_thread()
+ categories = {}
+ for thread in threads:
+ category = thread.category
+ if thread.category_tag:
+ category = thread.category_tag
+ if category in categories.keys():
+ categories[category].append(thread)
+ else:
+ categories[category] = [thread]
+ return categories
+
+def generate_top_author():
+ authors = []
+
+ author = Author()
+ author.name = 'Pierre-Yves Chibon'
+ author.avatar = 'https://secure.gravatar.com/avatar/072b4416fbfad867a44bc7a5be5eddb9'
+ author.kudos = 3
+ authors.append(author)
+
+ author = Author()
+ author.name = 'Stanislav Ochotnický'
+ author.avatar = 'http://sochotni.fedorapeople.org/sochotni.jpg'
+ author.kudos = 4
+ authors.append(author)
+
+ author = Author()
+ author.name = 'Toshio Kuratomi'
+ author.avatar = 'https://secure.gravatar.com/avatar/7a9c1d88f484c9806bceca0d6d91e948'
+ author.kudos = 5
+ authors.append(author)
+
+ return authors
+
+def generate_random_thread():
+ threads = []
+
+ ## 1
+ email = Email()
+ email.email_id = 1
+ email.title = 'Headsup! krb5 ccache defaults are changing in Rawhide'
+ email.age = '6 days'
+ email.body = '''Dear fellow developers,
+with the upcoming Fedora 18 release (currently Rawhide) we are going to change the place where krb5 credential cache files are saved by default.
+
+The new default for credential caches will be the /run/user/username directory.
+'''
+ email.tags.extend(['rawhide', 'krb5'])
+ email.participants = set(['Stephen Gallagher', 'Toshio Kuratomi', 'Kevin Fenzi', 'Seth Vidal'])
+ email.answers.extend([1,2,3,4,5,6,7,8,9,10,11,12])
+ email.liked = 1
+ email.author = 'Stephen Gallagher'
+ email.avatar = 'http://fedorapeople.org/~sgallagh/karrde712.png'
+ threads.append(email)
+
+ ## 2
+ email = Email()
+ email.email_id = 2
+ email.title = 'Problem in packaging kicad'
+ email.age = '6 days'
+ email.body = '''Paragraph 1: Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. '''
+ email.tags.extend(['packaging', 'kicad'])
+ email.participants = set(['Pierre-Yves Chibon', 'Tom "spot" Callaway', 'Toshio Kuratomi', 'Kevin Fenzi'])
+ email.answers.extend([1,2,3,4,5,6,7,8,9,10,11,12])
+ email.liked = 0
+ email.author = 'Pierre-Yves Chibon'
+ email.avatar = 'https://secure.gravatar.com/avatar/072b4416fbfad867a44bc7a5be5eddb9'
+ threads.append(email)
+
+ ## 3
+ email = Email()
+ email.email_id = 3
+ email.title = 'Update Java Guideline'
+ email.age = '6 days'
+ email.body = '''Paragraph 1: Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. '''
+ email.tags.extend(['rawhide', 'krb5'])
+ email.participants = set(['Stanislav Ochotnický', 'Tom "spot" Callaway', 'Stephen Gallagher', 'Jason Tibbitts', 'Rex Dieter', 'Toshio Kuratomi'])
+ email.answers.extend([1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19])
+ email.liked = 5
+ email.category = 'todo'
+ email.author = 'Stanislav Ochotnický'
+ email.avatar = 'http://sochotni.fedorapeople.org/sochotni.jpg'
+ threads.append(email)
+
+ ## 4
+ email = Email()
+ email.email_id = 4
+ email.title = 'Agenda for the next Board Meeting'
+ email.age = '6 days'
+ email.body = '''Paragraph 1: Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. '''
+ email.tags.extend(['agenda', 'board'])
+ email.participants = set(['Toshio Kuratomi', 'Tom "spot" Callaway', 'Robyn Bergeron', 'Max Spevack'])
+ email.answers.extend([1,2,3,4,5,6,7,8,9,10,11,12])
+ email.liked = 20
+ email.category = 'agenda'
+ email.author = 'Toshio Kuratomi'
+ email.avatar = 'https://secure.gravatar.com/avatar/7a9c1d88f484c9806bceca0d6d91e948'
+ threads.append(email)
+
+ ## 5
+ email = Email()
+ email.email_id = 5
+ email.title = 'I told you so! '
+ email.age = '6 days'
+ email.body = '''Paragraph 1: Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. '''
+ email.tags.extend(['systemd', 'mp3', 'pulseaudio'])
+ email.participants = set(['Pierre-Yves Chibon'])
+ email.answers.extend([1,2,3,4,5,6,7,8,9,10,11,12])
+ email.liked = 0
+ email.author = 'Pierre-Yves Chibon'
+ email.avatar = 'https://secure.gravatar.com/avatar/072b4416fbfad867a44bc7a5be5eddb9'
+ email.category = 'shut down'
+ email.category_tag = 'dead'
+ threads.append(email)
+
+ return threads
diff --git a/lib/notmuch.py b/lib/notmuch.py
new file mode 100644
index 0000000..848adea
--- /dev/null
+++ b/lib/notmuch.py
@@ -0,0 +1,105 @@
+#-*- coding: utf-8 -*-
+
+from __future__ import absolute_import
+
+from calendar import timegm
+from datetime import datetime, timedelta
+import json
+import os
+
+import bunch
+import notmuch
+
+from mm_app.lib import gravatar_url
+
+# Used to remove tags that notmuch added automatically that we don't want
+IGNORED_TAGS = (u'inbox', u'unread', u'signed')
+
+def get_ro_db(path):
+ # Instead of throwing an exception, notmuch bindings tend to segfault if
+ # the database path doesn't exist.
+
+ # Does the notmuch db exist?
+ actual_db_dir = os.path.join(path, '.notmuch')
+ if os.access(actual_db_dir, os.W_OK|os.X_OK) and os.path.isdir(actual_db_dir):
+ return notmuch.Database(path,
+ mode=notmuch.Database.MODE.READ_ONLY)
+
+ raise IOError('Notmuch database not present in %(path)s' %
+ {'path': path})
+
+def get_thread_info(thread):
+ thread_info = bunch.Bunch()
+
+ #
+ # Get information about the first email of a thread
+ #
+
+ first_email = tuple(thread.get_toplevel_messages())[0]
+ for tag in (tg for tg in first_email.get_tags() if tg.startswith('=msgid=')):
+ thread_info.email_id = tag.split('=msgid=', 1)[-1]
+ break
+ first_email_data = json.loads(first_email.format_message_as_json())
+
+ # Python-3.3 has ''.rsplit(maxsplit=1) (keyword arg). Until then, we need
+ # rsplit(None, 1) to get the desired behaviour
+ author = first_email_data['headers']['From'].rsplit(None, 1)
+ if author[-1].startswith('<'):
+ author[-1] = author[-1][1:]
+ if author[-1].endswith('>'):
+ author[-1] = author[-1][:-1]
+ # This accounts for From lines without a real name, just email address
+ name = author[0]
+ email = author[-1]
+ thread_info.author = name
+ thread_info.avatar = gravatar_url(email)
+
+ for body_part in first_email_data['body']:
+ try:
+ # The body may have many parts. We only want the part that we
+ # can guess is the actual text of an email message.
+ # For this prototype, that is defined as
+ # has a content-type and content keys. and the content-type
+ # is text/plain. When this is not a prototype, the heuristic
+ # should be more advanced
+ if body_part['content-type'] == u'text/plain':
+ thread_info.body = body_part['content']
+ break
+ except KeyError:
+ continue
+
+ #
+ # Get meta info about the thread itself
+ #
+
+ # Used for sorting threads
+ thread_info.most_recent = thread.get_newest_date()
+ date_as_offset = timegm(datetime.utcnow().timetuple()) - thread_info.most_recent
+ thread_info.age = str(timedelta(seconds=date_as_offset))
+ thread_info.title = thread.get_subject()
+ # Because notmuch doesn't allow us to extend the schema, everything is
+ # in a tag. Extract those tags that have special meaning to us
+ thread_info.tags = []
+ thread_info.answers = []
+ thread_info.liked = 0
+ for tag in thread.get_tags():
+ if tag.startswith('=msgid='):
+ msgid = tag.split('=msgid=', 1)[-1]
+ # The first email doesn't count as a reply :-)
+ if msgid != thread_info.email_id:
+ thread_info.answers.append(tag.split('=msgid=', 1)[-1])
+ elif tag.startswith('=threadlike='):
+ thread_info.liked = int(tag.split('=threadlike=', 1)[-1])
+ elif tag.startswith('=topic='):
+ thread_info.category = tag.split('=topic=', 1)[-1]
+ print thread_info.category
+ elif tag in IGNORED_TAGS:
+ continue
+ else:
+ thread_info.tags.append(tag)
+ # notmuch has this nice method call to give us the info but it returns
+ # it as a string instead of a list
+ thread_info.participants = set(thread.get_authors().replace(u'| ', u', ', 1).split(u', '))
+
+
+ return thread_info
diff --git a/manage.py b/manage.py
new file mode 100644
index 0000000..3e4eedc
--- /dev/null
+++ b/manage.py
@@ -0,0 +1,14 @@
+#!/usr/bin/env python
+from django.core.management import execute_manager
+import imp
+try:
+ imp.find_module('settings') # Assumed to be in the same directory.
+except ImportError:
+ import sys
+ 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" % __file__)
+ sys.exit(1)
+
+import settings
+
+if __name__ == "__main__":
+ execute_manager(settings)
diff --git a/settings.py b/settings.py
new file mode 100644
index 0000000..15c8ae4
--- /dev/null
+++ b/settings.py
@@ -0,0 +1,157 @@
+import os
+
+BASE_DIR = os.path.dirname(os.path.abspath(__file__))
+# Django settings for mm_app project.
+
+DEBUG = True
+TEMPLATE_DEBUG = DEBUG
+
+ADMINS = (
+ # ('Your Name', 'your_email@example.com'),
+)
+
+MANAGERS = ADMINS
+
+MAILMAN_API_URL=r'http://%(username)s:%(password)s@localhost:8001/3.0/'
+MAILMAN_USER='mailmanapi'
+MAILMAN_PASS='88ffd62d1094a6248415c59d7538793f3df5de2f04d244087952394e689e902a'
+
+DATABASES = {
+ 'default': {
+ 'ENGINE': 'django.db.backends.sqlite3', # Add 'postgresql_psycopg2', 'postgresql', 'mysql', 'sqlite3' or 'oracle'.
+ 'NAME': 'mm_app.dev.db', # Or path to database file if using sqlite3.
+ 'USER': '', # Not used with sqlite3.
+ 'PASSWORD': '', # Not used with sqlite3.
+ 'HOST': '', # Set to empty string for localhost. Not used with sqlite3.
+ 'PORT': '', # Set to empty string for default. Not used with sqlite3.
+ }
+}
+
+# Local time zone for this installation. Choices can be found here:
+# http://en.wikipedia.org/wiki/List_of_tz_zones_by_name
+# although not all choices may be available on all operating systems.
+# On Unix systems, a value of None will cause Django to use the same
+# timezone as the operating system.
+# If running in a Windows environment this must be set to the same as your
+# system time zone.
+TIME_ZONE = 'America/Chicago'
+
+# Language code for this installation. All choices can be found here:
+# http://www.i18nguy.com/unicode/language-identifiers.html
+LANGUAGE_CODE = 'en-us'
+
+SITE_ID = 1
+
+# If you set this to False, Django will make some optimizations so as not
+# to load the internationalization machinery.
+USE_I18N = True
+
+# If you set this to False, Django will not format dates, numbers and
+# calendars according to the current locale
+USE_L10N = True
+
+# Absolute filesystem path to the directory that will hold user-uploaded files.
+# Example: "/home/media/media.lawrence.com/media/"
+MEDIA_ROOT = ''
+
+# URL that handles the media served from MEDIA_ROOT. Make sure to use a
+# trailing slash.
+# Examples: "http://media.lawrence.com/media/", "http://example.com/media/"
+MEDIA_URL = ''
+
+# Absolute path to the directory static files should be collected to.
+# Don't put anything in this directory yourself; store your static files
+# in apps' "static/" subdirectories and in STATICFILES_DIRS.
+# Example: "/home/media/media.lawrence.com/static/"
+#STATIC_ROOT = ''
+STATIC_ROOT = BASE_DIR + '/static_files/'
+
+# URL prefix for static files.
+# Example: "http://media.lawrence.com/static/"
+STATIC_URL = '/static/'
+
+# URL prefix for admin static files -- CSS, JavaScript and images.
+# Make sure to use a trailing slash.
+# Examples: "http://foo.com/static/admin/", "/static/admin/".
+ADMIN_MEDIA_PREFIX = '/static/admin/'
+
+# Additional locations of static files
+STATICFILES_DIRS = (
+ # Put strings here, like "/home/html/static" or "C:/www/django/static".
+ # Always use forward slashes, even on Windows.
+ # Don't forget to use absolute paths, not relative paths.
+ BASE_DIR + '/static/',
+)
+
+# List of finder classes that know how to find static files in
+# various locations.
+STATICFILES_FINDERS = (
+ 'django.contrib.staticfiles.finders.FileSystemFinder',
+ 'django.contrib.staticfiles.finders.AppDirectoriesFinder',
+# 'django.contrib.staticfiles.finders.DefaultStorageFinder',
+)
+
+# Make this unique, and don't share it with anybody.
+SECRET_KEY = 'dtc3%x(k#mzpe32dmhtsb6!3p(izk84f7nuw1-+4x8zsxwsa^z'
+
+# List of callables that know how to import templates from various sources.
+TEMPLATE_LOADERS = (
+ 'django.template.loaders.filesystem.Loader',
+ 'django.template.loaders.app_directories.Loader',
+# 'django.template.loaders.eggs.Loader',
+)
+
+MIDDLEWARE_CLASSES = (
+ 'django.middleware.common.CommonMiddleware',
+ 'django.contrib.sessions.middleware.SessionMiddleware',
+ 'django.middleware.csrf.CsrfViewMiddleware',
+ 'django.contrib.auth.middleware.AuthenticationMiddleware',
+ 'django.contrib.messages.middleware.MessageMiddleware',
+)
+
+ROOT_URLCONF = 'mm_app.urls'
+
+TEMPLATE_DIRS = (
+ # Put strings here, like "/home/html/django_templates" or "C:/www/django/templates".
+ # Always use forward slashes, even on Windows.
+ # Don't forget to use absolute paths, not relative paths.
+ BASE_DIR + '/templates',
+)
+
+INSTALLED_APPS = (
+# 'django.contrib.auth',
+ 'django.contrib.contenttypes',
+# 'django.contrib.sessions',
+# 'django.contrib.sites',
+ 'django.contrib.messages',
+ 'django.contrib.staticfiles',
+ # Uncomment the next line to enable the admin:
+ # 'django.contrib.admin',
+ # Uncomment the next line to enable admin documentation:
+ # 'django.contrib.admindocs',
+)
+
+# A sample logging configuration. The only tangible logging
+# performed by this configuration is to send an email to
+# the site admins on every HTTP 500 error.
+# See http://docs.djangoproject.com/en/dev/topics/logging for
+# more details on how to customize your logging configuration.
+LOGGING = {
+ 'version': 1,
+ 'disable_existing_loggers': False,
+ 'handlers': {
+ 'mail_admins': {
+ 'level': 'ERROR',
+ 'class': 'django.utils.log.AdminEmailHandler'
+ }
+ },
+ 'loggers': {
+ 'django.request': {
+ 'handlers': ['mail_admins'],
+ 'level': 'ERROR',
+ 'propagate': True,
+ },
+ }
+}
+
+APP_NAME = "Fedora Mailman App"
diff --git a/static/css/stats.css b/static/css/stats.css
new file mode 100644
index 0000000..cd39079
--- /dev/null
+++ b/static/css/stats.css
@@ -0,0 +1,124 @@
+h2 {
+ margin-top: 0px;
+ padding-top: 10px;
+}
+/* Add icons to some text */
+.neutral {
+ background: url("../img/neutral.png") no-repeat scroll left;
+ padding-left: 20px;
+ padding-right: 20px;
+ font-weight: bold;
+}
+
+.like {
+ background: url("../img/like.png") no-repeat scroll left;
+ padding-left: 20px;
+ padding-right: 20px;
+ font-weight: bold;
+}
+
+.likealot {
+ background: url("../img/likealot.png") no-repeat scroll left;
+ padding-left: 20px;
+ padding-right: 20px;
+ font-weight: bold;
+}
+
+/* The content section of the page */
+.content {
+ width: 90%;
+ margin: auto;
+}
+
+#top_discussion {
+ width: 45%;
+ margin-right: 22px;
+}
+
+#discussion_by_topic {
+ width: 45%;
+ margin-top: 20px;
+ margin-right: 22px;
+}
+
+#most_active {
+ float: right;
+ width: 45%;
+}
+
+#discussion_marker {
+ float: right;
+ width: 45%;
+ margin-top: 20px;
+}
+
+.thread {
+ white-space: nowrap;
+}
+
+.thread * {
+ white-space: normal;
+}
+
+.thread_id {
+ font-weight: bold;
+ font-size: 125%;
+ color: rgb(102, 102, 102);
+ vertical-align: top;
+ padding-right: 10px;
+}
+
+.thread_title{
+ padding-right:20px;
+ color: rgb(102, 102, 102);
+ display: inline-block;
+}
+
+.thread_stats ul li {
+ margin-right:20px;
+}
+
+.category {
+ font-variant: small-caps;
+ font-weight: bold;
+ color: white;
+ -webkit-border-radius: 5px 5px 5px 5px;
+ -moz-border-radius: 5px 5px 5px 5px;
+ border-radius: 5px 5px 5px 5px;
+ vertical-align: top;
+ margin-bottom: 10px;
+ padding-top: 0px;
+ padding-left: 10px;
+}
+
+.category_entry {
+ list-style-type: circle;
+ margin-top: 0px;
+ padding-bottom: 10px;
+ padding-left: 25px;
+}
+
+.category_entry li {
+ padding-bottom: 10px;
+}
+
+.maker {
+ color: rgb(102, 102, 102);
+ padding-right: 10px;
+ padding-bottom: 20px;
+}
+
+.maker_id, .marker_name{
+ font-weight: bold;
+ font-size: 125%;
+ vertical-align: top;
+ padding-right: 20px;
+}
+
+.gravatar {
+ padding-right: 20px;
+}
+
+.score{
+ font-weight: bold;
+}
diff --git a/static/css/style.css b/static/css/style.css
new file mode 100644
index 0000000..00e46d8
--- /dev/null
+++ b/static/css/style.css
@@ -0,0 +1,324 @@
+
+@import url(http://fonts.googleapis.com/css?family=Droid+Sans);
+
+/* Generic classes */
+body {
+ margin: 0;
+ padding: 0;
+ font-family: 'Droid Sans', sans-serif;
+ color: rgb(77, 77, 77);
+}
+
+a {
+ color: rgb(55, 113, 200);
+ text-decoration: none;
+}
+
+.right {
+ text-align: right;
+}
+
+.inline-block {
+ display: inline-block;
+}
+
+.inline li, .inline-block li {
+ display: inline-block;
+ list-style-type: none;
+}
+
+/* Add icons to some text */
+.participant {
+ background: url("../img/participant.png") no-repeat scroll left top;
+ padding-left: 20px;
+}
+
+.discussion {
+ background: url("../img/discussion.png") no-repeat scroll left top;
+ padding-left: 20px;
+}
+
+.saved {
+ background: url("../img/saved.png") no-repeat scroll left top;
+ padding-left: 20px;
+}
+
+.notsaved {
+ background: url("../img/notsaved.png") no-repeat scroll left top;
+ padding-left: 20px;
+}
+
+.gravatar {
+ vertical-align: top;
+ width:40px;
+ font-size: 70%;
+}
+
+.gravatar img {
+ width: 40px;
+}
+
+.neutral {
+ background: url("../img/neutral.png") no-repeat scroll left;
+ padding-left: 20px;
+ padding-right: 20px;
+ font-weight: bold;
+}
+
+.like {
+ background: url("../img/like.png") no-repeat scroll left;
+ padding-left: 20px;
+ padding-right: 20px;
+ font-weight: bold;
+}
+
+.likealot {
+ background: url("../img/likealot.png") no-repeat scroll left;
+ padding-left: 20px;
+ padding-right: 20px;
+ font-weight: bold;
+}
+
+.youlike {
+ background: url("../img/youlike.png") no-repeat scroll left;
+ padding-left: 15px;
+ padding-right: 5px;
+}
+
+.youdislike {
+ background: url("../img/youdislike.png") no-repeat scroll left;
+ padding-left: 15px;
+ padding-right: 5px;
+}
+
+.showdiscussion {
+ background-image: linear-gradient(bottom, rgb(204,204,204) 11%, rgb(255,255,255) 100%);
+ background-image: -o-linear-gradient(bottom, rgb(204,204,204) 11%, rgb(255,255,255) 100%);
+ background-image: -moz-linear-gradient(bottom, rgb(204,204,204) 11%, rgb(255,255,255) 100%);
+ background-image: -webkit-linear-gradient(bottom, rgb(204,204,204) 11%, rgb(255,255,255) 100%);
+ background-image: -ms-linear-gradient(bottom, rgb(204,204,204) 11%, rgb(255,255,255) 100%);
+ background-image: -webkit-gradient(
+ linear,
+ left bottom,
+ left top,
+ color-stop(0.11, rgb(204,204,204)),
+ color-stop(1, rgb(255,255,255))
+ );
+ padding: 3px 7px 3px 7px;
+ -webkit-border-radius: 5px 5px 5px 5px;
+ -moz-border-radius: 5px 5px 5px 5px;
+ border-radius: 5px 5px 5px 5px;
+ border-style: solid;
+ border-width: 1px;
+ border-color: rgb(179, 179, 179);
+}
+
+.showdiscussion a {
+ color: rgb(77, 77, 77);
+}
+
+
+/* Top of the page -- header */
+.header {
+ background-color: rgb(236, 236, 236);
+ padding: 0;
+}
+
+.header ul {
+ padding: 0;
+ margin: 0;
+}
+
+.header hr{
+ color: rgb(204, 204, 204);
+ background-color: rgb(204, 204, 204);
+ border: 0 none;
+ margin-top: 0px;
+}
+
+#white {
+ color: rgb(255, 255, 255);
+ background-color: rgb(255, 255, 255);
+ margin-bottom: 0px;
+}
+
+#headline {
+ padding-left: 20px;
+ position: relative;
+}
+
+#top_right {
+ position: absolute;
+ right: 20px;
+ bottom: 0;
+ color: rgb(102, 102, 102);
+}
+
+#top_right li {
+ margin-left:10px;
+}
+
+#list_name {
+ font-size: 200%;
+ font-weight: bold;
+}
+
+#list_name a {
+ color: rgb(77, 77, 77);
+}
+
+#page_date {
+ font-size: 150%;
+}
+
+#list_email {
+ font-size:80%;
+ padding: 0 0 0 20px;
+ margin: 0;
+}
+
+#searchbox {
+ text-align:right;
+ padding-right: 20px;
+}
+
+#searchbox input {
+ width: 250px
+}
+
+#searchbox input::-webkit-input-placeholder {
+ font-style: italic;
+}
+
+#searchbox input:-moz-placeholder {
+ font-style: italic;
+}
+
+#newthread {
+ padding-left: 20px;
+}
+
+/* The content section of the page */
+.content {
+ width: 1024px;
+ margin: auto;
+}
+
+/* Thread list */
+
+.thread_title {
+ font-weight: bold;
+ font-size: 125%;
+}
+
+.thread_date {
+ font-style: italic;
+ font-size: 70%;
+ color: rgb(128, 0, 0);
+}
+
+.thread_content {
+ margin-top:10px;
+}
+
+.thread_email {
+}
+
+.thread_info {
+ text-align:right;
+ padding-right: 50px;
+}
+
+.tags {
+ text-align:left;
+ margin: 0px 0px 0px 200px;
+ padding: 0px;
+}
+
+/* Part containing the body of the mail which can be shown/hidden */
+.expander {
+ width: 768px;
+ background-image: linear-gradient(bottom, rgb(236,236,236) 11%, rgb(255,255,255) 100%);
+ background-image: -o-linear-gradient(bottom, rgb(236,236,236) 11%, rgb(255,255,255) 100%);
+ background-image: -moz-linear-gradient(bottom, rgb(236,236,236) 11%, rgb(255,255,255) 100%);
+ background-image: -webkit-linear-gradient(bottom, rgb(236,236,236) 11%, rgb(255,255,255) 100%);
+ background-image: -ms-linear-gradient(bottom, rgb(236,236,236) 11%, rgb(255,255,255) 100%);
+
+ background-image: -webkit-gradient(
+ linear,
+ left bottom,
+ left top,
+ color-stop(0.11, rgb(236,236,236)),
+ color-stop(1, rgb(255,255,255))
+ );
+ border-style: solid;
+ border-width: 1px;
+ border-color: rgb(236,236,236);
+ -webkit-border-radius: 5px 5px 5px 5px;
+ -moz-border-radius: 5px 5px 5px 5px;
+ border-radius: 5px 5px 5px 5px;
+ padding-left: 20px;
+ margin-left: 21px;
+ display: inline-block;
+ vertical-align: top;
+ white-space: pre;
+}
+
+.expander a {
+ float: right;
+ padding: 20px 10px 0px 0px;
+}
+
+/* Thread types */
+.type {
+ font-variant: small-caps;
+ font-weight: bold;
+ color: white;
+ padding: 3px;
+ -webkit-border-radius: 5px 5px 5px 5px;
+ -moz-border-radius: 5px 5px 5px 5px;
+ border-radius: 5px 5px 5px 5px;
+ vertical-align: top;
+ width: 110px;
+ text-align:center;
+}
+
+.type a {
+ color: white;
+}
+
+.type_question {
+ background-color: rgb(179, 128, 255);
+}
+
+.type_agenda {
+ background-color: rgb(42, 127, 255);
+}
+
+.type_todo {
+ background-color: rgb(200, 171, 55);
+}
+
+.type_dead {
+ background-color: rgb(0, 0, 0);
+}
+
+.type_announcement {
+ background-color: rgb(170, 212, 0);
+}
+
+.type_policy {
+ background-color: rgb(200, 55, 171);
+}
+
+.type_test {
+ background-color: rgb(200, 171, 55);
+}
+
+.invisible {
+ visibility: hidden;
+}
+
+.removed {
+ display: none;
+}[
diff --git a/static/img/discussion.png b/static/img/discussion.png
new file mode 100644
index 0000000..26e60d9
--- /dev/null
+++ b/static/img/discussion.png
Binary files differ
diff --git a/static/img/email_bg.png b/static/img/email_bg.png
new file mode 100644
index 0000000..f3ae7b7
--- /dev/null
+++ b/static/img/email_bg.png
Binary files differ
diff --git a/static/img/like.png b/static/img/like.png
new file mode 100644
index 0000000..7406cdd
--- /dev/null
+++ b/static/img/like.png
Binary files differ
diff --git a/static/img/likealot.png b/static/img/likealot.png
new file mode 100644
index 0000000..5ce4b88
--- /dev/null
+++ b/static/img/likealot.png
Binary files differ
diff --git a/static/img/neutral.png b/static/img/neutral.png
new file mode 100644
index 0000000..392f8c7
--- /dev/null
+++ b/static/img/neutral.png
Binary files differ
diff --git a/static/img/newthread.png b/static/img/newthread.png
new file mode 100644
index 0000000..e61b871
--- /dev/null
+++ b/static/img/newthread.png
Binary files differ
diff --git a/static/img/notsaved.png b/static/img/notsaved.png
new file mode 100644
index 0000000..a427a91
--- /dev/null
+++ b/static/img/notsaved.png
Binary files differ
diff --git a/static/img/participant.png b/static/img/participant.png
new file mode 100644
index 0000000..f2d700b
--- /dev/null
+++ b/static/img/participant.png
Binary files differ
diff --git a/static/img/saved.png b/static/img/saved.png
new file mode 100644
index 0000000..b240cd5
--- /dev/null
+++ b/static/img/saved.png
Binary files differ
diff --git a/static/img/show_discussion.png b/static/img/show_discussion.png
new file mode 100644
index 0000000..f7f42f1
--- /dev/null
+++ b/static/img/show_discussion.png
Binary files differ
diff --git a/static/img/youdislike.png b/static/img/youdislike.png
new file mode 100644
index 0000000..0c6387b
--- /dev/null
+++ b/static/img/youdislike.png
Binary files differ
diff --git a/static/img/youlike.png b/static/img/youlike.png
new file mode 100644
index 0000000..affe451
--- /dev/null
+++ b/static/img/youlike.png
Binary files differ
diff --git a/static/jquery.expander.js b/static/jquery.expander.js
new file mode 100644
index 0000000..9eabab4
--- /dev/null
+++ b/static/jquery.expander.js
@@ -0,0 +1,382 @@
+/*!
+ * jQuery Expander Plugin v1.4
+ *
+ * Date: Sun Dec 11 15:08:42 2011 EST
+ * Requires: jQuery v1.3+
+ *
+ * Copyright 2011, Karl Swedberg
+ * Dual licensed under the MIT and GPL licenses (just like jQuery):
+ * http://www.opensource.org/licenses/mit-license.php
+ * http://www.gnu.org/licenses/gpl.html
+ *
+ *
+ *
+ *
+*/
+
+(function($) {
+ $.expander = {
+ version: '1.4',
+ defaults: {
+ // the number of characters at which the contents will be sliced into two parts.
+ slicePoint: 100,
+
+ // whether to keep the last word of the summary whole (true) or let it slice in the middle of a word (false)
+ preserveWords: true,
+
+ // a threshold of sorts for whether to initially hide/collapse part of the element's contents.
+ // If after slicing the contents in two there are fewer words in the second part than
+ // the value set by widow, we won't bother hiding/collapsing anything.
+ widow: 4,
+
+ // text displayed in a link instead of the hidden part of the element.
+ // clicking this will expand/show the hidden/collapsed text
+ expandText: 'read more',
+ expandPrefix: '&hellip; ',
+
+ expandAfterSummary: false,
+
+ // class names for summary element and detail element
+ summaryClass: 'summary',
+ detailClass: 'details',
+
+ // class names for <span> around "read-more" link and "read-less" link
+ moreClass: 'read-more',
+ lessClass: 'read-less',
+
+ // number of milliseconds after text has been expanded at which to collapse the text again.
+ // when 0, no auto-collapsing
+ collapseTimer: 0,
+
+ // effects for expanding and collapsing
+ expandEffect: 'fadeIn',
+ expandSpeed: 250,
+ collapseEffect: 'fadeOut',
+ collapseSpeed: 200,
+
+ // allow the user to re-collapse the expanded text.
+ userCollapse: true,
+
+ // text to use for the link to re-collapse the text
+ userCollapseText: 'read less',
+ userCollapsePrefix: ' ',
+
+
+ // all callback functions have the this keyword mapped to the element in the jQuery set when .expander() is called
+
+ onSlice: null, // function() {}
+ beforeExpand: null, // function() {},
+ afterExpand: null, // function() {},
+ onCollapse: null // function(byUser) {}
+ }
+ };
+
+ $.fn.expander = function(options) {
+ var meth = 'init';
+
+ if (typeof options == 'string') {
+ meth = options;
+ options = {};
+ }
+
+ var opts = $.extend({}, $.expander.defaults, options),
+ rSelfClose = /^<(?:area|br|col|embed|hr|img|input|link|meta|param).*>$/i,
+ rAmpWordEnd = /(&(?:[^;]+;)?|\w+)$/,
+ rOpenCloseTag = /<\/?(\w+)[^>]*>/g,
+ rOpenTag = /<(\w+)[^>]*>/g,
+ rCloseTag = /<\/(\w+)>/g,
+ rLastCloseTag = /(<\/[^>]+>)\s*$/,
+ rTagPlus = /^<[^>]+>.?/,
+ delayedCollapse;
+
+ var methods = {
+ init: function() {
+ this.each(function() {
+ var i, l, tmp, summTagLess, summOpens, summCloses, lastCloseTag, detailText,
+ $thisDetails, $readMore,
+ openTagsForDetails = [],
+ closeTagsForsummaryText = [],
+ defined = {},
+ thisEl = this,
+ $this = $(this),
+ $summEl = $([]),
+ o = $.meta ? $.extend({}, opts, $this.data()) : opts,
+ hasDetails = !!$this.find('.' + o.detailClass).length,
+ hasBlocks = !!$this.find('*').filter(function() {
+ var display = $(this).css('display');
+ return (/^block|table|list/).test(display);
+ }).length,
+ el = hasBlocks ? 'div' : 'span',
+ detailSelector = el + '.' + o.detailClass,
+ moreSelector = 'span.' + o.moreClass,
+ expandSpeed = o.expandSpeed || 0,
+ allHtml = $.trim( $this.html() ),
+ allText = $.trim( $this.text() ),
+ summaryText = allHtml.slice(0, o.slicePoint);
+
+ // bail out if we've already set up the expander on this element
+ if ( $.data(this, 'expander') ) {
+ return;
+ }
+ $.data(this, 'expander', true);
+
+ // determine which callback functions are defined
+ $.each(['onSlice','beforeExpand', 'afterExpand', 'onCollapse'], function(index, val) {
+ defined[val] = $.isFunction(o[val]);
+ });
+
+ // back up if we're in the middle of a tag or word
+ summaryText = backup(summaryText);
+
+ // summary text sans tags length
+ summTagless = summaryText.replace(rOpenCloseTag, '').length;
+
+ // add more characters to the summary, one for each character in the tags
+ while (summTagless < o.slicePoint) {
+ newChar = allHtml.charAt(summaryText.length);
+ if (newChar == '<') {
+ newChar = allHtml.slice(summaryText.length).match(rTagPlus)[0];
+ }
+ summaryText += newChar;
+ summTagless++;
+ }
+
+ summaryText = backup(summaryText, o.preserveWords);
+
+ // separate open tags from close tags and clean up the lists
+ summOpens = summaryText.match(rOpenTag) || [];
+ summCloses = summaryText.match(rCloseTag) || [];
+
+ // filter out self-closing tags
+ tmp = [];
+ $.each(summOpens, function(index, val) {
+ if ( !rSelfClose.test(val) ) {
+ tmp.push(val);
+ }
+ });
+ summOpens = tmp;
+
+ // strip close tags to just the tag name
+ l = summCloses.length;
+ for (i = 0; i < l; i++) {
+ summCloses[i] = summCloses[i].replace(rCloseTag, '$1');
+ }
+
+ // tags that start in summary and end in detail need:
+ // a). close tag at end of summary
+ // b). open tag at beginning of detail
+ $.each(summOpens, function(index, val) {
+ var thisTagName = val.replace(rOpenTag, '$1');
+ var closePosition = $.inArray(thisTagName, summCloses);
+ if (closePosition === -1) {
+ openTagsForDetails.push(val);
+ closeTagsForsummaryText.push('</' + thisTagName + '>');
+
+ } else {
+ summCloses.splice(closePosition, 1);
+ }
+ });
+
+ // reverse the order of the close tags for the summary so they line up right
+ closeTagsForsummaryText.reverse();
+
+ // create necessary summary and detail elements if they don't already exist
+ if ( !hasDetails ) {
+
+ // end script if there is no detail text or if detail has fewer words than widow option
+ detailText = allHtml.slice(summaryText.length);
+
+ if ( detailText === '' || detailText.split(/\s+/).length < o.widow ) {
+ return;
+ }
+
+ // otherwise, continue...
+ lastCloseTag = closeTagsForsummaryText.pop() || '';
+ summaryText += closeTagsForsummaryText.join('');
+ detailText = openTagsForDetails.join('') + detailText;
+
+ } else {
+ // assume that even if there are details, we still need readMore/readLess/summary elements
+ // (we already bailed out earlier when readMore el was found)
+ // but we need to create els differently
+
+ // remove the detail from the rest of the content
+ detailText = $this.find(detailSelector).remove().html();
+
+ // The summary is what's left
+ summaryText = $this.html();
+
+ // allHtml is the summary and detail combined (this is needed when content has block-level elements)
+ allHtml = summaryText + detailText;
+
+ lastCloseTag = '';
+ }
+ o.moreLabel = $this.find(moreSelector).length ? '' : buildMoreLabel(o);
+
+ if (hasBlocks) {
+ detailText = allHtml;
+ }
+ summaryText += lastCloseTag;
+
+ // onSlice callback
+ o.summary = summaryText;
+ o.details = detailText;
+ o.lastCloseTag = lastCloseTag;
+
+ if (defined.onSlice) {
+ // user can choose to return a modified options object
+ // one last chance for user to change the options. sneaky, huh?
+ // but could be tricky so use at your own risk.
+ tmp = o.onSlice.call(thisEl, o);
+
+ // so, if the returned value from the onSlice function is an object with a details property, we'll use that!
+ o = tmp && tmp.details ? tmp : o;
+ }
+
+ // build the html with summary and detail and use it to replace old contents
+ var html = buildHTML(o, hasBlocks);
+
+ $this.html( html );
+
+ // set up details and summary for expanding/collapsing
+ $thisDetails = $this.find(detailSelector);
+ $readMore = $this.find(moreSelector);
+ $thisDetails.hide();
+ $readMore.find('a').unbind('click.expander').bind('click.expander', expand);
+
+ $summEl = $this.find('div.' + o.summaryClass);
+
+ if ( o.userCollapse && !$this.find('span.' + o.lessClass).length ) {
+ $this
+ .find(detailSelector)
+ .append('<span class="' + o.lessClass + '">' + o.userCollapsePrefix + '<a href="#">' + o.userCollapseText + '</a></span>');
+ }
+
+ $this
+ .find('span.' + o.lessClass + ' a')
+ .unbind('click.expander')
+ .bind('click.expander', function(event) {
+ event.preventDefault();
+ clearTimeout(delayedCollapse);
+ var $detailsCollapsed = $(this).closest(detailSelector);
+ reCollapse(o, $detailsCollapsed);
+ if (defined.onCollapse) {
+ o.onCollapse.call(thisEl, true);
+ }
+ });
+
+ function expand(event) {
+ event.preventDefault();
+ $readMore.hide();
+ $summEl.hide();
+ if (defined.beforeExpand) {
+ o.beforeExpand.call(thisEl);
+ }
+
+ $thisDetails.stop(false, true)[o.expandEffect](expandSpeed, function() {
+ $thisDetails.css({zoom: ''});
+ if (defined.afterExpand) {o.afterExpand.call(thisEl);}
+ delayCollapse(o, $thisDetails, thisEl);
+ });
+ }
+
+ }); // this.each
+ },
+ destroy: function() {
+ if ( !this.data('expander') ) {
+ return;
+ }
+ this.removeData('expander');
+ this.each(function() {
+ var $this = $(this),
+ o = $.meta ? $.extend({}, opts, $this.data()) : opts,
+ details = $this.find('.' + o.detailClass).contents();
+
+ $this.find('.' + o.moreClass).remove();
+ $this.find('.' + o.summaryClass).remove();
+ $this.find('.' + o.detailClass).after(details).remove();
+ $this.find('.' + o.lessClass).remove();
+
+ });
+ }
+ };
+
+ // run the methods (almost always "init")
+ if ( methods[meth] ) {
+ methods[ meth ].call(this);
+ }
+
+ // utility functions
+ function buildHTML(o, blocks) {
+ var el = 'span',
+ summary = o.summary;
+ if ( blocks ) {
+ el = 'div';
+ // if summary ends with a close tag, tuck the moreLabel inside it
+ if ( rLastCloseTag.test(summary) && !o.expandAfterSummary) {
+ summary = summary.replace(rLastCloseTag, o.moreLabel + '$1');
+ } else {
+ // otherwise (e.g. if ends with self-closing tag) just add moreLabel after summary
+ // fixes #19
+ summary += o.moreLabel;
+ }
+
+ // and wrap it in a div
+ summary = '<div class="' + o.summaryClass + '">' + summary + '</div>';
+ } else {
+ summary += o.moreLabel;
+ }
+
+ return [
+ summary,
+ '<',
+ el + ' class="' + o.detailClass + '"',
+ '>',
+ o.details,
+ '</' + el + '>'
+ ].join('');
+ }
+
+ function buildMoreLabel(o) {
+ var ret = '<span class="' + o.moreClass + '">' + o.expandPrefix;
+ ret += '<a href="#">' + o.expandText + '</a></span>';
+ return ret;
+ }
+
+ function backup(txt, preserveWords) {
+ if ( txt.lastIndexOf('<') > txt.lastIndexOf('>') ) {
+ txt = txt.slice( 0, txt.lastIndexOf('<') );
+ }
+ if (preserveWords) {
+ txt = txt.replace(rAmpWordEnd,'');
+ }
+ return txt;
+ }
+
+ function reCollapse(o, el) {
+ el.stop(true, true)[o.collapseEffect](o.collapseSpeed, function() {
+ var prevMore = el.prev('span.' + o.moreClass).show();
+ if (!prevMore.length) {
+ el.parent().children('div.' + o.summaryClass).show()
+ .find('span.' + o.moreClass).show();
+ }
+ });
+ }
+
+ function delayCollapse(option, $collapseEl, thisEl) {
+ if (option.collapseTimer) {
+ delayedCollapse = setTimeout(function() {
+ reCollapse(option, $collapseEl);
+ if ( $.isFunction(option.onCollapse) ) {
+ option.onCollapse.call(thisEl, false);
+ }
+ }, option.collapseTimer);
+ }
+ }
+
+ return this;
+ };
+
+ // plugin defaults
+ $.fn.expander.defaults = $.expander.defaults;
+})(jQuery);
diff --git a/templates/base.html b/templates/base.html
new file mode 100644
index 0000000..9445add
--- /dev/null
+++ b/templates/base.html
@@ -0,0 +1,61 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
+ <meta name="ROBOTS" content="INDEX, FOLLOW" />
+
+ <title>{% block title %}Mail app{% endblock %}</title>
+ <meta name="author" content="" />
+
+ <meta name="dc.language" content="en" />
+
+ <link rel="stylesheet" type="text/css" media="all" href="{{ STATIC_URL }}css/style.css" />
+
+ {% block additional_headers %}
+ {% endblock %}
+</head>
+
+<body>
+ <!-- Header -->
+ <div class="header">
+ {% block header %}
+ <div id="headline">
+ <ul class="inline-block">
+ <li id="list_name"><a href="/">{{list_name}}</a></li>
+ <li id="page_date">{{month}}</li>
+ </ul>
+ {% if month_participants and month_discussions %}
+ <ul class="inline-block" id="top_right">
+ <li class="participant"> {{month_participants}} participants</li>
+ <li class="discussion"> {{month_discussions}} discussions</li>
+ <li class="saved"> 1 saved</li>
+ </ul>
+ {% endif %}
+ </div>
+ <p id="list_email">
+ <a href="mailto:{{list_address}}">
+ {{list_address}}
+ </a>
+ </p>
+ <div id="searchbox">
+ <form action="/search" method="get">
+ {{ search_form }}
+ </form>
+ </div>
+ <div id="newthread">
+ <a href="#Create_new_thread">
+ <img src="{{ STATIC_URL }}img/newthread.png" alt="New thread" />
+ </a>
+ </div>
+ <hr id="white"/>
+ <hr />
+ </div>
+ {% endblock %}
+
+ <div class="content">
+ {% block content %}
+ {% endblock %}
+ </div>
+
+</body>
+</html>
diff --git a/templates/base2.html b/templates/base2.html
new file mode 100644
index 0000000..8490bc6
--- /dev/null
+++ b/templates/base2.html
@@ -0,0 +1,63 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
+ <meta name="ROBOTS" content="INDEX, FOLLOW" />
+
+ <title>{% block title %}Mail app{% endblock %}</title>
+ <meta name="author" content="" />
+
+ <meta name="dc.language" content="en" />
+
+ <link rel="stylesheet" type="text/css" media="all" href="{{ STATIC_URL }}css/style.css" />
+
+ {% block additional_headers %}
+ {% endblock %}
+</head>
+
+<body>
+ <!-- Header -->
+ <div class="header">
+ {% block header %}
+ <div id="headline">
+ <ul class="inline-block">
+ <li id="list_name"><a href="/">{{list_name}}</a></li>
+ <li id="page_date">{{month}}</li>
+ </ul>
+ {% if month_participants and month_discussions %}
+ <ul class="inline-block" id="top_right">
+ <li class="participant"> {{month_participants}} participants</li>
+ <li class="discussion"> {{month_discussions}} discussions</li>
+ <li class="saved"> 1 saved</li>
+ </ul>
+ {% endif %}
+ </div>
+ {% if list_address %}
+ <p id="list_email">
+ <a href="mailto:{{list_address}}">
+ {{list_address}}
+ </a>
+ </p>
+ <div id="searchbox">
+ <form action="/2/search/{{list_address}}" method="get">
+ {{ search_form }}
+ </form>
+ </div>
+ {% endif %}
+ <div id="newthread">
+ <a href="#Create_new_thread">
+ <img src="{{ STATIC_URL }}img/newthread.png" alt="New thread" />
+ </a>
+ </div>
+ <hr id="white"/>
+ <hr />
+ </div>
+ {% endblock %}
+
+ <div class="content">
+ {% block content %}
+ {% endblock %}
+ </div>
+
+</body>
+</html>
diff --git a/templates/index.html b/templates/index.html
new file mode 100644
index 0000000..caae906
--- /dev/null
+++ b/templates/index.html
@@ -0,0 +1,17 @@
+{% extends "base.html" %}
+
+{% block title %}{{ app_name }}{% endblock %}
+
+{% block content %}
+ <ul>
+ <li>
+ <a href="archives">Archives current month</a>
+ </li>
+ <li>
+ <a href="archives/2012/01/">Archives January 2012</a>
+ </li>
+ <li>
+ <a href="recent">Recent activities</a>
+ </li>
+ </ul>
+{% endblock %}
diff --git a/templates/index2.html b/templates/index2.html
new file mode 100644
index 0000000..440e66f
--- /dev/null
+++ b/templates/index2.html
@@ -0,0 +1,14 @@
+{% extends "base2.html" %}
+
+{% block title %}{{ app_name }}{% endblock %}
+
+{% block content %}
+ <ul>
+ {% for mlist in lists %}
+ <li>{{mlist.real_name}} ({{mlist.list_name}}) --
+ <a href="/2/recent/{{mlist.fqdn_listname}}">Recent</a> --
+ <a href="/2/archives/{{mlist.fqdn_listname}}">Archives current month</a>
+ </li>
+ {% endfor %}
+ </ul>
+{% endblock %}
diff --git a/templates/month_view.html b/templates/month_view.html
new file mode 100644
index 0000000..24cbf37
--- /dev/null
+++ b/templates/month_view.html
@@ -0,0 +1,71 @@
+{% extends "base.html" %}
+
+{% block title %}{{ app_name }}{% endblock %}
+
+{% block additional_headers %}
+ <script src="http://ajax.googleapis.com/ajax/libs/jquery/1.6/jquery.min.js"></script>
+ <script src="{{ STATIC_URL }}jquery.expander.js"></script>
+ <script>
+ $(document).ready(function() {
+ $('span.expander').expander({
+ userCollapseText: 'View Less',
+ expandText: 'View More'
+ });
+ });
+ </script>
+{% endblock %}
+
+{% block content %}
+
+ {% for email in threads %}
+ <!-- New thread -->
+ <div class="thread">
+ <div class="notsaved">
+ <span class="thread_title">{{email.title}}</span>
+ <span class="thread_date"> {{email.age}} ago</span>
+ </div>
+ <div class="thread_content">
+ {% if email.category_tag %}
+ <div class="inline-block type type_{{email.category_tag}}">
+ <a href="/tag/{{email.category_tag}}"> {{email.category}} </a>
+ </div>
+ {% else %}
+ <div class="inline-block type type_{{email.category}}">
+ <a href="/tag/{{email.category}}"> {{email.category}} </a>
+ </div>
+ {% endif %}
+ <div class="inline-block gravatar">
+ {% if email.avatar %}
+ <img src="{{email.avatar}}" alt="avatar" /> <br />
+ {% endif %}
+ {{email.author}}
+ </div>
+ <div class="inline-block thread_email">
+ <span class="expander">
+ {{email.body}}
+ </span>
+ </div>
+ </div>
+ <div class="thread_info">
+ <ul class="tags inline">
+ <li>Tags:</li>
+ {% for tag in email.tags %}
+ <li> <a href="/tag/{{tag}}">{{tag}}</a></li>
+ {% endfor %}
+ </ul>
+ <ul class="inline-block">
+ <li class="participant"> {{email.participants|length}} participants</li>
+ <li class="discussion"> {{email.answers|length}} comments</li>
+ </ul>
+ <ul class="inline-block">
+ <li class="like"> +{{email.liked}}</li>
+ <li class="youlike"> <a href="#like"> Like</a></li>
+ <li class="youdislike"> <a href="#dislike"> Dislike</a></li>
+ <li class="showdiscussion"> <a href="#show"> Show discussion</a></li>
+ </ul>
+ </div>
+ </div>
+ <!-- End of thread -->
+ {% endfor %}
+
+{% endblock %}
diff --git a/templates/month_view2.html b/templates/month_view2.html
new file mode 100644
index 0000000..b006d35
--- /dev/null
+++ b/templates/month_view2.html
@@ -0,0 +1,75 @@
+{% extends "base2.html" %}
+
+{% block title %}{{ app_name }}{% endblock %}
+
+{% block additional_headers %}
+ <script src="http://ajax.googleapis.com/ajax/libs/jquery/1.6/jquery.min.js"></script>
+ <script src="{{ STATIC_URL }}jquery.expander.js"></script>
+ <script>
+ $(document).ready(function() {
+ $('span.expander').expander({
+ userCollapseText: 'View Less',
+ expandText: 'View More'
+ });
+ });
+ </script>
+{% endblock %}
+
+{% block content %}
+
+ {% for email in threads %}
+ <!-- New thread -->
+ <div class="thread">
+ <div class="notsaved">
+ <span class="thread_title">{{email.title}}</span>
+ <span class="thread_date"> {{email.age}} ago</span>
+ </div>
+ <div class="thread_content">
+ {% if email.category_tag %}
+ <div class="inline-block type type_{{email.category_tag}}">
+ <a href="/2/tag/{{list_address}}/{{email.category_tag}}"> {{email.category}} </a>
+ </div>
+ {% else %}
+ <div class="inline-block type type_{{email.category}}">
+ <a href="/2/tag/{{list_address}}/{{email.category}}"> {{email.category}} </a>
+ </div>
+ {% endif %}
+ <div class="inline-block gravatar">
+ {% if email.avatar %}
+ <img src="{{email.avatar}}" alt="avatar" /> <br />
+ {% endif %}
+ {{email.author}}
+ </div>
+ <div class="inline-block thread_email">
+ <span class="expander">
+ {{email.body}}
+ </span>
+ </div>
+ </div>
+ <div class="thread_info">
+ <ul class="tags inline">
+ <li>Tags:</li>
+ {% for tag in email.tags %}
+ <li> <a href="/2/tag/{{list_address}}/{{tag}}">{{tag}}</a></li>
+ {% endfor %}
+ </ul>
+ <ul class="inline-block">
+ <li class="participant"> {{email.participants|length}} participants</li>
+ <li class="discussion"> {{email.answers|length}} comments</li>
+ </ul>
+ <ul class="inline-block">
+ <li class="like"> +{{email.liked}}</li>
+ <li class="youlike"> <a href="#like"> Like</a></li>
+ <li class="youdislike"> <a href="#dislike"> Dislike</a></li>
+ {% if email.answers %}
+ <li class="showdiscussion"> <a href="#show"> Show discussion</a></li>
+ {% else %}
+ <li class="showdiscussion invisible"> <a href="#show"> Show discussion</a></li>
+ {% endif %}
+ </ul>
+ </div>
+ </div>
+ <!-- End of thread -->
+ {% endfor %}
+
+{% endblock %}
diff --git a/templates/recent_activities.html b/templates/recent_activities.html
new file mode 100644
index 0000000..84dd366
--- /dev/null
+++ b/templates/recent_activities.html
@@ -0,0 +1,102 @@
+{% extends "base.html" %}
+
+{% block title %} {{ app_name }} {% endblock %}
+
+{% block additional_headers %}
+<link rel="stylesheet" type="text/css" media="all" href="{{ STATIC_URL }}css/stats.css" />
+{% endblock %}
+
+{% block content %}
+
+ <section id="most_active">
+ <h2>Recently active discussions</h2>
+ {% for email in top_threads %}
+ <!-- Start thread -->
+ <div class="thread">
+ <span class="thread_id">#{{forloop.counter}}</span>
+ <span class="thread_title">{{email.title}}</span>
+ <div class="thread_stats">
+ <ul class="inline-block">
+ {% if email.category_tag %}
+ <li class="type type_{{email.category_tag}}">
+ <a href="/tag/{{email.category_tag}}"> {{email.category}} </a>
+ </li>
+ {% else %}
+ <li class="type type_{{email.category}}">
+ <a href="/tag/{{email.category}}"> {{email.category}} </a>
+ </li>
+ {% endif %}
+ <li class="neutral"> 0 </li>
+ <li class="participant"> {{email.participants|join:", "}} </li>
+ <li class="discussion"> {{email.answers|length}} </li>
+ </ul>
+ </div>
+ </div>
+ <!-- End thread -->
+ {% endfor %}
+ </section>
+
+ <section id="top_discussion">
+ <h2>Top discussions the last 30 days</h2>
+ {% for email in most_active_threads %}
+ <!-- Start thread -->
+ <div class="thread">
+ <span class="thread_id">#{{forloop.counter}}</span>
+ <span class="thread_title">{{email.title}}</span>
+ <div class="thread_stats">
+ <ul class="inline-block">
+ {% if email.category_tag %}
+ <li class="type type_{{email.category_tag}}">
+ <a href="/tag/{{email.category_tag}}"> {{email.category}} </a>
+ </li>
+ {% else %}
+ <li class="type type_{{email.category}}">
+ <a href="/tag/{{email.category_tag}}"> {{email.category}} </a>
+ </li>
+ {% endif %}
+ <li class="neutral"> 0 </li>
+ <li class="participant"> {{email.participants|join:", "}} </li>
+ <li class="discussion"> {{email.answers|length}} </li>
+ </ul>
+ </div>
+ </div>
+ <!-- End thread -->
+ {% endfor %}
+ </section>
+
+ <section id="discussion_marker">
+ <h2>Prominent discussion maker</h2>
+ {% for author in top_author %}
+ <!-- Start discussion maker -->
+ <div class="maker">
+ <div class="inline-block maker_id"> #{{forloop.counter}} </div>
+ <div class="inline-block gravatar">
+ {% if author.avatar %}
+ <img src="{{author.avatar}}" alt="avatar" />
+ {% endif %}
+ </div>
+ <div class="inline-block">
+ <span class="marker_name">{{author.name}}</span> <br />
+ <span class="score">+{{author.kudos}}</span> kudos
+ </div>
+ </div>
+ <!-- End discussion maker -->
+ {% endfor %}
+
+ <h2>Tag cloud</h2>
+ </section>
+
+ <section id="discussion_by_topic">
+ <h2>Discussion by topic the last 30 days</h2>
+ {% for category, thread in threads_per_category.items %}
+ <div>
+ <h2 class="category type_{{category}}"> {{category}} </h2>
+ <ul class="category_entry">
+ {% for email in thread %}
+ <li>{{email.title}}</li>
+ {% endfor %}
+ </ul>
+ </div>
+ {% endfor %}
+ </section>
+{% endblock %}
diff --git a/templates/search.html b/templates/search.html
new file mode 100644
index 0000000..f3b44cf
--- /dev/null
+++ b/templates/search.html
@@ -0,0 +1,73 @@
+{% extends "base.html" %}
+
+{% block title %}{{ app_name }}{% endblock %}
+
+{% block additional_headers %}
+ <script src="http://ajax.googleapis.com/ajax/libs/jquery/1.6/jquery.min.js"></script>
+ <script src="{{ STATIC_URL }}jquery.expander.js"></script>
+ <script>
+ $(document).ready(function() {
+ $('span.expander').expander({
+ userCollapseText: 'View Less',
+ expandText: 'View More'
+ });
+ });
+ </script>
+{% endblock %}
+
+{% block content %}
+
+ {% for email in threads %}
+ <!-- New thread -->
+ <div class="thread">
+ <div class="notsaved">
+ <span class="thread_title">{{email.title}}</span>
+ <span class="thread_date"> 6 hours ago</span>
+ </div>
+ <div class="thread_content">
+ {% if email.category_tag %}
+ <div class="inline-block type type_{{email.category_tag}}">
+ <a href="/tag/{{email.category_tag}}"> {{email.category}} </a>
+ </div>
+ {% else %}
+ <div class="inline-block type type_{{email.category}}">
+ <a href="/tag/{{email.category}}"> {{email.category}} </a>
+ </div>
+ {% endif %}
+ <div class="inline-block gravatar">
+ {% if email.avatar %}
+ <img src="{{email.avatar}}" alt="avatar" /> <br />
+ {% endif %}
+ {{email.author}}
+ </div>
+ <div class="inline-block thread_email">
+ <span class="expander">
+ {{email.body}}
+ </span>
+ </div>
+ </div>
+ <div class="thread_info">
+ <ul class="tags inline">
+ <li>Tags:</li>
+ {% for tag in email.tags %}
+ <li> <a href="/tag/{{tag}}">{{tag}}</a></li>
+ {% endfor %}
+ </ul>
+ <ul class="inline-block">
+ <li class="participant"> {{email.participants|length}} participants</li>
+ <li class="discussion"> {{email.answers|length}} comments</li>
+ </ul>
+ <ul class="inline-block">
+ <li class="like"> +1</li>
+ <li class="youlike"> <a href="#like"> Like</a></li>
+ <li class="youdislike"> <a href="#dislike"> Dislike</a></li>
+ <li class="showdiscussion"> <a href="#show"> Show discussion</a></li>
+ </ul>
+ </div>
+ </div>
+ <!-- End of thread -->
+ {% empty %}
+ Sorry no emails could be found for your search.
+ {% endfor %}
+
+{% endblock %}
diff --git a/templates/search2.html b/templates/search2.html
new file mode 100644
index 0000000..df8abb1
--- /dev/null
+++ b/templates/search2.html
@@ -0,0 +1,77 @@
+{% extends "base2.html" %}
+
+{% block title %}{{ app_name }}{% endblock %}
+
+{% block additional_headers %}
+ <script src="http://ajax.googleapis.com/ajax/libs/jquery/1.6/jquery.min.js"></script>
+ <script src="{{ STATIC_URL }}jquery.expander.js"></script>
+ <script>
+ $(document).ready(function() {
+ $('span.expander').expander({
+ userCollapseText: 'View Less',
+ expandText: 'View More'
+ });
+ });
+ </script>
+{% endblock %}
+
+{% block content %}
+
+ {% for email in threads %}
+ <!-- New thread -->
+ <div class="thread">
+ <div class="notsaved">
+ <span class="thread_title">{{email.title}}</span>
+ <span class="thread_date">{{email.age}} ago</span>
+ </div>
+ <div class="thread_content">
+ {% if email.category_tag %}
+ <div class="inline-block type type_{{email.category_tag}}">
+ <a href="/2/tag/{{list_address}}/{{email.category_tag}}"> {{email.category}} </a>
+ </div>
+ {% else %}
+ <div class="inline-block type type_{{email.category}}">
+ <a href="/2/tag/{{list_address}}/{{email.category}}"> {{email.category}} </a>
+ </div>
+ {% endif %}
+ <div class="inline-block gravatar">
+ {% if email.avatar %}
+ <img src="{{email.avatar}}" alt="avatar" /> <br />
+ {% endif %}
+ {{email.author}}
+ </div>
+ <div class="inline-block thread_email">
+ <span class="expander">
+ {{email.body}}
+ </span>
+ </div>
+ </div>
+ <div class="thread_info">
+ <ul class="tags inline">
+ <li>Tags:</li>
+ {% for tag in email.tags %}
+ <li> <a href="/2/tag/{{list_address}}/{{tag}}">{{tag}}</a></li>
+ {% endfor %}
+ </ul>
+ <ul class="inline-block">
+ <li class="participant"> {{email.participants|length}} participants</li>
+ <li class="discussion"> {{email.answers|length}} comments</li>
+ </ul>
+ <ul class="inline-block">
+ <li class="like"> +{{email.liked}}</li>
+ <li class="youlike"> <a href="#like"> Like</a></li>
+ <li class="youdislike"> <a href="#dislike"> Dislike</a></li>
+ {% if email.answers %}
+ <li class="showdiscussion"> <a href="#show"> Show discussion</a></li>
+ {% else %}
+ <li class="showdiscussion invisible"> <a href="#show"> Show discussion</a></li>
+ {% endif %}
+ </ul>
+ </div>
+ </div>
+ <!-- End of thread -->
+ {% empty %}
+ Sorry no emails could be found for your search.
+ {% endfor %}
+
+{% endblock %}
diff --git a/urls.py b/urls.py
new file mode 100644
index 0000000..9b4fb91
--- /dev/null
+++ b/urls.py
@@ -0,0 +1,48 @@
+from django.conf.urls.defaults import patterns, include, url
+from django.conf import settings
+
+from django.contrib.staticfiles.urls import staticfiles_urlpatterns
+
+# Uncomment the next two lines to enable the admin:
+# from django.contrib import admin
+# admin.autodiscover()
+
+urlpatterns = patterns('',
+ # Examples:
+ # url(r'^$', 'mm_app.views.home', name='home'),
+ # url(r'^mm_app/', include('mm_app.foo.urls')),
+ # This will be the new index page
+ url(r'^2$', 'views.pages.index'),
+ url(r'^2/$', 'views.pages.index'),
+ # This will be the new archives page
+ url(r'^2/archives/(?P<mlist_fqdn>.*@.*)/(?P<year>\d{4})/(?P<month>\d{2})/$', 'views.pages.archives'),
+ url(r'^2/archives/(?P<mlist_fqdn>.*@.*)/(?P<year>\d{4})/(?P<month>\d{2})$', 'views.pages.archives'),
+ url(r'^2/archives/(?P<mlist_fqdn>.*@.*)/$', 'views.pages.archives'),
+ url(r'^2/archives/(?P<mlist_fqdn>.*@.*)$', 'views.pages.archives'),
+ # This will be the new recent page
+ url(r'^2/recent/(?P<mlist_fqdn>.*@.*)/$', 'views.pages.recent'),
+ url(r'^2/recent/(?P<mlist_fqdn>.*@.*)$', 'views.pages.recent'),
+ # Search
+ url(r'^2/search$', 'views.pages.search'),
+ url(r'^2/search/(?P<mlist_fqdn>.*@.*)$', 'views.pages.search_keyword'),
+ url(r'^2/search/(?P<mlist_fqdn>.*@.*)/$', 'views.pages.search_keyword'),
+ url(r'^2/search/(?P<mlist_fqdn>.*@.*)\/(?P<keyword>.*)$', 'views.pages.search_keyword'),
+ url(r'^2/tag/(?P<mlist_fqdn>.*@.*)\/(?P<tag>.*)$', 'views.pages.search_tag'),
+ # mockups:
+ url(r'^$', 'views.mockup.index'),
+ url(r'^archives$', 'views.mockup.archives'),
+ url(r'^archives/(?P<year>\d{4})/(?P<month>\d{2})/$', 'views.mockup.archives'),
+ url(r'^recent$', 'views.mockup.recent'),
+ url(r'^search$', 'views.mockup.search'),
+ url(r'^search\/(?P<keyword>.*)$', 'views.mockup.search_keyword'),
+ url(r'^tag\/(?P<tag>.*)$', 'views.mockup.search_tag'),
+
+ # Uncomment the admin/doc line below to enable admin documentation:
+ # url(r'^admin/doc/', include('django.contrib.admindocs.urls')),
+
+ # Uncomment the next line to enable the admin:
+ # url(r'^admin/', include(admin.site.urls)),
+)
+#) + static(settings.STATIC_URL, document_root=settings.STATIC_ROOT)
+urlpatterns += staticfiles_urlpatterns()
+
diff --git a/views/__init__.py b/views/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/views/__init__.py
diff --git a/views/mockup.py b/views/mockup.py
new file mode 100644
index 0000000..f2215f8
--- /dev/null
+++ b/views/mockup.py
@@ -0,0 +1,151 @@
+#-*- coding: utf-8 -*-
+
+from calendar import timegm
+from datetime import date, datetime, timedelta
+import json
+import logging
+import os
+import string
+from urlparse import urljoin
+
+import bunch
+from django import forms
+from django.http import HttpResponse, HttpResponseRedirect
+from django.template import RequestContext, loader
+from django.conf import settings
+import notmuch
+import urlgrabber
+
+from mm_app.lib.mockup import generate_random_thread, generate_top_author, \
+ generate_thread_per_category, get_email_tag
+
+from mm_app.lib import gravatar_url
+from lib.notmuch import get_ro_db
+
+# Move this into settings.py
+ARCHIVE_DIR = '/home/toshio/mm3/mailman/var/archives/hyperkitty/'
+# Used to remove tags that notmuch added automatically that we don't want
+IGNORED_TAGS = (u'inbox', u'unread', u'signed')
+
+MAILING_LIST = 'Fedora Development'
+MAILING_LIST_ADDRESS = 'devel@list.fedoraproject.org'
+MONTH = 'January 2011'
+MONTH_PARTICIPANTS = 284
+MONTH_DISCUSSIONS = 82
+logger = logging.getLogger(__name__)
+
+
+class SearchForm(forms.Form):
+ keyword = forms.CharField(max_length=100,
+ widget=forms.TextInput(
+ attrs={'placeholder': 'Search this list.'}
+ )
+ )
+
+
+def index(request):
+ t = loader.get_template('index.html')
+ search_form = SearchForm(auto_id=False)
+ c = RequestContext(request, {
+ 'app_name': settings.APP_NAME,
+ 'list_name' : MAILING_LIST,
+ 'list_address': MAILING_LIST_ADDRESS,
+ 'search_form': search_form['keyword'],
+ })
+ return HttpResponse(t.render(c))
+
+
+def archives(request, year=None, month=None):
+ if not year and not month:
+ today = date.today()
+ else:
+ try:
+ today = date(int(year), int(month), 1)
+ except ValueError, err:
+ logger.error('Wrong format given for the date')
+ search_form = SearchForm(auto_id=False)
+ t = loader.get_template('month_view.html')
+ c = RequestContext(request, {
+ 'app_name': settings.APP_NAME,
+ 'list_name' : MAILING_LIST,
+ 'list_address': MAILING_LIST_ADDRESS,
+ 'search_form': search_form['keyword'],
+ 'month': today.strftime("%B %Y"),
+ 'month_participants': MONTH_PARTICIPANTS,
+ 'month_discussions': MONTH_DISCUSSIONS,
+ 'threads': generate_random_thread(),
+ })
+ return HttpResponse(t.render(c))
+
+def recent(request):
+ t = loader.get_template('recent_activities.html')
+ threads = generate_random_thread()
+ threads2 = threads[:]
+ threads2.reverse()
+ authors = generate_top_author()
+ authors = sorted(authors, key=lambda author: author.kudos)
+ authors.reverse()
+ threads_per_category = generate_thread_per_category()
+ search_form = SearchForm(auto_id=False)
+ c = RequestContext(request, {
+ 'app_name': settings.APP_NAME,
+ 'list_name' : MAILING_LIST,
+ 'list_address': MAILING_LIST_ADDRESS,
+ 'search_form': search_form['keyword'],
+ 'month': 'Recent activity',
+ 'month_participants': MONTH_PARTICIPANTS,
+ 'month_discussions': MONTH_DISCUSSIONS,
+ 'top_threads': threads,
+ 'most_active_threads': threads2,
+ 'top_author': authors,
+ 'threads_per_category': threads_per_category,
+ })
+ return HttpResponse(t.render(c))
+
+
+def search(request):
+ keyword = request.GET.get('keyword')
+ return HttpResponseRedirect('/search/%s' % keyword)
+
+
+def search_keyword(request, keyword):
+ search_form = SearchForm(auto_id=False)
+ t = loader.get_template('search.html')
+ if keyword:
+ c = RequestContext(request, {
+ 'app_name': settings.APP_NAME,
+ 'list_name' : MAILING_LIST,
+ 'list_address': MAILING_LIST_ADDRESS,
+ 'search_form': search_form['keyword'],
+ 'month': 'Search',
+ 'month_participants': MONTH_PARTICIPANTS,
+ 'month_discussions': MONTH_DISCUSSIONS,
+ 'threads': generate_random_thread(),
+ })
+ else:
+ c = RequestContext(request, {
+ 'app_name': settings.APP_NAME,
+ 'list_name' : MAILING_LIST,
+ 'list_address': MAILING_LIST_ADDRESS,
+ 'search_form': search_form['keyword'],
+ 'month': 'Search',
+ 'month_participants': MONTH_PARTICIPANTS,
+ 'month_discussions': MONTH_DISCUSSIONS,
+ 'threads': [],
+ })
+ return HttpResponse(t.render(c))
+
+def search_tag(request, tag):
+ search_form = SearchForm(auto_id=False)
+ t = loader.get_template('search.html')
+ c = RequestContext(request, {
+ 'app_name': settings.APP_NAME,
+ 'list_name' : MAILING_LIST,
+ 'list_address': MAILING_LIST_ADDRESS,
+ 'search_form': search_form['keyword'],
+ 'month': 'Tag search',
+ 'month_participants': MONTH_PARTICIPANTS,
+ 'month_discussions': MONTH_DISCUSSIONS,
+ 'threads': get_email_tag(tag),
+ })
+ return HttpResponse(t.render(c))
diff --git a/views/pages.py b/views/pages.py
new file mode 100644
index 0000000..cfb13ce
--- /dev/null
+++ b/views/pages.py
@@ -0,0 +1,238 @@
+#-*- coding: utf-8 -*-
+
+from calendar import timegm
+from datetime import datetime, timedelta
+import json
+import logging
+import os
+from urlparse import urljoin
+
+from django import forms
+from django.http import HttpResponse, HttpResponseRedirect
+from django.template import RequestContext, loader
+from django.conf import settings
+import urlgrabber
+
+from mm_app.lib.mockup import generate_thread_per_category, generate_top_author
+
+from lib.notmuch import get_thread_info, get_ro_db
+
+# Move this into settings.py
+ARCHIVE_DIR = '/home/toshio/mm3/mailman/var/archives/hyperkitty/'
+
+MONTH_PARTICIPANTS = 284
+MONTH_DISCUSSIONS = 82
+logger = logging.getLogger(__name__)
+
+
+class SearchForm(forms.Form):
+ keyword = forms.CharField(max_length=100,
+ widget=forms.TextInput(
+ attrs={'placeholder': 'Search this list.'}
+ )
+ )
+
+
+def index(request):
+ t = loader.get_template('index2.html')
+ search_form = SearchForm(auto_id=False)
+ base_url = settings.MAILMAN_API_URL % {
+ 'username': settings.MAILMAN_USER, 'password': settings.MAILMAN_PASS}
+ data = json.load(urlgrabber.urlopen(urljoin(base_url, 'lists')))
+ list_data = sorted(data['entries'], key=lambda elem: (elem['mail_host'], elem['list_name']))
+ c = RequestContext(request, {
+ 'app_name': settings.APP_NAME,
+ 'lists': list_data,
+ 'search_form': search_form['keyword'],
+ })
+ return HttpResponse(t.render(c))
+
+def archives(request, mlist_fqdn, year=None, month=None):
+ # No year/month: past 32 days
+ # year and month: find the 32 days for that month
+ end_date = None
+ if year or month:
+ try:
+ begin_date = datetime(int(year), int(month), 1)
+ end_date = begin_date + timedelta(days=32)
+ month_string = begin_date.strftime('%B %Y')
+ except ValueError, err:
+ logger.error('Wrong format given for the date')
+
+ if not end_date:
+ end_date = datetime.utcnow()
+ begin_date = end_date - timedelta(days=32)
+ month_string = 'Past thirty days'
+ begin_timestamp = timegm(begin_date.timetuple())
+ end_timestamp = timegm(end_date.timetuple())
+
+ list_name = mlist_fqdn.split('@')[0]
+
+ search_form = SearchForm(auto_id=False)
+ t = loader.get_template('month_view2.html')
+
+ try:
+ db = get_ro_db(os.path.join(ARCHIVE_DIR, mlist_fqdn))
+ except IOError:
+ logger.error('No archive for mailing list %s' % mlist_fqdn)
+ return
+
+ msgs = db.create_query('%s..%s' % (begin_timestamp, end_timestamp)).search_messages()
+ participants = set()
+ discussions = set()
+ for msg in msgs:
+ message = json.loads(msg.format_message_as_json())
+ # Statistics on how many participants and threads this month
+ participants.add(message['headers']['From'])
+ discussions.add(msg.get_thread_id())
+
+ # Collect data about each thread
+ threads = []
+ for thread_id in discussions:
+ # Note: can't use tuple() due to a bug in notmuch
+ thread = [thread for thread in db.create_query('thread:%s' % thread_id).search_threads()]
+ if len(thread) != 1:
+ logger.warning('Unknown thread_id %(thread)s from %(mlist)s:'
+ ' %(start)s-%(end)s' % {
+ 'thread': thread_id, 'mlist': mlist_fqdn,
+ 'start': begin_timestamp, 'end': end_timestamp})
+ continue
+ thread = thread[0]
+ thread_info = get_thread_info(thread)
+ threads.append(thread_info)
+
+ # For threads, we need to have threads ordered by
+ # youngest to oldest with the oldest message within thread
+ threads.sort(key=lambda entry: entry.most_recent, reverse=True)
+
+ c = RequestContext(request, {
+ 'app_name': settings.APP_NAME,
+ 'list_name' : list_name,
+ 'list_address': mlist_fqdn,
+ 'search_form': search_form['keyword'],
+ 'month': month_string,
+ 'month_participants': len(participants),
+ 'month_discussions': len(discussions),
+ 'threads': threads,
+ })
+ return HttpResponse(t.render(c))
+
+def recent(request, mlist_fqdn):
+ t = loader.get_template('recent_activities.html')
+ search_form = SearchForm(auto_id=False)
+ list_name = mlist_fqdn.split('@')[0]
+
+ # Get stats for last 30 days
+ end_date = datetime.utcnow()
+ begin_date = end_date - timedelta(days=32)
+ begin_timestamp = timegm(begin_date.timetuple())
+ end_timestamp = timegm(end_date.timetuple())
+
+ try:
+ db = get_ro_db(os.path.join(ARCHIVE_DIR, mlist_fqdn))
+ except IOError:
+ logger.error('No archive for mailing list %s' % mlist_fqdn)
+ return
+
+ msgs = db.create_query('%s..%s' % (begin_timestamp, end_timestamp)).search_messages()
+ participants = set()
+ discussions = set()
+ for msg in msgs:
+ message = json.loads(msg.format_message_as_json())
+ # Statistics on how many participants and threads this month
+ participants.add(message['headers']['From'])
+ discussions.add(msg.get_thread_id())
+
+ thread_query = db.create_query('%s..%s' % (begin_timestamp, end_timestamp)).search_threads()
+ top_threads = []
+ for thread in thread_query:
+ thread_info = get_thread_info(thread)
+ top_threads.append(thread_info)
+ # top threads are the ones with the most posts
+ top_threads.sort(key=lambda entry: len(entry.answers), reverse=True)
+
+ # active threads are the ones that have the most recent posting
+ active_threads = sorted(top_threads, key=lambda entry: entry.most_recent, reverse=True)
+
+ # top authors are the ones that have the most kudos. How do we determine
+ # that? Most likes for their post?
+ authors = generate_top_author()
+ authors = sorted(authors, key=lambda author: author.kudos)
+ authors.reverse()
+
+ # threads per category is the top thread titles in each category
+ threads_per_category = generate_thread_per_category()
+ c = RequestContext(request, {
+ 'app_name': settings.APP_NAME,
+ 'list_name' : list_name,
+ 'list_address': mlist_fqdn,
+ 'search_form': search_form['keyword'],
+ 'month': 'Recent activity',
+ 'month_participants': len(participants),
+ 'month_discussions': len(discussions),
+ 'top_threads': top_threads,
+ 'most_active_threads': active_threads,
+ 'top_author': authors,
+ 'threads_per_category': threads_per_category,
+ })
+ return HttpResponse(t.render(c))
+
+def _search_results_page(request, mlist_fqdn, query_string, search_type):
+ search_form = SearchForm(auto_id=False)
+ t = loader.get_template('search2.html')
+
+ list_name = mlist_fqdn.split('@')[0]
+
+ try:
+ db = get_ro_db(os.path.join(ARCHIVE_DIR, mlist_fqdn))
+ except IOError:
+ logger.error('No archive for mailing list %s' % mlist_fqdn)
+ return
+
+ # Note, can't use tuple() because of a bug in notmuch
+ # Collect data about each thread
+ threads = []
+ participants = set()
+ if query_string:
+ for thread in db.create_query(query_string).search_threads():
+ thread_info = get_thread_info(thread)
+ participants.update(thread_info.participants)
+ threads.append(thread_info)
+
+ threads.sort(key=lambda entry: entry.most_recent, reverse=True)
+
+ c = RequestContext(request, {
+ 'app_name': settings.APP_NAME,
+ 'list_name' : list_name,
+ 'list_address': mlist_fqdn,
+ 'search_form': search_form['keyword'],
+ 'month': search_type,
+ 'month_participants': len(participants),
+ 'month_discussions': len(threads),
+ 'threads': threads,
+ })
+ return HttpResponse(t.render(c))
+
+
+def search(request, mlist_fqdn):
+ keyword = request.GET.get('keyword')
+ if keyword:
+ url = '/2/search/%s/%s' % (mlist_fqdn, keyword)
+ else:
+ url = '/2/search/%s' % (mlist_fqdn,)
+ return HttpResponseRedirect(url)
+
+
+def search_keyword(request, mlist_fqdn, keyword=None):
+ if not keyword:
+ keyword = request.GET.get('keyword')
+ return _search_results_page(request, mlist_fqdn, keyword, 'Search')
+
+
+def search_tag(request, mlist_fqdn, tag=None):
+ '''Searches both tag and topic'''
+ if tag:
+ query_string = 'tag:%(tag)s or tag:=topic=%(tag)s' % {'tag': tag}
+ else:
+ query_string = None
+ return _search_results_page(request, mlist_fqdn, query_string, 'Tag search')