Merge branch 'master' into i18n

This commit is contained in:
Santiago Lamora 2019-12-18 10:30:25 +01:00
commit 7744ce6d85
16 changed files with 657 additions and 114 deletions

View File

@ -2,7 +2,12 @@ import requests
import urllib.parse import urllib.parse
from django.conf import settings from django.conf import settings
from django.http import Http404
from django.urls.exceptions import NoReverseMatch from django.urls.exceptions import NoReverseMatch
from django.utils.translation import gettext_lazy as _
from .models import Domain, DatabaseService, MailService, SaasService, UserAccount
DOMAINS_PATH = 'domains/' DOMAINS_PATH = 'domains/'
TOKEN_PATH = '/api-token-auth/' TOKEN_PATH = '/api-token-auth/'
@ -15,12 +20,15 @@ API_PATHS = {
# services # services
'database-list': 'databases/', 'database-list': 'databases/',
'domain-list': 'domains/', 'domain-list': 'domains/',
'domain-detail': 'domains/{pk}/',
'address-list': 'addresses/', 'address-list': 'addresses/',
'mailbox-list': 'mailboxes/', 'mailbox-list': 'mailboxes/',
'mailinglist-list': 'lists/', 'mailinglist-list': 'lists/',
'saas-list': 'saas/', 'saas-list': 'saas/',
# other # other
'bill-list': 'bills/',
'bill-document': 'bills/{pk}/document/',
'payment-source-list': 'payment-sources/', 'payment-source-list': 'payment-sources/',
} }
@ -52,9 +60,15 @@ 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=None, url=None, render_as="json", 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) if resource is not None:
url = self.build_absolute_uri(resource)
elif url is None:
raise AttributeError("Provide `resource` or `url` params")
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(
@ -64,20 +78,70 @@ class Orchestra(object):
response.raise_for_status() response.raise_for_status()
status = response.status_code status = response.status_code
output = response.json() if render_as == "json":
output = response.json()
else:
output = response.content
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_bill_document(self, pk):
path = API_PATHS.get('bill-document').format_map({'pk': pk})
url = urllib.parse.urljoin(self.base_url, path)
status, bill_pdf = self.request("GET", render_as="html", url=url, raise_exception=False)
if status == 404:
raise Http404(_("No domain found matching the query"))
return bill_pdf
def retrieve_domain(self, pk):
path = API_PATHS.get('domain-detail').format_map({'pk': pk})
url = urllib.parse.urljoin(self.base_url, path)
status, domain_json = self.request("GET", url=url, raise_exception=False)
if status == 404:
raise Http404(_("No domain found matching the query"))
return Domain.new_from_json(domain_json)
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

@ -1,13 +1,19 @@
import ast
import logging
from django.utils.html import format_html from django.utils.html import format_html
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
logger = logging.getLogger(__name__)
class OrchestraModel: class OrchestraModel:
""" Base class from which all orchestra models will inherit. """ """ Base class from which all orchestra models will inherit. """
api_name = None api_name = None
verbose_name = None verbose_name = None
fields = () fields = ()
param_defaults = {} id = None
def __init__(self, **kwargs): def __init__(self, **kwargs):
if self.verbose_name is None: if self.verbose_name is None:
@ -16,9 +22,6 @@ class OrchestraModel:
for (param, default) in self.param_defaults.items(): for (param, default) in self.param_defaults.items():
setattr(self, param, kwargs.get(param, default)) setattr(self, param, kwargs.get(param, default))
# def get(self, key):
# # retrieve attr of the object and if undefined get raw data
# return getattr(self, key, self.data.get(key))
@classmethod @classmethod
def new_from_json(cls, data, **kwargs): def new_from_json(cls, data, **kwargs):
@ -35,10 +38,29 @@ class OrchestraModel:
c = cls(**json_data) c = cls(**json_data)
c._json = data c._json = data
# TODO(@slamora) remove/replace by private variable to ovoid name collisions
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 Bill(OrchestraModel):
api_name = 'bill'
param_defaults = {
"id": None,
"number": "1",
"type": "INVOICE",
"total": 0.0,
"is_sent": False,
"created_on": "",
"due_on": "",
"comments": "",
}
class BillingContact(OrchestraModel): class BillingContact(OrchestraModel):
param_defaults = { param_defaults = {
@ -59,6 +81,15 @@ class PaymentSource(OrchestraModel):
"is_active": False, "is_active": False,
} }
def __init__(self, **kwargs):
super().__init__(**kwargs)
# payment details are passed as a plain string
# try to convert to a python structure
try:
self.data = ast.literal_eval(self.data)
except (ValueError, SyntaxError) as e:
logger.error(e)
class UserAccount(OrchestraModel): class UserAccount(OrchestraModel):
api_name = 'accounts' api_name = 'accounts'
@ -117,15 +148,51 @@ 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')
description = _('Litle description of what to be expected in this section to aid the user. Even a link to more help if there is one available.') description = _('Litle description of what to be expected in this section to aid the user. Even a link to more help if there is one available.')
fields = ('mail_address', 'aliases', 'type', 'type_detail') fields = ('mail_address', 'aliases', 'type', 'type_detail')
param_defaults = {}
FORWARD = 'forward' FORWARD = 'forward'
MAILBOX = 'mailbox' MAILBOX = 'mailbox'
def __init__(self, **kwargs):
self.data = kwargs
super().__init__(**kwargs)
@property @property
def aliases(self): def aliases(self):
return [ return [
@ -165,6 +232,10 @@ class MailinglistService(OrchestraModel):
'admin_email': None, 'admin_email': None,
} }
def __init__(self, **kwargs):
self.data = kwargs
super().__init__(**kwargs)
@property @property
def status(self): def status(self):
# TODO(@slamora): where retrieve if the list is active? # TODO(@slamora): where retrieve if the list is active?

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

@ -7,6 +7,32 @@ a:hover {
color: rgba(0,0,0,.7); color: rgba(0,0,0,.7);
} }
.btn-arrow-left{
color: #eee;
background: #D3D0DA;
position: relative;
padding: 8px 20px 8px 30px;
margin-left: 1em; /** equal value than arrow.left **/
}
.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{
border-right-color: #D3D0DA;
z-index: 2;
}
.wrapper { .wrapper {
display: flex; display: flex;
width: 100%; width: 100%;
@ -172,3 +198,88 @@ 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;
}
.card.card-profile .card-header {
background: white;
border-bottom: none;
font-size: large;
text-transform: uppercase;
}
#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;
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

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

@ -1,5 +1,5 @@
{% extends "musician/base.html" %} {% extends "musician/base.html" %}
{% load i18n %} {% load i18n l10n %}
{% block content %} {% block content %}
@ -13,7 +13,6 @@
<col span="1" style="width: 40%;"> <col span="1" style="width: 40%;">
<col span="1" style="width: 10%;"> <col span="1" style="width: 10%;">
<col span="1" style="width: 10%;"> <col span="1" style="width: 10%;">
<col span="1" style="width: 10%;">
</colgroup> </colgroup>
<thead class="thead-dark"> <thead class="thead-dark">
<tr> <tr>
@ -21,7 +20,6 @@
<th scope="col">Bill date</th> <th scope="col">Bill date</th>
<th scope="col">Type</th> <th scope="col">Type</th>
<th scope="col">Total</th> <th scope="col">Total</th>
<th scope="col">Status</th>
<th scope="col">Download PDF</th> <th scope="col">Download PDF</th>
</tr> </tr>
</thead> </thead>
@ -29,11 +27,10 @@
{% for bill in object_list %} {% for bill in object_list %}
<tr> <tr>
<th scope="row">{{ bill.number }}</th> <th scope="row">{{ bill.number }}</th>
<td>{{ bill.date|date:"SHORT_DATE_FORMAT" }}</td> <td>{{ bill.created_on }}</td>
<td>{{ bill.type }}</td> <td>{{ bill.type }}</td>
<td>{{ bill.total_amount }}</td> <td>{{ bill.total|floatformat:2|localize }}€</td>
<td class="font-weight-bold">{{ bill.status }}</td> <td><a class="text-dark" href="{% url 'musician:bill-download' bill.id %}" target="_blank" rel="noopener noreferrer"><i class="fas fa-file-pdf"></i></a></td>
<td><a class="text-dark" href="{{ bill.pdf_url }}" target="_blank" rel="noopener noreferrer"><i class="fas fa-file-pdf"></i></a></td>
</tr> </tr>
{% endfor %} {% endfor %}
</tbody> </tbody>

View File

@ -3,38 +3,142 @@
{% 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> <div class="col-md">
{% for service in "123"|make_list %} <strong>{{ domain.name }}</strong>
<div class="card" style="width: 18rem;"> </div>
<div class="card-body"> <div class="col-md-8">
<h5 class="card-title">{% cycle 'Mail' 'Mailing list' 'Databases' %}</h5> <button type="button" class="btn text-secondary" data-toggle="modal" data-target="#configDetailsModal"
</div> data-domain="{{ domain.name }}" data-username="john" data-password="s3cre3t" data-root="/domainname/"
<img class="card-img-bottom" src="..." alt="Card image cap"> data-url="{% url 'musician:domain-detail' domain.id %}">
<div class="card-body"> {% trans "view configuration" %} <strong class="fas fa-tools"></strong>
<p class="card-text">Some quick example text to build on the card title and make up the bulk of the card's </button>
content.</p> </div>
<div class="col-md text-right">
{% trans "Expiration date" %}: <strong>{{ domain.expiration_date|date:"SHORT_DATE_FORMAT" }}</strong>
</div>
</div> </div>
</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 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 card %}
</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">
<a href="#domain-detail" class="btn btn-primary">{% trans "View DNS records" %}</a>
</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'));
modal.find('.modal-footer .btn').attr('href', button.data('url'));
})
</script>
{% endblock %} {% endblock %}

