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 %}
-
-{% 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 %}
+
+ {% 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 }}
+
+
+{% 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 %}
+
+{% 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 %}
+
+ {% trans "Warning!" %} {% trans "You have reached the limit of mailboxes of your subscription so extra fees may apply." %}
+
+
+{% endif %}
+
+
+{% 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 %}
+
+{% 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',
+}