clients: Cleanup framework

- Move all utilities to a separate clients.py module. Tests too.

- Use fewer custom template tags. Actually only one tag is really required.
  Keeping custom tags minimal is a goal.

- Merge the methods to generate app store URLs.

- Implement a validator for validating client information and use that instead
  of enums.

- Internationalize the text on template page.

- Add missing RPM package case.

- Cleanup CSS. Remove unused styles, minimize the styles set.

Signed-off-by: Sunil Mohan Adapa <sunil@medhas.org>
Reviewed-by: James Valleroy <jvalleroy@mailbox.org>
This commit is contained in:
Sunil Mohan Adapa 2017-12-06 15:20:57 +05:30 committed by James Valleroy
parent 13c4c687da
commit acd248e506
No known key found for this signature in database
GPG Key ID: 77C0C75E7B650808
7 changed files with 325 additions and 255 deletions

146
plinth/clients.py Normal file
View File

@ -0,0 +1,146 @@
#
# This file is part of Plinth.
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as
# published by the Free Software Foundation, either version 3 of the
# License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
#
"""
Utility methods for providing client information.
"""
from django.utils.functional import Promise
from enum import Enum
class Desktop_OS(Enum):
GNU_LINUX = 'gnu-linux'
MAC_OS = 'macos'
WINDOWS = 'windows'
class Mobile_OS(Enum):
ANDROID = 'android'
IOS = 'ios'
class Store(Enum):
APP_STORE = 'app-store'
F_DROID = 'f-droid'
GOOGLE_PLAY = 'google-play'
class Package(Enum):
DEB = 'deb'
HOMEBREW = 'brew'
RPM = 'rpm'
def enum_values(enum):
return [option.value for option in list(enum)]
def _check(client, condition):
"""Check if any of a list of clients satisfies the given condition"""
return any(platform for platform in client['platforms']
if condition(platform))
def _client_has_desktop(client):
"""Filter to find out whether an application has desktop clients"""
return _check(
client, lambda platform: platform.get('os') in enum_values(Desktop_OS))
def _client_has_mobile(client):
"""Filter to find out whether an application has mobile clients"""
return _check(
client, lambda platform: platform.get('os') in enum_values(Mobile_OS))
def _client_has_web(client):
"""Filter to find out whether an application has web clients"""
return _check(client, lambda platform: platform['type'] == 'web')
def _client_has_package(client):
"""Filter to find out whether an application has web clients"""
return _check(client, lambda platform: platform['type'] == 'package')
def of_type(clients, client_type):
"""Filter and get clients of a particular type"""
filters = {
'mobile': _client_has_mobile,
'desktop': _client_has_desktop,
'web': _client_has_web,
'package': _client_has_package,
}
return list(filter(filters[client_type], clients))
def store_url(store, package_id):
"""Return a full App store URL given package id and type of store."""
stores = {
'google-play': 'https://play.google.com/store/apps/details?id={}',
'f-droid': 'https://f-droid.org/packages/{}'
}
return stores[store].format(package_id)
def validate(clients):
"""Validate the clients' information schema."""
assert isinstance(clients, list)
for client in clients:
_validate_client(client)
return clients
def _validate_client(client):
"""Validate a single client's information schema."""
assert isinstance(client, dict)
assert 'name' in client
assert isinstance(client['platforms'], list)
for platform in client['platforms']:
_validate_platform(platform)
def _validate_platform(platform):
"""Validate a single platform's schema."""
assert platform['type'] in ('package', 'download', 'store', 'web')
validate_method = globals()['_validate_platform_' + platform['type']]
validate_method(platform)
def _validate_platform_package(platform):
"""Validate a platform of type package."""
assert platform['format'] in enum_values(Package)
assert isinstance(platform['name'], (str, Promise))
def _validate_platform_download(platform):
"""Validate a platform of type download."""
assert platform['os'] in enum_values(Desktop_OS)
assert isinstance(platform['url'], (str, Promise))
def _validate_platform_store(platform):
"""Validate a platform of type store."""
assert platform['os'] in enum_values(Mobile_OS)
assert platform['store_name'] in enum_values(Store)
assert isinstance(platform['url'], (str, Promise))
def _validate_platform_web(platform):
"""Validate a platform of type web."""
assert isinstance(platform['url'], (str, Promise))

