mirror of
https://github.com/jwinterm/modoboa.git
synced 2026-10-18 05:51:26 +02:00
Merge branch 'master' into feature/radicale
This commit is contained in:
+1
-1
@@ -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.
|
||||
|
||||
@@ -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
|
||||
<http://en.wikipedia.org/wiki/Local_Mail_Transfer_Protocol>`_ is used
|
||||
to let Postfix deliver messages to Dovecot.
|
||||
|
||||
postmaster_address = postmaster@<domain>
|
||||
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@<domain>
|
||||
mail_plugins = $mail_plugins quota sieve
|
||||
}
|
||||
|
||||
Replace ``<domain>`` 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 = <driver>:/etc/postfix/sql-domains.cf
|
||||
@@ -468,16 +502,11 @@ Use the following configuration in the :file:`/etc/postfix/main.cf` file
|
||||
...
|
||||
check_recipient_access <driver>:/etc/postfix/sql-maintain.cf
|
||||
permit_mynetworks
|
||||
reject_unverified_recipient
|
||||
...
|
||||
|
||||
# Stuff after
|
||||
|
||||
Replace ``<driver>`` 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.
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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("""
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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]
|
||||
@@ -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)
|
||||
@@ -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]
|
||||
@@ -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))
|
||||
@@ -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'
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
})();
|
||||
@@ -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 += "<p>" + d.name + ": " + point.y.toFixed(3) + "</p>";
|
||||
|
||||
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("<p>" + date + "</p>" + 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;
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
{% load i18n %}
|
||||
{% for name in graphs %}
|
||||
<p><img src="{{ MEDIA_URL }}stats/{{ name }}_{{ domain }}_AVERAGE_{% ifequal period 'custom' %}{{ period_name }}{% else %}{{ period }}{% endifequal %}.png"
|
||||
alt="{% trans 'No statistics available' %}" /></p>
|
||||
{% endfor %}
|
||||
@@ -4,6 +4,9 @@
|
||||
<link rel="stylesheet" type="text/css" href="{{ STATIC_URL }}stats/css/stats.css" />
|
||||
<script type="text/javascript" src="{{ STATIC_URL }}js/history.js"></script>
|
||||
<script type="text/javascript" src="{{ STATIC_URL }}js/autocompleter.js"></script>
|
||||
<script type="text/javascript" src="{{ STATIC_URL }}js/d3.v3.js"></script>
|
||||
<script type="text/javascript" src="{{ STATIC_URL }}stats/js/d3.legend.js"></script>
|
||||
<script type="text/javascript" src="{{ STATIC_URL }}stats/js/graphic.js"></script>
|
||||
<script type="text/javascript" src="{{ STATIC_URL }}stats/js/stats.js"></script>
|
||||
<script type="text/javascript">
|
||||
function get_domains_list(container) {
|
||||
@@ -69,7 +72,9 @@ $(document).ready(function() {
|
||||
</li>
|
||||
</ul>{% endfor %}
|
||||
<div class="tab-content">{% for id, gset in graph_sets.items %}
|
||||
<div class="tab-pane {% if forloop.first %}active{% endif %}" id="graphs_{{ gset.html_id }}"></div>
|
||||
<div class="tab-pane {% if forloop.first %}active{% endif %}" id="graphs_{{ gset.html_id }}">
|
||||
<div id="gset"></div>
|
||||
</div>
|
||||
</div>{% endfor %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
# coding: utf-8
|
||||
import re
|
||||
import time
|
||||
from django.shortcuts import render
|
||||
from django.utils.translation import ugettext as _
|
||||
from django.contrib.auth.decorators import (
|
||||
@@ -8,12 +9,12 @@ from django.contrib.auth.decorators import (
|
||||
from modoboa.lib import events
|
||||
from modoboa.lib.exceptions import BadRequest, PermDeniedException, NotFound
|
||||
from modoboa.lib.webutils import (
|
||||
_render_to_string, render_to_json_response
|
||||
render_to_json_response
|
||||
)
|
||||
from modoboa.extensions.admin.models import (
|
||||
Domain
|
||||
)
|
||||
from modoboa.extensions.stats.grapher import periods, str2Time, Grapher
|
||||
from modoboa.extensions.stats.lib import date_to_timestamp
|
||||
|
||||
|
||||
@login_required
|
||||
@@ -27,11 +28,13 @@ def index(request):
|
||||
if not Domain.objects.get_for_admin(request.user).count():
|
||||
raise NotFound(_("No statistics available"))
|
||||
|
||||
period = request.GET.get("period", "day")
|
||||
graph_sets = events.raiseDictEvent('GetGraphSets')
|
||||
periods = [{"name": "day", "label": _("Day")},
|
||||
{"name": "week", "label": _("Week")},
|
||||
{"name": "month", "label": _("Month")},
|
||||
{"name": "year", "label": _("Year")}]
|
||||
return render(request, 'stats/index.html', {
|
||||
"periods": periods,
|
||||
"period": period,
|
||||
"selection": "stats",
|
||||
"deflocation": deflocation,
|
||||
"graph_sets": graph_sets
|
||||
@@ -47,7 +50,7 @@ def graphs(request):
|
||||
raise NotFound(_("Unknown graphic set"))
|
||||
searchq = request.GET.get("searchquery", None)
|
||||
period = request.GET.get("period", "day")
|
||||
tplvars = dict(graphs=[], period=period)
|
||||
tplvars = dict(graphs={}, period=period)
|
||||
if searchq in [None, "global"]:
|
||||
if not request.user.is_superuser:
|
||||
if not Domain.objects.get_for_admin(request.user).count():
|
||||
@@ -64,27 +67,23 @@ def graphs(request):
|
||||
if not request.user.can_access(domain[0]):
|
||||
raise PermDeniedException
|
||||
tplvars.update(domain=domain[0].name)
|
||||
|
||||
if period == "custom":
|
||||
if not "start" in request.GET or not "end" in request.GET:
|
||||
raise BadRequest(_("Bad custom period"))
|
||||
start = request.GET["start"]
|
||||
end = request.GET["end"]
|
||||
G = Grapher()
|
||||
expr = re.compile(r'[:\- ]')
|
||||
period_name = "%s_%s" % (expr.sub('', start), expr.sub('', end))
|
||||
for tpl in gsets[gset].get_graphs():
|
||||
tplvars['graphs'].append(tpl.display_name)
|
||||
G.process(
|
||||
tplvars["domain"], period_name, str2Time(*expr.split(start)),
|
||||
str2Time(*expr.split(end)), tpl
|
||||
)
|
||||
tplvars["period_name"] = period_name
|
||||
tplvars["start"] = start
|
||||
tplvars["end"] = end
|
||||
start = date_to_timestamp(expr.split(start))
|
||||
end = date_to_timestamp(expr.split(end))
|
||||
else:
|
||||
tplvars['graphs'] = gsets[gset].get_graph_names()
|
||||
end = int(time.mktime(time.localtime()))
|
||||
start = "-1%s" % period
|
||||
period_name = period
|
||||
|
||||
return render_to_json_response({
|
||||
'content': _render_to_string(request, "stats/graphs.html", tplvars)
|
||||
})
|
||||
tplvars['graphs'] = gsets[gset].export(tplvars["domain"], start, end)
|
||||
tplvars["period_name"] = period_name
|
||||
tplvars["start"] = start
|
||||
tplvars["end"] = end
|
||||
|
||||
return render_to_json_response(tplvars)
|
||||
|
||||
Vendored
+5
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user