Merge branch 'gui-dashboard'

This commit is contained in:
Santiago Lamora 2019-12-12 15:02:41 +01:00
commit 20da66cfec
8 changed files with 356 additions and 34 deletions

View File

@ -4,6 +4,9 @@ import urllib.parse
from django.conf import settings from django.conf import settings
from django.urls.exceptions import NoReverseMatch from django.urls.exceptions import NoReverseMatch
from .models import Domain, DatabaseService, MailService, SaasService, UserAccount
DOMAINS_PATH = 'domains/' DOMAINS_PATH = 'domains/'
TOKEN_PATH = '/api-token-auth/' TOKEN_PATH = '/api-token-auth/'
@ -52,9 +55,11 @@ class Orchestra(object):
return response.json().get("token", None) return response.json().get("token", None)
def request(self, verb, resource, raise_exception=True): def request(self, verb, resource, querystring=None, raise_exception=True):
assert verb in ["HEAD", "GET", "POST", "PATCH", "PUT", "DELETE"] assert verb in ["HEAD", "GET", "POST", "PATCH", "PUT", "DELETE"]
url = self.build_absolute_uri(resource) url = self.build_absolute_uri(resource)
if querystring is not None:
url = "{}?{}".format(url, querystring)
verb = getattr(self.session, verb.lower()) verb = getattr(self.session, verb.lower())
response = verb(url, headers={"Authorization": "Token {}".format( response = verb(url, headers={"Authorization": "Token {}".format(
@ -68,16 +73,45 @@ class Orchestra(object):
return status, output return status, output
def retrieve_service_list(self, service_name): def retrieve_service_list(self, service_name, querystring=None):
pattern_name = '{}-list'.format(service_name) pattern_name = '{}-list'.format(service_name)
if pattern_name not in API_PATHS: if pattern_name not in API_PATHS:
raise ValueError("Unknown service {}".format(service_name)) raise ValueError("Unknown service {}".format(service_name))
_, output = self.request("GET", pattern_name) _, output = self.request("GET", pattern_name, querystring=querystring)
return output return output
def retreve_profile(self): def retrieve_profile(self):
_, output = self.request("GET", 'my-account') status, output = self.request("GET", 'my-account')
return output if status >= 400:
raise PermissionError("Cannot retrieve profile of an anonymous user.")
return UserAccount.new_from_json(output[0])
def retrieve_domain_list(self):
output = self.retrieve_service_list(Domain.api_name)
domains = []
for domain_json in output:
# filter querystring
querystring = "domain={}".format(domain_json['id'])
# retrieve services associated to a domain
domain_json['mails'] = self.retrieve_service_list(
MailService.api_name, querystring)
# TODO(@slamora): databases and sass are not related to a domain, so cannot be filtered
# domain_json['databases'] = self.retrieve_service_list(DatabaseService.api_name, querystring)
# domain_json['saas'] = self.retrieve_service_list(SaasService.api_name, querystring)
# TODO(@slamora): update when backend provides resource disk usage data
domain_json['usage'] = {
'usage': 300,
'total': 650,
'unit': 'MB',
'percent': 50,
}
# append to list a Domain object
domains.append(Domain.new_from_json(domain_json))
return domains
def verify_credentials(self): def verify_credentials(self):
""" """

View File

@ -46,6 +46,11 @@ class ExtendedPaginationMixin:
class UserTokenRequiredMixin(UserPassesTestMixin): class UserTokenRequiredMixin(UserPassesTestMixin):
"""
Checks that the request has a token that authenticates him/her.
If the user is logged adds context variable 'profile' with its information.
"""
def test_func(self): def test_func(self):
"""Check that the user has an authorized token.""" """Check that the user has an authorized token."""
token = self.request.session.get(SESSION_KEY_TOKEN, None) token = self.request.session.get(SESSION_KEY_TOKEN, None)
@ -60,3 +65,10 @@ class UserTokenRequiredMixin(UserPassesTestMixin):
return False return False
return True return True
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context.update({
'profile': self.orchestra.retrieve_profile(),
})
return context

View File

@ -39,6 +39,12 @@ class OrchestraModel:
c.data = data c.data = data
return c return c
def __repr__(self):
return '<%s: %s>' % (self.__class__.__name__, self)
def __str__(self):
return '%s object (%s)' % (self.__class__.__name__, self.id)
class BillingContact(OrchestraModel): class BillingContact(OrchestraModel):
param_defaults = { param_defaults = {
@ -117,6 +123,37 @@ class DatabaseService(OrchestraModel):
return super().new_from_json(data=data, users=users, usage=usage) return super().new_from_json(data=data, users=users, usage=usage)
class Domain(OrchestraModel):
api_name = 'domain'
param_defaults = {
"id": None,
"name": None,
"records": [],
"mails": [],
"usage": {},
}
@classmethod
def new_from_json(cls, data, **kwargs):
records = cls.param_defaults.get("records")
if 'records' in data:
records = [DomainRecord.new_from_json(record_data) for record_data in data['records']]
return super().new_from_json(data=data, records=records)
def __str__(self):
return self.name
class DomainRecord(OrchestraModel):
param_defaults = {
"type": None,
"value": None,
}
def __str__(self):
return '<%s: %s>' % (self.type, self.value)
class MailService(OrchestraModel): class MailService(OrchestraModel):
api_name = 'address' api_name = 'address'
verbose_name = _('Mail addresses') verbose_name = _('Mail addresses')

14
musician/settings.py Normal file
View File

@ -0,0 +1,14 @@
# allowed resources limit hardcoded because cannot be retrieved from the API.
ALLOWED_RESOURCES = {
'INDIVIDUAL':
{
# 'disk': 1024,
# 'traffic': 2048,
'mailbox': 2,
},
'ASSOCIATION': {
# 'disk': 5 * 1024,
# 'traffic': 20 * 1024,
'mailbox': 10,
}
}

View File

@ -172,3 +172,81 @@ h1.service-name {
.service-card .card-body .service-brand i.fab { .service-card .card-body .service-brand i.fab {
color: #9C9AA7; color: #9C9AA7;
} }
.card.resource-usage {
border-left: 5px solid #4C426A;
}
.card.resource-usage .progress {
height: 0.5rem;
margin-top: 0.75rem;
}
.card.resource-usage h5.card-title {
position: relative;
}
.card.resource-usage h5.card-title:after {
font-family: "Font Awesome 5 Free";
font-style: normal;
font-variant: normal;
text-rendering: auto;
-webkit-font-smoothing: antialiased;
position: absolute;
top: 0;
right: 10px;
color: #E8E7EB;
font-size: 2em;
}
.card.resource-usage.resource-disk h5.card-title:after {
content: "\f0a0";
font-weight: 900;
}
.card.resource-usage.resource-traffic h5.card-title:after {
content: "\f362";
font-weight: 900;
}
.card.resource-usage.resource-mailbox h5.card-title:after {
content: "\f0e0";
font-weight: 900;
}
.card.resource-usage.resource-notifications h5.card-title:after {
content: "\f0f3";
font-weight: 900;
}
#configDetailsModal .modal-header {
border-bottom: 0;
text-align: center;
}
#configDetailsModal .modal-header .modal-title {
width: 100%;
}
#configDetailsModal .modal-body {
padding-left: 4rem;
padding-right: 4rem;
}
#configDetailsModal .modal-body label {
width: 50%;
text-align: right;
padding-right: 4%;
}
#configDetailsModal .modal-body span {
display: inline-block;
width: 45%;
}
#configDetailsModal .modal-footer {
border-top: 0;
justify-content: center;
}

View File

@ -59,7 +59,7 @@
<div class="dropdown dropright"> <div class="dropdown dropright">
<button type="button" class="btn btn-primary nav-link text-light w-100" data-toggle="dropdown"> <button type="button" class="btn btn-primary nav-link text-light w-100" data-toggle="dropdown">
<img id="user-avatar" class="float-right" width="64" height="64" src="{% static "musician/images/default-profile-picture.png" %}" alt="user-profile-picture"/> <img id="user-avatar" class="float-right" width="64" height="64" src="{% static "musician/images/default-profile-picture.png" %}" alt="user-profile-picture"/>
<strong>{{ user.username|default:"Username" }}</strong><br/> <strong>{{ profile.username }}</strong><br/>
<i class="fas fa-cog"></i> Settings <i class="fas fa-cog"></i> Settings
</button> </button>
<div class="dropdown-menu"> <div class="dropdown-menu">
@ -93,6 +93,7 @@
<script src="{% static "musician/js/jquery-3.3.1.slim.min.js" %}"></script> <script src="{% static "musician/js/jquery-3.3.1.slim.min.js" %}"></script>
<script src="{% static "musician/js/popper.min.js" %}"></script> <script src="{% static "musician/js/popper.min.js" %}"></script>
<script src="{% static "musician/js/bootstrap.min.js" %}"></script> <script src="{% static "musician/js/bootstrap.min.js" %}"></script>
{% block extrascript %}{% endblock %}
{% endblock %} {% endblock %}
</body> </body>

View File

@ -3,38 +3,140 @@
{% block content %} {% block content %}
<h2>Welcome back {{ user.username }}</h2> <h2>{% trans "Welcome back" %} <strong>{{ profile.username }}</strong></h2>
<h3>Last time you logged in was {{ user.last_login }}</h3> <p>{% blocktrans with last_login=profile.last_login|default:"N/A" %}Last time you logged in was: {{ last_login }}{% endblocktrans %}</p>
<div class="row"> <div class="card-deck">
{% for i in "1234"|make_list %} {% for resource, usage in resource_usage.items %}
<div class="col-3 border"> <div class="card resource-usage resource-{{ resource }}">
Resource usage block <div class="card-body">
<h5 class="card-title">{{ usage.verbose_name }}</h5>
{% include "musician/components/usage_progress_bar.html" with detail=usage %}
</div>
</div> </div>
{% endfor %} {% endfor %}
<div class="card resource-usage resource-notifications">
<div class="card-body">
<h5 class="card-title">{% trans "Notifications" %}</h5>
{% for message in notifications %}
<p class="card-text">{{ message }}</p>
{% empty %}
<p class="card-text">{% trans "There is no notifications at this time." %}</p>
{% endfor %}
</div>
</div>
</div> </div>
<h1>Domains and websites</h1> <h1 class="service-name">{% trans "Your domains and websites" %}</h1>
<p>Little description of what to be expected...</p> <p class="service-description">Little description of what to be expected...</p>
{% for domain in domains %} {% for domain in domains %}
<div class="row border mt-4"> <div class="card service-card">
<div class="col-12 bg-light"> <div class="card-header">
<h3>{{ domain.name }}</h3> <div class="row">
<div class="col-md">
<strong>{{ domain.name }}</strong>
</div> </div>
{% for service in "123"|make_list %} <div class="col-md-8">
<div class="card" style="width: 18rem;"> <button type="button" class="btn text-secondary" data-toggle="modal" data-target="#configDetailsModal"
<div class="card-body"> data-domain="{{ domain.name }}" data-username="john" data-password="s3cre3t" data-root="/domainname/">
<h5 class="card-title">{% cycle 'Mail' 'Mailing list' 'Databases' %}</h5> {% trans "view configuration" %} <strong class="fas fa-tools"></strong>
</button>
</div> </div>
<img class="card-img-bottom" src="..." alt="Card image cap"> <div class="col-md text-right">
<div class="card-body"> {% trans "Expiration date" %}: <strong>{{ domain.expiration_date|date:"SHORT_DATE_FORMAT" }}</strong>
<p class="card-text">Some quick example text to build on the card title and make up the bulk of the card's
content.</p>
</div> </div>
</div> </div>
{% endfor card %} </div><!-- /card-header-->
<div class="card-body row text-center">
<div class="col-md-2 border-right">
<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.address_left.alert %}
<br/>
<span class="text-{{ domain.address_left.alert }}">{{ domain.address_left.count }} mail address left</span>
{% endif %}
</p>
<a class="stretched-link" href="{% url 'musician:mails' %}?domain={{ domain.id }}"></a>
</div> </div>
<div class="col-md-2 border-right">
<h4>{% trans "Mail list" %}</h4>
<p class="card-text"><i class="fas fa-mail-bulk fa-3x"></i></p>
<a class="stretched-link" href="{% url 'musician:mailing-lists' %}?domain={{ domain.id }}"></a>
</div>
<div class="col-md-2 border-right">
<h4>{% trans "Databases" %}</h4>
<p class="card-text"><i class="fas fa-database fa-3x"></i></p>
<p class="card-text text-dark">
0 {% trans "databases created" %}
{% comment %}
<!-- TODO databases related to a domain and resource usage
{{ domain.databases|length }} {% trans "databases created" %}<br/>
20 MB of 45MB
-->
{% endcomment %}
</p>
<a class="stretched-link" href="{% url 'musician:databases' %}?domain={{ domain.id }}"></a>
</div>
<div class="col-md-2 border-right">
<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">Nothing installed</p>
<a class="stretched-link" href="{% url 'musician:saas' %}?domain={{ domain.id }}"></a>
</div>
<div class="col-md-4">
<h4>{% trans "Disk usage" %}</h4>
<p class="card-text"><i class="fas fa-hdd fa-3x"></i></p>
<div class="w-75 m-auto">
{% include "musician/components/usage_progress_bar.html" with detail=domain.usage %}
</div>
</div>
</div>
</div>
{% endfor %} {% endfor %}
<!-- configuration details modal -->
<div class="modal fade" id="configDetailsModal" tabindex="-1" role="dialog" aria-labelledby="configDetailsModalLabel" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title text-secondary" id="configDetailsModalLabel">Configuration details</h5>
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
<span aria-hidden="true">&times;</span>
</button>
</div>
<div class="modal-body">
<h6 class="pl-4 mb-4">{% trans "FTP access:" %}</h6>
<div class="">
<p>
<label>{% trans "Username" %}:</label> <span id="config-username" class="font-weight-bold">username</span><br/>
<label>{% trans "Password:" %}</label> <span id="config-password" class="font-weight-bold">password</span>
</p>
<p class="border-top pt-3"><label>Root directory:</label> <span id="config-root" class="font-weight-bold">root directory</span></p>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-primary">{% trans "View DNS records" %}</button>
</div>
</div>
</div>
</div>
{% endblock %}
{% block extrascript %}
<script>
$('#configDetailsModal').on('show.bs.modal', function (event) {
var button = $(event.relatedTarget); // Button that triggered the modal
var modal = $(this);
// Extract info from data-* attributes
modal.find('.modal-title').text(button.data('domain'));
modal.find('.modal-body #config-username').text(button.data('username'));
modal.find('.modal-body #config-password').text(button.data('password'));
modal.find('.modal-body #config-root').text(button.data('root'));
})
</script>
{% endblock %} {% endblock %}

View File

@ -6,6 +6,7 @@ from django.http import HttpResponseRedirect
from django.shortcuts import render from django.shortcuts import render
from django.urls import reverse_lazy from django.urls import reverse_lazy
from django.utils.http import is_safe_url from django.utils.http import is_safe_url
from django.utils.translation import gettext_lazy as _
from django.views.generic.base import RedirectView, TemplateView from django.views.generic.base import RedirectView, TemplateView
from django.views.generic.detail import DetailView from django.views.generic.detail import DetailView
from django.views.generic.edit import FormView from django.views.generic.edit import FormView
@ -19,6 +20,7 @@ from .mixins import (CustomContextMixin, ExtendedPaginationMixin,
UserTokenRequiredMixin) UserTokenRequiredMixin)
from .models import (DatabaseService, MailinglistService, MailService, from .models import (DatabaseService, MailinglistService, MailService,
PaymentSource, SaasService, UserAccount) PaymentSource, SaasService, UserAccount)
from .settings import ALLOWED_RESOURCES
class DashboardView(CustomContextMixin, UserTokenRequiredMixin, TemplateView): class DashboardView(CustomContextMixin, UserTokenRequiredMixin, TemplateView):
@ -26,12 +28,56 @@ class DashboardView(CustomContextMixin, UserTokenRequiredMixin, TemplateView):
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs) context = super().get_context_data(**kwargs)
domains = self.orchestra.retrieve_domain_list()
# TODO retrieve all data needed from orchestra # TODO(@slamora) update when backend provides resource usage data
raw_domains = self.orchestra.retrieve_service_list('domain') resource_usage = {
'disk': {
'verbose_name': _('Disk usage'),
'usage': 534,
'total': 1024,
'unit': 'MB',
'percent': 50,
},
'traffic': {
'verbose_name': _('Traffic'),
'usage': 300,
'total': 2048,
'unit': 'MB/month',
'percent': 25,
},
'mailbox': {
'verbose_name': _('Mailbox usage'),
'usage': 1,
'total': 2,
'unit': 'accounts',
'percent': 50,
},
}
# TODO(@slamora) update when backend supports notifications
notifications = []
# show resource usage based on plan definition
# TODO(@slamora): validate concept of limits with Pangea
profile_type = context['profile'].type
for domain in domains:
address_left = ALLOWED_RESOURCES[profile_type]['mailbox'] - len(domain.mails)
alert = None
if address_left == 1:
alert = 'warning'
elif address_left < 1:
alert = 'danger'
domain.address_left = {
'count': address_left,
'alert': alert,
}
context.update({ context.update({
'domains': raw_domains 'domains': domains,
'resource_usage': resource_usage,
'notifications': notifications,
}) })
return context return context
@ -60,14 +106,12 @@ class ProfileView(CustomContextMixin, UserTokenRequiredMixin, TemplateView):
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs) context = super().get_context_data(**kwargs)
json_data = self.orchestra.retreve_profile()
try: try:
pay_source = self.orchestra.retrieve_service_list( pay_source = self.orchestra.retrieve_service_list(
PaymentSource.api_name)[0] PaymentSource.api_name)[0]
except IndexError: except IndexError:
pay_source = {} pay_source = {}
context.update({ context.update({
'profile': UserAccount.new_from_json(json_data[0]),
'payment': PaymentSource.new_from_json(pay_source) 'payment': PaymentSource.new_from_json(pay_source)
}) })