diff --git a/server/mergin/app.py b/server/mergin/app.py index d025b6e2..86c72d74 100644 --- a/server/mergin/app.py +++ b/server/mergin/app.py @@ -252,7 +252,7 @@ def custom_protect(): _get_csrf_token = csrf._get_csrf_token def get_csrf_token(): - if request.path.startswith("/v1/"): + if request.path.startswith("/v1/") or request.path.startswith("/v2/"): for header_name in app.app.config["WTF_CSRF_HEADERS"]: csrf_token = request.headers.get(header_name) if csrf_token: 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..106d30bd 100644 --- a/server/mergin/auth/controller.py +++ b/server/mergin/auth/controller.py @@ -510,7 +510,9 @@ def create_user(): username = request.json.get( "username", User.generate_username(request.json["email"]) ) - form = UserRegistrationForm() + + # in public endpoint we want to disable form csrf - for browser clients endpoint is protected anyway + form = UserRegistrationForm(meta={"csrf": False}) form.confirm.data = form.password.data form.username.data = username if not form.validate(): @@ -545,15 +547,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/auth/models.py b/server/mergin/auth/models.py index c16c21f6..b57631cb 100644 --- a/server/mergin/auth/models.py +++ b/server/mergin/auth/models.py @@ -6,6 +6,7 @@ import datetime from typing import List, Optional import bcrypt +import re from flask import current_app, request from sqlalchemy import or_, func, text @@ -196,6 +197,10 @@ def generate_username(cls, email: str) -> Optional[str]: if not "@" in email: return username = email.split("@")[0].strip().lower() + # remove forbidden chars + username = re.sub( + r"[\@\#\$\%\^\&\*\(\)\{\}\[\]\?\'\"`,;\:\+\=\~\\\/\|\<\>]", "", username + ) # check if we already do not have existing usernames suffix = db.session.execute( text( 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/models.py b/server/mergin/sync/models.py index de81676a..e353f68a 100644 --- a/server/mergin/sync/models.py +++ b/server/mergin/sync/models.py @@ -283,6 +283,8 @@ def unset_role(self, user_id: int) -> None: def get_member(self, user_id: int) -> Optional[ProjectMember]: """Get project member""" + from .permissions import ProjectPermissions + member = self._member(user_id) if member: return ProjectMember( @@ -291,6 +293,7 @@ def get_member(self, user_id: int) -> Optional[ProjectMember]: email=member.user.email, project_role=ProjectRole(member.role), workspace_role=self.workspace.get_user_role(member.user), + role=ProjectPermissions.get_user_project_role(self, member.user), ) def members_by_role(self, role: ProjectRole) -> List[int]: @@ -350,6 +353,7 @@ class ProjectMember: username: str workspace_role: WorkspaceRole project_role: Optional[ProjectRole] + role: ProjectRole @dataclass @@ -359,7 +363,8 @@ class ProjectAccessDetail: role: str username: str name: Optional[str] - project_permission: str + workspace_role: str + project_role: Optional[ProjectRole] type: str diff --git a/server/mergin/sync/private_api.yaml b/server/mergin/sync/private_api.yaml index 4160ed07..5db24675 100644 --- a/server/mergin/sync/private_api.yaml +++ b/server/mergin/sync/private_api.yaml @@ -329,6 +329,7 @@ paths: required: true schema: type: string + # // Kept for EE (collaborators + invitation) access, TODO: remove when a separate invitation endpoint is implemented get: tags: - project @@ -350,34 +351,29 @@ paths: "404": $ref: "#/components/responses/NotFoundResp" x-openapi-router-controller: mergin.sync.private_api_controller + /project/{id}/public: + parameters: + - name: id + in: path + description: Project uuid + required: true + schema: + type: string patch: - summary: Update direct project access (sharing) - operationId: update_project_access + summary: Update public project flag + operationId: update_project_public_flag requestBody: - description: Request data required: true content: application/json: schema: type: object properties: - user_id: - type: integer public: type: boolean - nullable: true - role: - type: string - enum: - - owner - - writer - - editor - - reader - - none - example: writer responses: - "200": - $ref: "#/components/schemas/ProjectAccessUpdated" + "204": + description: OK "400": $ref: "#/components/responses/BadStatusResp" "401": @@ -551,8 +547,7 @@ components: - id - type - email - - project_permission - - role + - workspace_role properties: id: description: User/Invitation (uu)id @@ -569,16 +564,9 @@ components: type: string format: email example: john.doe@example.com - role: + workspace_role: description: Workspace role - type: string - enum: - - owner - - admin - - writer - - editor - - reader - - guest + $ref: "#/components/schemas/WorkspaceRole" username: description: Present only for type `member` type: string @@ -587,13 +575,13 @@ components: description: Present only for type `member` type: string example: John Doe - project_permission: - type: string - enum: - - owner - - writer - - editor - - reader + role: + description: Project role defined as combination of project and workspace roles + $ref: "#/components/schemas/ProjectRole" + project_role: + nullable: true + description: Project role defined in database, not calculated version + $ref: "#/components/schemas/ProjectRole" invitation: description: Present only for type `invitation` type: object @@ -658,3 +646,19 @@ components: items: type: integer example: [1] + WorkspaceRole: + type: string + enum: + - owner + - admin + - writer + - editor + - reader + - guest + ProjectRole: + type: string + enum: + - owner + - writer + - editor + - reader diff --git a/server/mergin/sync/private_api_controller.py b/server/mergin/sync/private_api_controller.py index 616ac69d..00d0a13d 100644 --- a/server/mergin/sync/private_api_controller.py +++ b/server/mergin/sync/private_api_controller.py @@ -26,7 +26,6 @@ AdminProjectSchema, ProjectAccessSchema, ProjectAccessDetailSchema, - ProjectVersionListSchema, ) from .permissions import ( require_project_by_uuid, @@ -304,28 +303,16 @@ def unsubscribe_project(id): # pylint: disable=W0612 @auth_required -def update_project_access(id: str): - """Modify shared project access +def update_project_public_flag(id: str): + """Modify the project's public flag :param id: Project uuid """ project = require_project_by_uuid(id, ProjectPermissions.Update) - if "public" in request.json: - project.public = request.json["public"] - - if "user_id" in request.json and "role" in request.json: - user = User.query.filter_by( - id=request.json["user_id"], active=True - ).first_or_404("User does not exist") - - if request.json["role"] == "none": - project.unset_role(user.id) - else: - project.set_role(user.id, ProjectRole(request.json["role"])) - project_access_granted.send(project, user_id=user.id) + project.public = request.json.get("public", False) db.session.commit() - return ProjectAccessSchema().dump(project), 200 + return NoContent, 204 @auth_required diff --git a/server/mergin/sync/public_api.yaml b/server/mergin/sync/public_api.yaml index 48d39c13..c8072ad6 100644 --- a/server/mergin/sync/public_api.yaml +++ b/server/mergin/sync/public_api.yaml @@ -1044,6 +1044,23 @@ components: detail: Maximum number of people in this workspace is reached. Please upgrade your subscription to add more people (UsersLimitHit) rejected_emails: [ rejected@example.com ] users_quota: 6 + EditorsLimitHit: + allOf: + - $ref: '#/components/schemas/CustomError' + type: object + properties: + rejected_emails: + nullable: true + type: array + items: + type: string + editors_quota: + type: integer + example: + code: EditorsLimitHit + detail: Maximum number of editors in this workspace is reached. Please upgrade your subscription to add more (EditorsLimitHit) + rejected_emails: [ rejected@example.com ] + editors_quota: 6 UpdateProjectAccessError: allOf: - $ref: '#/components/schemas/CustomError' diff --git a/server/mergin/sync/public_api_controller.py b/server/mergin/sync/public_api_controller.py index 77faeec2..6a39e441 100644 --- a/server/mergin/sync/public_api_controller.py +++ b/server/mergin/sync/public_api_controller.py @@ -680,11 +680,34 @@ def update_project(namespace, project_name): # noqa: E501 # pylint: disable=W0 """ project = require_project(namespace, project_name, ProjectPermissions.Update) parsed_access = parse_project_access_update_request(request.json.get("access", {})) + + # get current status for easier rollback + modified_user_ids = [] + for role in list(ProjectRole.__reversed__()): + modified_user_ids.extend(parsed_access.get(role, [])) + current_permissions_map = { + user_id: project.get_role(user_id) for user_id in modified_user_ids + } + # get set of modified user_ids and possible (custom) errors id_diffs, error = current_app.ws_handler.update_project_members( project, parsed_access ) + # revert back rejected changes + if error and hasattr(error, "rejected_emails"): + rejected_users = ( + db.session.query(User.id) + .filter(User.email.in_(error.rejected_emails)) + .all() + ) + for user in rejected_users: + if current_permissions_map[user.id] is None: + project.unset_role(user.id) + else: + project.set_role(user.id, current_permissions_map[user.id]) + db.session.commit() + if not id_diffs and error: # nothing was done but there are errors return jsonify(error.to_dict()), 422 diff --git a/server/mergin/sync/public_api_v2.yaml b/server/mergin/sync/public_api_v2.yaml index a167cb26..3d47ca4e 100644 --- a/server/mergin/sync/public_api_v2.yaml +++ b/server/mergin/sync/public_api_v2.yaml @@ -136,10 +136,10 @@ paths: schema: type: object required: - - username + - user - role properties: - username: + user: type: string example: john.doe description: username or email @@ -265,6 +265,11 @@ components: - reader - guest example: writer + Role: + allOf: + - $ref: '#/components/schemas/ProjectRole' + nullable: false + description: combination of workspace role and project role ProjectMember: type: object properties: @@ -282,3 +287,5 @@ components: $ref: '#/components/schemas/WorkspaceRole' project_role: $ref: '#/components/schemas/ProjectRole' + role: + $ref: '#/components/schemas/Role' diff --git a/server/mergin/sync/public_api_v2_controller.py b/server/mergin/sync/public_api_v2_controller.py index d2076910..d5d36b11 100644 --- a/server/mergin/sync/public_api_v2_controller.py +++ b/server/mergin/sync/public_api_v2_controller.py @@ -81,6 +81,7 @@ def get_project_collaborators(id): email=user.email, project_role=project_role, workspace_role=workspace_role, + role=ProjectPermissions.get_user_project_role(project, user), ) ) @@ -92,7 +93,7 @@ def get_project_collaborators(id): def add_project_collaborator(id): """Add project collaborator""" project = require_project_by_uuid(id, ProjectPermissions.Update) - user = User.get_by_login(request.json["username"]) + user = User.get_by_login(request.json["user"]) if not user: abort(404) @@ -112,7 +113,7 @@ def update_project_collaborator(id, user_id): project = require_project_by_uuid(id, ProjectPermissions.Update) user = User.query.filter_by(id=user_id, active=True).first_or_404() if not project.get_role(user_id): - abort(404, "User is not a project member") + abort(404) project.set_role(user.id, ProjectRole(request.json["role"])) db.session.commit() @@ -125,7 +126,7 @@ def remove_project_collaborator(id, user_id): """Remove project collaborator""" project = require_project_by_uuid(id, ProjectPermissions.Update) if not project.get_role(user_id): - abort(404, "User is not a project member") + abort(404) project.unset_role(user_id) db.session.commit() diff --git a/server/mergin/sync/schemas.py b/server/mergin/sync/schemas.py index c782c5ca..0d8a11d5 100644 --- a/server/mergin/sync/schemas.py +++ b/server/mergin/sync/schemas.py @@ -358,10 +358,11 @@ def _deserialize(self, value, attr, data, **kwargs): class ProjectAccessDetailSchema(Schema): id = StrOrInt() email = fields.String() - role = fields.String() + role = fields.Enum(enum=ProjectRole, by_value=True) username = fields.String() name = fields.String() - project_permission = fields.String() + project_role = fields.Enum(enum=ProjectRole, by_value=True) + workspace_role = fields.Enum(enum=WorkspaceRole, by_value=True) type = fields.String() invitation = fields.Nested(ProjectInvitationAccessSchema()) @@ -404,3 +405,4 @@ class ProjectMemberSchema(Schema): email = fields.Email() project_role = fields.Enum(enum=ProjectRole, by_value=True) workspace_role = fields.Enum(enum=WorkspaceRole, by_value=True) + role = fields.Enum(enum=ProjectRole, by_value=True) 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..79aef2b9 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, ) @@ -305,6 +307,7 @@ def access_requests_query(): """Project access base query""" return AccessRequest.query.join(Project) + # not used in CE, TODO: remove together with EE when it's replaced there def project_access(self, project: Project) -> List[ProjectAccessDetail]: """ Project access users overview @@ -321,17 +324,19 @@ def project_access(self, project: Project) -> List[ProjectAccessDetail]: direct_members_ids = [u.user_id for u in project.project_users] users = User.query.filter(User.active.is_(True)).order_by(User.email) - direct_members = users.filter(User.id.in_(direct_members_ids)).all() + direct_members: list[User] = users.filter(User.id.in_(direct_members_ids)).all() for dm in direct_members: - project_role = ProjectPermissions.get_user_project_role(project, dm) + project_permission = ProjectPermissions.get_user_project_role(project, dm) + project_role = project.get_role(dm.id) member = ProjectAccessDetail( id=dm.id, username=dm.username, - role=ws.get_user_role(dm).value, + workspace_role=ws.get_user_role(dm).value, name=dm.profile.name(), email=dm.email, - project_permission=project_role and project_role.value, + role=project_permission and project_permission.value, + project_role=project_role.value if project_role else None, type="member", ) result.append(member) @@ -343,9 +348,28 @@ def project_access(self, project: Project) -> List[ProjectAccessDetail]: username=gm.username, name=gm.profile.name(), email=gm.email, + workspace_role=global_role, role=global_role, - project_permission=global_role, + project_role=None, type="member", ) 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..280626de 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,39 @@ def test_username_generation(client): user = add_user("user25", "user") assert User.generate_username(user.email) == user.username + "1" + + # generate username from email containing invalid chars for username, e.g. + + assert User.generate_username("tralala+test@example.com") == "tralalatest" + + +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_private_project_api.py b/server/mergin/tests/test_private_project_api.py index c8aabd9e..5d5ec518 100644 --- a/server/mergin/tests/test_private_project_api.py +++ b/server/mergin/tests/test_private_project_api.py @@ -333,72 +333,16 @@ def test_template_projects(client): def test_update_project_access(client, diff_project): - url = f"/app/project/{diff_project.id}/access" + url = f"/app/project/{diff_project.id}/public" original_creator_id = diff_project.creator.id - # create user and grant him write access - user = add_user("reader", "reader") - assert not diff_project.get_role(user.id) - - data = {"user_id": user.id, "role": "none"} - # nothing happens - resp = client.patch(url, headers=json_headers, data=json.dumps(data)) - assert resp.status_code == 200 - assert not diff_project.get_role(user.id) - - # grant read access - data["role"] = "reader" - resp = client.patch(url, headers=json_headers, data=json.dumps(data)) - assert resp.status_code == 200 - assert diff_project.get_role(user.id) is ProjectRole.READER - - # grant editor access - data["role"] = "editor" - resp = client.patch(url, headers=json_headers, data=json.dumps(data)) - assert resp.status_code == 200 - assert diff_project.get_role(user.id) is ProjectRole.EDITOR - - # change to write access - data["role"] = "writer" - resp = client.patch(url, headers=json_headers, data=json.dumps(data)) - assert resp.status_code == 200 - assert diff_project.get_role(user.id) is ProjectRole.WRITER - - # downgrade to read access - data["role"] = "reader" - resp = client.patch(url, headers=json_headers, data=json.dumps(data)) - assert resp.status_code == 200 - assert diff_project.get_role(user.id) is ProjectRole.READER - - # remove access - data["role"] = "none" - resp = client.patch(url, headers=json_headers, data=json.dumps(data)) - assert resp.status_code == 200 - assert not diff_project.get_role(user.id) + data = {} # update public parameter => public: True data["public"] = True resp = client.patch(url, headers=json_headers, data=json.dumps(data)) - assert resp.status_code == 200 + assert resp.status_code == 204 assert diff_project.public == True - # access of project creator can be removed - data["user_id"] = diff_project.creator_id - resp = client.patch( - f"/app/project/{diff_project.id}/access", - headers=json_headers, - data=json.dumps(data), - ) - assert resp.status_code == 200 - db.session.rollback() - assert not diff_project.get_role(user.id) - assert diff_project.creator_id == original_creator_id - - # try to grant access to inaccessible user - data = {"user_id": 100, "role": "reader"} - # nothing happens - resp = client.patch(url, headers=json_headers, data=json.dumps(data)) - assert resp.status_code == 404 - def test_restore_project(client, diff_project): """Test delete project by user and restore by admin""" @@ -474,61 +418,3 @@ def test_admin_project_list(client): p.delete() resp = client.get("/app/admin/projects?page=1&per_page=15&like=mergin") assert len(resp.json["items"]) == 14 - - -def test_get_project_access(client): - workspace = create_workspace() - user = User.query.filter(User.username == "mergin").first() - project = create_project("test-project", workspace, user) - url = f"/app/project/{project.id}/access" - users = [] - for i in range(5): - users.append(add_user(str(i), str(i))) - Configuration.GLOBAL_ADMIN = False - Configuration.GLOBAL_WRITE = False - Configuration.GLOBAL_READ = False - resp = client.get(url) - assert resp.status_code == 200 - assert len(resp.json) == 1 - assert resp.json[0]["project_permission"] == "owner" - project.set_role(users[0].id, ProjectRole.OWNER) - project.set_role(users[1].id, ProjectRole.WRITER) - project.set_role(users[2].id, ProjectRole.READER) - db.session.commit() - resp = client.get(url) - assert resp.status_code == 200 - assert len(resp.json) == 4 - assert sum(map(lambda x: int(x["project_permission"] == "owner"), resp.json)) == 2 - assert sum(map(lambda x: int(x["project_permission"] == "writer"), resp.json)) == 1 - assert sum(map(lambda x: int(x["project_permission"] == "reader"), resp.json)) == 1 - # user3 does not have access to the project - assert not any(users[3].email == access["email"] for access in resp.json) - assert any(users[2].email == access["email"] for access in resp.json) - Configuration.GLOBAL_READ = True - resp = client.get(url) - assert resp.status_code == 200 - assert len(resp.json) == 6 - assert sum(map(lambda x: int(x["project_permission"] == "owner"), resp.json)) == 2 - assert sum(map(lambda x: int(x["project_permission"] == "writer"), resp.json)) == 1 - assert sum(map(lambda x: int(x["project_permission"] == "reader"), resp.json)) == 3 - Configuration.GLOBAL_WRITE = True - resp = client.get(url) - assert resp.status_code == 200 - assert len(resp.json) == 6 - assert sum(map(lambda x: int(x["project_permission"] == "owner"), resp.json)) == 2 - assert sum(map(lambda x: int(x["project_permission"] == "writer"), resp.json)) == 4 - assert sum(map(lambda x: int(x["project_permission"] == "reader"), resp.json)) == 0 - Configuration.GLOBAL_ADMIN = True - resp = client.get(url) - assert resp.status_code == 200 - assert len(resp.json) == 6 - assert sum(map(lambda x: int(x["project_permission"] == "owner"), resp.json)) == 6 - assert sum(map(lambda x: int(x["project_permission"] == "writer"), resp.json)) == 0 - assert sum(map(lambda x: int(x["project_permission"] == "reader"), resp.json)) == 0 - # pretend a user was deleted to test that api can handle it - users[3].inactivate() - users[3].anonymize() - resp = client.get(url) - assert resp.status_code == 200 - assert len(resp.json) == 5 - assert sum(map(lambda x: int(x["project_permission"] == "owner"), resp.json)) == 5 diff --git a/server/mergin/tests/test_public_api_v2.py b/server/mergin/tests/test_public_api_v2.py index 6dc96610..a17d1696 100644 --- a/server/mergin/tests/test_public_api_v2.py +++ b/server/mergin/tests/test_public_api_v2.py @@ -90,7 +90,7 @@ def test_project_members(client): assert response.status_code == 404 # add direct access - response = client.post(url, json={"role": role, "username": user.email}) + response = client.post(url, json={"role": role, "user": user.email}) assert response.status_code == 201 assert response.json["id"] == user.id assert response.json["project_role"] == role 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..2c3bc54d 100644 --- a/web-app/packages/admin-lib/src/modules/admin/types.ts +++ b/web-app/packages/admin-lib/src/modules/admin/types.ts @@ -19,8 +19,8 @@ export interface UsersParams extends PaginatedRequestParams { export type UsersResponse = PaginatedResponse export interface UpdateUserData { - is_admin: boolean - active: boolean + is_admin?: boolean + active?: boolean } export interface CreateUserData { @@ -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/AccountDetailView.vue b/web-app/packages/admin-lib/src/modules/admin/views/AccountDetailView.vue index 86b9cd2b..fa2bd6e0 100644 --- a/web-app/packages/admin-lib/src/modules/admin/views/AccountDetailView.vue +++ b/web-app/packages/admin-lib/src/modules/admin/views/AccountDetailView.vue @@ -282,7 +282,6 @@ const switchAdminAccess = async () => { await adminStore.updateUser({ username: user.value.username, data: { - active: user.value.active, is_admin: !user.value.is_admin } }) 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..03c87bb9 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 }}
@@ -109,7 +97,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-MerginMaps-Commercial
{{ usage?.workspaces }}