View File

@ -23,113 +23,126 @@
{% if clients %}
<p>
<button id="collapsible-button" type="button" class="btn btn-default collapsed"
<button id="clients-button" type="button" class="btn btn-default collapsed"
data-toggle="collapse" data-target="#clients">
Client Apps
{% trans "Client Apps" %}
<span class="glyphicon glyphicon-chevron-right" aria-hidden="true"></span>
</button>
</p>
<table id="clients" class="table table-hover collapse">
<div>
<table id="clients" class="table table-striped collapse" style="width: 100%">
{% if clients|has_web_clients %}
{% with clients|of_type:'web' as web_clients %}
{% for client in web_clients %}
<tr>
{% if forloop.counter == 1 %}
<th rowspan=" {{ web_clients|length }}"> Web </th>
{% with clients|clients_of_type:'web' as web_clients %}
{% for client in web_clients %}
<tr>
{% if forloop.counter == 1 %}
<th rowspan="{{ web_clients|length }}">{% trans "Web" %}</th>
{% endif %}
{% for platform in client.platforms %}
{% if platform.type == 'web' %}
<td>{{ client.name }}</td>
<td>
<a class="btn btn-success" href="{{ platform.url }}" role="button">
{% trans "Launch" %}
<span class="glyphicon glyphicon-new-window"></span>
</a>
</td>
{% endif %}
{% endfor %}
</tr>
{% endfor %}
{% endwith %}
{% with clients|clients_of_type:'desktop' as desktop_clients %}
{% for client in desktop_clients %}
<tr>
{% if forloop.counter == 1 %}
<th rowspan="{{ desktop_clients|length }}">{% trans "Desktop" %}</th>
{% endif %}
<td>{{ client.name }}</td>
<td>
{% for platform in client.platforms %}
{% if platform.type == 'web' %}
<td> {{ client.name }} </td>
<td>
<a class="btn btn-success" href="{{ platform.url }}" role="button">
Launch <span class="glyphicon glyphicon-new-window"></span>
</a>
</td>
{% if platform.type == 'download' %}
<a class="btn btn-default" href="{{ platform.url }}" role="button">
{% with 'theme/icons/'|add:platform.os|add:'.png' as icon %}
<img class="client-icon" src="{% static icon %}" />
{% if platform.os == 'gnu-linux' %}
{% trans 'Play Store' %}
{% elif platform.os == 'windows' %}
{% trans 'Windows' %}
{% elif platform.os == 'macos' %}
{% trans 'macOS' %}
{% endif %}
{% endwith %}
</a>
{% endif %}
{% endfor %}
</tr>
{% endfor %}
{% endwith %}
{% endif %}
</td>
</tr>
{% endfor %}
{% endwith %}
{% if clients|has_desktop_clients %}
{% with clients|of_type:'desktop' as desktop_clients %}
{% for client in desktop_clients %}
<tr>
{% if forloop.counter == 1 %}
<th rowspan="{{ desktop_clients|length }}"> Desktop </th>
{% endif %}
<td> {{ client.name }} </td>
<td>
{% for platform in client.platforms %}
{% if platform.type == 'download' %}
<a class="btn btn-default" href="{{ platform.url }}" role="button">
{% with 'theme/icons/'|add:platform.os|add:'.png' as icon %}
<img class="client-icon" src="{% static icon %}" /> {{ platform.os|display_name }}
{% endwith %}
</a>
{% endif %}
{% endfor %}
</td>
</tr>
{% endfor %}
{% endwith %}
{% endif %}
{% if clients|has_mobile_clients %}
{% with clients|of_type:'mobile' as mobile_clients %}
{% for client in mobile_clients %}
<tr>
{% if forloop.counter == 1 %}
<th rowspan="{{ mobile_clients|length }}"> Mobile </th>
{% endif %}
<td> {{ client.name }} </td>
<td>
{% for platform in client.platforms %}
{% if platform.type == 'store' and platform.os == 'android' or platform.os == 'ios' %}
<a class="btn btn-default" href="{{ platform.url }}" role="button">
{% with 'theme/icons/'|add:platform.store_name|add:'.png' as icon %}
<img class="client-icon" src="{% static icon %}" /> {{ platform.store_name|display_name }}
{% endwith %}
</a>
{% endif %}
{% endfor %}
</td>
</tr>
{% endfor %}
{% endwith %}
{% endif %}
{% if clients|has_package_clients %}
{% with clients|of_type:'package' as package_clients %}
{% for client in package_clients %}
<tr>
{% if forloop.counter == 1 %}
<th rowspan="{{ package_clients|length }}"> Package </th>
{% endif %}
<td> {{ client.name }} </td>
<td>
<div class="row">
<ul>
{% for platform in client.platforms %}
{% if platform.type == 'package' %}
{% if platform.type == 'package' and platform.format == 'deb' %}
<li> <strong> Debian: </strong> {{ platform.name }} </li>
{% endif %}
{% if platform.type == 'package' and platform.format == 'homebrew' %}
<li> <strong> HomeBrew: </strong> {{ platform.name }} </li>
{% endif %}
{% with clients|clients_of_type:'mobile' as mobile_clients %}
{% for client in mobile_clients %}
<tr>
{% if forloop.counter == 1 %}
<th rowspan="{{ mobile_clients|length }}">{% trans "Mobile" %}</th>
{% endif %}
<td>{{ client.name }}</td>
<td>
{% for platform in client.platforms %}
{% if platform.type == 'store' and platform.os == 'android' or platform.os == 'ios' %}
<a class="btn btn-default" href="{{ platform.url }}" role="button">
{% with 'theme/icons/'|add:platform.store_name|add:'.png' as icon %}
<img class="client-icon" src="{% static icon %}" />
{% if platform.store_name == 'google-play' %}
{% trans 'Play Store' %}
{% elif platform.store_name == 'f-droid' %}
{% trans 'F-Droid' %}
{% elif platform.store_name == 'app-store' %}
{% trans 'App Store' %}
{% endif %}
{% endfor %}
</ul>
</div>
</td>
</tr>
{% endfor %}
{% endwith %}
{% endif %}
{% endwith %}
</a>
{% endif %}
{% endfor %}
</td>
</tr>
{% endfor %}
{% endwith %}
{% with clients|clients_of_type:'package' as package_clients %}
{% for client in package_clients %}
<tr>
{% if forloop.counter == 1 %}
<th rowspan="{{ package_clients|length }}">{% trans "Package" %}</th>
{% endif %}
<td>{{ client.name }}</td>
<td>
<div class="row">
<ul>
{% for platform in client.platforms %}
{% if platform.type == 'package' %}
{% if platform.format == 'deb' %}
<li><strong>{% trans "Debian:" %}</strong> {{ platform.name }}</li>
{% endif %}
{% if platform.format == 'homebrew' %}
<li><strong>{% trans "Homebrew:" %}</strong> {{ platform.name }}</li>
{% endif %}
{% if platform.format == 'rpm' %}
<li><strong>{% trans "RPM:" %}</strong> {{ platform.name }}</li>
{% endif %}
{% endif %}
{% endfor %}
</ul>
</div>
</td>
</tr>
{% endfor %}
{% endwith %}
</table>
</div>
{% endif %}