View File

@ -0,0 +1,30 @@
{% extends "musician/base.html" %}
{% load i18n %}
{% block content %}
<a class="btn-arrow-left" href="{% url 'musician:dashboard' %}">{% trans "Go back" %}</a>
<h1 class="service-name">{% trans "DNS settings for" %} <span class="font-weight-light">{{ object.name }}</span></h1>
<p class="service-description">Litle description of what to be expected in this section to aid the user. Even a link to more help if there is one available.</p>
<table class="table service-list">
<colgroup>
<col span="1" style="width: 12%;">
<col span="1" style="width: 88%;">
</colgroup>
<thead class="thead-dark">
<tr>
<th scope="col">{% trans "Type" %}</th>
<th scope="col">{% trans "Value" %}</th>
</tr>
</thead>
<tbody>
{% for record in object.records %}
<tr>
<td>{{ record.type }}</td>
<td>{{ record.value }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endblock %}

View File

@ -2,8 +2,11 @@
{% load i18n %} {% load i18n %}
{% block content %} {% block content %}
{% if active_domain %}
<a class="btn-arrow-left" href="{% url 'musician:mails' %}">{% trans "Go to global" %}</a>
{% endif %}
<h1 class="service-name">{{ service.verbose_name }}</h1> <h1 class="service-name">{{ service.verbose_name }}{% if active_domain %} <span class="font-weight-light">{% trans "for" %} {{ active_domain.name }}</span>{% endif %}</h1>
<p class="service-description">{{ service.description }}</p> <p class="service-description">{{ service.description }}</p>
<table class="table service-list"> <table class="table service-list">
<colgroup> <colgroup>

