Skip to content
Merged
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
- Refactored `Database.Tables.MeetTime` into `Models.Meeting` and renamed it to `MeetingParsedData`; consolidated and refactored timetable JSON parsing helpers
- Restructured `courseography.cabal` to separate executable from library, extract shared settings, and remove `hlint`
- Refactored `createReqBody` in `app/Config.hs` to use session field from `config.yaml`
- Refactored the `Times` table to `Time` with a database migration

## [0.8.1] - 2026-08-10

Expand Down
2 changes: 1 addition & 1 deletion app/Controllers/Timetable.hs
Original file line number Diff line number Diff line change
Expand Up @@ -149,7 +149,7 @@ pullDatabase (code, section, session) = runDb $ do
case meet of
Nothing -> return Nothing
Just x -> do
allTimes <- selectList [TimesMeeting ==. entityKey x] []
allTimes <- selectList [TimeMeeting ==. entityKey x] []
parsedTime <- mapM (buildTime . entityVal) allTimes
return $ Just (MeetingData (entityVal x) parsedTime)
where
Expand Down
6 changes: 6 additions & 0 deletions app/Database/Migrations.hs
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ migrationList =
, MigrationWrapper{version = 3, script = renameCoursesTable}
, MigrationWrapper{version = 4, script = splitTimes}
, MigrationWrapper{version = 5, script = addPrepField}
, MigrationWrapper{version = 6, script = renameTimesTable}
]

-- | Migration script which renames the Post tables to Program
Expand All @@ -70,6 +71,11 @@ addPrepField :: Migration
addPrepField = do
addMigration True "ALTER TABLE course ADD COLUMN prep VARCHAR NULL;"

-- | Migrate script which renames the Times table to Time
renameTimesTable :: Migration
renameTimesTable =
addMigration True "ALTER TABLE times RENAME TO time;"

-- | Gets the current version of the database.
-- If no version is defined, initialize the
-- version to the latest version and return that.
Expand Down
2 changes: 1 addition & 1 deletion app/Database/Tables.hs
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ Meeting
deriving Generic Show Eq
UniqueMeeting code session section

Times
Time
session T.Text Maybe
weekDay Double
startHour Double
Expand Down
22 changes: 11 additions & 11 deletions app/Models/Meeting.hs
Original file line number Diff line number Diff line change
Expand Up @@ -47,16 +47,16 @@ data MeetingData = MeetingData {meetData :: Meeting, timeData :: [TimeData]}
instance ToJSON MeetingData

-- | A single meeting section parsed from the timetable API, before it is inserted into the database.
-- Its 'Times' can't be built until the 'Meeting' has been inserted and has a 'MeetingId',
-- Its 'Time' can't be built until the 'Meeting' has been inserted and has a 'MeetingId',
-- so they are stored as functions awaiting that key.
data MeetingParsedData = MeetingParsedData {meetInfo :: Meeting, timeInfo :: [MeetingId -> Times]}
data MeetingParsedData = MeetingParsedData {meetInfo :: Meeting, timeInfo :: [MeetingId -> Time]}

instance FromJSON MeetingParsedData where
parseJSON = withObject "Invalid meeting" $ \o -> do
meeting <- parseMeeting o
rawTimes :: [Value] <- o .:? "meetingTimes" .!= []
timesFunctionList <- mapM parseTime rawTimes
return $ MeetingParsedData meeting timesFunctionList
timeFunctionList <- mapM parseTime rawTimes
return $ MeetingParsedData meeting timeFunctionList
where
-- Parse a lecture, tutorial or practical. Its code and session are left empty,
-- since they come from the enclosing course.
Expand Down Expand Up @@ -88,8 +88,8 @@ instance FromJSON MeetingParsedData where
return (T.concat [firstName, " ", lastName])
parseInstr _ = return ""

