Skip to content

Commit 6262dd0

Browse files
Add API endpoint for posting maintenance tasks
1 parent 5adb5d3 commit 6262dd0

File tree

6 files changed

+398
-30
lines changed

6 files changed

+398
-30
lines changed

changelog.d/2927.added.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Add endpoint for creating maintenance tasks

doc/howto/api_parameters.rst

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,32 @@ Provides access to NAVs interface data
8686
:Filters: ifname, ifindex, ifoperstatus, netbox, trunk, ifadminstatus, iftype,
8787
baseport
8888

89+
api/maintenance/
90+
----------------
91+
Create a maintenance task with the given components.
92+
93+
Supports POST requests:
94+
95+
POST: Returns the created maintenance task or an error. Requires a dict of the form::
96+
97+
{
98+
"start_time": "2025-09-29T11:11:11",
99+
"end_time": "2025-09-29T13:11:11",
100+
"no_end_time": false,
101+
"description": "Changing out old equipment in serverroom",
102+
"location": [1, 2],
103+
"room": ["myroom", "secondroom"],
104+
"netbox": [1, 2],
105+
"service": [1, 2],
106+
"netboxgroup": [1, 2]
107+
}
108+
109+
Set ``no_end_time`` to ``true`` and remove the ``end_time`` entry if the maintenance
110+
task does not have a determined end time yet.
111+
112+
The required fields are ``start_time``, ``end_time`` or ``no_end_time``,
113+
``description`` and at least one component id (``location``, ``room``, ``netbox``,
114+
``service`` or ``netboxgroup``).
89115

90116
api/netbox/[<id>]
91117
-----------------

python/nav/web/api/v1/serializers.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@
2121
from rest_framework.exceptions import ValidationError
2222

2323
from nav.web.api.v1.fields import DisplayNameWritableField
24-
from nav.models import manage, cabling, rack, profiles
24+
from nav.models import manage, cabling, rack, profiles, msgmaint
2525
from nav.web.seeddb.page.netbox.edit import get_sysname
2626

2727

@@ -536,3 +536,13 @@ class NetboxEntitySerializer(serializers.ModelSerializer):
536536
class Meta(object):
537537
model = manage.NetboxEntity
538538
fields = '__all__'
539+
540+
541+
class MaintenanceTaskSerializer(serializers.ModelSerializer):
542+
"""Serializer for the MaintenanceTask model"""
543+
544+
maintenance_components = serializers.CharField(source='get_components')
545+
546+
class Meta(object):
547+
model = msgmaint.MaintenanceTask
548+
fields = '__all__'

python/nav/web/api/v1/urls.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,4 +75,5 @@
7575
path('', include(router.urls)),
7676
re_path(r'^vendor/?$', views.VendorLookup.as_view(), name='vendor'),
7777
path('jwt/refresh/', views.JWTRefreshViewSet.as_view(), name='jwt-refresh'),
78+
path('maintenance/', views.MaintenanceTaskViewSet.as_view(), name='maintenance'),
7879
]

python/nav/web/api/v1/views.py

Lines changed: 143 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717

1818
from datetime import datetime, timedelta
1919
import logging
20-
from typing import Sequence
20+
from typing import Optional, Sequence, Union
2121

2222
from IPy import IP
2323
from django.http import HttpResponse, JsonResponse, QueryDict
@@ -49,9 +49,16 @@
4949

5050
from nav.django.settings import JWT_PUBLIC_KEY, JWT_NAME, LOCAL_JWT_IS_CONFIGURED
5151
from nav.macaddress import MacAddress
52-
from nav.models import manage, event, cabling, rack, profiles
52+
from nav.models import manage, event, cabling, rack, profiles, msgmaint
5353
from nav.models.api import JWTRefreshToken
5454
from nav.models.fields import INFINITY, UNRESOLVED
55+
from nav.web.maintenance.forms import MaintenanceTaskForm
56+
from nav.web.maintenance.utils import (
57+
ALLOWED_COMPONENTS,
58+
COMPONENTS_WITH_INTEGER_PK,
59+
ComponentType,
60+
get_components_from_keydict,
61+
)
5562
from nav.web.servicecheckers import load_checker_classes
5663
from nav.util import auth_token, is_valid_cidr
5764

