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
103 changes: 103 additions & 0 deletions campus/model/timetable.dbml
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
// For all, id is a CampusID with no relation to raw XML ID.

// An allocation refers to a new set of classes, people and class timings
// imported with a new timetable.
// It refers to one of these:
// https://github.com/nyjc-computing/nyxchange-timetable-v2/blob/data-schema/tt_file/tt_xml_docs.md
// This year's timetable and last year's timetable are two seperate allocations.
// The data for an allocation is stored in some .xml file, named `filename`

/* Sections that stay constant or only have additions. */
/* They will be relevant every for every allocation. */

// Describes a day in a repeating timetable.
// Assumption: This will stay constant across all timetables.
Table WeekDay {
id varchar [primary key]
label varchar // (cosmetic purposes: 'Mon A', 'Tue A', ... 'Mon B', 'Tue B', ..., 'Sat', 'Sun')
index integer [unique] // index 0 is earliest (eg. Mon A), followed by Tues A, etc.
}

// Timeslot which repeats across all `WeekDay`s
// Assumption: This will stay constant across all timetables.
Table TimeSlot {
id varchar [primary key]
label varchar // (primarily cosmetic: '0730', '0800', ...)
start_time varchar [unique] // ISO8601
end_time varchar [unique] // ISO8601
index integer [unique] // index 0 is slot (eg. 0730), followed by 0800, etc.
}

// Describes a single venue
// We have a set of venues that remain across years, except additions.
// Cannot refer to a group (eg. "All science labs")
Table Venue {
id varchar [primary key]
label varchar // (mutable, e.g. '03-39', 'i-Space 1', ..., follow XML when possible)
}

// Imagine each venue has its own timetable.
// This is one timeslot on such a timetable, representing an intersection of
// Venue and TimeSlot
// We use it for clean and convenient timetable coordinates, as
// we usually reason about an intersection of venue and time anyway
// Must be automatically generated for each Venue for all WeekDay, TimeSlot.
Table VenueTimeSlot {
id varchar [primary key]
weekday_id varchar
timeslot_id varchar
venue_id varchar
indexes {
(weekday_id, timeslot_id, venue_id) [unique]
}
}
Ref: VenueTimeSlot.weekday_id > WeekDay.id
Ref: VenueTimeSlot.timeslot_id > TimeSlot.id
Ref: VenueTimeSlot.venue_id > Venue.id

/* Sections that are only relevant to a specific allocation. */
/* New entries are created for each allocation. */
/* Which allocation the entry is relevant to is . */

// This represents a specific lesson taught to a class.
// Eg. Chem, taught to 2510. (eg. stored as '2510-CM')
// These are stored as an inconsistently formatted string, imported
// directly from the XML.
Table LessonGroup {
id varchar [primary key]
filename varchar // Allocation xml filename which this group is relevant to
label varchar
}

// Represents a single member of a LessonGroup.
// For example, 2510-Math will have:
// - Rosaline Tan
// - All the students
// Where each individual is one LessonGroupMember.
// 2510-Chem will have its own set of entries, even if the participant is duplicated.
Table LessonGroupMember {
id varchar [primary key]
filename varchar
lessongroup_id varchar
// XML id (aka TTCode or teacher_id). We have a unique mapping of these IDs
// to nyjc email etc., for each allocation.
ade_participant varchar
indexes {
(lessongroup_id, ade_participant, filename) [unique]
}
}
Ref: LessonGroup.id < LessonGroupMember.lessongroup_id

// A timetable represents a lesson for a LessonGroup
// at some VenueTimeSlot
Table Timetable {
id varchar [primary key]
filename varchar
lessongroup_id varchar
venuetimeslot_id varchar
indexes {
(venuetimeslot_id, lessongroup_id, filename) [unique]
}
}
Ref: Timetable.lessongroup_id > LessonGroup.id
Ref: Timetable.venuetimeslot_id > VenueTimeSlot.id
129 changes: 129 additions & 0 deletions campus/model/timetable.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
"""campus.model.timetable

Timetable model definitions for Campus.

The timetable schema describes a schema that stores the
following information from a timetable:
- Timeslots
- Lessons
- Students and teachers involved in said lessons
- Venues

!! The schema is documented in DETAIL by the `timetable.dbml` file.
!! Commenting both files would mean duplicate documentation.
!! Please visualise it [here](https://dbml-editor.alswl.com/)!!!!
!! The tool is also able to export the schema as SQL to create the respective tables.
"""

