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 from plinth.utils import format_lazy
class ConfigureForm(forms.Form): class DomainForm(forms.Form):
"""Form to configure the Dynamic DNS client.""" """Form to add/edit a domain in the Dynamic DNS client."""
help_update_url = \ help_update_url = \
gettext_lazy('The Variables &lt;User&gt;, &lt;Pass&gt;, &lt;Ip&gt;, ' gettext_lazy('The Variables &lt;User&gt;, &lt;Pass&gt;, &lt;Ip&gt;, '
'&lt;Domain&gt; may be used within the URL. For details ' '&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?' + const FREEDNS = 'https://freedns.afraid.org/dynamic/update.php?' +
'_YOURAPIKEYHERE_'; '_YOURAPIKEYHERE_';
document.getElementById('id_service_type').addEventListener('change', () => { document.getElementById('id_domain-service_type').addEventListener('change', () => {
setMode(); 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") { 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") { } 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 } else { // GnuDIP and other
document.getElementById('id_update_url').value = ''; document.getElementById('id_domain-update_url').value = '';
} }
}); });
document.getElementById('id_show_password').addEventListener('change', () => { document.getElementById('id_domain-show_password').addEventListener('change', () => {
if (document.getElementById('id_show_password').checked) { if (document.getElementById('id_domain-show_password').checked) {
document.getElementById('id_password').type = 'text'; document.getElementById('id_domain-password').type = 'text';
} else { } else {
document.getElementById('id_password').type = 'password'; document.getElementById('id_domain-password').type = 'password';
} }
}); });
function setMode() { 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") { if (service_type === "gnudip") {
setGnudipMode(); setGnudipMode();
} else { } else {
@ -62,19 +62,19 @@ document.addEventListener('DOMContentLoaded', () => {
document.querySelectorAll('.form-group').forEach((element) => { document.querySelectorAll('.form-group').forEach((element) => {
element.style.display = 'block'; element.style.display = 'block';
}); });
document.getElementById('id_update_url').closest('.form-group').style.display = 'none'; document.getElementById('id_domain-update_url').closest('.form-group').style.display = 'none';
document.getElementById('id_disable_ssl_cert_check').closest('.form-group').style.display = 'none'; document.getElementById('id_domain-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_domain-use_http_basic_auth').closest('.form-group').style.display = 'none';
document.getElementById('id_use_ipv6').closest('.form-group').style.display = 'none'; document.getElementById('id_domain-use_ipv6').closest('.form-group').style.display = 'none';
document.getElementById('id_server').closest('.form-group').style.display = 'block'; document.getElementById('id_domain-server').closest('.form-group').style.display = 'block';
} }
function setUpdateUrlMode() { function setUpdateUrlMode() {
document.getElementById('id_update_url').closest('.form-group').style.display = 'block'; document.getElementById('id_domain-update_url').closest('.form-group').style.display = 'block';
document.getElementById('id_disable_ssl_cert_check').closest('.form-group').style.display = 'block'; document.getElementById('id_domain-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_domain-use_http_basic_auth').closest('.form-group').style.display = 'block';
document.getElementById('id_use_ipv6').closest('.form-group').style.display = 'block'; document.getElementById('id_domain-use_ipv6').closest('.form-group').style.display = 'block';
document.getElementById('id_server').closest('.form-group').style.display = 'none'; document.getElementById('id_domain-server').closest('.form-group').style.display = 'none';
} }
setMode(); 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 bootstrap %}
{% load i18n %} {% load i18n %}
{% load static %}
{% block page_js %}
<script type="text/javascript" src="{% static 'dynamicdns/dynamicdns.js' %}"
defer></script>
{% endblock %}
{% block extra_content %} {% 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 %} {% if domains_status %}
<div class="table-responsive"> <div class="table-responsive domains-status">
<table class="table"> <table class="table">
<thead> <thead>
<tr> <tr>
@ -24,12 +26,20 @@
<th>{% trans "Last update" %}</th> <th>{% trans "Last update" %}</th>
<th>{% trans "Result" %}</th> <th>{% trans "Result" %}</th>
<th>{% trans "IP Address" %}</th> <th>{% trans "IP Address" %}</th>
<th>{% trans "Actions" %}</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{% for domain in domains_status.values %} {% for domain in domains_status.values %}
<tr> <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>{{ domain.timestamp|timesince }}</td>
<td> <td>
{% if domain.result %} {% if domain.result %}
@ -48,12 +58,28 @@
{% endif %} {% endif %}
</td> </td>
<td>{{ domain.ip_address|default_if_none:'-' }}</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> </tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>
</div> </div>
{% else %} {% else %}
{% trans "No status available." %} <p>{% trans "No domains configured." %}</p>
{% endif %} {% endif %}
{% endblock %} {% endblock %}

