Skip to content

Commit

Permalink
ID-1353 Support Favorite Resources in Sam (#1509)
Browse files Browse the repository at this point in the history
* ID-1353 Support Favorite Resources in Sam

* return favorites in combined user response
  • Loading branch information
tlangs authored Aug 13, 2024
1 parent 55fe2d7 commit e2cea3f
Show file tree
Hide file tree
Showing 15 changed files with 716 additions and 14 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -31,4 +31,5 @@
<include file="changesets/20240417_action_managed_identities.xml" relativeToChangelogFile="true"/>
<include file="changesets/20240416_add_sam_rac_tables.xml" relativeToChangelogFile="true"/>
<include file="changesets/20240701_add_group_version_and_last_synchronized_version.xml" relativeToChangelogFile="true"/>
<include file="changesets/20240809_sam_user_favorite_resources_table.xml" relativeToChangelogFile="true"/>
</databaseChangeLog>
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<databaseChangeLog logicalFilePath="dummy"
xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:ext="http://www.liquibase.org/xml/ns/dbchangelog-ext"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog-ext http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-ext.xsd http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-3.4.xsd">

<changeSet logicalFilePath="dummy" author="tlangs" id="add_sam_user_favorite_resources_table">
<createTable tableName="SAM_USER_FAVORITE_RESOURCES">
<column name="sam_user_id" type="VARCHAR">
<constraints primaryKey="true" foreignKeyName="FK_SUFR_USER_ID" referencedTableName="SAM_USER" referencedColumnNames="id" deleteCascade="true"/>
</column>
<column name="resource_id" type="BIGINT">
<constraints nullable="false" primaryKey="true" foreignKeyName="FK_SUFR_RESOURCE" referencedTableName="SAM_RESOURCE" referencedColumnNames="id" deleteCascade="true"/>
</column>
<column name="created_at" type="timestamptz" defaultValueComputed="now()"/>
</createTable>

</changeSet>
</databaseChangeLog>
100 changes: 100 additions & 0 deletions src/main/resources/swagger/api-docs.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -3239,6 +3239,106 @@ paths:
application/json:
schema:
$ref: '#/components/schemas/ErrorReport'
/api/users/v2/self/favoriteResources:
get:
tags:
- Users
summary: Gets a user's favorite resources.
operationId: getFavoriteResources
responses:
200:
description: list of the user's favorite resources
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/FullyQualifiedResourceId'
403:
description: user not found or is not allowed to use Terra
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorReport'
/api/users/v2/self/favoriteResources/{resourceTypeName}:
get:
tags:
- Users
summary: Gets a user's favorite resources of a given resource type.
operationId: getFavoriteResourcesOfType
parameters:
- name: resourceTypeName
in: path
description: Resource type to narrow favorite resources to
required: true
schema:
type: string
responses:
200:
description: list of the user's favorite resources of a specific resource type
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/FullyQualifiedResourceId'
404:
description: resource type not found
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorReport'
/api/users/v2/self/favoriteResources/{resourceTypeName}/{resourceId}:
put:
tags:
- Users
summary: Add a resource to the calling user's favorites
operationId: addFavoriteResource
parameters:
- name: resourceTypeName
in: path
description: Resource type of the resource to favorite
required: true
schema:
type: string
- name: resourceId
in: path
description: Resource ID of the resource to favorite
required: true
schema:
type: string
responses:
202:
description: Favorite resource added
content: { }
404:
description: resource not found
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorReport'
delete:
tags:
- Users
summary: Remove a resource from the calling user's favorites
operationId: removeFavoriteResource
parameters:
- name: resourceTypeName
in: path
description: Resource type of the resource to favorite
required: true
schema:
type: string
- name: resourceId
in: path
description: Resource ID of the resource to favorite
required: true
schema:
type: string
responses:
202:
description: Favorite resource removed
content: { }
/api/users/v2/{sam_user_id}:
get:
tags:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,25 @@ trait SecurityDirectives {
}
}

