From 2e6fa00d18b596847848726b08ae32bd852bb712 Mon Sep 17 00:00:00 2001 From: Raymond <42894676+ramonechen@users.noreply.github.com> Date: Thu, 13 Nov 2025 20:42:28 -0500 Subject: [PATCH 1/8] Remove old database models --- app/db_models/__init__.py | 0 app/db_models/course.py | 11 ----------- app/db_models/course_attribute.py | 19 ------------------- app/db_models/course_relationship.py | 12 ------------ app/db_models/course_restriction.py | 13 ------------- app/db_models/course_seats.py | 13 ------------- app/db_models/professor.py | 12 ------------ 7 files changed, 80 deletions(-) delete mode 100644 app/db_models/__init__.py delete mode 100644 app/db_models/course.py delete mode 100644 app/db_models/course_attribute.py delete mode 100644 app/db_models/course_relationship.py delete mode 100644 app/db_models/course_restriction.py delete mode 100644 app/db_models/course_seats.py delete mode 100644 app/db_models/professor.py diff --git a/app/db_models/__init__.py b/app/db_models/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/app/db_models/course.py b/app/db_models/course.py deleted file mode 100644 index 08cd01c..0000000 --- a/app/db_models/course.py +++ /dev/null @@ -1,11 +0,0 @@ -from sqlalchemy.dialects.mysql import TEXT, TINYINT, VARCHAR -from sqlmodel import Field, SQLModel - - -class Course(SQLModel, table=True): - dept: str = Field(primary_key=True, sa_type=VARCHAR(4)) - code_num: str = Field(primary_key=True, sa_type=VARCHAR(4)) - title: str = Field(sa_type=VARCHAR(255)) - desc_text: str = Field(sa_type=TEXT) - credit_min: int = Field(sa_type=TINYINT) - credit_max: int = Field(sa_type=TINYINT) diff --git a/app/db_models/course_attribute.py b/app/db_models/course_attribute.py deleted file mode 100644 index aaf214d..0000000 --- a/app/db_models/course_attribute.py +++ /dev/null @@ -1,19 +0,0 @@ -from sqlalchemy.dialects.mysql import ENUM, VARCHAR -from sqlmodel import Field, SQLModel - -_ATTR_ENUM = [ - "Communication Intensive", - "Culminating Exp/Capstone", - "Data Intensive I", - "Data Intensive II", - "Writing Intensive", - "HASS Inquiry", - "Introductory Level Course", - "PDII Option for Engr Majors", -] - - -class Course_Attribute(SQLModel, table=True): - dept: str = Field(primary_key=True, sa_type=VARCHAR(4)) - code_num: str = Field(primary_key=True, sa_type=VARCHAR(4)) - attr: str = Field(primary_key=True, sa_type=ENUM(*_ATTR_ENUM)) diff --git a/app/db_models/course_relationship.py b/app/db_models/course_relationship.py deleted file mode 100644 index faf24f7..0000000 --- a/app/db_models/course_relationship.py +++ /dev/null @@ -1,12 +0,0 @@ -from sqlalchemy.dialects.mysql import ENUM, VARCHAR -from sqlmodel import Field, SQLModel - -_RELATIONSHIP_ENUM = ["Coreq", "Cross"] - - -class Course_Relationship(SQLModel, table=True): - dept: str = Field(primary_key=True, sa_type=VARCHAR(4)) - code_num: str = Field(primary_key=True, sa_type=VARCHAR(4)) - relationship: str = Field(primary_key=True, sa_type=ENUM(*_RELATIONSHIP_ENUM)) - rel_dept: str = Field(primary_key=True, sa_type=VARCHAR(4)) - rel_code_num: str = Field(primary_key=True, sa_type=VARCHAR(4)) diff --git a/app/db_models/course_restriction.py b/app/db_models/course_restriction.py deleted file mode 100644 index a59deea..0000000 --- a/app/db_models/course_restriction.py +++ /dev/null @@ -1,13 +0,0 @@ -from sqlalchemy.dialects.mysql import ENUM, VARCHAR -from sqlmodel import Field, SQLModel - -_CATEGORY_ENUM = ["Major", "Level", "Classification"] -_RESTRICTION_ENUM = ["Must be", "May not be"] - - -class Course_Restriction(SQLModel, table=True): - dept: str = Field(primary_key=True, sa_type=VARCHAR(4)) - code_num: str = Field(primary_key=True, sa_type=VARCHAR(4)) - category: str = Field(primary_key=True, sa_type=ENUM(*_CATEGORY_ENUM)) - restr_rule: str = Field(primary_key=True, sa_type=ENUM(*_RESTRICTION_ENUM)) - restriction: str = Field(primary_key=True, sa_type=VARCHAR(255)) diff --git a/app/db_models/course_seats.py b/app/db_models/course_seats.py deleted file mode 100644 index 84698c8..0000000 --- a/app/db_models/course_seats.py +++ /dev/null @@ -1,13 +0,0 @@ -from sqlalchemy.dialects.mysql import ENUM, SMALLINT, VARCHAR -from sqlmodel import Field, SQLModel - -_SEM_ENUM = ["Fall", "Spring", "Summer"] - - -class Course_Seats(SQLModel, table=True): - sem_year: int = Field(primary_key=True, sa_type=SMALLINT) - semester: str = Field(primary_key=True, sa_type=ENUM(*_SEM_ENUM)) - dept: str = Field(primary_key=True, sa_type=VARCHAR(4)) - code_num: str = Field(primary_key=True, sa_type=VARCHAR(4)) - seats_filled: int = Field(sa_type=SMALLINT) - seats_total: int = Field(sa_type=SMALLINT) diff --git a/app/db_models/professor.py b/app/db_models/professor.py deleted file mode 100644 index e37abcc..0000000 --- a/app/db_models/professor.py +++ /dev/null @@ -1,12 +0,0 @@ -from sqlalchemy.dialects.mysql import ENUM, SMALLINT, VARCHAR -from sqlmodel import Field, SQLModel - -_SEMESTER_ENUM = ["Fall", "Spring", "Summer"] - - -class Professor(SQLModel, table=True): - sem_year: int = Field(primary_key=True, sa_type=SMALLINT) - semester: str = Field(primary_key=True, sa_type=ENUM(*_SEMESTER_ENUM)) - dept: str = Field(primary_key=True, sa_type=VARCHAR(4)) - code_num: str = Field(primary_key=True, sa_type=VARCHAR(4)) - prof_name: str = Field(primary_key=True, sa_type=VARCHAR(255)) From ccf44da5bc4c5c4bc17dbf32a323e5bde3403731 Mon Sep 17 00:00:00 2001 From: Raymond <42894676+ramonechen@users.noreply.github.com> Date: Thu, 13 Nov 2025 20:49:58 -0500 Subject: [PATCH 2/8] Update requirements.txt for new ORM code --- requirements.txt | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/requirements.txt b/requirements.txt index 963bcbf..f66168f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,15 +2,17 @@ # uvloop is an optional dependency for improved performance over asyncio, you may # uncomment it if using MacOS or any POSIX-compliant OS. +annotated-doc==0.0.4 annotated-types==0.7.0 anyio==4.11.0 -certifi==2025.10.5 +carpi-data-model @ git+https://github.com/Project-CARPI/database-schema.git@e188119f2d1d4f03b97c8a2457806c5b0a7f4749 +certifi==2025.11.12 click==8.3.0 colorama==0.4.6 dnspython==2.8.0 email-validator==2.3.0 -fastapi==0.119.1 -fastapi-cli==0.0.14 +fastapi==0.121.2 +fastapi-cli==0.0.16 fastapi-cloud-cli==0.3.1 greenlet==3.2.4 h11==0.16.0 @@ -22,23 +24,21 @@ Jinja2==3.1.6 markdown-it-py==4.0.0 MarkupSafe==3.0.3 mdurl==0.1.2 -mysql-connector-python==9.5.0 -pydantic==2.12.3 -pydantic-settings==2.11.0 -pydantic_core==2.41.4 +pydantic==2.12.4 +pydantic-settings==2.12.0 +pydantic_core==2.41.5 Pygments==2.19.2 -python-dotenv==1.1.1 +python-dotenv==1.2.1 python-multipart==0.0.20 PyYAML==6.0.3 rich==14.2.0 rich-toolkit==0.15.1 -rignore==0.7.1 -sentry-sdk==2.42.1 +rignore==0.7.6 +sentry-sdk==2.44.0 shellingham==1.5.4 sniffio==1.3.1 SQLAlchemy==2.0.44 -sqlmodel==0.0.27 -starlette==0.48.0 +starlette==0.49.3 typer==0.20.0 typing-inspection==0.4.2 typing_extensions==4.15.0 From b8d610f7307326610a3507275a0b784311d591f0 Mon Sep 17 00:00:00 2001 From: Raymond <42894676+ramonechen@users.noreply.github.com> Date: Thu, 13 Nov 2025 21:01:31 -0500 Subject: [PATCH 3/8] Refactor database setup to use SQLAlchemy --- app/dependencies.py | 29 ++++++++++++++++++----------- app/main.py | 1 - 2 files changed, 18 insertions(+), 12 deletions(-) diff --git a/app/dependencies.py b/app/dependencies.py index 8548032..2879e86 100644 --- a/app/dependencies.py +++ b/app/dependencies.py @@ -5,7 +5,9 @@ from fastapi import Depends, FastAPI from pydantic_settings import BaseSettings, SettingsConfigDict from sqlalchemy.engine import Engine -from sqlmodel import Session, SQLModel, create_engine +from sqlalchemy import create_engine +from sqlalchemy.orm import Session, sessionmaker +import carpi_data_model.models as models class _Settings(BaseSettings): @@ -22,20 +24,23 @@ class _Settings(BaseSettings): _settings = _Settings() _engine: Engine | None = None +_session_maker: sessionmaker | None = None @asynccontextmanager async def lifespan_func(app: FastAPI) -> AsyncGenerator[None, None]: # Initialize database connection pool - global _engine - _engine = create_engine( - url=f"{_settings.db_dialect}+{_settings.db_api}" - + f"://{_settings.db_username}:{_settings.db_password}" - + f"@{_settings.db_hostname}/{_settings.db_schema}", - # echo=True, - ) - # Creates tables in database based on SQLModel table models - SQLModel.metadata.create_all(_engine) + global _engine, _session_maker + if _engine is None: + _engine = create_engine( + url=f"{_settings.db_dialect}+{_settings.db_api}" + + f"://{_settings.db_username}:{_settings.db_password}" + + f"@{_settings.db_hostname}/{_settings.db_schema}", + # echo=True, + ) + _session_maker = sessionmaker(_engine) + # Creates tables in database based on SQLAlchemy table models + models.Base.metadata.create_all(_engine) yield @@ -47,7 +52,9 @@ def get_app_settings() -> _Settings: def get_db_session() -> Generator[Session, None, None]: - with Session(_engine) as session: + if _session_maker is None: + raise RuntimeError("Database engine is not initialized") + with _session_maker() as session: yield session diff --git a/app/main.py b/app/main.py index 5fb676b..ded9b87 100644 --- a/app/main.py +++ b/app/main.py @@ -19,7 +19,6 @@ def scan_and_include_routers(app: FastAPI) -> None: isinstance(attr, APIRouter) and getattr(attr, "__module__", None) == module.__name__ ): - app.include_router(attr) From 3e6a27570044978f5aa5c5618a162ae26674d077 Mon Sep 17 00:00:00 2001 From: Raymond Chen <42894676+ramonechen@users.noreply.github.com> Date: Fri, 14 Nov 2025 16:17:41 -0500 Subject: [PATCH 4/8] Fix carpi-data-model requirement link --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index f66168f..6ae40c2 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,7 +5,7 @@ annotated-doc==0.0.4 annotated-types==0.7.0 anyio==4.11.0 -carpi-data-model @ git+https://github.com/Project-CARPI/database-schema.git@e188119f2d1d4f03b97c8a2457806c5b0a7f4749 +carpi-data-model @ git+https://github.com/Project-CARPI/database-schema.git certifi==2025.11.12 click==8.3.0 colorama==0.4.6 From f4c56adb88a81d337708941cfc5accbdf8d4d194 Mon Sep 17 00:00:00 2001 From: Raymond Chen <42894676+ramonechen@users.noreply.github.com> Date: Fri, 14 Nov 2025 16:25:29 -0500 Subject: [PATCH 5/8] Add MySQL connector to requirements.txt --- requirements.txt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 6ae40c2..84331f5 100644 --- a/requirements.txt +++ b/requirements.txt @@ -24,6 +24,7 @@ Jinja2==3.1.6 markdown-it-py==4.0.0 MarkupSafe==3.0.3 mdurl==0.1.2 +mysql-connector-python==9.5.0 pydantic==2.12.4 pydantic-settings==2.12.0 pydantic_core==2.41.5 @@ -43,7 +44,7 @@ typer==0.20.0 typing-inspection==0.4.2 typing_extensions==4.15.0 urllib3==2.5.0 -# uvloop==0.22.1 uvicorn==0.38.0 +# uvloop==0.22.1 watchfiles==1.1.1 websockets==15.0.1 From d0587123632866acb7633208277aedfd3df380a8 Mon Sep 17 00:00:00 2001 From: Raymond Chen <42894676+ramonechen@users.noreply.github.com> Date: Fri, 14 Nov 2025 17:16:38 -0500 Subject: [PATCH 6/8] Fix automatic router detection in main() --- app/main.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/app/main.py b/app/main.py index ded9b87..39fecc4 100644 --- a/app/main.py +++ b/app/main.py @@ -4,7 +4,7 @@ from fastapi import APIRouter, FastAPI from fastapi.middleware.cors import CORSMiddleware -from app import lifespan_func +from app import lifespan_func, logger def scan_and_include_routers(app: FastAPI) -> None: @@ -15,10 +15,8 @@ def scan_and_include_routers(app: FastAPI) -> None: module = importlib.import_module(f"{package_module_name}.{module_name}") for attr_name in dir(module): attr = getattr(module, attr_name) - if ( - isinstance(attr, APIRouter) - and getattr(attr, "__module__", None) == module.__name__ - ): + if isinstance(attr, APIRouter): + logger.info(f"Including router from {module.__name__}") app.include_router(attr) From d29b58802120c625a721fb96d97c8a1e9ae37d07 Mon Sep 17 00:00:00 2001 From: Raymond Chen <42894676+ramonechen@users.noreply.github.com> Date: Fri, 14 Nov 2025 17:16:55 -0500 Subject: [PATCH 7/8] Refactor course router to use SQLAlchemy --- app/routers/course.py | 62 ++++++++++++++++++++++++------------------- 1 file changed, 34 insertions(+), 28 deletions(-) diff --git a/app/routers/course.py b/app/routers/course.py index 9ea7808..9d39ba2 100644 --- a/app/routers/course.py +++ b/app/routers/course.py @@ -1,17 +1,15 @@ from enum import Enum from fastapi import APIRouter -from sqlmodel import and_, desc, distinct, func, or_, select -from sqlmodel.sql.expression import Select, SelectOfScalar +from sqlalchemy import and_, desc, distinct, func, or_, select +from sqlalchemy.sql import Select from app import SessionDep -from app.db_models.course import Course -from app.db_models.course_attribute import Course_Attribute -from app.db_models.course_seats import Course_Seats +from carpi_data_model.models import Course, Course_Attribute, Course_Offering class CourseFilter(str, Enum): - departments = "departments" + subjects = "subjects" attributes = "attributes" semesters = "semesters" @@ -29,21 +27,25 @@ def search_course_query( dept_filter_regex: str, attr_filter_regex: str, sem_filter_regex: str, -) -> Select | SelectOfScalar: +) -> Select: return ( select( - Course.dept, + Course.subj_code, Course.code_num, Course.title, Course.desc_text, Course.credit_min, Course.credit_max, func.group_concat( - distinct(func.concat(Course_Seats.semester, " ", Course_Seats.sem_year)) + distinct( + func.concat(Course_Offering.semester, " ", Course_Offering.sem_year) + ) ).label("sem_list"), - func.group_concat(distinct(Course_Attribute.attr)).label("attr_list"), + func.group_concat(distinct(Course_Attribute.attr_code)).label("attr_list"), func.regexp_like( - func.concat(Course.dept, " ", Course.code_num), search_code_regex, "i" + func.concat(Course.subj_code, " ", Course.code_num), + search_code_regex, + "i", ).label("code_match"), func.regexp_like(Course.title, search_full_regex, "i").label( "title_exact_match" @@ -60,22 +62,22 @@ def search_course_query( ), ) .join( - Course_Seats, + Course_Offering, and_( - Course.dept == Course_Seats.dept, - Course.code_num == Course_Seats.code_num, + Course.subj_code == Course_Offering.subj_code, + Course.code_num == Course_Offering.code_num, ), ) .outerjoin( Course_Attribute, and_( - Course.dept == Course_Attribute.dept, + Course.subj_code == Course_Attribute.subj_code, Course.code_num == Course_Attribute.code_num, ), ) - .where(func.regexp_like(Course.dept, dept_filter_regex, "i")) + .where(func.regexp_like(Course.subj_code, dept_filter_regex, "i")) .group_by( - Course.dept, + Course.subj_code, Course.code_num, Course.title, Course.desc_text, @@ -85,7 +87,7 @@ def search_course_query( .having( or_( func.regexp_like( - func.concat(Course.dept, " ", Course.code_num), + func.concat(Course.subj_code, " ", Course.code_num), search_code_regex, "i", ), @@ -96,14 +98,18 @@ def search_course_query( func.regexp_like(Course.title, search_abbrev_regex, "i"), ), func.regexp_like( - func.ifnull(func.group_concat(distinct(Course_Attribute.attr)), ""), + func.ifnull( + func.group_concat(distinct(Course_Attribute.attr_code)), "" + ), attr_filter_regex, "i", ), func.regexp_like( func.group_concat( distinct( - func.concat(Course_Seats.semester, " ", Course_Seats.sem_year) + func.concat( + Course_Offering.semester, " ", Course_Offering.sem_year + ) ) ), sem_filter_regex, @@ -113,7 +119,7 @@ def search_course_query( .order_by( desc( func.regexp_like( - func.concat(Course.dept, " ", Course.code_num), + func.concat(Course.subj_code, " ", Course.code_num), search_code_regex, "i", ) @@ -124,7 +130,7 @@ def search_course_query( desc(func.regexp_like(Course.title, search_acronym_regex, "i")), desc(func.regexp_like(Course.title, search_abbrev_regex, "i")), Course.code_num, - Course.dept, + Course.subj_code, ) ) @@ -189,7 +195,7 @@ def search_course( regex_abbrev = regex_abbrev[:-3] else: regex_abbrev = "a^" - results = session.exec( + results = session.execute( search_course_query( regex_code, regex_full, @@ -208,12 +214,12 @@ def search_course( @router.get("/filter/values/{filter}") def get_filter_values(session: SessionDep, filter: CourseFilter) -> list[str]: column = None - if filter is CourseFilter.departments: - column = Course.dept + if filter is CourseFilter.subjects: + column = Course.subj_code elif filter is CourseFilter.attributes: - column = Course_Attribute.attr + column = Course_Attribute.attr_code elif filter is CourseFilter.semesters: - column = Course_Seats.semester + column = Course_Offering.semester else: return None - return session.exec(select(column).distinct()).all() + return session.execute(select(column).distinct()).all() From 75ba279d8f99ef62ac10b94adca92a34adedaf08 Mon Sep 17 00:00:00 2001 From: Raymond Chen <42894676+ramonechen@users.noreply.github.com> Date: Fri, 14 Nov 2025 17:29:24 -0500 Subject: [PATCH 8/8] Fix broken /api/v1/filter/values endpoint --- app/routers/course.py | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/app/routers/course.py b/app/routers/course.py index 9d39ba2..77adad4 100644 --- a/app/routers/course.py +++ b/app/routers/course.py @@ -1,11 +1,17 @@ from enum import Enum +from carpi_data_model.models import ( + Attribute, + Course, + Course_Attribute, + Course_Offering, + Subject, +) from fastapi import APIRouter from sqlalchemy import and_, desc, distinct, func, or_, select from sqlalchemy.sql import Select from app import SessionDep -from carpi_data_model.models import Course, Course_Attribute, Course_Offering class CourseFilter(str, Enum): @@ -215,11 +221,11 @@ def search_course( def get_filter_values(session: SessionDep, filter: CourseFilter) -> list[str]: column = None if filter is CourseFilter.subjects: - column = Course.subj_code + column = Subject.subj_code elif filter is CourseFilter.attributes: - column = Course_Attribute.attr_code + column = Attribute.attr_code elif filter is CourseFilter.semesters: column = Course_Offering.semester else: return None - return session.execute(select(column).distinct()).all() + return session.execute(select(column).distinct()).scalars().all()