Skip to content

Commit

Permalink
basic api implementation
Browse files Browse the repository at this point in the history
  • Loading branch information
ansibleguy committed Jan 20, 2024
1 parent 6dce295 commit bde1d78
Show file tree
Hide file tree
Showing 10 changed files with 192 additions and 25 deletions.
5 changes: 5 additions & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,11 @@ ua-parser
user-agents
django-user-agents

## api
djangorestframework==3.*
djangorestframework-api-key==2.*
drf-spectacular

## styles
django-bootstrap-v5
fontawesomefree
Expand Down
81 changes: 81 additions & 0 deletions src/ansible-webui/aw/api.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
from time import time
from typing import Callable

from django.core.exceptions import ObjectDoesNotExist
from django.urls import path
from rest_framework.views import APIView
from rest_framework.response import Response
from drf_spectacular.views import SpectacularAPIView, SpectacularSwaggerView, extend_schema

from aw.model.api import APIKey
from aw.utils.http import deny_request


def endpoint_wrapper(func) -> Callable:
def wrapper(request, *args, **kwargs):
bad, deny = deny_request(request)
if bad:
return deny

return func(request, *args, **kwargs)

return wrapper


class KeyRead(APIView):
http_method_names = ['get']

@staticmethod
@endpoint_wrapper
@extend_schema(
summary='Return a list of all existing API keys of the current user.',
parameters=None,
)
def get(request):
return Response({'tokens': [key.name for key in APIKey.objects.filter(user=request.user)]})


class KeyWrite(APIView):
http_method_names = ['post', 'delete']

@staticmethod
@endpoint_wrapper
@extend_schema(
summary='Create a new API key.',
)
def post(request):
token, secret = APIKey.objects.create_key(
name=f'{request.user}-{time()}',
user=request.user,
)
return Response({'token': token, 'secret': secret})

@staticmethod
@endpoint_wrapper
@extend_schema(
summary='Delete one of the existing API keys of the current user.',
)
def delete(request):
req_key = request.query_params.get('token', None)

if req_key is None:
return Response({'msg': 'No API key provided'})

try:
results = APIKey.objects.filter(user=request.user, name=req_key)
if len(results) == 1:
results.delete()
return Response({'msg': f"API key '{req_key}' revoked"})

except ObjectDoesNotExist:
pass

return Response(data={'msg': f"API key '{req_key}' not found"}, status=404)


