Work in progress for #140 and #143

Aliases to external addresses are now supported. Alias creation/modification forms have been rewritten. (autocompletion of addresses, dynamic number of input fields)

see #143

First version of the postfixadmin to modoboa migration script. It has not been tested deeply but I think it should work pretty well.

see #140

Some javascript errors (SqueezeBox related) have been fixed.
This commit is contained in:
Antoine Nguyen
2011-06-06 11:55:13 +02:00
parent 1d068ef404
commit a2ec92daa7
31 changed files with 1663 additions and 165 deletions
+59 -5
View File
@@ -250,7 +250,7 @@ For *MySQL* users, define the following map files on your server (it should work
password = <password>
dbname = <database>
hosts = 127.0.0.1
query = SELECT name FROM admin_domain WHERE name='%s' AND enabled='1'
query = SELECT name FROM admin_domain WHERE name='%s' AND enabled=1
``/etc/postfix/sql-domain-aliases.cf``::
@@ -266,7 +266,7 @@ For *MySQL* users, define the following map files on your server (it should work
password = <password>
dbname = <database>
hosts = 127.0.0.1
query = SELECT concat(t2.name, '/', t1.path) FROM admin_mailbox AS t1, admin_domain AS t2 WHERE t1.domain_id=t2.id AND t1.address='%u' AND t2.name='%d'
query = SELECT concat(t2.name, '/', t1.path) FROM admin_mailbox AS t1, admin_domain AS t2 WHERE t1.enabled=1 AND t1.domain_id=t2.id AND t1.address='%u' AND t2.enabled=1 AND t2.name='%d'
``/etc/postfix/sql-aliases.cf``::
@@ -274,7 +274,15 @@ For *MySQL* users, define the following map files on your server (it should work
password = <password>
dbname = <database>
hosts = 127.0.0.1
query = SELECT concat(t3.address, '@', t4.name) FROM admin_alias AS t1, admin_alias_mboxes AS t2, admin_mailbox AS t3, admin_domain AS t4 WHERE t1.enabled='1' AND t1.address='%u' AND t1.id=t2.alias_id AND t2.mailbox_id=t3.id AND t3.domain_id=t4.id AND t4.name='%d'
query = SELECT concat(t3.address, '@', t4.name) FROM admin_alias AS t1, admin_alias_mboxes AS t2, admin_mailbox AS t3, admin_domain AS t4 WHERE t1.enabled=1 AND t1.address='%u' AND t1.id=t2.alias_id AND t2.mailbox_id=t3.id AND t3.enabled=1 AND t3.domain_id=t4.id AND t4.enabled=1 AND t4.name='%d'
``/etc/postfix/sql-forwards.cf``::
user = <user>
password = <password>
dbname = <database>
hosts = 127.0.0.1
query = SELECT t1.extmboxes FROM admin_alias AS t1, admin_domain AS t2 WHERE t1.enabled=1 AND t1.address='%u' AND t1.domain_id=t2.id AND t2.name="%d" AND t2.enabled=1
``/etc/postfix/sql-domain-aliases-mailboxes.cf``::
@@ -301,6 +309,7 @@ Then, use the following configuration in the */etc/postfix/main.cf* file
virtual_alias_domains = mysql:/etc/postfix/sql-domain-aliases.cf
virtual_mailbox_maps = mysql:/etc/postfix/sql-mailboxes.cf
virtual_alias_maps = mysql:/etc/postfix/sql-aliases.cf,
mysql:/etc/postfix/sql-forwards.cf,
mysql:/etc/postfix/sql-domain-aliases-mailboxes.cf
# Stuff after
@@ -308,6 +317,51 @@ NB: Modoboa supports both maildir and mbox formats. You can specify
which format to use by modifying the MAILBOX_TYPE parameter available
in the admin panel.
Optional : 'catchall' aliases
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
Modoboa supports 'catchall' aliases creation. In the case you would
like to use this feature, you'll need to add specific queries to
*postfix* configuration.
First, create the following new maps:
``/etc/postfix/sql-email2email.cf``::
user = <user>
password = <password>
dbname = <database>
hosts = 127.0.0.1
query = SELECT concat(t1.address, '@', t2.name) FROM admin_mailbox AS t1, admin_domain AS t2 WHERE t1.domain_id=t2.id AND t1.address='%u' AND t2.name='%d'
``/etc/postfix/sql-catchall-aliases.cf``::
user = <user>
password = <password>
dbname = <database>
hosts = 127.0.0.1
query = SELECT concat(t3.address, '@', t4.name) FROM admin_alias AS t1, admin_alias_mboxes AS t2, admin_mailbox AS t3, admin_domain AS t4 WHERE t1.enabled='1' AND t1.address='*' AND t1.id=t2.alias_id AND t2.mailbox_id=t3.id AND t3.enabled='1' AND t3.domain_id=t4.id AND t4.enabled='1' AND t4.name='%d'
``/etc/postfix/sql-catchall-forwards.cf``::
user = <user>
password = <password>
dbname = <database>
hosts = 127.0.0.1
query = SELECT t1.extmboxes FROM admin_alias AS t1, admin_domain AS t2 WHERE t1.enabled='1' AND t1.address='*' AND t1.domain_id=t2.id AND t2.name="%d" AND t2.enabled='1'
Once done, edit the *main.cf* configuration file and add the new maps
to the ``virtual_alias_maps`` parameter like this::
virtual_alias_maps = mysql:/etc/postfix/sql-aliases.cf,
mysql:/etc/postfix/sql-forwards.cf,
mysql:/etc/postfix/sql-domain-aliases-mailboxes.cf,
mysql:/etc/postfix/sql-email2email.cf, # new
mysql:/etc/postfix/sql-catchall-aliases.cf, # new
mysql:/etc/postfix/sql-catchall-forwards.cf # new
Reload *postfix*.
.. _dovecot-label:
Dovecot
@@ -529,8 +583,8 @@ images). In order to display images correctly, you need to create a
$ mkdir <modoboa_dir>/static/tmp
To be able to release messages, first take a look at this page:
http://www.ijs.si/software/amavisd/amavisd-new-docs.html#quar-release. It
To be able to release messages, first take a look at `this page
<http://www.ijs.si/software/amavisd/amavisd-new-docs.html#quar-release>`_. It
explains how to configure *amavisd-new* to listen somewhere for the
AM.PDP protocol. This protocol is used to send release requests.
+39 -20
View File
@@ -4,7 +4,7 @@ from modoboa.admin.models import *
from django.utils.translation import ugettext as _
from django.contrib.auth.models import User
from modoboa.admin.templatetags.admin_extras import gender
from modoboa.lib import tables
from modoboa.lib import tables, split_mailbox
class DomainForm(forms.ModelForm):
class Meta:
@@ -87,51 +87,70 @@ class MailboxForm(ProxyForm):
return m
class AliasForm(ProxyForm):
domain = forms.ModelChoiceField(queryset=Domain.objects.all(), label=_("Domain"),
required=True,
empty_label=_("Select a domain"),
help_text=_("Select a domain in the list"))
targets = forms.CharField(label=_("Target(s)"), required=False,
help_text=_("Mailbox(es) this alias will point to"))
def __init__(self, *args, **kwargs):
super(AliasForm, self).__init__(*args, **kwargs)
self.fields['address'].widget.attrs['size'] = 14
self.fields['mboxes'].widget.attrs['size'] = 5
# This part is a little bit tricky. As 'domain' is not a
# member of Alias, we need to fill it manually. if
# self._domain exists, it means a DomainAdmin is going to
# create a new alias, we know how to fill the fields. Else,
# leave them empty (super admin mode).
# create a new alias, we know how to fill the fields.
if self._domain is not None:
self.fields["domain"].queryset = Domain.objects.filter(pk=self._domain.id)
elif len(args) < 2 or args[1]["domain"] == '':
self.fields['mboxes'].queryset = Mailbox.objects.none()
# if 'instance' exists, it means we are modifying an existing
# record, we need to select the right value for
# 'domain'. Finally, if super user is doing the job, we need
# to fill 'mboxes' because it's currently empty (see upper).
if kwargs.has_key("instance") or self._domain is not None:
domain = self._domain is None and kwargs["instance"].domain or self._domain
# record, we need to select the right value for 'domain' and
# to fill the first "targets" input (because it is created by the
# Form object, not by us).
domain = None
if self._domain:
domain = self._domain
if kwargs.has_key("instance"):
if self._domain is None:
domain = kwargs["instance"].domain
self.fields["targets"].initial = kwargs["instance"].first_target()
if domain is not None:
self.fields["domain"].initial = domain
self.fields['mboxes'].queryset = Mailbox.objects.filter(domain=domain)
self.fields.keyOrder = ['domain', 'address', 'mboxes', 'enabled']
self.fields.keyOrder = ['address', 'domain', 'enabled', 'targets']
class Meta:
model = Alias
fields = ('address', 'mboxes', 'enabled')
fields = ('address', 'domain', 'enabled')
def clean_address(self):
if self.cleaned_data["address"].find('@') != -1:
return self.cleaned_data["address"].rsplit("@", 1)[0]
return self.cleaned_data["address"]
def set_targets(self, user, values):
self.ext_targets = []
self.int_targets = []
for addr in values:
if addr == "":
continue
local_part, domain = split_mailbox(addr)
if domain is None:
raise AdminError("%s %s" % (_("Invalid mailbox"), addr))
try:
mb = Mailbox.objects.get(address=local_part, domain__name=domain)
except Mailbox.DoesNotExist:
self.ext_targets += [addr]
else:
if not user.is_superuser:
usermb = Mailbox.objects.get(user=user.id)
if usermb.domain.id != mb.domain.id:
raise AdminError("%s %s" % (_("Permission denied on"), addr))
self.int_targets += [mb]
def save(self, force_insert=False, force_update=False, commit=True):
a = super(AliasForm, self).save(commit=False)
if commit:
domain = self.cleaned_data["domain"].name
a.save()
a.save(self.int_targets, self.ext_targets)
self.save_m2m()
return a
class SuperAdminForm(forms.Form):
@@ -0,0 +1,103 @@
# encoding: utf-8
import datetime
from south.db import db
from south.v2 import SchemaMigration
from django.db import models
class Migration(SchemaMigration):
def forwards(self, orm):
# Adding field 'Alias.extmboxes'
db.add_column('admin_alias', 'extmboxes', self.gf('django.db.models.fields.TextField')(default='', blank=True), keep_default=False)
db.add_column('admin_alias', 'domain', self.gf('django.db.models.fields.related.ForeignKey')(to=orm['admin.Domain'], default=-1))
def backwards(self, orm):
# Deleting field 'Alias.extmboxes'
db.delete_column('admin_alias', 'extmboxes')
db.delete_column('admin_alias', 'domain')
models = {
'admin.alias': {
'Meta': {'object_name': 'Alias'},
'address': ('django.db.models.fields.CharField', [], {'max_length': '254'}),
'enabled': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
'extmboxes': ('django.db.models.fields.TextField', [], {'blank': 'True'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'mboxes': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['admin.Mailbox']", 'symmetrical': 'False'})
},
'admin.domain': {
'Meta': {'object_name': 'Domain'},
'enabled': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'name': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
'quota': ('django.db.models.fields.IntegerField', [], {})
},
'admin.domainalias': {
'Meta': {'object_name': 'DomainAlias'},
'enabled': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '100'}),
'target': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['admin.Domain']"})
},
'admin.extension': {
'Meta': {'object_name': 'Extension'},
'enabled': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'name': ('django.db.models.fields.CharField', [], {'max_length': '150'})
},
'admin.mailbox': {
'Meta': {'object_name': 'Mailbox'},
'address': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
'domain': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['admin.Domain']"}),
'gid': ('django.db.models.fields.IntegerField', [], {}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'name': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
'password': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
'path': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
'quota': ('django.db.models.fields.IntegerField', [], {}),
'uid': ('django.db.models.fields.IntegerField', [], {}),
'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"})
},
'auth.group': {
'Meta': {'object_name': 'Group'},
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '80'}),
'permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'})
},
'auth.permission': {
'Meta': {'ordering': "('content_type__app_label', 'content_type__model', 'codename')", 'unique_together': "(('content_type', 'codename'),)", 'object_name': 'Permission'},
'codename': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'name': ('django.db.models.fields.CharField', [], {'max_length': '50'})
},
'auth.user': {
'Meta': {'object_name': 'User'},
'date_joined': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}),
'email': ('django.db.models.fields.EmailField', [], {'max_length': '75', 'blank': 'True'}),
'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}),
'groups': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Group']", 'symmetrical': 'False', 'blank': 'True'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}),
'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
'is_superuser': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
'last_login': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}),
'last_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}),
'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}),
'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}),
'username': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '30'})
},
'contenttypes.contenttype': {
'Meta': {'ordering': "('name',)", 'unique_together': "(('app_label', 'model'),)", 'object_name': 'ContentType', 'db_table': "'django_content_type'"},
'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
'name': ('django.db.models.fields.CharField', [], {'max_length': '100'})
}
}
complete_apps = ['admin']
+101
View File
@@ -0,0 +1,101 @@
# encoding: utf-8
import datetime
from south.db import db
from south.v2 import DataMigration
from django.db import models
class Migration(DataMigration):
def forwards(self, orm):
"Write your forwards methods here."
for alias in orm.Alias.objects.all():
print alias.address
alias.domain = alias.mboxes.all()[0].domain
alias.save()
def backwards(self, orm):
"Write your backwards methods here."
models = {
'admin.alias': {
'Meta': {'object_name': 'Alias'},
'address': ('django.db.models.fields.CharField', [], {'max_length': '254'}),
'domain': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['admin.Domain']"}),
'enabled': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
'extmboxes': ('django.db.models.fields.TextField', [], {'blank': 'True'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'mboxes': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['admin.Mailbox']", 'symmetrical': 'False'})
},
'admin.domain': {
'Meta': {'object_name': 'Domain'},
'enabled': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'name': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
'quota': ('django.db.models.fields.IntegerField', [], {})
},
'admin.domainalias': {
'Meta': {'object_name': 'DomainAlias'},
'enabled': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '100'}),
'target': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['admin.Domain']"})
},
'admin.extension': {
'Meta': {'object_name': 'Extension'},
'enabled': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'name': ('django.db.models.fields.CharField', [], {'max_length': '150'})
},
'admin.mailbox': {
'Meta': {'object_name': 'Mailbox'},
'address': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
'domain': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['admin.Domain']"}),
'gid': ('django.db.models.fields.IntegerField', [], {}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'name': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
'password': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
'path': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
'quota': ('django.db.models.fields.IntegerField', [], {}),
'uid': ('django.db.models.fields.IntegerField', [], {}),
'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"})
},
'auth.group': {
'Meta': {'object_name': 'Group'},
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '80'}),
'permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'})
},
'auth.permission': {
'Meta': {'ordering': "('content_type__app_label', 'content_type__model', 'codename')", 'unique_together': "(('content_type', 'codename'),)", 'object_name': 'Permission'},
'codename': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'name': ('django.db.models.fields.CharField', [], {'max_length': '50'})
},
'auth.user': {
'Meta': {'object_name': 'User'},
'date_joined': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}),
'email': ('django.db.models.fields.EmailField', [], {'max_length': '75', 'blank': 'True'}),
'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}),
'groups': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Group']", 'symmetrical': 'False', 'blank': 'True'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}),
'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
'is_superuser': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
'last_login': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}),
'last_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}),
'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}),
'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}),
'username': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '30'})
},
'contenttypes.contenttype': {
'Meta': {'ordering': "('name',)", 'unique_together': "(('app_label', 'model'),)", 'object_name': 'ContentType', 'db_table': "'django_content_type'"},
'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
'name': ('django.db.models.fields.CharField', [], {'max_length': '100'})
}
}
complete_apps = ['admin']
+46 -4
View File
@@ -215,9 +215,11 @@ class Mailbox(models.Model):
class Alias(models.Model):
address = models.CharField(_('address'), max_length=254,
help_text=_("The alias address (without the domain part)"))
help_text=_("The alias address (without the domain part). For a 'catch-all' address, just enter an * character."))
domain = models.ForeignKey(Domain)
mboxes = models.ManyToManyField(Mailbox, verbose_name=_('mailboxes'),
help_text=_("The mailboxes this alias points to"))
extmboxes = models.TextField(blank=True)
enabled = models.BooleanField(_('enabled'),
help_text=_("Check to activate this alias"))
@@ -226,10 +228,50 @@ class Alias(models.Model):
("view_aliases", "View aliases"),
)
def __domain(self):
return self.mboxes.all()[0].domain
def save(self, int_targets, ext_targets, *args, **kwargs):
if len(ext_targets):
self.extmboxes = ",".join(ext_targets)
super(Alias, self).save(*args, **kwargs)
curmboxes = self.mboxes.all()
for t in int_targets:
if not t in curmboxes:
self.mboxes.add(t)
for t in curmboxes:
if not t in int_targets:
self.mboxes.remove(t)
domain = property(__domain)
def first_target(self):
"""Return the first target of this alias
It is taken from internal addresses, or external addresses.
"""
if len(self.mboxes.all()):
return self.mboxes.all()[0]
if len(self.extmboxes):
return self.extmboxes.split(",")[0]
return None
def get_targets(self):
"""Return a list containing targeted mailboxes
Internal and external addresses are mixed into a single list.
"""
result = []
if len(self.mboxes.all()):
for mb in self.mboxes.all()[1:]:
result += [mb.full_address]
if len(self.extmboxes):
result += self.extmboxes.split(",")
else:
result = self.extmboxes.split(",")[1:]
return result
def repr_extmboxes(self):
"""Return external targets as a string
A ready to print representation of external targets.
"""
return "<br/>".join(self.extmboxes.split(","))
class Extension(models.Model):
name = models.CharField(max_length=150)
+1 -1
View File
@@ -206,7 +206,7 @@ def mbalias_actions(user, alias):
"img" : static_url("pics/edit.png"),
"title" : _("Edit mailbox alias"),
"class" : "boxed",
"rel" : "{handler:'iframe',size:{x:300,y:300}}"}
"rel" : "{handler:'iframe',size:{x:320,y:300}}"}
]
return render_actions(actions)
+1
View File
@@ -16,6 +16,7 @@ urlpatterns = patterns('modoboa.admin.views',
(r'^mailboxes/new/$', 'newmailbox'),
(r'^mailboxes/edit/(?P<mbox_id>\d+)/$', 'editmailbox'),
(r'^mailboxes/delete/$', 'delmailbox'),
(r'^mailboxes/search/$', 'mailboxes_search'),
(r'^mbaliases/$', 'mbaliases'),
(r'^mbaliases/new/$', 'newmbalias'),
+63 -33
View File
@@ -6,6 +6,7 @@ from django.utils.http import urlquote
from django.core.urlresolvers import reverse
from django.contrib.auth.decorators \
import login_required, permission_required, user_passes_test
from django.db.models import Q
from django.contrib.auth.models import User, Group
from django.contrib import messages
from django.db import transaction, IntegrityError
@@ -14,7 +15,8 @@ from modoboa import admin, userprefs
from models import *
from admin.permissions import *
from modoboa.lib import crypt_password
from modoboa.lib import _render, ajax_response, getctx, events, parameters
from modoboa.lib import _render, ajax_response, ajax_simple_response, \
getctx, events, parameters, split_mailbox
from modoboa.lib.models import Parameter
import copy
@@ -60,7 +62,7 @@ def domains(request):
domains = Domain.objects.all()
for dom in domains:
dom.mbalias_counter = len(Alias.objects.filter(mboxes__domain__pk=dom.id))
dom.mbalias_counter = len(Alias.objects.filter(domain=dom.id))
deloptions = {"keepdir" : _("Do not delete domain directory")}
return render_domains_page(request, "domains",
domains=domains,
@@ -266,6 +268,22 @@ def mailboxes_raw(request, dom_id=None):
"target" : target
})
@login_required
@good_domain
@permission_required("admin.view_mailboxes")
def mailboxes_search(request):
if request.method != "POST":
return
local_part, domain = split_mailbox(request.POST["search"])
query = Q(address__startswith=local_part)
if domain is not None and domain != "":
query &= Q(domain__name__startswith=domain)
if request.GET.has_key("domid"):
query = Q(domain=request.GET["domid"]) & query
mboxes = Mailbox.objects.filter(query)
result = map(lambda mb: mb.full_address, mboxes)
return ajax_simple_response(result)
@login_required
@good_domain
@permission_required("admin.add_mailbox")
@@ -361,13 +379,50 @@ def delmailbox(request):
@permission_required("admin.view_aliases")
def mbaliases(request):
if request.GET.has_key("domid"):
aliases = Alias.objects.filter(mboxes__domain__pk=request.GET["domid"]).distinct()
aliases = Alias.objects.filter(domain=request.GET["domid"])
elif request.GET.has_key("mbid"):
aliases = Alias.objects.filter(mboxes__id=request.GET["mbid"]).distinct()
else:
aliases = Alias.objects.all()
if not request.user.is_superuser:
usermb = Mailbox.objects.get(user=request.user.id)
for al in aliases:
al.ui_disabled = False
for mb in al.mboxes.all():
if mb.domain.id != usermb.domain.id:
al.ui_disabled = True
break
return render_domains_page(request, "mbaliases", aliases=aliases)
def _validate_mbalias(request, form, successmsg, tplname, commonctx):
"""Mailbox alias validation
Common function shared between creation and modification actions.
"""
error = None
if form.is_valid():
try:
if not request.POST.has_key("targets"):
raise AdminError(_("No target defined"))
form.set_targets(request.user, request.POST.getlist("targets"))
try:
alias = form.save()
except IntegrityError:
raise AdminError(_("Alias with this name already exists"))
messages.info(request, successmsg, fail_silently=True)
return ajax_response(request, url=reverse(admin.views.mbaliases))
except AdminError, e:
error = str(e)
if request.POST.has_key("targets"):
targets = request.POST.getlist("targets")
commonctx["targets"] = targets[:-1]
commonctx["form"] = form
commonctx["error"] = error
return ajax_response(request, status="ko", template=tplname, **commonctx)
@login_required
@good_domain
@permission_required("admin.add_alias")
@@ -378,24 +433,10 @@ def newmbalias(request, tplname="admin/mbaliasform.html"):
"formid" : "mbaliasform"}
if request.method == "POST":
form = AliasForm(request.user, request.POST)
error = None
if form.is_valid():
try:
alias = form.save()
except IntegrityError:
error = _("Alias with this name already exists")
else:
form.save_m2m()
messages.info(request, _("Mailbox alias created"), fail_silently=True)
return ajax_response(request, url=reverse(admin.views.mbaliases))
commonctx["form"] = form
commonctx["error"] = error
return ajax_response(request, status="ko", template=tplname, **commonctx)
return _validate_mbalias(request, form, _("Mailbox alias created"),
tplname, commonctx)
form = AliasForm(request.user)
if request.GET.has_key("mbid"):
form.fields["mboxes"].initial = request.GET["mbid"]
commonctx["form"] = form
commonctx["noerrors"] = True
return _render(request, tplname, commonctx)
@@ -411,23 +452,12 @@ def editmbalias(request, alias_id, tplname="admin/mbaliasform.html"):
"formid" : "mbaliasform"}
if request.method == "POST":
form = AliasForm(request.user, request.POST, instance=alias)
error = None
if form.is_valid():
try:
alias = form.save()
except IntegrityError:
error = _("Alias with this name already exists")
else:
form.save_m2m()
messages.info(request, _("Mailbox alias modified"),
fail_silently=True)
return ajax_response(request, url=reverse(admin.views.mbaliases))
commonctx["form"] = form
commonctx["error"] = error
return ajax_response(request, status="ko", template=tplname, **commonctx)
return _validate_mbalias(request, form, _("Mailbox alias modified"),
tplname, commonctx)
form = AliasForm(request.user, instance=alias)
commonctx["form"] = form
commonctx["targets"] = alias.get_targets()
return _render(request, tplname, commonctx)
@login_required
+2 -1
View File
@@ -2,7 +2,8 @@
from django.utils.translation import ugettext as _
from managesieve import ManageSieveClient, Error
from modoboa.lib import ConnectionsManager, ConnectionError, parameters
from modoboa.lib import parameters
from modoboa.lib.connections import ConnectionsManager, ConnectionError
class SieveClientError(Exception):
pass
+2 -1
View File
@@ -15,7 +15,8 @@ import lxml
from django.utils.translation import ugettext as _
from django.conf import settings
from modoboa.lib import decode, u2u_decode, tables, \
imap_utf7, static_url, ConnectionsManager, parameters
imap_utf7, static_url, parameters
from modoboa.lib.connections import ConnectionsManager
from modoboa.lib.email_listing import MBconnector, EmailListing, Email
class WebmailError(Exception):
+18 -32
View File
@@ -12,7 +12,6 @@ from django.template.loader import render_to_string
from django.utils.translation import ugettext as _
from django.utils import simplejson
from modoboa.lib import parameters
from modoboa.auth.lib import decrypt
def exec_cmd(cmd, **kwargs):
import subprocess
@@ -33,7 +32,7 @@ def _render(request, tpl, user_context):
def _render_to_string(request, tpl, user_context):
"""Custom rendering function
Same a mrender_render.
Same as _render.
"""
return render_to_string(tpl, user_context,
context_instance=RequestContext(request))
@@ -109,6 +108,15 @@ def ajax_response(request, status="ok", respmsg=None,
jsonctx["norefresh"] = norefresh
return HttpResponse(simplejson.dumps(jsonctx), mimetype="application/json")
def ajax_simple_response(content):
"""Simple AJAX response
No extra formatting is done. The content is passed directly to simplejon.
:param content: the response's content (list, dict, string)
"""
return HttpResponse(simplejson.dumps(content), mimetype="application/json")
def decode(s, encodings=('utf8', 'latin1', 'windows-1252', 'ascii'), charset=None):
if charset is not None:
try:
@@ -177,6 +185,14 @@ def crypt_password(password):
return password
def split_mailbox(mailbox):
"""Try to split a mailbox in two parts
:return: a 2-uple (local part, domain)
"""
try:
mailbox.index("@")
except ValueError:
return mailbox, None
parts = mailbox.split('@')
if len(parts) == 2:
address = parts[0]
@@ -185,33 +201,3 @@ def split_mailbox(mailbox):
domain = parts[-1]
address = "@".join(parts[:-1])
return (address, domain)
class ConnectionsManager(type):
"""Singleton pattern implementation
This class is specialized in connection management.
"""
def __init__(cls, name, bases, dict):
super(ConnectionsManager, cls).__init__(name, bases, dict)
cls.instances = {}
def __call__(cls, **kwargs):
key = None
if kwargs.has_key("user"):
key = kwargs["user"]
else:
return None
if not cls.instances.has_key(key):
cls.instances[key] = None
if kwargs.has_key("password"):
kwargs["password"] = decrypt(kwargs["password"])
if cls.instances[key] is None:
cls.instances[key] = \
super(ConnectionsManager, cls).__call__(**kwargs)
else:
cls.instances[key].refresh(key, kwargs["password"])
return cls.instances[key]
class ConnectionError(Exception):
pass
+31
View File
@@ -0,0 +1,31 @@
from modoboa.auth.lib import decrypt
class ConnectionsManager(type):
"""Singleton pattern implementation
This class is specialized in connection management.
"""
def __init__(cls, name, bases, dict):
super(ConnectionsManager, cls).__init__(name, bases, dict)
cls.instances = {}
def __call__(cls, **kwargs):
key = None
if kwargs.has_key("user"):
key = kwargs["user"]
else:
return None
if not cls.instances.has_key(key):
cls.instances[key] = None
if kwargs.has_key("password"):
kwargs["password"] = decrypt(kwargs["password"])
if cls.instances[key] is None:
cls.instances[key] = \
super(ConnectionsManager, cls).__call__(**kwargs)
else:
cls.instances[key].refresh(key, kwargs["password"])
return cls.instances[key]
class ConnectionError(Exception):
pass
+7 -3
View File
@@ -8,9 +8,13 @@ class Migration(DataMigration):
def forwards(self, orm):
"Write your forwards methods here."
p = orm.Parameter.objects.get(name="webmail.SECRET_KEY")
p.name = "auth.SECRET_KEY"
p.save()
try:
p = orm.Parameter.objects.get(name="webmail.SECRET_KEY")
except orm.Parameter.DoesNotExist:
pass
else:
p.name = "auth.SECRET_KEY"
p.save()
def backwards(self, orm):
"Write your backwards methods here."
+50
View File
@@ -0,0 +1,50 @@
ul.autocompleter-choices
{
position: absolute;
margin: 0;
padding: 0;
list-style: none;
border: 1px solid #7c7c7c;
border-left-color: #c3c3c3;
border-right-color: #c3c3c3;
border-bottom-color: #ddd;
background-color: #fff;
text-align: left;
font-family: Verdana, Geneva, Arial, Helvetica, sans-serif;
z-index: 50;
background-color: #fff;
}
ul.autocompleter-choices li
{
position: relative;
margin: -2px 0 0 0;
padding: 0.2em 1.5em 0.2em 1em;
display: block;
float: none !important;
cursor: pointer;
font-weight: normal;
white-space: nowrap;
font-size: 1em;
line-height: 1.5em;
}
ul.autocompleter-choices li.autocompleter-selected
{
background-color: #444;
color: #fff;
}
ul.autocompleter-choices span.autocompleter-queried
{
display: inline;
float: none;
font-weight: bold;
margin: 0;
padding: 0;
}
ul.autocompleter-choices li.autocompleter-selected span.autocompleter-queried
{
color: #9FCFFF;
}
+31
View File
@@ -0,0 +1,31 @@
/**
* Autocompleter.Local
*
* http://digitarald.de/project/autocompleter/
*
* @version 1.1.2
*
* @license MIT-style license
* @author Harald Kirschner <mail [at] digitarald.de>
* @copyright Author
*/
Autocompleter.Local = new Class({
Extends: Autocompleter,
options: {
minLength: 0,
delay: 200
},
initialize: function(element, tokens, options) {
this.parent(element, options);
this.tokens = tokens;
},
query: function() {
this.update(this.filter());
}
});
+109
View File
@@ -0,0 +1,109 @@
/**
* Autocompleter.Request
*
* http://digitarald.de/project/autocompleter/
*
* @version 1.1.2
*
* @license MIT-style license
* @author Harald Kirschner <mail [at] digitarald.de>
* @copyright Author
*/
Autocompleter.Request = new Class({
Extends: Autocompleter,
options: {/*
indicator: null,
indicatorClass: null,
onRequest: $empty,
onComplete: $empty,*/
postData: {},
ajaxOptions: {},
postVar: 'value'
},
query: function(){
var data = $unlink(this.options.postData) || {};
data[this.options.postVar] = this.queryValue;
var indicator = $(this.options.indicator);
if (indicator) indicator.setStyle('display', '');
var cls = this.options.indicatorClass;
if (cls) this.element.addClass(cls);
this.fireEvent('onRequest', [this.element, this.request, data, this.queryValue]);
this.request.send({'data': data});
},
/**
* queryResponse - abstract
*
* Inherated classes have to extend this function and use this.parent()
*/
queryResponse: function() {
var indicator = $(this.options.indicator);
if (indicator) indicator.setStyle('display', 'none');
var cls = this.options.indicatorClass;
if (cls) this.element.removeClass(cls);
return this.fireEvent('onComplete', [this.element, this.request]);
}
});
Autocompleter.Request.JSON = new Class({
Extends: Autocompleter.Request,
initialize: function(el, url, options) {
this.parent(el, options);
this.request = new Request.JSON($merge({
'url': url,
'link': 'cancel'
}, this.options.ajaxOptions)).addEvent('onComplete', this.queryResponse.bind(this));
},
queryResponse: function(response) {
this.parent();
this.update(response);
}
});
Autocompleter.Request.HTML = new Class({
Extends: Autocompleter.Request,
initialize: function(el, url, options) {
this.parent(el, options);
this.request = new Request.HTML($merge({
'url': url,
'link': 'cancel',
'update': this.choices
}, this.options.ajaxOptions)).addEvent('onComplete', this.queryResponse.bind(this));
},
queryResponse: function(tree, elements) {
this.parent();
if (!elements || !elements.length) {
this.hideChoices();
} else {
this.choices.getChildren(this.options.choicesMatch).each(this.options.injectChoice || function(choice) {
var value = choice.innerHTML;
choice.inputValue = value;
this.addChoiceEvents(choice.set('html', this.markQueryValue(value)));
}, this);
this.showChoices();
}
}
});
/* compatibility */
Autocompleter.Ajax = {
Base: Autocompleter.Request,
Json: Autocompleter.Request.JSON,
Xhtml: Autocompleter.Request.HTML
};
+442
View File
@@ -0,0 +1,442 @@
/**
* Autocompleter
*
* http://digitarald.de/project/autocompleter/
*
* @version 1.1.2
*
* @license MIT-style license
* @author Harald Kirschner <mail [at] digitarald.de>
* @copyright Author
*/
var Autocompleter = new Class({
Implements: [Options, Events],
options: {/*
onOver: $empty,
onSelect: $empty,
onSelection: $empty,
onShow: $empty,
onHide: $empty,
onBlur: $empty,
onFocus: $empty,*/
minLength: 1,
markQuery: true,
width: 'inherit',
maxChoices: 10,
injectChoice: null,
customChoices: null,
emptyChoices: null,
visibleChoices: true,
className: 'autocompleter-choices',
zIndex: 42,
delay: 400,
observerOptions: {},
fxOptions: {},
autoSubmit: false,
overflow: false,
overflowMargin: 25,
selectFirst: false,
filter: null,
filterCase: false,
filterSubset: false,
forceSelect: false,
selectMode: true,
choicesMatch: null,
multiple: false,
separator: ', ',
separatorSplit: /\s*[,;]\s*/,
autoTrim: false,
allowDupes: false,
cache: true,
relative: false
},
initialize: function(element, options) {
this.element = $(element);
this.setOptions(options);
this.build();
this.observer = new Observer(this.element, this.prefetch.bind(this), $merge({
'delay': this.options.delay
}, this.options.observerOptions));
this.queryValue = null;
if (this.options.filter) this.filter = this.options.filter.bind(this);
var mode = this.options.selectMode;
this.typeAhead = (mode == 'type-ahead');
this.selectMode = (mode === true) ? 'selection' : mode;
this.cached = [];
},
/**
* build - Initialize DOM
*
* Builds the html structure for choices and appends the events to the element.
* Override this function to modify the html generation.
*/
build: function() {
if ($(this.options.customChoices)) {
this.choices = this.options.customChoices;
} else {
this.choices = new Element('ul', {
'class': this.options.className,
'styles': {
'zIndex': this.options.zIndex
}
}).inject(document.body);
this.relative = false;
if (this.options.relative) {
this.choices.inject(this.element, 'after');
this.relative = this.element.getOffsetParent();
}
this.fix = new OverlayFix(this.choices);
}
if (!this.options.separator.test(this.options.separatorSplit)) {
this.options.separatorSplit = this.options.separator;
}
this.fx = (!this.options.fxOptions) ? null : new Fx.Tween(this.choices, $merge({
'property': 'opacity',
'link': 'cancel',
'duration': 200
}, this.options.fxOptions)).addEvent('onStart', Chain.prototype.clearChain).set(0);
this.element.setProperty('autocomplete', 'off')
.addEvent((Browser.Engine.trident || Browser.Engine.webkit) ? 'keydown' : 'keypress', this.onCommand.bind(this))
.addEvent('click', this.onCommand.bind(this, [false]))
.addEvent('focus', this.toggleFocus.create({bind: this, arguments: true, delay: 100}))
.addEvent('blur', this.toggleFocus.create({bind: this, arguments: false, delay: 100}));
},
destroy: function() {
if (this.fix) this.fix.destroy();
this.choices = this.selected = this.choices.destroy();
},
toggleFocus: function(state) {
this.focussed = state;
if (!state) this.hideChoices(true);
this.fireEvent((state) ? 'onFocus' : 'onBlur', [this.element]);
},
onCommand: function(e) {
if (!e && this.focussed) return this.prefetch();
if (e && e.key && !e.shift) {
switch (e.key) {
case 'enter':
if (this.element.value != this.opted) return true;
if (this.selected && this.visible) {
this.choiceSelect(this.selected);
return !!(this.options.autoSubmit);
}
break;
case 'up': case 'down':
if (!this.prefetch() && this.queryValue !== null) {
var up = (e.key == 'up');
this.choiceOver((this.selected || this.choices)[
(this.selected) ? ((up) ? 'getPrevious' : 'getNext') : ((up) ? 'getLast' : 'getFirst')
](this.options.choicesMatch), true);
}
return false;
case 'esc': case 'tab':
this.hideChoices(true);
break;
}
}
return true;
},
setSelection: function(finish) {
var input = this.selected.inputValue, value = input;
var start = this.queryValue.length, end = input.length;
if (input.substr(0, start).toLowerCase() != this.queryValue.toLowerCase()) start = 0;
if (this.options.multiple) {
var split = this.options.separatorSplit;
value = this.element.value;
start += this.queryIndex;
end += this.queryIndex;
var old = value.substr(this.queryIndex).split(split, 1)[0];
value = value.substr(0, this.queryIndex) + input + value.substr(this.queryIndex + old.length);
if (finish) {
var tokens = value.split(this.options.separatorSplit).filter(function(entry) {
return this.test(entry);
}, /[^\s,]+/);
if (!this.options.allowDupes) tokens = [].combine(tokens);
var sep = this.options.separator;
value = tokens.join(sep) + sep;
end = value.length;
}
}
this.observer.setValue(value);
this.opted = value;
if (finish || this.selectMode == 'pick') start = end;
this.element.selectRange(start, end);
this.fireEvent('onSelection', [this.element, this.selected, value, input]);
},
showChoices: function() {
var match = this.options.choicesMatch, first = this.choices.getFirst(match);
this.selected = this.selectedValue = null;
if (this.fix) {
var pos = this.element.getCoordinates(this.relative), width = this.options.width || 'auto';
this.choices.setStyles({
'left': pos.left,
'top': pos.bottom,
'width': (width === true || width == 'inherit') ? pos.width : width
});
}
if (!first) return;
if (!this.visible) {
this.visible = true;
this.choices.setStyle('display', '');
if (this.fx) this.fx.start(1);
this.fireEvent('onShow', [this.element, this.choices]);
}
if (this.options.selectFirst || this.typeAhead || first.inputValue == this.queryValue) this.choiceOver(first, this.typeAhead);
var items = this.choices.getChildren(match), max = this.options.maxChoices;
var styles = {'overflowY': 'hidden', 'height': ''};
this.overflown = false;
if (items.length > max) {
var item = items[max - 1];
styles.overflowY = 'scroll';
styles.height = item.getCoordinates(this.choices).bottom;
this.overflown = true;
};
this.choices.setStyles(styles);
this.fix.show();
if (this.options.visibleChoices) {
var scroll = document.getScroll(),
size = document.getSize(),
coords = this.choices.getCoordinates();
if (coords.right > scroll.x + size.x) scroll.x = coords.right - size.x;
if (coords.bottom > scroll.y + size.y) scroll.y = coords.bottom - size.y;
window.scrollTo(Math.min(scroll.x, coords.left), Math.min(scroll.y, coords.top));
}
},
hideChoices: function(clear) {
if (clear) {
var value = this.element.value;
if (this.options.forceSelect) value = this.opted;
if (this.options.autoTrim) {
value = value.split(this.options.separatorSplit).filter($arguments(0)).join(this.options.separator);
}
this.observer.setValue(value);
}
if (!this.visible) return;
this.visible = false;
if (this.selected) this.selected.removeClass('autocompleter-selected');
this.observer.clear();
var hide = function(){
this.choices.setStyle('display', 'none');
this.fix.hide();
}.bind(this);
if (this.fx) this.fx.start(0).chain(hide);
else hide();
this.fireEvent('onHide', [this.element, this.choices]);
},
prefetch: function() {
var value = this.element.value, query = value;
if (this.options.multiple) {
var split = this.options.separatorSplit;
var values = value.split(split);
var index = this.element.getSelectedRange().start;
var toIndex = value.substr(0, index).split(split);
var last = toIndex.length - 1;
index -= toIndex[last].length;
query = values[last];
}
if (query.length < this.options.minLength) {
this.hideChoices();
} else {
if (query === this.queryValue || (this.visible && query == this.selectedValue)) {
if (this.visible) return false;
this.showChoices();
} else {
this.queryValue = query;
this.queryIndex = index;
if (!this.fetchCached()) this.query();
}
}
return true;
},
fetchCached: function() {
return false;
if (!this.options.cache
|| !this.cached
|| !this.cached.length
|| this.cached.length >= this.options.maxChoices
|| this.queryValue) return false;
this.update(this.filter(this.cached));
return true;
},
update: function(tokens) {
this.choices.empty();
this.cached = tokens;
var type = tokens && $type(tokens);
if (!type || (type == 'array' && !tokens.length) || (type == 'hash' && !tokens.getLength())) {
(this.options.emptyChoices || this.hideChoices).call(this);
} else {
if (this.options.maxChoices < tokens.length && !this.options.overflow) tokens.length = this.options.maxChoices;
tokens.each(this.options.injectChoice || function(token){
var choice = new Element('li', {'html': this.markQueryValue(token)});
choice.inputValue = token;
this.addChoiceEvents(choice).inject(this.choices);
}, this);
this.showChoices();
}
},
choiceOver: function(choice, selection) {
if (!choice || choice == this.selected) return;
if (this.selected) this.selected.removeClass('autocompleter-selected');
this.selected = choice.addClass('autocompleter-selected');
this.fireEvent('onSelect', [this.element, this.selected, selection]);
if (!this.selectMode) this.opted = this.element.value;
if (!selection) return;
this.selectedValue = this.selected.inputValue;
if (this.overflown) {
var coords = this.selected.getCoordinates(this.choices), margin = this.options.overflowMargin,
top = this.choices.scrollTop, height = this.choices.offsetHeight, bottom = top + height;
if (coords.top - margin < top && top) this.choices.scrollTop = Math.max(coords.top - margin, 0);
else if (coords.bottom + margin > bottom) this.choices.scrollTop = Math.min(coords.bottom - height + margin, bottom);
}
if (this.selectMode) this.setSelection();
},
choiceSelect: function(choice) {
if (choice) this.choiceOver(choice);
this.setSelection(true);
this.queryValue = false;
this.hideChoices();
},
filter: function(tokens) {
return (tokens || this.tokens).filter(function(token) {
return this.test(token);
}, new RegExp(((this.options.filterSubset) ? '' : '^') + this.queryValue.escapeRegExp(), (this.options.filterCase) ? '' : 'i'));
},
/**
* markQueryValue
*
* Marks the queried word in the given string with <span class="autocompleter-queried">*</span>
* Call this i.e. from your custom parseChoices, same for addChoiceEvents
*
* @param {String} Text
* @return {String} Text
*/
markQueryValue: function(str) {
return (!this.options.markQuery || !this.queryValue) ? str
: str.replace(new RegExp('(' + ((this.options.filterSubset) ? '' : '^') + this.queryValue.escapeRegExp() + ')', (this.options.filterCase) ? '' : 'i'), '<span class="autocompleter-queried">$1</span>');
},
/**
* addChoiceEvents
*
* Appends the needed event handlers for a choice-entry to the given element.
*
* @param {Element} Choice entry
* @return {Element} Choice entry
*/
addChoiceEvents: function(el) {
return el.addEvents({
'mouseover': this.choiceOver.bind(this, [el]),
'click': this.choiceSelect.bind(this, [el])
});
}
});
var OverlayFix = new Class({
initialize: function(el) {
if (Browser.Engine.trident) {
this.element = $(el);
this.relative = this.element.getOffsetParent();
this.fix = new Element('iframe', {
'frameborder': '0',
'scrolling': 'no',
'src': 'javascript:false;',
'styles': {
'position': 'absolute',
'border': 'none',
'display': 'none',
'filter': 'progid:DXImageTransform.Microsoft.Alpha(opacity=0)'
}
}).inject(this.element, 'after');
}
},
show: function() {
if (this.fix) {
var coords = this.element.getCoordinates(this.relative);
delete coords.right;
delete coords.bottom;
this.fix.setStyles($extend(coords, {
'display': '',
'zIndex': (this.element.getStyle('zIndex') || 1) - 1
}));
}
return this;
},
hide: function() {
if (this.fix) this.fix.setStyle('display', 'none');
return this;
},
destroy: function() {
if (this.fix) this.fix = this.fix.destroy();
}
});
Element.implement({
getSelectedRange: function() {
if (!Browser.Engine.trident) return {start: this.selectionStart, end: this.selectionEnd};
var pos = {start: 0, end: 0};
var range = this.getDocument().selection.createRange();
if (!range || range.parentElement() != this) return pos;
var dup = range.duplicate();
if (this.type == 'text') {
pos.start = 0 - dup.moveStart('character', -100000);
pos.end = pos.start + range.text.length;
} else {
var value = this.value;
var offset = value.length - value.match(/[\n\r]*$/)[0].length;
dup.moveToElementText(this);
dup.setEndPoint('StartToEnd', range);
pos.end = offset - dup.text.length;
dup.setEndPoint('StartToStart', range);
pos.start = offset - dup.text.length;
}
return pos;
},
selectRange: function(start, end) {
if (Browser.Engine.trident) {
var diff = this.value.substr(start, end - start).replace(/\r/g, '').length;
start = this.value.substr(0, start).replace(/\r/g, '').length;
var range = this.createTextRange();
range.collapse(true);
range.moveEnd('character', start + diff);
range.moveStart('character', start);
range.select();
} else {
this.focus();
this.setSelectionRange(start, end);
}
return this;
}
});
/* compatibility */
Autocompleter.Base = Autocompleter;
+69
View File
@@ -0,0 +1,69 @@
/**
* Observer - Observe formelements for changes
*
* - Additional code from clientside.cnet.com
*
* @version 1.1
*
* @license MIT-style license
* @author Harald Kirschner <mail [at] digitarald.de>
* @copyright Author
*/
var Observer = new Class({
Implements: [Options, Events],
options: {
periodical: false,
delay: 1000
},
initialize: function(el, onFired, options){
this.element = $(el) || $$(el);
this.addEvent('onFired', onFired);
this.setOptions(options);
this.bound = this.changed.bind(this);
this.resume();
},
changed: function() {
var value = this.element.get('value');
if ($equals(this.value, value)) return;
this.clear();
this.value = value;
this.timeout = this.onFired.delay(this.options.delay, this);
},
setValue: function(value) {
this.value = value;
this.element.set('value', value);
return this.clear();
},
onFired: function() {
this.fireEvent('onFired', [this.value, this.element]);
},
clear: function() {
$clear(this.timeout || null);
return this;
},
pause: function(){
if (this.timer) $clear(this.timer);
else this.element.removeEvent('keyup', this.bound);
return this.clear();
},
resume: function(){
this.value = this.element.get('value');
if (this.options.periodical) this.timer = this.changed.periodical(this.options.periodical, this);
else this.element.addEvent('keyup', this.bound);
return this;
}
});
var $equals = function(obj1, obj2) {
return (obj1 == obj2 || JSON.encode(obj1) == JSON.encode(obj2));
};
+4 -5
View File
@@ -118,15 +118,14 @@ var SqueezeBox = {
return !SqueezeBox.fromElement(this, options);
});
},
open: function(subject, options) {
this.initialize();
if (this.element != null) this.trash();
this.element = $(subject) || false;
this.setOptions(Object.merge(this.presets, options || {}));
if (this.element && this.options.parse) {
var obj = this.element.getProperty(this.options.parse);
if (obj && (obj = JSON.decode(obj, this.options.parseSecure))) this.setOptions(obj);
@@ -134,7 +133,7 @@ var SqueezeBox = {
this.url = ((this.element) ? (this.element.get('href')) : subject) || this.options.url || '';
this.assignOptions();
var handler = handler || this.options.handler;
if (handler) return this.setContent(handler, this.parsers[handler].call(this, true));
var ret = false;
@@ -147,7 +146,7 @@ var SqueezeBox = {
return false;
}, this);
},
fromElement: function(from, options) {
return this.open(from, options);
},
+7 -7
View File
@@ -25,7 +25,7 @@ var Confirmation = new Class({
id: "dlg-iconbox",
html: "<img src='/static/pics/dialog-warning.png' alt='Warning' />"
}).inject(content);
this.textdiv = new Element("div", {
id: "dlg-textbox",
html: "<b>" + question + "</b>"
@@ -42,19 +42,19 @@ var Confirmation = new Class({
.addEvent("click", this.cancel_handler)
.inject(buttonsdiv);
new Element("input", {type: "hidden", name: "result", id: "result", value: "cancel"})
new Element("input", {type: "hidden", name: "result",
id: "result", value: "cancel"})
.inject(content);
SqueezeBox.initialize({
size: {x: 350, y: 140},
SqueezeBox.open(content, {
size: {x: 350, y: 140},
handler: "adopt",
onClose: this.close_handler
onClose: this.close_handler.bind(content)
});
SqueezeBox.open(content);
},
close_handler: function(content) {
var result = $("confirmbox").getElement("input[name=result]");
var result = this.getElement("input[name=result]");
if (result.get("value") != "ok") {
return;
}
-23
View File
@@ -12,18 +12,6 @@ window.addEvent('domready', function() {
});
}
if ($$("a[name=deleteDom]"))
$$("a[name=deleteDom]").addEvent('click', function(event) {
if (!confirm(gettext("Delete this domain?")))
event.stop();
});
if ($$("a[name=deleteAlias]"))
$$("a[name=deleteAlias]").addEvent('click', function(event) {
if (!confirm(gettext("Delete this alias?")))
event.stop();
});
$(document.body).addEvent('click', function(event) {
var target = event.target;
if (target.get('tag') == 'a') {
@@ -56,14 +44,3 @@ window.addEvent('domready', function() {
selectable: true
});
});
loadPage = function(url, destination, callback) {
destination = (destination == null) ? "page" : destination;
if (callback)
new Request({url: url, method: "get", onSuccess: callback}).send();
else
new Request({url: url, method: "get", onSuccess: function(data) {
$(destination).set('html', data);
}}).send();
};
+77
View File
@@ -0,0 +1,77 @@
var DynamicTextInput = new Class({
Implements: [Options],
options: {
},
initialize: function(targets, options) {
if (!$defined(targets) || !targets.length) {
return;
}
for (var cpt = 0; cpt < targets.length; cpt++) {
var target = targets[cpt];
var btn;
if (cpt == 0) {
btn = new Element("img", {
src: parent.static_url("pics/add.png")
});
btn.addEvent("click", this.newinput.bind(this));
} else {
var btnid = "btn_" + cpt;
btn = new Element("img", {
src: parent.static_url("pics/remove.png"),
id: btnid
});
btn.addEvent("click", this.removeinput.bind(this));
}
btn.setStyles({
"float": "right",
"margin-top": "2px"
});
btn.inject(target, "after");
}
this.nextid = targets.length;
this.target = targets[0];
this.setOptions(options);
},
newinput: function(event) {
if (this.target.get("value") == "") {
return;
}
var coords = this.target.getCoordinates();
var div = new Element("div").addClass("row");
var label = new Element("label").inject(div);
var ninput = new Element("input", {
type: "text",
id: this.target.get("id") + "_" + this.nextid,
name: this.target.get("name"),
value: this.target.get("value")
}).inject(div);
var delbtn = new Element("img", {
id: "delbtn_" + this.nextid,
src: parent.static_url("pics/remove.png")
});
this.nextid += 1;
delbtn.setStyles({
"float" : "right",
"margin-top": "2px"
});
div.inject(this.target.getParent(), "after");
delbtn.inject(ninput, "after");
delbtn.addEvent("click", this.removeinput.bind(this));
this.target.set("value", "");
},
removeinput: function(event) {
var id = (event.target.get("id").split("_"))[1];
var input = $("id_" + this.target.get("name") + "_" + id);
input.destroy();
event.target.destroy();
}
});
-6
View File
@@ -1,10 +1,4 @@
var error_callbacks = {
mbaliasform: function() {
if ($("id_domain").value == 0) {
$("id_mboxes").set("html", "");
}
mbaliasform_init();
},
permform: function() {
if ($defined($("id_domain"))) {
$("id_domain").options[0].setProperty("selected", "selected");
+1
View File
@@ -7,6 +7,7 @@
{% if error %}<div class="error">{{ error }}</div>{% endif %}
<form action="{{ action }}" method="POST" id="{{ formid }}" name="{{ formid }}">
{% include "common/genericform.html" %}
{% block extrafields %}{% endblock %}
<div class="spacer"></div>
<div class="lastrow" align="center">
<input type="submit" name="submit" value="{{ submit_label }}" />
+10 -3
View File
@@ -12,11 +12,10 @@ window.addEvent("domready", function() {
var sizes = $("objects").get("rel").split(" ");
evt.stop();
SqueezeBox.initialize({
size: {x: sizes[0], y: sizes[1]},
SqueezeBox.open(rooturl + type + "/new/", {
size: {x: parseInt(sizes[0]), y: parseInt(sizes[1])},
handler: 'iframe'
});
SqueezeBox.open(rooturl + type + "/new/");
});
$$("a[name=remove]").addEvent("click", function(evt) {
@@ -28,11 +27,19 @@ window.addEvent("domready", function() {
var selection = "";
objtable._selectedRows.each(function(item) {
var options = JSON.decode(item.get("rel"));
if ($defined(options.ui_disabled) && options.ui_disabled == "True") {
return;
}
if (selection != "") {
selection += ",";
}
selection += item.get("id");
});
if (selection == "") {
return;
}
new Confirmation(rooturl + type + "/delete/", "selection=" + selection,
gettext("Remove this selection?"), {
{% if deloptions %}checkboxes: { {% join deloptions %} }{% endif %}
+9 -5
View File
@@ -2,27 +2,31 @@
{% load i18n admin_extras %}
{% block pagetitle %}{% trans "Mailbox aliases" %}{% endblock %}
{% block subcontent %}
<div id="objects" name="mbaliases" rel="310 300">
<div id="objects" name="mbaliases" rel="320 300">
{% if not aliases %}
<div class="info">{% trans "No mailbox alias defined." %}</div>
{% else %}
<table id="objects_table">
<tr>
<th>{% trans "Address" %}</th>
<th>{% trans "Mailbox" %}</th>
<th width="25%">{% trans "Address" %}</th>
<th width="40%">{% trans "Target(s)" %}</th>
<th width="70px" class="center">{% trans "Enabled" %}</th>
<th width="70px" class="center">{% trans "Actions" %}</th>
</tr>
{% for alias in aliases %}
<tr class="{% cycle 'odd' 'even' %}" id="{{ alias.id }}">
<tr class="{% cycle 'odd' 'even' %}" id="{{ alias.id }}"
rel="{ui_disabled : '{{ alias.ui_disabled }}'}">
<td>{{ alias.address }}@{{ alias.domain }}</td>
<td>
{% for mb in alias.mboxes.all %}
{{ mb.full_address }}<br/>
{% endfor %}
{{ alias.repr_extmboxes|safe }}
</td>
<td class="center">{{ alias.enabled|yesno }}</td>
<td class="center">{% mbalias_actions user alias %}</td>
<td class="center">
{% if alias.ui_disabled %}--{% else %}{% mbalias_actions user alias %}{% endif %}
</td>
</tr>
{% endfor %}
</table>
+26 -16
View File
@@ -1,24 +1,34 @@
{% extends "admin/adminform.html" %}
{% load i18n %}
{% block html_head %}
{{ block.super }}
<link rel="stylesheet" type="text/css" href="{{ MEDIA_URL }}css/Autocompleter.css" />
<script type="text/javascript" src="{{ MEDIA_URL }}js/dynamic_textinput.js"></script>
<script type="text/javascript" src="{{ MEDIA_URL }}js/Observer.js"></script>
<script type="text/javascript" src="{{ MEDIA_URL }}js/Autocompleter.js"></script>
<script type="text/javascript" src="{{ MEDIA_URL }}js/Autocompleter.Request.js"></script>
<script type="text/javascript">
function init_form() {
var dti = new DynamicTextInput($$("input[name=targets]"));
new Autocompleter.Request.JSON("id_targets",
"{% url admin.views.mailboxes_search %}", {
postVar: "search",
minLength: 3
});
}
window.addEvent("domready", function() {
mbaliasform_init = function() {
$("id_domain").addEvent("change", function(event) {
var domid = this.value;
if (domid == "") {
$("id_mboxes").set("html", "");
return;
}
new Request({
url: "{% url domains %}" + domid + "/rawmboxes/?target=mbaliases",
method: "get", onSuccess: function(data) {
$("id_mboxes").set("html", data);
}
}).send();
});
};
mbaliasform_init();
register_error_callback("mbaliasform", init_form);
init_form();
});
</script>
{% endblock %}
{% block extrafields %}
{% for t in targets %}
<div class="row">
<label></label>
<input type="text" name="targets" id="id_targets_{{ forloop.counter }}" value="{{ t }}" />
</div>
{% endfor %}
{% endblock %}
View File
View File
+198
View File
@@ -0,0 +1,198 @@
#!/usr/bin/env python
# coding: utf-8
"""
Automatic postfixadmin to Modoboa migration
===========================================
This script provides an easy way to migrate an existing postfixadmin
database to a Modoboa one.
As the two products do not share the same schema, some informations
will be lost on the new database. Here is the list:
* Description, transport and backup MX for domains
* Dates (creation/modification)
* Logs
* Fetchmail table
Mailboxes organization on the filesystem will be changed. PostfixAdmin
uses the following layout::
<topdir>/domain.tld/user@domain.tld/
Whereas Modoboa uses the following::
<topdir>/domain.tld/user/
To rename directories, you'll need the appropriate permissions.
Domain administrators that manage more than one domain will not be
completly restored. They will only be administrator of the domain that
corresponds to their username.
Vacation messages will not be migrated by this script. Users will have
to define new messages by using Modoboa's autoreply plugin. (if
activated)
One last point about passwords. PostfixAdmin uses different hash
algorithms to store passwords so this script does not modify
them. They are copied "as is" is the new database. In order to use
them, you'll have to adapt the ``PASSWORD_SCHEME`` parameter in the
admin. panel. Currently, it appears that the ``crypt`` scheme is
compatible with PostfixAdmin ``md5crypt`` algorithm...
"""
from django.conf import settings
from django.db.utils import ConnectionDoesNotExist
from django.db.models import Q
from django.contrib.auth.models import Group
import modoboa.admin.models as md_models
import models as pf_models
from modoboa.lib import split_mailbox, exec_cmd
def migrate_domain_aliases(domain, options):
print "\tMigrating domain aliases"
old_domain_aliases = pf_models.AliasDomain.objects.using(options._from).filter(target_domain=domain.name)
for old_da in old_domain_aliases:
new_da = md_models.DomainAlias()
new_da.name = old_da.alias_domain
new_da.target = old_da.target_domain
new_da.enabled = new_da.active
new_da.save(using=options.to)
def migrate_mailbox_aliases(domain, options):
print "\tMigrating mailbox aliases"
old_aliases = pf_models.Alias.objects.using(options._from).filter(domain=domain.name)
for old_al in old_aliases:
if old_al.address == old_al.goto:
continue
new_al = md_models.Alias()
local_part, tmp = split_mailbox(old_al.address)
if local_part is None or not len(local_part):
if tmp is None or not len(tmp):
print """Warning: skipping alias %s (cannot retrieve local part).
You will need to recreate it manually.
""" % old_al.address
continue
new_al.address = "*"
else:
new_al.address = local_part
new_al.domain = domain
new_al.enabled = old_al.active
extmboxes = []
intmboxes = []
for goto in old_al.goto.split(","):
try:
mb = md_models.Mailbox.objects.using(options.to).get(user__username=goto)
except md_models.Mailbox.DoesNotExist:
extmboxes += [goto]
else:
intmboxes += [mb]
new_al.save(intmboxes, extmboxes, using=options.to)
def migrate_mailboxes(domain, options):
print "\tMigrating mailboxes"
old_mboxes = pf_models.Mailbox.objects.using(options._from).filter(domain=domain.name)
for old_mb in old_mboxes:
new_mb = md_models.Mailbox()
new_mb.name = old_mb.name
new_mb.address = old_mb.local_part
new_mb.password = old_mb.password
new_mb.domain = domain
if old_mb.quota:
new_mb.quota = old_mb.quota / 1024000
new_mb.path = "%s/" % old_mb.local_part
if options.rename_dirs:
oldpath = os.path.join(options.mboxes_path, domain.name, old_mb.maildir)
newpath = os.path.join(options.mboxes_path, domain.name, new_mb.path)
code, output = exec_cmd("mv %s %s" % (oldpath, newpath))
if code:
print "Error: cannot rename mailbox directory\n%s" % output
sys.exit(1)
new_mb.save(using=options.to)
def migrate_domain(old_dom, options):
print "Migrating domain %s" % old_dom.domain
newdom = md_models.Domain()
newdom.name = old_dom.domain
newdom.enabled = old_dom.active
newdom.quota = old_dom.maxquota
newdom.save(using=options.to)
migrate_mailboxes(newdom, options)
migrate_mailbox_aliases(newdom, options)
migrate_domain_aliases(newdom, options)
def migrate_admins(options):
print "Migrating administrators"
dagroup = Group.objects.using(options.to).get(name="DomainAdmins")
for old_admin in pf_models.Admin.objects.using(options._from).all():
local_part, domname = split_mailbox(old_admin.username)
try:
query = Q(username=old_admin.username) & \
(Q(domain="ALL") | Q(domain=domname))
creds = pf_models.DomainAdmins.objects.using(options._from).get(query)
except pf_models.DomainAdmins.DoesNotExist:
print "Warning: skipping useless admin %s" % (old_admin.username)
continue
try:
mb = md_models.Mailbox.objects.using(options.to).get(user__username=old_admin.username)
except md_models.Mailbox.DoesNotExist:
try:
domain = md_models.Domain.objects.using(options.to).get(name=domname)
except md_models.Domain.DoesNotExist:
print "Warning: skipping domain admin %s, domain not found" \
% old_admin.username
continue
mb = md_models.Mailbox()
mb.address = local_part
mb.domain = domain
mb.name = local_part
mb.enabled = old_admin.active
mb.password = old_admin.password
mb.save(using=options.to)
if creds.domain == "ALL":
mb.user.is_superuser = True
else:
mb.user.groups.add(dagroup)
mb.save(using=options.to)
def do_migration(options):
pf_domains = pf_models.Domain.objects.using(options._from).all()
for olddom in pf_domains:
if olddom.domain == "ALL":
continue
migrate_domain(olddom, options)
migrate_admins(options)
if __name__ == "__main__":
from optparse import OptionParser
parser = OptionParser()
parser.add_option("-f", "--from", dest="_from", default="pfxadmin",
help="Name of postfixadmin db connection declared in settings.py")
parser.add_option("-t", "--to", default="modoboa",
help="Name of the Modoboa db connection declared in settings.py")
parser.add_option("-r", "--rename-dirs", action="store_true",
help="Rename mailbox directories (default is no)")
parser.add_option("-p", "--mboxes-path", default=None,
help="Path where directories are stored on the filesystem (used only if --rename-dirs)")
options, args = parser.parse_args()
if options.rename_dirs and options.mboxes_path is None:
print "Error: you must provide the --mboxes-path option"
sys.exit(1)
try:
do_migration(options)
except ConnectionDoesNotExist, e:
print e
+157
View File
@@ -0,0 +1,157 @@
# 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.
from django.db import models
class Admin(models.Model):
username = models.CharField(max_length=765, primary_key=True)
password = models.CharField(max_length=765)
created = models.DateTimeField()
modified = models.DateTimeField()
active = models.IntegerField()
class Meta:
db_table = u'admin'
app_label = 'pfxadmin_migrate'
class Alias(models.Model):
address = models.CharField(max_length=765, primary_key=True)
goto = models.TextField()
domain = models.CharField(max_length=765)
created = models.DateTimeField()
modified = models.DateTimeField()
active = models.IntegerField()
class Meta:
db_table = u'alias'
app_label = 'pfxadmin_migrate'
class AliasDomain(models.Model):
alias_domain = models.CharField(max_length=765, primary_key=True)
target_domain = models.CharField(max_length=765)
created = models.DateTimeField()
modified = models.DateTimeField()
active = models.IntegerField()
class Meta:
db_table = u'alias_domain'
app_label = 'pfxadmin_migrate'
class Config(models.Model):
id = models.IntegerField(primary_key=True)
name = models.CharField(unique=True, max_length=60)
value = models.CharField(max_length=60)
class Meta:
db_table = u'config'
app_label = 'pfxadmin_migrate'
class Domain(models.Model):
domain = models.CharField(max_length=765, primary_key=True)
description = models.CharField(max_length=765)
aliases = models.IntegerField()
mailboxes = models.IntegerField()
maxquota = models.BigIntegerField()
quota = models.BigIntegerField()
transport = models.CharField(max_length=765)
backupmx = models.IntegerField()
created = models.DateTimeField()
modified = models.DateTimeField()
active = models.IntegerField()
class Meta:
db_table = u'domain'
app_label = 'pfxadmin_migrate'
class DomainAdmins(models.Model):
username = models.CharField(max_length=765)
domain = models.CharField(max_length=765)
created = models.DateTimeField(primary_key=True)
active = models.IntegerField()
class Meta:
db_table = u'domain_admins'
app_label = 'pfxadmin_migrate'
class Fetchmail(models.Model):
id = models.IntegerField(primary_key=True)
mailbox = models.CharField(max_length=765)
src_server = models.CharField(max_length=765)
src_auth = models.CharField(max_length=33, blank=True)
src_user = models.CharField(max_length=765)
src_password = models.CharField(max_length=765)
src_folder = models.CharField(max_length=765)
poll_time = models.IntegerField()
fetchall = models.IntegerField()
keep = models.IntegerField()
protocol = models.CharField(max_length=12, blank=True)
usessl = models.IntegerField()
extra_options = models.TextField(blank=True)
returned_text = models.TextField(blank=True)
mda = models.CharField(max_length=765)
date = models.DateTimeField()
class Meta:
db_table = u'fetchmail'
app_label = 'pfxadmin_migrate'
class Log(models.Model):
timestamp = models.DateTimeField()
username = models.CharField(max_length=765)
domain = models.CharField(max_length=765)
action = models.CharField(max_length=765)
data = models.TextField()
class Meta:
db_table = u'log'
app_label = 'pfxadmin_migrate'
class Mailbox(models.Model):
username = models.CharField(max_length=765, primary_key=True)
password = models.CharField(max_length=765)
name = models.CharField(max_length=765)
maildir = models.CharField(max_length=765)
quota = models.BigIntegerField()
local_part = models.CharField(max_length=765)
domain = models.CharField(max_length=765)
created = models.DateTimeField()
modified = models.DateTimeField()
active = models.IntegerField()
class Meta:
db_table = u'mailbox'
app_label = 'pfxadmin_migrate'
class Quota(models.Model):
username = models.CharField(max_length=765, primary_key=True)
path = models.CharField(max_length=300, primary_key=True)
current = models.BigIntegerField(null=True, blank=True)
class Meta:
db_table = u'quota'
app_label = 'pfxadmin_migrate'
class Quota2(models.Model):
username = models.CharField(max_length=300, primary_key=True)
bytes = models.BigIntegerField()
messages = models.IntegerField()
class Meta:
db_table = u'quota2'
app_label = 'pfxadmin_migrate'
class Vacation(models.Model):
email = models.CharField(max_length=765)
subject = models.CharField(max_length=765)
body = models.TextField()
cache = models.TextField()
domain = models.CharField(max_length=765)
created = models.DateTimeField()
active = models.IntegerField()
class Meta:
db_table = u'vacation'
app_label = 'pfxadmin_migrate'
class VacationNotification(models.Model):
on_vacation = models.ForeignKey(Vacation, db_column='on_vacation')
notified = models.CharField(max_length=765, primary_key=True)
notified_at = models.DateTimeField()
class Meta:
db_table = u'vacation_notification'
app_label = 'pfxadmin_migrate'