Skip to content

Commit

Permalink
Separate backend and frontend settings
Browse files Browse the repository at this point in the history
  • Loading branch information
lhvy committed Aug 18, 2024
1 parent 94c3629 commit 7d1cea1
Show file tree
Hide file tree
Showing 14 changed files with 136 additions and 32 deletions.
5 changes: 5 additions & 0 deletions backend/server/db/helpers/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,9 @@ class UserPlannerStorage(BaseModel):
isSummerEnabled: bool
years: List[PlannerYear]
lockedTerms: Dict[str, bool]

class UserSettingsStorage(BaseModel):
showMarks: bool

class _BaseUserStorage(BaseModel):
# NOTE: could also put uid here if we want
Expand All @@ -49,6 +52,7 @@ class UserStorage(_BaseUserStorage):
degree: UserDegreeStorage
courses: UserCoursesStorage
planner: UserPlannerStorage
settings: UserSettingsStorage

class NotSetupUserStorage(_BaseUserStorage):
setup: Literal[False] = False
Expand All @@ -60,6 +64,7 @@ class PartialUserStorage(BaseModel):
degree: Optional[UserDegreeStorage] = None
courses: Optional[UserCoursesStorage] = None
planner: Optional[UserPlannerStorage] = None
settings: Optional[UserSettingsStorage] = None

#
# Session Token Models (redis)
Expand Down
21 changes: 18 additions & 3 deletions backend/server/db/helpers/users.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
from server.db.mongo.constants import UID_INDEX_NAME
from server.db.mongo.conn import usersCOL

from .models import NotSetupUserStorage, PartialUserStorage, UserCoursesStorage, UserDegreeStorage, UserPlannerStorage, UserStorage
from .models import NotSetupUserStorage, PartialUserStorage, UserCoursesStorage, UserDegreeStorage, UserPlannerStorage, UserSettingsStorage, UserStorage

# TODO-OLLI(pm): decide if we want to remove type ignores by constructing dictionaries manually

Expand Down Expand Up @@ -121,14 +121,29 @@ def update_user_planner(uid: str, data: UserPlannerStorage) -> bool:

return res.matched_count == 1

def update_user_settings(uid: str, data: UserSettingsStorage) -> bool:
res = usersCOL.update_one(
{ "uid": uid, "setup": True },
{
"$set": {
"settings": data.model_dump(),
},
},
upsert=False,
hint=UID_INDEX_NAME,
)

return res.matched_count == 1

def update_user(uid: str, data: PartialUserStorage) -> bool:
# updates certain properties of the user
# if enough are given, declares it as setup
fields = { "courses", "degree", "planner", "settings" }
payload = {
k: v
for k, v
in data.model_dump(
include={ "courses", "degree", "planner" },
include=fields,
exclude_unset=True,
).items()
if v is not None # cannot exclude_none since subclasses use None
Expand All @@ -138,7 +153,7 @@ def update_user(uid: str, data: PartialUserStorage) -> bool:
# most semantically correct
return user_is_setup(uid)

if "courses" in payload and "degree" in payload and "planner" in payload:
if fields.issubset(payload.keys()):
# enough to declare user as setup
payload["setup"] = True

Expand Down
4 changes: 4 additions & 0 deletions backend/server/db/mongo/col_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,10 +62,14 @@ class UserPlannerInfoDict(TypedDict):
years: List[PlannerYearDict]
lockedTerms: Dict[str, bool]

class UserSettingsInfoDict(TypedDict):
showMarks: bool

class UserInfoDict(TypedDict):
uid: str
setup: Literal[True]
guest: bool
degree: UserDegreeInfoDict
courses: Dict[str, UserCourseInfoDict]
planner: UserPlannerInfoDict
settings: UserSettingsInfoDict
13 changes: 12 additions & 1 deletion backend/server/db/mongo/setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ def _create_users_collection():
},
{
'bsonType': 'object',
'required': ['uid', 'setup', 'guest', 'degree', 'planner', 'courses'],
'required': ['uid', 'setup', 'guest', 'degree', 'planner', 'courses', 'settings'],
'additionalProperties': False,
'properties': {
'_id': { 'bsonType': 'objectId' },
Expand Down Expand Up @@ -159,6 +159,17 @@ def _create_users_collection():
},
}
}
},
'settings': {
'bsonType': 'object',
'required': ['showMarks'],
'additionalProperties': False,
'properties': {
'showMarks': {
'bsonType': 'bool',
'description': 'Whether to show marks in the Term Planner'
}
}
}
}
}
Expand Down
6 changes: 6 additions & 0 deletions backend/server/routers/model.py
Original file line number Diff line number Diff line change
Expand Up @@ -248,11 +248,17 @@ class CourseStorageWithExtra(TypedDict):
isMultiterm: bool
ignoreFromProgression: bool

