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 = "*" uritemplate = "*"
whitenoise = "*" whitenoise = "*"
django-taggit = "*" django-taggit = "*"
django-qr-code = "*"
[dev-packages] [dev-packages]
coveralls = "*" coveralls = "*"

View File

@ -9,6 +9,7 @@ from django.utils import timezone
from taggit.serializers import TagListSerializerField, TaggitSerializer from taggit.serializers import TagListSerializerField, TaggitSerializer
from core import models from core import models
from babybuddy import models as babybuddy_models
class CoreModelSerializer(serializers.HyperlinkedModelSerializer): 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 WeightSerializer(CoreModelSerializer, TaggableSerializer):
class Meta: class Meta:
model = models.Weight model = models.Weight
fields = ("id", "child", "weight", "date", "notes", "tags") 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.status_code, status.HTTP_200_OK)
self.assertEqual(response.data, entry) 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 -*- # -*- coding: utf-8 -*-
from collections import OrderedDict
from typing import NamedTuple, List, Any
from django.urls import include, path from django.urls import include, path
from rest_framework import routers from rest_framework import routers
from rest_framework.schemas import get_schema_view from rest_framework.schemas import get_schema_view
from . import views 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"bmi", views.BMIViewSet)
router.register(r"changes", views.DiaperChangeViewSet) router.register(r"changes", views.DiaperChangeViewSet)
router.register(r"children", views.ChildViewSet) 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"tummy-times", views.TummyTimeViewSet)
router.register(r"weight", views.WeightViewSet) 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" app_name = "api"
urlpatterns = [ urlpatterns = [
path("api/", include(router.urls)), path("api/", include(router.urls)),
path("api/auth/", include("rest_framework.urls", namespace="rest_framework")), 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 -*- # -*- 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.decorators import action
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework.schemas.openapi import AutoSchema
from core import models from core import models
from babybuddy import models as babybuddy_models
from . import serializers, filters from . import serializers, filters
@ -113,3 +117,20 @@ class WeightViewSet(viewsets.ModelViewSet):
queryset = models.Weight.objects.all() queryset = models.Weight.objects.all()
serializer_class = serializers.WeightSerializer serializer_class = serializers.WeightSerializer
filterset_fields = ("child", "date") 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", "imagekit",
"storages", "storages",
"import_export", "import_export",
"qr_code",
"django.contrib.admin", "django.contrib.admin",
"django.contrib.auth", "django.contrib.auth",
"django.contrib.contenttypes", "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> <h6 class="dropdown-header">{% trans "User" %}</h6>
<a href="{% url 'babybuddy:user-settings' %}" class="dropdown-item">{% trans "Settings" %}</a> <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-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"> <form action="{% url 'babybuddy:logout' %}" role="form" method="post">
{% csrf_token %} {% csrf_token %}
<button class="dropdown-item"> <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' %} {% extends 'babybuddy/page.html' %}
{% load i18n widget_tweaks %} {% load i18n widget_tweaks babybuddy_tags %}
{% block title %}{% trans "User Settings" %}{% endblock %} {% block title %}{% trans "User Settings" %}{% endblock %}
@ -78,7 +78,7 @@
<fieldset> <fieldset>
<legend>{% trans "API" %}</legend> <legend>{% trans "API" %}</legend>
<div class="form-group row"> <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"> <div class="col-sm-10">
<samp>{{ user.settings.api_key }}</samp> <samp>{{ user.settings.api_key }}</samp>
<input type="submit" name="api_key_regenerate" value="{% trans "Regenerate" %}" class="btn btn-danger btn-xs" /> <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 -*- # -*- coding: utf-8 -*-
from django import template from django import template
from django.apps import apps from django.apps import apps
from django.utils import timezone from django.utils import timezone
@ -55,3 +56,10 @@ def get_child_count():
@register.simple_tag() @register.simple_tag()
def get_current_timezone(): def get_current_timezone():
return timezone.get_current_timezone_name() 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) page = self.c.post("/user/settings/", params, follow=True)
self.assertEqual(page.status_code, 200) self.assertEqual(page.status_code, 200)
self.assertNotEqual( new_api_key = User.objects.get(pk=self.user.id).settings.api_key()
api_key_before, 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): def test_user_settings_invalid(self):
self.c.login(**self.credentials) self.c.login(**self.credentials)

View File

@ -52,6 +52,13 @@ class ViewsTestCase(TestCase):
page = self.c.get("/user/settings/") page = self.c.get("/user/settings/")
self.assertEqual(page.status_code, 200) 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): def test_user_views(self):
# Staff setting is required to access user management. # Staff setting is required to access user management.
page = self.c.get("/users/") 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("users/<int:pk>/delete/", views.UserDelete.as_view(), name="user-delete"),
path("user/password/", views.UserPassword.as_view(), name="user-password"), path("user/password/", views.UserPassword.as_view(), name="user-password"),
path("user/settings/", views.UserSettings.as_view(), name="user-settings"), path("user/settings/", views.UserSettings.as_view(), name="user-settings"),
path("user/add-device/", views.UserAddDevice.as_view(), name="user-add-device"),
] ]
urlpatterns = [ 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.models import User
from django.contrib.auth.views import LogoutView as LogoutViewBase from django.contrib.auth.views import LogoutView as LogoutViewBase
from django.contrib.messages.views import SuccessMessageMixin from django.contrib.messages.views import SuccessMessageMixin
from django.core.exceptions import BadRequest
from django.http import HttpResponseForbidden from django.http import HttpResponseForbidden
from django.middleware.csrf import REASON_BAD_ORIGIN from django.middleware.csrf import REASON_BAD_ORIGIN
from django.shortcuts import redirect, render from django.shortcuts import redirect, render
@ -147,6 +148,21 @@ class UserPassword(LoginRequiredMixin, View):
return render(request, self.template_name, {"form": form}) 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): class UserSettings(LoginRequiredMixin, View):
""" """
Handles both the User and Settings models. Handles both the User and Settings models.
@ -158,21 +174,19 @@ class UserSettings(LoginRequiredMixin, View):
template_name = "babybuddy/user_settings_form.html" template_name = "babybuddy/user_settings_form.html"
def get(self, request): def get(self, request):
settings = request.user.settings
return render( return render(
request, request,
self.template_name, self.template_name,
{ {
"form_user": self.form_user_class(instance=request.user), "form_user": self.form_user_class(instance=request.user),
"form_settings": self.form_settings_class( "form_settings": self.form_settings_class(instance=settings),
instance=request.user.settings
),
}, },
) )
def post(self, request): def post(self, request):
if request.POST.get("api_key_regenerate"): if handle_api_regenerate_request(request):
request.user.settings.api_key(reset=True)
messages.success(request, _("User API key regenerated."))
return redirect("babybuddy:user-settings") return redirect("babybuddy:user-settings")
form_user = self.form_user_class(instance=request.user, data=request.POST) 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): class Welcome(LoginRequiredMixin, TemplateView):
""" """
Basic introduction to Baby Buddy (meant to be shown when no data is in the Basic introduction to Baby Buddy (meant to be shown when no data is in the

View File

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

View File

@ -13,6 +13,7 @@ django-imagekit==4.1.0
django-import-export==2.9.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-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-storages==1.13.1
django-qr-code==3.1.1
django-taggit==3.0.0 django-taggit==3.0.0
django-widget-tweaks==1.4.12 django-widget-tweaks==1.4.12
djangorestframework==3.14.0 djangorestframework==3.14.0