diff --git a/doc/conf.py b/doc/conf.py index 3a248f79b..e807a8b4b 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -50,7 +50,7 @@ copyright = u'2014, Antoine Nguyen' # The short X.Y version. version = '1.1' # The full version, including alpha/beta/rc tags. -release = '1.1.1' +release = '1.1.2' # The language for content autogenerated by Sphinx. Refer to documentation # for a list of supported languages. diff --git a/doc/integration/imap_and_smtp.rst b/doc/integration/imap_and_smtp.rst index b0f0df61c..520909faf 100644 --- a/doc/integration/imap_and_smtp.rst +++ b/doc/integration/imap_and_smtp.rst @@ -197,13 +197,48 @@ SQLite users Replace values between ``<>`` with yours. -LDA -=== +LMTP +==== -The LDA is activated by default but you must define a *postmaster* -address. Open the :file:`conf.d/15-lda.conf` file modify the following line:: +`Local Mail Transport Protocol +`_ is used +to let Postfix deliver messages to Dovecot. - postmaster_address = postmaster@ +First, make sure the protocol is activated by looking at the +``protocols`` setting (generally inside +:file:`dovecot.conf`). It should be similar to the following example:: + + protocols = imap pop3 lmtp + +Then, open the :file:`conf.d/10-master.conf`, look for ``lmtp`` +service definition and add the following content inside:: + + service lmtp { + # stuff before + unix_listener /var/spool/postfix/private/dovecot-lmtp { + mode = 0600 + user = postfix + group = postfix + } + # stuff after + } + +We assume here that Postfix is *chrooted* within +:file:`/var/spool/postfix`. + +Finally, open the :file:`conf.d/20-lmtp.conf` and modify it as follows:: + + protocol lmtp { + postmaster_address = postmaster@ + mail_plugins = $mail_plugins quota sieve + } + +Replace ```` by the appropriate value. + +.. note:: + + If you don't plan to apply quota or to use filters, just adapt the + content of the ``mail_plugins`` setting. .. _dovecot_quota: @@ -454,8 +489,7 @@ Use the following configuration in the :file:`/etc/postfix/main.cf` file (this is just one possible configuration):: # Stuff before - virtual_transport = dovecot - dovecot_destination_recipient_limit = 1 + virtual_transport = lmtp:unix:private/dovecot-lmtp relay_domains = virtual_mailbox_domains = :/etc/postfix/sql-domains.cf @@ -468,16 +502,11 @@ Use the following configuration in the :file:`/etc/postfix/main.cf` file ... check_recipient_access :/etc/postfix/sql-maintain.cf permit_mynetworks + reject_unverified_recipient ... # Stuff after Replace ```` by the name of the database you use. -Then, edit the :file:`/etc/postfix/master.cf` file and add the following -definition at the end:: - - dovecot unix - n n - - pipe - flags=DRhu user=vmail:vmail argv=/usr/lib/dovecot/deliver -f ${sender} -d ${recipient} - Restart Postfix. diff --git a/modoboa/extensions/admin/forms/account.py b/modoboa/extensions/admin/forms/account.py index 75a256d44..aae2b82e8 100644 --- a/modoboa/extensions/admin/forms/account.py +++ b/modoboa/extensions/admin/forms/account.py @@ -30,7 +30,7 @@ class AccountFormGeneral(forms.ModelForm): help_text=ugettext_lazy("What level of permission this user will have") ) password1 = forms.CharField( - label=_("Password"), widget=forms.PasswordInput + label=ugettext_lazy("Password"), widget=forms.PasswordInput ) password2 = forms.CharField( label=ugettext_lazy("Confirmation"), diff --git a/modoboa/extensions/amavis/sql_listing.py b/modoboa/extensions/amavis/sql_listing.py index edc06fbb7..ac1b378ed 100644 --- a/modoboa/extensions/amavis/sql_listing.py +++ b/modoboa/extensions/amavis/sql_listing.py @@ -118,7 +118,7 @@ class SQLconnector(MBconnector): rs = qm["mail__msgrcpt__rs"] if rs == 'D': continue - elif rs == '': + elif rs in ['', ' ']: m["class"] = "unseen" elif rs == 'R': m["img_rstatus"] = static_url("pics/release.png") diff --git a/modoboa/extensions/amavis/views.py b/modoboa/extensions/amavis/views.py index d57b4af98..8eb7da2fb 100644 --- a/modoboa/extensions/amavis/views.py +++ b/modoboa/extensions/amavis/views.py @@ -128,7 +128,7 @@ def viewmail(request, mail_id): rcpt = request.GET["rcpt"] if request.user.mailbox_set.count(): mb = Mailbox.objects.get(user=request.user) - if rcpt in mb.alias_addresses: + if rcpt == mb.full_address or rcpt in mb.alias_addresses: get_wrapper().set_msgrcpt_status(rcpt, mail_id, 'V') content = Template(""" diff --git a/modoboa/extensions/stats/general_callbacks.py b/modoboa/extensions/stats/general_callbacks.py index ced60145c..8ead98633 100644 --- a/modoboa/extensions/stats/general_callbacks.py +++ b/modoboa/extensions/stats/general_callbacks.py @@ -15,8 +15,8 @@ def menu(target, user): @events.observe("GetGraphSets") -def get_default_graph_sets(): - from modoboa.extensions.stats.graph_templates import MailTraffic +def get_default_graphic_sets(): + from modoboa.extensions.stats.graphics import MailTraffic gset = MailTraffic() return {gset.html_id: gset} diff --git a/modoboa/extensions/stats/graph_templates.py b/modoboa/extensions/stats/graph_templates.py deleted file mode 100644 index cbe8ab161..000000000 --- a/modoboa/extensions/stats/graph_templates.py +++ /dev/null @@ -1,79 +0,0 @@ -# coding: utf-8 - -from django.utils.translation import ugettext_lazy - - -class Graph(object): - width = 540 - height = 120 - cf = 'AVERAGE' - - def __init__(self, **ds): - self.vars = {} - for dsname, dsdef in ds.iteritems(): - self.vars[dsname] = dsdef - - @property - def display_name(self): - return self.__class__.__name__.lower() - - -class AvgTraffic(Graph): - title = ugettext_lazy('Average normal traffic') - vertlabel = ugettext_lazy('msgs/min') - - def __init__(self): - super(AvgTraffic, self).__init__( - sent={'type': 'AREA', 'color': '#00FF00', - "legend": ugettext_lazy("sent messages")}, - recv={"type": "AREA", "color": "#0000FF", - "legend": ugettext_lazy("received messages")} - ) - - -class AvgBadTraffic(Graph): - title = ugettext_lazy('Average bad traffic') - vertlabel = ugettext_lazy('msgs/min') - - def __init__(self): - super(AvgBadTraffic, self).__init__( - bounced={'type': 'AREA', 'color': '#FFFF00', - "legend": ugettext_lazy("bounced messages")}, - reject={"type": "AREA", "color": "#FF0000", - "legend": ugettext_lazy("rejected messages")} - ) - - -class AvgTrafficSize(Graph): - title = ugettext_lazy('Average normal traffic size') - vertlabel = ugettext_lazy('bytes/min') - - def __init__(self): - super(AvgTrafficSize, self).__init__( - size_recv={'type': 'AREA', 'color': '#FF9900', - "legend": ugettext_lazy("received size")}, - size_sent={"type": "AREA", "color": "#339999", - "legend": ugettext_lazy("sent size")} - ) - - -class GraphSet(object): - title = None - graphs = [] - - @property - def html_id(self): - return self.__class__.__name__.lower() - - def get_graphs(self): - if not hasattr(self, '__graph_instances'): - self.__graph_instances = [gc() for gc in self.graphs] - return self.__graph_instances - - def get_graph_names(self): - return [g.display_name for g in self.get_graphs()] - - -class MailTraffic(GraphSet): - title = ugettext_lazy('Mail traffic') - graphs = [AvgTraffic, AvgBadTraffic, AvgTrafficSize] diff --git a/modoboa/extensions/stats/grapher.py b/modoboa/extensions/stats/grapher.py deleted file mode 100644 index d410f74b1..000000000 --- a/modoboa/extensions/stats/grapher.py +++ /dev/null @@ -1,94 +0,0 @@ -# coding: utf-8 -import sys -import os -import time -from django.utils.translation import ugettext as _, ugettext_lazy -from django.conf import settings -from modoboa.lib import parameters - -periods = [{"name": "day", "label": ugettext_lazy("Day")}, - {"name": "week", "label": ugettext_lazy("Week")}, - {"name": "month", "label": ugettext_lazy("Month")}, - {"name": "year", "label": ugettext_lazy("Year")}] - - -def str2Time(y, M, d, h="00", m="00", s="00"): - """Date conversion - - Returns a date and a time in seconds from the epoch. - - :return: an integer - """ - try: - if not M.isdigit(): - local = time.strptime("%s %s %s %s:%s:%s" % (y, M, d, h, m, s), - "%Y %b %d %H:%M:%S") - else: - local = time.strptime("%s %s %s %s:%s:%s" % (y, M, d, h, m, s), - "%Y %m %d %H:%M:%S") - except ValueError: - print >> sys.stderr, "Error: failed to convert date and time" - return 0 - - return int(time.mktime(local)) - - -class Grapher(object): - def __init__(self): - self.rrd_rootdir = parameters.get_admin("RRD_ROOTDIR") - self.img_rootdir = os.path.join(settings.MEDIA_ROOT, "stats") - - def process(self, target, suffix, start, end, graph_tpl): - import rrdtool - - rrdfile = "%s/%s.rrd" % (self.rrd_rootdir, target) - if not os.path.exists(rrdfile): - return - ext = "png" - path = "%s/%s_%s_%s_%s.%s" % (self.img_rootdir, graph_tpl.display_name, - target, graph_tpl.cf, suffix, ext) - start = str(start) - end = str(end) - defs = [] - lines = [] - for v, d in graph_tpl.vars.iteritems(): - defs += [str('DEF:%s=%s:%s:%s' % (v, rrdfile, v, graph_tpl.cf)), - str('CDEF:%spm=%s,60,*' % (v, v)), - str('VDEF:%s_total=%s,TOTAL' % (v, v))] - type = d["type"] if "type" in d else "LINE" - lines += [str("%s:%spm%s:%s:STACK" % (type, v, d["color"], - (d["legend"].encode("utf8")).ljust(20)))] - lines.append('GPRINT:%s_total:%s%%7.0lf%%s\\l' % (v, _("Total").encode('utf-8'))) - - first_ds = graph_tpl.vars.keys()[0] - defs.append(str('VDEF:%s_first=%s,FIRST' % (first_ds, first_ds))) - defs.append(str('VDEF:%s_last=%s,LAST' % (first_ds, first_ds))) - defs.append('GPRINT:%s_first:%s %%c:strftime' % (first_ds, "From")) - defs.append('GPRINT:%s_last:%s %%c\\c:strftime' % (first_ds, "to")) - defs.append('COMMENT:\\s') - - params = defs + lines - rrdtool.graph( - str(path), - "--imgformat", "PNG", - "--width", str(graph_tpl.width), - "--height", str(graph_tpl.height), - "--start", start, - "--end", end, - "--vertical-label", graph_tpl.vertlabel.encode("utf-8"), - "--lower-limit", "0", - "--slope-mode", - "--units-exponent", "0", - "--title", str("%s: %s" % (target, graph_tpl.title.encode("utf-8"))), - *params - ) - - if not os.path.exists(path): - print "[graph] Impossible to create %s graph" % path - - def make_defaults(self, target, graph_tpl): - end = "%d" % int(time.mktime(time.localtime())) - self.process(target, "day", "-1day", end, graph_tpl) - self.process(target, "week", "-1week", end, graph_tpl) - self.process(target, "month", "-1month", end, graph_tpl) - self.process(target, "year", "-1year", end, graph_tpl) diff --git a/modoboa/extensions/stats/graphics.py b/modoboa/extensions/stats/graphics.py new file mode 100644 index 000000000..bc3ebb195 --- /dev/null +++ b/modoboa/extensions/stats/graphics.py @@ -0,0 +1,155 @@ +import os +import inspect +from lxml import etree +from django.utils.translation import ugettext_lazy +from modoboa.lib import parameters +from modoboa.lib.sysutils import exec_cmd + + +class Curve(object): + """Graphic curve. + + Simple way to represent a graphic curve. + """ + def __init__(self, dsname, color, legend, cfunc="AVERAGE"): + """Constructor. + """ + self.dsname = dsname + self.color = color + self.legend = legend.encode("utf-8") + self.cfunc = cfunc + + def to_rrd_command_args(self, rrdfile): + """Convert this curve to the approriate RRDtool command. + + :param str rrdfile: RRD file name + :return: a list + """ + rrdfile = os.path.join( + parameters.get_admin("RRD_ROOTDIR"), "%s.rrd" % rrdfile + ) + return [ + 'DEF:%s=%s:%s:%s' % + (self.dsname, rrdfile, self.dsname, self.cfunc), + 'CDEF:%(ds)spm=%(ds)s,60,*' % {"ds": self.dsname}, + 'XPORT:%spm:"%s"' % (self.dsname, self.legend) + ] + + +class Graphic(object): + """Graphic. + + """ + def __init__(self): + """Constructor. + """ + self._curves = [] + try: + order = getattr(self, "order") + except AttributeError: + for member in inspect.getmembers(self): + if isinstance(member[1], Curve): + self._curves += [member[1]] + else: + for name in order: + try: + curve = getattr(self, name) + except AttributeError: + continue + if not isinstance(curve, Curve): + continue + self._curves += [curve] + + @property + def display_name(self): + return self.__class__.__name__.lower() + + def export(self, rrdfile, start, end): + """ + """ + result = [] + cmdargs = [] + for curve in self._curves: + result += [{ + "name": curve.legend, "color": curve.color, "data": [] + }] + cmdargs += curve.to_rrd_command_args(rrdfile) + cmd = "rrdtool xport --start %s --end %s " % (str(start), str(end)) + cmd += " ".join(cmdargs) + code, output = exec_cmd(cmd) + if code: + return [] + tree = etree.fromstring(output) + for row in tree.xpath('/xport/data/row'): + timestamp = int(row.find('t').text) + for vindex, value in enumerate(row.findall('v')): + if value.text == 'NaN': + result[vindex]['data'].append({'x': timestamp, 'y': 0}) + else: + result[vindex]['data'].append( + {'x': timestamp, 'y': float(value.text)} + ) + return result + + +class AverageTraffic(Graphic): + """Average traffic. + """ + title = ugettext_lazy('Average traffic') + vertlabel = ugettext_lazy('msgs/min') + + # Curve definitions + sent = Curve("sent", "lawngreen", ugettext_lazy("sent messages")) + recv = Curve("recv", "steelblue", ugettext_lazy("received messages")) + bounced = Curve("bounced", "yellow", ugettext_lazy("bounced messages")) + reject = Curve("reject", "tomato", ugettext_lazy("rejected messages")) + + order = ['reject', 'bounced', 'recv', 'sent'] + + +class AverageTrafficSize(Graphic): + """Average traffic size. + """ + title = ugettext_lazy('Average normal traffic size') + vertlabel = ugettext_lazy('bytes/min') + + # Curve definitions + size_recv = Curve("size_recv", "orange", ugettext_lazy("received size")) + size_sent = Curve( + "size_sent", "mediumturquoise", ugettext_lazy("sent size") + ) + + +class GraphicSet(object): + title = None + _graphics = [] + + def __init__(self): + self.__ginstances = [] + + @property + def html_id(self): + return self.__class__.__name__.lower() + + @property + def graphics(self): + if not self.__ginstances: + self.__ginstances = [graphic() for graphic in self._graphics] + return self.__ginstances + + def get_graphic_names(self): + return [graphic.display_name for graphic in self._graphics] + + def export(self, rrdfile, start, end): + result = {} + for graph in self.graphics: + result[graph.display_name] = { + "title": graph.title.encode("utf-8"), + "curves": graph.export(rrdfile, start, end) + } + return result + + +class MailTraffic(GraphicSet): + title = ugettext_lazy('Mail traffic') + _graphics = [AverageTraffic, AverageTrafficSize] diff --git a/modoboa/extensions/stats/lib.py b/modoboa/extensions/stats/lib.py new file mode 100644 index 000000000..c71e5b59d --- /dev/null +++ b/modoboa/extensions/stats/lib.py @@ -0,0 +1,24 @@ +# coding: utf-8 +import sys +import time + + +def date_to_timestamp(timetuple): + """Date conversion. + + Returns a date and a time in seconds from the epoch. + + :param list timetuple: list containing date + :return: an integer + """ + date = " ".join( + [("%d" % elem) if type(elem) is int else elem for elem in timetuple] + ) + fmt = "%Y %m %d %H %M %S" \ + if timetuple[1].isdigit() else "%Y %b %d %H %M %S" + try: + local = time.strptime(date, fmt) + except ValueError: + print >> sys.stderr, "Error: failed to convert date and time" + return 0 + return int(time.mktime(local)) diff --git a/modoboa/extensions/stats/management/commands/logparser.py b/modoboa/extensions/stats/management/commands/logparser.py index cbcfcb03e..e0935164b 100644 --- a/modoboa/extensions/stats/management/commands/logparser.py +++ b/modoboa/extensions/stats/management/commands/logparser.py @@ -28,8 +28,8 @@ from optparse import make_option from modoboa.lib import parameters from modoboa.extensions.admin.models import Domain from modoboa.extensions.stats import Stats -from modoboa.extensions.stats.grapher import str2Time, Grapher -from modoboa.extensions.stats.graph_templates import MailTraffic +from modoboa.extensions.stats.lib import date_to_timestamp + rrdstep = 60 xpoints = 540 @@ -123,7 +123,7 @@ class LogParser(object): ye = match.group("year") except IndexError: ye = self.year(mo) - self.cur_t = str2Time(ye, mo, da, ho, mi, se) + self.cur_t = date_to_timestamp([ye, mo, da, ho, mi, se]) self.cur_t = self.cur_t - self.cur_t % rrdstep self._prev_mi = mi self._prev_ho = ho @@ -329,15 +329,11 @@ class LogParser(object): for line in self.f.readlines(): self._parse_line(line) - G = Grapher() for dom, data in self.data.iteritems(): self._dprint("[rrd] dealing with domain %s" % dom) for t in sorted(data.keys()): self.update_rrd(dom, t) - for graph_tpl in MailTraffic().get_graphs(): - G.make_defaults(dom, graph_tpl) - class Command(BaseCommand): help = 'Log file parser' diff --git a/modoboa/extensions/stats/static/stats/css/stats.css b/modoboa/extensions/stats/static/stats/css/stats.css index e69de29bb..adb4ac2c7 100644 --- a/modoboa/extensions/stats/static/stats/css/stats.css +++ b/modoboa/extensions/stats/static/stats/css/stats.css @@ -0,0 +1,67 @@ +.chart { + position: relative; + margin-bottom: 20px; +} + +.grid .tick { + stroke: darkgrey; + stroke-dasharray: 1, 2; + opacity: 0.9; +} +.grid path { + stroke-width: 0; +} + +.line_over { + stroke: darkgrey; + stroke-width: 1.5px; + stroke-dasharray: 1, 2; + display: none; +} + +.axis { + font-size: 0.7em; +} + +.axis path, +.axis line { + fill: none; + stroke: #000; + shape-rendering: crispEdges; +} + +.area, .area2 { + fill-opacity: .6; +} + +.brush .extent { + stroke: #fff; + fill-opacity: .125; + shape-rendering: crispEdges; +} + +.legend rect { + fill: none; + stroke: black; + opacity: 0; +} + +.tip { + position: absolute; + z-index: 20; + top: 60px; + display: none; + font-size: 0.9em; + padding: 5px; + background: rgba(0, 0, 0, 0.6); + color: #fff; + border-radius: 5px; +} + +.tip p { + margin: 0; +} + +.tracker { + display: none; +} \ No newline at end of file diff --git a/modoboa/extensions/stats/static/stats/js/d3.legend.js b/modoboa/extensions/stats/static/stats/js/d3.legend.js new file mode 100644 index 000000000..52b267a6c --- /dev/null +++ b/modoboa/extensions/stats/static/stats/js/d3.legend.js @@ -0,0 +1,70 @@ +// d3.legend.js +// (C) 2012 ziggy.jonsson.nyc@gmail.com +// MIT licence + +(function() { + d3.legend = function(g, clicked) { + g.each(function() { + var g = d3.select(this), + items = {}, + svg = d3.select(g.property("nearestViewportElement")), + legendPadding = g.attr("data-style-padding") || 5, + lb = g.selectAll(".legend-box").data([true]), + li = g.selectAll(".legend-items").data([true]); + + lb.enter().append("rect").classed("legend-box", true); + li.enter().append("g").classed("legend-items", true); + + svg.selectAll("[data-legend]").each(function() { + var self = d3.select(this); + + items[self.attr("data-legend")] = { + pos: self.attr("data-legend-pos") || this.getBBox().y, + color: self.attr("data-legend-color") != undefined ? self.attr("data-legend-color") : self.style("fill") != 'none' ? self.style("fill") : self.style("stroke") + }; + }); + + items = d3.entries(items).sort(function(a, b) { + return a.value.pos - b.value.pos; + }); + + li.selectAll("text") + .data(items, function(d) { return d.key; }) + .call(function(d) { d.enter().append("text"); }) + .call(function(d) { d.exit().remove(); }) + .attr("y", function(d, i) { return i + "em"; }) + .attr("x", "1em") + .text(function(d) { return d.key; }); + + li.selectAll("circle") + .data(items, function(d) { return d.key; }) + .call(function(d) { d.enter().append("circle"); }) + .call(function(d) { d.exit().remove(); }) + .attr("cy", function(d, i) { return i - 0.25 + "em"; }) + .attr("cx", 0) + .attr("r", "4px") + .style("stroke", function(d) { return d.value.color; }) + .style("stroke-width", "1px") + .style("stroke-opacity", 0.8) + .style("fill", function(d) { return d.value.color; }) + .on("click", function(d) { + var curve = d3.selectAll("[data-legend='" + d.key + "']"); + var state = (curve.style("display") == "none") + ? "block" : "none"; + + curve.style("display", state); + d3.select(this).style( + "fill", (state == "none") ? "none" : d.value.color + ); + }); + + // Reposition and resize the box + var lbbox = li[0][0].getBBox(); + lb.attr("x", (lbbox.x - legendPadding)) + .attr("y", (lbbox.y - legendPadding)) + .attr("height", (lbbox.height + 2 * legendPadding)) + .attr("width", (lbbox.width + 2 * legendPadding)); + }); + return g; + }; +})(); diff --git a/modoboa/extensions/stats/static/stats/js/graphic.js b/modoboa/extensions/stats/static/stats/js/graphic.js new file mode 100644 index 000000000..b650184f3 --- /dev/null +++ b/modoboa/extensions/stats/static/stats/js/graphic.js @@ -0,0 +1,344 @@ +/** + * Creates an instance of ModoChart. + * + * @constructor + * @this {ModoChart} + * @param {string} selection add chart to this DOM element. + * + */ +function ModoChart(selection) { + var width = 0, + height = 300, + preview_height = 80, + brush_height = 7, + margin = { + left: 50, + top: 10, + bottom: 20 + }; + var container; + + /** + * Create and render the chart. + * + * @this {ModoChart} + * @param {Object} data chart's data. + */ + function my(data) { + container = d3.select(selection) + .append("div") + .attr("class", "chart"); + + container.append("h6").html(data.title); + width = $(selection).width(); + + var svg = container + .append("svg") + .attr("width", width) + .attr("height", my.totalHeight()); + + svg.append("defs").append("clipPath") + .attr("id", "clip") + .append("rect") + .attr("width", width - 2 * margin.left) + .attr("height", height + margin.top + margin.bottom); + + var x = d3.time.scale().range([0, width - 2 * margin.left]), + x2 = d3.time.scale().range([0, width - 2 * margin.left]); + var xAxis = d3.svg.axis().scale(x).orient("bottom"), + xAxis2 = d3.svg.axis().scale(x2).orient("bottom"); + var y = d3.scale.linear().range([height, 0]), + y2 = d3.scale.linear().range([preview_height, 0]); + var yAxis = d3.svg.axis().scale(y).orient("left"); + var bisectDate = d3.bisector(function(d) { return d.x * 1000; }).left; + + var brush = d3.svg.brush() + .x(x2) + .on("brush", brushed); + + x.domain( + d3.extent(data.curves[0].data.map(function(d) { return d.x * 1000; })) + ); + y.domain( + [d3.min(data.curves, function(d) { return d3.min(d.data, function(v) { return v.y; }); }), + d3.max(data.curves, function(d) { return d3.max(d.data, function(v) { return v.y; }); }) + ] + ); + x2.domain(x.domain()); + y2.domain(y.domain()); + + var area = d3.svg.area() + .interpolate("monotone") + .x(function(d) { return x(d.x * 1000); }) + .y0(height) + .y1(function(d) { return y(d.y); }); + var area2 = d3.svg.area() + .interpolate("monotone") + .x(function(d) { return x2(d.x * 1000); }) + .y0(preview_height) + .y1(function(d) { return y2(d.y); }); + + var focus = svg.append("g") + .attr("class", "focus") + .attr("width", width - 2 * margin.left) + .attr("height", height) + .attr("pointer-events", "all") + .attr("transform", + "translate(" + margin.left + "," + margin.top + ")"); + var context = svg.append("g") + .attr("class", "context") + .attr("transform", "translate(" + margin.left + "," + (height + margin.top + margin.bottom + brush_height) + ")"); + + var curvegroup = focus.append("g") + .attr('clip-path', 'url(#clip)'); + var curve = curvegroup.selectAll(".curve") + .data(data.curves) + .enter().append("g") + .attr("class", "curve"); + var curve2 = context.selectAll(".curve") + .data(data.curves) + .enter().append("g") + .attr("class", "curve"); + + /* Vertical line */ + var line = focus.append("line") + .attr("class", "line_over") + .attr("x1", 0) + .attr("y1", 0) + .attr("x2", 0) + .attr("y2", height); + + /* Custom rectangle to catch mouse events */ + var rect = focus.append("rect") + .attr("fill", "none") + .attr("width", width - 2 * margin.left) + .attr("height", height); + + var tooltip = container + .append("div") + .attr("class", "remove") + .attr("class", "tip"); + + var points = []; + + var path = curve.append("path") + .attr("class", "area") + .attr("d", function(d, i) { + var path = this; + + points[path] = {}; + d.data.forEach(function(d) { + points[path][d.x] = d.y; + }); + focus.append("circle") + .attr("class", "tracker tracker-" + i) + .attr("r", 4); + return area(d.data); + }) + .attr("data-legend", function(d) { return d.name; }) + .attr("stroke", function(d) { return d.color; }) + .attr("stroke-width", "2px") + .attr("stroke-opacity", 0.8) + .style("fill", function(d) { return d.color; }); + + var xrules = make_x_axis(); + + focus.append("g") + .attr("class", "grid") + .attr("transform", "translate(0," + height + ")") + .call(xrules); + focus.append("g") + .attr("class", "grid") + .call(make_y_axis); + focus.append("g") + .attr("class", "x axis") + .attr("transform", "translate(0," + height + ")") + .call(xAxis); + focus.append("g") + .attr("class", "y axis") + .call(yAxis); + + rect.on("mouseover", function(d) { + line.style("display", "block"); + tooltip.style("display", "block"); + data.curves.forEach(function(d, cid) { + var display = d3.selectAll("[data-legend='" + d.name + "']") + .style("display"); + if (display == "none") { + return; + } + focus.select(".tracker-" + cid).style("display", "block"); + }); + }).on("mousemove", function(d) { + var mousex = d3.mouse(this)[0], + mousey = d3.mouse(this)[1]; + var date = x.invert(mousex); + var text = ""; + + line.attr("x1", mousex).attr("x2", mousex); + data.curves.forEach(function(d, cid) { + var display = d3.selectAll("[data-legend='" + d.name + "']") + .style("display"); + if (display == "none") { + return; + } + var i = bisectDate(d.data, date.getTime(), 1), + d0 = d.data[i - 1], + d1 = d.data[i], + point = date.getTime() - (d0.x * 1000) > (d1.x * 1000) - date.getTime() ? d1 : d0; + text += "

