From f1d279c7bae9936b5446b94aca46f72f8b6acfb9 Mon Sep 17 00:00:00 2001 From: Paul Brost Date: Wed, 13 Nov 2024 11:00:27 -0500 Subject: [PATCH 01/18] code compiling for deployment of toy-sba --- modules/lo_event/lo_event/reduxLogger.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/lo_event/lo_event/reduxLogger.js b/modules/lo_event/lo_event/reduxLogger.js index 1aac21609..c5b74e60f 100644 --- a/modules/lo_event/lo_event/reduxLogger.js +++ b/modules/lo_event/lo_event/reduxLogger.js @@ -19,7 +19,7 @@ * use bits and pieces, or to treat this code as an examplar. */ import * as redux from 'redux'; -import thunk from 'redux-thunk'; +import { thunk } from 'redux-thunk'; const EMIT_EVENT = 'EMIT_EVENT'; const EMIT_LOCKFIELDS = 'EMIT_LOCKFIELDS'; From acaec6ba20996dfd6bc91f1268210503f25722fd Mon Sep 17 00:00:00 2001 From: Paul Brost Date: Fri, 20 Dec 2024 10:20:43 -0500 Subject: [PATCH 02/18] state sync implemented, state load backed out --- modules/lo_event/lo_event/browserStorage.js | 5 +-- modules/lo_event/lo_event/reduxLogger.js | 37 ++++++++++++++++++++- modules/lo_event/package.json | 6 ++++ package.json | 3 ++ 4 files changed, 48 insertions(+), 3 deletions(-) diff --git a/modules/lo_event/lo_event/browserStorage.js b/modules/lo_event/lo_event/browserStorage.js index 613d0fb31..aa59a94c6 100644 --- a/modules/lo_event/lo_event/browserStorage.js +++ b/modules/lo_event/lo_event/browserStorage.js @@ -58,7 +58,7 @@ const thunkStorage = { * `storage.sync.get`/`chrome.sync.get` API. */ function getWithCallback (getItem) { - function get (items, callback) { + function get (items, callback = () => {}) { if (typeof items === 'string') { items = [items]; } @@ -77,10 +77,11 @@ function getWithCallback (getItem) { * `storage.sync.set`/`chrome.sync.set` API. */ function setWithCallback (setItem) { - function set (items, callback) { + function set (items, callback = () => {}) { for (const item in items) { setItem(item, items[item]); } + console.log("checking if callback exists"); if (callback) callback(); } return set; diff --git a/modules/lo_event/lo_event/reduxLogger.js b/modules/lo_event/lo_event/reduxLogger.js index c5b74e60f..7ca7cd707 100644 --- a/modules/lo_event/lo_event/reduxLogger.js +++ b/modules/lo_event/lo_event/reduxLogger.js @@ -20,6 +20,8 @@ */ import * as redux from 'redux'; import { thunk } from 'redux-thunk'; +import { createStateSyncMiddleware, initMessageListener, } from "redux-state-sync"; +import debounce from "lodash/debounce"; const EMIT_EVENT = 'EMIT_EVENT'; const EMIT_LOCKFIELDS = 'EMIT_LOCKFIELDS'; @@ -34,6 +36,27 @@ function debug_log(...args) { } } +const KEY = "redux"; +export function loadState() { + try { + const serializedState = localStorage.getItem(KEY); + if (!serializedState) return undefined; + return JSON.parse(serializedState); + } catch (e) { + return undefined; + } +} + +export async function saveState(state) { + console.log("Saving state"); + try { + const serializedState = JSON.stringify(state); + localStorage.setItem(KEY, serializedState); + } catch (e) { + // Ignore + } +} + // Action creator function This is a little bit messy, since we // duplicate type from the payload. It's not clear if this is a good // idea. We used to have `type` be set to the current contents of @@ -173,8 +196,11 @@ const composeEnhancers = (typeof window !== 'undefined' && window.__REDUX_DEVTOO export let store = redux.createStore( reducer, {event: null}, // Base state - composeEnhancers(redux.applyMiddleware(thunk.default || thunk)) + composeEnhancers(redux.applyMiddleware((thunk.default || thunk), createStateSyncMiddleware())) ); + +initMessageListener(store); + let promise = null; let previousEvent = null; let lockFields = null; @@ -204,9 +230,18 @@ export function setState(state) { store.dispatch(emitSetState(state)); } +const debouncedSaveState = debounce((state) => { + saveState(state); +}, 1000); + function initializeStore () { store.subscribe(() => { const state = store.getState(); + + // we use debounce to save the state once every second + // for better performances in case multiple changes occur in a short time + debouncedSaveState(state); + if (state.lock_fields) { lockFields = state.lock_fields.fields; } diff --git a/modules/lo_event/package.json b/modules/lo_event/package.json index d62e93855..81b6fb32d 100644 --- a/modules/lo_event/package.json +++ b/modules/lo_event/package.json @@ -25,14 +25,20 @@ "homepage": "https://github.com/ETS-Next-Gen/writing_observer#readme", "type": "module", "dependencies": { + "@react-native-async-storage/async-storage": "^2.1.0", "aws-sdk": "^2.1614.0", + "debounce": "^2.2.0", "http-server": "^14.1.1", "indexeddb-js": "^0.0.14", "jasmine": "^5.1.0", + "localforage": "^1.10.0", "lodash": "^4.17.21", "react": "^18.3.1", "react-dom": "^18.3.1", + "react-router-dom": "^7.0.2", "redux": "^5.0.1", + "redux-persist": "^6.0.0", + "redux-state-sync": "^3.1.4", "redux-thunk": "^3.1.0", "sqlite3": "^5.1.6", "uuid": "^10.0.0", diff --git a/package.json b/package.json index b5d78de21..76f3136fb 100644 --- a/package.json +++ b/package.json @@ -36,5 +36,8 @@ "stylelint": "^15.5.0", "stylelint-config-standard": "^33.0.0", "stylelint-scss": "^4.6.0" + }, + "dependencies": { + "@react-native-async-storage/async-storage": "^2.1.0" } } From ea7873a5b6a7d514ed50880a05947a51a64f1add Mon Sep 17 00:00:00 2001 From: Paul Brost Date: Sat, 21 Dec 2024 09:28:59 -0500 Subject: [PATCH 03/18] persist redux store in local storage --- modules/lo_event/lo_event/reduxLogger.js | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/modules/lo_event/lo_event/reduxLogger.js b/modules/lo_event/lo_event/reduxLogger.js index 7ca7cd707..85a6bd423 100644 --- a/modules/lo_event/lo_event/reduxLogger.js +++ b/modules/lo_event/lo_event/reduxLogger.js @@ -193,9 +193,11 @@ const composeEnhancers = (typeof window !== 'undefined' && window.__REDUX_DEVTOO // // This shows up as an error in the test case. If the error goes away, we should switch this // back to thunk. +const presistedState = loadState(); + export let store = redux.createStore( reducer, - {event: null}, // Base state + presistedState, // Base state composeEnhancers(redux.applyMiddleware((thunk.default || thunk), createStateSyncMiddleware())) ); @@ -268,7 +270,7 @@ function initializeStore () { }); } -export function reduxLogger (subscribers, initialState = {}) { +export function reduxLogger (subscribers, initialState = null) { if (subscribers != null) { eventSubscribers = subscribers; } @@ -289,7 +291,10 @@ export function reduxLogger (subscribers, initialState = {}) { logEvent.getLockFields = function () { return lockFields; }; - setState(initialState); + //do we want to initialize the store here? We set it to the stored state in create store + //if (initialState) { + // setState(initialState); + //} return logEvent; } From 0c4fdafa2d77f80db6787a1a1d832039c8b13a22 Mon Sep 17 00:00:00 2001 From: Paul Brost Date: Sat, 21 Dec 2024 11:45:26 -0500 Subject: [PATCH 04/18] removed unused npm packages --- modules/lo_event/package.json | 4 ---- 1 file changed, 4 deletions(-) diff --git a/modules/lo_event/package.json b/modules/lo_event/package.json index 81b6fb32d..f5e85ee58 100644 --- a/modules/lo_event/package.json +++ b/modules/lo_event/package.json @@ -25,19 +25,15 @@ "homepage": "https://github.com/ETS-Next-Gen/writing_observer#readme", "type": "module", "dependencies": { - "@react-native-async-storage/async-storage": "^2.1.0", "aws-sdk": "^2.1614.0", "debounce": "^2.2.0", "http-server": "^14.1.1", "indexeddb-js": "^0.0.14", "jasmine": "^5.1.0", - "localforage": "^1.10.0", "lodash": "^4.17.21", "react": "^18.3.1", "react-dom": "^18.3.1", - "react-router-dom": "^7.0.2", "redux": "^5.0.1", - "redux-persist": "^6.0.0", "redux-state-sync": "^3.1.4", "redux-thunk": "^3.1.0", "sqlite3": "^5.1.6", From e47734b8b3730131a41d7b89f90b2533e9a5f9df Mon Sep 17 00:00:00 2001 From: Bradley Erickson Date: Tue, 7 Jan 2025 15:23:19 -0500 Subject: [PATCH 05/18] added draft of server side blob storage --- .../learning_observer/blob_storage.py | 36 +++++++++++++++++++ .../incoming_student_event.py | 31 ++++++++++++++++ 2 files changed, 67 insertions(+) create mode 100644 learning_observer/learning_observer/blob_storage.py diff --git a/learning_observer/learning_observer/blob_storage.py b/learning_observer/learning_observer/blob_storage.py new file mode 100644 index 000000000..f86c996e6 --- /dev/null +++ b/learning_observer/learning_observer/blob_storage.py @@ -0,0 +1,36 @@ +import learning_observer.kvs +import learning_observer.stream_analytics.helpers as sa_helpers + +def state_blob(): + '''Dummy function for the reducer name portion of the + KVS key + ''' + pass + +def _make_key(user_id, source, activity): + '''Helper function to format keys for the KVS + ''' + key = sa_helpers.make_key( + state_blob, + { + sa_helpers.EventField('source'): source, + sa_helpers.EventField('activity'): activity, + sa_helpers.KeyField.STUDENT: user_id + }, + sa_helpers.KeyStateType.INTERNAL + ) + return key + +async def fetch_blob(user_id, source, activity): + '''Fetch the blob from the KVS + ''' + key = _make_key(user_id, source, activity) + kvs = learning_observer.kvs.KVS() + return await kvs[key] + +async def save_blob(user_id, source, activity, blob): + '''Store a blob in the KVS + ''' + key = _make_key(user_id, source, activity) + kvs = learning_observer.kvs.KVS() + await kvs.set(key, blob) diff --git a/learning_observer/learning_observer/incoming_student_event.py b/learning_observer/learning_observer/incoming_student_event.py index 6cb7de904..76c1c4a9a 100644 --- a/learning_observer/learning_observer/incoming_student_event.py +++ b/learning_observer/learning_observer/incoming_student_event.py @@ -38,6 +38,7 @@ import learning_observer.auth.events import learning_observer.adapters.adapter import learning_observer.blacklist +import learning_observer.blob_storage import learning_observer.constants as constants @@ -447,6 +448,35 @@ async def filter_blacklist_events(events): await ws.send_json(bl_status) await ws.close() + async def process_blob_storage_events(events): + '''HACK This function manages events related to storing and + retrieving blobs from server-side storage. It is primarily + used for LO Assess. Ideally, this functionality should reside + in an independent module, rather than being directly integrated + into Learning Observer, as it is currently implemented. + ''' + async for event in events: + print('***EVENT', event) + # Extract metadata + if event['event'] in ['save_blob', 'fetch_blob']: + # TODO not 100% sure how auth/source/activity are stored + # in the event. That's why we have a print statement above + user_id = event['auth'] + source = event['source'] + activity = event['activity'] + + # Save, fetch, or ignore (continue) + if event['event'] == 'save_blob': + await learning_observer.blob_storage.save_blob( + user_id, source, activity, + event['blob'] + ) + elif event['event'] == 'fetch_blob': + blob = await learning_observer.blob_storage.fetch_blob(user_id, source, activity) + await ws.send_json(blob) + else: + yield event + async def check_for_reducer_update(events): '''Check to see if the reducers updated ''' @@ -470,6 +500,7 @@ async def process_ws_message_through_pipeline(): events = decode_lock_fields(events) events = handle_auth_events(events) events = filter_blacklist_events(events) + events = process_blob_storage_events(events) events = check_for_reducer_update(events) events = pass_through_reducers(events) # empty loop to start the generator pipeline From 13ab13c149307d1c5205676bd70e9740ec96802f Mon Sep 17 00:00:00 2001 From: Paul Brost Date: Fri, 10 Jan 2025 09:03:25 -0500 Subject: [PATCH 06/18] blob loading functionality passed to Brad --- .../lo_event/lo_event/lo_assess/reducers.js | 7 +- modules/lo_event/lo_event/reduxLogger.js | 114 +++++++++++++++--- modules/lo_event/lo_event/websocketLogger.js | 6 + 3 files changed, 108 insertions(+), 19 deletions(-) diff --git a/modules/lo_event/lo_event/lo_assess/reducers.js b/modules/lo_event/lo_event/lo_assess/reducers.js index 7cb469ec1..2f4ef610e 100644 --- a/modules/lo_event/lo_event/lo_assess/reducers.js +++ b/modules/lo_event/lo_event/lo_assess/reducers.js @@ -5,11 +5,13 @@ const DEBUG = false; const dclog = (...args) => {if(DEBUG) {console.log.apply(console, Array.from(args));} }; export const LOAD_DATA_EVENT = 'LOAD_DATA_EVENT'; +export const LOAD_STATE = 'LOAD_STATE'; export const NAVIGATE = 'NAVIGATE'; export const SHOW_SECTION='SHOW_SECTION'; export const STEPTHROUGH_NEXT = 'STEPTHROUGH_NEXT'; export const STEPTHROUGH_PREV = 'STEPTHROUGH_PREV'; export const STORE_VARIABLE = 'STORE_VARIABLE'; +export const STORE_SETTING = 'STORE_SETTING'; export const UPDATE_INPUT = 'UPDATE_INPUT'; export const UPDATE_LLM_RESPONSE = 'UPDATE_LLM_RESPONSE'; export const VIDEO_TIME_EVENT = 'VIDEO_TIME_EVENT'; @@ -46,11 +48,14 @@ export const updateResponseReducer = (state = initialState, action) => { registerReducer( [LOAD_DATA_EVENT, + LOAD_STATE, NAVIGATE, SHOW_SECTION, STEPTHROUGH_NEXT, STEPTHROUGH_PREV, + STORE_SETTING, STORE_VARIABLE, UPDATE_INPUT, - UPDATE_LLM_RESPONSE, VIDEO_TIME_EVENT], + UPDATE_LLM_RESPONSE, + VIDEO_TIME_EVENT], updateResponseReducer ); diff --git a/modules/lo_event/lo_event/reduxLogger.js b/modules/lo_event/lo_event/reduxLogger.js index 85a6bd423..33b749855 100644 --- a/modules/lo_event/lo_event/reduxLogger.js +++ b/modules/lo_event/lo_event/reduxLogger.js @@ -26,6 +26,18 @@ import debounce from "lodash/debounce"; const EMIT_EVENT = 'EMIT_EVENT'; const EMIT_LOCKFIELDS = 'EMIT_LOCKFIELDS'; const EMIT_SET_STATE = 'SET_STATE'; +const EMIT_LOAD_STATE = 'LOAD_STATE'; +const EMIT_STORE_SETTING = 'SET_SETTING'; +const EMIT_SAVE_BLOB = 'save_blob'; +const EMIT_FETCH_BLOB = 'fetch_blob'; + +const ACTION_LOAD_STATE = 'fetch_blob'; + +const REDUX_STORE_STATES = { + NOT_STARTED: 'NOT_STARTED', // init() has not been called + LOADING: 'LOADING', //loading event has fired + LOADED: 'LOADED' //loading event has completed +}; // TODO: Import debugLog and use those functions. const DEBUG = false; @@ -36,22 +48,39 @@ function debug_log(...args) { } } -const KEY = "redux"; -export function loadState() { +export function loadState(reduxStoreID) { + debug_log("***EMITTING FETCH BLOB***"); + emitEvent({ event: EMIT_FETCH_BLOB, reduxStoreID: reduxStoreID }); + //how do we get the fetched blob here? +} + +async function saveStateToLocalStorage(state) { + const reduxStoreStatus = state?.settings?.reduxStoreStatus || REDUX_STORE_STATES.NOT_STARTED; + if (reduxStoreStatus !== REDUX_STORE_STATES.LOADED) { + debug_log("not saving store locally b/c store has status: " + reduxStoreStatus); + return; + } + try { - const serializedState = localStorage.getItem(KEY); - if (!serializedState) return undefined; - return JSON.parse(serializedState); + const KEY = state?.settings?.reduxID || "redux"; + const serializedState = JSON.stringify(state); + localStorage.setItem(KEY, serializedState); + } catch (e) { - return undefined; + // Ignore } } -export async function saveState(state) { - console.log("Saving state"); +async function saveStateToServer(state) { + const reduxStoreStatus = state?.settings?.reduxStoreStatus || REDUX_STORE_STATES.NOT_STARTED; + if (reduxStoreStatus !== REDUX_STORE_STATES.LOADED) { + debug_log("not saving store locally b/c store has status: " + reduxStoreStatus); + return; + } + try { const serializedState = JSON.stringify(state); - localStorage.setItem(KEY, serializedState); + emitEvent({ event: EMIT_SAVE_BLOB, state: serializedState }); } catch (e) { // Ignore } @@ -143,19 +172,30 @@ function set_state_reducer(state = {}, action) { return action.payload; } +function set_setting_reducer(state = initialState, action) { + return { + ...state, + settings: { + ...state.settings, + pageIdentifier: "testing", + } + }; +} + const BASE_REDUCERS = { [EMIT_EVENT]: [store_last_event_reducer], [EMIT_LOCKFIELDS]: [lock_fields_reducer], - [EMIT_SET_STATE]: [set_state_reducer] + [EMIT_SET_STATE]: [set_state_reducer], + [EMIT_STORE_SETTING]: [set_setting_reducer] } -const APPLICATION_REDUCERS = { -} +const APPLICATION_REDUCERS = {} export const registerReducer = (keys, reducer) => { const reducerKeys = Array.isArray(keys) ? keys : [keys]; reducerKeys.forEach(key => { + debug_log("registering key: " + key); if (!APPLICATION_REDUCERS[key]) APPLICATION_REDUCERS[key] = []; @@ -173,11 +213,39 @@ const reducer = (state = {}, action) => { if (action.redux_type === EMIT_EVENT) { payload = JSON.parse(action.payload); + if (action.type === EMIT_LOAD_STATE) { + const reduxStoreID = payload.reduxID || "reduxStore"; + loadState(reduxStoreID); + return { + ...state, + ...serverState, + settings: { + ...state.settings, + reduxStoreStatus: REDUX_STORE_STATES.LOADING, + reduxID: reduxStoreID, + } + }; + } + if (action.type === "state_recieved") { + console.log("fetch_blob action returned"); + console.log({payload:payload}); + return { + ...state, + settings: { + ...state.settings, + reduxStoreStatus: REDUX_STORE_STATES.LOADED, + reduxID: reduxStoreID, + } + }; + } + + debug_log(Object.keys(payload)); if (APPLICATION_REDUCERS[payload.event]) { state = { ...state, application_state: composeReducers(...APPLICATION_REDUCERS[payload.event])(state.application_state || {}, payload) }; } + } return state; @@ -193,11 +261,15 @@ const composeEnhancers = (typeof window !== 'undefined' && window.__REDUX_DEVTOO // // This shows up as an error in the test case. If the error goes away, we should switch this // back to thunk. -const presistedState = loadState(); +//const presistedState = loadState(); export let store = redux.createStore( reducer, - presistedState, // Base state + { + settings: { + reduxStoreStatus: REDUX_STORE_STATES.NOT_STARTED, + } + }, // Base state composeEnhancers(redux.applyMiddleware((thunk.default || thunk), createStateSyncMiddleware())) ); @@ -232,17 +304,23 @@ export function setState(state) { store.dispatch(emitSetState(state)); } -const debouncedSaveState = debounce((state) => { - saveState(state); +const debouncedSaveStateToLocalStorage = debounce((state) => { + saveStateToLocalStorage(state); +}, 1000); + +const debouncedSaveStateToServer = debounce((state) => { + saveStateToServer(state); }, 1000); function initializeStore () { store.subscribe(() => { const state = store.getState(); + debouncedSaveStateToLocalStorage(state); + debouncedSaveStateToServer(state); // we use debounce to save the state once every second // for better performances in case multiple changes occur in a short time - debouncedSaveState(state); + //debouncedSaveState(state); if (state.lock_fields) { lockFields = state.lock_fields.fields; @@ -276,6 +354,7 @@ export function reduxLogger (subscribers, initialState = null) { } function logEvent (event) { + debug_log("logEvent fired"); store.dispatch(emitEvent(event)); } logEvent.lo_name = 'Redux Logger'; // A human-friendly name for the logger @@ -293,7 +372,6 @@ export function reduxLogger (subscribers, initialState = null) { //do we want to initialize the store here? We set it to the stored state in create store //if (initialState) { - // setState(initialState); //} return logEvent; diff --git a/modules/lo_event/lo_event/websocketLogger.js b/modules/lo_event/lo_event/websocketLogger.js index 714ae1104..c35a1369f 100644 --- a/modules/lo_event/lo_event/websocketLogger.js +++ b/modules/lo_event/lo_event/websocketLogger.js @@ -147,6 +147,12 @@ export function websocketLogger (server = {}) { case 'browser_event': util.dispatchCustomEvent(response.event_type, { detail: response.detail }); break; + case 'fetch_blob': + util.dispatchCustomEvent("state_recieved", { detail: response.detail }); + break; + case 'save_blob': + util.dispatchCustomEvent("save_blob", { detail: response.detail }); + break; default: debug.info(`Received response we do not yet handle: ${response}`); break; From c30165d38beec62c7ebdce729a9da4408aa5c7c7 Mon Sep 17 00:00:00 2001 From: Bradley Erickson Date: Tue, 14 Jan 2025 10:37:47 -0500 Subject: [PATCH 07/18] updated lo event to allow for fetching/retrieving state --- .../incoming_student_event.py | 10 +- modules/lo_event/lo_event/browserStorage.js | 1 - modules/lo_event/lo_event/reduxLogger.js | 92 +++++-------------- modules/lo_event/lo_event/util.js | 62 ++++++++++--- modules/lo_event/lo_event/websocketLogger.js | 18 ++-- 5 files changed, 88 insertions(+), 95 deletions(-) diff --git a/learning_observer/learning_observer/incoming_student_event.py b/learning_observer/learning_observer/incoming_student_event.py index 76c1c4a9a..b08578647 100644 --- a/learning_observer/learning_observer/incoming_student_event.py +++ b/learning_observer/learning_observer/incoming_student_event.py @@ -428,7 +428,7 @@ async def decode_lock_fields(events): ''' async for event in events: if event['event'] == 'lock_fields': - if event['fields'].get('source', '') != lock_fields.get('source', ''): + if 'source' not in event['fields'] or event['fields'].get('source', '') != lock_fields.get('source', ''): lock_fields.update(event['fields']) else: event.update(lock_fields) @@ -456,12 +456,11 @@ async def process_blob_storage_events(events): into Learning Observer, as it is currently implemented. ''' async for event in events: - print('***EVENT', event) # Extract metadata if event['event'] in ['save_blob', 'fetch_blob']: # TODO not 100% sure how auth/source/activity are stored # in the event. That's why we have a print statement above - user_id = event['auth'] + user_id = event['auth']['user_id'] source = event['source'] activity = event['activity'] @@ -473,7 +472,10 @@ async def process_blob_storage_events(events): ) elif event['event'] == 'fetch_blob': blob = await learning_observer.blob_storage.fetch_blob(user_id, source, activity) - await ws.send_json(blob) + await ws.send_json({ + 'status': 'fetch_blob', + 'data': blob + }) else: yield event diff --git a/modules/lo_event/lo_event/browserStorage.js b/modules/lo_event/lo_event/browserStorage.js index aa59a94c6..181eeb5b0 100644 --- a/modules/lo_event/lo_event/browserStorage.js +++ b/modules/lo_event/lo_event/browserStorage.js @@ -81,7 +81,6 @@ function setWithCallback (setItem) { for (const item in items) { setItem(item, items[item]); } - console.log("checking if callback exists"); if (callback) callback(); } return set; diff --git a/modules/lo_event/lo_event/reduxLogger.js b/modules/lo_event/lo_event/reduxLogger.js index 33b749855..ecbf3ef11 100644 --- a/modules/lo_event/lo_event/reduxLogger.js +++ b/modules/lo_event/lo_event/reduxLogger.js @@ -20,24 +20,16 @@ */ import * as redux from 'redux'; import { thunk } from 'redux-thunk'; -import { createStateSyncMiddleware, initMessageListener, } from "redux-state-sync"; +import { createStateSyncMiddleware, initMessageListener } from "redux-state-sync"; import debounce from "lodash/debounce"; +import * as util from './util.js'; + const EMIT_EVENT = 'EMIT_EVENT'; const EMIT_LOCKFIELDS = 'EMIT_LOCKFIELDS'; const EMIT_SET_STATE = 'SET_STATE'; -const EMIT_LOAD_STATE = 'LOAD_STATE'; -const EMIT_STORE_SETTING = 'SET_SETTING'; -const EMIT_SAVE_BLOB = 'save_blob'; -const EMIT_FETCH_BLOB = 'fetch_blob'; - -const ACTION_LOAD_STATE = 'fetch_blob'; -const REDUX_STORE_STATES = { - NOT_STARTED: 'NOT_STARTED', // init() has not been called - LOADING: 'LOADING', //loading event has fired - LOADED: 'LOADED' //loading event has completed -}; +let IS_LOADED = false; // TODO: Import debugLog and use those functions. const DEBUG = false; @@ -48,16 +40,18 @@ function debug_log(...args) { } } -export function loadState(reduxStoreID) { - debug_log("***EMITTING FETCH BLOB***"); - emitEvent({ event: EMIT_FETCH_BLOB, reduxStoreID: reduxStoreID }); - //how do we get the fetched blob here? +export function handleLoadState (data) { + IS_LOADED = true; + if (data) { + setState(data) + } else { + debug_log('No data provided while handling state from server, continuing.') + } } async function saveStateToLocalStorage(state) { - const reduxStoreStatus = state?.settings?.reduxStoreStatus || REDUX_STORE_STATES.NOT_STARTED; - if (reduxStoreStatus !== REDUX_STORE_STATES.LOADED) { - debug_log("not saving store locally b/c store has status: " + reduxStoreStatus); + if (!IS_LOADED) { + debug_log('Not saving store locally because IS_LOADED is set to false.') return; } @@ -72,15 +66,13 @@ async function saveStateToLocalStorage(state) { } async function saveStateToServer(state) { - const reduxStoreStatus = state?.settings?.reduxStoreStatus || REDUX_STORE_STATES.NOT_STARTED; - if (reduxStoreStatus !== REDUX_STORE_STATES.LOADED) { - debug_log("not saving store locally b/c store has status: " + reduxStoreStatus); + if (!IS_LOADED) { + debug_log('Not saving store on the server because IS_LOADED is set to false.') return; } try { - const serializedState = JSON.stringify(state); - emitEvent({ event: EMIT_SAVE_BLOB, state: serializedState }); + util.dispatchCustomEvent('save_blob', { detail: state }); } catch (e) { // Ignore } @@ -172,21 +164,10 @@ function set_state_reducer(state = {}, action) { return action.payload; } -function set_setting_reducer(state = initialState, action) { - return { - ...state, - settings: { - ...state.settings, - pageIdentifier: "testing", - } - }; -} - const BASE_REDUCERS = { [EMIT_EVENT]: [store_last_event_reducer], [EMIT_LOCKFIELDS]: [lock_fields_reducer], [EMIT_SET_STATE]: [set_state_reducer], - [EMIT_STORE_SETTING]: [set_setting_reducer] } const APPLICATION_REDUCERS = {} @@ -208,54 +189,24 @@ export const registerReducer = (keys, reducer) => { const reducer = (state = {}, action) => { let payload; - debug_log("Reducing ", action," on ", state); + debug_log('Reducing ', action, ' on ', state); state = BASE_REDUCERS[action.redux_type] ? composeReducers(...BASE_REDUCERS[action.redux_type])(state, action) : state; if (action.redux_type === EMIT_EVENT) { payload = JSON.parse(action.payload); - if (action.type === EMIT_LOAD_STATE) { - const reduxStoreID = payload.reduxID || "reduxStore"; - loadState(reduxStoreID); - return { - ...state, - ...serverState, - settings: { - ...state.settings, - reduxStoreStatus: REDUX_STORE_STATES.LOADING, - reduxID: reduxStoreID, - } - }; - } - if (action.type === "state_recieved") { - console.log("fetch_blob action returned"); - console.log({payload:payload}); - return { - ...state, - settings: { - ...state.settings, - reduxStoreStatus: REDUX_STORE_STATES.LOADED, - reduxID: reduxStoreID, - } - }; - } - - debug_log(Object.keys(payload)); if (APPLICATION_REDUCERS[payload.event]) { state = { ...state, application_state: composeReducers(...APPLICATION_REDUCERS[payload.event])(state.application_state || {}, payload) }; } - } return state; }; - const eventQueue = []; const composeEnhancers = (typeof window !== 'undefined' && window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__) || redux.compose; - // This should just be redux.applyMiddleware(thunk)) // There is a bug in our version of redux-thunk where, in node, this must be thunk.default. // @@ -265,11 +216,7 @@ const composeEnhancers = (typeof window !== 'undefined' && window.__REDUX_DEVTOO export let store = redux.createStore( reducer, - { - settings: { - reduxStoreStatus: REDUX_STORE_STATES.NOT_STARTED, - } - }, // Base state + {event: null}, // Base state composeEnhancers(redux.applyMiddleware((thunk.default || thunk), createStateSyncMiddleware())) ); @@ -400,3 +347,6 @@ export const awaitEvent = () => { promise.resolve = resolvePromise; return promise; }; + +// Start listening for fetch +util.consumeCustomEvent('fetch_blob', handleLoadState); diff --git a/modules/lo_event/lo_event/util.js b/modules/lo_event/lo_event/util.js index ee53636f5..0b1a3f940 100644 --- a/modules/lo_event/lo_event/util.js +++ b/modules/lo_event/lo_event/util.js @@ -410,19 +410,59 @@ export function formatTime(seconds) { * When working in a browser, we want to dispatch the event via the * `window` object. */ -export function dispatchCustomEvent(eventName, detail) { - const event = new CustomEvent(eventName, { detail }); - if (typeof window !== "undefined") { +export function dispatchCustomEvent (eventName, detail) { + const event = new CustomEvent(eventName, detail); + if (typeof window !== 'undefined') { // Web page: dispatch directly on window window.dispatchEvent(event); - } else if (typeof chrome !== "undefined" && chrome.runtime && chrome.runtime.sendMessage) { - // Chrome extension background script: use chrome.runtime to send messages - chrome.runtime.sendMessage({ eventName, detail }, (response) => { - if (chrome.runtime.lastError) { - console.warn(`No listeners found for event, ${eventName}, in this context.`); - } - }); + } else if (typeof chrome !== 'undefined' && chrome.runtime && chrome.runtime.sendMessage) { + // Chrome extension background script: use chrome.runtime to send messages + chrome.runtime.sendMessage({ eventName, detail }, (response) => { + if (chrome.runtime.lastError) { + console.warn(`No listeners found for event, ${eventName}, in this context.`); + } + }); + } else { + console.warn('Event dispatching is not supported in this environment.'); + } +} + +/** + * This function consumes a custom event in the appropriate context for + * our environment. + * + * When working in an extension, it listens for messages via the + * `chrome.runtime.onMessage` object. + * + * When working in a browser, it listens for events on the + * `window` object. + */ +export function consumeCustomEvent (eventName, callback) { + if (typeof window !== 'undefined') { + // Web page: listen for the event on the window object + const listener = (event) => { + callback(event.detail); + }; + window.addEventListener(eventName, listener); + + // Return a function to remove the event listener + return () => window.removeEventListener(eventName, listener); + } else if (typeof chrome !== 'undefined' && chrome.runtime && chrome.runtime.onMessage) { + // Chrome extension background script: listen for messages via chrome.runtime + const listener = (message, sender, sendResponse) => { + if (message.eventName === eventName) { + callback(message.detail, sender); + sendResponse?.({ status: 'received' }); + } + }; + chrome.runtime.onMessage.addListener(listener); + + // Return a function to remove the message listener + return () => chrome.runtime.onMessage.removeListener(listener); } else { - console.warn("Event dispatching is not supported in this environment."); + console.warn('Event consumption is not supported in this environment.'); + return () => { + console.warn('No listener to remove in this environment.'); + }; } } diff --git a/modules/lo_event/lo_event/websocketLogger.js b/modules/lo_event/lo_event/websocketLogger.js index c35a1369f..8724b72d7 100644 --- a/modules/lo_event/lo_event/websocketLogger.js +++ b/modules/lo_event/lo_event/websocketLogger.js @@ -125,7 +125,6 @@ export function websocketLogger (server = {}) { function receiveMessage (event) { const response = JSON.parse(event.data); - switch (response.status) { case 'blocklist': debug.info('Received block error from server'); @@ -136,22 +135,19 @@ export function websocketLogger (server = {}) { ); break; case 'auth': - storage.set({user_id: response.user_id}); - util.dispatchCustomEvent("auth", { detail: { user_id: response.user }}); + storage.set({ user_id: response.user_id }); + util.dispatchCustomEvent('auth', { detail: { user_id: response.user } }); break; // These should probably be behind a feature flag, as they assume // we trust the server. case 'local_storage': - storage.set({[response.key]: response.value}); + storage.set({ [response.key]: response.value }); break; case 'browser_event': util.dispatchCustomEvent(response.event_type, { detail: response.detail }); break; case 'fetch_blob': - util.dispatchCustomEvent("state_recieved", { detail: response.detail }); - break; - case 'save_blob': - util.dispatchCustomEvent("save_blob", { detail: response.detail }); + util.dispatchCustomEvent('fetch_blob', { detail: response.data }); break; default: debug.info(`Received response we do not yet handle: ${response}`); @@ -190,5 +186,11 @@ export function websocketLogger (server = {}) { queue.enqueue(data); }; + function handleSaveBlob (blob) { + queue.enqueue(JSON.stringify({ event: 'save_blob', blob })); + } + + util.consumeCustomEvent('save_blob', handleSaveBlob) + return wsLogData; } From 14dfb2535f8d2fdda394e8e72302221e3a6213a5 Mon Sep 17 00:00:00 2001 From: Paul Brost Date: Tue, 14 Jan 2025 15:34:14 -0500 Subject: [PATCH 08/18] change to handleLoadState to return IS_LOADED in state --- modules/lo_event/lo_event/reduxLogger.js | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/modules/lo_event/lo_event/reduxLogger.js b/modules/lo_event/lo_event/reduxLogger.js index ecbf3ef11..30bd8861e 100644 --- a/modules/lo_event/lo_event/reduxLogger.js +++ b/modules/lo_event/lo_event/reduxLogger.js @@ -43,9 +43,25 @@ function debug_log(...args) { export function handleLoadState (data) { IS_LOADED = true; if (data) { - setState(data) + setState( + { + ...state, + ...data, + settings: { + ...state.settings, + reduxStoreStatus: IS_LOADED + } + }); } else { debug_log('No data provided while handling state from server, continuing.') + setState( + { + ...state, + settings: { + ...state.settings, + reduxStoreStatus: IS_LOADED + } + }); } } From bc54dacc5d491b679d9f006233acfce87501ded3 Mon Sep 17 00:00:00 2001 From: Paul Brost Date: Wed, 15 Jan 2025 13:06:37 -0500 Subject: [PATCH 09/18] added lo_toy_sba module --- modules/lo_toy_sba/MANIFEST.in | 1 + modules/lo_toy_sba/README.md | 140 ++++++++++++++++++ modules/lo_toy_sba/lo_toy_sba/__init__.py | 0 .../lo_toy_sba/lo_toy_sba/assets/scripts.js | 102 +++++++++++++ .../lo_toy_sba/lo_toy_sba/dash_dashboard.py | 77 ++++++++++ modules/lo_toy_sba/lo_toy_sba/module.py | 97 ++++++++++++ modules/lo_toy_sba/lo_toy_sba/my_layout.py | 41 +++++ modules/lo_toy_sba/lo_toy_sba/reducers.py | 16 ++ modules/lo_toy_sba/setup.cfg | 10 ++ modules/lo_toy_sba/setup.py | 14 ++ 10 files changed, 498 insertions(+) create mode 100644 modules/lo_toy_sba/MANIFEST.in create mode 100644 modules/lo_toy_sba/README.md create mode 100644 modules/lo_toy_sba/lo_toy_sba/__init__.py create mode 100644 modules/lo_toy_sba/lo_toy_sba/assets/scripts.js create mode 100644 modules/lo_toy_sba/lo_toy_sba/dash_dashboard.py create mode 100644 modules/lo_toy_sba/lo_toy_sba/module.py create mode 100644 modules/lo_toy_sba/lo_toy_sba/my_layout.py create mode 100644 modules/lo_toy_sba/lo_toy_sba/reducers.py create mode 100644 modules/lo_toy_sba/setup.cfg create mode 100644 modules/lo_toy_sba/setup.py diff --git a/modules/lo_toy_sba/MANIFEST.in b/modules/lo_toy_sba/MANIFEST.in new file mode 100644 index 000000000..d7f3b21cc --- /dev/null +++ b/modules/lo_toy_sba/MANIFEST.in @@ -0,0 +1 @@ +include lo_toy_sba/assets/* diff --git a/modules/lo_toy_sba/README.md b/modules/lo_toy_sba/README.md new file mode 100644 index 000000000..9bb198541 --- /dev/null +++ b/modules/lo_toy_sba/README.md @@ -0,0 +1,140 @@ +# Learning Observer Example Module + +Welcome to the Learning Observer (LO) example module. This document +will detail everything need to create a module for the LO. + +## packaage structure + +```bash +module/ + lo_toy_sba/ + assets/ + ... + helpers/ + additional_script.py + module.py + reducers.py + dash_dashboards.py + MANIFEST.in + setup.py + setup.cfg +``` + +### setup.py + +This is a standard `setup.py` file. + +### setup.cfg + +Notice we include the following items in our `setup.cfg` file. + +```cfg +[options.entry_points] +lo_modules = + lo_toy_sba = lo_toy_sba.module + +[options.package_data] +lo_toy_sba = helpers/* +``` + +The `lo_modules` entry point tells Learning Observer to treat `lo_toy_sba.module` as a pluggable application. + +The package data section is where we include additional directories we want included in the build. + +### MANIFEST.in + +The manifest specifies which files to include during Python packaging. This specifies the additional non-python files we want included. If you do not have additional files needed, this file is unnecessary. + +For modules with Dash-made dashboards, this will typically include a relative path to the assets folder. + +### module.py + +This file defines everything about the module. See the dedicated section below. + +## Defining a module (module.py) + +Modules can include a variety items. This will cover each item and its purpose on the system. + +### NAME + +This one is pretty self explanatory. Give the module a short name to refer to it by. + +### EXECUTION_DAG + +The execution directed acyclic graph (DAG) is how we interact with the communication protocol. + +See `lo_toy_sba/module.py:EXECUTION_DAG` for a detailed example. + +### REDUCERS + +Reducers to define on the system. These are functions that will run over incoming events from students. + +See `lo_toy_sba/module.py:REDUCERS` for a detailed example. + +### DASH_PAGES + +Dashboards built using the Dash framework should be defined here. + +See `lo_toy_sba/module.py:DASH_PAGES` for a detailed example. + +### COURSE_DASHBOARDS + +The registered course dashboards are provided to the users for navigating around dashboards, such as on their Home screen. + +See `lo_toy_sba/module.py:COURSE_DASHBOARDS` for a detailed example. + +Note that the student counterpart, `STUDENT_DASHBOARDS`, exists. + +### THIRD_PARTY + +The third party items are downloaded and included when serving items from the module. This is usually used for including extra Javascript or CSS files. + +```python +THIRD_PARTY = { + 'name_of_item': { + 'url': 'url_to_third_party_tool', + 'hash': 'hash_of_download_OR_dict_of_versions_and_hashes' + } +} +``` + +### STATIC_FILE_GIT_REPOS + +We're still figuring this out, but we'd like to support hosting static files from the git repo of the module. +This allows us to have a Merkle-tree style record of which version is deployed in our log files. + +A common use case for this is serving static `.html` and `.js` files for your module. + +```python +STATIC_FILE_GIT_REPOS = { + 'repo_name': { + 'url': 'url_to_repo', + 'prefix': 'relative/path/to/directory', + # Branches we serve. This can either be a whitelist (e.g. which ones + # are available) or a blacklist (e.g. which ones are blocked) + 'whitelist': ['master'] + } +} +``` + +### EXTRA_VIEWS + +These are extra views to publish to the user. Currently, we only support `.json` files. + +```python +EXTRA_VIEWS = [{ + 'name': 'Name of view', + 'suburl': 'view-suburl', + 'static_json': python_dictionary_to_return +}] +``` + +## Creating a reducer (reducers.py) + +Reducers are ran over incoming student events. They can be defined using a decorator in the `learning_observer.stream_analytics` module. + +Each reducer should take the incoming `event` and the previous `internal_state` as parameters and return 2 new state objects. + +## Creating dashboards with Dash (dash_dashboard.py) + +Dash pages consist of a layout and callback functions. See `dash_dashboard.py` for a more detailed overview. diff --git a/modules/lo_toy_sba/lo_toy_sba/__init__.py b/modules/lo_toy_sba/lo_toy_sba/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/modules/lo_toy_sba/lo_toy_sba/assets/scripts.js b/modules/lo_toy_sba/lo_toy_sba/assets/scripts.js new file mode 100644 index 000000000..4258a9b2b --- /dev/null +++ b/modules/lo_toy_sba/lo_toy_sba/assets/scripts.js @@ -0,0 +1,102 @@ +/** + * Javascript callbacks to be used with the LO Example dashboard + */ + +// Initialize the `dash_clientside` object if it doesn't exist +if (!window.dash_clientside) { + window.dash_clientside = {}; +} + +window.dash_clientside.lo_toy_sba = { + /** + * Send updated queries to the communication protocol. + * @param {object} wsReadyState LOConnection status object + * @param {string} urlHash query string from hash for determining course id + * @returns stringified json object that is sent to the communication protocl + */ + sendToLOConnection: async function (wsReadyState, urlHash) { + if (wsReadyState === undefined) { + return window.dash_clientside.no_update + } + if (wsReadyState.readyState === 1) { + if (urlHash.length === 0) { return window.dash_clientside.no_update } + const decodedParams = decode_string_dict(urlHash.slice(1)) + if (!decodedParams.course_id) { return window.dash_clientside.no_update } + const outgoingMessage = { + lo_toy_sba_query: { + execution_dag: 'lo_toy_sba', + target_exports: ['student_event_counter_export'], + kwargs: decodedParams + } + }; + return JSON.stringify(outgoingMessage); + } + return window.dash_clientside.no_update; + }, + + /** + * Process a message from LOConnection + * @param {object} incomingMessage object received from LOConnection + * @returns parsed data to local storage + */ + receiveWSMessage: async function (incomingMessage) { + // TODO the naming here is broken serverside. Notice above we + // called the target export `student_event_counter_export`, i.e. the named + // export. Below, we need to call `lo_toy_sba_join_roster`, i.e. the name + // of the node. This ought to be cleaned up in the communication protocl. + const messageData = JSON.parse(incomingMessage.data).lo_toy_sba_query.student_event_counter_join_roster || []; + if (messageData.error !== undefined) { + console.error('Error received from server', messageData.error); + return []; + } + return messageData; + }, + + /** + * Build the student UI components based on the stored websocket data + * @param {*} wsStorageData information stored in the websocket store + * @returns Dash object to be displayed on page + */ + populateOutput: function(wsStorageData) { + if (!wsStorageData) { + return 'No students'; + } + let output = [] + // Iterate over students and create UI items for each + for (const student of wsStorageData) { + + // We define Dash components in JS via a dictionary + // of where the component lives, what it is, and any + // parameters we want to pass along to it. + // - `namespace`: the module the component is in + // - `type`: the component to use + // - `props`: any parameters the component expects + // The following produces a LONameTag and Span wrapped in a Div + studentBadge = { + namespace: 'dash_html_components', + type: 'Div', + props: { + children: [{ + namespace: 'lo_dash_react_components', + props: { + profile: student.profile, + className: 'student-name-tag d-inline-block', + includeName: true, + id: `${student.user_id}-activity-img` + }, + type: 'LONameTag' + },{ + namespace: 'dash_html_components', + props: { + children: ` - ${student.count} events`, + }, + type: 'Span' + + }] + } + } + output = output.concat(studentBadge) + } + return output; + } +} diff --git a/modules/lo_toy_sba/lo_toy_sba/dash_dashboard.py b/modules/lo_toy_sba/lo_toy_sba/dash_dashboard.py new file mode 100644 index 000000000..200c3646b --- /dev/null +++ b/modules/lo_toy_sba/lo_toy_sba/dash_dashboard.py @@ -0,0 +1,77 @@ +''' +This file will detail how to build a dashboard using +the Dash framework. + +If you are unfamiliar with Dash, it compiles python code +to react and serves it via a Flask server. You can register +callbacks to run when specific states change. Normal callbacks +execute Python code server side, but Clientside callbacks +execute Javascript code client side. Clientside functions are +preferred as it cuts down server and network resources. + +This file contains the hard stuff. You'll need to understand +this if you want to build dynamic, interactive dashboards. For +most simple dashboards, we tossed everything you need into +my_layout. +''' +from dash import html, dcc, callback, clientside_callback, ClientsideFunction, Output, Input +import dash_bootstrap_components as dbc +import lo_dash_react_components as lodrc + +from .my_layout import my_layout, my_data_layout + +_prefix = 'lo-toy-sba' +_namespace = 'lo_toy_sba' +_websocket = f'{_prefix}-websocket' +_websocket_storage = f'{_prefix}-websocket-store' +_output = f'{_prefix}-output' + +def layout(): + ''' + Function to define the page's layout. + ''' + return my_layout(_websocket, _websocket_storage, _output) + +# Send the initial state based on the url hash to LO. +# If this is not included, nothing will be returned from +# the communication protocol. +clientside_callback( + ClientsideFunction(namespace=_namespace, function_name='sendToLOConnection'), + Output(lodrc.LOConnectionStatusAIO.ids.websocket(_websocket), 'send'), + Input(lodrc.LOConnectionStatusAIO.ids.websocket(_websocket), 'state'), # used for initial setup + Input('_pages_location', 'hash') +) + +# Handle receiving a message from the websocket. +# This step will parse the message and update the +# local storage accordingly. +clientside_callback( + ClientsideFunction(namespace=_namespace, function_name='receiveWSMessage'), + Output(_websocket_storage, 'data'), + Input(lodrc.LOConnectionStatusAIO.ids.websocket(_websocket), 'message'), + prevent_initial_call=True +) + +# Build the UI based on what we've received from the +# communicaton protocol +# This clientside callback and the serverside callback below are +# the same +# clientside_callback( +# ClientsideFunction(namespace=_namespace, function_name='populateOutput'), +# Output(_output, 'children'), +# Input(_websocket_storage, 'data'), +# ) + + +@callback( + Output(_output, 'children'), + Input(_websocket_storage, 'data'), +) +def populate_output(data): + '''This method creates UI components for each student found + in the websocket's storage. + + This will use more network traffic and server resources + than using the equivalent clientside callback, `populateOutput`. + ''' + return my_data_layout(data) diff --git a/modules/lo_toy_sba/lo_toy_sba/module.py b/modules/lo_toy_sba/lo_toy_sba/module.py new file mode 100644 index 000000000..6fcb1b723 --- /dev/null +++ b/modules/lo_toy_sba/lo_toy_sba/module.py @@ -0,0 +1,97 @@ +''' +Toy-SBA Module + +Toy-SBA Module +''' +import learning_observer.downloads as d +import learning_observer.communication_protocol.util +from learning_observer.dash_integration import thirdparty_url, static_url +from learning_observer.stream_analytics.helpers import KeyField, Scope + +import lo_toy_sba.reducers +import lo_toy_sba.dash_dashboard + +# Name for the module +NAME = 'Toy-SBA Module' + +''' +Define execution DAGs for this module. We provide a default DAG +for fetching information from the provided reducer. The internal +structure looks like: + +`execution_dag`: defined directed acyclic graph (DAG) for querying data + : q.select() # or some other communication protocol query +`exports`: fetchable nodes from the execution dag + : { + "returns": , + "parameters": ["list", "of", "parameters", "needed"] + } + +NOTE interfacing with the communication protocol may change, +the current flow is the first iteration. We will mark where things +ought to be improved. +''' +EXECUTION_DAG = learning_observer.communication_protocol.util.generate_base_dag_for_student_reducer('student_event_counter', 'lo_toy_sba') + +''' +Add reducers to the module. + +`context`: TODO +`scope`: the granularity of event (by student, by student + document, etc) +`function`: the reducer function to run +`default` (optional): initial value to start with +''' +REDUCERS = [ + { + 'context': 'org.ets.sba', + # TODO scope is defined as a decorator on the function, why is + # is also defined here? + 'scope': Scope([KeyField.STUDENT]), + 'function': lo_toy_sba.reducers.student_event_counter, + 'default': {'count': 0} + } +] + +''' +Define pages created with Dash. +''' +DASH_PAGES = [ + { + 'MODULE': lo_toy_sba.dash_dashboard, + 'LAYOUT': lo_toy_sba.dash_dashboard.layout, + 'ASSETS': 'assets', + 'TITLE': 'Toy-SBA Module', + 'DESCRIPTION': 'Toy-SBA Module', + 'SUBPATH': 'lo-toy-sba', + 'CSS': [ + thirdparty_url("css/fontawesome_all.css") + ], + 'SCRIPTS': [ + static_url("liblo.js") + ] + } +] + +''' +Additional files we want included that come from a third part. +''' +THIRD_PARTY = { + "css/fontawesome_all.css": d.FONTAWESOME_CSS, + "webfonts/fa-solid-900.woff2": d.FONTAWESOME_WOFF2, + "webfonts/fa-solid-900.ttf": d.FONTAWESOME_TTF +} + +''' +The Course Dashboards are used to populate the modules +on the home screen. + +Note the icon uses Font Awesome v5 +''' +COURSE_DASHBOARDS = [{ + 'name': NAME, + 'url': "/lo_toy_sba/dash/lo-toy-sba", + "icon": { + "type": "fas", + "icon": "fa-play-circle" + } +}] diff --git a/modules/lo_toy_sba/lo_toy_sba/my_layout.py b/modules/lo_toy_sba/lo_toy_sba/my_layout.py new file mode 100644 index 000000000..18ae4275e --- /dev/null +++ b/modules/lo_toy_sba/lo_toy_sba/my_layout.py @@ -0,0 +1,41 @@ +from dash import html, dcc +import dash_bootstrap_components as dbc +import lo_dash_react_components as lodrc + +def my_layout(_websocket, _websocket_storage, _output): + ''' + This is the layout for the static part of your dashboard which + is loaded when the page first loads. + + * The data would be populated in a div with id _output. + * We pass the _websocket so we can render a component letting us know when things updated + * We pass the _websocket_storage, although we really should bubble that up. + ''' + page_layout = html.Div(children=[ + html.H1(children='Toy-SBA Module'), + dbc.InputGroup([ + dbc.InputGroupText(lodrc.LOConnectionStatusAIO(aio_id=_websocket)), + lodrc.ProfileSidebarAIO(class_name='rounded-0 rounded-end', color='secondary'), + ]), + dcc.Store(id=_websocket_storage), + html.H2('Output from reducers'), + html.Div(id=_output) + ]) + return page_layout + + +def my_data_layout(data): + ''' + This is the layout for the changing part of your dashboard + populated from the data. + ''' + if not data: + return 'No students' + output = [html.Div([ + lodrc.LONameTag( + profile=s['profile'], className='d-inline-block student-name-tag', + includeName=True, id=f'{s["user_id"]}-name-tag' + ), + html.Span(f' - {s["count"]} events') + ]) for s in data] + return output diff --git a/modules/lo_toy_sba/lo_toy_sba/reducers.py b/modules/lo_toy_sba/lo_toy_sba/reducers.py new file mode 100644 index 000000000..f34958ac7 --- /dev/null +++ b/modules/lo_toy_sba/lo_toy_sba/reducers.py @@ -0,0 +1,16 @@ +''' +This file defines reducers we wish to add to the incoming event +pipeline. The `learning_observer.stream_analytics` package includes +helper functions for Scoping the and setting the null state. +''' +from learning_observer.stream_analytics.helpers import student_event_reducer + + +@student_event_reducer(null_state={"count": 0}) +async def student_event_counter(event, internal_state): + ''' + An example of a per-student event counter + ''' + state = {"count": internal_state.get('count', 0) + 1} + + return state, state diff --git a/modules/lo_toy_sba/setup.cfg b/modules/lo_toy_sba/setup.cfg new file mode 100644 index 000000000..a354b3cf2 --- /dev/null +++ b/modules/lo_toy_sba/setup.cfg @@ -0,0 +1,10 @@ +[metadata] +name = Toy-SBA Module +description = Use this as a base template for creating new modules on the Learning Observer. + +[options] +packages = lo_toy_sba + +[options.entry_points] +lo_modules = + lo_toy_sba = lo_toy_sba.module diff --git a/modules/lo_toy_sba/setup.py b/modules/lo_toy_sba/setup.py new file mode 100644 index 000000000..2986da253 --- /dev/null +++ b/modules/lo_toy_sba/setup.py @@ -0,0 +1,14 @@ +''' +Install script. Everything is handled in setup.cfg + +To set up locally for development, run `python setup.py develop`, in a +virtualenv, preferably. +''' +from setuptools import setup + +setup( + name="lo_toy_sba", + package_data={ + 'lo_toy_sba': ['assets/*'], + } +) From feb65144444b93518ebfd6d547fdb8ec25ff5b3e Mon Sep 17 00:00:00 2001 From: Paul Brost Date: Thu, 16 Jan 2025 14:26:18 -0500 Subject: [PATCH 10/18] change to how state is returned in redux logger --- modules/lo_event/lo_event/reduxLogger.js | 1 + 1 file changed, 1 insertion(+) diff --git a/modules/lo_event/lo_event/reduxLogger.js b/modules/lo_event/lo_event/reduxLogger.js index 30bd8861e..49cbd337b 100644 --- a/modules/lo_event/lo_event/reduxLogger.js +++ b/modules/lo_event/lo_event/reduxLogger.js @@ -42,6 +42,7 @@ function debug_log(...args) { export function handleLoadState (data) { IS_LOADED = true; + const state = store.getState(); if (data) { setState( { From 6fea6357dde67f8624d9ac3ef62ff01b3b5df61c Mon Sep 17 00:00:00 2001 From: Paul Brost Date: Fri, 17 Jan 2025 11:31:00 -0500 Subject: [PATCH 11/18] changing reset logic --- modules/lo_event/lo_event/reduxLogger.js | 24 ++++++++++++++++++++ modules/lo_event/lo_event/websocketLogger.js | 2 ++ 2 files changed, 26 insertions(+) diff --git a/modules/lo_event/lo_event/reduxLogger.js b/modules/lo_event/lo_event/reduxLogger.js index 49cbd337b..903baf4fe 100644 --- a/modules/lo_event/lo_event/reduxLogger.js +++ b/modules/lo_event/lo_event/reduxLogger.js @@ -211,6 +211,21 @@ const reducer = (state = {}, action) => { if (action.redux_type === EMIT_EVENT) { payload = JSON.parse(action.payload); + console.log('@@@@ action.type: ', action.type); + if (action.type === "init_fetch_state") { + const reduxStoreID = payload.reduxID || "reduxStore"; + console.log('@@@@ fetch_blob dispatching'); + util.dispatchCustomEvent('fetch_blob', { blob_key_id: reduxStoreID }); + return { + ...state, + ...serverState, + settings: { + ...state.settings, + reduxStoreStatus: false, + reduxID: reduxStoreID, + } + }; + } debug_log(Object.keys(payload)); if (APPLICATION_REDUCERS[payload.event]) { @@ -265,6 +280,15 @@ function composeReducers(...reducers) { export function setState(state) { debug_log("Set state called"); + if (Object.keys(state).length === 0) { + const storeState = store.getState(); + state = { + settings: { + ...storeState.settings, + reduxStoreStatus: IS_LOADED + } + } + } store.dispatch(emitSetState(state)); } diff --git a/modules/lo_event/lo_event/websocketLogger.js b/modules/lo_event/lo_event/websocketLogger.js index 8724b72d7..f910810db 100644 --- a/modules/lo_event/lo_event/websocketLogger.js +++ b/modules/lo_event/lo_event/websocketLogger.js @@ -147,6 +147,8 @@ export function websocketLogger (server = {}) { util.dispatchCustomEvent(response.event_type, { detail: response.detail }); break; case 'fetch_blob': + console.log("@@@@ websocket fetch_blob dispatching"); + console.log({responsedata: response.data}); util.dispatchCustomEvent('fetch_blob', { detail: response.data }); break; default: From 92d56247fb4de15db1feed72655f93fe132095b4 Mon Sep 17 00:00:00 2001 From: Paul Brost Date: Fri, 17 Jan 2025 14:02:02 -0500 Subject: [PATCH 12/18] handle save_setting event in redux logger --- modules/lo_event/lo_event/reduxLogger.js | 12 +++--------- modules/lo_event/lo_event/websocketLogger.js | 1 - 2 files changed, 3 insertions(+), 10 deletions(-) diff --git a/modules/lo_event/lo_event/reduxLogger.js b/modules/lo_event/lo_event/reduxLogger.js index 903baf4fe..097875541 100644 --- a/modules/lo_event/lo_event/reduxLogger.js +++ b/modules/lo_event/lo_event/reduxLogger.js @@ -89,7 +89,7 @@ async function saveStateToServer(state) { } try { - util.dispatchCustomEvent('save_blob', { detail: state }); + store.dispatch('save_blob', { detail: state }); } catch (e) { // Ignore } @@ -211,18 +211,12 @@ const reducer = (state = {}, action) => { if (action.redux_type === EMIT_EVENT) { payload = JSON.parse(action.payload); - console.log('@@@@ action.type: ', action.type); - if (action.type === "init_fetch_state") { - const reduxStoreID = payload.reduxID || "reduxStore"; - console.log('@@@@ fetch_blob dispatching'); - util.dispatchCustomEvent('fetch_blob', { blob_key_id: reduxStoreID }); + if (action.type === "save_setting") { return { ...state, - ...serverState, settings: { ...state.settings, - reduxStoreStatus: false, - reduxID: reduxStoreID, + payload } }; } diff --git a/modules/lo_event/lo_event/websocketLogger.js b/modules/lo_event/lo_event/websocketLogger.js index f910810db..6bddedf81 100644 --- a/modules/lo_event/lo_event/websocketLogger.js +++ b/modules/lo_event/lo_event/websocketLogger.js @@ -147,7 +147,6 @@ export function websocketLogger (server = {}) { util.dispatchCustomEvent(response.event_type, { detail: response.detail }); break; case 'fetch_blob': - console.log("@@@@ websocket fetch_blob dispatching"); console.log({responsedata: response.data}); util.dispatchCustomEvent('fetch_blob', { detail: response.data }); break; From 0c0897c468eda5cc6729e43b77606f13c05e483f Mon Sep 17 00:00:00 2001 From: Bradley Erickson Date: Tue, 21 Jan 2025 09:41:32 -0500 Subject: [PATCH 13/18] code cleanup --- .../learning_observer/incoming_student_event.py | 2 -- modules/lo_event/lo_event/reduxLogger.js | 12 ++++++++++-- modules/lo_event/lo_event/websocketLogger.js | 1 - 3 files changed, 10 insertions(+), 5 deletions(-) diff --git a/learning_observer/learning_observer/incoming_student_event.py b/learning_observer/learning_observer/incoming_student_event.py index b08578647..b8a996b25 100644 --- a/learning_observer/learning_observer/incoming_student_event.py +++ b/learning_observer/learning_observer/incoming_student_event.py @@ -458,8 +458,6 @@ async def process_blob_storage_events(events): async for event in events: # Extract metadata if event['event'] in ['save_blob', 'fetch_blob']: - # TODO not 100% sure how auth/source/activity are stored - # in the event. That's why we have a print statement above user_id = event['auth']['user_id'] source = event['source'] activity = event['activity'] diff --git a/modules/lo_event/lo_event/reduxLogger.js b/modules/lo_event/lo_event/reduxLogger.js index 097875541..6ae5e50c4 100644 --- a/modules/lo_event/lo_event/reduxLogger.js +++ b/modules/lo_event/lo_event/reduxLogger.js @@ -40,6 +40,11 @@ function debug_log(...args) { } } +/** + * Update the redux logger's state with `data`. + * This is fired when consuming a custom `fetch_blob` + * event. + */ export function handleLoadState (data) { IS_LOADED = true; const state = store.getState(); @@ -82,6 +87,10 @@ async function saveStateToLocalStorage(state) { } } +/** + * Dispatch a `save_blob` event on the redux + * logger. + */ async function saveStateToServer(state) { if (!IS_LOADED) { debug_log('Not saving store on the server because IS_LOADED is set to false.') @@ -184,7 +193,7 @@ function set_state_reducer(state = {}, action) { const BASE_REDUCERS = { [EMIT_EVENT]: [store_last_event_reducer], [EMIT_LOCKFIELDS]: [lock_fields_reducer], - [EMIT_SET_STATE]: [set_state_reducer], + [EMIT_SET_STATE]: [set_state_reducer] } const APPLICATION_REDUCERS = {} @@ -336,7 +345,6 @@ export function reduxLogger (subscribers, initialState = null) { } function logEvent (event) { - debug_log("logEvent fired"); store.dispatch(emitEvent(event)); } logEvent.lo_name = 'Redux Logger'; // A human-friendly name for the logger diff --git a/modules/lo_event/lo_event/websocketLogger.js b/modules/lo_event/lo_event/websocketLogger.js index 6bddedf81..8724b72d7 100644 --- a/modules/lo_event/lo_event/websocketLogger.js +++ b/modules/lo_event/lo_event/websocketLogger.js @@ -147,7 +147,6 @@ export function websocketLogger (server = {}) { util.dispatchCustomEvent(response.event_type, { detail: response.detail }); break; case 'fetch_blob': - console.log({responsedata: response.data}); util.dispatchCustomEvent('fetch_blob', { detail: response.data }); break; default: From 6a01cf8db0fc80db87ac108b89cf78d97f0723ef Mon Sep 17 00:00:00 2001 From: Bradley Erickson Date: Tue, 21 Jan 2025 10:38:39 -0500 Subject: [PATCH 14/18] toy sba module cleanup --- modules/lo_toy_sba/MANIFEST.in | 1 - modules/lo_toy_sba/README.md | 142 +----------------- .../lo_toy_sba/lo_toy_sba/assets/scripts.js | 102 ------------- .../lo_toy_sba/lo_toy_sba/dash_dashboard.py | 77 ---------- modules/lo_toy_sba/lo_toy_sba/module.py | 73 ++++----- modules/lo_toy_sba/lo_toy_sba/my_layout.py | 41 ----- modules/lo_toy_sba/pyproject.toml | 3 + modules/lo_toy_sba/setup.cfg | 2 +- modules/lo_toy_sba/setup.py | 14 -- 9 files changed, 37 insertions(+), 418 deletions(-) delete mode 100644 modules/lo_toy_sba/lo_toy_sba/assets/scripts.js delete mode 100644 modules/lo_toy_sba/lo_toy_sba/dash_dashboard.py delete mode 100644 modules/lo_toy_sba/lo_toy_sba/my_layout.py create mode 100644 modules/lo_toy_sba/pyproject.toml delete mode 100644 modules/lo_toy_sba/setup.py diff --git a/modules/lo_toy_sba/MANIFEST.in b/modules/lo_toy_sba/MANIFEST.in index d7f3b21cc..e69de29bb 100644 --- a/modules/lo_toy_sba/MANIFEST.in +++ b/modules/lo_toy_sba/MANIFEST.in @@ -1 +0,0 @@ -include lo_toy_sba/assets/* diff --git a/modules/lo_toy_sba/README.md b/modules/lo_toy_sba/README.md index 9bb198541..38ccfe17d 100644 --- a/modules/lo_toy_sba/README.md +++ b/modules/lo_toy_sba/README.md @@ -1,140 +1,8 @@ -# Learning Observer Example Module +# LO Toy SBA -Welcome to the Learning Observer (LO) example module. This document -will detail everything need to create a module for the LO. +This module provides various functionality for using the Toy SBA code within the Learning Observer system. -## packaage structure +The included functionality -```bash -module/ - lo_toy_sba/ - assets/ - ... - helpers/ - additional_script.py - module.py - reducers.py - dash_dashboards.py - MANIFEST.in - setup.py - setup.cfg -``` - -### setup.py - -This is a standard `setup.py` file. - -### setup.cfg - -Notice we include the following items in our `setup.cfg` file. - -```cfg -[options.entry_points] -lo_modules = - lo_toy_sba = lo_toy_sba.module - -[options.package_data] -lo_toy_sba = helpers/* -``` - -The `lo_modules` entry point tells Learning Observer to treat `lo_toy_sba.module` as a pluggable application. - -The package data section is where we include additional directories we want included in the build. - -### MANIFEST.in - -The manifest specifies which files to include during Python packaging. This specifies the additional non-python files we want included. If you do not have additional files needed, this file is unnecessary. - -For modules with Dash-made dashboards, this will typically include a relative path to the assets folder. - -### module.py - -This file defines everything about the module. See the dedicated section below. - -## Defining a module (module.py) - -Modules can include a variety items. This will cover each item and its purpose on the system. - -### NAME - -This one is pretty self explanatory. Give the module a short name to refer to it by. - -### EXECUTION_DAG - -The execution directed acyclic graph (DAG) is how we interact with the communication protocol. - -See `lo_toy_sba/module.py:EXECUTION_DAG` for a detailed example. - -### REDUCERS - -Reducers to define on the system. These are functions that will run over incoming events from students. - -See `lo_toy_sba/module.py:REDUCERS` for a detailed example. - -### DASH_PAGES - -Dashboards built using the Dash framework should be defined here. - -See `lo_toy_sba/module.py:DASH_PAGES` for a detailed example. - -### COURSE_DASHBOARDS - -The registered course dashboards are provided to the users for navigating around dashboards, such as on their Home screen. - -See `lo_toy_sba/module.py:COURSE_DASHBOARDS` for a detailed example. - -Note that the student counterpart, `STUDENT_DASHBOARDS`, exists. - -### THIRD_PARTY - -The third party items are downloaded and included when serving items from the module. This is usually used for including extra Javascript or CSS files. - -```python -THIRD_PARTY = { - 'name_of_item': { - 'url': 'url_to_third_party_tool', - 'hash': 'hash_of_download_OR_dict_of_versions_and_hashes' - } -} -``` - -### STATIC_FILE_GIT_REPOS - -We're still figuring this out, but we'd like to support hosting static files from the git repo of the module. -This allows us to have a Merkle-tree style record of which version is deployed in our log files. - -A common use case for this is serving static `.html` and `.js` files for your module. - -```python -STATIC_FILE_GIT_REPOS = { - 'repo_name': { - 'url': 'url_to_repo', - 'prefix': 'relative/path/to/directory', - # Branches we serve. This can either be a whitelist (e.g. which ones - # are available) or a blacklist (e.g. which ones are blocked) - 'whitelist': ['master'] - } -} -``` - -### EXTRA_VIEWS - -These are extra views to publish to the user. Currently, we only support `.json` files. - -```python -EXTRA_VIEWS = [{ - 'name': 'Name of view', - 'suburl': 'view-suburl', - 'static_json': python_dictionary_to_return -}] -``` - -## Creating a reducer (reducers.py) - -Reducers are ran over incoming student events. They can be defined using a decorator in the `learning_observer.stream_analytics` module. - -Each reducer should take the incoming `event` and the previous `internal_state` as parameters and return 2 new state objects. - -## Creating dashboards with Dash (dash_dashboard.py) - -Dash pages consist of a layout and callback functions. See `dash_dashboard.py` for a more detailed overview. +1. Providing a stub reducer so the Toy SBA can save/fetch state - we need a reducer that matches the source of LO Event +1. Serve the Toy SBA built NextJS output - not yet implemented, instructions below diff --git a/modules/lo_toy_sba/lo_toy_sba/assets/scripts.js b/modules/lo_toy_sba/lo_toy_sba/assets/scripts.js deleted file mode 100644 index 4258a9b2b..000000000 --- a/modules/lo_toy_sba/lo_toy_sba/assets/scripts.js +++ /dev/null @@ -1,102 +0,0 @@ -/** - * Javascript callbacks to be used with the LO Example dashboard - */ - -// Initialize the `dash_clientside` object if it doesn't exist -if (!window.dash_clientside) { - window.dash_clientside = {}; -} - -window.dash_clientside.lo_toy_sba = { - /** - * Send updated queries to the communication protocol. - * @param {object} wsReadyState LOConnection status object - * @param {string} urlHash query string from hash for determining course id - * @returns stringified json object that is sent to the communication protocl - */ - sendToLOConnection: async function (wsReadyState, urlHash) { - if (wsReadyState === undefined) { - return window.dash_clientside.no_update - } - if (wsReadyState.readyState === 1) { - if (urlHash.length === 0) { return window.dash_clientside.no_update } - const decodedParams = decode_string_dict(urlHash.slice(1)) - if (!decodedParams.course_id) { return window.dash_clientside.no_update } - const outgoingMessage = { - lo_toy_sba_query: { - execution_dag: 'lo_toy_sba', - target_exports: ['student_event_counter_export'], - kwargs: decodedParams - } - }; - return JSON.stringify(outgoingMessage); - } - return window.dash_clientside.no_update; - }, - - /** - * Process a message from LOConnection - * @param {object} incomingMessage object received from LOConnection - * @returns parsed data to local storage - */ - receiveWSMessage: async function (incomingMessage) { - // TODO the naming here is broken serverside. Notice above we - // called the target export `student_event_counter_export`, i.e. the named - // export. Below, we need to call `lo_toy_sba_join_roster`, i.e. the name - // of the node. This ought to be cleaned up in the communication protocl. - const messageData = JSON.parse(incomingMessage.data).lo_toy_sba_query.student_event_counter_join_roster || []; - if (messageData.error !== undefined) { - console.error('Error received from server', messageData.error); - return []; - } - return messageData; - }, - - /** - * Build the student UI components based on the stored websocket data - * @param {*} wsStorageData information stored in the websocket store - * @returns Dash object to be displayed on page - */ - populateOutput: function(wsStorageData) { - if (!wsStorageData) { - return 'No students'; - } - let output = [] - // Iterate over students and create UI items for each - for (const student of wsStorageData) { - - // We define Dash components in JS via a dictionary - // of where the component lives, what it is, and any - // parameters we want to pass along to it. - // - `namespace`: the module the component is in - // - `type`: the component to use - // - `props`: any parameters the component expects - // The following produces a LONameTag and Span wrapped in a Div - studentBadge = { - namespace: 'dash_html_components', - type: 'Div', - props: { - children: [{ - namespace: 'lo_dash_react_components', - props: { - profile: student.profile, - className: 'student-name-tag d-inline-block', - includeName: true, - id: `${student.user_id}-activity-img` - }, - type: 'LONameTag' - },{ - namespace: 'dash_html_components', - props: { - children: ` - ${student.count} events`, - }, - type: 'Span' - - }] - } - } - output = output.concat(studentBadge) - } - return output; - } -} diff --git a/modules/lo_toy_sba/lo_toy_sba/dash_dashboard.py b/modules/lo_toy_sba/lo_toy_sba/dash_dashboard.py deleted file mode 100644 index 200c3646b..000000000 --- a/modules/lo_toy_sba/lo_toy_sba/dash_dashboard.py +++ /dev/null @@ -1,77 +0,0 @@ -''' -This file will detail how to build a dashboard using -the Dash framework. - -If you are unfamiliar with Dash, it compiles python code -to react and serves it via a Flask server. You can register -callbacks to run when specific states change. Normal callbacks -execute Python code server side, but Clientside callbacks -execute Javascript code client side. Clientside functions are -preferred as it cuts down server and network resources. - -This file contains the hard stuff. You'll need to understand -this if you want to build dynamic, interactive dashboards. For -most simple dashboards, we tossed everything you need into -my_layout. -''' -from dash import html, dcc, callback, clientside_callback, ClientsideFunction, Output, Input -import dash_bootstrap_components as dbc -import lo_dash_react_components as lodrc - -from .my_layout import my_layout, my_data_layout - -_prefix = 'lo-toy-sba' -_namespace = 'lo_toy_sba' -_websocket = f'{_prefix}-websocket' -_websocket_storage = f'{_prefix}-websocket-store' -_output = f'{_prefix}-output' - -def layout(): - ''' - Function to define the page's layout. - ''' - return my_layout(_websocket, _websocket_storage, _output) - -# Send the initial state based on the url hash to LO. -# If this is not included, nothing will be returned from -# the communication protocol. -clientside_callback( - ClientsideFunction(namespace=_namespace, function_name='sendToLOConnection'), - Output(lodrc.LOConnectionStatusAIO.ids.websocket(_websocket), 'send'), - Input(lodrc.LOConnectionStatusAIO.ids.websocket(_websocket), 'state'), # used for initial setup - Input('_pages_location', 'hash') -) - -# Handle receiving a message from the websocket. -# This step will parse the message and update the -# local storage accordingly. -clientside_callback( - ClientsideFunction(namespace=_namespace, function_name='receiveWSMessage'), - Output(_websocket_storage, 'data'), - Input(lodrc.LOConnectionStatusAIO.ids.websocket(_websocket), 'message'), - prevent_initial_call=True -) - -# Build the UI based on what we've received from the -# communicaton protocol -# This clientside callback and the serverside callback below are -# the same -# clientside_callback( -# ClientsideFunction(namespace=_namespace, function_name='populateOutput'), -# Output(_output, 'children'), -# Input(_websocket_storage, 'data'), -# ) - - -@callback( - Output(_output, 'children'), - Input(_websocket_storage, 'data'), -) -def populate_output(data): - '''This method creates UI components for each student found - in the websocket's storage. - - This will use more network traffic and server resources - than using the equivalent clientside callback, `populateOutput`. - ''' - return my_data_layout(data) diff --git a/modules/lo_toy_sba/lo_toy_sba/module.py b/modules/lo_toy_sba/lo_toy_sba/module.py index 6fcb1b723..0cbdf2b76 100644 --- a/modules/lo_toy_sba/lo_toy_sba/module.py +++ b/modules/lo_toy_sba/lo_toy_sba/module.py @@ -3,13 +3,10 @@ Toy-SBA Module ''' -import learning_observer.downloads as d import learning_observer.communication_protocol.util -from learning_observer.dash_integration import thirdparty_url, static_url from learning_observer.stream_analytics.helpers import KeyField, Scope import lo_toy_sba.reducers -import lo_toy_sba.dash_dashboard # Name for the module NAME = 'Toy-SBA Module' @@ -34,18 +31,14 @@ EXECUTION_DAG = learning_observer.communication_protocol.util.generate_base_dag_for_student_reducer('student_event_counter', 'lo_toy_sba') ''' -Add reducers to the module. - -`context`: TODO -`scope`: the granularity of event (by student, by student + document, etc) -`function`: the reducer function to run -`default` (optional): initial value to start with +This is a simple reducer we use to ensure events are +passed into the event pipeline to save/fetch state. +We need a reducer whose context matches the source of +a page using LO Event. ''' REDUCERS = [ { 'context': 'org.ets.sba', - # TODO scope is defined as a decorator on the function, why is - # is also defined here? 'scope': Scope([KeyField.STUDENT]), 'function': lo_toy_sba.reducers.student_event_counter, 'default': {'count': 0} @@ -53,45 +46,35 @@ ] ''' -Define pages created with Dash. +Which pages to link on the home page. ''' -DASH_PAGES = [ - { - 'MODULE': lo_toy_sba.dash_dashboard, - 'LAYOUT': lo_toy_sba.dash_dashboard.layout, - 'ASSETS': 'assets', - 'TITLE': 'Toy-SBA Module', - 'DESCRIPTION': 'Toy-SBA Module', - 'SUBPATH': 'lo-toy-sba', - 'CSS': [ - thirdparty_url("css/fontawesome_all.css") - ], - 'SCRIPTS': [ - static_url("liblo.js") - ] - } +COURSE_DASHBOARDS = [ + # { + # 'name': NAME, + # 'url': "/lo_toy_sba/toy-sba/", + # "icon": { + # "type": "fas", + # "icon": "fa-play-circle" + # } + # } ] + ''' -Additional files we want included that come from a third part. +Additional API calls we can define, this one returns the colors of the rainbow ''' -THIRD_PARTY = { - "css/fontawesome_all.css": d.FONTAWESOME_CSS, - "webfonts/fa-solid-900.woff2": d.FONTAWESOME_WOFF2, - "webfonts/fa-solid-900.ttf": d.FONTAWESOME_TTF -} +EXTRA_VIEWS = [ + # { + # 'name': 'Colors of the Rainbow', + # 'suburl': 'api/llm', + # 'method': 'POST', + # 'handler': function_to_call + # } +] ''' -The Course Dashboards are used to populate the modules -on the home screen. - -Note the icon uses Font Awesome v5 +Built NextJS pages we want to serve. ''' -COURSE_DASHBOARDS = [{ - 'name': NAME, - 'url': "/lo_toy_sba/dash/lo-toy-sba", - "icon": { - "type": "fas", - "icon": "fa-play-circle" - } -}] +NEXTJS_PAGES = [ + # {'path': 'toy_sba/'} +] \ No newline at end of file diff --git a/modules/lo_toy_sba/lo_toy_sba/my_layout.py b/modules/lo_toy_sba/lo_toy_sba/my_layout.py deleted file mode 100644 index 18ae4275e..000000000 --- a/modules/lo_toy_sba/lo_toy_sba/my_layout.py +++ /dev/null @@ -1,41 +0,0 @@ -from dash import html, dcc -import dash_bootstrap_components as dbc -import lo_dash_react_components as lodrc - -def my_layout(_websocket, _websocket_storage, _output): - ''' - This is the layout for the static part of your dashboard which - is loaded when the page first loads. - - * The data would be populated in a div with id _output. - * We pass the _websocket so we can render a component letting us know when things updated - * We pass the _websocket_storage, although we really should bubble that up. - ''' - page_layout = html.Div(children=[ - html.H1(children='Toy-SBA Module'), - dbc.InputGroup([ - dbc.InputGroupText(lodrc.LOConnectionStatusAIO(aio_id=_websocket)), - lodrc.ProfileSidebarAIO(class_name='rounded-0 rounded-end', color='secondary'), - ]), - dcc.Store(id=_websocket_storage), - html.H2('Output from reducers'), - html.Div(id=_output) - ]) - return page_layout - - -def my_data_layout(data): - ''' - This is the layout for the changing part of your dashboard - populated from the data. - ''' - if not data: - return 'No students' - output = [html.Div([ - lodrc.LONameTag( - profile=s['profile'], className='d-inline-block student-name-tag', - includeName=True, id=f'{s["user_id"]}-name-tag' - ), - html.Span(f' - {s["count"]} events') - ]) for s in data] - return output diff --git a/modules/lo_toy_sba/pyproject.toml b/modules/lo_toy_sba/pyproject.toml new file mode 100644 index 000000000..1b68d94ec --- /dev/null +++ b/modules/lo_toy_sba/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["setuptools>=42", "wheel"] +build-backend = "setuptools.build_meta" \ No newline at end of file diff --git a/modules/lo_toy_sba/setup.cfg b/modules/lo_toy_sba/setup.cfg index a354b3cf2..9c06f7fa1 100644 --- a/modules/lo_toy_sba/setup.cfg +++ b/modules/lo_toy_sba/setup.cfg @@ -1,6 +1,6 @@ [metadata] name = Toy-SBA Module -description = Use this as a base template for creating new modules on the Learning Observer. +description = Module for serving the Toy SBA work. [options] packages = lo_toy_sba diff --git a/modules/lo_toy_sba/setup.py b/modules/lo_toy_sba/setup.py deleted file mode 100644 index 2986da253..000000000 --- a/modules/lo_toy_sba/setup.py +++ /dev/null @@ -1,14 +0,0 @@ -''' -Install script. Everything is handled in setup.cfg - -To set up locally for development, run `python setup.py develop`, in a -virtualenv, preferably. -''' -from setuptools import setup - -setup( - name="lo_toy_sba", - package_data={ - 'lo_toy_sba': ['assets/*'], - } -) From fa9f587b059eb7e9d5c9991838db1eab9fd98a77 Mon Sep 17 00:00:00 2001 From: Bradley Erickson Date: Tue, 21 Jan 2025 10:40:07 -0500 Subject: [PATCH 15/18] small styling changes --- modules/lo_toy_sba/README.md | 2 +- modules/lo_toy_sba/lo_toy_sba/module.py | 2 +- modules/lo_toy_sba/pyproject.toml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/modules/lo_toy_sba/README.md b/modules/lo_toy_sba/README.md index 38ccfe17d..377245633 100644 --- a/modules/lo_toy_sba/README.md +++ b/modules/lo_toy_sba/README.md @@ -5,4 +5,4 @@ This module provides various functionality for using the Toy SBA code within the The included functionality 1. Providing a stub reducer so the Toy SBA can save/fetch state - we need a reducer that matches the source of LO Event -1. Serve the Toy SBA built NextJS output - not yet implemented, instructions below +1. Serve the Toy SBA built NextJS output - not yet implemented diff --git a/modules/lo_toy_sba/lo_toy_sba/module.py b/modules/lo_toy_sba/lo_toy_sba/module.py index 0cbdf2b76..6d26ffeaa 100644 --- a/modules/lo_toy_sba/lo_toy_sba/module.py +++ b/modules/lo_toy_sba/lo_toy_sba/module.py @@ -77,4 +77,4 @@ ''' NEXTJS_PAGES = [ # {'path': 'toy_sba/'} -] \ No newline at end of file +] diff --git a/modules/lo_toy_sba/pyproject.toml b/modules/lo_toy_sba/pyproject.toml index 1b68d94ec..8fe2f47af 100644 --- a/modules/lo_toy_sba/pyproject.toml +++ b/modules/lo_toy_sba/pyproject.toml @@ -1,3 +1,3 @@ [build-system] requires = ["setuptools>=42", "wheel"] -build-backend = "setuptools.build_meta" \ No newline at end of file +build-backend = "setuptools.build_meta" From 07dfb2692d39003cd3ccb9daa68402c027c4e6fb Mon Sep 17 00:00:00 2001 From: Paul Brost Date: Wed, 22 Jan 2025 12:43:28 -0500 Subject: [PATCH 16/18] debug logic for save state added --- modules/lo_event/lo_event/reduxLogger.js | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/modules/lo_event/lo_event/reduxLogger.js b/modules/lo_event/lo_event/reduxLogger.js index 6ae5e50c4..55f281ad9 100644 --- a/modules/lo_event/lo_event/reduxLogger.js +++ b/modules/lo_event/lo_event/reduxLogger.js @@ -98,9 +98,12 @@ async function saveStateToServer(state) { } try { + console.log("dispatching save_blob") store.dispatch('save_blob', { detail: state }); } catch (e) { // Ignore + console.log("Error in dispatch"); + console.log({e:e}); } } @@ -300,6 +303,9 @@ const debouncedSaveStateToLocalStorage = debounce((state) => { }, 1000); const debouncedSaveStateToServer = debounce((state) => { + console.log("****************************************"); + console.log("Saving state to server"); + console.log("****************************************"); saveStateToServer(state); }, 1000); From ebde4978443e4bf651cc25ade4681f8d1f3fd86e Mon Sep 17 00:00:00 2001 From: Paul Brost Date: Wed, 22 Jan 2025 15:03:37 -0500 Subject: [PATCH 17/18] save_blob fix --- modules/lo_event/lo_event/reduxLogger.js | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/modules/lo_event/lo_event/reduxLogger.js b/modules/lo_event/lo_event/reduxLogger.js index 55f281ad9..a2f005621 100644 --- a/modules/lo_event/lo_event/reduxLogger.js +++ b/modules/lo_event/lo_event/reduxLogger.js @@ -98,8 +98,9 @@ async function saveStateToServer(state) { } try { - console.log("dispatching save_blob") - store.dispatch('save_blob', { detail: state }); + //console.log("dispatching save_blob") + util.dispatchCustomEvent('save_blob', { detail: state }); + //store.dispatch('save_blob', { detail: state }); } catch (e) { // Ignore console.log("Error in dispatch"); From b30dee49406f9e827b57b1e7445622b348822e97 Mon Sep 17 00:00:00 2001 From: Bradley Erickson Date: Tue, 28 Jan 2025 09:46:19 -0500 Subject: [PATCH 18/18] cleaned up console statements and linted reduxlogger --- modules/lo_event/lo_event/reduxLogger.js | 71 +++++++++++------------- 1 file changed, 32 insertions(+), 39 deletions(-) diff --git a/modules/lo_event/lo_event/reduxLogger.js b/modules/lo_event/lo_event/reduxLogger.js index a2f005621..9dd53f33f 100644 --- a/modules/lo_event/lo_event/reduxLogger.js +++ b/modules/lo_event/lo_event/reduxLogger.js @@ -20,8 +20,8 @@ */ import * as redux from 'redux'; import { thunk } from 'redux-thunk'; -import { createStateSyncMiddleware, initMessageListener } from "redux-state-sync"; -import debounce from "lodash/debounce"; +import { createStateSyncMiddleware, initMessageListener } from 'redux-state-sync'; +import debounce from 'lodash/debounce'; import * as util from './util.js'; @@ -34,8 +34,8 @@ let IS_LOADED = false; // TODO: Import debugLog and use those functions. const DEBUG = false; -function debug_log(...args) { - if(DEBUG) { +function debug_log (...args) { + if (DEBUG) { console.log(...args); } } @@ -57,9 +57,9 @@ export function handleLoadState (data) { ...state.settings, reduxStoreStatus: IS_LOADED } - }); + }); } else { - debug_log('No data provided while handling state from server, continuing.') + debug_log('No data provided while handling state from server, continuing.'); setState( { ...state, @@ -67,21 +67,20 @@ export function handleLoadState (data) { ...state.settings, reduxStoreStatus: IS_LOADED } - }); + }); } } -async function saveStateToLocalStorage(state) { +async function saveStateToLocalStorage (state) { if (!IS_LOADED) { - debug_log('Not saving store locally because IS_LOADED is set to false.') + debug_log('Not saving store locally because IS_LOADED is set to false.'); return; } try { - const KEY = state?.settings?.reduxID || "redux"; + const KEY = state?.settings?.reduxID || 'redux'; const serializedState = JSON.stringify(state); localStorage.setItem(KEY, serializedState); - } catch (e) { // Ignore } @@ -91,20 +90,19 @@ async function saveStateToLocalStorage(state) { * Dispatch a `save_blob` event on the redux * logger. */ -async function saveStateToServer(state) { +async function saveStateToServer (state) { if (!IS_LOADED) { - debug_log('Not saving store on the server because IS_LOADED is set to false.') + debug_log('Not saving store on the server because IS_LOADED is set to false.'); return; } try { - //console.log("dispatching save_blob") + // console.log("dispatching save_blob") util.dispatchCustomEvent('save_blob', { detail: state }); - //store.dispatch('save_blob', { detail: state }); + // store.dispatch('save_blob', { detail: state }); } catch (e) { // Ignore - console.log("Error in dispatch"); - console.log({e:e}); + debug_log('Error in dispatch', { e }); } } @@ -139,11 +137,11 @@ const emitSetState = (state) => { }; }; -function store_last_event_reducer(state = {}, action) { +function store_last_event_reducer (state = {}, action) { return { ...state, event: action.payload }; }; -function lock_fields_reducer(state = {}, action) { +function lock_fields_reducer (state = {}, action) { const payload = JSON.parse(action.payload); return { ...state, @@ -190,7 +188,7 @@ export const updateComponentStateReducer = ({}) => (state = initialState, action return new_state; } -function set_state_reducer(state = {}, action) { +function set_state_reducer (state = {}, action) { return action.payload; } @@ -200,16 +198,16 @@ const BASE_REDUCERS = { [EMIT_SET_STATE]: [set_state_reducer] } -const APPLICATION_REDUCERS = {} +const APPLICATION_REDUCERS = {}; export const registerReducer = (keys, reducer) => { const reducerKeys = Array.isArray(keys) ? keys : [keys]; reducerKeys.forEach(key => { - debug_log("registering key: " + key); - if (!APPLICATION_REDUCERS[key]) + debug_log('registering key: ' + key); + if (!APPLICATION_REDUCERS[key]) { APPLICATION_REDUCERS[key] = []; - + } APPLICATION_REDUCERS[key].push(reducer); }); return reducer; @@ -224,7 +222,7 @@ const reducer = (state = {}, action) => { if (action.redux_type === EMIT_EVENT) { payload = JSON.parse(action.payload); - if (action.type === "save_setting") { + if (action.type === 'save_setting') { return { ...state, settings: { @@ -251,11 +249,11 @@ const composeEnhancers = (typeof window !== 'undefined' && window.__REDUX_DEVTOO // // This shows up as an error in the test case. If the error goes away, we should switch this // back to thunk. -//const presistedState = loadState(); +// const presistedState = loadState(); export let store = redux.createStore( reducer, - {event: null}, // Base state + { event: null }, // Base state composeEnhancers(redux.applyMiddleware((thunk.default || thunk), createStateSyncMiddleware())) ); @@ -286,7 +284,7 @@ function composeReducers(...reducers) { } export function setState(state) { - debug_log("Set state called"); + debug_log('Set state called'); if (Object.keys(state).length === 0) { const storeState = store.getState(); state = { @@ -294,7 +292,7 @@ export function setState(state) { ...storeState.settings, reduxStoreStatus: IS_LOADED } - } + }; } store.dispatch(emitSetState(state)); } @@ -304,21 +302,16 @@ const debouncedSaveStateToLocalStorage = debounce((state) => { }, 1000); const debouncedSaveStateToServer = debounce((state) => { - console.log("****************************************"); - console.log("Saving state to server"); - console.log("****************************************"); saveStateToServer(state); }, 1000); function initializeStore () { store.subscribe(() => { const state = store.getState(); - debouncedSaveStateToLocalStorage(state); - debouncedSaveStateToServer(state); - // we use debounce to save the state once every second // for better performances in case multiple changes occur in a short time - //debouncedSaveState(state); + debouncedSaveStateToLocalStorage(state); + debouncedSaveStateToServer(state); if (state.lock_fields) { lockFields = state.lock_fields.fields; @@ -367,9 +360,9 @@ export function reduxLogger (subscribers, initialState = null) { logEvent.getLockFields = function () { return lockFields; }; - //do we want to initialize the store here? We set it to the stored state in create store - //if (initialState) { - //} + // do we want to initialize the store here? We set it to the stored state in create store + // if (initialState) { + // } return logEvent; }