Merge pull request #8 from ribaguifi/dev/api-writable

Write operations for addresses and mailboxes
This commit is contained in:
Santiago L 2021-11-24 11:02:44 +01:00 committed by GitHub
commit 44f9390bee
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
24 changed files with 1207 additions and 267 deletions

View file

@ -1,14 +1,12 @@
import requests
import urllib.parse
from itertools import groupby
import requests
from django.conf import settings
from django.http import Http404
from django.urls.exceptions import NoReverseMatch
from django.utils.translation import gettext_lazy as _
from .models import Domain, DatabaseService, MailService, SaasService, UserAccount, WebSite
from .models import Address, DatabaseService, Domain, Mailbox, SaasService, UserAccount, WebSite
DOMAINS_PATH = 'domains/'
TOKEN_PATH = '/api-token-auth/'
@ -23,7 +21,10 @@ API_PATHS = {
'domain-list': 'domains/',
'domain-detail': 'domains/{pk}/',
'address-list': 'addresses/',
'address-detail': 'addresses/{pk}/',
'mailbox-list': 'mailboxes/',
'mailbox-detail': 'mailboxes/{pk}/',
'mailbox-password': 'mailboxes/{pk}/set_password/',
'mailinglist-list': 'lists/',
'saas-list': 'saas/',
'website-list': 'websites/',
@ -62,7 +63,7 @@ class Orchestra(object):
return response.json().get("token", None)
def request(self, verb, resource=None, url=None, render_as="json", querystring=None, raise_exception=True):
def request(self, verb, resource=None, url=None, data=None, render_as="json", querystring=None, raise_exception=True):
assert verb in ["HEAD", "GET", "POST", "PATCH", "PUT", "DELETE"]
if resource is not None:
url = self.build_absolute_uri(resource)
@ -73,14 +74,17 @@ class Orchestra(object):
url = "{}?{}".format(url, querystring)
verb = getattr(self.session, verb.lower())
response = verb(url, headers={"Authorization": "Token {}".format(
self.auth_token)}, allow_redirects=False)
headers = {
"Authorization": "Token {}".format(self.auth_token),
"Content-Type": "application/json",
}
response = verb(url, json=data, headers=headers, allow_redirects=False)
if raise_exception:
response.raise_for_status()
status = response.status_code
if render_as == "json":
if status < 500 and render_as == "json":
output = response.json()
else:
output = response.content
@ -109,52 +113,95 @@ class Orchestra(object):
raise Http404(_("No domain found matching the query"))
return bill_pdf
def create_mail_address(self, data):
resource = '{}-list'.format(Address.api_name)
return self.request("POST", resource=resource, data=data)
def retrieve_mail_address(self, pk):
path = API_PATHS.get('address-detail').format_map({'pk': pk})
url = urllib.parse.urljoin(self.base_url, path)
status, data = self.request("GET", url=url, raise_exception=False)
if status == 404:
raise Http404(_("No object found matching the query"))
return Address.new_from_json(data)
def update_mail_address(self, pk, data):
path = API_PATHS.get('address-detail').format_map({'pk': pk})
url = urllib.parse.urljoin(self.base_url, path)
return self.request("PUT", url=url, data=data)
def retrieve_mail_address_list(self, querystring=None):
def get_mailbox_id(value):
mailboxes = value.get('mailboxes')
# forwarded address should not grouped
if len(mailboxes) == 0:
return value.get('name')
return mailboxes[0]['id']
# retrieve mails applying filters (if any)
raw_data = self.retrieve_service_list(
MailService.api_name,
Address.api_name,
querystring=querystring,
)
# group addresses with the same mailbox
addresses = []
for key, group in groupby(raw_data, get_mailbox_id):
aliases = []
data = {}
for thing in group:
aliases.append(thing.pop('name'))
data = thing
data['names'] = aliases
addresses.append(MailService.new_from_json(data))
addresses = [Address.new_from_json(data) for data in raw_data]
# PATCH to include Pangea addresses not shown by orchestra
# described on issue #4
raw_mailboxes = self.retrieve_service_list('mailbox')
for mailbox in raw_mailboxes:
if mailbox['addresses'] == []:
address_data = {
'names': [mailbox['name']],
'forward': '',
'domain': {
'name': 'pangea.org.',
},
'mailboxes': [mailbox],
}
pangea_address = MailService.new_from_json(address_data)
addresses.append(pangea_address)
# TODO(@slamora) disabled hacky patch because breaks another funtionalities
# XXX Fix it on orchestra instead of here???
# raw_mailboxes = self.retrieve_mailbox_list()
# for mailbox in raw_mailboxes:
# if mailbox['addresses'] == []:
# address_data = {
# 'names': [mailbox['name']],
# 'forward': '',
# 'domain': {
# 'name': 'pangea.org.',
# },
# 'mailboxes': [mailbox],
# }
# pangea_address = Address.new_from_json(address_data)
# addresses.append(pangea_address)
return addresses
def delete_mail_address(self, pk):
path = API_PATHS.get('address-detail').format_map({'pk': pk})
url = urllib.parse.urljoin(self.base_url, path)
return self.request("DELETE", url=url, render_as=None)
def create_mailbox(self, data):
resource = '{}-list'.format(Mailbox.api_name)
return self.request("POST", resource=resource, data=data, raise_exception=False)
def retrieve_mailbox(self, pk):
path = API_PATHS.get('mailbox-detail').format_map({'pk': pk})
url = urllib.parse.urljoin(self.base_url, path)
status, data_json = self.request("GET", url=url, raise_exception=False)
if status == 404:
raise Http404(_("No mailbox found matching the query"))
return Mailbox.new_from_json(data_json)
def update_mailbox(self, pk, data):
path = API_PATHS.get('mailbox-detail').format_map({'pk': pk})
url = urllib.parse.urljoin(self.base_url, path)
status, response = self.request("PATCH", url=url, data=data, raise_exception=False)
return status, response
def retrieve_mailbox_list(self):
mailboxes = self.retrieve_service_list(Mailbox.api_name)
return [Mailbox.new_from_json(mailbox_data) for mailbox_data in mailboxes]
def delete_mailbox(self, pk):
path = API_PATHS.get('mailbox-detail').format_map({'pk': pk})
url = urllib.parse.urljoin(self.base_url, path)
# Mark as inactive instead of deleting
# return self.request("DELETE", url=url, render_as=None)
return self.request("PATCH", url=url, data={"is_active": False})
def set_password_mailbox(self, pk, data):
path = API_PATHS.get('mailbox-password').format_map({'pk': pk})
url = urllib.parse.urljoin(self.base_url, path)
status, response = self.request("POST", url=url, data=data, raise_exception=False)
return status, response
def retrieve_domain(self, pk):
path = API_PATHS.get('domain-detail').format_map({'pk': pk})
@ -174,8 +221,8 @@ class Orchestra(object):
querystring = "domain={}".format(domain_json['id'])
# retrieve services associated to a domain
domain_json['mails'] = self.retrieve_service_list(
MailService.api_name, querystring)
domain_json['addresses'] = self.retrieve_service_list(
Address.api_name, querystring)
# retrieve websites (as they cannot be filtered by domain on the API we should do it here)
domain_json['websites'] = self.filter_websites_by_domain(websites, domain_json['id'])

View file

@ -1,5 +1,8 @@
from django import forms
from django.contrib.auth.forms import AuthenticationForm
from django.core.exceptions import ValidationError
from django.utils.translation import gettext_lazy as _
from . import api
@ -20,3 +23,135 @@ class LoginForm(AuthenticationForm):
self.user = orchestra.retrieve_profile()
return self.cleaned_data
class MailForm(forms.Form):
name = forms.CharField()
domain = forms.ChoiceField()
mailboxes = forms.MultipleChoiceField(required=False)
forward = forms.EmailField(required=False)
def __init__(self, *args, **kwargs):
self.instance = kwargs.pop('instance', None)
if self.instance is not None:
kwargs['initial'] = self.instance.deserialize()
domains = kwargs.pop('domains')
mailboxes = kwargs.pop('mailboxes')
super().__init__(*args, **kwargs)
self.fields['domain'].choices = [(d.url, d.name) for d in domains]
self.fields['mailboxes'].choices = [(m.url, m.name) for m in mailboxes]
def clean(self):
cleaned_data = super().clean()
if not cleaned_data.get('mailboxes') and not cleaned_data.get('forward'):
raise ValidationError("A mailbox or forward address should be provided.")
return cleaned_data
def serialize(self):
assert hasattr(self, 'cleaned_data')
serialized_data = {
"name": self.cleaned_data["name"],
"domain": {"url": self.cleaned_data["domain"]},
"mailboxes": [{"url": mbox} for mbox in self.cleaned_data["mailboxes"]],
"forward": self.cleaned_data["forward"],
}
return serialized_data
class MailboxChangePasswordForm(forms.Form):
error_messages = {
'password_mismatch': _('The two password fields didnt match.'),
}
password = forms.CharField(
label=_("Password"),
strip=False,
widget=forms.PasswordInput(attrs={'autocomplete': 'new-password'}),
)
password2 = forms.CharField(
label=_("Password confirmation"),
widget=forms.PasswordInput(attrs={'autocomplete': 'new-password'}),
strip=False,
help_text=_("Enter the same password as before, for verification."),
)
def clean_password2(self):
password = self.cleaned_data.get("password")
password2 = self.cleaned_data.get("password2")
if password and password2 and password != password2:
raise ValidationError(
self.error_messages['password_mismatch'],
code='password_mismatch',
)
return password2
def serialize(self):
assert self.is_valid()
serialized_data = {
"password": self.cleaned_data["password2"],
}
return serialized_data
class MailboxCreateForm(forms.Form):
error_messages = {
'password_mismatch': _('The two password fields didnt match.'),
}
name = forms.CharField()
password = forms.CharField(
label=_("Password"),
strip=False,
widget=forms.PasswordInput(attrs={'autocomplete': 'new-password'}),
)
password2 = forms.CharField(
label=_("Password confirmation"),
widget=forms.PasswordInput(attrs={'autocomplete': 'new-password'}),
strip=False,
help_text=_("Enter the same password as before, for verification."),
)
addresses = forms.MultipleChoiceField(required=False)
def __init__(self, *args, **kwargs):
addresses = kwargs.pop('addresses')
super().__init__(*args, **kwargs)
self.fields['addresses'].choices = [(addr.url, addr.full_address_name) for addr in addresses]
def clean_password2(self):
password = self.cleaned_data.get("password")
password2 = self.cleaned_data.get("password2")
if password and password2 and password != password2:
raise ValidationError(
self.error_messages['password_mismatch'],
code='password_mismatch',
)
return password2
def serialize(self):
assert self.is_valid()
serialized_data = {
"name": self.cleaned_data["name"],
"password": self.cleaned_data["password2"],
"addresses": self.cleaned_data["addresses"],
}
return serialized_data
class MailboxUpdateForm(forms.Form):
addresses = forms.MultipleChoiceField(required=False)
def __init__(self, *args, **kwargs):
self.instance = kwargs.pop('instance', None)
if self.instance is not None:
kwargs['initial'] = self.instance.deserialize()
addresses = kwargs.pop('addresses')
super().__init__(*args, **kwargs)
self.fields['addresses'].choices = [(addr.url, addr.full_address_name) for addr in addresses]
def serialize(self):
assert self.is_valid()
serialized_data = {
"addresses": self.cleaned_data["addresses"],
}
return serialized_data

View file

@ -7,7 +7,7 @@ msgid ""
msgstr ""
"Project-Id-Version: \n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2020-01-23 17:49+0100\n"
"POT-Creation-Date: 2021-10-08 11:49+0200\n"
"PO-Revision-Date: 2020-01-28 17:27+0100\n"
"Last-Translator: \n"
"Language-Team: \n"
@ -18,10 +18,34 @@ msgstr ""
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
"X-Generator: Poedit 2.2.4\n"
#: api.py:108 api.py:117
#: api.py:112 api.py:203
msgid "No domain found matching the query"
msgstr "No trobem cap domini que coincideixi amb la teva consulta"
#: api.py:124
msgid "No object found matching the query"
msgstr "No trobem cap objecte que coincideixi amb la teva consulta"
#: api.py:177
msgid "No mailbox found matching the query"
msgstr "No trobem cap bústia que coincideixi amb la teva consulta"
#: forms.py:65
msgid "The two password fields didnt match."
msgstr "Les contrasenyes introduides no coincideixen."
#: forms.py:69
msgid "Password"
msgstr "Contrasenya"
#: forms.py:74
msgid "Password confirmation"
msgstr "Verificació de la contrasenya"
#: forms.py:77
msgid "Enter the same password as before, for verification."
msgstr "Introdueix la mateixa contrasenya per verificar."
#: mixins.py:14
msgid "Domains & websites"
msgstr "Dominis i llocs web"
@ -31,12 +55,12 @@ msgid "Mails"
msgstr "Correus"
#. Translators: This message appears on the page title
#: mixins.py:16 views.py:226
#: mixins.py:16 views.py:291
msgid "Mailing lists"
msgstr "Llistes de correu"
#. Translators: This message appears on the page title
#: mixins.py:17 models.py:138 views.py:255
#: mixins.py:17 models.py:147 views.py:427
msgid "Databases"
msgstr "Bases de dades"
@ -44,36 +68,41 @@ msgstr "Bases de dades"
msgid "SaaS"
msgstr "SaaS"
#: models.py:139
#, fuzzy
#: models.py:148
msgid "Description details for databases page."
msgstr "Consulta la configuració de les teves bases de dades."
#. Translators: This message appears on the page title
#: models.py:200 views.py:169
#: models.py:235 views.py:181
msgid "Mail addresses"
msgstr "Adreces de correu"
#: models.py:201
#, fuzzy
#: models.py:236
msgid "Description details for mail addresses page."
msgstr "Consulta aquí totes les adreces de correu que tens actives."
#: models.py:243
#: models.py:311
msgid "Mailbox"
msgstr "Bústia de correu"
#: models.py:312
msgid "Description details for mailbox page."
msgstr ""
"Aquí trobaràs les teves bústies de correu i els seus detalls de configuració."
#: models.py:337
msgid "Mailing list"
msgstr "Llista de correu"
#: models.py:244
#, fuzzy
#: models.py:338
msgid "Description details for mailinglist page."
msgstr "Consulta aquí els detalls de les teves llistes de correu."
#: models.py:267
#: models.py:364
msgid "Software as a Service (SaaS)"
msgstr "Software as a Service (SaaS)"
#: models.py:268
#, fuzzy
#: models.py:365
msgid "Description details for SaaS page."
msgstr ""
"Si tens algun servei SaaS (Software as a Service) contractat, aquí trobaràs "
@ -100,6 +129,58 @@ msgstr ""
"Envia un correu a <a href=\"mailto:%(support_email)s\">%(support_email)s</a> "
"indicant el teu nom dusuari/a i texplicarem què fer."
#: templates/musician/address_check_delete.html:7
#, python-format
msgid "Are you sure that you want remove the address: \"%(address_name)s\"?"
msgstr ""
"Estàs segur de que vols esborrar la adreça de correu: \"%(address_name)s\"?"
#: templates/musician/address_check_delete.html:8
#: templates/musician/mailbox_check_delete.html:11
msgid "WARNING: This action cannot be undone."
msgstr "AVÍS: Aquesta acció es irreversible."
#: templates/musician/address_check_delete.html:9
#: templates/musician/address_form.html:15
#: templates/musician/mailbox_check_delete.html:12
#: templates/musician/mailbox_form.html:24
msgid "Delete"
msgstr "Esborrar"
#: templates/musician/address_check_delete.html:10
#: templates/musician/address_form.html:11
#: templates/musician/mailbox_check_delete.html:13
#: templates/musician/mailbox_form.html:20
msgid "Cancel"
msgstr "Cancel·lar"
#: templates/musician/address_form.html:12
#: templates/musician/mailbox_form.html:21
msgid "Save"
msgstr "Desar"
#: templates/musician/addresses.html:15
msgid "Email"
msgstr "E-mail"
#: templates/musician/addresses.html:16
msgid "Domain"
msgstr "Domini"
#. Translators: This message appears on the page title
#: templates/musician/addresses.html:17 templates/musician/mail_base.html:22
#: views.py:320
msgid "Mailboxes"
msgstr "Bústia de correu"
#: templates/musician/addresses.html:18
msgid "Forward"
msgstr "Redirecció"
#: templates/musician/addresses.html:38
msgid "New mail address"
msgstr "Nova adreça de correu"
#: templates/musician/base.html:60
msgid "Settings"
msgstr "Configuració"
@ -110,7 +191,7 @@ msgstr "Perfil"
#. Translators: This message appears on the page title
#: templates/musician/base.html:64 templates/musician/billing.html:6
#: views.py:147
#: views.py:159
msgid "Billing"
msgstr "Factures"
@ -119,7 +200,6 @@ msgid "Log out"
msgstr "Surt"
#: templates/musician/billing.html:7
#, fuzzy
msgid "Billing page description."
msgstr "Consulta i descarrega les teves factures."
@ -132,7 +212,7 @@ msgid "Bill date"
msgstr "Data de la factura"
#: templates/musician/billing.html:21 templates/musician/databases.html:17
#: templates/musician/domain_detail.html:17 templates/musician/mail.html:22
#: templates/musician/domain_detail.html:17
msgid "Type"
msgstr "Tipus"
@ -178,7 +258,6 @@ msgid "Your domains and websites"
msgstr "Els teus dominis i llocs web"
#: templates/musician/dashboard.html:36
#, fuzzy
msgid "Dashboard page description."
msgstr ""
"Aquest és el teu panell de gestió, des don podràs consultar la configuració "
@ -209,7 +288,7 @@ msgid "Mail list"
msgstr "Llista de correu"
#. Translators: This message appears on the page title
#: templates/musician/dashboard.html:82 views.py:264
#: templates/musician/dashboard.html:82 views.py:436
msgid "Software as a Service"
msgstr "Software as a Service"
@ -217,7 +296,7 @@ msgstr "Software as a Service"
msgid "Nothing installed"
msgstr "No tens res instal·lat"
#: templates/musician/dashboard.html:89 views.py:42
#: templates/musician/dashboard.html:89 views.py:67
msgid "Disk usage"
msgstr "Ús del disc"
@ -279,7 +358,6 @@ msgid "DNS settings for"
msgstr "Configuració DNS per a"
#: templates/musician/domain_detail.html:8
#, fuzzy
msgid "DNS settings page description."
msgstr "Consulta aquí la teva configuració DNS."
@ -287,25 +365,57 @@ msgstr "Consulta aquí la teva configuració DNS."
msgid "Value"
msgstr "Valor"
#: templates/musician/mail.html:6 templates/musician/mailinglists.html:6
#: templates/musician/mail_base.html:6 templates/musician/mailinglists.html:6
msgid "Go to global"
msgstr "Totes les adreces"
#: templates/musician/mail.html:9 templates/musician/mailinglists.html:9
#: templates/musician/mail_base.html:10 templates/musician/mailinglists.html:9
msgid "for"
msgstr "per a"
#: templates/musician/mail.html:20
msgid "Mail address"
msgstr "Adreça de correu"
#: templates/musician/mail_base.html:18 templates/musician/mailboxes.html:16
msgid "Addresses"
msgstr "Adreces de correu"
#: templates/musician/mail.html:21
msgid "Aliases"
msgstr "Àlies"
#: templates/musician/mailbox_check_delete.html:7
#, python-format
msgid "Are you sure that you want remove the mailbox: \"%(name)s\"?"
msgstr "Estàs segur de que vols esborrar la bústia de correu: \"%(name)s\"?"
#: templates/musician/mail.html:23
msgid "Type details"
msgstr "Detalls de cada tipus"
#: templates/musician/mailbox_check_delete.html:9
msgid ""
"All mailbox's messages will be <strong>deleted and cannot be recovered"
"</strong>."
msgstr ""
"Tots els missatges <strong>s'esborraran i no es podran recuperar</strong>"
#: templates/musician/mailbox_form.html:9
msgid "Warning!"
msgstr "Atenció!"
#: templates/musician/mailbox_form.html:9
msgid ""
"You have reached the limit of mailboxes of your subscription so "
"<strong>extra fees</strong> may apply."
msgstr ""
"Has assolit el llímit de bústies de correu de la teva suscripció, les noves "
"bústies poden implicar <strong>costos adicionals</strong>."
#: templates/musician/mailbox_form.html:10
msgid "Close"
msgstr "Tancar"
#: templates/musician/mailboxes.html:14
msgid "Name"
msgstr "Nombre"
#: templates/musician/mailboxes.html:15
msgid "Filtering"
msgstr "Filtrat"
#: templates/musician/mailboxes.html:39
msgid "New mailbox"
msgstr "Nova bústia de correu"
#: templates/musician/mailinglists.html:34
msgid "Active"
@ -316,7 +426,6 @@ msgid "Inactive"
msgstr "Inactiu"
#: templates/musician/profile.html:7
#, fuzzy
msgid "Little description on profile page."
msgstr "Canvia les teves dades daccés i opcions de perfil des daquí."
@ -357,43 +466,46 @@ msgid "Open service admin panel"
msgstr "Obre el panell dadministració del servei"
#. Translators: This message appears on the page title
#: views.py:32
#: views.py:37
msgid "Dashboard"
msgstr "Panell de gestió"
#: views.py:49
#: views.py:76
msgid "Traffic"
msgstr "Tràfic"
#: views.py:56
#: views.py:85
msgid "Mailbox usage"
msgstr "Ús despai a la bústia de correu"
#. Translators: This message appears on the page title
#: views.py:96
#: views.py:108
msgid "User profile"
msgstr "El teu perfil"
#. Translators: This message appears on the page title
#: views.py:154
#: views.py:166
msgid "Download bill"
msgstr "Descarrega la factura"
#. Translators: This message appears on the page title
#: views.py:272
#: views.py:444
msgid "Domain details"
msgstr "Detalls del domini"
#. Translators: This message appears on the page title
#: views.py:298
#: views.py:470
msgid "Login"
msgstr "Accés"
#~ msgid "Aliases"
#~ msgstr "Àlies"
#~ msgid "Type details"
#~ msgstr "Detalls de cada tipus"
#~ msgid "databases created"
#~ msgstr "bases de dades creades"
#~ msgid "Username"
#~ msgstr "Nom dusuari/a"
#~ msgid "Password:"
#~ msgstr "Contrasenya:"

View file

@ -7,7 +7,7 @@ msgid ""
msgstr ""
"Project-Id-Version: \n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2020-01-23 17:49+0100\n"
"POT-Creation-Date: 2021-10-08 11:49+0200\n"
"PO-Revision-Date: 2020-01-28 17:27+0100\n"
"Last-Translator: \n"
"Language-Team: \n"
@ -18,10 +18,34 @@ msgstr ""
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
"X-Generator: Poedit 2.2.4\n"
#: api.py:108 api.py:117
#: api.py:112 api.py:203
msgid "No domain found matching the query"
msgstr "No hay dominios que coincidan con tu búsqueda"
#: api.py:124
msgid "No object found matching the query"
msgstr "No hay objetos que coincidan con tu búsqueda"
#: api.py:177
msgid "No mailbox found matching the query"
msgstr "No hay buzones de correo que coincidan con tu búsqueda"
#: forms.py:65
msgid "The two password fields didnt match."
msgstr "Las contraseñas introducidas no coinciden."
#: forms.py:69
msgid "Password"
msgstr "Contraseña"
#: forms.py:74
msgid "Password confirmation"
msgstr "Confirma la contraseña"
#: forms.py:77
msgid "Enter the same password as before, for verification."
msgstr "Introduce la misma contraseña para verificarla"
#: mixins.py:14
msgid "Domains & websites"
msgstr "Dominios y sitios web"
@ -31,12 +55,12 @@ msgid "Mails"
msgstr "Correos"
#. Translators: This message appears on the page title
#: mixins.py:16 views.py:226
#: mixins.py:16 views.py:291
msgid "Mailing lists"
msgstr "Listas de correo"
#. Translators: This message appears on the page title
#: mixins.py:17 models.py:138 views.py:255
#: mixins.py:17 models.py:147 views.py:427
msgid "Databases"
msgstr "Bases de datos"
@ -44,36 +68,41 @@ msgstr "Bases de datos"
msgid "SaaS"
msgstr "SaaS"
#: models.py:139
#, fuzzy
#: models.py:148
msgid "Description details for databases page."
msgstr "Consulta la configuración de tus bases de datos."
#. Translators: This message appears on the page title
#: models.py:200 views.py:169
#: models.py:235 views.py:181
msgid "Mail addresses"
msgstr "Direcciones de correo"
#: models.py:201
#, fuzzy
#: models.py:236
msgid "Description details for mail addresses page."
msgstr "Consulta aquí todas las direcciones de correo que tienes activas."
#: models.py:243
#: models.py:311
msgid "Mailbox"
msgstr "Buzón de correo"
#: models.py:312
msgid "Description details for mailbox page."
msgstr ""
"Aquí encontrarás tus buzones de correo y sus detalles de configuración."
#: models.py:337
msgid "Mailing list"
msgstr "Lista de correo"
#: models.py:244
#, fuzzy
#: models.py:338
msgid "Description details for mailinglist page."
msgstr "Consulta aquí los detalles de tus listas de correo."
#: models.py:267
#: models.py:364
msgid "Software as a Service (SaaS)"
msgstr "Software as a Service (SaaS)"
#: models.py:268
#, fuzzy
#: models.py:365
msgid "Description details for SaaS page."
msgstr ""
"Si tienes algún servicio SaaS (Software as a Service) contratado, aquí "
@ -100,6 +129,59 @@ msgstr ""
"Envía un correo a <a href=“mailto:%(support_email)s”>%(support_email)s</a> "
"indicando tu nombre de usuaria/o y te explicaremos qué hacer."
#: templates/musician/address_check_delete.html:7
#, python-format
msgid "Are you sure that you want remove the address: \"%(address_name)s\"?"
msgstr ""
"¿Estás seguro de que quieres borrar la dirección de correo \"%(address_name)s"
"\"?"
#: templates/musician/address_check_delete.html:8
#: templates/musician/mailbox_check_delete.html:11
msgid "WARNING: This action cannot be undone."
msgstr "AVISO: Esta acción es irreversible."
#: templates/musician/address_check_delete.html:9
#: templates/musician/address_form.html:15
#: templates/musician/mailbox_check_delete.html:12
#: templates/musician/mailbox_form.html:24
msgid "Delete"
msgstr "Borrar"
#: templates/musician/address_check_delete.html:10
#: templates/musician/address_form.html:11
#: templates/musician/mailbox_check_delete.html:13
#: templates/musician/mailbox_form.html:20
msgid "Cancel"
msgstr "Cancelar"
#: templates/musician/address_form.html:12
#: templates/musician/mailbox_form.html:21
msgid "Save"
msgstr "Guardar"
#: templates/musician/addresses.html:15
msgid "Email"
msgstr "E-mail"
#: templates/musician/addresses.html:16
msgid "Domain"
msgstr "Dominio"
#. Translators: This message appears on the page title
#: templates/musician/addresses.html:17 templates/musician/mail_base.html:22
#: views.py:320
msgid "Mailboxes"
msgstr "Buzones de correo"
#: templates/musician/addresses.html:18
msgid "Forward"
msgstr "Redirección"
#: templates/musician/addresses.html:38
msgid "New mail address"
msgstr "Nueva dirección de correo"
#: templates/musician/base.html:60
msgid "Settings"
msgstr "Configuración"
@ -110,7 +192,7 @@ msgstr "Perfil"
#. Translators: This message appears on the page title
#: templates/musician/base.html:64 templates/musician/billing.html:6
#: views.py:147
#: views.py:159
msgid "Billing"
msgstr "Facturas"
@ -119,7 +201,6 @@ msgid "Log out"
msgstr "Desconéctate"
#: templates/musician/billing.html:7
#, fuzzy
msgid "Billing page description."
msgstr "Consulta y descarga tus facturas."
@ -132,7 +213,7 @@ msgid "Bill date"
msgstr "Fecha de la factura"
#: templates/musician/billing.html:21 templates/musician/databases.html:17
#: templates/musician/domain_detail.html:17 templates/musician/mail.html:22
#: templates/musician/domain_detail.html:17
msgid "Type"
msgstr "Tipo"
@ -178,7 +259,6 @@ msgid "Your domains and websites"
msgstr "Tus dominios y sitios web"
#: templates/musician/dashboard.html:36
#, fuzzy
msgid "Dashboard page description."
msgstr ""
"Este es tu panel de gestión, desde donde podrás consultar la configuración "
@ -209,7 +289,7 @@ msgid "Mail list"
msgstr "Lista de correo"
#. Translators: This message appears on the page title
#: templates/musician/dashboard.html:82 views.py:264
#: templates/musician/dashboard.html:82 views.py:436
msgid "Software as a Service"
msgstr "Software as a Service"
@ -217,7 +297,7 @@ msgstr "Software as a Service"
msgid "Nothing installed"
msgstr "No tienes nada instalado"
#: templates/musician/dashboard.html:89 views.py:42
#: templates/musician/dashboard.html:89 views.py:67
msgid "Disk usage"
msgstr "Uso del disco"
@ -233,8 +313,8 @@ msgstr "Acceso FTP:"
#: templates/musician/dashboard.html:115
msgid "Contact with the support team to get details concerning FTP access."
msgstr ""
"Contactadnos a <a href=“mailto:%(support_email)s”>%(support_email)s</a> "
"para saber cómo acceder al FTP."
"Contactadnos a <a href=“mailto:%(support_email)s”>%(support_email)s</a> para "
"saber cómo acceder al FTP."
#: templates/musician/dashboard.html:124
msgid "No website configured."
@ -279,7 +359,6 @@ msgid "DNS settings for"
msgstr "Configuración DNS para"
#: templates/musician/domain_detail.html:8
#, fuzzy
msgid "DNS settings page description."
msgstr "Consulta aquí tu configuración DNS."
@ -287,25 +366,55 @@ msgstr "Consulta aquí tu configuración DNS."
msgid "Value"
msgstr "Valor"
#: templates/musician/mail.html:6 templates/musician/mailinglists.html:6
#: templates/musician/mail_base.html:6 templates/musician/mailinglists.html:6
msgid "Go to global"
msgstr "Todas las direcciones"
#: templates/musician/mail.html:9 templates/musician/mailinglists.html:9
#: templates/musician/mail_base.html:10 templates/musician/mailinglists.html:9
msgid "for"
msgstr "para"
#: templates/musician/mail.html:20
msgid "Mail address"
msgstr "Dirección de correo"
#: templates/musician/mail_base.html:18 templates/musician/mailboxes.html:16
msgid "Addresses"
msgstr "Direcciones de correo"
#: templates/musician/mail.html:21
msgid "Aliases"
msgstr "Alias"
#: templates/musician/mailbox_check_delete.html:7
#, python-format
msgid "Are you sure that you want remove the mailbox: \"%(name)s\"?"
msgstr "¿Estás seguro de que quieres borrar el buzón de correo \"%(name)s\"?"
#: templates/musician/mail.html:23
msgid "Type details"
msgstr "Detalles de cada tipo"
#: templates/musician/mailbox_check_delete.html:9
msgid ""
"All mailbox's messages will be <strong>deleted and cannot be recovered</"
"strong>."
msgstr ""
"Todos los mensajes <strong>se borrarán y no se podrán recuperar</strong>"
#: templates/musician/mailbox_form.html:9
msgid "Warning!"
msgstr "¡Aviso!"
#: templates/musician/mailbox_form.html:9
msgid ""
"You have reached the limit of mailboxes of your subscription so "
"<strong>extra fees</strong> may apply."
msgstr ""
#: templates/musician/mailbox_form.html:10
msgid "Close"
msgstr "Cerrar"
#: templates/musician/mailboxes.html:14
msgid "Name"
msgstr "Nombre"
#: templates/musician/mailboxes.html:15
msgid "Filtering"
msgstr "Filtrado"
#: templates/musician/mailboxes.html:39
msgid "New mailbox"
msgstr "Nuevo buzón de correo"
#: templates/musician/mailinglists.html:34
msgid "Active"
@ -316,7 +425,6 @@ msgid "Inactive"
msgstr "Inactivo"
#: templates/musician/profile.html:7
#, fuzzy
msgid "Little description on profile page."
msgstr "Cambia tus datos de acceso y opciones de perfil desde aquí."
@ -357,43 +465,46 @@ msgid "Open service admin panel"
msgstr "Abre el panel de administración del servicio"
#. Translators: This message appears on the page title
#: views.py:32
#: views.py:37
msgid "Dashboard"
msgstr "Panel de gestión"
#: views.py:49
#: views.py:76
msgid "Traffic"
msgstr "Tráfico"
#: views.py:56
#: views.py:85
msgid "Mailbox usage"
msgstr "Uso de espacio en tu buzón de correo"
#. Translators: This message appears on the page title
#: views.py:96
#: views.py:108
msgid "User profile"
msgstr "Tu perfil"
#. Translators: This message appears on the page title
#: views.py:154
#: views.py:166
msgid "Download bill"
msgstr "Descarga la factura"
#. Translators: This message appears on the page title
#: views.py:272
#: views.py:444
msgid "Domain details"
msgstr "Detalles del dominio"
#. Translators: This message appears on the page title
#: views.py:298
#: views.py:470
msgid "Login"
msgstr "Accede"
#~ msgid "Aliases"
#~ msgstr "Alias"
#~ msgid "Type details"
#~ msgstr "Detalles de cada tipo"
#~ msgid "databases created"
#~ msgstr "bases de datos creadas"
#~ msgid "Username"
#~ msgstr "Nombre de usuario/a"
#~ msgid "Password:"
#~ msgstr "Contraseña:"

View file

@ -12,10 +12,10 @@ class CustomContextMixin(ContextMixin):
# generate services menu items
services_menu = [
{'icon': 'globe-europe', 'pattern_name': 'musician:dashboard', 'title': _('Domains & websites')},
{'icon': 'envelope', 'pattern_name': 'musician:mails', 'title': _('Mails')},
{'icon': 'envelope', 'pattern_name': 'musician:address-list', 'title': _('Mails')},
{'icon': 'mail-bulk', 'pattern_name': 'musician:mailing-lists', 'title': _('Mailing lists')},
{'icon': 'database', 'pattern_name': 'musician:databases', 'title': _('Databases')},
{'icon': 'fire', 'pattern_name': 'musician:saas', 'title': _('SaaS')},
{'icon': 'database', 'pattern_name': 'musician:database-list', 'title': _('Databases')},
{'icon': 'fire', 'pattern_name': 'musician:saas-list', 'title': _('SaaS')},
]
context.update({
'services_menu': services_menu,

View file

@ -17,6 +17,7 @@ class OrchestraModel:
api_name = None
verbose_name = None
fields = ()
param_defaults = {}
id = None
def __init__(self, **kwargs):
@ -128,6 +129,10 @@ class UserAccount(OrchestraModel):
return super().new_from_json(data=data, billing=billing, language=language, last_login=last_login)
def allowed_resources(self, resource):
allowed_by_type = musician_settings.ALLOWED_RESOURCES[self.type]
return allowed_by_type[resource]
class DatabaseUser(OrchestraModel):
api_name = 'databaseusers'
@ -161,7 +166,7 @@ class DatabaseService(OrchestraModel):
return super().new_from_json(data=data, users=users, usage=usage)
@classmethod
def get_usage(self, data):
def get_usage(cls, data):
try:
resources = data['resources']
resource_disk = {}
@ -198,9 +203,10 @@ class Domain(OrchestraModel):
"id": None,
"name": None,
"records": [],
"mails": [],
"addresses": [],
"usage": {},
"websites": [],
"url": None,
}
@classmethod
@ -224,12 +230,19 @@ class DomainRecord(OrchestraModel):
return '<%s: %s>' % (self.type, self.value)
class MailService(OrchestraModel):
class Address(OrchestraModel):
api_name = 'address'
verbose_name = _('Mail addresses')
description = _('Description details for mail addresses page.')
fields = ('mail_address', 'aliases', 'type', 'type_detail')
param_defaults = {}
param_defaults = {
"id": None,
"name": None,
"domain": None,
"mailboxes": [],
"forward": None,
'url': None,
}
FORWARD = 'forward'
MAILBOX = 'mailbox'
@ -238,6 +251,15 @@ class MailService(OrchestraModel):
self.data = kwargs
super().__init__(**kwargs)
def deserialize(self):
data = {
'name': self.data['name'],
'domain': self.data['domain']['url'],
'mailboxes': [mbox['url'] for mbox in self.data['mailboxes']],
'forward': self.data['forward'],
}
return data
@property
def aliases(self):
return [
@ -245,8 +267,8 @@ class MailService(OrchestraModel):
]
@property
def mail_address(self):
return self.data['names'][0] + '@' + self.data['domain']['name']
def full_address_name(self):
return "{}@{}".format(self.name, self.domain['name'])
@property
def type(self):
@ -284,6 +306,32 @@ class MailService(OrchestraModel):
return mailbox_details
class Mailbox(OrchestraModel):
api_name = 'mailbox'
verbose_name = _('Mailbox')
description = _('Description details for mailbox page.')
fields = ('name', 'filtering', 'addresses', 'active')
param_defaults = {
'id': None,
'name': None,
'filtering': None,
'is_active': True,
'addresses': [],
'url': None,
}
@classmethod
def new_from_json(cls, data, **kwargs):
addresses = [Address.new_from_json(addr) for addr in data.get('addresses', [])]
return super().new_from_json(data=data, addresses=addresses)
def deserialize(self):
data = {
'addresses': [addr.url for addr in self.addresses],
}
return data
class MailinglistService(OrchestraModel):
api_name = 'mailinglist'
verbose_name = _('Mailing list')
@ -301,7 +349,10 @@ class MailinglistService(OrchestraModel):
@property
def address_name(self):
return "{}@{}".format(self.data['address_name'], self.data['address_domain']['name'])
address_domain = self.data['address_domain']
if address_domain is None:
return self.data['address_name']
return "{}@{}".format(self.data['address_name'], address_domain['name'])
@property
def manager_url(self):

View file

@ -4,31 +4,30 @@ a, a:hover, a:focus {
}
a:hover {
color: rgba(0,0,0,.7);
color: rgba(0, 0, 0, .7);
}
.btn-arrow-left{
.btn-arrow-left {
color: #eee;
background: #D3D0DA;
position: relative;
padding: 8px 20px 8px 30px;
margin-left: 1em; /** equal value than arrow.left **/
margin-left: 1em;
/** equal value than arrow.left **/
}
.btn-arrow-left::after,
.btn-arrow-left::before{
.btn-arrow-left::after, .btn-arrow-left::before {
content: "";
position: absolute;
top: 50%;
left: -1em;
margin-top: -19px;
border-top: 19px solid transparent;
border-bottom: 19px solid transparent;
border-right: 1em solid;
}
.btn-arrow-left::after{
.btn-arrow-left::after {
border-right-color: #D3D0DA;
z-index: 2;
}
@ -43,13 +42,12 @@ a:hover {
min-width: 280px;
max-width: 280px;
min-height: 100vh;
position: fixed;
z-index: 999;
display: flex;
flex-direction: column;
}
#sidebar #sidebar-services {
flex-grow: 1;
}
@ -62,20 +60,20 @@ a:hover {
padding-left: 2rem;
padding-right: 2rem;
}
#sidebar #sidebar-services {
padding-left: 1rem;
padding-right: 1rem;
}
#sidebar #user-profile-menu {
background:rgba(254, 251, 242, 0.25);
background: rgba(254, 251, 242, 0.25);
}
#sidebar ul.components {
padding: 20px 0;
}
#sidebar ul li a {
padding: 10px;
font-size: 1.1em;
@ -89,25 +87,26 @@ a:hover {
}
.vertical-center {
min-height: 100%; /* Fallback for browsers do NOT support vh unit */
min-height: 100vh; /* These two lines are counted as one :-) */
min-height: 100%;
/* Fallback for browsers do NOT support vh unit */
min-height: 100vh;
/* These two lines are counted as one :-) */
display: flex;
align-items: center;
}
}
/** login **/
#body-login .jumbotron {
background: #282532 no-repeat url("../images/logo-pangea-lilla-bg.svg") right;
}
#login-content {
background:white;
background: white;
padding: 2rem;
}
#login-content input[type="text"].form-control,
#login-content input[type="password"].form-control {
#login-content input[type="text"].form-control, #login-content input[type="password"].form-control {
border-radius: 0;
border: 0;
border-bottom: 2px solid #8E8E8E;
@ -121,6 +120,7 @@ a:hover {
margin-top: 1.5rem;
text-align: center;
}
#login-footer a {
color: #FEFBF2;
}
@ -130,34 +130,37 @@ a:hover {
background-position: right 5% top 10%;
color: #343434;
padding-left: 2rem;
margin-left: 280px; /** sidebar width **/
margin-left: 280px;
/** sidebar width **/
}
/** services **/
h1.service-name {
h1.service-name {
font: Bold 26px/34px Roboto;
margin-top: 3rem;
}
.service-description {
font: 16px/21px Roboto;
}
.table.service-list {
margin-top: 2rem;
table-layout: fixed;
}
/** TODO update theme instead of overriding **/
.service-list thead.thead-dark th,
.service-card .card-header {
.service-list thead.thead-dark th, .service-card .card-header {
background: rgba(80, 70, 110, 0.25);
color: #50466E;
border-color: transparent;
}
/** /TODO **/
.table.service-list td,
.table.service-list th {
.table.service-list td, .table.service-list th {
vertical-align: middle;
}
@ -202,11 +205,10 @@ h1.service-name {
.service-card .card-body {
color: #787878;
}
.service-card .card-body i.fas {
color:#9C9AA7;
color: #9C9AA7;
}
.service-manager-link {
@ -215,8 +217,7 @@ h1.service-name {
right: 15px;
}
.service-card .service-manager-link a,
.service-card .service-manager-link a i.fas {
.service-card .service-manager-link a, .service-card .service-manager-link a i.fas {
color: white;
}
@ -243,11 +244,9 @@ h1.service-name {
font-variant: normal;
text-rendering: auto;
-webkit-font-smoothing: antialiased;
position: absolute;
top: 0;
right: 10px;
color: #E8E7EB;
font-size: 2em;
}
@ -308,3 +307,13 @@ h1.service-name {
border-top: 0;
justify-content: center;
}
.roll-hover {
visibility: hidden;
display: inline-block;
margin-left: 2rem;
}
td:hover .roll-hover {
visibility: visible;
}

View file

@ -0,0 +1,12 @@
{% extends "musician/base.html" %}
{% load i18n %}
{% block content %}
<form method="post">
{% csrf_token %}
<p>{% blocktrans with address_name=object.full_address_name %}Are you sure that you want remove the address: "{{ address_name }}"?{% endblocktrans %}</p>
<p class="alert alert-warning"><strong>{% trans 'WARNING: This action cannot be undone.' %}</strong></p>
<input class="btn btn-danger" type="submit" value="{% trans 'Delete' %}">
<a class="btn btn-secondary" href="{% url 'musician:address-update' view.kwargs.pk %}">{% trans 'Cancel' %}</a>
</form>
{% endblock %}

View file

@ -0,0 +1,20 @@
{% extends "musician/base.html" %}
{% load bootstrap4 i18n %}
{% block content %}
<h1 class="service-name">{{ service.verbose_name }}</h1>
<form method="post">
{% csrf_token %}
{% bootstrap_form form %}
{% buttons %}
<a class="btn btn-light mr-2" href="{% url 'musician:address-list' %}">{% trans "Cancel" %}</a>
<button type="submit" class="btn btn-secondary">{% trans "Save" %}</button>
{% if form.instance %}
<div class="float-right">
<a class="btn btn-danger" href="{% url 'musician:address-delete' view.kwargs.pk %}">{% trans "Delete" %}</a>
</div>
{% endif %}
{% endbuttons %}
</form>
{% endblock %}

View file

@ -0,0 +1,41 @@
{% extends "musician/mail_base.html" %}
{% load i18n %}
{% block tabcontent %}
<div class="tab-pane fade show active" id="addresses" role="tabpanel" aria-labelledby="addresses-tab">
<table class="table service-list">
<colgroup>
<col span="1" style="width: 25%;">
<col span="1" style="width: 25%;">
<col span="1" style="width: 25%;">
<col span="1" style="width: 25%;">
</colgroup>
<thead class="thead-dark">
<tr>
<th scope="col">{% trans "Email" %}</th>
<th scope="col">{% trans "Domain" %}</th>
<th scope="col">{% trans "Mailboxes" %}</th>
<th scope="col">{% trans "Forward" %}</th>
</tr>
</thead>
<tbody>
{% for obj in object_list %}
<tr>
<td><a href="{% url 'musician:address-update' obj.id %}">{{ obj.full_address_name }}</a></td>
<td>{{ obj.domain.name }}</td>
<td>
{% for mailbox in obj.mailboxes %}
<a href="{% url 'musician:mailbox-update' mailbox.id %}">{{ mailbox.name }}</a>
{% if not forloop.last %}<br/> {% endif %}
{% endfor %}
</td>
<td>{{ obj.forward }}</td>
</tr>
{% endfor %}
</tbody>
{% include "musician/components/table_paginator.html" %}
</table>
<a class="btn btn-primary mt-4 mb-4" href="{% url 'musician:address-create' %}">{% trans "New mail address" %}</a>
</div>
{% endblock %}

View file

@ -82,6 +82,16 @@
{% endblock sidebar %}
</nav><!-- ./sidebar -->
<div id="content" class="container-fluid pt-4">
{% block messages %}
{% for message in messages %}
<div class="alert alert-{{ message.tags|default:'info' }} alert-dismissible fade show" role="alert">
{{ message }}
<button type="button" class="close" data-dismiss="alert" aria-label="Close">
<span aria-hidden="true">&times;</span>
</button>
</div>
{% endfor %}
{% endblock messages %}
{% block content %}
{% endblock content %}
</div><!-- ./content -->

View file

@ -16,6 +16,11 @@
<div class="card-body">
<h5 class="card-title">{{ usage.verbose_name }}</h5>
{% include "musician/components/usage_progress_bar.html" with detail=usage.data %}
{% if usage.data.alert %}
<div class="text-center mt-4">
{{ usage.data.alert }}
</div>
{% endif %}
</div>
</div>
{% endfor %}
@ -65,13 +70,9 @@
<h4>{% trans "Mail" %}</h4>
<p class="card-text"><i class="fas fa-envelope fa-3x"></i></p>
<p class="card-text text-dark">
{{ domain.mails|length }} {% trans "mail addresses created" %}
{% if domain.addresses_left.alert_level %}
<br/>
<span class="text-{{ domain.addresses_left.alert_level }}">{{ domain.addresses_left.count }} {% trans "mail address left" %}</span>
{% endif %}
{{ domain.addresses|length }} {% trans "mail addresses created" %}
</p>
<a class="stretched-link" href="{% url 'musician:mails' %}?domain={{ domain.id }}"></a>
<a class="stretched-link" href="{% url 'musician:address-list' %}?domain={{ domain.id }}"></a>
</div>
<div class="col-md-2 border-right">
<h4>{% trans "Mail list" %}</h4>
@ -82,7 +83,7 @@
<h4>{% trans "Software as a Service" %}</h4>
<p class="card-text"><i class="fas fa-fire fa-3x"></i></p>
<p class="card-text text-dark">{% trans "Nothing installed" %}</p>
<a class="stretched-link" href="{% url 'musician:saas' %}?domain={{ domain.id }}"></a>
<a class="stretched-link" href="{% url 'musician:saas-list' %}?domain={{ domain.id }}"></a>
</div>
<div class="col-md-1"></div>
<div class="col-md-4">

View file

@ -1,44 +0,0 @@
{% extends "musician/base.html" %}
{% load i18n %}
{% block content %}
{% if active_domain %}
<a class="btn-arrow-left" href="{% url 'musician:mails' %}">{% trans "Go to global" %}</a>
{% endif %}
<h1 class="service-name">{{ service.verbose_name }}{% if active_domain %} <span class="font-weight-light">{% trans "for" %} {{ active_domain.name }}</span>{% endif %}</h1>
<p class="service-description">{{ service.description }}</p>
<table class="table service-list">
<colgroup>
<col span="1" style="width: 25%;">
<col span="1" style="width: 50%;">
<col span="1" style="width: 5%;">
<col span="1" style="width: 20%;">
</colgroup>
<thead class="thead-dark">
<tr>
<th scope="col">{% trans "Mail address" %}</th>
<th scope="col">{% trans "Aliases" %}</th>
<th scope="col">{% trans "Type" %}</th>
<th scope="col">{% trans "Type details" %}</th>
</tr>
</thead>
<tbody>
{% for obj in object_list %}
<tr>
<td>{{ obj.mail_address }}</td>
<td>{{ obj.aliases|join:" , " }}</td>
<td>{{ obj.type|capfirst }}</td>
<td>
{% if obj.type == 'mailbox' %}
{% include "musician/components/usage_progress_bar.html" with detail=obj.type_detail %}
{% else %}
{{ obj.type_detail }}
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
{% include "musician/components/table_paginator.html" %}
</table>
{% endblock %}

View file

@ -0,0 +1,32 @@
{% extends "musician/base.html" %}
{% load i18n %}
{% block content %}
{% if active_domain %}
<a class="btn-arrow-left" href="{% url 'musician:address-list' %}">{% trans "Go to global" %}</a>
{% endif %}
<h1 class="service-name">{{ service.verbose_name }}
{% if active_domain %}<span class="font-weight-light">{% trans "for" %} {{ active_domain.name }}</span>{% endif %}
</h1>
<p class="service-description">{{ service.description }}</p>
{% with request.resolver_match.url_name as url_name %}
<ul class="nav nav-tabs" id="myTab" role="tablist">
<li class="nav-item">
<a class="nav-link {% if url_name == 'address-list' %}active{% endif %}" href="{% url 'musician:address-list' %}" role="tab"
aria-selected="{% if url_name == 'address-list' %}true{% else %}false{% endif %}">{% trans "Addresses" %}</a>
</li>
<li class="nav-item">
<a class="nav-link {% if url_name == 'mailbox-list' %}active{% endif %}" href="{% url 'musician:mailbox-list' %}" role="tab"
aria-selected="{% if url_name == 'mailbox-list' %}true{% else %}false{% endif %}">{% trans "Mailboxes" %}</a>
</li>
</ul>
{% endwith %}
<div class="tab-content" id="myTabContent">
{% block tabcontent %}
{% endblock %}
{% endblock %}

View file

@ -0,0 +1,15 @@
{% extends "musician/base.html" %}
{% load bootstrap4 i18n %}
{% block content %}
<h1 class="service-name">{% trans "Change password" %}: <span class="font-weight-light">{{ object.name }}</span></h1>
<form method="post">
{% csrf_token %}
{% bootstrap_form form %}
{% buttons %}
<a class="btn btn-light mr-2" href="{% url 'musician:mailbox-list' %}">{% trans "Cancel" %}</a>
<button type="submit" class="btn btn-secondary">{% trans "Save" %}</button>
{% endbuttons %}
</form>
{% endblock %}

View file

@ -0,0 +1,15 @@
{% extends "musician/base.html" %}
{% load i18n %}
{% block content %}
<form method="post">
{% csrf_token %}
<p>{% blocktrans with name=object.name %}Are you sure that you want remove the mailbox: "{{ name }}"?{% endblocktrans %}</p>
<div class="alert alert-danger" role="alert">
{% trans "All mailbox's messages will be <strong>deleted and cannot be recovered</strong>." %}
</div>
<p class="alert alert-warning"><strong>{% trans 'WARNING: This action cannot be undone.' %}</strong></p>
<input class="btn btn-danger" type="submit" value="{% trans 'Delete' %}">
<a class="btn btn-secondary" href="{% url 'musician:mailbox-list' %}">{% trans 'Cancel' %}</a>
</form>
{% endblock %}

View file

@ -0,0 +1,30 @@
{% extends "musician/base.html" %}
{% load bootstrap4 i18n %}
{% block content %}
<h1 class="service-name">{{ service.verbose_name }}</h1>
{% if extra_mailbox %}
<div class="alert alert-warning alert-dismissible fade show" role="alert">
<strong>{% trans "Warning!" %}</strong> {% trans "You have reached the limit of mailboxes of your subscription so <strong>extra fees</strong> may apply." %}
<button type="button" class="close" data-dismiss="alert" aria-label="{% trans 'Close' %}">
<span aria-hidden="true">&times;</span>
</button>
</div>
{% endif %}
<form method="post">
{% csrf_token %}
{% bootstrap_form form %}
{% buttons %}
<a class="btn btn-light mr-2" href="{% url 'musician:mailbox-list' %}">{% trans "Cancel" %}</a>
<button type="submit" class="btn btn-secondary">{% trans "Save" %}</button>
{% if form.instance %}
<div class="float-right">
<a class="btn btn-outline-warning" href="{% url 'musician:mailbox-password' view.kwargs.pk %}"><i class="fas fa-key"></i> {% trans "Change password" %}</a>
<a class="btn btn-danger" href="{% url 'musician:mailbox-delete' view.kwargs.pk %}">{% trans "Delete" %}</a>
</div>
{% endif %}
{% endbuttons %}
</form>
{% endblock %}

View file

@ -0,0 +1,46 @@
{% extends "musician/mail_base.html" %}
{% load i18n %}
{% block tabcontent %}
<div class="tab-pane fade show active" id="mailboxes" role="tabpanel" aria-labelledby="mailboxes-tab">
<table class="table service-list">
<colgroup>
<col span="1" style="width: 25%;">
<col span="1" style="width: 10%;">
<col span="1" style="width: 65%;">
</colgroup>
<thead class="thead-dark">
<tr>
<th scope="col">{% trans "Name" %}</th>
<th scope="col">{% trans "Filtering" %}</th>
<th scope="col">{% trans "Addresses" %}</th>
</tr>
</thead>
<tbody>
{% for mailbox in object_list %}
{# <!-- Exclude (don't render) inactive mailboxes -->#}
{% if mailbox.is_active %}
<tr>
<td>
<a href="{% url 'musician:mailbox-update' mailbox.id %}">{{ mailbox.name }}</a>
<a class="roll-hover btn btn-outline-warning" href="{% url 'musician:mailbox-password' mailbox.id %}">
<i class="fas fa-key"></i> {% trans "Update password" %}</a>
</td>
<td>{{ mailbox.filtering }}</td>
<td>
{% for addr in mailbox.addresses %}
<a href="{% url 'musician:address-update' addr.data.id %}">
{{ addr.full_address_name }}
</a><br/>
{% endfor %}
</td>
</tr>
{% endif %}{# <!-- /is_active --> #}
{% endfor %}
</tbody>
{% include "musician/components/table_paginator.html" %}
</table>
<a class="btn btn-primary mt-4 mb-4" href="{% url 'musician:mailbox-create' %}">{% trans "New mailbox" %}</a>
</div>
{% endblock %}

View file

@ -1,9 +1,37 @@
from django.test import TestCase
from .models import UserAccount
from .models import DatabaseService, UserAccount
from .utils import get_bootstraped_percent
class DatabaseTest(TestCase):
def test_database_from_json(self):
data = {
"url": "https://example.org/api/databases/1/",
"id": 1,
"name": "bluebird",
"type": "mysql",
"users": [
{
"url": "https://example.org/api/databaseusers/2/",
"id": 2,
"username": "bluebird"
}
],
"resources": [
{
"name": "disk",
"used": "1.798",
"allocated": None,
"unit": "MiB"
}
]
}
database = DatabaseService.new_from_json(data)
self.assertEqual(0, database.usage['percent'])
class DomainsTestCase(TestCase):
def test_domain_not_found(self):
response = self.client.post(
@ -118,3 +146,8 @@ class GetBootstrapedPercentTest(TestCase):
def test_invalid_total_is_zero(self):
value = get_bootstraped_percent(25, 0)
self.assertEqual(value, 0)
def test_invalid_total_is_none(self):
value = get_bootstraped_percent(25, None)
self.assertEqual(value, 0)

View file

@ -16,11 +16,19 @@ urlpatterns = [
path('auth/logout/', views.LogoutView.as_view(), name='logout'),
path('dashboard/', views.DashboardView.as_view(), name='dashboard'),
path('domains/<int:pk>/', views.DomainDetailView.as_view(), name='domain-detail'),
path('bills/', views.BillingView.as_view(), name='billing'),
path('billing/', views.BillingView.as_view(), name='billing'),
path('bills/<int:pk>/download/', views.BillDownloadView.as_view(), name='bill-download'),
path('profile/', views.ProfileView.as_view(), name='profile'),
path('mails/', views.MailView.as_view(), name='mails'),
path('address/', views.MailView.as_view(), name='address-list'),
path('address/new/', views.MailCreateView.as_view(), name='address-create'),
path('address/<int:pk>/', views.MailUpdateView.as_view(), name='address-update'),
path('address/<int:pk>/delete/', views.AddressDeleteView.as_view(), name='address-delete'),
path('mailboxes/', views.MailboxesView.as_view(), name='mailbox-list'),
path('mailboxes/new/', views.MailboxCreateView.as_view(), name='mailbox-create'),
path('mailboxes/<int:pk>/', views.MailboxUpdateView.as_view(), name='mailbox-update'),
path('mailboxes/<int:pk>/delete/', views.MailboxDeleteView.as_view(), name='mailbox-delete'),
path('mailboxes/<int:pk>/change-password/', views.MailboxChangePasswordView.as_view(), name='mailbox-password'),
path('mailing-lists/', views.MailingListsView.as_view(), name='mailing-lists'),
path('databases/', views.DatabasesView.as_view(), name='databases'),
path('software-as-a-service/', views.SaasView.as_view(), name='saas'),
path('databases/', views.DatabasesView.as_view(), name='database-list'),
path('saas/', views.SaasView.as_view(), name='saas-list'),
]

View file

@ -6,7 +6,7 @@ def get_bootstraped_percent(value, total):
"""
try:
percent = value / total
except ZeroDivisionError:
except (TypeError, ZeroDivisionError):
return 0
bootstraped = round(percent * 4) * 100 // 4

View file

@ -1,28 +1,38 @@
import logging
from os import stat
import smtplib
from django.conf import settings
from django.contrib import messages
from django.core.exceptions import ImproperlyConfigured
from django.core.mail import mail_managers
from django.http import HttpResponse, HttpResponseRedirect
from django.shortcuts import render
from django.urls import reverse_lazy
from django.utils import translation
from django.utils.html import format_html
from django.utils.http import is_safe_url
from django.utils.translation import gettext_lazy as _
from django.views import View
from django.views.generic.base import RedirectView, TemplateView
from django.views.generic.detail import DetailView
from django.views.generic.edit import FormView
from django.views.generic.edit import DeleteView, FormView
from django.views.generic.list import ListView
from requests.exceptions import HTTPError
from . import api, get_version
from .auth import login as auth_login
from .auth import logout as auth_logout
from .forms import LoginForm
from .forms import LoginForm, MailboxChangePasswordForm, MailboxCreateForm, MailboxUpdateForm, MailForm
from .mixins import (CustomContextMixin, ExtendedPaginationMixin,
UserTokenRequiredMixin)
from .models import (Bill, DatabaseService, MailinglistService, MailService,
PaymentSource, SaasService, UserAccount)
from .models import (Address, Bill, DatabaseService, Mailbox,
MailinglistService, PaymentSource, SaasService)
from .settings import ALLOWED_RESOURCES
from .utils import get_bootstraped_percent
logger = logging.getLogger(__name__)
class DashboardView(CustomContextMixin, UserTokenRequiredMixin, TemplateView):
template_name = "musician/dashboard.html"
@ -40,20 +50,6 @@ class DashboardView(CustomContextMixin, UserTokenRequiredMixin, TemplateView):
# show resource usage based on plan definition
profile_type = context['profile'].type
total_mailboxes = 0
for domain in domains:
total_mailboxes += len(domain.mails)
addresses_left = ALLOWED_RESOURCES[profile_type]['mailbox'] - len(domain.mails)
alert_level = None
if addresses_left == 1:
alert_level = 'warning'
elif addresses_left < 1:
alert_level = 'danger'
domain.addresses_left = {
'count': addresses_left,
'alert_level': alert_level,
}
# TODO(@slamora) update when backend provides resource usage data
resource_usage = {
@ -75,15 +71,7 @@ class DashboardView(CustomContextMixin, UserTokenRequiredMixin, TemplateView):
# 'percent': 25,
},
},
'mailbox': {
'verbose_name': _('Mailbox usage'),
'data': {
'usage': total_mailboxes,
'total': ALLOWED_RESOURCES[profile_type]['mailbox'],
'unit': 'accounts',
'percent': get_bootstraped_percent(total_mailboxes, ALLOWED_RESOURCES[profile_type]['mailbox']),
},
},
'mailbox': self.get_mailbox_usage(profile_type),
}
context.update({
@ -94,6 +82,28 @@ class DashboardView(CustomContextMixin, UserTokenRequiredMixin, TemplateView):
return context
def get_mailbox_usage(self, profile_type):
allowed_mailboxes = ALLOWED_RESOURCES[profile_type]['mailbox']
total_mailboxes = len(self.orchestra.retrieve_mailbox_list())
mailboxes_left = allowed_mailboxes - total_mailboxes
alert = ''
if mailboxes_left < 0:
alert = format_html("<span class='text-danger'>{} extra mailboxes</span>", mailboxes_left * -1)
elif mailboxes_left <= 1:
alert = format_html("<span class='text-warning'>{} mailbox left</span>", mailboxes_left)
return {
'verbose_name': _('Mailbox usage'),
'data': {
'usage': total_mailboxes,
'total': allowed_mailboxes,
'alert': alert,
'unit': 'mailboxes',
'percent': get_bootstraped_percent(total_mailboxes, allowed_mailboxes),
},
}
class ProfileView(CustomContextMixin, UserTokenRequiredMixin, TemplateView):
template_name = "musician/profile.html"
@ -168,8 +178,8 @@ class BillDownloadView(CustomContextMixin, UserTokenRequiredMixin, View):
class MailView(ServiceListView):
service_class = MailService
template_name = "musician/mail.html"
service_class = Address
template_name = "musician/addresses.html"
extra_context = {
# Translators: This message appears on the page title
'title': _('Mail addresses'),
@ -198,9 +208,86 @@ class MailView(ServiceListView):
context.update({
'active_domain': self.orchestra.retrieve_domain(domain_id)
})
context['mailboxes'] = self.orchestra.retrieve_mailbox_list()
return context
class MailCreateView(CustomContextMixin, UserTokenRequiredMixin, FormView):
service_class = Address
template_name = "musician/address_form.html"
form_class = MailForm
success_url = reverse_lazy("musician:address-list")
extra_context = {'service': service_class}
def get_form_kwargs(self):
kwargs = super().get_form_kwargs()
kwargs['domains'] = self.orchestra.retrieve_domain_list()
kwargs['mailboxes'] = self.orchestra.retrieve_mailbox_list()
return kwargs
def form_valid(self, form):
# handle request errors e.g. 400 validation
try:
serialized_data = form.serialize()
self.orchestra.create_mail_address(serialized_data)
except HTTPError as e:
form.add_error(field='__all__', error=e)
return self.form_invalid(form)
return super().form_valid(form)
class MailUpdateView(CustomContextMixin, UserTokenRequiredMixin, FormView):
service_class = Address
template_name = "musician/address_form.html"
form_class = MailForm
success_url = reverse_lazy("musician:address-list")
extra_context = {'service': service_class}
def get_form_kwargs(self):
kwargs = super().get_form_kwargs()
instance = self.orchestra.retrieve_mail_address(self.kwargs['pk'])
kwargs.update({
'instance': instance,
'domains': self.orchestra.retrieve_domain_list(),
'mailboxes': self.orchestra.retrieve_mailbox_list(),
})
return kwargs
def form_valid(self, form):
# handle request errors e.g. 400 validation
try:
serialized_data = form.serialize()
self.orchestra.update_mail_address(self.kwargs['pk'], serialized_data)
except HTTPError as e:
form.add_error(field='__all__', error=e)
return self.form_invalid(form)
return super().form_valid(form)
class AddressDeleteView(CustomContextMixin, UserTokenRequiredMixin, DeleteView):
template_name = "musician/address_check_delete.html"
success_url = reverse_lazy("musician:address-list")
def get_object(self, queryset=None):
obj = self.orchestra.retrieve_mail_address(self.kwargs['pk'])
return obj
def delete(self, request, *args, **kwargs):
self.object = self.get_object()
try:
self.orchestra.delete_mail_address(self.object.id)
messages.success(self.request, _('Address deleted!'))
except HTTPError as e:
messages.error(self.request, _('Cannot process your request, please try again later.'))
logger.error(e)
return HttpResponseRedirect(self.success_url)
class MailingListsView(ServiceListView):
service_class = MailinglistService
template_name = "musician/mailinglists.html"
@ -230,6 +317,161 @@ class MailingListsView(ServiceListView):
return ''
class MailboxesView(ServiceListView):
service_class = Mailbox
template_name = "musician/mailboxes.html"
extra_context = {
# Translators: This message appears on the page title
'title': _('Mailboxes'),
}
class MailboxCreateView(CustomContextMixin, UserTokenRequiredMixin, FormView):
service_class = Mailbox
template_name = "musician/mailbox_form.html"
form_class = MailboxCreateForm
success_url = reverse_lazy("musician:mailbox-list")
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context.update({
'extra_mailbox': self.is_extra_mailbox(context['profile']),
'service': self.service_class,
})
return context
def is_extra_mailbox(self, profile):
number_of_mailboxes = len(self.orchestra.retrieve_mailbox_list())
return number_of_mailboxes >= profile.allowed_resources('mailbox')
def get_form_kwargs(self):
kwargs = super().get_form_kwargs()
kwargs.update({
'addresses': self.orchestra.retrieve_mail_address_list(),
})
return kwargs
def form_valid(self, form):
serialized_data = form.serialize()
status, response = self.orchestra.create_mailbox(serialized_data)
if status >= 400:
if status == 400:
# handle errors & add to form (they will be rendered)
form.add_error(field=None, error=response)
else:
logger.error("{}: {}".format(status, response[:120]))
msg = "Sorry, an error occurred while processing your request ({})".format(status)
form.add_error(field='__all__', error=msg)
return self.form_invalid(form)
return super().form_valid(form)
class MailboxUpdateView(CustomContextMixin, UserTokenRequiredMixin, FormView):
service_class = Mailbox
template_name = "musician/mailbox_form.html"
form_class = MailboxUpdateForm
success_url = reverse_lazy("musician:mailbox-list")
extra_context = {'service': service_class}
def get_form_kwargs(self):
kwargs = super().get_form_kwargs()
instance = self.orchestra.retrieve_mailbox(self.kwargs['pk'])
kwargs.update({
'instance': instance,
'addresses': self.orchestra.retrieve_mail_address_list(),
})
return kwargs
def form_valid(self, form):
serialized_data = form.serialize()
status, response = self.orchestra.update_mailbox(self.kwargs['pk'], serialized_data)
if status >= 400:
if status == 400:
# handle errors & add to form (they will be rendered)
form.add_error(field=None, error=response)
else:
logger.error("{}: {}".format(status, response[:120]))
msg = "Sorry, an error occurred while processing your request ({})".format(status)
form.add_error(field='__all__', error=msg)
return self.form_invalid(form)
return super().form_valid(form)
class MailboxDeleteView(CustomContextMixin, UserTokenRequiredMixin, DeleteView):
template_name = "musician/mailbox_check_delete.html"
success_url = reverse_lazy("musician:mailbox-list")
def get_object(self, queryset=None):
obj = self.orchestra.retrieve_mailbox(self.kwargs['pk'])
return obj
def delete(self, request, *args, **kwargs):
self.object = self.get_object()
try:
self.orchestra.delete_mailbox(self.object.id)
messages.success(self.request, _('Mailbox deleted!'))
except HTTPError as e:
messages.error(self.request, _('Cannot process your request, please try again later.'))
logger.error(e)
self.notify_managers(self.object)
return HttpResponseRedirect(self.success_url)
def notify_managers(self, mailbox):
user = self.get_context_data()['profile']
subject = 'Mailbox {} ({}) deleted | Musician'.format(mailbox.id, mailbox.name)
content = (
"User {} ({}) has deleted its mailbox {} ({}) via musician.\n"
"The mailbox has been marked as inactive but has not been removed."
).format(user.username, user.full_name, mailbox.id, mailbox.name)
try:
mail_managers(subject, content, fail_silently=False)
except (smtplib.SMTPException, ConnectionRefusedError):
logger.error("Error sending email to managers", exc_info=True)
class MailboxChangePasswordView(CustomContextMixin, UserTokenRequiredMixin, FormView):
template_name = "musician/mailbox_change_password.html"
form_class = MailboxChangePasswordForm
success_url = reverse_lazy("musician:mailbox-list")
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
self.object = self.get_object()
context.update({
'object': self.object,
})
return context
def get_object(self, queryset=None):
obj = self.orchestra.retrieve_mailbox(self.kwargs['pk'])
return obj
def form_valid(self, form):
data = {
'password': form.cleaned_data['password2']
}
status, response = self.orchestra.set_password_mailbox(self.kwargs['pk'], data)
if status < 400:
messages.success(self.request, _('Password updated!'))
else:
messages.error(self.request, _('Cannot process your request, please try again later.'))
logger.error("{}: {}".format(status, str(response)[:100]))
return super().form_valid(form)
class DatabasesView(ServiceListView):
template_name = "musician/databases.html"
service_class = DatabaseService

View file

@ -1,5 +1,6 @@
django==2.2.24
python-decouple==3.1
django-bootstrap4
django-extensions
dj_database_url==0.5.0
requests==2.22.0

View file

@ -13,6 +13,7 @@ https://docs.djangoproject.com/en/2.2/ref/settings/
import os
from decouple import config, Csv
from django.contrib.messages import constants as messages
from django.utils.translation import gettext_lazy as _
from dj_database_url import parse as db_url
@ -41,6 +42,8 @@ EMAIL_HOST_PASSWORD = config('EMAIL_HOST_PASSWORD', default='')
EMAIL_PORT = config('EMAIL_PORT', default=25, cast=int)
EMAIL_BACKEND = 'django.core.mail.backends.smtp.EmailBackend'
ALLOWED_HOSTS = config('ALLOWED_HOSTS', default=[], cast=Csv())
@ -53,6 +56,7 @@ INSTALLED_APPS = [
'django.contrib.messages',
'django.contrib.staticfiles',
'django_extensions',
'bootstrap4',
'musician',
]
@ -148,12 +152,6 @@ USE_L10N = True
USE_TZ = True
LANGUAGES = (
('ca', _('Catalan')),
('es', _('Spanish')),
('en', _('English')),
)
# Static files (CSS, JavaScript, Images)
# https://docs.djangoproject.com/en/2.2/howto/static-files/
@ -176,3 +174,18 @@ URL_SAAS_GITLAB = config('URL_SAAS_GITLAB', None)
URL_SAAS_OWNCLOUD = config('URL_SAAS_OWNCLOUD', None)
URL_SAAS_WORDPRESS = config('URL_SAAS_WORDPRESS', None)
# Managers: who should get notifications about services changes that
# may require human actions (e.g. deleted mailboxes)
MANAGERS = []
# redefine MESSAGE_TAGS for a better integration with bootstrap
MESSAGE_TAGS = {
messages.DEBUG: 'debug',
messages.INFO: 'info',
messages.SUCCESS: 'success',
messages.WARNING: 'warning',
messages.ERROR: 'danger',
}