Skip to content

Commit

Permalink
Split settings across frontend and backend (#1183)
Browse files Browse the repository at this point in the history
* Abstract settings slice behind useSettings

* Separate backend and frontend settings

* Fix linting

* Reimplement hidden years

* Fix reset button

* Fix set_user

* Fix incorrect import
  • Loading branch information
lhvy authored Aug 18, 2024
1 parent cd736b4 commit cb9592e
Show file tree
Hide file tree
Showing 30 changed files with 380 additions and 79 deletions.
14 changes: 12 additions & 2 deletions backend/server/db/helpers/models.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
from typing import Dict, List, Literal, NewType, Optional, Union
from typing import Dict, List, Literal, NewType, Optional, Set, Union
from uuid import UUID
from pydantic import BaseModel
from pydantic import BaseModel, field_serializer

#
# NewTypes
Expand Down Expand Up @@ -40,6 +40,14 @@ class UserPlannerStorage(BaseModel):
years: List[PlannerYear]
lockedTerms: Dict[str, bool]

class UserSettingsStorage(BaseModel):
showMarks: bool
hiddenYears: Set[int]

@field_serializer('hiddenYears', when_used='always')
def serialize_hidden_years(self, hiddenYears: Set[int]):
return sorted(hiddenYears)

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

class NotSetupUserStorage(_BaseUserStorage):
setup: Literal[False] = False
Expand All @@ -60,6 +69,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
24 changes: 20 additions & 4 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 @@ -41,6 +41,7 @@ def reset_user(uid: str) -> bool:
"degree": "",
"courses": "",
"planner": "",
"settings": "",
},
"$set": {
"setup": False,
Expand All @@ -58,7 +59,7 @@ def set_user(uid: str, data: UserStorage, overwrite: bool = False) -> bool:
res = usersCOL.update_one(
{ "uid": uid },
{
"$set": data.model_dump(include={ "degree", "courses", "planner", "setup" }),
"$set": data.model_dump(include={ "degree", "courses", "planner", "settings", "setup" }),
"$setOnInsert": {
# The fields that are usually immutable
"uid": uid,
Expand Down Expand Up @@ -121,14 +122,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 +154,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
5 changes: 5 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,15 @@ class UserPlannerInfoDict(TypedDict):
years: List[PlannerYearDict]
lockedTerms: Dict[str, bool]

class UserSettingsInfoDict(TypedDict):
showMarks: bool
hiddenYears: List[int]

class UserInfoDict(TypedDict):
uid: str
setup: Literal[True]
guest: bool
degree: UserDegreeInfoDict
courses: Dict[str, UserCourseInfoDict]
planner: UserPlannerInfoDict
settings: UserSettingsInfoDict
21 changes: 20 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,25 @@ def _create_users_collection():
},
}
}
},
'settings': {
'bsonType': 'object',
'required': ['showMarks', 'hiddenYears'],
'additionalProperties': False,
'properties': {
'showMarks': {
'bsonType': 'bool',
'description': 'Whether to show marks in the Term Planner'
},
'hiddenYears': {
'bsonType': 'array',
'items': {
'bsonType': 'int'
},
'description': 'Indexes of years hidden in the Term Planner, 0 is startYear',
'uniqueItems': True
}
}
}
}
}
Expand Down
14 changes: 13 additions & 1 deletion backend/server/routers/model.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
""" model for interacting with the FE """
import json
import pickle
from typing import Literal, Optional, TypedDict, Union
from typing import Literal, Optional, Set, TypedDict, Union

from algorithms.objects.conditions import CompositeCondition
from algorithms.objects.user import User
Expand Down Expand Up @@ -248,11 +248,23 @@ class CourseStorageWithExtra(TypedDict):
isMultiterm: bool
ignoreFromProgression: bool

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

showMarks: bool
hiddenYears: Set[int]

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

yearIndex: int

@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
50 changes: 46 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, HiddenYear, 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 @@ -61,11 +64,15 @@ def _nto_degree(s: NEWUserDegreeStorage) -> DegreeLocalStorage:
'specs': s.specs,
}

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

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, hiddenYears=set()),
}
set_user(uid, item)

Expand Down Expand Up @@ -174,6 +183,32 @@ 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("/settings/hideYear")
def hide_year(hidden: HiddenYear, uid: Annotated[str, Security(require_uid)]):
user = get_setup_user(uid)
if hidden.yearIndex < 0 or hidden.yearIndex >= len(user['planner']['years']):
raise HTTPException(
status_code=400, detail=f"Invalid year index '{hidden.yearIndex}'"
)
user['settings'].hiddenYears.add(hidden.yearIndex)
set_user(uid, user, True)

