Skip to content
Draft
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@
<div class="row">
<div class="col-md-12">
<h3>History for {{ name }}</h3>
{% if hostnames|length > 1 %}
<p>{{ hostnames|join:", " }}</p>
{% endif %}
</div>
</div>
<div class="row">
Expand All @@ -22,7 +25,7 @@ <h3>History for {{ name }}</h3>
<div>
<ul class="attributes">
{% for attribute in no_history_attributes %}
<li>{{ attribute.attribute_id }}</li>
<li>{{ attribute }}</li>
{% endfor %}
</ul>
</div>
Expand All @@ -34,7 +37,9 @@ <h3>History for {{ name }}</h3>
</div>
</div>
<div class="form-group row input-controls buttons">
<input type="hidden" id="object_id" name="object_id" value="{{ object_id }}"/>
{% for object_id in object_ids %}
<input type="hidden" name="object_id" value="{{ object_id }}"/>
{% endfor %}
<button class="btn btn-success" type="submit">Filter</button>
</div>
</div>
Expand All @@ -46,6 +51,7 @@ <h3>History for {{ name }}</h3>
<thead>
<tr>
<th>Commit</th>
<th>Hostname</th>
<th>Date</th>
<th>Time Since</th>
<th>App</th>
Expand All @@ -62,6 +68,7 @@ <h3>History for {{ name }}</h3>
{% if not attribute_filter or attribute_filter == key %}
<tr>
<td><a href="{% url 'serverdb_changes' %}?commit_id={{ change.commit.id }}">{{ change.commit.id }}</a></td>
<td><a href="{% url 'servershell_inspect' %}?object_id={{ change.object_id }}">{{ change.hostname|default:change.object_id }}</a></td>
<td>{{ change.commit.change_on|date:"r" }}</td>
<td>{{ change.commit.change_on|timesince }}</td>
<td>{{ change.commit.app|default:"Servershell" }}</td>
Expand All @@ -88,6 +95,7 @@ <h3>History for {{ name }}</h3>
{% if not attribute_filter or attribute_filter == key %}
<tr>
<td><a href="{% url 'serverdb_changes' %}?commit_id={{ change.commit.id }}">{{ change.commit.id }}</a></td>
<td><a href="{% url 'servershell_inspect' %}?object_id={{ change.object_id }}">{{ change.hostname|default:change.object_id }}</a></td>
<td>{{ change.commit.change_on|date:"r" }}</td>
<td>{{ change.commit.change_on|timesince }}</td>
<td>{{ change.commit.app|default:"Servershell" }}</td>
Expand Down Expand Up @@ -127,14 +135,15 @@ <h3>History for {{ name }}</h3>
{% elif change.change_type == 'delete' %}
<tr class="">
<td><a href="{% url 'serverdb_changes' %}?commit_id={{ change.commit.id }}">{{ change.commit.id }}</a></td>
<td>{{ change.hostname|default:change.object_id }}</td>
<td>{{ change.commit.change_on|date:"r" }}</td>
<td>{{ change.channge_commit.change_on|timesince }}</td>
<td>{{ change.commit.change_on|timesince }}</td>
<td>{{ change.commit.app|default:"Servershell" }}</td>
<td>{{ change.commit.user }}</td>
<td>
<form method="post" action="{% url 'serverdb_recreate' change.commit.id %}">
{% csrf_token %}
<input type="hidden" name="object_id" value="{{ change.server_id }}" />
<input type="hidden" name="object_id" value="{{ change.object_id }}" />
<input class="btn btn-sm btn-danger" type="submit" value="Re-create this server" />
</form>
</td>
Expand All @@ -146,7 +155,7 @@ <h3>History for {{ name }}</h3>
</tbody>
</table>
{% if commit_id %}
<a href="{% url 'serverdb_history' %}?object_id={{ object_id }}">Show complete history</a>
<a href="{% url 'serverdb_history' %}?{{ object_ids_query }}">Show complete history</a>
{% endif %}
</div>
</div>
Expand Down
91 changes: 91 additions & 0 deletions packages/serveradmin/serveradmin/serverdb/tests/test_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from django.contrib.auth.models import User
from django.test import TransactionTestCase