View File

@ -76,8 +76,8 @@ class TestDynamicDNSApp(functional.BaseAppTests):
@staticmethod @staticmethod
def test_capitalized_domain_name(session_browser): def test_capitalized_domain_name(session_browser):
"""Test handling of capitalized domain name.""" """Test handling of capitalized domain name."""
_configure(session_browser, _configs['gnudip1']) config = dict(_configs['gnudip1'], domain='FreedomBox.example.com')
_configure(session_browser, {'domain': 'FreedomBox.example.com'}) _configure(session_browser, config)
_assert_has_config(session_browser, _assert_has_config(session_browser,
{'domain': 'freedombox.example.com'}) {'domain': 'freedombox.example.com'})
@ -105,32 +105,54 @@ class TestDynamicDNSApp(functional.BaseAppTests):
def _configure(browser, config): def _configure(browser, config):
functional.nav_to_module(browser, 'dynamicdns') 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(): for key, value in config.items():
field_id = f'id_domain-{key}'
if key == 'service_type': 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): elif isinstance(value, bool):
if value: if value:
browser.find_by_id(f'id_{key}').check() browser.find_by_id(field_id).check()
else: else:
browser.find_by_id(f'id_{key}').uncheck() browser.find_by_id(field_id).uncheck()
else: 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): def _assert_has_config(browser, config):
functional.nav_to_module(browser, 'dynamicdns') 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(): for key, value in config.items():
if key == 'password': if key == 'password':
continue continue
field_id = f'id_domain-{key}'
if isinstance(value, bool): if isinstance(value, bool):
assert browser.find_by_id(f'id_{key}').checked == value assert browser.find_by_id(field_id).checked == value
else: 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') 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 = [ urlpatterns = [
re_path(r'^sys/dynamicdns/$', views.DynamicDNSAppView.as_view(), re_path(r'^sys/dynamicdns/$', views.DynamicDNSAppView.as_view(),
name='index'), 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 # SPDX-License-Identifier: AGPL-3.0-or-later
""" """Views for the dynamicsdns module."""
Views for the dynamicsdns module.
"""
import datetime import datetime
from django.contrib import messages 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.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 import views
from plinth.modules import dynamicdns from plinth.modules import dynamicdns
from .forms import ConfigureForm from .forms import DomainForm
class DynamicDNSAppView(views.AppView): class DynamicDNSAppView(views.AppView):
"""Serve configuration page.""" """View to show app status."""
app_id = 'dynamicdns' app_id = 'dynamicdns'
template_name = 'dynamicdns.html' template_name = 'dynamicdns.html'
form_class = ConfigureForm
_error_messages = { _error_messages = {
'timeout': _('Connection timed out'), 'timeout': _('Connection timed out'),
@ -31,8 +33,10 @@ class DynamicDNSAppView(views.AppView):
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
"""Return the context data for rendering the template view.""" """Return the context data for rendering the template view."""
context = super().get_context_data(**kwargs) context = super().get_context_data(**kwargs)
status = dynamicdns.get_status()
config = dynamicdns.get_config() config = dynamicdns.get_config()
context['domains'] = config['domains']
status = dynamicdns.get_status()
domains_status = {} domains_status = {}
for domain_name, domain in status['domains'].items(): for domain_name, domain in status['domains'].items():
if domain_name not in config['domains']: if domain_name not in config['domains']:
@ -49,11 +53,35 @@ class DynamicDNSAppView(views.AppView):
context['domains_status'] = domains_status context['domains_status'] = domains_status
return context 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): def get_initial(self):
"""Get the current values for the form.""" """Get the current values for the form."""
initial = super().get_initial() initial = super().get_initial()
domain_name = self.kwargs.get('domain')
domains = dynamicdns.get_config()['domains'] 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) initial.update(domain)
return domain return domain
@ -63,21 +91,59 @@ class DynamicDNSAppView(views.AppView):
new_status = form.cleaned_data new_status = form.cleaned_data
if old_status != new_status: if old_status != new_status:
config = dynamicdns.get_config()
try: try:
del config['domains'][old_status['domain']] _domain_delete(old_status['domain'])
except KeyError: except KeyError:
pass pass
config['domains'][new_status['domain']] = new_status _domain_add(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'])
messages.success(self.request, _('Configuration updated')) messages.success(self.request, _('Configuration updated'))
# Perform an immediate update, even when configuration is not changed. # Perform an immediate update, even when configuration is not changed.
dynamicdns.update_dns(None) dynamicdns.update_dns(None)
return super().form_valid(form) 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')