1
0
Fork 0
mirror of https://gitlab.com/bramw/baserow.git synced 2025-04-10 23:50:12 +00:00

Merge branch '454-admin-dashboard' into 'develop'

Resolve "Admin dashboard"

Closes 

See merge request 
This commit is contained in:
Bram Wiepjes 2021-05-26 14:36:26 +00:00
commit 0dc29a15ce
29 changed files with 1379 additions and 9 deletions
backend
src/baserow
tests/baserow/api
changelog.md
premium
backend
src/baserow_premium
tests/baserow_premium
web-frontend/modules/baserow_premium
adminTypes.js
assets/scss/components
components/admin/dashboard/charts
pages/admin
plugin.jsroutes.js
services
web-frontend

View file

@ -1,3 +1,13 @@
from datetime import datetime
from pytz import timezone as pytz_timezone
from pytz.exceptions import UnknownTimeZoneError
from django.utils import timezone
from rest_framework import status
from rest_framework.exceptions import APIException
from .utils import (
map_exceptions as map_exceptions_utility,
get_request,
@ -196,3 +206,51 @@ def allowed_includes(*allowed):
return func_wrapper
return validate_decorator
def accept_timezone():
"""
This view decorator optionally accepts a timezone GET parameter. If provided, then
the timezone is parsed via the pytz package and a now date is calculated with
that timezone. A list of supported timezones can be found on
https://gist.github.com/heyalexej/8bf688fd67d7199be4a1682b3eec7568.
class SomeView(View):
@accept_timezone()
def get(self, request, now):
print(now.tzinfo)
HTTP /some-view/?timezone=Etc/GMT-1
>>> <StaticTzInfo 'Etc/GMT-1'>
"""
def validate_decorator(func):
def func_wrapper(*args, **kwargs):
request = get_request(args)
timezone_string = request.GET.get("timezone")
try:
kwargs["now"] = (
datetime.utcnow().astimezone(pytz_timezone(timezone_string))
if timezone_string
else timezone.now()
)
except UnknownTimeZoneError:
exc = APIException(
{
"error": "UNKNOWN_TIME_ZONE_ERROR",
"detail": f"The timezone {timezone_string} is not supported. A "
f"list of support timezones can be found on "
f"https://gist.github.com/heyalexej/8bf688fd67d7199be4a1682b3e"
f"ec7568.",
}
)
exc.status_code = status.HTTP_400_BAD_REQUEST
raise exc
return func(*args, **kwargs)
return func_wrapper
return validate_decorator

View file

@ -6,7 +6,7 @@ from django.contrib.auth.models import update_last_login
from baserow.api.groups.invitations.serializers import UserGroupInvitationSerializer
from baserow.core.user.utils import normalize_email_address
from baserow.core.models import Template
from baserow.core.models import Template, UserLogEntry
User = get_user_model()
@ -91,6 +91,7 @@ class NormalizedEmailWebTokenSerializer(JSONWebTokenSerializer):
# respond with machine readable error codes when authentication fails.
validated_data = super().validate(attrs)
update_last_login(None, validated_data["user"])
UserLogEntry.objects.create(actor=validated_data["user"], action="SIGNED_IN")
# Call the user_signed_in method for each plugin that is un the registry to
# notify all plugins that a user has signed in.
from baserow.core.registries import plugin_registry

View file

@ -251,7 +251,7 @@ EMAIL_BACKEND = "djcelery_email.backends.CeleryEmailBackend"
if os.getenv("EMAIL_SMTP", ""):
CELERY_EMAIL_BACKEND = "django.core.mail.backends.smtp.EmailBackend"
# EMAIL_SMTP_USE_TLS OR EMAIL_SMTP_USE_TLS for backwards compatibilty after
# EMAIL_SMTP_USE_TLS OR EMAIL_SMTP_USE_TLS for backwards compatibility after
# fixing #448.
EMAIL_USE_TLS = bool(os.getenv("EMAIL_SMTP_USE_TLS", "")) or bool(
os.getenv("EMAIL_SMPT_USE_TLS", "")

View file

@ -0,0 +1,48 @@
# Generated by Django 2.2.11 on 2021-05-19 19:45
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
("core", "0006_template_templatecategory"),
]
operations = [
migrations.CreateModel(
name="UserLogEntry",
fields=[
(
"id",
models.AutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"action",
models.CharField(
choices=[("SIGNED_IN", "Signed in")], max_length=20
),
),
("timestamp", models.DateTimeField(auto_now_add=True)),
(
"actor",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
to=settings.AUTH_USER_MODEL,
),
),
],
options={
"ordering": ["-timestamp"],
"get_latest_by": "timestamp",
},
),
]

View file

@ -237,3 +237,13 @@ class Template(models.Model):
class Meta:
ordering = ("name",)
class UserLogEntry(models.Model):
actor = models.ForeignKey(User, on_delete=models.CASCADE)
action = models.CharField(max_length=20, choices=(("SIGNED_IN", "Signed in"),))
timestamp = models.DateTimeField(auto_now_add=True)
class Meta:
get_latest_by = "timestamp"
ordering = ["-timestamp"]

View file