class SettingsStorage(BaseModel):
model_config = ConfigDict(extra='forbid')

showMarks: bool

@with_config(ConfigDict(extra='forbid'))
class Storage(TypedDict):
degree: DegreeLocalStorage
planner: PlannerLocalStorage
courses: dict[str, CourseStorage]
settings: SettingsStorage

class LocalStorage(BaseModel):
model_config = ConfigDict(extra='forbid')
Expand Down
28 changes: 24 additions & 4 deletions backend/server/routers/user.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,12 @@

from server.routers.auth_utility.middleware import HTTPBearerToUserID
from server.routers.courses import get_course
from server.routers.model import CourseMark, CourseStorage, DegreeLength, DegreeWizardInfo, StartYear, CourseStorageWithExtra, DegreeLocalStorage, LocalStorage, PlannerLocalStorage, Storage, SpecType
from server.routers.model import CourseMark, CourseStorage, DegreeLength, DegreeWizardInfo, SettingsStorage, StartYear, CourseStorageWithExtra, DegreeLocalStorage, LocalStorage, PlannerLocalStorage, Storage, SpecType
from server.routers.programs import get_programs
from server.routers.specialisations import get_specialisation_types, get_specialisations

import server.db.helpers.users as udb
from server.db.helpers.models import PartialUserStorage, UserStorage as NEWUserStorage, UserDegreeStorage as NEWUserDegreeStorage, UserPlannerStorage as NEWUserPlannerStorage, UserCoursesStorage as NEWUserCoursesStorage, UserCourseStorage as NEWUserCourseStorage
from server.db.helpers.models import PartialUserStorage, UserStorage as NEWUserStorage, UserDegreeStorage as NEWUserDegreeStorage, UserPlannerStorage as NEWUserPlannerStorage, UserCoursesStorage as NEWUserCoursesStorage, UserCourseStorage as NEWUserCourseStorage, UserSettingsStorage as NEWUserSettingsStorage


router = APIRouter(
Expand All @@ -31,6 +31,9 @@ def _otn_degree(s: DegreeLocalStorage) -> NEWUserDegreeStorage:
def _otn_courses(s: dict[str, CourseStorage]) -> NEWUserCoursesStorage:
return { code: NEWUserCourseStorage.model_validate(info) for code, info in s.items() }

def _otn_settings(s: SettingsStorage) -> NEWUserSettingsStorage:
return NEWUserSettingsStorage.model_validate(s.model_dump())

def _nto_courses(s: NEWUserCoursesStorage) -> dict[str, CourseStorage]:
return {
code: {
Expand Down Expand Up @@ -60,12 +63,16 @@ def _nto_degree(s: NEWUserDegreeStorage) -> DegreeLocalStorage:
'programCode': s.programCode,
'specs': s.specs,
}

def _nto_settings(s: NEWUserSettingsStorage) -> SettingsStorage:
return SettingsStorage(showMarks=s.showMarks)

def _nto_storage(s: NEWUserStorage) -> Storage:
return {
'courses': _nto_courses(s.courses),
'degree': _nto_degree(s.degree),
'planner': _nto_planner(s.planner),
'settings': _nto_settings(s.settings),
}


Expand All @@ -92,6 +99,7 @@ def set_user(uid: str, item: Storage, overwrite: bool = False):
courses=_otn_courses(item['courses']),
degree=_otn_degree(item['degree']),
planner=_otn_planner(item['planner']),
settings=_otn_settings(item['settings']),
))