@router.post("/settings/showYears")
def show_years(uid: Annotated[str, Security(require_uid)]):
user = get_setup_user(uid)
user['settings'].hiddenYears = set()
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 @@ -234,6 +269,12 @@ def update_degree_length(degreeLength: DegreeLength, uid: Annotated[str, Securit
for term in year.values():
user['planner']['unplanned'].extend(term)
user['planner']['years'] = user['planner']['years'][:diff]

user['settings'].hiddenYears = set(
yearIndex
for yearIndex in user['settings'].hiddenYears
if yearIndex < degreeLength.numYears
)
set_user(uid, user, True)

@router.put("/setProgram")
Expand Down Expand Up @@ -339,7 +380,8 @@ def setup_degree_wizard(wizard: DegreeWizardInfo, uid: Annotated[str, Security(r
'specs': wizard.specs,
},
'planner': planner,
'courses': {}
'courses': {},
'settings': SettingsStorage(showMarks=False, hiddenYears=set()),
}
set_user(uid, user, True)
return user
7 changes: 3 additions & 4 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import React, { Suspense, useEffect } from 'react';
import { useSelector } from 'react-redux';
import { BrowserRouter as Router, Route, Routes } from 'react-router-dom';
import { NotificationOutlined } from '@ant-design/icons';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
Expand All @@ -12,8 +11,8 @@ import RequireToken from 'components/Auth/RequireToken';
import ErrorBoundary from 'components/ErrorBoundary';
import PageLoading from 'components/PageLoading';
import { inDev } from 'config/constants';
import type { RootState } from 'config/store';
import { darkTheme, GlobalStyles, lightTheme } from 'config/theme';
import useSettings from 'hooks/useSettings';
import Login from 'pages/Login';
import LoginSuccess from 'pages/LoginSuccess';
import Logout from 'pages/Logout';
Expand All @@ -31,12 +30,12 @@ const ProgressionChecker = React.lazy(() => import('./pages/ProgressionChecker')
const TermPlanner = React.lazy(() => import('./pages/TermPlanner'));

const App = () => {
const { theme } = useSelector((state: RootState) => state.settings);

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
6 changes: 3 additions & 3 deletions frontend/src/components/FeedbackButton/FeedbackButton.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,9 @@
import React from 'react';
import { useSelector } from 'react-redux';
import { BugOutlined } from '@ant-design/icons';
import { Tooltip } from 'antd';
import { FEEDBACK_LINK } from 'config/constants';
import type { RootState } from 'config/store';
import useMediaQuery from 'hooks/useMediaQuery';
import useSettings from 'hooks/useSettings';
import S from './styles';

const FeedbackButton = () => {
Expand All @@ -13,7 +12,8 @@ const FeedbackButton = () => {
const openFeedbackLink = () => {
window.open(FEEDBACK_LINK, '_blank');
};
const { theme } = useSelector((state: RootState) => state.settings);

const { theme } = useSettings();

// Move this to the drawer if the screen is too small
return isTablet ? null : (
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,9 @@
import React, { Suspense, useEffect, useState } from 'react';
import { useSelector } from 'react-redux';
import { Tooltip as ReactTooltip } from 'react-tooltip'; // TODO: investigate using antd tooltip?
import type { LiquidConfig } from '@ant-design/plots';
import Spinner from 'components/Spinner';
import { darkGrey, lightGrey, lightYellow, purple, yellow } from 'config/constants';
import type { RootState } from 'config/store';
import useSettings from 'hooks/useSettings';

type Props = {
completedUOC: number;
Expand Down Expand Up @@ -32,7 +31,7 @@ const LiquidProgressChart = ({ completedUOC, totalUOC }: Props) => {
}

// dark mode always has white text
const { theme } = useSelector((state: RootState) => state.settings);
const { theme } = useSettings();
if (theme === 'dark') {
textColor = 'white';
}
Expand Down
5 changes: 2 additions & 3 deletions frontend/src/components/ProgressBar/ProgressBar.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,13 @@
import React from 'react';
import { useSelector } from 'react-redux';
import type { RootState } from 'config/store';
import useSettings from 'hooks/useSettings';
import S from './styles';

type Props = {
progress: number;
};

const ProgressBar = ({ progress }: Props) => {
const { theme } = useSelector((state: RootState) => state.settings);
const { theme } = useSettings();
const trailColor = theme === 'light' ? '#f5f5f5' : '#444249';

let bgColor = '#3cb371';
Expand Down
Loading

0 comments on commit cb9592e

Please sign in to comment.