diff --git a/app/api/network.py b/app/api/network.py index 2f728af0d31dfd8f2849115a5fbb4260a7e83d35..584eff344b15877745160c96103d673352bc491a 100644 --- a/app/api/network.py +++ b/app/api/network.py @@ -13,7 +13,7 @@ from flask import Blueprint, request from flask_login import login_required from .. import models from ..decorators import login_groups_accepted -from .utils import get_generic_model, create_generic_model +from . import utils bp = Blueprint("network_api", __name__) @@ -25,7 +25,9 @@ def get_scopes(): .. :quickref: Network; Get network scopes """ - return get_generic_model(models.NetworkScope, order_by=models.NetworkScope.name) + return utils.get_generic_model( + models.NetworkScope, order_by=models.NetworkScope.name + ) @bp.route("/scopes", methods=["POST"]) @@ -42,7 +44,7 @@ def create_scope(): :jsonparam domain_id: primary key of the default domain :jsonparam description: (optional) description """ - return create_generic_model( + return utils.create_generic_model( models.NetworkScope, mandatory_fields=("name", "first_vlan", "last_vlan", "supernet", "domain_id"), ) @@ -55,7 +57,7 @@ def get_networks(): .. :quickref: Network; Get networks """ - return get_generic_model(models.Network, order_by=models.Network.address) + return utils.get_generic_model(models.Network, order_by=models.Network.address) @bp.route("/networks", methods=["POST"]) @@ -76,7 +78,7 @@ def create_network(): :type admin_only: bool :jsonparam description: (optional) description """ - return create_generic_model( + return utils.create_generic_model( models.Network, mandatory_fields=( "vlan_name", @@ -105,7 +107,7 @@ def get_interfaces(): .filter(models.Domain.name == domain) ) query = query.order_by(models.Interface.ip) - return get_generic_model(model=None, query=query) + return utils.get_generic_model(model=None, query=query) network = request.args.get("network", None) if network is not None: query = models.Interface.query @@ -113,8 +115,8 @@ def get_interfaces(): models.Network.vlan_name == network ) query = query.order_by(models.Interface.ip) - return get_generic_model(model=None, query=query) - return get_generic_model(models.Interface, order_by=models.Interface.ip) + return utils.get_generic_model(model=None, query=query) + return utils.get_generic_model(models.Interface, order_by=models.Interface.ip) @bp.route("/interfaces", methods=["POST"]) @@ -133,11 +135,23 @@ def create_interface(): # The validate_interfaces method from the Network class is called when # setting interface.network. This is why we don't pass network_id here # but network (as vlan_name string) - return create_generic_model( + return utils.create_generic_model( models.Interface, mandatory_fields=("network", "ip", "name") ) +@bp.route("/interfaces/<int:interface_id>", methods=["DELETE"]) +@login_groups_accepted("admin") +def delete_interface(interface_id): + """Delete an interface + + .. :quickref: Network; Delete an interface + + :param interface_id: interface primary key + """ + return utils.delete_generic_model(models.Interface, interface_id) + + @bp.route("/groups") @login_required def get_ansible_groups(): @@ -145,7 +159,9 @@ def get_ansible_groups(): .. :quickref: Network; Get Ansible groups """ - return get_generic_model(models.AnsibleGroup, order_by=models.AnsibleGroup.name) + return utils.get_generic_model( + models.AnsibleGroup, order_by=models.AnsibleGroup.name + ) @bp.route("/groups", methods=["POST"]) @@ -158,7 +174,7 @@ def create_ansible_groups(): :jsonparam name: group name :jsonparam vars: (optional) Ansible variables """ - return create_generic_model(models.AnsibleGroup, mandatory_fields=("name",)) + return utils.create_generic_model(models.AnsibleGroup, mandatory_fields=("name",)) @bp.route("/hosts") @@ -168,7 +184,7 @@ def get_hosts(): .. :quickref: Network; Get hosts """ - return get_generic_model(models.Host, order_by=models.Host.name) + return utils.get_generic_model(models.Host, order_by=models.Host.name) @bp.route("/hosts", methods=["POST"]) @@ -185,7 +201,21 @@ def create_host(): :jsonparam ansible_vars: (optional) Ansible variables :jsonparam ansible_groups: (optional) list of Ansible groups names """ - return create_generic_model(models.Host, mandatory_fields=("name", "device_type")) + return utils.create_generic_model( + models.Host, mandatory_fields=("name", "device_type") + ) + + +@bp.route("/hosts/<int:host_id>", methods=["DELETE"]) +@login_groups_accepted("admin") +def delete_host(host_id): + """Delete a host + + .. :quickref: Network; Delete a host + + :param host_id: host primary key + """ + return utils.delete_generic_model(models.Host, host_id) @bp.route("/macs") @@ -195,7 +225,7 @@ def get_macs(): .. :quickref: Network; Get mac addresses """ - return get_generic_model(models.Mac, order_by=models.Mac.address) + return utils.get_generic_model(models.Mac, order_by=models.Mac.address) @bp.route("/macs", methods=["POST"]) @@ -208,7 +238,7 @@ def create_macs(): :jsonparam address: MAC address :jsonparam item_id: (optional) linked item primary key """ - return create_generic_model(models.Mac, mandatory_fields=("address",)) + return utils.create_generic_model(models.Mac, mandatory_fields=("address",)) @bp.route("/domains") @@ -218,7 +248,7 @@ def get_domains(): .. :quickref: Network; Get domains """ - return get_generic_model(models.Domain, order_by=models.Domain.name) + return utils.get_generic_model(models.Domain, order_by=models.Domain.name) @bp.route("/domains", methods=["POST"]) @@ -230,7 +260,7 @@ def create_domain(): :jsonparam name: domain name """ - return create_generic_model(models.Domain, mandatory_fields=("name",)) + return utils.create_generic_model(models.Domain, mandatory_fields=("name",)) @bp.route("/cnames") @@ -250,8 +280,8 @@ def get_cnames(): .filter(models.Domain.name == domain) ) query = query.order_by(models.Cname.name) - return get_generic_model(model=None, query=query) - return get_generic_model(models.Cname, order_by=models.Cname.name) + return utils.get_generic_model(model=None, query=query) + return utils.get_generic_model(models.Cname, order_by=models.Cname.name) @bp.route("/cnames", methods=["POST"]) @@ -264,4 +294,6 @@ def create_cname(): :jsonparam name: full cname :jsonparam interface_id: primary key of the associated interface """ - return create_generic_model(models.Cname, mandatory_fields=("name", "interface_id")) + return utils.create_generic_model( + models.Cname, mandatory_fields=("name", "interface_id") + ) diff --git a/app/api/utils.py b/app/api/utils.py index 6197c01966fb40e1e65d29958a77719f33cb967d..cee5266281b0b4fb2ce3200009cf2a31a8a48bad 100644 --- a/app/api/utils.py +++ b/app/api/utils.py @@ -102,3 +102,15 @@ def create_generic_model(model, mandatory_fields=("name",), **kwargs): db.session.add(instance) commit() return jsonify(instance.to_dict()), 201 + + +def delete_generic_model(model, primary_key): + """Delete the model based on the primary_key + + :param model: model class + :param primary_key: primary key of the instance to delete + """ + instance = model.query.get_or_404(primary_key) + db.session.delete(instance) + db.session.commit() + return jsonify(), 204 diff --git a/app/main/views.py b/app/main/views.py index 5e5171c21588f1990e39a12c5b1f90335106bacc..83d41bd60e4d0e5d5f7522944d4875087ca36417 100644 --- a/app/main/views.py +++ b/app/main/views.py @@ -12,7 +12,7 @@ This module implements the main blueprint. import os import redis import rq_dashboard -from flask import Blueprint, render_template, jsonify, g, current_app, abort +from flask import Blueprint, render_template, jsonify, g, current_app, abort, request from flask_login import login_required, current_user from rq import push_connection, pop_connection, Queue from ..extensions import sentry @@ -37,7 +37,14 @@ def forbidden_error(error): @bp.app_errorhandler(404) def not_found_error(error): - return render_template("404.html"), 404 + if ( + request.path.startswith("/api") + or request.accept_mimetypes.best == "application/json" + ): + # API request - return json + return jsonify({"message": "Resource not found"}), 404 + else: + return render_template("404.html"), 404 @bp.app_errorhandler(500) diff --git a/docs/api.rst b/docs/api.rst index 78539094db3c518df339b69e653ebd949d58c500..a489d506184c2945233c1d8e46f81e102f1f6734 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -39,8 +39,10 @@ Status code Description ====================== =============================================================================== :http:statuscode:`200` The `GET` or `PATCH` request was successfull. The resource is returned as JSON. :http:statuscode:`201` The `POST` request was successful. The created resource is returned as JSON. +:http:statuscode:`204` The `DELETE` request was successful. No content is returned. :http:statuscode:`401` Missing Authorization Header. :http:statuscode:`403` The user doesn't have the required permissions. +:http:statuscode:`404` A resource could not be accessed, e.g., the ID could not be found. :http:statuscode:`422` The entity could not be processed. :http:statuscode:`500` An error occured while processing the request on the server. ====================== =============================================================================== diff --git a/tests/functional/test_api.py b/tests/functional/test_api.py index 731f62fc282003774447d3fcb16eccad493ee88d..4360338e5d1aadda4c8870eab199f47be85add8a 100644 --- a/tests/functional/test_api.py +++ b/tests/functional/test_api.py @@ -74,6 +74,14 @@ def patch(client, url, data, token=None): return response +def delete(client, url, token=None): + headers = {"Content-Type": "application/json"} + if token is not None: + headers["Authorization"] = f"Bearer {token}" + response = client.delete(url, headers=headers) + return response + + def login(client, username, password): data = {"username": username, "password": password} return post(client, f"{API_URL}/user/login", data) @@ -1086,6 +1094,34 @@ def test_create_interface_ip_not_in_range_as_admin( assert response.status_code == 201 +def test_delete_interface_invalid_credentials(client, interface_factory, user_token): + interface1 = interface_factory() + response = delete( + client, f"{API_URL}/network/interfaces/{interface1.id}", token=user_token + ) + assert response.status_code == 403 + assert len(models.Interface.query.all()) == 1 + + +def test_delete_interface_success(client, interface_factory, admin_token): + interface1 = interface_factory() + response = delete( + client, f"{API_URL}/network/interfaces/{interface1.id}", token=admin_token + ) + assert response.status_code == 204 + assert len(models.Interface.query.all()) == 0 + + +def test_delete_interface_invalid_id(client, interface_factory, admin_token): + interface1 = interface_factory() + response = delete( + client, f"{API_URL}/network/hosts/{interface1.id + 1}", token=admin_token + ) + assert response.status_code == 404 + assert response.get_json() == {"message": "Resource not found"} + assert len(models.Interface.query.all()) == 1 + + def test_get_macs(client, mac_factory, readonly_token): # Create some macs mac1 = mac_factory() @@ -1327,6 +1363,44 @@ def test_create_host_as_consultant( assert response.status_code == 201 +def test_delete_host_invalid_credentials(client, host_factory, user_token): + host1 = host_factory() + response = delete(client, f"{API_URL}/network/hosts/{host1.id}", token=user_token) + assert response.status_code == 403 + assert len(models.Host.query.all()) == 1 + + +def test_delete_host_success(client, host_factory, admin_token): + host1 = host_factory() + response = delete(client, f"{API_URL}/network/hosts/{host1.id}", token=admin_token) + assert response.status_code == 204 + assert len(models.Host.query.all()) == 0 + + +def test_delete_host_invalid_id(client, host_factory, admin_token): + host1 = host_factory() + response = delete( + client, f"{API_URL}/network/hosts/{host1.id + 1}", token=admin_token + ) + assert response.status_code == 404 + assert response.get_json() == {"message": "Resource not found"} + assert len(models.Host.query.all()) == 1 + + +def test_delete_host_with_interfaces( + client, interface_factory, host_factory, admin_token +): + interface1 = interface_factory() + interface2 = interface_factory() + host1 = host_factory(interfaces=[interface1, interface2]) + assert len(host1.interfaces) == 2 + assert len(models.Interface.query.all()) == 2 + response = delete(client, f"{API_URL}/network/hosts/{host1.id}", token=admin_token) + assert response.status_code == 204 + assert len(models.Host.query.all()) == 0 + assert len(models.Interface.query.all()) == 0 + + def test_get_user_profile(client, readonly_token): response = get(client, f"{API_URL}/user/profile", token=readonly_token) assert response.status_code == 200