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
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
DOMAINS_PATH = 'domains/'
TOKEN_PATH = '/api-token-auth/'
@ -15,12 +20,15 @@ API_PATHS = {
# services
'database-list': 'databases/',
'domain-list': 'domains/',
'domain-detail': 'domains/{pk}/',
'address-list': 'addresses/',
'mailbox-list': 'mailboxes/',
'mailinglist-list': 'lists/',
'saas-list': 'saas/',
# other
'bill-list': 'bills/',
'bill-document': 'bills/{pk}/document/',
'payment-source-list': 'payment-sources/',
}
@ -52,9 +60,15 @@ class Orchestra(object):
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"]
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())
response = verb(url, headers={"Authorization": "Token {}".format(
@ -64,20 +78,70 @@ class Orchestra(object):
response.raise_for_status()
status = response.status_code
output = response.json()
if render_as == "json":
output = response.json()
else:
output = response.content
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)
if pattern_name not in API_PATHS:
raise ValueError("Unknown service {}".format(service_name))
_, output = self.request("GET", pattern_name)
_, output = self.request("GET", pattern_name, querystring=querystring)
return output
def retreve_profile(self):
_, output = self.request("GET", 'my-account')
return output
def retrieve_profile(self):
status, output = self.request("GET", 'my-account')
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):
"""

View File

@ -46,6 +46,11 @@ class ExtendedPaginationMixin:
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):
"""Check that the user has an authorized token."""
token = self.request.session.get(SESSION_KEY_TOKEN, None)
@ -60,3 +65,10 @@ class UserTokenRequiredMixin(UserPassesTestMixin):
return False
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.translation import gettext_lazy as _
logger = logging.getLogger(__name__)
class OrchestraModel:
""" Base class from which all orchestra models will inherit. """
api_name = None
verbose_name = None
fields = ()
param_defaults = {}
id = None
def __init__(self, **kwargs):
if self.verbose_name is None:
@ -16,9 +22,6 @@ class OrchestraModel:
for (param, default) in self.param_defaults.items():
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
def new_from_json(cls, data, **kwargs):
@ -35,10 +38,29 @@ class OrchestraModel:
c = cls(**json_data)
c._json = data
# TODO(@slamora) remove/replace by private variable to ovoid name collisions
c.data = data
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):
param_defaults = {
@ -59,6 +81,15 @@ class PaymentSource(OrchestraModel):
"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):
api_name = 'accounts'
@ -117,15 +148,51 @@ class DatabaseService(OrchestraModel):
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):
api_name = 'address'
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.')
fields = ('mail_address', 'aliases', 'type', 'type_detail')
param_defaults = {}
FORWARD = 'forward'
MAILBOX = 'mailbox'
def __init__(self, **kwargs):
self.data = kwargs
super().__init__(**kwargs)
@property
def aliases(self):
return [
@ -165,6 +232,10 @@ class MailinglistService(OrchestraModel):
'admin_email': None,
}
def __init__(self, **kwargs):
self.data = kwargs
super().__init__(**kwargs)
@property
def status(self):
# 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);
}
.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 {
display: flex;
width: 100%;
@ -172,3 +198,88 @@ h1.service-name {
.service-card .card-body .service-brand i.fab {
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">
<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"/>
<strong>{{ user.username|default:"Username" }}</strong><br/>
<strong>{{ profile.username }}</strong><br/>
<i class="fas fa-cog"></i> Settings
</button>
<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/popper.min.js" %}"></script>
<script src="{% static "musician/js/bootstrap.min.js" %}"></script>
{% block extrascript %}{% endblock %}
{% endblock %}
</body>

View File

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

View File

@ -3,38 +3,142 @@
{% block content %}
<h2>Welcome back {{ user.username }}</h2>
<h3>Last time you logged in was {{ user.last_login }}</h3>
<h2>{% trans "Welcome back" %} <strong>{{ profile.username }}</strong></h2>
<p>{% blocktrans with last_login=profile.last_login|default:"N/A" %}Last time you logged in was: {{ last_login }}{% endblocktrans %}</p>
<div class="row">
{% for i in "1234"|make_list %}
<div class="col-3 border">
Resource usage block
<div class="card-deck">
{% for resource, usage in resource_usage.items %}
<div class="card resource-usage resource-{{ resource }}">
<div class="card-body">
<h5 class="card-title">{{ usage.verbose_name }}</h5>
{% include "musician/components/usage_progress_bar.html" with detail=usage %}
</div>
</div>
{% 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>
<h1>Domains and websites</h1>
<p>Little description of what to be expected...</p>
<h1 class="service-name">{% trans "Your domains and websites" %}</h1>
<p class="service-description">Little description of what to be expected...</p>
{% for domain in domains %}
<div class="row border mt-4">
<div class="col-12 bg-light">
<h3>{{ domain.name }}</h3>
</div>
{% for service in "123"|make_list %}
<div class="card" style="width: 18rem;">
<div class="card-body">
<h5 class="card-title">{% cycle 'Mail' 'Mailing list' 'Databases' %}</h5>
</div>
<img class="card-img-bottom" src="..." alt="Card image cap">
<div class="card-body">
<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 class="card service-card">
<div class="card-header">
<div class="row">
<div class="col-md">
<strong>{{ domain.name }}</strong>
</div>
<div class="col-md-8">
<button type="button" class="btn text-secondary" data-toggle="modal" data-target="#configDetailsModal"
data-domain="{{ domain.name }}" data-username="john" data-password="s3cre3t" data-root="/domainname/"
data-url="{% url 'musician:domain-detail' domain.id %}">
{% trans "view configuration" %} <strong class="fas fa-tools"></strong>
</button>
</div>
<div class="col-md text-right">
{% trans "Expiration date" %}: <strong>{{ domain.expiration_date|date:"SHORT_DATE_FORMAT" }}</strong>
</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>
{% endfor card %}
</div>
{% 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 %}

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 %}
{% 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>
<table class="table service-list">
<colgroup>

View File

@ -2,8 +2,11 @@
{% load i18n %}
{% 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>
<table class="table service-list">

View File

@ -3,48 +3,60 @@
{% block content %}
<h1>Profile</h1>
<p>Little description of what to be expected...</p>
<h1 class="service-name">Profile</h1>
<p class="service-description">Little description of what to be expected...</p>
<div class="card">
<h5 class="card-header text-right">User information</h5>
<div class="card-body row">
<div class="col-md-4">
<div class="m-auto border border-secondary rounded-circle rounded-lg" style="width:125px; height:125px;">
{# <!-- <img class="" src="#" alt="User profile image" /> -->#}
<div class="card-deck">
<div class="card card-profile">
<h5 class="card-header">User information</h5>
<div class="card-body row">
<div class="col-md">
<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 class="col-md-8">
<p class="card-text">{{ profile.username }}</p>
<p class="card-text">{{ profile.type }}</p>
<p class="card-text">Preferred language: {{ profile.language }}</p>
<div class="col-md-9">
<p class="card-text">{{ profile.username }}</p>
<p class="card-text">{{ profile.type }}</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>
{% with profile.billing as contact %}
<div class="card mt-4">
<h5 class="card-header text-right">Billing information</h5>
<div class="card-body">
<div class="form-group">{{ contact.name }}</div>
<div class="form-group">{{ contact.address }}</div>
<div class="form-group">
{{ contact.zipcode }}
{{ contact.city }}
{{ contact.country }}
</div>
<div class="form-group">
{{ contact.vat }}
</div>
<!-- payment method -->
<div class="form-group">
payment method: {{ payment.method }}
</div>
<div class="form-group">
{# TODO(@slamora) format payment method details #}
{{ payment.data.data }}
</div>
{% with profile.billing as contact %}
<div class="card card-profile">
<h5 class="card-header">Billing information</h5>
<div class="card-body">
<div class="form-group">{{ contact.name }}</div>
<div class="form-group">{{ contact.address }}</div>
<div class="form-group">
{{ contact.zipcode }}
{{ contact.city }}
{{ contact.country }}
</div>
<div class="form-group">
{{ contact.vat }}
</div>
<!-- payment method -->
<div class="form-group">
payment method: {{ payment.method }}
</div>
<div class="form-group">
{% if payment.method == 'SEPADirectDebit' %}
IBAN {{ payment.data.iban }}
{% else %}
{# <!-- "TODO handle Credit Card" --> #}
Details: {{ payment.data }}
{% endif %}
</div>
</div>
</div>
</div>
{% endwith %}

View File

@ -1,3 +1,14 @@
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/logout/', views.LogoutView.as_view(), name='logout'),
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('mails/', views.MailView.as_view(), name='mails'),
path('mailing-lists/', views.MailingListsView.as_view(), name='mailing-lists'),

View File

@ -1,11 +1,12 @@
from itertools import groupby
from django.core.exceptions import ImproperlyConfigured
from django.http import HttpResponseRedirect
from django.http import HttpResponse, HttpResponseRedirect
from django.shortcuts import render
from django.urls import reverse_lazy
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
@ -17,8 +18,9 @@ from .auth import logout as auth_logout
from .forms import LoginForm
from .mixins import (CustomContextMixin, ExtendedPaginationMixin,
UserTokenRequiredMixin)
from .models import (DatabaseService, MailinglistService, MailService,
from .models import (Bill, DatabaseService, MailinglistService, MailService,
PaymentSource, SaasService, UserAccount)
from .settings import ALLOWED_RESOURCES
class DashboardView(CustomContextMixin, UserTokenRequiredMixin, TemplateView):
@ -26,48 +28,72 @@ class DashboardView(CustomContextMixin, UserTokenRequiredMixin, TemplateView):
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
domains = self.orchestra.retrieve_domain_list()
# TODO retrieve all data needed from orchestra
raw_domains = self.orchestra.retrieve_service_list('domain')
# TODO(@slamora) update when backend provides resource usage data
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({
'domains': raw_domains
'domains': domains,
'resource_usage': resource_usage,
'notifications': notifications,
})
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):
template_name = "musician/profile.html"
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
json_data = self.orchestra.retreve_profile()
try:
pay_source = self.orchestra.retrieve_service_list(
PaymentSource.api_name)[0]
except IndexError:
pay_source = {}
context.update({
'profile': UserAccount.new_from_json(json_data[0]),
'payment': PaymentSource.new_from_json(pay_source)
})
@ -84,10 +110,17 @@ class ServiceListView(CustomContextMixin, ExtendedPaginationMixin, UserTokenRequ
raise ImproperlyConfigured(
"ServiceListView requires a definiton of 'service'")
queryfilter = self.get_queryfilter()
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]
def get_queryfilter(self):
"""Does nothing by default. Should be implemented on subclasses"""
return ''
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context.update({
@ -96,6 +129,19 @@ class ServiceListView(CustomContextMixin, ExtendedPaginationMixin, UserTokenRequ
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):
service_class = MailService
template_name = "musician/mail.html"
@ -104,14 +150,20 @@ class MailView(ServiceListView):
def retrieve_mailbox(value):
mailboxes = value.get('mailboxes')
# forwarded address should not grouped
if len(mailboxes) == 0:
return ''
return value.get('name')
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(
self.service_class.api_name)
self.service_class.api_name,
querystring=queryfilter,
)
# group addresses with the same mailbox
addresses = []
for key, group in groupby(raw_data, retrieve_mailbox):
aliases = []
@ -125,12 +177,49 @@ class MailView(ServiceListView):
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):
service_class = MailinglistService
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):
template_name = "musician/databases.html"
service_class = DatabaseService
@ -141,6 +230,25 @@ class SaasView(ServiceListView):
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):
template_name = 'auth/login.html'
form_class = LoginForm