From e8c3269e4361eae6b77da7d55e8b141429f1e641 Mon Sep 17 00:00:00 2001 From: Martin Weise <martin.weise@tuwien.ac.at> Date: Fri, 13 Dec 2024 21:40:21 +0100 Subject: [PATCH] Fixed UI for private views and also library --- .../at/tuwien/endpoints/ViewEndpoint.java | 4 ++ dbrepo-ui/components/view/ViewToolbar.vue | 19 ++++++++ .../[database_id]/view/[view_id]/data.vue | 18 ++++--- lib/python/dbrepo/RestClient.py | 47 +++++-------------- lib/python/dbrepo/api/dto.py | 21 ++++----- lib/python/tests/test_unit_license.py | 4 +- 6 files changed, 58 insertions(+), 55 deletions(-) diff --git a/dbrepo-data-service/rest-service/src/main/java/at/tuwien/endpoints/ViewEndpoint.java b/dbrepo-data-service/rest-service/src/main/java/at/tuwien/endpoints/ViewEndpoint.java index 001350246e..84b24b66c7 100644 --- a/dbrepo-data-service/rest-service/src/main/java/at/tuwien/endpoints/ViewEndpoint.java +++ b/dbrepo-data-service/rest-service/src/main/java/at/tuwien/endpoints/ViewEndpoint.java @@ -270,6 +270,10 @@ public class ViewEndpoint extends AbstractEndpoint { // TODO improve with a single operation that checks if user xyz has access to view abc final PrivilegedViewDto view = metadataServiceGateway.getViewById(databaseId, viewId); if (!view.getIsPublic()) { + if (principal == null) { + log.error("Failed to get data from view: unauthorized"); + throw new NotAllowedException("Failed to get data from view: unauthorized"); + } metadataServiceGateway.getAccess(databaseId, UserUtil.getId(principal)); } try { diff --git a/dbrepo-ui/components/view/ViewToolbar.vue b/dbrepo-ui/components/view/ViewToolbar.vue index c43b730397..fdbb47fe92 100644 --- a/dbrepo-ui/components/view/ViewToolbar.vue +++ b/dbrepo-ui/components/view/ViewToolbar.vue @@ -34,6 +34,7 @@ :text="$t('navigation.info')" :to="`/database/${$route.params.database_id}/view/${$route.params.view_id}/info`" /> <v-tab + v-if="canReadData" :text="$t('navigation.data')" :to="`/database/${$route.params.database_id}/view/${$route.params.view_id}/data`" /> </v-tabs> @@ -96,6 +97,24 @@ export default { roles () { return this.userStore.getRoles }, + hasReadAccess () { + if (!this.access) { + return false + } + return this.access.type === 'read' || this.access.type === 'write_own' || this.access.type === 'write_all' + }, + canReadData () { + if (!this.view) { + return false + } + if (this.view.is_public) { + return true + } + if (!this.user) { + return false + } + return this.view.owner.id === this.user.id || this.hasReadAccess + }, identifiers () { if (!this.view) { return [] diff --git a/dbrepo-ui/pages/database/[database_id]/view/[view_id]/data.vue b/dbrepo-ui/pages/database/[database_id]/view/[view_id]/data.vue index bc460da08b..b42972913e 100644 --- a/dbrepo-ui/pages/database/[database_id]/view/[view_id]/data.vue +++ b/dbrepo-ui/pages/database/[database_id]/view/[view_id]/data.vue @@ -1,5 +1,6 @@ <template> - <div> + <div + v-if="canReadData"> <ViewToolbar v-if="view" /> <v-toolbar @@ -7,7 +8,6 @@ :title="$t('toolbars.database.current')" flat> <v-btn - v-if="canDownload" :prepend-icon="$vuetify.display.lgAndUp ? 'mdi-download' : null" variant="flat" :loading="downloadLoading" @@ -85,18 +85,24 @@ export default { access () { return this.userStore.getAccess }, - canDownload () { + hasReadAccess () { + if (!this.access) { + return false + } + return this.access.type === 'read' || this.access.type === 'write_own' || this.access.type === 'write_all' + }, + canReadData () { if (!this.view) { return false } if (this.view.is_public) { return true } - if (!this.access) { + if (!this.user) { return false } - return this.access.type === 'read' || this.access.type === 'write_own' || this.access.type === 'write_all' - } + return this.view.owner.id === this.user.id || this.hasReadAccess + }, }, mounted () { this.reload() diff --git a/lib/python/dbrepo/RestClient.py b/lib/python/dbrepo/RestClient.py index b80daff7d0..16eb31a3e3 100644 --- a/lib/python/dbrepo/RestClient.py +++ b/lib/python/dbrepo/RestClient.py @@ -957,8 +957,7 @@ class RestClient: raise ResponseCodeError(f'Failed to delete view: response code: {response.status_code} is not ' f'202 (ACCEPTED): {response.text}') - def get_view_data(self, database_id: int, view_id: int, page: int = 0, size: int = 10, - df: bool = False) -> Result | DataFrame: + def get_view_data(self, database_id: int, view_id: int, page: int = 0, size: int = 10) -> DataFrame: """ Get data of a view in a database with given database id and view id. @@ -966,9 +965,8 @@ class RestClient: :param view_id: The view id. :param page: The result pagination number. Optional. Default: 0. :param size: The result pagination size. Optional. Default: 10. - :param df: If true, the result is returned as Pandas DataFrame. Optional. Default: False. - :returns: The result of the view query, if successful. + :returns: The view data, if successful. :raises MalformedError: If the payload is rejected by the service. :raises ForbiddenError: If something went wrong with the authorization. @@ -984,11 +982,7 @@ class RestClient: params.append(('size', size)) response = self._wrapper(method="get", url=url, params=params) if response.status_code == 200: - body = response.json() - res = Result.model_validate(body) - if df: - return DataFrame.from_records(res.result) - return res + return DataFrame.from_records(response.json()) if response.status_code == 400: raise MalformedError(f'Failed to get view data: {response.text}') if response.status_code == 403: @@ -1026,7 +1020,7 @@ class RestClient: f'200 (OK): {response.text}') def get_table_data(self, database_id: int, table_id: int, page: int = 0, size: int = 10, - timestamp: datetime.datetime = None, df: bool = False) -> Result | DataFrame: + timestamp: datetime.datetime = None) -> DataFrame: """ Get data of a table in a database with given database id and table id. @@ -1035,9 +1029,8 @@ class RestClient: :param page: The result pagination number. Optional. Default: 0. :param size: The result pagination size. Optional. Default: 10. :param timestamp: The query execution time. Optional. - :param df: If true, the result is returned as Pandas DataFrame. Optional. Default: False. - :returns: The result of the view query, if successful. + :returns: The table data, if successful. :raises MalformedError: If the payload is rejected by the service. :raises ForbiddenError: If something went wrong with the authorization. @@ -1054,11 +1047,7 @@ class RestClient: params.append(('timestamp', timestamp)) response = self._wrapper(method="get", url=url, params=params) if response.status_code == 200: - body = response.json() - res = Result.model_validate(body) - if df: - return DataFrame.from_records(res.result) - return res + return DataFrame.from_records(response.json()) if response.status_code == 400: raise MalformedError(f'Failed to get table data: {response.text}') if response.status_code == 403: @@ -1551,7 +1540,7 @@ class RestClient: f'201 (CREATED): {response.text}') def create_subset(self, database_id: int, query: str, page: int = 0, size: int = 10, - timestamp: datetime.datetime = None, df: bool = False) -> Result | DataFrame: + timestamp: datetime.datetime = None) -> DataFrame: """ Executes a SQL query in a database where the current user has at least read access with given database id. The result set can be paginated with setting page and size (both). Historic data can be queried by setting @@ -1562,7 +1551,6 @@ class RestClient: :param page: The result pagination number. Optional. Default: 0. :param size: The result pagination size. Optional. Default: 10. :param timestamp: The timestamp at which the data validity is set. Optional. Default: <current timestamp>. - :param df: If true, the result is returned as Pandas DataFrame. Optional. Default: False. :returns: The result set, if successful. @@ -1585,11 +1573,8 @@ class RestClient: response = self._wrapper(method="post", url=url, headers={"Accept": "application/json"}, payload=ExecuteQuery(statement=query)) if response.status_code == 201: - body = response.json() - res = Result.model_validate(body) - if df: - return DataFrame.from_records(res.result) - return res + logging.info(f'Created subset with id: {response.headers["X-Id"]}') + return DataFrame.from_records(response.json()) if response.status_code == 400: raise MalformedError(f'Failed to create subset: {response.text}') if response.status_code == 403: @@ -1605,8 +1590,7 @@ class RestClient: raise ResponseCodeError(f'Failed to create subset: response code: {response.status_code} is not ' f'201 (CREATED): {response.text}') - def get_subset_data(self, database_id: int, subset_id: int, page: int = 0, size: int = 10, - df: bool = False) -> Result | DataFrame: + def get_subset_data(self, database_id: int, subset_id: int, page: int = 0, size: int = 10) -> DataFrame: """ Re-executes a query in a database with given database id and query id. @@ -1615,9 +1599,8 @@ class RestClient: :param page: The result pagination number. Optional. Default: 0. :param size: The result pagination size. Optional. Default: 10. :param size: The result pagination size. Optional. Default: 10. - :param df: If true, the result is returned as Pandas DataFrame. Optional. Default: False. - :returns: The result set, if successful. + :returns: The subset data, if successful. :raises MalformedError: If the payload is rejected by the service. :raises ForbiddenError: If something went wrong with the authorization. @@ -1631,11 +1614,7 @@ class RestClient: url += f'?page={page}&size={size}' response = self._wrapper(method="get", url=url, headers=headers) if response.status_code == 200: - body = response.json() - res = Result.model_validate(body) - if df: - return DataFrame.from_records(res.result) - return res + return DataFrame.from_records(response.json()) if response.status_code == 400: raise MalformedError(f'Failed to get query data: {response.text}') if response.status_code == 403: @@ -1936,7 +1915,7 @@ class RestClient: :returns: List of licenses, if successful. """ - url = f'/api/database/license' + url = f'/api/license' response = self._wrapper(method="get", url=url) if response.status_code == 200: body = response.json() diff --git a/lib/python/dbrepo/api/dto.py b/lib/python/dbrepo/api/dto.py index 4de986e870..e54d1eba16 100644 --- a/lib/python/dbrepo/api/dto.py +++ b/lib/python/dbrepo/api/dto.py @@ -1,10 +1,11 @@ from __future__ import annotations +import datetime from dataclasses import field from enum import Enum -import datetime -from typing import List, Optional, Any, Annotated -from pydantic import BaseModel, ConfigDict, PlainSerializer, Field +from typing import List, Optional, Annotated + +from pydantic import BaseModel, PlainSerializer, Field Timestamp = Annotated[ datetime.datetime, PlainSerializer(lambda v: v.strftime('%Y-%m-%dT%H:%M:%S.%f')[:-3] + 'Z', return_type=str) @@ -638,12 +639,6 @@ class CreateView(BaseModel): is_public: bool -class Result(BaseModel): - result: Any - headers: Any - id: Optional[int] = None - - class ViewBrief(BaseModel): id: int database_id: int @@ -874,8 +869,8 @@ class DataType(BaseModel): display_name: str value: str documentation: str - is_quoted: bool - is_buildable: bool + is_quoted: bool + is_buildable: bool size_min: Optional[int] = None size_max: Optional[int] = None size_default: Optional[int] = None @@ -884,8 +879,8 @@ class DataType(BaseModel): d_max: Optional[int] = None d_default: Optional[int] = None d_required: Optional[bool] = None - data_hint: Optional[str] = None - type_hint: Optional[str] = None + data_hint: Optional[str] = None + type_hint: Optional[str] = None class Column(BaseModel): diff --git a/lib/python/tests/test_unit_license.py b/lib/python/tests/test_unit_license.py index 2efb613c42..7f2a52890e 100644 --- a/lib/python/tests/test_unit_license.py +++ b/lib/python/tests/test_unit_license.py @@ -12,7 +12,7 @@ class DatabaseUnitTest(unittest.TestCase): def test_get_licenses_empty_succeeds(self): with requests_mock.Mocker() as mock: # mock - mock.get('/api/database/license', json=[]) + mock.get('/api/license', json=[]) # test response = RestClient().get_licenses() self.assertEqual([], response) @@ -22,7 +22,7 @@ class DatabaseUnitTest(unittest.TestCase): exp = [License(identifier='CC-BY-4.0', uri='https://creativecommons.org/licenses/by/4.0/', description='The Creative Commons Attribution license allows re-distribution and re-use of a licensed work on the condition that the creator is appropriately credited.')] # mock - mock.get('/api/database/license', json=[exp[0].model_dump()]) + mock.get('/api/license', json=[exp[0].model_dump()]) # test response = RestClient().get_licenses() self.assertEqual(exp, response) -- GitLab