Skip to content

Commit

Permalink
Add PostHog Integration and Make Instructors Deletable (#2291)
Browse files Browse the repository at this point in the history
* Make instructors deletable

* Remove unnecessary variables

* Updating test to handle both external resources and instructors

* Add PostHog env variables

* Update frontend PostHog integration

* Formatting

* Add OCW_STUDIO_CONTENT_DELETABLE feature flag

* Update README to describe PostHog integration

* Updating test and removing unnecessary await

* Updating Python test

* Feature flag can be enabled for individual users

* Suppressing TypeScript error

* Updating wording in README
  • Loading branch information
pt2302 authored Sep 11, 2024
1 parent 1bb0f7b commit a1fd0f7
Show file tree
Hide file tree
Showing 12 changed files with 250 additions and 95 deletions.
4 changes: 2 additions & 2 deletions .secrets.baseline
Original file line number Diff line number Diff line change
Expand Up @@ -113,9 +113,9 @@
"filename": "README.md",
"hashed_secret": "be4fc4886bd949b369d5e092eb87494f12e57e5b",
"is_verified": false,
"line_number": 244
"line_number": 246
}
]
},
"generated_at": "2024-01-18T19:14:12Z"
"generated_at": "2024-09-04T01:40:31Z"
}
21 changes: 21 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ OCW Studio manages deployments for OCW courses.
- [Enabling AWS MediaConvert transcoding](#enabling-aws-mediaconvert-transcoding)
- [Enabling 3Play integration](#enabling-3play-integration)
- [Enabling Open Catalog Search Webhooks](#enabling-open-catalog-search-webhooks)
- [Checking External Resource Availability](#checking-external-resource-availability)
- [Enabling PostHog Integration](#enabling-posthog-integration)

# Initial Setup

Expand Down Expand Up @@ -502,3 +504,22 @@ OPEN_CATALOG_WEBHOOK_KEY=secret key that will be used to confirm that webhook re
# Checking External Resource Availability

This feature sets up a cron job to validate external resource urls. The workflow for checking external resource availability is described [here](/external_resources/README.md).

# Enabling PostHog Integration

PostHog is used for dynamically testing and rolling out features that may not be ready for permanent deployment as part of OCW Studio. For example, we use the feature flag `OCW_STUDIO_CONTENT_DELETABLE` to control whether content can be deleted.

The following variables should be set in the `.env` file for PostHog integration:

```
POSTHOG_ENABLED=True
POSTHOG_API_HOST=https://app.posthog.com
POSTHOG_PROJECT_API_KEY=<obtain from the PostHog dashboard>
```

The following variables can be optionally set to configure PostHog requests:

```
POSTHOG_FEATURE_FLAG_REQUEST_TIMEOUT_MS=<3000 by default>
POSTHOG_MAX_RETRIES=<3 by default>
```
16 changes: 16 additions & 0 deletions app.json
Original file line number Diff line number Diff line change
Expand Up @@ -447,6 +447,22 @@
"PGBOUNCER_MIN_POOL_SIZE": {
"value": "5"
},
"POSTHOG_API_HOST": {
"description": "API host for PostHog",
"required": false
},
"POSTHOG_ENABLED": {
"description": "Whether PostHog is enabled",
"required": false
},
"POSTHOG_FEATURE_FLAG_REQUEST_TIMEOUT_MS": {
"description": "Timeout (ms) for PostHog feature flag requests",
"required": false
},
"POSTHOG_PROJECT_API_KEY": {
"description": "API token for communicating with PostHog",
"required": false
},
"PREPUBLISH_ACTIONS": {
"description": "Actions to perform before publish",
"required": false
Expand Down
30 changes: 30 additions & 0 deletions main/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -1235,3 +1235,33 @@
description="Name of environment from Heroku or other deployment",
required=False,
)
POSTHOG_API_HOST = get_string(
name="POSTHOG_API_HOST",
default=None,
description="API host for PostHog",
required=False,
)
POSTHOG_ENABLED = get_bool(
name="POSTHOG_ENABLED",
default=False,
description="Whether PostHog is enabled",
required=False,
)
POSTHOG_FEATURE_FLAG_REQUEST_TIMEOUT_MS = get_int(
name="POSTHOG_FEATURE_FLAG_REQUEST_TIMEOUT_MS",
default=3000,
description="Timeout (ms) for PostHog feature flag requests",
required=False,
)
POSTHOG_MAX_RETRIES = get_int(
name="POSTHOG_MAX_RETRIES",
default=3,
description="Number of times requests to PostHog are retried if failed",
required=False,
)
POSTHOG_PROJECT_API_KEY = get_string(
name="POSTHOG_PROJECT_API_KEY",
default=None,
description="API token for communicating with PostHog",
required=False,
)
2 changes: 2 additions & 0 deletions main/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ def _index(request):
"gdrive_enabled": is_gdrive_enabled(),
"features": settings.FEATURES,
"features_default": settings.FEATURES_DEFAULT,
"posthog_api_host": settings.POSTHOG_API_HOST,
"posthog_project_api_key": settings.POSTHOG_PROJECT_API_KEY,
}

user = request.user
Expand Down
2 changes: 2 additions & 0 deletions main/views_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,8 @@ def test_react_page( # pylint: disable=too-many-arguments # noqa: PLR0913
),
"features": settings.FEATURES,
"features_default": settings.FEATURES_DEFAULT,
"posthog_api_host": settings.POSTHOG_API_HOST,
"posthog_project_api_key": settings.POSTHOG_PROJECT_API_KEY,
}
else:
assert response.status_code == HTTP_302_FOUND
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@
"mini-css-extract-plugin": "^0.10.0",
"pluralize": "^8.0.0",
"postcss-loader": "^6.1.1",
"posthog-js": "^1.160.1",
"prettier": "^3.0.3",
"prettier-plugin-django-alpine": "^1.1.1",
"prop-types": "^15.7.2",
Expand Down
190 changes: 99 additions & 91 deletions static/js/components/RepeatableContentListing.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,11 @@ jest.mock("@use-it/interval", () => ({
default: jest.fn(),
}))

//mock the OCW_STUDIO_CONTENT_DELETABLE feature flag as set to true
jest.mock("posthog-js", () => ({
isFeatureEnabled: jest.fn().mockReturnValue(true),
}))

describe("RepeatableContentListing", () => {
let helper: IntegrationTestHelper,
render: TestRenderer,
Expand Down Expand Up @@ -215,102 +220,105 @@ describe("RepeatableContentListing", () => {
expect(spy).toHaveBeenCalledWith("?q=my-search-string")
})

it("should delete an external resource", async () => {
const externalResourceConfigItem =
makeRepeatableConfigItem("external-resource")
const externalContentItem = makeWebsiteContentListItem()
websiteContentDetailsLookup = {
[contentDetailKey({
name: website.name,
textId: externalContentItem.text_id,
})]: externalContentItem,
}
const externalResourceApiResponse = {
results: [externalContentItem],
count: 1,
next: null,
previous: null,
}
const contentListingLookup = {
[contentListingKey({
name: website.name,
type: externalResourceConfigItem.name,
offset: 0,
})]: {
...externalResourceApiResponse,
results: externalResourceApiResponse.results.map(
(item) => item.text_id,
),
},
}
helper.mockGetRequest(
siteApiContentListingUrl
.param({
const deletableTestCases = [
{ name: "external resource", configName: "external-resource" },
{ name: "instructor", configName: "instructor" },
]
deletableTestCases.forEach(({ name, configName }) => {
it(`should delete ${name}`, async () => {
const configItem = makeRepeatableConfigItem(configName)
const contentItem = makeWebsiteContentListItem()
websiteContentDetailsLookup = {
[contentDetailKey({
name: website.name,
})
.query({ offset: 0, type: externalResourceConfigItem.name })
.toString(),
externalResourceApiResponse,
)
render = helper.configureRenderer(
(props) => (
<WebsiteContext.Provider value={website}>
<RepeatableContentListing {...props} />
</WebsiteContext.Provider>
),
{ configItem: externalResourceConfigItem },
{
entities: {
websiteDetails: { [website.name]: website },
websiteContentListing: contentListingLookup,
websiteContentDetails: websiteContentDetailsLookup,
},
queries: {},
},
)
const contentItemToDelete = externalContentItem
const getStatusStub = helper.mockGetRequest(
siteApiDetailUrl
.param({ name: website.name })
.query({ only_status: true })
.toString(),
{ sync_status: "Complete" },
)
const deleteContentStub = helper.mockDeleteRequest(
siteApiContentDetailUrl
.param({
textId: contentItem.text_id,
})]: contentItem,
}
const apiResponse = {
results: [contentItem],
count: 1,
next: null,
previous: null,
}
const contentListingLookup = {
[contentListingKey({
name: website.name,
textId: contentItemToDelete.text_id,
})
.toString(),
{},
)
const { wrapper } = await render()
wrapper.find(".transparent-button").at(0).simulate("click")
wrapper.update()
act(() => {
wrapper.find("button.dropdown-item").at(0).simulate("click")
})
wrapper.update()
let dialog = wrapper.find("Dialog")
expect(dialog.prop("open")).toBe(true)
expect(dialog.prop("bodyContent")).toContain(contentItemToDelete.title)

// Confirm the deletion in the dialog
await act(async () => {
dialog.find("ModalFooter").find("button").at(1).simulate("click")
})
wrapper.update()
type: configItem.name,
offset: 0,
})]: {
...apiResponse,
results: apiResponse.results.map((item) => item.text_id),
},
}
helper.mockGetRequest(
siteApiContentListingUrl
.param({
name: website.name,
})
.query({ offset: 0, type: configItem.name })
.toString(),
apiResponse,
)
render = helper.configureRenderer(
(props) => (
<WebsiteContext.Provider value={website}>
<RepeatableContentListing {...props} />
</WebsiteContext.Provider>
),
{ configItem: configItem },
{
entities: {
websiteDetails: { [website.name]: website },
websiteContentListing: contentListingLookup,
websiteContentDetails: websiteContentDetailsLookup,
},
queries: {},
},
)
const contentItemToDelete = contentItem
const getStatusStub = helper.mockGetRequest(
siteApiDetailUrl
.param({ name: website.name })
.query({ only_status: true })
.toString(),
{ sync_status: "Complete" },
)
const deleteContentStub = helper.mockDeleteRequest(
siteApiContentDetailUrl
.param({
name: website.name,
textId: contentItemToDelete.text_id,
})
.toString(),
{},
)
const { wrapper } = await render()
wrapper.find(".transparent-button").at(0).simulate("click")
wrapper.update()
act(() => {
wrapper.find("button.dropdown-item").at(0).simulate("click")
})
wrapper.update()
let dialog = wrapper.find("Dialog")
expect(dialog.prop("open")).toBe(true)
expect(dialog.prop("bodyContent")).toContain(contentItemToDelete.title)

// Confirm the deletion in the dialog
await act(async () => {
dialog.find("ModalFooter").find("button").at(1).simulate("click")
})
wrapper.update()

// Assert the DELETE request was called
sinon.assert.calledOnce(deleteContentStub)
// Assert the DELETE request was called
sinon.assert.calledOnce(deleteContentStub)

// Assert the GET request for website status was called
sinon.assert.calledOnce(getStatusStub)
// Assert the GET request for website status was called
sinon.assert.calledOnce(getStatusStub)

// Assert the dialog is closed
dialog = wrapper.find("Dialog")
expect(dialog.prop("open")).toBe(false)
// Assert the dialog is closed
dialog = wrapper.find("Dialog")
expect(dialog.prop("open")).toBe(false)
})
})

it("should show each content item with edit links", async () => {
Expand Down
21 changes: 19 additions & 2 deletions static/js/components/RepeatableContentListing.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import React, {
MouseEvent as ReactMouseEvent,
useCallback,
useState,
useEffect,
} from "react"
import { Link, Route, useLocation } from "react-router-dom"
import { useMutation, useRequest } from "redux-query-react"
Expand Down Expand Up @@ -37,16 +38,32 @@ import SiteContentEditorDrawer from "./SiteContentEditorDrawer"
import { useWebsite } from "../context/Website"
import { formatUpdatedOn } from "../util/websites"
import Dialog from "./Dialog"
import posthog from "posthog-js"

export default function RepeatableContentListing(props: {
configItem: RepeatableConfigItem
}): JSX.Element | null {
const store = useStore()
const { configItem } = props
const isResource = configItem.name === "resource"
const isExternalResource = configItem.name === "external-resource"

const website = useWebsite()

const [isContentDeletable, setIsContentDeletable] = useState(false)

useEffect(() => {
const checkFeatureFlag = async () => {
const flagEnabled =
posthog.isFeatureEnabled("OCW_STUDIO_CONTENT_DELETABLE") ?? false
setIsContentDeletable(flagEnabled)
}
checkFeatureFlag()
}, [])

const isDeletable =
isContentDeletable &&
["external-resource", "instructor"].includes(configItem.name)

const getListingParams = useCallback(
(search: string): ContentListingParams => {
const qsParams = new URLSearchParams(search)
Expand Down Expand Up @@ -229,7 +246,7 @@ export default function RepeatableContentListing(props: {
title={item.title ?? ""}
subtitle={`Updated ${formatUpdatedOn(item)}`}
menuOptions={
isExternalResource ? [["Delete", startDelete(item)]] : undefined
isDeletable ? [["Delete", startDelete(item)]] : undefined
}
/>
))}
Expand Down
Loading

0 comments on commit a1fd0f7

Please sign in to comment.