diff --git a/musician/api.py b/musician/api.py index 7bb6ee0..6b60c0c 100644 --- a/musician/api.py +++ b/musician/api.py @@ -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']) diff --git a/musician/forms.py b/musician/forms.py index 7a66a00..98e23c4 100644 --- a/musician/forms.py +++ b/musician/forms.py @@ -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 didn’t 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 didn’t 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 diff --git a/musician/locale/ca/LC_MESSAGES/django.po b/musician/locale/ca/LC_MESSAGES/django.po index e5e7bb4..50660b4 100644 --- a/musician/locale/ca/LC_MESSAGES/django.po +++ b/musician/locale/ca/LC_MESSAGES/django.po @@ -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 didn’t 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 %(support_email)s " "indicant el teu nom d’usuari/a i t’explicarem 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 d’on 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 deleted and cannot be recovered" +"." +msgstr "" +"Tots els missatges s'esborraran i no es podran recuperar" + +#: 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 " +"extra fees may apply." +msgstr "" +"Has assolit el llímit de bústies de correu de la teva suscripció, les noves " +"bústies poden implicar costos adicionals." + +#: 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 d’accés i opcions de perfil des d’aquí." @@ -357,43 +466,46 @@ msgid "Open service admin panel" msgstr "Obre el panell d’administració 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 d’espai 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 d’usuari/a" - -#~ msgid "Password:" -#~ msgstr "Contrasenya:" diff --git a/musician/locale/es/LC_MESSAGES/django.po b/musician/locale/es/LC_MESSAGES/django.po index 7ff015b..e58aa03 100644 --- a/musician/locale/es/LC_MESSAGES/django.po +++ b/musician/locale/es/LC_MESSAGES/django.po @@ -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 didn’t 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 %(support_email)s " "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 %(support_email)s " -"para saber cómo acceder al FTP." +"Contactadnos a %(support_email)s 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 deleted and cannot be recovered." +msgstr "" +"Todos los mensajes se borrarán y no se podrán recuperar" + +#: 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 " +"extra fees 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:" diff --git a/musician/mixins.py b/musician/mixins.py index 3cc463a..ba54587 100644 --- a/musician/mixins.py +++ b/musician/mixins.py @@ -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, diff --git a/musician/models.py b/musician/models.py index 3239829..8aa4e77 100644 --- a/musician/models.py +++ b/musician/models.py @@ -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): diff --git a/musician/static/musician/css/default.css b/musician/static/musician/css/default.css index 3fbe7da..0bbfe4b 100644 --- a/musician/static/musician/css/default.css +++ b/musician/static/musician/css/default.css @@ -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 { - 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; +} diff --git a/musician/templates/musician/address_check_delete.html b/musician/templates/musician/address_check_delete.html new file mode 100644 index 0000000..27981d4 --- /dev/null +++ b/musician/templates/musician/address_check_delete.html @@ -0,0 +1,12 @@ +{% extends "musician/base.html" %} +{% load i18n %} + +{% block content %} +
+ {% csrf_token %} +

{% blocktrans with address_name=object.full_address_name %}Are you sure that you want remove the address: "{{ address_name }}"?{% endblocktrans %}

+

{% trans 'WARNING: This action cannot be undone.' %}

+ + {% trans 'Cancel' %} +
+{% endblock %} diff --git a/musician/templates/musician/address_form.html b/musician/templates/musician/address_form.html new file mode 100644 index 0000000..de21067 --- /dev/null +++ b/musician/templates/musician/address_form.html @@ -0,0 +1,20 @@ +{% extends "musician/base.html" %} +{% load bootstrap4 i18n %} + +{% block content %} +

{{ service.verbose_name }}

