Merge pull request #524 from MrApplejuice/app-integration

Extra API endpoints and login qr-code for app integration
This commit is contained in:
Christopher Charbonneau Wells 2022-10-20 05:21:00 -07:00 committed by GitHub
commit 50ffd2775b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
17 changed files with 281 additions and 29 deletions

View File

@ -24,6 +24,7 @@ pyyaml = "*"
uritemplate = "*"
whitenoise = "*"
django-taggit = "*"
django-qr-code = "*"
[dev-packages]
coveralls = "*"

View File

@ -9,6 +9,7 @@ from django.utils import timezone
from taggit.serializers import TagListSerializerField, TaggitSerializer
from core import models
from babybuddy import models as babybuddy_models
class CoreModelSerializer(serializers.HyperlinkedModelSerializer):
@ -258,13 +259,39 @@ class TummyTimeSerializer(CoreModelWithDurationSerializer, TaggableSerializer):
)
class UserSerializer(serializers.ModelSerializer):
class Meta:
model = User
fields = ("id", "username")
class WeightSerializer(CoreModelSerializer, TaggableSerializer):
class Meta:
model = models.Weight
fields = ("id", "child", "weight", "date", "notes", "tags")
class UserSerializer(serializers.ModelSerializer):
class Meta:
model = User
fields = (
"id",
"username",
"first_name",
"last_name",
"email",
"is_staff",
)
extra_kwargs = {k: {"read_only": True} for k in fields}
class ProfileSerializer(serializers.ModelSerializer):
user = UserSerializer(many=False)
api_key = serializers.SerializerMethodField("get_api_key")
def get_api_key(self, value):
return self.instance.api_key().key
class Meta:
model = babybuddy_models.Settings
fields = (
"user",
"language",
"timezone",
"api_key",
)
extra_kwargs = {k: {"read_only": True} for k in fields}

View File

@ -874,3 +874,37 @@ class WeightAPITestCase(TestBase.BabyBuddyAPITestCaseBase):
)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(response.data, entry)
class TestProfileAPITestCase(APITestCase):
endpoint = reverse("api:profile")
def setUp(self):
self.client.login(username="admin", password="admin")
def test_get(self):
response = self.client.get(self.endpoint)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertDictContainsSubset(
{
"language": "en-US",
"timezone": "UTC",
},
response.data,
)
self.assertDictContainsSubset(
{
"id": 1,
"username": "admin",
"first_name": "",
"last_name": "",
"email": "",
"is_staff": True,
},
response.data["user"],
)
# Test that api_key is in the mix and "some long string"
self.assertIn("api_key", response.data)
self.assertTrue(isinstance(response.data["api_key"], str))
self.assertGreater(len(response.data["api_key"]), 30)

View File

@ -1,11 +1,51 @@
# -*- coding: utf-8 -*-
from collections import OrderedDict
from typing import NamedTuple, List, Any
from django.urls import include, path
from rest_framework import routers
from rest_framework.schemas import get_schema_view
from . import views
router = routers.DefaultRouter()
class ExtraPath(NamedTuple):
path: str
reverese_name: str
route: Any
class CustomRouterWithExtraPaths(routers.DefaultRouter):
extra_api_urls: List[ExtraPath]
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.extra_api_urls = []
def add_detail_path(self, url_path, reverese_name, *args, **kwargs):
self.extra_api_urls = self.extra_api_urls or []
kwargs["name"] = reverese_name
self.extra_api_urls.append(
ExtraPath(url_path, reverese_name, path(url_path, *args, **kwargs))
)
def get_api_root_view(self, api_urls=None):
api_root_dict = OrderedDict()
list_name = self.routes[0].name
for prefix, viewset, basename in self.registry:
api_root_dict[prefix] = list_name.format(basename=basename)
for extra_path in self.extra_api_urls:
api_root_dict[extra_path.path] = extra_path.reverese_name
return self.APIRootView.as_view(api_root_dict=api_root_dict)
@property
def urls(self):
return super().urls + [e.route for e in self.extra_api_urls]
router = CustomRouterWithExtraPaths()
router.register(r"bmi", views.BMIViewSet)
router.register(r"changes", views.DiaperChangeViewSet)
router.register(r"children", views.ChildViewSet)
@ -21,18 +61,21 @@ router.register(r"timers", views.TimerViewSet)
router.register(r"tummy-times", views.TummyTimeViewSet)
router.register(r"weight", views.WeightViewSet)
router.add_detail_path("profile", "profile", views.ProfileView.as_view())
router.add_detail_path(
"schema",
"openapi-schema",
get_schema_view(
title="Baby Buddy API",
version=1,
description="API documentation for the Baby Buddy application",
),
)
app_name = "api"
urlpatterns = [
path("api/", include(router.urls)),
path("api/auth/", include("rest_framework.urls", namespace="rest_framework")),
path(
"api/schema",
get_schema_view(
title="Baby Buddy API",
version=1,
description="API documentation for the Baby Buddy application",
),
name="openapi-schema",
),
]

View File