@ -1,5 +1,6 @@
import pytest
import json
import pytz
from unittest.mock import MagicMock
@ -16,6 +17,7 @@ from baserow.api.decorators import (
validate_body,
validate_body_custom_fields,
allowed_includes,
accept_timezone,
)
from baserow.core.models import Group
from baserow.core.registry import (
@ -322,3 +324,44 @@ def test_allowed_includes():
assert not test_3
test_3(None, request)
def test_accept_timezone():
factory = APIRequestFactory()
request = Request(
factory.get(
"/some-page/",
data={"timezone": "NOT_EXISTING"},
)
)
@accept_timezone()
def test_1(self, request, now):
pass
with pytest.raises(APIException) as api_exception:
test_1(None, request)
assert api_exception.value.detail["error"] == "UNKNOWN_TIME_ZONE_ERROR"
request = Request(factory.get("/some-page/"))
@accept_timezone()
def test_1(self, request, now):
assert now.tzinfo == pytz.utc
test_1(None, request)
request = Request(
factory.get(
"/some-page/",
data={"timezone": "Etc/GMT+2"},
)
)
@accept_timezone()
def test_1(self, request, now):
assert now.tzinfo == pytz.timezone("Etc/GMT+2")
test_1(None, request)

View file

@ -12,6 +12,7 @@ from django.contrib.auth import get_user_model
from rest_framework_jwt.settings import api_settings
from baserow.core.registries import plugin_registry, Plugin
from baserow.core.models import UserLogEntry
jwt_payload_handler = api_settings.JWT_PAYLOAD_HANDLER
@ -75,6 +76,12 @@ def test_token_auth(api_client, data_fixture):
user.refresh_from_db()
assert user.last_login == datetime(2020, 1, 1, 12, 00, tzinfo=timezone("UTC"))
logs = UserLogEntry.objects.all()
assert len(logs) == 1
assert logs[0].actor_id == user.id
assert logs[0].action == "SIGNED_IN"
assert logs[0].timestamp == datetime(2020, 1, 1, 12, 00, tzinfo=timezone("UTC"))
with freeze_time("2020-01-02 12:00"):
response = api_client.post(
reverse("api:user:token_auth"),
@ -90,7 +97,7 @@ def test_token_auth(api_client, data_fixture):
assert json["user"]["is_staff"] is False
user.refresh_from_db()
assert user.last_login == datetime(2020, 1, 2, 12, 00, tzinfo=timezone("UTC"))
assert user.last_login == datetime(2020, 1, 2, 12, 0, tzinfo=timezone("UTC"))
data_fixture.create_user(
email="test2@test.nl", password="password", first_name="Test1", is_active=False

View file

@ -9,6 +9,7 @@
* Made it possible to order the groups by drag and drop.
* Made it possible to order the applications by drag and drop.
* Made it possible to order the tables by drag and drop.
* **Premium**: Added an admin dashboard.
* Added today, this month and this year filter.
* Added a human-readable error message when a user tries to sign in with a deactivated
account.

View file

@ -0,0 +1,190 @@
from django.db.models import Count, Q
from django.contrib.auth import get_user_model
from django.utils import timezone
from baserow.core.models import UserLogEntry
User = get_user_model()
class AdminDashboardHandler:
def get_counts_from_delta_range(
self,
queryset,
date_field_name,
delta_mapping,
expression="pk",
now=None,
distinct=False,
additional_filters=None,
include_previous=False,
):
"""
Calculates the count of the queryset for multiple date ranges. The
`delta_mapping` should be a dict containing a timedelta that will be
subtracted from the provided now date. The count will be calculated for each
(now - delta) until now. This will all be done in a single query for better
performance.
Example:
self.get_counts_from_delta_range(
queryset=User.objects,
date_field_name="date_joined",
delta_mapping={
"24_hours": timedelta(hours=24)
},
include_previous=True,
) ==
"24_hours": 3,
"previous_24_hours": 2
}
:param queryset: The base queryset that is used to calculate the counts for.
:type: queryset: QuerySet
:param date_field_name: The date or datetime field name in the queryset that
is used for the range.
:type date_field_name: str
:param delta_mapping: The key of this dict must be a unique name and the
value is the timedelta that is used to calculate the range.
:type delta_mapping: dict
:param expression: The expression that is used when doing the count.
:type expression: str
:param now: If not provided, the current date will be used. This date is used
as base for calculating the range which is (now - delta) until now.
:type now: datetime or None
:param distinct: Indicates if the results
:type distinct: bool
:param additional_filters:
:type additional_filters: dict
:param include_previous: Indicates if an additional count of the the range
before each delta_mapping mapping value must be added to the response
under the name `previous_{name}`. If the delta is for example 24 hours
than additionally a count of the range 48 hours before now until 24 hours
before will be included. This can for example be used to show a
difference of how will things are performing.
:type include_previous: bool
:return: A dict containing the provided `delta_mapping` keys and the values
are the corresponding counts.
:rtype: dict
"""
if not now:
now = timezone.now()
if not additional_filters:
additional_filters = {}
def get_count(start, end):
return Count(
expression,
filter=Q(
**{
f"{date_field_name}__gt": start,
f"{date_field_name}__lte": end,
**additional_filters,
}
),
distinct=distinct,
)
aggregations = {}
for name, delta in delta_mapping.items():
aggregations[name] = get_count(now - delta, now)
if include_previous:
aggregations[f"previous_{name}"] = get_count(
now - delta - delta, now - delta
)
return queryset.aggregate(**aggregations)
def get_new_user_counts(self, delta_mapping, now=None, include_previous=False):
return self.get_counts_from_delta_range(
queryset=User.objects,
date_field_name="date_joined",
delta_mapping=delta_mapping,
now=now,
include_previous=include_previous,
)
def get_active_user_count(self, delta_mapping, now=None, include_previous=False):
return self.get_counts_from_delta_range(
queryset=UserLogEntry.objects,
date_field_name="timestamp",
delta_mapping=delta_mapping,
expression="actor_id",
now=now,
distinct=True,
additional_filters={"action": "SIGNED_IN"},
include_previous=include_previous,
)
def get_new_user_count_per_day(self, delta, now=None):
"""
Returns the new user count for each day in the provided range. The range is
calculated based by subtracting the delta from the row until now. (now -
delta until now).
:param delta: The timedelta that is subtracted from the now date to
calculate the range. If for example timedelta(days=14) is provided,
then the count of the last 14 days is returned.
:type delta: timedelta
:param now: If not provided, the current date will be used. This date is used
as base for calculating the range which is (now - delta) until now. The
timezone of the object is respected.
:type now: datetime or None
:rtype: A list containing a dict for each date including the date and the count.
:rtype: list
"""
if not now:
now = timezone.now()
return (
User.objects.filter(date_joined__gt=now - delta, date_joined__lte=now)
.extra(
{"date": "date(date_joined at time zone %s)"},
select_params=(str(now.tzinfo),),
)
.order_by("date")
.values("date")
.annotate(count=Count("id"))
)
def get_active_user_count_per_day(self, delta, now=None):
"""
Returns the active user count for each day in the provided range. Someone is
classified as an active user if he has signed in during the provided date
range. The range is calculated based by subtracting the delta from the row
until now. (now - delta until now).
:param delta: The timedelta that is subtracted from the now date to
calculate the range. If for example timedelta(days=14) is provided,
then the count of the last 14 days is returned.
:type delta: timedelta
:param now: If not provided, the current date will be used. This date is used
as base for calculating the range which is (now - delta) until now. The
timezone of the object is respected.
:type now: datetime or None
:rtype: A list containing a dict for each date including the date and the count.
:rtype: list
"""
if not now:
now = timezone.now()
return (
UserLogEntry.objects.filter(
action="SIGNED_IN", timestamp__gt=now - delta, timestamp__lte=now
)
.extra(
{"date": "date(timestamp at time zone %s)"},
select_params=(str(now.tzinfo),),
)
.order_by("date")
.values("date")
.annotate(count=Count("actor_id", distinct=True))
)

View file

@ -0,0 +1,30 @@
from rest_framework import serializers
class AdminDashboardPerDaySerializer(serializers.Serializer):
date = serializers.DateField()
count = serializers.IntegerField()
class AdminDashboardSerializer(serializers.Serializer):
total_users = serializers.IntegerField()
total_groups = serializers.IntegerField()
total_applications = serializers.IntegerField()
new_users_last_24_hours = serializers.IntegerField()
new_users_last_7_days = serializers.IntegerField()
new_users_last_30_days = serializers.IntegerField()
previous_new_users_last_24_hours = serializers.IntegerField()
previous_new_users_last_7_days = serializers.IntegerField()
previous_new_users_last_30_days = serializers.IntegerField()
active_users_last_24_hours = serializers.IntegerField()
active_users_last_7_days = serializers.IntegerField()
active_users_last_30_days = serializers.IntegerField()
previous_active_users_last_24_hours = serializers.IntegerField()
previous_active_users_last_7_days = serializers.IntegerField()
previous_active_users_last_30_days = serializers.IntegerField()
new_users_per_day = serializers.ListSerializer(
child=AdminDashboardPerDaySerializer()
)
active_users_per_day = serializers.ListSerializer(
child=AdminDashboardPerDaySerializer()
)

View file

@ -0,0 +1,10 @@
from django.conf.urls import url
from baserow_premium.api.admin_dashboard.views import AdminDashboardView
app_name = "baserow_premium.api.admin_dashboard"
urlpatterns = [
url(r"^$", AdminDashboardView.as_view(), name="dashboard"),
]

View file

@ -0,0 +1,86 @@
from datetime import timedelta
from django.contrib.auth import get_user_model
from drf_spectacular.utils import extend_schema
from rest_framework.response import Response
from rest_framework.permissions import IsAdminUser
from rest_framework.views import APIView
from baserow.api.decorators import accept_timezone
from baserow.core.models import Group, Application
from baserow_premium.admin_dashboard.handler import AdminDashboardHandler
from .serializers import AdminDashboardSerializer
User = get_user_model()
class AdminDashboardView(APIView):
permission_classes = (IsAdminUser,)
@extend_schema(
tags=["Admin dashboard"],
operation_id="admin_dashboard",
description="Returns the new and active users for the last 24 hours, 7 days and"
" 30 days. The `previous_` values are the values of the period before, so for "
"example `previous_new_users_last_24_hours` are the new users that signed up "
"from 48 to 24 hours ago. It can be used to calculate an increase or decrease "
"in the amount of signups. A list of the new and active users for every day "
"for the last 30 days is also included.",
responses={
200: AdminDashboardSerializer,
},
)
@accept_timezone()
def get(self, request, now):
"""
Returns the new and active users for the last 24 hours, 7 days and 30 days.
The `previous_` values are the values of the period before, so for example
`previous_new_users_last_24_hours` are the new users that signed up from 48
to 24 hours ago. It can be used to calculate an increase or decrease in the
amount of signups. A list of the new and active users for every day for the
last 30 days is also included.
"""
handler = AdminDashboardHandler()
total_users = User.objects.filter(is_active=True).count()
total_groups = Group.objects.all().count()
total_applications = Application.objects.all().count()
new_users = handler.get_new_user_counts(
{
"new_users_last_24_hours": timedelta(hours=24),
"new_users_last_7_days": timedelta(days=7),
"new_users_last_30_days": timedelta(days=30),
},
include_previous=True,
)
active_users = handler.get_active_user_count(
{
"active_users_last_24_hours": timedelta(hours=24),
"active_users_last_7_days": timedelta(days=7),
"active_users_last_30_days": timedelta(days=30),
},
include_previous=True,
)
new_users_per_day = handler.get_new_user_count_per_day(
timedelta(days=30), now=now
)
active_users_per_day = handler.get_active_user_count_per_day(
timedelta(days=30), now=now
)
serializer = AdminDashboardSerializer(
{
"total_users": total_users,
"total_groups": total_groups,
"total_applications": total_applications,
"new_users_per_day": new_users_per_day,
"active_users_per_day": active_users_per_day,
**new_users,
**active_users,
}
)
return Response(serializer.data)

View file

@ -1,10 +1,14 @@
from django.urls import path, include
from .user_admin import urls as user_admin_urls
from .admin_dashboard import urls as admin_dashboard_urls
app_name = "baserow_premium.api"
urlpatterns = [
path("admin/user/", include(user_admin_urls, namespace="admin_user")),
path(
"admin/dashboard/", include(admin_dashboard_urls, namespace="admin_dashboard")
),
]

View file

@ -0,0 +1,300 @@
import pytest
from pytz import timezone
from datetime import timedelta, datetime, date
from baserow.core.models import UserLogEntry
from baserow_premium.admin_dashboard.handler import AdminDashboardHandler
@pytest.mark.django_db
def test_get_new_user_counts(data_fixture):
tz = timezone("UTC")
data_fixture.create_user(date_joined=datetime(2020, 12, 30, tzinfo=tz))
data_fixture.create_user(date_joined=datetime(2021, 1, 1, tzinfo=tz))
data_fixture.create_user(date_joined=datetime(2021, 1, 2, tzinfo=tz))
data_fixture.create_user(date_joined=datetime(2021, 1, 3, tzinfo=tz))
data_fixture.create_user(date_joined=datetime(2021, 1, 4, tzinfo=tz))
data_fixture.create_user(date_joined=datetime(2021, 1, 5, tzinfo=tz))
data_fixture.create_user(date_joined=datetime(2021, 1, 10, tzinfo=tz))
data_fixture.create_user(date_joined=datetime(2021, 1, 23, tzinfo=tz))
data_fixture.create_user(date_joined=datetime(2021, 1, 24, tzinfo=tz))
data_fixture.create_user(date_joined=datetime(2021, 1, 25, tzinfo=tz))
data_fixture.create_user(date_joined=datetime(2021, 1, 26, tzinfo=tz))
data_fixture.create_user(date_joined=datetime(2021, 1, 27, tzinfo=tz))
data_fixture.create_user(date_joined=datetime(2021, 1, 28, tzinfo=tz))
data_fixture.create_user(date_joined=datetime(2021, 1, 29, tzinfo=tz))
data_fixture.create_user(date_joined=datetime(2021, 1, 30, 12, 1, tzinfo=tz))
data_fixture.create_user(date_joined=datetime(2021, 1, 30, 15, 1, tzinfo=tz))
handler = AdminDashboardHandler()
now = datetime(2021, 1, 30, 23, 59, tzinfo=tz)
assert handler.get_new_user_counts(
{
"last_24_hours": timedelta(hours=24),
"last_7_days": timedelta(days=7),
"last_30_days": timedelta(days=30),
"last_40_days": timedelta(days=40),
"last_10_days": timedelta(days=10),
"last_2_days": timedelta(days=2),
},
now=now,
) == {
"last_24_hours": 2,
"last_7_days": 8,
"last_30_days": 15,
"last_40_days": 16,
"last_10_days": 9,
"last_2_days": 3,
}
assert handler.get_new_user_counts(
{
"last_24_hours": timedelta(hours=24),
"last_7_days": timedelta(days=7),
},
now=now,
include_previous=True,
) == {
"last_24_hours": 2,
"last_7_days": 8,
"previous_last_24_hours": 1,
"previous_last_7_days": 1,
}
@pytest.mark.django_db
def test_get_active_user_counts(data_fixture):
tz = timezone("UTC")
user_1 = data_fixture.create_user()
user_2 = data_fixture.create_user()
user_3 = data_fixture.create_user()
def create_entries(user, dates):
for d in dates:
entry = UserLogEntry()
entry.actor = user
entry.action = "SIGNED_IN"
entry.save()
# To override the auto_now_add.
entry.timestamp = d
entry.save()
create_entries(
user_1,
[
datetime(2020, 12, 30, tzinfo=tz),
datetime(2021, 1, 1, tzinfo=tz),
datetime(2021, 1, 2, tzinfo=tz),
datetime(2021, 1, 3, tzinfo=tz),
datetime(2021, 1, 4, tzinfo=tz),
datetime(2021, 1, 5, tzinfo=tz),
datetime(2021, 1, 7, tzinfo=tz),
datetime(2021, 1, 7, tzinfo=tz),
datetime(2021, 1, 7, tzinfo=tz),
datetime(2021, 1, 8, tzinfo=tz),
datetime(2021, 1, 9, tzinfo=tz),
datetime(2021, 1, 10, tzinfo=tz),
datetime(2021, 1, 20, tzinfo=tz),
datetime(2021, 1, 21, tzinfo=tz),
datetime(2021, 1, 22, tzinfo=tz),
datetime(2021, 1, 29, tzinfo=tz),
],
)
create_entries(
user_2,
[
datetime(2020, 12, 20, tzinfo=tz),
datetime(2021, 1, 1, tzinfo=tz),
datetime(2021, 1, 2, tzinfo=tz),
datetime(2021, 1, 3, tzinfo=tz),
datetime(2021, 1, 4, tzinfo=tz),
datetime(2021, 1, 10, tzinfo=tz),
datetime(2021, 1, 11, tzinfo=tz),
datetime(2021, 1, 12, tzinfo=tz),
datetime(2021, 1, 13, tzinfo=tz),
datetime(2021, 1, 14, tzinfo=tz),
datetime(2021, 1, 15, tzinfo=tz),
datetime(2021, 1, 16, tzinfo=tz),
datetime(2021, 1, 20, tzinfo=tz),
datetime(2021, 1, 21, tzinfo=tz),
datetime(2021, 1, 24, tzinfo=tz),
],
)
create_entries(
user_3,
[
datetime(2020, 12, 20, tzinfo=tz),
datetime(2020, 12, 21, tzinfo=tz),
datetime(2020, 12, 23, tzinfo=tz),
datetime(2020, 12, 25, tzinfo=tz),
datetime(2020, 12, 27, tzinfo=tz),
datetime(2020, 12, 30, tzinfo=tz),
],
)
handler = AdminDashboardHandler()
now = datetime(2021, 1, 30, 23, 59, tzinfo=tz)
assert handler.get_active_user_count(
{
"last_24_hours": timedelta(hours=24),
"last_7_days": timedelta(days=7),
"last_30_days": timedelta(days=30),
"last_40_days": timedelta(days=40),
"last_10_days": timedelta(days=10),
},
now=now,
) == {
"last_24_hours": 0,
"last_7_days": 2,
"last_30_days": 2,
"last_40_days": 3,
"last_10_days": 2,
}
assert handler.get_active_user_count(
{
"last_24_hours": timedelta(hours=24),
"last_7_days": timedelta(days=7),
"last_30_days": timedelta(days=30),
"last_40_days": timedelta(days=40),
"last_10_days": timedelta(days=10),
},
now=now,
include_previous=True,
) == {
"last_24_hours": 0,
"last_7_days": 2,
"last_30_days": 2,
"last_40_days": 3,
"last_10_days": 2,
"previous_last_24_hours": 1,
"previous_last_7_days": 2,
"previous_last_30_days": 3,
"previous_last_40_days": 2,
"previous_last_10_days": 2,
}
@pytest.mark.django_db
def test_get_new_users_per_day(data_fixture):
utc = timezone("UTC")
gmt3 = timezone("Etc/GMT+3")
data_fixture.create_user(date_joined=datetime(2020, 12, 29, 12, 1, tzinfo=utc))
data_fixture.create_user(date_joined=datetime(2021, 1, 1, 1, 1, tzinfo=utc))
data_fixture.create_user(date_joined=datetime(2021, 1, 1, 12, 1, tzinfo=utc))
data_fixture.create_user(date_joined=datetime(2021, 1, 2, 12, 1, tzinfo=utc))
data_fixture.create_user(date_joined=datetime(2021, 1, 2, 12, 1, tzinfo=utc))
data_fixture.create_user(date_joined=datetime(2021, 1, 2, 12, 1, tzinfo=utc))
data_fixture.create_user(date_joined=datetime(2021, 1, 30, 12, 1, tzinfo=utc))
data_fixture.create_user(date_joined=datetime(2021, 1, 30, 15, 1, tzinfo=utc))
handler = AdminDashboardHandler()
now = datetime(2021, 1, 30, 23, 59, tzinfo=utc)
counts = handler.get_new_user_count_per_day(timedelta(days=30), now)
assert len(counts) == 3
assert counts[0]["date"] == date(2021, 1, 1)
assert counts[0]["count"] == 2
assert counts[1]["date"] == date(2021, 1, 2)
assert counts[1]["count"] == 3
assert counts[2]["date"] == date(2021, 1, 30)
assert counts[2]["count"] == 2
now = datetime(2021, 1, 1, 13, 00, tzinfo=utc)
counts = handler.get_new_user_count_per_day(timedelta(days=1), now)
assert len(counts) == 1
assert counts[0]["date"] == date(2021, 1, 1)
assert counts[0]["count"] == 2
now = datetime(2021, 1, 1, 13, 00, tzinfo=gmt3)
counts = handler.get_new_user_count_per_day(timedelta(days=1), now)
assert len(counts) == 2
assert counts[0]["date"] == date(2020, 12, 31)
assert counts[0]["count"] == 1
assert counts[1]["date"] == date(2021, 1, 1)
assert counts[1]["count"] == 1
@pytest.mark.django_db
def test_get_active_users_per_day(data_fixture):
utc = timezone("UTC")
gmt3 = timezone("Etc/GMT+3")
user_1 = data_fixture.create_user()
user_2 = data_fixture.create_user()
user_3 = data_fixture.create_user()
def create_entries(user, dates):
for d in dates:
entry = UserLogEntry()
entry.actor = user
entry.action = "SIGNED_IN"
entry.save()
# To override the auto_now_add.
entry.timestamp = d
entry.save()
create_entries(
user_1,
[
datetime(2020, 12, 29, tzinfo=utc),
datetime(2021, 1, 1, 1, 1, tzinfo=utc),
datetime(2021, 1, 1, 12, 1, tzinfo=utc),
datetime(2021, 1, 1, 13, 1, tzinfo=utc),
datetime(2021, 1, 1, 14, 1, tzinfo=utc),
datetime(2021, 1, 10, 14, 1, tzinfo=utc),
],
)
create_entries(
user_2,
[
datetime(2020, 12, 29, tzinfo=utc),
datetime(2021, 1, 1, 1, 1, tzinfo=utc),
datetime(2021, 1, 10, 12, 1, tzinfo=utc),
datetime(2021, 1, 10, 13, 1, tzinfo=utc),
],
)
create_entries(
user_3,
[
datetime(2021, 1, 2, tzinfo=utc),
datetime(2021, 1, 10, tzinfo=utc),
],
)
handler = AdminDashboardHandler()
now = datetime(2021, 1, 30, 23, 59, tzinfo=utc)
counts = handler.get_active_user_count_per_day(timedelta(days=30), now)
assert len(counts) == 3
assert counts[0]["date"] == date(2021, 1, 1)
assert counts[0]["count"] == 2
assert counts[1]["date"] == date(2021, 1, 2)
assert counts[1]["count"] == 1
assert counts[2]["date"] == date(2021, 1, 10)
assert counts[2]["count"] == 3
now = datetime(2021, 1, 1, 13, 00, tzinfo=utc)
counts = handler.get_active_user_count_per_day(timedelta(days=1), now)
assert len(counts) == 1
assert counts[0]["date"] == date(2021, 1, 1)
assert counts[0]["count"] == 2
now = datetime(2021, 1, 1, 13, 00, tzinfo=gmt3)
counts = handler.get_active_user_count_per_day(timedelta(days=1), now)
assert len(counts) == 2
assert counts[0]["date"] == date(2020, 12, 31)
assert counts[0]["count"] == 2
assert counts[1]["date"] == date(2021, 1, 1)
assert counts[1]["count"] == 1

View file

@ -0,0 +1,85 @@
import pytest
from freezegun import freeze_time
from django.shortcuts import reverse
from rest_framework.status import (
HTTP_200_OK,
HTTP_403_FORBIDDEN,
)
from baserow.core.models import UserLogEntry
@pytest.mark.django_db
def test_admin_dashboard(api_client, data_fixture):
with freeze_time("2020-01-01 00:01"):
normal_user, normal_token = data_fixture.create_user_and_token(
is_staff=False,
)
admin_user, admin_token = data_fixture.create_user_and_token(
is_staff=True,
)
data_fixture.create_database_application(user=normal_user)
UserLogEntry.objects.create(actor=admin_user, action="SIGNED_IN")
response = api_client.get(
reverse("api:premium:admin_dashboard:dashboard"),
format="json",
HTTP_AUTHORIZATION=f"JWT {normal_token}",
)
assert response.status_code == HTTP_403_FORBIDDEN
response = api_client.get(
reverse("api:premium:admin_dashboard:dashboard"),
format="json",
HTTP_AUTHORIZATION=f"JWT {admin_token}",
)
assert response.status_code == HTTP_200_OK
assert response.json() == {
"total_users": 2,
"total_groups": 1,
"total_applications": 1,
"new_users_last_24_hours": 2,
"new_users_last_7_days": 2,
"new_users_last_30_days": 2,
"previous_new_users_last_24_hours": 0,
"previous_new_users_last_7_days": 0,
"previous_new_users_last_30_days": 0,
"active_users_last_24_hours": 1,
"active_users_last_7_days": 1,
"active_users_last_30_days": 1,
"previous_active_users_last_24_hours": 0,
"previous_active_users_last_7_days": 0,
"previous_active_users_last_30_days": 0,
"new_users_per_day": [{"date": "2020-01-01", "count": 2}],
"active_users_per_day": [{"date": "2020-01-01", "count": 1}],
}
url = reverse("api:premium:admin_dashboard:dashboard")
response = api_client.get(
f"{url}?timezone=Etc/GMT%2B1",
format="json",
HTTP_AUTHORIZATION=f"JWT {admin_token}",
)
assert response.status_code == HTTP_200_OK
assert response.json() == {
"total_users": 2,
"total_groups": 1,
"total_applications": 1,
"new_users_last_24_hours": 2,
"new_users_last_7_days": 2,
"new_users_last_30_days": 2,
"previous_new_users_last_24_hours": 0,
"previous_new_users_last_7_days": 0,
"previous_new_users_last_30_days": 0,
"active_users_last_24_hours": 1,
"active_users_last_7_days": 1,
"active_users_last_30_days": 1,
"previous_active_users_last_24_hours": 0,
"previous_active_users_last_7_days": 0,
"previous_active_users_last_30_days": 0,
"new_users_per_day": [{"date": "2019-12-31", "count": 2}],
"active_users_per_day": [{"date": "2019-12-31", "count": 1}],
}

View file

@ -1,5 +1,27 @@
import { AdminType } from '@baserow/modules/core/adminTypes'
export class DashboardType extends AdminType {
static getType() {
return 'dashboard'
}
getIconClass() {
return 'chart-line'
}
getName() {
return 'Dashboard'
}
getRouteName() {
return 'admin-dashboard'
}
getOrder() {
return 0
}
}
export class UsersAdminType extends AdminType {
static getType() {
return 'users'
@ -18,6 +40,6 @@ export class UsersAdminType extends AdminType {
}
getOrder() {
return 0
return 1
}
}

View file

@ -0,0 +1,59 @@
.admin-dashboard {
padding: 30px;
}
.admin-dashboard__box {
position: relative;
border: 1px solid $color-neutral-200;
background-color: $white;
border-radius: 6px;
padding: 30px;
height: 100%;
}
.admin-dashboard__box-title {
font-size: 18px;
font-weight: bold;
}
.admin-dashboard__numbers {
&:not(:first-child) {
margin-top: 33px;
}
&:not(:last-child) {
margin-bottom: 33px;
}
}
.admin-dashboard__numbers-name {
font-size: 15px;
font-weight: 600;
color: $color-neutral-300;
margin-bottom: 8px;
}
.admin-dashboard__numbers-value {
font-size: 24px;
font-weight: 600;
color: $color-primary-900;
margin-bottom: 8px;
}
.admin-dashboard__numbers-percentage {
font-size: 14px;
line-height: 14px;
height: 14px;
min-width: 1px;
font-weight: 600;
color: $color-neutral-300;
}
.admin-dashboard__numbers-percentage-value {
color: $color-success-500;
margin-right: 4px;
&.admin-dashboard__numbers-percentage-value--negative {
color: $color-error-500;
}
}

View file

@ -1,2 +1,3 @@
@import 'user_admin';
@import 'crud_table';
@import 'admin_dashboard';

View file

@ -0,0 +1,96 @@
<script>
import { Line } from 'vue-chartjs'
export default {
extends: Line,
props: {
newUsers: {
type: Array,
required: true,
},
activeUsers: {
type: Array,
required: true,
},
},
watch: {
newUsers() {
this.render()
},
activeUsers() {
this.render()
},
},
mounted() {
this.render()
},
methods: {
render() {
const labels = []
const day = 24 * 60 * 60 * 1000
for (let i = 0; i < 30; i++) {
const date = new Date(new Date().getTime() - day * i)
labels.unshift(date.toISOString().substr(0, 10))
}
const newUserData = this.mapCount(labels, this.newUsers)
const activeUserData = this.mapCount(labels, this.activeUsers)
this.renderChart(
{
labels,
datasets: [
{
label: 'New users',
borderColor: '#59cd90',
backgroundColor: 'transparent',
color: '#9bf2c4',
data: newUserData,
},
{
label: 'Active users',
borderColor: '#198dd6',
backgroundColor: 'transparent',
color: '#b4bac2',
data: activeUserData,
},
],
},
{
responsive: true,
maintainAspectRatio: false,
legend: {
align: 'start',
position: 'bottom',
},
scales: {
xAxes: [
{
type: 'time',
time: {
parser: 'YYYY-MM-DD',
displayFormats: {
day: 'MMM D',
},
tooltipFormat: 'MMM D',
unit: 'day',
},
},
],
},
}
)
},
mapCount(labels, values) {
return labels.map((date1) => {
for (let i = 0; i < values.length; i++) {
if (date1 === values[i].date) {
return values[i].count
}
}
return 0
})
},
},
}
</script>

View file

@ -0,0 +1,261 @@
<template>
<div class="layout__col-2-scroll">
<div class="admin-dashboard">
<h1>Dashboard</h1>
<div class="row margin-bottom-3">
<div class="col col-4">
<div class="admin-dashboard__box">
<div v-if="loading" class="loading-overlay"></div>
<div class="admin-dashboard__box-title">Totals</div>
<div class="admin-dashboard__numbers">
<div class="admin-dashboard__numbers-name">Total users</div>
<div class="admin-dashboard__numbers-value">
{{ data.total_users }}
</div>
<div class="admin-dashboard__numbers-percentage">
<nuxt-link :to="{ name: 'admin-users' }">view all</nuxt-link>
</div>
</div>
<div class="admin-dashboard__numbers">
<div class="admin-dashboard__numbers-name">Total groups</div>
<div class="admin-dashboard__numbers-value">
{{ data.total_groups }}
</div>
<div class="admin-dashboard__numbers-percentage"></div>
</div>
<div class="admin-dashboard__numbers">
<div class="admin-dashboard__numbers-name">
Total applications
</div>
<div class="admin-dashboard__numbers-value">
{{ data.total_applications }}
</div>
</div>
</div>
</div>
<div class="col col-4">
<div class="admin-dashboard__box">
<div v-if="loading" class="loading-overlay"></div>
<div class="admin-dashboard__box-title">New users</div>
<div class="admin-dashboard__numbers">
<div class="admin-dashboard__numbers-name">
New users last 24 hours
</div>
<div class="admin-dashboard__numbers-value">
{{ data.new_users_last_24_hours }}
</div>
<div class="admin-dashboard__numbers-percentage">
<span
class="admin-dashboard__numbers-percentage-value"
:class="{
'admin-dashboard__numbers-percentage-value--negative': isNegative(
percentages.new_users_last_24_hours
),
}"
>{{ percentages.new_users_last_24_hours }}</span
>
</div>
</div>
<div class="admin-dashboard__numbers">
<div class="admin-dashboard__numbers-name">
New users last 7 days
</div>
<div class="admin-dashboard__numbers-value">
{{ data.new_users_last_7_days }}
</div>
<div class="admin-dashboard__numbers-percentage">
<span
class="admin-dashboard__numbers-percentage-value"
:class="{
'admin-dashboard__numbers-percentage-value--negative': isNegative(
percentages.new_users_last_7_days
),
}"
>{{ percentages.new_users_last_7_days }}</span
>
</div>
</div>
<div class="admin-dashboard__numbers">
<div class="admin-dashboard__numbers-name">
New users last 30 days
</div>
<div class="admin-dashboard__numbers-value">
{{ data.new_users_last_30_days }}
</div>
<div class="admin-dashboard__numbers-percentage">
<span
class="admin-dashboard__numbers-percentage-value"
:class="{
'admin-dashboard__numbers-percentage-value--negative': isNegative(
percentages.new_users_last_30_days
),
}"
>{{ percentages.new_users_last_30_days }}</span
>
</div>
</div>
</div>
</div>
<div class="col col-4">
<div class="admin-dashboard__box">
<div v-if="loading" class="loading-overlay"></div>
<div class="admin-dashboard__box-title">Active users</div>
<div class="admin-dashboard__numbers">
<div class="admin-dashboard__numbers-name">
Active users last 24 hours
</div>
<div class="admin-dashboard__numbers-value">
{{ data.active_users_last_24_hours }}
</div>
<div class="admin-dashboard__numbers-percentage">
<span
class="admin-dashboard__numbers-percentage-value"
:class="{
'admin-dashboard__numbers-percentage-value--negative': isNegative(
percentages.new_users_last_30_days
),
}"
>{{ percentages.active_users_last_24_hours }}</span
>
</div>
</div>
<div class="admin-dashboard__numbers">
<div class="admin-dashboard__numbers-name">
Active users last 7 days
</div>
<div class="admin-dashboard__numbers-value">
{{ data.active_users_last_7_days }}
</div>
<div class="admin-dashboard__numbers-percentage">
<span
class="admin-dashboard__numbers-percentage-value"
:class="{
'admin-dashboard__numbers-percentage-value--negative': isNegative(
percentages.active_users_last_7_days
),
}"
>{{ percentages.active_users_last_7_days }}</span
>
</div>
</div>
<div class="admin-dashboard__numbers">
<div class="admin-dashboard__numbers-name">
Active users last 30 days
</div>
<div class="admin-dashboard__numbers-value">
{{ data.active_users_last_30_days }}
</div>
<div class="admin-dashboard__numbers-percentage">
<span
class="admin-dashboard__numbers-percentage-value"
:class="{
'admin-dashboard__numbers-percentage-value--negative': isNegative(
percentages.active_users_last_30_days
),
}"
>{{ percentages.active_users_last_30_days }}</span
>
</div>
</div>
</div>
</div>
</div>
<div class="admin-dashboard__box">
<div v-if="loading" class="loading-overlay"></div>
<ActiveUsers
:new-users="data.new_users_per_day"
:active-users="data.active_users_per_day"
></ActiveUsers>
</div>
</div>
</div>
</template>
<script>
import ActiveUsers from '@baserow_premium/components/admin/dashboard/charts/ActiveUsers'
import AdminDashboardService from '@baserow_premium/services/adminDashboard'
export default {
components: { ActiveUsers },
layout: 'app',
middleware: 'staff',
data() {
return {
loading: true,
data: {
new_users_last_24_hours: 0,
new_users_last_7_days: 0,
new_users_last_30_days: 0,
active_users_last_24_hours: 0,
active_users_last_7_days: 0,
active_users_last_30_days: 0,
previous_new_users_last_24_hours: 0,
previous_new_users_last_7_days: 0,
previous_new_users_last_30_days: 0,
previous_active_users_last_24_hours: 0,
previous_active_users_last_7_days: 0,
previous_active_users_last_30_days: 0,
new_users_per_day: [],
active_users_per_day: [],
},
}
},
computed: {
percentages() {
const percentage = (value1, value2) => {
if (value1 === 0 || value2 === 0) {
return ''
}
let value = value1 / value2 - 1
value = Math.round(value * 100 * 100) / 100
value = `${value > 0 ? '+ ' : '- '}${Math.abs(value)}%`
return value
}
return {
new_users_last_24_hours: percentage(
this.data.new_users_last_24_hours,
this.data.previous_new_users_last_24_hours
),
new_users_last_7_days: percentage(
this.data.new_users_last_7_days,
this.data.previous_new_users_last_7_days
),
new_users_last_30_days: percentage(
this.data.new_users_last_30_days,
this.data.previous_new_users_last_30_days
),
active_users_last_24_hours: percentage(
this.data.active_users_last_24_hours,
this.data.previous_active_users_last_24_hours
),
active_users_last_7_days: percentage(
this.data.active_users_last_7_days,
this.data.previous_active_users_last_7_days
),
active_users_last_30_days: percentage(
this.data.active_users_last_30_days,
this.data.previous_active_users_last_30_days
),
}
},
},
/**
* Because the results depend on the timezone of the user, we can only fetch the data
* on the client side. Therefore, this part is not included in the asyncData.
*/
async mounted() {
const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone
const { data } = await AdminDashboardService(this.$client).dashboard(
timezone
)
this.data = data
this.loading = false
},
methods: {
isNegative(value) {
return value.substr(0, 1) === '-'
},
},
}
</script>