from adminapi.filters import Any
from serveradmin.dataset import Query
from serveradmin.serverdb.models import Change

Expand Down Expand Up @@ -66,3 +67,93 @@ def test_recreate_fails_if_hostname_exists(self):
self.assertEqual(200, response.status_code)
self.assertEqual('serverdb/changes.html', response.template_name)
self.assertEqual(302, response.redirect_chain[0][1])


class HistoryViewTest(TransactionTestCase):
fixtures = ['auth_user.json', 'test_dataset.json']

def setUp(self) -> None:
self.client.login(username='hannah.acker', password='hannah.acker')

self.object_ids = []
for i in (1, 2):
vm = Query().new_object('vm')
vm['hostname'] = f'test-serverdb-history-{i}'
vm['intern_ip'] = ip_address(f'10.0.0.{i}')
vm.commit(user=User.objects.first())
self.object_ids.append(
Query({'hostname': f'test-serverdb-history-{i}'}).get()[
'object_id'])

vms = Query({'object_id': Any(*self.object_ids)}, ['intern_ip'])
for i, vm in enumerate(vms, start=11):
vm['intern_ip'] = ip_address(f'10.0.0.{i}')
vms.commit(user=User.objects.first())

def _url(self, *object_ids, **params):
query = '&'.join(f'object_id={i}' for i in object_ids)
for key, value in params.items():
query += f'&{key}={value}'
return f'/serverdb/history?{query}'

def test_multiple_objects(self):
response = self.client.get(self._url(*self.object_ids))

self.assertEqual(200, response.status_code)
self.assertEqual('2 objects', response.context['name'])
changes = list(response.context['changes'])
self.assertEqual(
set(self.object_ids), {c.object_id for c in changes})
self.assertEqual(
{'test-serverdb-history-1', 'test-serverdb-history-2'},
{c.hostname for c in changes})

def test_single_object(self):
response = self.client.get(self._url(self.object_ids[0]))

self.assertEqual(200, response.status_code)
self.assertEqual('test-serverdb-history-1', response.context['name'])
self.assertEqual(
{self.object_ids[0]},
{c.object_id for c in response.context['changes']})

def test_attribute_filter(self):
response = self.client.get(
self._url(*self.object_ids, attribute_filter='intern_ip'))

changes = list(response.context['changes'])
self.assertTrue(changes)
self.assertTrue(all('intern_ip' in c.change_json for c in changes))
self.assertEqual(
set(self.object_ids), {c.object_id for c in changes})

def test_missing_object_is_skipped(self):
response = self.client.get(self._url(self.object_ids[0], 999999999))

self.assertEqual(200, response.status_code)
self.assertEqual(
[self.object_ids[0]], response.context['object_ids'])
self.assertIn(
'999999999',
' '.join(str(m) for m in response.context['messages']))

def test_only_missing_objects_redirects(self):
response = self.client.get(self._url(999999999), follow=True)

self.assertEqual('serverdb/changes.html', response.template_name)
self.assertEqual(302, response.redirect_chain[0][1])

def test_no_parameter(self):
response = self.client.get('/serverdb/history')
self.assertEqual(400, response.status_code)

def test_changes_multiple_objects(self):
object_ids = ','.join(str(i) for i in self.object_ids)
response = self.client.get(
f'/serverdb/changes?object_id={object_ids}')

