Skip to content

Commit

Permalink
Add location support, refactor backend and timetable API (#11)
Browse files Browse the repository at this point in the history
* Move initial caching to timetable module

* Add location support & rework api

* Add location support to frontend

* Update server responses and docstrings, and add semester number back to item code

* Cache category under identity only

* Use item identities for generated api URLs and timetable viewer

* Update versions & requirements
  • Loading branch information
novanai authored Sep 25, 2024
1 parent f4b3642 commit 352d22c
Show file tree
Hide file tree
Showing 18 changed files with 401 additions and 413 deletions.
2 changes: 1 addition & 1 deletion backend/Dockerfile
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
FROM python:3.12.5-slim
FROM python:3.12.6-slim

WORKDIR /app

Expand Down
2 changes: 1 addition & 1 deletion backend/__init__.py
Original file line number Diff line number Diff line change
@@ -1 +1 @@
__version__ = "2.2.1"
__version__ = "2.3.0"
10 changes: 5 additions & 5 deletions backend/api_docs.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@
"The module(s) to generate a timetable for.",
str,
required=False,
example="CA103,CA116,MS134",
example="CSC1061,CSC1003,MTH1025",
),
"format": ParameterInfo(
"The response format.\n\nAllowed values: 'ical' or 'json'.\nDefault: 'ical'.",
Expand Down Expand Up @@ -72,7 +72,7 @@
PRODID:-//[email protected]//TimetableSync//EN
METHOD:PUBLISH
BEGIN:VEVENT
SUMMARY:CA116 Computing Programming I (Lecture)
SUMMARY:CSC1003 Computer Programming I (Lecture)
DTSTART:20230925T090000Z
DTEND:20230925T110000Z
DTSTAMP:20231030T154328Z
Expand Down Expand Up @@ -111,18 +111,18 @@
),
],
description="Lecture",
name="CA116[1]OC/L1/01",
name="CSC1003[1]OC/L1/01",
event_type="On Campus",
last_modified=datetime.datetime.fromisoformat(
"2023-06-29T09:41:17.367634+00:00"
),
module_name="CA116[1] Computing Programming I",
module_name="CSC1003[1] Computer Programming I",
staff_member="Blott S",
weeks=[3, 4, 5, 6, 7, 8, 9, 10, 11, 12],
group_name=None,
parsed_name_data=[
models.ParsedNameData(
module_codes=["CA116"],
module_codes=["CSC1003"],
semester=models.Semester.SEMESTER_1,
delivery_type=models.DeliveryType.ON_CAMPUS,
activity_type=models.ActivityType.LECTURE,
Expand Down
4 changes: 2 additions & 2 deletions backend/requirements.txt
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
aiohttp==3.10.5
aiohttp==3.10.6
blacksheep==2.0.7
uvicorn==0.30.6
uvicorn[standard]==0.30.6
175 changes: 84 additions & 91 deletions backend/server.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import collections
import datetime
import logging
import time

import blacksheep
import orjson
Expand All @@ -27,95 +27,49 @@

@app.on_start
async def start_session() -> None:
await get_or_fetch_and_cache_categories()
await utils.get_basic_category_results(api)


@app.on_stop
async def stop_session() -> None:
await api.session.close()


async def get_or_fetch_and_cache_categories() -> (
tuple[models.Category, models.Category]
):
if not (
courses := await api.get_category_results(
models.CategoryType.PROGRAMMES_OF_STUDY
)
):
start = time.time()
courses = await api.fetch_category_results(
models.CategoryType.PROGRAMMES_OF_STUDY, cache=True
)
logger.info(f"Cached Programmes of Study in {time.time()-start:.2f}s")

if not (modules := await api.get_category_results(models.CategoryType.MODULES)):
start = time.time()
modules = await api.fetch_category_results(
models.CategoryType.MODULES, cache=True
)
logger.info(f"Cached Modules in {time.time()-start:.2f}s")

return courses, modules


@docs.ignore()
@blacksheep.route("/api/healthcheck")
async def healthcheck() -> blacksheep.Response:
return blacksheep.Response(status=200)
return blacksheep.ok()


@docs.ignore()
@blacksheep.route("/api/all/{category_type}")
async def all_category_values(
category_type: str,
) -> blacksheep.Response:
if category_type not in ("courses", "modules"):
return blacksheep.Response(
status=400,
content=blacksheep.Content(
content_type=b"text/plain",
data=b"Invalid value provided.",
),
if category_type not in ("courses", "modules", "locations"):
return blacksheep.status_code(
400,
"Invalid value provided.",
)

courses, modules = await get_or_fetch_and_cache_categories()

data: list[str | dict[str, str]]

if category_type == "courses":
data = list(set(c.name for c in courses.items))
data.sort(key=str)
else:
assert category_type == "modules"
codes: list[str] = []
data = []

for m in modules.items:
if m.code not in codes:
data.append(
{
"name": m.name,
"value": m.code,
}
)
codes.append(m.code)
categories = await utils.get_basic_category_results(api)

return blacksheep.Response(
status=200,
content=blacksheep.Content(
content_type=b"application/json",
data=orjson.dumps(data),
data=orjson.dumps(getattr(categories, category_type)),
),
)


@docs(api_docs.API)
@blacksheep.route("/api")
async def timetable_api(
course: str | None = None,
course: str | None = None, # NOTE: backwards compatibility only
courses: str | None = None,
modules: str | None = None,
locations: str | None = None,
format: str | None = None,
display: bool | None = None,
start: str | None = None,
Expand All @@ -125,23 +79,86 @@ async def timetable_api(
start_date = datetime.datetime.fromisoformat(start) if start else None
end_date = datetime.datetime.fromisoformat(end) if end else None

if not course and not courses and not modules:
raise ValueError("No courses or modules provided.")
if not course and not courses and not modules and not locations:
raise ValueError("No courses, modules or locations provided.")
elif format_ is models.ResponseFormat.UNKNOWN:
raise ValueError(f"Invalid format '{format_}'.")

events: list[models.Event] = []
codes: dict[models.CategoryType, list[str]] = collections.defaultdict(list)

if course or courses:
codes = [c.strip() for c in courses.split(",")] if courses else []
if course and course.strip() not in codes:
codes.append(course.strip())
def str_to_list(text: str) -> list[str]:
return [t.strip() for t in text.split(",")]

events.extend(await generate_courses_timetables(codes, start_date, end_date))
if courses and courses.strip():
codes[models.CategoryType.PROGRAMMES_OF_STUDY].extend(str_to_list(courses))
if course and course.strip() not in codes[models.CategoryType.PROGRAMMES_OF_STUDY]:
codes[models.CategoryType.PROGRAMMES_OF_STUDY].append(course.strip())
if modules:
codes = [m.strip() for m in modules.split(",")]

events.extend(await generate_modules_timetables(codes, start_date, end_date))
codes[models.CategoryType.MODULES].extend(str_to_list(modules))
if locations:
codes[models.CategoryType.LOCATIONS].extend(str_to_list(locations))

for group, cat_codes in codes.items():
for code in cat_codes:
# code is a category item identity and timetable is cached
timetable = await api.get_category_item_timetable(
group.value, code, start=start_date, end=end_date
)
if timetable:
events.extend(timetable.events)
logger.info(
f"Using cached events for {group} {timetable.identity} (total {len(timetable.events)})"
)
continue

# code is a category item identity and timetable must be fetched
item = await api.get_category_item(group, code)
if item:
timetables = await api.fetch_category_items_timetables(
group,
[item.identity],
start=start_date,
end=end_date,
)
events.extend(timetables[0].events)
logger.info(
f"Fetched events for {group} {timetables[0].identity} (total {len(timetables[0].events)})"
)
continue

# code is not a category item, search cached category items for it
category = await api.get_category(group, query=code, count=1)
if not category or not category.items:
# could not find category item in cache, fetch it
category = await api.fetch_category(group, query=code)
if not category.items:
raise ValueError(f"Invalid code/identity: {code}")

item = category.items[0]

# timetable is cached
timetable = await api.get_category_item_timetable(
group.value, item.identity, start=start_date, end=end_date
)
if timetable:
events.extend(timetable.events)
logger.info(
f"Using cached events for {group} {timetable.identity} (total {len(timetable.events)})"
)
continue

# timetable is not cached
timetables = await api.fetch_category_items_timetables(
group,
[item.identity],
start=start_date,
end=end_date,
)
logger.info(
f"Fetched events for {group} {timetables[0].identity} (total {len(timetables[0].events)})"
)
events.extend(timetables[0].events)

if format_ is models.ResponseFormat.ICAL:
timetable = utils.generate_ical_file(events)
Expand All @@ -158,28 +175,4 @@ async def timetable_api(
)


async def generate_courses_timetables(
course_codes: list[str],
start: datetime.datetime | None = None,
end: datetime.datetime | None = None,
) -> list[models.Event]:
logger.info(f"Generating timetables for courses {', '.join(course_codes)}")

events = await api.gather_events_for_courses(course_codes, start, end)

return events


async def generate_modules_timetables(
module_codes: list[str],
start: datetime.datetime | None = None,
end: datetime.datetime | None = None,
) -> list[models.Event]:
logger.info(f"Generating timetable for modules {', '.join(module_codes)}")

events = await api.gather_events_for_modules(module_codes, start, end)

return events


# TODO: add error handler
2 changes: 1 addition & 1 deletion frontend/Dockerfile
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
FROM node:22.6.0-slim
FROM node:22.9.0-slim

WORKDIR /app

Expand Down
4 changes: 2 additions & 2 deletions frontend/package-lock.json

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

2 changes: 1 addition & 1 deletion frontend/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "frontend",
"version": "0.3.1",
"version": "0.4.0",
"private": true,
"scripts": {
"dev": "vite dev",
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ html[data-theme="night"] .fc .fc-list-sticky .fc-list-day > * {
}
}

@media (max-width: 640px) {
@media (max-width: 768px) {
.fc-toolbar-title {
display: none;
}
Expand Down
5 changes: 5 additions & 0 deletions frontend/src/routes/+layout.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ export async function load({ fetch, params }) {
/** @type {array.str} */
let courses;
let modules;
let locations;
let response;

response = await fetch('/api/all/courses');
Expand All @@ -11,9 +12,13 @@ export async function load({ fetch, params }) {
response = await fetch('/api/all/modules');
modules = await response.json();

response = await fetch('/api/all/locations');
locations = await response.json();

return {
courses: courses,
modules: modules,
locations: locations,
};
}
// Force to run in browser
Expand Down
10 changes: 9 additions & 1 deletion frontend/src/routes/generator/+page.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,12 @@
data: data.modules,
max: 20,
},
locations: {
name: "Locations",
selected: [],
data: data.locations,
max: 8
},
};
</script>

Expand Down Expand Up @@ -69,7 +75,9 @@
</div>
<Svelecte
class="mt-2"
options={option.data}
options={option.data.map((item) => {
return { value: item.identity, text: item.name };
})}
multiple
max={option.max}
clearable
Expand Down
Loading

0 comments on commit 352d22c

Please sign in to comment.