diff --git a/CaPPMS/App.razor b/CaPPMS/App.razor index acc332c..5e6f0fa 100644 --- a/CaPPMS/App.razor +++ b/CaPPMS/App.razor @@ -1,5 +1,5 @@ - + diff --git a/CaPPMS/Attributes/AllowedStringNumericBasedValuesAttribute.cs b/CaPPMS/Attributes/AllowedStringNumericBasedValuesAttribute.cs new file mode 100644 index 0000000..b3adb4b --- /dev/null +++ b/CaPPMS/Attributes/AllowedStringNumericBasedValuesAttribute.cs @@ -0,0 +1,58 @@ +using CaPPMS.Extensions; +using Humanizer; +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; + +namespace CaPPMS.Attributes +{ + /// + /// Represents an attribute that specifies the range of values that are allowed for a property or parameter. + /// + [AttributeUsage(AttributeTargets.Property | AttributeTargets.Field | AttributeTargets.Parameter, AllowMultiple = false)] + public class AllowedStringNumericBasedValuesAttribute : ValidationAttribute + { + private HashSet stringLookup = new HashSet(StringComparer.OrdinalIgnoreCase); + private HashSet intLookup = new(); + + /// + /// Initializes a new instance of the class. + /// + /// The bottom of the range. + /// The top of the range. + public AllowedStringNumericBasedValuesAttribute(int start, int end) + { + while(start <= end) + { + stringLookup.Add(start.ToWords()); + intLookup.Add(start); + start++; + } + } + + /// + /// Determines whether a specified object is valid. (Overrides ) + /// + /// + /// + public override bool IsValid(object? value) + { + if (value == null) + { + return false; + } + + if (stringLookup.Contains(value.NullSafeToString())) + { + return true; + } + + if (int.TryParse(value.NullSafeToString(), out int result) && intLookup.Contains(result)) + { + return true; + } + + return false; + } + } +} diff --git a/CaPPMS/Attributes/SqlIdPropertyAttribute.cs b/CaPPMS/Attributes/SqlIdPropertyAttribute.cs new file mode 100644 index 0000000..3916aa7 --- /dev/null +++ b/CaPPMS/Attributes/SqlIdPropertyAttribute.cs @@ -0,0 +1,9 @@ +using System; + +namespace CaPPMS.Attributes +{ + [AttributeUsage(AttributeTargets.Property)] + public class SqlIdPropertyAttribute : Attribute + { + } +} diff --git a/CaPPMS/Attributes/SqlTableNameAttribute.cs b/CaPPMS/Attributes/SqlTableNameAttribute.cs new file mode 100644 index 0000000..3c46c43 --- /dev/null +++ b/CaPPMS/Attributes/SqlTableNameAttribute.cs @@ -0,0 +1,25 @@ +using System; + +namespace CaPPMS.Attributes +{ + /// + /// Attribute to associate a class with the backing table name. + /// + [AttributeUsage(AttributeTargets.Class)] + public class SqlTableNameAttribute : Attribute + { + /// + /// Initialize the attribute with the table name. + /// + /// + public SqlTableNameAttribute(string tableName) + { + TableName = tableName; + } + + /// + /// Associated table name. + /// + public string TableName { get; } + } +} diff --git a/CaPPMS/CaPPMS.csproj b/CaPPMS/CaPPMS.csproj index 367d327..253f314 100644 --- a/CaPPMS/CaPPMS.csproj +++ b/CaPPMS/CaPPMS.csproj @@ -61,6 +61,7 @@ Never + @@ -78,11 +79,88 @@ + + + + + PreserveNewest + + + PreserveNewest + + + PreserveNewest + + + PreserveNewest + + + PreserveNewest + + + PreserveNewest + + + PreserveNewest + + + PreserveNewest + + + PreserveNewest + + + PreserveNewest + + + PreserveNewest + + + PreserveNewest + + + PreserveNewest + + + PreserveNewest + + + PreserveNewest + + + PreserveNewest + + + PreserveNewest + + + PreserveNewest + + + PreserveNewest + + + PreserveNewest + + + PreserveNewest + + + PreserveNewest + + + PreserveNewest + + + Always + + + Always @@ -91,4 +169,11 @@ Always + + + + + + + \ No newline at end of file diff --git a/CaPPMS/CaPPMS.sln b/CaPPMS/CaPPMS.sln index 8b58f91..c50d584 100644 --- a/CaPPMS/CaPPMS.sln +++ b/CaPPMS/CaPPMS.sln @@ -1,21 +1,16 @@ - -Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio Version 17 -VisualStudioVersion = 17.9.34701.34 -MinimumVisualStudioVersion = 10.0.40219.1 +Microsoft Visual Studio Solution File, Format Version 12.00 Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "CaPPMS", "CaPPMS.csproj", "{AA9B970F-5351-4EA2-A78C-7E83F05C57C1}" EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "CaPPMSTests", "..\CaPPMSTests\CaPPMSTests.csproj", "{E5612EEB-8B7F-4E6D-9608-A55795477FEC}" -EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution {AA9B970F-5351-4EA2-A78C-7E83F05C57C1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {AA9B970F-5351-4EA2-A78C-7E83F05C57C1}.Debug|Any CPU.Build.0 = Debug|Any CPU - {E5612EEB-8B7F-4E6D-9608-A55795477FEC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {E5612EEB-8B7F-4E6D-9608-A55795477FEC}.Debug|Any CPU.Build.0 = Debug|Any CPU + {AA9B970F-5351-4EA2-A78C-7E83F05C57C1}.Release|Any CPU.ActiveCfg = Release|Any CPU + {AA9B970F-5351-4EA2-A78C-7E83F05C57C1}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/CaPPMS/Data/DBOperationsService.cs b/CaPPMS/Data/DBOperationsService.cs index 811accc..ea81c2b 100644 --- a/CaPPMS/Data/DBOperationsService.cs +++ b/CaPPMS/Data/DBOperationsService.cs @@ -1,10 +1,16 @@ -using Humanizer; +using CaPPMS.Attributes; +using CaPPMS.Extensions; +using CaPPMS.Model; +using Humanizer; using Microsoft.Data.Sqlite; using Microsoft.Extensions.Logging; using System; using System.Collections.Generic; +using System.Data; using System.IO; +using System.Linq; using System.Reflection; +using System.Runtime.Serialization; using System.Threading; using System.Threading.Tasks; @@ -61,45 +67,68 @@ public DBOperationsService(string dboperationsFilePath, ILogger logger) } /// - /// Get Students List + /// Get Students List. /// - /// Team ID - /// List of Students - public List RetrieveStudents(int teamId = -1) + /// Class ID. + /// List of Students. + public async Task> GetStudentsByClassAsync(long classId = -1) { var students = new List(); - string query = "SELECT StudentId, FirstName, LastName, TeamId FROM Students"; + string query = "SELECT * FROM Students"; + if (classId > -1) + { + query += " WHERE ClassId = @classId"; + } + + SqliteParameter classParam = new("@classId", classId); + await ExecuteQueryAsync( + query, + (reader, map) => + { + students.Add(reader.ConvertRecord(map)); + }, + classParam); + + return students.AsReadOnly(); + } + + /// + /// Get Students List. + /// + /// Team ID. + /// List of Students. + public async Task> GetStudentsByTeamAsync(int teamId = -1) + { + var students = new List(); + string query = "SELECT * FROM Students"; if (teamId > -1) { query += " WHERE TeamId = @teamId"; } SqliteParameter teamParam = new("@teamId", teamId); - ExecuteQueryAsync( + await ExecuteQueryAsync( query, - (reader) => + (reader, map) => { - var student = new Student(); - student.StudentId = Convert.ToInt32(reader["StudentId"]); - student.FirstName = reader["FirstName"].NullSafeToString(); - student.LastName = reader["LastName"].NullSafeToString(); - student.AssignedTeam.TeamId = Convert.ToInt32(reader["TeamId"]); - students.Add(student); + students.Add(reader.ConvertRecord(map)); }, teamParam); - return students; + return students.AsReadOnly(); } /// /// Get Student scores. /// /// List of scores. - public List RetrieveStudentScores() + public async Task > GetStudentScoresAsync() { - List studentScores = new(); - ExecuteQueryAsync(readStudentScore, (record) => studentScores.Add(ReadStudentRecord(record))); - return studentScores; + List studentScores = []; + await ExecuteQueryAsync( + readStudentScore, + (record, map) => studentScores.Add(record.ConvertRecord(map))); + return studentScores.AsReadOnly(); } /// @@ -107,161 +136,257 @@ public List RetrieveStudentScores() /// /// /// - public List RetrieveStudentScores(int studentId) + public async Task> GetStudentScoresAsync(int studentId) { - List studentScores = new (); + List studentScores = []; SqliteParameter parameter = new("@studentId", studentId); - ExecuteQueryAsync(readStudentScore, (record) => studentScores.Add(ReadStudentRecord(record)), parameter); - return studentScores; + await ExecuteQueryAsync( + readStudentScore, + (record, map) => studentScores.Add(record.ConvertRecord(map)), + parameter); + return studentScores.AsReadOnly(); } /// /// Get student score details. /// /// List of student scores. - public List RetrieveStudentScoreDetails() - { + public async Task> GetStudentScoreDetails() + { List studentScores = new List(); - ExecuteQueryAsync(readStudentScoreDetails, (record) => studentScores.Add(ReadStudentRecord(record))); - return studentScores; + await ExecuteQueryAsync( + readStudentScoreDetails, + (record, map) => studentScores.Add(record.ConvertRecord(map))); + return studentScores.AsReadOnly(); } /// - /// Get a team list. + /// Get number of weeks for the course. /// - /// List of teams. - public List RetrieveTeamList() - { - List teamList = new List(); + /// + public List> GetWeeks() + { + int weeks = this.GetNumberOfWeeks(); - using (SqliteConnection connection = new(connectionString)) + List> result = []; + for (int i = 1; i <= weeks; i++) { - try - { - connection.Open(); - - string query = "SELECT TeamId, TeamName FROM Teams"; - - using (SqliteCommand command = new(query, connection)) - { - using (SqliteDataReader reader = command.ExecuteReader()) - { - while (reader.Read()) - { - teamList.Add(new Teams() - { - TeamId = Convert.ToInt32(reader["TeamId"]), - Name = reader["TeamName"].ToString() ?? string.Empty - }); - } - } - } - } - catch (Exception ex) - { - logger.LogError($"Error accessing the database: {ex.Message}"); - } - finally - { - connection.Close(); - } + result.Add( + Tuple.Create( + i, + i.ToWords(WordForm.Normal) + .ApplyCase(LetterCasing.Sentence))); } - return teamList; + return result; } /// - /// Get number of weeks for the course. + /// Gets the number of weeks a cohort is active. /// - /// - public List RetrieveWeeks() + /// Default is 12 weeks, else what is configured. + public int GetNumberOfWeeks() { string numWeeks = Program.GetConfigurationSetting("CourseWeeks"); - if (string.IsNullOrEmpty(numWeeks)) { numWeeks = "12"; } - int weeks = int.Parse(numWeeks); + return int.Parse(numWeeks); + } - List result = new List(); - for (int i = 1; i <= weeks; i++) + #region Get + /// + /// Gets records of a type. + /// + /// Type of record to get. + /// Id of record, if not given, all records will be retrieved. + /// . + /// + public async Task> GetRecords(long id = -1, string conditionItem = "ClassId") where T : ISqlTableModel, new() + { + List records = new(); + + // Get the table name. + string? tableName = typeof(T).GetCustomAttribute()?.TableName; + if (string.IsNullOrEmpty(tableName)) { - result.Add(i.ToWords(WordForm.Normal).ApplyCase(LetterCasing.Sentence)); + throw new InvalidOperationException("Table name not found."); } - return result; + // Build the query + SqliteParameter parameter = new("@id", id); + string query = $"SELECT * FROM {tableName};"; + if (id > -1) + { + query += $" WHERE {conditionItem} = @id"; + } + + // Execute + await ExecuteQueryAsync( + query, + (record, map) => records.Add(record.ConvertRecord(map)), + parameter); + return records.AsReadOnly(); } + #endregion /// - /// Update Student to team assignment. + /// Add a record to the database. /// - /// Student ID. - /// Team ID - /// True if successful. - public async Task UpdateTeamAssignmentAsync(int studentId, int teamId) + /// Type of record to add. + /// + /// + /// + public async Task AddRecord(T record) where T : ISqlTableModel, new() { - string query = "UPDATE Students Set TeamId = @teamId WHERE StudentId = @studentId"; - List parameters = - [ - new SqliteParameter("@teamId", teamId), - new SqliteParameter("@studentId", studentId), - ]; - int rowsAffected = await ExecuteNonQueryAsync(query, [.. parameters]); - return rowsAffected > 0; + ArgumentNullException.ThrowIfNull(record); + + // Get the table name. + string? tableName = record.GetType().GetCustomAttribute()?.TableName; + if (string.IsNullOrEmpty(tableName)) + { + throw new InvalidOperationException("Table name not found."); + } + + // Get the properties + PropertyInfo[] properties = record.GetType().GetProperties(); + + // Get the properties that are not the ID + PropertyInfo[] nonIdProperties = properties + .Where(prop => + { + return prop.GetCustomAttribute() == null + && prop.GetCustomAttribute() == null; + }) + .ToArray(); + + // Build the query + string query = $"INSERT INTO {tableName} ("; + string values = "VALUES ("; + List parameters = new(); + foreach (PropertyInfo property in nonIdProperties) + { + query += $"{property.Name}, "; + values += $"@{property.Name}, "; + parameters.Add(new SqliteParameter($"@{property.Name}", property.GetValue(record))); + } + + query = query.TrimEnd(',', ' ') + ") "; + values = values.TrimEnd(',', ' ') + ");"; + query += values; + // Execute + int result = await ExecuteNonQueryAsync(query, [.. parameters]); + return result > -1; } /// - /// Return team for student. + /// Update a record in the database. /// - /// - /// - public async Task RetrieveUsersTeamAsync(string username) + /// Type of record. + /// Record to update. + /// true if record changed > 0 + /// + public async Task UpdateRecord(T record) where T : ISqlTableModel, new() { - int teamId = -1; - string query = "SELECT TeamId FROM Students WHERE Email = @email"; - SqliteParameter parameter = new ("@email", username); - await ExecuteQueryAsync( - query, - (reader) => + ArgumentNullException.ThrowIfNull(record); + + // Get the table name. + string? tableName = record.GetType().GetCustomAttribute()?.TableName; + if (string.IsNullOrEmpty(tableName)) + { + throw new InvalidOperationException("Table name not found."); + } + + // Get the properties + PropertyInfo[] properties = record.GetType().GetProperties(); + + // Get the properties that are not the ID + PropertyInfo[] nonIdProperties = properties + .Where(prop => { - teamId = reader["TeamId"] == DBNull.Value ? -1 : Convert.ToInt32(reader["TeamId"]); - }, - parameter); + return prop.GetCustomAttribute() == null + && prop.GetCustomAttribute() == null; + }) + .ToArray(); + + PropertyInfo? id = properties.FirstOrDefault(prop => prop.GetCustomAttribute() != null); + if (id == null) + { + throw new InvalidOperationException("ID property not found."); + } + + // Build the query + string query = $"UPDATE {tableName}\n"; + + // SET + query += "SET "; + List parameters = new(); + foreach (PropertyInfo property in nonIdProperties) + { + query += $"{property.Name} = @{property.Name}, "; + parameters.Add(new SqliteParameter($"@{property.Name}", property.GetValue(record))); + } + + // Trim the fat. + query = query.TrimEnd(',', ' '); + + // WHERE + query += $"\nWHERE {id.Name} = {id.GetValue(record)};"; - return teamId; + // Execute + int result = await ExecuteNonQueryAsync(query, [.. parameters]); + return result > -1; } /// - /// Add Student to the database. + /// Remove a record from the database. /// - /// Student to add. - public async Task AddStudent(Student student) + /// Type of record. + /// Record to take action on. + /// true if record changed > 0 + /// + public async Task RemoveRecord(T record) { - const string insertCommand = @" -INSERT INTO Students (FirstName, LastName, Email, TeamId) -VALUES (@FirstName, @LastName, @Email, @TeamId)"; - List parameters = - [ - new SqliteParameter("@FirstName", student.FirstName), - new SqliteParameter("@LastName", student.LastName), - new SqliteParameter("@Email", student.Email), - new SqliteParameter("@TeamId", student.AssignedTeam.TeamId) - ]; - - int result = await ExecuteNonQueryAsync(insertCommand, [.. parameters]); - - if (result > -1) + ArgumentNullException.ThrowIfNull(record); + + // Get the table name. + string? tableName = record.GetType().GetCustomAttribute()?.TableName; + + if (string.IsNullOrEmpty(tableName)) + { + throw new InvalidOperationException("Table name not found."); + } + + // Look for the ID property + PropertyInfo? idProperty = record.GetType().GetProperties().FirstOrDefault(prop => prop.GetCustomAttribute() != null); + if (idProperty == null) { - return true; + throw new InvalidOperationException("ID property not found."); } - else + + object? propertyValue = idProperty?.GetValue(record); + if (propertyValue == null) { - return false; + throw new InvalidOperationException("ID property value not found."); } + + // Execute + string query = $"DELETE FROM {tableName} WHERE {idProperty?.Name} = @{idProperty?.Name};"; + List parameters = new() + { + new SqliteParameter($"@{idProperty?.Name}", propertyValue) + }; + int result = await ExecuteNonQueryAsync(query, [.. parameters]); + return result > -1; } + /// + /// Ensure the database exists. + /// + /// + /// public async Task EnsureDbExistsAsync() { DateTime timout = DateTime.Now.Add(brokerTimeout); @@ -276,7 +401,7 @@ public async Task EnsureDbExistsAsync() } } - FileInfo dbFileInfo = new FileInfo(this.databaseFilePath); + FileInfo dbFileInfo = new (this.databaseFilePath); dbFileInfo.Directory?.Create(); if (!dbFileInfo.Exists) @@ -294,7 +419,40 @@ public async Task EnsureDbExistsAsync() dbBroker = 0; } - private async Task ExecuteQueryAsync(string query, Action readerAction, params SqliteParameter[] sqliteParameters) + private static string GetResourceData(string name) + { + string data = string.Empty; + Assembly executing = Assembly.GetExecutingAssembly(); + string[] fileNames = executing.GetManifestResourceNames(); + foreach (string fileName in fileNames) + { + if (!fileName.EndsWith(name, StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + if (!(executing.GetManifestResourceStream(fileName) is Stream stream)) + { + continue; + } + + using (StreamReader sr = new StreamReader(stream)) + { + data = sr.ReadToEnd(); + } + + break; + } + + if (string.IsNullOrEmpty(data)) + { + throw new InvalidDataException($"Expected to find the requested data but didn't or is empty. File:{name}"); + } + + return data; + } + + private async Task ExecuteQueryAsync(string query, Action> readerAction, params SqliteParameter[] sqliteParameters) { using (SqliteConnection connection = new(connectionString)) { @@ -309,11 +467,12 @@ private async Task ExecuteQueryAsync(string query, Action read command.Parameters.Add(param); } - using (SqliteDataReader reader = await command.ExecuteReaderAsync()) + using (IDataReader reader = await command.ExecuteReaderAsync()) { + Dictionary columnMap = reader.GetColumnMap(); while (reader.Read()) { - readerAction?.Invoke(reader); + readerAction?.Invoke(reader, columnMap); } } } @@ -351,7 +510,7 @@ private async Task ExecuteNonQueryAsync(string query, params SqliteParamete connection.Close(); } } - catch(Exception ex) + catch (Exception ex) { Console.WriteLine($"Error accessing the database: {ex.Message}"); } @@ -359,59 +518,19 @@ private async Task ExecuteNonQueryAsync(string query, params SqliteParamete return result; } - private StudentScores ReadStudentRecord(SqliteDataReader reader) - { - return new StudentScores( - Convert.ToInt64(reader["StudentId"]), - reader["FirstName"].NullSafeToString(), - reader["LastName"].NullSafeToString()) - { - AverageScore = reader["AverageScore"] == DBNull.Value ? 0 : Convert.ToInt32(reader["AverageScore"]), - Week = reader["Week"].NullSafeToString(), - Score = reader["Score"] == DBNull.Value ? 0 : Convert.ToDouble(reader["Score"]), - Comment = reader["Comments"].NullSafeToString() - }; - } - - private static string GetResourceData(string name) + private async Task CreateDbAsync() { - string data = string.Empty; - Assembly executing = Assembly.GetExecutingAssembly(); - string[] fileNames = executing.GetManifestResourceNames(); - foreach (string fileName in fileNames) + this.logger.LogDebug($"Creating database at {this.databaseFilePath}"); + string dbCreationScript = GetResourceData(StudentDataBaseCreation); + try { - if (!fileName.EndsWith(name, StringComparison.OrdinalIgnoreCase)) - { - continue; - } - - if (!(executing.GetManifestResourceStream(fileName) is Stream stream)) - { - continue; - } - - using (StreamReader sr = new StreamReader(stream)) - { - data = sr.ReadToEnd(); - } - - break; + int result = await ExecuteNonQueryAsync(dbCreationScript); + this.logger.LogDebug($"Database creation result: {result > -1}"); } - - if (string.IsNullOrEmpty(data)) + catch (Exception ex) { - throw new InvalidDataException($"Expected to find the requested data but didn't or is empty. File:{name}"); + this.logger.LogError($"Error creating database. Error:{ex.GetBaseException()}"); } - - return data; - } - - private async Task CreateDbAsync() - { - this.logger.LogDebug($"Creating database at {this.databaseFilePath}"); - string dbCreationScript = GetResourceData(StudentDataBaseCreation); - int result = await ExecuteNonQueryAsync(dbCreationScript); - this.logger.LogDebug($"Database creation result: {result > -1}"); } } } \ No newline at end of file diff --git a/CaPPMS/Data/Review.cs b/CaPPMS/Data/Review.cs deleted file mode 100644 index 835344c..0000000 --- a/CaPPMS/Data/Review.cs +++ /dev/null @@ -1,23 +0,0 @@ -using System.ComponentModel.DataAnnotations; - -namespace CaPPMS.Data -{ - public class Review - { - public int StudentReviewId { get; set; } - - [Required(ErrorMessage = "Student selection is required")] - public int ReviewedStudentId { get; set; } = -1; - - public string ReviewersEmail { get; set; } = string.Empty; - - [Required(ErrorMessage = "Week selection is required")] - public string Week { get; set; } = string.Empty; - - [Required(ErrorMessage = "Score is required")] - [Range(0, 100, ErrorMessage = "The value must be between 0 and 100.")] - public string Score { get; set; } = string.Empty; - - public string Comments { get; set; } = string.Empty; - } -} \ No newline at end of file diff --git a/CaPPMS/Data/Student.cs b/CaPPMS/Data/Student.cs deleted file mode 100644 index b116eab..0000000 --- a/CaPPMS/Data/Student.cs +++ /dev/null @@ -1,18 +0,0 @@ -using System.ComponentModel.DataAnnotations; - -namespace CaPPMS.Data -{ - public class Student - { - [Required] - public int StudentId { get; set; } = default(int); - - public string? FirstName { get; set; } - - public string? LastName { get; set; } - - public string? Email { get; set; } - - public Teams AssignedTeam { get; set; } = new Teams(); - } -} \ No newline at end of file diff --git a/CaPPMS/Data/StudentScores.cs b/CaPPMS/Data/StudentScores.cs deleted file mode 100644 index b345e99..0000000 --- a/CaPPMS/Data/StudentScores.cs +++ /dev/null @@ -1,45 +0,0 @@ -namespace CaPPMS.Data -{ - public class StudentScores - { - public StudentScores(long id, string firstName, string lastName) - { - this.StudentId = id; - } - - /// - /// Student ID - /// - public long StudentId { get; private set; } - - /// - /// Student First name. - /// - public string FirstName { get; private set; } = string.Empty; - - /// - /// Student last name. - /// - public string LastName { get; private set; } = string.Empty; - - /// - /// Current Score. - /// - public double Score { get; set; } - - /// - /// Average Score. - /// - public double AverageScore { get; set; } - - /// - /// Comment. - /// - public string Comment { get; set; } = string.Empty; - - /// - /// Week of class. - /// - public string Week { get; set; } = string.Empty; - } -} \ No newline at end of file diff --git a/CaPPMS/Data/Teams.cs b/CaPPMS/Data/Teams.cs deleted file mode 100644 index 31b4df6..0000000 --- a/CaPPMS/Data/Teams.cs +++ /dev/null @@ -1,9 +0,0 @@ -namespace CaPPMS.Data -{ - public class Teams - { - public int TeamId { get; set; } - - public string Name { get; set; } = string.Empty; - } -} \ No newline at end of file diff --git a/CaPPMS/Extensions/IDataReaderExtension.cs b/CaPPMS/Extensions/IDataReaderExtension.cs new file mode 100644 index 0000000..019f1fd --- /dev/null +++ b/CaPPMS/Extensions/IDataReaderExtension.cs @@ -0,0 +1,96 @@ +using CaPPMS.Attributes; +using System; +using System.Collections.Generic; +using System.Data; +using System.Linq; +using System.Reflection; +using System.Runtime.Serialization; + +namespace CaPPMS.Extensions +{ + public static class IDataReaderExtension + { + /// + /// Converts the row of the into the specified type. + /// + /// Type of object. + /// . + /// . + public static T ConvertRecord(this IDataReader dataReader, Dictionary? columnMap = null) where T : new() + { + ArgumentNullException.ThrowIfNull(dataReader); + + if (columnMap == null) + { + columnMap = dataReader.GetColumnMap(); + } + + T record = new(); + typeof(T).GetProperties(BindingFlags.Instance | BindingFlags.Public) + .Where(prop => + { + return prop.GetCustomAttribute() == null; + }) + .Foreach(property => + { + if (!columnMap.TryGetValue(property.Name, out int columnId)) + { + return; + } + + object? value = dataReader.GetValue(columnId); + if (value != DBNull.Value) + { + property.SetValue(record, ConvertValue(value, property.PropertyType)); + } + }); + + return record; + } + + /// + /// Get the column map. + /// + /// This . + /// Column Map. + public static Dictionary GetColumnMap(this IDataReader dataReader) + { + ArgumentNullException.ThrowIfNull(dataReader); + Dictionary columnMap = new(StringComparer.OrdinalIgnoreCase); + for (int i = 0; i < dataReader.FieldCount; i++) + { + columnMap.Add(dataReader.GetName(i), i); + } + + return columnMap; + } + + private static object ConvertValue(object value, Type expectedType) + { + // Trying to auto convert to DateTime? fails. + if (value.TryConvertDateTime(out DateTime? result)) + { + if (result == null) + { + return DateTime.MinValue; + } + + return result; + } + + // Check doubles + if (expectedType == typeof(double)) + { + return Convert.ToDouble(value); + } + + // Check lists + if (expectedType == typeof(List)) + { + return value?.ToString()?.Split(',').ToList() ?? []; + } + + return value; + } + } +} diff --git a/CaPPMS/Extensions/UtilityExtensions.cs b/CaPPMS/Extensions/UtilityExtensions.cs new file mode 100644 index 0000000..85261d5 --- /dev/null +++ b/CaPPMS/Extensions/UtilityExtensions.cs @@ -0,0 +1,96 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; + +namespace CaPPMS.Extensions +{ + public static class UtilityExtensions + { + /// + /// Check if the object matches the filter. + /// + /// + /// + /// + public static bool IsMatch(this object item, string filter) + { + if (item == null) + { + return false; + } + + if (string.IsNullOrEmpty(filter)) + { + return true; + } + + PropertyInfo[] properties = item.GetType().GetProperties(BindingFlags.Instance | BindingFlags.Public); + + string[] filterParts = filter.Split(new char[] {' ', ',' }, StringSplitOptions.RemoveEmptyEntries); + bool propertyMatch = filterParts.All(part => + { + return properties.Any(property => + { + object? value = property.GetValue(item); + if (value == null) + { + return true; + } + + if (value is IEnumerable enemerable) + { + return string.Join(" ", enemerable).IndexOf(part, StringComparison.OrdinalIgnoreCase) >= 0; + } + + return value.NullSafeToString().IndexOf(part, StringComparison.OrdinalIgnoreCase) >= 0; + }); + }); + + return propertyMatch; + } + + public static IEnumerable Foreach(this IEnumerable value, Action action) + { + if (value == null) + { + return []; + } + + foreach (T item in value) + { + action?.Invoke(item); + } + + return value; + } + + /// + /// Return null safe object. + /// + /// + /// + public static string NullSafeToString(this object? obj) + { + return obj?.ToString() ?? "(null)"; + } + + public static bool TryConvertDateTime(this object? date, out DateTime? result) + { + result = null; + if (date == null) + { + return false; + } + + if (DateTime.TryParse(date.NullSafeToString(), out DateTime parsedDate)) + { + result = parsedDate; + return true; + } + + return false; + } + } +} diff --git a/CaPPMS/Model/ClassInformation.cs b/CaPPMS/Model/ClassInformation.cs new file mode 100644 index 0000000..323a056 --- /dev/null +++ b/CaPPMS/Model/ClassInformation.cs @@ -0,0 +1,126 @@ +using CaPPMS.Attributes; +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Linq; + +namespace CaPPMS.Model +{ + /// + /// The class information. + /// + [SqlTableName("ClassInformation")] + public class ClassInformation : ISqlTableModel + { + private static readonly List> cohortSeasons = new() + { + { Tuple.Create([11, 12, 1, 2], "Spring") }, + { Tuple.Create([3, 4, 5, 6], "Summer") }, + { Tuple.Create([7, 8, 9, 10],"Fall") } + }; + + private DateTime startDate; + + public ClassInformation() + { + DateTime now = DateTime.Now; + int year = now.Year; + if (now.Month > 10) + { + year++; + } + string season = cohortSeasons.First(x => x.Item1.Contains(now.Month)).Item2; + Cohort = $"{season} {year}"; + this.StartDate = NearestDay(now, DayOfWeek.Wednesday); + } + + /// + /// The class id. + /// + [SqlIdProperty] + public long ClassId { get; set; } = -1; + + /// + /// The Course name. + /// + [ColumnHeader] + public string Course { get; set; } = "SWEN 670"; + + /// + /// The class name. + /// + [ColumnHeader] + public string Cohort { get; set; } + + /// + /// The start of the class. + /// + [ColumnHeader] + [DisplayName("Start Date")] + public DateTime StartDate + { + get + { + return this.startDate; + } + set + { + // Anything less than 10 years ago, assume the user made a mistake and set it to the nearest Wednesday. + // Initial date is the nearest Wednesday as a result for when the page loads. + if (value.Year < DateTime.Now.AddYears(-10).Year) + { + this.startDate = NearestDay(DateTime.Now, DayOfWeek.Wednesday); + } + else + { + this.startDate = value; + } + + this.EndDate = CalculateEndDate(this.startDate); + } + } + + /// + /// The end of the class. + /// + [ColumnHeader] + [DisplayName("End Date")] + public DateTime EndDate { get; set; } = DateTime.Now.AddDays(83); + + public override string ToString() + { + return $"{Course}-{Cohort}"; + } + + private static DateTime NearestDay(DateTime date, DayOfWeek day) + { + while (date.DayOfWeek != day) + { + date = date.AddDays(1); + } + + return date; + } + + private DateTime CalculateEndDate(DateTime startDate) + { + DateTime endDate = startDate.AddDays(83); + return NearestDay(endDate, DayOfWeek.Tuesday); + } + } + + public class ClassInformationComparer : IComparer + { + public int Compare(ClassInformation? x, ClassInformation? y) + { + if (x == null && y == null) return 0; + if (x == null) return -1; + if (y == null) return 1; + + string xStr = $"{x.ToString()} - Start:{x.StartDate}. End:{x.EndDate}"; + string yStr = $"{y.ToString()} - Start:{y.StartDate}. End:{y.EndDate}"; + + return xStr.CompareTo(yStr); + } + } +} diff --git a/CaPPMS/Model/ISqlTableModel.cs b/CaPPMS/Model/ISqlTableModel.cs new file mode 100644 index 0000000..dfc004c --- /dev/null +++ b/CaPPMS/Model/ISqlTableModel.cs @@ -0,0 +1,7 @@ +namespace CaPPMS.Model +{ + public interface ISqlTableModel + { + // Enforce that all models have a class id property + } +} diff --git a/CaPPMS/Model/Review.cs b/CaPPMS/Model/Review.cs new file mode 100644 index 0000000..76e0adb --- /dev/null +++ b/CaPPMS/Model/Review.cs @@ -0,0 +1,35 @@ +using CaPPMS.Attributes; +using System.ComponentModel.DataAnnotations; +using System.Runtime.Serialization; + +namespace CaPPMS.Model +{ + [SqlTableName("StudentReviews")] + public class Review : ISqlTableModel + { + public Review() { } + + [SqlIdProperty] + public long StudentReviewId { get; set; } = -1; + + [Required(ErrorMessage = "Student selection is required")] + public long ReviewedStudentId { get; set; } = -1; + + public long ReviewedById { get; set; } = -1; + + [IgnoreDataMember] + public string RatedStudent { get; set; } = string.Empty; + + [Required(ErrorMessage = "Week selection is required")] + [AllowedStringNumericBasedValues(1, 12, ErrorMessage = "Please select a week between 1 and 10.")] + public string Week { get; set; } = "1"; + + [Required(ErrorMessage = "Score is required")] + [AllowedStringNumericBasedValues(0, 100, ErrorMessage = "Please select a score between 0 and 100.")] + public string Score { get; set; } = "0"; + + [Required(ErrorMessage = "Your comments for the student are appreciated.", AllowEmptyStrings = false)] + [StringLength(maximumLength: 255, MinimumLength = 25, ErrorMessage = "Please use at least 25 characters to describe the interaction of this student.")] + public string Comments { get; set; } = string.Empty; + } +} \ No newline at end of file diff --git a/CaPPMS/Model/Student.cs b/CaPPMS/Model/Student.cs new file mode 100644 index 0000000..c04ef88 --- /dev/null +++ b/CaPPMS/Model/Student.cs @@ -0,0 +1,236 @@ +using CaPPMS.Attributes; +using System.Collections.Generic; +using System; +using System.ComponentModel; +using System.ComponentModel.DataAnnotations; +using System.Runtime.Serialization; +using CaPPMS.Extensions; +using System.Linq; + +namespace CaPPMS.Model +{ + [SqlTableName("Students")] + public class Student : ISqlTableModel + { + private const string EmailSuffix = "@student.umgc.edu"; + + public Student() { } + + [Required] + [SqlIdProperty] + public long StudentId { get; set; } = -1; + + [ColumnHeader] + [DisplayName("First Name")] + public string FirstName { get; set; } = string.Empty; + + [ColumnHeader] + [DisplayName("Last Name")] + public string LastName { get; set; } = string.Empty; + + [ColumnHeader] + public string Email { get; set; } = string.Empty; + + [ColumnHeader] + public string GitHub { get; set; } = string.Empty; + + [ColumnHeader] + [DisplayName("Team")] + [IgnoreDataMember] + public string? TeamName + { + get + { + return this.AssignedTeam.Name; + } + set + { + this.AssignedTeam.Name = value ?? string.Empty; + } + } + + [IgnoreDataMember] + public Team AssignedTeam { get; private set; } = new Team(); + + public long TeamId + { + get + { + return this.AssignedTeam.TeamId; + } + set + { + this.AssignedTeam.TeamId = value; + } + } + + public long ClassId { get; set; } = -1; + + /// + /// Set the team for the student. + /// + /// Appropiate team. + public void SetTeam(Team team) + { + this.AssignedTeam = team.Clone(); + } + + public void SetProperty(string propertyName, object value) + { + var property = this.GetType().GetProperty(propertyName); + if (property != null && property.CanWrite) + { + if (value is string stringData && string.IsNullOrEmpty(stringData)) + { + throw new InvalidOperationException("Value cannot be null or empty."); + } + + property.SetValue(this, value); + } + } + + public Student Clone() + { + Student student = (Student)this.MemberwiseClone(); + Team team = new() + { + TeamId = this.AssignedTeam.TeamId, + Name = this.AssignedTeam.Name, + ClassId = this.AssignedTeam.ClassId + }; + student.AssignedTeam = team; + return student; + } + + /// + /// Parse the data from a CSV file. + /// + /// The CSV. + /// . + public static List ParseData(string data, long classId, out List> droppedRecords) + { + if (string.IsNullOrEmpty(data)) + { + throw new ArgumentException("Data cannot be null or empty."); + } + + if (classId < 0) + { + throw new ArgumentException("Invalid class ID."); + } + + List students = new List(); + droppedRecords = []; + + try + { + // Split the data into lines + string[] lines = data + .Replace("\r", string.Empty) + .Split(new char[] { '\n' }, StringSplitOptions.RemoveEmptyEntries); + + string[] headerFields = lines[0].Split(','); + if (headerFields.Length < 3) + { + throw new Exception("Invalid file format."); + } + + Dictionary headerMap = new Dictionary(StringComparer.OrdinalIgnoreCase); + + for (int i = 0; i < headerFields.Length; i++) + { + headerMap[headerFields[i].Trim()] = i; + } + + // Iterate over each line (excluding the header) + for (int i = 1; i < lines.Length; i++) + { + string trimmedLine = lines[i].Trim(); + + // Skip empty lines. + if (string.IsNullOrWhiteSpace(trimmedLine)) + { + continue; + } + + string[] fields = lines[i].Split(','); + + // Create a new Student object + Student student = new Student(); + + // Populate the fields + student.LastName = fields[headerMap["LastName"]]; + student.FirstName = fields[headerMap["FirstName"]]; + student.Email = fields[headerMap["Email Address"]]; + student.GitHub = fields[headerMap["Github.com account"]].Replace("https://github.com/", string.Empty); + + string error = string.Empty; + + // Validation + if (string.IsNullOrEmpty(student.LastName)) + { + error += "Last name is required."; + } + + if (string.IsNullOrEmpty(student.FirstName)) + { + error += ", First name is required."; + } + + if (!student.Email.EndsWith(EmailSuffix, StringComparison.OrdinalIgnoreCase)) + { + error += $", Invalid email address. Given address={student.Email}"; + } + + if (student.GitHub.Contains("@")) + { + error += $", Invalid GitHub username. Given Github={student.GitHub}"; + } + + if (!string.IsNullOrEmpty(error)) + { + error = error.TrimStart(',', ' '); + droppedRecords.Add(Tuple.Create(student, error)); + continue; + } + + student.ClassId = classId; + + // Add the student to the list + students.Add(student); + } + } + catch (Exception ex) + { + // TODO: log + Console.WriteLine(ex.Message); + } + + return students; + } + + /// + /// Check if the object matches the filter. + /// + /// Search Term. + /// If object matches the filter. + public bool IsMatch(string filter) + { + if (string.IsNullOrEmpty(filter)) + { + return true; + } + + string[] filters = filter.Split(new char[] { ' ', ',' }, StringSplitOptions.RemoveEmptyEntries); + + return filters.All(f => + { + return this.FirstName.IndexOf(f, StringComparison.OrdinalIgnoreCase) >= 0 + || this.LastName.IndexOf(f, StringComparison.OrdinalIgnoreCase) >= 0 + || this.Email.IndexOf(f, StringComparison.OrdinalIgnoreCase) >= 0 + || this.GitHub.IndexOf(f, StringComparison.OrdinalIgnoreCase) >= 0 + || this.TeamName.NullSafeToString().IndexOf(f, StringComparison.OrdinalIgnoreCase) >= 0; + }); + } + } +} \ No newline at end of file diff --git a/CaPPMS/Model/StudentScores.cs b/CaPPMS/Model/StudentScores.cs new file mode 100644 index 0000000..a8c2ef6 --- /dev/null +++ b/CaPPMS/Model/StudentScores.cs @@ -0,0 +1,29 @@ +using System.Collections.Generic; + +namespace CaPPMS.Model +{ + public class StudentScores : Student + { + public StudentScores() { } + + /// + /// Current Score. + /// + public double Score { get; set; } + + /// + /// Average Score. + /// + public double AverageScore { get; set; } + + /// + /// Comment. + /// + public List Comments { get; set; } = []; + + /// + /// Week of class. + /// + public string Week { get; set; } = string.Empty; + } +} \ No newline at end of file diff --git a/CaPPMS/Model/Table/Cell.cs b/CaPPMS/Model/Table/Cell.cs index bdce6c3..d4e7a88 100644 --- a/CaPPMS/Model/Table/Cell.cs +++ b/CaPPMS/Model/Table/Cell.cs @@ -17,20 +17,20 @@ public class Cell : IComparable { public Cell() { } - public Cell(int rowId, int columnId, object value) + public Cell(int rowId, int columnId, object? value) { this.RowId = rowId; this.ColumnId = columnId; this.Value = value; } - public Cell(int rowId, int columnId, object value, CellType cellType) + public Cell(int rowId, int columnId, object? value, CellType cellType) : this(rowId, columnId, value) { this.CellType = cellType; } - public Cell(int rowId, int columnId, object value, CellType cellType, IEnumerable attributes) + public Cell(int rowId, int columnId, object? value, CellType cellType, IEnumerable attributes) : this(rowId, columnId, value, cellType) { this.Attributes = attributes; @@ -38,7 +38,7 @@ public Cell(int rowId, int columnId, object value, CellType cellType, IEnumerabl public CellType CellType { get; private set; } = CellType.Data; - public object Value { get; set; } + public object? Value { get; set; } public int RowId { get; private set; } diff --git a/CaPPMS/Model/Table/CustomCell.cs b/CaPPMS/Model/Table/CustomCell.cs new file mode 100644 index 0000000..baa4bea --- /dev/null +++ b/CaPPMS/Model/Table/CustomCell.cs @@ -0,0 +1,18 @@ +using System; +using System.Threading.Tasks; + +namespace CaPPMS.Model.Table +{ + public class CustomCell + { + public string CssClasses { get; set; } = string.Empty; + + public Func? Value { get; set; } + + public string Header { get; set; } = string.Empty; + + public Func> OnClick { get; set; } = default!; + + public bool IsVisible { get; set; } = true; + } +} diff --git a/CaPPMS/Model/Table/Paginate.razor b/CaPPMS/Model/Table/Paginate.razor index 71fe922..6de82d1 100644 --- a/CaPPMS/Model/Table/Paginate.razor +++ b/CaPPMS/Model/Table/Paginate.razor @@ -1,4 +1,5 @@ - +@typeparam T where T : class, new() + @if (IsDisabled(true)) { @@ -19,9 +20,11 @@ else @code { - [Parameter] public EventCallback PageChanged { get; set; } [Parameter] - public Table Parent { get; set; } + public EventCallback PageChanged { get; set; } + + [Parameter] + public required Table Parent { get; set; } private bool IsDisabled(bool isLeftArrow) { diff --git a/CaPPMS/Model/Table/Row.cs b/CaPPMS/Model/Table/Row.cs index 88f821e..2a338b7 100644 --- a/CaPPMS/Model/Table/Row.cs +++ b/CaPPMS/Model/Table/Row.cs @@ -6,17 +6,28 @@ namespace CaPPMS.Model.Table { - public class Row : IList + public class Row : IList where T : class, new() { - public Row() { } + /// + /// Used for creating header. + /// + public Row() + { + } + + public Row(T item) + { + this.DataBoundItem = item; + } - public Row(int rowNumber) + public Row(int rowNumber, T item) + : this(item) { this.RowNumber = rowNumber; } - public Row(int rowNumber, List cells) - : this(rowNumber) + public Row(int rowNumber, T item, List cells) + : this(rowNumber, item) { this.Cells = cells; } @@ -43,7 +54,7 @@ public Row(int rowNumber, List cells) public bool IsReadOnly => false; - public object DataBoundItem { get; set; } + public T DataBoundItem { get; set; } public Cell this[int index] { diff --git a/CaPPMS/Model/Table/Table.cs b/CaPPMS/Model/Table/Table.cs index 9dce2b0..bdacee5 100644 --- a/CaPPMS/Model/Table/Table.cs +++ b/CaPPMS/Model/Table/Table.cs @@ -1,4 +1,5 @@ using CaPPMS.Attributes; +using iTextSharp.text; using Microsoft.AspNetCore.Components; using System; using System.Collections; @@ -15,13 +16,15 @@ namespace CaPPMS.Model.Table /// To add a column, simply decrorate with [ColumnHeader] /// 2. The rows will look preference DisplayNameAttribute over the Property Name. DisplayNameAttribute is not required for the table to build. /// - public class Table : ComponentBase, IList + public class Table : ComponentBase, IList> where T : class, new() { - public event EventHandler DataSourceChanged; + public event EventHandler> DataSourceChanged; + public event EventHandler RowsPerPageChanged; - public event EventHandler FilterChanged; - public readonly int[] RowsPerPageOptions = new int[] { 10, 25, 50, 100 }; + public event EventHandler FilterChanged; + + public readonly int[] RowsPerPageOptions = [10, 25, 50, 100]; private int rowsPerPage = 5; @@ -29,7 +32,7 @@ public Table() { } - public Row this[int index] + public Row this[int index] { get { @@ -41,10 +44,10 @@ public Row this[int index] } } - private IEnumerable dataSource; + private IEnumerable dataSource = []; [Parameter] - public IEnumerable DataSource + public IEnumerable DataSource { get { @@ -52,10 +55,10 @@ public IEnumerable DataSource } set { - if (value is IEnumerable) + if (value is IEnumerable) { this.dataSource = value; - DataSourceChanged?.Invoke(this, new TableDataChangedEventArgs(this.dataSource)); + this.DataSourceChanged?.Invoke(this, new TableDataChangedEventArgs(this.dataSource)); } } } @@ -75,6 +78,7 @@ public int RecordsPerPage private string filter = string.Empty; + [Parameter] public string Filter { get @@ -84,13 +88,13 @@ public string Filter set { this.filter = value; - this.FilterChanged?.Invoke(this.filter, EventArgs.Empty); + this.FilterChanged?.Invoke(this, this.filter); } } - public Row HeaderRow => GetHeaderRow(); + public Row HeaderRow => GetHeaderRow(); - public List Rows => this.GetRows().ToList(); + public List> Rows => this.GetRows().ToList(); public int Count => this.DataSource.Count(); @@ -101,11 +105,12 @@ public string Filter public int CurrentPage { get; private set; } = 1; public int SortColumnIndex { get; set; } = 0; + public bool IsColumnSortAscending { get; set; } = true; #region IList Interface - public void Add(Row item) + public void Add(Row item) { this.Rows.Add(item); } @@ -115,32 +120,32 @@ public void Clear() this.Rows.Clear(); } - public bool Contains(Row item) + public bool Contains(Row item) { return this .Rows.Contains(item); } - public void CopyTo(Row[] array, int arrayIndex) + public void CopyTo(Row[] array, int arrayIndex) { this.CopyTo(array, arrayIndex); } - public IEnumerator GetEnumerator() + public IEnumerator> GetEnumerator() { return this.Rows.GetEnumerator(); } - public int IndexOf(Row item) + public int IndexOf(Row item) { return this.Rows.IndexOf(item); } - public void Insert(int index, Row item) + public void Insert(int index, Row item) { this.Rows.Insert(index, item); } - public bool Remove(Row item) + public bool Remove(Row item) { return this.Rows.Remove(item); } @@ -169,9 +174,9 @@ public void SetPageNumber(int page) this.StateHasChanged(); } - public Row GetHeaderRow() + public Row GetHeaderRow() { - var row = new Row(); + var row = new Row(); List cNames = new List(GetColumnNames()); for(int c = 0; c < cNames.Count; c++) @@ -182,14 +187,19 @@ public Row GetHeaderRow() return row; } - public IEnumerable GetRows() + public IEnumerable> GetRows() { + if (this.dataSource == null) + { + return []; + } + var dataList = this.dataSource.ToList(); - List rows = new List(); + List> rows = []; for (int r = 0; r < dataList.Count; r++) { - Row row = new Row(r); + Row row = new Row(r, dataList[r]); for (int c = 0; c < this.HeaderRow.Count; c++) { var prop = dataList[r].GetType().GetRuntimeProperties() @@ -226,11 +236,24 @@ public IEnumerable GetRows() return rows.Skip(skipNumber).Take(this.rowsPerPage).ToArray(); } - public void SetDataSource(IEnumerable dataSource) + public void SetDataSource(IEnumerable dataSource) { this.DataSource = dataSource; } + protected virtual void SortByColumn(int column) + { + if (SortColumnIndex == column) + { + IsColumnSortAscending = !IsColumnSortAscending; + } + else + { + IsColumnSortAscending = true; + SortColumnIndex = column; + } + } + private List GetColumnNames() { List columns = new List(); @@ -241,7 +264,7 @@ private List GetColumnNames() return columns; } - foreach (var prop in this.dataSource.First().GetType().GetRuntimeProperties()) + foreach (var prop in typeof(T).GetProperties(BindingFlags.Public | BindingFlags.Instance)) { var attribute = prop.GetCustomAttribute(); if (attribute is null) diff --git a/CaPPMS/Model/Table/TableDataChangedEventArgs.cs b/CaPPMS/Model/Table/TableDataChangedEventArgs.cs index f24a952..efa292f 100644 --- a/CaPPMS/Model/Table/TableDataChangedEventArgs.cs +++ b/CaPPMS/Model/Table/TableDataChangedEventArgs.cs @@ -3,13 +3,13 @@ namespace CaPPMS.Model.Table { - public class TableDataChangedEventArgs : EventArgs + public class TableDataChangedEventArgs : EventArgs { - public TableDataChangedEventArgs(IEnumerable data) + public TableDataChangedEventArgs(IEnumerable data) { this.Data = data; } - public IEnumerable Data { get; private set; } + public IEnumerable Data { get; private set; } } } diff --git a/CaPPMS/Model/Teams.cs b/CaPPMS/Model/Teams.cs new file mode 100644 index 0000000..841382e --- /dev/null +++ b/CaPPMS/Model/Teams.cs @@ -0,0 +1,30 @@ +using CaPPMS.Attributes; + +namespace CaPPMS.Model +{ + [SqlTableName("Teams")] + public class Team : ISqlTableModel + { + /// + /// Id of the team. + /// + [SqlIdProperty] + public long TeamId { get; set; } + + /// + /// Name of the team. + /// + [ColumnHeader] + public string Name { get; set; } = string.Empty; + + /// + /// Class ID. + /// + public long ClassId { get; set; } + + internal Team Clone() + { + return (Team)this.MemberwiseClone(); + } + } +} \ No newline at end of file diff --git a/CaPPMS/ObjectExtensions.cs b/CaPPMS/ObjectExtensions.cs deleted file mode 100644 index 2efedb3..0000000 --- a/CaPPMS/ObjectExtensions.cs +++ /dev/null @@ -1,18 +0,0 @@ -namespace CaPPMS -{ - /// - /// Common Extensions. - /// - public static class ObjectExtensions - { - /// - /// Return null safe object. - /// - /// - /// - public static string NullSafeToString(this object obj) - { - return obj?.ToString() ?? "(null)"; - } - } -} diff --git a/CaPPMS/Pages/StudentReviews/ManageStudents.razor b/CaPPMS/Pages/StudentReviews/ManageStudents.razor index f90724c..f042918 100644 --- a/CaPPMS/Pages/StudentReviews/ManageStudents.razor +++ b/CaPPMS/Pages/StudentReviews/ManageStudents.razor @@ -1,305 +1,411 @@ -@using CaPPMS.Pages.StudentReviews; +@using System.IO; @using Microsoft.Extensions.FileProviders; -@using System.IO; +@using Microsoft.Identity.Web + +@using CaPPMS.Pages.StudentReviews; @inject DBOperationsService DBOperationsService +@inject MicrosoftIdentityConsentAndConditionalAccessHandler consentHandler +@inject NavigationManager NavigationManager @namespace CaPPMS.Pages.StudentReviews -@page "/StudentReviews/ManageStudents" +@attribute [Authorize] +@page "/ManageStudents" Manage Students + + + +@if (availableClasses.Count() == 0 || !consentHandler.User.IsAdmin()) +{ +

No classes available. Please add one.

+ return; +} +
-
Student Directory
-
- - -
- - Load New Class Image - Load New Class + +
+ Student Directory. +
+
+ Class Context: + + + + @foreach (ClassInformation classInfo in availableClasses) + { + + } + + + +  Upload Class CSV: 
-
-
- - - + + + @if(droppedStudents.Any()) + { +
+
+
Parser failed on these students...
+
+ - - + + + - - @foreach (var student in Students) + + @foreach (var student in droppedStudents) { - - - - + + + + + }
Edit First Name Last NameAssigned TeamEmailGitHubReason
@student.FirstName@student.LastName - - - @foreach (var team in teams) - { - - } - - @student.Item1.FirstName@student.Item1.LastName@student.Item1.Email@student.Item1.GitHub@student.Item2
-
-
-
- - +
+ } -
-
+
+ +
+
+
+
@code { - private List teams; - SelectedStudent selectedStudent; - string teamId = string.Empty; - string searchTeam = string.Empty; - - protected override void OnInitialized() + private string searchTerm = string.Empty; + private IEnumerable availableClasses = []; + private bool isAvailableClassLoaded => availableClasses.Any(c => DateTime.Now.Month >= c.StartDate.Month && DateTime.Now.Month <= c.EndDate.Month); + private ClassInformation selectedClass = new ClassInformation(); + private IEnumerable teams = new List(); + private List students = []; + private IEnumerable> droppedStudents = []; + private ClassInformation SelectedClass { - teams = RetrieveTeams(); - var students = DBOperationsService.RetrieveStudents(); - - foreach (var student in students) + get { - Students.Add(student); + return this.selectedClass; + } + set + { + this.selectedClass = value; + _ = Task.Run(async () => await ClassChangedAsync(this.selectedClass)); } } - - public void EditStudent(Student student) + private string SearchTerm { - selectedStudent = new SelectedStudent() + get { - StudentId = student.StudentId, - FirstName = student.FirstName, - LastName = student.LastName, - AssignedTeam = new Teams() { TeamId = student.AssignedTeam.TeamId, Name = student.AssignedTeam.Name } - }; - - HidePanel = false; - teamId = selectedStudent.AssignedTeam.TeamId.ToString(); + return this.searchTerm; + } + set + { + this.searchTerm = value; + _ = Task.Run(async () => await SearchAsync()); + } } - public void Update() + protected override async Task OnInitializedAsync() { - selectedStudent.AssignedTeam.TeamId = int.Parse(teamId); + await Refresh(updateSelectedClass: true); + } - if (UpdateTeamMembersAsync(selectedStudent).GetAwaiter().GetResult()) + private async Task Refresh(bool updateSelectedClass) + { + availableClasses = await DBOperationsService.GetRecords(); + if (updateSelectedClass && availableClasses.Any()) { - var obj = Students.FirstOrDefault(x => x.StudentId == selectedStudent.StudentId); - - if (obj != null) - { - obj.AssignedTeam.TeamId = int.Parse(teamId); - } + this.SelectedClass = availableClasses.First(); } + + await this.InvokeAsync(() => this.StateHasChanged()); } - private async Task HandleFileSelection(InputFileChangeEventArgs e) + private async Task DeleteStudent(Student student) { - try + // Implement the logic to delete a student + await DBOperationsService.RemoveRecord(student); + await this.ClassChangedAsync(this.selectedClass); + } + + private async Task UpdateStudents() + { + foreach (Student student in students) { - var files = e.GetMultipleFiles(); + if (student.ClassId < 0 || student.TeamId < 0) + { + continue; + } - foreach (var file in e.GetMultipleFiles()) + // Add students with -1 student id. + if (student.StudentId < 0) { - using var stream = file.OpenReadStream(); - await ProcessStreamAsync(stream, file.Name); + await DBOperationsService.AddRecord(student); + continue; } + + await DBOperationsService.UpdateRecord(student); } - catch (Exception ex) - { - Console.WriteLine(ex.Message); - } + + await Task.Delay(500); + NavigationManager.Refresh(); } - private async Task ProcessStreamAsync(Stream stream, string fileName) + private async Task ClassChangedAsync + (ClassInformation classInformation) { - try - { - using var reader = new StreamReader(stream); - var fileContent = await reader.ReadToEndAsync(); + // Students associated with the current selected class. + students = (await DBOperationsService.GetStudentsByClassAsync(selectedClass.ClassId)) + .Where(s => s.IsMatch(searchTerm)) + .OrderBy(s => s.FirstName) + .ToList(); + + // Add blank if wanting to add a new student manually. + students.Add(new Student() { ClassId = selectedClass.ClassId, TeamId = -1 }); - List students = ParseData(fileContent); + // Teams associated with the current selected class. + teams = (await DBOperationsService.GetRecords()).Where(t => t.ClassId == selectedClass.ClassId).ToList(); - foreach (Student student in students) + // Assign the team to the student. + foreach (Student student in students) + { + if (student.StudentId < 0) { - DBOperationsService.AddStudent(student); + continue; } + + Team? team = teams.FirstOrDefault(t => t.TeamId == student.TeamId); + if (team == null) + { + continue; + } + + student.SetTeam(team); } - catch (Exception ex) - { - Console.WriteLine(ex.Message); - } + + await this.InvokeAsync(() => this.StateHasChanged()); } - public void CloseWindow() + private dynamic GetGroupedTeams() { - HidePanel = true; // Hide the Selected Student panel + var items = students + .GroupBy(s => s.TeamName) + .Where(g => !string.IsNullOrEmpty(g.Key) && g.Count() > 0) + .Select(g => new { TeamName = g.Key, Count = g.Count(), Members = string.Join(",", g.Select(s => $"{s.FirstName} {s.LastName}")) }) + .OrderBy(item => item.TeamName); + return items; } - public class SelectedStudent + private async Task SearchAsync() { - public SelectedStudent() - { - AssignedTeam = new Teams(); - } - - public int StudentId { get; set; } - public string? FirstName { get; set; } - public string? LastName { get; set; } - public Teams? AssignedTeam { get; set; } + await ClassChangedAsync(SelectedClass); } - static List ParseData(string data) + private async Task HandleFileSelection(InputFileChangeEventArgs e) { - List students = new List(); - try { - // Split the data into lines - string[] lines = data.Split('\n'); - - // Iterate over each line (excluding the header) - for (int i = 1; i < lines.Length; i++) + var files = e.GetMultipleFiles(); + foreach (var file in e.GetMultipleFiles()) { - string trimmedLine = lines[i].Trim(); - - if (!string.IsNullOrWhiteSpace(trimmedLine)) + if (file.Name.EndsWith(".csv", StringComparison.OrdinalIgnoreCase)) + { + // Process CSV file + await ProcessStreamAsync(file.OpenReadStream(), file.Name); + } + else { - string[] fields = lines[i].Split(','); + // Handle unsupported file types + Console.WriteLine($"Unsupported file type: {file.Name}"); + } + } + } + catch (Exception ex) + { + Console.WriteLine(ex.Message); + } + } - // Create a new Student object - Student student = new Student(); + private async Task ProcessStreamAsync(Stream stream, string fileName) + { + try + { + string fileContent = string.Empty; + using (StreamReader reader = new StreamReader(stream)) + { + fileContent = await reader.ReadToEndAsync(); + } - // Populate the fields - student.LastName = fields[0]; - student.FirstName = fields[1]; - student.Email = fields[2]; - student.AssignedTeam.TeamId = int.Parse(fields[3]); + if (string.IsNullOrEmpty(fileContent)) + { + return; + } - // Add the student to the list - students.Add(student); - } + // TODO: Add dropped records to another table for notification to user. + List students = Student.ParseData(fileContent, selectedClass.ClassId, out List> droppedStudents); + var currentStudents = await DBOperationsService.GetRecords(); + foreach (Student student in students + .Where(s => !currentStudents + .Any(c => string.Equals(c.Email, s.Email, StringComparison.OrdinalIgnoreCase)))) + { + await DBOperationsService.AddRecord(student); } + + this.droppedStudents = droppedStudents; } catch (Exception ex) { Console.WriteLine(ex.Message); } - return students; + await ClassChangedAsync(this.selectedClass); } } \ No newline at end of file diff --git a/CaPPMS/Pages/StudentReviews/ManageStudents.razor.cs b/CaPPMS/Pages/StudentReviews/ManageStudents.razor.cs deleted file mode 100644 index 0332335..0000000 --- a/CaPPMS/Pages/StudentReviews/ManageStudents.razor.cs +++ /dev/null @@ -1,61 +0,0 @@ -using CaPPMS.Data; -using System.Collections.Generic; -using System.Threading.Tasks; - -namespace CaPPMS.Pages.StudentReviews -{ - public partial class ManageStudents - { - public bool HidePanel { get; set; } = true; - - private List value = new List(); - - public List Students - { - get - { - if (value != null && value.Count > 0) - { - var tempTeam = teams.Find(e => e.TeamId == value[0].AssignedTeam.TeamId); - - if (value.Count > 0 && tempTeam != null) - { - value[0].AssignedTeam.Name = tempTeam.Name; - } - } - else - { - value = new List(); - } - - return value; - } - - set { this.Students = value; } - } - - public ManageStudents() - { - teams = new List(); - selectedStudent = new SelectedStudent(); - } - - public List RetrieveTeams() - { - return DBOperationsService.RetrieveTeamList(); - } - - public async Task UpdateTeamAssignmentsAsync() - { - foreach (var student in Students) - { - await DBOperationsService.UpdateTeamAssignmentAsync(student.StudentId, student.AssignedTeam.TeamId); - } - } - - public async Task UpdateTeamMembersAsync(SelectedStudent student) - { - return await DBOperationsService.UpdateTeamAssignmentAsync(student.StudentId, student.AssignedTeam?.TeamId ?? -1); - } - } -} \ No newline at end of file diff --git a/CaPPMS/Pages/StudentReviews/MyRatings.razor b/CaPPMS/Pages/StudentReviews/MyRatings.razor index 3cf6d3f..11312cd 100644 --- a/CaPPMS/Pages/StudentReviews/MyRatings.razor +++ b/CaPPMS/Pages/StudentReviews/MyRatings.razor @@ -5,77 +5,79 @@ @page "/StudentReviews/MyRatings" My Ratings - +@{ + // TODO: Remove style and use site.css +} @@ -107,7 +109,7 @@ @scoreDetails.Week @scoreDetails.Score - @scoreDetails.Comment + @scoreDetails.Comments } @@ -122,10 +124,12 @@ @code { - private List? studentScores; + private List studentScores = []; - protected override void OnInitialized() + protected override async Task OnInitializedAsync() { - studentScores = DBOperationsService.RetrieveStudentScores(18); + // TODO: Replace the hardcoded student ID with the actual student ID + studentScores.AddRange(await DBOperationsService.GetStudentScoresAsync(18)); + await base.OnInitializedAsync(); } } \ No newline at end of file diff --git a/CaPPMS/Pages/StudentReviews/Admin.razor b/CaPPMS/Pages/StudentReviews/StudentAdmin.razor similarity index 71% rename from CaPPMS/Pages/StudentReviews/Admin.razor rename to CaPPMS/Pages/StudentReviews/StudentAdmin.razor index d367764..37909e1 100644 --- a/CaPPMS/Pages/StudentReviews/Admin.razor +++ b/CaPPMS/Pages/StudentReviews/StudentAdmin.razor @@ -1,85 +1,14 @@ -@using Microsoft.AspNetCore.Components.Forms -@using CaPPMS.Pages.StudentReviews +@using CaPPMS.Pages.StudentReviews +@using Microsoft.AspNetCore.Components.Forms +@using Microsoft.Identity.Web -@page "/StudentReviews/Admin" +@attribute [Authorize] +@page "/adminstudentreview" @inject DBOperationsService DBOperationsService +@inject MicrosoftIdentityConsentAndConditionalAccessHandler consentHandler Student Scores - - -
Student Scores
@@ -170,20 +99,20 @@ private long previousId = 0; private SelectedStudent selectedStudent = new SelectedStudent(); - protected override void OnInitialized() + protected override async Task OnInitializedAsync() { - studentScores = DBOperationsService.RetrieveStudentScores(); - base.OnInitialized(); + studentScores.AddRange(await DBOperationsService.GetStudentScoresAsync()); + await base.OnInitializedAsync(); } - public List studentScores; + public List studentScores = []; public bool HidePanel { get; set; } = true; public void EditStudent(StudentScores student) { var scoresAndComments = studentScores .Where(s => s.StudentId == student.StudentId) - .Select(s => new Tuple(s.Week, s.Score, s.Comment)) + .Select(s => new Tuple(s.Week, s.Score, string.Join("\n", s.Comments))) .ToList(); selectedStudent = new SelectedStudent() diff --git a/CaPPMS/Pages/StudentReviews/Admin.razor.cs b/CaPPMS/Pages/StudentReviews/StudentAdmin.razor.cs similarity index 91% rename from CaPPMS/Pages/StudentReviews/Admin.razor.cs rename to CaPPMS/Pages/StudentReviews/StudentAdmin.razor.cs index e1b0885..c27bdf1 100644 --- a/CaPPMS/Pages/StudentReviews/Admin.razor.cs +++ b/CaPPMS/Pages/StudentReviews/StudentAdmin.razor.cs @@ -1,5 +1,4 @@ -using CaPPMS.Data; - +using CaPPMS.Model; using System.Collections.Generic; namespace CaPPMS.Pages.StudentReviews @@ -7,6 +6,7 @@ namespace CaPPMS.Pages.StudentReviews public partial class Admin { public List Students { get; set; } + public Admin() { Students = new List(); diff --git a/CaPPMS/Pages/StudentReviews/StudentReview.razor b/CaPPMS/Pages/StudentReviews/StudentReview.razor index 8aa1d79..2149f61 100644 --- a/CaPPMS/Pages/StudentReviews/StudentReview.razor +++ b/CaPPMS/Pages/StudentReviews/StudentReview.razor @@ -1,114 +1,99 @@ @using System.ComponentModel.DataAnnotations +@using System.Text.RegularExpressions +@using Humanizer @using Microsoft.AspNetCore.Components.Forms +@using Microsoft.Identity.Web + @using CaPPMS.Pages.StudentReviews -@using System.Text.RegularExpressions @namespace CaPPMS.Pages.StudentReviews @inject DBOperationsService DBOperationsService +@inject MicrosoftIdentityConsentAndConditionalAccessHandler consentHandler -@page "/StudentReviews/" +@attribute [Authorize] +@page "/StudentReviews" Weekly Peer Reviews - - - - -
-
Evaluations of Own Team Members
-
-

- To fairly acknowledge your team members for their contributions to the success of your team's project, each team member submits an evaluation for all team members (excluding yourself). This feedback about your team is required for Milestones 1 through 4. -

-

- Please provide a numeric value between 0 and 100 to your peers below for each milestone. If a team member is not contributing, the correct score for the team member is zero. If there are four such zeroes from the team members, the student is going to fail this course, no matter what he or she gets in other assessments. This is your way of getting your teammates attention. -

-
-
- - @**@ + + +
-
Student to be Evaluated
+
Student to be Evaluated
+
-
-

@StatusMessage

- -
-
+
+

@StatusMessage

+ +
+ + + +
+
+
+ +@if(GetCompletedReviewsAsync().GetAwaiter().GetResult().Any()) +{ - -
+} @code { - protected override void OnInitialized() + + private ConfirmationDialogBox confirmationDialogBox = new ConfirmationDialogBox(); + private List studentsToReview = new List(); + private Student loggedInStudent = new Student(); + private long selectedStudentId = -1; + private int studentId = -1; + private int StudentId { + get => studentId; + set + { + this.studentId = value; + OnStudentIdChangedAsync(value); + } + } + private string reviewWeek = "1"; + private string ReviewWeek + { + get => reviewWeek; + set + { + reviewWeek = value; + OnReviewWeekChangedAsync(this.reviewWeek); + } + } + private Review review = new Review(); + private Review Review + { + get => review; + set + { + review = value; + } + } + private string formId = "reviewForm"; + + protected override async Task OnInitializedAsync() + { + IEnumerable students = await DBOperationsService.GetRecords(); + foreach (Student student in students) + { + if (student.Email == consentHandler.User.Identity.Name) + { + loggedInStudent = student.Clone(); + break; + } + } + + studentsToReview = students.Where(s => s.TeamId == loggedInStudent.TeamId && s.StudentId != loggedInStudent.StudentId).ToList(); + review.StudentReviewId = loggedInStudent.StudentId; + this.OnReviewWeekChangedAsync(ReviewWeek); + await base.OnInitializedAsync(); + } + + private async void OnReviewWeekChangedAsync(string week) + { + await GetCompletedReviewAsync(); + this.StateHasChanged(); + } + + private async void OnStudentIdChangedAsync(int studentId) + { + this.selectedStudentId = studentId; + await GetCompletedReviewAsync(); + } + + private async Task> GetCompletedReviewsAsync() + { + long loggedInId = loggedInStudent.StudentId; + return await DBOperationsService.GetRecords(loggedInId, "ReviewedById"); + } + + private async Task GetCompletedReviewAsync() + { + Review? review = (await GetCompletedReviewsAsync()).FirstOrDefault(r => + { + int week = int.Parse(r.Week); + bool isMatch = + r.ReviewedStudentId == studentId && + string.Equals(week.ToString(), ReviewWeek, StringComparison.OrdinalIgnoreCase); + + return isMatch; + }); + + this.review = review ?? new Review(); + this.StateHasChanged(); } // Method to clear the status message @@ -186,48 +264,49 @@ ClearMessage(); // Clear the status message when a field gains focus } - public async Task HandleValidSubmit() + private async Task HandleValidSubmit() { - await SubmitEvaluation(); - - // After successful submission, await the display of a confirmation message - // The confirmation message indicates that the peer review submission was successful - DisplayMessage("Submission was successful"); - - review.ReviewedStudentId = -1; - review.Score = string.Empty; - review.Comments = string.Empty; + await ShowConfirmationMessage("Are you sure you want to submit this evaluation?"); } - private async Task ShowAlert(string message) + private async Task DeleteReview(Review review) { - // Call JavaScript function to display alert - // await JSRuntime.InvokeVoidAsync("displayAlert", message); - await Task.CompletedTask; + if (await DBOperationsService.RemoveRecord(review)) + { + this.StateHasChanged(); + } } - private bool IsValidScore(string score) //new method to validate the score input to + private async Task CompleteSubmission() { - if (int.TryParse(score, out int parsedScore)) //check to see if input can be parsed as an int in specific range + if (review.ReviewedById < 0) { - // Check if score is within the range of 0 to 100 - return parsedScore >= 0 && parsedScore <= 100; + review.ReviewedById = loggedInStudent.StudentId; } - else + + if (review.ReviewedStudentId < 0) { - // Return false if score is not a valid integer - return false; + review.ReviewedStudentId = StudentId; + } + + if (await SubmitEvaluation()) + { + // After successful submission, await the display of a confirmation message + // The confirmation message indicates that the peer review submission was successful + DisplayMessage("Submission was successful"); + + review = new Review() + { + ReviewedById = loggedInStudent.StudentId + }; // Clear the form fields after submission + + await GetCompletedReviewAsync(); } } private async Task ShowConfirmationMessage(string message) // Method declaration for showing a confirmation message asynchronously { - // Using the JSRuntime service to invoke a JavaScript function asynchronously - // The JavaScript function is named "displayAlert" and it takes a message parameter - // The message parameter contains the confirmation message to be displayed - // The method is awaited to ensure it completes before moving to the next operation - // await JSRuntime.InvokeVoidAsync("displayAlert", message); - + confirmationDialogBox.ShowPopup(message); await Task.CompletedTask; } } \ No newline at end of file diff --git a/CaPPMS/Pages/StudentReviews/StudentReview.razor.cs b/CaPPMS/Pages/StudentReviews/StudentReview.razor.cs index 6a322b9..7ec4375 100644 --- a/CaPPMS/Pages/StudentReviews/StudentReview.razor.cs +++ b/CaPPMS/Pages/StudentReviews/StudentReview.razor.cs @@ -1,147 +1,61 @@ -using CaPPMS.Data; -using Microsoft.Data.Sqlite; -using System; +using CaPPMS.Model; using System.Collections.Generic; +using System.Linq; using System.Threading.Tasks; namespace CaPPMS.Pages.StudentReviews { public partial class StudentReview { - const string _connectionString = "Data Source=Data\\StudentReviews.db"; - private string username = string.Empty; private string log = string.Empty; private int count = -1; private bool isSubmitButtonDisabled = true; - private string SelectedWeek = string.Empty; - - public StudentReview() - { - //int teamId = this.bOperationsService.RetrieveUsersTeam(username); - //ClassList = this.bOperationsService.RetrieveStudents(teamId); - //WeeksList = this.bOperationsService.RetrieveWeeks(); - } public Student student = new Student(); - public Review review = new Review(); - - public List completedReviews = new List(); - public List? ClassList { get; set; } public List? WeeksList { get; set; } public string? StatusMessage { get; set; } - public bool HidePanel { get; set; } = true; - - public async Task SubmitEvaluation() //method returns the tasks now making it awaitable for SubmitEvaluation confirmation + /// + /// Submit the evaluation. + /// + /// true if successful. + public async Task SubmitEvaluation() { - try + string? studentName = await LookupStudentAsync(review.ReviewedStudentId); + if (string.IsNullOrEmpty(studentName)) { - await Task.Run(() => //database operation now wrapped insided 'Task.Run' allowing to await method call -preserves the synchronous behavior while making method awaitable - { - using (SqliteConnection connection = new(_connectionString)) - { - connection.Open(); - - string sql = @"INSERT INTO StudentReviews - (ReviewedStudentId, ReviewersEmail, Week, Score, Comments) - VALUES (@ReviewedStudentId, @ReviewersEmail, @Week, @Score, @Comments)"; - - using (SqliteCommand command = new(sql, connection)) - { - command.Parameters.AddWithValue("@ReviewedStudentId", review.ReviewedStudentId); - command.Parameters.AddWithValue("@ReviewersEmail", username); - command.Parameters.AddWithValue("@Week", review.Week); - command.Parameters.AddWithValue("@Score", review.Score); - command.Parameters.AddWithValue("@Comments", review.Comments); - - // Execute the query - int rowsAffected = command.ExecuteNonQuery(); - - if (rowsAffected > 0) - { - Student? foundStudent = ClassList?.Find(student => student.StudentId == review.ReviewedStudentId); - - if (foundStudent != null) - { - completedReviews.Add(new CompletedReviews() - { - Week = review.Week, - RatedStudent = LookupStudent(review.ReviewedStudentId), - Score = review.Score, - Comments = review.Comments - }); - } + return false; + } - HidePanel = false; - } - else - { - Console.WriteLine("No rows affected. Data insertion failed."); - } - } - } - }); + if (!string.IsNullOrEmpty(reviewWeek)) + { + review.Week = reviewWeek; } - catch (Exception ex) + + if (await DBOperationsService.AddRecord(review)) { - throw new Exception($"Error inserting data: {ex.Message}"); + review.RatedStudent = studentName; + return true; } + + return false; } - public string LookupStudent(int studentId) + public async Task LookupStudentAsync(long studentId) { - Student student = new Student(); + Student? student = (await DBOperationsService.GetRecords(studentId)).FirstOrDefault(); - using (SqliteConnection connection = new SqliteConnection(_connectionString)) + if (student == null) { - try - { - connection.Open(); - - string query = "SELECT FirstName, LastName FROM Students WHERE StudentId = @studentId"; - - using (SqliteCommand command = new(query, connection)) - { - command.Parameters.AddWithValue("@studentId", studentId); - - using (SqliteDataReader reader = command.ExecuteReader()) - { - while (reader.Read()) - { - student.FirstName = reader["FirstName"].NullSafeToString(); - student.LastName = reader["LastName"].NullSafeToString(); - } - } - } - } - catch (Exception ex) - { - Console.WriteLine($"Error accessing the database: {ex.Message}"); - } - finally - { - connection.Close(); - } + return null; } return student.FirstName + " " + student.LastName; } - - } - - public struct CompletedReviews - { - public string RatedStudent { get; set; } - - public string Week { get; set; } - - public string Score { get; set; } - - public string Comments { get; set; } } } \ No newline at end of file diff --git a/CaPPMS/Pages/Users.razor b/CaPPMS/Pages/Users.razor index 58de4ab..380d95c 100644 --- a/CaPPMS/Pages/Users.razor +++ b/CaPPMS/Pages/Users.razor @@ -1,5 +1,4 @@ @using System.Diagnostics -@using Extensions; @using Microsoft.AspNetCore.Authorization @using Microsoft.Graph @using Microsoft.Identity.Web diff --git a/CaPPMS/SQLScripts/StudentDataBaseCreation.sql b/CaPPMS/SQLScripts/StudentDataBaseCreation.sql index ef4587a..ba90092 100644 --- a/CaPPMS/SQLScripts/StudentDataBaseCreation.sql +++ b/CaPPMS/SQLScripts/StudentDataBaseCreation.sql @@ -1,25 +1,40 @@ -CREATE TABLE "StudentReviews" ( +CREATE TABLE "StudentReviews" +( "StudentReviewId" INTEGER NOT NULL, + "ReviewedById" INTEGER, "ReviewedStudentId" INTEGER, - "ReviewersEmail" TEXT, "Week" TEXT, - "Score" INTEGER, + "Score" TEXT, "Comments" TEXT, PRIMARY KEY("StudentReviewId" AUTOINCREMENT) ); -CREATE TABLE "Students" ( +CREATE TABLE "Students" +( "StudentId" INTEGER, "FirstName" TEXT NOT NULL, "LastName" TEXT NOT NULL, "Email" TEXT, - "TeamId" INTEGER, + "GitHub" TEXT, + "TeamId" INTEGER, + "ClassId" INTEGER, PRIMARY KEY("StudentId" AUTOINCREMENT) ); -CREATE TABLE "Teams" ( - "TeamId" INTEGER NOT NULL, - "TeamName" TEXT NOT NULL, - PRIMARY KEY("TeamId") +CREATE TABLE "Teams" +( + "TeamId" INTEGER NOT NULL, + "Name" TEXT NOT NULL, + "ClassId" INTEGER NOT NULL, + PRIMARY KEY("TeamId" AUTOINCREMENT) ); +CREATE TABLE "ClassInformation" +( + "ClassId" INTEGER NOT NULL, + "Cohort" TEXT NOT NULL, + "Course" TEXT NOT NULL, + "StartDate" Date, + "EndDate" Date, + PRIMARY KEY("ClassId" AUTOINCREMENT) +); \ No newline at end of file diff --git a/CaPPMS/Shared/AddNewCourse.razor b/CaPPMS/Shared/AddNewCourse.razor new file mode 100644 index 0000000..8043573 --- /dev/null +++ b/CaPPMS/Shared/AddNewCourse.razor @@ -0,0 +1,77 @@ +@using Microsoft.Identity.Web +@using MudBlazor +@inject DBOperationsService DBOperationsService +@inject MicrosoftIdentityConsentAndConditionalAccessHandler consentHandler + +@if (consentHandler.User != null && !consentHandler.User.IsAdmin()) +{ +

You are not authorized to view this information.

+ return; +} + +
+
+ + + + + +
+
+ +@code { + private ClassInformation classInformation = new ClassInformation(); + private bool userHadSetEndDate = false; + + [Parameter] + public EventCallback OnTableUpdated { get; set; } + + protected override async Task OnInitializedAsync() + { + await base.OnInitializedAsync(); + } + + private async Task AddNewCourseAsync() + { + if (await DBOperationsService.AddRecord(classInformation)) + { + ClassInformation? createdClass = null; + ClassInformationComparer comparer = new ClassInformationComparer(); + foreach (ClassInformation cohort in await DBOperationsService.GetRecords()) + { + if (comparer.Compare(cohort, classInformation) == 0) + { + createdClass = cohort; + break; + } + } + + if (createdClass != null) + { + // Create 5 default teams for the class. + // TODO: Make this configurable. + for (int i = 1; i <= 5; i++) + { + await DBOperationsService.AddRecord(new Team + { + Name = $"Team {(char)(i + 64)}", + ClassId = createdClass.ClassId + }); + } + } + } + + await OnTableUpdated.InvokeAsync(); + + await RefreshPage(); + } + + private async Task RefreshPage() + { + await this.InvokeAsync(() => + { + classInformation = new ClassInformation(); + this.StateHasChanged(); + }); + } +} diff --git a/CaPPMS/Shared/ConfirmationDialogBox.razor b/CaPPMS/Shared/ConfirmationDialogBox.razor index 4e64399..1a34c24 100644 --- a/CaPPMS/Shared/ConfirmationDialogBox.razor +++ b/CaPPMS/Shared/ConfirmationDialogBox.razor @@ -1,4 +1,7 @@ - +@if (this.ShowCommandButton) +{ + +} @if (Show) { @@ -8,11 +11,14 @@

+ @if (ShowCancelButton) + { +
+ +
+ }
- -
-
- +
@@ -20,19 +26,36 @@ } @code { - public bool Show { get; set; } + + [Parameter] public string ConfirmationButtonText { get; set; } = "Confirm"; + [Parameter] public bool ShowCancelButton { get; set; } = true; + [Parameter] public string CancelButtonText { get; set; } = "Cancel"; [Parameter] public string ButtonClass { get; set; } = ""; [Parameter] public string Command { get; set; } = "Action"; + [Parameter] public bool ShowCommandButton { get; set; } = true; [Parameter] public string Prompt { get; set; } = "Are you sure?"; [Parameter] public EventCallback ConfirmedChanged { get; set; } + public bool Show { get; set; } + public async Task Confirmation(bool value) { Show = false; await ConfirmedChanged.InvokeAsync(value); } - public void ShowPop() + + public void ShowPopup() { Show = true; } + + public void ShowPopup(string? message) + { + if (!string.IsNullOrEmpty(message)) + { + Prompt = message; + } + + this.ShowPopup(); + } } \ No newline at end of file diff --git a/CaPPMS/Shared/PopOver.razor b/CaPPMS/Shared/PopOver.razor new file mode 100644 index 0000000..9559aad --- /dev/null +++ b/CaPPMS/Shared/PopOver.razor @@ -0,0 +1,35 @@ +@typeparam T where T : class + +@if (Show) +{ +
+ +

@Title

+ +
+ @ChildContent +
+
+} + +@code { + private bool show = false; + + [Parameter] + public bool Show { get; set; } + + [Parameter] + public string Title { get; set; } = "PopOver Title"; + + [Parameter] + public RenderFragment? ChildContent { get; set; } + + [Parameter] + public T? Context { get; set; } + + public Task Close(MouseEventArgs e) + { + Show = false; + return Task.CompletedTask; + } +} diff --git a/CaPPMS/Shared/ProjectTable.razor b/CaPPMS/Shared/ProjectTable.razor index 1c2bfe7..e3d0220 100644 --- a/CaPPMS/Shared/ProjectTable.razor +++ b/CaPPMS/Shared/ProjectTable.razor @@ -2,7 +2,7 @@ @using CaPPMS.Model @using CaPPMS.Model.Table @namespace CaPPMS.Shared -@inherits CaPPMS.Model.Table.Table +@inherits CaPPMS.Model.Table.Table @implements IIdea

Project List

@@ -41,9 +41,9 @@ else { - @foreach (var cell in row) @@ -52,7 +52,7 @@ else } - @if (!projectListCollapseMap[row.DataBoundItem as ProjectInformation]) + @if (!projectListCollapseMap[row.DataBoundItem]) { @@ -60,15 +60,15 @@ else { + CollapseEntry="@(projectListCollapseMap[row.DataBoundItem] ? "collapse" : "")" + ProjectInformation="row.DataBoundItem"> - + } else { - + } @@ -196,11 +196,11 @@ else } projectListCollapseMap.Clear(); - foreach (Row row in this.GetRows()) + foreach (Row row in this.GetRows()) { - if (!projectListCollapseMap.ContainsKey(row.DataBoundItem as ProjectInformation)) + if (!projectListCollapseMap.ContainsKey(row.DataBoundItem)) { - ProjectInformation newInfo = row.DataBoundItem as ProjectInformation; + ProjectInformation newInfo = row.DataBoundItem; projectListCollapseMap.Add(newInfo, true); if (oldCollapseMap.ContainsKey(newInfo.ProjectID)) { diff --git a/CaPPMS/Shared/TableComponent.razor b/CaPPMS/Shared/TableComponent.razor new file mode 100644 index 0000000..dc1e1d7 --- /dev/null +++ b/CaPPMS/Shared/TableComponent.razor @@ -0,0 +1,52 @@ +@typeparam T where T : class, new() +@inherits Table + +

@TableName

+ +@if (this.HeaderRow.Count == 0) +{ +
Loading ...
+ return; +} + +
+ + + + + @foreach (var header in this.HeaderRow) + { + + } + + + + @foreach (var row in this.GetRows()) + { + + @foreach (var cell in row) + { + @((MarkupString)cell.ToString()) + } + + } + +
+ @header.Value +
+
+ +@code { + [Parameter] + public string TableName { get; set; } = typeof(T).Name; + + [Parameter] + public string TableCssClasses { get; set; } = "table"; +} \ No newline at end of file diff --git a/CaPPMS/Shared/UMGCHeader.razor b/CaPPMS/Shared/UMGCHeader.razor index d992a25..dc6114f 100644 --- a/CaPPMS/Shared/UMGCHeader.razor +++ b/CaPPMS/Shared/UMGCHeader.razor @@ -1,4 +1,6 @@ -
+@using Microsoft.Identity.Web +@inject MicrosoftIdentityConsentAndConditionalAccessHandler consentHandler +