parseTime :: Value -> Parser (MeetingId -> Times)
parseTime = withObject "Expected Object for Times" $ \o -> do
parseTime :: Value -> Parser (MeetingId -> Time)
parseTime = withObject "Expected Object for Time" $ \o -> do
startObject <- o .: "start"
endObject <- o .: "end"
meetingDay :: Maybe Int <- startObject .:? "day" .!= Nothing
Expand All @@ -102,7 +102,7 @@ instance FromJSON MeetingParsedData where
session <- o .: "sessionCode"

let (adjustedDay, adjustedStartTime, adjustedEndTime) = convertTimeVals meetingDay meetingStartTime meetingEndTime
return $ \meetingId -> Times session adjustedDay adjustedStartTime adjustedEndTime meetingId buildingCode'
return $ \meetingId -> Time session adjustedDay adjustedStartTime adjustedEndTime meetingId buildingCode'

-- Converts the miliseconds time into hourly time
-- Assumes times are rounded to the nearest hour
Expand Down Expand Up @@ -135,7 +135,7 @@ instance FromJSON MeetingParsedData where
convertTimeVals _ _ _ = (5.0, 25.0, 25.0)

-- | Insert or update a meeting and then delete
-- and re-insert the corresponding Times into the database.
-- and re-insert the corresponding Time records into the database.
insertMeetingParsedData :: MeetingParsedData -> SqlPersistM ()
insertMeetingParsedData (MeetingParsedData meetingData meetingTimeFunctions) = do
-- Check if the meeting already exists in the meeting table
Expand All @@ -148,7 +148,7 @@ insertMeetingParsedData (MeetingParsedData meetingData meetingTimeFunctions) = d
-- meeting already exists, so update/replace
entity <- upsert meetingData (meetingUpdates meetingData)
let meetingKey = entityKey entity
deleteWhere [TimesMeeting ==. meetingKey]
deleteWhere [TimeMeeting ==. meetingKey]
let allTimes = map ($ meetingKey) meetingTimeFunctions
insertMany_ allTimes
Nothing -> do
Expand Down Expand Up @@ -179,7 +179,7 @@ meetingQuery meetingCodes = do
-- | Queries the database for all times corresponding to a given meeting.
buildMeetTimes :: Entity Meeting -> SqlPersistM MeetingData
buildMeetTimes meet = do
allTimes :: [Entity Times] <- selectList [TimesMeeting ==. entityKey meet] []
allTimes :: [Entity Time] <- selectList [TimeMeeting ==. entityKey meet] []
parsedTime <- mapM (buildTime . entityVal) allTimes
return $ MeetingData (entityVal meet) parsedTime

Expand All @@ -205,7 +205,7 @@ getMeetingTime (meetingCode_, meetingSection_, meetingSession_) = do
, MeetingSession ==. meetingSession_
]
[]
allTimes <- selectList [TimesMeeting ==. entityKey (fromJust maybeEntityMeetings)] []
allTimes <- selectList [TimeMeeting ==. entityKey (fromJust maybeEntityMeetings)] []
mapM (buildTime . entityVal) allTimes

