From 34cc4a81acd1d9f69d38d12f7a617aa1b17cf756 Mon Sep 17 00:00:00 2001 From: tonio Date: Fri, 6 Nov 2009 18:47:01 +0100 Subject: [PATCH 01/28] First work for ticket #26. --- admin/models.py | 4 ++ admin/templatetags/admin_extras.py | 23 +++++++++-- admin/urls.py | 4 +- admin/views.py | 29 +++++++++++++- extensions/amavis_quarantine/main.py | 4 +- extensions/stats/main.py | 8 +++- lib/parameters.py | 57 ++++++++++++++++++++++++++++ locale/fr/LC_MESSAGES/django.po | 2 +- static/js/settings.js | 2 + static/stylesheets/box.css | 8 ++++ templates/admin/parameters.html | 42 ++++++++++++++++++++ templates/admin/permissions.html | 2 +- templates/admin/settings_menu.html | 18 --------- urls.py | 2 +- 14 files changed, 176 insertions(+), 29 deletions(-) create mode 100644 lib/parameters.py create mode 100644 templates/admin/parameters.html delete mode 100644 templates/admin/settings_menu.html diff --git a/admin/models.py b/admin/models.py index 732ec50a8..cb9e65a8f 100644 --- a/admin/models.py +++ b/admin/models.py @@ -115,3 +115,7 @@ class Alias(models.Model): permissions = ( ("view_aliases", "View aliases"), ) + +class Parameter(models.Model): + name = models.CharField(max_length=255) + value = models.CharField(max_length=255) diff --git a/admin/templatetags/admin_extras.py b/admin/templatetags/admin_extras.py index 4e5cbf15c..b377b3a2c 100644 --- a/admin/templatetags/admin_extras.py +++ b/admin/templatetags/admin_extras.py @@ -46,9 +46,26 @@ def domain_menu(domain_id, selection, perms): "perms" : perms}) @register.simple_tag -def settings_menu(selection): - return render_to_string('admin/settings_menu.html', - {"selection" : selection}) +def settings_menu(selection, perms): + entries = [ + {"name" : "permissions", + "url" : reverse(admin.views.settings), + "label" : _("Permissions"), + "img" : "/static/pics/permissions.png"}, + {"name" : "addperm", + "url" : reverse(admin.views.addpermission), + "img" : "/static/pics/add.png", + "label" : _("Add permission"), + "class" : "boxed", + "rel" : "{handler:'iframe',size:{x:320,y:210}}"}, + {"name" : "parameters", + "url" : reverse(admin.views.viewparameters), + "img" : "/static/pics/domains.png", + "label" : _("Parameters")}, + ] + return render_to_string('main/menu.html', + {"selection" : selection, "entries" : entries, + "perms" : perms}) @register.simple_tag def loadadminextmenu(perms): diff --git a/admin/urls.py b/admin/urls.py index 770268375..cb296bf2f 100644 --- a/admin/urls.py +++ b/admin/urls.py @@ -18,5 +18,7 @@ urlpatterns = patterns('mailng.admin.views', (r'^domains/(?P\d+)/delalias/(?P\d+)/$', 'delalias'), (r'^settings/$', 'settings'), (r'^settings/addperm/$', 'addpermission'), - (r'^settings/delperm/(?P\d+)/(?P\w+)/$', 'deletepermission') + (r'^settings/delperm/(?P\d+)/(?P\w+)/$', 'deletepermission'), + (r'^settings/parameters/$', 'viewparameters'), + (r'^settings/parameters/save/$', 'saveparameters'), ) diff --git a/admin/views.py b/admin/views.py index 5c1cfa794..7b917765f 100644 --- a/admin/views.py +++ b/admin/views.py @@ -11,11 +11,11 @@ from django.contrib.auth.decorators \ import login_required, permission_required, user_passes_test from django.contrib.auth.models import User, Group from mailng import admin, main -from mailng.admin.models import Domain, Mailbox, Alias +from mailng.admin.models import Domain, Mailbox, Alias, Parameter from forms import MailboxForm, DomainForm, AliasForm, PermissionForm from mailng.lib.authbackends import crypt_password from mailng.lib import _render, _ctx_ok, _ctx_ko -from mailng.lib import events +from mailng.lib import events, parameters import string import copy @@ -404,3 +404,28 @@ def deletepermission(request, mbox_id, group): mbox.user.groups.remove(grp) mbox.user.save() return HttpResponseRedirect(reverse(admin.views.settings)) + +@login_required +@user_passes_test(lambda u: u.is_superuser) +def viewparameters(request): + params = {} + for app, app_params in parameters._params.iteritems(): + params[app] = {} + for pname, pdef in app_params.iteritems(): + params[app][pname] = pdef + params[app][pname]["value"] = parameters.get(app, pname) + + return _render(request, 'admin/parameters.html', { + "params" : params + }) + +@login_required +@user_passes_test(lambda u: u.is_superuser) +def saveparameters(request): + for pname, v in request.POST.iteritems(): + if pname == "update": + continue + app, name = pname.split('.') + parameters.save(app, name, v) + request.user.message_set.create(message=_("Configuration saved.")) + return HttpResponseRedirect(reverse(admin.views.viewparameters)) diff --git a/extensions/amavis_quarantine/main.py b/extensions/amavis_quarantine/main.py index a3fd1fb50..3ffd5b9e6 100644 --- a/extensions/amavis_quarantine/main.py +++ b/extensions/amavis_quarantine/main.py @@ -13,7 +13,7 @@ from django.core.urlresolvers import reverse from django.core.paginator import Paginator from django.contrib.auth.decorators \ import login_required -from mailng.lib import events +from mailng.lib import events, parameters from mailng.lib import _render, _ctx_ok, _ctx_ko, decode from mailng.lib import db from mailng.admin.models import Mailbox @@ -37,6 +37,8 @@ def map_cid(url): def init(): events.register("UserMenuDisplay", menu) + parameters.register("amavis_quarantine", "MAX_MESSAGES_AGE", "int", 14, + help=_("Quarantine messages maximum age (in days) before deletion")) def urls(): return (r'^mailng/quarantine/', diff --git a/extensions/stats/main.py b/extensions/stats/main.py index a7d1f65a9..a87ccb30f 100644 --- a/extensions/stats/main.py +++ b/extensions/stats/main.py @@ -10,7 +10,7 @@ from postfix log : sent, received, bounced, rejected from django.core.urlresolvers import reverse from django.utils.translation import ugettext as _ from django.conf.urls.defaults import * -from mailng.lib import events, _render +from mailng.lib import events, parameters, _render from mailng.admin.views import good_domain from mailng.admin.models import Domain, Mailbox from django.contrib.auth.decorators \ @@ -20,6 +20,12 @@ graph_types = ['AVERAGE', 'MAX'] def init(): events.register("AdminMenuDisplay", menu) + parameters.register("stats", "LOGFILE", "string", "/var/log/mail.log", + help=_("Path to log file used to collect statistics")) + parameters.register("stats", "RRD_ROOTDIR", "string", "/tmp/mailng", + help=_("Path to directory where RRD files are stored")) + parameters.register("stats", "IMG_ROOTDIR", "string", "/tmp/mailng", + help=_("Path to directory where PNG files are stored")) def urls(): return (r'^mailng/stats/', diff --git a/lib/parameters.py b/lib/parameters.py new file mode 100644 index 000000000..dfaf50f27 --- /dev/null +++ b/lib/parameters.py @@ -0,0 +1,57 @@ +# -*- coding: utf-8 -*- + +import string +from mailng.admin.models import Parameter + +""" +This interface provides a simple way to declare and store parameters +in MailNG's database. + +Core components or extensions can register their own parameters, which +will be available and modifiable directly from the web interface. + +Only super users will be able to acces this part of the web interface. +""" + +_params = {} + +class NotDefined(Exception): + def __init__(self, app, name): + self.app = app + self.name = name + + def __str__(self): + return "Application '%s' and/or parameter '%s' not defined" % (self.app, + self.name) + +def register(app, name, type="string", default=None, help=None): + """Register a new parameter. + + app corresponds to a core component (admin, main) or an extension. + """ + if not app in _params.keys(): + _params[app] = {} + _params[app][name] = {"type" : type, "default" : default, "help" : help} + +def save(app, name, value): + if not app in _params.keys() or not name in _params[app].keys(): + raise NotDefined(app, name) + fullname = "%s.%s" % (app, name) + try: + p = Parameter.objects.get(name=fullname) + except Parameter.DoesNotExist: + p = Parameter() + p.name = fullname + if p.value != value: + p.value = value + p.save() + return True + +def get(app, name): + if not app in _params.keys() or not name in _params[app].keys(): + raise NotDefined(app, name) + try: + p = Parameter.objects.get(name="%s.%s" % (app, name)) + except Parameter.DoesNotExist: + return _params[app][name]["defaut"] + return p.value diff --git a/locale/fr/LC_MESSAGES/django.po b/locale/fr/LC_MESSAGES/django.po index b27fc70c8..a8a990bcc 100644 --- a/locale/fr/LC_MESSAGES/django.po +++ b/locale/fr/LC_MESSAGES/django.po @@ -470,7 +470,7 @@ msgstr "Domaines" #: templates/layout.html:86 templates/admin/settings_header.html:6 msgid "Settings" -msgstr "Paramètres" +msgstr "Configuration" #: templates/admin/addpermission.html:24 templates/admin/settings_menu.html:15 msgid "Add permission" diff --git a/static/js/settings.js b/static/js/settings.js index 014a8b98f..8e0721e6b 100644 --- a/static/js/settings.js +++ b/static/js/settings.js @@ -9,4 +9,6 @@ window.addEvent('domready', function() { if (!confirm(gettext("Remove this permission?"))) event.stop(); }); + + var tip = new Tips($$(".Tips")); }); \ No newline at end of file diff --git a/static/stylesheets/box.css b/static/stylesheets/box.css index 3368b9ac5..6b1331ce6 100644 --- a/static/stylesheets/box.css +++ b/static/stylesheets/box.css @@ -31,6 +31,14 @@ input:hover { background-color: #ffffcc; } +td.odd { + background: #F7F9Fb; +} + +td.even { + +} + .title { float: left; width: 100%; diff --git a/templates/admin/parameters.html b/templates/admin/parameters.html new file mode 100644 index 000000000..9a9c3711b --- /dev/null +++ b/templates/admin/parameters.html @@ -0,0 +1,42 @@ +{% extends "admin/settings_header.html" %} +{% block html_head %} + + +{% endblock %} +{% block menu %} +{% load admin_extras %} +{% settings_menu "parameters" perms %} +{% endblock %} +{% block setcontent %} +{% load i18n %} +{% url admin.views.saveparameters as action %} +
+ + + + + + {% for app, ps in params.items %} + {% for name, def in ps.items %} + + + + + {% endfor %} + {% endfor %} +
{% trans "Name" %}{% trans "Value" %}
{{ app }}::{{ name }} + {% if def.value %} + + {% else %} + + {% endif %} + {% if def.help %} + + + + {% endif %} +
+ +

