diff --git a/extensions/amavis_quarantine/cleanup.py b/extensions/amavis_quarantine/cleanup.py index 9d052c3da..235be0789 100755 --- a/extensions/amavis_quarantine/cleanup.py +++ b/extensions/amavis_quarantine/cleanup.py @@ -1,33 +1,49 @@ #!/usr/bin/env python # -*- coding: utf-8 -*- -from modoboa.lib import db, parameters +import time +from modoboa.extensions.amavis_quarantine.models import * if __name__ == "__main__": from modoboa.extensions.amavis_quarantine import main + from modoboa.lib import parameters + from optparse import OptionParser + + parser = OptionParser() + parser.add_option("--debug", action="store_true", + help="Activate debug output") + options, args = parser.parse_args() + + if options.debug: + import logging + l = logging.getLogger("django.db.backends") + l.setLevel(logging.DEBUG) + l.addHandler(logging.StreamHandler()) main.init() - conn = db.getconnection("amavis_quarantine") + max_messages_age = int(parameters.get_admin("MAX_MESSAGES_AGE", app="amavis_quarantine")) + flags = ['D'] + if parameters.get_admin("RELEASED_MSGS_CLEANUP", + app="amavis_quarantine") == "yes": + flags += ['R'] + + print "Deleting marked messages..." + ids = Msgrcpt.objects.filter(rs__in=flags).values("mail_id") + for msg in Msgs.objects.filter(mail_id__in=ids): + if not msg.msgrcpt_set.exclude(rs__in=flags).count(): + msg.delete() + print "Deleting messages older than %d days..." % max_messages_age - db.execute(conn, """ -DELETE FROM msgs WHERE time_num < UNIX_TIMESTAMP()-%d*24*3600; -""" % max_messages_age) + limit = int(time.time()) - (max_messages_age * 24 * 3600) + Msgs.objects.filter(time_num__lt=limit).delete() print "Deleting unreferenced e-mail addresses..." - db.execute(conn, """ -DELETE FROM maddr - WHERE NOT EXISTS (SELECT 1 FROM msgs WHERE sid=id) - AND NOT EXISTS (SELECT 1 FROM msgrcpt WHERE rid=id); -""") - - print "Optimizing tables..." - db.execute(conn, """ -OPTIMIZE TABLE msgs, msgrcpt, quarantine, maddr; -""") - - db.close(conn) - + for maddr in Maddr.objects.all(): + if not len(maddr.msgs_set.all()) and not len(maddr.msgrcpt_set.all()): + maddr.delete() + print "Done." + diff --git a/extensions/amavis_quarantine/dbrouter.py b/extensions/amavis_quarantine/dbrouter.py new file mode 100644 index 000000000..df908458e --- /dev/null +++ b/extensions/amavis_quarantine/dbrouter.py @@ -0,0 +1,30 @@ +class AmavisRouter(object): + """A router to control all database operations on models in + the amavis_quarantine application""" + + def db_for_read(self, model, **hints): + "Point all operations on amavis_quarantine models to 'amavis'" + if model._meta.app_label == 'amavis_quarantine': + return 'amavis' + return None + + def db_for_write(self, model, **hints): + "Point all operations on amavis_quarantine models to 'amavis'" + if model._meta.app_label == 'amavis_quarantine': + return 'amavis' + return None + + def allow_relation(self, obj1, obj2, **hints): + "Allow any relation if a model in amavis_quarantine is involved" + if obj1._meta.app_label == 'amavis_quarantine' \ + or obj2._meta.app_label == 'amavis_quarantine': + return True + return None + + def allow_syncdb(self, db, model): + "Make sure the amavis_quarantine app only appears on the 'amavis' db" + if db == 'amavis': + return model._meta.app_label == 'amavis_quarantine' + elif model._meta.app_label == 'amavis_quarantine': + return False + return None diff --git a/extensions/amavis_quarantine/main.py b/extensions/amavis_quarantine/main.py index 892ecb0e1..cf330f2a8 100644 --- a/extensions/amavis_quarantine/main.py +++ b/extensions/amavis_quarantine/main.py @@ -19,6 +19,8 @@ def init(): parameters.register_admin("MAX_MESSAGES_AGE", type="int", deflt=14, help=_("Quarantine messages maximum age (in days) before deletion")) + parameters.register_admin("RELEASED_MSGS_CLEANUP", type="list_yesno", deflt="no", + help=_("Remove messages marked as released while cleaning up the database")) parameters.register_admin("AM_PDP_MODE", type="list", deflt="unix", values=[("inet", "inet"), ("unix", "unix")], diff --git a/extensions/amavis_quarantine/models.py b/extensions/amavis_quarantine/models.py new file mode 100644 index 000000000..10187e656 --- /dev/null +++ b/extensions/amavis_quarantine/models.py @@ -0,0 +1,141 @@ +# This is an auto-generated Django model module. +# You'll have to do the following manually to clean this up: +# * Rearrange models' order +# * Make sure each model has one field with primary_key=True +# Feel free to rename the models, but don't rename db_table values or field names. +# +# Also note: You'll have to insert the output of 'django-admin.py sqlcustom [appname]' +# into your database. +# +# Original Amavis version : 2.6.2 + +from django.db import models + +class Maddr(models.Model): + partition_tag = models.IntegerField(unique=True, null=True, blank=True) + id = models.BigIntegerField(primary_key=True) + email = models.CharField(unique=True, max_length=255) + domain = models.CharField(max_length=765) + class Meta: + db_table = u'maddr' + managed = False + +class Mailaddr(models.Model): + id = models.IntegerField(primary_key=True) + priority = models.IntegerField() + email = models.CharField(unique=True, max_length=255) + class Meta: + db_table = u'mailaddr' + managed = False + +class Msgs(models.Model): + partition_tag = models.IntegerField(null=True, blank=True) + mail_id = models.CharField(max_length=12, primary_key=True) + secret_id = models.CharField(max_length=12, blank=True) + am_id = models.CharField(max_length=60) + time_num = models.IntegerField() + time_iso = models.CharField(max_length=48) + sid = models.ForeignKey(Maddr, db_column='sid') + policy = models.CharField(max_length=765, blank=True) + client_addr = models.CharField(max_length=765, blank=True) + size = models.IntegerField() + content = models.CharField(max_length=1, blank=True) + quar_type = models.CharField(max_length=1, blank=True) + quar_loc = models.CharField(max_length=255, blank=True) + dsn_sent = models.CharField(max_length=3, blank=True) + spam_level = models.FloatField(null=True, blank=True) + message_id = models.CharField(max_length=765, blank=True) + from_addr = models.CharField(max_length=765, blank=True) + subject = models.CharField(max_length=765, blank=True) + host = models.CharField(max_length=765) + class Meta: + db_table = u'msgs' + managed = False + +class Msgrcpt(models.Model): + partition_tag = models.IntegerField(null=True, blank=True) + mail = models.ForeignKey(Msgs, primary_key=True) + rid = models.ForeignKey(Maddr, db_column='rid', primary_key=True) + ds = models.CharField(max_length=3) + rs = models.CharField(max_length=3) + bl = models.CharField(max_length=3, blank=True) + wl = models.CharField(max_length=3, blank=True) + bspam_level = models.FloatField(null=True, blank=True) + smtp_resp = models.CharField(max_length=765, blank=True) + class Meta: + db_table = u'msgrcpt' + managed = False + unique_together = ("mail", "rid") + +class Policy(models.Model): + id = models.IntegerField(primary_key=True) + policy_name = models.CharField(max_length=96, blank=True) + virus_lover = models.CharField(max_length=3, blank=True) + spam_lover = models.CharField(max_length=3, blank=True) + banned_files_lover = models.CharField(max_length=3, blank=True) + bad_header_lover = models.CharField(max_length=3, blank=True) + bypass_virus_checks = models.CharField(max_length=3, blank=True) + bypass_spam_checks = models.CharField(max_length=3, blank=True) + bypass_banned_checks = models.CharField(max_length=3, blank=True) + bypass_header_checks = models.CharField(max_length=3, blank=True) + spam_modifies_subj = models.CharField(max_length=3, blank=True) + virus_quarantine_to = models.CharField(max_length=192, blank=True) + spam_quarantine_to = models.CharField(max_length=192, blank=True) + banned_quarantine_to = models.CharField(max_length=192, blank=True) + bad_header_quarantine_to = models.CharField(max_length=192, blank=True) + clean_quarantine_to = models.CharField(max_length=192, blank=True) + other_quarantine_to = models.CharField(max_length=192, blank=True) + spam_tag_level = models.FloatField(null=True, blank=True) + spam_tag2_level = models.FloatField(null=True, blank=True) + spam_kill_level = models.FloatField(null=True, blank=True) + spam_dsn_cutoff_level = models.FloatField(null=True, blank=True) + spam_quarantine_cutoff_level = models.FloatField(null=True, blank=True) + addr_extension_virus = models.CharField(max_length=192, blank=True) + addr_extension_spam = models.CharField(max_length=192, blank=True) + addr_extension_banned = models.CharField(max_length=192, blank=True) + addr_extension_bad_header = models.CharField(max_length=192, blank=True) + warnvirusrecip = models.CharField(max_length=3, blank=True) + warnbannedrecip = models.CharField(max_length=3, blank=True) + warnbadhrecip = models.CharField(max_length=3, blank=True) + newvirus_admin = models.CharField(max_length=192, blank=True) + virus_admin = models.CharField(max_length=192, blank=True) + banned_admin = models.CharField(max_length=192, blank=True) + bad_header_admin = models.CharField(max_length=192, blank=True) + spam_admin = models.CharField(max_length=192, blank=True) + spam_subject_tag = models.CharField(max_length=192, blank=True) + spam_subject_tag2 = models.CharField(max_length=192, blank=True) + message_size_limit = models.IntegerField(null=True, blank=True) + banned_rulenames = models.CharField(max_length=192, blank=True) + class Meta: + db_table = u'policy' + managed = False + +class Quarantine(models.Model): + partition_tag = models.IntegerField(null=True, blank=True) + mail = models.ForeignKey(Msgs, primary_key=True) + chunk_ind = models.IntegerField() + mail_text = models.TextField() + class Meta: + db_table = u'quarantine' + managed = False + ordering = ["-mail__time_num"] + unique_together = ("mail", "chunk_ind") + +class Users(models.Model): + id = models.IntegerField(primary_key=True) + priority = models.IntegerField() + policy_id = models.IntegerField() + email = models.CharField(unique=True, max_length=255) + fullname = models.CharField(max_length=765, blank=True) + local = models.CharField(max_length=3, blank=True) + class Meta: + db_table = u'users' + managed = False + +class Wblist(models.Model): + rid = models.IntegerField(primary_key=True) + sid = models.IntegerField(primary_key=True) + wb = models.CharField(max_length=30) + class Meta: + db_table = u'wblist' + managed = False diff --git a/extensions/amavis_quarantine/sql_listing.py b/extensions/amavis_quarantine/sql_listing.py index f05cd4f80..255d9bb7f 100644 --- a/extensions/amavis_quarantine/sql_listing.py +++ b/extensions/amavis_quarantine/sql_listing.py @@ -2,9 +2,11 @@ from datetime import datetime from django.utils.translation import ugettext as _ -from modoboa.lib import tables, db, static_url +from django.db.models import Q +from modoboa.lib import tables, static_url from modoboa.lib.email_listing import MBconnector, EmailListing from modoboa.lib.emailutils import * +from models import * class Qtable(tables.Table): tableid = "emails" @@ -16,7 +18,7 @@ class Qtable(tables.Table): from_ = tables.Column("from", label=_("From"), limit=30) subject = tables.Column("subject", label=_("Subject"), limit=40) time = tables.Column("date", label=_("Date")) - to = tables.Column("to", label=_("To"), limit=30) + to = tables.Column("to", label=_("To"), sortable=False) cols_order = ['type', 'rstatus', 'to', 'from_', 'subject', 'time'] @@ -24,64 +26,51 @@ class Qtable(tables.Table): return datetime.fromtimestamp(value) class SQLconnector(MBconnector): - def __init__(self, filter=None): - self.conn = db.getconnection("amavis_quarantine") - self.filter = "" - if filter: - for a in filter: - if not a: - continue - if a[0] == "&": - self.filter += " AND " - else: - self.filter += " OR " - self.filter += a[1:] - query = self._get_query() - status, cursor = db.execute(self.conn, """ -SELECT count(quarantine.mail_id) AS total -%s -""" % query) - if not status: - print cursor - self.count = 0 - else: - self.count = int(cursor.fetchone()[0]) - - def _get_query(self): - return """ -FROM quarantine, maddr, msgrcpt, msgs -WHERE quarantine.mail_id=msgrcpt.mail_id -AND msgrcpt.rid=maddr.id -AND msgrcpt.mail_id=msgs.mail_id -AND quarantine.chunk_ind=1 -%s -ORDER BY msgs.time_num DESC -""" % self.filter + orders = { + "from" : "mail__from_addr", + "subject" : "mail__subject", + "date" : "mail__time_num" + } + + def __init__(self, mail_ids=None, filter=None): + self.count = None + self.mail_ids = mail_ids + self.filter = filter def messages_count(self, **kwargs): + if self.count is None: + filter = Q(chunk_ind=1) + if self.mail_ids is not None: + filter &= Q(mail__in=self.mail_ids) + if self.filter: + filter &= self.filter + self.messages = Quarantine.objects.filter(filter) + if kwargs.has_key("order"): + totranslate = kwargs["order"][1:] + sign = kwargs["order"][:1] + if sign == " ": + sign = "" + order = sign + self.orders[totranslate] + self.messages = self.messages.order_by(order) + self.count = self.messages.count() return self.count def fetch(self, start=None, stop=None, **kwargs): - query = self._get_query() - status, cursor = db.execute(self.conn, """ -SELECT msgs.from_addr, maddr.email, msgs.subject, msgs.content, quarantine.mail_id, - msgs.time_num, msgs.content, msgrcpt.rs -%s -LIMIT %d,%d -""" % (query, start - 1, kwargs["nbelems"])) - if not status: - print cursor - return [] + messages = self.messages[start - 1:stop] emails = [] - rows = cursor.fetchall() - for row in rows: - m = {"from" : row[0], "to" : row[1], - "subject" : row[2], "content" : row[3], - "mailid" : row[4], "date" : row[5], - "type" : row[6]} - if row[7] == "R": - m["img_rstatus"] = static_url("pics/release.png"); - emails.append(m) + for qm in messages: + for rcpt in qm.mail.msgrcpt_set.all(): + m = {"from" : qm.mail.from_addr, + "to" : rcpt.rid.email, + "subject" : qm.mail.subject, + "mailid" : qm.mail_id, + "date" : qm.mail.time_num, + "type" : qm.mail.content} + if rcpt.rs == '': + m["class"] = "unseen" + elif rcpt.rs == 'R': + m["img_rstatus"] = static_url("pics/release.png") + emails.append(m) return emails class SQLlisting(EmailListing): @@ -91,16 +80,17 @@ class SQLlisting(EmailListing): defcallback = "updatelisting" reset_wm_url = True - def __init__(self, user, filter, **kwargs): + def __init__(self, user, msgs, filter, **kwargs): if not user.is_superuser: Qtable.cols_order.remove('to') - self.mbc = SQLconnector(filter) - EmailListing.__init__(self, **kwargs) + self.mbc = SQLconnector(msgs, filter) + + super(SQLlisting, self).__init__(**kwargs) class SQLemail(Email): def __init__(self, msg, *args, **kwargs): - Email.__init__(self, msg, *args, **kwargs) - fields = ["X-Amavis-Alert", "Subject", "From", "To", "Date"] + super(SQLemail, self).__init__(msg, *args, **kwargs) + fields = ["X-Amavis-Alert", "Subject", "From", "To", "Cc", "Date"] for f in fields: label = f if not msg.has_key(f): diff --git a/extensions/amavis_quarantine/static/js/quarantine.js b/extensions/amavis_quarantine/static/js/quarantine.js index 5e3d9b960..28db0502c 100644 --- a/extensions/amavis_quarantine/static/js/quarantine.js +++ b/extensions/amavis_quarantine/static/js/quarantine.js @@ -1,6 +1,7 @@ function updatehash(evt) { evt.stop(); - current_anchor.parse_string(evt.target.get("href"), true).setparams(navparams); + var url = $defined(this.get("href")) ? this.get("href") : ""; + current_anchor.parse_string(url, true).setparams(navparams); current_anchor.update(); } @@ -20,13 +21,12 @@ function actioncallback(resp) { $("menubar").set("html", ""); $("navbar").set("html", ""); if (resp.status == "ok") { - infobox.info(resp.message); + infobox.info(resp.respmsg); + current_anchor.parse_string(resp.url, true).setparams(navparams); + current_anchor.update(1); } else { - infobox.error(resp.message); + infobox.error(resp.respmsg); } - infobox.hide(1); - current_anchor.parse_string(resp.url, true).setparams(navparams); - current_anchor.update(1); } function actionclick(evt) { @@ -37,6 +37,17 @@ function actionclick(evt) { }).get(); } +function viewmail_cs(evt) { + evt.stop(); + var to = this.getFirst("td[name=to]"); + + current_anchor.baseurl(this.get("id")); + if ($defined(to)) { + current_anchor.setparam("rcpt", to.get("html").trim()); + } + current_anchor.update(); +} + /* * Callback of the 'listing' action (the main screen) */ @@ -92,7 +103,9 @@ function listing_cb(resp) { mysubmit(event, "delete"); }); searchbox_init(); - $$("tr").addEvent("dblclick", viewmail); + init_sortable_columns(); + update_sortable_column(); + $$("tbody>tr").addEvent("dblclick", viewmail_cs); } /* diff --git a/extensions/amavis_quarantine/templates/amavis_quarantine/index.html b/extensions/amavis_quarantine/templates/amavis_quarantine/index.html index 359dcc0b9..22368f1f8 100644 --- a/extensions/amavis_quarantine/templates/amavis_quarantine/index.html +++ b/extensions/amavis_quarantine/templates/amavis_quarantine/index.html @@ -9,7 +9,13 @@ event.stop(); var selection = new Array(); $$("tr[class*=table-tr-selected]").each(function(item) { - selection.push(item.get("id")); + var content = ""; + var to = item.getFirst("td[name=to]"); + + if ($defined(to)) { + content = to.get("html").trim() + " "; + } + selection.push(content + item.get("id")); }); new Element("input", { "type" : "hidden", "name" : "selection", "value" : selection diff --git a/extensions/amavis_quarantine/templatetags/amextras.py b/extensions/amavis_quarantine/templatetags/amextras.py index b92813a72..da40bd633 100644 --- a/extensions/amavis_quarantine/templatetags/amextras.py +++ b/extensions/amavis_quarantine/templatetags/amextras.py @@ -9,7 +9,7 @@ from modoboa.extensions import amavis_quarantine register = template.Library() @register.simple_tag -def viewm_menu(selection, backurl, mail_id, perms): +def viewm_menu(selection, backurl, mail_id, rcpt, perms): options_menu = [ {"name" : "viewmode", "label" : _("View as plain text"), @@ -34,12 +34,14 @@ def viewm_menu(selection, backurl, mail_id, perms): "class" : "boxed", "rel" : "{handler:'iframe',size:{x:600,y:500}}"}, {"name" : "release", - "url" : reverse(amavis_quarantine.views.release, args=[mail_id]), + "url" : reverse(amavis_quarantine.views.release, args=[mail_id]) \ + + "?rcpt=%s" % rcpt, "img" : static_url("pics/release.png"), "label" : _("Release"), "confirm" : _("Release this message?")}, {"name" : "delete", - "url" : reverse(amavis_quarantine.views.delete, args=[mail_id]), + "url" : reverse(amavis_quarantine.views.delete, args=[mail_id]) \ + + "?rcpt=%s" % rcpt, "img" : static_url("pics/remove.png"), "label" : _("Delete"), "confirm" : _("Delete this message?")}, @@ -55,7 +57,7 @@ def viewm_menu(selection, backurl, mail_id, perms): "perms" : perms}) @register.simple_tag -def quar_menu(selection, perms): +def quar_menu(selection, user): entries = [ {"name" : "release", "url" : "", @@ -88,9 +90,13 @@ def quar_menu(selection, perms): ] menu = render_to_string('common/menu.html', - {"selection" : selection, "entries" : entries, - "perms" : perms}) + {"selection" : selection, "entries" : entries}) + if user.is_superuser or is_domain_admin(user): + extraopts = [{"name" : "to", "label" : _("To")}] + else: + extraopts = [] searchbar = render_to_string('common/email_searchbar.html', { - "MEDIA_URL" : settings.MEDIA_URL + "MEDIA_URL" : settings.MEDIA_URL, + "extraopts" : extraopts }) return menu + searchbar diff --git a/extensions/amavis_quarantine/views.py b/extensions/amavis_quarantine/views.py index ed9abef20..b9630077b 100644 --- a/extensions/amavis_quarantine/views.py +++ b/extensions/amavis_quarantine/views.py @@ -1,10 +1,6 @@ # -*- coding: utf-8 -*- -from datetime import datetime -import time import email -import re -import os from django.http import HttpResponseRedirect, HttpResponse from django.template import Template, Context from django.utils import simplejson @@ -12,51 +8,78 @@ from django.utils.translation import ugettext as _, ungettext from django.core.urlresolvers import reverse from django.contrib.auth.decorators \ import login_required -from modoboa.lib import _render, _ctx_ok, _ctx_ko, decode, getctx -from modoboa.lib import db, parameters +from django.db.models import Q +from modoboa.lib import parameters, _render, getctx, ajax_response from modoboa.admin.models import Mailbox from lib import AMrelease from templatetags.amextras import * from modoboa.lib.email_listing import parse_search_parameters from sql_listing import * +from admin.lib import is_domain_admin def __get_current_url(request): - res = "?page=%s" % request.session["page"] - for p in ["criteria", "pattern"]: - if p in request.session.keys(): - res += "&%s=%s" % (p, request.session[p]) + if request.session.has_key("page"): + res = "?page=%s" % request.session["page"] + else: + res = "" + params = "&".join(map(lambda p: "%s=%s" % (p, request.session[p]), + filter(request.session.has_key, ["criteria", "pattern"]))) + if params != "": + res += "?%s" % (params) return res @login_required -def _listing(request, internal=False, filter=None): - #start = time.time() - if not request.user.is_superuser: - mb = Mailbox.objects.get(user=request.user.id) - if filter is None: - filter = ["&maddr.email='%s'" % mb.full_address] - else: - filter += ["&maddr.email='%s'" % mb.full_address] +def _listing(request): + filter = None + rcptfilter = None + msgs = None + + order = request.GET.has_key("order") and request.GET["order"] or "-date" + if not request.session.has_key("navparams"): + request.session["navparams"] = {} + request.session["navparams"]["order"] = order parse_search_parameters(request) if request.session.has_key("pattern"): - tmp = "" - for c in request.session["criteria"].split(','): - if tmp != "": - tmp += " OR " - tmp += "msgs.%s LIKE '%%%s%%'" % (c, request.session["pattern"]) - tmp = "&(%s)" % tmp - if filter is None: - filter = [tmp] - else: - filter += [tmp] + criteria = request.session["criteria"] + if criteria == "both": + criteria = "from_addr,subject,to" + for c in criteria.split(","): + if c == "from_addr": + nfilter = Q(mail__from_addr__contains=request.session["pattern"]) + elif c == "subject": + nfilter = Q(mail__subject__contains=request.session["pattern"]) + elif c == "to": + rcptfilter = request.session["pattern"] + continue + else: + raise Exception("unsupported search criteria %s" % c) + filter = nfilter if filter is None else filter | nfilter - pageid = request.GET.has_key("page") and int(request.GET["page"]) or 1 - request.session["page"] = pageid # ?? nécessaire - lst = SQLlisting(request.user, filter, baseurl="listing/", empty=internal, + q = ~Q(rs='D') + if not request.user.is_superuser: + mb = Mailbox.objects.get(user=request.user.id) + if is_domain_admin(request.user): + q &= Q(rid__email__contains=mb.domain.name) + else: + q &= Q(rid__email=mb.full_address) + if (request.user.is_superuser or is_domain_admin(request.user)) \ + and rcptfilter is not None: + q &= Q(rid__email__contains=rcptfilter) + msgs = Msgrcpt.objects.filter(q).values("mail_id") + + if request.GET.has_key("page"): + request.session["page"] = request.GET["page"] + pageid = int(request.session["page"]) + else: + if request.session.has_key("page"): + del request.session["page"] + pageid = 1 + + lst = SQLlisting(request.user, msgs, filter, baseurl="listing/", + navparams=request.session["navparams"], elems_per_page=int(parameters.get_user(request.user, "MESSAGES_PER_PAGE"))) - if internal: - return lst.render(request, pageid=int(pageid)) page = lst.paginator.getpage(pageid) if page: content = lst.fetch(request, page.id_start, page.id_stop) @@ -65,26 +88,21 @@ def _listing(request, internal=False, filter=None): content = "