diff --git a/.flake8 b/.flake8 new file mode 100644 index 0000000..08abe94 --- /dev/null +++ b/.flake8 @@ -0,0 +1,11 @@ +[flake8] +max-line-length = 88 +extend-ignore = E203, W503, F403, F401 +exclude = + .git, + __pycache__, + build, + dist, + .venv, + env, + venv \ No newline at end of file diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 1bd8001..d17db44 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -2,9 +2,9 @@ name: CI on: push: - branches: [main, master] + branches: [ main ] pull_request: - branches: [main, master] + branches: [ main ] jobs: test: @@ -30,19 +30,19 @@ jobs: pip install -r requirements.txt pip install -r requirements-dev.txt -# - name: Lint with flake8 -# run: flake8 . + - name: Lint with flake8 + run: flake8 . -# - name: Format check with black -# run: black --check . + - name: Format check with black + run: black --check . -# - name: Type check with mypy -# run: mypy . + - name: Type check with mypy + run: mypy . -# - name: Run tests with coverage -# run: pytest --cov=./ --cov-report=xml --cov-report=term + - name: Run tests with coverage + run: pytest --cov=./ --cov-report=xml --cov-report=term - # - name: Upload coverage to Codecov - # uses: codecov/codecov-action@v3 - # with: - # files: ./coverage.xml + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v3 + with: + files: ./coverage.xml diff --git a/requirements-dev.txt b/requirements-dev.txt index 8fc8616..2c2d885 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -6,4 +6,5 @@ coverage # Code quality flake8 black -mypy \ No newline at end of file +mypy +pydantic[mypy]==2.11.7 \ No newline at end of file diff --git a/syncortexGA/constraints.py b/syncortexGA/constraints/__init__.py similarity index 100% rename from syncortexGA/constraints.py rename to syncortexGA/constraints/__init__.py diff --git a/syncortexGA/genetic_algorithm.py b/syncortexGA/constraints/hard_constraints.py similarity index 100% rename from syncortexGA/genetic_algorithm.py rename to syncortexGA/constraints/hard_constraints.py diff --git a/syncortexGA/models.py b/syncortexGA/constraints/soft_constraints.py similarity index 100% rename from syncortexGA/models.py rename to syncortexGA/constraints/soft_constraints.py diff --git a/syncortexGA/exceptions.py b/syncortexGA/exceptions.py index 991aa1a..e69de29 100644 --- a/syncortexGA/exceptions.py +++ b/syncortexGA/exceptions.py @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/syncortexGA/genetic/__init__.py b/syncortexGA/genetic/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/syncortexGA/genetic/algorithm.py b/syncortexGA/genetic/algorithm.py new file mode 100644 index 0000000..e69de29 diff --git a/syncortexGA/models/__init__.py b/syncortexGA/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/syncortexGA/models/timetable_model.py b/syncortexGA/models/timetable_model.py new file mode 100644 index 0000000..aa5b391 --- /dev/null +++ b/syncortexGA/models/timetable_model.py @@ -0,0 +1,152 @@ +from pydantic import BaseModel, model_validator, StringConstraints +from typing import List, Optional, Literal, Annotated + +# Accepts only time strings in 24-hour format like "08:00", "23:59" +TimeStr = Annotated[str, StringConstraints(pattern=r"^(?:[01]\d|2[0-3]):[0-5]\d$")] + + +# ========== TimeSlot ========== +class TimeSlot(BaseModel): + """Represents a specific time slot on a given day.""" + + day: Literal["Saturday", "Sunday", "Monday", "Tuesday", "Wednesday", "Thursday"] + start: TimeStr # Format: "HH:MM" + end: TimeStr # Format: "HH:MM" + + +# ========== Session Patterns ========== +class FixedSessionPattern(BaseModel): + """Represents a fixed pattern with exactly 2 weekly sessions.""" + + slots: List[TimeSlot] + + @model_validator(mode="after") + def check_two_slots(cls, model): + if len(model.slots) != 2: + raise ValueError("FixedSessionPattern must have exactly 2 slots") + return model + + +class AlternatingSessionPattern(BaseModel): + """ + Represents a pattern where a course alternates between two slots weekly. + Paired with another course and occurs on even or odd weeks. + """ + + fixed_slot: TimeSlot + alternating_slot: TimeSlot + alternating_mode: Literal["odd", "even"] + paired_course_id: int + + +class SessionPattern(BaseModel): + """ + Wraps either a fixed or alternating session pattern. + Only one of the patterns must be set based on the type. + """ + + type: Literal["fixed", "alternating"] + fixed_pattern: Optional[FixedSessionPattern] = None + alternating_pattern: Optional[AlternatingSessionPattern] = None + + @model_validator(mode="after") + def validate_only_one_pattern(cls, model): + if model.type == "fixed": + if not model.fixed_pattern or model.alternating_pattern: + raise ValueError("For type='fixed', only fixed_pattern must be set") + elif model.type == "alternating": + if not model.alternating_pattern or model.fixed_pattern: + raise ValueError( + "For type='alternating', " "only alternating_pattern must be set" + ) + return model + + +# ========== Course ========== +class Course(BaseModel): + """ + Represents a course that may or may not include a lab. + Lab courses must define a lab slot and cannot have a session pattern. + Non-lab courses must define a session pattern and cannot have a lab slot. + """ + + id: int + name: str + code: str + instructor_id: int + has_lab: bool = False + lab_slot: Optional[TimeSlot] = None + lab_room_id: Optional[int] = None + lab_instructor_id: Optional[int] = None + session_pattern: Optional[SessionPattern] = None + + @model_validator(mode="after") + def validate_course(cls, model): + if model.has_lab: + if not model.lab_slot: + raise ValueError("Lab slot must be defined for lab courses") + if model.session_pattern: + raise ValueError("Lab courses must not have session pattern") + else: + if not model.session_pattern: + raise ValueError("Non-lab courses must have a session pattern") + if model.lab_slot: + raise ValueError("Non-lab courses must not have lab slot") + return model + + +# ========== Student ========== +class Student(BaseModel): + """Represents a student belonging to a specific group.""" + + full_name: str + student_number: str # e.g., "401234567" + group_id: int # Refers to StudentGroup.id + + +# ========== StudentGroup ========== +class StudentGroup(BaseModel): + """Represents a group of students (e.g., a class or cohort).""" + + id: int + name: str # e.g., "CS-401" + major: str # e.g., "Computer Engineering" + entry_year: int # e.g., 2022 + + +# ========== Instructor ========== +class Instructor(BaseModel): + """Represents an instructor with available and preferred time slots.""" + + id: int + full_name: str + available_slots: List[TimeSlot] + preferred_slots: Optional[List[TimeSlot]] = None + + +# ========== Room ========== +class Room(BaseModel): + """Represents a classroom or lab with capacity constraints.""" + + id: int + name: str # e.g., "Room 101" + capacity: int + is_lab: bool = False + + +# ========== Scheduled Session & Timetable ========== +class ScheduledSession(BaseModel): + """Represents a scheduled class session (theory or lab).""" + + course_id: int + group_id: int + instructor_id: int + room_id: int + slot: TimeSlot + type: Literal["theory", "lab"] + + +class Timetable(BaseModel): + """Represents the final schedule as a list of sessions.""" + + sessions: List[ScheduledSession] diff --git a/tests/unit/test_models.py b/tests/unit/test_models.py new file mode 100644 index 0000000..a3f577a --- /dev/null +++ b/tests/unit/test_models.py @@ -0,0 +1,331 @@ +import pytest +from pydantic import ValidationError +from syncortexGA.models.timetable_model import ( + TimeSlot, + FixedSessionPattern, + AlternatingSessionPattern, + SessionPattern, + Course, + Student, + StudentGroup, + Instructor, + Room, + ScheduledSession, + Timetable, +) + + +# ====== TimeSlot Tests ====== + + +def test_timeslot_valid(): + ts = TimeSlot(day="Monday", start="08:00", end="10:00") + assert ts.day == "Monday" + assert ts.start == "08:00" + assert ts.end == "10:00" + + +def test_timeslot_invalid_day(): + with pytest.raises(ValidationError): + TimeSlot(day="Friday", start="08:00", end="10:00") + + +def test_timeslot_invalid_time_format(): + with pytest.raises(ValidationError): + TimeSlot(day="Monday", start="8am", end="10am") + + +# ====== FixedSessionPattern Tests ====== + + +def test_fixed_session_pattern_valid(): + slots = [ + TimeSlot(day="Saturday", start="08:00", end="10:00"), + TimeSlot(day="Monday", start="10:00", end="12:00"), + ] + pattern = FixedSessionPattern(slots=slots) + assert len(pattern.slots) == 2 + + +def test_fixed_session_pattern_invalid_count(): + with pytest.raises(ValidationError): + FixedSessionPattern( + slots=[TimeSlot(day="Saturday", start="08:00", end="10:00")] + ) + + +def test_fixed_session_pattern_empty(): + with pytest.raises(ValidationError): + FixedSessionPattern(slots=[]) + + +# ====== AlternatingSessionPattern Tests ====== + + +def test_alternating_session_pattern_valid(): + pattern = AlternatingSessionPattern( + fixed_slot=TimeSlot(day="Sunday", start="08:00", end="10:00"), + alternating_slot=TimeSlot(day="Tuesday", start="10:00", end="12:00"), + alternating_mode="odd", + paired_course_id=123, + ) + assert pattern.alternating_mode == "odd" + + +def test_alternating_session_pattern_invalid_mode(): + with pytest.raises(ValidationError): + AlternatingSessionPattern( + fixed_slot=TimeSlot(day="Sunday", start="08:00", end="10:00"), + alternating_slot=TimeSlot(day="Tuesday", start="10:00", end="12:00"), + alternating_mode="week3", + paired_course_id=123, + ) + + +# ====== SessionPattern Tests ====== + + +def test_session_pattern_fixed_valid(): + fixed = FixedSessionPattern( + slots=[ + TimeSlot(day="Saturday", start="08:00", end="10:00"), + TimeSlot(day="Monday", start="10:00", end="12:00"), + ] + ) + sp = SessionPattern(type="fixed", fixed_pattern=fixed) + assert sp.type == "fixed" + + +def test_session_pattern_alternating_valid(): + alt = AlternatingSessionPattern( + fixed_slot=TimeSlot(day="Sunday", start="08:00", end="10:00"), + alternating_slot=TimeSlot(day="Tuesday", start="10:00", end="12:00"), + alternating_mode="even", + paired_course_id=99, + ) + sp = SessionPattern(type="alternating", alternating_pattern=alt) + assert sp.type == "alternating" + + +def test_session_pattern_invalid_type(): + with pytest.raises(ValidationError): + SessionPattern(type="random") + + +def test_session_pattern_mismatch_fixed(): + fixed = FixedSessionPattern( + slots=[ + TimeSlot(day="Saturday", start="08:00", end="10:00"), + TimeSlot(day="Monday", start="10:00", end="12:00"), + ] + ) + alt = AlternatingSessionPattern( + fixed_slot=fixed.slots[0], + alternating_slot=fixed.slots[1], + alternating_mode="odd", + paired_course_id=5, + ) + with pytest.raises(ValidationError): + SessionPattern(type="fixed", fixed_pattern=fixed, alternating_pattern=alt) + + +def test_session_pattern_mismatch_alternating(): + fixed = FixedSessionPattern( + slots=[ + TimeSlot(day="Saturday", start="08:00", end="10:00"), + TimeSlot(day="Monday", start="10:00", end="12:00"), + ] + ) + with pytest.raises(ValidationError): + SessionPattern(type="alternating", fixed_pattern=fixed) + + +# ====== Course Tests ====== + + +def test_course_lab_valid(): + course = Course( + id=1, + name="Physics Lab", + code="PHY101", + instructor_id=10, + has_lab=True, + lab_slot=TimeSlot(day="Wednesday", start="14:00", end="16:00"), + lab_room_id=3, + lab_instructor_id=15, + ) + assert course.has_lab is True + + +def test_course_lab_missing_lab_slot(): + with pytest.raises(ValidationError): + Course( + id=2, name="Chemistry Lab", code="CHEM101", instructor_id=11, has_lab=True + ) + + +def test_course_lab_with_session_pattern(): + lab_slot = TimeSlot(day="Wednesday", start="14:00", end="16:00") + session_pattern = SessionPattern( + type="fixed", + fixed_pattern=FixedSessionPattern( + slots=[ + TimeSlot(day="Monday", start="08:00", end="10:00"), + TimeSlot(day="Thursday", start="08:00", end="10:00"), + ] + ), + ) + with pytest.raises(ValidationError): + Course( + id=3, + name="Bio Lab", + code="BIO101", + instructor_id=12, + has_lab=True, + lab_slot=lab_slot, + session_pattern=session_pattern, + ) + + +def test_course_non_lab_valid(): + session_pattern = SessionPattern( + type="fixed", + fixed_pattern=FixedSessionPattern( + slots=[ + TimeSlot(day="Monday", start="08:00", end="10:00"), + TimeSlot(day="Thursday", start="08:00", end="10:00"), + ] + ), + ) + course = Course( + id=4, + name="Math", + code="MATH101", + instructor_id=13, + has_lab=False, + session_pattern=session_pattern, + ) + assert course.has_lab is False + + +def test_course_non_lab_missing_session_pattern(): + with pytest.raises(ValidationError): + Course(id=5, name="History", code="HIST101", instructor_id=14, has_lab=False) + + +def test_course_non_lab_with_lab_slot(): + with pytest.raises(ValidationError): + Course( + id=6, + name="English", + code="ENG101", + instructor_id=15, + has_lab=False, + lab_slot=TimeSlot(day="Wednesday", start="14:00", end="16:00"), + ) + + +# ====== Student Tests ====== + + +def test_student_valid(): + student = Student(full_name="Alice Smith", student_number="401234567", group_id=100) + assert student.student_number == "401234567" + + +def test_student_missing_field(): + with pytest.raises(ValidationError): + Student(full_name="Bob") + + +# ====== StudentGroup Tests ====== + + +def test_student_group_valid(): + group = StudentGroup( + id=1, name="CS-401", major="Computer Engineering", entry_year=2023 + ) + assert group.name == "CS-401" + + +# ====== Instructor Tests ====== + + +def test_instructor_valid(): + instructor = Instructor( + id=1, + full_name="Dr. John", + available_slots=[TimeSlot(day="Monday", start="08:00", end="12:00")], + preferred_slots=[TimeSlot(day="Monday", start="10:00", end="12:00")], + ) + assert instructor.full_name == "Dr. John" + + +def test_instructor_no_preferred(): + instructor = Instructor( + id=2, + full_name="Dr. Jane", + available_slots=[TimeSlot(day="Tuesday", start="08:00", end="10:00")], + ) + assert instructor.preferred_slots is None + + +# ====== Room Tests ====== + + +def test_room_valid(): + room = Room(id=1, name="Room 101", capacity=30, is_lab=False) + assert room.capacity == 30 + + +def test_room_lab_flag(): + room = Room(id=2, name="Lab 1", capacity=20, is_lab=True) + assert room.is_lab is True + + +# ====== ScheduledSession Tests ====== + + +def test_scheduled_session_valid(): + session = ScheduledSession( + course_id=1, + group_id=10, + instructor_id=5, + room_id=3, + slot=TimeSlot(day="Thursday", start="10:00", end="12:00"), + type="theory", + ) + assert session.type == "theory" + + +def test_scheduled_session_invalid_type(): + with pytest.raises(ValidationError): + ScheduledSession( + course_id=1, + group_id=10, + instructor_id=5, + room_id=3, + slot=TimeSlot(day="Thursday", start="10:00", end="12:00"), + type="exam", + ) + + +# ====== Timetable Tests ====== + + +def test_timetable_valid(): + session = ScheduledSession( + course_id=1, + group_id=10, + instructor_id=5, + room_id=3, + slot=TimeSlot(day="Thursday", start="10:00", end="12:00"), + type="lab", + ) + timetable = Timetable(sessions=[session]) + assert len(timetable.sessions) == 1 + + +def test_timetable_empty(): + timetable = Timetable(sessions=[]) + assert timetable.sessions == []