diff --git a/backend/api/academics/__init__.py b/backend/api/academics/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/backend/api/academics/course.py b/backend/api/academics/course.py new file mode 100644 index 000000000..290e1e4a5 --- /dev/null +++ b/backend/api/academics/course.py @@ -0,0 +1,99 @@ +"""Courses Course API + +This API is used to access course data.""" + +from fastapi import APIRouter, Depends +from ..authentication import registered_user +from ...services.academics import CourseService +from ...models import User +from ...models.academics import Course, CourseDetails + +__authors__ = ["Ajay Gandecha"] +__copyright__ = "Copyright 2023" +__license__ = "MIT" + + +api = APIRouter(prefix="/api/academics/course") +openapi_tags = { + "name": "Academics", + "description": "Academic and course information are managed via these endpoints.", +} + + +@api.get("", response_model=list[CourseDetails], tags=["Academics"]) +def get_courses(course_service: CourseService = Depends()) -> list[CourseDetails]: + """ + Get all courses + + Returns: + list[CourseDetails]: All `Course`s in the `Course` database table + """ + return course_service.all() + + +@api.get("/{id}", response_model=CourseDetails, tags=["Academics"]) +def get_course_by_id( + id: str, course_service: CourseService = Depends() +) -> CourseDetails: + """ + Gets one course by its id + + Returns: + CourseDetails: Course with the given ID + """ + return course_service.get_by_id(id) + + +@api.get("/{subject_code}/{number}", response_model=CourseDetails, tags=["Academics"]) +def get_course_by_subject_code( + subject_code: str, number: str, course_service: CourseService = Depends() +) -> CourseDetails: + """ + Gets one course by its properties + + Returns: + CourseDetails: Course with the given ID + """ + return course_service.get(subject_code, number) + + +@api.post("", response_model=CourseDetails, tags=["Academics"]) +def new_course( + course: Course, + subject: User = Depends(registered_user), + course_service: CourseService = Depends(), +) -> CourseDetails: + """ + Adds a new course to the database + + Returns: + CourseDetails: Course created + """ + return course_service.create(subject, course) + + +@api.put("", response_model=CourseDetails, tags=["Academics"]) +def update_course( + course: Course, + subject: User = Depends(registered_user), + course_service: CourseService = Depends(), +) -> CourseDetails: + """ + Updates a course to the database + + Returns: + CourseDetails: Course updated + """ + return course_service.update(subject, course) + + +@api.delete("/{course_id}", response_model=None, tags=["Academics"]) +def delete_course( + course_id: str, + subject: User = Depends(registered_user), + course_service: CourseService = Depends(), +): + """ + Deletes a course from the database + """ + return course_service.delete(subject, course_id) diff --git a/backend/api/academics/section.py b/backend/api/academics/section.py new file mode 100644 index 000000000..f1191ed0c --- /dev/null +++ b/backend/api/academics/section.py @@ -0,0 +1,128 @@ +"""Section Course API + +This API is used to access course data.""" + +from fastapi import APIRouter, Depends +from ..authentication import registered_user +from ...services.academics import SectionService +from ...models import User +from ...models.academics import Section, SectionDetails + +__authors__ = ["Ajay Gandecha"] +__copyright__ = "Copyright 2023" +__license__ = "MIT" + + +api = APIRouter(prefix="/api/academics/section") + + +@api.get("", response_model=list[SectionDetails], tags=["Academics"]) +def get_sections(section_service: SectionService = Depends()) -> list[SectionDetails]: + """ + Get all sections + + Returns: + list[SectionDetails]: All `Section`s in the `Section` database table + """ + return section_service.all() + + +@api.get("/{id}", response_model=SectionDetails, tags=["Academics"]) +def get_section_by_id( + id: int, section_service: SectionService = Depends() +) -> SectionDetails: + """ + Gets one section by its id + + Returns: + SectionDetails: Section with the given ID + """ + return section_service.get_by_id(id) + + +@api.get("/term/{term_id}", response_model=list[SectionDetails], tags=["Academics"]) +def get_section_by_term_id( + term_id: str, section_service: SectionService = Depends() +) -> list[SectionDetails]: + """ + Gets list of sections by term ID + + Returns: + list[SectionDetails]: Sections with the given term + """ + return section_service.get_by_term(term_id) + + +@api.get("/subject/{subject}", response_model=list[SectionDetails], tags=["Academics"]) +def get_section_by_subject( + subject: str, section_service: SectionService = Depends() +) -> list[SectionDetails]: + """ + Gets a list of sections by a subject + + Returns: + list[SectionDetails]: Sections with the given section + """ + return section_service.get_by_subject(subject) + + +@api.get( + "/{subject_code}/{course_number}/{section_number}", + response_model=SectionDetails, + tags=["Academics"], +) +def get_section_by_subject_code( + subject_code: str, + course_number: str, + section_number: str, + section_service: SectionService = Depends(), +) -> SectionDetails: + """ + Gets one section by its properties + + Returns: + SectionDetails: Course with the given properties + """ + return section_service.get(subject_code, course_number, section_number) + + +@api.post("", response_model=SectionDetails, tags=["Academics"]) +def new_section( + section: Section, + subject: User = Depends(registered_user), + section_service: SectionService = Depends(), +) -> SectionDetails: + """ + Adds a new section to the database + + Returns: + SectionDetails: Section created + """ + return section_service.create(subject, section) + + +@api.put("", response_model=SectionDetails, tags=["Academics"]) +def update_section( + section: Section, + subject: User = Depends(registered_user), + section_service: SectionService = Depends(), +) -> SectionDetails: + """ + Updates a section to the database + + Returns: + SectionDetails: Section updated + """ + return section_service.update(subject, section) + + +@api.delete("/{section_id}", response_model=None, tags=["Academics"]) +def delete_section( + section_id: int, + subject: User = Depends(registered_user), + section_service: SectionService = Depends(), +): + """ + Deletes a section from the database + """ + return section_service.delete(subject, section_id) diff --git a/backend/api/academics/term.py b/backend/api/academics/term.py new file mode 100644 index 000000000..c3968c7f9 --- /dev/null +++ b/backend/api/academics/term.py @@ -0,0 +1,92 @@ +"""Courses Term API + +This API is used to access term data.""" + +from fastapi import APIRouter, Depends +from ..authentication import registered_user +from ...services.academics import TermService +from ...models import User +from ...models.academics import Term, TermDetails +from datetime import datetime + +__authors__ = ["Ajay Gandecha"] +__copyright__ = "Copyright 2023" +__license__ = "MIT" + + +api = APIRouter(prefix="/api/academics/term") + + +@api.get("", response_model=list[TermDetails], tags=["Academics"]) +def get_terms(term_service: TermService = Depends()) -> list[TermDetails]: + """ + Get all terms + + Returns: + list[TermDetails]: All `Term`s in the `Term` database table + """ + return term_service.all() + + +@api.get("/current", response_model=TermDetails, tags=["Academics"]) +def get_current_term(term_service: TermService = Depends()) -> TermDetails: + """ + Gets the current term based on the current date + + Returns: + TermDetails: Currently active term + """ + return term_service.get_by_date(datetime.today()) + + +@api.get("/{id}", response_model=TermDetails, tags=["Academics"]) +def get_term_by_id(id: str, term_service: TermService = Depends()) -> TermDetails: + """ + Gets one term by its id + + Returns: + TermDetails: Term with the given ID + """ + return term_service.get_by_id(id) + + +@api.post("", response_model=TermDetails, tags=["Academics"]) +def new_term( + term: Term, + subject: User = Depends(registered_user), + term_service: TermService = Depends(), +) -> TermDetails: + """ + Adds a new term to the database + + Returns: + TermDetails: Term created + """ + return term_service.create(subject, term) + + +@api.put("", response_model=TermDetails, tags=["Academics"]) +def update_term( + term: Term, + subject: User = Depends(registered_user), + term_service: TermService = Depends(), +) -> TermDetails: + """ + Updates a term to the database + + Returns: + TermDetails: Term updated + """ + return term_service.update(subject, term) + + +@api.delete("/{term_id}", response_model=None, tags=["Academics"]) +def delete_term( + term_id: str, + subject: User = Depends(registered_user), + term_service: TermService = Depends(), +): + """ + Deletes a term from the database + """ + return term_service.delete(subject, term_id) diff --git a/backend/api/room.py b/backend/api/room.py new file mode 100644 index 000000000..2a2214f0d --- /dev/null +++ b/backend/api/room.py @@ -0,0 +1,121 @@ +"""Room API + +Room routes are used to create, retrieve, and update Rooms.""" + +from fastapi import APIRouter, Depends + +from ..services import RoomService +from ..models import Room +from ..models import RoomDetails +from ..api.authentication import registered_user +from ..models.user import User + +__authors__ = ["Ajay Gandecha"] +__copyright__ = "Copyright 2023" +__license__ = "MIT" + +api = APIRouter(prefix="/api/room") +openapi_tags = { + "name": "Rooms", + "description": "Create, update, delete, and retrieve rooms.", +} + + +@api.get("", response_model=list[RoomDetails], tags=["Rooms"]) +def get_rooms( + room_service: RoomService = Depends(), +) -> list[RoomDetails]: + """ + Get all room + + Parameters: + room_service: a valid RoomService + + Returns: + list[RoomDetails]: All rooms in the `Room` database table + """ + return room_service.all() + + +@api.get( + "/{id}", + response_model=RoomDetails, + tags=["Rooms"], +) +def get_room_by_id(id: str, room_service: RoomService = Depends()) -> RoomDetails: + """ + Get room with matching id + + Parameters: + id: a string representing a unique identifier for a room + room_service: a valid RoomService + + Returns: + RoomDetails: RoomDetails with matching slug + """ + + return room_service.get_by_id(id) + + +@api.post("", response_model=RoomDetails, tags=["Rooms"]) +def new_room( + room: RoomDetails, + subject: User = Depends(registered_user), + room_service: RoomService = Depends(), +) -> RoomDetails: + """ + Create room + + Parameters: + room: a valid room model + subject: a valid User model representing the currently logged in User + room_service: a valid RoomService + + Returns: + RoomDetails: Created room + """ + + return room_service.create(subject, room) + + +@api.put( + "", + response_model=RoomDetails, + tags=["Rooms"], +) +def update_room( + room: RoomDetails, + subject: User = Depends(registered_user), + room_service: RoomService = Depends(), +) -> RoomDetails: + """ + Update room + + Parameters: + room: a valid Room model + subject: a valid User model representing the currently logged in User + room_service: a valid RoomService + + Returns: + RoomDetails: Updated room + """ + + return room_service.update(subject, room) + + +@api.delete("/{id}", response_model=None, tags=["Rooms"]) +def delete_room( + id: str, + subject: User = Depends(registered_user), + room_service: RoomService = Depends(), +): + """ + Delete room based on id + + Parameters: + id: a string representing a unique identifier for an room + subject: a valid User model representing the currently logged in User + room_service: a valid RoomService + """ + + room_service.delete(subject, id) diff --git a/backend/entities/__init__.py b/backend/entities/__init__.py index 925b36e96..fbf2a3052 100644 --- a/backend/entities/__init__.py +++ b/backend/entities/__init__.py @@ -16,6 +16,7 @@ from .entity_base import EntityBase from .user_entity import UserEntity from .role_entity import RoleEntity +from .room_entity import RoomEntity from .permission_entity import PermissionEntity from .user_role_table import user_role_table from .organization_entity import OrganizationEntity diff --git a/backend/entities/academics/__init__.py b/backend/entities/academics/__init__.py new file mode 100644 index 000000000..7e55921a2 --- /dev/null +++ b/backend/entities/academics/__init__.py @@ -0,0 +1,5 @@ +from .section_entity import SectionEntity +from .course_entity import CourseEntity +from .term_entity import TermEntity +from .section_member_entity import SectionMemberEntity +from .section_room_entity import SectionRoomEntity diff --git a/backend/entities/academics/course_entity.py b/backend/entities/academics/course_entity.py new file mode 100644 index 000000000..a4a9426d5 --- /dev/null +++ b/backend/entities/academics/course_entity.py @@ -0,0 +1,93 @@ +"""Definition of SQLAlchemy table-backed object mapping entity for Course.""" + +from typing import Self +from sqlalchemy import Integer, String +from sqlalchemy.orm import Mapped, mapped_column, relationship +from ..entity_base import EntityBase +from ...models.academics import Course +from ...models.academics import CourseDetails + +__authors__ = ["Ajay Gandecha"] +__copyright__ = "Copyright 2023" +__license__ = "MIT" + + +class CourseEntity(EntityBase): + """Serves as the database model schema defining the shape of the `Course` table""" + + # Name for the course table in the PostgreSQL database + __tablename__ = "academics__course" + + # Course properties (columns in the database table) + + # Unique ID for the course + # Course IDs are serialized in the following format: + # Examples: COMP110, COMP283H + id: Mapped[str] = mapped_column(String(9), primary_key=True) + # Subject for the course (for example, the subject of COMP 110 would be COMP) + subject_code: Mapped[str] = mapped_column(String(4), default="") + # Number for the course (for example, the code of COMP 110 would be 110) + number: Mapped[str] = mapped_column(String(4), default="") + # Title or name for the course + title: Mapped[str] = mapped_column(String, default="") + # Course description for the course + description: Mapped[str] = mapped_column(String, default="") + # Credit hours for a course (-1 = variable / not set) + credit_hours: Mapped[int] = mapped_column(Integer, default=-1) + + # NOTE: This field establishes a one-to-many relationship between the course and section tables. + sections: Mapped[list["SectionEntity"]] = relationship( + back_populates="course", cascade="all,delete" + ) + + @classmethod + def from_model(cls, model: Course) -> Self: + """ + Class method that converts a `Course` model into a `CourseEntity` + + Parameters: + - model (Course): Model to convert into an entity + Returns: + CourseEntity: Entity created from model + """ + return cls( + id=model.id, + subject_code=model.subject_code, + number=model.number, + title=model.title, + description=model.description, + credit_hours=model.credit_hours, + ) + + def to_model(self) -> Course: + """ + Converts a `CourseEntity` object into a `Course` model object + + Returns: + Course: `Course` object from the entity + """ + return Course( + id=self.id, + subject_code=self.subject_code, + number=self.number, + title=self.title, + description=self.description, + credit_hours=self.credit_hours, + ) + + def to_details_model(self) -> CourseDetails: + """ + Converts a `CourseEntity` object into a `CourseDetails` model object + + Returns: + CourseDetails: `CourseDetails` object from the entity + """ + return CourseDetails( + id=self.id, + subject_code=self.subject_code, + number=self.number, + title=self.title, + description=self.description, + credit_hours=self.credit_hours, + sections=[section.to_model() for section in self.sections], + ) diff --git a/backend/entities/academics/section_entity.py b/backend/entities/academics/section_entity.py new file mode 100644 index 000000000..b72823069 --- /dev/null +++ b/backend/entities/academics/section_entity.py @@ -0,0 +1,150 @@ +"""Definition of SQLAlchemy table-backed object mapping entity for Course Sections.""" + +from typing import Self +from sqlalchemy import Integer, String, DateTime, ForeignKey +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from ...models.room_assignment_type import RoomAssignmentType + +from ..entity_base import EntityBase +from datetime import datetime +from ...models.academics import Section +from ...models.academics import SectionDetails +from ...models.academics.section_member import SectionMember +from ...models.roster_role import RosterRole + +__authors__ = ["Ajay Gandecha"] +__copyright__ = "Copyright 2023" +__license__ = "MIT" + + +class SectionEntity(EntityBase): + """Serves as the database model schema defining the shape of the `Section` table""" + + # Name for the course section table in the PostgreSQL database + __tablename__ = "academics__section" + + # Section properties (columns in the database table) + + # Unique ID for the section + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + # Course the section is for + # NOTE: This defines a one-to-many relationship between the course and sections tables. + course_id: Mapped[str] = mapped_column(ForeignKey("academics__course.id")) + course: Mapped["CourseEntity"] = relationship(back_populates="sections") + + # Number of the section (for example, COMP 100-003's code would be "003") + number: Mapped[str] = mapped_column(String, default="") + + # Term the section is in + # NOTE: This defines a one-to-many relationship between the term and sections tables. + term_id: Mapped[str] = mapped_column(ForeignKey("academics__term.id")) + term: Mapped["TermEntity"] = relationship(back_populates="course_sections") + + # Meeting pattern of the course + # For example, MWF 4:40PM - 5:30PM. + meeting_pattern: Mapped[str] = mapped_column(String, default="") + + # Override fields for specific sections, such as COMP 590: Special Topics + override_title: Mapped[str] = mapped_column(String, default="") + override_description: Mapped[str] = mapped_column(String, default="") + + # Room the section is in + # NOTE: This defines a one-to-many relationship between the room and sections tables. + rooms: Mapped[list["SectionRoomEntity"]] = relationship( + back_populates="section", cascade="delete" + ) + + lecture_rooms: Mapped[list["SectionRoomEntity"]] = relationship( + back_populates="section", + viewonly=True, + primaryjoin="and_(SectionEntity.id==SectionRoomEntity.section_id, SectionRoomEntity.assignment_type=='LECTURE_ROOM')", + ) + + office_hour_rooms: Mapped[list["SectionRoomEntity"]] = relationship( + back_populates="section", + viewonly=True, + primaryjoin="and_(SectionEntity.id==SectionRoomEntity.section_id, SectionRoomEntity.assignment_type=='OFFICE_HOURS')", + ) + + # Members of the course + members: Mapped[list["SectionMemberEntity"]] = relationship( + back_populates="section", + ) + + # Relationship subset of members queries for non-students + staff: Mapped[list["SectionMemberEntity"]] = relationship( + back_populates="section", + viewonly=True, + primaryjoin="and_(SectionEntity.id==SectionMemberEntity.section_id, SectionMemberEntity.member_role!='STUDENT')", + ) + + @classmethod + def from_model(cls, model: Section) -> Self: + """ + Class method that converts a `Section` model into a `SectionEntity` + + Parameters: + - model (Section): Model to convert into an entity + Returns: + SectionEntity: Entity created from model + """ + return cls( + id=model.id, + course_id=model.course_id, + number=model.number, + term_id=model.term_id, + meeting_pattern=model.meeting_pattern, + override_title=model.override_title, + override_description=model.override_description, + ) + + def to_model(self) -> Section: + """ + Converts a `SectionEntity` object into a `Section` model object + + Returns: + Section: `Section` object from the entity + """ + + return Section( + id=self.id, + course_id=self.course_id, + number=self.number, + term_id=self.term_id, + meeting_pattern=self.meeting_pattern, + lecture_room=( + self.lecture_rooms[0].room.to_model() + if len(self.lecture_rooms) > 0 + else None + ), + office_hour_rooms=[room.to_model() for room in self.office_hour_rooms], + staff=[members.to_flat_model() for members in self.staff], + override_title=self.override_title, + override_description=self.override_description, + ) + + def to_details_model(self) -> SectionDetails: + """ + Converts a `SectionEntity` object into a `SectionDetails` model object + + Returns: + SectionDetails: `SectionDetails` object from the entity + """ + + section = self.to_model() + + return SectionDetails( + id=self.id, + course_id=self.course_id, + course=self.course.to_model(), + number=self.number, + term_id=self.term_id, + term=self.term.to_model(), + meeting_pattern=self.meeting_pattern, + lecture_room=section.lecture_room, + office_hour_rooms=section.office_hour_rooms, + staff=section.staff, + override_title=self.override_title, + override_description=self.override_description, + ) diff --git a/backend/entities/academics/section_member_entity.py b/backend/entities/academics/section_member_entity.py new file mode 100644 index 000000000..696c7cc14 --- /dev/null +++ b/backend/entities/academics/section_member_entity.py @@ -0,0 +1,61 @@ +"""Definition of SQLAlchemy table-backed object mapping entity for the user - section association table.""" + +from typing import Self +from sqlalchemy import ForeignKey, Integer +from sqlalchemy import Enum as SQLAlchemyEnum +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from ...models.roster_role import RosterRole +from ...models.academics.section_member import SectionMember + +from ..entity_base import EntityBase + +__authors__ = ["Ajay Gandecha"] +__copyright__ = "Copyright 2023" +__license__ = "MIT" + + +class SectionMemberEntity(EntityBase): + """Serves as the database model schema defining the shape of the `UserSection` table + + This table is the association / join table to establish the many-to-many relationship + between the `user` and `section` tables. + + To establish this relationship, this entity contains two primary key fields for each related + table. + """ + + # Name for the user section table in the PostgreSQL database + __tablename__ = "academics__user_section" + + # User Section properties (columns in the database table) + + # Section for the current relation + # NOTE: This is ultimately a join table for a many-to-many relationship + section_id: Mapped[int] = mapped_column( + ForeignKey("academics__section.id"), primary_key=True + ) + section: Mapped["SectionEntity"] = relationship(back_populates="members") + + # User for the current relation + # NOTE: This is ultimately a join table for a many-to-many relationship + user_id: Mapped[int] = mapped_column(ForeignKey("user.id"), primary_key=True) + user: Mapped["UserEntity"] = relationship(back_populates="sections") + + # Type of relationship + member_role: Mapped[RosterRole] = mapped_column(SQLAlchemyEnum(RosterRole)) + + def to_flat_model(self) -> SectionMember: + """ + Converts a `SectionEntity` object into a `SectionMember` model object + + Returns: + SectionMember: `SectionMember` object from the entity + """ + return SectionMember( + id=self.user.id, + first_name=self.user.first_name, + last_name=self.user.last_name, + pronouns=self.user.pronouns, + member_role=self.member_role, + ) diff --git a/backend/entities/academics/section_room_entity.py b/backend/entities/academics/section_room_entity.py new file mode 100644 index 000000000..14c08ef7e --- /dev/null +++ b/backend/entities/academics/section_room_entity.py @@ -0,0 +1,50 @@ +"""Definition of SQLAlchemy table-backed object mapping entity for the room - section association table.""" + +from typing import Self +from sqlalchemy import ForeignKey, Integer +from sqlalchemy import Enum as SQLAlchemyEnum +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from backend.models.room_assignment_type import RoomAssignmentType + +from ...models.roster_role import RosterRole +from ...models.academics.section_member import SectionMember + +from ..entity_base import EntityBase + +__authors__ = ["Ajay Gandecha"] +__copyright__ = "Copyright 2023" +__license__ = "MIT" + + +class SectionRoomEntity(EntityBase): + """Serves as the database model schema defining the shape of the `SectionRoom` table + + This table is the association / join table to establish the many-to-many relationship + between the `room` and `section` tables. + + To establish this relationship, this entity contains two primary key fields for each related + table. + """ + + # Name for the section room table in the PostgreSQL database + __tablename__ = "academics__section_room" + + # Properties (columns in the database table) + + # Section for the current relation + # NOTE: This is ultimately a join table for a many-to-many relationship + section_id: Mapped[int] = mapped_column( + ForeignKey("academics__section.id"), primary_key=True + ) + section: Mapped["SectionEntity"] = relationship(back_populates="rooms") + + # Room for the current relation + # NOTE: This is ultimately a join table for a many-to-many relationship + room_id: Mapped[str] = mapped_column(ForeignKey("room.id"), primary_key=True) + room: Mapped["RoomEntity"] = relationship(back_populates="course_sections") + + # Type of relationship + assignment_type: Mapped[RoomAssignmentType] = mapped_column( + SQLAlchemyEnum(RoomAssignmentType) + ) diff --git a/backend/entities/academics/term_entity.py b/backend/entities/academics/term_entity.py new file mode 100644 index 000000000..87c23f6cf --- /dev/null +++ b/backend/entities/academics/term_entity.py @@ -0,0 +1,77 @@ +"""Definition of SQLAlchemy table-backed object mapping entity for Terms.""" + +from typing import Self +from sqlalchemy import Integer, String, DateTime +from sqlalchemy.orm import Mapped, mapped_column, relationship +from ..entity_base import EntityBase +from datetime import datetime +from ...models.academics.term import Term +from ...models.academics.term_details import TermDetails + +__authors__ = ["Ajay Gandecha"] +__copyright__ = "Copyright 2023" +__license__ = "MIT" + + +class TermEntity(EntityBase): + """Serves as the database model schema defining the shape of the `Term` table""" + + # Name for the term table in the PostgreSQL database + __tablename__ = "academics__term" + + # Term properties (columns in the database table) + + # Unique ID for the term + # Format: + # For example, F23 + id: Mapped[str] = mapped_column(String(6), primary_key=True) + + # Name of the term (for example, "Fall 2023") + name: Mapped[str] = mapped_column(String, default="") + # Starting date for the term + start: Mapped[datetime] = mapped_column(DateTime) + # Ending date for the term + end: Mapped[datetime] = mapped_column(DateTime) + + # NOTE: This field establishes a one-to-many relationship between the term and section tables. + course_sections: Mapped[list["SectionEntity"]] = relationship( + back_populates="term", + cascade="all,delete", + order_by="SectionEntity.course_id + SectionEntity.number", + ) + + @classmethod + def from_model(cls, model: Term) -> Self: + """ + Class method that converts a `Term` model into a `TermEntity` + + Parameters: + - model (Term): Model to convert into an entity + Returns: + TermEntity: Entity created from model + """ + return cls(id=model.id, name=model.name, start=model.start, end=model.end) + + def to_model(self) -> Term: + """ + Converts a `TermEntity` object into a `Term` model object + + Returns: + Term: `Term` object from the entity + """ + return Term(id=self.id, name=self.name, start=self.start, end=self.end) + + def to_details_model(self) -> TermDetails: + """ + Converts a `TermEntity` object into a `TermDetails` model object + + Returns: + TermDetails: `TermDetails` object from the entity + """ + return TermDetails( + id=self.id, + name=self.name, + start=self.start, + end=self.end, + course_sections=[section.to_model() for section in self.course_sections], + ) diff --git a/backend/entities/coworking/__init__.py b/backend/entities/coworking/__init__.py index 6a32a6a05..f341dbcc8 100644 --- a/backend/entities/coworking/__init__.py +++ b/backend/entities/coworking/__init__.py @@ -1,5 +1,4 @@ from .operating_hours_entity import OperatingHoursEntity -from .room_entity import RoomEntity -from .seat_entity import SeatEntity from .reservation_entity import ReservationEntity from .reservation_seat_table import reservation_seat_table +from .seat_entity import SeatEntity diff --git a/backend/entities/coworking/reservation_entity.py b/backend/entities/coworking/reservation_entity.py index ef0817a45..ae45507ff 100644 --- a/backend/entities/coworking/reservation_entity.py +++ b/backend/entities/coworking/reservation_entity.py @@ -5,7 +5,6 @@ from sqlalchemy.orm import Mapped, mapped_column, relationship, Session from ..entity_base import EntityBase from ...models.coworking import Reservation, ReservationState -from .room_entity import RoomEntity from .seat_entity import SeatEntity from ..user_entity import UserEntity from .reservation_user_table import reservation_user_table @@ -29,9 +28,7 @@ class ReservationEntity(EntityBase): end: Mapped[datetime] = mapped_column(DateTime, nullable=False) state: Mapped[ReservationState] = mapped_column(String, nullable=False) walkin: Mapped[bool] = mapped_column(Boolean, nullable=False) - room_id: Mapped[str] = mapped_column( - String, ForeignKey("coworking__room.id"), nullable=True - ) + room_id: Mapped[str] = mapped_column(String, ForeignKey("room.id"), nullable=True) created_at: Mapped[datetime] = mapped_column( DateTime, default=datetime.now, nullable=False ) @@ -42,7 +39,7 @@ class ReservationEntity(EntityBase): # Relationships users: Mapped[list[UserEntity]] = relationship(secondary=reservation_user_table) seats: Mapped[list[SeatEntity]] = relationship(secondary=reservation_seat_table) - room: Mapped[RoomEntity] = relationship("RoomEntity") + room: Mapped["RoomEntity"] = relationship("RoomEntity") def to_model(self) -> Reservation: """Converts the entity to a model. diff --git a/backend/entities/coworking/seat_entity.py b/backend/entities/coworking/seat_entity.py index d3f8f4219..40e701814 100644 --- a/backend/entities/coworking/seat_entity.py +++ b/backend/entities/coworking/seat_entity.py @@ -27,7 +27,7 @@ class SeatEntity(EntityBase): x: Mapped[int] = mapped_column(Integer) y: Mapped[int] = mapped_column(Integer) # SeatDetails Model Fields Follow - room_id: Mapped[str] = mapped_column(String, ForeignKey("coworking__room.id")) + room_id: Mapped[str] = mapped_column(String, ForeignKey("room.id")) room: Mapped["RoomEntity"] = relationship("RoomEntity", back_populates="seats") # type: ignore diff --git a/backend/entities/coworking/room_entity.py b/backend/entities/room_entity.py similarity index 88% rename from backend/entities/coworking/room_entity.py rename to backend/entities/room_entity.py index 7b8b8e596..f9690ff00 100644 --- a/backend/entities/coworking/room_entity.py +++ b/backend/entities/room_entity.py @@ -2,8 +2,10 @@ from sqlalchemy import Integer, String, Boolean from sqlalchemy.orm import Mapped, mapped_column, relationship -from ..entity_base import EntityBase -from ...models.coworking import Room, RoomDetails + +from backend.entities.coworking import SeatEntity +from .entity_base import EntityBase +from ..models import Room, RoomDetails from typing import Self __authors__ = ["Kris Jordan"] @@ -14,7 +16,7 @@ class RoomEntity(EntityBase): """Entity for Rooms under XL management.""" - __tablename__ = "coworking__room" + __tablename__ = "room" # Room Model Fields id: Mapped[str] = mapped_column(String, primary_key=True) @@ -29,6 +31,10 @@ class RoomEntity(EntityBase): "SeatEntity", back_populates="room" ) + course_sections: Mapped[list["SectionRoomEntity"]] = relationship( # type: ignore + back_populates="room" + ) + def to_model(self) -> Room: """Converts the entity to a model. diff --git a/backend/entities/user_entity.py b/backend/entities/user_entity.py index b90baee99..d31d95f3d 100644 --- a/backend/entities/user_entity.py +++ b/backend/entities/user_entity.py @@ -4,6 +4,9 @@ from sqlalchemy import Integer, String from sqlalchemy.orm import Mapped, mapped_column, relationship from typing import Self + +from backend.entities.academics.section_member_entity import SectionMemberEntity +from backend.models.academics.section_member import SectionMember from .entity_base import EntityBase from .user_role_table import user_role_table from ..models import User @@ -53,6 +56,9 @@ class UserEntity(EntityBase): # NOTE: This field establishes a one-to-many relationship between the permission and users table. permissions: Mapped["PermissionEntity"] = relationship(back_populates="user") + # Section relations that the user is a part of. + sections: Mapped[list["SectionMemberEntity"]] = relationship(back_populates="user") + @classmethod def from_model(cls, model: User) -> Self: """ diff --git a/backend/main.py b/backend/main.py index a7b07bba3..e42cc0d88 100644 --- a/backend/main.py +++ b/backend/main.py @@ -13,8 +13,10 @@ profile, authentication, user, + room, ) from .api.coworking import status, reservation, ambassador, operating_hours +from .api.academics import term, course, section from .api.admin import users as admin_users from .api.admin import roles as admin_roles from .services.exceptions import UserPermissionException, ResourceNotFoundException @@ -38,6 +40,8 @@ organizations.openapi_tags, events.openapi_tags, reservation.openapi_tags, + room.openapi_tags, + course.openapi_tags, health.openapi_tags, admin_users.openapi_tags, admin_roles.openapi_tags, @@ -61,6 +65,10 @@ authentication, admin_users, admin_roles, + term, + course, + section, + room, ] for feature_api in feature_apis: diff --git a/backend/migrations/README b/backend/migrations/README index 98e4f9c44..a31eae901 100644 --- a/backend/migrations/README +++ b/backend/migrations/README @@ -1 +1,27 @@ -Generic single-database configuration. \ No newline at end of file +Migrations are only needed for production deployment. For local development, +the database is created and dropped with the `reset_demo` script. + +To create a migration: + +1. Switch to main branch and pull latest from main. +2. Run delete database script +3. Run create database script (with `main`'s understanding of the schema) +4. Run the reset demo or test script to create tables +5. Stamp alembic at the latest revision: `alembic stamp head` +6. Switch to the branch you want to migrate +7. Run `alembic revision --autogenerate -m "migration message"` +8. Edit the contents of the generated migration to match the desired migration + Common issues: + 1. Renamed tables shouldn't be created and dropped as new tables + 2. Enums need to be explicitly dropped (see 3b3cd for an example) + +Test the migration: + +8. Run `alembic upgrade head` to run the migration (all tables should be created) +9. Try using the app to validate functionality +10. Run `alembic downgrade head-1` to undo the migration and test rollback +11. Repeat 8 and 9 to confirm rollback and upgrade work as expected + +On production deploy: + +12. Run `alembic upgrade head` to run the migration on a pod in production \ No newline at end of file diff --git a/backend/migrations/versions/3b3cd40813a5_add_academics_feature_tables.py b/backend/migrations/versions/3b3cd40813a5_add_academics_feature_tables.py new file mode 100644 index 000000000..0c8846678 --- /dev/null +++ b/backend/migrations/versions/3b3cd40813a5_add_academics_feature_tables.py @@ -0,0 +1,114 @@ +"""Add Academics Feature Tables + +Revision ID: 3b3cd40813a5 +Revises: 63fc48273e15 +Create Date: 2023-12-30 08:36:42.253188 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = "3b3cd40813a5" +down_revision = "63fc48273e15" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.rename_table("coworking__room", "room") + + op.create_table( + "academics__course", + sa.Column("id", sa.String(length=9), nullable=False), + sa.Column("subject_code", sa.String(length=4), nullable=False), + sa.Column("number", sa.String(length=4), nullable=False), + sa.Column("title", sa.String(), nullable=False), + sa.Column("description", sa.String(), nullable=False), + sa.Column("credit_hours", sa.Integer(), nullable=False), + sa.PrimaryKeyConstraint("id"), + ) + + op.create_table( + "academics__term", + sa.Column("id", sa.String(length=6), nullable=False), + sa.Column("name", sa.String(), nullable=False), + sa.Column("start", sa.DateTime(), nullable=False), + sa.Column("end", sa.DateTime(), nullable=False), + sa.PrimaryKeyConstraint("id"), + ) + + op.create_table( + "academics__section", + sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column("course_id", sa.String(length=9), nullable=False), + sa.Column("number", sa.String(), nullable=False), + sa.Column("term_id", sa.String(length=6), nullable=False), + sa.Column("meeting_pattern", sa.String(), nullable=False), + sa.Column("override_title", sa.String(), nullable=False), + sa.Column("override_description", sa.String(), nullable=False), + sa.ForeignKeyConstraint( + ["course_id"], + ["academics__course.id"], + ), + sa.ForeignKeyConstraint( + ["term_id"], + ["academics__term.id"], + ), + sa.PrimaryKeyConstraint("id"), + ) + op.create_table( + "academics__section_room", + sa.Column("section_id", sa.Integer(), nullable=False), + sa.Column("room_id", sa.String(), nullable=False), + sa.Column( + "assignment_type", + sa.Enum("LECTURE_ROOM", "OFFICE_HOURS", name="roomassignmenttype"), + nullable=False, + ), + sa.ForeignKeyConstraint( + ["room_id"], + ["room.id"], + ), + sa.ForeignKeyConstraint( + ["section_id"], + ["academics__section.id"], + ), + sa.PrimaryKeyConstraint("section_id", "room_id"), + ) + op.create_table( + "academics__user_section", + sa.Column("section_id", sa.Integer(), nullable=False), + sa.Column("user_id", sa.Integer(), nullable=False), + sa.Column( + "member_role", + sa.Enum("STUDENT", "UTA", "GTA", "INSTRUCTOR", name="rosterrole"), + nullable=False, + ), + sa.ForeignKeyConstraint( + ["section_id"], + ["academics__section.id"], + ), + sa.ForeignKeyConstraint( + ["user_id"], + ["user.id"], + ), + sa.PrimaryKeyConstraint("section_id", "user_id"), + ) + + +def downgrade() -> None: + op.drop_table("academics__user_section") + op.execute("DROP TYPE rosterrole") + + op.drop_table("academics__section_room") + op.execute("DROP TYPE roomassignmenttype") + + op.drop_table("academics__section") + + op.drop_table("academics__term") + + op.drop_table("academics__course") + + op.rename_table("room", "coworking__room") diff --git a/backend/models/__init__.py b/backend/models/__init__.py index 0325ff1b8..20bcc44b4 100644 --- a/backend/models/__init__.py +++ b/backend/models/__init__.py @@ -10,6 +10,8 @@ from .organization import Organization from .event import Event from .event_details import EventDetails +from .room import Room +from .room_details import RoomDetails __authors__ = ["Kris Jordan"] __copyright__ = "Copyright 2023" diff --git a/backend/models/academics/__init__.py b/backend/models/academics/__init__.py new file mode 100644 index 000000000..48b3ce4a6 --- /dev/null +++ b/backend/models/academics/__init__.py @@ -0,0 +1,15 @@ +from .term import Term +from .term_details import TermDetails +from .course import Course +from .course_details import CourseDetails +from .section import Section +from .section_details import SectionDetails + +__all__ = [ + "Term", + "TermDetails", + "Course", + "CourseDetails", + "Section", + "SectionDetails", +] diff --git a/backend/models/academics/course.py b/backend/models/academics/course.py new file mode 100644 index 000000000..0e42582ce --- /dev/null +++ b/backend/models/academics/course.py @@ -0,0 +1,22 @@ +from pydantic import BaseModel +from datetime import datetime + +__authors__ = ["Ajay Gandecha"] +__copyright__ = "Copyright 2023" +__license__ = "MIT" + + +class Course(BaseModel): + """ + Pydantic model to represent a `Course`. + + This model is based on the `CourseEntity` model, which defines the shape + of the `Course` database in the PostgreSQL database + """ + + id: str + subject_code: str + number: str + title: str + description: str + credit_hours: int diff --git a/backend/models/academics/course_details.py b/backend/models/academics/course_details.py new file mode 100644 index 000000000..974d991ee --- /dev/null +++ b/backend/models/academics/course_details.py @@ -0,0 +1,19 @@ +from pydantic import BaseModel +from .section import Section +from .course import Course + +__authors__ = ["Ajay Gandecha"] +__copyright__ = "Copyright 2023" +__license__ = "MIT" + + +class CourseDetails(Course): + """ + Pydantic model to represent a `Course`, including back-populated + relationship fields. + + This model is based on the `CourseEntity` model, which defines the shape + of the `Course` database in the PostgreSQL database. + """ + + sections: list[Section] diff --git a/backend/models/academics/section.py b/backend/models/academics/section.py new file mode 100644 index 000000000..00a85a9bd --- /dev/null +++ b/backend/models/academics/section.py @@ -0,0 +1,28 @@ +from pydantic import BaseModel +from datetime import datetime +from .section_member import SectionMember +from ..room import Room + +__authors__ = ["Ajay Gandecha"] +__copyright__ = "Copyright 2023" +__license__ = "MIT" + + +class Section(BaseModel): + """ + Pydantic model to represent a `Section`. + + This model is based on the `SectionEntity` model, which defines the shape + of the `Section` database in the PostgreSQL database + """ + + id: int | None = None + course_id: str + number: str + term_id: str + meeting_pattern: str + staff: list[SectionMember] = [] + lecture_room: Room | None = None + office_hour_rooms: list[Room] = [] + override_title: str + override_description: str diff --git a/backend/models/academics/section_details.py b/backend/models/academics/section_details.py new file mode 100644 index 000000000..f90e7319a --- /dev/null +++ b/backend/models/academics/section_details.py @@ -0,0 +1,24 @@ +from pydantic import BaseModel + +from ..room import Room +from .course import Course +from .term import Term +from .section import Section +from .section_member import SectionMember + +__authors__ = ["Ajay Gandecha"] +__copyright__ = "Copyright 2023" +__license__ = "MIT" + + +class SectionDetails(Section): + """ + Pydantic model to represent an `Section`, including back-populated + relationship fields. + + This model is based on the `SectionEntity` model, which defines the shape + of the `Section` database in the PostgreSQL database. + """ + + course: Course + term: Term diff --git a/backend/models/academics/section_member.py b/backend/models/academics/section_member.py new file mode 100644 index 000000000..6bf5d20ae --- /dev/null +++ b/backend/models/academics/section_member.py @@ -0,0 +1,23 @@ +from pydantic import BaseModel +from ..roster_role import RosterRole + + +__authors__ = ["Ajay Gandecha"] +__copyright__ = "Copyright 2023" +__license__ = "MIT" + + +class SectionMember(BaseModel): + """ + Pydantic model to represent the information about a user who is a + staff of a section of a course. + + This model is based on the `UserEntity` model, which defines the shape + of the `User` database in the PostgreSQL database + """ + + id: int | None = None + first_name: str + last_name: str + pronouns: str + member_role: RosterRole diff --git a/backend/models/academics/term.py b/backend/models/academics/term.py new file mode 100644 index 000000000..b11cc2aa0 --- /dev/null +++ b/backend/models/academics/term.py @@ -0,0 +1,19 @@ +from pydantic import BaseModel +from datetime import datetime +from ..coworking.time_range import TimeRange + +__authors__ = ["Ajay Gandecha"] +__copyright__ = "Copyright 2023" +__license__ = "MIT" + + +class Term(TimeRange, BaseModel): + """ + Pydantic model to represent a `Term`. + + This model is based on the `TermEntity` model, which defines the shape + of the `Term` database in the PostgreSQL database + """ + + id: str + name: str diff --git a/backend/models/academics/term_details.py b/backend/models/academics/term_details.py new file mode 100644 index 000000000..3fdc73022 --- /dev/null +++ b/backend/models/academics/term_details.py @@ -0,0 +1,19 @@ +from pydantic import BaseModel +from .section import Section +from .term import Term + +__authors__ = ["Ajay Gandecha"] +__copyright__ = "Copyright 2023" +__license__ = "MIT" + + +class TermDetails(Term): + """ + Pydantic model to represent an `Term`, including back-populated + relationship fields. + + This model is based on the `TermEntity` model, which defines the shape + of the `Term` database in the PostgreSQL database. + """ + + course_sections: list[Section] diff --git a/backend/models/coworking/__init__.py b/backend/models/coworking/__init__.py index dfe3d7648..7c839c4c7 100644 --- a/backend/models/coworking/__init__.py +++ b/backend/models/coworking/__init__.py @@ -1,6 +1,3 @@ -from .room import Room -from .room_details import RoomDetails - from .seat import Seat from .seat_details import SeatDetails @@ -22,8 +19,6 @@ from .status import Status __all__ = [ - "Room", - "RoomDetails", "Seat", "SeatDetails", "TimeRange", diff --git a/backend/models/coworking/availability.py b/backend/models/coworking/availability.py index 11bc18cc0..b584af262 100644 --- a/backend/models/coworking/availability.py +++ b/backend/models/coworking/availability.py @@ -2,7 +2,7 @@ from pydantic import BaseModel, validator -from .room import Room +from ..room import Room from .seat import Seat from .time_range import TimeRange from .availability_list import AvailabilityList diff --git a/backend/models/coworking/reservation.py b/backend/models/coworking/reservation.py index 469dff8b6..48850bbad 100644 --- a/backend/models/coworking/reservation.py +++ b/backend/models/coworking/reservation.py @@ -2,7 +2,7 @@ from pydantic import BaseModel from datetime import datetime from ...models.user import User, UserIdentity -from .room import Room +from ..room import Room from .seat import Seat, SeatIdentity from .time_range import TimeRange diff --git a/backend/models/coworking/seat_details.py b/backend/models/coworking/seat_details.py index eeb84fe54..7aca1c730 100644 --- a/backend/models/coworking/seat_details.py +++ b/backend/models/coworking/seat_details.py @@ -3,7 +3,7 @@ from pydantic import BaseModel from .seat import Seat -from .room import Room +from .. import Room __authors__ = ["Kris Jordan"] __copyright__ = "Copyright 2023" diff --git a/backend/models/coworking/room.py b/backend/models/room.py similarity index 100% rename from backend/models/coworking/room.py rename to backend/models/room.py diff --git a/backend/models/room_assignment_type.py b/backend/models/room_assignment_type.py new file mode 100644 index 000000000..61804b7c1 --- /dev/null +++ b/backend/models/room_assignment_type.py @@ -0,0 +1,12 @@ +"""Enum definition for types of room assignments for room section assignments.""" + +from enum import Enum + +__authors__ = ["Ajay Gandecha"] +__copyright__ = "Copyright 2023" +__license__ = "MIT" + + +class RoomAssignmentType(Enum): + LECTURE_ROOM = 0 + OFFICE_HOURS = 1 diff --git a/backend/models/coworking/room_details.py b/backend/models/room_details.py similarity index 94% rename from backend/models/coworking/room_details.py rename to backend/models/room_details.py index 9d3810be5..557ccf894 100644 --- a/backend/models/coworking/room_details.py +++ b/backend/models/room_details.py @@ -4,7 +4,7 @@ """ from .room import Room -from .seat import Seat +from .coworking.seat import Seat __authors__ = ["Kris Jordan"] __copyright__ = "Copyright 2023" diff --git a/backend/models/roster_role.py b/backend/models/roster_role.py new file mode 100644 index 000000000..e19ad172e --- /dev/null +++ b/backend/models/roster_role.py @@ -0,0 +1,14 @@ +"""Enum definition for roles in a course section roster.""" + +from enum import Enum + +__authors__ = ["Ajay Gandecha"] +__copyright__ = "Copyright 2023" +__license__ = "MIT" + + +class RosterRole(Enum): + STUDENT = 0 + UTA = 1 + GTA = 2 + INSTRUCTOR = 3 diff --git a/backend/models/user.py b/backend/models/user.py index 6856fc820..1eaaf0c63 100644 --- a/backend/models/user.py +++ b/backend/models/user.py @@ -59,5 +59,5 @@ class ProfileForm(BaseModel): first_name: str last_name: str - email: str pronouns: str + email: str diff --git a/backend/models/user_details.py b/backend/models/user_details.py index 68d9aa7b1..4ec7bdd6f 100644 --- a/backend/models/user_details.py +++ b/backend/models/user_details.py @@ -1,5 +1,5 @@ from .permission import Permission -from .user import User +from .user import User, UserIdentity __authors__ = ["Kris Jordan"] __copyright__ = "Copyright 2023" diff --git a/backend/script/repl.py b/backend/script/repl.py index 29f5a3b0b..c5c568cbb 100644 --- a/backend/script/repl.py +++ b/backend/script/repl.py @@ -27,9 +27,11 @@ from backend.entities import * from backend.entities.coworking import * +from backend.entities.academics import * print(" - all entities in backend/entities/__init__.py") print(" - all entities in backend/entities/coworking/__init__.py") +print(" - all entities in backend/entities/courses/__init__.py") from backend.models import * from backend.models.coworking import * diff --git a/backend/script/reset_demo.py b/backend/script/reset_demo.py index 22816907e..bc42e8300 100644 --- a/backend/script/reset_demo.py +++ b/backend/script/reset_demo.py @@ -17,12 +17,13 @@ from ..env import getenv from .. import entities -from ..test.services import role_data, user_data, permission_data +from ..test.services import role_data, user_data, permission_data, room_data from ..test.services.organization import organization_demo_data from ..test.services.event import event_demo_data -from ..test.services.coworking import room_data, seat_data, operating_hours_data, time +from ..test.services.coworking import seat_data, operating_hours_data, time from ..test.services.coworking.reservation import reservation_data +from ..test.services.academics import course_data, term_data, section_data __authors__ = ["Kris Jordan", "Ajay Gandecha"] __copyright__ = "Copyright 2023" @@ -48,9 +49,11 @@ organization_demo_data.insert_fake_data(session) event_demo_data.insert_fake_data(session) operating_hours_data.insert_fake_data(session, time) - room_data.insert_fake_data(session) seat_data.insert_fake_data(session) + room_data.insert_fake_data(session) reservation_data.insert_fake_data(session, time) - + course_data.insert_fake_data(session) + term_data.insert_fake_data(session) + section_data.insert_fake_data(session) # Commit changes to the database session.commit() diff --git a/backend/script/reset_testing.py b/backend/script/reset_testing.py index 5d1de9f0d..905188409 100644 --- a/backend/script/reset_testing.py +++ b/backend/script/reset_testing.py @@ -17,10 +17,10 @@ from ..env import getenv from .. import entities -from ..test.services import role_data, user_data, permission_data +from ..test.services import role_data, user_data, permission_data, room_data from ..test.services.organization import organization_test_data from ..test.services.event import event_test_data -from ..test.services.coworking import room_data, seat_data, operating_hours_data, time +from ..test.services.coworking import seat_data, operating_hours_data, time from ..test.services.coworking.reservation import reservation_data __authors__ = ["Kris Jordan", "Ajay Gandecha"] diff --git a/backend/services/__init__.py b/backend/services/__init__.py index a555f61fb..a02a5d9e2 100644 --- a/backend/services/__init__.py +++ b/backend/services/__init__.py @@ -5,3 +5,4 @@ from .organization import OrganizationService from .event import EventService from .exceptions import ResourceNotFoundException, UserPermissionException +from .room import RoomService diff --git a/backend/services/academics/__init__.py b/backend/services/academics/__init__.py new file mode 100644 index 000000000..63e82dbfb --- /dev/null +++ b/backend/services/academics/__init__.py @@ -0,0 +1,3 @@ +from .term import TermService +from .course import CourseService +from .section import SectionService diff --git a/backend/services/academics/course.py b/backend/services/academics/course.py new file mode 100644 index 000000000..68f66c2a5 --- /dev/null +++ b/backend/services/academics/course.py @@ -0,0 +1,175 @@ +""" +The Course Service allows the API to manipulate courses data in the database. +""" + +from fastapi import Depends +from sqlalchemy import select +from sqlalchemy.orm import Session + +from ...database import db_session +from ...models.academics import Course +from ...models.academics import CourseDetails +from ...models.user import User +from ...entities.academics import CourseEntity +from ..permission import PermissionService + +from ...services.exceptions import ResourceNotFoundException +from datetime import datetime + +__authors__ = ["Ajay Gandecha"] +__copyright__ = "Copyright 2023" +__license__ = "MIT" + + +class CourseService: + """Service that performs all of the actions on the `Course` table""" + + def __init__( + self, + session: Session = Depends(db_session), + permission_svc: PermissionService = Depends(), + ): + """Initializes the database session.""" + self._session = session + self._permission_svc = permission_svc + + def all(self) -> list[CourseDetails]: + """Retrieves all courses from the table + + Returns: + list[CourseDetails]: List of all `CourseDetails` + """ + # Select all entries in `Course` table + query = select(CourseEntity).order_by(CourseEntity.id) + + entities = self._session.scalars(query).all() + + # Convert entries to a model and return + return [entity.to_details_model() for entity in entities] + + def get_by_id(self, id: str) -> CourseDetails: + """Gets the course from the table for an id. + + Args: + id: ID of the course to retrieve. + Returns: + CourseDetails: Course based on the id. + """ + # Select all entries in the `Course` table and sort by end date + query = select(CourseEntity).filter(CourseEntity.id == id) + entity = self._session.scalars(query).one_or_none() + + # Raise an error if no entity was found. + if entity is None: + raise ResourceNotFoundException(f"Course with id: {id} does not exist.") + + # Return the model + return entity.to_details_model() + + def get(self, subject_code: str, number: str) -> CourseDetails: + """Gets a course based on its subject code and course number. + + Args: + subject_code: Subject code to query by (ex. COMP) + number: Course number to query by (ex. 110 in COMP 110) + Returns: + CourseDetails: Course for the parameters. + """ + # Select all entries in the `Course` table that contains this date. + query = select(CourseEntity).where( + CourseEntity.subject_code == subject_code, CourseEntity.number == number + ) + entity = self._session.scalars(query).one_or_none() + + # Rause an error if no entity was found. + if entity is None: + raise ResourceNotFoundException( + f"No course found for the given subject and number: {subject_code} {number}." + ) + + # Return the model + return entity.to_details_model() + + def create(self, subject: User, course: Course) -> CourseDetails: + """Creates a new course. + + Args: + subject: a valid User model representing the currently logged in User + course: Course to add to table + + Returns: + CourseDetails: Object added to table + """ + + # Check if user has admin permissions + self._permission_svc.enforce(subject, "academics.course.create", f"course/") + + # Create new object + course_entity = CourseEntity.from_model(course) + + # Add new object to table and commit changes + self._session.add(course_entity) + self._session.commit() + + # Return added object + return course_entity.to_details_model() + + def update(self, subject: User, course: Course) -> CourseDetails: + """Updates a course. + + Args: + subject: a valid User model representing the currently logged in User + course: Course to update + + Returns: + CourseDetails: Object updated in the table + """ + + # Check if user has admin permissions + self._permission_svc.enforce( + subject, "academics.course.update", f"course/{course.id}" + ) + + # Find the entity to update + course_entity = self._session.get(CourseEntity, course.id) + + # Raise an error if no entity was found + if course_entity is None: + raise ResourceNotFoundException( + f"Course with id: {course.id} does not exist." + ) + + # Update the entity + course_entity.subject_code = course.subject_code + course_entity.number = course.number + course_entity.title = course.title + course_entity.description = course.description + course_entity.credit_hours = course.credit_hours + + # Commit changes + self._session.commit() + + # Return edited object + return course_entity.to_details_model() + + def delete(self, subject: User, id: str) -> None: + """Deletes a course. + + Args: + subject: a valid User model representing the currently logged in User + id: ID of course to delete + """ + + # Check if user has admin permissions + self._permission_svc.enforce(subject, "academics.course.delete", f"course/{id}") + + # Find the entity to delete + course_entity = self._session.get(CourseEntity, id) + + # Raise an error if no entity was found + if course_entity is None: + raise ResourceNotFoundException(f"Course with id: {id} does not exist.") + + # Delete and commit changes + self._session.delete(course_entity) + self._session.commit() diff --git a/backend/services/academics/section.py b/backend/services/academics/section.py new file mode 100644 index 000000000..a775e7d49 --- /dev/null +++ b/backend/services/academics/section.py @@ -0,0 +1,266 @@ +""" +The Section Service allows the API to manipulate sections data in the database. +""" + +from fastapi import Depends +from sqlalchemy import select +from sqlalchemy.orm import Session + +from ...database import db_session +from ...models.academics import Section +from ...models.academics import SectionDetails +from ...models import User, Room +from ...models.room_assignment_type import RoomAssignmentType +from ...entities.academics import SectionEntity +from ...entities.academics import CourseEntity +from ...entities.academics import SectionRoomEntity +from ..permission import PermissionService + +from ...services.exceptions import ResourceNotFoundException +from datetime import datetime + +__authors__ = ["Ajay Gandecha"] +__copyright__ = "Copyright 2023" +__license__ = "MIT" + + +class SectionService: + """Service that performs all of the actions on the `Section` table""" + + def __init__( + self, + session: Session = Depends(db_session), + permission_svc: PermissionService = Depends(), + ): + """Initializes the database session.""" + self._session = session + self._permission_svc = permission_svc + + def all(self) -> list[SectionDetails]: + """Retrieves all sections from the table + + Returns: + list[SectionDetails]: List of all `SectionDetails` + """ + # Select all entries in `Section` table + query = select(SectionEntity).order_by( + SectionEntity.course_id, SectionEntity.number + ) + entities = self._session.scalars(query).all() + + # Convert entries to a model and return + return [entity.to_details_model() for entity in entities] + + def get_by_term(self, term_id: str) -> list[SectionDetails]: + """Retrieves all sections from the table by a term. + + Args: + term_id: ID of the term to query by. + Returns: + list[SectionDetails]: List of all `SectionDetails` + """ + # Select all entries in the `Section` tabl + query = ( + select(SectionEntity) + .where(SectionEntity.term_id == term_id) + .order_by(SectionEntity.course_id, SectionEntity.number) + ) + entities = self._session.scalars(query).all() + + # Return the model + return [entity.to_details_model() for entity in entities] + + def get_by_subject(self, subject_code: str) -> list[SectionDetails]: + """Retrieves all sections from the table by subject code. + + Args: + subject_code: subject to query by. + Returns: + list[SectionDetails]: List of all `SectionDetails` + """ + # Select all entries in the `Section` table + query = ( + select(SectionEntity) + .join(CourseEntity) + .where(CourseEntity.subject_code == subject_code) + ) + entities = self._session.scalars(query).all() + + # Return the model + return [entity.to_details_model() for entity in entities] + + def get_by_id(self, id: int) -> SectionDetails: + """Gets the section from the table for an id. + + Args: + id: ID of the section to retrieve. + Returns: + SectionDetails: Section based on the id. + """ + # Select all entries in the `Section` table and sort by end date + query = select(SectionEntity).filter(SectionEntity.id == id) + entity = self._session.scalars(query).one_or_none() + + # Raise an error if no entity was found. + if entity is None: + raise ResourceNotFoundException(f"Section with id: {id} does not exist.") + + # Return the model + return entity.to_details_model() + + def get( + self, subject_code: str, course_number: str, section_number: str + ) -> SectionDetails: + """Gets a course based on its subject code, course number, and section number. + + Args: + subject_code: Subject code to query by (ex. COMP) + course_number: Course number to query by (ex. 110 in COMP 110) + section_number: Section number to query by (ex. 003 in COMP 110-003) + Returns: + SectionDetails: Section for the parameters. + """ + # Select all entries in the `Section` table that contains this date. + query = ( + select(SectionEntity) + .where(SectionEntity.number == section_number) + .join(CourseEntity) + .where( + CourseEntity.subject_code == subject_code, + CourseEntity.number == course_number, + ) + ) + entity = self._session.scalars(query).one_or_none() + + # Rause an error if no entity was found. + if entity is None: + raise ResourceNotFoundException( + f"No section found for the given subject and number: {subject_code} {course_number}-{section_number}." + ) + + # Return the model + return entity.to_details_model() + + def create(self, subject: User, section: Section) -> SectionDetails: + """Creates a new section. + + Args: + subject: a valid User model representing the currently logged in User + section: Section to add to table + + Returns: + SectionDetails: Object added to table + """ + + # Check if user has admin permissions + self._permission_svc.enforce(subject, "academics.section.create", f"section/") + + # Create new object + section_entity = SectionEntity.from_model(section) + + # Add new object to table and commit changes + self._session.add(section_entity) + + self._session.commit() + + # Find added object + added_section = section_entity.to_details_model() + + # Now, attempt to add the lecture room + if section.lecture_room is not None: + # Check if user has admin permissions + self._permission_svc.enforce( + subject, "academics.section.create", f"section/" + ) + + # Then, attempt to create room relation + section_room_entity = SectionRoomEntity( + section_id=added_section.id, + room_id=section.lecture_room.id, + assignment_type=RoomAssignmentType.LECTURE_ROOM, + ) + self._session.add(section_room_entity) + self._session.commit() + + # Now, refresh the data and return. + return self._session.get(SectionEntity, added_section.id).to_details_model() + + def update(self, subject: User, section: Section) -> SectionDetails: + """Updates a section. + + Args: + subject: a valid User model representing the currently logged in User + section: Section to update + + Returns: + SectionDetails: Object updated in the table + """ + + # Check if user has admin permissions + self._permission_svc.enforce( + subject, "academics.section.update", f"section/{section.id}" + ) + + # Find the entity to update + section_entity = self._session.get(SectionEntity, section.id) + + # Raise an error if no entity was found + if section_entity is None: + raise ResourceNotFoundException( + f"Section with id: {section.id} does not exist." + ) + + # Update the entity + section_entity.course_id = section.course_id + section_entity.number = section.number + section_entity.term_id = section.term_id + section_entity.meeting_pattern = section.meeting_pattern + section_entity.override_title = section.override_title + section_entity.override_description = section.override_description + + query = select(SectionRoomEntity).where( + SectionRoomEntity.section_id == section.id, + SectionRoomEntity.assignment_type == RoomAssignmentType.LECTURE_ROOM, + ) + section_room_entity = self._session.scalars(query).one_or_none() + + if section.lecture_room is not None: + if section_room_entity is not None: + section_room_entity.room_id = section.lecture_room.id + else: + section_room_entity = SectionRoomEntity( + section_id=section.id, + room_id=section.lecture_room.id, + assignment_type=RoomAssignmentType.LECTURE_ROOM, + ) + self._session.add(section_room_entity) + + # Commit changes + self._session.commit() + + # Return edited object + return section_entity.to_details_model() + + def delete(self, subject: User, id: int) -> None: + """Deletes a section. + + Args: + subject: a valid User model representing the currently logged in User + id: ID of section to delete + """ + + # Check if user has admin permissions + self._permission_svc.enforce( + subject, "academics.section.delete", f"section/{id}" + ) + + # Find the entity to delete + section_entity = self._session.get(SectionEntity, id) + + # Raise an error if no entity was found + if section_entity is None: + raise ResourceNotFoundException(f"Section with id: {id} does not exist.") + + # Delete and commit changes + self._session.delete(section_entity) + self._session.commit() diff --git a/backend/services/academics/term.py b/backend/services/academics/term.py new file mode 100644 index 000000000..6781c239b --- /dev/null +++ b/backend/services/academics/term.py @@ -0,0 +1,172 @@ +""" +The Terms Service allows the API to manipulate terms data in the database. +""" + +from fastapi import Depends +from sqlalchemy import select +from sqlalchemy.orm import Session + +from ...database import db_session +from ...models.academics import Term +from ...models.academics import TermDetails +from ...models import User +from ...entities.academics import TermEntity +from ..permission import PermissionService + +from ...services.exceptions import ResourceNotFoundException +from datetime import datetime + +__authors__ = ["Ajay Gandecha"] +__copyright__ = "Copyright 2023" +__license__ = "MIT" + + +class TermService: + """Service that performs all of the actions on the `Term` table""" + + def __init__( + self, + session: Session = Depends(db_session), + permission_svc: PermissionService = Depends(), + ): + """Initializes the database session.""" + self._session = session + self._permission_svc = permission_svc + + def all(self) -> list[TermDetails]: + """Retrieves all terms from the table + + Returns: + list[TermDetails]: List of all `TermDetails` + """ + # Select all entries in `Term` table + query = select(TermEntity).order_by(TermEntity.start) + + entities = self._session.scalars(query).all() + + # Convert entries to a model and return + return [entity.to_details_model() for entity in entities] + + def get_by_id(self, id: str) -> TermDetails: + """Gets the term from the table for an id. + + Args: + id: ID of the term to retrieve. + Returns: + TermDetails: Term based on the id. + """ + # Select all entries in the `Term` table and sort by end date + query = select(TermEntity).filter(TermEntity.id == id).limit(1) + entity = self._session.scalars(query).one_or_none() + + # Raise an error if no entity was found. + if entity is None: + raise ResourceNotFoundException(f"Term with id: {id} does not exist.") + + # Return the model + return entity.to_details_model() + + def get_by_date(self, date: datetime) -> TermDetails: + """Gets the active term for a given date, if it exists. + + Args: + date: Date to query the active term for. + Returns: + TermDetails: Term based on the provided date. + """ + # Select all entries in the `Term` table that contains this date. + # This query either selects the most current term, or the upcoming term if there + # is no currently active term + query = ( + select(TermEntity).where(date < TermEntity.end).order_by(TermEntity.start) + ) + entity = self._session.scalars(query).first() + + # Rause an error if no entity was found. + if entity is None: + raise ResourceNotFoundException( + f"No active term found for the provided date: {date}." + ) + + # Return the model + return entity.to_details_model() + + def create(self, subject: User, term: Term) -> TermDetails: + """Creates a new term. + + Args: + subject: a valid User model representing the currently logged in User + term (Term): Term to add to table + + Returns: + TermDetails: Object added to table + """ + + # Check if user has admin permissions + self._permission_svc.enforce(subject, "academics.term.create", f"term/") + + # Create new object + term_entity = TermEntity.from_model(term) + + # Add new object to table and commit changes + self._session.add(term_entity) + self._session.commit() + + # Return added object + return term_entity.to_details_model() + + def update(self, subject: User, term: Term) -> TermDetails: + """Updates a term. + + Args: + subject: a valid User model representing the currently logged in User + term (Term): Term to update + + Returns: + TermDetails: Object updated in the table + """ + + # Check if user has admin permissions + self._permission_svc.enforce( + subject, "academics.term.update", f"term/{term.id}" + ) + + # Find the entity to update + term_entity = self._session.get(TermEntity, term.id) + + # Raise an error if no entity was found + if term_entity is None: + raise ResourceNotFoundException(f"Term with id: {term.id} does not exist.") + + # Update the entity + term_entity.name = term.name + term_entity.start = term.start + term_entity.end = term.end + + # Commit changes + self._session.commit() + + # Return edited object + return term_entity.to_details_model() + + def delete(self, subject: User, id: str) -> None: + """Deletes a term. + + Args: + subject: a valid User model representing the currently logged in User + id (str): ID for term to delete + """ + + # Check if user has admin permissions + self._permission_svc.enforce(subject, "academics.term.delete", f"term/{id}") + + # Find the entity to delete + term_entity = self._session.get(TermEntity, id) + + # Raise an error if no entity was found + if term_entity is None: + raise ResourceNotFoundException(f"Term with id: {id} does not exist.") + + # Delete and commit changes + self._session.delete(term_entity) + self._session.commit() diff --git a/backend/services/coworking/__init__.py b/backend/services/coworking/__init__.py index 1e633f858..2ed17e1eb 100644 --- a/backend/services/coworking/__init__.py +++ b/backend/services/coworking/__init__.py @@ -1,6 +1,5 @@ from .policy import PolicyService from .status import StatusService from .operating_hours import OperatingHoursService -from .room import RoomService from .seat import SeatService from .reservation import ReservationService diff --git a/backend/services/coworking/room.py b/backend/services/coworking/room.py deleted file mode 100644 index ff23bb5e7..000000000 --- a/backend/services/coworking/room.py +++ /dev/null @@ -1,32 +0,0 @@ -"""Service that manages rooms in the coworking space.""" - -from fastapi import Depends -from sqlalchemy.orm import Session -from ...database import db_session -from ...models.coworking import RoomDetails -from ...entities.coworking import RoomEntity - -__authors__ = ["Kris Jordan"] -__copyright__ = "Copyright 2023" -__license__ = "MIT" - - -class RoomService: - """RoomService is the access layer to coworking rooms. And a good pun.""" - - def __init__(self, session: Session = Depends(db_session)): - """Initializes a new RoomService. - - Args: - session (Session): The database session to use, typically injected by FastAPI. - """ - self._session = session - - def list(self) -> list[RoomDetails]: - """Returns all rooms in the coworking space. - - Returns: - list[RoomDetails]: All rooms in the coworking space ordered by increasing capacity. - """ - entities = self._session.query(RoomEntity).order_by(RoomEntity.capacity).all() - return [entity.to_details_model() for entity in entities] diff --git a/backend/services/room.py b/backend/services/room.py new file mode 100644 index 000000000..e4c18f5cb --- /dev/null +++ b/backend/services/room.py @@ -0,0 +1,146 @@ +""" +The Room Service allows the API to manipulate rooms data in the database. +""" + +from fastapi import Depends +from sqlalchemy import select +from sqlalchemy.orm import Session + +from ..database import db_session +from ..models import Room +from ..models import RoomDetails +from ..models.user import User +from ..entities import RoomEntity +from .permission import PermissionService + +from ..services.exceptions import ResourceNotFoundException +from datetime import datetime + +__authors__ = ["Ajay Gandecha"] +__copyright__ = "Copyright 2023" +__license__ = "MIT" + + +class RoomService: + """Service that performs all of the actions on the `Room` table""" + + def __init__( + self, + session: Session = Depends(db_session), + permission_svc: PermissionService = Depends(), + ): + """Initializes the database session.""" + self._session = session + self._permission_svc = permission_svc + + def all(self) -> list[RoomDetails]: + """Retrieves all rooms from the table + + Returns: + list[RoomDetails]: List of all `RoomDetails` + """ + # Select all entries in `Room` table + query = select(RoomEntity).order_by(RoomEntity.capacity) + entities = self._session.scalars(query).all() + + # Convert entries to a model and return + return [entity.to_details_model() for entity in entities] + + def get_by_id(self, id: str) -> RoomDetails: + """Gets the room from the table for an id. + + Args: + id: ID of the room to retrieve. + Returns: + RoomDetails: Room based on the id. + """ + # Select all entries in the `Room` table and sort by end date + query = select(RoomEntity).filter(RoomEntity.id == id) + entity = self._session.scalars(query).one_or_none() + + # Raise an error if no entity was found. + if entity is None: + raise ResourceNotFoundException(f"Room with id: {id} does not exist.") + + # Return the model + return entity.to_details_model() + + def create(self, subject: User, room: RoomDetails) -> RoomDetails: + """Creates a new room. + + Args: + subject: a valid User model representing the currently logged in User + room: Room to add to table + + Returns: + RoomDetails: Object added to table + """ + + # Check if user has admin permissions + self._permission_svc.enforce(subject, "room.create", f"room/") + + # Create new object + room_entity = RoomEntity.from_model(room) + + # Add new object to table and commit changes + self._session.add(room_entity) + self._session.commit() + + # Return added object + return room_entity.to_details_model() + + def update(self, subject: User, room: RoomDetails) -> RoomDetails: + """Updates a room. + + Args: + subject: a valid User model representing the currently logged in User + room: Room to update + + Returns: + RoomDetails: Object updated in the table + """ + + # Check if user has admin permissions + self._permission_svc.enforce(subject, "room.update", f"room/{room.id}") + + # Find the entity to update + room_entity = self._session.get(RoomEntity, room.id) + + # Raise an error if no entity was found + if room_entity is None: + raise ResourceNotFoundException(f"Room with id: {room.id} does not exist.") + + # Update the entity + room_entity.nickname = room.nickname + room_entity.building = room.building + room_entity.room = room.room + room_entity.capacity = room.capacity + room_entity.reservable = room.reservable + + # Commit changes + self._session.commit() + + # Return edited object + return room_entity.to_details_model() + + def delete(self, subject: User, id: str) -> None: + """Deletes a room. + + Args: + subject: a valid User model representing the currently logged in User + id: ID of room to delete + """ + + # Check if user has admin permissions + self._permission_svc.enforce(subject, "room.delete", f"room/{id}") + + # Find the entity to delete + room_entity = self._session.get(RoomEntity, id) + + # Raise an error if no entity was found + if room_entity is None: + raise ResourceNotFoundException(f"Room with id: {id} does not exist.") + + # Delete and commit changes + self._session.delete(room_entity) + self._session.commit() diff --git a/backend/test/services/academics/__init__.py b/backend/test/services/academics/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/backend/test/services/academics/course_data.py b/backend/test/services/academics/course_data.py new file mode 100644 index 000000000..5689bea92 --- /dev/null +++ b/backend/test/services/academics/course_data.py @@ -0,0 +1,71 @@ +"""Course data for tests.""" + +import pytest +from sqlalchemy.orm import Session +from ....entities.academics import CourseEntity +from ....models.academics import Course +from ..reset_table_id_seq import reset_table_id_seq +from datetime import datetime + +__authors__ = ["Ajay Gandecha"] +__copyright__ = "Copyright 2023" +__license__ = "MIT" + +comp_110 = Course( + id="comp110", + subject_code="COMP", + number="110", + title="Introduction to Programming and Data Science", + description="Introduces students to programming and data science from a computational perspective. With an emphasis on modern applications in society, students gain experience with problem decomposition, algorithms for data analysis, abstraction design, and ethics in computing. No prior programming experience expected. Foundational concepts include data types, sequences, boolean logic, control flow, functions/methods, recursion, classes/objects, input/output, data organization, transformations, and visualizations.", + credit_hours=3, +) + +comp_210 = Course( + id="comp210", + subject_code="COMP", + number="210", + title="Data Structures and Analysis", + description="This course will teach you how to organize the data used in computer programs so that manipulation of that data can be done efficiently on large problems and large data instances. Rather than learning to use the data structures found in the libraries of programming languages, you will be learning how those libraries are constructed, and why the items that are included in them are there (and why some are excluded).", + credit_hours=3, +) + +comp_301 = Course( + id="comp301", + subject_code="COMP", + number="301", + title="Foundations of Programming", + description="Students will learn how to reason about how their code is structured, identify whether a given structure is effective in a given context, and look at ways of organizing units of code that support larger programs. In a nutshell, the primary goal of the course is to equip students with tools and techniques that will help them not only in later courses in the major but also in their careers afterwards.", + credit_hours=3, +) + +edited_comp_110 = Course( + id="comp110", + subject_code="COMP", + number="110", + title="Introduction to Programming", + description="Introduces students to programming and data science from a computational perspective. With an emphasis on modern applications in society, students gain experience with problem decomposition, algorithms for data analysis, abstraction design, and ethics in computing. No prior programming experience expected. Foundational concepts include data types, sequences, boolean logic, control flow, functions/methods, recursion, classes/objects, input/output, data organization, transformations, and visualizations.", + credit_hours=3, +) + +new_course = Course( + id="comp423", + subject_code="COMP", + number="423", + title="Foundations of Software Engineering", + description="Best course in the department : )", + credit_hours=3, +) + +courses = [comp_110, comp_210, comp_301] + + +def insert_fake_data(session: Session): + for course in courses: + entity = CourseEntity.from_model(course) + session.add(entity) + + +@pytest.fixture(autouse=True) +def fake_data_fixture(session: Session): + insert_fake_data(session) + session.commit() diff --git a/backend/test/services/academics/course_test.py b/backend/test/services/academics/course_test.py new file mode 100644 index 000000000..dd30232d3 --- /dev/null +++ b/backend/test/services/academics/course_test.py @@ -0,0 +1,134 @@ +"""Tests for Courses Course Service.""" + +from unittest.mock import create_autospec +import pytest +from backend.services.exceptions import ( + ResourceNotFoundException, + UserPermissionException, +) +from backend.services.permission import PermissionService +from ....services.academics import CourseService +from ....models.academics import CourseDetails + +# Imported fixtures provide dependencies injected for the tests as parameters. +from .fixtures import permission_svc, course_svc + +# Import the setup_teardown fixture explicitly to load entities in database +from .course_data import fake_data_fixture as insert_course_fake_data + +# Import the fake model data in a namespace for test assertions +from . import course_data +from .. import user_data + +__authors__ = ["Ajay Gandecha"] +__copyright__ = "Copyright 2023" +__license__ = "MIT" + + +def test_all(course_svc: CourseService): + courses = course_svc.all() + + assert len(courses) == len(course_data.courses) + assert isinstance(courses[0], CourseDetails) + + +def test_get_by_id(course_svc: CourseService): + course = course_svc.get_by_id(course_data.comp_110.id) + + assert isinstance(course, CourseDetails) + assert course.id == course_data.comp_110.id + + +def test_get_by_id_not_found(course_svc: CourseService): + with pytest.raises(ResourceNotFoundException): + term = course_svc.get_by_id("COMP888") + pytest.fail() # Fail test if no error was thrown above + + +def test_get(course_svc: CourseService): + course = course_svc.get("COMP", "110") + + assert isinstance(course, CourseDetails) + assert course.id == course_data.comp_110.id + + +def test_get_not_found(course_svc: CourseService): + with pytest.raises(ResourceNotFoundException): + course = course_svc.get("COMP", "888") + pytest.fail() # Fail test if no error was thrown above + + +def test_create_as_root(course_svc: CourseService): + permission_svc = create_autospec(PermissionService) + course_svc._permission_svc = permission_svc + + course = course_svc.create(user_data.root, course_data.new_course) + + permission_svc.enforce.assert_called_with( + user_data.root, "academics.course.create", "course/" + ) + assert isinstance(course, CourseDetails) + assert course.id == course_data.new_course.id + + +def test_create_as_user(course_svc: CourseService): + with pytest.raises(UserPermissionException): + course = course_svc.create(user_data.user, course_data.new_course) + pytest.fail() + + +def test_update_as_root(course_svc: CourseService): + permission_svc = create_autospec(PermissionService) + course_svc._permission_svc = permission_svc + + course = course_svc.update(user_data.root, course_data.edited_comp_110) + + permission_svc.enforce.assert_called_with( + user_data.root, "academics.course.update", f"course/{course.id}" + ) + assert isinstance(course, CourseDetails) + assert course.id == course_data.edited_comp_110.id + + +def test_update_as_root_not_found(course_svc: CourseService): + permission_svc = create_autospec(PermissionService) + course_svc._permission_svc = permission_svc + + with pytest.raises(ResourceNotFoundException): + course = course_svc.update(user_data.root, course_data.new_course) + pytest.fail() + + +def test_update_as_user(course_svc: CourseService): + with pytest.raises(UserPermissionException): + course = course_svc.create(user_data.user, course_data.edited_comp_110) + pytest.fail() + + +def test_delete_as_root(course_svc: CourseService): + permission_svc = create_autospec(PermissionService) + course_svc._permission_svc = permission_svc + + course_svc.delete(user_data.root, course_data.comp_110.id) + + permission_svc.enforce.assert_called_with( + user_data.root, "academics.course.delete", f"course/{course_data.comp_110.id}" + ) + + courses = course_svc.all() + assert len(courses) == len(course_data.courses) - 1 + + +def test_delete_as_root_not_found(course_svc: CourseService): + permission_svc = create_autospec(PermissionService) + course_svc._permission_svc = permission_svc + + with pytest.raises(ResourceNotFoundException): + course = course_svc.delete(user_data.root, course_data.new_course.id) + pytest.fail() + + +def test_delete_as_user(course_svc: CourseService): + with pytest.raises(UserPermissionException): + course = course_svc.delete(user_data.user, course_data.comp_110.id) + pytest.fail() diff --git a/backend/test/services/academics/fixtures.py b/backend/test/services/academics/fixtures.py new file mode 100644 index 000000000..819037fef --- /dev/null +++ b/backend/test/services/academics/fixtures.py @@ -0,0 +1,35 @@ +"""Fixtures used for testing the Courses Services.""" + +import pytest +from unittest.mock import create_autospec +from sqlalchemy.orm import Session +from ....services import PermissionService +from ....services.academics import TermService, CourseService, SectionService + +__authors__ = ["Ajay Gandecha"] +__copyright__ = "Copyright 2023" +__license__ = "MIT" + + +@pytest.fixture() +def permission_svc(session: Session): + """PermissionService fixture.""" + return PermissionService(session) + + +@pytest.fixture() +def term_svc(session: Session, permission_svc: PermissionService): + """TermService fixture.""" + return TermService(session, permission_svc) + + +@pytest.fixture() +def course_svc(session: Session, permission_svc: PermissionService): + """CourseService fixture.""" + return CourseService(session, permission_svc) + + +@pytest.fixture() +def section_svc(session: Session, permission_svc: PermissionService): + """CourseService fixture.""" + return SectionService(session, permission_svc) diff --git a/backend/test/services/academics/section_data.py b/backend/test/services/academics/section_data.py new file mode 100644 index 000000000..a8a8fad5e --- /dev/null +++ b/backend/test/services/academics/section_data.py @@ -0,0 +1,150 @@ +"""Section data for tests.""" + +import pytest +from sqlalchemy.orm import Session +from backend.entities.academics.section_room_entity import SectionRoomEntity +from backend.entities.academics.course_entity import CourseEntity +from backend.entities.room_entity import RoomEntity +from backend.models.room_assignment_type import RoomAssignmentType + +from ....models.room import Room +from ....models.room_details import RoomDetails +from ....entities.academics import SectionEntity +from ....entities.academics import SectionMemberEntity +from ....models.academics import Section +from ....models.roster_role import RosterRole + +from ..reset_table_id_seq import reset_table_id_seq +from datetime import datetime + +# Import the setup_teardown fixture explicitly to load entities in database +from .term_data import fake_data_fixture as insert_term_fake_data +from .course_data import fake_data_fixture as insert_course_fake_data +from ..role_data import fake_data_fixture as insert_role_fake_data +from ..user_data import fake_data_fixture as insert_user_fake_data + +from . import course_data, term_data +from .. import user_data, role_data, permission_data + +__authors__ = ["Ajay Gandecha"] +__copyright__ = "Copyright 2023" +__license__ = "MIT" + +virtual_room = RoomDetails( + id="404", + nickname="Virtual", + building="Virtual", + room="Virtual", + capacity=999, + reservable=False, + seats=[], +) + +comp_101_001 = Section( + id=1, + course_id=course_data.comp_110.id, + number="001", + term_id=term_data.f_23.id, + meeting_pattern="TTh 12:00PM - 1:15PM", + override_title="", + override_description="", +) + +comp_101_002 = Section( + id=2, + course_id=course_data.comp_110.id, + number="002", + term_id=term_data.f_23.id, + meeting_pattern="TTh 1:30PM - 2:45PM", + override_title="", + override_description="", +) + +comp_301_001 = Section( + id=3, + course_id=course_data.comp_301.id, + number="001", + term_id=term_data.f_23.id, + meeting_pattern="TTh 8:00AM - 9:15AM", + override_title="", + override_description="", +) + +edited_comp_110 = Section( + id=2, + course_id=course_data.comp_110.id, + number="002", + term_id=term_data.f_23.id, + meeting_pattern="MW 1:30PM - 2:45PM", + override_title="", + override_description="", +) + +new_section = Section( + id=4, + course_id=course_data.comp_110.id, + number="003", + term_id=term_data.f_23.id, + meeting_pattern="MW 1:30PM - 2:45PM", + override_title="", + override_description="", +) + +ta = SectionMemberEntity( + user_id=user_data.ambassador.id, + section_id=comp_101_001.id, + member_role=RosterRole.INSTRUCTOR, +) + +room_assignment_110_001 = ( + comp_101_001.id, + virtual_room.id, + RoomAssignmentType.LECTURE_ROOM, +) + +room_assignment_110_002 = ( + comp_101_002.id, + virtual_room.id, + RoomAssignmentType.LECTURE_ROOM, +) +room_assignment_301_001 = ( + comp_301_001.id, + virtual_room.id, + RoomAssignmentType.LECTURE_ROOM, +) + +sections = [comp_101_001, comp_101_002, comp_301_001] +assignments = [ + room_assignment_110_001, + room_assignment_110_002, + room_assignment_301_001, +] +comp_110_sections = [comp_101_001, comp_101_002] + + +def insert_fake_data(session: Session): + room_entity = RoomEntity.from_model(virtual_room) + session.add(room_entity) + + for section in sections: + entity = SectionEntity.from_model(section) + session.add(entity) + + session.add(ta) + + for assignment in assignments: + section_id, room_id, assignment_type = assignment + entity = SectionRoomEntity( + section=session.get(SectionEntity, section_id), + room=session.get(RoomEntity, room_id), + assignment_type=assignment_type, + ) + session.add(entity) + + reset_table_id_seq(session, SectionEntity, SectionEntity.id, len(sections) + 1) + + +@pytest.fixture(autouse=True) +def fake_data_fixture(session: Session): + insert_fake_data(session) + session.commit() diff --git a/backend/test/services/academics/section_test.py b/backend/test/services/academics/section_test.py new file mode 100644 index 000000000..23ca67ddf --- /dev/null +++ b/backend/test/services/academics/section_test.py @@ -0,0 +1,178 @@ +"""Tests for Courses Section Service.""" + +from unittest.mock import create_autospec +import pytest +from backend.services.exceptions import ( + ResourceNotFoundException, + UserPermissionException, +) +from backend.services.permission import PermissionService +from ....services.academics import SectionService +from ....models.academics import SectionDetails + +# Imported fixtures provide dependencies injected for the tests as parameters. +from .fixtures import permission_svc, section_svc + +# Import the setup_teardown fixture explicitly to load entities in database +from ..core_data import setup_insert_data_fixture as insert_order_0 +from .term_data import fake_data_fixture as insert_order_1 +from .course_data import fake_data_fixture as insert_order_2 +from .section_data import fake_data_fixture as insert_order_3 + +# Import the fake model data in a namespace for test assertions +from . import term_data +from . import section_data +from .. import user_data + +__authors__ = ["Ajay Gandecha"] +__copyright__ = "Copyright 2023" +__license__ = "MIT" + + +def test_all(section_svc: SectionService): + sections = section_svc.all() + + assert len(sections) == len(section_data.sections) + assert isinstance(sections[0], SectionDetails) + + +def test_get_by_term(section_svc: SectionService): + sections = section_svc.get_by_term(term_data.f_23.id) + + assert len(sections) == len(section_data.sections) + assert isinstance(sections[0], SectionDetails) + + +def test_get_by_term_not_found(section_svc: SectionService): + sections = section_svc.get_by_term(term_data.sp_24.id) + + assert len(sections) == 0 + + +def test_get_by_subject(section_svc: SectionService): + sections = section_svc.get_by_subject("COMP") + + assert len(sections) == len(section_data.sections) + assert isinstance(sections[0], SectionDetails) + + +def test_get_by_subject_not_found(section_svc: SectionService): + sections = section_svc.get_by_subject("INLS") + + assert len(sections) == 0 + + +def test_get_by_id(section_svc: SectionService): + if section_data.comp_101_001.id is None: + raise ResourceNotFoundException("Invalid ID for section.") + + section = section_svc.get_by_id(section_data.comp_101_001.id) + + assert isinstance(section, SectionDetails) + assert section.id == section_data.comp_101_001.id + + +def test_get_by_id_not_found(section_svc: SectionService): + with pytest.raises(ResourceNotFoundException): + section = section_svc.get_by_id(0) + pytest.fail() # Fail test if no error was thrown above + + +def test_get(section_svc: SectionService): + section = section_svc.get("COMP", "110", "001") + + assert isinstance(section, SectionDetails) + assert section.id == section_data.comp_101_001.id + + +def test_get_not_found(section_svc: SectionService): + with pytest.raises(ResourceNotFoundException): + section = section_svc.get("COMP", "888", "001") + pytest.fail() # Fail test if no error was thrown above + + +def test_create_as_root(section_svc: SectionService): + permission_svc = create_autospec(PermissionService) + section_svc._permission_svc = permission_svc + + section = section_svc.create(user_data.root, section_data.new_section) + + permission_svc.enforce.assert_called_with( + user_data.root, "academics.section.create", "section/" + ) + assert isinstance(section, SectionDetails) + assert section.id == section_data.new_section.id + + +def test_create_as_user(section_svc: SectionService): + with pytest.raises(UserPermissionException): + section = section_svc.create(user_data.user, section_data.new_section) + pytest.fail() + + +def test_update_as_root(section_svc: SectionService): + permission_svc = create_autospec(PermissionService) + section_svc._permission_svc = permission_svc + + section = section_svc.update(user_data.root, section_data.edited_comp_110) + + permission_svc.enforce.assert_called_with( + user_data.root, "academics.section.update", f"section/{section.id}" + ) + assert isinstance(section, SectionDetails) + assert section.id == section_data.edited_comp_110.id + + +def test_update_as_root_not_found(section_svc: SectionService): + permission_svc = create_autospec(PermissionService) + section_svc._permission_svc = permission_svc + + with pytest.raises(ResourceNotFoundException): + section = section_svc.update(user_data.root, section_data.new_section) + pytest.fail() + + +def test_update_as_user(section_svc: SectionService): + with pytest.raises(UserPermissionException): + section = section_svc.create(user_data.user, section_data.edited_comp_110) + pytest.fail() + + +def test_delete_as_root(section_svc: SectionService): + if section_data.comp_101_001.id is None: + raise ResourceNotFoundException("Invalid ID for section.") + + permission_svc = create_autospec(PermissionService) + section_svc._permission_svc = permission_svc + + section_svc.delete(user_data.root, section_data.comp_101_001.id) + + permission_svc.enforce.assert_called_with( + user_data.root, + "academics.section.delete", + f"section/{section_data.comp_101_001.id}", + ) + + sections = section_svc.all() + assert len(sections) == len(section_data.sections) - 1 + + +def test_delete_as_root_not_found(section_svc: SectionService): + permission_svc = create_autospec(PermissionService) + section_svc._permission_svc = permission_svc + + with pytest.raises(ResourceNotFoundException): + if section_data.new_section.id is None: + raise ResourceNotFoundException("Invalid ID for section.") + + section = section_svc.delete(user_data.root, section_data.new_section.id) + pytest.fail() + + +def test_delete_as_user(section_svc: SectionService): + if section_data.comp_101_001.id is None: + raise ResourceNotFoundException("Invalid ID for section.") + + with pytest.raises(UserPermissionException): + section = section_svc.delete(user_data.user, section_data.comp_101_001.id) + pytest.fail() diff --git a/backend/test/services/academics/term_data.py b/backend/test/services/academics/term_data.py new file mode 100644 index 000000000..935e8836c --- /dev/null +++ b/backend/test/services/academics/term_data.py @@ -0,0 +1,53 @@ +"""Term data for tests.""" + +import pytest +from sqlalchemy.orm import Session +from ....entities.academics import TermEntity +from ....models.academics import Term +from ..reset_table_id_seq import reset_table_id_seq +from datetime import datetime + +__authors__ = ["Ajay Gandecha"] +__copyright__ = "Copyright 2023" +__license__ = "MIT" + + +sp_23 = Term( + id="S23", name="Spring 2023", start=datetime(2023, 1, 10), end=datetime(2023, 5, 10) +) + +f_23 = Term( + id="F23", name="Fall 2023", start=datetime(2023, 8, 20), end=datetime(2023, 12, 15) +) + +edited_f_23 = Term( + id="F23", + name="Best Semester Ever", + start=datetime(2023, 8, 20), + end=datetime(2023, 12, 15), +) + +sp_24 = Term( + id="S24", name="Spring 2024", start=datetime(2024, 1, 10), end=datetime(2024, 5, 10) +) + +new_term = Term( + id="F24", name="Fall 2024", start=datetime(2024, 8, 20), end=datetime(2024, 12, 15) +) + +terms = [sp_23, f_23, sp_24] + +today = datetime(2023, 12, 1) +bad_day = datetime(3000, 1, 1) + + +def insert_fake_data(session: Session): + for term in terms: + entity = TermEntity.from_model(term) + session.add(entity) + + +@pytest.fixture(autouse=True) +def fake_data_fixture(session: Session): + insert_fake_data(session) + session.commit() diff --git a/backend/test/services/academics/term_test.py b/backend/test/services/academics/term_test.py new file mode 100644 index 000000000..a8b091602 --- /dev/null +++ b/backend/test/services/academics/term_test.py @@ -0,0 +1,134 @@ +"""Tests for Courses Term Service.""" + +from unittest.mock import create_autospec +import pytest +from backend.services.exceptions import ( + ResourceNotFoundException, + UserPermissionException, +) +from backend.services.permission import PermissionService +from ....services.academics import TermService +from ....models.academics import TermDetails + +# Imported fixtures provide dependencies injected for the tests as parameters. +from .fixtures import permission_svc, term_svc + +# Import the setup_teardown fixture explicitly to load entities in database +from .term_data import fake_data_fixture as insert_term_fake_data + +# Import the fake model data in a namespace for test assertions +from . import term_data +from .. import user_data + +__authors__ = ["Ajay Gandecha"] +__copyright__ = "Copyright 2023" +__license__ = "MIT" + + +def test_all(term_svc: TermService): + terms = term_svc.all() + + assert len(terms) == len(term_data.terms) + assert isinstance(terms[0], TermDetails) + + +def test_get_by_id(term_svc: TermService): + term = term_svc.get_by_id(term_data.sp_23.id) + + assert isinstance(term, TermDetails) + assert term.id == term_data.sp_23.id + + +def test_get_by_id_not_found(term_svc: TermService): + with pytest.raises(ResourceNotFoundException): + term = term_svc.get_by_id("SP99") + pytest.fail() # Fail test if no error was thrown above + + +def test_get_by_date(term_svc: TermService): + term = term_svc.get_by_date(term_data.today) + + assert isinstance(term, TermDetails) + assert term.id == term_data.f_23.id + + +def test_get_by_date_not_found(term_svc: TermService): + with pytest.raises(ResourceNotFoundException): + term = term_svc.get_by_date(term_data.bad_day) + pytest.fail() # Fail test if no error was thrown above + + +def test_create_as_root(term_svc: TermService): + permission_svc = create_autospec(PermissionService) + term_svc._permission_svc = permission_svc + + term = term_svc.create(user_data.root, term_data.new_term) + + permission_svc.enforce.assert_called_with( + user_data.root, "academics.term.create", "term/" + ) + assert isinstance(term, TermDetails) + assert term.id == term_data.new_term.id + + +def test_create_as_user(term_svc: TermService): + with pytest.raises(UserPermissionException): + term = term_svc.create(user_data.user, term_data.new_term) + pytest.fail() + + +def test_update_as_root(term_svc: TermService): + permission_svc = create_autospec(PermissionService) + term_svc._permission_svc = permission_svc + + term = term_svc.update(user_data.root, term_data.edited_f_23) + + permission_svc.enforce.assert_called_with( + user_data.root, "academics.term.update", f"term/{term.id}" + ) + assert isinstance(term, TermDetails) + assert term.id == term_data.edited_f_23.id + + +def test_update_as_root_not_found(term_svc: TermService): + permission_svc = create_autospec(PermissionService) + term_svc._permission_svc = permission_svc + + with pytest.raises(ResourceNotFoundException): + term = term_svc.update(user_data.root, term_data.new_term) + pytest.fail() + + +def test_update_as_user(term_svc: TermService): + with pytest.raises(UserPermissionException): + term = term_svc.create(user_data.user, term_data.edited_f_23) + pytest.fail() + + +def test_delete_as_root(term_svc: TermService): + permission_svc = create_autospec(PermissionService) + term_svc._permission_svc = permission_svc + + term_svc.delete(user_data.root, term_data.f_23.id) + + permission_svc.enforce.assert_called_with( + user_data.root, "academics.term.delete", f"term/{term_data.f_23.id}" + ) + + terms = term_svc.all() + assert len(terms) == len(term_data.terms) - 1 + + +def test_delete_as_root_not_found(term_svc: TermService): + permission_svc = create_autospec(PermissionService) + term_svc._permission_svc = permission_svc + + with pytest.raises(ResourceNotFoundException): + term = term_svc.delete(user_data.root, term_data.new_term.id) + pytest.fail() + + +def test_delete_as_user(term_svc: TermService): + with pytest.raises(UserPermissionException): + term = term_svc.delete(user_data.user, term_data.f_23.id) + pytest.fail() diff --git a/backend/test/services/coworking/fixtures.py b/backend/test/services/coworking/fixtures.py index d79539e10..70b54b22d 100644 --- a/backend/test/services/coworking/fixtures.py +++ b/backend/test/services/coworking/fixtures.py @@ -6,7 +6,6 @@ from ....services import PermissionService from ....services.coworking import ( OperatingHoursService, - RoomService, SeatService, ReservationService, PolicyService, @@ -30,12 +29,6 @@ def operating_hours_svc(session: Session, permission_svc: PermissionService): return OperatingHoursService(session, permission_svc) -@pytest.fixture() -def room_svc(session: Session): - """RoomService fixture.""" - return RoomService(session) - - @pytest.fixture() def seat_svc(session: Session): """SeatService fixture.""" diff --git a/backend/test/services/coworking/reservation/change_test.py b/backend/test/services/coworking/reservation/change_test.py index a3b48e941..a5a009062 100644 --- a/backend/test/services/coworking/reservation/change_test.py +++ b/backend/test/services/coworking/reservation/change_test.py @@ -29,7 +29,7 @@ # Since there are relationship dependencies between the entities, order matters. from ...core_data import setup_insert_data_fixture as insert_order_0 from ..operating_hours_data import fake_data_fixture as insert_order_1 -from ..room_data import fake_data_fixture as insert_order_2 +from ...room_data import fake_data_fixture as insert_order_2 from ..seat_data import fake_data_fixture as insert_order_3 from .reservation_data import fake_data_fixture as insert_order_4 diff --git a/backend/test/services/coworking/reservation/draft_test.py b/backend/test/services/coworking/reservation/draft_test.py index 49d217bc7..f7d30e79b 100644 --- a/backend/test/services/coworking/reservation/draft_test.py +++ b/backend/test/services/coworking/reservation/draft_test.py @@ -27,7 +27,7 @@ # Since there are relationship dependencies between the entities, order matters. from ...core_data import setup_insert_data_fixture as insert_order_0 from ..operating_hours_data import fake_data_fixture as insert_order_1 -from ..room_data import fake_data_fixture as insert_order_2 +from ...room_data import fake_data_fixture as insert_order_2 from ..seat_data import fake_data_fixture as insert_order_3 from .reservation_data import fake_data_fixture as insert_order_4 diff --git a/backend/test/services/coworking/reservation/get_current_reservations_for_user_test.py b/backend/test/services/coworking/reservation/get_current_reservations_for_user_test.py index 7be7348bf..93e8ea26b 100644 --- a/backend/test/services/coworking/reservation/get_current_reservations_for_user_test.py +++ b/backend/test/services/coworking/reservation/get_current_reservations_for_user_test.py @@ -20,7 +20,7 @@ # Since there are relationship dependencies between the entities, order matters. from ...core_data import setup_insert_data_fixture as insert_order_0 from ..operating_hours_data import fake_data_fixture as insert_order_1 -from ..room_data import fake_data_fixture as insert_order_2 +from ...room_data import fake_data_fixture as insert_order_2 from ..seat_data import fake_data_fixture as insert_order_3 from .reservation_data import fake_data_fixture as insert_order_4 diff --git a/backend/test/services/coworking/reservation/get_reservation_test.py b/backend/test/services/coworking/reservation/get_reservation_test.py index f3a27b61f..ebe56c1f6 100644 --- a/backend/test/services/coworking/reservation/get_reservation_test.py +++ b/backend/test/services/coworking/reservation/get_reservation_test.py @@ -23,7 +23,7 @@ # Since there are relationship dependencies between the entities, order matters. from ...core_data import setup_insert_data_fixture as insert_order_0 from ..operating_hours_data import fake_data_fixture as insert_order_1 -from ..room_data import fake_data_fixture as insert_order_2 +from ...room_data import fake_data_fixture as insert_order_2 from ..seat_data import fake_data_fixture as insert_order_3 from .reservation_data import fake_data_fixture as insert_order_4 diff --git a/backend/test/services/coworking/reservation/get_seat_reservations_test.py b/backend/test/services/coworking/reservation/get_seat_reservations_test.py index 74907d4f5..e413581de 100644 --- a/backend/test/services/coworking/reservation/get_seat_reservations_test.py +++ b/backend/test/services/coworking/reservation/get_seat_reservations_test.py @@ -24,7 +24,7 @@ # Since there are relationship dependencies between the entities, order matters. from ...core_data import setup_insert_data_fixture as insert_order_0 from ..operating_hours_data import fake_data_fixture as insert_order_1 -from ..room_data import fake_data_fixture as insert_order_2 +from ...room_data import fake_data_fixture as insert_order_2 from ..seat_data import fake_data_fixture as insert_order_3 from .reservation_data import fake_data_fixture as insert_order_4 diff --git a/backend/test/services/coworking/reservation/list_all_active_and_upcoming_test.py b/backend/test/services/coworking/reservation/list_all_active_and_upcoming_test.py index 960d735a2..00f0b82fa 100644 --- a/backend/test/services/coworking/reservation/list_all_active_and_upcoming_test.py +++ b/backend/test/services/coworking/reservation/list_all_active_and_upcoming_test.py @@ -21,7 +21,7 @@ # Since there are relationship dependencies between the entities, order matters. from ...core_data import setup_insert_data_fixture as insert_order_0 from ..operating_hours_data import fake_data_fixture as insert_order_1 -from ..room_data import fake_data_fixture as insert_order_2 +from ...room_data import fake_data_fixture as insert_order_2 from ..seat_data import fake_data_fixture as insert_order_3 from .reservation_data import fake_data_fixture as insert_order_4 diff --git a/backend/test/services/coworking/reservation/seat_availability_test.py b/backend/test/services/coworking/reservation/seat_availability_test.py index b50a4ea1d..1ee70b774 100644 --- a/backend/test/services/coworking/reservation/seat_availability_test.py +++ b/backend/test/services/coworking/reservation/seat_availability_test.py @@ -21,7 +21,7 @@ # Since there are relationship dependencies between the entities, order matters. from ...core_data import setup_insert_data_fixture as insert_order_0 from ..operating_hours_data import fake_data_fixture as insert_order_1 -from ..room_data import fake_data_fixture as insert_order_2 +from ...room_data import fake_data_fixture as insert_order_2 from ..seat_data import fake_data_fixture as insert_order_3 from .reservation_data import fake_data_fixture as insert_order_4 diff --git a/backend/test/services/coworking/reservation/staff_checkin_reservation_test.py b/backend/test/services/coworking/reservation/staff_checkin_reservation_test.py index 37c23337b..5cc20de1a 100644 --- a/backend/test/services/coworking/reservation/staff_checkin_reservation_test.py +++ b/backend/test/services/coworking/reservation/staff_checkin_reservation_test.py @@ -25,7 +25,7 @@ # Since there are relationship dependencies between the entities, order matters. from ...core_data import setup_insert_data_fixture as insert_order_0 from ..operating_hours_data import fake_data_fixture as insert_order_1 -from ..room_data import fake_data_fixture as insert_order_2 +from ...room_data import fake_data_fixture as insert_order_2 from ..seat_data import fake_data_fixture as insert_order_3 from .reservation_data import fake_data_fixture as insert_order_4 diff --git a/backend/test/services/coworking/reservation/state_transition_test.py b/backend/test/services/coworking/reservation/state_transition_test.py index 553231228..548c8a989 100644 --- a/backend/test/services/coworking/reservation/state_transition_test.py +++ b/backend/test/services/coworking/reservation/state_transition_test.py @@ -35,7 +35,7 @@ # Since there are relationship dependencies between the entities, order matters. from ...core_data import setup_insert_data_fixture as insert_order_0 from ..operating_hours_data import fake_data_fixture as insert_order_1 -from ..room_data import fake_data_fixture as insert_order_2 +from ...room_data import fake_data_fixture as insert_order_2 from ..seat_data import fake_data_fixture as insert_order_3 from .reservation_data import fake_data_fixture as insert_order_4 diff --git a/backend/test/services/coworking/room_test.py b/backend/test/services/coworking/room_test.py deleted file mode 100644 index fa90a60e5..000000000 --- a/backend/test/services/coworking/room_test.py +++ /dev/null @@ -1,29 +0,0 @@ -"""Tests for Coworking Rooms Service.""" - -from ....services.coworking import RoomService -from ....models.coworking import RoomDetails - -# Imported fixtures provide dependencies injected for the tests as parameters. -from .fixtures import room_svc - -# Import the setup_teardown fixture explicitly to load entities in database -from .room_data import fake_data_fixture - -# Import the fake model data in a namespace for test assertions -from . import room_data - -__authors__ = ["Kris Jordan"] -__copyright__ = "Copyright 2023" -__license__ = "MIT" - - -def test_list(room_svc: RoomService): - rooms = room_svc.list() - assert len(rooms) == len(room_data.rooms) - assert isinstance(rooms[0], RoomDetails) - - -def test_list_ordered_by_capacity(room_svc: RoomService): - rooms = room_svc.list() - for i in range(1, len(rooms)): - assert rooms[i - 1].capacity <= rooms[i].capacity diff --git a/backend/test/services/coworking/seat_data.py b/backend/test/services/coworking/seat_data.py index fbf9a21b1..cc6a2dd19 100644 --- a/backend/test/services/coworking/seat_data.py +++ b/backend/test/services/coworking/seat_data.py @@ -9,7 +9,7 @@ from typing import Sequence from ..reset_table_id_seq import reset_table_id_seq -from .room_data import the_xl +from ..room_data import the_xl __authors__ = ["Kris Jordan"] __copyright__ = "Copyright 2023" diff --git a/backend/test/services/coworking/seat_test.py b/backend/test/services/coworking/seat_test.py index 53793da65..d2bf4c8b5 100644 --- a/backend/test/services/coworking/seat_test.py +++ b/backend/test/services/coworking/seat_test.py @@ -7,7 +7,7 @@ from .fixtures import seat_svc # Import the setup_teardown fixture explicitly to load entities in database -from .room_data import fake_data_fixture as insert_room_fake_data +from ..room_data import fake_data_fixture as insert_room_fake_data from .seat_data import fake_data_fixture as insert_seat_fake_data # Import the fake model data in a namespace for test assertions diff --git a/backend/test/services/coworking/status_test.py b/backend/test/services/coworking/status_test.py index 53a03b0a5..f3be6a5e9 100644 --- a/backend/test/services/coworking/status_test.py +++ b/backend/test/services/coworking/status_test.py @@ -13,7 +13,7 @@ from .time import * from ..core_data import setup_insert_data_fixture as insert_order_0 from .operating_hours_data import fake_data_fixture as insert_order_1 -from .room_data import fake_data_fixture as insert_order_2 +from ..room_data import fake_data_fixture as insert_order_2 from .seat_data import fake_data_fixture as insert_order_3 from .reservation.reservation_data import fake_data_fixture as insert_order_4 diff --git a/backend/test/services/fixtures.py b/backend/test/services/fixtures.py index f79ed182e..0ce77c66f 100644 --- a/backend/test/services/fixtures.py +++ b/backend/test/services/fixtures.py @@ -9,6 +9,7 @@ RoleService, OrganizationService, EventService, + RoomService, ) __authors__ = ["Kris Jordan", "Ajay Gandecha"] @@ -54,3 +55,9 @@ def organization_svc_integration(session: Session): def event_svc_integration(session: Session): """This fixture is used to test the EventService class with a real PermissionService.""" return EventService(session, PermissionService(session)) + + +@pytest.fixture() +def room_svc(session: Session): + """RoomService fixture.""" + return RoomService(session, PermissionService(session)) diff --git a/backend/test/services/coworking/room_data.py b/backend/test/services/room_data.py similarity index 74% rename from backend/test/services/coworking/room_data.py rename to backend/test/services/room_data.py index 9750bbcbf..c899be41a 100644 --- a/backend/test/services/coworking/room_data.py +++ b/backend/test/services/room_data.py @@ -2,9 +2,9 @@ import pytest from sqlalchemy.orm import Session -from ....entities.coworking import RoomEntity -from ....models.coworking import RoomDetails -from ..reset_table_id_seq import reset_table_id_seq +from ...entities import RoomEntity +from ...models import RoomDetails +from .reset_table_id_seq import reset_table_id_seq __authors__ = ["Kris Jordan"] __copyright__ = "Copyright 2023" @@ -61,6 +61,26 @@ seats=[], ) +new_room = RoomDetails( + id="FB009", + building="Fred Brooks", + room="009", + nickname="Large Room", + capacity=100, + reservable=False, + seats=[], +) + +edited_xl = RoomDetails( + id="SN156", + building="Sitterson", + room="156", + nickname="The CSXL", + capacity=100, + reservable=False, + seats=[], +) + rooms = [the_xl, group_a, group_b, group_c, pair_a] diff --git a/backend/test/services/room_test.py b/backend/test/services/room_test.py new file mode 100644 index 000000000..9a8065578 --- /dev/null +++ b/backend/test/services/room_test.py @@ -0,0 +1,125 @@ +"""Tests for Coworking Rooms Service.""" +from unittest.mock import create_autospec +import pytest +from backend.services.exceptions import ( + ResourceNotFoundException, + UserPermissionException, +) +from backend.services.permission import PermissionService +from ...services import RoomService +from ...models import RoomDetails + +# Imported fixtures provide dependencies injected for the tests as parameters. +from .fixtures import room_svc + +# Import the setup_teardown fixture explicitly to load entities in database +from .role_data import fake_data_fixture as fake_role_data_fixture +from .user_data import fake_data_fixture as fake_user_data_fixture +from .room_data import fake_data_fixture as fake_room_data_fixture + +# Import the fake model data in a namespace for test assertions +from . import room_data +from . import user_data + +__authors__ = ["Kris Jordan"] +__copyright__ = "Copyright 2023" +__license__ = "MIT" + + +def test_list(room_svc: RoomService): + rooms = room_svc.all() + assert len(rooms) == len(room_data.rooms) + assert isinstance(rooms[0], RoomDetails) + + +def test_list_ordered_by_capacity(room_svc: RoomService): + rooms = room_svc.all() + for i in range(1, len(rooms)): + assert rooms[i - 1].capacity <= rooms[i].capacity + + +def test_get_by_id(room_svc: RoomService): + room = room_svc.get_by_id(room_data.group_a.id) + + assert isinstance(room, RoomDetails) + assert room.id == room_data.group_a.id + + +def test_get_by_id_not_found(room_svc: RoomService): + with pytest.raises(ResourceNotFoundException): + room = room_svc.get_by_id("500") + pytest.fail() # Fail test if no error was thrown above + + +def test_create_as_root(room_svc: RoomService): + permission_svc = create_autospec(PermissionService) + room_svc._permission_svc = permission_svc + + room = room_svc.create(user_data.root, room_data.new_room) + + permission_svc.enforce.assert_called_with(user_data.root, "room.create", "room/") + assert isinstance(room, RoomDetails) + assert room.id == room_data.new_room.id + + +def test_create_as_user(room_svc: RoomService): + with pytest.raises(UserPermissionException): + room = room_svc.create(user_data.user, room_data.new_room) + pytest.fail() + + +def test_update_as_root(room_svc: RoomService): + permission_svc = create_autospec(PermissionService) + room_svc._permission_svc = permission_svc + + room = room_svc.update(user_data.root, room_data.edited_xl) + + permission_svc.enforce.assert_called_with( + user_data.root, "room.update", f"room/{room.id}" + ) + assert isinstance(room, RoomDetails) + assert room.id == room_data.edited_xl.id + + +def test_update_as_root_not_found(room_svc: RoomService): + permission_svc = create_autospec(PermissionService) + room_svc._permission_svc = permission_svc + + with pytest.raises(ResourceNotFoundException): + room = room_svc.update(user_data.root, room_data.new_room) + pytest.fail() + + +def test_update_as_user(room_svc: RoomService): + with pytest.raises(UserPermissionException): + room = room_svc.create(user_data.user, room_data.edited_xl) + pytest.fail() + + +def test_delete_as_root(room_svc: RoomService): + permission_svc = create_autospec(PermissionService) + room_svc._permission_svc = permission_svc + + room_svc.delete(user_data.root, room_data.group_b.id) + + permission_svc.enforce.assert_called_with( + user_data.root, "room.delete", f"room/{room_data.group_b.id}" + ) + + rooms = room_svc.all() + assert len(rooms) == len(room_data.rooms) - 1 + + +def test_delete_as_root_not_found(room_svc: RoomService): + permission_svc = create_autospec(PermissionService) + room_svc._permission_svc = permission_svc + + with pytest.raises(ResourceNotFoundException): + room = room_svc.delete(user_data.root, room_data.new_room.id) + pytest.fail() + + +def test_delete_as_user(room_svc: RoomService): + with pytest.raises(UserPermissionException): + room = room_svc.delete(user_data.user, room_data.the_xl.id) + pytest.fail() diff --git a/docs/images/specs/academics/academics-api.png b/docs/images/specs/academics/academics-api.png new file mode 100644 index 000000000..5b2f33208 Binary files /dev/null and b/docs/images/specs/academics/academics-api.png differ diff --git a/docs/images/specs/academics/academics-home.png b/docs/images/specs/academics/academics-home.png new file mode 100644 index 000000000..ec7827227 Binary files /dev/null and b/docs/images/specs/academics/academics-home.png differ diff --git a/docs/images/specs/academics/admin-gear.png b/docs/images/specs/academics/admin-gear.png new file mode 100644 index 000000000..18ba42063 Binary files /dev/null and b/docs/images/specs/academics/admin-gear.png differ diff --git a/docs/images/specs/academics/backend-entity.png b/docs/images/specs/academics/backend-entity.png new file mode 100644 index 000000000..3b3b88512 Binary files /dev/null and b/docs/images/specs/academics/backend-entity.png differ diff --git a/docs/images/specs/academics/course-catalog.png b/docs/images/specs/academics/course-catalog.png new file mode 100644 index 000000000..f66c16bbc Binary files /dev/null and b/docs/images/specs/academics/course-catalog.png differ diff --git a/docs/images/specs/academics/editor.png b/docs/images/specs/academics/editor.png new file mode 100644 index 000000000..f7d97e85e Binary files /dev/null and b/docs/images/specs/academics/editor.png differ diff --git a/docs/images/specs/academics/room-api.png b/docs/images/specs/academics/room-api.png new file mode 100644 index 000000000..5cf340cf6 Binary files /dev/null and b/docs/images/specs/academics/room-api.png differ diff --git a/docs/images/specs/academics/section-offerings.png b/docs/images/specs/academics/section-offerings.png new file mode 100644 index 000000000..6326df44c Binary files /dev/null and b/docs/images/specs/academics/section-offerings.png differ diff --git a/docs/images/specs/academics/term-admin.png b/docs/images/specs/academics/term-admin.png new file mode 100644 index 000000000..7c06be217 Binary files /dev/null and b/docs/images/specs/academics/term-admin.png differ diff --git a/docs/specs/academics.md b/docs/specs/academics.md new file mode 100644 index 000000000..9c58aabdf --- /dev/null +++ b/docs/specs/academics.md @@ -0,0 +1,209 @@ +# Academics Feature Technical Specification + +> Written by [Ajay Gandecha](https://github.com/ajaygandecha) for the CSXL Web Application.
_Last Updated: 12/24/2023_ + +This document contains the technical specifications for the Academics feature of the CSXL web application. This feature adds _5_ new database tables, _25_ new API routes, and _12_ new frontend components to the application. + +The Academics Feature adds UNC course data to the CSXL web application. The web application can now store data on UNC courses, course offerings / sections for each courses, and terms. Section data also stores instructors and TAs for a course, as well as lecture and office hour rooms. + +All visitors to the CSXL page are able to view a _COMP Course Catalog_ to see all of the courses that the UNC Computer Sciende department offers, as well as a _Section Offerings_ page where students can view course sections being offered for various terms. Course data is modifiable to the CSXL web page administrator. + +## Table of Contents + +* [Frontend Features](#FrontendFeatures) + * [User Features](#UserFeatures) + * [Academics Home](#AcademicsHome) + * [Course Catalog](#CourseCatalog) + * [Section Offerings](#SectionOfferings) + * [Admin Features](#AdminFeatures) + * [Gear Icon to Access Admin Features](#GearIcontoAccessAdminFeatures) + * [Academics Admin Tabbed Page](#AcademicsAdminTabbedPage) + * [Academics Admin Editor Pages](#AcademicsAdminEditorPages) + * [Conclusion](#Conclusion) +* [Backend Design and Implementation](#BackendDesignandImplementation) + * [Entity Design](#EntityDesign) + * [Pydantic Model Implementation](#PydanticModelImplementation) + * [API Implementation](#APIImplementation) + * [Permission Summary](#PermissionSummary) + * [Testing](#Testing) + +## Frontend Features + +The frontend features add _12_ new Angular components, all at the `/academics` route. + +### User Features + +The following pages have been added and are available for all users of the CSXL site. These pages are ultimately powered by new Angular service functions connected to new backend APIs. + +#### Academics Home + +![Academics home page](../images/specs/academics/academics-home.png) + +The home page for the new Academics feature is available on the side navigation toolbar at `/academics`. The home page contains links to both the _course catalog_ and the _section offerings_ page. + +In the future, this page will be heavily extended to add personalized academics features for users of the CSXL web app. For now, this page will remain static and exist merely for informational and navigational purposes. + +#### Course Catalog + +![Course catalog](../images/specs/academics/course-catalog.png) + +The course catalog page serves as the main hub for students to learn more about COMP courses at UNC. The page exists at the `/academics/catalog` route. The course page shows the courses available in the backend. Right now, the course page shows this data in a simple table. Users can click on courses to see a dropdown to learn more about a course's _credit hours_ and _description_. + +In the future, when more courses outside of just COMP courses are added here, this page will include a dropdown in the top right that allows users to switch the course subject they look for courses on. + +#### Section Offerings + +![Section offerings](../images/specs/academics/section-offerings.png) + +The section offerings page serves as the main hub for students to view offerings of COMP courses by semester / term. The page exists at the `/academics/offerings` route. The section page shows this data in a table. Users can click on courses to see a dropdown to learn more about a course. There is also a dropdown in the top right that allows users to view course offerings based on all of the semesters / terms saved in the database. + +In the future, when more courses outside of just COMP courses are added here, this page will include another dropdown in the top right that allows users to switch the course subject they look for courses on. + +### Admin Features + +In order to support admin features for term, course, and section data, many components were added. In addition, the existing `NavigationComponent` was modified to enable better navigation to admin pages. + +#### Gear Icon to Access Admin Features + +![Admin Gear](../images/specs/academics/admin-gear.png) + +The Academics feature adds a gear icon to the top right of the Navigation toolbar exposing the admin page to users with the correct permissions. This gear icon links to the admin page. + +To implement this, a new frontend service called the `NagivationAdminGearService` manages when to show the gear. Upon redirect, the navigation component clears gear data, and on initialization, components use the `NagivationAdminGearService.showAdminGear(permissionAction: string, permissionResource: string, tooltip: string, targetUrl: string)` to conditionally show the gear on the navigation bar if the permissions are met. + +This feature can easily be added throughout the CSXL application. For now, the functionality is only used in the academics admin features. + +#### Academics Admin Tabbed Page + +![Academics Admin Tabs](../images/specs/academics/term-admin.png) + +Once the admin clicks on the gear icon shown previously, they are redirected to the Acadmics Admin page. This page contains four subcomponents accessible by tags - admin pages to modify _terms_, _courses_, _sections_, and _rooms_ in the backend database. + +All of the pages look similar - they display a table with current data and enable creating, editing, and deleting items. All four pages implement their own versions of `RxObject` to ensure that the view updates automatically when data is removed from the table. + +#### Academics Admin Editor Pages + +![Academics Editor](../images/specs/academics/editor.png) + +Upon creation or modification of a new item, the admin user is redirected to an editor for the respective data. If editing an item, the editor page is automatically preopopulated to include previous data. + +### Conclusion + +In total, the following components have been added: + +| Name | Route | Description | +| ------------------------ | ----------------------------- | -------------------------------------------------------- | +| **Academics Home** | `/academics` | Main home page for the academics feature. | +| **Course Catalog** | `/academics/catalog` | Displays all COMP courses and their details. | +| **Section Offerings** | `/academics/offerings` | Displays offerings for COMP courses by term. | +| **Academics Admin Home** | `/academics/admin` | Exposes the academics admin features. | +| **Term Admin** | `/academics/admin/term` | Shows all term data and exposes CRUD functionality. | +| **Course Admin** | `/academics/admin/course` | Shows all course data and exposes CRUD functionality. | +| **Section Admin** | `/academics/admin/section` | Shows all section data and exposes CRUD functionality. | +| **Room Admin** | `/academics/admin/room` | Shows all room data and exposes CRUD functionality. | +| **Term Editor** | `/academics/term/edit/:id` | Form to show when terms are to be created and edited. | +| **Course Editor** | `/academics/course/edit/:id` | Form to show when courses are to be created and edited. | +| **Section Editor** | `/academics/section/edit/:id` | Form to show when sections are to be created and edited. | +| **Room Editor** | `/academics/room/edit/:id` | Form to show when room are to be created and edited. | + +## Backend Design and Implementation + +The academics feature ultimately adds _5_ new database tables and _25_ new API routes. + +### Entity Design + +The Academics Feature adds five new database tables and entities. They are as follows: + +| Table Name | Entity | Description | +| ------------------------- | ------------------- | ---------------------------------------------------------- | +| `academics__term` | `TermEntity` | Stores terms / semesters. | +| `academics__courses` | `CourseEntity` | Stores courses. | +| `academics__sections` | `SectionEntity` | Stores section offerings for a given course. | +| `academics__section_user` | `SectionUserEntity` | Stores instructors, TAs, and students of a course section. | +| `academics__section_room` | `SectionRoomEntity` | Stores lecture and office hours rooms of a course section. | + +The fields and relationships between these entities are shown below: + +![Entity Design](../images/specs/academics/backend-entity.png) + +As you can see, the two association tables defined by `SectionUserEntity` and `SectionRoomEntity` relate to (and therefore add relationship fields to) the existing `user` and `room` tables. + +### Pydantic Model Implementation + +The Pydantic models for terms and courses are nearly one-to-one with their entity counterparts. However, sections utilize a more custom model structure, as shown below: + + + + + + +
`Section` and `SectionDetail` Models
+ +```py +# Both models are slightly simplified for better +# comprehensibility here. +class Section(BaseModel): + id: int | None + course_id: str + number: str + term_id: str + meeting_pattern: str + staff: list[SectionMember] + lecture_room: Room | None + office_hour_rooms: list[Room] + +class SectionDetails(Section): +course: Course +term: Term + +``` + +
+ +As you can see, the room relation is split up into `lecture_room` and `office_hour_rooms` respectively. This helps to simplify frontend logic and prevent numerous filtering calls having to be made. The data is automatically updated in the API. + +The user relation is also stripped down to just `staff`, which contains only *instructors* and *TAs* and excludes students. This is done for security purposes. The public GET API should not expose entire student rosters. + +### API Implementation + +The Academics feature adds 25 new API routes to handle CRUD operations on terms, courses, sections, and room data. + +Here is a summary of the APIs added: + +#### Room APIs: + +![Room APIs](../images/specs/academics/room-api.png) + +#### Academics APIs: + +![Academics APIs](../images/specs/academics/academics-api.png) + +### Permission Summary + +All of these API routes call on **backend service functions** to perform these operations. These backend services are protected by permissions. Here is a summary of the permissions that this feature added: + +| Action | Resource | Description | +| ---- | ---- | -------- | +| `"academics.term.create"` | `"term"` | Gives the user permission to create terms in the database. | +| `"academics.term.update"` | `"term/{id}"` | Gives the user permission to update a term in the database. | +| `"academics.term.delete"` | `"term/{id}"` | Gives the user permission to delete a term in the database. | +| `"academics.course.create"` | `"course"` | Gives the user permission to create courses in the database. | +| `"academics.course.update"` | `"course/{id}"` | Gives the user permission to update a course in the database. | +| `"academics.course.delete"` | `"course/{id}"` | Gives the user permission to delete a course in the database. | +| `"academics.section.create"` | `"section"` | Gives the user permission to create sections in the database. | +| `"academics.section.update"` | `"section/{id}"` | Gives the user permission to update a section in the database. | +| `"academics.section.delete"` | `"section/{id}"` | Gives the user permission to delete a section in the database. | +| `"room.create"` | `"room"` | Gives the user permission to create rooms in the database. | +| `"room.update"` | `"room/{id}"` | Gives the user permission to update a room in the database. | +| `"room.delete"` | `"room/{id}"` | Gives the user permission to delete a room in the database. | + +### Testing + +The Academics feature adds full, thorough testing to every new service function added in the course, section, term, and room services. All tests pass, and all services created or modified have 100% test coverage. + +## Future Considerations + +* If we begin to add more course types to the page, I would love to switch the input select for course subject codes to use the material chip components. +* We can now implement the gear icon for other admin features and refactor the folder structure - notably, for organizations. +* We may want a separate `Academics` page specifically for unauthenticated users. +* We can consider creating detail pages for courses and terms. At the moment though, it does not seem necessary. diff --git a/frontend/src/app/academics/academics-admin/academics-admin.component.css b/frontend/src/app/academics/academics-admin/academics-admin.component.css new file mode 100644 index 000000000..e69de29bb diff --git a/frontend/src/app/academics/academics-admin/academics-admin.component.html b/frontend/src/app/academics/academics-admin/academics-admin.component.html new file mode 100644 index 000000000..20fe6ba7e --- /dev/null +++ b/frontend/src/app/academics/academics-admin/academics-admin.component.html @@ -0,0 +1,15 @@ + + + + diff --git a/frontend/src/app/academics/academics-admin/academics-admin.component.ts b/frontend/src/app/academics/academics-admin/academics-admin.component.ts new file mode 100644 index 000000000..cf28a4815 --- /dev/null +++ b/frontend/src/app/academics/academics-admin/academics-admin.component.ts @@ -0,0 +1,33 @@ +/** + * The Academics Admin page enables the administrator to add, edit, + * and delete terms, courses, sections, and rooms. + * + * @author Ajay Gandecha + * @copyright 2023 + * @license MIT + */ + +import { Component } from '@angular/core'; +import { Observable } from 'rxjs'; +import { Profile } from 'src/app/models.module'; +import { ProfileService } from 'src/app/profile/profile.service'; + +@Component({ + selector: 'app-academics-admin', + templateUrl: './academics-admin.component.html', + styleUrls: ['./academics-admin.component.css'] +}) +export class AcademicsAdminComponent { + public profile$: Observable; + + public links = [ + { label: 'Sections', path: '/academics/admin/section' }, + { label: 'Courses', path: '/academics/admin/course' }, + { label: 'Rooms', path: '/academics/admin/room' }, + { label: 'Terms', path: '/academics/admin/term' } + ]; + + constructor(public profileService: ProfileService) { + this.profile$ = profileService.profile$; + } +} diff --git a/frontend/src/app/academics/academics-admin/course/admin-course.component.css b/frontend/src/app/academics/academics-admin/course/admin-course.component.css new file mode 100644 index 000000000..0ac5784ce --- /dev/null +++ b/frontend/src/app/academics/academics-admin/course/admin-course.component.css @@ -0,0 +1,31 @@ +.mat-mdc-row .mat-mdc-cell { + border-bottom: 1px solid transparent; + border-top: 1px solid transparent; + cursor: pointer; +} + +.mat-mdc-row:hover .mat-mdc-cell { + border-color: white; +} + +.header { + display: flex; + align-items: center; + justify-content: space-between; +} + +.row { + display: flex; + flex-direction: row; + width: 100%; + justify-content: space-between; + align-items: center; +} + +.modify-buttons { + margin-left: auto; +} + +#edit-button { + margin-right: 8px; +} diff --git a/frontend/src/app/academics/academics-admin/course/admin-course.component.html b/frontend/src/app/academics/academics-admin/course/admin-course.component.html new file mode 100644 index 000000000..27e832f3c --- /dev/null +++ b/frontend/src/app/academics/academics-admin/course/admin-course.component.html @@ -0,0 +1,28 @@ + +
+ + + + + + + + +
+
+ Courses + +
+
+
+