View file

@ -1,7 +1,8 @@
import { PremPlugin } from '@baserow_premium/plugins'
import { UsersAdminType } from '@baserow_premium/adminTypes'
import { DashboardType, UsersAdminType } from '@baserow_premium/adminTypes'
export default ({ app }) => {
app.$registry.register('plugin', new PremPlugin())
app.$registry.register('admin', new DashboardType())
app.$registry.register('admin', new UsersAdminType())
}

View file

@ -1,8 +1,11 @@
// import path from 'path'
import path from 'path'
export const routes = [
{
name: 'admin-dashboard',
path: '/admin/dashboard',
component: path.resolve(__dirname, 'pages/admin/dashboard.vue'),
},
{
name: 'admin-users',
path: '/admin/users',

View file

@ -0,0 +1,15 @@
export default (client) => {
return {
dashboard(timezone = null) {
const config = {
params: {},
}
if (timezone !== null) {
config.params.timezone = timezone
}
return client.get(`/admin/dashboard/`, config)
},
}
}

View file

@ -20,6 +20,7 @@
"@nuxtjs/axios": "^5.8.0",
"axios": "^0.21.0",
"bignumber.js": "^9.0.1",
"chart.js": "2.9.4",
"cookie-universal-nuxt": "^2.1.3",
"cross-env": "^7.0.2",
"jwt-decode": "^3.1.2",
@ -34,6 +35,7 @@
"resize-observer-polyfill": "^1.5.1",
"sass-loader": "^10.1.1",
"thenby": "^1.3.4",
"vue-chartjs": "^3.5.1",
"vuejs-datepicker": "^1.6.2",
"vuelidate": "^0.7.5"
},

View file

@ -1530,6 +1530,13 @@
dependencies:
"@babel/types" "^7.3.0"
"@types/chart.js@^2.7.55":
version "2.9.32"
resolved "https://registry.yarnpkg.com/@types/chart.js/-/chart.js-2.9.32.tgz#b17d9a8c41ad348183a2ce041ebdeef892998251"
integrity sha512-d45JiRQwEOlZiKwukjqmqpbqbYzUX2yrXdH9qVn6kXpPDsTYCo6YbfFOlnUaJ8S/DhJwbBJiLsMjKpW5oP8B2A==
dependencies:
moment "^2.10.2"
"@types/cookie@^0.3.3":
version "0.3.3"
resolved "https://registry.yarnpkg.com/@types/cookie/-/cookie-0.3.3.tgz#85bc74ba782fb7aa3a514d11767832b0e3bc6803"
@ -3095,6 +3102,29 @@ chardet@^0.7.0:
resolved "https://registry.yarnpkg.com/chardet/-/chardet-0.7.0.tgz#90094849f0937f2eedc2425d0d28a9e5f0cbad9e"
integrity sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA==
chart.js@2.9.4:
version "2.9.4"
resolved "https://registry.yarnpkg.com/chart.js/-/chart.js-2.9.4.tgz#0827f9563faffb2dc5c06562f8eb10337d5b9684"
integrity sha512-B07aAzxcrikjAPyV+01j7BmOpxtQETxTSlQ26BEYJ+3iUkbNKaOJ/nDbT6JjyqYxseM0ON12COHYdU2cTIjC7A==
dependencies:
chartjs-color "^2.1.0"
moment "^2.10.2"
chartjs-color-string@^0.6.0:
version "0.6.0"
resolved "https://registry.yarnpkg.com/chartjs-color-string/-/chartjs-color-string-0.6.0.tgz#1df096621c0e70720a64f4135ea171d051402f71"
integrity sha512-TIB5OKn1hPJvO7JcteW4WY/63v6KwEdt6udfnDE9iCAZgy+V4SrbSxoIbTw/xkUIapjEI4ExGtD0+6D3KyFd7A==
dependencies:
color-name "^1.0.0"
chartjs-color@^2.1.0:
version "2.4.1"
resolved "https://registry.yarnpkg.com/chartjs-color/-/chartjs-color-2.4.1.tgz#6118bba202fe1ea79dd7f7c0f9da93467296c3b0"
integrity sha512-haqOg1+Yebys/Ts/9bLo/BqUcONQOdr/hoEr2LLTRl6C5LXctUdHxsCYfvQVg5JIxITrfCNUDr4ntqmQk9+/0w==
dependencies:
chartjs-color-string "^0.6.0"
color-convert "^1.9.3"
check-types@^8.0.3:
version "8.0.3"
resolved "https://registry.yarnpkg.com/check-types/-/check-types-8.0.3.tgz#3356cca19c889544f2d7a95ed49ce508a0ecf552"
@ -3289,7 +3319,7 @@ collection-visit@^1.0.0:
map-visit "^1.0.0"
object-visit "^1.0.0"
color-convert@^1.9.0, color-convert@^1.9.1:
color-convert@^1.9.0, color-convert@^1.9.1, color-convert@^1.9.3:
version "1.9.3"
resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-1.9.3.tgz#bb71850690e1f136567de629d2d5471deda4c1e8"
integrity sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==
@ -7697,7 +7727,7 @@ moment-timezone@^0.5.33:
dependencies:
moment ">= 2.9.0"
"moment@>= 2.9.0", moment@^2.26.0:
"moment@>= 2.9.0", moment@^2.10.2, moment@^2.26.0:
version "2.29.1"
resolved "https://registry.yarnpkg.com/moment/-/moment-2.29.1.tgz#b2be769fa31940be9eeea6469c075e35006fa3d3"
integrity sha512-kHmoybcPV8Sqy59DwNDY3Jefr64lK/by/da0ViFcuA4DH0vQg5Q6Ze5VimxkfQNSC+Mls/Kx53s7TjP1RhFEDQ==
@ -11745,6 +11775,13 @@ vm-browserify@^1.0.1:
resolved "https://registry.yarnpkg.com/vm-browserify/-/vm-browserify-1.1.2.tgz#78641c488b8e6ca91a75f511e7a3b32a86e5dda0"
integrity sha512-2ham8XPWTONajOR0ohOKOHXkm3+gaBmGut3SRuu75xLd/RRaY6vqgh8NBYYk7+RW3u5AtzPQZG8F10LHkl0lAQ==
vue-chartjs@^3.5.1:
version "3.5.1"
resolved "https://registry.yarnpkg.com/vue-chartjs/-/vue-chartjs-3.5.1.tgz#d25e845708f7744ae51bed9d23a975f5f8fc6529"
integrity sha512-foocQbJ7FtveICxb4EV5QuVpo6d8CmZFmAopBppDIGKY+esJV8IJgwmEW0RexQhxqXaL/E1xNURsgFFYyKzS/g==
dependencies:
"@types/chart.js" "^2.7.55"
vue-client-only@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/vue-client-only/-/vue-client-only-2.0.0.tgz#ddad8d675ee02c761a14229f0e440e219de1da1c"