+ +
+ {% csrf_token %} + {% bootstrap_form form %} + {% buttons %} + {% trans "Cancel" %} + + {% if form.instance %} + + {% endif %} + {% endbuttons %} +
+{% endblock %} diff --git a/musician/templates/musician/addresses.html b/musician/templates/musician/addresses.html new file mode 100644 index 0000000..1ebc8b7 --- /dev/null +++ b/musician/templates/musician/addresses.html @@ -0,0 +1,41 @@ +{% extends "musician/mail_base.html" %} +{% load i18n %} + +{% block tabcontent %} +
+ + + + + + + + + + + + + + + + + {% for obj in object_list %} + + + + + + + {% endfor %} + + {% include "musician/components/table_paginator.html" %} +
{% trans "Email" %}{% trans "Domain" %}{% trans "Mailboxes" %}{% trans "Forward" %}
{{ obj.full_address_name }}{{ obj.domain.name }} + {% for mailbox in obj.mailboxes %} + {{ mailbox.name }} + {% if not forloop.last %}
{% endif %} + {% endfor %} +
{{ obj.forward }}
+ {% trans "New mail address" %} + +
+{% endblock %} diff --git a/musician/templates/musician/base.html b/musician/templates/musician/base.html index 27a1eb8..7b56ab9 100644 --- a/musician/templates/musician/base.html +++ b/musician/templates/musician/base.html @@ -82,6 +82,16 @@ {% endblock sidebar %}
+ {% block messages %} + {% for message in messages %} + + {% endfor %} + {% endblock messages %} {% block content %} {% endblock content %}
diff --git a/musician/templates/musician/dashboard.html b/musician/templates/musician/dashboard.html index addca3d..591b26e 100644 --- a/musician/templates/musician/dashboard.html +++ b/musician/templates/musician/dashboard.html @@ -16,6 +16,11 @@
{{ usage.verbose_name }}
{% include "musician/components/usage_progress_bar.html" with detail=usage.data %} + {% if usage.data.alert %} +
+ {{ usage.data.alert }} +
+ {% endif %}
{% endfor %} @@ -65,13 +70,9 @@

{% trans "Mail" %}

- {{ domain.mails|length }} {% trans "mail addresses created" %} - {% if domain.addresses_left.alert_level %} -
- {{ domain.addresses_left.count }} {% trans "mail address left" %} - {% endif %} + {{ domain.addresses|length }} {% trans "mail addresses created" %}

- +

{% trans "Mail list" %}

@@ -82,7 +83,7 @@

{% trans "Software as a Service" %}

{% trans "Nothing installed" %}

- +
diff --git a/musician/templates/musician/mail.html b/musician/templates/musician/mail.html deleted file mode 100644 index e8ae8be..0000000 --- a/musician/templates/musician/mail.html +++ /dev/null @@ -1,44 +0,0 @@ -{% extends "musician/base.html" %} -{% load i18n %} - -{% block content %} -{% if active_domain %} -{% trans "Go to global" %} -{% endif %} - -

{{ service.verbose_name }}{% if active_domain %} {% trans "for" %} {{ active_domain.name }}{% endif %}

-

{{ service.description }}

- - - - - - - - - - - - - - - - - {% for obj in object_list %} - - - - - - - {% endfor %} - - {% include "musician/components/table_paginator.html" %} -
{% trans "Mail address" %}{% trans "Aliases" %}{% trans "Type" %}{% trans "Type details" %}
{{ obj.mail_address }}{{ obj.aliases|join:" , " }}{{ obj.type|capfirst }} - {% if obj.type == 'mailbox' %} - {% include "musician/components/usage_progress_bar.html" with detail=obj.type_detail %} - {% else %} - {{ obj.type_detail }} - {% endif %} -
-{% endblock %} diff --git a/musician/templates/musician/mail_base.html b/musician/templates/musician/mail_base.html new file mode 100644 index 0000000..9445f7f --- /dev/null +++ b/musician/templates/musician/mail_base.html @@ -0,0 +1,32 @@ +{% extends "musician/base.html" %} +{% load i18n %} + +{% block content %} +{% if active_domain %} +{% trans "Go to global" %} +{% endif %} + +

{{ service.verbose_name }} + {% if active_domain %}{% trans "for" %} {{ active_domain.name }}{% endif %} +

+

{{ service.description }}

+ +{% with request.resolver_match.url_name as url_name %} + + +{% endwith %} + +
+ {% block tabcontent %} + {% endblock %} + +{% endblock %} diff --git a/musician/templates/musician/mailbox_change_password.html b/musician/templates/musician/mailbox_change_password.html new file mode 100644 index 0000000..e18b95a --- /dev/null +++ b/musician/templates/musician/mailbox_change_password.html @@ -0,0 +1,15 @@ +{% extends "musician/base.html" %} +{% load bootstrap4 i18n %} + +{% block content %} +