" + d.name + ": " + point.y.toFixed(3) + "

"; + + var tracker_x = x(point.x * 1000), + tracker_y = y(point.y); + + focus.selectAll(".tracker-" + cid) + .style("fill", d.color) + .attr("cx", tracker_x) + .attr("cy", tracker_y); + }); + if (mousex + tooltip[0][0].clientWidth >= width - 2 * margin.left) { + var left = mousex - (tooltip[0][0].clientWidth - (margin.left - 20)); + tooltip.style("left", left + "px"); + } else { + tooltip.style("left", (mousex + margin.left + 20) + "px"); + } + tooltip.html("

" + date + "

" + text); + }).on("mouseout", function(d) { + tooltip.style("display", "none"); + line.style("display", "none"); + focus.selectAll(".tracker").style("display", "none"); + }); + + var legend = focus.append("g") + .attr("class", "legend") + .attr("transform", "translate(50,10)") + .style("font-size", "0.8em") + .call(d3.legend); + + curve2.append("path") + .attr("class", "area2") + .attr("d", function(d) { return area2(d.data); }) + .attr("stroke", function(d) { return d.color; }) + .attr("stroke-width", "2px") + .attr("stroke-opacity", 0.8) + .attr("data-legend", function(d) { return d.name; }) + .style("fill", function(d) { return d.color; }); + + context.append("g") + .attr("class", "x axis") + .attr("transform", "translate(0," + preview_height + ")") + .call(xAxis2); + + context.append("g") + .attr("class", "grid") + .attr("transform", "translate(0," + preview_height + ")") + .call(make_preview_x_axis); + + context.append("g") + .attr("class", "x brush") + .call(brush) + .selectAll("rect") + .attr("y", -6) + .attr("height", preview_height + brush_height); + + function make_x_axis() { + return d3.svg.axis() + .scale(x) + .orient("bottom") + .ticks(6) + .tickSize(-height, 0, 0) + .tickFormat(""); + } + + function make_y_axis() { + return d3.svg.axis() + .scale(y) + .orient("left") + .ticks(6) + .tickSize(-(width - 2 * margin.left), 0, 0) + .tickFormat(""); + } + + function make_preview_x_axis() { + return d3.svg.axis() + .scale(x2) + .orient("bottom") + .ticks(6) + .tickSize(-preview_height, 0, 0) + .tickFormat(""); + } + + function brushed() { + x.domain(brush.empty() ? x2.domain() : brush.extent()); + focus.selectAll(".area") + .attr("d", function(d) { return area(d.data); }); + focus.select(".x.axis").call(xAxis); + focus.select(".grid").call(xrules); + } + } + + /** + * Set or get chart's width. + * + * @this {ModoChart} + * @param {integer} value new width. + * @return {number} current width. + */ + my.width = function(value) { + if (!arguments.length) return width; + width = value; + return my; + }; + + /** + * Set or get chart's height. + * + * @this {ModoChart} + * @param {integer} value new height. + * @return {number} current height. + */ + my.height = function(value) { + if (!arguments.length) return height; + height = value; + return my; + }; + + /** + * Set or get chart's margin. + * + * @this {ModoChart} + * @param {Object} value new margin. + * @return {Object} current margin. + */ + my.margin = function(value) { + if (!arguments.length) return margin; + margin = value; + return my; + }; + + /** + * Compute and return chart's total height. + * + * @this {ModoChart} + * @return {integer} total height. + */ + my.totalHeight = function() { + return height + 2 * margin.top + + 2 * margin.bottom + preview_height + + brush_height; + }; + + /** + * Update the chart with new data. + * + * @this {ModoChart} + * @param {Object} data new data + */ + my.update = function(data) { + container.select("svg").remove(); + $(container[0][0]).empty(); + my(data); + }; + + return my; +} diff --git a/modoboa/extensions/stats/static/stats/js/stats.js b/modoboa/extensions/stats/static/stats/js/stats.js index 0e9d54f12..d81027ccb 100644 --- a/modoboa/extensions/stats/static/stats/js/stats.js +++ b/modoboa/extensions/stats/static/stats/js/stats.js @@ -12,7 +12,7 @@ Stats.prototype = { initialize: function(options) { this.options = $.extend({}, this.defaults, options); - this.options.defcallback = $.proxy(this.graphs_cb, this); + this.options.defcallback = $.proxy(this.charts_cb, this); var navobj = new History(this.options); this.navobj = navobj; @@ -63,12 +63,13 @@ Stats.prototype = { listen: function() { $(".period_selector").click($.proxy(this.change_period, this)); - $("#customsend").on("click", $.proxy(this.customgraphs, this)); + $("#customsend").on("click", $.proxy(this.custom_period, this)); + $(window).resize($.proxy(this.resize_charts, this)); }, register_nav_callbacks: function() { this.navobj.register_callback("graphs", - $.proxy(this.graphs_cb, this)); + $.proxy(this.charts_cb, this)); }, change_period: function(e) { @@ -79,9 +80,37 @@ Stats.prototype = { this.navobj.setparam("period", $link.attr("data-period")).update(); }, - graphs_cb: function(data) { - if (data.content) { - $(".tab-pane.active").html(data.content); + /** + * Update all charts on resize event. + * + * @this {Stats} + */ + resize_charts: function() { + var data = this.data; + $.each(this.charts, function(id, mychart) { + mychart.update(data.graphs[id]); + }); + }, + + /** + * Create or update charts. + * + * @this {Stats} + * @param {Object} data + */ + charts_cb: function(data) { + this.data = data; + if (this.charts !== undefined) { + $.each(this.charts, function(id, mychart) { + mychart.update(data.graphs[id]); + }); + } else { + this.charts = {}; + $.each(data.graphs, $.proxy(function(id, graphdef) { + var mychart = ModoChart("#gset"); + this.charts[id] = mychart; + mychart(graphdef); + }, this)); } }, @@ -94,7 +123,7 @@ Stats.prototype = { this.navobj.delparam("searchquery").update(); }, - customgraphs: function(e) { + custom_period: function(e) { e.preventDefault(); var $fromdate = $("#id_from"); var $todate = $("#id_to"); diff --git a/modoboa/extensions/stats/templates/stats/graphs.html b/modoboa/extensions/stats/templates/stats/graphs.html deleted file mode 100644 index 114deb675..000000000 --- a/modoboa/extensions/stats/templates/stats/graphs.html +++ /dev/null @@ -1,5 +0,0 @@ -{% load i18n %} -{% for name in graphs %} -

{% trans 'No statistics available' %}

-{% endfor %} diff --git a/modoboa/extensions/stats/templates/stats/index.html b/modoboa/extensions/stats/templates/stats/index.html index 9aec1d1ef..25cfa2041 100644 --- a/modoboa/extensions/stats/templates/stats/index.html +++ b/modoboa/extensions/stats/templates/stats/index.html @@ -4,6 +4,9 @@ + + +