Add an API!
This commit is contained in:
commit
1280433a49
16
GUIDE.md
16
GUIDE.md
@ -212,6 +212,22 @@ In a single-page application, the page never reloads. (That's the entire point o
|
|||||||
|
|
||||||
Fortunately, Shynet offers a simple method you can call from anywhere within your JavaScript to indicate that a new page has been loaded: `Shynet.newPageLoad()`. Add this method call to the code that handles routing in your app, and you'll be ready to go.
|
Fortunately, Shynet offers a simple method you can call from anywhere within your JavaScript to indicate that a new page has been loaded: `Shynet.newPageLoad()`. Add this method call to the code that handles routing in your app, and you'll be ready to go.
|
||||||
|
|
||||||
|
|
||||||
|
### API
|
||||||
|
|
||||||
|
All the information displayed on the dashboard can be obtained via API on url ```//shynet.example.com/api/v1/dashboard/```. By default this endpoint will return the full data from all services over the last last 30 days. The `Authentication` header should be set to use user's parsonal API token (```'Authorization: Token <user API token>'```).
|
||||||
|
|
||||||
|
There are 3 optional query parameters:
|
||||||
|
* `uuid` - to get data only from one service
|
||||||
|
* `startDate` - to set start date in format YYYY-MM-DD
|
||||||
|
* `endDate` - to set end date in format YYYY-MM-DD
|
||||||
|
|
||||||
|
Example in HTTPie:
|
||||||
|
```http get '//shynet.example.com/api/v1/dashboard/?uuid={{service_uuid}}&startDate=2021-01-01&endDate=2050-01-01' 'Authorization:Token {{user_api_token}}'```
|
||||||
|
|
||||||
|
Example in cURL:
|
||||||
|
```curl -H 'Authorization:Token {{user_api_token}}' '//shynet.example.com/api/v1/dashboard/?uuid={{service_uuid}}&startDate=2021-01-01&endDate=2050-01-01'```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Troubleshooting
|
## Troubleshooting
|
||||||
|
14
poetry.lock
generated
14
poetry.lock
generated
@ -301,6 +301,17 @@ python3-openid = ">=3.0.8"
|
|||||||
requests = "*"
|
requests = "*"
|
||||||
requests-oauthlib = ">=0.3.0"
|
requests-oauthlib = ">=0.3.0"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "django-cors-headers"
|
||||||
|
version = "3.13.0"
|
||||||
|
description = "django-cors-headers is a Django application for handling the server headers required for Cross-Origin Resource Sharing (CORS)."
|
||||||
|
category = "main"
|
||||||
|
optional = false
|
||||||
|
python-versions = ">=3.7"
|
||||||
|
|
||||||
|
[package.dependencies]
|
||||||
|
Django = ">=3.2"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "django-coverage-plugin"
|
name = "django-coverage-plugin"
|
||||||
version = "2.0.3"
|
version = "2.0.3"
|
||||||
@ -963,7 +974,7 @@ multidict = ">=4.0"
|
|||||||
[metadata]
|
[metadata]
|
||||||
lock-version = "1.1"
|
lock-version = "1.1"
|
||||||
python-versions = "^3.8"
|
python-versions = "^3.8"
|
||||||
content-hash = "ab6edbffdf2275a6e323a34d85f1f354264ad3a46d3c179922a7ee72f88ce1a2"
|
content-hash = "af3d0422c23c7381c78eaab410bdd1a0fa1ca2f19d615a4d365808b705e73188"
|
||||||
|
|
||||||
[metadata.files]
|
[metadata.files]
|
||||||
aiohttp = [
|
aiohttp = [
|
||||||
@ -1084,6 +1095,7 @@ django = []
|
|||||||
django-allauth = [
|
django-allauth = [
|
||||||
{file = "django-allauth-0.45.0.tar.gz", hash = "sha256:6d46be0e1480316ccd45476db3aefb39db70e038d2a543112d314b76bb999a4e"},
|
{file = "django-allauth-0.45.0.tar.gz", hash = "sha256:6d46be0e1480316ccd45476db3aefb39db70e038d2a543112d314b76bb999a4e"},
|
||||||
]
|
]
|
||||||
|
django-cors-headers = []
|
||||||
django-coverage-plugin = []
|
django-coverage-plugin = []
|
||||||
django-debug-toolbar = []
|
django-debug-toolbar = []
|
||||||
django-health-check = []
|
django-health-check = []
|
||||||
|
@ -26,6 +26,7 @@ django-health-check = "^3.16.4"
|
|||||||
django-npm = "^1.0.0"
|
django-npm = "^1.0.0"
|
||||||
python-dotenv = "^0.18.0"
|
python-dotenv = "^0.18.0"
|
||||||
django-debug-toolbar = "^3.2.1"
|
django-debug-toolbar = "^3.2.1"
|
||||||
|
django-cors-headers = "^3.11.0"
|
||||||
|
|
||||||
[tool.poetry.dev-dependencies]
|
[tool.poetry.dev-dependencies]
|
||||||
pytest-sugar = "^0.9.4"
|
pytest-sugar = "^0.9.4"
|
||||||
|
0
shynet/api/__init__.py
Normal file
0
shynet/api/__init__.py
Normal file
1
shynet/api/admin.py
Normal file
1
shynet/api/admin.py
Normal file
@ -0,0 +1 @@
|
|||||||
|
# from django.contrib import admin
|
6
shynet/api/apps.py
Normal file
6
shynet/api/apps.py
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class ApiConfig(AppConfig):
|
||||||
|
default_auto_field = 'django.db.models.BigAutoField'
|
||||||
|
name = 'api'
|
0
shynet/api/migrations/__init__.py
Normal file
0
shynet/api/migrations/__init__.py
Normal file
23
shynet/api/mixins.py
Normal file
23
shynet/api/mixins.py
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
from django.http import JsonResponse
|
||||||
|
from django.contrib.auth.models import AnonymousUser
|
||||||
|
|
||||||
|
from core.models import User
|
||||||
|
|
||||||
|
|
||||||
|
class ApiTokenRequiredMixin:
|
||||||
|
def _get_user_by_token(self, request):
|
||||||
|
token = request.headers.get('Authorization')
|
||||||
|
if not token or not token.startswith('Token '):
|
||||||
|
return AnonymousUser()
|
||||||
|
|
||||||
|
token = token.split(' ')[1]
|
||||||
|
user = User.objects.filter(api_token=token).first()
|
||||||
|
|
||||||
|
return user if user else AnonymousUser()
|
||||||
|
|
||||||
|
def dispatch(self, request, *args, **kwargs):
|
||||||
|
request.user = self._get_user_by_token(request)
|
||||||
|
if not request.user.is_authenticated:
|
||||||
|
return JsonResponse(data={}, status=403)
|
||||||
|
|
||||||
|
return super().dispatch(request, *args, **kwargs)
|
1
shynet/api/models.py
Normal file
1
shynet/api/models.py
Normal file
@ -0,0 +1 @@
|
|||||||
|
# from django.db import models
|
3
shynet/api/tests.py
Normal file
3
shynet/api/tests.py
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
from django.test import TestCase
|
||||||
|
|
||||||
|
# Create your tests here.
|
7
shynet/api/urls.py
Normal file
7
shynet/api/urls.py
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
from django.urls import path
|
||||||
|
|
||||||
|
from . import views
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
path("dashboard/", views.DashboardApiView.as_view(), name="services"),
|
||||||
|
]
|
60
shynet/api/views.py
Normal file
60
shynet/api/views.py
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
import uuid
|
||||||
|
from django.http import JsonResponse
|
||||||
|
from django.db.models import Q
|
||||||
|
from django.db.models.query import QuerySet
|
||||||
|
from django.views.generic import View
|
||||||
|
|
||||||
|
from dashboard.mixins import DateRangeMixin
|
||||||
|
from core.models import Service
|
||||||
|
|
||||||
|
from .mixins import ApiTokenRequiredMixin
|
||||||
|
|
||||||
|
|
||||||
|
def is_valid_uuid(value):
|
||||||
|
try:
|
||||||
|
uuid.UUID(value)
|
||||||
|
return True
|
||||||
|
except ValueError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
class DashboardApiView(ApiTokenRequiredMixin, DateRangeMixin, View):
|
||||||
|
def get(self, request, *args, **kwargs):
|
||||||
|
services = Service.objects.filter(
|
||||||
|
Q(owner=request.user) | Q(collaborators__in=[request.user])
|
||||||
|
).distinct()
|
||||||
|
|
||||||
|
uuid = request.GET.get('uuid')
|
||||||
|
if uuid and is_valid_uuid(uuid):
|
||||||
|
services = services.filter(uuid=uuid)
|
||||||
|
|
||||||
|
try:
|
||||||
|
start = self.get_start_date()
|
||||||
|
end = self.get_end_date()
|
||||||
|
except ValueError:
|
||||||
|
return JsonResponse(status=400, data={'error': 'Invalid date format'})
|
||||||
|
|
||||||
|
services_data = [
|
||||||
|
{
|
||||||
|
'name': s.name,
|
||||||
|
'uuid': s.uuid,
|
||||||
|
'link': s.link,
|
||||||
|
'stats': s.get_core_stats(start, end),
|
||||||
|
}
|
||||||
|
for s in services
|
||||||
|
]
|
||||||
|
|
||||||
|
services_data = self._convert_querysets_to_lists(services_data)
|
||||||
|
|
||||||
|
return JsonResponse(data={'services': services_data})
|
||||||
|
|
||||||
|
def _convert_querysets_to_lists(self, services_data):
|
||||||
|
for service_data in services_data:
|
||||||
|
for key, value in service_data['stats'].items():
|
||||||
|
if isinstance(value, QuerySet):
|
||||||
|
service_data['stats'][key] = list(value)
|
||||||
|
for key, value in service_data['stats']['compare'].items():
|
||||||
|
if isinstance(value, QuerySet):
|
||||||
|
service_data['stats']['compare'][key] = list(value)
|
||||||
|
|
||||||
|
return services_data
|
24
shynet/core/migrations/0009_auto_20211117_0217.py
Normal file
24
shynet/core/migrations/0009_auto_20211117_0217.py
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
# Generated by Django 3.2.5 on 2021-11-17 07:17
|
||||||
|
|
||||||
|
import core.models
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('core', '0008_auto_20200628_1403'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='user',
|
||||||
|
name='api_token',
|
||||||
|
field=models.TextField(default=core.models._default_api_token, unique=True),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='user',
|
||||||
|
name='id',
|
||||||
|
field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
|
||||||
|
),
|
||||||
|
]
|
@ -1,8 +1,9 @@
|
|||||||
import ipaddress
|
import ipaddress
|
||||||
import json
|
|
||||||
import re
|
import re
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
|
from secrets import token_urlsafe
|
||||||
|
|
||||||
from django.apps import apps
|
from django.apps import apps
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.contrib.auth.models import AbstractUser
|
from django.contrib.auth.models import AbstractUser
|
||||||
@ -44,9 +45,14 @@ def _parse_network_list(networks: str):
|
|||||||
return [ipaddress.ip_network(network.strip()) for network in networks.split(",")]
|
return [ipaddress.ip_network(network.strip()) for network in networks.split(",")]
|
||||||
|
|
||||||
|
|
||||||
|
def _default_api_token():
|
||||||
|
return token_urlsafe(32)
|
||||||
|
|
||||||
|
|
||||||
class User(AbstractUser):
|
class User(AbstractUser):
|
||||||
username = models.TextField(default=_default_uuid, unique=True)
|
username = models.TextField(default=_default_uuid, unique=True)
|
||||||
email = models.EmailField(unique=True)
|
email = models.EmailField(unique=True)
|
||||||
|
api_token = models.TextField(default=_default_api_token, unique=True)
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return self.email
|
return self.email
|
||||||
@ -211,6 +217,7 @@ class Service(models.Model):
|
|||||||
chart_data, chart_tooltip_format, chart_granularity = self._get_chart_data(
|
chart_data, chart_tooltip_format, chart_granularity = self._get_chart_data(
|
||||||
sessions, hits, start_time, end_time, tz_now
|
sessions, hits, start_time, end_time, tz_now
|
||||||
)
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"currently_online": currently_online,
|
"currently_online": currently_online,
|
||||||
"session_count": session_count,
|
"session_count": session_count,
|
||||||
|
@ -2,8 +2,8 @@
|
|||||||
|
|
||||||
{% load i18n a17t_tags %}
|
{% load i18n a17t_tags %}
|
||||||
|
|
||||||
{% block head_title %}{% trans "Change Password" %}{% endblock %}
|
{% block head_title %}{% trans "Change authentication info" %}{% endblock %}
|
||||||
{% block page_title %}{% trans "Change Password" %}{% endblock %}
|
{% block page_title %}{% trans "Change authentication info" %}{% endblock %}
|
||||||
|
|
||||||
{% block card %}
|
{% block card %}
|
||||||
<form method="POST" action="{% url 'account_change_password' %}" class="password_change max-w-lg">
|
<form method="POST" action="{% url 'account_change_password' %}" class="password_change max-w-lg">
|
||||||
@ -11,4 +11,17 @@
|
|||||||
{{ form|a17t }}
|
{{ form|a17t }}
|
||||||
<button type="submit" name="action" class="button ~urge !high">{% trans "Change Password" %}</button>
|
<button type="submit" name="action" class="button ~urge !high">{% trans "Change Password" %}</button>
|
||||||
</form>
|
</form>
|
||||||
|
<hr class="sep">
|
||||||
|
<div>
|
||||||
|
<p class="label mb-1">Personal API token</p>
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<span class='chip ~info !normal'>{{request.user.api_token}}</span>
|
||||||
|
<form method="POST" action="{% url 'dashboard:api_token_refresh' %}">
|
||||||
|
{% csrf_token %}
|
||||||
|
<button type="submit" name="action" class="button ~neutral @high">{% trans "Refresh token" %}</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<p class="support mt-1">To learn more about the API, see our <a href="https://github.com/milesmcc/shynet/blob/master/GUIDE.md#api">API guide</a>.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
@ -34,5 +34,20 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
<hr class="sep h-4">
|
||||||
|
<h5>API</h5>
|
||||||
|
<div class="card ~neutral !low content">
|
||||||
|
<p>Shynet provides a simple API that you can use to pull data programmatically. You can access this data via this URL:</p>
|
||||||
|
<code class="text-sm">{{script_protocol}}{{request.get_host}}{% url 'api:services' %}?uuid={{object.uuid}}</code>
|
||||||
|
<p>
|
||||||
|
There are 2 optional query parameters:
|
||||||
|
<ul>
|
||||||
|
<li><code class="text-sm">startDate</code> — to set the start date (in format YYYY-MM-DD)</li>
|
||||||
|
<li><code class="text-sm">endDate</code> — to set the end date (in format YYYY-MM-DD)</li>
|
||||||
|
</ul>
|
||||||
|
</p>
|
||||||
|
<p>Example using cURL:</p>
|
||||||
|
<code class="text-sm">curl -H 'Authorization: Token {{request.user.api_token}}' '{{script_protocol}}{{request.get_host}}{% url 'api:services' %}?uuid={{object.uuid}}&startDate=2021-01-01&endDate=2050-01-01'</code>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
@ -1,6 +1,4 @@
|
|||||||
from django.contrib import admin
|
from django.urls import path
|
||||||
from django.urls import include, path
|
|
||||||
from django.views.generic import RedirectView
|
|
||||||
|
|
||||||
from . import views
|
from . import views
|
||||||
|
|
||||||
@ -28,4 +26,9 @@ urlpatterns = [
|
|||||||
views.ServiceSessionView.as_view(),
|
views.ServiceSessionView.as_view(),
|
||||||
name="service_session",
|
name="service_session",
|
||||||
),
|
),
|
||||||
|
path(
|
||||||
|
"api-token-refresh/",
|
||||||
|
views.RefreshApiTokenView.as_view(),
|
||||||
|
name="api_token_refresh",
|
||||||
|
),
|
||||||
]
|
]
|
||||||
|
@ -3,8 +3,7 @@ from django.contrib.auth.mixins import LoginRequiredMixin
|
|||||||
from django.contrib.messages.views import SuccessMessageMixin
|
from django.contrib.messages.views import SuccessMessageMixin
|
||||||
from django.core.cache import cache
|
from django.core.cache import cache
|
||||||
from django.db.models import Q
|
from django.db.models import Q
|
||||||
from django.shortcuts import get_object_or_404, reverse
|
from django.shortcuts import get_object_or_404, reverse, redirect
|
||||||
from django.utils import timezone
|
|
||||||
from django.views.generic import (
|
from django.views.generic import (
|
||||||
CreateView,
|
CreateView,
|
||||||
DeleteView,
|
DeleteView,
|
||||||
@ -12,11 +11,12 @@ from django.views.generic import (
|
|||||||
ListView,
|
ListView,
|
||||||
TemplateView,
|
TemplateView,
|
||||||
UpdateView,
|
UpdateView,
|
||||||
|
View,
|
||||||
)
|
)
|
||||||
from rules.contrib.views import PermissionRequiredMixin
|
from rules.contrib.views import PermissionRequiredMixin
|
||||||
|
|
||||||
from analytics.models import Session
|
from analytics.models import Session
|
||||||
from core.models import Service
|
from core.models import Service, _default_api_token
|
||||||
|
|
||||||
from .forms import ServiceForm
|
from .forms import ServiceForm
|
||||||
from .mixins import DateRangeMixin
|
from .mixins import DateRangeMixin
|
||||||
@ -155,3 +155,10 @@ class ServiceSessionView(LoginRequiredMixin, PermissionRequiredMixin, DetailView
|
|||||||
data = super().get_context_data(**kwargs)
|
data = super().get_context_data(**kwargs)
|
||||||
data["object"] = get_object_or_404(Service, pk=self.kwargs.get("pk"))
|
data["object"] = get_object_or_404(Service, pk=self.kwargs.get("pk"))
|
||||||
return data
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
class RefreshApiTokenView(LoginRequiredMixin, View):
|
||||||
|
def post(self, request):
|
||||||
|
request.user.api_token = _default_api_token()
|
||||||
|
request.user.save()
|
||||||
|
return redirect('account_change_password')
|
||||||
|
@ -60,16 +60,19 @@ INSTALLED_APPS = [
|
|||||||
"core",
|
"core",
|
||||||
"dashboard.apps.DashboardConfig",
|
"dashboard.apps.DashboardConfig",
|
||||||
"analytics",
|
"analytics",
|
||||||
|
"api",
|
||||||
"allauth",
|
"allauth",
|
||||||
"allauth.account",
|
"allauth.account",
|
||||||
"allauth.socialaccount",
|
"allauth.socialaccount",
|
||||||
"debug_toolbar",
|
"debug_toolbar",
|
||||||
|
"corsheaders",
|
||||||
]
|
]
|
||||||
|
|
||||||
MIDDLEWARE = [
|
MIDDLEWARE = [
|
||||||
"django.middleware.security.SecurityMiddleware",
|
"django.middleware.security.SecurityMiddleware",
|
||||||
"whitenoise.middleware.WhiteNoiseMiddleware",
|
"whitenoise.middleware.WhiteNoiseMiddleware",
|
||||||
"django.contrib.sessions.middleware.SessionMiddleware",
|
"django.contrib.sessions.middleware.SessionMiddleware",
|
||||||
|
"corsheaders.middleware.CorsMiddleware",
|
||||||
"django.middleware.common.CommonMiddleware",
|
"django.middleware.common.CommonMiddleware",
|
||||||
"django.middleware.csrf.CsrfViewMiddleware",
|
"django.middleware.csrf.CsrfViewMiddleware",
|
||||||
"django.contrib.auth.middleware.AuthenticationMiddleware",
|
"django.contrib.auth.middleware.AuthenticationMiddleware",
|
||||||
@ -371,3 +374,6 @@ DASHBOARD_PAGE_SIZE = int(os.getenv("DASHBOARD_PAGE_SIZE", "5"))
|
|||||||
USE_RELATIVE_MAX_IN_BAR_VISUALIZATION = (
|
USE_RELATIVE_MAX_IN_BAR_VISUALIZATION = (
|
||||||
os.getenv("USE_RELATIVE_MAX_IN_BAR_VISUALIZATION", "True") == "True"
|
os.getenv("USE_RELATIVE_MAX_IN_BAR_VISUALIZATION", "True") == "True"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
CORS_ALLOW_ALL_ORIGINS = True
|
||||||
|
CORS_ALLOW_METHODS = ["GET", "OPTIONS"]
|
||||||
|
@ -25,4 +25,5 @@ urlpatterns = [
|
|||||||
path("dashboard/", include(("dashboard.urls", "dashboard"), namespace="dashboard")),
|
path("dashboard/", include(("dashboard.urls", "dashboard"), namespace="dashboard")),
|
||||||
path("healthz/", include("health_check.urls")),
|
path("healthz/", include("health_check.urls")),
|
||||||
path("", include(("core.urls", "core"), namespace="core")),
|
path("", include(("core.urls", "core"), namespace="core")),
|
||||||
|
path("api/v1/", include(("api.urls", "api"), namespace="api")),
|
||||||
]
|
]
|
||||||
|
Loading…
Reference in New Issue
Block a user