diff --git a/backend/src/backend/primary/main.py b/backend/src/backend/primary/main.py index e66d49753..9bb4f23db 100644 --- a/backend/src/backend/primary/main.py +++ b/backend/src/backend/primary/main.py @@ -22,6 +22,7 @@ from .routers.well.router import router as well_router from .routers.seismic.router import router as seismic_router from .routers.surface_polygons.router import router as surface_polygons_router +from .routers.graph.router import router as graph_router logging.basicConfig( level=logging.WARNING, @@ -60,6 +61,7 @@ def custom_generate_unique_id(route: APIRoute) -> str: app.include_router(well_router, prefix="/well", tags=["well"]) app.include_router(seismic_router, prefix="/seismic", tags=["seismic"]) app.include_router(surface_polygons_router, prefix="/surface_polygons", tags=["surface_polygons"]) +app.include_router(graph_router, prefix="/graph", tags=["graph"]) authHelper = AuthHelper() app.include_router(authHelper.router) diff --git a/backend/src/backend/primary/routers/explore.py b/backend/src/backend/primary/routers/explore.py index f8114312e..a8f687d43 100644 --- a/backend/src/backend/primary/routers/explore.py +++ b/backend/src/backend/primary/routers/explore.py @@ -18,6 +18,8 @@ class FieldInfo(BaseModel): class CaseInfo(BaseModel): uuid: str name: str + status: str + user: str class EnsembleInfo(BaseModel): @@ -64,11 +66,11 @@ async def get_cases( if field_identifier == "DROGON": for case_info in case_info_arr: if case_info.uuid == "10f41041-2c17-4374-a735-bb0de62e29dc": - ret_arr.insert(0, CaseInfo(uuid=case_info.uuid, name=f"GOOD -- {case_info.name}")) + ret_arr.insert(0, CaseInfo(uuid=case_info.uuid, name=f"GOOD -- {case_info.name}", status=case_info.status, user=case_info.user)) else: - ret_arr.append(CaseInfo(uuid=case_info.uuid, name=case_info.name)) + ret_arr.append(CaseInfo(uuid=case_info.uuid, name=case_info.name, status=case_info.status, user=case_info.user)) else: - ret_arr = [CaseInfo(uuid=ci.uuid, name=ci.name) for ci in case_info_arr] + ret_arr = [CaseInfo(uuid=ci.uuid, name=ci.name, status=ci.status, user=ci.user) for ci in case_info_arr] return ret_arr diff --git a/backend/src/backend/primary/routers/general.py b/backend/src/backend/primary/routers/general.py index 93f9a5ba5..bcb6b6b45 100644 --- a/backend/src/backend/primary/routers/general.py +++ b/backend/src/backend/primary/routers/general.py @@ -66,8 +66,8 @@ async def logged_in_user( if authenticated_user.has_graph_access_token() and includeGraphApiInfo: graph_api_access = GraphApiAccess(authenticated_user.get_graph_access_token()) try: - avatar_b64str_future = asyncio.create_task(graph_api_access.get_user_profile_photo()) - graph_user_info_future = asyncio.create_task(graph_api_access.get_user_info()) + avatar_b64str_future = asyncio.create_task(graph_api_access.get_user_profile_photo("me")) + graph_user_info_future = asyncio.create_task(graph_api_access.get_user_info("me")) avatar_b64str = await avatar_b64str_future graph_user_info = await graph_user_info_future diff --git a/backend/src/backend/primary/routers/graph/__init__.py b/backend/src/backend/primary/routers/graph/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/backend/src/backend/primary/routers/graph/router.py b/backend/src/backend/primary/routers/graph/router.py new file mode 100644 index 000000000..393558255 --- /dev/null +++ b/backend/src/backend/primary/routers/graph/router.py @@ -0,0 +1,43 @@ +import asyncio +import logging + +import httpx +from fastapi import APIRouter, Depends, Query + +from src.backend.auth.auth_helper import AuthHelper +from src.services.utils.authenticated_user import AuthenticatedUser +from src.services.graph_access.graph_access import GraphApiAccess + +from .schemas import GraphUserPhoto + +LOGGER = logging.getLogger(__name__) + +router = APIRouter() + + +@router.get("/user_photo/") +async def user_info( + # fmt:off + authenticated_user: AuthenticatedUser = Depends(AuthHelper.get_authenticated_user), + user_id: str = Query(description="User id"), + # fmt:on +) -> GraphUserPhoto: + """Get username, display name and avatar from Microsoft Graph API for a given user id""" + + user_photo = GraphUserPhoto( + avatar_b64str=None, + ) + + if authenticated_user.has_graph_access_token(): + graph_api_access = GraphApiAccess(authenticated_user.get_graph_access_token()) + try: + avatar_b64str = await graph_api_access.get_user_profile_photo(user_id) + + user_photo.avatar_b64str = avatar_b64str + except httpx.HTTPError as exc: + print("Error while fetching user avatar and info from Microsoft Graph API (HTTP error):\n", exc) + except httpx.InvalidURL as exc: + print("Error while fetching user avatar and info from Microsoft Graph API (Invalid URL):\n", exc) + + # Return 404 if no user info was found? + return user_photo diff --git a/backend/src/backend/primary/routers/graph/schemas.py b/backend/src/backend/primary/routers/graph/schemas.py new file mode 100644 index 000000000..5cdf04418 --- /dev/null +++ b/backend/src/backend/primary/routers/graph/schemas.py @@ -0,0 +1,4 @@ +from pydantic import BaseModel + +class GraphUserPhoto(BaseModel): + avatar_b64str: str | None = None diff --git a/backend/src/config.py b/backend/src/config.py index 7ea09f2ce..dec6095e4 100644 --- a/backend/src/config.py +++ b/backend/src/config.py @@ -16,7 +16,7 @@ SMDA_SUBSCRIPTION_KEY = os.environ["WEBVIZ_SMDA_SUBSCRIPTION_KEY"] SMDA_RESOURCE_SCOPE = os.environ["WEBVIZ_SMDA_RESOURCE_SCOPE"] SUMO_ENV = os.getenv("WEBVIZ_SUMO_ENV", "dev") -GRAPH_SCOPES = ["User.Read"] +GRAPH_SCOPES = ["User.ReadBasic.All"] RESOURCE_SCOPES_DICT = { "sumo": [f"api://{sumo_app_reg[SUMO_ENV]['RESOURCE_ID']}/access_as_user"], diff --git a/backend/src/services/graph_access/graph_access.py b/backend/src/services/graph_access/graph_access.py index fe3b92c6e..d1658d38d 100644 --- a/backend/src/services/graph_access/graph_access.py +++ b/backend/src/services/graph_access/graph_access.py @@ -20,18 +20,29 @@ async def _request(self, url: str) -> httpx.Response: ) return response - async def get_user_profile_photo(self) -> str | None: + async def get_user_profile_photo(self, user_id: str) -> str | None: print("entering get_user_profile_photo") - response = await self._request("https://graph.microsoft.com/v1.0/me/photo/$value") + request_url = f"https://graph.microsoft.com/v1.0/me/photo/$value" + if user_id != "me": + request_url = f"https://graph.microsoft.com/v1.0/users/{user_id}/photo/$value" + + print(f"Trying to fetch user photo from: {request_url}") + response = await self._request(request_url) if response.status_code == 200: return base64.b64encode(response.content).decode("utf-8") else: + print(f"Failed ({response.status_code}): {response.content}") return None - async def get_user_info(self) -> Mapping[str, str] | None: + async def get_user_info(self, user_id) -> Mapping[str, str] | None: print("entering get_user_info") - response = await self._request("https://graph.microsoft.com/v1.0/me") + request_url = f"https://graph.microsoft.com/v1.0/me" + if user_id != "me": + request_url = f"https://graph.microsoft.com/v1.0/users/{user_id}" + + print(f"Trying to fetch user info from: {request_url}") + response = await self._request(request_url) if response.status_code == 200: return response.json() diff --git a/backend/src/services/sumo_access/sumo_explore.py b/backend/src/services/sumo_access/sumo_explore.py index b007127ca..301f85552 100644 --- a/backend/src/services/sumo_access/sumo_explore.py +++ b/backend/src/services/sumo_access/sumo_explore.py @@ -20,6 +20,8 @@ class AssetInfo(BaseModel): class CaseInfo(BaseModel): uuid: str name: str + status: str + user: str class IterationInfo(BaseModel): @@ -49,7 +51,7 @@ async def get_cases(self, field_identifier: str) -> List[CaseInfo]: case_info_arr: List[CaseInfo] = [] async for case in case_collection: - case_info_arr.append(CaseInfo(uuid=case.uuid, name=case.name)) + case_info_arr.append(CaseInfo(uuid=case.uuid, name=case.name, status=case.status, user=case.user)) # Sort on case name before returning case_info_arr.sort(key=lambda case_info: case_info.name) diff --git a/frontend/src/api/ApiService.ts b/frontend/src/api/ApiService.ts index f8fe1b27b..2317b563a 100644 --- a/frontend/src/api/ApiService.ts +++ b/frontend/src/api/ApiService.ts @@ -7,6 +7,7 @@ import { AxiosHttpRequest } from './core/AxiosHttpRequest'; import { DefaultService } from './services/DefaultService'; import { ExploreService } from './services/ExploreService'; +import { GraphService } from './services/GraphService'; import { GridService } from './services/GridService'; import { InplaceVolumetricsService } from './services/InplaceVolumetricsService'; import { ParametersService } from './services/ParametersService'; @@ -24,6 +25,7 @@ export class ApiService { public readonly default: DefaultService; public readonly explore: ExploreService; + public readonly graph: GraphService; public readonly grid: GridService; public readonly inplaceVolumetrics: InplaceVolumetricsService; public readonly parameters: ParametersService; @@ -52,6 +54,7 @@ export class ApiService { this.default = new DefaultService(this.request); this.explore = new ExploreService(this.request); + this.graph = new GraphService(this.request); this.grid = new GridService(this.request); this.inplaceVolumetrics = new InplaceVolumetricsService(this.request); this.parameters = new ParametersService(this.request); diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts index 565094c9f..8725a574d 100644 --- a/frontend/src/api/index.ts +++ b/frontend/src/api/index.ts @@ -23,6 +23,7 @@ export type { EnsembleSensitivity as EnsembleSensitivity_api } from './models/En export type { EnsembleSensitivityCase as EnsembleSensitivityCase_api } from './models/EnsembleSensitivityCase'; export type { FieldInfo as FieldInfo_api } from './models/FieldInfo'; export { Frequency as Frequency_api } from './models/Frequency'; +export type { GraphUserPhoto as GraphUserPhoto_api } from './models/GraphUserPhoto'; export type { GridIntersection as GridIntersection_api } from './models/GridIntersection'; export type { GridSurface as GridSurface_api } from './models/GridSurface'; export type { HTTPValidationError as HTTPValidationError_api } from './models/HTTPValidationError'; @@ -57,6 +58,7 @@ export type { WellCompletionsZone as WellCompletionsZone_api } from './models/We export { DefaultService } from './services/DefaultService'; export { ExploreService } from './services/ExploreService'; +export { GraphService } from './services/GraphService'; export { GridService } from './services/GridService'; export { InplaceVolumetricsService } from './services/InplaceVolumetricsService'; export { ParametersService } from './services/ParametersService'; diff --git a/frontend/src/api/models/CaseInfo.ts b/frontend/src/api/models/CaseInfo.ts index 9cc9a1231..4b392b2eb 100644 --- a/frontend/src/api/models/CaseInfo.ts +++ b/frontend/src/api/models/CaseInfo.ts @@ -5,5 +5,7 @@ export type CaseInfo = { uuid: string; name: string; + status: string; + user: string; }; diff --git a/frontend/src/api/models/GraphUserPhoto.ts b/frontend/src/api/models/GraphUserPhoto.ts new file mode 100644 index 000000000..eec0b3657 --- /dev/null +++ b/frontend/src/api/models/GraphUserPhoto.ts @@ -0,0 +1,8 @@ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ + +export type GraphUserPhoto = { + avatar_b64str: (string | null); +}; + diff --git a/frontend/src/api/services/GraphService.ts b/frontend/src/api/services/GraphService.ts new file mode 100644 index 000000000..f54d0c4fe --- /dev/null +++ b/frontend/src/api/services/GraphService.ts @@ -0,0 +1,35 @@ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +import type { GraphUserPhoto } from '../models/GraphUserPhoto'; + +import type { CancelablePromise } from '../core/CancelablePromise'; +import type { BaseHttpRequest } from '../core/BaseHttpRequest'; + +export class GraphService { + + constructor(public readonly httpRequest: BaseHttpRequest) {} + + /** + * User Info + * Get username, display name and avatar from Microsoft Graph API for a given user id + * @param userId User id + * @returns GraphUserPhoto Successful Response + * @throws ApiError + */ + public userInfo( + userId: string, + ): CancelablePromise { + return this.httpRequest.request({ + method: 'GET', + url: '/graph/user_photo/', + query: { + 'user_id': userId, + }, + errors: { + 422: `Validation Error`, + }, + }); + } + +} diff --git a/frontend/src/framework/internal/components/SelectEnsemblesDialog/private-components/userAvatar.tsx b/frontend/src/framework/internal/components/SelectEnsemblesDialog/private-components/userAvatar.tsx new file mode 100644 index 000000000..f0982bb09 --- /dev/null +++ b/frontend/src/framework/internal/components/SelectEnsemblesDialog/private-components/userAvatar.tsx @@ -0,0 +1,46 @@ +import { UseQueryResult, useQuery } from "@tanstack/react-query"; +import React from "react"; + +import { GraphUserPhoto_api } from "@api"; +import { apiService } from "@framework/ApiService"; +import { CircularProgress } from "@lib/components/CircularProgress"; +import { AccountCircle } from "@mui/icons-material"; + +export type UserAvatarProps = { + userId: string; +} + +const STALE_TIME = 60 * 1000; +const CACHE_TIME = 60 * 1000; + +function useUserInfoQuery( + userId: string +): UseQueryResult { + return useQuery({ + queryKey: ["getUserInfo", userId], + queryFn: () => apiService.graph.userInfo(userId), + staleTime: STALE_TIME, + cacheTime: CACHE_TIME, + enabled: userId !== "", + }); +} + +export const UserAvatar: React.FC = (props) => { + const userInfo = useUserInfoQuery(props.userId); + + if (userInfo.isFetching) { + return ; + } + + if (userInfo.data?.avatar_b64str) { + return ( + Avatar + ); + } + return ; +} \ No newline at end of file diff --git a/frontend/src/framework/internal/components/SelectEnsemblesDialog/selectEnsemblesDialog.tsx b/frontend/src/framework/internal/components/SelectEnsemblesDialog/selectEnsemblesDialog.tsx index 618bc9666..06729a6fa 100644 --- a/frontend/src/framework/internal/components/SelectEnsemblesDialog/selectEnsemblesDialog.tsx +++ b/frontend/src/framework/internal/components/SelectEnsemblesDialog/selectEnsemblesDialog.tsx @@ -15,6 +15,8 @@ import { Add, Check, Remove } from "@mui/icons-material"; import { useQuery } from "@tanstack/react-query"; import { isEqual } from "lodash"; +import { Switch } from "@lib/components/Switch"; +import { UserAvatar } from "./private-components/userAvatar"; export type EnsembleItem = { caseUuid: string; @@ -30,9 +32,20 @@ export type SelectEnsemblesDialogProps = { const STALE_TIME = 0; const CACHE_TIME = 5 * 60 * 1000; +interface CaseFilterSettings { + keep: boolean; + onlyMyCases: boolean; + users: string[]; +} + export const SelectEnsemblesDialog: React.FC = (props) => { const [confirmCancel, setConfirmCancel] = React.useState(false); const [newlySelectedEnsembles, setNewlySelectedEnsembles] = React.useState([]); + const [casesFilteringOptions, setCasesFilteringOptions] = React.useState({ + keep: true, + onlyMyCases: false, + users: [], + }); React.useLayoutEffect(() => { setNewlySelectedEnsembles(props.selectedEnsembles); @@ -149,8 +162,30 @@ export const SelectEnsemblesDialog: React.FC = (prop return !isEqual(props.selectedEnsembles, newlySelectedEnsembles); } + function handleKeepCasesSwitchChange(e: React.ChangeEvent) { + setCasesFilteringOptions((prev) => ({ ...prev, keep: e.target.checked })); + } + + function handleCasesByMeChange(e: React.ChangeEvent) { + setCasesFilteringOptions((prev) => ({ ...prev, onlyMyCases: e.target.checked })); + } + + function filterCases(cases: CaseInfo_api[] | undefined): CaseInfo_api[] | undefined { + if (!cases) { + return cases; + } + let filteredCases = cases; + if (casesFilteringOptions.keep) { + filteredCases = filteredCases.filter((c) => c.status === "keep"); + } + if (casesFilteringOptions.onlyMyCases) { + filteredCases = filteredCases.filter((c) => c.user === "me"); + } + return filteredCases; + } + const fieldOpts = fieldsQuery.data?.map((f) => ({ value: f.field_identifier, label: f.field_identifier })) ?? []; - const caseOpts = casesQuery.data?.map((c) => ({ value: c.uuid, label: c.name })) ?? []; + const caseOpts = filterCases(casesQuery.data)?.map((c) => ({ value: c.uuid, label: c.name, icon: })) ?? []; const ensembleOpts = ensemblesQuery.data?.map((e) => ({ value: e.name, label: `${e.name} (${e.realization_count} reals)` })) ?? []; @@ -198,6 +233,10 @@ export const SelectEnsemblesDialog: React.FC = (prop errorComponent={
Error loading cases
} loadingComponent={} > +
+ + +