mirror of
https://github.com/jwinterm/modoboa.git
synced 2026-10-19 05:58:34 +02:00
Models modification for better integration with third party softwares
(ie. Postfix and others). Some bugfixes. Added documentation.
This commit is contained in:
@@ -36,19 +36,28 @@ http://<youraddress>:8000/mailng/admin/.
|
||||
Tips to link Mailng with postfix
|
||||
--------------------------------
|
||||
|
||||
First, define following maps on your server::
|
||||
First, define following maps on your server (this should work with
|
||||
Postfix versions >= 2.2)::
|
||||
|
||||
# /etc/postfix/sql-domains.cf
|
||||
user = <user>
|
||||
password = <password>
|
||||
dbname = <database>
|
||||
query = SELECT name FROM domains WHERE name='%s' AND enabled='1'
|
||||
query = SELECT name FROM admin_domain WHERE name='%s' AND enabled='1'
|
||||
|
||||
# /etc/postfix/sql-mailboxes.cf
|
||||
user = <user>
|
||||
password = <password>
|
||||
dbname = <database>
|
||||
query = SELECT path FROM mailboxes WHERE address = '%s' AND enabled = '1'
|
||||
query = SELECT path FROM admin_mailbox WHERE full_address = '%s' AND enabled = '1'
|
||||
|
||||
# /etc/postfix/sql-aliases.cf
|
||||
user = <user>
|
||||
password = <password>
|
||||
dbname = <database>
|
||||
query = SELECT t2.full_address FROM admin_alias AS t1, admin_mailbox AS t2
|
||||
WHERE t1.full_address='%s' AND t1.mbox=t2.id
|
||||
AND t1.enabled='1'
|
||||
|
||||
Then, use the following configuration in your /etc/postfix/main.cf file
|
||||
(this is one possible configuration)::
|
||||
@@ -65,6 +74,8 @@ Then, use the following configuration in your /etc/postfix/main.cf file
|
||||
relay_domains = mysql:/etc/postfix/sql-domains.cf
|
||||
virtual_mailbox_domains = mysql:/etc/postfix/sql-domains.cf
|
||||
virtual_mailbox_maps = mysql:/etc/postfix/sql-mailboxes.cf
|
||||
virtual_alias_domains =
|
||||
virtual_alias_maps = mysql:/etc/postfix/sql-aliases.cf
|
||||
# Stuff after
|
||||
|
||||
NB: Mailng only supports maildir format for now. Others will surely be
|
||||
added soon.
|
||||
|
||||
+3
-1
@@ -6,13 +6,14 @@ class DomainForm(forms.ModelForm):
|
||||
model = Domain
|
||||
|
||||
class MailboxForm(forms.ModelForm):
|
||||
quota = forms.IntegerField(label="Quota", required=False)
|
||||
password1 = forms.CharField(label="Password", widget=forms.PasswordInput)
|
||||
password2 = forms.CharField(label="Confirmation", widget=forms.PasswordInput)
|
||||
enabled = forms.BooleanField(label="Enabled", required=False)
|
||||
|
||||
class Meta:
|
||||
model = Mailbox
|
||||
fields = ('name', 'address', 'quota')
|
||||
fields = ('name', 'address')
|
||||
|
||||
def clean_password2(self):
|
||||
if self.cleaned_data["password1"] != self.cleaned_data["password2"]:
|
||||
@@ -31,4 +32,5 @@ class AliasForm(forms.ModelForm):
|
||||
|
||||
class Meta:
|
||||
model = Alias
|
||||
fields = ('address', 'mbox', 'enabled')
|
||||
|
||||
|
||||
+60
-4
@@ -1,30 +1,86 @@
|
||||
from django.db import models
|
||||
from django.contrib.auth.models import User
|
||||
|
||||
# Create your models here.
|
||||
from django.conf import settings
|
||||
from mailng.lib import exec_pipe
|
||||
import os
|
||||
|
||||
class Domain(models.Model):
|
||||
name = models.CharField(max_length=100)
|
||||
quota = models.IntegerField()
|
||||
enabled = models.BooleanField()
|
||||
|
||||
def create_dir(self):
|
||||
code, output = exec_pipe("sudo -u %s mkdir %s/%s" \
|
||||
% (settings.VIRTUAL_UID, settings.STORAGE_PATH,
|
||||
self.name))
|
||||
if code:
|
||||
os.system("echo '%s' >> /tmp/vmail.log" % output)
|
||||
return False
|
||||
return True
|
||||
|
||||
def rename_dir(self, newname):
|
||||
code, output = exec_pipe("sudo -u %s mv %s/%s %s/%s" \
|
||||
% (settings.VIRTUAL_UID,
|
||||
settings.STORAGE_PATH, self.name,
|
||||
settings.STORAGE_PATH, newname))
|
||||
if code:
|
||||
os.system("echo '%s' >> /tmp/vmail.log" % output)
|
||||
return False
|
||||
return True
|
||||
|
||||
def delete_dir(self):
|
||||
code, output = exec_pipe("sudo -u %s rm -r %s/%s" \
|
||||
% (settings.VIRTUAL_UID, settings.STORAGE_PATH,
|
||||
self.name))
|
||||
if code:
|
||||
return False
|
||||
return True
|
||||
|
||||
class Mailbox(models.Model):
|
||||
name = models.CharField(max_length=100)
|
||||
address = models.CharField(max_length=100)
|
||||
# password = models.CharField(max_length=100)
|
||||
full_address = models.CharField(max_length=150)
|
||||
quota = models.IntegerField()
|
||||
# enabled = models.BooleanField()
|
||||
uid = models.IntegerField()
|
||||
gid = models.IntegerField()
|
||||
path = models.CharField(max_length=200)
|
||||
domain = models.ForeignKey(Domain)
|
||||
user = models.ForeignKey(User)
|
||||
path = models.CharField(max_length=255)
|
||||
|
||||
def __str__(self):
|
||||
return "%s" % (self.address)
|
||||
|
||||
def create_dir(self, domain):
|
||||
path = "%s/%s/%s" % (settings.STORAGE_PATH, domain.name, self.address)
|
||||
code, output = exec_pipe("sudo -u %s mkdir -p %s/.maildir" \
|
||||
% (settings.VIRTUAL_UID, path))
|
||||
if code:
|
||||
return False
|
||||
self.path = "%s/.maildir/" % path
|
||||
return True
|
||||
|
||||
def rename_dir(self, domain, newaddress):
|
||||
path = "%s/%s" % (settings.STORAGE_PATH, domain)
|
||||
code, output = exec_pipe("sudo -u %s mv %s/%s %s/%s" \
|
||||
% (settings.VIRTUAL_UID,
|
||||
path, self.address, path, newaddress))
|
||||
if code:
|
||||
os.system("echo '%s' >> /tmp/vmail.log" % output)
|
||||
return False
|
||||
self.path = "%s/%s/.maildir/" % (path, newaddress)
|
||||
return True
|
||||
|
||||
def delete_dir(self):
|
||||
code, output = exec_pipe("sudo -u %s rm -r %s/%s/%s" \
|
||||
% (settings.VIRTUAL_UID, settings.STORAGE_PATH,
|
||||
self.domain.name, self.name))
|
||||
if code:
|
||||
return False
|
||||
return True
|
||||
|
||||
class Alias(models.Model):
|
||||
address = models.CharField(max_length=100)
|
||||
full_address = models.CharField(max_length=150)
|
||||
mbox = models.ForeignKey(Mailbox)
|
||||
enabled = models.BooleanField()
|
||||
|
||||
+86
-41
@@ -8,7 +8,6 @@ from django.core.urlresolvers import reverse
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.contrib.auth.models import User
|
||||
from mailng import admin
|
||||
from mailng.lib.fsops import *
|
||||
from mailng.admin.models import Domain, Mailbox, Alias
|
||||
from forms import MailboxForm, DomainForm, AliasForm
|
||||
|
||||
@@ -41,7 +40,8 @@ def newdomain(request):
|
||||
if Domain.objects.filter(name=request.POST["name"]):
|
||||
error = "Domain with this name already defined"
|
||||
else:
|
||||
if create_domain(request.POST["name"]):
|
||||
domain = form.save(commit=False)
|
||||
if domain.create_dir():
|
||||
form.save()
|
||||
ctx = _ctx_ok(reverse(admin.views.domains))
|
||||
return HttpResponse(simplejson.dumps(ctx),
|
||||
@@ -62,12 +62,20 @@ def editdomain(request, dom_id):
|
||||
domain = Domain.objects.get(pk=dom_id)
|
||||
if request.method == "POST":
|
||||
form = DomainForm(request.POST, instance=domain)
|
||||
error = None
|
||||
if form.is_valid():
|
||||
form.save()
|
||||
ctx = _ctx_ok(reverse(admin.views.domains))
|
||||
return HttpResponse(simplejson.dumps(ctx), mimetype="application/json")
|
||||
if domain.name != request.POST["name"]:
|
||||
if Domain.objects.filter(name=request.POST["name"]):
|
||||
error = "Domain with this name already defined"
|
||||
else:
|
||||
if not domain.rename_dir(request.POST["name"]):
|
||||
error = "Failed to rename domain, check permissions"
|
||||
if not error:
|
||||
form.save()
|
||||
ctx = _ctx_ok(reverse(admin.views.domains))
|
||||
return HttpResponse(simplejson.dumps(ctx), mimetype="application/json")
|
||||
ctx = _ctx_ko("admin/editdomain.html", {
|
||||
"domain" : domain, "form" : form
|
||||
"domain" : domain, "form" : form, "error" : error
|
||||
})
|
||||
return HttpResponse(simplejson.dumps(ctx), mimetype="application/json")
|
||||
|
||||
@@ -79,7 +87,7 @@ def editdomain(request, dom_id):
|
||||
@login_required
|
||||
def deldomain(request, dom_id):
|
||||
domain = Domain.objects.get(pk=dom_id)
|
||||
destroy_domain(domain.name)
|
||||
domain.delete_dir()
|
||||
domain.delete()
|
||||
return HttpResponseRedirect('/mailng/admin/')
|
||||
|
||||
@@ -102,8 +110,8 @@ def newmailbox(request, dom_id=None):
|
||||
domain=dom_id):
|
||||
error = "Mailbox with this address already exists"
|
||||
else:
|
||||
if create_mailbox(domain.name, request.POST["address"]):
|
||||
mb = form.save(commit=False)
|
||||
mb = form.save(commit=False)
|
||||
if mb.create_dir(domain):
|
||||
user = User()
|
||||
user.username = user.email = "%s@%s" % (mb.address, domain.name)
|
||||
user.set_password(request.POST["password1"])
|
||||
@@ -111,13 +119,16 @@ def newmailbox(request, dom_id=None):
|
||||
and True or False
|
||||
fname, lname = mb.name.split()
|
||||
user.first_name = fname
|
||||
|
||||
user.last_name = lname
|
||||
user.save()
|
||||
mb.user = user
|
||||
|
||||
|
||||
mb.uid = mb.gid = 500
|
||||
mb.domain = domain
|
||||
mb.quota = request.POST["quota"]
|
||||
if not mb.quota:
|
||||
mb.quota = domain.quota
|
||||
mb.full_address = "%s@%s" % (mb.address, domain.name)
|
||||
mb.save()
|
||||
ctx = _ctx_ok(reverse(admin.views.mailboxes, args=[domain.id]))
|
||||
return HttpResponse(simplejson.dumps(ctx),
|
||||
@@ -138,22 +149,39 @@ def editmailbox(request, dom_id, mbox_id=None):
|
||||
mb = Mailbox.objects.get(pk=mbox_id)
|
||||
if request.method == "POST":
|
||||
form = MailboxForm(request.POST, instance=mb)
|
||||
error = None
|
||||
if form.is_valid():
|
||||
if request.POST["enabled"] != mb.user.is_active \
|
||||
or request.POST["password1"] != mb.user.password:
|
||||
mb.user.is_active = request.POST["enabled"]
|
||||
mb.user.password = request.POST["password1"]
|
||||
mb.user.save()
|
||||
form.save()
|
||||
ctx = _ctx_ok(reverse(admin.views.mailboxes, args=[dom_id]))
|
||||
else:
|
||||
ctx = _ctx_ko("admin/editmailbox.html", {
|
||||
"mbox" : mb, "form" : form
|
||||
})
|
||||
json = simplejson.dumps(ctx)
|
||||
return HttpResponse(json, mimetype="application/json")
|
||||
if mb.address != request.POST["address"]:
|
||||
if Mailbox.objects.filter(address=request.POST["address"],
|
||||
domain=dom_id):
|
||||
error = "Mailbox with this address already exists"
|
||||
else:
|
||||
if not mb.rename_dir(mb.domain.name, request.POST["address"]):
|
||||
error = "Failed to rename mailbox, check permissions"
|
||||
if not error:
|
||||
mb = form.save(commit=False)
|
||||
enabled = request.POST.has_key("enabled") \
|
||||
and request.POST["enabled"] or False
|
||||
if enabled != mb.user.is_active \
|
||||
or request.POST["password1"] != mb.user.password:
|
||||
mb.user.is_active = enabled
|
||||
mb.user.password = request.POST["password1"]
|
||||
mb.user.save()
|
||||
mb.quota = request.POST["quota"]
|
||||
if not mb.quota:
|
||||
mb.quota = mb.domain.quota
|
||||
mb.full_address = "%s@%s" % (mb.address, mb.domain.name)
|
||||
mb.save()
|
||||
ctx = _ctx_ok(reverse(admin.views.mailboxes, args=[dom_id]))
|
||||
return HttpResponse(simplejson.dumps(ctx),
|
||||
mimetype="application/json")
|
||||
ctx = _ctx_ko("admin/editmailbox.html", {
|
||||
"mbox" : mb, "form" : form, "error" : error
|
||||
})
|
||||
return HttpResponse(simplejson.dumps(ctx), mimetype="application/json")
|
||||
|
||||
form = MailboxForm(instance=mb)
|
||||
form.fields['quota'].initial = mb.quota
|
||||
form.fields['enabled'].initial = mb.user.is_active
|
||||
form.fields['password1'].initial = mb.user.password
|
||||
form.fields['password2'].initial = mb.user.password
|
||||
@@ -164,7 +192,8 @@ def editmailbox(request, dom_id, mbox_id=None):
|
||||
@login_required
|
||||
def delmailbox(request, dom_id, mbox_id=None):
|
||||
mb = Mailbox.objects.get(pk=mbox_id)
|
||||
destroy_mailbox(mb.domain.name, mb.address)
|
||||
mb.delete_dir()
|
||||
mb.user.delete()
|
||||
mb.delete()
|
||||
return HttpResponseRedirect(reverse(admin.views.mailboxes,
|
||||
args=[dom_id]))
|
||||
@@ -188,15 +217,22 @@ def newalias(request, dom_id, mbox_id):
|
||||
mbox = Mailbox.objects.get(pk=mbox_id)
|
||||
if request.method == "POST":
|
||||
form = AliasForm(request.POST)
|
||||
error = None
|
||||
if form.is_valid():
|
||||
form.save()
|
||||
ctx = _ctx_ok(reverse(admin.views.aliases, args=[mbox.domain.id]))
|
||||
else:
|
||||
ctx = _ctx_ko("admin/newalias.html", {
|
||||
"mbox" : mbox, "form" : form
|
||||
})
|
||||
json = simplejson.dumps(ctx)
|
||||
return HttpResponse(json, mimetype="application/json")
|
||||
if Alias.objects.filter(address=request.POST["address"],
|
||||
mbox=mbox_id):
|
||||
error = "Alias with this address already exists"
|
||||
else:
|
||||
alias = form.save(commit=False)
|
||||
alias.full_address = "%s@%s" % (alias.address, mbox.domain.name)
|
||||
alias.save()
|
||||
ctx = _ctx_ok(reverse(admin.views.aliases, args=[mbox.domain.id]))
|
||||
return HttpResponse(simplejson.dumps(ctx),
|
||||
mimetype="application/json")
|
||||
ctx = _ctx_ko("admin/newalias.html", {
|
||||
"mbox" : mbox, "form" : form, "error" : error
|
||||
})
|
||||
return HttpResponse(simplejson.dumps(ctx), mimetype="application/json")
|
||||
|
||||
form = AliasForm({"mbox" : mbox_id}, domain=mbox.domain)
|
||||
return render_to_response('admin/newalias.html', {
|
||||
@@ -208,15 +244,24 @@ def editalias(request, dom_id, alias_id):
|
||||
alias = Alias.objects.get(pk=alias_id)
|
||||
if request.method == "POST":
|
||||
form = AliasForm(request.POST, instance=alias)
|
||||
error = None
|
||||
if form.is_valid():
|
||||
form.save()
|
||||
ctx = _ctx_ok(reverse(admin.views.aliases, args=[dom_id]))
|
||||
else:
|
||||
ctx = _ctx_ko("admin/editalias.html", {
|
||||
"alias" : alias, "form" : form
|
||||
})
|
||||
json = simplejson.dumps(ctx)
|
||||
return HttpResponse(json, mimetype="application/json")
|
||||
if alias.address != request.POST["address"] \
|
||||
and Alias.objects.filter(address=request.POST["address"],
|
||||
mbox=alias.mbox.id):
|
||||
error = "Alias with this address already exists"
|
||||
else:
|
||||
alias = form.save(commit=False)
|
||||
alias.full_address = "%s@%s" % (alias.address,
|
||||
alias.mbox.domain.name)
|
||||
alias.save()
|
||||
ctx = _ctx_ok(reverse(admin.views.aliases, args=[dom_id]))
|
||||
return HttpResponse(simplejson.dumps(ctx),
|
||||
mimetype="application/json")
|
||||
ctx = _ctx_ko("admin/editalias.html", {
|
||||
"alias" : alias, "form" : form, "error" : error
|
||||
})
|
||||
return HttpResponse(simplejson.dumps(ctx), mimetype="application/json")
|
||||
form = AliasForm(instance=alias)
|
||||
return _render(request, 'admin/editalias.html', {
|
||||
"form" : form, "alias" : alias
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
import popen2
|
||||
import os
|
||||
import time
|
||||
|
||||
def exec_pipe(cmd):
|
||||
"""Exécute une commande et récupère la sortie générée
|
||||
|
||||
Le module popen2 est utilisé pour l'exécution de la commande. Un
|
||||
objet Popen4 est créé car il regroupe la sortie standard et la sortie
|
||||
d'erreur dans le même fd.
|
||||
De plus, popen2 est le seul à pouvoir capturer retourner le code de
|
||||
sortie de la commande.
|
||||
|
||||
cmd -- commande à exécuter
|
||||
|
||||
Retourne un couple (code de sortie, texte de sortie)
|
||||
"""
|
||||
child = popen2.Popen4(cmd)
|
||||
child.tochild.close()
|
||||
while True:
|
||||
ret = child.poll()
|
||||
if ret != -1:
|
||||
break
|
||||
time.sleep(0.001)
|
||||
output = child.fromchild.read()
|
||||
if not ret:
|
||||
code = 0
|
||||
else:
|
||||
if os.WIFEXITED(ret):
|
||||
code = os.WEXITSTATUS(ret), output
|
||||
else:
|
||||
code = -1
|
||||
return code, output
|
||||
|
||||
@@ -1,69 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
import popen2
|
||||
import os
|
||||
import time
|
||||
from django.conf import settings
|
||||
|
||||
def exec_pipe(cmd):
|
||||
"""Exécute une commande et récupère la sortie générée
|
||||
|
||||
Le module popen2 est utilisé pour l'exécution de la commande. Un
|
||||
objet Popen4 est créé car il regroupe la sortie standard et la sortie
|
||||
d'erreur dans le même fd.
|
||||
De plus, popen2 est le seul à pouvoir capturer retourner le code de
|
||||
sortie de la commande.
|
||||
|
||||
cmd -- commande à exécuter
|
||||
|
||||
Retourne un couple (code de sortie, texte de sortie)
|
||||
"""
|
||||
child = popen2.Popen4(cmd)
|
||||
child.tochild.close()
|
||||
while True:
|
||||
ret = child.poll()
|
||||
if ret != -1:
|
||||
break
|
||||
time.sleep(0.001)
|
||||
output = child.fromchild.read()
|
||||
if not ret:
|
||||
code = 0
|
||||
else:
|
||||
if os.WIFEXITED(ret):
|
||||
code = os.WEXITSTATUS(ret), output
|
||||
else:
|
||||
code = -1
|
||||
return code, output
|
||||
|
||||
|
||||
def create_domain(name):
|
||||
code, output = exec_pipe("sudo -u %s mkdir %s/%s" \
|
||||
% (settings.VIRTUAL_UID, settings.STORAGE_PATH,
|
||||
name))
|
||||
if code:
|
||||
os.system("echo '%s' >> /tmp/vmail.log" % output)
|
||||
return False
|
||||
return True
|
||||
|
||||
def destroy_domain(name):
|
||||
code, output = exec_pipe("sudo -u %s rm -r %s/%s" \
|
||||
% (settings.VIRTUAL_UID, settings.STORAGE_PATH,
|
||||
name))
|
||||
if code:
|
||||
return False
|
||||
return True
|
||||
|
||||
def create_mailbox(domain, name):
|
||||
code, output = exec_pipe("sudo -u %s mkdir -p %s/%s/%s/.maildir" \
|
||||
% (settings.VIRTUAL_UID, settings.STORAGE_PATH,
|
||||
domain, name))
|
||||
if code:
|
||||
return False
|
||||
return True
|
||||
|
||||
def destroy_mailbox(domain, name):
|
||||
code, output = exec_pipe("sudo -u %s rm -r %s/%s/%s" \
|
||||
% (settings.VIRTUAL_UID, settings.STORAGE_PATH,
|
||||
domain, name))
|
||||
if code:
|
||||
return False
|
||||
return True
|
||||
@@ -23,6 +23,7 @@
|
||||
rel="{handler:'iframe',size:{x:300,y:190}}">
|
||||
<img src="/static/pics/edit.png" border="0" title="Edit alias"/>
|
||||
</a>
|
||||
{% url admin.views.delalias domain.id alias.id as del_url %}
|
||||
<a href="{{ del_url }}" name="deleteAlias">
|
||||
<img src="/static/pics/remove.png" border="0" title="Delete alias" />
|
||||
</a>
|
||||
@@ -30,7 +31,6 @@
|
||||
<td>{{ alias.address }}@{{ alias.mbox.domain.name }}</td>
|
||||
<td>{{ alias.mbox.address }}@{{ alias.mbox.domain.name }}</td>
|
||||
<td>{{ alias.enabled|yesno }}</td
|
||||
{% url admin.views.delalias domain.id alias.id as del_url %}
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
<div class="title"><h1>Alias {{ alias.address }}</h1></div>
|
||||
{% if error %}<div class="error">{{ error }}</div>{% endif %}
|
||||
{% url admin.views.editalias alias.mbox.domain.id alias.id as edit_url %}
|
||||
<form action="{{ edit_url }}" method="POST" id="aliasform" name="aliasform">
|
||||
{% include "admin/genericform.html" %}
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
{% block content %}
|
||||
<div class="title"><h1>Domain {{ domain.name }}</h1></div>
|
||||
|
||||
{% if error %}<div class="error">{{ error }}</div>{% endif %}
|
||||
{% url admin.views.editdomain domain.id as dom_url %}
|
||||
<form action="{{ dom_url }}" method="post" name="domform" id="domform">
|
||||
{% include "admin/genericform.html" %}
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
{% block content %}
|
||||
<div class="title"><h1>Mailbox {{ mbox.name }}</h1></div>
|
||||
|
||||
{% if error %}<div class="error">{{ error }}</div>{% endif %}
|
||||
{% url admin.views.editmailbox mbox.domain_id mbox.id as box_url %}
|
||||
<form action="{{ box_url }}" method="POST" id="mboxform" name="mboxform">
|
||||
{% include "admin/genericform.html" %}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
<div class="title"><h1>New alias</h1></div>
|
||||
{% if error %}<div class="error">{{ error }}</div>{% endif %}
|
||||
{% url admin.views.newalias mbox.domain_id mbox.id as box_url %}
|
||||
<form action="{{ box_url }}" method="POST" id="aliasform" name="aliasform">
|
||||
{% include "admin/genericform.html" %}
|
||||
|
||||
Reference in New Issue
Block a user