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 <sunil@medhas.org>
Reviewed-by: James Valleroy <jvalleroy@mailbox.org>
This commit is contained in:
Sunil Mohan Adapa 2025-01-13 17:08:39 -08:00 committed by James Valleroy
parent 6d2f992a42
commit 4176f53e05
No known key found for this signature in database
GPG Key ID: 77C0C75E7B650808
8 changed files with 215 additions and 59 deletions

View File

@ -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 &lt;User&gt;, &lt;Pass&gt;, &lt;Ip&gt;, '
'&lt;Domain&gt; may be used within the URL. For details '

View File

@ -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();

View File

@ -0,0 +1,25 @@
{% extends "base.html" %}
{% comment %}
# SPDX-License-Identifier: AGPL-3.0-or-later
{% endcomment %}
{% load i18n %}
{% block content %}
<h3>{{ title }}</h3>
<p>
{% blocktrans trimmed %}
App configurations will be updated.
{% endblocktrans %}
</p>
<form class="form form-delete" method="post">
{% csrf_token %}
<input type="submit" class="btn btn-md btn-danger"
value="{% blocktrans %}Delete{% endblocktrans %}"/>
</form>
{% endblock %}

View File

@ -0,0 +1,11 @@
{% extends "form.html" %}
{% comment %}
# SPDX-License-Identifier: AGPL-3.0-or-later
{% endcomment %}
{% load static %}
{% block page_js %}
<script type="text/javascript" src="{% static 'dynamicdns/dynamicdns.js' %}"
defer></script>
{% endblock %}

View File

@ -5,18 +5,20 @@
{% load bootstrap %}
{% load i18n %}
{% load static %}
{% block page_js %}
<script type="text/javascript" src="{% static 'dynamicdns/dynamicdns.js' %}"
defer></script>
{% endblock %}
{% block extra_content %}
<h3>{% trans "Status" %}</h3>
<h3>{% trans "Domains" %}</h3>
<div class="btn-toolbar">
<a href="{% url 'dynamicdns:domain-add' %}" class="btn btn-primary"
role="button" title="{% trans 'Add Domain' %}">
<span class="fa fa-plus" aria-hidden="true"></span>
{% trans 'Add Domain' %}
</a>
</div>
{% if domains_status %}
<div class="table-responsive">
<div class="table-responsive domains-status">
<table class="table">
<thead>
<tr>
@ -24,12 +26,20 @@
<th>{% trans "Last update" %}</th>
<th>{% trans "Result" %}</th>
<th>{% trans "IP Address" %}</th>
<th>{% trans "Actions" %}</th>
</tr>
</thead>
<tbody>
{% for domain in domains_status.values %}
<tr>
<td>{{ domain.domain }}</td>
<td class="domain-name">
<a href="{% url 'dynamicdns:domain-edit' domain.domain %}"
title="{% blocktrans trimmed with domain=domain.domain %}
Edit domain {{ domain }}
{% endblocktrans %}">
{{ domain.domain }}
</a>
</td>
<td>{{ domain.timestamp|timesince }}</td>
<td>
{% if domain.result %}
@ -48,12 +58,28 @@
{% endif %}
</td>
<td>{{ domain.ip_address|default_if_none:'-' }}</td>
<td>
<a href="{% url 'dynamicdns:domain-edit' domain.domain %}"
class="btn btn-default btn-sm domain-edit" role="button"
title="{% blocktrans trimmed with domain=domain.domain %}
Edit domain {{ domain }}
{% endblocktrans %}">
<span class="fa fa-pencil-square-o" aria-hidden="true"></span>
</a>
<a href="{% url 'dynamicdns:domain-delete' domain.domain %}"
class="btn btn-default btn-sm domain-delete" role="button"
title="{% blocktrans trimmed with domain=domain.main %}
Delete domain {{ domain }}
{% endblocktrans %}">
<span class="fa fa-trash" aria-hidden="true"></span>
</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
{% trans "No status available." %}
<p>{% trans "No domains configured." %}</p>
{% endif %}
{% endblock %}

View File

@ -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')

View File

@ -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<domain>[^/]+)/edit/$',
views.DomainView.as_view(), name='domain-edit'),
re_path(r'^sys/dynamicdns/domain/(?P<domain>[^/]+)/delete/$',
views.DomainDeleteView.as_view(), name='domain-delete'),
]

View File

@ -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')