diff --git a/Todo.Tests/ApiControllers/TodoItemApiControllerTests.cs b/Todo.Tests/ApiControllers/TodoItemApiControllerTests.cs new file mode 100644 index 00000000..c76c668c --- /dev/null +++ b/Todo.Tests/ApiControllers/TodoItemApiControllerTests.cs @@ -0,0 +1,80 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using System.Threading.Tasks; +using Todo.Controllers; +using Todo.Data; +using Todo.Data.Entities; +using Xunit; + +namespace Todo.Tests.Controllers +{ + public class TodoItemApiControllerTests + { + private readonly ApplicationDbContext _context; + private readonly TodoItemApiController _controller; + + public TodoItemApiControllerTests() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(databaseName: "TodoDatabase") + .Options; + + _context = new ApplicationDbContext(options); + _controller = new TodoItemApiController(_context); + } + + [Fact] + public async Task Create_ValidRequest_ReturnsOkResult() + { + // Arrange + var request = new TodoItemRequest + { + TodoListId = 1, + Title = "Test Title", + ResponsiblePartyId = "user1", + Importance = Importance.Medium, + Rank = 1 + }; + + // Act + var result = await _controller.Create(request) as ActionResult; + + // Assert + Assert.NotNull(result); + var okResult = result as OkObjectResult; + Assert.NotNull(okResult); + Assert.Equal(200, okResult.StatusCode); + + var createdItem = okResult.Value as TodoItem; + Assert.NotNull(createdItem); + Assert.Equal(request.Title, createdItem.Title); + Assert.Equal(request.ResponsiblePartyId, createdItem.ResponsiblePartyId); + Assert.Equal(request.Importance, createdItem.Importance); + Assert.Equal(request.Rank, createdItem.Rank); + } + + [Fact] + public async Task Create_InvalidRequest_ReturnsBadRequest() + { + // Arrange + var request = new TodoItemRequest + { + TodoListId = 1, + Title = "", // Invalid title + ResponsiblePartyId = "user1", + Importance = Importance.Medium, + Rank = 1 + }; + + // Act + var result = await _controller.Create(request) as ActionResult; + + // Assert + Assert.NotNull(result); + var badRequestResult = result as BadRequestObjectResult; + Assert.NotNull(badRequestResult); + Assert.Equal(400, badRequestResult.StatusCode); + Assert.Equal("Invalid data.", badRequestResult.Value); + } + } +} \ No newline at end of file diff --git a/Todo.Tests/Services/TestGravatar.cs b/Todo.Tests/Services/TestGravatar.cs new file mode 100644 index 00000000..9c57372d --- /dev/null +++ b/Todo.Tests/Services/TestGravatar.cs @@ -0,0 +1,71 @@ +using System; +using System.Net.Http; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using System.Threading.Tasks; + +namespace Todo.Tests.Services +{ + public static class Gravatar + { + private static HttpClient _httpClient = new HttpClient(); + + // Property to allow setting HttpClient for testing + public static HttpClient HttpClientInstance + { + get => _httpClient; + set => _httpClient = value; + } + + public static async Task GetGravatarProfileNameAsync(string email) + { + try + { + var emailHash = GetHash(email.Trim().ToLower()); + var requestUrl = $"https://en.gravatar.com/{emailHash}.json"; + + var response = await _httpClient.GetAsync(requestUrl); + if (response.IsSuccessStatusCode) + { + var jsonResponse = await response.Content.ReadAsStringAsync(); + var gravatarProfile = JsonSerializer.Deserialize(jsonResponse); + return gravatarProfile?.entry?[0]?.displayName ?? email; + } + } + catch (Exception ex) + { + throw new Exception(ex.Message, ex); + } + + return email; // Fallback to email if something goes wrong + } + + public static string GetHash(string emailAddress) + { + using (var md5 = MD5.Create()) + { + var inputBytes = Encoding.Default.GetBytes(emailAddress.Trim().ToLowerInvariant()); + var hashBytes = md5.ComputeHash(inputBytes); + + var builder = new StringBuilder(); + foreach (var b in hashBytes) + { + builder.Append(b.ToString("X2")); + } + return builder.ToString().ToLowerInvariant(); + } + } + + private class GravatarProfile + { + public GravatarEntry[] entry { get; set; } + } + + private class GravatarEntry + { + public string displayName { get; set; } + } + } +} + diff --git a/Todo.Tests/TestTodoListBuilder.cs b/Todo.Tests/TestTodoListBuilder.cs index 85be83c3..9ca5bc09 100644 --- a/Todo.Tests/TestTodoListBuilder.cs +++ b/Todo.Tests/TestTodoListBuilder.cs @@ -12,7 +12,7 @@ public class TestTodoListBuilder { private readonly string title; private readonly IdentityUser owner; - private readonly List<(string, Importance)> items = new List<(string, Importance)>(); + private readonly List<(string, Importance, int)> items = new List<(string, Importance, int)>(); public TestTodoListBuilder(IdentityUser owner, string title) { @@ -20,16 +20,16 @@ public TestTodoListBuilder(IdentityUser owner, string title) this.owner = owner; } - public TestTodoListBuilder WithItem(string itemTitle, Importance importance) + public TestTodoListBuilder WithItem(string itemTitle, Importance importance, int rank) { - items.Add((itemTitle, importance)); + items.Add((itemTitle, importance, rank)); return this; } public TodoList Build() { var todoList = new TodoList(owner, title); - var todoItems = items.Select(itm => new TodoItem(todoList.TodoListId, owner.Id, itm.Item1, itm.Item2)); + var todoItems = items.Select(itm => new TodoItem(todoList.TodoListId, owner.Id, itm.Item1, itm.Item2, itm.Item3)); todoItems.ToList().ForEach(tlItm => { todoList.Items.Add(tlItm); diff --git a/Todo.Tests/Todo.Tests.csproj b/Todo.Tests/Todo.Tests.csproj index 726a5e71..6cb3863a 100644 --- a/Todo.Tests/Todo.Tests.csproj +++ b/Todo.Tests/Todo.Tests.csproj @@ -10,8 +10,10 @@ + + diff --git a/Todo.Tests/WhenTodoItemIsConvertedToEditFields.cs b/Todo.Tests/WhenTodoItemIsConvertedToEditFields.cs index 2025be1b..78c82fab 100644 --- a/Todo.Tests/WhenTodoItemIsConvertedToEditFields.cs +++ b/Todo.Tests/WhenTodoItemIsConvertedToEditFields.cs @@ -15,7 +15,7 @@ public class WhenTodoItemIsConvertedToEditFields public WhenTodoItemIsConvertedToEditFields() { var todoList = new TestTodoListBuilder(new IdentityUser("alice@example.com"), "shopping") - .WithItem("bread", Importance.High) + .WithItem("bread", Importance.High, 10) .Build() ; diff --git a/Todo/ApiControllers/TodoItemApiController.cs b/Todo/ApiControllers/TodoItemApiController.cs new file mode 100644 index 00000000..f7966d46 --- /dev/null +++ b/Todo/ApiControllers/TodoItemApiController.cs @@ -0,0 +1,59 @@ +using Microsoft.AspNetCore.Mvc; +using System; +using System.Threading.Tasks; +using Todo.Data; +using Todo.Data.Entities; + +namespace Todo.Controllers +{ + [ApiController] + [Route("api/[controller]")] + + public class TodoItemApiController : ControllerBase + { + private readonly ApplicationDbContext _context; + + public TodoItemApiController(ApplicationDbContext context) + { + _context = context; + } + + [HttpPost] + public async Task Create(TodoItemRequest request) + { + try + { + if (request == null || string.IsNullOrEmpty(request.Title)) + { + return BadRequest("Invalid data."); + } + + var todoItem = new TodoItem(request.TodoListId, request.ResponsiblePartyId, request.Title, request.Importance, request.Rank) + { + Rank = request.Rank, + IsDone = false + }; + + _context.TodoItems.Add(todoItem); + await _context.SaveChangesAsync(); + + return Ok(todoItem); + } + catch (Exception ex) + { + + return NotFound(); + throw ex; + } + } + } +} + +public class TodoItemRequest +{ + public int TodoListId { get; set; } + public string Title { get; set; } + public string ResponsiblePartyId { get; set; } + public Importance Importance { get; set; } + public int Rank { get; set; } +} \ No newline at end of file diff --git a/Todo/Controllers/TodoItemController.cs b/Todo/Controllers/TodoItemController.cs index e4a44fce..a82f9d45 100644 --- a/Todo/Controllers/TodoItemController.cs +++ b/Todo/Controllers/TodoItemController.cs @@ -1,6 +1,6 @@ -using System.Threading.Tasks; -using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; +using System.Threading.Tasks; using Todo.Data; using Todo.Data.Entities; using Todo.EntityModelMappers.TodoItems; @@ -33,7 +33,7 @@ public async Task Create(TodoItemCreateFields fields) { if (!ModelState.IsValid) { return View(fields); } - var item = new TodoItem(fields.TodoListId, fields.ResponsiblePartyId, fields.Title, fields.Importance); + var item = new TodoItem(fields.TodoListId, fields.ResponsiblePartyId, fields.Title, fields.Importance, fields.Rank); await dbContext.AddAsync(item); await dbContext.SaveChangesAsync(); diff --git a/Todo/Controllers/TodoListController.cs b/Todo/Controllers/TodoListController.cs index 04548080..2abad0c1 100644 --- a/Todo/Controllers/TodoListController.cs +++ b/Todo/Controllers/TodoListController.cs @@ -1,8 +1,8 @@ -using System.Threading; -using System.Threading.Tasks; -using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; +using System.Threading; +using System.Threading.Tasks; using Todo.Data; using Todo.Data.Entities; using Todo.EntityModelMappers.TodoLists; @@ -31,10 +31,22 @@ public IActionResult Index() return View(viewmodel); } - public IActionResult Detail(int todoListId) + public async Task Detail(int todoListId, string sortOrder = "Importance") { var todoList = dbContext.SingleTodoList(todoListId); + var viewmodel = TodoListDetailViewmodelFactory.Create(todoList); + + viewmodel.CurrentSort = sortOrder; + + foreach (var item in viewmodel.Items) + { + var gravatarName = await Gravatar.GetGravatarProfileNameAsync(item.ResponsibleParty.Email); + + item.ResponsibleParty.GravatarName = ((gravatarName == null) || (gravatarName == item.ResponsibleParty.Email)) + ? item.ResponsibleParty.Email : gravatarName; + } + return View(viewmodel); } diff --git a/Todo/Data/Entities/Importance.cs b/Todo/Data/Entities/Importance.cs index 2a39bf83..a7d05f94 100644 --- a/Todo/Data/Entities/Importance.cs +++ b/Todo/Data/Entities/Importance.cs @@ -1,8 +1,8 @@ namespace Todo.Data.Entities { public enum Importance { - High, - Medium, - Low, + High = 0, + Medium = 1, + Low = 2, } } \ No newline at end of file diff --git a/Todo/Data/Entities/TodoItem.cs b/Todo/Data/Entities/TodoItem.cs index 4e08ea41..feb9f97d 100644 --- a/Todo/Data/Entities/TodoItem.cs +++ b/Todo/Data/Entities/TodoItem.cs @@ -8,6 +8,7 @@ public class TodoItem public string ResponsiblePartyId { get; set; } public IdentityUser ResponsibleParty { get; set; } public bool IsDone { get; set; } + public int Rank { get; set; } public Importance Importance { get; set; } public int TodoListId { get; set; } @@ -15,12 +16,13 @@ public class TodoItem protected TodoItem() { } - public TodoItem(int todoListId, string responsiblePartyId, string title, Importance importance) + public TodoItem(int todoListId, string responsiblePartyId, string title, Importance importance, int rank) { TodoListId = todoListId; ResponsiblePartyId = responsiblePartyId; Title = title; Importance = importance; + Rank = rank; } } } \ No newline at end of file diff --git a/Todo/EntityModelMappers/TodoItems/TodoItemEditFieldsFactory.cs b/Todo/EntityModelMappers/TodoItems/TodoItemEditFieldsFactory.cs index 6d3e893b..d9563d4c 100644 --- a/Todo/EntityModelMappers/TodoItems/TodoItemEditFieldsFactory.cs +++ b/Todo/EntityModelMappers/TodoItems/TodoItemEditFieldsFactory.cs @@ -9,7 +9,7 @@ public static TodoItemEditFields Create(TodoItem todoItem) { var todoList = todoItem.TodoList; return new TodoItemEditFields(todoList.TodoListId, todoList.Title, todoItem.TodoItemId, todoItem.Title, - todoItem.IsDone, todoItem.ResponsiblePartyId, todoItem.Importance); + todoItem.IsDone, todoItem.ResponsiblePartyId, todoItem.Importance, todoItem.Rank); } public static void Update(TodoItemEditFields src, TodoItem dest) @@ -18,6 +18,7 @@ public static void Update(TodoItemEditFields src, TodoItem dest) dest.IsDone = src.IsDone; dest.ResponsiblePartyId = src.ResponsiblePartyId; dest.Importance = src.Importance; + dest.Rank = src.Rank; } } } \ No newline at end of file diff --git a/Todo/EntityModelMappers/TodoItems/TodoItemSummaryViewmodelFactory.cs b/Todo/EntityModelMappers/TodoItems/TodoItemSummaryViewmodelFactory.cs index c223577b..7cf82c5f 100644 --- a/Todo/EntityModelMappers/TodoItems/TodoItemSummaryViewmodelFactory.cs +++ b/Todo/EntityModelMappers/TodoItems/TodoItemSummaryViewmodelFactory.cs @@ -7,7 +7,7 @@ public static class TodoItemSummaryViewmodelFactory { public static TodoItemSummaryViewmodel Create(TodoItem ti) { - return new TodoItemSummaryViewmodel(ti.TodoItemId, ti.Title, ti.IsDone, UserSummaryViewmodelFactory.Create(ti.ResponsibleParty), ti.Importance); + return new TodoItemSummaryViewmodel(ti.TodoItemId, ti.Title, ti.IsDone, UserSummaryViewmodelFactory.Create(ti.ResponsibleParty), ti.Importance, ti.Rank); } } } \ No newline at end of file diff --git a/Todo/Models/TodoItems/TodoItemCreateFields.cs b/Todo/Models/TodoItems/TodoItemCreateFields.cs index dfaed4af..f431100e 100644 --- a/Todo/Models/TodoItems/TodoItemCreateFields.cs +++ b/Todo/Models/TodoItems/TodoItemCreateFields.cs @@ -1,4 +1,5 @@ -using Todo.Data.Entities; +using System.ComponentModel.DataAnnotations; +using Todo.Data.Entities; namespace Todo.Models.TodoItems { @@ -7,8 +8,11 @@ public class TodoItemCreateFields public int TodoListId { get; set; } public string Title { get; set; } public string TodoListTitle { get; set; } + + [Display(Name = "Responsible Party")] public string ResponsiblePartyId { get; set; } public Importance Importance { get; set; } = Importance.Medium; + public int Rank { get; set; } = 0; public TodoItemCreateFields() { } diff --git a/Todo/Models/TodoItems/TodoItemEditFields.cs b/Todo/Models/TodoItems/TodoItemEditFields.cs index 43c970ef..d99caed3 100644 --- a/Todo/Models/TodoItems/TodoItemEditFields.cs +++ b/Todo/Models/TodoItems/TodoItemEditFields.cs @@ -1,4 +1,5 @@ -using Todo.Data.Entities; +using System.ComponentModel.DataAnnotations; +using Todo.Data.Entities; namespace Todo.Models.TodoItems { @@ -9,12 +10,15 @@ public class TodoItemEditFields public string TodoListTitle { get; set; } public int TodoItemId { get; set; } public bool IsDone { get; set; } + + [Display(Name = "Responsible Party")] public string ResponsiblePartyId { get; set; } public Importance Importance { get; set; } + public int Rank { get; set; } public TodoItemEditFields() { } - public TodoItemEditFields(int todoListId, string todoListTitle, int todoItemId, string title, bool isDone, string responsiblePartyId, Importance importance) + public TodoItemEditFields(int todoListId, string todoListTitle, int todoItemId, string title, bool isDone, string responsiblePartyId, Importance importance, int rank) { TodoListId = todoListId; TodoListTitle = todoListTitle; @@ -22,7 +26,8 @@ public TodoItemEditFields(int todoListId, string todoListTitle, int todoItemId, Title = title; IsDone = isDone; ResponsiblePartyId = responsiblePartyId; - Importance = Importance.Medium; + Importance = importance; + Rank = rank; } } } \ No newline at end of file diff --git a/Todo/Models/TodoItems/TodoItemSummaryViewmodel.cs b/Todo/Models/TodoItems/TodoItemSummaryViewmodel.cs index 934010a7..844852b4 100644 --- a/Todo/Models/TodoItems/TodoItemSummaryViewmodel.cs +++ b/Todo/Models/TodoItems/TodoItemSummaryViewmodel.cs @@ -9,14 +9,16 @@ public class TodoItemSummaryViewmodel public UserSummaryViewmodel ResponsibleParty { get; } public bool IsDone { get; } public Importance Importance { get; } + public int Rank { get; } - public TodoItemSummaryViewmodel(int todoItemId, string title, bool isDone, UserSummaryViewmodel responsibleParty, Importance importance) + public TodoItemSummaryViewmodel(int todoItemId, string title, bool isDone, UserSummaryViewmodel responsibleParty, Importance importance, int rank) { TodoItemId = todoItemId; Title = title; IsDone = isDone; ResponsibleParty = responsibleParty; Importance = importance; + Rank = rank; } } } \ No newline at end of file diff --git a/Todo/Models/TodoItems/UserSummaryViewmodel.cs b/Todo/Models/TodoItems/UserSummaryViewmodel.cs index eb31b5ed..9bf44e76 100644 --- a/Todo/Models/TodoItems/UserSummaryViewmodel.cs +++ b/Todo/Models/TodoItems/UserSummaryViewmodel.cs @@ -4,6 +4,7 @@ public class UserSummaryViewmodel { public string UserName { get; } public string Email { get; } + public string GravatarName { get; set; } public UserSummaryViewmodel(string userName, string email) { diff --git a/Todo/Models/TodoLists/TodoListDetailViewmodel.cs b/Todo/Models/TodoLists/TodoListDetailViewmodel.cs index 15777277..91a0ab20 100644 --- a/Todo/Models/TodoLists/TodoListDetailViewmodel.cs +++ b/Todo/Models/TodoLists/TodoListDetailViewmodel.cs @@ -9,6 +9,8 @@ public class TodoListDetailViewmodel public string Title { get; } public ICollection Items { get; } + public string CurrentSort { get; set; } + public TodoListDetailViewmodel(int todoListId, string title, ICollection items) { Items = items; diff --git a/Todo/Services/ApplicationDbContextConvenience.cs b/Todo/Services/ApplicationDbContextConvenience.cs index 3549d4b3..4a99beda 100644 --- a/Todo/Services/ApplicationDbContextConvenience.cs +++ b/Todo/Services/ApplicationDbContextConvenience.cs @@ -11,7 +11,8 @@ public static IQueryable RelevantTodoLists(this ApplicationDbContext d { return dbContext.TodoLists.Include(tl => tl.Owner) .Include(tl => tl.Items) - .Where(tl => tl.Owner.Id == userId); + .Where(tl => tl.Owner.Id == userId || tl.Items.Any(item => item.ResponsiblePartyId == userId)) + .Distinct(); } public static TodoList SingleTodoList(this ApplicationDbContext dbContext, int todoListId) diff --git a/Todo/Services/Gravatar.cs b/Todo/Services/Gravatar.cs index 81ac8efe..642ad58b 100644 --- a/Todo/Services/Gravatar.cs +++ b/Todo/Services/Gravatar.cs @@ -1,10 +1,39 @@ -using System.Security.Cryptography; +using System.Net.Http; +using System; +using System.Security.Cryptography; using System.Text; +using System.Threading.Tasks; +using System.Text.Json; namespace Todo.Services { public static class Gravatar { + private static readonly HttpClient _httpClient = new HttpClient(); + + public static async Task GetGravatarProfileNameAsync(string email) + { + try + { + var emailHash = GetHash(email.Trim().ToLower()); + var requestUrl = $"https://en.gravatar.com/{emailHash}.json"; + + var response = await _httpClient.GetAsync(requestUrl); + if (response.IsSuccessStatusCode) + { + var jsonResponse = await response.Content.ReadAsStringAsync(); + var gravatarProfile = JsonSerializer.Deserialize(jsonResponse); + return gravatarProfile?.entry?[0]?.displayName ?? email; + } + } + catch (Exception ex) + { + throw new Exception(ex.Message, ex); + } + + return email; // Fallback to email if something goes wrong + } + public static string GetHash(string emailAddress) { using (var md5 = MD5.Create()) @@ -20,5 +49,15 @@ public static string GetHash(string emailAddress) return builder.ToString().ToLowerInvariant(); } } + + private class GravatarProfile + { + public GravatarEntry[] entry { get; set; } + } + + private class GravatarEntry + { + public string displayName { get; set; } + } } } \ No newline at end of file diff --git a/Todo/Startup.cs b/Todo/Startup.cs index e071f3c8..1c367906 100644 --- a/Todo/Startup.cs +++ b/Todo/Startup.cs @@ -36,6 +36,9 @@ public void ConfigureServices(IServiceCollection services) .AddEntityFrameworkStores(); services.AddMvc(options => options.EnableEndpointRouting = false).SetCompatibilityVersion(CompatibilityVersion.Version_3_0); + + // Add this line to support API controllers + services.AddControllers(); } // This method gets called by the runtime. Use this method to configure the HTTP request pipeline. @@ -64,6 +67,13 @@ public void Configure(IApplicationBuilder app, IWebHostEnvironment env) name: "default", template: "{controller=Home}/{action=Index}/{id?}"); }); + + // Add this line to map attribute-routed API controllers + app.UseRouting(); + app.UseEndpoints(endpoints => + { + endpoints.MapControllers(); + }); } } } diff --git a/Todo/Todo.csproj b/Todo/Todo.csproj index 6e31e445..4c00ec7a 100644 --- a/Todo/Todo.csproj +++ b/Todo/Todo.csproj @@ -1,4 +1,4 @@ - + netcoreapp3.1 @@ -10,6 +10,11 @@ + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + diff --git a/Todo/Views/TodoItem/Create.cshtml b/Todo/Views/TodoItem/Create.cshtml index 0b1b7c3b..30371b58 100644 --- a/Todo/Views/TodoItem/Create.cshtml +++ b/Todo/Views/TodoItem/Create.cshtml @@ -38,6 +38,13 @@ + +
+ + + +
+
@Html.DropDownListFor( diff --git a/Todo/Views/TodoItem/Edit.cshtml b/Todo/Views/TodoItem/Edit.cshtml index 5668a213..ff2c8b8d 100644 --- a/Todo/Views/TodoItem/Edit.cshtml +++ b/Todo/Views/TodoItem/Edit.cshtml @@ -39,6 +39,12 @@ )
+ +
+ + + +
+ + + \ No newline at end of file