@@ -177,6 +184,7 @@ def get_endpoints(request=None, version=1):
177184
'vendor': reverse_lazy('{}vendor'.format(prefix), **kwargs),
178185
'netboxentity': reverse_lazy('{}netboxentity-list'.format(prefix), **kwargs),
179186
'jwt_refresh': reverse_lazy('{}jwt-refresh'.format(prefix), **kwargs),
187+
'maintenance': reverse_lazy('{}maintenance'.format(prefix), **kwargs),
180188
}
181189

182190

@@ -1342,30 +1350,9 @@ class JWTRefreshViewSet(NAVAPIMixin, APIView):
13421350
def post(self, request):
13431351
if not LOCAL_JWT_IS_CONFIGURED:
13441352
return Response("Invalid token", status=status.HTTP_403_FORBIDDEN)
1345-
# This adds support for requests via the browseable API.
1346-
# Browseble API sends QueryDict with _content key.
1347-
# Tests send QueryDict without _content key so it can be treated
1348-
# as a regular dict.
1349-
if isinstance(request.data, QueryDict) and '_content' in request.data:
1350-
json_string = request.data.get('_content')
1351-
if not json_string:
1352-
return Response("Empty JSON body", status=status.HTTP_400_BAD_REQUEST)
1353-
try:
1354-
data = json.loads(json_string)
1355-
except json.JSONDecodeError:
1356-
return Response("Invalid JSON", status=status.HTTP_400_BAD_REQUEST)
1357-
if not isinstance(data, dict):
1358-
return Response(
1359-
"Invalid request body. Must be a JSON dict",
1360-
status=status.HTTP_400_BAD_REQUEST,
1361-
)
1362-
elif isinstance(request.data, dict):
1363-
data = request.data
1364-
else:
1365-
return Response(
1366-
"Invalid request body. Must be a JSON dict",
1367-
status=status.HTTP_400_BAD_REQUEST,
1368-
)
1353+
data, error = _validate_post_data(request.data)
1354+
if error:
1355+
return Response(error, status=status.HTTP_400_BAD_REQUEST)
13691356

