Skip to content

Commit

Permalink
Add timetable viewer (#4)
Browse files Browse the repository at this point in the history
* Add timetable viewer, rework timetable api wrapper slightly

* Add additional logging

* Fix caching issues

* Update timetable view & add event modals

* Encode api url

* Move event fetching to frontend, update event displaying

* Revert unintentional change to iCal display format

* Format code

* Fix semester removal regex

* Update requirements

* Bump versions
  • Loading branch information
novanai authored Sep 3, 2024
1 parent f54c282 commit 9e896dd
Show file tree
Hide file tree
Showing 20 changed files with 551 additions and 312 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.4-slim
FROM python:3.12.5-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.1.0"
__version__ = "2.2.0"
15 changes: 14 additions & 1 deletion backend/api_docs.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,14 +11,20 @@

API = EndpointDocs(
summary="Generate a timetable.",
description="One of 'course' or 'modules' must be provided, but not both. All other parameters are optional.",
description="One of 'course', 'courses' or 'modules' must be provided, but not both. All other parameters are optional.",
parameters={
"course": ParameterInfo(
"The course to generate a timetable for.",
str,
required=False,
example="COMSCI1",
),
"courses": ParameterInfo(
"The course(s) to generate a timetable for.",
str,
required=False,
example="COMSCI1,COMSCI2",
),
"modules": ParameterInfo(
"The module(s) to generate a timetable for.",
str,
Expand All @@ -31,6 +37,12 @@
required=False,
example="json",
),
"display": ParameterInfo(
"Whether or not to include additional display info",
bool,
required=False,
example="true",
),
"start": ParameterInfo(
"Only get timetable events later than this datetime.",
str,
Expand Down Expand Up @@ -75,6 +87,7 @@
],
content_type="text/plain",
),
# TODO: update this to include display properties if available
ContentInfo(
list[models.Event],
examples=[
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.1
aiohttp==3.10.5
blacksheep==2.0.7
uvicorn==0.30.5
uvicorn==0.30.6
172 changes: 61 additions & 111 deletions backend/server.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,7 @@
import datetime
import logging
import time
import traceback

import aiohttp
import blacksheep
import orjson
from blacksheep.server.openapi.v3 import OpenAPIHandler
Expand Down Expand Up @@ -46,19 +44,17 @@ async def get_or_fetch_and_cache_categories() -> (
)
):
start = time.time()
logger.info("Caching Programmes of Study")
courses = await api.fetch_category_results(
models.CategoryType.PROGRAMMES_OF_STUDY, cache=True
)
logger.info(f"Cached Programmes of Study in {time.time()-start}s")
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()
logger.info("Caching Modules")
modules = await api.fetch_category_results(
models.CategoryType.MODULES, cache=True
)
logger.info(f"Cached Modules in {time.time()-start}s")
logger.info(f"Cached Modules in {time.time()-start:.2f}s")

return courses, modules

Expand All @@ -85,17 +81,25 @@ async def all_category_values(

courses, modules = await get_or_fetch_and_cache_categories()

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

if category_type == "courses":
data = [c.name for c in courses.items]
data = list(set(c.name for c in courses.items))
data.sort(key=str)
else:
assert category_type == "modules"
data = [
{
"name": m.name,
"value": m.code,
}
for m in modules.items
]
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)

return blacksheep.Response(
status=200,
Expand All @@ -109,127 +113,73 @@ async def all_category_values(
@docs(api_docs.API)
@blacksheep.route("/api")
async def timetable_api(
course: blacksheep.FromQuery[str] | None = None,
modules: blacksheep.FromQuery[str] | None = None,
format: blacksheep.FromQuery[str] | None = None,
start: blacksheep.FromQuery[str] | None = None,
end: blacksheep.FromQuery[str] | None = None,
course: str | None = None,
courses: str | None = None,
modules: str | None = None,
format: str | None = None,
display: bool | None = None,
start: str | None = None,
end: str | None = None,
) -> blacksheep.Response:
format_ = models.ResponseFormat.from_str(format.value if format else None)
start_date = utils.to_isoformat(start.value) if start else None
end_date = utils.to_isoformat(end.value) if end else None

message: str | None = None
format_ = models.ResponseFormat.from_str(format if format else None)
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 modules:
message = "No course or modules provided."
elif course and modules:
message = "Cannot provide both course and modules."
if not course and not courses and not modules:
raise ValueError("No courses or modules provided.")
elif format_ is models.ResponseFormat.UNKNOWN:
message = f"Invalid format '{format_}'."
elif start and not start_date:
message = f"Invalid start date '{start.value}'."
elif end and not end_date:
message = f"Invalid end date '{end.value}'."
elif start_date and end_date and start_date > end_date:
message = "Start date cannot be later then end date."

if message:
logger.error(f"400 on /api: {message}")
if format_ in (models.ResponseFormat.UNKNOWN, models.ResponseFormat.ICAL):
content = blacksheep.Content(
content_type=b"text/plain", data=message.encode()
)
else:
assert format_ is models.ResponseFormat.JSON
content = blacksheep.Content(
content_type=b"application/json",
data=orjson.dumps(models.APIError(400, message)),
)
raise ValueError(f"Invalid format '{format_}'.")

return blacksheep.Response(
400,
content=content,
)
events: list[models.Event] = []

if course:
calendar, error = await gen_course_timetable(
course.value, format_, start_date, end_date
)
else:
assert modules
calendar, error = await gen_modules_timetable(
modules.value, format_, start_date, end_date
)
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())

if error:
logger.error(f"400 on /api: {calendar.decode()}")
return blacksheep.Response(
400,
content=blacksheep.Content(content_type=b"text/plain", data=calendar),
)
events.extend(await generate_courses_timetables(codes, start_date, end_date))
elif modules:
codes = [m.strip() for m in modules.split(",")]

events.extend(await generate_modules_timetables(codes, start_date, end_date))

if format_ is models.ResponseFormat.ICAL:
timetable = utils.generate_ical_file(events)
else:
assert format_ is models.ResponseFormat.JSON
timetable = utils.generate_json_file(events, display)

return blacksheep.Response(
200,
content=blacksheep.Content(
format_.content_type.encode(),
data=calendar,
data=timetable,
),
)


async def gen_course_timetable(
course_code: str,
format: models.ResponseFormat,
async def generate_courses_timetables(
course_codes: list[str],
start: datetime.datetime | None = None,
end: datetime.datetime | None = None,
) -> tuple[bytes, bool]:
logger.info(f"Generating timetable for course {course_code}")
) -> list[models.Event]:
logger.info(f"Generating timetables for courses {', '.join(course_codes)}")

try:
events = await api.generate_course_timetable(course_code, start, end)
except models.InvalidCodeError as e:
return f"Invalid course code '{e.code}'.".encode(), True
events = await api.gather_events_for_courses(course_codes, start, end)

if format is models.ResponseFormat.ICAL:
calendar = utils.generate_ical_file(events)
else:
assert format is models.ResponseFormat.JSON
calendar = utils.generate_json_file(events)
return events

return calendar, False


async def gen_modules_timetable(
modules_str: str,
format: models.ResponseFormat,
async def generate_modules_timetables(
module_codes: list[str],
start: datetime.datetime | None = None,
end: datetime.datetime | None = None,
) -> tuple[bytes, bool]:
modules = [m.strip() for m in modules_str.split(",")]

logger.info(f"Generating timetable for modules {', '.join(modules)}")
) -> list[models.Event]:
logger.info(f"Generating timetable for modules {', '.join(module_codes)}")

try:
events = await api.generate_modules_timetable(modules, start, end)
except models.InvalidCodeError as e:
return f"Invalid module code '{e.code}'.".encode(), True
events = await api.gather_events_for_modules(module_codes, start, end)

if format is models.ResponseFormat.ICAL:
calendar = utils.generate_ical_file(events)
else:
assert format is models.ResponseFormat.JSON
calendar = utils.generate_json_file(events)

return calendar, False
return events


@app.exception_handler(aiohttp.ClientResponseError)
async def handle_badurl(
request: blacksheep.Request,
exception: aiohttp.ClientResponseError,
):
logger.error(traceback.format_exception(exception))
return blacksheep.Response(
500, content=blacksheep.Content(b"text/plain", b"500 Internal Server Error")
)
# TODO: add error handler
4 changes: 2 additions & 2 deletions compose.yaml
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
services:
nginx:
container_name: nginx
image: nginx:1.26.1-alpine
image: nginx:1.27.1-alpine3.20
restart: unless-stopped
depends_on:
- backend
- frontend
ports:
- "80:80"
volumes:
volumes:
- ./nginx:/etc/nginx/conf.d/

backend:
Expand Down
49 changes: 46 additions & 3 deletions frontend/package-lock.json

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

Loading

0 comments on commit 9e896dd

Please sign in to comment.