diff --git a/backend/api/issues/resources.py b/backend/api/issues/resources.py index 737f4f84ed..d392c7d154 100644 --- a/backend/api/issues/resources.py +++ b/backend/api/issues/resources.py @@ -7,7 +7,7 @@ from backend.models.dtos.mapping_issues_dto import MappingIssueCategoryDTO from backend.models.dtos.user_dto import AuthUserDTO from backend.services.mapping_issues_service import MappingIssueCategoryService -from backend.services.users.authentication_service import pm_only +from backend.services.users.authentication_service import admin_only router = APIRouter( prefix="/tasks", @@ -52,7 +52,7 @@ async def get_issue(category_id: int, db: Database = Depends(get_db)): async def patch_issue( request: Request, category_id: int, - user: AuthUserDTO = Depends(pm_only), + user: AuthUserDTO = Depends(admin_only), db: Database = Depends(get_db), data: MappingIssueCategoryDTO = Body(...), ): @@ -121,7 +121,7 @@ async def patch_issue( async def delete_issue( request: Request, category_id: int, - user: AuthUserDTO = Depends(pm_only), + user: AuthUserDTO = Depends(admin_only), db: Database = Depends(get_db), ): """ @@ -200,7 +200,7 @@ async def get_issues_categories( @router.post("/issues/categories/", response_model=MappingIssueCategoryDTO) async def post_issues_categories( request: Request, - user: AuthUserDTO = Depends(pm_only), + user: AuthUserDTO = Depends(admin_only), db: Database = Depends(get_db), data: dict = Body(...), ): diff --git a/backend/api/licenses/resources.py b/backend/api/licenses/resources.py index 429bb1ad86..8cf277a2a1 100644 --- a/backend/api/licenses/resources.py +++ b/backend/api/licenses/resources.py @@ -6,7 +6,7 @@ from backend.models.dtos.licenses_dto import LicenseDTO from backend.models.dtos.user_dto import AuthUserDTO from backend.services.license_service import LicenseService -from backend.services.users.authentication_service import pm_only +from backend.services.users.authentication_service import admin_only router = APIRouter( prefix="/licenses", @@ -19,7 +19,7 @@ async def post_license( license_dto: LicenseDTO, db: Database = Depends(get_db), - user: AuthUserDTO = Depends(pm_only), + user: AuthUserDTO = Depends(admin_only), ): """ Creates a new mapping license @@ -100,7 +100,7 @@ async def patch_license( license_dto: LicenseDTO, license_id: int, db: Database = Depends(get_db), - user: AuthUserDTO = Depends(pm_only), + user: AuthUserDTO = Depends(admin_only), ): """ Update a specified mapping license @@ -155,7 +155,7 @@ async def patch_license( async def delete_license( license_id: int, db: Database = Depends(get_db), - user: AuthUserDTO = Depends(pm_only), + user: AuthUserDTO = Depends(admin_only), ): """ Delete a specified mapping license diff --git a/backend/api/mapping_badges/resources.py b/backend/api/mapping_badges/resources.py index 580454ffe9..5243646694 100644 --- a/backend/api/mapping_badges/resources.py +++ b/backend/api/mapping_badges/resources.py @@ -11,7 +11,7 @@ ) from backend.models.dtos.user_dto import AuthUserDTO from backend.services.mapping_badges import MappingBadgeService -from backend.services.users.authentication_service import pm_only +from backend.services.users.authentication_service import admin_only router = APIRouter( prefix="/badges", @@ -39,7 +39,7 @@ async def get_mapping_badges( async def create_mapping_badge( data: MappingBadgeCreateDTO, db: Database = Depends(get_db), - user: AuthUserDTO = Depends(pm_only), + user: AuthUserDTO = Depends(admin_only), ) -> MappingBadgeDTO: """ Creates a new MappingBadge @@ -73,7 +73,7 @@ async def update_mapping_badge( data: MappingBadgeUpdateDTO, badge_id: int, db: Database = Depends(get_db), - user: AuthUserDTO = Depends(pm_only), + user: AuthUserDTO = Depends(admin_only), ) -> MappingBadgeDTO: """ Updates a mapping badge @@ -92,7 +92,7 @@ async def update_mapping_badge( async def delete_mapping_badge( badge_id: int, db: Database = Depends(get_db), - user: AuthUserDTO = Depends(pm_only), + user: AuthUserDTO = Depends(admin_only), ): """ Deletes a mapping badge diff --git a/backend/api/mapping_levels/resources.py b/backend/api/mapping_levels/resources.py index 569e6f8164..2ede8009bf 100644 --- a/backend/api/mapping_levels/resources.py +++ b/backend/api/mapping_levels/resources.py @@ -8,7 +8,7 @@ ) from backend.models.dtos.user_dto import AuthUserDTO from backend.services.mapping_levels import MappingLevelService -from backend.services.users.authentication_service import pm_only +from backend.services.users.authentication_service import admin_only router = APIRouter( prefix="/levels", @@ -36,7 +36,7 @@ async def get_mapping_levels( async def create_mapping_level( data: MappingLevelCreateDTO, db: Database = Depends(get_db), - user: AuthUserDTO = Depends(pm_only), + user: AuthUserDTO = Depends(admin_only), ): """ Create a new mapping level @@ -70,7 +70,7 @@ async def update_mapping_level( data: MappingLevelUpdateDTO, level_id: int, db: Database = Depends(get_db), - user: AuthUserDTO = Depends(pm_only), + user: AuthUserDTO = Depends(admin_only), ): """ Update a given mapping level @@ -89,7 +89,7 @@ async def update_mapping_level( async def delete_mapping_level( level_id: int, db: Database = Depends(get_db), - user: AuthUserDTO = Depends(pm_only), + user: AuthUserDTO = Depends(admin_only), ): """ Delete the specified mapping level diff --git a/backend/api/users/actions.py b/backend/api/users/actions.py index 85ba80b5ba..3c1f23a753 100644 --- a/backend/api/users/actions.py +++ b/backend/api/users/actions.py @@ -7,7 +7,7 @@ from backend.models.dtos.user_dto import AuthUserDTO, UserDTO, UserRegisterEmailDTO from backend.services.interests_service import InterestService from backend.services.messaging.message_service import MessageService -from backend.services.users.authentication_service import login_required, pm_only +from backend.services.users.authentication_service import login_required, admin_only from backend.services.users.user_service import UserService, UserServiceError router = APIRouter( @@ -117,7 +117,7 @@ async def set_mapping_level( request: Request, username, level, - user: AuthUserDTO = Depends(pm_only), + user: AuthUserDTO = Depends(admin_only), db: Database = Depends(get_db), ): """ @@ -173,7 +173,7 @@ async def set_user_role( request: Request, username: str, role: str, - user: AuthUserDTO = Depends(pm_only), + user: AuthUserDTO = Depends(admin_only), db: Database = Depends(get_db), ): """ @@ -228,7 +228,7 @@ async def set_user_role( async def update_stats( request: Request, username: str, - _: AuthUserDTO = Depends(pm_only), + _: AuthUserDTO = Depends(admin_only), db: Database = Depends(get_db), ): """ @@ -255,7 +255,7 @@ async def update_stats( async def approve_level( request: Request, username: str, - voter: AuthUserDTO = Depends(pm_only), + voter: AuthUserDTO = Depends(admin_only), db: Database = Depends(get_db), ): """ @@ -282,7 +282,7 @@ async def set_user_is_expert( request: Request, user_name, is_expert, - user: AuthUserDTO = Depends(pm_only), + user: AuthUserDTO = Depends(admin_only), db: Database = Depends(get_db), ): """ diff --git a/backend/main.py b/backend/main.py index 70e7f837d2..29b10d91c1 100644 --- a/backend/main.py +++ b/backend/main.py @@ -10,7 +10,6 @@ from pyinstrument import Profiler from sentry_sdk.integrations.asgi import SentryAsgiMiddleware from starlette.middleware.authentication import AuthenticationMiddleware - from backend.config import settings from backend.db import db_connection from backend.exceptions import BadRequest, Conflict, Forbidden, NotFound, Unauthorized @@ -55,16 +54,21 @@ async def lifespan(app): # Custom exception handler for invalid token and logout. @_app.exception_handler(HTTPException) async def custom_http_exception_handler(request: Request, exc: HTTPException): - if exc.status_code == 401 and "InvalidToken" in exc.detail.get("SubCode", ""): - return JSONResponse( - content={ - "Error": exc.detail["Error"], - "SubCode": exc.detail["SubCode"], - }, - status_code=exc.status_code, - headers={"WWW-Authenticate": "Bearer"}, - ) - + try: + if exc.status_code == 401 and "InvalidToken" in exc.detail.get( + "SubCode", "" + ): + return JSONResponse( + content={ + "Error": exc.detail["Error"], + "SubCode": exc.detail["SubCode"], + }, + status_code=exc.status_code, + headers={"WWW-Authenticate": "Bearer"}, + ) + except Exception as e: + logging.debug(f"Exception while handling custom HTTPException: {e}") + pass if isinstance(exc.detail, dict) and "error" in exc.detail: error_response = exc.detail else: diff --git a/backend/models/postgis/project.py b/backend/models/postgis/project.py index 26d56b7258..2fda8b6e97 100644 --- a/backend/models/postgis/project.py +++ b/backend/models/postgis/project.py @@ -29,6 +29,7 @@ orm, select, update, + UniqueConstraint, ) from sqlalchemy.dialects.postgresql import ARRAY from sqlalchemy.ext.hybrid import hybrid_property @@ -96,10 +97,16 @@ class ProjectTeams(Base): __tablename__ = "project_teams" - team_id = Column(Integer, ForeignKey("teams.id"), primary_key=True) - project_id = Column(Integer, ForeignKey("projects.id"), primary_key=True) + + id = Column(BigInteger, primary_key=True, autoincrement=True) + team_id = Column(Integer, ForeignKey("teams.id"), nullable=False) + project_id = Column(Integer, ForeignKey("projects.id"), nullable=False) role = Column(Integer, nullable=False) + __table_args__ = ( + UniqueConstraint("team_id", "project_id", "role", name="uq_project_team_role"), + ) + project = relationship( "Project", backref=backref("teams", cascade="all, delete-orphan") ) diff --git a/backend/models/postgis/task.py b/backend/models/postgis/task.py index 4e2d9b974f..2af49565a8 100644 --- a/backend/models/postgis/task.py +++ b/backend/models/postgis/task.py @@ -1396,7 +1396,8 @@ async def get_tasks_as_geojson_feature_collection( t.task_status, ST_AsGeoJSON(t.geometry) AS geojson, t.locked_by, - t.mapped_by + t.mapped_by, + t.validated_by FROM tasks t WHERE t.project_id = :project_id """ @@ -1449,6 +1450,7 @@ async def get_tasks_as_geojson_feature_collection( taskStatus=TaskStatus(row["task_status"]).name, lockedBy=row["locked_by"], mappedBy=row["mapped_by"], + validatedBy=row["validated_by"], ) feature = geojson.Feature( geometry=task_geometry, properties=task_properties diff --git a/backend/services/users/authentication_service.py b/backend/services/users/authentication_service.py index ee6d71b53a..714ce57561 100644 --- a/backend/services/users/authentication_service.py +++ b/backend/services/users/authentication_service.py @@ -70,18 +70,18 @@ def verify_token(token): class TokenAuthBackend(AuthenticationBackend): async def authenticate(self, conn): if "authorization" not in conn.headers: - return + return None auth = conn.headers["authorization"] try: scheme, credentials = auth.split() if scheme.lower() != "token": - return + return None try: decoded_token = base64.b64decode(credentials).decode("ascii") except UnicodeDecodeError: logger.debug("Unable to decode token") - return False + return None except (ValueError, UnicodeDecodeError, binascii.Error): raise AuthenticationError("Invalid auth credentials") @@ -90,7 +90,7 @@ async def authenticate(self, conn): ) if not valid_token: logger.debug("Token not valid.") - return + return None tm.authenticated_user_id = user_id return AuthCredentials(["authenticated"]), SimpleUser(user_id) @@ -251,7 +251,6 @@ async def login_required( raise AuthenticationError("Invalid auth credentials") valid_token, user_id = AuthenticationService.is_valid_token(decoded_token, 604800) if not valid_token: - logger.debug("Token not valid") raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail={"Error": "Token is expired or invalid", "SubCode": "InvalidToken"}, @@ -275,17 +274,23 @@ async def login_required_optional( decoded_token = base64.b64decode(credentials).decode("ascii") except UnicodeDecodeError: logger.debug("Unable to decode token") - raise HTTPException(status_code=401, detail="Invalid token") + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail={ + "Error": "Token is expired or invalid", + "SubCode": "InvalidToken", + }, + headers={"WWW-Authenticate": "Bearer"}, + ) except (ValueError, UnicodeDecodeError, binascii.Error): raise AuthenticationError("Invalid auth credentials") valid_token, user_id = AuthenticationService.is_valid_token(decoded_token, 604800) if not valid_token: - logger.debug("Token not valid") return None return AuthUserDTO(id=user_id) -async def pm_only( +async def admin_only( Authorization: str = Security(APIKeyHeader(name="Authorization")), db: Database = Depends(get_db), ): diff --git a/frontend/src/components/comments/commentInput.js b/frontend/src/components/comments/commentInput.js index 20f6826123..e567c6859d 100644 --- a/frontend/src/components/comments/commentInput.js +++ b/frontend/src/components/comments/commentInput.js @@ -1,4 +1,4 @@ -import { useRef, useEffect, useState } from 'react'; +import { useRef, useEffect, useState, useCallback } from 'react'; import { useSelector } from 'react-redux'; import MDEditor from '@uiw/react-md-editor'; import Tribute from 'tributejs'; @@ -21,6 +21,7 @@ import { CurrentUserAvatar } from '../user/avatar'; const maxFileSize = 1 * 1024 * 1024; // 1MB function CommentInputField({ + sessionkey, comment, setComment, contributors, @@ -130,6 +131,23 @@ function CommentInputField({ }); }; + useEffect(() => { + if (!sessionkey) return; + const commenEvent = sessionStorage.getItem(sessionkey); + if (commenEvent) { + setComment(commenEvent); + } + }, [sessionkey, setComment]); + + const onCommentChange = useCallback( + (e) => { + setComment(e); + if (!sessionkey) return; + sessionStorage.setItem(sessionkey, e); + }, + [sessionkey, setComment], + ); + return (
{isShowTabNavs && ( @@ -165,7 +183,7 @@ function CommentInputField({ extraCommands={[]} height={200} value={comment} - onChange={setComment} + onChange={onCommentChange} textareaProps={{ ...getInputProps(), spellCheck: 'true', diff --git a/frontend/src/components/partners/partnersActivity.js b/frontend/src/components/partners/partnersActivity.js index 4399396c56..9a2359b9aa 100644 --- a/frontend/src/components/partners/partnersActivity.js +++ b/frontend/src/components/partners/partnersActivity.js @@ -6,7 +6,7 @@ import PartnersProgresBar from './partnersProgresBar'; import messages from './messages'; import { OHSOME_STATS_API_URL } from '../../config'; -export const Activity = ({ partner }) => { +export const Activity = ({ partner }: Object) => { const [data, setData] = useState(null); const fetchData = async () => { @@ -17,10 +17,12 @@ export const Activity = ({ partner }) => { } primaryHashtag = primaryHashtag.toLowerCase(); - const secondaryHashtags = partner.secondary_hashtag - ?.split(',') - ?.map((tag) => tag.trim().replace('#', '').toLowerCase()) - ?.join(','); + const secondaryHashtags = + partner.secondary_hashtag + ?.split(',') + ?.map((tag) => tag.trim().replace('#', '').toLowerCase()) + ?.join(',') || ''; + const response = await fetch( OHSOME_STATS_API_URL + `/stats/hashtags/${primaryHashtag},${secondaryHashtags}`, ); diff --git a/frontend/src/components/projectDetail/questionsAndComments.js b/frontend/src/components/projectDetail/questionsAndComments.js index 4fb083ef64..d2a00ed2ed 100644 --- a/frontend/src/components/projectDetail/questionsAndComments.js +++ b/frontend/src/components/projectDetail/questionsAndComments.js @@ -26,12 +26,14 @@ export const PostProjectComment = ({ projectId, refetchComments, contributors }) const token = useSelector((state) => state.auth.token); const locale = useSelector((state) => state.preferences['locale']); const [comment, setComment] = useState(''); + const SESSION_KEY = 'project-comment'; const mutation = useMutation({ mutationFn: () => postProjectComment(projectId, comment, token, locale), onSuccess: () => { refetchComments(); setComment(''); + sessionStorage.clear(SESSION_KEY); }, }); @@ -44,6 +46,7 @@ export const PostProjectComment = ({ projectId, refetchComments, contributors })
}> { ); const { data: teamsData, isFetching: isTeamsLoading } = useTeamsQuery({ omitMemberList: true }); - const teamRoles = [ - { value: 'MAPPER', label: 'Mapper' }, - { value: 'VALIDATOR', label: 'Validator' }, - { value: 'PROJECT_MANAGER', label: 'Project Manager' }, - ]; + const teamRoles = useMemo( + () => [ + { value: 'MAPPER', label: 'Mapper' }, + { value: 'VALIDATOR', label: 'Validator' }, + { value: 'PROJECT_MANAGER', label: 'Project Manager' }, + ], + [], + ); const getLabel = (value) => { return teamRoles.filter((r) => r.value === value)[0].label; }; - const editTeam = (id) => { - const team = projectInfo.teams.filter((t) => t.teamId === id)[0]; + const editTeam = (teamId, roleValue) => { + const team = projectInfo.teams.filter((t) => t.teamId === teamId && t.role === roleValue)[0]; const role = teamRoles.filter((r) => team.role === r.value)[0]; setTeamSelect((t) => { @@ -46,8 +49,8 @@ export const TeamSelect = () => { }); }; - const removeTeam = (id) => { - const teams = projectInfo.teams.filter((t) => t.teamId !== id); + const removeTeam = (teamId, roleValue) => { + const teams = projectInfo.teams.filter((t) => !(t.teamId === teamId && t.role === roleValue)); setProjectInfo({ ...projectInfo, teams: teams }); }; @@ -69,7 +72,7 @@ export const TeamSelect = () => { const updateTeam = () => { const teams = projectInfo.teams.map((t) => { let item = t; - if (t.teamId === teamSelect.team.teamId) { + if (t.teamId === teamSelect.team.teamId && t.role === teamSelect.team.role) { item = newTeam(); } return item; @@ -99,6 +102,16 @@ export const TeamSelect = () => { ]; } + // Filter out assigned roles and display only remaining roles in options + const roleList = useCallback(() => { + const existingRolesOfSelectdTeam = projectInfo.teams.reduce( + (prev, curr) => (curr.teamId === teamSelect.team?.teamId ? [...prev, curr.role] : prev), + [], + ); + + return teamRoles.filter((role) => !existingRolesOfSelectdTeam.includes(role.value)); + }, [projectInfo.teams, teamRoles, teamSelect]); + return (
@@ -117,12 +130,15 @@ export const TeamSelect = () => {
{getLabel(t.role)}
- editTeam(t.teamId)}> + editTeam(t.teamId, t.role)} + > removeTeam(t.teamId)} + onClick={() => removeTeam(t.teamId, t.role)} > @@ -161,7 +177,7 @@ export const TeamSelect = () => { classNamePrefix="react-select" getOptionLabel={(option) => option.label} getOptionValue={(option) => option.value} - options={teamRoles} + options={roleList()} onChange={(value) => handleSelect(value, 'role')} className="w-40 fl mr2 z-3" isDisabled={teamSelect.team.name === null ? true : false} diff --git a/frontend/src/hooks/UsePermissions.js b/frontend/src/hooks/UsePermissions.js index 53ae3d5cc4..adb376bab9 100644 --- a/frontend/src/hooks/UsePermissions.js +++ b/frontend/src/hooks/UsePermissions.js @@ -29,7 +29,7 @@ export function useEditProjectAllowed(project) { export function useEditTeamAllowed(team) { const userDetails = useSelector((state) => state.auth.userDetails); const organisations = useSelector((state) => state.auth.organisations); - const pmTeams = useSelector((state) => state.auth.pmTeams); + const tmTeams = useSelector((state) => state.auth.tmTeams); const [isAllowed, setIsAllowed] = useState(false); useEffect(() => { @@ -39,9 +39,11 @@ export function useEditTeamAllowed(team) { if (organisations && organisations.includes(team.organisation_id)) setIsAllowed(true); // team managers can edit it // verify from the redux store - if (pmTeams && pmTeams.includes(team.teamId)) setIsAllowed(true); - // and verify based on the team members list + // removed pm and use tm list + if (tmTeams && tmTeams?.includes(team?.teamId)) setIsAllowed(true); + if (team.members) { + // and verify based on the team members list const managers = team.members .filter((member) => member.active && member.function === 'MANAGER') .map((member) => member.username); @@ -49,7 +51,7 @@ export function useEditTeamAllowed(team) { setIsAllowed(true); } } - }, [pmTeams, userDetails.role, userDetails.username, organisations, team]); + }, [userDetails.role, userDetails.username, organisations, team, tmTeams]); return [isAllowed]; } diff --git a/frontend/src/hooks/tests/UseEditTeamPermissions.test.js b/frontend/src/hooks/tests/UseEditTeamPermissions.test.js index d003846f39..29a42fc316 100644 --- a/frontend/src/hooks/tests/UseEditTeamPermissions.test.js +++ b/frontend/src/hooks/tests/UseEditTeamPermissions.test.js @@ -37,7 +37,7 @@ describe('test edit team permissions based on manager permissions', () => { }; it('team manager CAN edit it - verify based on redux store', () => { act(() => { - store.dispatch({ type: 'SET_PM_TEAMS', teams: [1, 2, 3] }); + store.dispatch({ type: 'SET_TM_TEAMS', teams: [1, 2, 3] }); }); const wrapper = ({ children }) => {children}; const { result } = renderHook(() => useEditTeamAllowed(team), { wrapper }); @@ -48,7 +48,7 @@ describe('test edit team permissions based on manager permissions', () => { it('team manager CAN edit it - verify based on team members', () => { const userDetails = { username: 'test', role: 'MAPPER' }; act(() => { - store.dispatch({ type: 'SET_PM_TEAMS', teams: [] }); + store.dispatch({ type: 'SET_TM_TEAMS', teams: [] }); store.dispatch({ type: 'SET_USER_DETAILS', userDetails: userDetails }); }); const wrapper = ({ children }) => {children}; @@ -60,7 +60,7 @@ describe('test edit team permissions based on manager permissions', () => { it('MAPPER can not edit it - verify based on team members', () => { const userDetails = { username: 'another_user', role: 'MAPPER' }; act(() => { - store.dispatch({ type: 'SET_PM_TEAMS', teams: [] }); + store.dispatch({ type: 'SET_TM_TEAMS', teams: [] }); store.dispatch({ type: 'SET_USER_DETAILS', userDetails: userDetails }); }); const wrapper = ({ children }) => {children}; @@ -71,7 +71,7 @@ describe('test edit team permissions based on manager permissions', () => { it('user that is NOT a team manager can not edit it', () => { act(() => { - store.dispatch({ type: 'SET_PM_TEAMS', teams: [2, 3] }); + store.dispatch({ type: 'SET_TM_TEAMS', teams: [2, 3] }); }); const wrapper = ({ children }) => {children}; const { result } = renderHook(() => useEditTeamAllowed(team), { wrapper }); diff --git a/frontend/src/store/actions/auth.js b/frontend/src/store/actions/auth.js index ef38b19b94..bac4d9d691 100644 --- a/frontend/src/store/actions/auth.js +++ b/frontend/src/store/actions/auth.js @@ -8,6 +8,7 @@ export const types = { SET_OSM: 'SET_OSM', SET_ORGANISATIONS: 'SET_ORGANISATIONS', SET_PM_TEAMS: 'SET_PM_TEAMS', + SET_TM_TEAMS: 'SET_TM_TEAMS', UPDATE_OSM_INFO: 'UPDATE_OSM_INFO', GET_USER_DETAILS: 'GET_USER_DETAILS', SET_TOKEN: 'SET_TOKEN', @@ -75,6 +76,13 @@ export function updatePMsTeams(teams) { }; } +export function updateTMsTeams(teams) { + return { + type: types.SET_TM_TEAMS, + teams: teams, + }; +} + export function updateToken(token) { return { type: types.SET_TOKEN, @@ -106,7 +114,7 @@ export const setAuthDetails = (username, token, osm_oauth_token) => (dispatch) = // UPDATES OSM INFORMATION OF THE USER export const setUserDetails = (username, encodedToken, update = false) => - (dispatch) => { + async (dispatch) => { // only trigger the loader if this function is not being triggered to update the user information if (!update) dispatch(setLoader(true)); fetchLocalJSONAPI(`users/${username}/openstreetmap/`, encodedToken) @@ -115,31 +123,44 @@ export const setUserDetails = console.log(error); dispatch(setLoader(false)); }); - // GET USER DETAILS - fetchLocalJSONAPI(`users/queries/${username}/`, encodedToken) - .then((userDetails) => { - dispatch(updateUserDetails(userDetails)); - // GET USER ORGS INFO - fetchLocalJSONAPI( - `organisations/?omitManagerList=true&manager_user_id=${userDetails.id}`, - encodedToken, - ) - .then((orgs) => - dispatch(updateOrgsInfo(orgs.organisations.map((org) => org.organisationId))), - ) - .catch((error) => dispatch(updateOrgsInfo([]))); - fetchLocalJSONAPI( - `teams/?omitMemberList=true&team_role=PROJECT_MANAGER&member=${userDetails.id}`, - encodedToken, - ) - .then((teams) => dispatch(updatePMsTeams(teams.teams.map((team) => team.teamId)))) - .catch((error) => dispatch(updatePMsTeams([]))); - dispatch(setLoader(false)); - }) - .catch((error) => { - if (error.message === 'InvalidToken') dispatch(logout()); - dispatch(setLoader(false)); - }); + + try { + const userDetails = await fetchLocalJSONAPI(`users/queries/${username}/`, encodedToken); + + dispatch(updateUserDetails(userDetails)); + + const userId = userDetails.id; + + const orgsPromise = fetchLocalJSONAPI( + `organisations/?omitManagerList=true&manager_user_id=${userId}`, + encodedToken, + ) + .then((orgs) => dispatch(updateOrgsInfo(orgs.organisations.map((o) => o.organisationId)))) + .catch(() => dispatch(updateOrgsInfo([]))); + + const pmsTeamsPromise = fetchLocalJSONAPI( + `teams/?omitMemberList=true&team_role=PROJECT_MANAGER&member=${userId}`, + encodedToken, + ) + .then((teams) => dispatch(updatePMsTeams(teams.teams.map((t) => t.teamId)))) + .catch(() => dispatch(updatePMsTeams([]))); + + const tmsTeamsPromise = fetchLocalJSONAPI( + `teams/?fullMemberList=false&manager=${userId}`, + encodedToken, + ) + .then((teams) => dispatch(updateTMsTeams(teams.teams.map((t) => t.teamId)))) + .catch(() => dispatch(updateTMsTeams([]))); + + // Run all parallel requests at once + await Promise.all([orgsPromise, pmsTeamsPromise, tmsTeamsPromise]); + } catch (error) { + if (error.message === 'InvalidToken') { + dispatch(logout()); + } + } finally { + dispatch(setLoader(false)); + } }; export const getUserDetails = (state) => (dispatch) => { diff --git a/frontend/src/store/reducers/auth.js b/frontend/src/store/reducers/auth.js index 30656f6b40..0765dc56c3 100644 --- a/frontend/src/store/reducers/auth.js +++ b/frontend/src/store/reducers/auth.js @@ -23,6 +23,9 @@ export function authorizationReducer(state = initialState, action) { case types.SET_PM_TEAMS: { return { ...state, pmTeams: action.teams }; } + case types.SET_TM_TEAMS: { + return { ...state, tmTeams: action.teams }; + } case types.SET_TOKEN: { return { ...state, token: action.token }; } diff --git a/frontend/src/views/management.js b/frontend/src/views/management.js index 94fc4c3e84..a8a46e52e3 100644 --- a/frontend/src/views/management.js +++ b/frontend/src/views/management.js @@ -87,8 +87,12 @@ export const ManagementSection = (props) => { location.pathname === '/manage', [location.pathname], ); - // access this page from here and restrictd on the page itslf if it has no edit access - const isProjectEditRoute = location.pathname.startsWith('/manage/projects') && id; + + // access this page from here and restricted on the page itself if it has no edit access + const isProjectEditRoute = + (location.pathname.startsWith('/manage/projects') || + location.pathname.startsWith('/manage/teams')) && + id; return ( <> diff --git a/migrations/versions/763165f937cf_.py b/migrations/versions/763165f937cf_.py new file mode 100644 index 0000000000..2cb13d06a6 --- /dev/null +++ b/migrations/versions/763165f937cf_.py @@ -0,0 +1,54 @@ +""" + +Revision ID: 763165f937cf +Revises: 4489b9e235f8 +Create Date: 2025-11-20 12:09:24.690604 + +""" + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = "763165f937cf" +down_revision = "4489b9e235f8" +branch_labels = None +depends_on = None + + +def upgrade(): + op.execute("CREATE SEQUENCE IF NOT EXISTS project_teams_id_seq;") + op.add_column( + "project_teams", + sa.Column( + "id", + sa.BigInteger(), + nullable=True, + server_default=sa.text("nextval('project_teams_id_seq'::regclass)"), + ), + ) + op.execute( + "UPDATE project_teams SET id = nextval('project_teams_id_seq') WHERE id IS NULL;" + ) + op.alter_column("project_teams", "id", nullable=False) + op.execute("ALTER SEQUENCE project_teams_id_seq OWNED BY project_teams.id;") + op.create_unique_constraint( + "uq_project_team_role", "project_teams", ["team_id", "project_id", "role"] + ) + op.drop_constraint("project_teams_pkey", "project_teams", type_="primary") + op.create_primary_key("project_teams_pkey", "project_teams", ["id"]) + op.create_index("ix_project_teams_project_id", "project_teams", ["project_id"]) + op.create_index("ix_project_teams_team_id", "project_teams", ["team_id"]) + + +def downgrade(): + op.drop_constraint("project_teams_pkey", "project_teams", type_="primary") + op.drop_index("ix_project_teams_project_id", table_name="project_teams") + op.drop_index("ix_project_teams_team_id", table_name="project_teams") + op.create_primary_key( + "project_teams_pkey", "project_teams", ["team_id", "project_id"] + ) + op.drop_constraint("uq_project_team_role", "project_teams", type_="unique") + op.drop_column("project_teams", "id") + op.execute("DROP SEQUENCE IF EXISTS project_teams_id_seq;")