diff --git a/server/mergin/auth/api.yaml b/server/mergin/auth/api.yaml index e0771689..fa482a36 100644 --- a/server/mergin/auth/api.yaml +++ b/server/mergin/auth/api.yaml @@ -753,6 +753,11 @@ components: type: string format: date-time example: 2023-07-30T08:47:58Z + last_signed_in: + nullable: true + type: string + format: date-time + example: 2025-12-18T08:47:58Z profile: $ref: "#/components/schemas/UserProfile" PaginatedUsers: diff --git a/server/mergin/auth/controller.py b/server/mergin/auth/controller.py index 3e00ce16..0a862384 100644 --- a/server/mergin/auth/controller.py +++ b/server/mergin/auth/controller.py @@ -424,6 +424,9 @@ def register_user(): # pylint: disable=W0613,W0612 @auth_required(permissions=["admin"]) def get_user(username): user = User.query.filter(User.username == username).first_or_404() + if not user.last_signed_in: + last_signed_in = LoginHistory.get_users_last_signed_in([user.id]) + user.last_signed_in = last_signed_in.get(user.id) data = UserSchema().dump(user) return data, 200 diff --git a/server/mergin/auth/models.py b/server/mergin/auth/models.py index 5dcf275e..470b934b 100644 --- a/server/mergin/auth/models.py +++ b/server/mergin/auth/models.py @@ -268,7 +268,7 @@ class UserProfile(db.Model): ), ) - def name(self): + def name(self) -> Optional[str]: return f'{self.first_name if self.first_name else ""} {self.last_name if self.last_name else ""}'.strip() diff --git a/server/mergin/auth/schemas.py b/server/mergin/auth/schemas.py index 3f614ae1..52ed01f6 100644 --- a/server/mergin/auth/schemas.py +++ b/server/mergin/auth/schemas.py @@ -11,7 +11,7 @@ class UserProfileSchema(ma.SQLAlchemyAutoSchema): name = ma.Function( - lambda obj: f'{obj.first_name if obj.first_name else ""} {obj.last_name if obj.last_name else ""}'.strip(), + lambda obj: obj.name(), dump_only=True, ) storage = fields.Method("get_storage", dump_only=True) @@ -70,6 +70,7 @@ class Meta: "profile", "scheduled_removal", "registration_date", + "last_signed_in", ) load_instance = True diff --git a/server/mergin/sync/config.py b/server/mergin/sync/config.py index 7200dae5..e616a0ca 100644 --- a/server/mergin/sync/config.py +++ b/server/mergin/sync/config.py @@ -75,3 +75,6 @@ class Configuration(object): UPLOAD_CHUNKS_EXPIRATION = config( "UPLOAD_CHUNKS_EXPIRATION", default=86400, cast=int ) + EXCLUDED_CLONE_FILENAMES = config( + "EXCLUDED_CLONE_FILENAMES", default="qgis_cfg.xml", cast=Csv() + ) diff --git a/server/mergin/sync/models.py b/server/mergin/sync/models.py index 9574a69d..62e4c8b1 100644 --- a/server/mergin/sync/models.py +++ b/server/mergin/sync/models.py @@ -304,6 +304,7 @@ def get_member(self, user_id: int) -> Optional[ProjectMember]: project_role=ProjectRole(member.role), workspace_role=self.workspace.get_user_role(member.user), role=ProjectPermissions.get_user_project_role(self, member.user), + name=member.user.profile.name(), ) def members_by_role(self, role: ProjectRole) -> List[int]: @@ -364,6 +365,7 @@ class ProjectMember: workspace_role: WorkspaceRole project_role: Optional[ProjectRole] role: ProjectRole + name: Optional[str] @dataclass diff --git a/server/mergin/sync/private_api.yaml b/server/mergin/sync/private_api.yaml index 48a88933..4c799b3f 100644 --- a/server/mergin/sync/private_api.yaml +++ b/server/mergin/sync/private_api.yaml @@ -657,6 +657,12 @@ components: type: string format: date-time example: 2018-11-30T08:47:58.636074Z + last_signed_in: + description: Present only for type `member` + nullable: true + type: string + format: date-time + example: 2025-12-18T08:47:58Z ProjectAccessUpdated: type: object properties: diff --git a/server/mergin/sync/public_api_controller.py b/server/mergin/sync/public_api_controller.py index 0b487874..f8b88cd1 100644 --- a/server/mergin/sync/public_api_controller.py +++ b/server/mergin/sync/public_api_controller.py @@ -1136,9 +1136,12 @@ def clone_project(namespace, project_name): # noqa: E501 ) p.updated = datetime.utcnow() db.session.add(p) + files_to_exclude = current_app.config.get("EXCLUDED_CLONE_FILENAMES", []) try: - p.storage.initialize(template_project=cloned_project) + p.storage.initialize( + template_project=cloned_project, excluded_files=files_to_exclude + ) except InitializationError as e: abort(400, f"Failed to clone project: {str(e)}") @@ -1149,6 +1152,8 @@ def clone_project(namespace, project_name): # noqa: E501 # transform source files to new uploaded files file_changes = [] for file in cloned_project.files: + if os.path.basename(file.path) in files_to_exclude: + continue file_changes.append( ProjectFileChange( file.path, diff --git a/server/mergin/sync/public_api_v2.yaml b/server/mergin/sync/public_api_v2.yaml index c1c74f68..c81be8af 100644 --- a/server/mergin/sync/public_api_v2.yaml +++ b/server/mergin/sync/public_api_v2.yaml @@ -330,7 +330,7 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/Project" + $ref: "#/components/schemas/ProjectDetail" "204": $ref: "#/components/responses/NoContent" "400": @@ -367,6 +367,73 @@ paths: $ref: "#/components/schemas/ProjectLocked" x-openapi-router-controller: mergin.sync.public_api_v2_controller + /workspaces/{workspace_id}/projects: + get: + tags: + - workspace + summary: List projects in the workspace + operationId: list_workspace_projects + parameters: + - $ref: '#/components/parameters/WorkspaceId' + - name: page + in: query + description: page number + required: true + schema: + type: integer + minimum: 1 + example: 1 + - name: per_page + in: query + description: Number of results per page + required: true + schema: + type: integer + maximum: 50 + example: 50 + - name: order_params + in: query + description: Sorting fields + required: false + schema: + type: string + example: name ASC, expire DESC + - name: q + in: query + description: Filter by name with ilike pattern + required: false + schema: + type: string + example: my-survey + responses: + "200": + description: List of workspace projects that match the query limited to 50 + content: + application/json: + schema: + type: object + properties: + page: + type: integer + example: 1 + per_page: + type: integer + example: 20 + count: + type: integer + example: 10 + projects: + type: array + maxItems: 50 + items: + $ref: "#/components/schemas/Project" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "404": + $ref: "#/components/responses/NotFound" + x-openapi-router-controller: mergin.sync.public_api_v2_controller components: responses: NoContent: @@ -393,6 +460,13 @@ components: type: string format: uuid pattern: \b[0-9a-f]{8}\b-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-\b[0-9a-f]{12}\b + WorkspaceId: + name: workspace_id + in: path + description: Workspace id + required: true + schema: + type: integer schemas: # Errors CustomError: @@ -528,7 +602,7 @@ components: $ref: "#/components/schemas/ProjectRole" role: $ref: "#/components/schemas/Role" - ProjectDetail: + Project: type: object required: - id @@ -586,7 +660,7 @@ components: description: List of files in the project items: allOf: - - $ref: '#/components/schemas/File' + - $ref: "#/components/schemas/File" - type: object properties: mtime: @@ -594,6 +668,24 @@ components: format: date-time description: File modification timestamp example: 2024-11-19T13:50:00Z + ProjectDetail: + allOf: + - $ref: "#/components/schemas/Project" + - type: object + properties: + files: + type: array + description: List of files in the project + items: + allOf: + - $ref: "#/components/schemas/File" + - type: object + properties: + mtime: + type: string + format: date-time + description: File modification timestamp + example: 2024-11-19T13:50:00Z File: type: object description: Project file metadata @@ -768,84 +860,6 @@ components: type: string format: date-time example: 2019-02-26T08:47:58.636074Z - Project: - type: object - required: - - name - properties: - id: - type: string - example: f9ef87ac-1dae-48ab-85cb-062a4784fb83 - description: Project UUID - name: - type: string - example: mergin - namespace: - type: string - example: mergin - creator: - nullable: true - type: integer - example: 1 - description: Project creator ID - created: - type: string - format: date-time - example: 2018-11-30T08:47:58.636074Z - updated: - type: string - nullable: true - format: date-time - example: 2018-11-30T08:47:58.636074Z - description: Last modified - version: - type: string - nullable: true - example: v2 - description: Last project version - disk_usage: - type: integer - example: 25324373 - description: Project size in bytes - permissions: - type: object - properties: - delete: - type: boolean - example: false - update: - type: boolean - example: false - upload: - type: boolean - example: true - tags: - type: array - nullable: true - items: - $ref: "#/components/schemas/MerginTag" - uploads: - type: array - nullable: true - items: - type: string - example: 669b838e-a30b-4338-b2b6-3da144742a82 - description: UUID for ongoing upload - access: - $ref: "#/components/schemas/Access" - files: - type: array - items: - allOf: - - $ref: "#/components/schemas/FileInfo" - role: - nullable: true - type: string - enum: - - reader - - editor - - writer - - owner VersionName: type: string pattern: '^$|^v\d+$' diff --git a/server/mergin/sync/public_api_v2_controller.py b/server/mergin/sync/public_api_v2_controller.py index 1bfd8738..3e28aa40 100644 --- a/server/mergin/sync/public_api_v2_controller.py +++ b/server/mergin/sync/public_api_v2_controller.py @@ -40,16 +40,16 @@ project_version_created, push_finished, ) -from .permissions import ProjectPermissions, require_project_by_uuid +from .permissions import ProjectPermissions, require_project_by_uuid, projects_query from .public_api_controller import catch_sync_failure from .schemas import ( ProjectMemberSchema, UploadChunkSchema, - ProjectSchema, ) from .storages.disk import move_to_tmp, save_to_file from .utils import get_device_id, get_ip, get_user_agent, get_chunk_location from .workspace import WorkspaceRole +from ..utils import parse_order_params @auth_required @@ -114,6 +114,7 @@ def get_project_collaborators(id): project_role=project_role, workspace_role=workspace_role, role=ProjectPermissions.get_user_project_role(project, user), + name=user.profile.name(), ) ) @@ -360,7 +361,12 @@ def create_project_version(id): finally: # remove artifacts upload.clear() - return ProjectSchema().dump(project), 201 + + result = ProjectSchemaV2().dump(project) + result["files"] = ProjectFileSchema( + only=("path", "mtime", "size", "checksum"), many=True + ).dump(project.files) + return result, 201 @auth_required @@ -391,3 +397,51 @@ def upload_chunk(id: str): UploadChunkSchema().dump({"id": chunk_id, "valid_until": valid_until}), 200, ) + + +@auth_required +def list_workspace_projects(workspace_id, page, per_page, order_params=None, q=None): + """Paginate over workspace projects with optional filtering. + + :param workspace_id: ID of the workspace to list projects from + :param workspace_id: int + :param page: page number + :type page: int + :param per_page: Number of results per page + :type per_page: int + :param order_params: Sorting fields e.g. "name ASC,updated DESC" + :type order_params: str + :param q: Filter by name with ilike pattern + :type q: str + + :rtype: Dict[str: List[Project], str: Integer, str: Integer, str: Integer] + """ + ws = current_app.ws_handler.get(workspace_id) + if not (ws and ws.is_active): + abort(404, "Workspace not found") + + if ws.user_has_permissions(current_user, "read"): + # regular members can list all projects + projects = Project.query.filter_by(workspace_id=ws.id).filter( + Project.removed_at.is_(None) + ) + elif ws.user_has_permissions(current_user, "guest"): + # guest can list only explicitly shared projects + projects = projects_query( + ProjectPermissions.Read, as_admin=False, public=False + ).filter(Project.workspace_id == ws.id) + else: + abort(403, "You do not have permissions to workspace") + + if q: + projects = projects.filter(Project.name.ilike(f"%{q}%")) + + if order_params: + order_by_params = parse_order_params(Project, order_params) + projects = projects.order_by(*order_by_params) + + result = projects.paginate(page, per_page).items + total = projects.paginate(page, per_page).total + + data = ProjectSchemaV2(many=True).dump(result) + return jsonify(projects=data, count=total, page=page, per_page=per_page), 200 diff --git a/server/mergin/sync/schemas.py b/server/mergin/sync/schemas.py index 8d1df050..4eecca0c 100644 --- a/server/mergin/sync/schemas.py +++ b/server/mergin/sync/schemas.py @@ -405,6 +405,7 @@ class ProjectMemberSchema(Schema): 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) + name = fields.String() class UploadChunkSchema(Schema): diff --git a/server/mergin/sync/storages/disk.py b/server/mergin/sync/storages/disk.py index 4491ad98..7b038755 100644 --- a/server/mergin/sync/storages/disk.py +++ b/server/mergin/sync/storages/disk.py @@ -178,7 +178,7 @@ def _project_dir(self): ) return project_dir - def initialize(self, template_project=None): + def initialize(self, template_project=None, excluded_files=None): if os.path.exists(self.project_dir): raise InitializationError( "Project directory already exists: {}".format(self.project_dir) @@ -193,8 +193,12 @@ def initialize(self, template_project=None): if ws.disk_usage() + template_project.disk_usage > ws.storage: self.delete() raise InitializationError("Disk quota reached") + if excluded_files is None: + excluded_files = [] for file in template_project.files: + if os.path.basename(file.path) in excluded_files: + continue src = os.path.join(template_project.storage.project_dir, file.location) dest = os.path.join( self.project_dir, diff --git a/server/mergin/sync/workspace.py b/server/mergin/sync/workspace.py index 76245ef3..28c70e0a 100644 --- a/server/mergin/sync/workspace.py +++ b/server/mergin/sync/workspace.py @@ -77,7 +77,9 @@ def user_has_permissions(self, user, permissions): if role is WorkspaceRole.OWNER: return True - if permissions == "read": + if permissions == "guest": + return role == WorkspaceRole.GUEST + elif permissions == "read": return role >= WorkspaceRole.READER elif permissions == "edit": return role >= WorkspaceRole.EDITOR diff --git a/server/mergin/tests/test_project_controller.py b/server/mergin/tests/test_project_controller.py index c7a0550e..054ba063 100644 --- a/server/mergin/tests/test_project_controller.py +++ b/server/mergin/tests/test_project_controller.py @@ -1728,6 +1728,8 @@ def test_clone_project(client, data, username, expected): assert resp.json["code"] == "StorageLimitHit" assert resp.json["detail"] == "You have reached a data limit (StorageLimitHit)" if expected == 200: + excluded_filenames = current_app.config.get("EXCLUDED_CLONE_FILENAMES") + proj = data.get("project", test_project).strip() template = Project.query.filter_by( name=test_project, workspace_id=test_workspace_id @@ -1735,9 +1737,12 @@ def test_clone_project(client, data, username, expected): project = Project.query.filter_by( name=proj, workspace_id=test_workspace_id ).first() + template_files_filtered = [ + f for f in template.files if f.path not in excluded_filenames + ] assert not any( x.checksum != y.checksum and x.path != y.path - for x, y in zip(project.files, template.files) + for x, y in zip(project.files, template_files_filtered) ) assert os.path.exists( os.path.join(project.storage.project_dir, project.files[0].location) @@ -1755,6 +1760,12 @@ def test_clone_project(client, data, username, expected): item for item in changes if item.change == PushChangeType.UPDATE.value ] assert pv.device_id == json_headers["X-Device-Id"] + + assert not any(f.path == excluded_filenames[0] for f in project.files) + assert not os.path.exists( + os.path.join(project.storage.project_dir, excluded_filenames[0]) + ) + assert len(project.files) == len(template.files) - 1 # cleanup shutil.rmtree(project.storage.project_dir) @@ -2000,7 +2011,7 @@ def test_get_projects_by_uuids(client): {"page": 1, "per_page": 5, "desc": False}, 200, "v1", - {"added": 12, "removed": 0, "updated": 0, "updated_diff": 0}, + {"added": 13, "removed": 0, "updated": 0, "updated_diff": 0}, ), ( {"page": 2, "per_page": 3, "desc": True}, diff --git a/server/mergin/tests/test_projects/test/qgis_cfg.xml b/server/mergin/tests/test_projects/test/qgis_cfg.xml new file mode 100644 index 00000000..c2eece89 --- /dev/null +++ b/server/mergin/tests/test_projects/test/qgis_cfg.xml @@ -0,0 +1,4 @@ + +0737fe398eb9f26bd847fb9da2407646a2e8c89dc2f93eba5a059f19eedd8017e50c557d2c7d435a2701d881cdaf4fbbd3a892e4367053b5bfc348b556ae252314d9b06fc70a4f184362d064023c1ed6c4dd7ee14dce10ea91595e8548f7ba3d3eaca1d41063f50d1ccc12bfb90c059271254dca780e0d60e68bd234844fda81a0781977907485b397aa1263aef81863625eb439dc349fca0dbc641b4a606657f17e55d2c02fbc95388bf9f96977c65fcf7b723689d5fcdaf73190a5597425b3d33c858c2c4ef8c334b5f601c98db05557c8f690cdb9f73c725bf7ee420fffb6037cc9e80c7374a55ab55baf4aaa7f1c957fd40bb69b9fcb41ec42b063330bbddcd73f4de69e47772309167cb20ef4fe3250db96c29b71772edd18c7e73c501d569f4f8deda15fb0bcf6701d81902a6fc6c722db9e0d766d18a45297232224738c07a1a4f8fe490954efcae05fc1e43eac4eb5efc8b9008dcff4cf3688db4b7e268c9adf75d88d4d1d3648232c6fc2ac98b49b3bbb19368ce460b4a7a9828558d473eb0f1ba34c09f1ba9ce0170ac6d6c656176760e4012c56daef3f5f05320d5f84260d2e6b5a0b15620c33802d1c8c2f28084eef63b32f8130edd4789972b25960e12eb79351d11316f78aea3a941b9e7f1f4042f708d873ac7807ce0652819b2e2f77f9aac1c50cf72d2341118c41419f5f4c31474d8dbe56558dab9cb4b8e4fe8df9c8b4a057d9c6fe6b098b78e150aadd2a45cf1ea15e02f8f1f8b1b46d1a5513c26a63ea08788675feaa912e884ceee57adc120393c8a5bc42988f7b210195f6eff5de3e332d0d67321d05b907f836eb0f0f9e97388f89b699638804978639ad8b4c889f1a56952f949242679506cba5cc35538ea01b1621dd6a154f92b721b5247e294a5394df9c87765675b737dcb28346fc4032b68f87f46150aa4aa136378903036aff61fd41cf0cbcdd0865660f26d7f1d49f29ff5962adc209b9db71d12bf49bf67950496f18ca1de0a5cd7186e1bc0fcf826ffd1bb91ab36412c43730db5ff9ec57990fee27c5158446294bf0d8e61e12ee53e80b606b541c754ed45b2289079df8b647a8ca12fb1706e371523a581af50d333adfe5e84bcce2a60e84e24bdc1eb74610bc28b279b15c4f2020b045d2e4a7f846e488d74e761d98c05f105452235f602b3fe8beccf4b11d35ba6042dcc97f68090f40edbd6e8497434c193343cca98ebeabdd8620ec7eec642efda7cd45f0a9547ea821ac193eb1a8fb8c9c71d2e607b4651de5b8b613bc38aa4ba06bdb65a3d6b6e92546f1a4113e0bbce99aadbab3bbb07f31d6f90b3ff58b4494815e97a265c1c5e8a826bf14177427e03247395a18941753c0e580c42661a9c959ad57b93b97fb4adeca49927f3bec95eff361e95c324623f1c7c4d39e71250938d4189461cd6c1e978a5445f88eaf47670f23145cb7c8faf42ac83158743004fefb17a37a25edcf2425d530dd12ca52fdcfc399542cd288773c06931ce9aaac94df69dc6514fa3b1b8629dcfe725c0dcd77b5db967c5620dbc2444f4b78fb247e33a54ed2cbcaba3b92833b6d75b4900697da646f04da9a04d6353556b0ab70f8dd952eed9bd9cee1d53e760b292080862a74f625eb402662aadd94efaa6cce0727d3ccab5b6e112f25562effadfcf70307800e0d28976327576e99380facea2828ddb6a85addb4d4c0cfdb73cd848a9f707f8f978caf5de82756c80f42d53719987d5b4826397de8674d75dc1308dd3e96af37e9b3e42175dca1a5ff58a4aa4881a344113711a93340ee6515e5b9d03d1f4979531c84ec187b9303ea763b2641f530144cf52a81812349511219fc92bb038ec62d438c3beaf723 + + \ No newline at end of file diff --git a/server/mergin/tests/test_public_api_v2.py b/server/mergin/tests/test_public_api_v2.py index 34a5a2a1..e058c589 100644 --- a/server/mergin/tests/test_public_api_v2.py +++ b/server/mergin/tests/test_public_api_v2.py @@ -2,7 +2,7 @@ # # SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-MerginMaps-Commercial -from mergin.sync.tasks import remove_transaction_chunks, remove_unused_chunks +from mergin.sync.tasks import remove_transaction_chunks from . import DEFAULT_USER from .utils import ( add_user, @@ -22,10 +22,10 @@ from sqlalchemy.exc import IntegrityError import pytest from datetime import datetime, timedelta, timezone +import json from mergin.app import db from mergin.config import Configuration -from mergin.sync.config import Configuration as SyncConfiguration from mergin.sync.errors import ( BigChunkError, ProjectLocked, @@ -51,6 +51,7 @@ _get_changes_with_diff_0_size, _get_changes_without_added, ) +from ..sync.interfaces import WorkspaceRole def test_schedule_delete_project(client): @@ -598,3 +599,97 @@ def test_full_push(client): os.path.join(project.storage.project_dir, "v2", test_file["path"]) ) assert not Upload.query.filter_by(project_id=project.id).first() + + +def test_list_workspace_projects(client): + admin = User.query.filter_by(username=DEFAULT_USER[0]).first() + test_workspace = create_workspace() + url = f"v2/workspaces/{test_workspace.id}/projects" + for i in range(1, 11): + create_project(f"project_{i}", test_workspace, admin) + + # missing required query params + assert client.get(url).status_code == 400 + + # success + page = 1 + per_page = 10 + response = client.get(url + f"?page={page}&per_page={per_page}") + resp_data = json.loads(response.data) + assert response.status_code == 200 + assert resp_data["count"] == 11 + assert len(resp_data["projects"]) == per_page + # correct number on the last page + page = 4 + per_page = 3 + response = client.get(url + f"?page={page}&per_page={per_page}") + assert response.json["count"] == 11 + assert len(response.json["projects"]) == 2 + # name search - more results + page = 1 + per_page = 3 + response = client.get( + url + f"?page={page}&per_page={per_page}&q=1&order_params=updated ASC" + ) + assert response.json["count"] == 2 + assert len(response.json["projects"]) == 2 + assert response.json["projects"][1]["name"] == "project_10" + # name search - specific result + project_name = "project_4" + response = client.get(url + f"?page={page}&per_page={per_page}&q={project_name}") + assert response.json["projects"][0]["name"] == project_name + # sorting + response = client.get( + url + f"?page={page}&per_page={per_page}&q=1&order_params=created DESC" + ) + assert response.json["projects"][0]["name"] == "project_10" + + # no permissions to workspace + user2 = add_user("user", "password") + login(client, user2.username, "password") + with patch.object( + Configuration, + "GLOBAL_READ", + 0, + ), patch.object( + Configuration, + "GLOBAL_WRITE", + 0, + ), patch.object( + Configuration, + "GLOBAL_ADMIN", + 0, + ): + resp = client.get(url + "?page=1&per_page=10") + assert resp.status_code == 200 + assert resp.json["count"] == 0 + + # no existing workspace + assert ( + client.get("/v1/workspace/1234/projects?page=1&per_page=10").status_code == 404 + ) + + # project shared directly + p = Project.query.filter_by(workspace_id=test_workspace.id).first() + p.set_role(user2.id, ProjectRole.READER) + db.session.commit() + resp = client.get(url + "?page=1&per_page=10") + resp_data = json.loads(resp.data) + assert resp_data["count"] == 1 + assert resp_data["projects"][0]["name"] == p.name + + # deactivate project + p.removed_at = datetime.utcnow() + db.session.commit() + resp = client.get(url + "?page=1&per_page=10") + assert resp.json["count"] == 0 + + # add user as a reader + with patch.object(Configuration, "GLOBAL_READ", 1): + resp = client.get(url + "?page=1&per_page=10") + assert p.name not in [proj["name"] for proj in resp.json["projects"]] + assert resp.json["count"] == 10 + + # logout + logout(client) + assert client.get(url + "?page=1&per_page=10").status_code == 401 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 cb283c78..9096e9bb 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 @@ -7,116 +7,122 @@ - - - -
- -