13701357
incoming_token = data.get('refresh_token')
13711358
if incoming_token is None:
@@ -1423,3 +1410,133 @@ def post(self, request):
14231410
'refresh_token': refresh_token,
14241411
}
14251412
return Response(response_data)
1413+
1414+
1415+
class MaintenanceTaskViewSet(NAVAPIMixin, APIView):
1416+
"""
1417+
A ViewSet for posting MaintenanceTasks.
1418+
1419+
Responds with a JSON dict representation of the created maintenance task or an error
1420+
message.
1421+
1422+
Example POST request:
1423+
`/api/1/maintenance/` with body
1424+
`{
1425+
"start_time": "2025-09-29T11:11:11",
1426+
"end_time": "2025-09-29T13:11:11",
1427+
"no_end_time": false,
1428+
"description": "Changing out old equipment in serverroom",
1429+
"location": [1, 2],
1430+
"room": ["myroom", "secondroom"],
1431+
"netbox": [1, 2],
1432+
"service": [1, 2],
1433+
"netboxgroup": [1, 2]
1434+
}`
1435+
1436+
Example POST response:
1437+
`{
1438+
"id": 59,
1439+
"maintenance_components": "[<Netbox: buick.lab.uninett.no>,
1440+
<Netbox: oldsmobile.lab.uninett.no>]",
1441+
"start_time": "2025-10-01T09:17:00",
1442+
"end_time": "9999-12-31T23:59:59.999999",
1443+
"description": "Changing out old equipment in serverroom",
1444+
"author": "admin",
1445+
"state": "scheduled"
1446+
}`
1447+
"""
1448+
1449+
def post(self, request):
1450+
data, error = _validate_post_data(request.data)
1451+
if error:
1452+
return Response(error, status=status.HTTP_400_BAD_REQUEST)
1453+
1454+
component_keys = {
1455+
key: value for key, value in data.items() if key in ALLOWED_COMPONENTS
1456+
}
1457+
1458+
components, component_errors = _validate_and_get_components(component_keys)
1459+
if component_errors:
1460+
return Response(component_errors, status=status.HTTP_400_BAD_REQUEST)
1461+
1462+
task_form = MaintenanceTaskForm(data)
1463+
1464+
if not task_form.is_valid():
1465+
return Response(task_form.errors, status=status.HTTP_400_BAD_REQUEST)
1466+
1467+
start_time = task_form.cleaned_data['start_time']
1468+
end_time = task_form.cleaned_data['end_time']
1469+
no_end_time = task_form.cleaned_data['no_end_time']
1470+
state = msgmaint.MaintenanceTask.STATE_SCHEDULED
1471+
if start_time < datetime.now() and end_time and end_time <= datetime.now():
1472+
state = msgmaint.MaintenanceTask.STATE_SCHEDULED
1473+
1474+
new_task = msgmaint.MaintenanceTask()
1475+
new_task.start_time = start_time
1476+
if no_end_time:
1477+
new_task.end_time = INFINITY
1478+
elif not no_end_time and end_time:
1479+
new_task.end_time = end_time
1480+
new_task.description = task_form.cleaned_data['description']
1481+
new_task.state = state
1482+
new_task.author = request.account.login
1483+
new_task.save()
1484+
1485+
for component in components:
1486+
table = component._meta.db_table
1487+
descr = str(component) if table in COMPONENTS_WITH_INTEGER_PK else None
1488+
task_component = msgmaint.MaintenanceComponent(
1489+
maintenance_task=new_task,
1490+
key=table,
1491+
value=component.pk,
1492+
description=descr,
1493+
)
1494+
task_component.save()
1495+
1496+
serializer = serializers.MaintenanceTaskSerializer(instance=new_task)
1497+
1498+
return Response(serializer.data)
1499+
1500+
1501+
def _validate_and_get_components(
1502+
component_data: dict[str, list[Union[int, str]]],
1503+
) -> tuple[Optional[list[ComponentType]], str]:
1504+
"""
1505+
Validates the given components and returns a tuple of the found components and
1506+
potential errors
1507+
"""
1508+
try:
1509+
components, component_data_errors = get_components_from_keydict(component_data)
1510+
except Exception as e: # noqa
1511+
component_data_errors = str(e)
1512+
if component_data_errors:
1513+
return None, component_data_errors
1514+
1515+
if not components:
1516+
return components, "No components to put on maintenance selected."
1517+
return components, ""
1518+
1519+
1520+
def _validate_post_data(data) -> tuple[Optional[dict], Optional[str]]:
1521+
"""
1522+
This adds support for requests via the browseable API.
1523+
Browseable API sends QueryDict with _content key.
1524+
Tests send QueryDict without _content key so it can be treated as a regular dict.
1525+
"""
1526+
if isinstance(data, QueryDict) and '_content' in data:
1527+
json_string = data.get('_content')
1528+
if not json_string:
1529+
return None, "Empty JSON body"
1530+
try:
1531+
data = json.loads(json_string)
1532+
except json.JSONDecodeError:
1533+
return None, "Invalid JSON"
1534+
if not isinstance(data, dict):
1535+
return None, "Invalid request body. Must be a JSON dict"
1536+
return data, None
1537+
elif isinstance(data, dict):
1538+
return data, None
1539+
else:
1540+
return None, "Invalid request body. Must be a JSON dict"
1541+
1542+
return data

0 commit comments

Comments
 (0)