From 4176f53e05a3f94c58e6ee1b060d990afa3398a9 Mon Sep 17 00:00:00 2001 From: Sunil Mohan Adapa Date: Mon, 13 Jan 2025 17:08:39 -0800 Subject: [PATCH] dynamicdns: Implement adding multiple domains Tests: - Functional tests pass. - Adding domain triggers domain_added signal. - Editing a domain triggers domain removed and domain added signals. - Deleting a domain trigger domain removed signal. - For each of the action, the status table shows updated information. Signed-off-by: Sunil Mohan Adapa Reviewed-by: James Valleroy --- plinth/modules/dynamicdns/forms.py | 4 +- .../modules/dynamicdns/static/dynamicdns.js | 40 ++++---- .../templates/dynamicdns-domain-delete.html | 25 +++++ .../templates/dynamicdns-domain.html | 11 +++ .../dynamicdns/templates/dynamicdns.html | 46 +++++++-- .../dynamicdns/tests/test_functional.py | 44 ++++++--- plinth/modules/dynamicdns/urls.py | 6 ++ plinth/modules/dynamicdns/views.py | 98 ++++++++++++++++--- 8 files changed, 215 insertions(+), 59 deletions(-) create mode 100644 plinth/modules/dynamicdns/templates/dynamicdns-domain-delete.html create mode 100644 plinth/modules/dynamicdns/templates/dynamicdns-domain.html diff --git a/plinth/modules/dynamicdns/forms.py b/plinth/modules/dynamicdns/forms.py index 22c62dee7..a7165661b 100644 --- a/plinth/modules/dynamicdns/forms.py +++ b/plinth/modules/dynamicdns/forms.py @@ -12,8 +12,8 @@ from plinth import cfg from plinth.utils import format_lazy -class ConfigureForm(forms.Form): - """Form to configure the Dynamic DNS client.""" +class DomainForm(forms.Form): + """Form to add/edit a domain in the Dynamic DNS client.""" help_update_url = \ gettext_lazy('The Variables <User>, <Pass>, <Ip>, ' '<Domain> may be used within the URL. For details ' diff --git a/plinth/modules/dynamicdns/static/dynamicdns.js b/plinth/modules/dynamicdns/static/dynamicdns.js index 73d3435d4..acc18d840 100644 --- a/plinth/modules/dynamicdns/static/dynamicdns.js +++ b/plinth/modules/dynamicdns/static/dynamicdns.js @@ -28,29 +28,29 @@ document.addEventListener('DOMContentLoaded', () => { const FREEDNS = 'https://freedns.afraid.org/dynamic/update.php?' + '_YOURAPIKEYHERE_'; - document.getElementById('id_service_type').addEventListener('change', () => { + document.getElementById('id_domain-service_type').addEventListener('change', () => { setMode(); - const service_type = document.getElementById('id_service_type').value; + const service_type = document.getElementById('id_domain-service_type').value; if (service_type === "noip.com") { - document.getElementById('id_update_url').value = NOIP; + document.getElementById('id_domain-update_url').value = NOIP; } else if (service_type === "freedns.afraid.org") { - document.getElementById('id_update_url').value = FREEDNS; + document.getElementById('id_domain-update_url').value = FREEDNS; } else { // GnuDIP and other - document.getElementById('id_update_url').value = ''; + document.getElementById('id_domain-update_url').value = ''; } }); - document.getElementById('id_show_password').addEventListener('change', () => { - if (document.getElementById('id_show_password').checked) { - document.getElementById('id_password').type = 'text'; + document.getElementById('id_domain-show_password').addEventListener('change', () => { + if (document.getElementById('id_domain-show_password').checked) { + document.getElementById('id_domain-password').type = 'text'; } else { - document.getElementById('id_password').type = 'password'; + document.getElementById('id_domain-password').type = 'password'; } }); function setMode() { - const service_type = document.getElementById('id_service_type').value; + const service_type = document.getElementById('id_domain-service_type').value; if (service_type === "gnudip") { setGnudipMode(); } else { @@ -62,19 +62,19 @@ document.addEventListener('DOMContentLoaded', () => { document.querySelectorAll('.form-group').forEach((element) => { element.style.display = 'block'; }); - document.getElementById('id_update_url').closest('.form-group').style.display = 'none'; - document.getElementById('id_disable_ssl_cert_check').closest('.form-group').style.display = 'none'; - document.getElementById('id_use_http_basic_auth').closest('.form-group').style.display = 'none'; - document.getElementById('id_use_ipv6').closest('.form-group').style.display = 'none'; - document.getElementById('id_server').closest('.form-group').style.display = 'block'; + document.getElementById('id_domain-update_url').closest('.form-group').style.display = 'none'; + document.getElementById('id_domain-disable_ssl_cert_check').closest('.form-group').style.display = 'none'; + document.getElementById('id_domain-use_http_basic_auth').closest('.form-group').style.display = 'none'; + document.getElementById('id_domain-use_ipv6').closest('.form-group').style.display = 'none'; + document.getElementById('id_domain-server').closest('.form-group').style.display = 'block'; } function setUpdateUrlMode() { - document.getElementById('id_update_url').closest('.form-group').style.display = 'block'; - document.getElementById('id_disable_ssl_cert_check').closest('.form-group').style.display = 'block'; - document.getElementById('id_use_http_basic_auth').closest('.form-group').style.display = 'block'; - document.getElementById('id_use_ipv6').closest('.form-group').style.display = 'block'; - document.getElementById('id_server').closest('.form-group').style.display = 'none'; + document.getElementById('id_domain-update_url').closest('.form-group').style.display = 'block'; + document.getElementById('id_domain-disable_ssl_cert_check').closest('.form-group').style.display = 'block'; + document.getElementById('id_domain-use_http_basic_auth').closest('.form-group').style.display = 'block'; + document.getElementById('id_domain-use_ipv6').closest('.form-group').style.display = 'block'; + document.getElementById('id_domain-server').closest('.form-group').style.display = 'none'; } setMode(); diff --git a/plinth/modules/dynamicdns/templates/dynamicdns-domain-delete.html b/plinth/modules/dynamicdns/templates/dynamicdns-domain-delete.html new file mode 100644 index 000000000..9affc6ead --- /dev/null +++ b/plinth/modules/dynamicdns/templates/dynamicdns-domain-delete.html @@ -0,0 +1,25 @@ +{% extends "base.html" %} +{% comment %} +# SPDX-License-Identifier: AGPL-3.0-or-later +{% endcomment %} + +{% load i18n %} + +{% block content %} + +

{{ title }}

+ +

+ {% blocktrans trimmed %} + App configurations will be updated. + {% endblocktrans %} +

+ +
+ {% csrf_token %} + + +
+ +{% endblock %} diff --git a/plinth/modules/dynamicdns/templates/dynamicdns-domain.html b/plinth/modules/dynamicdns/templates/dynamicdns-domain.html new file mode 100644 index 000000000..8943b5da9 --- /dev/null +++ b/plinth/modules/dynamicdns/templates/dynamicdns-domain.html @@ -0,0 +1,11 @@ +{% extends "form.html" %} +{% comment %} +# SPDX-License-Identifier: AGPL-3.0-or-later +{% endcomment %} + +{% load static %} + +{% block page_js %} + +{% endblock %} diff --git a/plinth/modules/dynamicdns/templates/dynamicdns.html b/plinth/modules/dynamicdns/templates/dynamicdns.html index 0a517321f..55a498fba 100644 --- a/plinth/modules/dynamicdns/templates/dynamicdns.html +++ b/plinth/modules/dynamicdns/templates/dynamicdns.html @@ -5,18 +5,20 @@ {% load bootstrap %} {% load i18n %} -{% load static %} - -{% block page_js %} - -{% endblock %} {% block extra_content %} -

{% trans "Status" %}

+

{% trans "Domains" %}

+ + {% if domains_status %} -
+
@@ -24,12 +26,20 @@ + {% for domain in domains_status.values %} - + + {% endfor %}
{% trans "Last update" %} {% trans "Result" %} {% trans "IP Address" %}{% trans "Actions" %}
{{ domain.domain }} + + {{ domain.domain }} + + {{ domain.timestamp|timesince }} {% if domain.result %} @@ -48,12 +58,28 @@ {% endif %} {{ domain.ip_address|default_if_none:'-' }} + + + + + + +
{% else %} - {% trans "No status available." %} +

{% trans "No domains configured." %}

{% endif %} {% endblock %} diff --git a/plinth/modules/dynamicdns/tests/test_functional.py b/plinth/modules/dynamicdns/tests/test_functional.py index cd0363e26..10df62575 100644 --- a/plinth/modules/dynamicdns/tests/test_functional.py +++ b/plinth/modules/dynamicdns/tests/test_functional.py @@ -76,8 +76,8 @@ class TestDynamicDNSApp(functional.BaseAppTests): @staticmethod def test_capitalized_domain_name(session_browser): """Test handling of capitalized domain name.""" - _configure(session_browser, _configs['gnudip1']) - _configure(session_browser, {'domain': 'FreedomBox.example.com'}) + config = dict(_configs['gnudip1'], domain='FreedomBox.example.com') + _configure(session_browser, config) _assert_has_config(session_browser, {'domain': 'freedombox.example.com'}) @@ -105,32 +105,54 @@ class TestDynamicDNSApp(functional.BaseAppTests): def _configure(browser, config): functional.nav_to_module(browser, 'dynamicdns') + current_domains = _get_domains(browser) + for domain in current_domains: + if domain.endswith('.example.com'): + _delete_domain(browser, domain) + + functional.nav_to_module(browser, 'dynamicdns') + functional.click_link_by_href(browser, + '/plinth/sys/dynamicdns/domain/add/') for key, value in config.items(): + field_id = f'id_domain-{key}' if key == 'service_type': - browser.find_by_id(f'id_{key}').select(value) + browser.find_by_id(field_id).select(value) elif isinstance(value, bool): if value: - browser.find_by_id(f'id_{key}').check() + browser.find_by_id(field_id).check() else: - browser.find_by_id(f'id_{key}').uncheck() + browser.find_by_id(field_id).uncheck() else: - browser.find_by_id(f'id_{key}').fill(value) + browser.find_by_id(field_id).fill(value) - functional.submit(browser, form_class='form-configuration') + functional.submit(browser, form_class='form-domain') def _assert_has_config(browser, config): functional.nav_to_module(browser, 'dynamicdns') + link = f'/plinth/sys/dynamicdns/domain/{config["domain"]}/edit/' + functional.click_link_by_href(browser, link) for key, value in config.items(): if key == 'password': continue + field_id = f'id_domain-{key}' if isinstance(value, bool): - assert browser.find_by_id(f'id_{key}').checked == value + assert browser.find_by_id(field_id).checked == value else: - assert value == browser.find_by_id(f'id_{key}').value + assert value == browser.find_by_id(field_id).value -def _get_domain(browser): +def _get_domains(browser): + """Return the list of configured domains.""" functional.nav_to_module(browser, 'dynamicdns') - return browser.find_by_id('id_domain').value + elements = browser.find_by_css('.domains-status .domain-name a') + return [element.text.strip() for element in elements] + + +def _delete_domain(browser, domain): + """Delete a given domain.""" + functional.nav_to_module(browser, 'dynamicdns') + link = f'/plinth/sys/dynamicdns/domain/{domain}/delete/' + functional.click_link_by_href(browser, link) + functional.submit(browser, form_class='form-delete') diff --git a/plinth/modules/dynamicdns/urls.py b/plinth/modules/dynamicdns/urls.py index af9f805d6..f92ecfd69 100644 --- a/plinth/modules/dynamicdns/urls.py +++ b/plinth/modules/dynamicdns/urls.py @@ -10,4 +10,10 @@ from . import views urlpatterns = [ re_path(r'^sys/dynamicdns/$', views.DynamicDNSAppView.as_view(), name='index'), + re_path(r'^sys/dynamicdns/domain/add/$', views.DomainView.as_view(), + name='domain-add'), + re_path(r'^sys/dynamicdns/domain/(?P[^/]+)/edit/$', + views.DomainView.as_view(), name='domain-edit'), + re_path(r'^sys/dynamicdns/domain/(?P[^/]+)/delete/$', + views.DomainDeleteView.as_view(), name='domain-delete'), ] diff --git a/plinth/modules/dynamicdns/views.py b/plinth/modules/dynamicdns/views.py index 9d0d0034b..0e4e48d38 100644 --- a/plinth/modules/dynamicdns/views.py +++ b/plinth/modules/dynamicdns/views.py @@ -1,24 +1,26 @@ # SPDX-License-Identifier: AGPL-3.0-or-later -""" -Views for the dynamicsdns module. -""" +"""Views for the dynamicsdns module.""" import datetime from django.contrib import messages +from django.shortcuts import redirect +from django.urls import reverse_lazy from django.utils.translation import gettext_lazy as _ +from django.views.generic import TemplateView +from django.views.generic.edit import FormView from plinth import views from plinth.modules import dynamicdns -from .forms import ConfigureForm +from .forms import DomainForm class DynamicDNSAppView(views.AppView): - """Serve configuration page.""" + """View to show app status.""" + app_id = 'dynamicdns' template_name = 'dynamicdns.html' - form_class = ConfigureForm _error_messages = { 'timeout': _('Connection timed out'), @@ -31,8 +33,10 @@ class DynamicDNSAppView(views.AppView): def get_context_data(self, **kwargs): """Return the context data for rendering the template view.""" context = super().get_context_data(**kwargs) - status = dynamicdns.get_status() config = dynamicdns.get_config() + context['domains'] = config['domains'] + + status = dynamicdns.get_status() domains_status = {} for domain_name, domain in status['domains'].items(): if domain_name not in config['domains']: @@ -49,11 +53,35 @@ class DynamicDNSAppView(views.AppView): context['domains_status'] = domains_status return context + +class DomainView(FormView): + """View to add/edit a dynamic DNS domain.""" + + template_name = 'dynamicdns-domain.html' + form_class = DomainForm + prefix = 'domain' + success_url = reverse_lazy('dynamicdns:index') + + def get_context_data(self, **kwargs): + """Return the context data for rendering the template view.""" + context = super().get_context_data(**kwargs) + domain_name = self.kwargs.get('domain') + if not domain_name: + context['title'] = _('Add Dynamic Domain') + else: + context['title'] = _('Edit Dynamic Domain') + + return context + def get_initial(self): """Get the current values for the form.""" initial = super().get_initial() + domain_name = self.kwargs.get('domain') domains = dynamicdns.get_config()['domains'] - domain = list(domains.values())[0] if domains else {} + domain = {} + if domains and domain_name and domain_name in domains: + domain = domains[domain_name] + initial.update(domain) return domain @@ -63,21 +91,59 @@ class DynamicDNSAppView(views.AppView): new_status = form.cleaned_data if old_status != new_status: - config = dynamicdns.get_config() try: - del config['domains'][old_status['domain']] + _domain_delete(old_status['domain']) except KeyError: pass - config['domains'][new_status['domain']] = new_status - dynamicdns.set_config(config) - if old_status.get('domain'): - dynamicdns.notify_domain_removed(old_status['domain']) - - dynamicdns.notify_domain_added(new_status['domain']) + _domain_add(new_status['domain'], new_status) messages.success(self.request, _('Configuration updated')) # Perform an immediate update, even when configuration is not changed. dynamicdns.update_dns(None) return super().form_valid(form) + + +def _domain_add(domain: str, domain_config: dict): + """Add a domain to the configuration.""" + config = dynamicdns.get_config() + config['domains'][domain] = domain_config + dynamicdns.set_config(config) + dynamicdns.notify_domain_added(domain) + + +def _domain_delete(domain: str): + """Remove a domain from the configuration. + + Raises KeyError if the domain is not found in the configuration. + """ + config = dynamicdns.get_config() + del config['domains'][domain] + dynamicdns.set_config(config) + if domain: + dynamicdns.notify_domain_removed(domain) + + +class DomainDeleteView(TemplateView): + """Confirm and delete a domain.""" + template_name = 'dynamicdns-domain-delete.html' + + def get_context_data(self, **kwargs): + """Return additional context data for rendering the template.""" + context = super().get_context_data(**kwargs) + domain = self.kwargs['domain'] + context['domain'] = domain + context['title'] = str( + _('Delete Domain {domain}?')).format(domain=domain) + return context + + def post(self, request, domain): + """Delete a domain.""" + try: + _domain_delete(domain) + messages.success(request, _('Domain deleted.')) + except KeyError: + raise + + return redirect('dynamicdns:index')