+
+{% endblock %} diff --git a/templates/admin/permissions.html b/templates/admin/permissions.html index 1ecd1dee8..6295edf95 100644 --- a/templates/admin/permissions.html +++ b/templates/admin/permissions.html @@ -1,7 +1,7 @@ {% extends "admin/settings_header.html" %} {% block menu %} {% load admin_extras %} -{% settings_menu "permissions" %} +{% settings_menu "permissions" perms %} {% endblock %} {% block setcontent %} {% load i18n %} diff --git a/templates/admin/settings_menu.html b/templates/admin/settings_menu.html deleted file mode 100644 index 5463b8fd3..000000000 --- a/templates/admin/settings_menu.html +++ /dev/null @@ -1,18 +0,0 @@ -{% load i18n %} - diff --git a/urls.py b/urls.py index 9d999ca52..db5e81700 100644 --- a/urls.py +++ b/urls.py @@ -33,6 +33,6 @@ if menus != (): if settings.DEBUG: urlpatterns += patterns( '', - (r'^static/(?P.*)$', 'django.views.static.serve', {'document_root': '/home/tonio/projets/mailng/static'}) + (r'^static/(?P.*)$', 'django.views.static.serve', {'document_root': '/home/tonio/projets/devel/mailng/static'}) ) From 81fb5a4358411c25d38dcd3c721fd013b96d08a4 Mon Sep 17 00:00:00 2001 From: Florent Dionisi Date: Thu, 26 Nov 2009 18:19:19 +0100 Subject: [PATCH 02/28] #29 [stats] performance improvement (it goes twice as fast for one day log): - parse line timestamp only when line timestamp changes by more than rrd time step (time computing cost a arm). - compute line id just once (can be from 9 to 18 chars) --- extensions/stats/logparser.py | 100 +++++++++++++++++++--------------- 1 file changed, 56 insertions(+), 44 deletions(-) diff --git a/extensions/stats/logparser.py b/extensions/stats/logparser.py index 375deedf6..003c7bf44 100755 --- a/extensions/stats/logparser.py +++ b/extensions/stats/logparser.py @@ -49,7 +49,7 @@ class LogParser(object): self.debug = debug self.verbose = verbose self.cfs = ['AVERAGE', 'MAX'] - + self.last_month = None if not self.year: self.year = time.localtime().tm_year @@ -186,57 +186,69 @@ class LogParser(object): self.data["global"][cur_t][counter] += val def process(self): + id_expr = re.compile("(\w{9,18}): (.*)") + prev_se = -1 + prev_mi = -1 + prev_ho = -1 for line in self.f.readlines(): m = self.line_expr.match(line) if not m: continue (mo, da, ho, mi, se, host, prog, pid, log) = m.groups() - se = int(int(se) / rrdstep) # rrd step is one-minute => se = 0 - cur_t = self.str2Time(self.year, mo, da, ho, mi, se) - cur_t = cur_t - cur_t % rrdstep - m = re.search("(\w{10}): message-id=<([^>]*)>", log) + if prev_se != se or prev_mi != mi or prev_ho != ho: + cur_t = self.str2Time(self.year, mo, da, ho, mi, se) + cur_t = cur_t - cur_t % rrdstep + prev_mi = mi + prev_ho = ho + prev_se = se + m = id_expr.search(log) if m: - self.workdict[m.group(1)] = {'from' : m.group(2), 'size' : 0} - continue + (line_id, line_log) = m.groups() - m = re.search("(\w{10}): from=<([^>]*)>, size=(\d+)", log) - if m: - self.workdict[m.group(1)] = {'from' : m.group(2), - 'size' : string.atoi(m.group(3))} - continue - - m = re.search("(\w{10}): to=<([^>]*)>.*status=(\S+)", log) - if m: - if not self.workdict.has_key(m.group(1)): - print "Inconsistent mail (%s: %s), skipping" % (m.group(1), m.group(2)) + m = re.search("message-id=<([^>]*)>", line_log) + if m: + self.workdict[line_id] = {'from' : m.group(1), 'size' : 0} continue - if not m.group(3) in variables: - print "Unsupported status %s, skipping" % m.group(3) + + m = re.search("from=<([^>]*)>, size=(\d+)", line_log) + if m: + self.workdict[line_id] = {'from' : m.group(1), + 'size' : string.atoi(m.group(2))} continue - addrfrom = re.match("([^@]+)@(.+)", self.workdict[m.group(1)]['from']) - if addrfrom and addrfrom.group(2) in self.domains: - self.inc_counter(addrfrom.group(2), cur_t, 'sent') - self.inc_counter(addrfrom.group(2), cur_t, 'size_sent', - self.workdict[m.group(1)]['size']) - addrto = re.match("([^@]+)@(.+)", m.group(2)) - if addrto.group(2) in self.domains: - if m.group(3) == "sent": - self.inc_counter(addrto.group(2), cur_t, 'recv') - self.inc_counter(addrto.group(2), cur_t, 'size_recv', - self.workdict[m.group(1)]['size']) - else: - self.inc_counter(addrto.group(2), cur_t, m.group(3)) - continue - - m = re.search("NOQUEUE: reject: .*from=<(.*)> to=<([^>]*)>", log) - if m: - addrto = re.match("([^@]+)@(.+)", m.group(2)) - if addrto and addrto.group(2) in self.domains: - self.inc_counter(addrto.group(2), cur_t, 'reject') - continue - + + m = re.search("to=<([^>]*)>.*status=(\S+)", line_log) + if m: + if not self.workdict.has_key(line_id): + print "Inconsistent mail (%s: %s), skipping" % (line_id, m.group(1)) + continue + if not m.group(2) in variables: + print "Unsupported status %s, skipping" % m.group(2) + continue + + addrfrom = re.match("([^@]+)@(.+)", self.workdict[line_id]['from']) + if addrfrom and addrfrom.group(2) in self.domains: + self.inc_counter(addrfrom.group(2), cur_t, 'sent') + self.inc_counter(addrfrom.group(2), cur_t, 'size_sent', + self.workdict[line_id]['size']) + addrto = re.match("([^@]+)@(.+)", m.group(1)) + if addrto.group(2) in self.domains: + if m.group(2) == "sent": + self.inc_counter(addrto.group(2), cur_t, 'recv') + self.inc_counter(addrto.group(2), cur_t, 'size_recv', + self.workdict[line_id]['size']) + else: + self.inc_counter(addrto.group(2), cur_t, m.group(2)) + continue + else: + m = re.search("NOQUEUE: reject: .*from=<(.*)> to=<([^>]*)>", log) + if m: + addrto = re.match("([^@]+)@(.+)", m.group(2)) + if addrto and addrto.group(2) in self.domains: + self.inc_counter(addrto.group(2), cur_t, 'reject') + continue + # Sort everything by time G = grapher.Grapher() for dom, data in self.data.iteritems(): @@ -256,12 +268,12 @@ if __name__ == "__main__": parser = OptionParser() parser.add_option("-l","--logFile", default=log_file, help="postfix log in syslog format", metavar="FILE") - parser.add_option("-v","--verbose", default=False, action="store_true", + parser.add_option("-v","--verbose", default=False, action="store_true", dest="verbose", help="set verbose mode") - parser.add_option("-d","--debug", default=False, action="store_true", + parser.add_option("-d","--debug", default=False, action="store_true", dest="debug", help="set debug mode") (options, args) = parser.parse_args() - + P = LogParser(options.logFile, rrd_rootdir, debug=options.debug, verbose=options.verbose) P.process() From 02d45cc8434f97ec88030c7077e0a88d213e3da1 Mon Sep 17 00:00:00 2001 From: Florent Dionisi Date: Fri, 27 Nov 2009 17:45:59 +0100 Subject: [PATCH 03/28] #24 : added calendar class for mootools from http://www.electricprism.com/aeron/calendar/. - some css stuff as well. - selector view implementation for stats plugin (from admin menu) --- extensions/stats/main.py | 26 +- extensions/stats/templates/adminindex.html | 14 +- extensions/stats/templates/calendar.html | 38 + extensions/stats/urls.py | 1 + static/js/calendar.js | 1344 ++++++++++++++++++++ static/stylesheets/calendar.css | 99 ++ 6 files changed, 1517 insertions(+), 5 deletions(-) create mode 100644 extensions/stats/templates/calendar.html create mode 100644 static/js/calendar.js create mode 100644 static/stylesheets/calendar.css diff --git a/extensions/stats/main.py b/extensions/stats/main.py index a87ccb30f..d25825bb4 100644 --- a/extensions/stats/main.py +++ b/extensions/stats/main.py @@ -7,6 +7,7 @@ This module provides rrdtool support to retreive statistics from postfix log : sent, received, bounced, rejected """ +import calendar from django.core.urlresolvers import reverse from django.utils.translation import ugettext as _ from django.conf.urls.defaults import * @@ -89,6 +90,8 @@ def graph_display(request,dom_id,graph_t=graph_types): @login_required @user_passes_test(lambda u: u.is_superuser) def adminindex(request): + CH = calendar.HTMLCalendar(calendar.MONDAY) + CH_print = CH.formatmonth(2009,11) period = request.GET.has_key("period") and request.GET["period"] or "day" domain = request.GET.has_key("domain") and request.GET["domain"] or "global" if domain != "global": @@ -100,8 +103,25 @@ def adminindex(request): "graphs" : [{"name" : "traffic", "label" : _("Average normal traffic")}, {"name" : "badtraffic", "label" : _("Average bad traffic")}, {"name" : "size", "label" : _("Average normal traffic size")}], - "periods" : ["day", "week", "month", "year"], - "period" : period + "periods" : ["day", "week", "month", "year","Custom"], + "period" : period, "cal" : CH_print + }) + +@login_required +@user_passes_test(lambda u: u.is_superuser) +def custom_period(request): + print "[stats] json get" + start = request.GET.has_key("start") + if not start: + print "[stats] no start selected" + return + end = request.GET.has_key("end") + return _render(request, 'stats/graphs.html', { + "domain" : domain, + "graphs" : [{"name" : "traffic", "label" : _("Average normal traffic")}, + {"name" : "badtraffic", "label" : _("Average bad traffic")}, + {"name" : "size", "label" : _("Average normal traffic size")}], + "period" : "%s_%s" %(start,end) }) @login_required @@ -118,6 +138,6 @@ def index(request, dom_id=None): "graphs" : [{"name" : "traffic", "label" : _("Average normal traffic")}, {"name" : "badtraffic", "label" : _("Average bad traffic")}, {"name" : "size", "label" : _("Average normal traffic size")}], - "periods" : ["day", "week", "month", "year"], + "periods" : ["day", "week", "month", "year","Custom"], "period" : period }) diff --git a/extensions/stats/templates/adminindex.html b/extensions/stats/templates/adminindex.html index 25cf9ff8e..1f170898f 100644 --- a/extensions/stats/templates/adminindex.html +++ b/extensions/stats/templates/adminindex.html @@ -4,6 +4,7 @@ {% block html_head %} + @@ -27,6 +28,7 @@ window.addEvent("domready", function() { }); }); + {% endblock %} {% block middletitle %}{% trans "Global statistics" %}{% endblock %} {% block content %} @@ -46,7 +48,15 @@ window.addEvent("domready", function() { {% endfor %} + {% ifequal period "Custom" %} + {% include "stats/calendar.html" %} + {% url extensions.stats.main.adminindex as c_stats_url %} + {% endifequal %} - {% include "stats/graphs.html" %} + + {% include "stats/graphs.html" %} {% endblock %} diff --git a/extensions/stats/templates/calendar.html b/extensions/stats/templates/calendar.html new file mode 100644 index 000000000..d48d2bfff --- /dev/null +++ b/extensions/stats/templates/calendar.html @@ -0,0 +1,38 @@ +{% load i18n %} +
+
+ {% trans "Start" %}: + {% trans "End" %}: + +
+
+ + + diff --git a/extensions/stats/urls.py b/extensions/stats/urls.py index 904b014c4..b72709229 100644 --- a/extensions/stats/urls.py +++ b/extensions/stats/urls.py @@ -2,6 +2,7 @@ from django.conf.urls.defaults import * urlpatterns = patterns('mailng.extensions.stats.main', url(r'^$', 'adminindex', name='fullindex'), + url(r'^custom_period$', 'custom_period'), url(r'^(?P\d+)/$', 'index', name='domindex'), (r'^display/(?P\d+)/(?P\w+)/$', 'graph_display'), (r'^domain/(?P\d+)/$', 'domain'), diff --git a/static/js/calendar.js b/static/js/calendar.js new file mode 100644 index 000000000..ab4935248 --- /dev/null +++ b/static/js/calendar.js @@ -0,0 +1,1344 @@ +/** + * Calendar + * + * version 1.1.2 + * - added option onValuesStart + * version 1.1.1 + * - added bounds option + * - fixes chrome toggle bug + * version 1.1 + * - fix: events in fx not work bug + * - fix: ie6 iframe bug + * - added displayHead and displayFooter abstract render function + * version 1.0 + * - new option dayLabelLength + * - IE6 Fix works with variable content height + */ + +/** + * Calendar: a Javascript class for Mootools + * + * adds accessible and unobtrusive date pickers to your form elements + * based on a work by Aeron Glemann + * modifyed by Jens Klose + * + * @licence MIT Style License + */ +var Calendar = new Class({ + + Implements: [Events,Options], + + options: { + dayLabelLength: 2, + blocked: [], // blocked dates + bounds: {start:null , end:null}, // + classes: { + calendar:'calendar', + prev:'prev', + next:'next', + month:'month', + year:'year', + today:'today', + invalid:'invalid', + valid:'valid', + inactive:'inactive', + active:'active', + hover:'hover', + hilite:'hilite' + }, + days: ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'], // days of the week starting at sunday + direction: 0, // -1 past, 0 past + future, 1 future + draggable: true, + months: ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'], + navigation: 1, // 0 = no nav; 1 = single nav for month; 2 = dual nav for month and year + offset: 0, // first day of the week: 0 = sunday, 1 = monday, etc.. + onHideStart: $empty, + onHideComplete: $empty, + onShowStart: $empty, + onShowComplete: $empty, + onValuesStart: $empty, + pad: 1, // padding between multiple calendars + tweak: { + x: 0, + y: 0 + } // tweak calendar positioning + }, + + /** + * initialize: calendar constructor + * + * @param obj (obj) a js object containing the form elements and format strings { id: 'format', id: 'format' etc } + * @param props (obj) optional properties + */ + initialize: function(obj, options) { + // basic error checking + if (!obj) { + return false; + } + + this.setOptions(options); + this.classes = this.options.classes; + + // create cal element with css styles required for proper cal functioning + this.calendar = new Element('div', { + 'class':this.classes.calendar, + 'styles': { + left: '-1000px', + opacity: 0, + position: 'absolute', + top: '-1000px', + zIndex: 1000 + } + }).inject(document.body); + + this.calendar.coord = this.calendar.getCoordinates(); + + // iex 6 needs a transparent iframe underneath the calendar in order to not allow select elements to render through + if (Browser.Engine.trident4) { + this.iframe = new IFrame({ + 'styles': { + left: '-1000px', + position: 'absolute', + top: '-1000px', + zIndex: 999 + } + }).inject(document.body); + this.iframe.style.filter = 'progid:DXImageTransform.Microsoft.Alpha(style=0,opacity=0)'; + } + + // initialize fade method + this.fx = new Fx.Tween(this.calendar,{ + link : 'chain', + duration : 'short', + onStart: function() { + if (this.calendar.getStyle('opacity') == 0) { // show + //this.calendar.setStyle('display', 'block'); + this.fireEvent('onShowStart', this.element); + } + else { // hide + this.fireEvent('onHideStart', this.element); + } + }.bind(this), + onComplete: function() { + if (this.calendar.getStyle('opacity') == 0) { // hidden + //this.calendar.setStyle('display', 'none'); + if (Browser.Engine.trident4) { + this.iframe.setStyles({ + left: '-1000px', + top: '-1000px' + }); + } + this.fireEvent('onHideComplete', this.element); + } + else { // shown + this.fireEvent('onShowComplete', this.element); + } + }.bind(this) + }); + + // initialize drag method + if (window.Drag && this.options.draggable) { + this.drag = new Drag.Move(this.calendar, { + onDrag: function() { + if (Browser.Engine.trident4) { + this.iframe.setStyles({ + left: this.calendar.getStyle('left'), + top: this.calendar.getStyle('top') + }); + } + }.bind(this) + }); + } + + // create calendars array + this.calendars = []; + var id = 0; + var d = new Date(); // today + d.setDate(d.getDate() + this.options.direction.toInt()); // correct today for directional offset + + for (var i in obj) { + var cal = { + button: new Element('button', { + 'type': 'button' + }), + el: $(i), + els: [], + id: id++, + month: d.getMonth(), + visible: false, + year: d.getFullYear() + }; + + // fix for bad element (naughty, naughty element!) + if (!this.element(i, obj[i], cal)) { + continue; + } + // show cal on click + this.fn = function(e, cal) { + e.stop(); + this.toggle(cal); + }.create({ + 'arguments': cal, + 'bind': this, + 'event': true + }); + + cal.el.addClass(this.classes.calendar); + // create cal button + cal.button.addClass(this.classes.calendar) + .addEvent('click', this.fn) + .inject(cal.el,'after'); + // read in default value + cal.val = this.read(cal); + $extend(cal, this.bounds(cal)); // abs bounds of calendar + $extend(cal, this.values(cal)); // valid days, months, years + this.rebuild(cal); + this.calendars.push(cal); // add to cals array + } + return this; + }, + + /** + * blocked: returns an array of blocked days for the month / year + * + * @param cal (obj) + * @returns blocked days (array) + */ + blocked: function(cal) { + var blocked = []; + var offset = new Date(cal.year, cal.month, 1).getDay(); // day of the week (offset) + var last = new Date(cal.year, cal.month + 1, 0).getDate(); // last day of this month + + this.options.blocked.each(function(date) { + var values = date.split(' '); + + for (var i = 0; i < 3; i++) { + if (!values[i]) { + values[i] = '*'; + } // make sure blocked date contains values for at least d, m and y + values[i] = values[i].contains(',') ? values[i].split(',') : new Array(values[i]); // split multiple values + } + + if (values[2].contains(cal.year + '') || values[2].contains('*')) { + if (values[1].contains(cal.month + 1 + '') || values[1].contains('*')) { + values[0].each(function(val) { // if blocked value indicates this month / year + if (val > 0) { + blocked.push(val.toInt()); + } // add date to blocked array + }); + + if (values[3]) { // optional value for day of week + values[3] = values[3].contains(',') ? values[3].split(',') : new Array(values[3]); + + for (var i = 0; i < last; i++) { + var day = (i + offset) % 7; + + if (values[3].contains(day + '')) { + blocked.push(i + 1); // add every date that corresponds to the blocked day of the week to the blocked array + } + } + } + } + } + }, this); + + return blocked; + }, + + /** + * bounds: returns the start / end bounds of the calendar + * + * @param cal (obj) + * @returns obj + */ + bounds: function(cal) { + // check external settings + if ($defined(this.options.bounds.start)&&$defined(this.options.bounds.end)) { + if ($type(this.options.bounds.end) == 'date' && + $type(this.options.bounds.start) == 'date' && + this.options.bounds.end>=this.options.bounds.start) { + return { + 'start': this.options.bounds.start, + 'end': this.options.bounds.end + } + } + } + + // 1. first we assume the calendar has no bounds (or a thousand years in either direction) + + // by default the calendar will accept a millennium in either direction + var start = new Date(1000, 0, 1); // jan 1, 1000 + var end = new Date(2999, 11, 31); // dec 31, 2999 + + // 2. but if the cal is one directional we adjust accordingly + var date = new Date().getDate() + this.options.direction.toInt(); + + if (this.options.direction > 0) { + start = new Date(); + start.setDate(date + this.options.pad * cal.id); + } + + if (this.options.direction < 0) { + end = new Date(); + end.setDate(date - this.options.pad * (this.calendars.length - cal.id - 1)); + } + + // 3. then we can further filter the limits by using the pre-existing values in the selects + cal.els.each(function(el) { + if (el.get('tag') == 'select') { + if (el.retrieve('format').test('(y|Y)')) { // search for a year select + var years = []; + + el.getChildren().each(function(option) { // get options + var values = this.unformat(option.get('value'), el.retrieve('format')); + + if (!years.contains(values[0])) { + years.push(values[0]); + } // add to years array + }, this); + + years.sort(this.sort); + + if (years[0] > start.getFullYear()) { + d = new Date(years[0], start.getMonth() + 1, 0); // last day of new month + + if (start.getDate() > d.getDate()) { + start.setDate(d.getDate()); + } + + start.setYear(years[0]); + } + + if (years.getLast() < end.getFullYear()) { + d = new Date(years.getLast(), end.getMonth() + 1, 0); // last day of new month + + if (end.getDate() > d.getDate()) { + end.setDate(d.getDate()); + } + + end.setYear(years.getLast()); + } + } + + if (el.retrieve('format').test('(F|m|M|n)')) { // search for a month select + var months_start = []; + var months_end = []; + + el.getChildren().each(function(option) { // get options + var values = this.unformat(option.get('value'), el.retrieve('format')); + + if ($type(values[0]) != 'number' || values[0] == years[0]) { // if it's a year / month combo for curr year, or simply a month select + if (!months_start.contains(values[1])) { + months_start.push(values[1]); + } // add to months array + } + + if ($type(values[0]) != 'number' || values[0] == years.getLast()) { // if it's a year / month combo for curr year, or simply a month select + if (!months_end.contains(values[1])) { + months_end.push(values[1]); + } // add to months array + } + }, this); + + months_start.sort(this.sort); + months_end.sort(this.sort); + + if (months_start[0] > start.getMonth()) { + d = new Date(start.getFullYear(), months_start[0] + 1, 0); // last day of new month + + if (start.getDate() > d.getDate()) { + start.setDate(d.getDate()); + } + + start.setMonth(months_start[0]); + } + + if (months_end.getLast() < end.getMonth()) { + d = new Date(start.getFullYear(), months_end.getLast() + 1, 0); // last day of new month + + if (end.getDate() > d.getDate()) { + end.setDate(d.getDate()); + } + + end.setMonth(months_end.getLast()); + } + } + } + }, this); + + return { + 'start': start, + 'end': end + }; + }, + + /** + * caption: returns the caption element with header and navigation + * + * @param cal (obj) + * @returns caption (element) + */ + caption: function(cal) { + // start by assuming navigation is allowed + var navigation = { + prev: { + 'month': true, + 'year': true + }, + next: { + 'month': true, + 'year': true + } + }; + + // if we're in an out of bounds year + if (cal.year == cal.start.getFullYear()) { + navigation.prev.year = false; + if (cal.month == cal.start.getMonth() && this.options.navigation == 1) { + navigation.prev.month = false; + } + } + if (cal.year == cal.end.getFullYear()) { + navigation.next.year = false; + if (cal.month == cal.end.getMonth() && this.options.navigation == 1) { + navigation.next.month = false; + } + } + + // special case of improved navigation but months array with only 1 month we can disable all month navigation + if ($type(cal.months) == 'array') { + if (cal.months.length == 1 && this.options.navigation == 2) { + navigation.prev.month = navigation.next.month = false; + } + } + + var caption = new Element('caption'); + + var prev = new Element('a').addClass(this.classes.prev).appendText('\x3c'); // < + var next = new Element('a').addClass(this.classes.next).appendText('\x3e'); // > + + if (this.options.navigation == 2) { + var month = new Element('span').addClass(this.classes.month).inject(caption); + + if (navigation.prev.month) { + prev.clone().addEvent('click', function(cal) { + this.navigate(cal, 'm', -1); + }.pass(cal, this)).inject(month); + } + + month.adopt(new Element('span').appendText(this.options.months[cal.month])); + + if (navigation.next.month) { + next.clone().addEvent('click', function(cal) { + this.navigate(cal, 'm', 1); + }.pass(cal, this)).inject(month); + } + + var year = new Element('span').addClass(this.classes.year).inject(caption); + + if (navigation.prev.year) { + prev.clone().addEvent('click', function(cal) { + this.navigate(cal, 'y', -1); + }.pass(cal, this)).inject(year); + } + + year.adopt(new Element('span').appendText(cal.year)); + + if (navigation.next.year) { + next.clone().addEvent('click', function(cal) { + this.navigate(cal, 'y', 1); + }.pass(cal, this)).inject(year); + } + } + else { // 1 or 0 + if (navigation.prev.month && this.options.navigation) { + prev.clone().addEvent('click', function(cal) { + this.navigate(cal, 'm', -1); + }.pass(cal, this)).inject(caption); + } + + caption.adopt(new Element('span').addClass(this.classes.month).appendText(this.options.months[cal.month])); + + caption.adopt(new Element('span').addClass(this.classes.year).appendText(cal.year)); + + if (navigation.next.month && this.options.navigation) { + next.clone().addEvent('click', function(cal) { + this.navigate(cal, 'm', 1); + }.pass(cal, this)).inject(caption); + } + + } + + return caption; + }, + + /** + * changed: run when a select value is changed + * + * @param cal (obj) + * @returns caption (element) + */ + changed: function(cal) { + cal.val = this.read(cal); // update calendar val from inputs + + $extend(cal, this.values(cal)); // update bounds - based on curr month + + this.rebuild(cal); // rebuild days select + + if (!cal.val) { + return; + } // in case the same date was clicked the cal has no set date we should exit + + if (cal.val.getDate() < cal.days[0]) { + cal.val.setDate(cal.days[0]); + } + if (cal.val.getDate() > cal.days.getLast()) { + cal.val.setDate(cal.days.getLast()); + } + + cal.els.each(function(el) { // then we can set the value to the field + el.set('value',this.format(cal.val, el.retrieve('format'))); + }, this); + + this.check(cal); // checks other cals + + this.calendars.each(function(kal) { // update cal graphic if visible + if (kal.visible) { + this.display(kal); + } + }, this); + }, + + /** + * check: checks other calendars to make sure no overlapping values + * + * @param cal (obj) + */ + check: function(cal) { + this.calendars.each(function(kal, i) { + if (kal.val) { // if calendar has value set + var change = false; + + if (i < cal.id) { // preceding calendar + var bound = new Date(Date.parse(cal.val)); + + bound.setDate(bound.getDate() - (this.options.pad * (cal.id - i))); + + if (bound < kal.val) { + change = true; + } + } + if (i > cal.id) { // following calendar + var bound = new Date(Date.parse(cal.val)); + + bound.setDate(bound.getDate() + (this.options.pad * (i - cal.id))); + + if (bound > kal.val) { + change = true; + } + } + + if (change) { + if (kal.start > bound) { + bound = kal.start; + } + if (kal.end < bound) { + bound = kal.end; + } + + kal.month = bound.getMonth(); + kal.year = bound.getFullYear(); + + $extend(kal, this.values(kal)); + + // TODO - IN THE CASE OF SELECT MOVE TO NEAREST VALID VALUE + // IN THE CASE OF INPUT DISABLE + + // if new date is not valid better unset cal value + // otherwise it would mean incrementally checking to find the nearest valid date which could be months / years away + kal.val = kal.days.contains(bound.getDate()) ? bound : null; + + this.write(kal); + + if (kal.visible) { + this.display(kal); + } // update cal graphic if visible + } + } + }, this); + }, + + /** + * clicked: run when a valid day is clicked in the calendar + * + * @param td (obj) + * @param day (int) + * @param cal (obj) + */ + clicked: function(td, day, cal) { + cal.val = (this.value(cal) == day) ? null : new Date(cal.year, cal.month, day); // set new value - if same then disable + + this.write(cal); + + // ok - in the special case that it's all selects and there's always a date no matter what (at least as far as the form is concerned) + // we can't let the calendar undo a date selection - it's just not possible!! + if (!cal.val) { + cal.val = this.read(cal); + } + + if (cal.val) { + this.check(cal); // checks other cals + this.toggle(cal); // hide cal + } + else { // remove active class and replace with valid + td.addClass(this.classes.valid); + td.removeClass(this.classes.active); + } + }, + + /** + * display: create calendar element + * + * @param cal (obj) + */ + display: function(cal) { + // 1. header and navigation + this.calendar.empty(); // init div + this.calendar.className = this.classes.calendar + ' ' + this.options.months[cal.month].toLowerCase(); + // render optional head content + var extra = this.displayHead(cal); + if ($defined(extra) && $defined(extra.inject)) extra.inject(this.calendar); + //this.displayHead(cal).inject(this.calendar, 'top'); + + var div = new Element('div').inject(this.calendar); // a wrapper div to help correct browser css problems with the caption element + var table = new Element('table').inject(div).adopt(this.caption(cal)); + // 2. day names + var thead = new Element('thead').inject(table); + var tr = new Element('tr').inject(thead); + + for (var i = 0; i <= 6; i++) { + var th = this.options.days[(i + this.options.offset) % 7]; + + tr.adopt(new Element('th', { + 'title': th + }).appendText(th.substr(0, this.options.dayLabelLength))); + } + + // 3. day numbers + var tbody = new Element('tbody').inject(table); + var tr = new Element('tr').inject(tbody); + var d = new Date(cal.year, cal.month, 1); + var offset = ((d.getDay() - this.options.offset) + 7) % 7; // day of the week (offset) + var last = new Date(cal.year, cal.month + 1, 0).getDate(); // last day of this month + var prev = new Date(cal.year, cal.month, 0).getDate(); // last day of previous month + var active = this.value(cal); // active date (if set and within curr month) + var valid = cal.days; // valid days for curr month + var inactive = []; // active dates set by other calendars + var hilited = []; + this.calendars.each(function(kal, i) { + if (kal != cal && kal.val) { + if (cal.year == kal.val.getFullYear() && cal.month == kal.val.getMonth()) { + inactive.push(kal.val.getDate()); + } + if (cal.val) { + for (var day = 1; day <= last; day++) { + d.setDate(day); + if ((i < cal.id && d > kal.val && d < cal.val) || (i > cal.id && d > cal.val && d < kal.val)) { + if (!hilited.contains(day)) { + hilited.push(day); + } + } + } + } + } + }, this); + var d = new Date(); + var today = new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime(); // today obv + + for (var i = 1; i < 43; i++) { // 1 to 42 (6 x 7 or 6 weeks) + if ((i - 1) % 7 == 0) { + tr = new Element('tr').inject(tbody); + } // each week is it's own table row + var td = new Element('td').inject(tr); + var day = i - offset; + var date = new Date(cal.year, cal.month, day); + var cls = ''; + + if (day === active) { + cls = this.classes.active; + } // active + else if (inactive.contains(day)) { + cls = this.classes.inactive; + } // inactive + else if (valid.contains(day)) { + cls = this.classes.valid; + } // valid + else if (day >= 1 && day <= last) { + cls = this.classes.invalid; + } // invalid + + if (date.getTime() == today) { + cls = cls + ' ' + this.classes.today; + } // adds class for today + if (hilited.contains(day)) { + cls = cls + ' ' + this.classes.hilite; + } // adds class if hilited + td.addClass(cls); + + if (valid.contains(day)) { // if it's a valid - clickable - day we add interaction + td.set('title', this.format(date, 'D M jS Y')); + td.addEvents({ + 'click': function(td, day, cal) { + this.clicked(td, day, cal); + }.pass([td, day, cal], this), + 'mouseover': function(td, cls) { + td.addClass(cls); + }.pass([td, this.classes.hover]), + 'mouseout': function(td, cls) { + td.removeClass(cls); + }.pass([td, this.classes.hover]) + }); + } + + // pad calendar with last days of prev month and first days of next month + if (day < 1) { + day = prev + day; + } + else if (day > last) { + day = day - last; + } + td.appendText(day); + } + // render optional footer content + extra = this.displayFooter(cal) + if ($defined(extra) && $defined(extra.inject)) extra.inject(this.calendar); + + this.calendar.coord = this.calendar.getCoordinates(); + }, + + /** + * displayHead + * + * @abstract + */ + displayHead: function(cal) {}, + + /** + * displayFooter + * + * @abstract + */ + displayFooter: function(cal) {}, + + /** + * element: helper function + * + * @param el (string) element id + * @param f (string) format string + * @param cal (obj) + */ + element: function(el, f, cal) { + if ($type(f) == 'object') { // in the case of multiple inputs per calendar + for (var i in f) { + if (!this.element(i, f[i], cal)) { + return false; + } + } + + return true; + } + + el = $(el); + + if (!el) { + return false; + } + + el.store('format',f); + + if (el.get('tag') == 'select') { // select elements allow the user to manually set the date via select option + el.addEvent('change', function(cal) { + this.changed(cal); + }.pass(cal, this)); + } + else { // input (type text) elements restrict the user to only setting the date via the calendar + el.set('readonly','readonly'); + el.addEvent('focus', function(cal) { + this.toggle(cal); + }.pass(cal, this)); + } + + cal.els.push(el); + + return true; + }, + + /** + * format: formats a date object according to passed in instructions + * + * @param date (obj) + * @param f (string) any combination of punctuation / separators and d, j, D, l, S, m, n, F, M, y, Y + * @returns string + */ + format: function(date, f) { + var g = ''; + + if (date) { + var d = date.getDate(); // 1 - 31 + var day = this.options.days[date.getDay()]; // Sunday - Saturday + var m = date.getMonth() + 1; // 1 - 12 + var month = this.options.months[date.getMonth()]; // January - December + var y = date.getFullYear() + ''; // 19xx - 20xx + + for (var i = 0; i < f.length; i++) { + var c = f.charAt(i); // format char + switch(c) { + // year cases + case 'y': // xx - xx + y = y.substr(2); + case 'Y': // 19xx - 20xx + g += y; + break; + + // month cases + case 'm': // 01 - 12 + if (m < 10) { + m = '0' + m; + } + case 'n': // 1 - 12 + g += m; + break; + + case 'M': // Jan - Dec + month = month.substr(0, 3); + case 'F': // January - December + g += month; + break; + + // day cases + case 'd': // 01 - 31 + if (d < 10) { + d = '0' + d; + } + case 'j': // 1 - 31 + g += d; + break; + + case 'D': // Sun - Sat + day = day.substr(0, 3); + case 'l': // Sunday - Saturday + g += day; + break; + + case 'S': // st, nd, rd or th (works well with j) + if (d % 10 == 1 && d != '11') { + g += 'st'; + } + else if (d % 10 == 2 && d != '12') { + g += 'nd'; + } + else if (d % 10 == 3 && d != '13') { + g += 'rd'; + } + else { + g += 'th'; + } + break; + + default: + g += c; + } + } + } + return g; // return format with values replaced + }, + + /** + * navigate: calendar navigation + * + * @param cal (obj) + * @param type (str) m or y for month or year + * @param n (int) + or - for next or prev + */ + navigate: function(cal, type, n) { + switch (type) { + case 'm': // month + if ($type(cal.months) == 'array') { + var i = cal.months.indexOf(cal.month) + n; // index of current month + + if (i < 0 || i == cal.months.length) { // out of range + if (this.options.navigation == 1) { // if type 1 nav we'll need to increment the year + this.navigate(cal, 'y', n); + } + + i = (i < 0) ? cal.months.length - 1 : 0; + } + + cal.month = cal.months[i]; + } + else { + var i = cal.month + n; + + if (i < 0 || i == 12) { + if (this.options.navigation == 1) { + this.navigate(cal, 'y', n); + } + + i = (i < 0) ? 11 : 0; + } + + cal.month = i; + } + break; + + case 'y': // year + if ($type(cal.years) == 'array') { + var i = cal.years.indexOf(cal.year) + n; + + cal.year = cal.years[i]; + } + else { + cal.year += n; + } + break; + } + $extend(cal, this.values(cal)); + if ($type(cal.months) == 'array') { // if the calendar has a months select + var i = cal.months.indexOf(cal.month); // and make sure the curr months exists for the new year + + if (i < 0) { + cal.month = cal.months[0]; + } // otherwise we'll reset the month + } + this.display(cal); + }, + + /** + * read: compiles cal value based on array of inputs passed in + * + * @param cal (obj) + * @returns date (obj) or (null) + */ + read: function(cal) { + var arr = [null, null, null]; + cal.els.each(function(el) { + // returns an array which may contain empty values + var values = this.unformat(el.get('value'), el.retrieve('format')); + + values.each(function(val, i) { + if ($type(val) == 'number') { + arr[i] = val; + } + }); + }, this); + // we can update the cals month and year values + if ($type(arr[0]) == 'number') { + cal.year = arr[0]; + } + if ($type(arr[1]) == 'number') { + cal.month = arr[1]; + } + var val = null; + if (arr.every(function(i) { + return $type(i) == 'number'; + })) { // if valid date + var last = new Date(arr[0], arr[1] + 1, 0).getDate(); // last day of month + + if (arr[2] > last) { + arr[2] = last; + } // make sure we stay within the month (ex in case default day of select is 31 and month is feb) + val = new Date(arr[0], arr[1], arr[2]); + } + return (cal.val == val) ? null : val; // if new date matches old return null (same date clicked twice = disable) + }, + + /** + * rebuild: rebuilds days + months selects + * + * @param cal (obj) + */ + rebuild: function(cal) { + cal.els.each(function(el) { + /* + if (el.getTag() == 'select' && el.format.test('^(F|m|M|n)$')) { // special case for months-only select + if (!cal.options) { cal.options = el.clone(); } // clone a copy of months select + + var val = (cal.val) ? cal.val.getMonth() : el.value.toInt(); + + el.empty(); // initialize select + + cal.months.each(function(month) { + // create an option element + var option = new Element('option', { + 'selected': (val == month), + 'value': this.format(new Date(1, month, 1), el.format); + }).appendText(day).injectInside(el); + }, this); + } + */ + if (el.get('tag') == 'select' && el.retrieve('format').test('^(d|j)$')) { // special case for days-only select + var d = this.value(cal); + + if (!d) { + d = el.get('value').toInt(); + } // if the calendar doesn't have a set value, try to use value from select + + el.empty(); // initialize select + + cal.days.each(function(day) { + // create an option element + var option = new Element('option', { + 'value': ((el.retrieve('format') == 'd' && day < 10) ? '0' + day : day) + }).appendText(day).inject(el); + if(d == day){ + option.set('selected','selected'); + } + }, this); + } + }, this); + }, + + /** + * sort: helper function for numerical sorting + * + * @param a (int) + * @param b (int) + */ + sort: function(a, b) { + return a - b; + }, + + /** + * toggle: show / hide calendar + * + * @param cal (obj) + */ + toggle: function(cal) { + document.removeEvent('mousedown', this.fn); // always remove the current mousedown script first + + if (cal.visible) { // simply hide curr cal + cal.visible = false; + cal.button.removeClass(this.classes.active); // active + this.fx.start('opacity', 0); //*RAL - Fx.Tween.start([property], from, [to]) + } + else { // otherwise show (may have to hide others) + // hide cal on out-of-bounds click + this.fn = function(e, cal) { + var event = new Event(e); + var el = $(event.target); + var stop = false; + + while (el != document.body && el.nodeType == 1) { + if (el == this.calendar) { + stop = true; + } + this.calendars.each(function(kal) { + if (kal.button == el || kal.els.contains(el)) { + stop = true; + } + }); + + if (stop) { + e.stop(); + return false; + } + else { + el = el.getParent(); + } + } + + this.toggle(cal); + }.create({ + 'arguments': cal, + 'bind': this, + 'event': true + }); + + document.addEvent('mousedown', this.fn); + + this.calendars.each(function(kal) { + if (kal == cal) { + kal.visible = true; + kal.button.addClass(this.classes.active); // css c-icon-active + } + else { + kal.visible = false; + kal.button.removeClass(this.classes.active); // css c-icon-active + } + }, this); + + this.display(cal); + + var size = window.getScrollSize(); + var coord = cal.button.getCoordinates(); + var x = coord.right + this.options.tweak.x; + var y = coord.top + this.options.tweak.y; + + // make sure the calendar doesn't open off screen (does't work in safari!?) + if (x + this.calendar.coord.width > size.x) { + x -= (x + this.calendar.coord.width - size.x); + } + if (y + this.calendar.coord.height > size.y) { + y -= (y + this.calendar.coord.height - size.y); + } + + this.calendar.setStyles({ + left: x + 'px', + top: y + 'px' + }); + // IE6 Iframe Fix + if (Browser.Engine.trident4) { + this.iframe.setStyles({ + left: x + 'px', + top: y + 'px', + height: this.calendar.coord.height + 'px', + width: this.calendar.coord.width + 'px' + }); + } + this.fx.start('opacity', 1); //*RAL - Fx.Tween.start([property], from, [to]) + } + }, + + /** + * unformat: takes a value from an input and parses the d, m and y elements + * + * @param val (string) + * @param f (string) any combination of punctuation / separators and d, j, D, l, S, m, n, F, M, y, Y + * @returns array + */ + unformat: function(val, f) { + f = f.escapeRegExp(); + + var re = { + d: '([0-9]{2})', + j: '([0-9]{1,2})', + D: '(' + this.options.days.map(function(day) { + return day.substr(0, 3); + }).join('|') + ')', + l: '(' + this.options.days.join('|') + ')', + S: '(st|nd|rd|th)', + F: '(' + this.options.months.join('|') + ')', + m: '([0-9]{2})', + M: '(' + this.options.months.map(function(month) { + return month.substr(0, 3); + }).join('|') + ')', + N: '([0-9]{1,2})', + n: '([0-9]{1,2})', + Y: '([0-9]{4})', + y: '([0-9]{2})' + }; + + var arr = []; // array of indexes + var g = ''; + + // convert our format string to regexp + for (var i = 0; i < f.length; i++) { + var c = f.charAt(i); + if (re[c]) { + arr.push(c); + g += re[c]; + } + else { + g += c; + } + } + + // match against date + var matches = val.match('^' + g + '$'); + var dates = new Array(3); + if (matches) { + matches = matches.slice(1); // remove first match which is the date + arr.each(function(c, i) { + i = matches[i]; + + switch(c) { + // year cases + case 'y': + i = '19' + i; // 2 digit year assumes 19th century (same as JS) + case 'Y': + dates[0] = i.toInt(); + break; + + // month cases + case 'F': + i = i.substr(0, 3); + case 'M': + i = this.options.months.map(function(month) { + return month.substr(0, 3); + }).indexOf(i) + 1; + case 'm': + case 'n': + dates[1] = i.toInt() - 1; + break; + + // day cases + case 'd': + case 'j': + dates[2] = i.toInt(); + break; + } + }, this); + } + return dates; + }, + + /** + * value: returns day value of calendar if set + * + * @param cal (obj) + * @returns day (int) or null + */ + value: function(cal) { + var day = null; + if (cal.val) { + if (cal.year == cal.val.getFullYear() && cal.month == cal.val.getMonth()) { + day = cal.val.getDate(); + } + } + return day; + }, + + /** + * values: returns the years, months (for curr year) and days (for curr month and year) for the calendar + * + * @param cal (obj) + * @returns obj + */ + values: function(cal) { + // make it possible + this.fireEvent('onValuesStart', this.element); + var years, months, days; + + cal.els.each(function(el) { + if (el.get('tag') == 'select') { + if (el.retrieve('format').test('(y|Y)')) { // search for a year select + years = []; + + el.getChildren().each(function(option) { // get options + var values = this.unformat(option.get('value'), el.retrieve('format')); + + if (!years.contains(values[0])) { + years.push(values[0]); + } // add to years array + }, this); + + years.sort(this.sort); + } + + if (el.retrieve('format').test('(F|m|M|n|N)')) { // search for a month select + months = []; // 0 - 11 should be + + el.getChildren().each(function(option) { // get options + var values = this.unformat(option.get('value'), el.retrieve('format')); + + if ($type(values[0]) != 'number' || values[0] == cal.year) { // if it's a year / month combo for curr year, or simply a month select + if (!months.contains(values[1])) { + months.push(values[1]); + } // add to months array + } + }, this); + + months.sort(this.sort); + } + + if (el.retrieve('format').test('(d|j)') && !el.retrieve('format').test('^(d|j)$')) { // search for a day select, but NOT a days only select + days = []; // 1 - 31 + + el.getChildren().each(function(option) { // get options + var values = this.unformat(option.get('value'), el.retrieve('format')); + + // in the special case of days we dont want the value if its a days only select + // otherwise that will screw up the options rebuilding + // we will take the values if they are exact dates though + if (values[0] == cal.year && values[1] == cal.month) { + if (!days.contains(values[2])) { + days.push(values[2]); + } // add to days array + } + }, this); + } + } + }, this); + + // we start with what would be the first and last days were there no restrictions + var first = 1; + var last = new Date(cal.year, cal.month + 1, 0).getDate(); // last day of the month + + // if we're in an out of bounds year + if (cal.year == cal.start.getFullYear()) { + // in the special case of improved navigation but no months array, we'll need to construct one + if (months == null && this.options.navigation == 2) { + months = []; + + for (var i = 0; i < 12; i ++) { + if (i >= cal.start.getMonth()) { + months.push(i); + } + } + } + + // if we're in an out of bounds month + if (cal.month == cal.start.getMonth()) { + first = cal.start.getDate(); // first day equals day of bound + } + } + if (cal.year == cal.end.getFullYear()) { + // in the special case of improved navigation but no months array, we'll need to construct one + if (months == null && this.options.navigation == 2) { + months = []; + + for (var i = 0; i < 12; i ++) { + if (i <= cal.end.getMonth()) { + months.push(i); + } + } + } + + if (cal.month == cal.end.getMonth()) { + last = cal.end.getDate(); // last day equals day of bound + } + } + + // let's get our invalid days + var blocked = this.blocked(cal); + + // finally we can prepare all the valid days in a neat little array + if ($type(days) == 'array') { // somewhere there was a days select + days = days.filter(function(day) { + if (day >= first && day <= last && !blocked.contains(day)) { + return day; + } + }); + } + else { // no days select we'll need to construct a valid days array + days = []; + + for (var i = first; i <= last; i++) { + if (!blocked.contains(i)) { + days.push(i); + } + } + } + + days.sort(this.sort); // sorting our days will give us first and last of month + + return { + 'days': days, + 'months': months, + 'years': years + }; + }, + + /** + * write: sets calendars value to form elements + * + * @param cal (obj) + */ + write: function(cal) { + this.rebuild(cal); // in the case of options, we'll need to make sure we have the correct number of days available + + cal.els.each(function(el) { // then we can set the value to the field + el.set('value',this.format(cal.val, el.retrieve('format'))); + }, this); + } +}); \ No newline at end of file diff --git a/static/stylesheets/calendar.css b/static/stylesheets/calendar.css new file mode 100644 index 000000000..60b6b567e --- /dev/null +++ b/static/stylesheets/calendar.css @@ -0,0 +1,99 @@ +/* + Document : calendar.css + Created on : 29.07.2009 + Author : jens klose + Description: + example styles for calendar +*/ +.calendarForm{ + float:right; +} +.calendar{ + font-family:verdana,sans-serif; + font-size:10px; + float:None; +} +button.calendar{ + position:relative; + top:-3px; + left:1px; + background-color:transparent; + background-image:url(../pics/calendar.gif); + border:none; + margin: 0px; + padding:0px; + margin-right:3px; + cursor:pointer; + width:16px; + height:16px; +} +input.calendar{ + cursor:pointer; + border: 1px solid #87847F; + height:12px; + width:100px; + color: #87847F; +} +div.calendar{ + background:white; + border: 1px solid #87847F; + width:180px +} +a.footer{ + color:#474747; + padding:2px; +} +.calendar table{ + width:100%; +} +.calendar th, +.calendar td{ + text-align:center; + font-size:10px; + padding: 0.1em 0.25em 0.1em 0.3em; +} +.calendar tbody td, +.calendar .invalid{ + color:silver; +} +.calendar .valid{ + color:blue; + font-weight:bold; +} +.calendar .valid.hover{ + background:#FFFF77; + cursor:pointer; + font-weight:bold; +} +.calendar caption a, +.calendar .next{ + color:blue; + font-weight:bold; + padding:2px; +} +.calendar .next.hover{ + background:#FFFF77; + cursor:pointer; + font-weight:bold; +} +.calendar .prev{ + color:blue; + font-weight:bold; + padding:2px; +} +.calendar .prev.hover{ + background:#FFFF77; + cursor:pointer; + font-weight:bold; +} +.calendar .active, +.calendar .hilite, +.calendar .inactive{ + color:white; + background-color:#BE3916; + font-weight:bold; +} +.calendar .active, +.calendar .inactive{ + background-color:red; +} \ No newline at end of file From 6074497a800730f2472c03cfd368fc5302abbdf6 Mon Sep 17 00:00:00 2001 From: Florent Dionisi Date: Tue, 1 Dec 2009 18:04:48 +0100 Subject: [PATCH 04/28] #24 : period selector added for adminmenu stats. TODO : better parsing of POST and GET request parameters in view functions. Add same view for usermenu stats. --- extensions/stats/grapher.py | 7 +- extensions/stats/logparser.py | 34 ++++++---- extensions/stats/main.py | 77 +++++++++++++++++----- extensions/stats/templates/adminindex.html | 14 ++-- extensions/stats/templates/calendar.html | 41 ++++++------ extensions/stats/templates/graphs.html | 2 +- extensions/stats/templates/index.html | 2 + 7 files changed, 116 insertions(+), 61 deletions(-) diff --git a/extensions/stats/grapher.py b/extensions/stats/grapher.py index 439e1273e..c1889b40e 100644 --- a/extensions/stats/grapher.py +++ b/extensions/stats/grapher.py @@ -49,6 +49,10 @@ size_avg_template = { } } +tpl = {'traffic':traffic_avg_template, + 'badtraffic':badtraffic_avg_template, + 'size':size_avg_template} + class Grapher(object): def __init__(self): self.rrd_rootdir = getoption("RRD_ROOTDIR", "/tmp") @@ -57,6 +61,7 @@ class Grapher(object): def process(self, target, suffix, start, end, tpl=traffic_avg_template): rrdfile = "%s/%s.rrd" % (self.rrd_rootdir, target) if not os.path.exists(rrdfile): + print "[graph] no rrd file detected" return ext = "png" path = "%s/%s_%s_%s_%s.%s" % (self.img_rootdir, tpl['name'], @@ -72,7 +77,7 @@ class Grapher(object): lines += ["%s:%spm%s:%s" % (type, v, d["color"], d["legend"].encode("utf-8"))] params = defs + lines - rrdtool.graph(path, + rrdtool.graph(str(path), "--imgformat", "PNG", "--width", tpl["width"], "--height", tpl["height"], diff --git a/extensions/stats/logparser.py b/extensions/stats/logparser.py index 003c7bf44..e2c449822 100755 --- a/extensions/stats/logparser.py +++ b/extensions/stats/logparser.py @@ -35,6 +35,25 @@ points_per_sample = 3 variables = ["sent", "recv", "bounced", "reject", "spam", "virus", "size_sent", "size_recv"] +def str2Time(y, M, d, h ="0", m ="0", s="0"): + """str2Time + + return epoch time from Year Month Day Hour:Minute:Second time format + """ + try: + local = time.strptime("%s %s %s %s:%s:%s" %(y, M, d, h, m, s), \ + "%Y %b %d %H:%M:%S") + except: + # try with 01-12 month format + try: + local = time.strptime("%s %s %s %s:%s:%s" %(y, M, d, h, m, s), \ + "%Y %m %d %H:%M:%S") + except: + print "[rrd] ERROR unrecognized %s time format" %(y, M, d, h, m, s) + return 0 + return int(time.mktime(local)) + + class LogParser(object): def __init__(self, logfile, workdir, year=None, debug=False, verbose=False): @@ -65,19 +84,6 @@ class LogParser(object): self.lupdates = {} self.line_expr = re.compile("(\w+)\s+(\d+)\s+(\d+):(\d+):(\d+)\s+(\w+)\s+(\w+)/?\w*[[](\d+)[]]:\s+(.*)") - def str2Time(self, y, M, d, h, m, s): - """str2Time - - return epoch time from Year Month Day Hour:Minute:Second time format - """ - try: - local = time.strptime("%s %s %s %s:%s:%s" %(y, M, d, h, m, s), \ - "%Y %b %d %H:%M:%S") - except: - print "[rrd] ERROR unrecognized %s time format" %(y, M, d, h, m, s) - return 0 - return int(time.mktime(local)) - def init_rrd(self, fname, m): """init_rrd @@ -198,7 +204,7 @@ class LogParser(object): se = int(int(se) / rrdstep) # rrd step is one-minute => se = 0 if prev_se != se or prev_mi != mi or prev_ho != ho: - cur_t = self.str2Time(self.year, mo, da, ho, mi, se) + cur_t = str2Time(self.year, mo, da, ho, mi, se) cur_t = cur_t - cur_t % rrdstep prev_mi = mi prev_ho = ho diff --git a/extensions/stats/main.py b/extensions/stats/main.py index d25825bb4..b59217344 100644 --- a/extensions/stats/main.py +++ b/extensions/stats/main.py @@ -16,9 +16,16 @@ from mailng.admin.views import good_domain from mailng.admin.models import Domain, Mailbox from django.contrib.auth.decorators \ import login_required, user_passes_test, permission_required +from extensions.stats.grapher import Grapher,tpl +from extensions.stats.logparser import str2Time graph_types = ['AVERAGE', 'MAX'] +graph_list = [{"name" : "traffic", "label" : _("Average normal traffic")}, + {"name" : "badtraffic", "label" : _("Average bad traffic")}, + {"name" : "size", "label" : _("Average normal traffic size")}] + + def init(): events.register("AdminMenuDisplay", menu) parameters.register("stats", "LOGFILE", "string", "/var/log/mail.log", @@ -100,9 +107,7 @@ def adminindex(request): domains = Domain.objects.all() return _render(request, 'stats/adminindex.html', { "domains" : domains, "domain" : domain, - "graphs" : [{"name" : "traffic", "label" : _("Average normal traffic")}, - {"name" : "badtraffic", "label" : _("Average bad traffic")}, - {"name" : "size", "label" : _("Average normal traffic size")}], + "graphs" : graph_list, "periods" : ["day", "week", "month", "year","Custom"], "period" : period, "cal" : CH_print }) @@ -110,18 +115,56 @@ def adminindex(request): @login_required @user_passes_test(lambda u: u.is_superuser) def custom_period(request): - print "[stats] json get" - start = request.GET.has_key("start") - if not start: - print "[stats] no start selected" - return - end = request.GET.has_key("end") - return _render(request, 'stats/graphs.html', { - "domain" : domain, - "graphs" : [{"name" : "traffic", "label" : _("Average normal traffic")}, - {"name" : "badtraffic", "label" : _("Average bad traffic")}, - {"name" : "size", "label" : _("Average normal traffic size")}], - "period" : "%s_%s" %(start,end) + start = None + end = None + start_ref = None + end_ref = None + domains = Domain.objects.all() + domain = request.GET.has_key("domain") and request.GET["domain"] or "global" + period = request.GET.has_key("period") and request.GET["period"] or "Custom" + + if domain != "global": + domain = Domain.objects.get(pk=domain) + domain = domain.name + try: + start_ref = str(request.POST.get('start')) + start = start_ref.split(', ')[1] + end_ref = str(request.POST.get('end')) + end = end_ref.split(', ')[1] + except: + pass + + if not end or not start: + return _render(request, 'stats/adminindex.html', { + "domains" : domains, "domain" : domain, + "graphs" : graph_list, + "period" : period, + "periods" : ["day", "week", "month", "year","Custom"], + "period_name":None, + "start" : start_ref, + "end" : end_ref, + "messages" : ["Custom period not selected"] + }) + + period_name = "%s_%s" %(start.replace('/',''),end.replace('/','')) + G = Grapher() + for tpl_name in graph_list: + G.process(domain, + period_name, + str2Time(*start.split('/')), + str2Time(*end.split('/')), + tpl[tpl_name['name']]) + + print "[stats] ", period + return _render(request, 'stats/adminindex.html', { + "domains" : domains, "domain" : domain, + "graphs" : graph_list, + "period" : period, + "periods" : ["day", "week", "month", "year","Custom"], + "period_name": period_name, + "start" : start_ref, + "end" : end_ref, + "messages" : None }) @login_required @@ -135,9 +178,7 @@ def index(request, dom_id=None): return _render(request, 'stats/index.html', { "domain" : domain, - "graphs" : [{"name" : "traffic", "label" : _("Average normal traffic")}, - {"name" : "badtraffic", "label" : _("Average bad traffic")}, - {"name" : "size", "label" : _("Average normal traffic size")}], + "graphs" : graph_list, "periods" : ["day", "week", "month", "year","Custom"], "period" : period }) diff --git a/extensions/stats/templates/adminindex.html b/extensions/stats/templates/adminindex.html index 1f170898f..815ae4907 100644 --- a/extensions/stats/templates/adminindex.html +++ b/extensions/stats/templates/adminindex.html @@ -49,14 +49,14 @@ window.addEvent("domready", function() { {% endfor %} {% ifequal period "Custom" %} - {% include "stats/calendar.html" %} - {% url extensions.stats.main.adminindex as c_stats_url %} + {% include "stats/calendar.html" %} + {% if start or end %} +

{% trans "Custom period : from" %} {{ start }} {% trans "to" %} {{ end }}

+ {% include "stats/graphs.html" %} + {% endif %} + {% else %} + {% include "stats/graphs.html" %} {% endifequal %} - - {% include "stats/graphs.html" %} {% endblock %} diff --git a/extensions/stats/templates/calendar.html b/extensions/stats/templates/calendar.html index d48d2bfff..90b302eb5 100644 --- a/extensions/stats/templates/calendar.html +++ b/extensions/stats/templates/calendar.html @@ -1,33 +1,34 @@ {% load i18n %}
-
- {% trans "Start" %}: - {% trans "End" %}: - -
+ {% url extensions.stats.main.custom_period as c_stats_url %} +
+ + {% trans "Start" %}: + {% trans "End" %}: + +
+ + diff --git a/templates/registration/login.html b/templates/registration/login.html index 53c8a1e2f..3d9cc6b7b 100644 --- a/templates/registration/login.html +++ b/templates/registration/login.html @@ -1,26 +1,40 @@ -{% extends "layout.html" %} {% load i18n %} -{% block html_head %} - -{% endblock %} -{% block content %} -
-

{% trans "Welcome" %}

-{% if form.errors %} -
-

{% trans "Your username and password didn't match. Please try again." %}

+ + + + + {% trans "Welcome to MailNG" %} + + + + + + {% url django.views.i18n.javascript_catalog as catalog_url %} + + + + +
+ +
+

{% trans "Welcome" %}

+
+ {% include "common/genericform.html" %} + +
+ +
+ +
+
+
-{% endif %} -
- {% for field in form %} -
- {{ field.label_tag }}{{ field }} -
- {% endfor %} -
- -
- -
-
-{% endblock %} + + diff --git a/urls.py b/urls.py index db5e81700..09d1c770b 100644 --- a/urls.py +++ b/urls.py @@ -21,7 +21,7 @@ urlpatterns = patterns('', (r'^mailng/admin/', include('mailng.admin.urls')), (r'^mailng/main/', include('mailng.main.urls')), - (r'^accounts/login/$', 'django.contrib.auth.views.login'), + (r'^accounts/login/$', 'mailng.auth.views.dologin'), (r'^accounts/logout/$', 'django.contrib.auth.views.logout_then_login'), (r'^jsi18n/$', 'django.views.i18n.javascript_catalog', {'packages': ('mailng',),}) From bf971301b6f5f7154bd350230e85a20e7635b52a Mon Sep 17 00:00:00 2001 From: tonio Date: Fri, 15 Jan 2010 18:06:41 +0100 Subject: [PATCH 06/28] More work about webmail. It's almost possible to view emails content! --- auth/views.py | 1 + extensions/amavis_quarantine/main.py | 69 ++----------------- extensions/webmail/imap_listing.py | 19 ++++-- extensions/webmail/main.py | 28 +++++++- extensions/webmail/templates/index.html | 2 + extensions/webmail/templates/viewmail.html | 47 +++++++++++++ extensions/webmail/templatetags/__init__.py | 0 extensions/webmail/templatetags/webextras.py | 20 ++++++ extensions/webmail/urls.py | 6 +- lib/email_listing.py | 72 +++++++++++++++++++- static/stylesheets/default.css | 2 +- static/stylesheets/login.css | 7 +- templates/common/getmailcontent.html | 1 + templates/nlayout.html | 7 +- 14 files changed, 204 insertions(+), 77 deletions(-) create mode 100644 extensions/webmail/templates/viewmail.html create mode 100644 extensions/webmail/templatetags/__init__.py create mode 100644 extensions/webmail/templatetags/webextras.py create mode 100644 templates/common/getmailcontent.html diff --git a/auth/views.py b/auth/views.py index 7cd567b80..75e730a90 100755 --- a/auth/views.py +++ b/auth/views.py @@ -16,6 +16,7 @@ def dologin(request): if user: if user.is_active: login(request, user) + request.session["password"] = request.POST["password"] next = request.POST["next"] if next == "None": diff --git a/extensions/amavis_quarantine/main.py b/extensions/amavis_quarantine/main.py index c6f319ce2..946e2bf4d 100644 --- a/extensions/amavis_quarantine/main.py +++ b/extensions/amavis_quarantine/main.py @@ -7,10 +7,7 @@ import time import email import re import os -import lxml.html -from lxml.html.clean import Cleaner from django.http import HttpResponseRedirect, HttpResponse -from django.template import Template from django.utils import simplejson from django.conf.urls.defaults import * from django.utils.translation import ugettext as _ @@ -24,16 +21,6 @@ from mailng.admin.models import Mailbox from lib import AMrelease from sql_listing import * -attached_map = {} - -def map_cid(url): - map = globals()["attached_map"] - m = re.match(".*cid:([^\"]+)", url) - if m: - if map.has_key(m.group(1)): - return map[m.group(1)] - return url - def init(): events.register("UserMenuDisplay", menu) parameters.register("amavis_quarantine", "MAX_MESSAGES_AGE", "int", 14, @@ -69,25 +56,10 @@ def index(request, message=None): lst = SQLlisting(filter) return lst.render(request, pageid=int(pageid), start=start) -@login_required -def viewmail_plain(request, content): - body = decode(content) - return (True, body) - -@login_required -def viewmail_html(request, content): - links = request.GET.has_key("links") and request.GET["links"] or "0" - html = lxml.html.fromstring(content) - if links == "0": - html.rewrite_links(lambda x: None) - else: - html.rewrite_links(map_cid) - body = lxml.html.tostring(html) - body = Template(decode(body)).render({}) - return (False, body) - @login_required def getmailcontent(request, mail_id): + from mailng.lib.email_listing import Email + conn = db.getconnection("amavis_quarantine") status, cursor = db.execute(conn, """ SELECT mail_text @@ -100,41 +72,8 @@ WHERE quarantine.mail_id='%s' msg = email.message_from_string(content) links = request.GET.has_key("links") and request.GET["links"] or "0" mode = request.GET.has_key("mode") and request.GET["mode"] or "plain" - contents = {"html" : "", "plain" : ""} - for part in msg.walk(): - #print part.get_content_type() - if part.get_content_maintype() == 'multipart': - continue - if part.get_content_type() in ("text/html", "text/plain"): - contents[part.get_content_subtype()] += part.get_payload(decode=True) - if mode != "html" or links == "0": - continue - - if part.get_content_maintype() == "image": - if part.has_key("Content-Location"): - fname = part["Content-Location"] - if re.match("^http:", fname): - path = fname - else: - path = "/%s" % os.path.join("static/tmp", fname) - fp = open(path, "wb") - fp.write(part.get_payload(decode=True)) - fp.close() - m = re.match("<(.+)>", part["Content-ID"]) - if m: - attached_map[m.group(1)] = path - else: - attached_map[part["Content-ID"]] = path - - if not contents.has_key(mode) or contents[mode] == "": - if mode == "html": - mode = "plain" - else: - mode = "html" - (pre, body) = globals()["viewmail_%s" % mode](request, contents[mode]) - return _render(request, 'amavis_quarantine/getmailcontent.html', { - "body" : body, "pre" : pre - }) + mail = Email(msg, mode, links) + return mail.render(request) @login_required def viewmail(request, mail_id): diff --git a/extensions/webmail/imap_listing.py b/extensions/webmail/imap_listing.py index b6ff4ad19..c5977d064 100644 --- a/extensions/webmail/imap_listing.py +++ b/extensions/webmail/imap_listing.py @@ -82,20 +82,27 @@ class IMAPconnector(MBconnector): result += [name] return sorted(result) - def fetch(self, start=None, stop=None, folder=None): - if not start or not stop: + def fetch(self, start=None, stop=None, folder=None, all=False): + if not start and not stop: return [] result = [] self.m.select(self._encodefolder(folder)) - range = "%d:%d" % (start, stop) - typ, data = self.m.fetch(range, '(BODY[HEADER.FIELDS (DATE FROM SUBJECT)])') - imapid = start + if start and stop: + range = "%d:%d" % (start, stop) + else: + range = start + if not all: + query = '(BODY[HEADER.FIELDS (DATE FROM TO SUBJECT)])' + else: + query = '(RFC822)' + typ, data = self.m.fetch(range, query) + imapid = int(start) for response_part in data: if isinstance(response_part, tuple): msg = email.message_from_string(response_part[1]) msg["imapid"] = imapid result += [msg] - imapid += 1 + imapid = imapid + 1 return result class ImapListing(EmailListing): diff --git a/extensions/webmail/main.py b/extensions/webmail/main.py index 684587404..cc2dc6381 100644 --- a/extensions/webmail/main.py +++ b/extensions/webmail/main.py @@ -41,7 +41,8 @@ def index(request): @login_required def folder(request, folder): - lst = ImapListing(folder=folder) + lst = ImapListing(request.user.username, request.session["password"], + folder=folder) try: pageid = request.GET["page"] except KeyError: @@ -55,3 +56,28 @@ def folder(request, folder): navbar = "" ctx = {"status" : "ok", "listing" : content, "navbar" : navbar} return HttpResponse(simplejson.dumps(ctx), mimetype="application/json") + +def fetchmail(request, mail_id, all=False): + mbc = IMAPconnector(parameters.get("webmail", "SERVER_ADDRESS"), 143) + mbc.login(request.user.username, request.session["password"]) + res = mbc.fetch(start=mail_id, all=all) + if len(res): + return res[0] + return None + +@login_required +def viewmail(request, mail_id): + header = fetchmail(request, mail_id) + pageid = request.GET.has_key("page") and request.GET["page"] or "1" + if header: + return _render(request, "webmail/viewmail.html", { + "header" : header, "pageid" : pageid + }) + +@login_required +def getmailcontent(request, mail_id): + from mailng.lib.email_listing import Email + + msg = fetchmail(request, mail_id, True) + email = Email(msg) + return email.render(request) diff --git a/extensions/webmail/templates/index.html b/extensions/webmail/templates/index.html index af4971081..eb15bc75d 100644 --- a/extensions/webmail/templates/index.html +++ b/extensions/webmail/templates/index.html @@ -57,6 +57,8 @@ $("navbar").set("html", resp.navbar); }}).get(); }); + {% url extensions.webmail.main.viewmail as view_url %} + bindRows("{{ view_url }}", "{{ current_page }}"); {% endblock %} {% block middletitle %}{% trans "Webmail" %}{% endblock %} {% block menu %} diff --git a/extensions/webmail/templates/viewmail.html b/extensions/webmail/templates/viewmail.html new file mode 100644 index 000000000..b2cb5e310 --- /dev/null +++ b/extensions/webmail/templates/viewmail.html @@ -0,0 +1,47 @@ +{% extends "nlayout.html" %} +{% load i18n %} +{% block html_head %} + + +{% endblock %} +{% block middletitle %}{% trans "Email content" %}{% endblock %} +{% block menu %} + {% load webextras %} + {% viewm_menu "" header.imapid pageid perms %} +{% endblock %} +{% block content %} +
+ + + + + +
{% trans "Subject" %}{{ header.Subject }}
{% trans "From" %}{{ header.From }}
{% trans "To" %}{{ header.To }}
{% trans "Date" %}{{ header.Date }}
+
+ +{% url extensions.webmail.main.getmailcontent header.imapid as geturl %} + +{% endblock %} diff --git a/extensions/webmail/templatetags/__init__.py b/extensions/webmail/templatetags/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/extensions/webmail/templatetags/webextras.py b/extensions/webmail/templatetags/webextras.py new file mode 100644 index 000000000..99bc4cc5c --- /dev/null +++ b/extensions/webmail/templatetags/webextras.py @@ -0,0 +1,20 @@ +from django import template +from django.template.loader import render_to_string +from django.utils.translation import ugettext as _ +from django.core.urlresolvers import reverse +from mailng.extensions import webmail + +register = template.Library() + +@register.simple_tag +def viewm_menu(selection, mail_id, page_id, perms): + entries = [ + {"name" : "back", + "url" : reverse(webmail.main.index) + "?page=%s" % page_id, + "img" : "/static/pics/back.png", + "label" : _("Back to list")}, + ] + + return render_to_string('main/menu.html', + {"selection" : selection, "entries" : entries, + "perms" : perms}) diff --git a/extensions/webmail/urls.py b/extensions/webmail/urls.py index c61787ee9..0203315fd 100644 --- a/extensions/webmail/urls.py +++ b/extensions/webmail/urls.py @@ -2,5 +2,9 @@ from django.conf.urls.defaults import * urlpatterns = patterns('mailng.extensions.webmail.main', (r'^$', "index"), - (r'^(?P.+)/$', "folder"), + url(r'^viewmail/(?P[\w\-\+]+)/$', 'viewmail', + name="viewmail_std"), + url(r'^viewmail/$', 'viewmail', name="viewmail_null"), + (r'^getmailcontent/(?P[\w\-\+]+)/$', 'getmailcontent'), +# (r'^(?P.+)/$', "folder"), ) diff --git a/lib/email_listing.py b/lib/email_listing.py index e56867631..c5bdf5d6a 100644 --- a/lib/email_listing.py +++ b/lib/email_listing.py @@ -1,7 +1,11 @@ # -*- coding: utf-8 -*- import time +import lxml from django.template.loader import render_to_string -from mailng.lib import _render +from django.template import Template +from mailng.lib import _render, decode + +attached_map = {} class MBconnector(object): def __init__(self, address, port): @@ -93,3 +97,69 @@ class EmailListing: "selection" : self.folder, "current_page" : pageid }) + +class Email(object): + def __init__(self, msg, mode="plain", links="0"): + self.attached_map = {} + contents = {"html" : "", "plain" : ""} + for part in msg.walk(): + if part.get_content_maintype() == 'multipart': + continue + if part.get_content_type() in ("text/html", "text/plain"): + contents[part.get_content_subtype()] += part.get_payload(decode=True) + if mode != "html" or links == "0": + continue + + if part.get_content_maintype() == "image": + if part.has_key("Content-Location"): + fname = part["Content-Location"] + if re.match("^http:", fname): + path = fname + else: + path = "/%s" % os.path.join("static/tmp", fname) + fp = open(path, "wb") + fp.write(part.get_payload(decode=True)) + fp.close() + m = re.match("<(.+)>", part["Content-ID"]) + if m: + self.attached_map[m.group(1)] = path + else: + self.attached_map[part["Content-ID"]] = path + + if not contents.has_key(mode) or contents[mode] == "": + if mode == "html": + mode = "plain" + else: + mode = "html" + self.pre, self.body = \ + getattr(self, "viewmail_%s" % mode)(contents[mode], links=links) + + def map_cid(self, url): + import re + + map = globals()["attached_map"] + m = re.match(".*cid:([^\"]+)", url) + if m: + if map.has_key(m.group(1)): + return map[m.group(1)] + return url + + def render(self, request): + return _render(request, 'common/getmailcontent.html', { + "body" : self.body, "pre" : self.pre + }) + + def viewmail_plain(self, content, **kwargs): + body = decode(content) + return (True, body) + + def viewmail_html(self, content, **kwargs): + links = kwargs.has_key("links") and kwargs["links"] or "0" + html = lxml.html.fromstring(content) + if links == "0": + html.rewrite_links(lambda x: None) + else: + html.rewrite_links(self.map_cid) + body = lxml.html.tostring(html) + body = Template(decode(body)).render({}) + return (False, body) diff --git a/static/stylesheets/default.css b/static/stylesheets/default.css index 84ef58b6c..07b9c14e0 100644 --- a/static/stylesheets/default.css +++ b/static/stylesheets/default.css @@ -4,7 +4,7 @@ html, body { background: #153F65; margin: 0; padding: 0; - + height: 100%; } #topmenubar { diff --git a/static/stylesheets/login.css b/static/stylesheets/login.css index 11060e5c0..798736283 100644 --- a/static/stylesheets/login.css +++ b/static/stylesheets/login.css @@ -10,8 +10,13 @@ html, body { margin: 100px auto; width: 610px; padding-bottom: 15px; - border: 5px solid #333; background: white; + + border: 5px solid #333; + /*-moz-box-shadow: 0 10px 30px #222;*/ + -webkit-box-shadow: 0 10px 30px #222; + -moz-border-radius: 10px; + -webkit-border-radius: 5px; } #loginbox h2 { diff --git a/templates/common/getmailcontent.html b/templates/common/getmailcontent.html new file mode 100644 index 000000000..872bdaf88 --- /dev/null +++ b/templates/common/getmailcontent.html @@ -0,0 +1 @@ +{% if pre %}
{% endif %}{{ body }}{% if pre %}
{% endif %} diff --git a/templates/nlayout.html b/templates/nlayout.html index 85ef5d431..f86d4ab33 100644 --- a/templates/nlayout.html +++ b/templates/nlayout.html @@ -22,8 +22,13 @@
- From 35f840fbaba758d837bfd04e3eb5a723327714d0 Mon Sep 17 00:00:00 2001 From: tonio Date: Fri, 5 Mar 2010 11:33:53 +0100 Subject: [PATCH 24/28] It is now possible to access emails attachments. --- extensions/webmail/imap_listing.py | 1 + extensions/webmail/main.py | 49 +++++++++++++++------- extensions/webmail/templates/headers.html | 12 ++++++ extensions/webmail/templates/viewmail.html | 7 ++++ extensions/webmail/urls.py | 1 + lib/email_listing.py | 21 ++++++++-- 6 files changed, 74 insertions(+), 17 deletions(-) diff --git a/extensions/webmail/imap_listing.py b/extensions/webmail/imap_listing.py index 92c84d1d4..7d5bd9371 100644 --- a/extensions/webmail/imap_listing.py +++ b/extensions/webmail/imap_listing.py @@ -243,3 +243,4 @@ class ImapEmail(Email): self.headers += [{"name" : label, "value" : getattr(IMAPheader, "parse_%s" % f.lower())(msg[f])}] except AttributeError: self.headers += [{"name" : label, "value" : msg[f]}] + diff --git a/extensions/webmail/main.py b/extensions/webmail/main.py index 69bf4d0db..06157f57e 100644 --- a/extensions/webmail/main.py +++ b/extensions/webmail/main.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- import time import sys -from django.http import HttpResponse +from django.http import HttpResponse, Http404 from django.template import Template, Context from django.template.loader import render_to_string from django.utils import simplejson @@ -114,19 +114,40 @@ def viewmail(request, folder, mail_id=None): @login_required def getmailcontent(request, folder, mail_id): - msg = fetchmail(request, folder, mail_id, True) - if "class" in msg.keys() and msg["class"] == "unseen": - IMAPconnector(request).msgseen(msg["imapid"]) - email = ImapEmail(msg, mode="html", links="1") - try: - pageid = request.session["page"] - except KeyError: - pageid = "1" - folder, imapid = msg["imapid"].split("/") - return _render(request, "webmail/viewmail.html", { - "headers" : email.render_headers(), "folder" : folder, - "imapid" : imapid, "mailbody" : email.body, "pre" : email.pre - }) + msg = fetchmail(request, folder, mail_id, True) + if "class" in msg.keys() and msg["class"] == "unseen": + IMAPconnector(request).msgseen(msg["imapid"]) + email = ImapEmail(msg, mode="html", links="1") + try: + pageid = request.session["page"] + except KeyError: + pageid = "1" + folder, imapid = msg["imapid"].split("/") + return _render(request, "webmail/viewmail.html", { + "headers" : email.render_headers(folder, mail_id), + "folder" : folder, "imapid" : imapid, "mailbody" : email.body, + "pre" : email.pre + }) + +@login_required +def getattachment(request, folder, mail_id): + msg = fetchmail(request, folder, mail_id, True) + for part in msg.walk(): + fname = part.get_filename() + if fname is None: + continue + decoded = decode_header(fname) + if decoded[0][1] is None: + fname = unicode(decoded[0][0]) + else: + fname = unicode(decoded[0][0], decoded[0][1]) + if fname == request.GET["fname"]: + resp = HttpResponse(part.get_payload(decode=1)) + for hdr in ["Content-Disposition", "Content-Type"]: + resp[hdr] = re.sub("\s", "", part[hdr]) + resp["Content-Length"] = len(resp.content) + return resp + raise Http404 @login_required def move(request): diff --git a/extensions/webmail/templates/headers.html b/extensions/webmail/templates/headers.html index 4c06206cc..213b8a759 100644 --- a/extensions/webmail/templates/headers.html +++ b/extensions/webmail/templates/headers.html @@ -1,5 +1,17 @@ +{% load i18n %} {% for hdr in headers %} {% endfor %} + {% if attachments %} + + + + + {% endif %}
{{ hdr.name }}{{ hdr.value }}
{% trans "Attachments" %} + {% url extensions.webmail.main.getattachment folder mail_id as test %} + {% for attachment in attachments %} + {{ attachment }} + {% endfor %} +
diff --git a/extensions/webmail/templates/viewmail.html b/extensions/webmail/templates/viewmail.html index 5830936cd..640958ad4 100644 --- a/extensions/webmail/templates/viewmail.html +++ b/extensions/webmail/templates/viewmail.html @@ -33,6 +33,13 @@ #emailheaders th { background: #F7F9Fb; } + #emailheaders a, #emailheaders a:visited { + color: blue; + text-decoration: none; + } + #emailheaders a:hover { + text-decoration: underline; + } diff --git a/extensions/webmail/urls.py b/extensions/webmail/urls.py index 7ef16e31b..e088de8ed 100644 --- a/extensions/webmail/urls.py +++ b/extensions/webmail/urls.py @@ -5,6 +5,7 @@ urlpatterns = patterns('mailng.extensions.webmail.main', (r'^compose/$', "compose"), (r'^move/$', "move"), (r'^empty/(?P.+)/', "empty"), + (r'^(?P.+)/(?P[\w\-\+]+)/part/$', 'getattachment'), (r'^(?P.+)/(?P[\w\-\+]+)/content/$', 'getmailcontent'), (r'^(?P.+)/(?P[\w\-\+]+)/reply/$', 'reply'), (r'^(?P.+)/(?P[\w\-\+]+)/$', 'viewmail'), diff --git a/lib/email_listing.py b/lib/email_listing.py index c879d1371..216143ae2 100644 --- a/lib/email_listing.py +++ b/lib/email_listing.py @@ -1,11 +1,13 @@ # -*- coding: utf-8 -*- import time import re +from email.header import decode_header import lxml import lxml.html from lxml import etree from django.template.loader import render_to_string from django.template import Template, Context +from django.utils.translation import ugettext as _ from mailng.lib import _render, decode attached_map = {} @@ -126,9 +128,13 @@ class Email(object): self.attached_map = {} contents = {"html" : "", "plain" : ""} self.headers = [] + self.attachments = [] for part in msg.walk(): if part.get_content_maintype() == 'multipart': continue + if part.has_key("Content-Disposition"): + if re.match("^attachment", part["Content-Disposition"]): + self.attachments += [part] if part.get_content_type() in ("text/html", "text/plain"): contents[part.get_content_subtype()] += part.get_payload(decode=True) if mode != "html" or links == "0": @@ -168,9 +174,18 @@ class Email(object): return map[m.group(1)] return url - def render_headers(self): + def render_headers(self, folder, mail_id): + attachments = [] + for part in self.attachments: + decoded = decode_header(part.get_filename()) + if decoded[0][1] is None: + attachments += [decoded[0][0]] + else: + attachments += [unicode(decoded[0][0], decoded[0][1])] return render_to_string("webmail/headers.html", { - "headers" : self.headers + "headers" : self.headers, + "folder" : folder, "mail_id" : mail_id, + "attachments" : attachments != [] and attachments or None }) def render(self, request): @@ -190,7 +205,7 @@ class Email(object): else: html.rewrite_links(self.map_cid) body = html.find("body") - if not body: + if body is None: body = lxml.html.tostring(html) else: body = lxml.html.tostring(body) From a3ea75ff223a1ea12b379a6e33df2180cc574280 Mon Sep 17 00:00:00 2001 From: tonio Date: Fri, 5 Mar 2010 13:53:14 +0100 Subject: [PATCH 25/28] Ticket #33 --- extensions/webmail/main.py | 6 ++++++ extensions/webmail/templates/index.html | 9 ++++++++- extensions/webmail/templates/viewmail.html | 2 +- extensions/webmail/templatetags/webextras.py | 4 ++++ extensions/webmail/urls.py | 1 + static/js/common.js | 9 ++------- 6 files changed, 22 insertions(+), 9 deletions(-) diff --git a/extensions/webmail/main.py b/extensions/webmail/main.py index 06157f57e..b87224cd8 100644 --- a/extensions/webmail/main.py +++ b/extensions/webmail/main.py @@ -163,6 +163,12 @@ def move(request): mbc.move(",".join(msgset), fdname, request.GET["to"]) return folder(request, fdname, False) +@login_required +def delete(request, fdname, mail_id): + mbc = IMAPconnector(request) + mbc.move(mail_id, fdname, "Trash") + return folder(request, fdname, False) + @login_required def empty(request, name): mbc = IMAPconnector(request) diff --git a/extensions/webmail/templates/index.html b/extensions/webmail/templates/index.html index fa3f6354f..988f20e67 100644 --- a/extensions/webmail/templates/index.html +++ b/extensions/webmail/templates/index.html @@ -160,7 +160,14 @@ tr.selected { updatelisting(resp, "hidden"); $$("a[name=back]").addEvent("click", loadFolder); $$("a[name=reply]").addEvent("click", reply); - setDivHeight("mailcontent", /*$("table-container").getSize().y*/0, 0); + $$("a[name=delete]").addEvent("click", function(event) { + var lnk = event.target; + + event.stop(); + new Request.JSON({url: lnk.get("href"), + onSuccess: get_callback("folder")}).get(); + }); + setDivHeight("mailcontent", 5, 0); }); compose_callback = function(resp) { diff --git a/extensions/webmail/templates/viewmail.html b/extensions/webmail/templates/viewmail.html index 640958ad4..f5798cb47 100644 --- a/extensions/webmail/templates/viewmail.html +++ b/extensions/webmail/templates/viewmail.html @@ -22,7 +22,7 @@ } #emailheaders td, #emailheaders th { - height: 20px; + height: 22px; text-align: left; padding-left: 10px; border: 1px solid #ccc; diff --git a/extensions/webmail/templatetags/webextras.py b/extensions/webmail/templatetags/webextras.py index b371fa337..c393d09c7 100644 --- a/extensions/webmail/templatetags/webextras.py +++ b/extensions/webmail/templatetags/webextras.py @@ -42,6 +42,10 @@ def viewm_menu(selection, session, mail_id, perms): "url" : "", "img" : "/static/pics/alias.png", "label" : _("Forward")}, + {"name" : "delete", + "img" : "/static/pics/remove.png", + "url" : reverse(webmail.main.delete, args=[session["folder"], mail_id]), + "label" : _("Delete")}, ] return render_to_string('main/menu.html', diff --git a/extensions/webmail/urls.py b/extensions/webmail/urls.py index e088de8ed..84a3f3374 100644 --- a/extensions/webmail/urls.py +++ b/extensions/webmail/urls.py @@ -6,6 +6,7 @@ urlpatterns = patterns('mailng.extensions.webmail.main', (r'^move/$', "move"), (r'^empty/(?P.+)/', "empty"), (r'^(?P.+)/(?P[\w\-\+]+)/part/$', 'getattachment'), + (r'^(?P.+)/(?P[\w\-\+]+)/delete/$', 'delete'), (r'^(?P.+)/(?P[\w\-\+]+)/content/$', 'getmailcontent'), (r'^(?P.+)/(?P[\w\-\+]+)/reply/$', 'reply'), (r'^(?P.+)/(?P[\w\-\+]+)/$', 'viewmail'), diff --git a/static/js/common.js b/static/js/common.js index 46c13f0df..2f64d6e4e 100644 --- a/static/js/common.js +++ b/static/js/common.js @@ -210,15 +210,10 @@ confirmation = function(question, action, callback) { setDivHeight = function(id, extrah, modulo) { var contentsize = $(document.body).getSize().y - $("topmenubar").getSize().y - - $("header").getSize().y + - $("header").getSize().y + - $("footer").getSize().y - extrah; - if ($("navbar")) { - contentsize -= $("navbar").getSize().y; - } - if ($("thead")) { - contentsize -= $("thead").getSize().y; - } if (modulo) { contentsize -= contentsize % modulo; } From 312ebe57ec5030577547380a0dbbddddcb70cfc6 Mon Sep 17 00:00:00 2001 From: tonio Date: Fri, 5 Mar 2010 17:56:55 +0100 Subject: [PATCH 26/28] Compose, Reply, Reply all. New paramaters to configure connections to IMAP and SMTP servers. --- extensions/amavis_quarantine/sql_listing.py | 2 +- extensions/webmail/forms.py | 2 +- extensions/webmail/imap_listing.py | 63 +++++++++----- extensions/webmail/main.py | 84 ++++++++++++++----- extensions/webmail/templates/compose.html | 7 +- extensions/webmail/templates/index.html | 16 ++-- extensions/webmail/templates/viewmail.html | 3 + extensions/webmail/templatetags/webextras.py | 4 +- lib/email_listing.py | 10 ++- static/pics/settings.png | Bin 611 -> 629 bytes templates/nlayout.html | 2 +- 11 files changed, 138 insertions(+), 55 deletions(-) diff --git a/extensions/amavis_quarantine/sql_listing.py b/extensions/amavis_quarantine/sql_listing.py index 5e29fc3e7..fe24843a8 100644 --- a/extensions/amavis_quarantine/sql_listing.py +++ b/extensions/amavis_quarantine/sql_listing.py @@ -57,7 +57,7 @@ AND quarantine.chunk_ind=1 ORDER BY msgs.time_num DESC """ % self.filter - def messages_count(self, folder=None): + def messages_count(self, **kwargs): return self.count def fetch(self, start=None, stop=None, **kwargs): diff --git a/extensions/webmail/forms.py b/extensions/webmail/forms.py index a57423774..32c5ede87 100644 --- a/extensions/webmail/forms.py +++ b/extensions/webmail/forms.py @@ -4,6 +4,6 @@ from django.utils.translation import ugettext as _ class ComposeMailForm(forms.Form): from_ = forms.CharField(label=_("From")) to = forms.CharField(label=_("To"), widget=forms.Textarea()) - cc = forms.CharField(label=_("Cc"), widget=forms.Textarea()) + cc = forms.CharField(label=_("Cc"), widget=forms.Textarea(), required=False) subject = forms.CharField(label=_("Subject"), max_length=255) diff --git a/extensions/webmail/imap_listing.py b/extensions/webmail/imap_listing.py index 7d5bd9371..2a3fc2dbb 100644 --- a/extensions/webmail/imap_listing.py +++ b/extensions/webmail/imap_listing.py @@ -31,21 +31,36 @@ class WMtable(tables.Table): except AttributeError: return value -class IMAPheader(object): - addr_exp = re.compile("([^<\(]+)<|\(([^>\)]+)>|\)") - name_exp = re.compile("\"(.+)\"") +class EmailAddress(object): + addr_exp = re.compile("([^<\(]+)[<(]([^>)]+)[>)]") + name_exp = re.compile('"([^"]+)"') + def __init__(self, address): + self.fulladdress = address + self.name = self.address = None + m = EmailAddress.addr_exp.match(self.fulladdress) + if m is None: + return + name = m.group(1).strip() + self.address = m.group(2).strip() + m2 = EmailAddress.name_exp.match(name) + if m2: + name = m2.group(1) + self.name = "" + for part in decode_header(name): + if self.name != "": + self.name += " " + if part[1] is None: + self.name += part[0] + else: + self.name += unicode(part[0], part[1]) + self.fulladdress = "%s <%s>" % (self.name, self.address) + +class IMAPheader(object): @staticmethod def parse_address(value): - m = IMAPheader.addr_exp.match(value) - if m: - name = m.group(1) - name = name.strip() - m2 = IMAPheader.name_exp.match(name) - if m2: - return IMAPheader.parse_subject(m2.group(1)) - return IMAPheader.parse_subject(name) - return value + addr = EmailAddress(value) + return addr.name and addr.name or value @staticmethod def parse_address_list(values): @@ -126,16 +141,27 @@ class IMAPconnector(object): return "INBOX" return folder.encode("imap4-utf-7") - def messages_count(self, folder=None, order=None): - if order: - sign = order[:1] - criterion = order[1:].upper() + def messages_count(self, **kwargs): + """An enhanced version of messages_count + + With IMAP, to know how many messages a mailbox contains, we + have to make a request to the server. To avoid requests + multiplications, we sort messages in the same time. This will + be usefull for other methods. + + :param order: sorting order + :param folder: mailbox to scan + """ + if "order" in kwargs.keys() and kwargs["order"]: + sign = kwargs["order"][:1] + criterion = kwargs["order"][1:].upper() if sign == '-': criterion = "REVERSE %s" % criterion else: criterion = "REVERSE DATE" print criterion + folder = kwargs.has_key("folder") and kwargs["folder"] or None (status, data) = self.m.select(self._encodefolder(folder)) (status, data) = self.m.sort("(%s)" % criterion, "UTF-8", "(NOT DELETED)") self.messages = data[0].split() @@ -210,9 +236,8 @@ class ImapListing(EmailListing): tpl = "webmail/index.html" tbltype = WMtable - def __init__(self, user, password, baseurl=None, navparams={}, **kwargs): + def __init__(self, user, password, baseurl=None, **kwargs): self.mbc = IMAPconnector(user=user, password=password) - self.navparams = navparams EmailListing.__init__(self, **kwargs) if baseurl: self.paginator.baseurl = baseurl @@ -232,7 +257,7 @@ class ImapEmail(Email): def __init__(self, msg, **kwargs): Email.__init__(self, msg, **kwargs) - fields = ["Subject", "From", "To", "Cc", "Date"] + fields = ["Subject", "From", "To", "Reply-To", "Cc", "Date"] for f in fields: label = f if not f in msg.keys(): diff --git a/extensions/webmail/main.py b/extensions/webmail/main.py index b87224cd8..d25263f7a 100644 --- a/extensions/webmail/main.py +++ b/extensions/webmail/main.py @@ -22,13 +22,18 @@ def init(): parameters.register("webmail", "IMAP_SERVER", "string", "127.0.0.1", help=_("Address of your IMAP server")) parameters.register("webmail", "IMAP_SECURED", "list_yesno", "no", - help=_("Use a secure connexion to access IMAP server")) + help=_("Use a secured connection to access IMAP server")) parameters.register("webmail", "IMAP_PORT", "int", "143", help=_("Listening port of your IMAP server")) parameters.register("webmail", "SMTP_SERVER", "string", "127.0.0.1", help=_("Address of your SMTP server")) parameters.register("webmail", "SMTP_PORT", "int", "25", help=_("Listening port of your SMTP server")) + parameters.register("webmail", "SMTP_AUTHENTICATION", "list_yesno", "no", + help=_("Server needs authentication")) + parameters.register("webmail", "SMTP_SECURED", "list_yesno", "no", + help=_("Use a secured connection to access SMTP server")) + def urls(): return (r'^mailng/webmail/', @@ -57,7 +62,7 @@ def getctx(status, level=1, **kwargs): def folder(request, name, updatenav=True): if not name: name = "INBOX" - order = request.GET.has_key("order") and request.GET["order"] or None + order = request.GET.has_key("order") and request.GET["order"] or "-date" if updatenav: pageid = request.GET.has_key("page") and int(request.GET["page"]) or 1 if not "navparams" in request.session.keys(): @@ -183,23 +188,40 @@ def render_compose(request, form, bodyheader=None, body=None): ctx = getctx("ok", level=2, menu=menu, listing=content) return HttpResponse(simplejson.dumps(ctx), mimetype="application/json") +def send_mail(request): + form = ComposeMailForm(request.POST) + if form.is_valid(): + from email.mime.text import MIMEText + import smtplib + + msg = MIMEText(request.POST["id_body"].encode("utf-8"), + _charset="utf-8") + msg["Subject"] = request.POST["subject"] + msg["From"] = request.POST["from_"] + msg["To"] = request.POST["to"] + rcpts = msg['To'].split(',') + if "cc" in request.POST.keys(): + msg["Cc"] = request.POST["cc"] + rcpts += msg["Cc"].split(",") + try: + s = smtplib.SMTP(parameters.get("webmail", "SMTP_SERVER")) + if parameters.get("webmail", "SMTP_SECURED") == "yes": + s.starttls() + except (smtplib.SMTPException, ssl.SSLError), error: + print error + + if parameters.get("webmail", "SMTP_AUTHENTICATION") == "yes": + s.login(request.user.username, request.session["password"]) + s.sendmail(msg['From'], rcpts, msg.as_string()) + s.quit() + ctx = getctx("ok", url="%s?page=%s" % (request.session["folder"], + request.session["page"])) + return HttpResponse(simplejson.dumps(ctx), mimetype="application/json") + @login_required def compose(request): if request.method == "POST": - form = ComposeMailForm(request.POST) - if form.is_valid(): - from email.mime.text import MIMEText - import smtplib - - msg = MIMEText(request.POST["id_body"].encode("utf-8"), - _charset="utf-8") - msg["Subject"] = request.POST["subject"] - msg["From"] = request.POST["from_"] - msg["to"] = request.POST["to"] - s = smtplib.SMTP(parameters.get("webmail", "SMTP_SERVER")) - s.sendmail(msg['From'], [msg['To']], msg.as_string()) - s.quit() - return folder(request, request.session["navparams"]["folder"], False) + return send_mail(request) form = ComposeMailForm() form.fields["from_"].initial = request.user.username @@ -208,7 +230,7 @@ def compose(request): @login_required def reply(request, folder, mail_id): if request.method == "POST": - return + return send_mail(request) msg = fetchmail(request, folder, mail_id, True) email = ImapEmail(msg) lines = email.body.split('\n') @@ -219,11 +241,27 @@ def reply(request, folder, mail_id): body += ">%s" % l form = ComposeMailForm() form.fields["from_"].initial = request.user.username - form.fields["to"].initial = msg["From"] - m = re.match("re\s*:\s*.+", msg["Subject"].lower()) - if m: - form.fields["subject"].initial = msg["Subject"] + if not "Reply-To" in msg.keys(): + form.fields["to"].initial = msg["From"] else: - form.fields["subject"].initial = "Re: %s" % msg["Subject"] - textheader = msg["From"] + " " + _("wrote:") + form.fields["to"].initial = msg["Reply-To"] + if request.GET.has_key("all"): + form.fields["cc"].initial = "" + toparse = msg["To"].split(",") + if "Cc" in msg.keys(): + toparse += msg["Cc"].split(",") + for addr in toparse: + tmp = EmailAddress(addr) + if tmp.address and tmp.address == request.user.username: + continue + if form.fields["cc"].initial != "": + form.fields["cc"].initial += ", " + form.fields["cc"].initial += tmp.fulladdress + subject = IMAPheader.parse_subject(msg["Subject"]) + m = re.match("re\s*:\s*.+", subject.lower()) + if m: + form.fields["subject"].initial = subject + else: + form.fields["subject"].initial = "Re: %s" % subject + textheader = EmailAddress(msg["From"]).fulladdress + " " + _("wrote:") return render_compose(request, form, textheader, body) diff --git a/extensions/webmail/templates/compose.html b/extensions/webmail/templates/compose.html index 01bb05acd..369cbbd62 100644 --- a/extensions/webmail/templates/compose.html +++ b/extensions/webmail/templates/compose.html @@ -43,8 +43,13 @@
{% include "common/genericform.html" %}
+ {% if body %} +{{ body }} + + {% else %} + + {% endif %} diff --git a/extensions/webmail/templates/index.html b/extensions/webmail/templates/index.html index 988f20e67..80be116bb 100644 --- a/extensions/webmail/templates/index.html +++ b/extensions/webmail/templates/index.html @@ -160,6 +160,7 @@ tr.selected { updatelisting(resp, "hidden"); $$("a[name=back]").addEvent("click", loadFolder); $$("a[name=reply]").addEvent("click", reply); + $$("a[name=replyall]").addEvent("click", replyall); $$("a[name=delete]").addEvent("click", function(event) { var lnk = event.target; @@ -173,16 +174,16 @@ tr.selected { compose_callback = function(resp) { updatelisting(resp); window.addEvent("resize", function(evt) { - var bodysize = $("listing").getSize().y - $("mailheader").getSize().y; - $("id_body").setStyle("height", bodysize + "px"); + setDivHeight("id_body", $("mailheader").getSize().y, 0); }); $$("a[name=back]").addEvent("click", loadFolder); $$("a[name=sendmail]").addEvent("click", function(evt) { evt.stop(); $("composemail").set("send", { - onSuccess: function(res) { - updatelisting(JSON.decode(res)); - $$("tr").addEvent("dblclick", viewmail); + onSuccess: function(resp) { + resp = JSON.decode(resp); + current_anchor.parse_string(resp.url, true).setparams(navparams); + current_anchor.update(); } }); $("composemail").send(); @@ -201,6 +202,11 @@ tr.selected { location.hash += event.target.get("href"); } register_callback("reply", compose_callback); + replyall = function(event) { + event.stop(); + location.hash += event.target.get("href") + "?all=1"; + } + register_callback("replyall", compose_callback); loadFolder = function(event, obj) { if (!$defined(obj)) { diff --git a/extensions/webmail/templates/viewmail.html b/extensions/webmail/templates/viewmail.html index f5798cb47..1c49fc399 100644 --- a/extensions/webmail/templates/viewmail.html +++ b/extensions/webmail/templates/viewmail.html @@ -40,6 +40,9 @@ #emailheaders a:hover { text-decoration: underline; } + pre { + font-size: medium; + } diff --git a/extensions/webmail/templatetags/webextras.py b/extensions/webmail/templatetags/webextras.py index c393d09c7..1941a9fe5 100644 --- a/extensions/webmail/templatetags/webextras.py +++ b/extensions/webmail/templatetags/webextras.py @@ -34,8 +34,8 @@ def viewm_menu(selection, session, mail_id, perms): "url" : "reply/", "img" : "/static/pics/reply.png", "label" : _("Reply")}, - {"name" : "reply-all", - "url" : "", + {"name" : "replyall", + "url" : "reply/", "img" : "/static/pics/reply-all.png", "label" : _("Reply all")}, {"name" : "forward", diff --git a/lib/email_listing.py b/lib/email_listing.py index 216143ae2..5d77853f6 100644 --- a/lib/email_listing.py +++ b/lib/email_listing.py @@ -17,6 +17,9 @@ class MBconnector(object): self.address = address self.port = port + def messages_count(self, **kwargs): + pass + class Page(object): def __init__(self, pageid, id_start, id_stop, items, items_per_page, has_previous, has_next, @@ -80,11 +83,13 @@ class Paginator(object): return p class EmailListing: - def __init__(self, folder=None, elems_per_page=40, **kwargs): + def __init__(self, folder=None, elems_per_page=40, navparams={}, **kwargs): self.folder = folder self.elems_per_page = elems_per_page + self.navparams = navparams order = "order" in kwargs.keys() and kwargs["order"] or None - self.paginator = Paginator(self.mbc.messages_count(self.folder, order), + self.paginator = Paginator(self.mbc.messages_count(folder=self.folder, + order=order), elems_per_page) def render_navbar(self, page): @@ -212,3 +217,4 @@ class Email(object): body = re.sub("<(/?)body", lambda m: "<%sdiv" % m.group(1), body) body = Template(decode(body)).render({}) return (False, body) + diff --git a/static/pics/settings.png b/static/pics/settings.png index 9460dfc746be14e4b2a6a94f91d002fa4a56318f..259ed26dd000fcfb81b2c312980765a181a65486 100644 GIT binary patch delta 558 zcmV+}0@3~B1oZ@vQhy5s3pXIqsqSY00013yMObu0Z*6U5Zgc=ca%Ew3Wn>_CX>@2H zRA^-&M@dak?_?!z0005mNkl zB{f-z2o(e&Wd1=BTy-IcD-ky>{R5>f2t%?_3|K*ty!n`-f`8j2l!-JYS+s~Babagj z+jP~1cXfHs<#6vg=Y?~YbQbXR*^|E0KU#Wx=G>Um+`V@vqw8@KK!{2fAfGc>U0XSZ zog{M!bUkk7a|V`W0hpnnO3Q8#3aX%ErjltiO#|o;l8gE|O=HjAr(hPTmg@ji%XJE7kv)6g$-!_9j#TZt wNz^ej(q2lLRHUf2EfP`!6$3*;pld;NWk8DJ!ikO0VpoFFHi=0W6@pc1 z)0c)IL_|6>Zf1rt9qYog`?=?wd+)hF{3itGBb`yAfC|X*Sbrk-7fm#sQEZObQdIk| z_5-MoY;Vl*0cBb`wU_gcfMMFGTgMu}QvtE<=B=4KoPc<}H(@mN9z zasVQr4&EFxo<5r}0QL5*5jNM?kUAym^{@9@vx(gq6)Z7;U=?o+3>uoYCFr_t=( zeoiR7F9OIr1%E5Fbg&WWyGXfGVY&7NAQ_J(?*8+PPQ1#$G)k2c-6wk}mJ65@Oai*A zK`@d!4AiXmm5@%?35laej?rv3c{leKw>3OY@%V68aOMzyJ_~kyIB@do(69TV7 - + {% endblock %} +{% block menu %} + +{% endblock %} diff --git a/extensions/webmail/templatetags/webextras.py b/extensions/webmail/templatetags/webextras.py index f36817730..f15d3488b 100644 --- a/extensions/webmail/templatetags/webextras.py +++ b/extensions/webmail/templatetags/webextras.py @@ -75,6 +75,17 @@ def listing_menu(selection, folder, perms): "url" : reverse(webmail.main.compose), "img" : "/static/pics/edit.png", "label" : _("New message")}, + {"name" : "actions", + "img" : "/static/pics/domains.png", + "label" : _("Mark messages"), + "class" : "menubardropdown", + "menu" : [ + {"name" : "mark-read", + "label" : _("As read")}, + {"name" : "mark-unread", + "label" : _("As unread")} + ] + } ] if folder in ["Trash"]: entries += [ diff --git a/lib/__init__.py b/lib/__init__.py index c2582c4bd..581f964d3 100644 --- a/lib/__init__.py +++ b/lib/__init__.py @@ -1,6 +1,7 @@ # -*- coding: utf-8 -*- import popen2 import os +import sys import time from django.conf import settings from django.template import RequestContext @@ -67,6 +68,16 @@ def _ctx_ok(url): def _ctx_ko(tpl, ctx): return {"status" : "ko", "content" : render_to_string(tpl, ctx)} +def getctx(status, level=1, callback=None, **kwargs): + if not callback: + callername = sys._getframe(level).f_code.co_name + else: + callername = callback + ctx = {"status" : status, "callback" : callername} + for kw, v in kwargs.iteritems(): + ctx[kw] = v + return ctx + def decode(s, encodings=('utf8', 'latin1', 'windows-1252', 'ascii')): for encoding in encodings: try: diff --git a/lib/email_listing.py b/lib/email_listing.py index 5d77853f6..0d8b337a0 100644 --- a/lib/email_listing.py +++ b/lib/email_listing.py @@ -83,16 +83,23 @@ class Paginator(object): return p class EmailListing: - def __init__(self, folder=None, elems_per_page=40, navparams={}, **kwargs): + def __init__(self, baseurl=None, folder=None, elems_per_page=40, + navparams={}, **kwargs): self.folder = folder self.elems_per_page = elems_per_page self.navparams = navparams order = "order" in kwargs.keys() and kwargs["order"] or None - self.paginator = Paginator(self.mbc.messages_count(folder=self.folder, - order=order), - elems_per_page) + self.empty = "empty" in kwargs.keys() and kwargs["empty"] or False + if not self.empty: + self.paginator = Paginator(self.mbc.messages_count(folder=self.folder, + order=order), + elems_per_page) + if baseurl: + self.paginator.baseurl = baseurl def render_navbar(self, page): + if page is None: + return "" return render_to_string("common/navbar.html", { "page" : page }) @@ -110,9 +117,10 @@ class EmailListing: return tpl.render(Context({"table" : table.render(request)})) def render(self, request, pageid=1, **kwargs): - page = self.paginator.getpage(pageid) - thead = listing = "" - if "empty" in kwargs.keys() and not kwargs["empty"]: + listing = "" + page = None + if not self.empty: + page = self.paginator.getpage(pageid) if not page: listing = "Empty folder" else: @@ -125,7 +133,7 @@ class EmailListing: "folders" : self.getfolders(), "selection" : self.folder, "navparams" : self.navparams, - "current_page" : pageid + "deflocation" : self.deflocation, "defcallback" : self.defcallback }) class Email(object): diff --git a/lib/tables.py b/lib/tables.py index 3a3e07f33..6d64b8bb2 100644 --- a/lib/tables.py +++ b/lib/tables.py @@ -55,16 +55,21 @@ class Table(object): for row in rows: nrow = {"id" : row[self.idkey], "cols" : []} for c in self.columns: + newcol = {} + try: + newcol["width"] = getattr(c, "width") + except AttributeError: + pass if isinstance(c, SelectionColumn): - nrow["cols"] += [{"value" : c.render(row[self.idkey]), "safe" : True}] - continue + newcol["value"] = c.render(row[self.idkey]) + newcol["safe"] = True + if isinstance(c, ImgColumn): key = "img_%s" % c.name if key in row.keys(): - nrow["cols"] += [{"value" : c.render(row[key]), "safe" : True, "width" : c.width}] - else: - nrow["cols"] += [{"width" : c.width}] - continue + newcol["value"] = c.render(row[key]) + newcol["safe"] = True + if row.has_key(c.name): try: cssclass = getattr(c, "cssclass") @@ -72,10 +77,9 @@ class Table(object): cssclass = "" if "class" in row.keys(): cssclass += (cssclass != "") and ", %s" % row["class"] or row["class"] - - nrow["cols"] += [{"value" : self.parse(c.name, row[c.name]), - "class" : cssclass, - "width" : c.width}] + newcol["value"] = self.parse(c.name, row[c.name]) + newcol["class"] = cssclass + nrow["cols"] += [newcol] self.rows += [nrow] def render(self, request): diff --git a/static/js/common.js b/static/js/common.js index 2f64d6e4e..4f9be35e5 100644 --- a/static/js/common.js +++ b/static/js/common.js @@ -3,6 +3,12 @@ window.addEvent('domready', function(){ SqueezeBox.assign($$('a.boxed'), { parse: 'rel', }); + + + infobox = new InfoBox({ + parent : $("menubar"), + message : "Loading" + }); }); callbacks = {}; @@ -18,18 +24,23 @@ get_callback = function(name) { current_anchor = null; check_anchor = function() { - if (current_anchor.serialized == location.hash) { + if (current_anchor.serialized == location.hash + && !$defined(current_anchor.force)) { return; } + delete(current_anchor.force); current_anchor.from_string(location.hash); if (!current_anchor.serialized) { query = current_anchor.deflocation; } else { query = current_anchor.serialized.substring(1); } + infobox.show("Loading...", "gray"); new Request.JSON({url: query, onSuccess: function(resp) { callback = ($defined(resp.callback)) ? resp.callback : "default"; callbacks[callback](resp); + infobox.notice("Done"); + infobox.hide(1); }}).get(); } @@ -96,8 +107,26 @@ function HashWrapper(deflocation) { return res; }; - this.update = function() { + this.update = function(force) { location.hash = this.serialize(); + if ($defined(force)) { + this.force = force; + } + }; + + this.updateparams = function(str) { + if (str.charAt(0) == '?') { + str = str.substring(1); + } + var elems = str.split('&'); + for (var i = 0; i < elems.length; i++) { + this.setparamfromstring(elems[i]); + } + }; + + this.setparamfromstring = function(str) { + var def = str.split('='); + this.params.set(def[0], def[1]); }; this.setparam = function(name, value) { @@ -158,6 +187,9 @@ parse_menubar = function(id) { } }); }); + SqueezeBox.assign($$('a.boxed'), { + parse: 'rel', + }); } } diff --git a/static/js/infobox.js b/static/js/infobox.js new file mode 100644 index 000000000..9852af96b --- /dev/null +++ b/static/js/infobox.js @@ -0,0 +1,105 @@ +var InfoBox = new Class({ + Implements: [Options, Events], + options: { + parent : null, + message : "This is my info box!", + width : 200, + }, + + profiles: { + gray : {"background" : "#EEEEEE", "border" : "#CCCCCC"}, + green : {"background" : "#DAF8DA", "border" : "#166D14"}, + red : {"background" : "#F9D2D2", "border" : "#FF2A00"}, + yellow : {"background" : "#FDE6B5", "border" : "#FF7F00"} + }, + + initialize: function(options) { + this.setOptions(options); + + var boxleft = document.body.getSize().x / 2 - this.options.width / 2; + this.box = new Element("div", {"id" : "infobox"}); + this.box.setStyles({ + "font-size" : "normal", + "font-weight" : "bold", + "text-align" : "left", + "width" : this.options. width + "px", + "padding" : "10px", + "position" : "absolute", + "top" : "0px", + "left" : boxleft + "px", + "display" : "none" + }); + this.setProfile("gray"); + this.spinner = new Element("img", { + "src" : "/static/stylesheets/spinner.gif" + }); + this.spinner.setStyle("float", "left"); + this.spinner.inject(this.box); + + this.label = new Element("label").set("html", this.options.message); + this.label.setStyles({ + "margin-left" : "10px" + }); + this.label.inject(this.box); + this.box.inject(document.body); + }, + + setMessage: function(text) { + this.label.set("html", text); + }, + + setProfile: function(color) { + this.box.setStyles({ + "background" : this.profiles[color].background, + "border" : "2px solid " + this.profiles[color].border + }); + }, + + notice: function(message) { + this.setProfile("green"); + this.setMessage(message); + }, + + error: function(message) { + this.setProfile("red"); + this.setMessage(message); + }, + + warning: function(message) { + this.setProfile("yellow"); + this.setMessage(message); + }, + + show: function(message, prof, delay) { + if ($defined(this.start)) { + if ($time() - this.start > this.delay) { + $clear(this.showtimer); + delete(this.start); + delete(this.delay); + } else { + this.showtimer = this.show.delay(100, this, [message, prof]); + return; + } + } + if ($defined(message)) { + this.setMessage(message); + } + if ($defined(prof)) { + this.setProfile(prof); + } + if ($defined(delay)) { + this.start = $time(); + this.delay = delay * 1000; + } + this.box.setStyle("display", "block"); + }, + + hide: function(timer) { + if ($defined(timer)) { + this.timer = this.hide.periodical(timer * 1000, this); + return; + } + $clear(this.timer); + this.box.setStyle("display", "none"); + }, +}); \ No newline at end of file diff --git a/static/stylesheets/default.css b/static/stylesheets/default.css index 809492b69..61e1b46e2 100644 --- a/static/stylesheets/default.css +++ b/static/stylesheets/default.css @@ -177,11 +177,11 @@ div#footer { border-left: 5px solid #ccc; } -.left { +div.left { float: left; } -.right { +div.right { float: right; } diff --git a/templates/common/email_listing.html b/templates/common/email_listing.html index e0c19ef14..10f1abd1e 100644 --- a/templates/common/email_listing.html +++ b/templates/common/email_listing.html @@ -2,20 +2,6 @@ {% load i18n %} {% block html_head %} {% endblock %} @@ -55,5 +75,5 @@ window.addEvent("domready", function() {
{{ listing }}
{% endblock %} -{% block footer %}{% endblock %} +{% block footer %}{% endblock %} diff --git a/templates/common/email_searchbar.html b/templates/common/email_searchbar.html new file mode 100644 index 000000000..99b7e9f9f --- /dev/null +++ b/templates/common/email_searchbar.html @@ -0,0 +1,10 @@ +{% load i18n %} + diff --git a/extensions/webmail/templates/viewmail.html b/templates/common/viewmail.html similarity index 100% rename from extensions/webmail/templates/viewmail.html rename to templates/common/viewmail.html diff --git a/templates/nlayout.html b/templates/nlayout.html index 683576397..ad3092f79 100644 --- a/templates/nlayout.html +++ b/templates/nlayout.html @@ -13,6 +13,7 @@ + {% url django.views.i18n.javascript_catalog as catalog_url %}