From ef85740071d102901697a9d09decabcc56f3f70f Mon Sep 17 00:00:00 2001 From: Scott Kirkland Date: Fri, 22 May 2026 15:49:52 -0700 Subject: [PATCH 1/8] Switch course rebuild source to CoursesRaw --- CONTEXT.md | 28 +- ...fferingsRaw.sql => usp_LoadCoursesRaw.sql} | 6 +- ...20260522152058_UseCoursesRawSourceTable.cs | 346 ++++++++++++++++++ src/tacos.core/createMigration | 17 + src/tacos.core/updateDbFromMigrations | 10 + src/tacos.mvc/Startup.cs | 14 +- src/tacos.sql/dbo/Tables/CoursesRaw.sql | 19 + src/tacos.sql/tacos.sql.sqlproj | 3 +- 8 files changed, 423 insertions(+), 20 deletions(-) rename datamart/stored-procedures/{usp_LoadCourseOfferingsRaw.sql => usp_LoadCoursesRaw.sql} (97%) create mode 100644 src/tacos.core/Migrations/20260522152058_UseCoursesRawSourceTable.cs create mode 100755 src/tacos.core/createMigration create mode 100755 src/tacos.core/updateDbFromMigrations create mode 100644 src/tacos.sql/dbo/Tables/CoursesRaw.sql diff --git a/CONTEXT.md b/CONTEXT.md index ebf6b30..9c28300 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -8,7 +8,7 @@ Tacos uses catalog and offering data to help departments plan course support req An enrollment and section summary for a course in a specific academic term. _Avoid_: DESII course -**CourseOfferingsRaw**: +**CoursesRaw**: The canonical local source of raw course offering rows used to rebuild the course list. _Avoid_: DESII_Courses @@ -29,11 +29,11 @@ Exactly two complete academic years of selected terms used to rebuild the course _Avoid_: Arbitrary term subset **Available Term Code**: -An **Academic Term Code** present in **CourseOfferingsRaw** and therefore eligible for a rebuild selection. +An **Academic Term Code** present in **CoursesRaw** and therefore eligible for a rebuild selection. _Avoid_: Manually typed term code **Academic Year Span**: -A human-readable label composed from the `AcademicYear` values in **CourseOfferingsRaw**, such as `2024-25 and 2025-26`. +A human-readable label composed from the `AcademicYear` values in **CoursesRaw**, such as `2024-25 and 2025-26`. _Avoid_: Ending-year-only label **Most Recent Academic Year**: @@ -63,17 +63,17 @@ _Avoid_: Duplicate course ## Relationships - A **Course Offering** belongs to exactly one academic term. -- **CourseOfferingsRaw** contains many **Course Offerings**. -- **CourseOfferingsRaw** is populated by an external process outside the course rebuild workflow. +- **CoursesRaw** contains many **Course Offerings**. +- **CoursesRaw** is populated by an external process outside the course rebuild workflow. - **CourseDescription** is populated by an external process outside the course rebuild workflow. -- The **Course List** is rebuilt from **CourseOfferingsRaw** rows whose academic terms are in the **Processing Window** and from active catalog data. +- The **Course List** is rebuilt from **CoursesRaw** rows whose academic terms are in the **Processing Window** and from active catalog data. - The frontend may group **Academic Term Codes** by academic year or quarter, but processing uses the exact **Processing Term Set** provided by the user. - A valid **Processing Window** contains exactly two academic years, each with the required `10`, `01`, and `03` terms. - Users select **Available Term Codes** directly or choose an **Academic Year Span** helper that derives the six-code **Processing Window**. - The frontend previews the six **Academic Term Codes** represented by each **Academic Year Span** selection. -- The frontend lists **Academic Year Span** helpers derived from **CourseOfferingsRaw** `AcademicYear` values for the selected term codes. +- The frontend lists **Academic Year Span** helpers derived from **CoursesRaw** `AcademicYear` values for the selected term codes. - When users choose an **Academic Year Span**, the underlying processing key remains the later year in the two-year **Processing Window**. -- An **Academic Year Span** helper is valid only when all six implied **Academic Term Codes** are present in **CourseOfferingsRaw**. +- An **Academic Year Span** helper is valid only when all six implied **Academic Term Codes** are present in **CoursesRaw**. - `WasCourseTaughtInMostRecentYear` is true when a course appears in the **Most Recent Academic Year**. - `IsCourseTaughtOnceEveryTwoYears` is true when a course is an **Every-Other-Year Course**. - **Catalog-Only Courses** remain selectable in the **Course List** with zero offering metrics. @@ -86,10 +86,10 @@ _Avoid_: Duplicate course ## Example Dialogue > **Dev:** "Should the processor read from DESII_Courses?" -> **Domain expert:** "No. DESII_Courses is legacy language. The local source is CourseOfferingsRaw." +> **Domain expert:** "No. DESII_Courses is legacy language. The local source is CoursesRaw." > -> **Dev:** "Does the rebuild workflow load CourseOfferingsRaw?" -> **Domain expert:** "No. CourseOfferingsRaw is populated separately; the rebuild only consumes it." +> **Dev:** "Does the rebuild workflow load CoursesRaw?" +> **Domain expert:** "No. CoursesRaw is populated separately; the rebuild only consumes it." > > **Dev:** "Does the rebuild workflow refresh CourseDescription?" > **Domain expert:** "No. CourseDescription is populated separately; the rebuild only consumes it." @@ -101,7 +101,7 @@ _Avoid_: Duplicate course > **Domain expert:** "No. The selected terms must form two complete academic years." > > **Dev:** "Can admins type any term code?" -> **Domain expert:** "No. They choose from term codes already present in CourseOfferingsRaw, with UI help for selecting a complete window." +> **Domain expert:** "No. They choose from term codes already present in CoursesRaw, with UI help for selecting a complete window." > > **Dev:** "Should the UI show only an ending year like 2025?" > **Domain expert:** "No. Show the academic year span, such as 2024-25, and preview the six term codes the selection implies." @@ -126,9 +126,9 @@ _Avoid_: Duplicate course ## Flagged Ambiguities -- "DESII_Courses" was used as the historical source-table name for offering rows; resolved: new work should use **CourseOfferingsRaw**. +- "DESII_Courses" was used as the historical source-table name for offering rows; resolved: new work should use **CoursesRaw**. - "Which terms to use" means an explicit **Processing Term Set**, not a backend-derived date window. - "Frontend-selected terms" does not mean an arbitrary subset; resolved: selected terms must form a valid **Processing Window**. -- "Available terms" means terms present in **CourseOfferingsRaw**, not every possible Banner term code. +- "Available terms" means terms present in **CoursesRaw**, not every possible Banner term code. - `WasCourseTaughtInMostRecentYear` previously had conflicting legacy SQL behavior; resolved: the flag is literal. - Course identifiers were inconsistently shown with and without spaces; resolved: **Course Number** is no-space uppercase in both `Courses.Number` and `CourseDescription.Course`, and UI/API input may be normalized into that form. diff --git a/datamart/stored-procedures/usp_LoadCourseOfferingsRaw.sql b/datamart/stored-procedures/usp_LoadCoursesRaw.sql similarity index 97% rename from datamart/stored-procedures/usp_LoadCourseOfferingsRaw.sql rename to datamart/stored-procedures/usp_LoadCoursesRaw.sql index 8ccefd0..4be9ee3 100644 --- a/datamart/stored-procedures/usp_LoadCourseOfferingsRaw.sql +++ b/datamart/stored-procedures/usp_LoadCoursesRaw.sql @@ -1,4 +1,4 @@ -CREATE PROCEDURE dbo.usp_LoadCourseOfferingsRaw +CREATE PROCEDURE dbo.usp_LoadCoursesRaw AS BEGIN SET NOCOUNT ON; @@ -6,9 +6,9 @@ BEGIN BEGIN TRANSACTION; - TRUNCATE TABLE dbo.CourseOfferingsRaw; + TRUNCATE TABLE dbo.CoursesRaw; - INSERT INTO dbo.CourseOfferingsRaw + INSERT INTO dbo.CoursesRaw ( AcademicYear, AcademicTermCode, diff --git a/src/tacos.core/Migrations/20260522152058_UseCoursesRawSourceTable.cs b/src/tacos.core/Migrations/20260522152058_UseCoursesRawSourceTable.cs new file mode 100644 index 0000000..89c48f8 --- /dev/null +++ b/src/tacos.core/Migrations/20260522152058_UseCoursesRawSourceTable.cs @@ -0,0 +1,346 @@ +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace tacos.core.Migrations +{ + /// + [DbContext(typeof(TacoDbContext))] + [Migration("20260522152058_UseCoursesRawSourceTable")] + public partial class UseCoursesRawSourceTable : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.Sql( + """ + IF OBJECT_ID(N'[dbo].[CoursesRaw]', N'U') IS NULL + BEGIN + CREATE TABLE [dbo].[CoursesRaw] + ( + [AcademicYear] [nvarchar](7) NULL, + [AcademicTermCode] [nvarchar](6) NULL, + [CollegeCode] [nvarchar](2) NULL, + [College] [nvarchar](100) NULL, + [DeptCode] [nvarchar](4) NULL, + [DeptName] [nvarchar](100) NULL, + [SubjectCode] [nvarchar](4) NULL, + [CourseNumber] [nvarchar](7) NULL, + [CourseName] [nvarchar](255) NULL, + [Enrollment] [int] NULL, + [NumCreditSections] [int] NULL, + [NumNonCreditSections] [int] NULL + ); + END; + + IF COL_LENGTH(N'[dbo].[CoursesRaw]', N'AcademicYear') IS NULL + ALTER TABLE [dbo].[CoursesRaw] ADD [AcademicYear] [nvarchar](7) NULL; + IF COL_LENGTH(N'[dbo].[CoursesRaw]', N'AcademicTermCode') IS NULL + ALTER TABLE [dbo].[CoursesRaw] ADD [AcademicTermCode] [nvarchar](6) NULL; + IF COL_LENGTH(N'[dbo].[CoursesRaw]', N'CollegeCode') IS NULL + ALTER TABLE [dbo].[CoursesRaw] ADD [CollegeCode] [nvarchar](2) NULL; + IF COL_LENGTH(N'[dbo].[CoursesRaw]', N'College') IS NULL + ALTER TABLE [dbo].[CoursesRaw] ADD [College] [nvarchar](100) NULL; + IF COL_LENGTH(N'[dbo].[CoursesRaw]', N'DeptCode') IS NULL + ALTER TABLE [dbo].[CoursesRaw] ADD [DeptCode] [nvarchar](4) NULL; + IF COL_LENGTH(N'[dbo].[CoursesRaw]', N'DeptName') IS NULL + ALTER TABLE [dbo].[CoursesRaw] ADD [DeptName] [nvarchar](100) NULL; + IF COL_LENGTH(N'[dbo].[CoursesRaw]', N'SubjectCode') IS NULL + ALTER TABLE [dbo].[CoursesRaw] ADD [SubjectCode] [nvarchar](4) NULL; + IF COL_LENGTH(N'[dbo].[CoursesRaw]', N'CourseNumber') IS NULL + ALTER TABLE [dbo].[CoursesRaw] ADD [CourseNumber] [nvarchar](7) NULL; + IF COL_LENGTH(N'[dbo].[CoursesRaw]', N'CourseName') IS NULL + ALTER TABLE [dbo].[CoursesRaw] ADD [CourseName] [nvarchar](255) NULL; + IF COL_LENGTH(N'[dbo].[CoursesRaw]', N'Enrollment') IS NULL + ALTER TABLE [dbo].[CoursesRaw] ADD [Enrollment] [int] NULL; + IF COL_LENGTH(N'[dbo].[CoursesRaw]', N'NumCreditSections') IS NULL + ALTER TABLE [dbo].[CoursesRaw] ADD [NumCreditSections] [int] NULL; + IF COL_LENGTH(N'[dbo].[CoursesRaw]', N'NumNonCreditSections') IS NULL + ALTER TABLE [dbo].[CoursesRaw] ADD [NumNonCreditSections] [int] NULL; + """, + suppressTransaction: true + ); + + migrationBuilder.Sql( + """ + IF EXISTS + ( + SELECT 1 + FROM [dbo].[CoursesRaw] + WHERE LEN(CONVERT(nvarchar(max), [AcademicYear])) > 7 + OR LEN(CONVERT(nvarchar(max), [AcademicTermCode])) > 6 + OR LEN(CONVERT(nvarchar(max), [CollegeCode])) > 2 + OR LEN(CONVERT(nvarchar(max), [College])) > 100 + OR LEN(CONVERT(nvarchar(max), [DeptCode])) > 4 + OR LEN(CONVERT(nvarchar(max), [DeptName])) > 100 + OR LEN(CONVERT(nvarchar(max), [SubjectCode])) > 4 + OR LEN(CONVERT(nvarchar(max), [CourseNumber])) > 7 + OR LEN(CONVERT(nvarchar(max), [CourseName])) > 255 + ) + BEGIN + THROW 50014, 'CoursesRaw contains values too long for the expected source table schema.', 1; + END; + + IF EXISTS + ( + SELECT 1 + FROM [dbo].[CoursesRaw] + WHERE TRY_CONVERT(int, [Enrollment]) IS NULL AND [Enrollment] IS NOT NULL + ) + BEGIN + THROW 50015, 'CoursesRaw contains Enrollment values that cannot be converted to int.', 1; + END; + + IF EXISTS + ( + SELECT 1 + FROM [dbo].[CoursesRaw] + WHERE TRY_CONVERT(int, [NumCreditSections]) IS NULL AND [NumCreditSections] IS NOT NULL + ) + BEGIN + THROW 50016, 'CoursesRaw contains NumCreditSections values that cannot be converted to int.', 1; + END; + + IF EXISTS + ( + SELECT 1 + FROM [dbo].[CoursesRaw] + WHERE TRY_CONVERT(int, [NumNonCreditSections]) IS NULL AND [NumNonCreditSections] IS NOT NULL + ) + BEGIN + THROW 50017, 'CoursesRaw contains NumNonCreditSections values that cannot be converted to int.', 1; + END; + """, + suppressTransaction: true + ); + + migrationBuilder.Sql( + """ + IF EXISTS + ( + SELECT 1 + FROM + ( + VALUES + (N'AcademicYear', N'nvarchar', 7), + (N'AcademicTermCode', N'nvarchar', 6), + (N'CollegeCode', N'nvarchar', 2), + (N'College', N'nvarchar', 100), + (N'DeptCode', N'nvarchar', 4), + (N'DeptName', N'nvarchar', 100), + (N'SubjectCode', N'nvarchar', 4), + (N'CourseNumber', N'nvarchar', 7), + (N'CourseName', N'nvarchar', 255), + (N'Enrollment', N'int', NULL), + (N'NumCreditSections', N'int', NULL), + (N'NumNonCreditSections', N'int', NULL) + ) Expected([ColumnName], [DataType], [CharacterMaximumLength]) + LEFT JOIN INFORMATION_SCHEMA.COLUMNS Columns + ON Columns.[TABLE_SCHEMA] = N'dbo' + AND Columns.[TABLE_NAME] = N'CoursesRaw' + AND Columns.[COLUMN_NAME] = Expected.[ColumnName] + WHERE Columns.[COLUMN_NAME] IS NULL + OR Columns.[DATA_TYPE] <> Expected.[DataType] + OR ISNULL(Columns.[CHARACTER_MAXIMUM_LENGTH], -1) <> ISNULL(Expected.[CharacterMaximumLength], -1) + ) + BEGIN + DROP TABLE IF EXISTS [dbo].[CoursesRaw_Rebuild]; + + CREATE TABLE [dbo].[CoursesRaw_Rebuild] + ( + [AcademicYear] [nvarchar](7) NULL, + [AcademicTermCode] [nvarchar](6) NULL, + [CollegeCode] [nvarchar](2) NULL, + [College] [nvarchar](100) NULL, + [DeptCode] [nvarchar](4) NULL, + [DeptName] [nvarchar](100) NULL, + [SubjectCode] [nvarchar](4) NULL, + [CourseNumber] [nvarchar](7) NULL, + [CourseName] [nvarchar](255) NULL, + [Enrollment] [int] NULL, + [NumCreditSections] [int] NULL, + [NumNonCreditSections] [int] NULL + ); + + INSERT INTO [dbo].[CoursesRaw_Rebuild] + ( + [AcademicYear], + [AcademicTermCode], + [CollegeCode], + [College], + [DeptCode], + [DeptName], + [SubjectCode], + [CourseNumber], + [CourseName], + [Enrollment], + [NumCreditSections], + [NumNonCreditSections] + ) + SELECT + CONVERT(nvarchar(7), [AcademicYear]), + CONVERT(nvarchar(6), [AcademicTermCode]), + CONVERT(nvarchar(2), [CollegeCode]), + CONVERT(nvarchar(100), [College]), + CONVERT(nvarchar(4), [DeptCode]), + CONVERT(nvarchar(100), [DeptName]), + CONVERT(nvarchar(4), [SubjectCode]), + CONVERT(nvarchar(7), [CourseNumber]), + CONVERT(nvarchar(255), [CourseName]), + CONVERT(int, [Enrollment]), + CONVERT(int, [NumCreditSections]), + CONVERT(int, [NumNonCreditSections]) + FROM [dbo].[CoursesRaw]; + + DROP TABLE [dbo].[CoursesRaw]; + EXEC sp_rename N'[dbo].[CoursesRaw_Rebuild]', N'CoursesRaw'; + END; + """, + suppressTransaction: true + ); + + migrationBuilder.Sql( + """ + DECLARE @OldTableName nvarchar(128) = N'CourseOfferingsRaw'; + DECLARE @NewTableName nvarchar(128) = N'CoursesRaw'; + DECLARE @Procedures TABLE + ( + [ProcedureName] nvarchar(300) NOT NULL PRIMARY KEY + ); + + INSERT INTO @Procedures ([ProcedureName]) + VALUES + (N'[dbo].[usp_GetCourseRebuildAcademicYearSpanOptions]'), + (N'[dbo].[usp_RebuildCoursesFromProcessingWindow]'); + + DECLARE @ProcedureName nvarchar(300); + + DECLARE SourceProcedureCursor CURSOR LOCAL FAST_FORWARD FOR + SELECT [ProcedureName] + FROM @Procedures; + + OPEN SourceProcedureCursor; + FETCH NEXT FROM SourceProcedureCursor INTO @ProcedureName; + + WHILE @@FETCH_STATUS = 0 + BEGIN + DECLARE @Definition nvarchar(max) = OBJECT_DEFINITION(OBJECT_ID(@ProcedureName)); + + IF @Definition IS NULL + THROW 50012, 'Expected course raw source consumer procedure was not found.', 1; + + DECLARE @ProcedureIndex int = CHARINDEX(N'PROCEDURE', UPPER(@Definition)); + IF @ProcedureIndex = 0 + THROW 50013, 'Expected course raw source consumer procedure did not contain a procedure definition.', 1; + + IF CHARINDEX(@OldTableName, @Definition) > 0 + BEGIN + SET @Definition = REPLACE(@Definition, @OldTableName, @NewTableName); + SET @Definition = STUFF(@Definition, 1, @ProcedureIndex - 1, N'CREATE OR ALTER '); + + EXEC sp_executesql @Definition; + END; + + FETCH NEXT FROM SourceProcedureCursor INTO @ProcedureName; + END; + + CLOSE SourceProcedureCursor; + DEALLOCATE SourceProcedureCursor; + """, + suppressTransaction: true + ); + + migrationBuilder.Sql( + """ + IF OBJECT_ID(N'[dbo].[CoursesRaw]', N'U') IS NOT NULL + AND NOT EXISTS + ( + SELECT 1 + FROM sys.indexes + WHERE object_id = OBJECT_ID(N'[dbo].[CoursesRaw]') + AND name = N'IX_CoursesRaw_AcademicTermCode_AcademicYear' + ) + BEGIN + CREATE NONCLUSTERED INDEX [IX_CoursesRaw_AcademicTermCode_AcademicYear] + ON [dbo].[CoursesRaw] ([AcademicTermCode], [AcademicYear]); + END; + """, + suppressTransaction: true + ); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.Sql( + """ + DECLARE @OldTableName nvarchar(128) = N'CoursesRaw'; + DECLARE @NewTableName nvarchar(128) = N'CourseOfferingsRaw'; + DECLARE @Procedures TABLE + ( + [ProcedureName] nvarchar(300) NOT NULL PRIMARY KEY + ); + + INSERT INTO @Procedures ([ProcedureName]) + VALUES + (N'[dbo].[usp_GetCourseRebuildAcademicYearSpanOptions]'), + (N'[dbo].[usp_RebuildCoursesFromProcessingWindow]'); + + DECLARE @ProcedureName nvarchar(300); + + DECLARE SourceProcedureCursor CURSOR LOCAL FAST_FORWARD FOR + SELECT [ProcedureName] + FROM @Procedures; + + OPEN SourceProcedureCursor; + FETCH NEXT FROM SourceProcedureCursor INTO @ProcedureName; + + WHILE @@FETCH_STATUS = 0 + BEGIN + DECLARE @Definition nvarchar(max) = OBJECT_DEFINITION(OBJECT_ID(@ProcedureName)); + + IF @Definition IS NULL + THROW 50012, 'Expected course raw source consumer procedure was not found.', 1; + + DECLARE @ProcedureIndex int = CHARINDEX(N'PROCEDURE', UPPER(@Definition)); + IF @ProcedureIndex = 0 + THROW 50013, 'Expected course raw source consumer procedure did not contain a procedure definition.', 1; + + IF CHARINDEX(@OldTableName, @Definition) > 0 + BEGIN + SET @Definition = REPLACE(@Definition, @OldTableName, @NewTableName); + SET @Definition = STUFF(@Definition, 1, @ProcedureIndex - 1, N'CREATE OR ALTER '); + + EXEC sp_executesql @Definition; + END; + + FETCH NEXT FROM SourceProcedureCursor INTO @ProcedureName; + END; + + CLOSE SourceProcedureCursor; + DEALLOCATE SourceProcedureCursor; + """, + suppressTransaction: true + ); + + migrationBuilder.Sql( + """ + IF OBJECT_ID(N'[dbo].[CoursesRaw]', N'U') IS NOT NULL + AND EXISTS + ( + SELECT 1 + FROM sys.indexes + WHERE object_id = OBJECT_ID(N'[dbo].[CoursesRaw]') + AND name = N'IX_CoursesRaw_AcademicTermCode_AcademicYear' + ) + BEGIN + DROP INDEX [IX_CoursesRaw_AcademicTermCode_AcademicYear] + ON [dbo].[CoursesRaw]; + END; + """, + suppressTransaction: true + ); + } + } +} diff --git a/src/tacos.core/createMigration b/src/tacos.core/createMigration new file mode 100755 index 0000000..6e04623 --- /dev/null +++ b/src/tacos.core/createMigration @@ -0,0 +1,17 @@ +#!/usr/bin/env bash +set -euo pipefail + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +if [[ $# -lt 1 ]]; then + echo "Usage: ./createMigration [dotnet ef args...]" + exit 1 +fi + +migration_name="$1" +shift + +dotnet ef migrations add "$migration_name" \ + --project "$script_dir/tacos.core.csproj" \ + --startup-project "$script_dir/../tacos.mvc/tacos.mvc.csproj" \ + "$@" diff --git a/src/tacos.core/updateDbFromMigrations b/src/tacos.core/updateDbFromMigrations new file mode 100755 index 0000000..eab857e --- /dev/null +++ b/src/tacos.core/updateDbFromMigrations @@ -0,0 +1,10 @@ +#!/usr/bin/env bash +set -euo pipefail + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +export Database__CommandTimeoutSeconds="${Database__CommandTimeoutSeconds:-180}" + +dotnet ef database update "$@" \ + --project "$script_dir/tacos.core.csproj" \ + --startup-project "$script_dir/../tacos.mvc/tacos.mvc.csproj" diff --git a/src/tacos.mvc/Startup.cs b/src/tacos.mvc/Startup.cs index 01ab93e..a49d561 100644 --- a/src/tacos.mvc/Startup.cs +++ b/src/tacos.mvc/Startup.cs @@ -37,8 +37,18 @@ public void ConfigureServices(IServiceCollection services) services.Configure(Configuration.GetSection("Sparkpost")); // setup entity framework - services.AddDbContextPool(o => - o.UseSqlServer(Configuration.GetConnectionString("DefaultConnection"))); + var databaseCommandTimeoutSeconds = Configuration.GetValue("Database:CommandTimeoutSeconds"); + + services.AddDbContextPool(o => + o.UseSqlServer( + Configuration.GetConnectionString("DefaultConnection"), + sqlOptions => + { + if (databaseCommandTimeoutSeconds.HasValue) + { + sqlOptions.CommandTimeout(databaseCommandTimeoutSeconds.Value); + } + })); services.AddIdentity() .AddEntityFrameworkStores() diff --git a/src/tacos.sql/dbo/Tables/CoursesRaw.sql b/src/tacos.sql/dbo/Tables/CoursesRaw.sql new file mode 100644 index 0000000..799bf24 --- /dev/null +++ b/src/tacos.sql/dbo/Tables/CoursesRaw.sql @@ -0,0 +1,19 @@ +CREATE TABLE [dbo].[CoursesRaw] ( + [AcademicYear] [nvarchar](7) NULL, + [AcademicTermCode] [nvarchar](6) NULL, + [CollegeCode] [nvarchar](2) NULL, + [College] [nvarchar](100) NULL, + [DeptCode] [nvarchar](4) NULL, + [DeptName] [nvarchar](100) NULL, + [SubjectCode] [nvarchar](4) NULL, + [CourseNumber] [nvarchar](7) NULL, + [CourseName] [nvarchar](255) NULL, + [Enrollment] [int] NULL, + [NumCreditSections] [int] NULL, + [NumNonCreditSections] [int] NULL +); + +GO + +CREATE NONCLUSTERED INDEX [IX_CoursesRaw_AcademicTermCode_AcademicYear] + ON [dbo].[CoursesRaw] ([AcademicTermCode], [AcademicYear]); diff --git a/src/tacos.sql/tacos.sql.sqlproj b/src/tacos.sql/tacos.sql.sqlproj index 3775d6a..8d0a1d7 100644 --- a/src/tacos.sql/tacos.sql.sqlproj +++ b/src/tacos.sql/tacos.sql.sqlproj @@ -74,6 +74,7 @@ + @@ -82,4 +83,4 @@ - \ No newline at end of file + From 85482acb688e144080dbeffa0ef3c9df6ab4bf97 Mon Sep 17 00:00:00 2001 From: Scott Kirkland Date: Fri, 22 May 2026 15:54:04 -0700 Subject: [PATCH 2/8] new descriptions raw table --- ...n.sql => usp_LoadCourseDescriptionRaw.sql} | 6 +- ...260522155158_CreateCourseDescriptionRaw.cs | 106 ++++++++++++++++++ .../dbo/Tables/CourseDescriptionRaw.sql | 47 ++++++++ src/tacos.sql/tacos.sql.sqlproj | 1 + 4 files changed, 157 insertions(+), 3 deletions(-) rename datamart/stored-procedures/{usp_LoadCourseDescription.sql => usp_LoadCourseDescriptionRaw.sql} (97%) create mode 100644 src/tacos.core/Migrations/20260522155158_CreateCourseDescriptionRaw.cs create mode 100644 src/tacos.sql/dbo/Tables/CourseDescriptionRaw.sql diff --git a/datamart/stored-procedures/usp_LoadCourseDescription.sql b/datamart/stored-procedures/usp_LoadCourseDescriptionRaw.sql similarity index 97% rename from datamart/stored-procedures/usp_LoadCourseDescription.sql rename to datamart/stored-procedures/usp_LoadCourseDescriptionRaw.sql index 6b3dc10..367d7ae 100644 --- a/datamart/stored-procedures/usp_LoadCourseDescription.sql +++ b/datamart/stored-procedures/usp_LoadCourseDescriptionRaw.sql @@ -1,4 +1,4 @@ -CREATE PROCEDURE dbo.usp_LoadCourseDescription +CREATE PROCEDURE dbo.usp_LoadCourseDescriptionRaw AS BEGIN SET NOCOUNT ON; @@ -6,9 +6,9 @@ BEGIN BEGIN TRANSACTION; - TRUNCATE TABLE dbo.CourseDescription; + TRUNCATE TABLE dbo.CourseDescriptionRaw; - INSERT INTO dbo.CourseDescription + INSERT INTO dbo.CourseDescriptionRaw ( Course, SubjectCode, diff --git a/src/tacos.core/Migrations/20260522155158_CreateCourseDescriptionRaw.cs b/src/tacos.core/Migrations/20260522155158_CreateCourseDescriptionRaw.cs new file mode 100644 index 0000000..a3927df --- /dev/null +++ b/src/tacos.core/Migrations/20260522155158_CreateCourseDescriptionRaw.cs @@ -0,0 +1,106 @@ +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace tacos.core.Migrations +{ + /// + [DbContext(typeof(TacoDbContext))] + [Migration("20260522155158_CreateCourseDescriptionRaw")] + public partial class CreateCourseDescriptionRaw : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.Sql( + """ + IF OBJECT_ID(N'[dbo].[CourseDescriptionRaw]', N'U') IS NULL + BEGIN + CREATE TABLE [dbo].[CourseDescriptionRaw] + ( + [Course] [nvarchar](20) NULL, + [SubjectCode] [nvarchar](20) NULL, + [CourseNumber] [nvarchar](20) NULL, + [CrossListing] [nvarchar](200) NULL, + [Title] [nvarchar](255) NULL, + [AbbreviatedTitle] [nvarchar](100) NULL, + [CourseDescription] [nvarchar](max) NULL, + [College] [nvarchar](100) NULL, + [Department] [nvarchar](200) NULL, + [Status] [nvarchar](50) NULL, + [CreatedOn] [datetime2](6) NULL, + [UpdatedOn] [datetime2](6) NULL, + [FirstLearningActivity] [nvarchar](100) NULL, + [FirstContactHoursPeriod] [nvarchar](50) NULL, + [SecondLearningActivity] [nvarchar](100) NULL, + [SecondContactHoursPeriod] [nvarchar](50) NULL, + [ThirdLearningActivity] [nvarchar](100) NULL, + [ThirdContactHoursPeriod] [nvarchar](50) NULL, + [FourthLearningActivity] [nvarchar](100) NULL, + [FourthContactHoursPeriod] [nvarchar](50) NULL, + [Ge2ArtsHumanities] [nvarchar](100) NULL, + [Ge2ScienceEngineering] [nvarchar](100) NULL, + [Ge2SocialSciences] [nvarchar](100) NULL, + [Ge2Diversity] [nvarchar](100) NULL, + [Ge2WritingExperience] [nvarchar](100) NULL, + [Ge3ArtsHumanities] [nvarchar](100) NULL, + [Ge3ScienceEngineering] [nvarchar](100) NULL, + [Ge3SocialSciences] [nvarchar](100) NULL, + [Ge3AmericanCultures] [nvarchar](100) NULL, + [Ge3DomesticDiversity] [nvarchar](100) NULL, + [Ge3OralLiteracy] [nvarchar](100) NULL, + [Ge3QuantitativeLiteracy] [nvarchar](100) NULL, + [Ge3ScientificLiteracy] [nvarchar](100) NULL, + [Ge3VisualLiteracy] [nvarchar](100) NULL, + [Ge3WorldCultures] [nvarchar](100) NULL, + [Ge3WritingExperience] [nvarchar](100) NULL, + [Quarters] [nvarchar](300) NULL, + [QuartersOffered] [nvarchar](100) NULL, + [EffectiveTerm] [nvarchar](6) NULL, + [Effective] [nvarchar](100) NULL + ); + END; + + IF OBJECT_ID(N'[dbo].[CourseDescriptionRaw]', N'U') IS NOT NULL + AND NOT EXISTS + ( + SELECT 1 + FROM sys.indexes + WHERE object_id = OBJECT_ID(N'[dbo].[CourseDescriptionRaw]') + AND name = N'IX_CourseDescriptionRaw_Course_Status' + ) + BEGIN + CREATE NONCLUSTERED INDEX [IX_CourseDescriptionRaw_Course_Status] + ON [dbo].[CourseDescriptionRaw] ([Course], [Status]); + END; + """, + suppressTransaction: true + ); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.Sql( + """ + IF OBJECT_ID(N'[dbo].[CourseDescriptionRaw]', N'U') IS NOT NULL + AND EXISTS + ( + SELECT 1 + FROM sys.indexes + WHERE object_id = OBJECT_ID(N'[dbo].[CourseDescriptionRaw]') + AND name = N'IX_CourseDescriptionRaw_Course_Status' + ) + BEGIN + DROP INDEX [IX_CourseDescriptionRaw_Course_Status] + ON [dbo].[CourseDescriptionRaw]; + END; + + DROP TABLE IF EXISTS [dbo].[CourseDescriptionRaw]; + """, + suppressTransaction: true + ); + } + } +} diff --git a/src/tacos.sql/dbo/Tables/CourseDescriptionRaw.sql b/src/tacos.sql/dbo/Tables/CourseDescriptionRaw.sql new file mode 100644 index 0000000..5da5a63 --- /dev/null +++ b/src/tacos.sql/dbo/Tables/CourseDescriptionRaw.sql @@ -0,0 +1,47 @@ +CREATE TABLE [dbo].[CourseDescriptionRaw] ( + [Course] [nvarchar](20) NULL, + [SubjectCode] [nvarchar](20) NULL, + [CourseNumber] [nvarchar](20) NULL, + [CrossListing] [nvarchar](200) NULL, + [Title] [nvarchar](255) NULL, + [AbbreviatedTitle] [nvarchar](100) NULL, + [CourseDescription] [nvarchar](max) NULL, + [College] [nvarchar](100) NULL, + [Department] [nvarchar](200) NULL, + [Status] [nvarchar](50) NULL, + [CreatedOn] [datetime2](6) NULL, + [UpdatedOn] [datetime2](6) NULL, + [FirstLearningActivity] [nvarchar](100) NULL, + [FirstContactHoursPeriod] [nvarchar](50) NULL, + [SecondLearningActivity] [nvarchar](100) NULL, + [SecondContactHoursPeriod] [nvarchar](50) NULL, + [ThirdLearningActivity] [nvarchar](100) NULL, + [ThirdContactHoursPeriod] [nvarchar](50) NULL, + [FourthLearningActivity] [nvarchar](100) NULL, + [FourthContactHoursPeriod] [nvarchar](50) NULL, + [Ge2ArtsHumanities] [nvarchar](100) NULL, + [Ge2ScienceEngineering] [nvarchar](100) NULL, + [Ge2SocialSciences] [nvarchar](100) NULL, + [Ge2Diversity] [nvarchar](100) NULL, + [Ge2WritingExperience] [nvarchar](100) NULL, + [Ge3ArtsHumanities] [nvarchar](100) NULL, + [Ge3ScienceEngineering] [nvarchar](100) NULL, + [Ge3SocialSciences] [nvarchar](100) NULL, + [Ge3AmericanCultures] [nvarchar](100) NULL, + [Ge3DomesticDiversity] [nvarchar](100) NULL, + [Ge3OralLiteracy] [nvarchar](100) NULL, + [Ge3QuantitativeLiteracy] [nvarchar](100) NULL, + [Ge3ScientificLiteracy] [nvarchar](100) NULL, + [Ge3VisualLiteracy] [nvarchar](100) NULL, + [Ge3WorldCultures] [nvarchar](100) NULL, + [Ge3WritingExperience] [nvarchar](100) NULL, + [Quarters] [nvarchar](300) NULL, + [QuartersOffered] [nvarchar](100) NULL, + [EffectiveTerm] [nvarchar](6) NULL, + [Effective] [nvarchar](100) NULL +); + +GO + +CREATE NONCLUSTERED INDEX [IX_CourseDescriptionRaw_Course_Status] + ON [dbo].[CourseDescriptionRaw] ([Course], [Status]); diff --git a/src/tacos.sql/tacos.sql.sqlproj b/src/tacos.sql/tacos.sql.sqlproj index 8d0a1d7..da08df7 100644 --- a/src/tacos.sql/tacos.sql.sqlproj +++ b/src/tacos.sql/tacos.sql.sqlproj @@ -74,6 +74,7 @@ + From bc00a4161eb83032c6e748ea4b980e3ecb5bd55c Mon Sep 17 00:00:00 2001 From: Scott Kirkland Date: Tue, 26 May 2026 11:05:09 -0700 Subject: [PATCH 3/8] Align CourseDescription with raw schema --- src/tacos.core/Data/CourseDescription.cs | 209 +++++++++++++++++- ...522162307_AlignCourseDescriptionWithRaw.cs | 193 ++++++++++++++++ ...2162926_ReorderCourseDescriptionLikeRaw.cs | 170 ++++++++++++++ .../Migrations/TacoDbContextModelSnapshot.cs | 146 ++++++++++-- .../dbo/Tables/CourseDescription.sql | 44 +++- 5 files changed, 723 insertions(+), 39 deletions(-) create mode 100644 src/tacos.core/Migrations/20260522162307_AlignCourseDescriptionWithRaw.cs create mode 100644 src/tacos.core/Migrations/20260522162926_ReorderCourseDescriptionLikeRaw.cs diff --git a/src/tacos.core/Data/CourseDescription.cs b/src/tacos.core/Data/CourseDescription.cs index 16d5e21..7f6eb82 100644 --- a/src/tacos.core/Data/CourseDescription.cs +++ b/src/tacos.core/Data/CourseDescription.cs @@ -1,4 +1,4 @@ -using System.ComponentModel.DataAnnotations; +using System; using System.ComponentModel.DataAnnotations.Schema; using Microsoft.EntityFrameworkCore; @@ -6,7 +6,6 @@ namespace tacos.core.Data { public class CourseDescription { - [Key] public string Course { get; set; } public string SubjectCode { get; set; } @@ -28,18 +27,58 @@ public class CourseDescription public string Status { get; set; } - public string CreatedOn { get; set; } + public DateTime? CreatedOn { get; set; } - public string UpdatedOn { get; set; } + public DateTime? UpdatedOn { get; set; } public string FirstLearningActivity { get; set; } + public string FirstContactHoursPeriod { get; set; } + public string SecondLearningActivity { get; set; } + public string SecondContactHoursPeriod { get; set; } + public string ThirdLearningActivity { get; set; } + public string ThirdContactHoursPeriod { get; set; } + public string FourthLearningActivity { get; set; } + public string FourthContactHoursPeriod { get; set; } + + public string Ge2ArtsHumanities { get; set; } + + public string Ge2ScienceEngineering { get; set; } + + public string Ge2SocialSciences { get; set; } + + public string Ge2Diversity { get; set; } + + public string Ge2WritingExperience { get; set; } + + public string Ge3ArtsHumanities { get; set; } + + public string Ge3ScienceEngineering { get; set; } + + public string Ge3SocialSciences { get; set; } + + public string Ge3AmericanCultures { get; set; } + + public string Ge3DomesticDiversity { get; set; } + + public string Ge3OralLiteracy { get; set; } + + public string Ge3QuantitativeLiteracy { get; set; } + + public string Ge3ScientificLiteracy { get; set; } + + public string Ge3VisualLiteracy { get; set; } + + public string Ge3WorldCultures { get; set; } + + public string Ge3WritingExperience { get; set; } + public string Quarters { get; set; } public string QuartersOffered { get; set; } @@ -51,7 +90,167 @@ public class CourseDescription public static void OnModelCreating(ModelBuilder builder) { builder.Entity() - .ToTable("CourseDescription"); + .ToTable("CourseDescription") + .HasKey(x => new { x.SubjectCode, x.CourseNumber, x.Status }); + + builder.Entity() + .Property(x => x.Course) + .HasMaxLength(20); + + builder.Entity() + .Property(x => x.SubjectCode) + .HasMaxLength(20) + .IsRequired(); + + builder.Entity() + .Property(x => x.CourseNumber) + .HasMaxLength(20) + .IsRequired(); + + builder.Entity() + .Property(x => x.CrossListing) + .HasMaxLength(200); + + builder.Entity() + .Property(x => x.Title) + .HasMaxLength(255); + + builder.Entity() + .Property(x => x.AbbreviatedTitle) + .HasMaxLength(100); + + builder.Entity() + .Property(x => x.College) + .HasMaxLength(100); + + builder.Entity() + .Property(x => x.Department) + .HasMaxLength(200); + + builder.Entity() + .Property(x => x.Status) + .HasMaxLength(50) + .IsRequired(); + + builder.Entity() + .Property(x => x.CreatedOn) + .HasPrecision(6); + + builder.Entity() + .Property(x => x.UpdatedOn) + .HasPrecision(6); + + builder.Entity() + .Property(x => x.FirstLearningActivity) + .HasMaxLength(100); + + builder.Entity() + .Property(x => x.FirstContactHoursPeriod) + .HasMaxLength(50); + + builder.Entity() + .Property(x => x.SecondLearningActivity) + .HasMaxLength(100); + + builder.Entity() + .Property(x => x.SecondContactHoursPeriod) + .HasMaxLength(50); + + builder.Entity() + .Property(x => x.ThirdLearningActivity) + .HasMaxLength(100); + + builder.Entity() + .Property(x => x.ThirdContactHoursPeriod) + .HasMaxLength(50); + + builder.Entity() + .Property(x => x.FourthLearningActivity) + .HasMaxLength(100); + + builder.Entity() + .Property(x => x.FourthContactHoursPeriod) + .HasMaxLength(50); + + builder.Entity() + .Property(x => x.Ge2ArtsHumanities) + .HasMaxLength(100); + + builder.Entity() + .Property(x => x.Ge2ScienceEngineering) + .HasMaxLength(100); + + builder.Entity() + .Property(x => x.Ge2SocialSciences) + .HasMaxLength(100); + + builder.Entity() + .Property(x => x.Ge2Diversity) + .HasMaxLength(100); + + builder.Entity() + .Property(x => x.Ge2WritingExperience) + .HasMaxLength(100); + + builder.Entity() + .Property(x => x.Ge3ArtsHumanities) + .HasMaxLength(100); + + builder.Entity() + .Property(x => x.Ge3ScienceEngineering) + .HasMaxLength(100); + + builder.Entity() + .Property(x => x.Ge3SocialSciences) + .HasMaxLength(100); + + builder.Entity() + .Property(x => x.Ge3AmericanCultures) + .HasMaxLength(100); + + builder.Entity() + .Property(x => x.Ge3DomesticDiversity) + .HasMaxLength(100); + + builder.Entity() + .Property(x => x.Ge3OralLiteracy) + .HasMaxLength(100); + + builder.Entity() + .Property(x => x.Ge3QuantitativeLiteracy) + .HasMaxLength(100); + + builder.Entity() + .Property(x => x.Ge3ScientificLiteracy) + .HasMaxLength(100); + + builder.Entity() + .Property(x => x.Ge3VisualLiteracy) + .HasMaxLength(100); + + builder.Entity() + .Property(x => x.Ge3WorldCultures) + .HasMaxLength(100); + + builder.Entity() + .Property(x => x.Ge3WritingExperience) + .HasMaxLength(100); + + builder.Entity() + .Property(x => x.Quarters) + .HasMaxLength(300); + + builder.Entity() + .Property(x => x.QuartersOffered) + .HasMaxLength(100); + + builder.Entity() + .Property(x => x.EffectiveTerm) + .HasMaxLength(6); + + builder.Entity() + .Property(x => x.Effective) + .HasMaxLength(100); } } } diff --git a/src/tacos.core/Migrations/20260522162307_AlignCourseDescriptionWithRaw.cs b/src/tacos.core/Migrations/20260522162307_AlignCourseDescriptionWithRaw.cs new file mode 100644 index 0000000..7ba8623 --- /dev/null +++ b/src/tacos.core/Migrations/20260522162307_AlignCourseDescriptionWithRaw.cs @@ -0,0 +1,193 @@ +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace tacos.core.Migrations +{ + /// + [DbContext(typeof(TacoDbContext))] + [Migration("20260522162307_AlignCourseDescriptionWithRaw")] + public partial class AlignCourseDescriptionWithRaw : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.Sql( + """ + IF COL_LENGTH(N'[dbo].[CourseDescription]', N'FirstContactHoursPeriod') IS NULL + ALTER TABLE [dbo].[CourseDescription] ADD [FirstContactHoursPeriod] [nvarchar](50) NULL; + IF COL_LENGTH(N'[dbo].[CourseDescription]', N'SecondContactHoursPeriod') IS NULL + ALTER TABLE [dbo].[CourseDescription] ADD [SecondContactHoursPeriod] [nvarchar](50) NULL; + IF COL_LENGTH(N'[dbo].[CourseDescription]', N'ThirdContactHoursPeriod') IS NULL + ALTER TABLE [dbo].[CourseDescription] ADD [ThirdContactHoursPeriod] [nvarchar](50) NULL; + IF COL_LENGTH(N'[dbo].[CourseDescription]', N'FourthContactHoursPeriod') IS NULL + ALTER TABLE [dbo].[CourseDescription] ADD [FourthContactHoursPeriod] [nvarchar](50) NULL; + IF COL_LENGTH(N'[dbo].[CourseDescription]', N'Ge2ArtsHumanities') IS NULL + ALTER TABLE [dbo].[CourseDescription] ADD [Ge2ArtsHumanities] [nvarchar](100) NULL; + IF COL_LENGTH(N'[dbo].[CourseDescription]', N'Ge2ScienceEngineering') IS NULL + ALTER TABLE [dbo].[CourseDescription] ADD [Ge2ScienceEngineering] [nvarchar](100) NULL; + IF COL_LENGTH(N'[dbo].[CourseDescription]', N'Ge2SocialSciences') IS NULL + ALTER TABLE [dbo].[CourseDescription] ADD [Ge2SocialSciences] [nvarchar](100) NULL; + IF COL_LENGTH(N'[dbo].[CourseDescription]', N'Ge2Diversity') IS NULL + ALTER TABLE [dbo].[CourseDescription] ADD [Ge2Diversity] [nvarchar](100) NULL; + IF COL_LENGTH(N'[dbo].[CourseDescription]', N'Ge2WritingExperience') IS NULL + ALTER TABLE [dbo].[CourseDescription] ADD [Ge2WritingExperience] [nvarchar](100) NULL; + IF COL_LENGTH(N'[dbo].[CourseDescription]', N'Ge3ArtsHumanities') IS NULL + ALTER TABLE [dbo].[CourseDescription] ADD [Ge3ArtsHumanities] [nvarchar](100) NULL; + IF COL_LENGTH(N'[dbo].[CourseDescription]', N'Ge3ScienceEngineering') IS NULL + ALTER TABLE [dbo].[CourseDescription] ADD [Ge3ScienceEngineering] [nvarchar](100) NULL; + IF COL_LENGTH(N'[dbo].[CourseDescription]', N'Ge3SocialSciences') IS NULL + ALTER TABLE [dbo].[CourseDescription] ADD [Ge3SocialSciences] [nvarchar](100) NULL; + IF COL_LENGTH(N'[dbo].[CourseDescription]', N'Ge3AmericanCultures') IS NULL + ALTER TABLE [dbo].[CourseDescription] ADD [Ge3AmericanCultures] [nvarchar](100) NULL; + IF COL_LENGTH(N'[dbo].[CourseDescription]', N'Ge3DomesticDiversity') IS NULL + ALTER TABLE [dbo].[CourseDescription] ADD [Ge3DomesticDiversity] [nvarchar](100) NULL; + IF COL_LENGTH(N'[dbo].[CourseDescription]', N'Ge3OralLiteracy') IS NULL + ALTER TABLE [dbo].[CourseDescription] ADD [Ge3OralLiteracy] [nvarchar](100) NULL; + IF COL_LENGTH(N'[dbo].[CourseDescription]', N'Ge3QuantitativeLiteracy') IS NULL + ALTER TABLE [dbo].[CourseDescription] ADD [Ge3QuantitativeLiteracy] [nvarchar](100) NULL; + IF COL_LENGTH(N'[dbo].[CourseDescription]', N'Ge3ScientificLiteracy') IS NULL + ALTER TABLE [dbo].[CourseDescription] ADD [Ge3ScientificLiteracy] [nvarchar](100) NULL; + IF COL_LENGTH(N'[dbo].[CourseDescription]', N'Ge3VisualLiteracy') IS NULL + ALTER TABLE [dbo].[CourseDescription] ADD [Ge3VisualLiteracy] [nvarchar](100) NULL; + IF COL_LENGTH(N'[dbo].[CourseDescription]', N'Ge3WorldCultures') IS NULL + ALTER TABLE [dbo].[CourseDescription] ADD [Ge3WorldCultures] [nvarchar](100) NULL; + IF COL_LENGTH(N'[dbo].[CourseDescription]', N'Ge3WritingExperience') IS NULL + ALTER TABLE [dbo].[CourseDescription] ADD [Ge3WritingExperience] [nvarchar](100) NULL; + """, + suppressTransaction: true + ); + + migrationBuilder.Sql( + """ + DECLARE @PrimaryKeyName sysname; + + SELECT @PrimaryKeyName = [name] + FROM sys.key_constraints + WHERE [parent_object_id] = OBJECT_ID(N'[dbo].[CourseDescription]') + AND [type] = N'PK'; + + IF @PrimaryKeyName IS NOT NULL + BEGIN + EXEC(N'ALTER TABLE [dbo].[CourseDescription] DROP CONSTRAINT [' + @PrimaryKeyName + N'];'); + END; + + ALTER TABLE [dbo].[CourseDescription] ALTER COLUMN [Course] [nvarchar](20) NULL; + ALTER TABLE [dbo].[CourseDescription] ALTER COLUMN [SubjectCode] [nvarchar](20) NOT NULL; + ALTER TABLE [dbo].[CourseDescription] ALTER COLUMN [CourseNumber] [nvarchar](20) NOT NULL; + ALTER TABLE [dbo].[CourseDescription] ALTER COLUMN [CrossListing] [nvarchar](200) NULL; + ALTER TABLE [dbo].[CourseDescription] ALTER COLUMN [Title] [nvarchar](255) NULL; + ALTER TABLE [dbo].[CourseDescription] ALTER COLUMN [CourseDescription] [nvarchar](max) NULL; + ALTER TABLE [dbo].[CourseDescription] ALTER COLUMN [Department] [nvarchar](200) NULL; + ALTER TABLE [dbo].[CourseDescription] ALTER COLUMN [QuartersOffered] [nvarchar](100) NULL; + ALTER TABLE [dbo].[CourseDescription] ALTER COLUMN [Effective] [nvarchar](100) NULL; + + IF @PrimaryKeyName IS NOT NULL + BEGIN + ALTER TABLE [dbo].[CourseDescription] + ADD CONSTRAINT [PK_CourseDescription] PRIMARY KEY CLUSTERED + ( + [SubjectCode] ASC, + [CourseNumber] ASC, + [Status] ASC + ); + END; + """, + suppressTransaction: true + ); + + migrationBuilder.Sql( + """ + IF COL_LENGTH(N'[dbo].[CourseDescription]', N'CreatedOnRaw') IS NULL + ALTER TABLE [dbo].[CourseDescription] ADD [CreatedOnRaw] [datetime2](6) NULL; + IF COL_LENGTH(N'[dbo].[CourseDescription]', N'UpdatedOnRaw') IS NULL + ALTER TABLE [dbo].[CourseDescription] ADD [UpdatedOnRaw] [datetime2](6) NULL; + """, + suppressTransaction: true + ); + + migrationBuilder.Sql( + """ + UPDATE [dbo].[CourseDescription] + SET + [CreatedOnRaw] = TRY_CONVERT(datetime2(6), [CreatedOn]), + [UpdatedOnRaw] = TRY_CONVERT(datetime2(6), [UpdatedOn]); + """, + suppressTransaction: true + ); + + migrationBuilder.Sql( + """ + ALTER TABLE [dbo].[CourseDescription] DROP COLUMN [CreatedOn]; + ALTER TABLE [dbo].[CourseDescription] DROP COLUMN [UpdatedOn]; + + EXEC sp_rename N'[dbo].[CourseDescription].[CreatedOnRaw]', N'CreatedOn', N'COLUMN'; + EXEC sp_rename N'[dbo].[CourseDescription].[UpdatedOnRaw]', N'UpdatedOn', N'COLUMN'; + """, + suppressTransaction: true + ); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.Sql( + """ + IF COL_LENGTH(N'[dbo].[CourseDescription]', N'CreatedOnText') IS NULL + ALTER TABLE [dbo].[CourseDescription] ADD [CreatedOnText] [nvarchar](50) NULL; + IF COL_LENGTH(N'[dbo].[CourseDescription]', N'UpdatedOnText') IS NULL + ALTER TABLE [dbo].[CourseDescription] ADD [UpdatedOnText] [nvarchar](50) NULL; + """, + suppressTransaction: true + ); + + migrationBuilder.Sql( + """ + UPDATE [dbo].[CourseDescription] + SET + [CreatedOnText] = CONVERT(nvarchar(50), [CreatedOn], 121), + [UpdatedOnText] = CONVERT(nvarchar(50), [UpdatedOn], 121); + """, + suppressTransaction: true + ); + + migrationBuilder.Sql( + """ + ALTER TABLE [dbo].[CourseDescription] DROP COLUMN [CreatedOn]; + ALTER TABLE [dbo].[CourseDescription] DROP COLUMN [UpdatedOn]; + + EXEC sp_rename N'[dbo].[CourseDescription].[CreatedOnText]', N'CreatedOn', N'COLUMN'; + EXEC sp_rename N'[dbo].[CourseDescription].[UpdatedOnText]', N'UpdatedOn', N'COLUMN'; + """, + suppressTransaction: true + ); + + migrationBuilder.Sql( + """ + ALTER TABLE [dbo].[CourseDescription] DROP COLUMN [FirstContactHoursPeriod]; + ALTER TABLE [dbo].[CourseDescription] DROP COLUMN [SecondContactHoursPeriod]; + ALTER TABLE [dbo].[CourseDescription] DROP COLUMN [ThirdContactHoursPeriod]; + ALTER TABLE [dbo].[CourseDescription] DROP COLUMN [FourthContactHoursPeriod]; + ALTER TABLE [dbo].[CourseDescription] DROP COLUMN [Ge2ArtsHumanities]; + ALTER TABLE [dbo].[CourseDescription] DROP COLUMN [Ge2ScienceEngineering]; + ALTER TABLE [dbo].[CourseDescription] DROP COLUMN [Ge2SocialSciences]; + ALTER TABLE [dbo].[CourseDescription] DROP COLUMN [Ge2Diversity]; + ALTER TABLE [dbo].[CourseDescription] DROP COLUMN [Ge2WritingExperience]; + ALTER TABLE [dbo].[CourseDescription] DROP COLUMN [Ge3ArtsHumanities]; + ALTER TABLE [dbo].[CourseDescription] DROP COLUMN [Ge3ScienceEngineering]; + ALTER TABLE [dbo].[CourseDescription] DROP COLUMN [Ge3SocialSciences]; + ALTER TABLE [dbo].[CourseDescription] DROP COLUMN [Ge3AmericanCultures]; + ALTER TABLE [dbo].[CourseDescription] DROP COLUMN [Ge3DomesticDiversity]; + ALTER TABLE [dbo].[CourseDescription] DROP COLUMN [Ge3OralLiteracy]; + ALTER TABLE [dbo].[CourseDescription] DROP COLUMN [Ge3QuantitativeLiteracy]; + ALTER TABLE [dbo].[CourseDescription] DROP COLUMN [Ge3ScientificLiteracy]; + ALTER TABLE [dbo].[CourseDescription] DROP COLUMN [Ge3VisualLiteracy]; + ALTER TABLE [dbo].[CourseDescription] DROP COLUMN [Ge3WorldCultures]; + ALTER TABLE [dbo].[CourseDescription] DROP COLUMN [Ge3WritingExperience]; + """, + suppressTransaction: true + ); + } + } +} diff --git a/src/tacos.core/Migrations/20260522162926_ReorderCourseDescriptionLikeRaw.cs b/src/tacos.core/Migrations/20260522162926_ReorderCourseDescriptionLikeRaw.cs new file mode 100644 index 0000000..640d621 --- /dev/null +++ b/src/tacos.core/Migrations/20260522162926_ReorderCourseDescriptionLikeRaw.cs @@ -0,0 +1,170 @@ +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace tacos.core.Migrations +{ + /// + [DbContext(typeof(TacoDbContext))] + [Migration("20260522162926_ReorderCourseDescriptionLikeRaw")] + public partial class ReorderCourseDescriptionLikeRaw : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.Sql( + """ + DROP TABLE IF EXISTS [dbo].[CourseDescription_Rebuild]; + + CREATE TABLE [dbo].[CourseDescription_Rebuild] + ( + [Course] [nvarchar](20) NULL, + [SubjectCode] [nvarchar](20) NOT NULL, + [CourseNumber] [nvarchar](20) NOT NULL, + [CrossListing] [nvarchar](200) NULL, + [Title] [nvarchar](255) NULL, + [AbbreviatedTitle] [nvarchar](100) NULL, + [CourseDescription] [nvarchar](max) NULL, + [College] [nvarchar](100) NULL, + [Department] [nvarchar](200) NULL, + [Status] [nvarchar](50) NOT NULL, + [CreatedOn] [datetime2](6) NULL, + [UpdatedOn] [datetime2](6) NULL, + [FirstLearningActivity] [nvarchar](100) NULL, + [FirstContactHoursPeriod] [nvarchar](50) NULL, + [SecondLearningActivity] [nvarchar](100) NULL, + [SecondContactHoursPeriod] [nvarchar](50) NULL, + [ThirdLearningActivity] [nvarchar](100) NULL, + [ThirdContactHoursPeriod] [nvarchar](50) NULL, + [FourthLearningActivity] [nvarchar](100) NULL, + [FourthContactHoursPeriod] [nvarchar](50) NULL, + [Ge2ArtsHumanities] [nvarchar](100) NULL, + [Ge2ScienceEngineering] [nvarchar](100) NULL, + [Ge2SocialSciences] [nvarchar](100) NULL, + [Ge2Diversity] [nvarchar](100) NULL, + [Ge2WritingExperience] [nvarchar](100) NULL, + [Ge3ArtsHumanities] [nvarchar](100) NULL, + [Ge3ScienceEngineering] [nvarchar](100) NULL, + [Ge3SocialSciences] [nvarchar](100) NULL, + [Ge3AmericanCultures] [nvarchar](100) NULL, + [Ge3DomesticDiversity] [nvarchar](100) NULL, + [Ge3OralLiteracy] [nvarchar](100) NULL, + [Ge3QuantitativeLiteracy] [nvarchar](100) NULL, + [Ge3ScientificLiteracy] [nvarchar](100) NULL, + [Ge3VisualLiteracy] [nvarchar](100) NULL, + [Ge3WorldCultures] [nvarchar](100) NULL, + [Ge3WritingExperience] [nvarchar](100) NULL, + [Quarters] [nvarchar](300) NULL, + [QuartersOffered] [nvarchar](100) NULL, + [EffectiveTerm] [nvarchar](6) NULL, + [Effective] [nvarchar](100) NULL + ); + + INSERT INTO [dbo].[CourseDescription_Rebuild] + ( + [Course], + [SubjectCode], + [CourseNumber], + [CrossListing], + [Title], + [AbbreviatedTitle], + [CourseDescription], + [College], + [Department], + [Status], + [CreatedOn], + [UpdatedOn], + [FirstLearningActivity], + [FirstContactHoursPeriod], + [SecondLearningActivity], + [SecondContactHoursPeriod], + [ThirdLearningActivity], + [ThirdContactHoursPeriod], + [FourthLearningActivity], + [FourthContactHoursPeriod], + [Ge2ArtsHumanities], + [Ge2ScienceEngineering], + [Ge2SocialSciences], + [Ge2Diversity], + [Ge2WritingExperience], + [Ge3ArtsHumanities], + [Ge3ScienceEngineering], + [Ge3SocialSciences], + [Ge3AmericanCultures], + [Ge3DomesticDiversity], + [Ge3OralLiteracy], + [Ge3QuantitativeLiteracy], + [Ge3ScientificLiteracy], + [Ge3VisualLiteracy], + [Ge3WorldCultures], + [Ge3WritingExperience], + [Quarters], + [QuartersOffered], + [EffectiveTerm], + [Effective] + ) + SELECT + [Course], + [SubjectCode], + [CourseNumber], + [CrossListing], + [Title], + [AbbreviatedTitle], + [CourseDescription], + [College], + [Department], + [Status], + [CreatedOn], + [UpdatedOn], + [FirstLearningActivity], + [FirstContactHoursPeriod], + [SecondLearningActivity], + [SecondContactHoursPeriod], + [ThirdLearningActivity], + [ThirdContactHoursPeriod], + [FourthLearningActivity], + [FourthContactHoursPeriod], + [Ge2ArtsHumanities], + [Ge2ScienceEngineering], + [Ge2SocialSciences], + [Ge2Diversity], + [Ge2WritingExperience], + [Ge3ArtsHumanities], + [Ge3ScienceEngineering], + [Ge3SocialSciences], + [Ge3AmericanCultures], + [Ge3DomesticDiversity], + [Ge3OralLiteracy], + [Ge3QuantitativeLiteracy], + [Ge3ScientificLiteracy], + [Ge3VisualLiteracy], + [Ge3WorldCultures], + [Ge3WritingExperience], + [Quarters], + [QuartersOffered], + [EffectiveTerm], + [Effective] + FROM [dbo].[CourseDescription]; + + DROP TABLE [dbo].[CourseDescription]; + EXEC sp_rename N'[dbo].[CourseDescription_Rebuild]', N'CourseDescription'; + + ALTER TABLE [dbo].[CourseDescription] + ADD CONSTRAINT [PK_CourseDescription] PRIMARY KEY CLUSTERED + ( + [SubjectCode] ASC, + [CourseNumber] ASC, + [Status] ASC + ); + """, + suppressTransaction: true + ); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + } + } +} diff --git a/src/tacos.core/Migrations/TacoDbContextModelSnapshot.cs b/src/tacos.core/Migrations/TacoDbContextModelSnapshot.cs index 0c88dd7..cf50510 100644 --- a/src/tacos.core/Migrations/TacoDbContextModelSnapshot.cs +++ b/src/tacos.core/Migrations/TacoDbContextModelSnapshot.cs @@ -201,67 +201,169 @@ protected override void BuildModel(ModelBuilder modelBuilder) modelBuilder.Entity("tacos.core.Data.CourseDescription", b => { b.Property("Course") - .HasColumnType("nvarchar(450)"); + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); b.Property("AbbreviatedTitle") - .HasColumnType("nvarchar(max)"); + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); b.Property("College") - .HasColumnType("nvarchar(max)"); + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); b.Property("CourseNumber") - .HasColumnType("nvarchar(max)"); + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); - b.Property("CreatedOn") - .HasColumnType("nvarchar(max)"); + b.Property("CreatedOn") + .HasPrecision(6) + .HasColumnType("datetime2(6)"); b.Property("CrossListing") - .HasColumnType("nvarchar(max)"); + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); b.Property("Department") - .HasColumnType("nvarchar(max)"); + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); b.Property("Description") .HasColumnType("nvarchar(max)") .HasColumnName("CourseDescription"); b.Property("Effective") - .HasColumnType("nvarchar(max)"); + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); b.Property("EffectiveTerm") - .HasColumnType("nvarchar(max)"); + .HasMaxLength(6) + .HasColumnType("nvarchar(6)"); b.Property("FirstLearningActivity") - .HasColumnType("nvarchar(max)"); + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("FirstContactHoursPeriod") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); b.Property("FourthLearningActivity") - .HasColumnType("nvarchar(max)"); + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("FourthContactHoursPeriod") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("Ge2ArtsHumanities") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Ge2Diversity") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Ge2ScienceEngineering") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Ge2SocialSciences") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Ge2WritingExperience") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Ge3AmericanCultures") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Ge3ArtsHumanities") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Ge3DomesticDiversity") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Ge3OralLiteracy") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Ge3QuantitativeLiteracy") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Ge3ScienceEngineering") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Ge3ScientificLiteracy") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Ge3SocialSciences") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Ge3VisualLiteracy") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Ge3WorldCultures") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Ge3WritingExperience") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); b.Property("Quarters") - .HasColumnType("nvarchar(max)"); + .HasMaxLength(300) + .HasColumnType("nvarchar(300)"); b.Property("QuartersOffered") - .HasColumnType("nvarchar(max)"); + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); b.Property("SecondLearningActivity") - .HasColumnType("nvarchar(max)"); + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("SecondContactHoursPeriod") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); b.Property("Status") - .HasColumnType("nvarchar(max)"); + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); b.Property("SubjectCode") - .HasColumnType("nvarchar(max)"); + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); b.Property("ThirdLearningActivity") - .HasColumnType("nvarchar(max)"); + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("ThirdContactHoursPeriod") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); b.Property("Title") - .HasColumnType("nvarchar(max)"); + .HasMaxLength(255) + .HasColumnType("nvarchar(255)"); - b.Property("UpdatedOn") - .HasColumnType("nvarchar(max)"); + b.Property("UpdatedOn") + .HasPrecision(6) + .HasColumnType("datetime2(6)"); - b.HasKey("Course"); + b.HasKey("SubjectCode", "CourseNumber", "Status"); b.ToTable("CourseDescription", (string)null); }); diff --git a/src/tacos.sql/dbo/Tables/CourseDescription.sql b/src/tacos.sql/dbo/Tables/CourseDescription.sql index c573bf5..8f842ee 100644 --- a/src/tacos.sql/dbo/Tables/CourseDescription.sql +++ b/src/tacos.sql/dbo/Tables/CourseDescription.sql @@ -1,28 +1,48 @@ CREATE TABLE [dbo].[CourseDescription]( - [Course] [nvarchar](10) NULL, - [SubjectCode] [nvarchar](3) NOT NULL, - [CourseNumber] [nvarchar](5) NOT NULL, - [CrossListing] [nvarchar](50) NULL, - [Title] [nvarchar](200) NULL, + [Course] [nvarchar](20) NULL, + [SubjectCode] [nvarchar](20) NOT NULL, + [CourseNumber] [nvarchar](20) NOT NULL, + [CrossListing] [nvarchar](200) NULL, + [Title] [nvarchar](255) NULL, [AbbreviatedTitle] [nvarchar](100) NULL, - [CourseDescription] [nvarchar](2000) NULL, + [CourseDescription] [nvarchar](max) NULL, [College] [nvarchar](100) NULL, - [Department] [nvarchar](100) NULL, + [Department] [nvarchar](200) NULL, [Status] [nvarchar](50) NOT NULL, - [CreatedOn] [nvarchar](50) NULL, - [UpdatedOn] [nvarchar](50) NULL, + [CreatedOn] [datetime2](6) NULL, + [UpdatedOn] [datetime2](6) NULL, [FirstLearningActivity] [nvarchar](100) NULL, + [FirstContactHoursPeriod] [nvarchar](50) NULL, [SecondLearningActivity] [nvarchar](100) NULL, + [SecondContactHoursPeriod] [nvarchar](50) NULL, [ThirdLearningActivity] [nvarchar](100) NULL, + [ThirdContactHoursPeriod] [nvarchar](50) NULL, [FourthLearningActivity] [nvarchar](100) NULL, + [FourthContactHoursPeriod] [nvarchar](50) NULL, + [Ge2ArtsHumanities] [nvarchar](100) NULL, + [Ge2ScienceEngineering] [nvarchar](100) NULL, + [Ge2SocialSciences] [nvarchar](100) NULL, + [Ge2Diversity] [nvarchar](100) NULL, + [Ge2WritingExperience] [nvarchar](100) NULL, + [Ge3ArtsHumanities] [nvarchar](100) NULL, + [Ge3ScienceEngineering] [nvarchar](100) NULL, + [Ge3SocialSciences] [nvarchar](100) NULL, + [Ge3AmericanCultures] [nvarchar](100) NULL, + [Ge3DomesticDiversity] [nvarchar](100) NULL, + [Ge3OralLiteracy] [nvarchar](100) NULL, + [Ge3QuantitativeLiteracy] [nvarchar](100) NULL, + [Ge3ScientificLiteracy] [nvarchar](100) NULL, + [Ge3VisualLiteracy] [nvarchar](100) NULL, + [Ge3WorldCultures] [nvarchar](100) NULL, + [Ge3WritingExperience] [nvarchar](100) NULL, [Quarters] [nvarchar](300) NULL, - [QuartersOffered] [nvarchar](50) NULL, + [QuartersOffered] [nvarchar](100) NULL, [EffectiveTerm] [nvarchar](6) NULL, - [Effective] [nvarchar](50) NULL, + [Effective] [nvarchar](100) NULL, CONSTRAINT [PK_CourseDescription] PRIMARY KEY CLUSTERED ( [SubjectCode] ASC, [CourseNumber] ASC, [Status] ASC )WITH (STATISTICS_NORECOMPUTE = OFF, IGNORE_DUP_KEY = OFF) ON [PRIMARY] -) ON [PRIMARY] \ No newline at end of file +) ON [PRIMARY] From ef46739e6e6ca2ba09ea309c69d8f74c8c046049 Mon Sep 17 00:00:00 2001 From: Scott Kirkland Date: Tue, 26 May 2026 11:13:53 -0700 Subject: [PATCH 4/8] Sync course descriptions during rebuild --- Test/Services/CourseRebuildServiceTests.cs | 17 +++ ...placeCourseDescriptionsFromRawProcedure.cs | 132 ++++++++++++++++++ .../Services/CourseRebuildService.cs | 30 ++++ 3 files changed, 179 insertions(+) create mode 100644 src/tacos.core/Migrations/20260526111118_ReplaceCourseDescriptionsFromRawProcedure.cs diff --git a/Test/Services/CourseRebuildServiceTests.cs b/Test/Services/CourseRebuildServiceTests.cs index 8076328..142fbbf 100644 --- a/Test/Services/CourseRebuildServiceTests.cs +++ b/Test/Services/CourseRebuildServiceTests.cs @@ -58,6 +58,7 @@ public async Task RebuildCourses_should_validate_and_execute_with_ordered_proces "202503" }); + gateway.Operations.ShouldBe(new[] { "ReplaceCourseDescriptionsFromRaw", "RebuildCourses" }); gateway.RebuildCalls.Count.ShouldBe(1); gateway.RebuildCalls[0].ShouldBe(new[] { @@ -92,6 +93,7 @@ await Should.ThrowAsync(() => service.RebuildC })); gateway.RebuildCalls.ShouldBeEmpty(); + gateway.ReplaceCourseDescriptionCalls.ShouldBe(0); } [Fact] @@ -111,6 +113,7 @@ await Should.ThrowAsync(() => service.RebuildC })); gateway.RebuildCalls.ShouldBeEmpty(); + gateway.ReplaceCourseDescriptionCalls.ShouldBe(0); } [Fact] @@ -133,6 +136,7 @@ await Should.ThrowAsync(() => service.RebuildC })); gateway.RebuildCalls.ShouldBeEmpty(); + gateway.ReplaceCourseDescriptionCalls.ShouldBe(0); } [Fact] @@ -155,6 +159,7 @@ await Should.ThrowAsync(() => service.RebuildC })); gateway.RebuildCalls.ShouldBeEmpty(); + gateway.ReplaceCourseDescriptionCalls.ShouldBe(0); } private static IEnumerable CreateSpanRows( @@ -194,6 +199,10 @@ private class FakeCourseRebuildSqlGateway : ICourseRebuildSqlGateway public IReadOnlyList Rows { get; set; } = new List(); + public IList Operations { get; } = new List(); + + public int ReplaceCourseDescriptionCalls { get; private set; } + public IList> RebuildCalls { get; } = new List>(); public Task> GetAcademicYearSpanTermRowsAsync() @@ -201,8 +210,16 @@ public Task> GetAcademicYear return Task.FromResult(Rows); } + public Task ReplaceCourseDescriptionsFromRawAsync() + { + Operations.Add("ReplaceCourseDescriptionsFromRaw"); + ReplaceCourseDescriptionCalls++; + return Task.CompletedTask; + } + public Task RebuildCoursesAsync(IReadOnlyList academicTermCodes) { + Operations.Add("RebuildCourses"); RebuildCalls.Add(academicTermCodes.ToList()); return Task.CompletedTask; } diff --git a/src/tacos.core/Migrations/20260526111118_ReplaceCourseDescriptionsFromRawProcedure.cs b/src/tacos.core/Migrations/20260526111118_ReplaceCourseDescriptionsFromRawProcedure.cs new file mode 100644 index 0000000..a596373 --- /dev/null +++ b/src/tacos.core/Migrations/20260526111118_ReplaceCourseDescriptionsFromRawProcedure.cs @@ -0,0 +1,132 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace tacos.core.Migrations +{ + /// + public partial class ReplaceCourseDescriptionsFromRawProcedure : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.Sql( + """ + CREATE OR ALTER PROCEDURE [dbo].[usp_ReplaceCourseDescriptionsFromRaw] + AS + BEGIN + SET NOCOUNT ON; + SET XACT_ABORT ON; + + IF OBJECT_ID(N'[dbo].[CourseDescriptionRaw]', N'U') IS NULL + BEGIN + THROW 50012, 'CourseDescriptionRaw source table is required before course descriptions can be replaced.', 1; + END; + + BEGIN TRANSACTION; + + DELETE FROM [dbo].[CourseDescription]; + + INSERT INTO [dbo].[CourseDescription] + ( + [Course], + [SubjectCode], + [CourseNumber], + [CrossListing], + [Title], + [AbbreviatedTitle], + [CourseDescription], + [College], + [Department], + [Status], + [CreatedOn], + [UpdatedOn], + [FirstLearningActivity], + [FirstContactHoursPeriod], + [SecondLearningActivity], + [SecondContactHoursPeriod], + [ThirdLearningActivity], + [ThirdContactHoursPeriod], + [FourthLearningActivity], + [FourthContactHoursPeriod], + [Ge2ArtsHumanities], + [Ge2ScienceEngineering], + [Ge2SocialSciences], + [Ge2Diversity], + [Ge2WritingExperience], + [Ge3ArtsHumanities], + [Ge3ScienceEngineering], + [Ge3SocialSciences], + [Ge3AmericanCultures], + [Ge3DomesticDiversity], + [Ge3OralLiteracy], + [Ge3QuantitativeLiteracy], + [Ge3ScientificLiteracy], + [Ge3VisualLiteracy], + [Ge3WorldCultures], + [Ge3WritingExperience], + [Quarters], + [QuartersOffered], + [EffectiveTerm], + [Effective] + ) + SELECT + [Course], + [SubjectCode], + [CourseNumber], + [CrossListing], + [Title], + [AbbreviatedTitle], + [CourseDescription], + [College], + [Department], + [Status], + [CreatedOn], + [UpdatedOn], + [FirstLearningActivity], + [FirstContactHoursPeriod], + [SecondLearningActivity], + [SecondContactHoursPeriod], + [ThirdLearningActivity], + [ThirdContactHoursPeriod], + [FourthLearningActivity], + [FourthContactHoursPeriod], + [Ge2ArtsHumanities], + [Ge2ScienceEngineering], + [Ge2SocialSciences], + [Ge2Diversity], + [Ge2WritingExperience], + [Ge3ArtsHumanities], + [Ge3ScienceEngineering], + [Ge3SocialSciences], + [Ge3AmericanCultures], + [Ge3DomesticDiversity], + [Ge3OralLiteracy], + [Ge3QuantitativeLiteracy], + [Ge3ScientificLiteracy], + [Ge3VisualLiteracy], + [Ge3WorldCultures], + [Ge3WritingExperience], + [Quarters], + [QuartersOffered], + [EffectiveTerm], + [Effective] + FROM [dbo].[CourseDescriptionRaw]; + + COMMIT TRANSACTION; + END; + """, + suppressTransaction: true + ); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.Sql( + "DROP PROCEDURE IF EXISTS [dbo].[usp_ReplaceCourseDescriptionsFromRaw];", + suppressTransaction: true + ); + } + } +} diff --git a/src/tacos.mvc/Services/CourseRebuildService.cs b/src/tacos.mvc/Services/CourseRebuildService.cs index 205a7b3..65895cf 100644 --- a/src/tacos.mvc/Services/CourseRebuildService.cs +++ b/src/tacos.mvc/Services/CourseRebuildService.cs @@ -30,6 +30,8 @@ public interface ICourseRebuildSqlGateway { Task> GetAcademicYearSpanTermRowsAsync(); + Task ReplaceCourseDescriptionsFromRawAsync(); + Task RebuildCoursesAsync(IReadOnlyList academicTermCodes); } @@ -95,6 +97,7 @@ public async Task RebuildCoursesAsync(IEnumerable academicTermCodes) } } + public async Task ReplaceCourseDescriptionsFromRawAsync() + { + var connection = GetSqlConnection(); + var shouldCloseConnection = connection.State != ConnectionState.Open; + + if (shouldCloseConnection) + { + await connection.OpenAsync(); + } + + try + { + using var command = connection.CreateCommand(); + command.CommandText = "dbo.usp_ReplaceCourseDescriptionsFromRaw"; + command.CommandType = CommandType.StoredProcedure; + + await command.ExecuteNonQueryAsync(); + } + finally + { + if (shouldCloseConnection) + { + await connection.CloseAsync(); + } + } + } + private SqlConnection GetSqlConnection() { if (_dbContext.Database.GetDbConnection() is SqlConnection connection) From e8bc55fbe9db0fefd127677ca303bc3275f147ec Mon Sep 17 00:00:00 2001 From: Scott Kirkland Date: Tue, 26 May 2026 11:20:16 -0700 Subject: [PATCH 5/8] drop unused tble --- ...placeCourseDescriptionsFromRawProcedure.cs | 3 +++ .../20260526111811_DropCourseOfferingsRaw.cs | 27 +++++++++++++++++++ 2 files changed, 30 insertions(+) create mode 100644 src/tacos.core/Migrations/20260526111811_DropCourseOfferingsRaw.cs diff --git a/src/tacos.core/Migrations/20260526111118_ReplaceCourseDescriptionsFromRawProcedure.cs b/src/tacos.core/Migrations/20260526111118_ReplaceCourseDescriptionsFromRawProcedure.cs index a596373..424971d 100644 --- a/src/tacos.core/Migrations/20260526111118_ReplaceCourseDescriptionsFromRawProcedure.cs +++ b/src/tacos.core/Migrations/20260526111118_ReplaceCourseDescriptionsFromRawProcedure.cs @@ -1,3 +1,4 @@ +using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Migrations; #nullable disable @@ -5,6 +6,8 @@ namespace tacos.core.Migrations { /// + [DbContext(typeof(TacoDbContext))] + [Migration("20260526111118_ReplaceCourseDescriptionsFromRawProcedure")] public partial class ReplaceCourseDescriptionsFromRawProcedure : Migration { /// diff --git a/src/tacos.core/Migrations/20260526111811_DropCourseOfferingsRaw.cs b/src/tacos.core/Migrations/20260526111811_DropCourseOfferingsRaw.cs new file mode 100644 index 0000000..964c4ec --- /dev/null +++ b/src/tacos.core/Migrations/20260526111811_DropCourseOfferingsRaw.cs @@ -0,0 +1,27 @@ +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace tacos.core.Migrations +{ + /// + [DbContext(typeof(TacoDbContext))] + [Migration("20260526111811_DropCourseOfferingsRaw")] + public partial class DropCourseOfferingsRaw : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.Sql( + "DROP TABLE IF EXISTS [dbo].[CourseOfferingsRaw];", + suppressTransaction: true + ); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + } + } +} From 1dd9a1bdda2187d934ec3edf558b1153bc979297 Mon Sep 17 00:00:00 2001 From: Scott Kirkland Date: Tue, 26 May 2026 11:32:06 -0700 Subject: [PATCH 6/8] Deduplicate raw course descriptions --- ...icateCourseDescriptionsFromRawProcedure.cs | 194 ++++++++++++++++++ 1 file changed, 194 insertions(+) create mode 100644 src/tacos.core/Migrations/20260526112751_DeduplicateCourseDescriptionsFromRawProcedure.cs diff --git a/src/tacos.core/Migrations/20260526112751_DeduplicateCourseDescriptionsFromRawProcedure.cs b/src/tacos.core/Migrations/20260526112751_DeduplicateCourseDescriptionsFromRawProcedure.cs new file mode 100644 index 0000000..e2f41d0 --- /dev/null +++ b/src/tacos.core/Migrations/20260526112751_DeduplicateCourseDescriptionsFromRawProcedure.cs @@ -0,0 +1,194 @@ +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace tacos.core.Migrations +{ + /// + [DbContext(typeof(TacoDbContext))] + [Migration("20260526112751_DeduplicateCourseDescriptionsFromRawProcedure")] + public partial class DeduplicateCourseDescriptionsFromRawProcedure : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.Sql( + """ + CREATE OR ALTER PROCEDURE [dbo].[usp_ReplaceCourseDescriptionsFromRaw] + AS + BEGIN + SET NOCOUNT ON; + SET XACT_ABORT ON; + + IF OBJECT_ID(N'[dbo].[CourseDescriptionRaw]', N'U') IS NULL + BEGIN + THROW 50012, 'CourseDescriptionRaw source table is required before course descriptions can be replaced.', 1; + END; + + IF EXISTS + ( + SELECT 1 + FROM [dbo].[CourseDescriptionRaw] + WHERE [SubjectCode] IS NULL + OR [CourseNumber] IS NULL + OR [Status] IS NULL + ) + BEGIN + THROW 50013, 'CourseDescriptionRaw contains rows without a CourseDescription primary key.', 1; + END; + + BEGIN TRANSACTION; + + DELETE FROM [dbo].[CourseDescription]; + + ;WITH RankedCourseDescriptions AS + ( + SELECT + [Course], + [SubjectCode], + [CourseNumber], + [CrossListing], + [Title], + [AbbreviatedTitle], + [CourseDescription], + [College], + [Department], + [Status], + [CreatedOn], + [UpdatedOn], + [FirstLearningActivity], + [FirstContactHoursPeriod], + [SecondLearningActivity], + [SecondContactHoursPeriod], + [ThirdLearningActivity], + [ThirdContactHoursPeriod], + [FourthLearningActivity], + [FourthContactHoursPeriod], + [Ge2ArtsHumanities], + [Ge2ScienceEngineering], + [Ge2SocialSciences], + [Ge2Diversity], + [Ge2WritingExperience], + [Ge3ArtsHumanities], + [Ge3ScienceEngineering], + [Ge3SocialSciences], + [Ge3AmericanCultures], + [Ge3DomesticDiversity], + [Ge3OralLiteracy], + [Ge3QuantitativeLiteracy], + [Ge3ScientificLiteracy], + [Ge3VisualLiteracy], + [Ge3WorldCultures], + [Ge3WritingExperience], + [Quarters], + [QuartersOffered], + [EffectiveTerm], + [Effective], + ROW_NUMBER() OVER + ( + PARTITION BY [SubjectCode], [CourseNumber], [Status] + ORDER BY [UpdatedOn] DESC, [CreatedOn] DESC, TRY_CONVERT(int, [EffectiveTerm]) DESC, [Course] + ) AS [RowNumber] + FROM [dbo].[CourseDescriptionRaw] + ) + INSERT INTO [dbo].[CourseDescription] + ( + [Course], + [SubjectCode], + [CourseNumber], + [CrossListing], + [Title], + [AbbreviatedTitle], + [CourseDescription], + [College], + [Department], + [Status], + [CreatedOn], + [UpdatedOn], + [FirstLearningActivity], + [FirstContactHoursPeriod], + [SecondLearningActivity], + [SecondContactHoursPeriod], + [ThirdLearningActivity], + [ThirdContactHoursPeriod], + [FourthLearningActivity], + [FourthContactHoursPeriod], + [Ge2ArtsHumanities], + [Ge2ScienceEngineering], + [Ge2SocialSciences], + [Ge2Diversity], + [Ge2WritingExperience], + [Ge3ArtsHumanities], + [Ge3ScienceEngineering], + [Ge3SocialSciences], + [Ge3AmericanCultures], + [Ge3DomesticDiversity], + [Ge3OralLiteracy], + [Ge3QuantitativeLiteracy], + [Ge3ScientificLiteracy], + [Ge3VisualLiteracy], + [Ge3WorldCultures], + [Ge3WritingExperience], + [Quarters], + [QuartersOffered], + [EffectiveTerm], + [Effective] + ) + SELECT + [Course], + [SubjectCode], + [CourseNumber], + [CrossListing], + [Title], + [AbbreviatedTitle], + [CourseDescription], + [College], + [Department], + [Status], + [CreatedOn], + [UpdatedOn], + [FirstLearningActivity], + [FirstContactHoursPeriod], + [SecondLearningActivity], + [SecondContactHoursPeriod], + [ThirdLearningActivity], + [ThirdContactHoursPeriod], + [FourthLearningActivity], + [FourthContactHoursPeriod], + [Ge2ArtsHumanities], + [Ge2ScienceEngineering], + [Ge2SocialSciences], + [Ge2Diversity], + [Ge2WritingExperience], + [Ge3ArtsHumanities], + [Ge3ScienceEngineering], + [Ge3SocialSciences], + [Ge3AmericanCultures], + [Ge3DomesticDiversity], + [Ge3OralLiteracy], + [Ge3QuantitativeLiteracy], + [Ge3ScientificLiteracy], + [Ge3VisualLiteracy], + [Ge3WorldCultures], + [Ge3WritingExperience], + [Quarters], + [QuartersOffered], + [EffectiveTerm], + [Effective] + FROM RankedCourseDescriptions + WHERE [RowNumber] = 1; + + COMMIT TRANSACTION; + END; + """, + suppressTransaction: true + ); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + } + } +} From e5e3faa60925a1c756efea9991fbb8b73c7b20dd Mon Sep 17 00:00:00 2001 From: Scott Kirkland Date: Tue, 26 May 2026 11:35:34 -0700 Subject: [PATCH 7/8] Optimize course rebuild span options --- ...500_MaterializeCourseRebuildSpanOptions.cs | 273 ++++++++++++++++++ 1 file changed, 273 insertions(+) create mode 100644 src/tacos.core/Migrations/20260526120500_MaterializeCourseRebuildSpanOptions.cs diff --git a/src/tacos.core/Migrations/20260526120500_MaterializeCourseRebuildSpanOptions.cs b/src/tacos.core/Migrations/20260526120500_MaterializeCourseRebuildSpanOptions.cs new file mode 100644 index 0000000..7f459f4 --- /dev/null +++ b/src/tacos.core/Migrations/20260526120500_MaterializeCourseRebuildSpanOptions.cs @@ -0,0 +1,273 @@ +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace tacos.core.Migrations +{ + /// + [DbContext(typeof(TacoDbContext))] + [Migration("20260526120500_MaterializeCourseRebuildSpanOptions")] + public partial class MaterializeCourseRebuildSpanOptions : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.Sql( + """ + CREATE OR ALTER PROCEDURE [dbo].[usp_GetCourseRebuildAcademicYearSpanOptions] + AS + BEGIN + SET NOCOUNT ON; + + IF OBJECT_ID(N'[dbo].[CoursesRaw]', N'U') IS NULL + BEGIN + THROW 50000, 'CoursesRaw source table is required before course rebuild options can be listed.', 1; + END; + + CREATE TABLE #AvailableTermCodes + ( + [AcademicTermCode] nvarchar(6) NOT NULL, + [AcademicYear] nvarchar(20) NULL + ); + + INSERT INTO #AvailableTermCodes + ( + [AcademicTermCode], + [AcademicYear] + ) + SELECT DISTINCT + LTRIM(RTRIM([AcademicTermCode])) AS [AcademicTermCode], + NULLIF(LTRIM(RTRIM(CONVERT(nvarchar(20), [AcademicYear]))), N'') AS [AcademicYear] + FROM [dbo].[CoursesRaw] + WHERE [AcademicTermCode] IS NOT NULL + AND LEN(LTRIM(RTRIM([AcademicTermCode]))) = 6 + AND TRY_CONVERT(int, LEFT(LTRIM(RTRIM([AcademicTermCode])), 4)) IS NOT NULL + AND RIGHT(LTRIM(RTRIM([AcademicTermCode])), 2) IN (N'10', N'01', N'03'); + + CREATE CLUSTERED INDEX [IX_AvailableTermCodes_AcademicTermCode_AcademicYear] + ON #AvailableTermCodes ([AcademicTermCode], [AcademicYear]); + + ;WITH AvailableAcademicYears AS + ( + SELECT DISTINCT + CASE + WHEN RIGHT([AcademicTermCode], 2) = N'10' + THEN TRY_CONVERT(int, LEFT([AcademicTermCode], 4)) + ELSE TRY_CONVERT(int, LEFT([AcademicTermCode], 4)) - 1 + END AS [AcademicYearStart] + FROM #AvailableTermCodes + ), + RequiredTerms AS + ( + SELECT + AcademicYears.[AcademicYearStart] AS [LaterAcademicYearStart], + TermValues.[TermOrder], + TermValues.[AcademicTermCode] + FROM AvailableAcademicYears AcademicYears + CROSS APPLY + ( + VALUES + (1, CONVERT(nvarchar(4), AcademicYears.[AcademicYearStart] - 1) + N'10'), + (2, CONVERT(nvarchar(4), AcademicYears.[AcademicYearStart]) + N'01'), + (3, CONVERT(nvarchar(4), AcademicYears.[AcademicYearStart]) + N'03'), + (4, CONVERT(nvarchar(4), AcademicYears.[AcademicYearStart]) + N'10'), + (5, CONVERT(nvarchar(4), AcademicYears.[AcademicYearStart] + 1) + N'01'), + (6, CONVERT(nvarchar(4), AcademicYears.[AcademicYearStart] + 1) + N'03') + ) TermValues([TermOrder], [AcademicTermCode]) + ), + RequiredTermsWithAvailability AS + ( + SELECT + RequiredTerms.[LaterAcademicYearStart], + RequiredTerms.[TermOrder], + RequiredTerms.[AcademicTermCode], + SourceYears.[AcademicYear], + CASE + WHEN SourceYears.[AcademicTermCode] IS NULL THEN 0 + ELSE 1 + END AS [IsAvailable] + FROM RequiredTerms + OUTER APPLY + ( + SELECT TOP (1) + AvailableTermCodes.[AcademicTermCode], + AvailableTermCodes.[AcademicYear] + FROM #AvailableTermCodes AvailableTermCodes + WHERE AvailableTermCodes.[AcademicTermCode] = RequiredTerms.[AcademicTermCode] + ORDER BY + CASE WHEN AvailableTermCodes.[AcademicYear] IS NULL THEN 1 ELSE 0 END, + AvailableTermCodes.[AcademicYear] + ) SourceYears + ), + AcademicYearLabels AS + ( + SELECT + LabelTerms.[LaterAcademicYearStart], + STUFF( + ( + SELECT N' and ' + LabelYears.[AcademicYear] + FROM + ( + SELECT DISTINCT + [LaterAcademicYearStart], + [AcademicYear] + FROM RequiredTermsWithAvailability + WHERE [AcademicYear] IS NOT NULL + ) LabelYears + WHERE LabelYears.[LaterAcademicYearStart] = LabelTerms.[LaterAcademicYearStart] + ORDER BY LabelYears.[AcademicYear] + FOR XML PATH(N''), TYPE + ).value(N'.', N'nvarchar(max)'), + 1, + 5, + N'' + ) AS [AcademicYearSpan] + FROM RequiredTermsWithAvailability LabelTerms + GROUP BY LabelTerms.[LaterAcademicYearStart] + ) + SELECT + COALESCE( + AcademicYearLabels.[AcademicYearSpan], + CONVERT(nvarchar(4), RequiredTermsWithAvailability.[LaterAcademicYearStart]) + N'-' + RIGHT(CONVERT(nvarchar(4), RequiredTermsWithAvailability.[LaterAcademicYearStart] + 1), 2) + ) AS [AcademicYearSpan], + RequiredTermsWithAvailability.[LaterAcademicYearStart] AS [StartingAcademicYear], + RequiredTermsWithAvailability.[AcademicTermCode], + RequiredTermsWithAvailability.[TermOrder], + CAST(RequiredTermsWithAvailability.[IsAvailable] AS bit) AS [IsAvailable], + CAST(MIN(RequiredTermsWithAvailability.[IsAvailable]) OVER (PARTITION BY RequiredTermsWithAvailability.[LaterAcademicYearStart]) AS bit) AS [IsComplete] + FROM RequiredTermsWithAvailability + LEFT JOIN AcademicYearLabels + ON AcademicYearLabels.[LaterAcademicYearStart] = RequiredTermsWithAvailability.[LaterAcademicYearStart] + ORDER BY RequiredTermsWithAvailability.[LaterAcademicYearStart] DESC, RequiredTermsWithAvailability.[TermOrder]; + END; + """, + suppressTransaction: true + ); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.Sql( + """ + CREATE OR ALTER PROCEDURE [dbo].[usp_GetCourseRebuildAcademicYearSpanOptions] + AS + BEGIN + SET NOCOUNT ON; + + IF OBJECT_ID(N'[dbo].[CoursesRaw]', N'U') IS NULL + BEGIN + THROW 50000, 'CoursesRaw source table is required before course rebuild options can be listed.', 1; + END; + + ;WITH AvailableTermCodes AS + ( + SELECT DISTINCT + LTRIM(RTRIM([AcademicTermCode])) AS [AcademicTermCode], + NULLIF(LTRIM(RTRIM(CONVERT(nvarchar(20), [AcademicYear]))), N'') AS [AcademicYear] + FROM [dbo].[CoursesRaw] + WHERE [AcademicTermCode] IS NOT NULL + AND LEN(LTRIM(RTRIM([AcademicTermCode]))) = 6 + AND TRY_CONVERT(int, LEFT(LTRIM(RTRIM([AcademicTermCode])), 4)) IS NOT NULL + AND RIGHT(LTRIM(RTRIM([AcademicTermCode])), 2) IN (N'10', N'01', N'03') + ), + AvailableAcademicYears AS + ( + SELECT DISTINCT + CASE + WHEN RIGHT([AcademicTermCode], 2) = N'10' + THEN TRY_CONVERT(int, LEFT([AcademicTermCode], 4)) + ELSE TRY_CONVERT(int, LEFT([AcademicTermCode], 4)) - 1 + END AS [AcademicYearStart] + FROM AvailableTermCodes + ), + RequiredTerms AS + ( + SELECT + AcademicYears.[AcademicYearStart] AS [LaterAcademicYearStart], + TermValues.[TermOrder], + TermValues.[AcademicTermCode] + FROM AvailableAcademicYears AcademicYears + CROSS APPLY + ( + VALUES + (1, CONVERT(nvarchar(4), AcademicYears.[AcademicYearStart] - 1) + N'10'), + (2, CONVERT(nvarchar(4), AcademicYears.[AcademicYearStart]) + N'01'), + (3, CONVERT(nvarchar(4), AcademicYears.[AcademicYearStart]) + N'03'), + (4, CONVERT(nvarchar(4), AcademicYears.[AcademicYearStart]) + N'10'), + (5, CONVERT(nvarchar(4), AcademicYears.[AcademicYearStart] + 1) + N'01'), + (6, CONVERT(nvarchar(4), AcademicYears.[AcademicYearStart] + 1) + N'03') + ) TermValues([TermOrder], [AcademicTermCode]) + ), + RequiredTermsWithAvailability AS + ( + SELECT + RequiredTerms.[LaterAcademicYearStart], + RequiredTerms.[TermOrder], + RequiredTerms.[AcademicTermCode], + SourceYears.[AcademicYear], + CASE + WHEN SourceYears.[AcademicTermCode] IS NULL THEN 0 + ELSE 1 + END AS [IsAvailable] + FROM RequiredTerms + OUTER APPLY + ( + SELECT TOP (1) + AvailableTermCodes.[AcademicTermCode], + AvailableTermCodes.[AcademicYear] + FROM AvailableTermCodes + WHERE AvailableTermCodes.[AcademicTermCode] = RequiredTerms.[AcademicTermCode] + ORDER BY + CASE WHEN AvailableTermCodes.[AcademicYear] IS NULL THEN 1 ELSE 0 END, + AvailableTermCodes.[AcademicYear] + ) SourceYears + ), + AcademicYearLabels AS + ( + SELECT + LabelTerms.[LaterAcademicYearStart], + STUFF( + ( + SELECT N' and ' + LabelYears.[AcademicYear] + FROM + ( + SELECT DISTINCT + [LaterAcademicYearStart], + [AcademicYear] + FROM RequiredTermsWithAvailability + WHERE [AcademicYear] IS NOT NULL + ) LabelYears + WHERE LabelYears.[LaterAcademicYearStart] = LabelTerms.[LaterAcademicYearStart] + ORDER BY LabelYears.[AcademicYear] + FOR XML PATH(N''), TYPE + ).value(N'.', N'nvarchar(max)'), + 1, + 5, + N'' + ) AS [AcademicYearSpan] + FROM RequiredTermsWithAvailability LabelTerms + GROUP BY LabelTerms.[LaterAcademicYearStart] + ) + SELECT + COALESCE( + AcademicYearLabels.[AcademicYearSpan], + CONVERT(nvarchar(4), RequiredTermsWithAvailability.[LaterAcademicYearStart]) + N'-' + RIGHT(CONVERT(nvarchar(4), RequiredTermsWithAvailability.[LaterAcademicYearStart] + 1), 2) + ) AS [AcademicYearSpan], + RequiredTermsWithAvailability.[LaterAcademicYearStart] AS [StartingAcademicYear], + RequiredTermsWithAvailability.[AcademicTermCode], + RequiredTermsWithAvailability.[TermOrder], + CAST(RequiredTermsWithAvailability.[IsAvailable] AS bit) AS [IsAvailable], + CAST(MIN(RequiredTermsWithAvailability.[IsAvailable]) OVER (PARTITION BY RequiredTermsWithAvailability.[LaterAcademicYearStart]) AS bit) AS [IsComplete] + FROM RequiredTermsWithAvailability + LEFT JOIN AcademicYearLabels + ON AcademicYearLabels.[LaterAcademicYearStart] = RequiredTermsWithAvailability.[LaterAcademicYearStart] + ORDER BY RequiredTermsWithAvailability.[LaterAcademicYearStart] DESC, RequiredTermsWithAvailability.[TermOrder]; + END; + """, + suppressTransaction: true + ); + } + } +} From b5e93ad313a91fa3df2e7b8db0a3707fb56106a3 Mon Sep 17 00:00:00 2001 From: Scott Kirkland Date: Tue, 26 May 2026 13:17:11 -0700 Subject: [PATCH 8/8] rebuild timeout + success msg --- src/tacos.mvc/ClientApp/css/site.scss | 28 +++++++++++++++++++ .../ClientApp/pages/ManageCourseRebuild.tsx | 27 ++++++++++++++---- .../Services/CourseRebuildService.cs | 11 ++++++++ 3 files changed, 60 insertions(+), 6 deletions(-) diff --git a/src/tacos.mvc/ClientApp/css/site.scss b/src/tacos.mvc/ClientApp/css/site.scss index 2959e0b..14c7df2 100644 --- a/src/tacos.mvc/ClientApp/css/site.scss +++ b/src/tacos.mvc/ClientApp/css/site.scss @@ -655,6 +655,34 @@ hr { margin-top: 0.75rem; } +.tacos-rebuild-alert { + align-items: center; + margin-bottom: 1rem; +} + +.tacos-rebuild-alert__content { + line-height: 1.45; +} + +.tacos-rebuild-alert__title { + margin-bottom: 0.15rem; + font-size: 1.2rem; + font-weight: 800; +} + +.tacos-rebuild-alert__taco { + width: 3rem; + height: 3rem; + object-fit: contain; + flex: 0 0 auto; +} + +.tacos-rebuild-alert--success { + border-left: 5px solid #2e7d32; + background-color: #e8f5e9; + color: #173d1b; +} + .tacos-term-preview { margin-bottom: 1.5rem; } diff --git a/src/tacos.mvc/ClientApp/pages/ManageCourseRebuild.tsx b/src/tacos.mvc/ClientApp/pages/ManageCourseRebuild.tsx index f9907e5..d3b1a12 100644 --- a/src/tacos.mvc/ClientApp/pages/ManageCourseRebuild.tsx +++ b/src/tacos.mvc/ClientApp/pages/ManageCourseRebuild.tsx @@ -23,7 +23,8 @@ interface ManageCourseRebuildPageProps { type AlertState = { message: string; - type: "alert-error" | "alert-info"; + title?: string; + type: "alert-error" | "alert-info" | "alert-success"; }; function errorMessage(error: unknown, fallback: string): string { @@ -97,7 +98,7 @@ export function ManageCourseRebuildPage({ optionsUrl, rebuildUrl }: ManageCourse } const confirmed = window.confirm( - `Rebuild the Course List from ${selectedOption.academicYearSpan} and reset all submissions? This can take a minute; stay on this page until it finishes.` + `Rebuild the Course List from ${selectedOption.academicYearSpan} and reset all submissions? This usually takes a minute or two; stay on this page until it finishes.` ); if (!confirmed) { @@ -107,7 +108,7 @@ export function ManageCourseRebuildPage({ optionsUrl, rebuildUrl }: ManageCourse const academicTermCodes = selectedOption.terms.map(term => term.academicTermCode); setAlert({ - message: "Rebuilding courses and resetting submissions. This can take a minute; stay on this page until it finishes.", + message: "Rebuilding courses and resetting submissions. This usually takes a minute or two; stay on this page until it finishes.", type: "alert-info" }); setIsRebuilding(true); @@ -128,8 +129,9 @@ export function ManageCourseRebuildPage({ optionsUrl, rebuildUrl }: ManageCourse const result = await response.json() as CourseRebuildResult; setAlert({ + title: "All set", message: `Course List rebuilt from ${result.academicYearSpan}; submissions were reset.`, - type: "alert-info" + type: "alert-success" }); } catch (error) { setAlert({ @@ -148,10 +150,23 @@ export function ManageCourseRebuildPage({ optionsUrl, rebuildUrl }: ManageCourse <> {alert && (
- {alert.message} + {alert.type === "alert-success" && ( + + )} +
+ {alert.title &&
{alert.title}
} +
{alert.message}
+
)} diff --git a/src/tacos.mvc/Services/CourseRebuildService.cs b/src/tacos.mvc/Services/CourseRebuildService.cs index 65895cf..f7fda6b 100644 --- a/src/tacos.mvc/Services/CourseRebuildService.cs +++ b/src/tacos.mvc/Services/CourseRebuildService.cs @@ -111,6 +111,8 @@ public async Task RebuildCoursesAsync(IEnumerable> GetAcadem using var command = connection.CreateCommand(); command.CommandText = "dbo.usp_GetCourseRebuildAcademicYearSpanOptions"; command.CommandType = CommandType.StoredProcedure; + ApplyCommandTimeout(command); await using var reader = await command.ExecuteReaderAsync(); while (await reader.ReadAsync()) @@ -175,6 +178,7 @@ public async Task RebuildCoursesAsync(IReadOnlyList academicTermCodes) using var command = connection.CreateCommand(); command.CommandText = "dbo.usp_RebuildCoursesFromProcessingWindow"; command.CommandType = CommandType.StoredProcedure; + ApplyCommandTimeout(command); var termCodeTable = new DataTable(); termCodeTable.Columns.Add("AcademicTermCode", typeof(string)); @@ -216,6 +220,7 @@ public async Task ReplaceCourseDescriptionsFromRawAsync() using var command = connection.CreateCommand(); command.CommandText = "dbo.usp_ReplaceCourseDescriptionsFromRaw"; command.CommandType = CommandType.StoredProcedure; + ApplyCommandTimeout(command); await command.ExecuteNonQueryAsync(); } @@ -228,6 +233,12 @@ public async Task ReplaceCourseDescriptionsFromRawAsync() } } + private void ApplyCommandTimeout(SqlCommand command) + { + command.CommandTimeout = _dbContext.Database.GetCommandTimeout() + ?? DefaultCommandTimeoutSeconds; + } + private SqlConnection GetSqlConnection() { if (_dbContext.Database.GetDbConnection() is SqlConnection connection)