@ -1,9 +1,13 @@
# -*- coding: utf-8 -*-
from rest_framework import viewsets
from django.shortcuts import get_object_or_404
from rest_framework import viewsets, views
from rest_framework.decorators import action
from rest_framework.response import Response
from rest_framework.schemas.openapi import AutoSchema
from core import models
from babybuddy import models as babybuddy_models
from . import serializers, filters
@ -113,3 +117,20 @@ class WeightViewSet(viewsets.ModelViewSet):
queryset = models.Weight.objects.all()
serializer_class = serializers.WeightSerializer
filterset_fields = ("child", "date")
class ProfileView(views.APIView):
schema = AutoSchema(operation_id_base="CurrentProfile")
action = "get"
basename = "profile"
queryset = babybuddy_models.Settings.objects.all()
serializer_class = serializers.ProfileSerializer
def get(self, request):
settings = get_object_or_404(
babybuddy_models.Settings.objects, user=request.user
)
serializer = self.serializer_class(settings)
return Response(serializer.data)

View File

@ -36,6 +36,7 @@ INSTALLED_APPS = [
"imagekit",
"storages",
"import_export",
"qr_code",
"django.contrib.admin",
"django.contrib.auth",
"django.contrib.contenttypes",

View File

@ -0,0 +1,4 @@
{% load i18n widget_tweaks babybuddy_tags qr_code %}
{% url 'babybuddy:root-router' as relative_root_url %}
{% make_absolute_url relative_root_url as absolute_root_url %}
BABYBUDDY-LOGIN:{"url":"{{ absolute_root_url }}","api_key":"{{ user.settings.api_key }}"}

View File

@ -323,6 +323,7 @@
<h6 class="dropdown-header">{% trans "User" %}</h6>
<a href="{% url 'babybuddy:user-settings' %}" class="dropdown-item">{% trans "Settings" %}</a>
<a href="{% url 'babybuddy:user-password' %}" class="dropdown-item">{% trans "Password" %}</a>
<a href="{% url 'babybuddy:user-add-device' %}" class="dropdown-item">{% trans "Add a device" %}</a>
<form action="{% url 'babybuddy:logout' %}" role="form" method="post">
{% csrf_token %}
<button class="dropdown-item">

View File

@ -0,0 +1,37 @@
{% extends 'babybuddy/page.html' %}
{% load i18n widget_tweaks babybuddy_tags qr_code %}
{% block title %}{% trans "Add a device" %}{% endblock %}
{% block breadcrumbs %}
<li class="breadcrumb-item">{% trans "User" %}</li>
<li class="breadcrumb-item active">{% trans "Add a device" %}</li>
{% endblock %}
{% block content %}
<h1>{% trans "Add a device" %}</h1>
<div class="container-fluid">
<legend>{% trans "Authentication Methods" %}</legend>
<fieldset>
<div class="form-group row">
<label class="col-sm-2 col-form-label">{% trans "Key" %}</label>
<div class="col-sm-10">
<form method="post">
{% csrf_token %}
<samp>{{ user.settings.api_key }}</samp>
<input type="submit" name="api_key_regenerate" value="{% trans "Regenerate" %}" class="btn btn-danger btn-xs" />
</form>
</div>
</div>
<div class="form-group row">
<label class="col-sm-2 col-form-label">{% trans "Login QR code" %}</label>
<div class="col-sm-10">
<div style="display:inline-block;background-color:white;" data-qr-code-content="{{ qr_code_data }}">
{% qr_from_text qr_code_data size="s" %}
</div>
</div>
</div>
</fieldset>
</div>
{% endblock %}

View File

@ -1,5 +1,5 @@
{% extends 'babybuddy/page.html' %}
{% load i18n widget_tweaks %}
{% load i18n widget_tweaks babybuddy_tags %}
{% block title %}{% trans "User Settings" %}{% endblock %}
@ -78,7 +78,7 @@
<fieldset>
<legend>{% trans "API" %}</legend>
<div class="form-group row">
<label for="id_email" class="col-sm-2 col-form-label">{% trans "Key" %}</label>
<label class="col-sm-2 col-form-label">{% trans "Key" %}</label>
<div class="col-sm-10">
<samp>{{ user.settings.api_key }}</samp>
<input type="submit" name="api_key_regenerate" value="{% trans "Regenerate" %}" class="btn btn-danger btn-xs" />

View File

@ -1,4 +1,5 @@
# -*- coding: utf-8 -*-
from django import template
from django.apps import apps
from django.utils import timezone
@ -55,3 +56,10 @@ def get_child_count():
@register.simple_tag()
def get_current_timezone():
return timezone.get_current_timezone_name()
@register.simple_tag(takes_context=True)
def make_absolute_url(context, url):
request = context["request"]
abs_url = request.build_absolute_uri(url)
return abs_url

View File

@ -117,9 +117,23 @@ class FormsTestCase(TestCase):
page = self.c.post("/user/settings/", params, follow=True)
self.assertEqual(page.status_code, 200)
self.assertNotEqual(
api_key_before, User.objects.get(pk=self.user.id).settings.api_key()
)
new_api_key = User.objects.get(pk=self.user.id).settings.api_key()
self.assertNotEqual(api_key_before, new_api_key)
# API key can also be regenerated on the add-device page
api_key_before = new_api_key
params = {"api_key_regenerate": "Regenerate"}
page = self.c.post("/user/add-device/", params, follow=True)
self.assertEqual(page.status_code, 200)
new_api_key = User.objects.get(pk=self.user.id).settings.api_key()
self.assertNotEqual(api_key_before, new_api_key)
def test_invalid_post_to_add_device(self):
self.c.login(**self.credentials)
page = self.c.get("/user/add-device/")
self.assertEqual(page.status_code, 200)
page = self.c.post("/user/add-device/", params={"garbage": True}, follow=True)
self.assertEqual(page.status_code, 400)
def test_user_settings_invalid(self):
self.c.login(**self.credentials)

View File

@ -52,6 +52,13 @@ class ViewsTestCase(TestCase):
page = self.c.get("/user/settings/")
self.assertEqual(page.status_code, 200)
def test_add_device_page(self):
page = self.c.get("/user/add-device/")
self.assertRegex(
page.content.decode(),
r""".*<div [^>]* data-qr-code-content="[^"]+"[^>]*>.*""",
)
def test_user_views(self):
# Staff setting is required to access user management.
page = self.c.get("/users/")

View File

@ -42,6 +42,7 @@ app_patterns = [
path("users/<int:pk>/delete/", views.UserDelete.as_view(), name="user-delete"),
path("user/password/", views.UserPassword.as_view(), name="user-password"),
path("user/settings/", views.UserSettings.as_view(), name="user-settings"),
path("user/add-device/", views.UserAddDevice.as_view(), name="user-add-device"),
]
urlpatterns = [

View File

@ -5,6 +5,7 @@ from django.contrib.auth.forms import PasswordChangeForm
from django.contrib.auth.models import User
from django.contrib.auth.views import LogoutView as LogoutViewBase
from django.contrib.messages.views import SuccessMessageMixin
from django.core.exceptions import BadRequest
from django.http import HttpResponseForbidden
from django.middleware.csrf import REASON_BAD_ORIGIN
from django.shortcuts import redirect, render
@ -147,6 +148,21 @@ class UserPassword(LoginRequiredMixin, View):
return render(request, self.template_name, {"form": form})
def handle_api_regenerate_request(request) -> bool:
"""
Checks if the current request contains a request to update the API key
and if it does, updeates the API key.
Returns True, if the API-key regenerate request was detected and handled.
"""
if request.POST.get("api_key_regenerate"):
request.user.settings.api_key(reset=True)
messages.success(request, _("User API key regenerated."))
return True
return False
class UserSettings(LoginRequiredMixin, View):
"""
Handles both the User and Settings models.
@ -158,21 +174,19 @@ class UserSettings(LoginRequiredMixin, View):
template_name = "babybuddy/user_settings_form.html"
def get(self, request):
settings = request.user.settings
return render(
request,
self.template_name,
{
"form_user": self.form_user_class(instance=request.user),
"form_settings": self.form_settings_class(
instance=request.user.settings
),
"form_settings": self.form_settings_class(instance=settings),
},
)
def post(self, request):
if request.POST.get("api_key_regenerate"):
request.user.settings.api_key(reset=True)
messages.success(request, _("User API key regenerated."))
if handle_api_regenerate_request(request):
return redirect("babybuddy:user-settings")
form_user = self.form_user_class(instance=request.user, data=request.POST)
@ -195,6 +209,31 @@ class UserSettings(LoginRequiredMixin, View):
)
class UserAddDevice(LoginRequiredMixin, View):
form_user_class = forms.UserForm
template_name = "babybuddy/user_add_device.html"
qr_code_template = "babybuddy/login_qr_code.txt"
def get(self, request):
qr_code_response = render(request, self.qr_code_template)
qr_code_data = qr_code_response.content.decode().strip()
return render(
request,
self.template_name,
{
"form_user": self.form_user_class(instance=request.user),
"qr_code_data": qr_code_data,
},
)
def post(self, request):
if handle_api_regenerate_request(request):
return redirect("babybuddy:user-add-device")
else:
raise BadRequest()
class Welcome(LoginRequiredMixin, TemplateView):
"""
Basic introduction to Baby Buddy (meant to be shown when no data is in the

View File

@ -4530,6 +4530,19 @@ paths:
description: ''
tags:
- api
/api/profile:
get:
operationId: retrieveCurrentProfile
description: ''
parameters: []
responses:
'200':
content:
application/json:
schema: {}
description: ''
tags:
- api
/api/timers/{id}/restart/:
patch:
operationId: restartTimer
@ -4848,7 +4861,7 @@ components:
slug:
type: string
readOnly: true
pattern: ^[-a-zA-Z0-9_]+$
pattern: ^[-\w]+\z
name:
type: string
maxLength: 100

View File

@ -13,6 +13,7 @@ django-imagekit==4.1.0
django-import-export==2.9.0
django-ipware==4.0.2; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4, 3.5'
django-storages==1.13.1
django-qr-code==3.1.1
django-taggit==3.0.0
django-widget-tweaks==1.4.12
djangorestframework==3.14.0