getMeetingSection :: T.Text -> T.Text
Expand Down
21 changes: 11 additions & 10 deletions app/Models/Time.hs
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,15 @@ module Models.Time (
import Data.Aeson (ToJSON)
import qualified Data.Text as T
import Database.Persist.Sqlite (SqlPersistM)
import Database.Tables (Building, Times (..))
import Database.Tables (Building, Time (..))
import qualified Database.Tables as Tables
import GHC.Generics (Generic)
import Models.Building (getBuilding)

-- | The time and location data for a single meeting occurrence, as returned by the back-end to the front-end.
--
-- This is different from the schema-defined 'Times' type (in "Database.Tables").
-- Whereas 'Times' stores a raw room code string, 'TimeData' resolves the location to a
-- This is different from the schema-defined 'Time' type (in "Database.Tables").
-- Whereas 'Time' stores a raw room code string, 'TimeData' resolves the location to a
-- full 'Building' object for JSON serialization to the client.
data TimeData
= TimeData
Expand All @@ -29,14 +30,14 @@ data TimeData

instance ToJSON TimeData

-- | Convert a Times record into a TimeData by resolving room codes to Buildings
buildTime :: Times -> SqlPersistM TimeData
-- | Convert a Time record into a TimeData by resolving room codes to Buildings
buildTime :: Time -> SqlPersistM TimeData
buildTime t = do
location <- getBuilding (timesLocation t)
location <- getBuilding (Tables.timeLocation t)
return $
TimeData
(timesSession t)
(timesWeekDay t)
(timesStartHour t)
(timesEndHour t)
(Tables.timeSession t)
(Tables.timeWeekDay t)
(Tables.timeStartHour t)
(Tables.timeEndHour t)
location
2 changes: 1 addition & 1 deletion app/WebParsing/UtsgJsonParser.hs
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ insertTimetableData respBody =
return $ map (\m -> m{meetInfo = (meetInfo m){meetingCode = courseCode, meetingSession = session}}) meetingsParsedData

-- | Retrieve timetable information for @page@ and insert/update the corresponding Meeting
-- and Times data into the database. Repeat for all pages in increasing order.
-- and Time records into the database. Repeat for all pages in increasing order.
insertTimetablePages :: Int -> SqlPersistM ()
insertTimetablePages page = do
respBody <- liftIO $ makeRequest page
Expand Down
100 changes: 50 additions & 50 deletions backend-test/Controllers/CourseControllerTests.hs
Original file line number Diff line number Diff line change
Expand Up @@ -15,14 +15,14 @@ import qualified Data.Map as Map
import Data.Maybe (fromMaybe, mapMaybe)
import qualified Data.Text as T
import Database.Persist.Sqlite (SqlPersistM, insert, insertMany_, insert_, toSqlKey)
import Database.Tables (Building (..), Course (..), Meeting (..), MeetingId, Times (..))
import Database.Tables (Building (..), Course (..), Meeting (..), MeetingId, Time (..))
import Happstack.Server (rsBody, rsCode)
import Test.Tasty (TestTree)
import Test.Tasty.HUnit (assertEqual, testCase)
import TestHelpers (clearDatabase, mockGetRequest, runServerPart, runServerPartWith, withDatabase)

-- | List of test cases as (case description, input course name, course data, list of meeting data, status code, expected JSON output)
retrieveCourseTestCases :: [(String, T.Text, Map.Map T.Text T.Text, [(Meeting, [Times])], Int, String)]
retrieveCourseTestCases :: [(String, T.Text, Map.Map T.Text T.Text, [(Meeting, [Time])], Int, String)]
retrieveCourseTestCases =
[
( "Course exists with meeting times, and other meeting times exist"
Expand Down Expand Up @@ -58,21 +58,21 @@ retrieveCourseTestCases =
, meetingExtra = 0
}
,
[ Times
{ timesSession = Just "20269"
, timesWeekDay = 0.0
, timesStartHour = 10.0
, timesEndHour = 11.0
, timesMeeting = placeholderMeetingId
, timesLocation = Just "MP"
[ Time
{ timeSession = Just "20269"
, timeWeekDay = 0.0
, timeStartHour = 10.0
, timeEndHour = 11.0
, timeMeeting = placeholderMeetingId
, timeLocation = Just "MP"
}
, Times
{ timesSession = Just "20269"
, timesWeekDay = 2.0
, timesStartHour = 13.0
, timesEndHour = 14.0
, timesMeeting = placeholderMeetingId
, timesLocation = Just "SS"
, Time
{ timeSession = Just "20269"
, timeWeekDay = 2.0
, timeStartHour = 13.0
, timeEndHour = 14.0
, timeMeeting = placeholderMeetingId
, timeLocation = Just "SS"
}
]
)
Expand All @@ -88,21 +88,21 @@ retrieveCourseTestCases =
, meetingExtra = 0
}
,
[ Times
{ timesSession = Just "20271"
, timesWeekDay = 1.0
, timesStartHour = 10.0
, timesEndHour = 11.0
, timesMeeting = placeholderMeetingId
, timesLocation = Just "WW"
[ Time
{ timeSession = Just "20271"
, timeWeekDay = 1.0
, timeStartHour = 10.0
, timeEndHour = 11.0
, timeMeeting = placeholderMeetingId
, timeLocation = Just "WW"
}
, Times
{ timesSession = Just "20271"
, timesWeekDay = 4.0
, timesStartHour = 13.0
, timesEndHour = 14.0
, timesMeeting = placeholderMeetingId
, timesLocation = Just "SS"
, Time
{ timeSession = Just "20271"
, timeWeekDay = 4.0
, timeStartHour = 13.0
, timeEndHour = 14.0
, timeMeeting = placeholderMeetingId
, timeLocation = Just "SS"
}
]
)
Expand Down Expand Up @@ -144,21 +144,21 @@ retrieveCourseTestCases =
, meetingExtra = 0
}
,
[ Times
{ timesSession = Just "20269"
, timesWeekDay = 0.0
, timesStartHour = 10.0
, timesEndHour = 11.0
, timesMeeting = placeholderMeetingId
, timesLocation = Just "MP"
[ Time
{ timeSession = Just "20269"
, timeWeekDay = 0.0
, timeStartHour = 10.0
, timeEndHour = 11.0
, timeMeeting = placeholderMeetingId
, timeLocation = Just "MP"
}
, Times
{ timesSession = Just "20269"
, timesWeekDay = 2.0
, timesStartHour = 13.0
, timesEndHour = 14.0
, timesMeeting = placeholderMeetingId
, timesLocation = Just "SS"
, Time
{ timeSession = Just "20269"
, timeWeekDay = 2.0
, timeStartHour = 13.0
, timeEndHour = 14.0
, timeMeeting = placeholderMeetingId
, timeLocation = Just "SS"
}
]
)
Expand Down Expand Up @@ -235,7 +235,7 @@ retrieveCourseTestCases =
]

-- | Run a test case (case, input, expected status code, expected output) on the retrieveCourse function.
runRetrieveCourseTest :: (String, T.Text, Map.Map T.Text T.Text, [(Meeting, [Times])], Int, String) -> TestTree
runRetrieveCourseTest :: (String, T.Text, Map.Map T.Text T.Text, [(Meeting, [Time])], Int, String) -> TestTree
runRetrieveCourseTest (label, courseName, courseData, meetingTimes, expectedCode, expectedBody) =
testCase label $ do
let currCourseName = fromMaybe "" $ Map.lookup "name" courseData
Expand Down Expand Up @@ -288,17 +288,17 @@ insertTimetableData = mapM_ insertCourse
where
insertCourse code = insert_ (Course code Nothing Nothing Nothing Nothing Nothing Nothing Nothing Nothing Nothing [])

-- | Placeholder key for the test Times, replaced with the real key when the Meeting is inserted
-- | Placeholder key for test Time records, replaced with the real key when the Meeting is inserted
placeholderMeetingId :: MeetingId
placeholderMeetingId = toSqlKey 0

-- | Helper function to insert meetings and their times into the database
insertMeetingTimes :: [(Meeting, [Times])] -> SqlPersistM ()
insertMeetingTimes :: [(Meeting, [Time])] -> SqlPersistM ()
insertMeetingTimes = mapM_ insertMeeting
where
insertMeeting (meetingData, meetingTimes) = do
meetingKey <- insert meetingData
insertMany_ $ map (\t -> t{timesMeeting = meetingKey}) meetingTimes
insertMany_ $ map (\t -> t{timeMeeting = meetingKey}) meetingTimes

-- | Helper function to insert dummy buildings from a list of codes
insertBuildings :: [T.Text] -> SqlPersistM ()
Expand All @@ -316,8 +316,8 @@ insertBuildings = mapM_ insertBuilding
}

-- | Helper function to get a list of unique locations involved in the given meetings
getUniqueBuildings :: [(Meeting, [Times])] -> [T.Text]
getUniqueBuildings = nub . concatMap (mapMaybe timesLocation . snd)
getUniqueBuildings :: [(Meeting, [Time])] -> [T.Text]
getUniqueBuildings = nub . concatMap (mapMaybe timeLocation . snd)

-- | List of test cases as (label, input courses, expected output)
indexTestCases :: [(String, [T.Text], String)]
Expand Down
14 changes: 7 additions & 7 deletions backend-test/Models/MeetingTests.hs
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ module Models.MeetingTests (
import Data.Aeson (decodeStrictText)
import qualified Data.Text as T
import Database.Persist.Sqlite (toSqlKey)
import Database.Tables (Meeting (..), MeetingId, Times (..))
import Database.Tables (Meeting (..), MeetingId, Time (..))
import Models.Meeting (MeetingParsedData (..))
import Test.Tasty (TestTree, testGroup)
import Test.Tasty.HUnit (assertEqual, testCase)
Expand Down Expand Up @@ -72,32 +72,32 @@ dummyMeetingId :: MeetingId
dummyMeetingId = toSqlKey 1

-- | List of test cases as (label, input JSON string, expected output)
parseTimeTestCases :: [(String, T.Text, Maybe Times)]
parseTimeTestCases :: [(String, T.Text, Maybe Time)]
parseTimeTestCases =
[ ("Empty JSON string, Nothing returned", "", Nothing)
,
( "Valid JSON string"
, "{ \"start\": { \"day\": 1, \"millisofday\": 36000000 }, \"end\": { \"millisofday\": 39600000 }, \"building\": { \"buildingCode\": \"BA\", \"buildingRoomNumber\": \"1130\" }, \"sessionCode\": \"20269\" }"
, Just
(Times (Just "20269") 0.0 10.0 11.0 dummyMeetingId (Just "BA"))
(Time (Just "20269") 0.0 10.0 11.0 dummyMeetingId (Just "BA"))
)
,
( "Valid JSON string with no day, default time values returned"
, "{ \"start\": { \"millisofday\": 43200000 }, \"end\": { \"millisofday\": 50400000 }, \"building\": { \"buildingCode\": \"MY\", \"buildingRoomNumber\": \"150\" }, \"sessionCode\": \"20271\" }"
, Just
(Times (Just "20271") 5.0 25.0 25.0 dummyMeetingId (Just "MY"))
(Time (Just "20271") 5.0 25.0 25.0 dummyMeetingId (Just "MY"))
)
,
( "Valid JSON string with no start millisofday, default time values returned"
, "{ \"start\": { \"day\": 3 }, \"end\": { \"millisofday\": 50400000 }, \"building\": { \"buildingCode\": \"MY\", \"buildingRoomNumber\": \"150\" }, \"sessionCode\": \"20271\" }"
, Just
(Times (Just "20271") 5.0 25.0 25.0 dummyMeetingId (Just "MY"))
(Time (Just "20271") 5.0 25.0 25.0 dummyMeetingId (Just "MY"))
)
,
( "Valid JSON string with no end millisofday, default time values returned"
, "{ \"start\": { \"day\": 3, \"millisofday\": 43200000 }, \"end\": { }, \"building\": { \"buildingCode\": \"MY\", \"buildingRoomNumber\": \"150\" }, \"sessionCode\": \"20271\" }"
, Just
(Times (Just "20271") 5.0 25.0 25.0 dummyMeetingId (Just "MY"))
(Time (Just "20271") 5.0 25.0 25.0 dummyMeetingId (Just "MY"))
)
,
( "Invalid JSON string with no start value, Nothing returned"
Expand All @@ -123,7 +123,7 @@ parseTimeTestCases =

-- | Run a test case (label, input JSON string, expected output) on the parsing of a meeting time.
-- The time is wrapped in a minimal valid meeting so it is parsed by the FromJSON instance of MeetingParsedData.
runParseTimeTest :: (String, T.Text, Maybe Times) -> TestTree
runParseTimeTest :: (String, T.Text, Maybe Time) -> TestTree
runParseTimeTest (label, input, expected) =
testCase label $ do
let meetingJSON = T.concat ["{\"teachMethod\":\"LEC\",\"meetingTimes\":[", input, "]}"]
Expand Down
Loading
Loading