def requireAnyAction(
resource: FullyQualifiedResourceId,
userId: WorkbenchUserId,
samRequestContext: SamRequestContext
): Directive0 =
Directives.mapInnerRoute { innerRoute =>
onSuccess(policyEvaluatorService.listUserResourceActions(resource, userId, samRequestContext)) { actions =>
if (actions.nonEmpty) {
innerRoute
} else {
Directives.failWith(
new WorkbenchExceptionWithErrorReport(
ErrorReport(StatusCodes.Forbidden, s"You do not have access to ${resource.resourceTypeName.value}/${resource.resourceId.value}")
)
)
}
}
}

/** in the case where we don't have the required action, we need to figure out if we should return a Not Found (you have no access) vs a Forbidden (you have
* access, just not the right kind)
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,15 +8,17 @@ import akka.http.scaladsl.server.{Directive0, ExceptionHandler, Route}
import cats.effect.IO
import org.broadinstitute.dsde.workbench.model._
import org.broadinstitute.dsde.workbench.sam.model.api.SamUserResponse._
import org.broadinstitute.dsde.workbench.sam.model.api.SamJsonSupport._
import org.broadinstitute.dsde.workbench.sam.model.api._
import org.broadinstitute.dsde.workbench.sam.model.{ResourceRoleName, ResourceTypeName, TermsOfServiceDetails}
import org.broadinstitute.dsde.workbench.sam.model.{FullyQualifiedResourceId, ResourceId, ResourceRoleName, ResourceTypeName, TermsOfServiceDetails}
import org.broadinstitute.dsde.workbench.sam.service.{ResourceService, TosService, UserService}
import org.broadinstitute.dsde.workbench.sam.util.SamRequestContext
import spray.json.enrichAny
import spray.json.DefaultJsonProtocol._

/** Created by tlangs on 10/12/2023.
*/
trait UserRoutesV2 extends SamUserDirectives with SamRequestContextDirectives {
trait UserRoutesV2 extends SamUserDirectives with SamRequestContextDirectives with SamModelDirectives with SecurityDirectives {
val userService: UserService
val tosService: TosService
val resourceService: ResourceService
Expand Down Expand Up @@ -81,6 +83,27 @@ trait UserRoutesV2 extends SamUserDirectives with SamRequestContextDirectives {
getSamUserCombinedState(samUser, samRequestContext)
}
}
} ~
// api/user/v2/self/favoriteResources
pathPrefix("favoriteResources") {
withActiveUser(samRequestContextWithoutUser) { samUser: SamUser =>
val samRequestContext = samRequestContextWithoutUser.copy(samUser = Some(samUser))
pathEndOrSingleSlash {
getFavoriteResources(samUser, samRequestContext)
} ~
// api/user/v2/self/favoriteResources/{resourceTypeName}
pathPrefix(Segment) { resourceTypeName =>
withNonAdminResourceType(ResourceTypeName(resourceTypeName)) { resourceType =>
getFavoriteResourcesOfType(samUser, resourceType.name, samRequestContext) ~
// api/user/v2/self/favoriteResources/{resourceTypeName}/{resourceId}
pathPrefix(Segment) { resourceId =>
val resource = FullyQualifiedResourceId(resourceType.name, ResourceId(resourceId))
addFavoriteResource(samUser, resource, samRequestContext) ~
removeFavoriteResource(samUser, resource, samRequestContext)
}
}
}
}
}
} ~
pathPrefix(Segment) { samUserId =>
Expand Down Expand Up @@ -194,12 +217,14 @@ trait UserRoutesV2 extends SamUserDirectives with SamRequestContextDirectives {
includePublic = false,
samRequestContext
)
favoriteResources <- resourceService.getUserFavoriteResources(samUser.id, samRequestContext)
} yield SamUserCombinedStateResponse(
samUser,
allowances,
maybeAttributes,
termsOfServiceDetails.getOrElse(TermsOfServiceDetails(None, None, permitsSystemUsage = false, isCurrentVersion = false)),
Map("enterpriseFeatures" -> enterpriseFeatures.toJson)
Map("enterpriseFeatures" -> enterpriseFeatures.toJson),
favoriteResources
)
}
}
Expand All @@ -217,4 +242,39 @@ trait UserRoutesV2 extends SamUserDirectives with SamRequestContextDirectives {
}
}
}

