diff --git a/server/mergin/auth/api.yaml b/server/mergin/auth/api.yaml index 39c92946..df924109 100644 --- a/server/mergin/auth/api.yaml +++ b/server/mergin/auth/api.yaml @@ -924,7 +924,7 @@ components: type: object properties: active_monthly_contributors: - type: array + type: integer description: count of users who made a project change last months items: type: integer @@ -934,9 +934,9 @@ components: description: total number of projects example: 12 storage: - type: string + type: number description: projest files size in bytes - example: 1024 kB + example: 1024 users: type: integer description: count of registered accounts diff --git a/server/mergin/auth/controller.py b/server/mergin/auth/controller.py index 6e0e9dd5..8b693ed3 100644 --- a/server/mergin/auth/controller.py +++ b/server/mergin/auth/controller.py @@ -545,15 +545,13 @@ def create_user(): @auth_required(permissions=["admin"]) def get_server_usage(): data = { - "active_monthly_contributors": [ - current_app.ws_handler.monthly_contributors_count(), - current_app.ws_handler.monthly_contributors_count(month_offset=1), - current_app.ws_handler.monthly_contributors_count(month_offset=2), - current_app.ws_handler.monthly_contributors_count(month_offset=3), - ], - "projects": Project.query.count(), + "active_monthly_contributors": current_app.ws_handler.monthly_contributors_count(), + "projects": Project.query.filter(Project.removed_at.is_(None)).count(), "storage": files_size(), - "users": User.query.count(), + "users": User.query.filter( + is_(User.username.ilike("deleted_%"), False), + ).count(), "workspaces": current_app.ws_handler.workspace_count(), + "editors": current_app.ws_handler.server_editors_count(), } return data, 200 diff --git a/server/mergin/stats/tasks.py b/server/mergin/stats/tasks.py index 762517f8..da7a9cb6 100644 --- a/server/mergin/stats/tasks.py +++ b/server/mergin/stats/tasks.py @@ -7,6 +7,7 @@ import json import logging from flask import current_app +from sqlalchemy.sql.operators import is_ from .models import MerginInfo from ..celery import celery @@ -60,12 +61,15 @@ def send_statistics(): "url": current_app.config["MERGIN_BASE_URL"], "contact_email": current_app.config["CONTACT_EMAIL"], "licence": current_app.config["SERVER_TYPE"], - "projects_count": Project.query.count(), - "users_count": User.query.count(), + "projects_count": Project.query.filter(Project.removed_at.is_(None)).count(), + "users_count": User.query.filter( + is_(User.username.ilike("deleted_%"), False) + ).count(), "workspaces_count": current_app.ws_handler.workspace_count(), "last_change": str(last_change_item.updated) + "Z" if last_change_item else "", "server_version": current_app.config["VERSION"], "monthly_contributors": current_app.ws_handler.monthly_contributors_count(), + "editors": current_app.ws_handler.server_editors_count(), } try: diff --git a/server/mergin/sync/interfaces.py b/server/mergin/sync/interfaces.py index 3961ee37..aa916b38 100644 --- a/server/mergin/sync/interfaces.py +++ b/server/mergin/sync/interfaces.py @@ -172,6 +172,13 @@ def monthly_contributors_count(): """ pass + @staticmethod + def server_editors_count(): + """ + Return number of workspace editors in current server instance + """ + pass + class AbstractProjectHandler(ABC): @abstractmethod diff --git a/server/mergin/sync/utils.py b/server/mergin/sync/utils.py index e8c9f367..b4120acc 100644 --- a/server/mergin/sync/utils.py +++ b/server/mergin/sync/utils.py @@ -333,7 +333,7 @@ def files_size(): LEFT OUTER JOIN file_history fh ON fh.id = lf.file_id WHERE fh.change = 'update_diff'::push_change_type ) - SELECT pg_size_pretty(SUM(sum)) FROM partials; + SELECT COALESCE(SUM(sum), 0) FROM partials; """ ) return db.session.execute(files_size).scalar() diff --git a/server/mergin/sync/workspace.py b/server/mergin/sync/workspace.py index db7865cf..8cc0a27a 100644 --- a/server/mergin/sync/workspace.py +++ b/server/mergin/sync/workspace.py @@ -6,12 +6,14 @@ from typing import Dict, Tuple, Optional, Set, List from flask_login import current_user from sqlalchemy import Column, literal, extract +from sqlalchemy.sql.operators import is_ from .errors import UpdateProjectAccessError from .models import ( Project, AccessRequest, ProjectAccessDetail, + ProjectRole, ProjectVersion, ProjectUser, ) @@ -349,3 +351,21 @@ def project_access(self, project: Project) -> List[ProjectAccessDetail]: ) result.append(member) return result + + def server_editors_count(self) -> int: + if Configuration.GLOBAL_ADMIN or Configuration.GLOBAL_WRITE: + return User.query.filter( + is_(User.username.ilike("deleted_%"), False), + ).count() + + return ( + db.session.query(ProjectUser.user_id) + .select_from(Project) + .join(ProjectUser) + .filter( + Project.removed_at.is_(None), + ProjectUser.role != ProjectRole.READER.value, + ) + .group_by(ProjectUser.user_id) + .count() + ) diff --git a/server/mergin/tests/test_auth.py b/server/mergin/tests/test_auth.py index 2613c68f..91027fb4 100644 --- a/server/mergin/tests/test_auth.py +++ b/server/mergin/tests/test_auth.py @@ -3,6 +3,7 @@ # SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-MerginMaps-Commercial from datetime import datetime, timedelta +import os import pytest import json from flask import url_for @@ -10,6 +11,8 @@ from sqlalchemy import desc from unittest.mock import patch +from mergin.tests import test_workspace + from ..auth.models import User, UserProfile, LoginHistory from ..auth.tasks import anonymize_removed_users from ..app import db @@ -21,7 +24,15 @@ test_workspace_name, test_project, ) -from .utils import add_user, login_as_admin, login +from .utils import ( + add_user, + create_project, + create_workspace, + login_as_admin, + login, + upload_file_to_project, + test_project_dir, +) @pytest.fixture(scope="function") @@ -838,3 +849,36 @@ def test_username_generation(client): user = add_user("user25", "user") assert User.generate_username(user.email) == user.username + "1" + + +def test_server_usage(client): + """Test server usage endpoint""" + login_as_admin(client) + workspace = create_workspace() + init_project = Project.query.filter_by(workspace_id=workspace.id).first() + user = add_user() + admin = User.query.filter_by(username="mergin").first() + # create new project + project = create_project("project", workspace, admin) + project.set_role(user.id, ProjectRole.READER) + db.session.commit() + upload_file_to_project(project, "test.txt", client) + resp = client.get("/app/admin/usage") + assert resp.status_code == 200 + assert resp.json["users"] == 2 + assert resp.json["workspaces"] == 1 + assert resp.json["projects"] == 2 + assert resp.json["storage"] == project.disk_usage + init_project.disk_usage + assert resp.json["active_monthly_contributors"] == 1 + assert resp.json["editors"] == 1 + project.set_role(user.id, ProjectRole.EDITOR) + db.session.commit() + resp = client.get("/app/admin/usage") + assert resp.json["editors"] == 2 + user.inactivate() + user.anonymize() + project.delete() + resp = client.get("/app/admin/usage") + assert resp.json["editors"] == 1 + assert resp.json["users"] == 1 + assert resp.json["projects"] == 1 diff --git a/server/mergin/tests/test_statistics.py b/server/mergin/tests/test_statistics.py index 2c25116e..b73cb042 100644 --- a/server/mergin/tests/test_statistics.py +++ b/server/mergin/tests/test_statistics.py @@ -5,11 +5,15 @@ import json from unittest.mock import patch import requests +from sqlalchemy.sql.operators import is_ + +from mergin.auth.models import User +from mergin.sync.models import Project, ProjectRole from ..app import db from ..stats.tasks import send_statistics from ..stats.models import MerginInfo -from .utils import Response +from .utils import Response, add_user, create_project, create_workspace def test_send_statistics(app, caplog): @@ -24,6 +28,12 @@ def test_send_statistics(app, caplog): mock.return_value = Response(True, {}) app.config["COLLECT_STATISTICS"] = False app.config["CONTACT_EMAIL"] = "test@example.com" + user = add_user() + admin = User.query.filter_by(username="mergin").first() + # create new project + workspace = create_workspace() + project = create_project("project", workspace, admin) + project.set_role(user.id, ProjectRole.EDITOR) task = send_statistics.s().apply() # nothing was done assert task.status == "SUCCESS" @@ -49,18 +59,35 @@ def test_send_statistics(app, caplog): "last_change", "server_version", "monthly_contributors", + "editors", } assert data["workspaces_count"] == 1 assert data["service_uuid"] == app.config["SERVICE_ID"] assert data["licence"] == "ce" assert data["monthly_contributors"] == 1 + assert data["users_count"] == 2 + assert data["projects_count"] == 2 assert data["contact_email"] == "test@example.com" + assert data["editors"] == 2 # repeated action does not do anything task = send_statistics.s().apply() assert task.status == "SUCCESS" assert info.last_reported == ts + # project removed / users removed in time + info.last_reported = None + project.delete() + user.inactivate() + user.anonymize() + db.session.commit() + task = send_statistics.s().apply() + url, data = mock.call_args + data = json.loads(data["data"]) + assert data["projects_count"] == 1 + assert data["users_count"] == 1 + assert data["editors"] == 1 + info.last_reported = None db.session.commit() # server responds with non-ok status diff --git a/server/mergin/tests/test_workspace.py b/server/mergin/tests/test_workspace.py index 29ed6c5f..2aafc268 100644 --- a/server/mergin/tests/test_workspace.py +++ b/server/mergin/tests/test_workspace.py @@ -36,10 +36,16 @@ def test_workspace_implementation(client): assert handler.list_active()[0].name == ws.name assert len(handler.list_user_workspaces(user.username)) == 1 assert handler.list_user_workspaces(user.username)[0].name == ws.name + Configuration.GLOBAL_READ = True + assert ws.user_has_permissions(user, "read") + assert not ws.user_has_permissions(user, "write") + # admin is counted as editor + assert handler.server_editors_count() == 1 # change global flag to enable user push to any project Configuration.GLOBAL_WRITE = True assert ws.user_has_permissions(user, "write") assert ws.user_has_permissions(user, "read") + assert handler.server_editors_count() == 2 assert not ws.user_has_permissions(user, "admin") assert not ws.user_has_permissions(user, "owner") @@ -70,6 +76,7 @@ def test_workspace_implementation(client): project.removed_by = user.id db.session.commit() assert ws.disk_usage() == default_project_usage + assert handler.server_editors_count() == 2 current_time = datetime.datetime.now(datetime.timezone.utc) latest_version.created = datetime.datetime.combine( diff --git a/web-app/packages/admin-lib/src/modules/admin/store.ts b/web-app/packages/admin-lib/src/modules/admin/store.ts index a86cb042..7b9281ff 100644 --- a/web-app/packages/admin-lib/src/modules/admin/store.ts +++ b/web-app/packages/admin-lib/src/modules/admin/store.ts @@ -42,6 +42,7 @@ export interface AdminState { checkForUpdates?: boolean isServerConfigHidden: boolean latestServerVersion?: LatestServerVersionResponse + usage: ServerUsageResponse } const cookies = new Cookies() @@ -62,7 +63,8 @@ export const useAdminStore = defineStore('adminModule', { user: null, checkForUpdates: undefined, isServerConfigHidden: false, - latestServerVersion: undefined + latestServerVersion: undefined, + usage: undefined }), getters: { displayUpdateAvailable: (state) => { @@ -112,9 +114,6 @@ export const useAdminStore = defineStore('adminModule', { setIsServerConfigHidden(value: boolean) { this.isServerConfigHidden = value }, - setUsage(data: ServerUsageResponse){ - this.usage = data - }, async fetchUsers(payload: { params: PaginatedUsersParams }) { const notificationStore = useNotificationStore() @@ -322,7 +321,7 @@ export const useAdminStore = defineStore('adminModule', { const notificationStore = useNotificationStore() try { const response = await AdminApi.getServerUsage() - this.setUsage(response.data) + this.usage = response.data } catch (e) { notificationStore.error({ text: errorUtils.getErrorMessage(e) }) } diff --git a/web-app/packages/admin-lib/src/modules/admin/types.ts b/web-app/packages/admin-lib/src/modules/admin/types.ts index d099f070..f0000b8b 100644 --- a/web-app/packages/admin-lib/src/modules/admin/types.ts +++ b/web-app/packages/admin-lib/src/modules/admin/types.ts @@ -72,11 +72,12 @@ export interface PaginatedAdminProjectsParams extends PaginatedRequestParams { export type ServerUsageResponse = ServerUsage export interface ServerUsage { - active_monthly_contributors: number[] + active_monthly_contributors: number projects: number storage: string users: number workspaces: number + editors: number } /* eslint-enable camelcase */ diff --git a/web-app/packages/admin-lib/src/modules/admin/views/OverviewView.vue b/web-app/packages/admin-lib/src/modules/admin/views/OverviewView.vue index 6c9fbbfc..39bacdec 100644 --- a/web-app/packages/admin-lib/src/modules/admin/views/OverviewView.vue +++ b/web-app/packages/admin-lib/src/modules/admin/views/OverviewView.vue @@ -14,11 +14,8 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-MerginMaps-Commercial
- - + +
{{ usage?.editors }} - -
- +
{{ + $filters.filesize(usage.storage, null, 0) + }}
- +
{{ usage?.users }}
- +
{{ usage?.projects }}
+ + +
+ +
+ + +
{{ usage?.workspaces }}