+ {{ element.subject_code }}{{ element.number }}: {{ element.title }} +

+
+ +
+
+
+
diff --git a/frontend/src/app/academics/academics-admin/course/admin-course.component.ts b/frontend/src/app/academics/academics-admin/course/admin-course.component.ts new file mode 100644 index 000000000..53536fa64 --- /dev/null +++ b/frontend/src/app/academics/academics-admin/course/admin-course.component.ts @@ -0,0 +1,91 @@ +/** + * The Courses Admin page enables the administrator to add, edit, + * and delete courses. + * + * @author Ajay Gandecha + * @copyright 2023 + * @license MIT + */ + +import { Component, inject } from '@angular/core'; +import { Observable } from 'rxjs'; +import { permissionGuard } from 'src/app/permission.guard'; +import { Course } from '../../academics.models'; +import { Route, Router } from '@angular/router'; +import { MatSnackBar } from '@angular/material/snack-bar'; +import { AcademicsService } from '../../academics.service'; +import { RxCourseList } from '../rx-academics-admin'; + +@Component({ + selector: 'app-admin-course', + templateUrl: './admin-course.component.html', + styleUrls: ['./admin-course.component.css'] +}) +export class AdminCourseComponent { + public static Route = { + path: 'course', + component: AdminCourseComponent, + title: 'Course Administration', + canActivate: [permissionGuard('academics.course', '*')] + }; + + /** Courses List */ + public courses: RxCourseList = new RxCourseList(); + public courses$: Observable = this.courses.value$; + + public displayedColumns: string[] = ['name']; + + constructor( + private router: Router, + private snackBar: MatSnackBar, + private academicsService: AcademicsService + ) { + academicsService + .getCourses() + .subscribe((courses) => this.courses.set(courses)); + } + + /** Event handler to open the Course Editor to create a new course */ + createCourse(): void { + // Navigate to the course editor + this.router.navigate(['academics', 'course', 'edit', 'new']); + } + + /** Event handler to open the Course Editor to update a course + * @param course: course to update + */ + updateCourse(course: Course): void { + // Navigate to the course editor + this.router.navigate(['academics', 'course', 'edit', course.id]); + } + + /** Delete a course object from the backend database table using the backend HTTP delete request. + * @param course: course to delete + * @returns void + */ + deleteCourse(course: Course): void { + let confirmDelete = this.snackBar.open( + 'Are you sure you want to delete this course?', + 'Delete' + ); + confirmDelete.onAction().subscribe(() => { + this.academicsService.deleteCourse(course).subscribe({ + next: () => { + this.courses.removeCourse(course); + this.snackBar.open('This course has been deleted.', '', { + duration: 2000 + }); + }, + error: () => { + this.snackBar.open( + 'Delete failed. Make sure to remove all sections for this course first.', + '', + { + duration: 2000 + } + ); + } + }); + }); + } +} diff --git a/frontend/src/app/academics/academics-admin/course/course-editor/course-editor.component.css b/frontend/src/app/academics/academics-admin/course/course-editor/course-editor.component.css new file mode 100644 index 000000000..6a9dd99ba --- /dev/null +++ b/frontend/src/app/academics/academics-admin/course/course-editor/course-editor.component.css @@ -0,0 +1,17 @@ +.mat-mdc-card { + margin: 1em; + max-width: 640px; + } + +.mat-mdc-form-field { + width: stretch; +} + +.mat-mdc-card-actions .mdc-button { + margin-left: 8px; + margin-bottom: 8px; +} + +mat-card-content { + margin-top: 8px; +} \ No newline at end of file diff --git a/frontend/src/app/academics/academics-admin/course/course-editor/course-editor.component.html b/frontend/src/app/academics/academics-admin/course/course-editor/course-editor.component.html new file mode 100644 index 000000000..51307cd3f --- /dev/null +++ b/frontend/src/app/academics/academics-admin/course/course-editor/course-editor.component.html @@ -0,0 +1,75 @@ + +
+ + + + + Create Course + + + Update Course + + + + + + Subject Code + + + + + Number + + + + + Title + + + + + Course Description + + + + + Credit Hours + + + + + + + + +
diff --git a/frontend/src/app/academics/academics-admin/course/course-editor/course-editor.component.ts b/frontend/src/app/academics/academics-admin/course/course-editor/course-editor.component.ts new file mode 100644 index 000000000..2be849af7 --- /dev/null +++ b/frontend/src/app/academics/academics-admin/course/course-editor/course-editor.component.ts @@ -0,0 +1,167 @@ +/** + * The Course editor page enables the administrator to add and edit + * courses. + * + * @author Ajay Gandecha + * @copyright 2023 + * @license MIT + */ + +import { Component, inject } from '@angular/core'; +import { + ActivatedRoute, + ActivatedRouteSnapshot, + CanActivateFn, + Route, + Router, + RouterStateSnapshot +} from '@angular/router'; +import { FormBuilder, FormControl, Validators } from '@angular/forms'; +import { PermissionService } from 'src/app/permission.service'; +import { profileResolver } from 'src/app/profile/profile.resolver'; +import { courseResolver } from 'src/app/academics/academics.resolver'; +import { Course } from 'src/app/academics/academics.models'; +import { MatSnackBar } from '@angular/material/snack-bar'; +import { AcademicsService } from 'src/app/academics/academics.service'; +import { Profile } from 'src/app/models.module'; + +const canActivateEditor: CanActivateFn = ( + route: ActivatedRouteSnapshot, + state: RouterStateSnapshot +) => { + /** Determine if page is viewable by user based on permissions */ + + let id: string = route.params['id']; + + if (id === 'new') { + return inject(PermissionService).check('academics.course.create', 'course'); + } else { + return inject(PermissionService).check( + 'academics.course.update', + `course/${id}` + ); + } +}; + +@Component({ + selector: 'app-course-editor', + templateUrl: './course-editor.component.html', + styleUrls: ['./course-editor.component.css'] +}) +export class CourseEditorComponent { + /** Route information to be used in the Routing Module */ + public static Route: Route = { + path: 'course/edit/:id', + component: CourseEditorComponent, + title: 'Course Editor', + canActivate: [canActivateEditor], + resolve: { + profile: profileResolver, + course: courseResolver + } + }; + + /** Store the currently-logged-in user's profile. */ + public profile: Profile | null = null; + + /** Store the course. */ + public course: Course; + + /** Store the course id. */ + courseId: string = 'new'; + + /** Add validators to the form */ + subject_code = new FormControl('', [Validators.required]); + number = new FormControl('', [Validators.required]); + title = new FormControl('', [Validators.required]); + description = new FormControl('', [Validators.required]); + credit_hours = new FormControl(3, [Validators.required]); + + /** Course Editor Form */ + public courseForm = this.formBuilder.group({ + subject_code: this.subject_code, + number: this.number, + title: this.title, + description: this.description, + credit_hours: this.credit_hours + }); + + /** Constructs the course editor component */ + constructor( + private route: ActivatedRoute, + private router: Router, + protected formBuilder: FormBuilder, + protected snackBar: MatSnackBar, + private academicsService: AcademicsService + ) { + /** Initialize data from resolvers. */ + const data = this.route.snapshot.data as { + profile: Profile; + course: Course; + }; + this.profile = data.profile; + this.course = data.course; + + /** Set course form data */ + this.courseForm.setValue({ + subject_code: this.course.subject_code, + number: this.course.number, + title: this.course.title, + description: this.course.description, + credit_hours: this.course.credit_hours + }); + + /** Get id from the url */ + this.courseId = this.route.snapshot.params['id']; + } + + /** Event handler to handle submitting the Update Course Form. + * @returns {void} + */ + onSubmit(): void { + if (this.courseForm.valid) { + Object.assign(this.course, this.courseForm.value); + + if (this.courseId == 'new') { + this.course.id = + this.course.subject_code.toLowerCase() + this.course.number; + + this.academicsService.createCourse(this.course).subscribe({ + next: (course) => this.onSuccess(course), + error: (err) => this.onError(err) + }); + } else { + this.academicsService.updateCourse(this.course).subscribe({ + next: (course) => this.onSuccess(course), + error: (err) => this.onError(err) + }); + } + } + } + + /** Opens a confirmation snackbar when a course is successfully updated. + * @returns {void} + */ + private onSuccess(course: Course): void { + this.router.navigate(['/academics/admin/course']); + + let message: string = + this.courseId === 'new' ? 'Course Created' : 'Course Updated'; + + this.snackBar.open(message, '', { duration: 2000 }); + } + + /** Opens a snackbar when there is an error updating a course. + * @returns {void} + */ + private onError(err: any): void { + let message: string = + this.courseId === 'new' + ? 'Error: Course Not Created' + : 'Error: Course Not Updated'; + + this.snackBar.open(message, '', { + duration: 2000 + }); + } +} diff --git a/frontend/src/app/academics/academics-admin/room/admin-room.component.css b/frontend/src/app/academics/academics-admin/room/admin-room.component.css new file mode 100644 index 000000000..0ac5784ce --- /dev/null +++ b/frontend/src/app/academics/academics-admin/room/admin-room.component.css @@ -0,0 +1,31 @@ +.mat-mdc-row .mat-mdc-cell { + border-bottom: 1px solid transparent; + border-top: 1px solid transparent; + cursor: pointer; +} + +.mat-mdc-row:hover .mat-mdc-cell { + border-color: white; +} + +.header { + display: flex; + align-items: center; + justify-content: space-between; +} + +.row { + display: flex; + flex-direction: row; + width: 100%; + justify-content: space-between; + align-items: center; +} + +.modify-buttons { + margin-left: auto; +} + +#edit-button { + margin-right: 8px; +} diff --git a/frontend/src/app/academics/academics-admin/room/admin-room.component.html b/frontend/src/app/academics/academics-admin/room/admin-room.component.html new file mode 100644 index 000000000..2105b8d2e --- /dev/null +++ b/frontend/src/app/academics/academics-admin/room/admin-room.component.html @@ -0,0 +1,28 @@ + +
+ + + + + + + + +
+
+ Rooms + +
+
+
+

+ {{ element.nickname }} +

+
+ +
+
+
+
diff --git a/frontend/src/app/academics/academics-admin/room/admin-room.component.ts b/frontend/src/app/academics/academics-admin/room/admin-room.component.ts new file mode 100644 index 000000000..2704a8406 --- /dev/null +++ b/frontend/src/app/academics/academics-admin/room/admin-room.component.ts @@ -0,0 +1,91 @@ +/** + * The Rooms Admin page enables the administrator to add, edit, + * and delete rooms. + * + * @author Ajay Gandecha + * @copyright 2023 + * @license MIT + */ + +import { Component } from '@angular/core'; +import { MatSnackBar } from '@angular/material/snack-bar'; +import { Router } from '@angular/router'; +import { permissionGuard } from 'src/app/permission.guard'; +import { AcademicsService } from '../../academics.service'; +import { RxRoomList } from '../rx-academics-admin'; +import { Observable } from 'rxjs'; +import { Room } from '../../academics.models'; + +@Component({ + selector: 'app-admin-room', + templateUrl: './admin-room.component.html', + styleUrls: ['./admin-room.component.css'] +}) +export class AdminRoomComponent { + public static Route = { + path: 'room', + component: AdminRoomComponent, + title: 'Room Administration', + canActivate: [permissionGuard('academics.term', '*')] + }; + + /** Rooms List */ + public rooms: RxRoomList = new RxRoomList(); + public rooms$: Observable = this.rooms.value$; + + public displayedColumns: string[] = ['name']; + + constructor( + private router: Router, + private snackBar: MatSnackBar, + private academicsService: AcademicsService + ) { + academicsService.getRooms().subscribe((rooms) => { + this.rooms.set(rooms); + }); + } + + /** Event handler to open the Term Editor to create a new term */ + createRoom(): void { + // Navigate to the term editor + this.router.navigate(['academics', 'room', 'edit', 'new']); + } + + /** Event handler to open the Room Editor to update a course + * @param room: room to update + */ + updateRoom(room: Room): void { + // Navigate to the course editor + this.router.navigate(['academics', 'room', 'edit', room.id]); + } + + /** Delete a room object from the backend database table using the backend HTTP delete request. + * @param room: room to delete + * @returns void + */ + deleteRoom(room: Room): void { + let confirmDelete = this.snackBar.open( + 'Are you sure you want to delete this room?', + 'Delete' + ); + confirmDelete.onAction().subscribe(() => { + this.academicsService.deleteRoom(room).subscribe({ + next: () => { + this.rooms.removeRoom(room); + this.snackBar.open('This room has been deleted.', '', { + duration: 2000 + }); + }, + error: () => { + this.snackBar.open( + 'Delete failed because this room is being used elsewhere.', + '', + { + duration: 2000 + } + ); + } + }); + }); + } +} diff --git a/frontend/src/app/academics/academics-admin/room/room-editor/room-editor.component.css b/frontend/src/app/academics/academics-admin/room/room-editor/room-editor.component.css new file mode 100644 index 000000000..bae19deff --- /dev/null +++ b/frontend/src/app/academics/academics-admin/room/room-editor/room-editor.component.css @@ -0,0 +1,21 @@ +.mat-mdc-card { + margin: 1em; + max-width: 640px; + } + +.mat-mdc-form-field { + width: stretch; +} + +.mat-mdc-card-actions .mdc-button { + margin-left: 8px; + margin-bottom: 8px; +} + +mat-card-content { + margin-top: 8px; +} + +.checkbox { + margin-top: 0px; +} \ No newline at end of file diff --git a/frontend/src/app/academics/academics-admin/room/room-editor/room-editor.component.html b/frontend/src/app/academics/academics-admin/room/room-editor/room-editor.component.html new file mode 100644 index 000000000..ff1181e8e --- /dev/null +++ b/frontend/src/app/academics/academics-admin/room/room-editor/room-editor.component.html @@ -0,0 +1,82 @@ + +
+ + + + + Create Room + + + Update Room + + + + + + Room ID + + + + + Room Nickname + + + + + Room Building + + + + + Room Number + + + + + Room Capacity + + + +

+ Reservable? +

+
+ + + + +
+
diff --git a/frontend/src/app/academics/academics-admin/room/room-editor/room-editor.component.ts b/frontend/src/app/academics/academics-admin/room/room-editor/room-editor.component.ts new file mode 100644 index 000000000..194e2951d --- /dev/null +++ b/frontend/src/app/academics/academics-admin/room/room-editor/room-editor.component.ts @@ -0,0 +1,168 @@ +/** + * The Room editor page enables the administrator to add and edit + * rooms. + * + * @author Ajay Gandecha + * @copyright 2023 + * @license MIT + */ + +import { Component, inject } from '@angular/core'; +import { + ActivatedRoute, + ActivatedRouteSnapshot, + CanActivateFn, + Route, + Router, + RouterStateSnapshot +} from '@angular/router'; +import { FormBuilder, FormControl, Validators } from '@angular/forms'; +import { PermissionService } from 'src/app/permission.service'; +import { profileResolver } from 'src/app/profile/profile.resolver'; +import { + roomResolver, + termResolver +} from 'src/app/academics/academics.resolver'; +import { Room, Term } from 'src/app/academics/academics.models'; +import { MatSnackBar } from '@angular/material/snack-bar'; +import { AcademicsService } from 'src/app/academics/academics.service'; +import { Profile } from 'src/app/models.module'; +import { DatePipe } from '@angular/common'; + +const canActivateEditor: CanActivateFn = ( + route: ActivatedRouteSnapshot, + state: RouterStateSnapshot +) => { + /** Determine if page is viewable by user based on permissions */ + + let id: string = route.params['id']; + + if (id === 'new') { + return inject(PermissionService).check('room.create', 'room'); + } else { + return inject(PermissionService).check('room.update', `room/${id}`); + } +}; +@Component({ + selector: 'app-room-editor', + templateUrl: './room-editor.component.html', + styleUrls: ['./room-editor.component.css'] +}) +export class RoomEditorComponent { + /** Route information to be used in the Routing Module */ + public static Route: Route = { + path: 'room/edit/:id', + component: RoomEditorComponent, + title: 'Room Editor', + canActivate: [canActivateEditor], + resolve: { + profile: profileResolver, + room: roomResolver + } + }; + + /** Store the currently-logged-in user's profile. */ + public profile: Profile | null = null; + + /** Store the room. */ + public room: Room; + + /** Store the room id. */ + roomId: string = 'new'; + + /** Add validators to the form */ + id = new FormControl('', [Validators.required]); + nickname = new FormControl('', [Validators.required]); + building = new FormControl('', [Validators.required]); + roomName = new FormControl('', [Validators.required]); + capacity = new FormControl(0, [Validators.required]); + reservable = new FormControl(false, [Validators.required]); + + /** Room Editor Form */ + public roomForm = this.formBuilder.group({ + id: this.id, + nickname: this.nickname, + building: this.building, + room: this.roomName, + capacity: this.capacity, + reservable: this.reservable + }); + + /** Constructs the room editor component */ + constructor( + private route: ActivatedRoute, + private router: Router, + protected formBuilder: FormBuilder, + protected snackBar: MatSnackBar, + private academicsService: AcademicsService, + private datePipe: DatePipe + ) { + /** Initialize data from resolvers. */ + const data = this.route.snapshot.data as { + profile: Profile; + room: Room; + }; + this.profile = data.profile; + this.room = data.room; + + /** Get id from the url */ + this.roomId = this.route.snapshot.params['id']; + + /** Set room form data */ + this.roomForm.setValue({ + id: this.room.id, + nickname: this.room.nickname, + building: this.room.building, + room: this.room.room, + capacity: this.room.capacity, + reservable: this.room.reservable + }); + } + + /** Event handler to handle submitting the Update Term Form. + * @returns {void} + */ + onSubmit(): void { + if (this.roomForm.valid) { + Object.assign(this.room, this.roomForm.value); + + if (this.roomId == 'new') { + this.academicsService.createRoom(this.room).subscribe({ + next: (room) => this.onSuccess(room), + error: (err) => this.onError(err) + }); + } else { + this.academicsService.updateRoom(this.room).subscribe({ + next: (room) => this.onSuccess(room), + error: (err) => this.onError(err) + }); + } + } + } + + /** Opens a confirmation snackbar when a course is successfully updated. + * @returns {void} + */ + private onSuccess(room: Room): void { + this.router.navigate(['/academics/admin/room']); + + let message: string = + this.roomId === 'new' ? 'Room Created' : 'Room Updated'; + + this.snackBar.open(message, '', { duration: 2000 }); + } + + /** Opens a snackbar when there is an error updating a room. + * @returns {void} + */ + private onError(err: any): void { + let message: string = + this.roomId === 'new' + ? 'Error: Room Not Created' + : 'Error: Room Not Updated'; + + this.snackBar.open(message, '', { + duration: 2000 + }); + } +} diff --git a/frontend/src/app/academics/academics-admin/rx-academics-admin.ts b/frontend/src/app/academics/academics-admin/rx-academics-admin.ts new file mode 100644 index 000000000..96e7e6821 --- /dev/null +++ b/frontend/src/app/academics/academics-admin/rx-academics-admin.ts @@ -0,0 +1,80 @@ +import { RxObject } from 'src/app/rx-object'; +import { Course, Room, Section, Term } from '../academics.models'; + +export class RxTermList extends RxObject { + pushTerm(term: Term): void { + this.value.push(term); + this.notify(); + } + + updateTerm(term: Term): void { + this.value = this.value.map((o) => { + return o.id !== term.id ? o : term; + }); + this.notify(); + } + + removeTerm(termToRemove: Term): void { + this.value = this.value.filter((term) => termToRemove.id !== term.id); + this.notify(); + } +} + +export class RxCourseList extends RxObject { + pushCourse(course: Course): void { + this.value.push(course); + this.notify(); + } + + updateCourse(course: Course): void { + this.value = this.value.map((o) => { + return o.id !== course.id ? o : course; + }); + this.notify(); + } + + removeCourse(courseToRemove: Course): void { + this.value = this.value.filter((course) => courseToRemove.id !== course.id); + this.notify(); + } +} + +export class RxSectionList extends RxObject { + pushSection(section: Section): void { + this.value.push(section); + this.notify(); + } + + updateSection(section: Section): void { + this.value = this.value.map((o) => { + return o.id !== section.id ? o : section; + }); + this.notify(); + } + + removeSection(sectionToRemove: Section): void { + this.value = this.value.filter( + (section) => sectionToRemove.id !== section.id + ); + this.notify(); + } +} + +export class RxRoomList extends RxObject { + pushRoom(room: Room): void { + this.value.push(room); + this.notify(); + } + + updateRoom(room: Room): void { + this.value = this.value.map((o) => { + return o.id !== room.id ? o : room; + }); + this.notify(); + } + + removeRoom(roomToRemove: Room): void { + this.value = this.value.filter((room) => roomToRemove.id !== room.id); + this.notify(); + } +} diff --git a/frontend/src/app/academics/academics-admin/section/admin-section.component.css b/frontend/src/app/academics/academics-admin/section/admin-section.component.css new file mode 100644 index 000000000..0ac5784ce --- /dev/null +++ b/frontend/src/app/academics/academics-admin/section/admin-section.component.css @@ -0,0 +1,31 @@ +.mat-mdc-row .mat-mdc-cell { + border-bottom: 1px solid transparent; + border-top: 1px solid transparent; + cursor: pointer; +} + +.mat-mdc-row:hover .mat-mdc-cell { + border-color: white; +} + +.header { + display: flex; + align-items: center; + justify-content: space-between; +} + +.row { + display: flex; + flex-direction: row; + width: 100%; + justify-content: space-between; + align-items: center; +} + +.modify-buttons { + margin-left: auto; +} + +#edit-button { + margin-right: 8px; +} diff --git a/frontend/src/app/academics/academics-admin/section/admin-section.component.html b/frontend/src/app/academics/academics-admin/section/admin-section.component.html new file mode 100644 index 000000000..b64297412 --- /dev/null +++ b/frontend/src/app/academics/academics-admin/section/admin-section.component.html @@ -0,0 +1,53 @@ + +
+ + + + + + + + +
+
+ Sections + + Select Term + + {{ + term.name + }} + + + + +
+
+
+

+ {{ courseFromId(element.course_id)?.subject_code }} + {{ courseFromId(element.course_id)?.number }} - + {{ element.number }}: + {{ + element.override_title !== '' + ? element.override_title + : courseFromId(element.course_id)?.title + }} +

+
+ +
+
+
+
+ + + +

Please make a term first!

+
+
+
diff --git a/frontend/src/app/academics/academics-admin/section/admin-section.component.ts b/frontend/src/app/academics/academics-admin/section/admin-section.component.ts new file mode 100644 index 000000000..23eb94b4f --- /dev/null +++ b/frontend/src/app/academics/academics-admin/section/admin-section.component.ts @@ -0,0 +1,131 @@ +/** + * The Sections Admin page enables the administrator to add, edit, + * and delete sections. + * + * @author Ajay Gandecha + * @copyright 2023 + * @license MIT + */ + +import { Component } from '@angular/core'; +import { Observable } from 'rxjs'; +import { permissionGuard } from 'src/app/permission.guard'; +import { Course, Section, Term } from '../../academics.models'; +import { ActivatedRoute, Router } from '@angular/router'; +import { MatSnackBar } from '@angular/material/snack-bar'; +import { AcademicsService } from '../../academics.service'; +import { FormControl } from '@angular/forms'; +import { + coursesResolver, + currentTermResolver, + termsResolver +} from '../../academics.resolver'; +import { RxTermList } from '../rx-academics-admin'; + +@Component({ + selector: 'app-admin-section', + templateUrl: './admin-section.component.html', + styleUrls: ['./admin-section.component.css'] +}) +export class AdminSectionComponent { + public static Route = { + path: 'section', + component: AdminSectionComponent, + title: 'Section Administration', + canActivate: [permissionGuard('academics.section', '*')], + resolve: { + terms: termsResolver, + currentTerm: currentTermResolver, + courses: coursesResolver + } + }; + + /** Store list of sections */ + public sections$: Observable; + + /** Store list of Terms */ + public terms: RxTermList = new RxTermList(); + public terms$: Observable = this.terms.value$; + + /** Store list of Courses */ + public courses: Course[]; + + /** Store the currently selected term from the form */ + public displayTerm: FormControl = new FormControl(); + + public displayedColumns: string[] = ['name']; + + constructor( + private route: ActivatedRoute, + private router: Router, + private snackBar: MatSnackBar, + private academicsService: AcademicsService + ) { + // Initialize data from resolvers + const data = this.route.snapshot.data as { + terms: Term[]; + currentTerm: Term | undefined; + courses: Course[]; + }; + + this.terms.set(data.terms); + this.courses = data.courses; + + if (data.currentTerm) { + this.displayTerm.setValue(data.currentTerm); + this.sections$ = academicsService.getSectionsByTerm( + this.displayTerm.value + ); + } else { + this.sections$ = new Observable(); + } + } + + /** Event handler to open the Section Editor to create a new term */ + createSection(): void { + // Navigate to the section editor + this.router.navigate(['academics', 'section', 'edit', 'new']); + } + + /** Event handler to open the Section Editor to update a section + * @param section: section to update + */ + updateSection(section: Section): void { + // Navigate to the section editor + this.router.navigate(['academics', 'section', 'edit', section.id]); + } + + /** Delete a section object from the backend database table using the backend HTTP delete request. + * @param section: section to delete + * @returns void + */ + deleteSection(section: Section): void { + let confirmDelete = this.snackBar.open( + 'Are you sure you want to delete this section?', + 'Delete' + ); + confirmDelete.onAction().subscribe(() => { + this.academicsService.deleteSection(section).subscribe(() => { + let termToUpdate = this.displayTerm.value; + termToUpdate.course_sections = + termToUpdate.course_sections?.filter((s) => s.id !== section.id) ?? + []; + this.terms.updateTerm(termToUpdate); + this.snackBar.open('This Section has been deleted.', '', { + duration: 2000 + }); + }); + }); + } + + /** Helper function that returns the course object from the list with the given ID. + * @param id ID of the course to look up. + * @returns Course for the ID, if it exists. + */ + courseFromId(id: string): Course | null { + // Find the course for the given ID + let coursesFilter = this.courses.filter((c) => c.id === id); + // Return either the course if it exists, or null. + return coursesFilter.length > 0 ? coursesFilter[0] : null; + } +} diff --git a/frontend/src/app/academics/academics-admin/section/section-editor/section-editor.component.css b/frontend/src/app/academics/academics-admin/section/section-editor/section-editor.component.css new file mode 100644 index 000000000..e6eac15e9 --- /dev/null +++ b/frontend/src/app/academics/academics-admin/section/section-editor/section-editor.component.css @@ -0,0 +1,21 @@ +.mat-mdc-card { + margin: 1em; + max-width: 640px; +} + +.mat-mdc-form-field { + width: stretch; +} + +.mat-mdc-card-actions .mdc-button { + margin-left: 8px; + margin-bottom: 8px; +} + +mat-card-content { + margin-top: 8px; +} + +.checkbox { + margin-top: 0px; +} \ No newline at end of file diff --git a/frontend/src/app/academics/academics-admin/section/section-editor/section-editor.component.html b/frontend/src/app/academics/academics-admin/section/section-editor/section-editor.component.html new file mode 100644 index 000000000..108b736e8 --- /dev/null +++ b/frontend/src/app/academics/academics-admin/section/section-editor/section-editor.component.html @@ -0,0 +1,102 @@ + +
+ + + + + Create Section + + + Update Section + + + + + + Select Term + + {{ + term.name + }} + + + + + Select Course + + + {{ course.subject_code }} {{ course.number }}: + {{ course.title }} + + + + + Section Number + + + + + Meeting Pattern + + + + + Select Room + + + {{ room.nickname }} + + + + +

+ Override Course Name and Description? +

+ + Override Title + + + + + Override Description + + +
+ + + + +
+
diff --git a/frontend/src/app/academics/academics-admin/section/section-editor/section-editor.component.ts b/frontend/src/app/academics/academics-admin/section/section-editor/section-editor.component.ts new file mode 100644 index 000000000..ab4d09422 --- /dev/null +++ b/frontend/src/app/academics/academics-admin/section/section-editor/section-editor.component.ts @@ -0,0 +1,252 @@ +/** + * The Section editor page enables the administrator to add and edit + * sections. + * + * @author Ajay Gandecha + * @copyright 2023 + * @license MIT + */ + +import { Component, inject } from '@angular/core'; +import { + ActivatedRoute, + ActivatedRouteSnapshot, + CanActivateFn, + Route, + Router, + RouterStateSnapshot +} from '@angular/router'; +import { FormBuilder, FormControl, Validators } from '@angular/forms'; +import { PermissionService } from 'src/app/permission.service'; +import { profileResolver } from 'src/app/profile/profile.resolver'; +import { + coursesResolver, + roomsResolver, + sectionResolver, + termResolver, + termsResolver +} from 'src/app/academics/academics.resolver'; +import { + Course, + Room, + Section, + Term +} from 'src/app/academics/academics.models'; +import { MatSnackBar } from '@angular/material/snack-bar'; +import { AcademicsService } from 'src/app/academics/academics.service'; +import { Profile } from 'src/app/models.module'; +import { DatePipe } from '@angular/common'; +import { ReplaySubject } from 'rxjs'; + +const canActivateEditor: CanActivateFn = ( + route: ActivatedRouteSnapshot, + state: RouterStateSnapshot +) => { + /** Determine if page is viewable by user based on permissions */ + + let id: string = route.params['id']; + + if (id === 'new') { + return inject(PermissionService).check( + 'academics.section.create', + 'section' + ); + } else { + return inject(PermissionService).check( + 'academics.section.update', + `section/${id}` + ); + } +}; +@Component({ + selector: 'app-section-editor', + templateUrl: './section-editor.component.html', + styleUrls: ['./section-editor.component.css'] +}) +export class SectionEditorComponent { + /** Route information to be used in the Routing Module */ + public static Route: Route = { + path: 'section/edit/:id', + component: SectionEditorComponent, + title: 'Section Editor', + canActivate: [canActivateEditor], + resolve: { + profile: profileResolver, + section: sectionResolver, + terms: termsResolver, + courses: coursesResolver, + rooms: roomsResolver + } + }; + + /** Store the currently-logged-in user's profile. */ + public profile: Profile | null = null; + + /** Store the section. */ + public section: Section; + + /** Store a list of terms. */ + public terms: Term[]; + + /** Store a list of courses. */ + public courses: Course[]; + + /** Store a list of rooms. */ + public rooms: Room[]; + + /** Store the section id. */ + sectionIdString: string = 'new'; + + /** Add validators to the form */ + public term: FormControl = new FormControl(null, [ + Validators.required + ]); + public course: FormControl = new FormControl(null, [ + Validators.required + ]); + number = new FormControl('', [Validators.required]); + meeting_pattern = new FormControl('', [Validators.required]); + override_title = new FormControl(''); + override_description = new FormControl(''); + + public room: FormControl = new FormControl(null, [ + Validators.required + ]); + + public override = new FormControl(false, [Validators.required]); + + isOverriding: ReplaySubject = new ReplaySubject(1); + isOverriding$ = this.isOverriding.asObservable(); + + /** Section Editor Form */ + public sectionForm = this.formBuilder.group({ + number: this.number, + meeting_pattern: this.meeting_pattern, + override_title: this.override_title, + override_description: this.override_description + }); + + /** Constructs the term editor component */ + constructor( + private route: ActivatedRoute, + private router: Router, + protected formBuilder: FormBuilder, + protected snackBar: MatSnackBar, + private academicsService: AcademicsService, + private datePipe: DatePipe + ) { + /** Initialize data from resolvers. */ + const data = this.route.snapshot.data as { + profile: Profile; + section: Section; + terms: Term[]; + courses: Course[]; + rooms: Room[]; + }; + + this.profile = data.profile; + this.section = data.section; + this.terms = data.terms; + this.courses = data.courses; + this.rooms = data.rooms; + + /** Get id from the url */ + this.sectionIdString = this.route.snapshot.params['id']; + + /** Set section form data */ + this.sectionForm.setValue({ + number: this.section.number, + meeting_pattern: this.section.meeting_pattern, + override_title: this.section.override_title, + override_description: this.section.override_description + }); + + /** Set the value of the override flag to on if data exists. */ + if ( + this.section.override_title !== '' || + this.section.override_description !== '' + ) { + this.override.setValue(true); + this.isOverriding.next(true); + } + + /** Update the isOverriding replay subject when the checkmark is changed. */ + this.override.valueChanges.subscribe((val) => { + this.isOverriding.next(val ?? false); + if (!val) { + this.override_title.setValue(''); + this.override_description.setValue(''); + } + }); + + /** Select the term, course, and room, if it exists. */ + let termFilter = this.terms.filter((t) => t.id == this.section.term_id); + let courseFilter = this.courses.filter( + (c) => c.id == this.section.course_id + ); + let roomFilter = this.rooms.filter( + (c) => c.id == this.section.lecture_room?.id + ); + this.term.setValue(termFilter.length > 0 ? termFilter[0] : null); + this.course.setValue(courseFilter.length > 0 ? courseFilter[0] : null); + this.room.setValue(roomFilter.length > 0 ? roomFilter[0] : null); + } + + /** Event handler to handle submitting the Update Section Form. + * @returns {void} + */ + onSubmit(): void { + if (this.sectionForm.valid) { + this.section.id = +this.sectionIdString; + this.section.number = this.sectionForm.value.number ?? ''; + this.section.meeting_pattern = + this.sectionForm.value.meeting_pattern ?? ''; + this.section.term_id = this.term.value!.id; + this.section.course_id = this.course.value!.id; + + this.section.lecture_room = this.room.value!; + + this.section.override_title = this.sectionForm.value.override_title ?? ''; + this.section.override_description = + this.sectionForm.value.override_description ?? ''; + + if (this.sectionIdString == 'new') { + this.academicsService.createSection(this.section).subscribe({ + next: (section) => this.onSuccess(section), + error: (err) => this.onError(err) + }); + } else { + this.academicsService.updateSection(this.section).subscribe({ + next: (section) => this.onSuccess(section), + error: (err) => this.onError(err) + }); + } + } + } + + /** Opens a confirmation snackbar when a course is successfully updated. + * @returns {void} + */ + private onSuccess(section: Section): void { + this.router.navigate(['/academics/admin/section']); + + let message: string = + this.sectionIdString === 'new' ? 'Section Created' : 'Section Updated'; + + this.snackBar.open(message, '', { duration: 2000 }); + } + + /** Opens a snackbar when there is an error updating a course. + * @returns {void} + */ + private onError(err: any): void { + let message: string = + this.sectionIdString === 'new' + ? 'Error: Section Not Created' + : 'Error: Section Not Updated'; + + this.snackBar.open(message, '', { + duration: 2000 + }); + } +} diff --git a/frontend/src/app/academics/academics-admin/term/admin-term.component.css b/frontend/src/app/academics/academics-admin/term/admin-term.component.css new file mode 100644 index 000000000..0ac5784ce --- /dev/null +++ b/frontend/src/app/academics/academics-admin/term/admin-term.component.css @@ -0,0 +1,31 @@ +.mat-mdc-row .mat-mdc-cell { + border-bottom: 1px solid transparent; + border-top: 1px solid transparent; + cursor: pointer; +} + +.mat-mdc-row:hover .mat-mdc-cell { + border-color: white; +} + +.header { + display: flex; + align-items: center; + justify-content: space-between; +} + +.row { + display: flex; + flex-direction: row; + width: 100%; + justify-content: space-between; + align-items: center; +} + +.modify-buttons { + margin-left: auto; +} + +#edit-button { + margin-right: 8px; +} diff --git a/frontend/src/app/academics/academics-admin/term/admin-term.component.html b/frontend/src/app/academics/academics-admin/term/admin-term.component.html new file mode 100644 index 000000000..86f13d7d6 --- /dev/null +++ b/frontend/src/app/academics/academics-admin/term/admin-term.component.html @@ -0,0 +1,28 @@ + +
+ + + + + + + + +
+
+ Terms + +
+
+
+

+ {{ element.name }} +

+
+ +
+
+
+
diff --git a/frontend/src/app/academics/academics-admin/term/admin-term.component.ts b/frontend/src/app/academics/academics-admin/term/admin-term.component.ts new file mode 100644 index 000000000..d15217a9a --- /dev/null +++ b/frontend/src/app/academics/academics-admin/term/admin-term.component.ts @@ -0,0 +1,80 @@ +/** + * The Term Admin page enables the administrator to add, edit, + * and delete terms. + * + * @author Ajay Gandecha + * @copyright 2023 + * @license MIT + */ + +import { Component } from '@angular/core'; +import { Observable } from 'rxjs'; +import { permissionGuard } from 'src/app/permission.guard'; +import { Term } from '../../academics.models'; +import { Router } from '@angular/router'; +import { MatSnackBar } from '@angular/material/snack-bar'; +import { AcademicsService } from '../../academics.service'; +import { RxTermList } from '../rx-academics-admin'; + +@Component({ + selector: 'app-admin-term', + templateUrl: './admin-term.component.html', + styleUrls: ['./admin-term.component.css'] +}) +export class AdminTermComponent { + public static Route = { + path: 'term', + component: AdminTermComponent, + title: 'Term Administration', + canActivate: [permissionGuard('academics.term', '*')] + }; + + /** Terms List */ + public terms: RxTermList = new RxTermList(); + public terms$: Observable = this.terms.value$; + + public displayedColumns: string[] = ['name']; + + constructor( + private router: Router, + private snackBar: MatSnackBar, + private academicsService: AcademicsService + ) { + academicsService.getTerms().subscribe((terms) => { + this.terms.set(terms); + }); + } + + /** Event handler to open the Term Editor to create a new term */ + createTerm(): void { + // Navigate to the term editor + this.router.navigate(['academics', 'term', 'edit', 'new']); + } + + /** Event handler to open the Term Editor to update a course + * @param term: term to update + */ + updateTerm(term: Term): void { + // Navigate to the course editor + this.router.navigate(['academics', 'term', 'edit', term.id]); + } + + /** Delete a temr object from the backend database table using the backend HTTP delete request. + * @param term: term to delete + * @returns void + */ + deleteTerm(term: Term): void { + let confirmDelete = this.snackBar.open( + 'Are you sure you want to delete this term?', + 'Delete' + ); + confirmDelete.onAction().subscribe(() => { + this.academicsService.deleteTerm(term).subscribe(() => { + this.terms.removeTerm(term); + this.snackBar.open('This term has been deleted.', '', { + duration: 2000 + }); + }); + }); + } +} diff --git a/frontend/src/app/academics/academics-admin/term/term-editor/term-editor.component.css b/frontend/src/app/academics/academics-admin/term/term-editor/term-editor.component.css new file mode 100644 index 000000000..6a9dd99ba --- /dev/null +++ b/frontend/src/app/academics/academics-admin/term/term-editor/term-editor.component.css @@ -0,0 +1,17 @@ +.mat-mdc-card { + margin: 1em; + max-width: 640px; + } + +.mat-mdc-form-field { + width: stretch; +} + +.mat-mdc-card-actions .mdc-button { + margin-left: 8px; + margin-bottom: 8px; +} + +mat-card-content { + margin-top: 8px; +} \ No newline at end of file diff --git a/frontend/src/app/academics/academics-admin/term/term-editor/term-editor.component.html b/frontend/src/app/academics/academics-admin/term/term-editor/term-editor.component.html new file mode 100644 index 000000000..c4918e0f0 --- /dev/null +++ b/frontend/src/app/academics/academics-admin/term/term-editor/term-editor.component.html @@ -0,0 +1,67 @@ + +
+ + + + + Create Term + + + Update Term + + + + + + Term ID + + + + + Term Name + + + + + + + + + + + + + + + + +
diff --git a/frontend/src/app/academics/academics-admin/term/term-editor/term-editor.component.ts b/frontend/src/app/academics/academics-admin/term/term-editor/term-editor.component.ts new file mode 100644 index 000000000..416614b02 --- /dev/null +++ b/frontend/src/app/academics/academics-admin/term/term-editor/term-editor.component.ts @@ -0,0 +1,164 @@ +/** + * The Term editor page enables the administrator to add and edit + * terms. + * + * @author Ajay Gandecha + * @copyright 2023 + * @license MIT + */ + +import { Component, inject } from '@angular/core'; +import { + ActivatedRoute, + ActivatedRouteSnapshot, + CanActivateFn, + Route, + Router, + RouterStateSnapshot +} from '@angular/router'; +import { FormBuilder, FormControl, Validators } from '@angular/forms'; +import { PermissionService } from 'src/app/permission.service'; +import { profileResolver } from 'src/app/profile/profile.resolver'; +import { termResolver } from 'src/app/academics/academics.resolver'; +import { Term } from 'src/app/academics/academics.models'; +import { MatSnackBar } from '@angular/material/snack-bar'; +import { AcademicsService } from 'src/app/academics/academics.service'; +import { Profile } from 'src/app/models.module'; +import { DatePipe } from '@angular/common'; + +const canActivateEditor: CanActivateFn = ( + route: ActivatedRouteSnapshot, + state: RouterStateSnapshot +) => { + /** Determine if page is viewable by user based on permissions */ + + let id: string = route.params['id']; + + if (id === 'new') { + return inject(PermissionService).check('academics.term.create', 'term'); + } else { + return inject(PermissionService).check( + 'academics.term.update', + `term/${id}` + ); + } +}; +@Component({ + selector: 'app-term-editor', + templateUrl: './term-editor.component.html', + styleUrls: ['./term-editor.component.css'] +}) +export class TermEditorComponent { + /** Route information to be used in the Routing Module */ + public static Route: Route = { + path: 'term/edit/:id', + component: TermEditorComponent, + title: 'Term Editor', + canActivate: [canActivateEditor], + resolve: { + profile: profileResolver, + term: termResolver + } + }; + + /** Store the currently-logged-in user's profile. */ + public profile: Profile | null = null; + + /** Store the term. */ + public term: Term; + + /** Store the term id. */ + termId: string = 'new'; + + /** Add validators to the form */ + id = new FormControl('', [Validators.required]); + name = new FormControl('', [Validators.required]); + start = new FormControl('', [Validators.required]); + end = new FormControl('', [Validators.required]); + + /** Term Editor Form */ + public termForm = this.formBuilder.group({ + id: this.id, + name: this.name, + start: this.start, + end: this.end + }); + + /** Constructs the term editor component */ + constructor( + private route: ActivatedRoute, + private router: Router, + protected formBuilder: FormBuilder, + protected snackBar: MatSnackBar, + private academicsService: AcademicsService, + private datePipe: DatePipe + ) { + /** Initialize data from resolvers. */ + const data = this.route.snapshot.data as { + profile: Profile; + term: Term; + }; + this.profile = data.profile; + this.term = data.term; + + console.log(data.term); + + /** Get id from the url */ + this.termId = this.route.snapshot.params['id']; + + /** Set term form data */ + this.termForm.setValue({ + id: this.termId == 'new' ? '' : this.term.id, + name: this.term.name, + start: this.datePipe.transform(this.term.start, 'yyyy-MM-ddTHH:mm'), + end: this.datePipe.transform(this.term.end, 'yyyy-MM-ddTHH:mm') + }); + } + + /** Event handler to handle submitting the Update Term Form. + * @returns {void} + */ + onSubmit(): void { + if (this.termForm.valid) { + Object.assign(this.term, this.termForm.value); + + if (this.termId == 'new') { + this.academicsService.createTerm(this.term).subscribe({ + next: (term) => this.onSuccess(term), + error: (err) => this.onError(err) + }); + } else { + this.academicsService.updateTerm(this.term).subscribe({ + next: (term) => this.onSuccess(term), + error: (err) => this.onError(err) + }); + } + } + } + + /** Opens a confirmation snackbar when a course is successfully updated. + * @returns {void} + */ + private onSuccess(term: Term): void { + this.router.navigate(['/academics/admin/term']); + + let message: string = + this.termId === 'new' ? 'Term Created' : 'Term Updated'; + + this.snackBar.open(message, '', { duration: 2000 }); + } + + /** Opens a snackbar when there is an error updating a course. + * @returns {void} + */ + private onError(err: any): void { + let message: string = + this.termId === 'new' + ? 'Error: Course Not Created' + : 'Error: Course Not Updated'; + + this.snackBar.open(message, '', { + duration: 2000 + }); + } +} diff --git a/frontend/src/app/academics/academics-home/academics-home.component.css b/frontend/src/app/academics/academics-home/academics-home.component.css new file mode 100644 index 000000000..6c4800192 --- /dev/null +++ b/frontend/src/app/academics/academics-home/academics-home.component.css @@ -0,0 +1,6 @@ + +.link-button { + margin-left: 8px; + margin-top: 14px; + margin-bottom: 14px; +} diff --git a/frontend/src/app/academics/academics-home/academics-home.component.html b/frontend/src/app/academics/academics-home/academics-home.component.html new file mode 100644 index 000000000..736b0aa08 --- /dev/null +++ b/frontend/src/app/academics/academics-home/academics-home.component.html @@ -0,0 +1,29 @@ + + + Computer Science Courses at UNC + + +

+ The UNC Department of Computer Science offers numerous courses for + students to explore various aspects of computer science. +

+

+ View all COMP couses in the Course Catalog, or view course + offerings by terms in Section Offerings page! +

+
+ + + + +
diff --git a/frontend/src/app/academics/academics-home/academics-home.component.ts b/frontend/src/app/academics/academics-home/academics-home.component.ts new file mode 100644 index 000000000..3a4d83115 --- /dev/null +++ b/frontend/src/app/academics/academics-home/academics-home.component.ts @@ -0,0 +1,39 @@ +/** + * The Academics homepage serves as the hub for all academic features + * for students in the CSXL community. + * + * @author Ajay Gandecha + * @copyright 2023 + * @license MIT + */ + +import { Component, OnInit } from '@angular/core'; +import { RouterModule } from '@angular/router'; +import { NagivationAdminGearService } from 'src/app/navigation/navigation-admin-gear.service'; +import { PermissionService } from 'src/app/permission.service'; + +@Component({ + selector: 'app-academics-home', + templateUrl: './academics-home.component.html', + styleUrls: ['./academics-home.component.css'] +}) +export class AcademicsHomeComponent implements OnInit { + /** Route information to be used in Course Routing Module */ + public static Route = { + path: '', + title: 'Academics', + component: AcademicsHomeComponent, + canActivate: [] + }; + + constructor(private gearService: NagivationAdminGearService) {} + + ngOnInit() { + this.gearService.showAdminGear( + 'academics.*', + '*', + '', + 'academics/admin/section' + ); + } +} diff --git a/frontend/src/app/academics/academics-routing.module.ts b/frontend/src/app/academics/academics-routing.module.ts new file mode 100644 index 000000000..00a926cb4 --- /dev/null +++ b/frontend/src/app/academics/academics-routing.module.ts @@ -0,0 +1,40 @@ +import { NgModule } from '@angular/core'; +import { RouterModule, Routes } from '@angular/router'; +import { CoursesHomeComponent } from './course-catalog/course-catalog.component'; +import { SectionOfferingsComponent } from './section-offerings/section-offerings.component'; +import { AcademicsHomeComponent } from './academics-home/academics-home.component'; +import { AcademicsAdminComponent } from './academics-admin/academics-admin.component'; +import { AdminTermComponent } from './academics-admin/term/admin-term.component'; +import { AdminCourseComponent } from './academics-admin/course/admin-course.component'; +import { AdminSectionComponent } from './academics-admin/section/admin-section.component'; +import { CourseEditorComponent } from './academics-admin/course/course-editor/course-editor.component'; +import { TermEditorComponent } from './academics-admin/term/term-editor/term-editor.component'; +import { SectionEditorComponent } from './academics-admin/section/section-editor/section-editor.component'; +import { AdminRoomComponent } from './academics-admin/room/admin-room.component'; +import { RoomEditorComponent } from './academics-admin/room/room-editor/room-editor.component'; + +const routes: Routes = [ + { + path: 'admin', + component: AcademicsAdminComponent, + children: [ + AdminTermComponent.Route, + AdminCourseComponent.Route, + AdminSectionComponent.Route, + AdminRoomComponent.Route + ] + }, + AcademicsHomeComponent.Route, + CoursesHomeComponent.Route, + SectionOfferingsComponent.Route, + CourseEditorComponent.Route, + TermEditorComponent.Route, + SectionEditorComponent.Route, + RoomEditorComponent.Route +]; + +@NgModule({ + imports: [RouterModule.forChild(routes)], + exports: [RouterModule] +}) +export class AcademicsRoutingModule {} diff --git a/frontend/src/app/academics/academics.models.ts b/frontend/src/app/academics/academics.models.ts new file mode 100644 index 000000000..b04537217 --- /dev/null +++ b/frontend/src/app/academics/academics.models.ts @@ -0,0 +1,73 @@ +/** + * These helper modules define the structure of data that is accessible + * at the `/api/academics` endpoints. + * + * @author Ajay Gandecha + * @copyright 2023 + * @license MIT + */ + +import { Seat } from '../coworking/coworking.models'; +import { TimeRange } from '../time-range'; + +/** Defines a Course */ +export interface Course { + id: string; + subject_code: string; + number: string; + title: string; + description: string; + credit_hours: number; + sections: Section[] | null; +} + +/** Defines a Course Section */ +export interface Section { + id: number | null; + course_id: string; + number: string; + term_id: string; + meeting_pattern: string; + course: Course | null; + term: Term | null; + staff: SectionMember[] | null; + lecture_room: Room | null; + office_hour_rooms: Room[] | null; + override_title: string; + override_description: string; +} + +/** Defines a Term */ +export interface Term extends TimeRange { + id: string; + name: string; + course_sections: Section[] | null; +} + +/** Defines a Section Member */ +export interface SectionMember { + id: number | null; + first_name: string; + last_name: string; + pronouns: string; + member_role: RosterRole; +} + +/** Defines a Room */ +export interface Room { + id: string; + nickname: string; + building: string | null; + room: string | null; + capacity: number | null; + reservable: boolean | null; + seats: Seat[] | null; +} + +/** Defines a Roster Role */ +export enum RosterRole { + STUDENT = 0, + UTA = 1, + GTA = 2, + INSTRUCTOR = 3 +} diff --git a/frontend/src/app/academics/academics.module.ts b/frontend/src/app/academics/academics.module.ts new file mode 100644 index 000000000..a01b084fd --- /dev/null +++ b/frontend/src/app/academics/academics.module.ts @@ -0,0 +1,65 @@ +import { AsyncPipe, CommonModule } from '@angular/common'; +import { NgModule } from '@angular/core'; + +import { MatCardModule } from '@angular/material/card'; +import { MatDividerModule } from '@angular/material/divider'; +import { MatListModule } from '@angular/material/list'; +import { MatExpansionModule } from '@angular/material/expansion'; +import { MatButtonModule } from '@angular/material/button'; +import { MatTableModule } from '@angular/material/table'; +import { AcademicsRoutingModule } from './academics-routing.module'; +import { CoursesHomeComponent } from './course-catalog/course-catalog.component'; +import { MatIconModule } from '@angular/material/icon'; +import { SectionOfferingsComponent } from './section-offerings/section-offerings.component'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatSelectModule } from '@angular/material/select'; +import { ReactiveFormsModule } from '@angular/forms'; +import { AcademicsHomeComponent } from './academics-home/academics-home.component'; +import { AcademicsAdminComponent } from './academics-admin/academics-admin.component'; +import { MatTabsModule } from '@angular/material/tabs'; +import { AdminSectionComponent } from './academics-admin/section/admin-section.component'; +import { AdminCourseComponent } from './academics-admin/course/admin-course.component'; +import { AdminTermComponent } from './academics-admin/term/admin-term.component'; +import { CourseEditorComponent } from './academics-admin/course/course-editor/course-editor.component'; +import { MatInputModule } from '@angular/material/input'; +import { TermEditorComponent } from './academics-admin/term/term-editor/term-editor.component'; +import { SectionEditorComponent } from './academics-admin/section/section-editor/section-editor.component'; +import { AdminRoomComponent } from './academics-admin/room/admin-room.component'; +import { RoomEditorComponent } from './academics-admin/room/room-editor/room-editor.component'; +import { MatCheckboxModule } from '@angular/material/checkbox'; + +@NgModule({ + declarations: [ + CoursesHomeComponent, + SectionOfferingsComponent, + AcademicsHomeComponent, + AcademicsAdminComponent, + AdminSectionComponent, + AdminCourseComponent, + AdminTermComponent, + CourseEditorComponent, + TermEditorComponent, + SectionEditorComponent, + AdminRoomComponent, + RoomEditorComponent + ], + imports: [ + AcademicsRoutingModule, + CommonModule, + MatCardModule, + MatDividerModule, + MatListModule, + MatExpansionModule, + MatButtonModule, + MatTableModule, + MatIconModule, + MatFormFieldModule, + MatSelectModule, + ReactiveFormsModule, + MatTabsModule, + MatInputModule, + MatCheckboxModule, + AsyncPipe + ] +}) +export class AcademicsModule {} diff --git a/frontend/src/app/academics/academics.resolver.ts b/frontend/src/app/academics/academics.resolver.ts new file mode 100644 index 000000000..b1d484d50 --- /dev/null +++ b/frontend/src/app/academics/academics.resolver.ts @@ -0,0 +1,160 @@ +/** + * The Academics Resolver allows courses data to be injected into the routes + * of components. + * + * @author Ajay Gandecha + * @copyright 2023 + * @license MIT + */ + +import { inject } from '@angular/core'; +import { ResolveFn } from '@angular/router'; +import { AcademicsService } from './academics.service'; +import { Course, Room, Section, SectionMember, Term } from './academics.models'; +import { catchError, of } from 'rxjs'; + +/** This resolver injects the list of courses into the catalog component. */ +export const coursesResolver: ResolveFn = ( + route, + state +) => { + return inject(AcademicsService).getCourses(); +}; + +/** This resolver injects the list of courses into the catalog component. */ +export const courseResolver: ResolveFn = (route, state) => { + // If the course is new, return a blank one + if (route.paramMap.get('id')! == 'new') { + return { + id: '', + subject_code: '', + number: '', + title: '', + description: '', + credit_hours: -1, + sections: null + }; + } + + // Otherwise, return the course. + // If there is an error, return undefined + return inject(AcademicsService) + .getCourse(route.paramMap.get('id')!) + .pipe( + catchError((error) => { + console.log(error); + return of(undefined); + }) + ); +}; + +/** This resolver injects the list of terms into the offerings component. */ +export const termsResolver: ResolveFn = (route, state) => { + return inject(AcademicsService).getTerms(); +}; + +/** This resolver injects the current term into the admin component. */ +export const currentTermResolver: ResolveFn = ( + route, + state +) => { + return inject(AcademicsService) + .getCurrentTerm() + .pipe( + catchError((error) => { + return of(undefined); + }) + ); +}; + +/** This resolver injects a term into the catalog component. */ +export const termResolver: ResolveFn = (route, state) => { + // If the term is new, return a blank one + if (route.paramMap.get('id')! == 'new') { + return { + id: '', + name: '', + start: new Date(), + end: new Date(), + course_sections: null + }; + } + + // Otherwise, return the term. + // If there is an error, return undefined + return inject(AcademicsService) + .getTerm(route.paramMap.get('id')!) + .pipe( + catchError((error) => { + console.log(error); + return of(undefined); + }) + ); +}; + +/** This resolver injects a section into the catalog component. */ +export const sectionResolver: ResolveFn
= ( + route, + state +) => { + // If the term is new, return a blank one + if (route.paramMap.get('id')! == 'new') { + return { + id: null, + course_id: '', + number: '', + term_id: '', + meeting_pattern: '', + course: null, + term: null, + staff: [], + lecture_room: null, + office_hour_rooms: [], + override_title: '', + override_description: '' + }; + } + + // Otherwise, return the section. + // If there is an error, return undefined + return inject(AcademicsService) + .getSection(+route.paramMap.get('id')!) + .pipe( + catchError((error) => { + console.log(error); + return of(undefined); + }) + ); +}; + +/** This resolver injects the list of rooms into the offerings component. */ +export const roomsResolver: ResolveFn = (route, state) => { + return inject(AcademicsService).getRooms(); +}; + +/** This resolver injects a room into the catalog component. */ +export const roomResolver: ResolveFn = (route, state) => { + // If the term is new, return a blank one + if (route.paramMap.get('id')! == 'new') { + return { + id: '', + nickname: '', + building: '', + room: '', + capacity: 100, + reservable: false, + seats: [] + }; + } + + // Otherwise, return the room. + // If there is an error, return undefined + return inject(AcademicsService) + .getRoom(route.paramMap.get('id')!) + .pipe( + catchError((error) => { + console.log(error); + return of(undefined); + }) + ); +}; diff --git a/frontend/src/app/academics/academics.service.ts b/frontend/src/app/academics/academics.service.ts new file mode 100644 index 000000000..bbb7e9dda --- /dev/null +++ b/frontend/src/app/academics/academics.service.ts @@ -0,0 +1,190 @@ +/** + * The Academics Service abstracts HTTP requests to the backend + * from the components. + * + * @author Ajay Gandecha + * @copyright 2023 + * @license MIT + */ + +import { Injectable } from '@angular/core'; +import { HttpClient } from '@angular/common/http'; +import { AuthenticationService } from '../authentication.service'; +import { MatSnackBar } from '@angular/material/snack-bar'; +import { Observable } from 'rxjs'; +import { Course, Room, Section, Term } from './academics.models'; + +@Injectable({ + providedIn: 'root' +}) +export class AcademicsService { + constructor( + protected http: HttpClient, + protected auth: AuthenticationService, + protected snackBar: MatSnackBar + ) {} + + /** Returns all term entries from the backend database. + * @returns {Observable} + */ + getTerms(): Observable { + return this.http.get('/api/academics/term'); + } + + /** Returns the current term from the backend database. + * @returns {Observable} + */ + getCurrentTerm(): Observable { + return this.http.get('/api/academics/term/current'); + } + + /** Returns one term from the backend database. + * @param id ID of the course to look up + * @returns {Observable} + */ + getTerm(id: string): Observable { + return this.http.get(`/api/academics/term/${id}`); + } + + /** Creates a new term. + * @param term: Term to create + * @returns {Observable} + */ + createTerm(term: Term): Observable { + return this.http.post('/api/academics/term', term); + } + + /** Update a term. + * @param term: Term to update + * @returns {Observable} + */ + updateTerm(term: Term): Observable { + return this.http.put('/api/academics/term', term); + } + + /** Delete a term. + * @param course: Term to update + * @returns {Observable} + */ + deleteTerm(term: Term) { + return this.http.delete(`/api/academics/term/${term.id}`); + } + + /** Returns all course entries from the backend database. + * @returns {Observable} + */ + getCourses(): Observable { + return this.http.get('/api/academics/course'); + } + + /** Returns one course from the backend database. + * @param id ID of the course to look up + * @returns {Observable} + */ + getCourse(id: string): Observable { + return this.http.get(`/api/academics/course/${id}`); + } + + /** Creates a new course. + * @param course: Course to create + * @returns {Observable} + */ + createCourse(course: Course): Observable { + return this.http.post('/api/academics/course', course); + } + + /** Update a course. + * @param course: Course to update + * @returns {Observable} + */ + updateCourse(course: Course): Observable { + return this.http.put('/api/academics/course', course); + } + + /** Delete a course. + * @param course: Course to delete + * @returns {Observable} + */ + deleteCourse(course: Course) { + return this.http.delete(`/api/academics/course/${course.id}`); + } + + /** Returns all section entries by a term. + * @param term Term to get sections by + * @returns {Observable} + */ + getSectionsByTerm(term: Term): Observable { + return this.http.get(`/api/academics/section/term/${term.id}`); + } + + /** Returns one section from the backend database. + * @param id ID of the section to look up + * @returns {Observable
} + */ + getSection(id: number): Observable
{ + return this.http.get
(`/api/academics/section/${id}`); + } + + /** Creates a new section. + * @param section: Section to create + * @returns {Observable
} + */ + createSection(section: Section): Observable
{ + return this.http.post
('/api/academics/section', section); + } + + /** Update a section. + * @param section: Section to update + * @returns {Observable
} + */ + updateSection(section: Section): Observable
{ + return this.http.put
('/api/academics/section', section); + } + + /** Delete a section. + * @param section: Section to delete + * @returns {Observable
} + */ + deleteSection(section: Section) { + return this.http.delete(`/api/academics/section/${section.id}`); + } + + /** Returns all room entries from the backend database. + * @returns {Observable} + */ + getRooms(): Observable { + return this.http.get('/api/room'); + } + + /** Returns one soom from the backend database. + * @param id ID of the room to look up + * @returns {Observable} + */ + getRoom(id: string): Observable { + return this.http.get(`/api/room/${id}`); + } + + /** Creates a new room. + * @param room: room to create + * @returns {Observable} + */ + createRoom(room: Room): Observable { + return this.http.post('/api/room', room); + } + + /** Update a room. + * @param room: room to update + * @returns {Observable} + */ + updateRoom(room: Room): Observable { + return this.http.put('/api/room', room); + } + + /** Delete a room. + * @param room: room to delete + * @returns {Observable} + */ + deleteRoom(room: Room) { + return this.http.delete(`/api/academics/room/${room.id}`); + } +} diff --git a/frontend/src/app/academics/course-catalog/course-catalog.component.css b/frontend/src/app/academics/course-catalog/course-catalog.component.css new file mode 100644 index 000000000..dd3aa546f --- /dev/null +++ b/frontend/src/app/academics/course-catalog/course-catalog.component.css @@ -0,0 +1,16 @@ +.mat-mdc-card { + max-width: 100%; +} + +tr.example-detail-row { + height: 0; +} + +.example-element-row td { + border-bottom-width: 0; +} + +.example-element-detail { + overflow: hidden; + display: flex; +} diff --git a/frontend/src/app/academics/course-catalog/course-catalog.component.html b/frontend/src/app/academics/course-catalog/course-catalog.component.html new file mode 100644 index 000000000..248ca02d1 --- /dev/null +++ b/frontend/src/app/academics/course-catalog/course-catalog.component.html @@ -0,0 +1,74 @@ + + + + All Courses + + + + + + + + + + + + + + + + + + + + + + + +
Code + {{ element.subject_code }} {{ element.number }} + Title{{ element.title }} +   + +
+ +
+
+
+
+

Credit Hours: {{ element.credit_hours }}

+

Description:

+

{{ element.description }}

+
+
+
+
+
diff --git a/frontend/src/app/academics/course-catalog/course-catalog.component.ts b/frontend/src/app/academics/course-catalog/course-catalog.component.ts new file mode 100644 index 000000000..b2358856a --- /dev/null +++ b/frontend/src/app/academics/course-catalog/course-catalog.component.ts @@ -0,0 +1,80 @@ +/** + * The Course Catalog enables users to view all COMP courses at UNC. + * + * @author Ajay Gandecha + * @copyright 2023 + * @license MIT + */ + +import { Component, OnInit } from '@angular/core'; +import { coursesResolver } from '../academics.resolver'; +import { Course } from '../academics.models'; +import { ActivatedRoute } from '@angular/router'; +import { AcademicsService } from '../academics.service'; +import { + animate, + state, + style, + transition, + trigger +} from '@angular/animations'; +import { NagivationAdminGearService } from 'src/app/navigation/navigation-admin-gear.service'; + +@Component({ + selector: 'app-courses-home', + templateUrl: './course-catalog.component.html', + styleUrls: ['./course-catalog.component.css'], + animations: [ + trigger('detailExpand', [ + state('collapsed,void', style({ height: '0px', minHeight: '0' })), + state('expanded', style({ height: '*' })), + transition( + 'expanded <=> collapsed', + animate('225ms cubic-bezier(0.4, 0.0, 0.2, 1)') + ) + ]) + ] +}) +export class CoursesHomeComponent implements OnInit { + /** Route information to be used in Course Routing Module */ + public static Route = { + path: 'catalog', + title: 'Course Catalog', + component: CoursesHomeComponent, + canActivate: [], + resolve: { courses: coursesResolver } + }; + + /** Store list of Courses */ + public courses: Course[]; + + /** Store the columns to display in the table */ + public displayedColumns: string[] = ['code', 'title']; + /** Store the columns to display when extended */ + public columnsToDisplayWithExpand = [...this.displayedColumns, 'expand']; + /** Store the element where the dropdown is currently active */ + public expandedElement: Course | null = null; + + /** Constructor for the course catalog page. */ + constructor( + private route: ActivatedRoute, + public academicsService: AcademicsService, + private gearService: NagivationAdminGearService + ) { + // Initialize data from resolvers + const data = this.route.snapshot.data as { + courses: Course[]; + }; + + this.courses = data.courses; + } + + ngOnInit() { + this.gearService.showAdminGear( + 'academics.*', + '*', + '', + 'academics/admin/course' + ); + } +} diff --git a/frontend/src/app/academics/section-offerings/section-offerings.component.css b/frontend/src/app/academics/section-offerings/section-offerings.component.css new file mode 100644 index 000000000..3dcc1c216 --- /dev/null +++ b/frontend/src/app/academics/section-offerings/section-offerings.component.css @@ -0,0 +1,16 @@ +.mat-mdc-card { + max-width: 100%; +} + +tr.example-detail-row { + height: 0; +} + +.example-element-row td { + border-bottom-width: 0; +} + +.example-element-detail { + overflow: hidden; + display: flex; +} \ No newline at end of file diff --git a/frontend/src/app/academics/section-offerings/section-offerings.component.html b/frontend/src/app/academics/section-offerings/section-offerings.component.html new file mode 100644 index 000000000..8bc7b0629 --- /dev/null +++ b/frontend/src/app/academics/section-offerings/section-offerings.component.html @@ -0,0 +1,119 @@ + + +
+ {{ displayTerm.value.name }} Course Offerings + + Select Term + + {{ + term.name + }} + + +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Code + {{ courseFromId(element.course_id)?.subject_code }} + {{ courseFromId(element.course_id)?.number }} - + {{ element.number }} + Title + {{ + element.override_title !== '' + ? element.override_title + : courseFromId(element.course_id)?.title + }} + Instructor + {{ instructorNameForSection(element) }} + Meeting Pattern + {{ element.meeting_pattern }} + Room + {{ element.lecture_room?.nickname ?? 'Unknown' }} + +   + +
+ +
+
+
+

+ {{ + element.override_description !== '' + ? element.override_description + : courseFromId(element.course_id)?.description + }} +

+
+
+
+
+
diff --git a/frontend/src/app/academics/section-offerings/section-offerings.component.ts b/frontend/src/app/academics/section-offerings/section-offerings.component.ts new file mode 100644 index 000000000..7b28353cc --- /dev/null +++ b/frontend/src/app/academics/section-offerings/section-offerings.component.ts @@ -0,0 +1,142 @@ +/** + * The Section Offerings page enables users to view all current offerings of + * the COMP courses. + * + * @author Ajay Gandecha + * @copyright 2023 + * @license MIT + */ + +import { Component, OnInit } from '@angular/core'; +import { + coursesResolver, + currentTermResolver, + termsResolver +} from '../academics.resolver'; +import { + Course, + RosterRole, + Section, + SectionMember, + Term +} from '../academics.models'; +import { ActivatedRoute } from '@angular/router'; +import { AcademicsService } from '../academics.service'; +import { + animate, + state, + style, + transition, + trigger +} from '@angular/animations'; +import { FormControl } from '@angular/forms'; +import { NagivationAdminGearService } from 'src/app/navigation/navigation-admin-gear.service'; + +@Component({ + selector: 'app-offerings', + templateUrl: './section-offerings.component.html', + styleUrls: ['./section-offerings.component.css'], + animations: [ + trigger('detailExpand', [ + state('collapsed,void', style({ height: '0px', minHeight: '0' })), + state('expanded', style({ height: '*' })), + transition( + 'expanded <=> collapsed', + animate('225ms cubic-bezier(0.4, 0.0, 0.2, 1)') + ) + ]) + ] +}) +export class SectionOfferingsComponent implements OnInit { + /** Route information to be used in Course Routing Module */ + public static Route = { + path: 'offerings', + title: 'Section Offerings', + component: SectionOfferingsComponent, + canActivate: [], + resolve: { + terms: termsResolver, + courses: coursesResolver, + currentTerm: currentTermResolver + } + }; + + /** Store list of Courses */ + public courses: Course[]; + /** Store list of Terms */ + public terms: Term[]; + + /** Store the currently selected term from the form */ + public displayTerm: FormControl = new FormControl(); + + /** Store the columns to display in the table */ + public displayedColumns: string[] = [ + 'code', + 'title', + 'instructor', + 'meetingpattern', + 'room' + ]; + /** Store the columns to display when extended */ + public columnsToDisplayWithExpand = [...this.displayedColumns, 'expand']; + /** Store the element where the dropdown is currently active */ + public expandedElement: Section | null = null; + + /** Constructor for the course catalog page. */ + constructor( + private route: ActivatedRoute, + public coursesService: AcademicsService, + private gearService: NagivationAdminGearService + ) { + // Initialize data from resolvers + const data = this.route.snapshot.data as { + courses: Course[]; + terms: Term[]; + currentTerm: Term; + }; + this.courses = data.courses; + this.terms = data.terms; + + // Set initial display term + this.displayTerm.setValue(data.currentTerm); + } + + ngOnInit() { + this.gearService.showAdminGear( + 'academics.*', + '*', + '', + 'academics/admin/section' + ); + } + + /** Helper function that returns the course object from the list with the given ID. + * @param id ID of the course to look up. + * @returns Course for the ID, if it exists. + */ + courseFromId(id: string): Course | null { + // Find the course for the given ID + let coursesFilter = this.courses.filter((c) => c.id === id); + // Return either the course if it exists, or null. + return coursesFilter.length > 0 ? coursesFilter[0] : null; + } + + /** Helper function that generates an instructor's name for a given section. + * @param section Section to create the instructor name for. + * @returns Name of the section's instructor, or 'Unknown' if no instructor is set. + */ + instructorNameForSection(section: Section): string { + // Find all staff with the instructor role + let staffFilter = section.staff?.filter( + (s) => s.member_role == RosterRole.INSTRUCTOR + ); + // Find the instructor + let instructor = staffFilter?.length ?? 0 > 0 ? staffFilter![0] : null; + // Return the name for the instructor + // If instructor exists: + // Otherwise: 'Unknown' + return instructor + ? instructor.first_name + ' ' + instructor.last_name + : 'Unknown'; + } +} diff --git a/frontend/src/app/app-routing.module.ts b/frontend/src/app/app-routing.module.ts index 2ffd18243..9c5f7e1bb 100644 --- a/frontend/src/app/app-routing.module.ts +++ b/frontend/src/app/app-routing.module.ts @@ -17,6 +17,12 @@ const routes: Routes = [ loadChildren: () => import('./coworking/coworking.module').then((m) => m.CoworkingModule) }, + { + path: 'academics', + title: 'Academics', + loadChildren: () => + import('./academics/academics.module').then((m) => m.AcademicsModule) + }, { path: 'admin', title: 'Admin', diff --git a/frontend/src/app/coworking/coworking.models.ts b/frontend/src/app/coworking/coworking.models.ts index c32339750..1fa403d4c 100644 --- a/frontend/src/app/coworking/coworking.models.ts +++ b/frontend/src/app/coworking/coworking.models.ts @@ -1,14 +1,5 @@ import { Profile } from '../models.module'; - -export interface TimeRangeJSON { - start: string; - end: string; -} - -export interface TimeRange { - start: Date; - end: Date; -} +import { TimeRangeJSON, TimeRange } from '../time-range'; export interface OperatingHoursJSON extends TimeRangeJSON { id: number; diff --git a/frontend/src/app/navigation/navigation-admin-gear.service.ts b/frontend/src/app/navigation/navigation-admin-gear.service.ts new file mode 100644 index 000000000..455c4bb7d --- /dev/null +++ b/frontend/src/app/navigation/navigation-admin-gear.service.ts @@ -0,0 +1,53 @@ +import { Injectable } from '@angular/core'; +import { PermissionService } from '../permission.service'; +import { ReplaySubject } from 'rxjs'; +import { AdminSettingsNavigationData } from './navigation.service'; + +@Injectable({ + providedIn: 'root' +}) +export class NagivationAdminGearService { + private adminSettingsData: ReplaySubject = + new ReplaySubject(1); + public adminSettingsData$ = this.adminSettingsData.asObservable(); + + constructor(private permissionService: PermissionService) {} + + /** + * This function updates an internal reactive object setup to manage when to show the admin + * page gear icon or not. + * + * @param permissionAction Permission action that must pass for the admin settings to appear. + * @param permissionResource Permission resource that must pass for the admin settings to appear. + * @param tooltip Tooltip to display when hovering over the settings gear icon. + * @param targetUrl URL for the admin page for the button to redirect to. + */ + public showAdminGear( + permissionAction: string, + permissionResource: string, + tooltip: string, + targetUrl: string + ) { + // First, check to see if the user has the permissions. + this.permissionService + .check(permissionAction, permissionResource) + .subscribe((hasPermission) => { + // If the user has the permission, then update the settings + // navigation data so that it shows. If not, clear the data. + if (hasPermission) { + // Update the settings data + this.adminSettingsData.next({ + tooltip: tooltip, + url: targetUrl + }); + } else { + // Reset the settings data + this.adminSettingsData.next(null); + } + }); + } + + public resetAdminSettingsNavigation() { + this.adminSettingsData.next(null); + } +} diff --git a/frontend/src/app/navigation/navigation.component.css b/frontend/src/app/navigation/navigation.component.css index 337ac5aa5..07172478b 100644 --- a/frontend/src/app/navigation/navigation.component.css +++ b/frontend/src/app/navigation/navigation.component.css @@ -36,4 +36,20 @@ .mat-mdc-progress-bar { position: fixed; +} + +.mat-toolbar { + width: 100%; +} + +.toolbar { + width: 100%; + display: flex; + flex-direction: row; +} + +#gear-icon { + margin-left: auto; + margin-top: auto; + margin-bottom: auto; } \ No newline at end of file diff --git a/frontend/src/app/navigation/navigation.component.html b/frontend/src/app/navigation/navigation.component.html index 33dc12eb9..7b1e5d56c 100644 --- a/frontend/src/app/navigation/navigation.component.html +++ b/frontend/src/app/navigation/navigation.component.html @@ -26,8 +26,9 @@
Coworking - Organizations Events + Organizations + Academics About the XL
@@ -57,15 +58,29 @@ - - {{ navigationService.title$ | async }} +
+ +

{{ navigationService.title$ | async }}

+ +
e instanceof RouterEvent)) + .subscribe((_) => { + navigationAdminGearService.resetAdminSettingsNavigation(); + }); } ngOnInit(): void { diff --git a/frontend/src/app/navigation/navigation.service.ts b/frontend/src/app/navigation/navigation.service.ts index bf39a30e5..710c1971e 100644 --- a/frontend/src/app/navigation/navigation.service.ts +++ b/frontend/src/app/navigation/navigation.service.ts @@ -1,6 +1,7 @@ import { HttpErrorResponse } from '@angular/common/http'; import { Injectable } from '@angular/core'; -import { BehaviorSubject } from 'rxjs'; +import { BehaviorSubject, ReplaySubject } from 'rxjs'; +import { PermissionService } from '../permission.service'; @Injectable({ providedIn: 'root' @@ -57,3 +58,8 @@ export class NavigationService { setTimeout(operation, 0); } } + +export interface AdminSettingsNavigationData { + tooltip: string; + url: string; +} diff --git a/frontend/src/app/organization/organization-editor/organization-editor.component.html b/frontend/src/app/organization/organization-editor/organization-editor.component.html index 73f65112c..23d0ea153 100644 --- a/frontend/src/app/organization/organization-editor/organization-editor.component.html +++ b/frontend/src/app/organization/organization-editor/organization-editor.component.html @@ -1,4 +1,4 @@ - +
diff --git a/frontend/src/app/permission.service.ts b/frontend/src/app/permission.service.ts index 53b0e7b2b..43484db60 100644 --- a/frontend/src/app/permission.service.ts +++ b/frontend/src/app/permission.service.ts @@ -1,6 +1,7 @@ import { Injectable } from '@angular/core'; -import { map, Observable } from 'rxjs'; +import { map, Observable, ReplaySubject } from 'rxjs'; import { Profile, ProfileService, Permission } from './profile/profile.service'; +import { AdminSettingsNavigationData } from './navigation/navigation.service'; @Injectable({ providedIn: 'root' diff --git a/frontend/src/app/time-range.ts b/frontend/src/app/time-range.ts new file mode 100644 index 000000000..aaf7bf54b --- /dev/null +++ b/frontend/src/app/time-range.ts @@ -0,0 +1,19 @@ +/** + * TimeRange abstracts out start and end time fields for + * time-sensitive models. TimeRange enables all time- + * sentitive models to use the same structure for easy + * comparisons between times / dates and managing converting + * JSONified time data to TypeScript `Date` objects. + * + * @author Kris Jordan + */ + +export interface TimeRangeJSON { + start: string; + end: string; +} + +export interface TimeRange { + start: Date; + end: Date; +}