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 #454 See merge request bramw/baserow!254
This commit is contained in:
commit
0dc29a15ce
29 changed files with 1379 additions and 9 deletions
backend
src/baserow
tests/baserow/api
premium
backend
src/baserow_premium
admin_dashboard
api
migrations
user_admin
tests/baserow_premium
web-frontend/modules/baserow_premium
adminTypes.js
assets/scss/components
components/admin/dashboard/charts
pages/admin
plugin.jsroutes.jsservices
web-frontend
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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", "")
|
||||
|
|
48
backend/src/baserow/core/migrations/0007_userlogentry.py
Normal file
48
backend/src/baserow/core/migrations/0007_userlogentry.py
Normal 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",
|
||||
},
|
||||
),
|
||||
]
|
|
@ -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"]
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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.
|
||||
|
|
190
premium/backend/src/baserow_premium/admin_dashboard/handler.py
Normal file
190
premium/backend/src/baserow_premium/admin_dashboard/handler.py
Normal 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))
|
||||
)
|
|
@ -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()
|
||||
)
|
|
@ -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"),
|
||||
]
|
|
@ -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)
|
|
@ -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")
|
||||
),
|
||||
]
|
||||
|
|
|
@ -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
|
|
@ -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}],
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -1,2 +1,3 @@
|
|||
@import 'user_admin';
|
||||
@import 'crud_table';
|
||||
@import 'admin_dashboard';
|
||||
|
|
|
@ -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>
|
|
@ -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>
|
|
@ -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())
|
||||
}
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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)
|
||||
},
|
||||
}
|
||||
}
|
|
@ -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"
|
||||
},
|
||||
|
|
|
@ -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"
|
||||
|
|
Loading…
Add table
Reference in a new issue