- {{ user?.username }} -

-

+ + +

- - {{ user?.email }} -

-
-
+

+ {{ + profile?.name + ? `${profile.name} (${user?.username})` + : user.username + }} +

+

-

Full name
-
- {{ profile?.name || '-' }} -
-
-
-
Registered
-
- {{ $filters.date(user?.registration_date) }} -
-
-
-
- - - - - - - - - + + + + + diff --git a/web-app/packages/lib/src/common/components/UserSummary.vue b/web-app/packages/lib/src/common/components/UserSummary.vue new file mode 100644 index 00000000..674265fd --- /dev/null +++ b/web-app/packages/lib/src/common/components/UserSummary.vue @@ -0,0 +1,39 @@ + + + diff --git a/web-app/packages/lib/src/common/components/index.ts b/web-app/packages/lib/src/common/components/index.ts index 790534e6..59677e76 100644 --- a/web-app/packages/lib/src/common/components/index.ts +++ b/web-app/packages/lib/src/common/components/index.ts @@ -16,6 +16,7 @@ export { default as FullStorageWarningTemplate } from './FullStorageWarningTempl export { default as TipMessage } from './TipMessage.vue' export { default as AppOnboardingPage } from './AppOnboardingPage.vue' export { default as UsageCard } from './UsageCard.vue' +export { default as UserSummary } from './UserSummary.vue' export * from './types' export * from './data-view' export * from './app-settings' diff --git a/web-app/packages/lib/src/modules/project/components/ProjectMembersTable.vue b/web-app/packages/lib/src/modules/project/components/ProjectMembersTable.vue index e7e01062..ef994b92 100644 --- a/web-app/packages/lib/src/modules/project/components/ProjectMembersTable.vue +++ b/web-app/packages/lib/src/modules/project/components/ProjectMembersTable.vue @@ -38,33 +38,20 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-MerginMaps-Commercial /> -