diff --git a/musician/api.py b/musician/api.py index d712a24..8b4139c 100644 --- a/musician/api.py +++ b/musician/api.py @@ -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): """ diff --git a/musician/mixins.py b/musician/mixins.py index 1785d98..5c65f47 100644 --- a/musician/mixins.py +++ b/musician/mixins.py @@ -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 diff --git a/musician/models.py b/musician/models.py index ba7192f..db3367b 100644 --- a/musician/models.py +++ b/musician/models.py @@ -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? diff --git a/musician/settings.py b/musician/settings.py new file mode 100644 index 0000000..5581061 --- /dev/null +++ b/musician/settings.py @@ -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, + } +} diff --git a/musician/static/musician/css/default.css b/musician/static/musician/css/default.css index 23bd5b1..a9d7da4 100644 --- a/musician/static/musician/css/default.css +++ b/musician/static/musician/css/default.css @@ -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; +} diff --git a/musician/static/musician/images/default-profile-picture-primary-color.png b/musician/static/musician/images/default-profile-picture-primary-color.png new file mode 100644 index 0000000..feaf2c7 Binary files /dev/null and b/musician/static/musician/images/default-profile-picture-primary-color.png differ diff --git a/musician/templates/musician/base.html b/musician/templates/musician/base.html index 2839aee..9dc1ffc 100644 --- a/musician/templates/musician/base.html +++ b/musician/templates/musician/base.html @@ -59,7 +59,7 @@