private def getFavoriteResources(samUser: SamUser, samRequestContext: SamRequestContext): Route =
getWithTelemetry(samRequestContext) {
complete {
resourceService.getUserFavoriteResources(samUser.id, samRequestContext).map(StatusCodes.OK -> _)
}
}

private def getFavoriteResourcesOfType(samUser: SamUser, resourceTypeName: ResourceTypeName, samRequestContext: SamRequestContext): Route =
getWithTelemetry(samRequestContext) {
complete {
resourceService.getUserFavoriteResourcesOfType(samUser.id, resourceTypeName, samRequestContext).map(StatusCodes.OK -> _)
}
}

private def addFavoriteResource(samUser: SamUser, resource: FullyQualifiedResourceId, samRequestContext: SamRequestContext): Route =
putWithTelemetry(samRequestContext) {
changeForbiddenToNotFound {
requireAnyAction(resource, samUser.id, samRequestContext) {
complete {
resourceService.addUserFavoriteResource(samUser.id, resource, samRequestContext).map {
case true => StatusCodes.Accepted
case false => StatusCodes.NotFound
}
}
}
}
}

private def removeFavoriteResource(samUser: SamUser, resource: FullyQualifiedResourceId, samRequestContext: SamRequestContext): Route =
deleteWithTelemetry(samRequestContext) {
complete {
resourceService.removeUserFavoriteResource(samUser.id, resource, samRequestContext).map(_ => StatusCodes.Accepted)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import org.broadinstitute.dsde.workbench.sam.azure.{
PetManagedIdentityId
}
import org.broadinstitute.dsde.workbench.sam.model.api.{AdminUpdateUserRequest, SamUser, SamUserAttributes}
import org.broadinstitute.dsde.workbench.sam.model.{BasicWorkbenchGroup, FullyQualifiedResourceId, ResourceAction, SamUserTos}
import org.broadinstitute.dsde.workbench.sam.model.{BasicWorkbenchGroup, FullyQualifiedResourceId, ResourceAction, ResourceTypeName, SamUserTos}
import org.broadinstitute.dsde.workbench.sam.util.SamRequestContext

import java.time.Instant
Expand Down Expand Up @@ -178,4 +178,16 @@ trait DirectoryDAO {
def setUserAttributes(samUserAttributes: SamUserAttributes, samRequestContext: SamRequestContext): IO[Unit]

def listParentGroups(groupName: WorkbenchGroupName, samRequestContext: SamRequestContext): IO[Set[WorkbenchGroupName]]

def addUserFavoriteResource(userId: WorkbenchUserId, resourceId: FullyQualifiedResourceId, samRequestContext: SamRequestContext): IO[Boolean]

def removeUserFavoriteResource(userId: WorkbenchUserId, resourceId: FullyQualifiedResourceId, samRequestContext: SamRequestContext): IO[Unit]

def getUserFavoriteResources(userId: WorkbenchUserId, samRequestContext: SamRequestContext): IO[Set[FullyQualifiedResourceId]]

def getUserFavoriteResourcesOfType(
userId: WorkbenchUserId,
resourceTypeName: ResourceTypeName,
samRequestContext: SamRequestContext
): IO[Set[FullyQualifiedResourceId]]
}
Original file line number Diff line number Diff line change
Expand Up @@ -1378,4 +1378,91 @@ class PostgresDirectoryDAO(protected val writeDbRef: DbReference, protected val

loadParentGroupsQuery.map(rs => WorkbenchGroupName(rs.string(parent.resultName.name))).list().apply().toSet
}

override def addUserFavoriteResource(
userId: WorkbenchUserId,
fullyQualifiedResourceId: FullyQualifiedResourceId,
samRequestContext: SamRequestContext
): IO[Boolean] =
serializableWriteTransaction("addUserFavoriteResource", samRequestContext) { implicit session =>
val resourceTable = ResourceTable.syntax
val resourceTypeTable = ResourceTypeTable.syntax
val userFavoriteResourcesTable = UserFavoriteResourcesTable.syntax
val userFavoriteResourcesColumns = UserFavoriteResourcesTable.column
Try {
samsql"""
insert into ${UserFavoriteResourcesTable as userFavoriteResourcesTable} (${userFavoriteResourcesColumns.samUserId}, ${userFavoriteResourcesColumns.resourceId})
values (
$userId,
(select ${resourceTable.result.id} from ${ResourceTable as resourceTable} left join ${ResourceTypeTable as resourceTypeTable} on ${resourceTable.resourceTypeId} = ${resourceTypeTable.id} where ${resourceTable.name} = ${fullyQualifiedResourceId.resourceId} and ${resourceTypeTable.name} = ${fullyQualifiedResourceId.resourceTypeName})
)
on conflict do nothing
""".update().apply() > 0
}.getOrElse(false)
}

override def removeUserFavoriteResource(
userId: WorkbenchUserId,
fullyQualifiedResourceId: FullyQualifiedResourceId,
samRequestContext: SamRequestContext
): IO[Unit] =
serializableWriteTransaction("removeUserFavoriteResource", samRequestContext) { implicit session =>
val resourceTable = ResourceTable.syntax
val resourceTypeTable = ResourceTypeTable.syntax
val userFavoriteResourcesColumns = UserFavoriteResourcesTable.column
samsql"""
delete from ${UserFavoriteResourcesTable.table}
where ${userFavoriteResourcesColumns.samUserId} = $userId
and ${userFavoriteResourcesColumns.resourceId} = (select ${resourceTable.result.id} from ${ResourceTable as resourceTable} left join ${ResourceTypeTable as resourceTypeTable} on ${resourceTable.resourceTypeId} = ${resourceTypeTable.id} where ${resourceTable.name} = ${fullyQualifiedResourceId.resourceId} and ${resourceTypeTable.name} = ${fullyQualifiedResourceId.resourceTypeName})
""".update().apply()
}

override def getUserFavoriteResources(userId: WorkbenchUserId, samRequestContext: SamRequestContext): IO[Set[FullyQualifiedResourceId]] =
readOnlyTransaction("getUserFavoriteResources", samRequestContext) { implicit session =>
val resourceTable = ResourceTable.syntax
val resourceTypeTable = ResourceTypeTable.syntax
val userFavoriteResourcesTable = UserFavoriteResourcesTable.syntax

val loadUserFavoriteResourcesQuery =
samsql"""select ${resourceTable.result.name}, ${resourceTypeTable.result.name}
from ${ResourceTable as resourceTable}
join ${ResourceTypeTable as resourceTypeTable} on ${resourceTable.resourceTypeId} = ${resourceTypeTable.id}
join ${UserFavoriteResourcesTable as userFavoriteResourcesTable} on ${resourceTable.id} = ${userFavoriteResourcesTable.resourceId}
where ${userFavoriteResourcesTable.samUserId} = $userId"""

loadUserFavoriteResourcesQuery
.map(rs =>
FullyQualifiedResourceId(ResourceTypeName(rs.string(resourceTypeTable.resultName.name)), ResourceId(rs.string(resourceTable.resultName.name)))
)
.list()
.apply()
.toSet
}

def getUserFavoriteResourcesOfType(
userId: WorkbenchUserId,
resourceTypeName: ResourceTypeName,
samRequestContext: SamRequestContext
): IO[Set[FullyQualifiedResourceId]] =
readOnlyTransaction("getUserFavoriteResourcesOfType", samRequestContext) { implicit session =>
val resourceTable = ResourceTable.syntax
val resourceTypeTable = ResourceTypeTable.syntax
val userFavoriteResourcesTable = UserFavoriteResourcesTable.syntax

val loadUserFavoriteResourcesQuery =
samsql"""select ${resourceTable.result.name}
from ${ResourceTable as resourceTable}
join ${ResourceTypeTable as resourceTypeTable} on ${resourceTable.resourceTypeId} = ${resourceTypeTable.id}
join ${UserFavoriteResourcesTable as userFavoriteResourcesTable} on ${resourceTable.id} = ${userFavoriteResourcesTable.resourceId}
where ${userFavoriteResourcesTable.samUserId} = $userId
and ${resourceTypeTable.name} = $resourceTypeName
"""

loadUserFavoriteResourcesQuery
.map(rs => FullyQualifiedResourceId(resourceTypeName, ResourceId(rs.string(resourceTable.resultName.name))))
.list()
.apply()
.toSet
}

}
Loading

0 comments on commit e2cea3f

Please sign in to comment.