assert res
Expand All @@ -117,7 +125,8 @@ def save_local_storage(localStorage: LocalStorage, uid: Annotated[str, Security(
item: Storage = {
'degree': localStorage.degree,
'planner': real_planner,
'courses': courses
'courses': courses,
'settings': SettingsStorage(showMarks=False)
}
set_user(uid, item)

Expand Down Expand Up @@ -174,6 +183,16 @@ def get_user_p(uid: Annotated[str, Security(require_uid)]) -> Dict[str, CourseSt

return res

@router.get("/data/settings")
def get_user_settings(uid: Annotated[str, Security(require_uid)]) -> SettingsStorage:
return get_setup_user(uid)['settings']

@router.post("/settings/toggleShowMarks")
def toggle_show_marks(uid: Annotated[str, Security(require_uid)]):
user = get_setup_user(uid)
user['settings'].showMarks = not user['settings'].showMarks
set_user(uid, user, True)

@router.post("/toggleSummerTerm")
def toggle_summer_term(uid: Annotated[str, Security(require_uid)]):
user = get_setup_user(uid)
Expand Down Expand Up @@ -339,7 +358,8 @@ def setup_degree_wizard(wizard: DegreeWizardInfo, uid: Annotated[str, Security(r
'specs': wizard.specs,
},
'planner': planner,
'courses': {}
'courses': {},
'settings': SettingsStorage(showMarks=False),
}
set_user(uid, user, True)
return user
4 changes: 2 additions & 2 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,12 +30,12 @@ const ProgressionChecker = React.lazy(() => import('./pages/ProgressionChecker')
const TermPlanner = React.lazy(() => import('./pages/TermPlanner'));

const App = () => {
const { theme } = useSettings();

const [queryClient] = React.useState(
() => new QueryClient({ defaultOptions: { queries: { refetchOnWindowFocus: false } } })
);

const { theme } = useSettings(queryClient);

useEffect(() => {
// using local storage since I don't want to risk invalidating the redux state right now
const cooldownMs = 1000 * 60 * 60 * 24 * 7; // every 7 days
Expand Down
1 change: 1 addition & 0 deletions frontend/src/components/FeedbackButton/FeedbackButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ const FeedbackButton = () => {
const openFeedbackLink = () => {
window.open(FEEDBACK_LINK, '_blank');
};

const { theme } = useSettings();

// Move this to the drawer if the screen is too small
Expand Down
50 changes: 40 additions & 10 deletions frontend/src/hooks/useSettings.ts
Original file line number Diff line number Diff line change
@@ -1,38 +1,68 @@
import { useCallback } from 'react';
import { QueryClient, useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { getUserSettings, toggleShowMarks as toggleShowMarksApi } from 'utils/api/userApi';
import { useAppDispatch, useAppSelector } from 'hooks';
import {
toggleLockedCourses as toggleLocked,
toggleShowMarks as toggleMarks,
toggleShowPastWarnings as togglePastWarnings,
toggleTheme
} from 'reducers/settingsSlice';
import useToken from './useToken';

type Theme = 'light' | 'dark';

interface Settings {
theme: string;
theme: Theme;
showMarks: boolean;
showLockedCourses: boolean;
showPastWarnings: boolean;
mutateTheme: (theme: 'light' | 'dark') => void;
mutateTheme: (theme: Theme) => void;
toggleShowMarks: () => void;
toggleLockedCourses: () => void;
toggleShowPastWarnings: () => void;
}

function useSettings(): Settings {
const settings = useAppSelector((state) => state.settings);
function useSettings(queryClient?: QueryClient): Settings {
const localSettings = useAppSelector((state) => state.settings);
const dispatch = useAppDispatch();

const mutateTheme = useCallback(
(theme: 'light' | 'dark') => dispatch(toggleTheme(theme)),
[dispatch]
const token = useToken({ allowUnset: true });
const realQueryClient = useQueryClient(queryClient);
const settingsQuery = useQuery(
{
queryKey: ['settings'],
queryFn: () => getUserSettings(token!),
placeholderData: { showMarks: false },
enabled: !!token
},
queryClient
);
const userSettings = settingsQuery.data ?? {
showMarks: false
};

const showMarksMutation = useMutation(
{
mutationFn: () => (token ? toggleShowMarksApi(token) : Promise.reject(new Error('No token'))),
onSuccess: () => {
realQueryClient.invalidateQueries({ queryKey: ['settings'] });
},
onError: (error) => {
// eslint-disable-next-line no-console
console.error('Error toggling show marks: ', error);
}
},
queryClient
);

const toggleShowMarks = useCallback(() => dispatch(toggleMarks()), [dispatch]);
const mutateTheme = useCallback((theme: Theme) => dispatch(toggleTheme(theme)), [dispatch]);
const toggleLockedCourses = useCallback(() => dispatch(toggleLocked()), [dispatch]);
const toggleShowPastWarnings = useCallback(() => dispatch(togglePastWarnings()), [dispatch]);
const toggleShowMarks = showMarksMutation.mutate;

return {
...settings,
...userSettings,
...localSettings,
mutateTheme,
toggleShowMarks,
toggleLockedCourses,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@ const preloadedState: RootState = {
},
settings: {
theme: 'dark',
showMarks: false,
showLockedCourses: false,
showPastWarnings: false
},
Expand Down
10 changes: 2 additions & 8 deletions frontend/src/reducers/settingsSlice.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,13 @@ import { createSlice } from '@reduxjs/toolkit';
type Theme = 'light' | 'dark';

type SettingsSliceState = {
theme: string;
showMarks: boolean;
theme: Theme;
showLockedCourses: boolean;
showPastWarnings: boolean;
};

export const initialSettingsState: SettingsSliceState = {
theme: 'light',
showMarks: false,
showLockedCourses: false,
showPastWarnings: true
};
Expand All @@ -24,9 +22,6 @@ const settingsSlice = createSlice({
toggleTheme: (state, action: PayloadAction<Theme>) => {
state.theme = action.payload;
},
toggleShowMarks: (state) => {
state.showMarks = !state.showMarks;
},
toggleLockedCourses: (state) => {
state.showLockedCourses = !state.showLockedCourses;
},
Expand All @@ -36,7 +31,6 @@ const settingsSlice = createSlice({
}
});

export const { toggleTheme, toggleShowMarks, toggleLockedCourses, toggleShowPastWarnings } =
settingsSlice.actions;
export const { toggleTheme, toggleLockedCourses, toggleShowPastWarnings } = settingsSlice.actions;

export default settingsSlice.reducer;
2 changes: 0 additions & 2 deletions frontend/src/test/testUtil.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,6 @@ export const renderWithProviders = async (
},
settings: {
theme: 'dark',
showMarks: false,
showLockedCourses: false,
showPastWarnings: false
},
Expand All @@ -46,7 +45,6 @@ export const renderWithProviders = async (
settings: {
theme: 'dark',
showLockedCourses: true,
showMarks: true,
showPastWarnings: true,
token: 'token' // force token to be dummy
}
Expand Down
4 changes: 4 additions & 0 deletions frontend/src/types/userResponse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,10 @@ export type CourseResponse = {
};
export type CoursesResponse = Record<string, CourseResponse>;

export type SettingsResponse = {
showMarks: boolean;
};

export type ValidateResponse = {
is_accurate: boolean;
handbook_note: string;
Expand Down
Loading

0 comments on commit 7d1cea1

Please sign in to comment.