Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(all): Add JSON import and export support #1194

Draft
wants to merge 1 commit into
base: 331-staging
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions backend/server/db/helpers/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@ class UserDegreeStorage(BaseModel):
programCode: str
specs: List[str]

class UserCourseMinimal(BaseModel):
mark: Union[Literal['SY', 'FL', 'PS', 'CR', 'DN', 'HD'], int, None]
ignoreFromProgression: bool

class UserCourseStorage(BaseModel):
code: str
Expand Down Expand Up @@ -52,6 +55,12 @@ class _BaseUserStorage(BaseModel):
# NOTE: could also put uid here if we want
guest: bool

class UserImport(BaseModel):
degree: UserDegreeStorage
courses: dict[str, UserCourseMinimal]
planner: UserPlannerStorage
settings: UserSettingsStorage

class UserStorage(_BaseUserStorage):
setup: Literal[True] = True
degree: UserDegreeStorage
Expand Down
6 changes: 0 additions & 6 deletions backend/server/routers/model.py
Original file line number Diff line number Diff line change
Expand Up @@ -265,12 +265,6 @@ class Storage(TypedDict):
courses: dict[str, CourseStorage]
settings: SettingsStorage

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

degree: DegreeLocalStorage
planner: PlannerLocalStorage

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

Expand Down
105 changes: 79 additions & 26 deletions backend/server/routers/user.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
from itertools import chain
from typing import Annotated, Dict, Optional, cast
from fastapi import APIRouter, HTTPException, Security

from server.db.helpers.models import PartialUserStorage, UserCourseStorage, UserCoursesStorage, UserImport
from data.processors.models import SpecData
from server.routers.utility.common import get_all_specialisations, get_course_details
from server.routers.utility.sessions.middleware import HTTPBearerToUserID
from server.routers.utility.user import get_setup_user, set_user
from server.routers.model import CourseMark, CourseStorage, DegreeLength, DegreeWizardInfo, HiddenYear, SettingsStorage, StartYear, CourseStorageWithExtra, DegreeLocalStorage, LocalStorage, PlannerLocalStorage, Storage, SpecType
from server.routers.model import CourseMark, DegreeLength, DegreeWizardInfo, HiddenYear, SettingsStorage, StartYear, CourseStorageWithExtra, DegreeLocalStorage, PlannerLocalStorage, Storage, SpecType

import server.db.helpers.users as udb

Expand All @@ -17,30 +17,83 @@

require_uid = HTTPBearerToUserID()

# Ideally not used often.
@router.post("/saveLocalStorage")
def save_local_storage(localStorage: LocalStorage, uid: Annotated[str, Security(require_uid)]):
planned: list[str] = sum((sum(year.values(), [])
for year in localStorage.planner['years']), [])
unplanned: list[str] = localStorage.planner['unplanned']
courses: dict[str, CourseStorage] = {
course: {
'code': course,
'mark': None, # wtf we nuking marks?
'uoc': get_course_details(course)['UOC'],
'ignoreFromProgression': False
}
for course in chain(planned, unplanned)
}
# cancer, but the FE inspired this cancer
real_planner = localStorage.planner.copy()
item: Storage = {
'degree': localStorage.degree,
'planner': real_planner,
'courses': courses,
'settings': SettingsStorage(showMarks=False, hiddenYears=set()),
}
set_user(uid, item)
@router.put("/import")
def import_user(data: UserImport, uid: Annotated[str, Security(require_uid)]):
assert udb.reset_user(uid)

if data.planner.startYear < 2019:
raise HTTPException(status_code=400, detail="Invalid start year")
if len(data.planner.years) > 10:
raise HTTPException(status_code=400, detail="Too many years")
if len(data.planner.years) < 1:
raise HTTPException(status_code=400, detail="Not enough years")

possible_specs = get_all_specialisations(data.degree.programCode)
if possible_specs is None:
raise HTTPException(status_code=400, detail="Invalid program code")

flattened_containers: list[tuple[bool, list[str]]] = [
(
program_sub_container["is_optional"],
list(program_sub_container["specs"].keys())
)
for spec_type_container in cast(dict[SpecType, dict[str, SpecData]], possible_specs).values()
for program_sub_container in spec_type_container.values()
]

invalid_lhs_specs = set(data.degree.specs).difference(
spec_code
for (_, spec_codes) in flattened_containers
for spec_code in spec_codes
)

spec_reqs_not_met = any(
(
not is_optional
and not set(spec_codes).intersection(data.degree.specs)
)
for (is_optional, spec_codes) in flattened_containers
)

if invalid_lhs_specs or spec_reqs_not_met:
raise HTTPException(status_code=400, detail="Invalid specialisations")

for term in data.planner.lockedTerms.keys():
year, termIndex = term.split("T")
if int(year) < data.planner.startYear or int(year) >= data.planner.startYear + len(data.planner.years):
raise HTTPException(status_code=400, detail="Invalid locked term")
if termIndex not in ["0", "1", "2", "3"]:
raise HTTPException(status_code=400, detail="Invalid locked term")
for year in data.settings.hiddenYears:
if year < 0 or year >= len(data.planner.years):
raise HTTPException(status_code=400, detail="Invalid hidden year")

courses = []
for course in data.planner.unplanned:
if course not in data.courses.keys():
raise HTTPException(status_code=400, detail="Unplanned course not in courses")
courses.append(course)
for year in data.planner.years:
for term in [year.T0, year.T1, year.T2, year.T3]:
for course in term:
if course not in data.courses.keys():
raise HTTPException(status_code=400, detail="Planned course not in courses")
courses.append(course)
for course in data.courses.keys():
if course not in courses:
raise HTTPException(status_code=400, detail="Course in courses not in planner")
mark = data.courses[course].mark
if isinstance(mark, int) and (mark < 0 or mark > 100):
raise HTTPException(status_code=400, detail="Invalid mark")
userCourses: UserCoursesStorage = {}
for course in courses:
# This raises an exception
uoc = get_course_details(course)["UOC"]
userCourses[course] = UserCourseStorage(code=course, mark=data.courses[course].mark, uoc=uoc, ignoreFromProgression=data.courses[course].ignoreFromProgression)