urlpatterns_api = [
path('api/key', KeyRead.as_view()),
path('api/key/<str:token>', KeyWrite.as_view()),
path('api/_schema/', SpectacularAPIView.as_view(), name='_schema'),
path('api', SpectacularSwaggerView.as_view(url_name='_schema'), name='swagger-ui'),
]
3 changes: 2 additions & 1 deletion src/ansible-webui/aw/config/navigation.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@
},
'Manage': {
'Settings': '/ui/settings/',
'System': '/ui/m/',
'System': '/ui/mgmt/',
'API Docs': '/api',
},
},
'right': {
Expand Down
7 changes: 7 additions & 0 deletions src/ansible-webui/aw/model/api.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
from django.db import models
from django.conf import settings
from rest_framework_api_key.models import AbstractAPIKey


class APIKey(AbstractAPIKey):
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
11 changes: 6 additions & 5 deletions src/ansible-webui/aw/model/job.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@

from crontab import CronTab

from aw.config.main import config
from aw.model.base import BareModel, BaseModel, CHOICES_BOOL
from aw.config.hardcoded import SHORT_TIME_FORMAT

Expand Down Expand Up @@ -109,8 +108,10 @@ class JobExecutionResultHost(BareModel):
tasks_ignored = models.PositiveSmallIntegerField(default=0)
tasks_changed = models.PositiveSmallIntegerField(default=0)

error = models.ForeignKey(JobError, on_delete=models.CASCADE, related_name='jobresulthost_fk_error')
result = models.ForeignKey(JobExecutionResult, on_delete=models.CASCADE, related_name='jobresulthost_fk_result')
error = models.ForeignKey(JobError, on_delete=models.SET_NULL, related_name='jobresulthost_fk_error', null=True)
result = models.ForeignKey(
JobExecutionResult, on_delete=models.SET_NULL, related_name='jobresulthost_fk_result', null=True
)

def __str__(self) -> str:
result = 'succeeded'
Expand All @@ -133,12 +134,12 @@ def __str__(self) -> str:
class JobExecution(MetaJob):
# NOTE: scheduled execution will have no user
user = models.ForeignKey(
settings.AUTH_USER_MODEL, on_delete=models.PROTECT, null=True,
settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True,
related_name='jobexec_fk_user', editable=False,
)
job = models.ForeignKey(Job, on_delete=models.CASCADE, related_name='jobexec_fk_job')
result = models.ForeignKey(
JobExecutionResult, on_delete=models.CASCADE, related_name='jobexec_fk_result',
JobExecutionResult, on_delete=models.SET_NULL, related_name='jobexec_fk_result',
null=True, default=None, blank=True, # execution is created before result is available
)
status = models.PositiveSmallIntegerField(default=0, choices=CHOICES_JOB_EXEC_STATUS)
Expand Down
29 changes: 12 additions & 17 deletions src/ansible-webui/aw/route.py
Original file line number Diff line number Diff line change
@@ -1,22 +1,17 @@
from django.contrib.auth.decorators import login_required # user_passes_test
from django.shortcuts import HttpResponse, redirect, render
from django.contrib.auth.decorators import login_required
from django.shortcuts import redirect, render
from django.conf import settings
from django.contrib.auth.views import logout_then_login
from django.shortcuts import HttpResponse

from aw.config.hardcoded import LOGIN_PATH


def _deny(request) -> (bool, HttpResponse):
if request.method not in ['GET', 'POST', 'PUT']:
return True, HttpResponse(status=405)

return False, None
from aw.utils.http import deny_request


@login_required
def ui(request, **kwargs):
def ui(request, **kwargs) -> HttpResponse:
del kwargs
bad, deny = _deny(request)
bad, deny = deny_request(request)
if bad:
return deny

Expand All @@ -31,40 +26,40 @@ def ui(request, **kwargs):

# @user_passes_test(authorized_to_write, login_url=LOGIN_PATH)
@login_required
def ui_write(request, **kwargs):
def ui_write(request, **kwargs) -> HttpResponse:
del kwargs
return render(request, status=200, template_name='fallback.html', context={'content': 'OK - write'})


# @user_passes_test(authorized_to_exec, login_url=LOGIN_PATH)
@login_required
def ui_exec(request, **kwargs):
def ui_exec(request, **kwargs) -> HttpResponse:
del kwargs
return render(request, status=200, template_name='fallback.html', context={'content': 'OK - exec'})


@login_required
def manage(request, **kwargs):
def manage(request, **kwargs) -> HttpResponse:
del kwargs
return render(request, status=200, template_name='fallback.html', context={
'content': '<iframe width="100%" height="100%" marginheight="0" marginwidth="0" frameborder="0" '
'scrolling="auto" src="/m/" title="Manage"></iframe>'
})


def not_implemented(request, **kwargs):
def not_implemented(request, **kwargs) -> HttpResponse:
del kwargs
return render(request, status=404, template_name='fallback.html', context={'content': 'Not yet implemented'})


def catchall(request, **kwargs):
def catchall(request, **kwargs) -> HttpResponse:
del kwargs
if request.user.is_authenticated:
return redirect(settings.LOGIN_REDIRECT_URL)

return redirect(LOGIN_PATH)


def logout(request, **kwargs):
def logout(request, **kwargs) -> HttpResponse:
del kwargs
return logout_then_login(request)
51 changes: 50 additions & 1 deletion src/ansible-webui/aw/settings.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
from pathlib import Path

from aw.config.main import config
from aw.config.main import config, VERSION
from aw.config.hardcoded import LOGIN_PATH
from aw.utils.deployment import deployment_dev, deployment_prod
from aw.config.environment import get_aw_env_var
Expand All @@ -24,6 +24,10 @@
'django.contrib.messages',
'django.contrib.staticfiles',
'django_user_agents',
# api
'rest_framework',
'rest_framework_api_key',
'drf_spectacular',
# styles
'bootstrap5',
'fontawesomefree',
Expand Down Expand Up @@ -120,3 +124,48 @@

SECRET_KEY = config['_secret']
TIMEZONE = config['timezone']

# api
REST_FRAMEWORK = {
'DEFAULT_AUTHENTICATION_CLASSES': (
'rest_framework.authentication.SessionAuthentication',
),
"DEFAULT_PERMISSION_CLASSES": [
"rest_framework.permissions.IsAuthenticated",
"rest_framework_api_key.permissions.HasAPIKey",
],
'PAGE_SIZE': 100,
'DEFAULT_SCHEMA_CLASS': 'drf_spectacular.openapi.AutoSchema',
}
SPECTACULAR_SETTINGS = {
'TITLE': 'Ansible-WebUI API',
# 'DESCRIPTION': 'Your project description',
'VERSION': VERSION,
'SERVE_INCLUDE_SCHEMA': False,
'SERVE_PERMISSIONS': ['rest_framework.permissions.IsAuthenticated'],
'SWAGGER_UI_FAVICON_HREF': STATIC_URL + 'img/ansible.svg',
'APPEND_COMPONENTS': {
'securitySchemes': {
'apiKey': {
'type': 'apiKey',
'in': 'header',
'name': 'Authorization',
},
# 'session': {
# 'type': 'apiKey',
# 'in': 'cookie',
# 'name': 'sessionid',
# },
},
},
'SECURITY': [
{'apiKey': []},
# {'session': []},
],
'SWAGGER_UI_SETTINGS': {
'displayOperationId': False,
},
}

if deployment_dev():
SPECTACULAR_SETTINGS['SWAGGER_UI_SETTINGS']['persistAuthorization'] = True
17 changes: 17 additions & 0 deletions src/ansible-webui/aw/templates/rest_framework/api.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
{% extends "rest_framework/base.html" %}
{# https://github.com/encode/django-rest-framework/blob/master/rest_framework/templates/rest_framework/base.html #}
{% load static %}
{% block title %}
Ansible-WebUI API
{% endblock %}
{% block meta %}
<meta http-equiv="Content-Type" content="text/html; charset=utf-8"/>
<meta name="robots" content="NONE,NOARCHIVE" />

<link rel="icon" type="image/svg" href="{% static 'img/ansible.svg' %}">
{% endblock %}
{% block branding %}
<a class='navbar-brand' rel="nofollow" href='/'>
Ansible-WebUI
</a>
{% endblock %}
5 changes: 4 additions & 1 deletion src/ansible-webui/aw/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

from base.serve_static import urlpatterns_static
from aw.route import ui, catchall, logout, not_implemented, manage
from aw.api import urlpatterns_api
from aw.config.environment import check_aw_env_var_true
from aw.utils.deployment import deployment_dev

Expand All @@ -13,6 +14,8 @@
if deployment_dev() or check_aw_env_var_true(var='serve_static', fallback=True):
urlpatterns += urlpatterns_static

urlpatterns += urlpatterns_api

urlpatterns += [
# auth
path('a/', include('django.contrib.auth.urls')), # login page
Expand All @@ -21,7 +24,7 @@

# app
path('ui/', ui),
path('ui/m/', manage),
path('ui/mgmt/', manage),
path('ui/job/', not_implemented),
path('ui/settings/', not_implemented),
re_path(r'^ui/*', ui),
Expand Down
8 changes: 8 additions & 0 deletions src/ansible-webui/aw/utils/http.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
from django.shortcuts import HttpResponse


def deny_request(request) -> (bool, HttpResponse):
if request.method not in ['GET', 'POST', 'PUT']:
return True, HttpResponse(status=405)

return False, None

0 comments on commit bde1d78

Please sign in to comment.