from typing import ClassVar
from dataclasses import dataclass, field

from campus.common import schema
from campus.common.schema.openapi import String, Integer, Boolean, DateTime
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Prefer referencing String, Integer, Boolean, DateTime from schema (they are re-exported under the schema namespace for ease of use)

See docs/STYLE-GUIDE.md for examples

from campus.common.utils import uid

from .base import Model
from . import constraints

# NOTE: Assumes reusing the same object is not an issue
unique_field = field(metadata={
"constraints": [constraints.UNIQUE],
})


@dataclass(eq=False, kw_only=True)
class WeekDay(Model):
"""
Describes a day in a repeating timetable.
"""
Comment on lines +36 to +38
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[C#01]
We follow PEP257 on docstring formatting; one-line docstrings should have both triple-quotes on the same line.

See https://peps.python.org/pep-0257/

Suggested change
"""
Describes a day in a repeating timetable.
"""
"""Describes a day in a repeating timetable."""

id: schema.CampusID = unique_field
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[C#02]
While this isn't specified in Campus API/schema, it should (and I'll probably update it sometime or have one of you take it on):

  • We reserve Campus IDs for entities that will be queried by the API: Users, Venues, Circles, Clients, ...
  • These are entities that will have their own URL in the Campus API (/api/v1/users/{user_uuid})
  • We give them a string UUID to avoid the confusion of integer IDs: getting an ID isn't enough to tell what you're looking at--e.g. "is this User ID 1, Client ID 1, or Venue ID 1"? Campus ID is designed to tell you at a glance what you "have"
  • This means non-queryable entities are not required/obliged to use Campus schema, and since we are not tracking their use in the DB/API, we should avoiding creating Campus IDs unless necessary

WeekDay and TimeSlot are not Campus entities, but technically enum values. This is a common feature in many programming languages, e.g. https://learn.microsoft.com/en-us/dotnet/csharp/language-reference/builtin-types/enum, but we will not force their use because enum types often have their own constraints. Here we define the model for reference, i.e. to help other readers understand what we mean by a WeekDay or TimeSlot when they encounter it in the DB or in code.

So we can stick/revert to using int IDs

Suggested change
id: schema.CampusID = unique_field
id: int

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hi Mr Ng, i realise the reason i used CampusID initially is because setting id to schema.Integer triggers a type error. Model constrains id to be id: schema.CampusID | schema.UserID.

Copy link
Contributor Author

@ngjunsiang ngjunsiang Feb 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hi Mr Ng, i realise the reason i used CampusID initially is because setting id to schema.Integer triggers a type error. Model constrains id to be id: schema.CampusID | schema.UserID.

@sparsetable I forgot to mention: for "internal representations" (won't be queried directly through Campus API), there is no need to inherit base.Model. We use that primarily to enforce that all public Campus models have an id and created_at property polymorphically.

Internal representations will never be returned by the resource/API, just used as intermediate representations between JSON and database rows, and do not need to be polymorphic in the same way.

I realise that means we need another way to create the database table; let me investigate this and get back to you

Copy link
Contributor Author

@ngjunsiang ngjunsiang Feb 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@sparsetable I just added an InternalModel class without id and created_at fields that you can use: #345

This has been merged into weekly branch, merge those changes in to see and use the class.

label: String
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[C#03]
Reference String datatype from schema namespace for clarity.

Suggested change
label: String
label: schema.String

index: Integer
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

See [C#03].



@dataclass(eq=False, kw_only=True)
class TimeSlot(Model):
"""
Timeslot which repeats across all `WeekDay`s
"""
id: schema.CampusID = field(default_factory=(
lambda: uid.generate_category_uid("timetable", length=8)
))
Comment on lines +49 to +51
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

See [C#02].

Suggested change
id: schema.CampusID = field(default_factory=(
lambda: uid.generate_category_uid("timetable", length=8)
))
id: int

label: String
start_time: DateTime = unique_field
end_time: DateTime = unique_field
index: Integer = unique_field
Comment on lines +52 to +55
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

See [C#03].



@dataclass(eq=False, kw_only=True)
class Venue(Model):
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[C#04]
In view that Venue is going to be its own Campus entity class soon, we might want to name this differently to avoid naming conflict.

Suggested change
class Venue(Model):
class TimetableVenue(Model):

"""
Describes a single venue
"""
id: schema.CampusID = field(default_factory=(
lambda: uid.generate_category_uid("timetable", length=8)
))
Comment on lines +63 to +65
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

See [C#02].

Suggested change
id: schema.CampusID = field(default_factory=(
lambda: uid.generate_category_uid("timetable", length=8)
))
id: int

label: String
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

See [C#03].



@dataclass(eq=False, kw_only=True)
class VenueTimeSlot(Model):
"""
Imagine each venue has its own timetable.
This is one timeslot on such a timetable, representing an intersection of
Venue and TimeSlot
"""
id: schema.CampusID = field(default_factory=(
lambda: uid.generate_category_uid("timetable", length=8)
))
weekday_id: schema.CampusID # FK -> weekday_id
timeslot_id: schema.CampusID
venue_id: schema.CampusID
Comment on lines +76 to +81
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

See [C#02].

Suggested change
id: schema.CampusID = field(default_factory=(
lambda: uid.generate_category_uid("timetable", length=8)
))
weekday_id: schema.CampusID # FK -> weekday_id
timeslot_id: schema.CampusID
venue_id: schema.CampusID
id: int
weekday_id: int # FK -> weekday_id
timeslot_id: int
venue_id: int

__constraints__ = constraints.Unique("weekday_id", "timeslot_id", "venue_id")


@dataclass(eq=False, kw_only=True)
class LessonGroup(Model):
"""
This represents a specific lesson taught to a class.
Eg. Chem, taught to 2510. (eg. stored as '2510-CM')
"""
id: schema.CampusID = field(default_factory=(
lambda: uid.generate_category_uid("timetable", length=8)
))
Comment on lines +91 to +93
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[C#05]
These are essentially what we understand as TTCodes. We can name the category more explicitly.

Suggested change
id: schema.CampusID = field(default_factory=(
lambda: uid.generate_category_uid("timetable", length=8)
))
id: schema.CampusID = field(default_factory=(
lambda: uid.generate_category_uid("timetable-group", length=8)
))

filename: String
label: String


@dataclass(eq=False, kw_only=True)
class LessonGroupMember(Model):
"""
Represents a single member of a LessonGroup.
For example, 2510-Math will have:
- Rosaline Tan
- All the students
Where each individual is one LessonGroupMember.
2510-Chem will have its own set of entries, even if the participant is duplicated.
"""
id: schema.CampusID = field(default_factory=(
lambda: uid.generate_category_uid("timetable", length=8)
))
Comment on lines +108 to +110
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[C#06]
We use 16-char UUIDs for IDs that we know are likely to be much more numerous (due to the logic of combinatorics)

Suggested change
id: schema.CampusID = field(default_factory=(
lambda: uid.generate_category_uid("timetable", length=8)
))
id: schema.CampusID = field(default_factory=(
lambda: uid.generate_category_uid("timetable-groupmember", length=16)
))

filename: String
lessongroup_id: String
ade_participant: String
__constraints__ = constraints.Unique("lessongroup_id", "ade_participant", "filename")


@dataclass(eq=False, kw_only=True)
class Timetable(Model):
"""
A timetable represents a lesson for a LessonGroup
at some VenueTimeSlot
"""
Comment on lines +118 to +122
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[C#07]
On 2nd thought, let's make this more explicit.

Suggested change
class Timetable(Model):
"""
A timetable represents a lesson for a LessonGroup
at some VenueTimeSlot
"""
class TimetableEntry(Model):
"""
A timetable entry represents the intersection of:
- a LessonGroup (students + tutor(s))
- a TimetableVenue
- a WeekDay
- a TimeSlot
"""

id: schema.CampusID = field(default_factory=(
lambda: uid.generate_category_uid("timetable", length=8)
))
Comment on lines +123 to +125
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[C#08]

See [C#05], [C#06]

Suggested change
id: schema.CampusID = field(default_factory=(
lambda: uid.generate_category_uid("timetable", length=8)
))
id: schema.CampusID = field(default_factory=(
lambda: uid.generate_category_uid("timetable-entry", length=16)
))

filename: String
lessongroup_id: String
venuetimeslot_id: String
__constraints__ = constraints.Unique("lessongroup_id", "venuetimeslot_id", "filename")