user = PartialUserStorage(degree=data.degree, courses=userCourses, planner=data.planner, settings=data.settings)
udb.reset_user(uid)
udb.update_user(uid, user)


@router.get("/data/all")
Expand Down
46 changes: 19 additions & 27 deletions frontend/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 2 additions & 3 deletions frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
"@tanstack/react-query": "5.29.2",
"@tippyjs/react": "4.2.6",
"antd": "5.18.1",
"axios": "1.6.8",
"axios": "1.7.7",
"dayjs": "1.11.11",
"fast-fuzzy": "1.12.0",
"framer-motion": "11.0.20",
Expand All @@ -37,7 +37,7 @@
"redux-thunk": "3.1.0",
"styled-components": "6.1.11",
"use-debounce": "10.0.1",
"uuid": "9.0.1"
"zod": "3.23.8"
},
"scripts": {
"start": "vite --port 3000 --host",
Expand Down Expand Up @@ -83,7 +83,6 @@
"@types/react-helmet": "6.1.11",
"@types/react-scroll": "1.8.10",
"@types/styled-components": "5.1.34",
"@types/uuid": "9.0.8",
"@typescript-eslint/eslint-plugin": "7.3.1",
"@typescript-eslint/parser": "7.3.1",
"@vitejs/plugin-react": "4.2.1",
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/components/EditMarkModal/EditMarkModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ const EditMarkModal = ({ code, open, onCancel }: Props) => {
// mark is a letter grade
updateMarkMutation.mutate({ course: code, mark: markValue as Grade });
} else if (markValue === '' || markValue === undefined) {
updateMarkMutation.mutate({ course: code, mark: undefined });
updateMarkMutation.mutate({ course: code, mark: null });
} else {
message.error('Could not update mark. Please enter a valid mark or letter grade');
}
Expand Down
16 changes: 11 additions & 5 deletions frontend/src/hooks/useSettings.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { useCallback } from 'react';
import { QueryClient, useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { SettingsResponse } from 'types/userResponse';
import {
getUserSettings,
hideYear as hideYearApi,
Expand All @@ -16,12 +17,13 @@ import useToken from './useToken';

type Theme = 'light' | 'dark';

interface Settings {
export interface LocalSettings {
theme: Theme;
showMarks: boolean;
showLockedCourses: boolean;
showPastWarnings: boolean;
hiddenYears: number[];
}

export interface Settings extends LocalSettings, SettingsResponse {
mutateTheme: (theme: Theme) => void;
toggleShowMarks: () => void;
hideYear: (yearIndex: number) => void;
Expand Down Expand Up @@ -103,8 +105,12 @@ function useSettings(queryClient?: QueryClient): Settings {
const showYears = showYearsMutation.mutate;

return {
...userSettings,
...localSettings,
// Do not use the spread operator here, as it doesn't update after the mutation
theme: localSettings.theme,
showLockedCourses: localSettings.showLockedCourses,
showPastWarnings: localSettings.showPastWarnings,
showMarks: userSettings.showMarks,
hiddenYears: userSettings.hiddenYears,
mutateTheme,
toggleShowMarks,
hideYear,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ const HeroContent = ({ startLocation }: Props) => {
transition={{ type: 'spring', stiffness: 400, damping: 10 }}
whileHover={{ scale: 1.1 }}
>
START WITH CSESOC <Space size="large" />
GET STARTED <Space size="large" />
<ArrowRightOutlined style={{ strokeWidth: '5rem', stroke: '#9453e6' }} />
</S.HeroCTA>
</Link>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,13 +1,20 @@
import React, { useState } from 'react';
import { Radio } from 'antd';
import { getUser } from 'utils/api/userApi';
import { exportUser } from 'utils/export';
import useToken from 'hooks/useToken';
import CS from '../common/styles';
import S from './styles';

type Props = {
plannerRef: React.RefObject<HTMLDivElement>;
};
// type Props = {
// plannerRef: React.RefObject<HTMLDivElement>;
// };

// const ExportPlannerMenu = ({ plannerRef }: Props) => {
const ExportPlannerMenu = () => {
const token = useToken();

const ExportPlannerMenu = ({ plannerRef }: Props) => {
const plannerRef = React.createRef<HTMLDivElement>();
const exportFormats = ['png', 'jpg', 'json'];
const exportFields = { fileName: 'Term Planner' };

Expand All @@ -21,6 +28,21 @@
exportComponentAsPNG(plannerRef, exportFields);
} else if (format === 'jpg') {
exportComponentAsJPEG(plannerRef, exportFields);
} else if (format === 'json') {
getUser(token)
.then((user) => {
const exported = exportUser(user);
const blob = new Blob([JSON.stringify(exported)], { type: 'application/json' });
const jsonObjectUrl = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = jsonObjectUrl;
const date = new Date();
a.download = `circles-planner-export-${date.toISOString()}.json`;
a.click();
})
.catch((err) => {
console.error('Error at exportPlannerMenu: ', err);

Check warning on line 44 in frontend/src/pages/TermPlanner/ExportPlannerMenu/ExportPlannerMenu.tsx

View workflow job for this annotation

GitHub Actions / Frontend Eslint

Unexpected console statement
});
}
};

Expand Down
Loading
Loading