feat(posts): add posts feature to DataHub (#6110)

This commit is contained in:
Aditya Radhakrishnan 2022-10-04 15:37:28 -07:00 committed by GitHub
parent 3c0f63c50a
commit ee1a1ef45b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
29 changed files with 1246 additions and 7 deletions

View File

@ -72,7 +72,7 @@ play {
platform { platform {
playVersion = '2.7.6' playVersion = '2.7.6'
scalaVersion = '2.12' scalaVersion = '2.12'
javaVersion = JavaVersion.VERSION_1_8 javaVersion = JavaVersion.VERSION_11
} }
injectedRoutesGenerator = true injectedRoutesGenerator = true
@ -81,7 +81,7 @@ play {
model { model {
components { components {
play { play {
platform play: '2.7.6', scala: '2.12', java: '1.8' platform play: '2.7.6', scala: '2.12', java: '11'
injectedRoutesGenerator = true injectedRoutesGenerator = true
binaries.all { binaries.all {

View File

@ -3,6 +3,7 @@ package com.linkedin.datahub.graphql;
import com.datahub.authentication.AuthenticationConfiguration; import com.datahub.authentication.AuthenticationConfiguration;
import com.datahub.authentication.group.GroupService; import com.datahub.authentication.group.GroupService;
import com.datahub.authentication.invite.InviteTokenService; import com.datahub.authentication.invite.InviteTokenService;
import com.datahub.authentication.post.PostService;
import com.datahub.authentication.token.StatefulTokenService; import com.datahub.authentication.token.StatefulTokenService;
import com.datahub.authentication.user.NativeUserService; import com.datahub.authentication.user.NativeUserService;
import com.datahub.authorization.AuthorizationConfiguration; import com.datahub.authorization.AuthorizationConfiguration;
@ -175,6 +176,8 @@ import com.linkedin.datahub.graphql.resolvers.policy.DeletePolicyResolver;
import com.linkedin.datahub.graphql.resolvers.policy.GetGrantedPrivilegesResolver; import com.linkedin.datahub.graphql.resolvers.policy.GetGrantedPrivilegesResolver;
import com.linkedin.datahub.graphql.resolvers.policy.ListPoliciesResolver; import com.linkedin.datahub.graphql.resolvers.policy.ListPoliciesResolver;
import com.linkedin.datahub.graphql.resolvers.policy.UpsertPolicyResolver; import com.linkedin.datahub.graphql.resolvers.policy.UpsertPolicyResolver;
import com.linkedin.datahub.graphql.resolvers.post.CreatePostResolver;
import com.linkedin.datahub.graphql.resolvers.post.ListPostsResolver;
import com.linkedin.datahub.graphql.resolvers.recommendation.ListRecommendationsResolver; import com.linkedin.datahub.graphql.resolvers.recommendation.ListRecommendationsResolver;
import com.linkedin.datahub.graphql.resolvers.role.AcceptRoleResolver; import com.linkedin.datahub.graphql.resolvers.role.AcceptRoleResolver;
import com.linkedin.datahub.graphql.resolvers.role.BatchAssignRoleResolver; import com.linkedin.datahub.graphql.resolvers.role.BatchAssignRoleResolver;
@ -310,6 +313,7 @@ public class GmsGraphQLEngine {
private final GroupService groupService; private final GroupService groupService;
private final RoleService roleService; private final RoleService roleService;
private final InviteTokenService inviteTokenService; private final InviteTokenService inviteTokenService;
private final PostService postService;
private final FeatureFlags featureFlags; private final FeatureFlags featureFlags;
@ -386,7 +390,7 @@ public class GmsGraphQLEngine {
final VisualConfiguration visualConfiguration, final TelemetryConfiguration telemetryConfiguration, final VisualConfiguration visualConfiguration, final TelemetryConfiguration telemetryConfiguration,
final TestsConfiguration testsConfiguration, final DatahubConfiguration datahubConfiguration, final TestsConfiguration testsConfiguration, final DatahubConfiguration datahubConfiguration,
final SiblingGraphService siblingGraphService, final GroupService groupService, final RoleService roleService, final SiblingGraphService siblingGraphService, final GroupService groupService, final RoleService roleService,
final InviteTokenService inviteTokenService, final FeatureFlags featureFlags) { final InviteTokenService inviteTokenService, final PostService postService, final FeatureFlags featureFlags) {
this.entityClient = entityClient; this.entityClient = entityClient;
this.graphClient = graphClient; this.graphClient = graphClient;
@ -407,6 +411,7 @@ public class GmsGraphQLEngine {
this.groupService = groupService; this.groupService = groupService;
this.roleService = roleService; this.roleService = roleService;
this.inviteTokenService = inviteTokenService; this.inviteTokenService = inviteTokenService;
this.postService = postService;
this.ingestionConfiguration = Objects.requireNonNull(ingestionConfiguration); this.ingestionConfiguration = Objects.requireNonNull(ingestionConfiguration);
this.authenticationConfiguration = Objects.requireNonNull(authenticationConfiguration); this.authenticationConfiguration = Objects.requireNonNull(authenticationConfiguration);
@ -676,6 +681,7 @@ public class GmsGraphQLEngine {
.dataFetcher("entities", getEntitiesResolver()) .dataFetcher("entities", getEntitiesResolver())
.dataFetcher("listRoles", new ListRolesResolver(this.entityClient)) .dataFetcher("listRoles", new ListRolesResolver(this.entityClient))
.dataFetcher("getInviteToken", new GetInviteTokenResolver(this.inviteTokenService)) .dataFetcher("getInviteToken", new GetInviteTokenResolver(this.inviteTokenService))
.dataFetcher("listPosts", new ListPostsResolver(this.entityClient))
); );
} }
@ -798,7 +804,7 @@ public class GmsGraphQLEngine {
.dataFetcher("batchAssignRole", new BatchAssignRoleResolver(this.roleService)) .dataFetcher("batchAssignRole", new BatchAssignRoleResolver(this.roleService))
.dataFetcher("createInviteToken", new CreateInviteTokenResolver(this.inviteTokenService)) .dataFetcher("createInviteToken", new CreateInviteTokenResolver(this.inviteTokenService))
.dataFetcher("acceptRole", new AcceptRoleResolver(this.roleService, this.inviteTokenService)) .dataFetcher("acceptRole", new AcceptRoleResolver(this.roleService, this.inviteTokenService))
.dataFetcher("createPost", new CreatePostResolver(this.postService))
); );
} }

View File

@ -107,6 +107,10 @@ public class AuthorizationUtils {
groupUrnStr, orPrivilegeGroups); groupUrnStr, orPrivilegeGroups);
} }
public static boolean canCreateGlobalAnnouncements(@Nonnull QueryContext context) {
return isAuthorized(context, Optional.empty(), PoliciesConfig.CREATE_GLOBAL_ANNOUNCEMENTS_PRIVILEGE);
}
public static boolean isAuthorized( public static boolean isAuthorized(
@Nonnull QueryContext context, @Nonnull QueryContext context,
@Nonnull Optional<ResourceSpec> resourceSpec, @Nonnull Optional<ResourceSpec> resourceSpec,

View File

@ -0,0 +1,60 @@
package com.linkedin.datahub.graphql.resolvers.post;
import com.datahub.authentication.Authentication;
import com.datahub.authentication.post.PostService;
import com.linkedin.common.Media;
import com.linkedin.datahub.graphql.QueryContext;
import com.linkedin.datahub.graphql.authorization.AuthorizationUtils;
import com.linkedin.datahub.graphql.exception.AuthorizationException;
import com.linkedin.datahub.graphql.generated.CreatePostInput;
import com.linkedin.datahub.graphql.generated.PostContentType;
import com.linkedin.datahub.graphql.generated.PostType;
import com.linkedin.datahub.graphql.generated.UpdateMediaInput;
import com.linkedin.datahub.graphql.generated.UpdatePostContentInput;
import com.linkedin.post.PostContent;
import graphql.schema.DataFetcher;
import graphql.schema.DataFetchingEnvironment;
import java.util.concurrent.CompletableFuture;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import static com.linkedin.datahub.graphql.resolvers.ResolverUtils.*;
@Slf4j
@RequiredArgsConstructor
public class CreatePostResolver implements DataFetcher<CompletableFuture<Boolean>> {
private final PostService _postService;
@Override
public CompletableFuture<Boolean> get(final DataFetchingEnvironment environment) throws Exception {
final QueryContext context = environment.getContext();
if (!AuthorizationUtils.canCreateGlobalAnnouncements(context)) {
throw new AuthorizationException(
"Unauthorized to create posts. Please contact your DataHub administrator if this needs corrective action.");
}
final CreatePostInput input = bindArgument(environment.getArgument("input"), CreatePostInput.class);
final PostType type = input.getPostType();
final UpdatePostContentInput content = input.getContent();
final PostContentType contentType = content.getContentType();
final String title = content.getTitle();
final String link = content.getLink();
final String description = content.getDescription();
final UpdateMediaInput updateMediaInput = content.getMedia();
final Authentication authentication = context.getAuthentication();
Media media = updateMediaInput == null ? null
: _postService.mapMedia(updateMediaInput.getType().toString(), updateMediaInput.getLocation());
PostContent postContent = _postService.mapPostContent(contentType.toString(), title, description, link, media);
return CompletableFuture.supplyAsync(() -> {
try {
return _postService.createPost(type.toString(), postContent, authentication);
} catch (Exception e) {
throw new RuntimeException("Failed to create a new post", e);
}
});
}
}

View File

@ -0,0 +1,72 @@
package com.linkedin.datahub.graphql.resolvers.post;
import com.datahub.authentication.Authentication;
import com.linkedin.common.urn.Urn;
import com.linkedin.datahub.graphql.QueryContext;
import com.linkedin.datahub.graphql.generated.ListPostsInput;
import com.linkedin.datahub.graphql.generated.ListPostsResult;
import com.linkedin.datahub.graphql.types.post.PostMapper;
import com.linkedin.entity.EntityResponse;
import com.linkedin.entity.client.EntityClient;
import com.linkedin.metadata.query.filter.SortCriterion;
import com.linkedin.metadata.query.filter.SortOrder;
import com.linkedin.metadata.search.SearchEntity;
import com.linkedin.metadata.search.SearchResult;
import graphql.schema.DataFetcher;
import graphql.schema.DataFetchingEnvironment;
import java.util.HashSet;
import java.util.Map;
import java.util.concurrent.CompletableFuture;
import java.util.stream.Collectors;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import static com.linkedin.datahub.graphql.resolvers.ResolverUtils.*;
import static com.linkedin.metadata.Constants.*;
@Slf4j
@RequiredArgsConstructor
public class ListPostsResolver implements DataFetcher<CompletableFuture<ListPostsResult>> {
private static final Integer DEFAULT_START = 0;
private static final Integer DEFAULT_COUNT = 20;
private static final String DEFAULT_QUERY = "";
private final EntityClient _entityClient;
@Override
public CompletableFuture<ListPostsResult> get(final DataFetchingEnvironment environment) throws Exception {
final QueryContext context = environment.getContext();
final Authentication authentication = context.getAuthentication();
final ListPostsInput input = bindArgument(environment.getArgument("input"), ListPostsInput.class);
final Integer start = input.getStart() == null ? DEFAULT_START : input.getStart();
final Integer count = input.getCount() == null ? DEFAULT_COUNT : input.getCount();
final String query = input.getQuery() == null ? DEFAULT_QUERY : input.getQuery();
return CompletableFuture.supplyAsync(() -> {
try {
final SortCriterion sortCriterion =
new SortCriterion().setField(LAST_MODIFIED_FIELD_NAME).setOrder(SortOrder.DESCENDING);
// First, get all Post Urns.
final SearchResult gmsResult = _entityClient.search(POST_ENTITY_NAME, query, null, sortCriterion, start, count,
context.getAuthentication());
// Then, get and hydrate all Posts.
final Map<Urn, EntityResponse> entities = _entityClient.batchGetV2(POST_ENTITY_NAME,
new HashSet<>(gmsResult.getEntities().stream().map(SearchEntity::getEntity).collect(Collectors.toList())),
null, authentication);
final ListPostsResult result = new ListPostsResult();
result.setStart(gmsResult.getFrom());
result.setCount(gmsResult.getPageSize());
result.setTotal(gmsResult.getNumEntities());
result.setPosts(entities.values().stream().map(PostMapper::map).collect(Collectors.toList()));
return result;
} catch (Exception e) {
throw new RuntimeException("Failed to list posts", e);
}
});
}
}

View File

@ -0,0 +1,76 @@
package com.linkedin.datahub.graphql.types.post;
import com.linkedin.data.DataMap;
import com.linkedin.datahub.graphql.generated.AuditStamp;
import com.linkedin.datahub.graphql.generated.EntityType;
import com.linkedin.datahub.graphql.generated.Media;
import com.linkedin.datahub.graphql.generated.MediaType;
import com.linkedin.datahub.graphql.generated.Post;
import com.linkedin.datahub.graphql.generated.PostContent;
import com.linkedin.datahub.graphql.generated.PostContentType;
import com.linkedin.datahub.graphql.generated.PostType;
import com.linkedin.datahub.graphql.types.common.mappers.util.MappingHelper;
import com.linkedin.datahub.graphql.types.mappers.ModelMapper;
import com.linkedin.entity.EntityResponse;
import com.linkedin.entity.EnvelopedAspectMap;
import com.linkedin.post.PostInfo;
import javax.annotation.Nonnull;
import static com.linkedin.metadata.Constants.*;
public class PostMapper implements ModelMapper<EntityResponse, Post> {
public static final PostMapper INSTANCE = new PostMapper();
public static Post map(@Nonnull final EntityResponse entityResponse) {
return INSTANCE.apply(entityResponse);
}
@Override
public Post apply(@Nonnull final EntityResponse entityResponse) {
final Post result = new Post();
result.setUrn(entityResponse.getUrn().toString());
result.setType(EntityType.POST);
EnvelopedAspectMap aspectMap = entityResponse.getAspects();
MappingHelper<Post> mappingHelper = new MappingHelper<>(aspectMap, result);
mappingHelper.mapToResult(POST_INFO_ASPECT_NAME, this::mapPostInfo);
return mappingHelper.getResult();
}
private void mapPostInfo(@Nonnull Post post, @Nonnull DataMap dataMap) {
PostInfo postInfo = new PostInfo(dataMap);
post.setPostType(PostType.valueOf(postInfo.getType().toString()));
post.setContent(mapPostContent(postInfo.getContent()));
AuditStamp lastModified = new AuditStamp();
lastModified.setTime(postInfo.getLastModified());
post.setLastModified(lastModified);
}
@Nonnull
private com.linkedin.datahub.graphql.generated.PostContent mapPostContent(
@Nonnull com.linkedin.post.PostContent postContent) {
PostContent result = new PostContent();
result.setContentType(PostContentType.valueOf(postContent.getType().toString()));
result.setTitle(postContent.getTitle());
if (postContent.hasDescription()) {
result.setDescription(postContent.getDescription());
}
if (postContent.hasLink()) {
result.setLink(postContent.getLink().toString());
}
if (postContent.hasMedia()) {
result.setMedia(mapPostMedia(postContent.getMedia()));
}
return result;
}
@Nonnull
private Media mapPostMedia(@Nonnull com.linkedin.common.Media postMedia) {
Media result = new Media();
result.setType(MediaType.valueOf(postMedia.getType().toString()));
result.setLocation(postMedia.getLocation().toString());
return result;
}
}

View File

@ -183,6 +183,11 @@ type Query {
Get invite token Get invite token
""" """
getInviteToken(input: GetInviteTokenInput!): InviteToken getInviteToken(input: GetInviteTokenInput!): InviteToken
"""
List all Posts
"""
listPosts(input: ListPostsInput!): ListPostsResult
} }
""" """
@ -518,6 +523,11 @@ type Mutation {
Create invite token Create invite token
""" """
createInviteToken(input: CreateInviteTokenInput!): InviteToken createInviteToken(input: CreateInviteTokenInput!): InviteToken
"""
Create a post
"""
createPost(input: CreatePostInput!): Boolean
} }
""" """
@ -693,6 +703,11 @@ enum EntityType {
A DataHub Role A DataHub Role
""" """
DATAHUB_ROLE DATAHUB_ROLE
"""
A DataHub Post
"""
POST
} }
""" """
@ -9385,3 +9400,223 @@ input AcceptRoleInput {
""" """
inviteToken: String! inviteToken: String!
} }
"""
The type of post
"""
enum PostType {
"""
Posts on the home page
"""
HOME_PAGE_ANNOUNCEMENT,
}
"""
The type of post
"""
enum PostContentType {
"""
Text content
"""
TEXT,
"""
Link content
"""
LINK
}
"""
The type of media
"""
enum MediaType {
"""
An image
"""
IMAGE
}
"""
Input provided when creating a Post
"""
input CreatePostInput {
"""
The type of post
"""
postType: PostType!
"""
The content of the post
"""
content: UpdatePostContentInput!
}
"""
Input provided for filling in a post content
"""
input UpdatePostContentInput {
"""
The type of post content
"""
contentType: PostContentType!
"""
The title of the post
"""
title: String!
"""
Optional content of the post
"""
description: String
"""
Optional link that the post is associated with
"""
link: String
"""
Optional media contained in the post
"""
media: UpdateMediaInput
}
"""
Input provided for filling in a post content
"""
input UpdateMediaInput {
"""
The type of media
"""
type: MediaType!
"""
The location of the media (a URL)
"""
location: String!
}
"""
Input provided when listing existing posts
"""
input ListPostsInput {
"""
The starting offset of the result set returned
"""
start: Int
"""
The maximum number of Roles to be returned in the result set
"""
count: Int
"""
Optional search query
"""
query: String
}
"""
The result obtained when listing Posts
"""
type ListPostsResult {
"""
The starting offset of the result set returned
"""
start: Int!
"""
The number of Roles in the returned result set
"""
count: Int!
"""
The total number of Roles in the result set
"""
total: Int!
"""
The Posts themselves
"""
posts: [Post!]!
}
"""
Input provided when creating a Post
"""
type Post implements Entity {
"""
The primary key of the Post
"""
urn: String!
"""
The standard Entity Type
"""
type: EntityType!
"""
Granular API for querying edges extending from the Post
"""
relationships(input: RelationshipsInput!): EntityRelationshipsResult
"""
The type of post
"""
postType: PostType!
"""
The content of the post
"""
content: PostContent!
"""
When the post was last modified
"""
lastModified: AuditStamp!
}
"""
Post content
"""
type PostContent {
"""
The type of post content
"""
contentType: PostContentType!
"""
The title of the post
"""
title: String!
"""
Optional content of the post
"""
description: String
"""
Optional link that the post is associated with
"""
link: String
"""
Optional media contained in the post
"""
media: Media
}
"""
Media content
"""
type Media {
"""
The type of media
"""
type: MediaType!
"""
The location of the media (a URL)
"""
location: String!
}

View File

@ -0,0 +1,91 @@
package com.linkedin.datahub.graphql.resolvers.post;
import com.datahub.authentication.Authentication;
import com.datahub.authentication.post.PostService;
import com.linkedin.common.Media;
import com.linkedin.common.url.Url;
import com.linkedin.datahub.graphql.QueryContext;
import com.linkedin.datahub.graphql.generated.CreatePostInput;
import com.linkedin.datahub.graphql.generated.MediaType;
import com.linkedin.datahub.graphql.generated.PostContentType;
import com.linkedin.datahub.graphql.generated.PostType;
import com.linkedin.datahub.graphql.generated.UpdateMediaInput;
import com.linkedin.datahub.graphql.generated.UpdatePostContentInput;
import graphql.schema.DataFetchingEnvironment;
import org.testng.annotations.BeforeMethod;
import org.testng.annotations.Test;
import static com.linkedin.datahub.graphql.TestUtils.*;
import static org.mockito.Mockito.*;
import static org.testng.Assert.*;
public class CreatePostResolverTest {
private static final MediaType POST_MEDIA_TYPE = MediaType.IMAGE;
private static final String POST_MEDIA_LOCATION =
"https://datahubproject.io/img/datahub-logo-color-light-horizontal.svg";
private static final PostContentType POST_CONTENT_TYPE = PostContentType.LINK;
private static final String POST_TITLE = "title";
private static final String POST_DESCRIPTION = "description";
private static final String POST_LINK = "https://datahubproject.io";
private PostService _postService;
private CreatePostResolver _resolver;
private DataFetchingEnvironment _dataFetchingEnvironment;
private Authentication _authentication;
@BeforeMethod
public void setupTest() throws Exception {
_postService = mock(PostService.class);
_dataFetchingEnvironment = mock(DataFetchingEnvironment.class);
_authentication = mock(Authentication.class);
_resolver = new CreatePostResolver(_postService);
}
@Test
public void testNotAuthorizedFails() {
QueryContext mockContext = getMockDenyContext();
when(_dataFetchingEnvironment.getContext()).thenReturn(mockContext);
assertThrows(() -> _resolver.get(_dataFetchingEnvironment).join());
}
@Test
public void testCreatePost() throws Exception {
QueryContext mockContext = getMockAllowContext();
when(_dataFetchingEnvironment.getContext()).thenReturn(mockContext);
when(mockContext.getAuthentication()).thenReturn(_authentication);
UpdateMediaInput media = new UpdateMediaInput();
media.setType(POST_MEDIA_TYPE);
media.setLocation(POST_MEDIA_LOCATION);
Media mediaObj = new Media().setType(com.linkedin.common.MediaType.valueOf(POST_MEDIA_TYPE.toString()))
.setLocation(new Url(POST_MEDIA_LOCATION));
when(_postService.mapMedia(eq(POST_MEDIA_TYPE.toString()), eq(POST_MEDIA_LOCATION))).thenReturn(mediaObj);
UpdatePostContentInput content = new UpdatePostContentInput();
content.setTitle(POST_TITLE);
content.setDescription(POST_DESCRIPTION);
content.setLink(POST_LINK);
content.setContentType(POST_CONTENT_TYPE);
content.setMedia(media);
com.linkedin.post.PostContent postContentObj = new com.linkedin.post.PostContent().setType(
com.linkedin.post.PostContentType.valueOf(POST_CONTENT_TYPE.toString()))
.setTitle(POST_TITLE)
.setDescription(POST_DESCRIPTION)
.setLink(new Url(POST_LINK))
.setMedia(new Media().setType(com.linkedin.common.MediaType.valueOf(POST_MEDIA_TYPE.toString()))
.setLocation(new Url(POST_MEDIA_LOCATION)));
when(_postService.mapPostContent(eq(POST_CONTENT_TYPE.toString()), eq(POST_TITLE), eq(POST_DESCRIPTION),
eq(POST_LINK), any(Media.class))).thenReturn(postContentObj);
CreatePostInput input = new CreatePostInput();
input.setPostType(PostType.HOME_PAGE_ANNOUNCEMENT);
input.setContent(content);
when(_dataFetchingEnvironment.getArgument(eq("input"))).thenReturn(input);
when(_postService.createPost(eq(PostType.HOME_PAGE_ANNOUNCEMENT.toString()), eq(postContentObj),
eq(_authentication))).thenReturn(true);
assertTrue(_resolver.get(_dataFetchingEnvironment).join());
}
}

View File

@ -0,0 +1,120 @@
package com.linkedin.datahub.graphql.resolvers.post;
import com.datahub.authentication.Authentication;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableMap;
import com.linkedin.common.Media;
import com.linkedin.common.MediaType;
import com.linkedin.common.url.Url;
import com.linkedin.common.urn.Urn;
import com.linkedin.datahub.graphql.QueryContext;
import com.linkedin.datahub.graphql.generated.ListPostsInput;
import com.linkedin.datahub.graphql.generated.ListPostsResult;
import com.linkedin.entity.Aspect;
import com.linkedin.entity.EntityResponse;
import com.linkedin.entity.EnvelopedAspect;
import com.linkedin.entity.EnvelopedAspectMap;
import com.linkedin.entity.client.EntityClient;
import com.linkedin.metadata.search.SearchEntity;
import com.linkedin.metadata.search.SearchEntityArray;
import com.linkedin.metadata.search.SearchResult;
import com.linkedin.metadata.search.SearchResultMetadata;
import com.linkedin.policy.DataHubRoleInfo;
import com.linkedin.post.PostContent;
import com.linkedin.post.PostContentType;
import com.linkedin.post.PostInfo;
import com.linkedin.post.PostType;
import graphql.schema.DataFetchingEnvironment;
import java.net.URISyntaxException;
import java.util.Map;
import org.testng.annotations.BeforeMethod;
import org.testng.annotations.Test;
import static com.linkedin.datahub.graphql.TestUtils.*;
import static com.linkedin.metadata.Constants.*;
import static org.mockito.Mockito.*;
import static org.testng.Assert.*;
public class ListPostsResolverTest {
private static Map<Urn, EntityResponse> _entityResponseMap;
private static final String POST_URN_STRING = "urn:li:post:examplePost";
private static final MediaType POST_MEDIA_TYPE = MediaType.IMAGE;
private static final String POST_MEDIA_LOCATION =
"https://datahubproject.io/img/datahub-logo-color-light-horizontal.svg";
private static final PostContentType POST_CONTENT_TYPE = PostContentType.LINK;
private static final String POST_TITLE = "title";
private static final String POST_DESCRIPTION = "description";
private static final String POST_LINK = "https://datahubproject.io";
private static final Media MEDIA = new Media().setType(POST_MEDIA_TYPE).setLocation(new Url(POST_MEDIA_LOCATION));
private static final PostContent POST_CONTENT = new PostContent().setType(POST_CONTENT_TYPE)
.setTitle(POST_TITLE)
.setDescription(POST_DESCRIPTION)
.setLink(new Url(POST_LINK))
.setMedia(MEDIA);
private static final PostType POST_TYPE = PostType.HOME_PAGE_ANNOUNCEMENT;
private EntityClient _entityClient;
private ListPostsResolver _resolver;
private DataFetchingEnvironment _dataFetchingEnvironment;
private Authentication _authentication;
private Map<Urn, EntityResponse> getMockPostsEntityResponse() throws URISyntaxException {
Urn postUrn = Urn.createFromString(POST_URN_STRING);
EntityResponse entityResponse = new EntityResponse().setUrn(postUrn);
PostInfo postInfo = new PostInfo();
postInfo.setType(POST_TYPE);
postInfo.setContent(POST_CONTENT);
DataHubRoleInfo dataHubRoleInfo = new DataHubRoleInfo();
dataHubRoleInfo.setDescription(postUrn.toString());
dataHubRoleInfo.setName(postUrn.toString());
entityResponse.setAspects(new EnvelopedAspectMap(ImmutableMap.of(DATAHUB_ROLE_INFO_ASPECT_NAME,
new EnvelopedAspect().setValue(new Aspect(dataHubRoleInfo.data())))));
return ImmutableMap.of(postUrn, entityResponse);
}
@BeforeMethod
public void setupTest() throws Exception {
_entityResponseMap = getMockPostsEntityResponse();
_entityClient = mock(EntityClient.class);
_dataFetchingEnvironment = mock(DataFetchingEnvironment.class);
_authentication = mock(Authentication.class);
_resolver = new ListPostsResolver(_entityClient);
}
@Test
public void testNotAuthorizedFails() {
QueryContext mockContext = getMockDenyContext();
when(_dataFetchingEnvironment.getContext()).thenReturn(mockContext);
assertThrows(() -> _resolver.get(_dataFetchingEnvironment).join());
}
@Test
public void testListPosts() throws Exception {
QueryContext mockContext = getMockAllowContext();
when(_dataFetchingEnvironment.getContext()).thenReturn(mockContext);
when(mockContext.getAuthentication()).thenReturn(_authentication);
ListPostsInput input = new ListPostsInput();
when(_dataFetchingEnvironment.getArgument("input")).thenReturn(input);
final SearchResult roleSearchResult =
new SearchResult().setMetadata(new SearchResultMetadata()).setFrom(0).setPageSize(10).setNumEntities(1);
roleSearchResult.setEntities(
new SearchEntityArray(ImmutableList.of(new SearchEntity().setEntity(Urn.createFromString(POST_URN_STRING)))));
when(_entityClient.search(eq(POST_ENTITY_NAME), any(), eq(null), any(), anyInt(), anyInt(),
eq(_authentication))).thenReturn(roleSearchResult);
when(_entityClient.batchGetV2(eq(POST_ENTITY_NAME), any(), any(), any())).thenReturn(_entityResponseMap);
ListPostsResult result = _resolver.get(_dataFetchingEnvironment).join();
assertEquals(result.getStart(), 0);
assertEquals(result.getCount(), 10);
assertEquals(result.getTotal(), 1);
assertEquals(result.getPosts().size(), 1);
}
}

View File

@ -0,0 +1,62 @@
import React from 'react';
import { Divider, Typography } from 'antd';
import styled from 'styled-components';
import { useListPostsQuery } from '../../graphql/post.generated';
import { Post, PostContentType } from '../../types.generated';
import { PostTextCard } from '../search/PostTextCard';
import { PostLinkCard } from '../search/PostLinkCard';
const RecommendationContainer = styled.div`
margin-bottom: 32px;
max-width: 1000px;
min-width: 750px;
`;
const RecommendationTitle = styled(Typography.Title)`
margin-top: 0px;
margin-bottom: 0px;
padding: 0px;
`;
const ThinDivider = styled(Divider)`
margin-top: 12px;
margin-bottom: 12px;
`;
const LinkPostsContainer = styled.div`
display: flex;
flex-direction: row;
`;
export const HomePagePosts = () => {
const { data: postsData } = useListPostsQuery({
variables: {
input: {
start: 0,
count: 10,
},
},
});
const textPosts =
postsData?.listPosts?.posts?.filter((post) => post?.content?.contentType === PostContentType.Text) || [];
const linkPosts =
postsData?.listPosts?.posts?.filter((post) => post?.content?.contentType === PostContentType.Link) || [];
const hasPosts = textPosts.length > 0 || linkPosts.length > 0;
return hasPosts ? (
<RecommendationContainer>
<RecommendationTitle level={4}>Pinned</RecommendationTitle>
<ThinDivider />
{textPosts.map((post) => (
<PostTextCard textPost={post as Post} />
))}
<LinkPostsContainer>
{linkPosts.map((post, index) => (
<PostLinkCard linkPost={post as Post} index={index} />
))}
</LinkPostsContainer>
</RecommendationContainer>
) : (
<></>
);
};

View File

@ -1,5 +1,5 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import styled from 'styled-components'; import styled from 'styled-components/macro';
import { Button, Divider, Empty, Typography } from 'antd'; import { Button, Divider, Empty, Typography } from 'antd';
import { RocketOutlined } from '@ant-design/icons'; import { RocketOutlined } from '@ant-design/icons';
import { import {
@ -16,6 +16,7 @@ import { useGetEntityCountsQuery } from '../../graphql/app.generated';
import { GettingStartedModal } from './GettingStartedModal'; import { GettingStartedModal } from './GettingStartedModal';
import { ANTD_GRAY } from '../entity/shared/constants'; import { ANTD_GRAY } from '../entity/shared/constants';
import { useGetAuthenticatedUser } from '../useGetAuthenticatedUser'; import { useGetAuthenticatedUser } from '../useGetAuthenticatedUser';
import { HomePagePosts } from './HomePagePosts';
const RecommendationsContainer = styled.div` const RecommendationsContainer = styled.div`
margin-top: 32px; margin-top: 32px;
@ -139,6 +140,7 @@ export const HomePageRecommendations = ({ userUrn }: Props) => {
return ( return (
<RecommendationsContainer> <RecommendationsContainer>
<HomePagePosts />
{orderedEntityCounts && orderedEntityCounts.length > 0 && ( {orderedEntityCounts && orderedEntityCounts.length > 0 && (
<RecommendationContainer> <RecommendationContainer>
{domainRecommendationModule && ( {domainRecommendationModule && (

View File

@ -0,0 +1,94 @@
import React from 'react';
// import { Link } from 'react-router-dom';
import { Button, Image, Typography } from 'antd';
import { ArrowRightOutlined } from '@ant-design/icons';
import styled from 'styled-components/macro';
import { ANTD_GRAY } from '../entity/shared/constants';
import { Post } from '../../types.generated';
const CardContainer = styled(Button)<{ isLastCardInRow?: boolean }>`
display: flex;
flex-direction: row;
justify-content: space-between;
margin-right: ${(props) => (props.isLastCardInRow ? '0%' : '4%')};
margin-left: 12px;
margin-bottom: 12px;
width: 29%;
height: 100px;
border: 1px solid ${ANTD_GRAY[4]};
border-radius: 12px;
box-shadow: ${(props) => props.theme.styles['box-shadow']};
&&:hover {
box-shadow: ${(props) => props.theme.styles['box-shadow-hover']};
}
white-space: unset;
`;
const LogoContainer = styled.div`
margin-top: 25px;
margin-left: 25px;
margin-right: 40px;
`;
const PlatformLogo = styled(Image)`
width: auto;
object-fit: contain;
background-color: transparent;
`;
const TextContainer = styled.div`
display: flex;
flex: 1;
justify-content: center;
align-items: start;
flex-direction: column;
`;
const HeaderText = styled(Typography.Text)`
line-height: 10px;
margin-top: 12px;
`;
const TitleDiv = styled.div`
display: flex;
justify-content: space-evenly;
align-items: center;
gap: 6px;
font-size: 14px;
`;
const Title = styled(Typography.Title)`
word-break: break-word;
`;
const NUM_CARDS_PER_ROW = 3;
type Props = {
linkPost: Post;
index: number;
};
export const PostLinkCard = ({ linkPost, index }: Props) => {
const hasMedia = !!linkPost?.content?.media?.location;
const link = linkPost?.content?.link || '';
const isLastCardInRow = (index + 1) % NUM_CARDS_PER_ROW === 0;
return (
<CardContainer type="link" href={link} isLastCardInRow={isLastCardInRow}>
{hasMedia && (
<LogoContainer>
<PlatformLogo width={50} height={50} preview={false} src={linkPost?.content?.media?.location} />
</LogoContainer>
)}
<TextContainer>
<HeaderText type="secondary">Link</HeaderText>
<Title level={5}>
<TitleDiv>
{linkPost?.content?.title}
<ArrowRightOutlined />
</TitleDiv>
</Title>
</TextContainer>
</CardContainer>
);
};

View File

@ -0,0 +1,65 @@
import React from 'react';
import { Typography } from 'antd';
import styled from 'styled-components/macro';
import { ANTD_GRAY } from '../entity/shared/constants';
import { Post } from '../../types.generated';
const CardContainer = styled.div`
display: flex;
flex-direction: row;
margin-right: 12px;
margin-left: 12px;
margin-bottom: 12px;
height: 140px;
border: 1px solid ${ANTD_GRAY[4]};
border-radius: 12px;
box-shadow: ${(props) => props.theme.styles['box-shadow']};
&&:hover {
box-shadow: ${(props) => props.theme.styles['box-shadow-hover']};
}
white-space: unset;
`;
const TextContainer = styled.div`
margin-left: 12px;
display: flex;
justify-content: center;
align-items: start;
flex-direction: column;
`;
const Title = styled(Typography.Title)`
word-break: break-word;
`;
const HeaderText = styled(Typography.Text)`
margin-top: 12px;
`;
const AnnouncementText = styled(Typography.Paragraph)`
font-size: 12px;
color: ${ANTD_GRAY[7]};
`;
type Props = {
textPost: Post;
};
export const PostTextCard = ({ textPost }: Props) => {
return (
<CardContainer>
<TextContainer>
<HeaderText type="secondary">Announcement</HeaderText>
<Title
ellipsis={{
rows: 1,
}}
level={5}
>
{textPost?.content?.title}
</Title>
<AnnouncementText>{textPost?.content?.description}</AnnouncementText>
</TextContainer>
</CardContainer>
);
};

View File

@ -114,4 +114,8 @@ mutation createInviteToken($input: CreateInviteTokenInput!) {
mutation acceptRole($input: AcceptRoleInput!) { mutation acceptRole($input: AcceptRoleInput!) {
acceptRole(input: $input) acceptRole(input: $input)
}
mutation createPost($input: CreatePostInput!) {
createPost(input: $input)
} }

View File

@ -0,0 +1,22 @@
query listPosts($input: ListPostsInput!) {
listPosts(input: $input) {
start
count
total
posts {
urn
type
postType
content {
contentType
title
description
link
media {
type
location
}
}
}
}
}

View File

@ -47,6 +47,7 @@ public class Constants {
public static final String DATA_HUB_UPGRADE_ENTITY_NAME = "dataHubUpgrade"; public static final String DATA_HUB_UPGRADE_ENTITY_NAME = "dataHubUpgrade";
public static final String INVITE_TOKEN_ENTITY_NAME = "inviteToken"; public static final String INVITE_TOKEN_ENTITY_NAME = "inviteToken";
public static final String DATAHUB_ROLE_ENTITY_NAME = "dataHubRole"; public static final String DATAHUB_ROLE_ENTITY_NAME = "dataHubRole";
public static final String POST_ENTITY_NAME = "post";
/** /**
@ -243,7 +244,6 @@ public class Constants {
public static final String IS_MEMBER_OF_GROUP_RELATIONSHIP_NAME = "IsMemberOfGroup"; public static final String IS_MEMBER_OF_GROUP_RELATIONSHIP_NAME = "IsMemberOfGroup";
public static final String IS_MEMBER_OF_NATIVE_GROUP_RELATIONSHIP_NAME = "IsMemberOfNativeGroup"; public static final String IS_MEMBER_OF_NATIVE_GROUP_RELATIONSHIP_NAME = "IsMemberOfNativeGroup";
// acryl-main only
public static final String CHANGE_EVENT_PLATFORM_EVENT_NAME = "entityChangeEvent"; public static final String CHANGE_EVENT_PLATFORM_EVENT_NAME = "entityChangeEvent";
/** /**
@ -258,6 +258,10 @@ public class Constants {
public static final String DATA_PROCESS_INSTANCE_PROPERTIES_ASPECT_NAME = "dataProcessInstanceProperties"; public static final String DATA_PROCESS_INSTANCE_PROPERTIES_ASPECT_NAME = "dataProcessInstanceProperties";
public static final String DATA_PROCESS_INSTANCE_RUN_EVENT_ASPECT_NAME = "dataProcessInstanceRunEvent"; public static final String DATA_PROCESS_INSTANCE_RUN_EVENT_ASPECT_NAME = "dataProcessInstanceRunEvent";
// Posts
public static final String POST_INFO_ASPECT_NAME = "postInfo";
public static final String LAST_MODIFIED_FIELD_NAME = "lastModified";
private Constants() { private Constants() {
} }
} }

View File

@ -0,0 +1,16 @@
namespace com.linkedin.common
/**
* Carries information about which roles a user is assigned to.
*/
record Media {
/**
* Type of content the Media is storing, e.g. image, video, etc.
*/
type: MediaType
/**
* Where the media content is stored.
*/
location: Url
}

View File

@ -0,0 +1,11 @@
namespace com.linkedin.common
/**
* Enum defining the type of content a Media object holds.
*/
enum MediaType {
/**
* The Media holds an image.
*/
IMAGE
}

View File

@ -0,0 +1,14 @@
namespace com.linkedin.metadata.key
/**
* Key for a Post.
*/
@Aspect = {
"name": "postKey"
}
record PostKey {
/**
* A unique id for the DataHub Post record. Generated on the server side at Post creation time.
*/
id: string
}

View File

@ -0,0 +1,37 @@
namespace com.linkedin.post
import com.linkedin.common.Media
import com.linkedin.common.Url
/**
* Content stored inside a Post.
*/
record PostContent {
/**
* Title of the post.
*/
@Searchable = {
"fieldType": "TEXT_PARTIAL"
}
title: string
/**
* Type of content held in the post.
*/
type: PostContentType
/**
* Optional description of the post.
*/
description: optional string
/**
* Optional link that the post is associated with.
*/
link: optional Url
/**
* Optional media that the post is storing
*/
media: optional Media
}

View File

@ -0,0 +1,16 @@
namespace com.linkedin.post
/**
* Enum defining the type of content held in a Post.
*/
enum PostContentType {
/**
* Text content
*/
TEXT
/**
* Link content
*/
LINK
}

View File

@ -0,0 +1,35 @@
namespace com.linkedin.post
/**
* Information about a DataHub Post.
*/
@Aspect = {
"name": "postInfo"
}
record PostInfo {
/**
* Type of the Post.
*/
type: PostType
/**
* Content stored in the post.
*/
content: PostContent
/**
* The time at which the post was initially created
*/
@Searchable = {
"fieldType": "COUNT"
}
created: long
/**
* The time at which the post was last modified
*/
@Searchable = {
"fieldType": "COUNT"
}
lastModified: long
}

View File

@ -0,0 +1,11 @@
namespace com.linkedin.post
/**
* Enum defining types of Posts.
*/
enum PostType {
/**
* The Post is an Home Page announcement.
*/
HOME_PAGE_ANNOUNCEMENT
}

View File

@ -259,4 +259,9 @@ entities:
keyAspect: dataHubRoleKey keyAspect: dataHubRoleKey
aspects: aspects:
- dataHubRoleInfo - dataHubRoleInfo
- name: post
category: core
keyAspect: postKey
aspects:
- postInfo
events: events:

View File

@ -0,0 +1,71 @@
package com.datahub.authentication.post;
import com.datahub.authentication.Authentication;
import com.linkedin.common.Media;
import com.linkedin.common.MediaType;
import com.linkedin.common.url.Url;
import com.linkedin.entity.client.EntityClient;
import com.linkedin.metadata.key.PostKey;
import com.linkedin.mxe.MetadataChangeProposal;
import com.linkedin.post.PostContent;
import com.linkedin.post.PostContentType;
import com.linkedin.post.PostInfo;
import com.linkedin.post.PostType;
import com.linkedin.r2.RemoteInvocationException;
import java.time.Instant;
import java.util.UUID;
import javax.annotation.Nonnull;
import javax.annotation.Nullable;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import static com.linkedin.metadata.Constants.*;
import static com.linkedin.metadata.entity.AspectUtils.*;
@Slf4j
@RequiredArgsConstructor
public class PostService {
private final EntityClient _entityClient;
@Nonnull
public Media mapMedia(@Nonnull String type, @Nonnull String location) {
final Media media = new Media();
media.setType(MediaType.valueOf(type));
media.setLocation(new Url(location));
return media;
}
@Nonnull
public PostContent mapPostContent(@Nonnull String contentType, @Nonnull String title, @Nullable String description, @Nullable String link,
@Nullable Media media) {
final PostContent postContent = new PostContent().setType(PostContentType.valueOf(contentType)).setTitle(title);
if (description != null) {
postContent.setDescription(description);
}
if (link != null) {
postContent.setLink(new Url(link));
}
if (media != null) {
postContent.setMedia(media);
}
return postContent;
}
public boolean createPost(@Nonnull String postType, @Nonnull PostContent postContent,
@Nonnull Authentication authentication) throws RemoteInvocationException {
final String uuid = UUID.randomUUID().toString();
final PostKey postKey = new PostKey().setId(uuid);
final long currentTimeMillis = Instant.now().toEpochMilli();
final PostInfo postInfo = new PostInfo().setType(PostType.valueOf(postType))
.setContent(postContent)
.setCreated(currentTimeMillis)
.setLastModified(currentTimeMillis);
final MetadataChangeProposal proposal =
buildMetadataChangeProposal(POST_ENTITY_NAME, postKey, POST_INFO_ASPECT_NAME, postInfo);
_entityClient.ingestProposal(proposal, authentication);
return true;
}
}

View File

@ -0,0 +1,66 @@
package com.datahub.authentication.post;
import com.datahub.authentication.Actor;
import com.datahub.authentication.ActorType;
import com.datahub.authentication.Authentication;
import com.linkedin.common.Media;
import com.linkedin.common.MediaType;
import com.linkedin.common.url.Url;
import com.linkedin.entity.client.EntityClient;
import com.linkedin.post.PostContent;
import com.linkedin.post.PostContentType;
import com.linkedin.post.PostType;
import com.linkedin.r2.RemoteInvocationException;
import org.testng.annotations.BeforeMethod;
import org.testng.annotations.Test;
import static org.mockito.Mockito.*;
import static org.testng.Assert.*;
public class PostServiceTest {
private static final MediaType POST_MEDIA_TYPE = MediaType.IMAGE;
private static final String POST_MEDIA_LOCATION =
"https://datahubproject.io/img/datahub-logo-color-light-horizontal.svg";
private static final PostContentType POST_CONTENT_TYPE = PostContentType.LINK;
private static final String POST_TITLE = "title";
private static final String POST_DESCRIPTION = "description";
private static final String POST_LINK = "https://datahubproject.io";
private static final Media MEDIA = new Media().setType(POST_MEDIA_TYPE).setLocation(new Url(POST_MEDIA_LOCATION));
private static final PostContent POST_CONTENT = new PostContent().setType(POST_CONTENT_TYPE)
.setTitle(POST_TITLE)
.setDescription(POST_DESCRIPTION)
.setLink(new Url(POST_LINK))
.setMedia(MEDIA);
private static final PostType POST_TYPE = PostType.HOME_PAGE_ANNOUNCEMENT;
private static final String DATAHUB_SYSTEM_CLIENT_ID = "__datahub_system";
private static final Authentication SYSTEM_AUTHENTICATION =
new Authentication(new Actor(ActorType.USER, DATAHUB_SYSTEM_CLIENT_ID), "");
private EntityClient _entityClient;
private PostService _postService;
@BeforeMethod
public void setupTest() {
_entityClient = mock(EntityClient.class);
_postService = new PostService(_entityClient);
}
@Test
public void testMapMedia() {
Media media = _postService.mapMedia(POST_MEDIA_TYPE.toString(), POST_MEDIA_LOCATION);
assertEquals(MEDIA, media);
}
@Test
public void testMapPostContent() {
PostContent postContent =
_postService.mapPostContent(POST_CONTENT_TYPE.toString(), POST_TITLE, POST_DESCRIPTION, POST_LINK, MEDIA);
assertEquals(POST_CONTENT, postContent);
}
@Test
public void testCreatePost() throws RemoteInvocationException {
_postService.createPost(POST_TYPE.toString(), POST_CONTENT, SYSTEM_AUTHENTICATION);
verify(_entityClient, times(1)).ingestProposal(any(), eq(SYSTEM_AUTHENTICATION));
}
}

View File

@ -0,0 +1,28 @@
package com.linkedin.gms.factory.auth;
import com.datahub.authentication.post.PostService;
import com.linkedin.gms.factory.spring.YamlPropertySourceFactory;
import com.linkedin.metadata.client.JavaEntityClient;
import javax.annotation.Nonnull;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.PropertySource;
import org.springframework.context.annotation.Scope;
@Configuration
@PropertySource(value = "classpath:/application.yml", factory = YamlPropertySourceFactory.class)
public class PostServiceFactory {
@Autowired
@Qualifier("javaEntityClient")
private JavaEntityClient _javaEntityClient;
@Bean(name = "postService")
@Scope("singleton")
@Nonnull
protected PostService getInstance() throws Exception {
return new PostService(this._javaEntityClient);
}
}

View File

@ -2,6 +2,7 @@ package com.linkedin.gms.factory.graphql;
import com.datahub.authentication.group.GroupService; import com.datahub.authentication.group.GroupService;
import com.datahub.authentication.invite.InviteTokenService; import com.datahub.authentication.invite.InviteTokenService;
import com.datahub.authentication.post.PostService;
import com.datahub.authentication.token.StatefulTokenService; import com.datahub.authentication.token.StatefulTokenService;
import com.datahub.authentication.user.NativeUserService; import com.datahub.authentication.user.NativeUserService;
import com.datahub.authorization.role.RoleService; import com.datahub.authorization.role.RoleService;
@ -123,6 +124,10 @@ public class GraphQLEngineFactory {
@Qualifier("inviteTokenService") @Qualifier("inviteTokenService")
private InviteTokenService _inviteTokenService; private InviteTokenService _inviteTokenService;
@Autowired
@Qualifier("postService")
private PostService _postService;
@Value("${platformAnalytics.enabled}") // TODO: Migrate to DATAHUB_ANALYTICS_ENABLED @Value("${platformAnalytics.enabled}") // TODO: Migrate to DATAHUB_ANALYTICS_ENABLED
private Boolean isAnalyticsEnabled; private Boolean isAnalyticsEnabled;
@ -157,6 +162,7 @@ public class GraphQLEngineFactory {
_groupService, _groupService,
_roleService, _roleService,
_inviteTokenService, _inviteTokenService,
_postService,
_configProvider.getFeatureFlags() _configProvider.getFeatureFlags()
).builder().build(); ).builder().build();
} }
@ -186,6 +192,7 @@ public class GraphQLEngineFactory {
_groupService, _groupService,
_roleService, _roleService,
_inviteTokenService, _inviteTokenService,
_postService,
_configProvider.getFeatureFlags() _configProvider.getFeatureFlags()
).builder().build(); ).builder().build();
} }

View File

@ -93,6 +93,11 @@ public class PoliciesConfig {
"Create Domains", "Create Domains",
"Create new Domains."); "Create new Domains.");
public static final Privilege CREATE_GLOBAL_ANNOUNCEMENTS_PRIVILEGE = Privilege.of(
"CREATE_GLOBAL_ANNOUNCEMENTS",
"Create Global Announcements",
"Create new Global Announcements.");
public static final List<Privilege> PLATFORM_PRIVILEGES = ImmutableList.of( public static final List<Privilege> PLATFORM_PRIVILEGES = ImmutableList.of(
MANAGE_POLICIES_PRIVILEGE, MANAGE_POLICIES_PRIVILEGE,
MANAGE_USERS_AND_GROUPS_PRIVILEGE, MANAGE_USERS_AND_GROUPS_PRIVILEGE,
@ -107,7 +112,7 @@ public class PoliciesConfig {
MANAGE_USER_CREDENTIALS_PRIVILEGE, MANAGE_USER_CREDENTIALS_PRIVILEGE,
MANAGE_TAGS_PRIVILEGE, MANAGE_TAGS_PRIVILEGE,
CREATE_TAGS_PRIVILEGE, CREATE_TAGS_PRIVILEGE,
CREATE_DOMAINS_PRIVILEGE CREATE_DOMAINS_PRIVILEGE, CREATE_GLOBAL_ANNOUNCEMENTS_PRIVILEGE
); );
// Resource Privileges // // Resource Privileges //