View File

@ -16,40 +16,14 @@
#
import os
from enum import Enum
from django import template
from plinth import clients as clients_module
register = template.Library()
class Desktop_OS(Enum):
GNU_LINUX = 'gnu-linux'
MAC_OS = 'mac-os'
WINDOWS = 'windows'
class Mobile_OS(Enum):
ANDROID = 'android'
IOS = 'ios'
class Store(Enum):
APP_STORE = 'app-store'
F_DROID = 'f-droid'
GOOGLE_PLAY = 'google-play'
class Package(Enum):
DEB = 'deb'
HOMEBREW = 'brew'
RPM = 'rpm'
def enum_values(enum):
return [x.value for x in list(enum)]
def mark_active_menuitem(menu, path):
"""Mark the best-matching menu item with 'active'
@ -89,59 +63,7 @@ def show_subsubmenu(context, menu):
return {'subsubmenu': menu}
def __check(clients, cond):
"""Check if any of a list of clients satisfies the given condition"""
clients = clients if isinstance(clients, list) else [clients]
return any(pf for client in clients for pf in client['platforms']
if cond(pf))
@register.filter(name='has_desktop_clients')
def has_desktop_clients(clients):
"""Filter to find out whether an application has desktop clients"""
return __check(clients,
lambda x: x.get('os', '') in enum_values(Desktop_OS))
@register.filter(name='has_mobile_clients')
def has_mobile_clients(clients):
"""Filter to find out whether an application has mobile clients"""
return __check(clients,
lambda x: x.get('os', '') in enum_values(Mobile_OS))
@register.filter(name='has_web_clients')
def has_web_clients(clients):
"""Filter to find out whether an application has web clients"""
return __check(clients, lambda x: x['type'] == 'web')
@register.filter(name='has_package_clients')
def has_package_clients(clients):
"""Filter to find out whether an application has web clients"""
return __check(clients, lambda x: x['type'] == 'package')
@register.filter(name='of_type')
def of_type(clients, typ):
@register.filter(name='clients_of_type')
def clients_of_type(clients, client_type):
"""Filter and get clients of a particular type"""
filters = {
'mobile': has_mobile_clients,
'desktop': has_desktop_clients,
'web': has_web_clients,
'package': has_package_clients,
}
return list(filter(filters.get(typ, lambda x: x), clients))
@register.filter(name='display_name')
def display_name(string):
names = {
'gnu-linux': 'GNU/Linux',
'windows': 'Windows',
'mac-os': 'macOS',
'google-play': 'Play Store',
'f-droid': 'F-Droid',
'app-store': 'App Store'
}
return names.get(string, string)
return clients_module.of_type(clients, client_type)

View File

@ -0,0 +1,52 @@
#
# This file is part of Plinth.
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as
# published by the Free Software Foundation, either version 3 of the
# License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
#
"""
Test module for clients module.
"""
import unittest
from plinth import clients
from plinth.modules.deluge.manifest import clients as deluge_clients
from plinth.modules.infinoted.manifest import clients as infinoted_clients
from plinth.modules.quassel.manifest import clients as quassel_clients
from plinth.modules.syncthing.manifest import clients as syncthing_clients
from plinth.modules.tor.manifest import clients as tor_clients
class TestClients(unittest.TestCase):
"""Test utilities provided by clients module."""
def test_of_type_web(self):
"""Test filtering clients of type web."""
self.assertTrue(clients.of_type(syncthing_clients, 'web'))
self.assertFalse(clients.of_type(quassel_clients, 'web'))
def test_of_type_mobile(self):
"""Test filtering clients of type mobile."""
self.assertTrue(clients.of_type(syncthing_clients, 'mobile'))
self.assertFalse(clients.of_type(infinoted_clients, 'mobile'))
def test_of_type_desktop(self):
"""Test filtering clients of type desktop."""
self.assertTrue(clients.of_type(syncthing_clients, 'desktop'))
self.assertFalse(clients.of_type(deluge_clients, 'desktop'))
def test_of_type_package(self):
"""Test filtering clients of type package."""
self.assertTrue(clients.of_type(syncthing_clients, 'package'))
self.assertFalse(clients.of_type(tor_clients, 'package'))

View File

@ -20,10 +20,6 @@ Test module for custom Django template tags.
import unittest
from plinth.modules.syncthing.manifest import clients as syncthing_clients
from plinth.modules.infinoted.manifest import clients as infinoted_clients
from plinth.modules.deluge.manifest import clients as deluge_clients
from plinth.modules.quassel.manifest import clients as quassel_clients
from plinth.templatetags import plinth_extras
@ -68,22 +64,3 @@ class TestShowSubSubMenu(unittest.TestCase):
menu = plinth_extras.mark_active_menuitem(menu, check_path)
self.assert_active_url(menu, expected_active_path)
self.assertTrue(self._verify_active_menuitems(menu))
def test_has_web_clients(self):
"""Test for a utility function that returns
whether an application has web clients"""
self.assertTrue(plinth_extras.has_web_clients(syncthing_clients))
self.assertFalse(plinth_extras.has_web_clients(quassel_clients))
def test_has_mobile_clients(self):
"""Test for a utility function that returns
whether an application has mobile clients"""
self.assertTrue(plinth_extras.has_mobile_clients(syncthing_clients))
self.assertFalse(plinth_extras.has_mobile_clients(infinoted_clients))
def test_has_desktop_clients(self):
"""Test for a utility function that returns
whether an application has desktop clients"""
self.assertTrue(
plinth_extras.has_desktop_clients(syncthing_clients[0]))
self.assertFalse(plinth_extras.has_desktop_clients(deluge_clients))

View File

@ -115,12 +115,3 @@ class YAMLFile(object):
def is_file_empty(self):
return os.stat(self.yaml_file).st_size == 0
def play_store_url(package_id):
return 'https://play.google.com/store/apps/details?id={}'.format(
package_id)
def f_droid_url(package_id):
return 'https://f-droid.org/packages/{}'.format(package_id)

View File

@ -43,10 +43,8 @@ body {
}
.running-status.loading {
border: 4px solid #f3f3f3;
/* Light grey */
border-top: 4px solid #3498db;
/* Blue */
border: 4px solid #f3f3f3; /* Light grey */
border-top: 4px solid #3498db; /* Blue */
border-radius: 50%;
width: 16px;
height: 16px;
@ -68,13 +66,11 @@ body {
}
/* Hide log out button if user dropdown is available */
.js #logout-nojs {
display: none;
}
/* Hide the dropdown icon when javascript is not available */
.no-js .nav .dropdown .caret {
display: none;
}
@ -85,7 +81,6 @@ body {
/* Sticky footer styles
-------------------------------------------------- */
footer .license-info {
opacity: 0.75;
}
@ -125,25 +120,13 @@ footer license-info p {
margin: 20px 0;
}
.clients-info {
padding-top: 15px;
}
.heading {
font-weight: bold;
display: inline;
}
.clients-info li {
padding-left: 15px;
padding-bottom: 5px;
}
.clients-info li span {
position: relative;
left: -18px;
.shortcut-label {
min-height: 50px;
}
/*
* Clients information
*/
.client-icon {
display: inline-block;
width: 100%;
@ -162,20 +145,17 @@ footer license-info p {
line-height: 3.1em;
}
.shortcut-label {
min-height: 50px;
}
/* Icon when collapsible content is shown */
#collapsible-button:after {
font-family: "Glyphicons Halflings";
content: "\e114";
#clients-button .glyphicon {
margin-left: 5px;
}
/* Icon when collapsible content is hidden */
#collapsible-button.collapsed:after {
#clients-button .glyphicon:before,
.no-js #clients-button.collapsed .glyphicon:before {
content: "\e114";
}
#clients-button.collapsed .glyphicon:before {
content: "\e080";
}
@ -183,14 +163,3 @@ footer license-info p {
.no-js .collapse {
display: block;
}
.no-js #collapsible-button:after {
font-family: "Glyphicons Halflings";
content: "\e114";
margin-left: 5px;
}
.no-js #collapsible-button.collapsed:after {
content: "\e114";
}