|
17 | 17 |
|
18 | 18 | from datetime import datetime, timedelta |
19 | 19 | import logging |
20 | | -from typing import Sequence |
| 20 | +from typing import Optional, Sequence, Union |
21 | 21 |
|
22 | 22 | from IPy import IP |
23 | 23 | from django.http import HttpResponse, JsonResponse, QueryDict |
|
49 | 49 |
|
50 | 50 | from nav.django.settings import JWT_PUBLIC_KEY, JWT_NAME, LOCAL_JWT_IS_CONFIGURED |
51 | 51 | 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 |
53 | 53 | from nav.models.api import JWTRefreshToken |
54 | 54 | 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 | +) |
55 | 62 | from nav.web.servicecheckers import load_checker_classes |
56 | 63 | from nav.util import auth_token, is_valid_cidr |
57 | 64 |
|
@@ -177,6 +184,7 @@ def get_endpoints(request=None, version=1): |
177 | 184 | 'vendor': reverse_lazy('{}vendor'.format(prefix), **kwargs), |
178 | 185 | 'netboxentity': reverse_lazy('{}netboxentity-list'.format(prefix), **kwargs), |
179 | 186 | 'jwt_refresh': reverse_lazy('{}jwt-refresh'.format(prefix), **kwargs), |
| 187 | + 'maintenance': reverse_lazy('{}maintenance'.format(prefix), **kwargs), |
180 | 188 | } |
181 | 189 |
|
182 | 190 |
|
@@ -1342,30 +1350,9 @@ class JWTRefreshViewSet(NAVAPIMixin, APIView): |
1342 | 1350 | def post(self, request): |
1343 | 1351 | if not LOCAL_JWT_IS_CONFIGURED: |
1344 | 1352 | 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) |
1369 | 1356 |
|
1370 | 1357 | incoming_token = data.get('refresh_token') |
1371 | 1358 | if incoming_token is None: |
@@ -1423,3 +1410,133 @@ def post(self, request): |
1423 | 1410 | 'refresh_token': refresh_token, |
1424 | 1411 | } |
1425 | 1412 | 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