{% trans "Change password" %}: {{ object.name }}

+ +
+ {% csrf_token %} + {% bootstrap_form form %} + {% buttons %} + {% trans "Cancel" %} + + {% endbuttons %} +
+{% endblock %} diff --git a/musician/templates/musician/mailbox_check_delete.html b/musician/templates/musician/mailbox_check_delete.html new file mode 100644 index 0000000..18b9249 --- /dev/null +++ b/musician/templates/musician/mailbox_check_delete.html @@ -0,0 +1,15 @@ +{% extends "musician/base.html" %} +{% load i18n %} + +{% block content %} +
+ {% csrf_token %} +

{% blocktrans with name=object.name %}Are you sure that you want remove the mailbox: "{{ name }}"?{% endblocktrans %}

+ +

{% trans 'WARNING: This action cannot be undone.' %}

+ + {% trans 'Cancel' %} +
+{% endblock %} diff --git a/musician/templates/musician/mailbox_form.html b/musician/templates/musician/mailbox_form.html new file mode 100644 index 0000000..5fb9465 --- /dev/null +++ b/musician/templates/musician/mailbox_form.html @@ -0,0 +1,30 @@ +{% extends "musician/base.html" %} +{% load bootstrap4 i18n %} + +{% block content %} +

{{ service.verbose_name }}

+ +{% if extra_mailbox %} + +{% endif %} + +
+ {% csrf_token %} + {% bootstrap_form form %} + {% buttons %} + {% trans "Cancel" %} + + {% if form.instance %} + + {% endif %} + {% endbuttons %} +
+{% endblock %} diff --git a/musician/templates/musician/mailboxes.html b/musician/templates/musician/mailboxes.html new file mode 100644 index 0000000..a9f3700 --- /dev/null +++ b/musician/templates/musician/mailboxes.html @@ -0,0 +1,46 @@ +{% extends "musician/mail_base.html" %} +{% load i18n %} + +{% block tabcontent %} +
+ + + + + + + + + + + + + + + {% for mailbox in object_list %} + {# #} + {% if mailbox.is_active %} + + + + + + {% endif %}{# #} + {% endfor %} + + {% include "musician/components/table_paginator.html" %} +
{% trans "Name" %}{% trans "Filtering" %}{% trans "Addresses" %}
+ {{ mailbox.name }} + + {% trans "Update password" %} + {{ mailbox.filtering }} + {% for addr in mailbox.addresses %} + + {{ addr.full_address_name }} +
+ {% endfor %} +
+ {% trans "New mailbox" %} + +
+{% endblock %} diff --git a/musician/tests.py b/musician/tests.py index 8becf4d..4927c4a 100644 --- a/musician/tests.py +++ b/musician/tests.py @@ -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) diff --git a/musician/urls.py b/musician/urls.py index 9139f35..c4402e2 100644 --- a/musician/urls.py +++ b/musician/urls.py @@ -16,11 +16,19 @@ urlpatterns = [ path('auth/logout/', views.LogoutView.as_view(), name='logout'), path('dashboard/', views.DashboardView.as_view(), name='dashboard'), path('domains//', 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//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//', views.MailUpdateView.as_view(), name='address-update'), + path('address//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//', views.MailboxUpdateView.as_view(), name='mailbox-update'), + path('mailboxes//delete/', views.MailboxDeleteView.as_view(), name='mailbox-delete'), + path('mailboxes//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'), ] diff --git a/musician/utils.py b/musician/utils.py index affc93f..8dea94e 100644 --- a/musician/utils.py +++ b/musician/utils.py @@ -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 diff --git a/musician/views.py b/musician/views.py index b70fbf5..663f498 100644 --- a/musician/views.py +++ b/musician/views.py @@ -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("{} extra mailboxes", mailboxes_left * -1) + elif mailboxes_left <= 1: + alert = format_html("{} mailbox left", 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 diff --git a/requirements.txt b/requirements.txt index d908e8d..c9213f6 100644 --- a/requirements.txt +++ b/requirements.txt @@ -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 diff --git a/userpanel/settings.py b/userpanel/settings.py index 5d0ee92..08121c2 100644 --- a/userpanel/settings.py +++ b/userpanel/settings.py @@ -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', +}