self.assertEqual(200, response.status_code)
commit_ids = [c.id for c in response.context['commits']]
# Two creates and one shared update commit, without duplicates
self.assertEqual(3, len(commit_ids))
self.assertEqual(len(commit_ids), len(set(commit_ids)))
120 changes: 78 additions & 42 deletions packages/serveradmin/serveradmin/serverdb/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
from django.contrib.auth.decorators import login_required
from django.core.exceptions import ValidationError
from django.core.paginator import Paginator
from django.db.models import Q, Subquery, OuterRef, Prefetch, CharField
from django.db.models import Q, Subquery, OuterRef, Prefetch, CharField, QuerySet
from django.db.models.fields.json import KeyTextTransform
from django.db.models.functions import Coalesce, Cast
from django.http import HttpResponseBadRequest
Expand All @@ -27,6 +27,44 @@
from serveradmin.serverdb.query_committer import CommitError, commit_query


def _parse_object_ids(values: list[str]) -> list[int]:
"""Parse object ids from (possibly comma separated) request values

Invalid entries are silently ignored.
"""
object_ids = []
for value in values:
for object_id in value.split(','):
object_id = object_id.strip()
if object_id.isdigit() and int(object_id) not in object_ids:
object_ids.append(int(object_id))

return object_ids

Comment thread
brainexe marked this conversation as resolved.
Outdated

def _annotate_hostname(changes: QuerySet) -> QuerySet:
"""Annotate changes with the hostname of their object

The hostname is fetched either from the server table or from the change
entry where the object was deleted.
"""
server_hostname = Server.objects.filter(
server_id=OuterRef('object_id')).values('hostname')
# Workaround: In previous versions of Serveradmin restoring objects with
# the same id was possible, so we can end up with objects being deleted
# multiple times. We only take the latest into account.
change_hostname = Change.objects.filter(
object_id=OuterRef('object_id'),
change_type=Change.Type.DELETE).values('change_json').order_by('-id')[:1]

return changes.annotate(hostname=Coalesce(
Subquery(server_hostname),
Cast(
KeyTextTransform('hostname', Subquery(change_hostname)),
output_field=CharField())
))


@login_required
def changes(request):
commits = ChangeCommit.objects.all().order_by('-change_on')
Expand Down Expand Up @@ -57,9 +95,11 @@ def changes(request):
'change_json__object_id', flat=True))
commits = commits.filter(change__object_id__in=object_ids)

f_object_id = request.GET.get('object_id')
if f_object_id:
commits = commits.filter(change__object_id=f_object_id)
f_object_ids = _parse_object_ids(request.GET.getlist('object_id'))
f_object_id = ','.join(str(i) for i in f_object_ids)
Comment thread
brainexe marked this conversation as resolved.
Outdated
if f_object_ids:
commits = commits.filter(
change__object_id__in=f_object_ids).distinct()

f_user_or_app = request.GET.get('user_or_app')
if f_user_or_app:
Expand All @@ -75,26 +115,8 @@ def changes(request):
f_attribute = None

commits = commits.select_related('app', 'user')

# This complex statement is just here to be able to prefetch the changes'
# relation including the hostname fetched either from the server table
# or from the change entry where the object was deleted.
server_hostname = Server.objects.filter(
server_id=OuterRef('object_id')).values('hostname')
# Workaround: In previous versions of Serveradmin restoring objects with
# the same id was possible, so we can end up with objects being deleted
# multiple times. We only take the latest into account.
change_hostname = Change.objects.filter(
object_id=OuterRef('object_id'),
change_type=Change.Type.DELETE).values('change_json').order_by('-id')[:1]
commits = commits.prefetch_related(Prefetch(
'change_set',
queryset=Change.objects.all().annotate(hostname=Coalesce(
Subquery(server_hostname),
Cast(
KeyTextTransform('hostname', Subquery(change_hostname)),
output_field=CharField())
))))
'change_set', queryset=_annotate_hostname(Change.objects.all())))

class NoCountPaginator(Paginator):
@cached_property
Expand Down Expand Up @@ -122,25 +144,31 @@ def count(self):