View File

@ -2,8 +2,11 @@
{% load i18n %} {% load i18n %}
{% block content %} {% block content %}
{% if active_domain %}
<a class="btn-arrow-left" href="{% url 'musician:mailing-lists' %}">{% trans "Go to global" %}</a>
{% endif %}
<h1 class="service-name">{{ service.verbose_name }}</h1> <h1 class="service-name">{{ service.verbose_name }}{% if active_domain %} <span class="font-weight-light">{% trans "for" %} {{ active_domain.name }}</span>{% endif %}</h1>
<p class="service-description">{{ service.description }}</p> <p class="service-description">{{ service.description }}</p>
<table class="table service-list"> <table class="table service-list">

View File

@ -3,48 +3,60 @@
{% block content %} {% block content %}
<h1>Profile</h1> <h1 class="service-name">Profile</h1>
<p>Little description of what to be expected...</p> <p class="service-description">Little description of what to be expected...</p>
<div class="card"> <div class="card-deck">
<h5 class="card-header text-right">User information</h5> <div class="card card-profile">
<div class="card-body row"> <h5 class="card-header">User information</h5>
<div class="col-md-4"> <div class="card-body row">
<div class="m-auto border border-secondary rounded-circle rounded-lg" style="width:125px; height:125px;"> <div class="col-md">
{# <!-- <img class="" src="#" alt="User profile image" /> -->#} <div class="border-primary rounded-circle d-inline-block p-1" style="background-color: white; border: 5px solid grey">
<img id="user-avatar" width="160" height="160" src="/static/musician/images/default-profile-picture-primary-color.png" alt="user-profile-picture">
</div>
</div> </div>
</div> <div class="col-md-9">
<div class="col-md-8"> <p class="card-text">{{ profile.username }}</p>
<p class="card-text">{{ profile.username }}</p> <p class="card-text">{{ profile.type }}</p>
<p class="card-text">{{ profile.type }}</p> <p class="card-text">Preferred language: {{ profile.language }}</p>
<p class="card-text">Preferred language: {{ profile.language }}</p> </div>
{% comment %}
<!-- disabled until set_password is implemented -->
<div class="col-md-12 text-right">
<a class="btn btn-primary pl-5 pr-5" href="#">Set new password</a>
</div>
{% endcomment %}
</div> </div>
</div> </div>
</div>
{% with profile.billing as contact %} {% with profile.billing as contact %}
<div class="card mt-4"> <div class="card card-profile">
<h5 class="card-header text-right">Billing information</h5> <h5 class="card-header">Billing information</h5>
<div class="card-body"> <div class="card-body">
<div class="form-group">{{ contact.name }}</div> <div class="form-group">{{ contact.name }}</div>
<div class="form-group">{{ contact.address }}</div> <div class="form-group">{{ contact.address }}</div>
<div class="form-group"> <div class="form-group">
{{ contact.zipcode }} {{ contact.zipcode }}
{{ contact.city }} {{ contact.city }}
{{ contact.country }} {{ contact.country }}
</div> </div>
<div class="form-group"> <div class="form-group">
{{ contact.vat }} {{ contact.vat }}
</div> </div>
<!-- payment method --> <!-- payment method -->
<div class="form-group"> <div class="form-group">
payment method: {{ payment.method }} payment method: {{ payment.method }}
</div> </div>
<div class="form-group"> <div class="form-group">
{# TODO(@slamora) format payment method details #} {% if payment.method == 'SEPADirectDebit' %}
{{ payment.data.data }} IBAN {{ payment.data.iban }}
</div> {% else %}
{# <!-- "TODO handle Credit Card" --> #}
Details: {{ payment.data }}
{% endif %}
</div>
</div>
</div> </div>
</div> </div>
{% endwith %} {% endwith %}

View File

@ -1,3 +1,14 @@
from django.test import TestCase from django.test import TestCase
# Create your tests here.
class DomainsTestCase(TestCase):
def test_domain_not_found(self):
response = self.client.post(
'/auth/login/',
{'username': 'admin', 'password': 'admin'},
follow=True
)
self.assertEqual(200, response.status_code)
response = self.client.get('/domains/3/')
self.assertEqual(404, response.status_code)

View File

@ -15,7 +15,9 @@ urlpatterns = [
path('auth/login/', views.LoginView.as_view(), name='login'), path('auth/login/', views.LoginView.as_view(), name='login'),
path('auth/logout/', views.LogoutView.as_view(), name='logout'), path('auth/logout/', views.LogoutView.as_view(), name='logout'),
path('dashboard/', views.DashboardView.as_view(), name='dashboard'), path('dashboard/', views.DashboardView.as_view(), name='dashboard'),
path('billing/', views.BillingView.as_view(), name='billing'), path('domains/<int:pk>/', views.DomainDetailView.as_view(), name='domain-detail'),
path('bills/', views.BillingView.as_view(), name='billing'),
path('bills/<int:pk>/download/', views.BillDownloadView.as_view(), name='bill-download'),
path('profile/', views.ProfileView.as_view(), name='profile'), path('profile/', views.ProfileView.as_view(), name='profile'),
path('mails/', views.MailView.as_view(), name='mails'), path('mails/', views.MailView.as_view(), name='mails'),
path('mailing-lists/', views.MailingListsView.as_view(), name='mailing-lists'), path('mailing-lists/', views.MailingListsView.as_view(), name='mailing-lists'),

View File

@ -1,11 +1,12 @@
from itertools import groupby from itertools import groupby
from django.core.exceptions import ImproperlyConfigured from django.core.exceptions import ImproperlyConfigured
from django.http import HttpResponseRedirect from django.http import HttpResponse, 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 import View
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
@ -17,8 +18,9 @@ from .auth import logout as auth_logout
from .forms import LoginForm from .forms import LoginForm
from .mixins import (CustomContextMixin, ExtendedPaginationMixin, from .mixins import (CustomContextMixin, ExtendedPaginationMixin,
UserTokenRequiredMixin) UserTokenRequiredMixin)
from .models import (DatabaseService, MailinglistService, MailService, from .models import (Bill, 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,48 +28,72 @@ 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
class BillingView(CustomContextMixin, ExtendedPaginationMixin, UserTokenRequiredMixin, ListView):
template_name = "musician/billing.html"
def get_queryset(self):
# TODO (@slamora) retrieve user bills
from django.utils import timezone
return [
{
'number': 24,
'date': timezone.now(),
'type': 'subscription',
'total_amount': '25,00 €',
'status': 'paid',
'pdf_url': 'https://example.org/bill.pdf'
},
]
class ProfileView(CustomContextMixin, UserTokenRequiredMixin, TemplateView): class ProfileView(CustomContextMixin, UserTokenRequiredMixin, TemplateView):
template_name = "musician/profile.html" template_name = "musician/profile.html"
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)
}) })
@ -84,10 +110,17 @@ class ServiceListView(CustomContextMixin, ExtendedPaginationMixin, UserTokenRequ
raise ImproperlyConfigured( raise ImproperlyConfigured(
"ServiceListView requires a definiton of 'service'") "ServiceListView requires a definiton of 'service'")
queryfilter = self.get_queryfilter()
json_qs = self.orchestra.retrieve_service_list( json_qs = self.orchestra.retrieve_service_list(
self.service_class.api_name) self.service_class.api_name,
querystring=queryfilter,
)
return [self.service_class.new_from_json(data) for data in json_qs] return [self.service_class.new_from_json(data) for data in json_qs]
def get_queryfilter(self):
"""Does nothing by default. Should be implemented on subclasses"""
return ''
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs) context = super().get_context_data(**kwargs)
context.update({ context.update({
@ -96,6 +129,19 @@ class ServiceListView(CustomContextMixin, ExtendedPaginationMixin, UserTokenRequ
return context return context
class BillingView(ServiceListView):
service_class = Bill
template_name = "musician/billing.html"
class BillDownloadView(CustomContextMixin, UserTokenRequiredMixin, View):
def get(self, request, *args, **kwargs):
pk = self.kwargs.get('pk')
bill = self.orchestra.retrieve_bill_document(pk)
return HttpResponse(bill)
class MailView(ServiceListView): class MailView(ServiceListView):
service_class = MailService service_class = MailService
template_name = "musician/mail.html" template_name = "musician/mail.html"
@ -104,14 +150,20 @@ class MailView(ServiceListView):
def retrieve_mailbox(value): def retrieve_mailbox(value):
mailboxes = value.get('mailboxes') mailboxes = value.get('mailboxes')
# forwarded address should not grouped
if len(mailboxes) == 0: if len(mailboxes) == 0:
return '' return value.get('name')
return mailboxes[0]['id'] return mailboxes[0]['id']
# group addresses with the same mailbox # retrieve mails applying filters (if any)
queryfilter = self.get_queryfilter()
raw_data = self.orchestra.retrieve_service_list( raw_data = self.orchestra.retrieve_service_list(
self.service_class.api_name) self.service_class.api_name,
querystring=queryfilter,
)
# group addresses with the same mailbox
addresses = [] addresses = []
for key, group in groupby(raw_data, retrieve_mailbox): for key, group in groupby(raw_data, retrieve_mailbox):
aliases = [] aliases = []
@ -125,12 +177,49 @@ class MailView(ServiceListView):
return addresses return addresses
def get_queryfilter(self):
"""Retrieve query params (if any) to filter queryset"""
domain_id = self.request.GET.get('domain')
if domain_id:
return "domain={}".format(domain_id)
return ''
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
domain_id = self.request.GET.get('domain')
if domain_id:
context.update({
'active_domain': self.orchestra.retrieve_domain(domain_id)
})
return context
class MailingListsView(ServiceListView): class MailingListsView(ServiceListView):
service_class = MailinglistService service_class = MailinglistService
template_name = "musician/mailinglists.html" template_name = "musician/mailinglists.html"
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
domain_id = self.request.GET.get('domain')
if domain_id:
context.update({
'active_domain': self.orchestra.retrieve_domain(domain_id)
})
return context
def get_queryfilter(self):
"""Retrieve query params (if any) to filter queryset"""
# TODO(@slamora): this is not working because backend API
# doesn't support filtering by domain
domain_id = self.request.GET.get('domain')
if domain_id:
return "domain={}".format(domain_id)
return ''
class DatabasesView(ServiceListView): class DatabasesView(ServiceListView):
template_name = "musician/databases.html" template_name = "musician/databases.html"
service_class = DatabaseService service_class = DatabaseService
@ -141,6 +230,25 @@ class SaasView(ServiceListView):
template_name = "musician/saas.html" template_name = "musician/saas.html"
class DomainDetailView(CustomContextMixin, UserTokenRequiredMixin, DetailView):
template_name = "musician/domain_detail.html"
def get_queryset(self):
# Return an empty list to avoid a request to retrieve all the
# user domains. We will get a 404 if the domain doesn't exists
# while invoking `get_object`
return []
def get_object(self, queryset=None):
if queryset is None:
queryset = self.get_queryset()
pk = self.kwargs.get(self.pk_url_kwarg)
domain = self.orchestra.retrieve_domain(pk)
return domain
class LoginView(FormView): class LoginView(FormView):
template_name = 'auth/login.html' template_name = 'auth/login.html'
form_class = LoginForm form_class = LoginForm