@login_required
def history(request):
if 'object_id' in request.GET:
query = Q(server_id=request.GET['object_id'])
error_text = f'object_id {request.GET["object_id"]}'
elif 'hostname' in request.GET:
query = Q(hostname=request.GET['hostname'])
error_text = f'hostname {request.GET["hostname"]}'
else:
object_ids = _parse_object_ids(request.GET.getlist('object_id'))
hostnames = request.GET.getlist('hostname')
if not object_ids and not hostnames:
return HttpResponseBadRequest(
'object_id or hostname parameter is mandatory')

try:
server = Server.objects.get(query)
except Server.DoesNotExist:
servers = list(Server.objects.filter(
Q(server_id__in=object_ids) | Q(hostname__in=hostnames)
).order_by('hostname'))

missing = [str(i) for i in object_ids
if i not in {s.server_id for s in servers}]
missing += [h for h in hostnames if h not in {s.hostname for s in servers}]
if not servers:
messages.error(
request,
f'Object with {error_text} does not or no longer exist.')
f'Object(s) {", ".join(missing)} do not or no longer exist.')
return redirect(reverse('serverdb_changes'))
if missing:
messages.warning(
request,
f'Object(s) {", ".join(missing)} do not or no longer exist.')

obj_history = Change.objects.filter(object_id=server.server_id)
server_ids = [s.server_id for s in servers]
obj_history = Change.objects.filter(object_id__in=server_ids)

commit_id = request.GET.get('commit_id')
if commit_id:
Expand All @@ -150,24 +178,32 @@ def history(request):
if attribute_filter:
obj_history = obj_history.filter(change_json__has_key=attribute_filter)

obj_history = obj_history.order_by('-commit_id')
obj_history = obj_history.order_by('-commit_id', '-id')
obj_history = obj_history.select_related('commit__app', 'commit__user')
obj_history = _annotate_hostname(obj_history)

page = request.GET.get('page', 1)
pager = Paginator(obj_history, 25)
page_obj = pager.get_page(page)

no_history_attributes = ServertypeAttribute.objects.filter(
servertype_id=server.servertype_id
).select_related('attribute').filter(
attribute__history=False
).only('attribute_id')
servertype_id__in={s.servertype_id for s in servers},
attribute__history=False,
).order_by('attribute_id').values_list(
'attribute_id', flat=True).distinct()

if len(servers) == 1:
name = servers[0].hostname
else:
name = f'{len(servers)} objects'
Comment thread
brainexe marked this conversation as resolved.
Outdated

return TemplateResponse(request, 'serverdb/history.html', {
'changes': page_obj,
'commit_id': commit_id,
'object_id': server.server_id,
'name': server.hostname,
'object_ids': server_ids,
'object_ids_query': '&'.join(f'object_id={i}' for i in server_ids),
'name': name,
'hostnames': [s.hostname for s in servers],
'attribute_filter': attribute_filter,
'no_history_attributes': no_history_attributes,
})
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -427,7 +427,7 @@ servershell.commands = {
let url;
let selection = servershell.get_selected();
if (selection.length > 0) {
url = servershell.urls.changes + `?object_id=${selection[0]}`;
url = servershell.urls.changes + `?object_id=${selection.join(',')}`;
Comment thread
brainexe marked this conversation as resolved.
Outdated
}
else {
url = servershell.urls.changes;
Expand Down Expand Up @@ -653,20 +653,20 @@ servershell.commands = {
}
},
history: function(attribute_id) {
if (!validate_selected(1, 1)) {
if (!validate_selected(1, 100)) {
return;
}

let object_id = servershell.get_selected()[0];
let url = servershell.urls.history + `?object_id=${object_id}`;
let selection = servershell.get_selected();
let url = servershell.urls.history + '?' + selection.map(o => `object_id=${o}`).join('&');

if (attribute_id) {
url += `&attribute_filter=${attribute_id}`;
}

window.open(url, '_blank');

servershell.alert(`History for ${object_id} opened in a new tab`, 'success');
servershell.alert(`History for ${selection.length} object(s) opened in a new tab`, 'success');
},
commit: function() {
spinner.enable('commit');
Expand Down
Loading
Loading