From 442af965c654cecafb4af53ca70b6e3b09921b2a Mon Sep 17 00:00:00 2001 From: Joe Milazzo Date: Mon, 10 Oct 2022 12:59:20 -0500 Subject: [PATCH] Restricted Profiles (#1581) * Added ReadingList age rating from all series and started on some unit tests for the new flows. * Wrote more unit tests for Reading Lists * Added ability to restrict user accounts to a given age rating via admin edit user modal and invite user. This commit contains all basic code, but no query modifications. * When updating a reading list's title via UI, explicitly check if there is an existing RL with the same title. * Refactored Reading List calculation to work properly in the flows it's invoked from. * Cleaned up an unused method * Promoted Collections no longer show tags where a Series exists within them that is above the user's age rating. * Collection search now respects age restrictions * Series Detail page now checks if the user has explicit access (as a user might bypass with direct url access) * Hooked up age restriction for dashboard activity streams. * Refactored some methods from Series Controller and Library Controller to a new Search Controller to keep things organized * Updated Search to respect age restrictions * Refactored all the Age Restriction queries to extensions * Related Series no longer show up if they are out of the age restriction * Fixed a bad mapping for the update age restriction api * Fixed a UI state change after updating age restriction * Fixed unit test * Added a migration for reading lists * Code cleanup --- API.Tests/Services/ReadingListServiceTests.cs | 376 +++- API/Constants/PolicyConstants.cs | 6 +- API/Controllers/AccountController.cs | 34 +- API/Controllers/CollectionController.cs | 8 +- API/Controllers/LibraryController.cs | 17 - API/Controllers/MetadataController.cs | 6 +- API/Controllers/OPDSController.cs | 6 +- API/Controllers/ReadingListController.cs | 12 +- API/Controllers/SearchController.cs | 67 + API/Controllers/SeriesController.cs | 27 +- API/DTOs/Account/InviteUserDto.cs | 5 + API/DTOs/Account/UpdateAgeRestrictionDto.cs | 10 + API/DTOs/Account/UpdateUserDto.cs | 7 + API/DTOs/MemberDto.cs | 6 + API/DTOs/UserDto.cs | 6 + API/Data/MigrateChangeRestrictionRoles.cs | 36 + API/Data/MigrateReadingListAgeRating.cs | 41 + ...009172653_ReadingListAgeRating.Designer.cs | 1667 ++++++++++++++++ .../20221009172653_ReadingListAgeRating.cs | 26 + .../20221009211237_UserAgeRating.Designer.cs | 1670 +++++++++++++++++ .../20221009211237_UserAgeRating.cs | 27 + .../Migrations/DataContextModelSnapshot.cs | 6 + .../Repositories/CollectionTagRepository.cs | 14 +- .../Repositories/ReadingListRepository.cs | 10 +- API/Data/Repositories/SeriesRepository.cs | 117 +- API/Data/Repositories/UserRepository.cs | 2 + API/Entities/AppUser.cs | 6 +- API/Entities/Enums/AgeRating.cs | 5 + API/Entities/ReadingList.cs | 7 + API/Extensions/QueryableExtensions.cs | 23 + API/Services/AccountService.cs | 11 + API/Services/ReadingListService.cs | 46 +- API/Services/SeriesService.cs | 8 + .../Tasks/Scanner/ParseScannedFiles.cs | 1 - API/Startup.cs | 6 +- TestData | 1 + UI/Web/src/app/_models/member.ts | 6 +- UI/Web/src/app/_models/metadata/age-rating.ts | 4 + UI/Web/src/app/_models/user.ts | 2 + UI/Web/src/app/_services/account.service.ts | 30 +- UI/Web/src/app/_services/library.service.ts | 8 - UI/Web/src/app/_services/search.service.ts | 31 + UI/Web/src/app/_services/series.service.ts | 8 - .../admin/edit-user/edit-user.component.html | 102 +- .../admin/edit-user/edit-user.component.ts | 9 + .../invite-user/invite-user.component.html | 104 +- .../invite-user/invite-user.component.ts | 14 +- .../role-selector/role-selector.component.ts | 8 +- .../edit-series-modal.component.scss | 5 - .../edit-series-relation.component.ts | 7 +- .../nav/nav-header/nav-header.component.ts | 12 +- UI/Web/src/app/pipe/age-rating.pipe.ts | 4 +- UI/Web/src/app/pipe/default-value.pipe.ts | 2 +- .../change-age-restriction.component.html | 32 + .../change-age-restriction.component.scss | 0 .../change-age-restriction.component.ts | 79 + .../change-password.component.ts | 10 +- .../restriction-selector.component.html | 19 + .../restriction-selector.component.scss | 0 .../restriction-selector.component.ts | 68 + .../user-preferences.component.html | 1 + .../app/user-settings/user-settings.module.ts | 7 +- UI/Web/src/theme/components/_modal.scss | 5 + 63 files changed, 4638 insertions(+), 262 deletions(-) create mode 100644 API/Controllers/SearchController.cs create mode 100644 API/DTOs/Account/UpdateAgeRestrictionDto.cs create mode 100644 API/Data/MigrateChangeRestrictionRoles.cs create mode 100644 API/Data/MigrateReadingListAgeRating.cs create mode 100644 API/Data/Migrations/20221009172653_ReadingListAgeRating.Designer.cs create mode 100644 API/Data/Migrations/20221009172653_ReadingListAgeRating.cs create mode 100644 API/Data/Migrations/20221009211237_UserAgeRating.Designer.cs create mode 100644 API/Data/Migrations/20221009211237_UserAgeRating.cs create mode 100644 API/Extensions/QueryableExtensions.cs create mode 160000 TestData create mode 100644 UI/Web/src/app/_services/search.service.ts create mode 100644 UI/Web/src/app/user-settings/change-age-restriction/change-age-restriction.component.html create mode 100644 UI/Web/src/app/user-settings/change-age-restriction/change-age-restriction.component.scss create mode 100644 UI/Web/src/app/user-settings/change-age-restriction/change-age-restriction.component.ts create mode 100644 UI/Web/src/app/user-settings/restriction-selector/restriction-selector.component.html create mode 100644 UI/Web/src/app/user-settings/restriction-selector/restriction-selector.component.scss create mode 100644 UI/Web/src/app/user-settings/restriction-selector/restriction-selector.component.ts diff --git a/API.Tests/Services/ReadingListServiceTests.cs b/API.Tests/Services/ReadingListServiceTests.cs index 4df8fb688..200900bb1 100644 --- a/API.Tests/Services/ReadingListServiceTests.cs +++ b/API.Tests/Services/ReadingListServiceTests.cs @@ -4,10 +4,13 @@ using System.IO.Abstractions.TestingHelpers; using System.Linq; using System.Threading.Tasks; using API.Data; +using API.Data.Repositories; +using API.DTOs.ReadingLists; using API.Entities; using API.Entities.Enums; using API.Helpers; using API.Services; +using API.SignalR; using AutoMapper; using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore; @@ -79,9 +82,10 @@ public class ReadingListServiceTests private async Task ResetDb() { - _context.Series.RemoveRange(_context.Series.ToList()); - - await _context.SaveChangesAsync(); + _context.AppUser.RemoveRange(_context.AppUser); + _context.Series.RemoveRange(_context.Series); + _context.ReadingList.RemoveRange(_context.ReadingList); + await _unitOfWork.CommitAsync(); } private static MockFileSystem CreateFileSystem() @@ -99,11 +103,373 @@ public class ReadingListServiceTests #endregion + #region UpdateReadingListItemPosition - #region RemoveFullyReadItems + [Fact] + public async Task UpdateReadingListItemPosition_MoveLastToFirst_TwoItemsShouldShift() + { + await ResetDb(); + _context.AppUser.Add(new AppUser() + { + UserName = "majora2007", + ReadingLists = new List(), + Libraries = new List() + { + new Library() + { + Name = "Test LIb", + Type = LibraryType.Book, + Series = new List() + { + new Series() + { + Name = "Test", + Metadata = DbFactory.SeriesMetadata(new List()), + Volumes = new List() + { + new Volume() + { + Name = "0", + Chapters = new List() + { + new Chapter() + { + Number = "1", + AgeRating = AgeRating.Everyone, + }, + new Chapter() + { + Number = "2", + AgeRating = AgeRating.X18Plus + }, + new Chapter() + { + Number = "3", + AgeRating = AgeRating.X18Plus + } + } + } + } + } + } + }, + } + }); + + await _context.SaveChangesAsync(); + + var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync("majora2007", AppUserIncludes.ReadingLists); + var readingList = new ReadingList(); + user.ReadingLists = new List() + { + readingList + }; + + await _readingListService.AddChaptersToReadingList(1, new List() {1, 2, 3}, readingList); + await _unitOfWork.CommitAsync(); + Assert.Equal(3, readingList.Items.Count); + + await _readingListService.UpdateReadingListItemPosition(new UpdateReadingListPosition() + { + FromPosition = 2, ToPosition = 0, ReadingListId = 1, ReadingListItemId = 3 + }); + + + Assert.Equal(3, readingList.Items.Count); + Assert.Equal(0, readingList.Items.Single(i => i.ChapterId == 3).Order); + Assert.Equal(1, readingList.Items.Single(i => i.ChapterId == 1).Order); + Assert.Equal(2, readingList.Items.Single(i => i.ChapterId == 2).Order); + } - // TODO: Implement all methods here #endregion + #region DeleteReadingListItem + + [Fact] + public async Task DeleteReadingListItem_DeleteFirstItem_SecondShouldBecomeFirst() + { + await ResetDb(); + _context.AppUser.Add(new AppUser() + { + UserName = "majora2007", + ReadingLists = new List(), + Libraries = new List() + { + new Library() + { + Name = "Test LIb", + Type = LibraryType.Book, + Series = new List() + { + new Series() + { + Name = "Test", + Metadata = DbFactory.SeriesMetadata(new List()), + Volumes = new List() + { + new Volume() + { + Name = "0", + Chapters = new List() + { + new Chapter() + { + Number = "1", + AgeRating = AgeRating.Everyone + }, + new Chapter() + { + Number = "2", + AgeRating = AgeRating.X18Plus + } + } + } + } + } + } + }, + } + }); + + await _context.SaveChangesAsync(); + + var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync("majora2007", AppUserIncludes.ReadingLists); + var readingList = new ReadingList(); + user.ReadingLists = new List() + { + readingList + }; + + await _readingListService.AddChaptersToReadingList(1, new List() {1, 2}, readingList); + await _unitOfWork.CommitAsync(); + Assert.Equal(2, readingList.Items.Count); + + await _readingListService.DeleteReadingListItem(new UpdateReadingListPosition() + { + ReadingListId = 1, ReadingListItemId = 1 + }); + + Assert.Equal(1, readingList.Items.Count); + Assert.Equal(2, readingList.Items.First().ChapterId); + } + + #endregion + + #region RemoveFullyReadItems + + [Fact] + public async Task RemoveFullyReadItems_RemovesAllFullyReadItems() + { + await ResetDb(); + _context.AppUser.Add(new AppUser() + { + UserName = "majora2007", + ReadingLists = new List(), + Libraries = new List() + { + new Library() + { + Name = "Test LIb", + Type = LibraryType.Book, + Series = new List() + { + new Series() + { + Name = "Test", + Metadata = DbFactory.SeriesMetadata(new List()), + Volumes = new List() + { + new Volume() + { + Name = "0", + Chapters = new List() + { + new Chapter() + { + Number = "1", + AgeRating = AgeRating.Everyone, + Pages = 1 + }, + new Chapter() + { + Number = "2", + AgeRating = AgeRating.X18Plus, + Pages = 1 + }, + new Chapter() + { + Number = "3", + AgeRating = AgeRating.X18Plus, + Pages = 1 + } + } + } + } + } + } + }, + } + }); + + await _context.SaveChangesAsync(); + + var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync("majora2007", AppUserIncludes.ReadingLists | AppUserIncludes.Progress); + var readingList = new ReadingList(); + user.ReadingLists = new List() + { + readingList + }; + + await _readingListService.AddChaptersToReadingList(1, new List() {1, 2, 3}, readingList); + await _unitOfWork.CommitAsync(); + Assert.Equal(3, readingList.Items.Count); + + var readerService = new ReaderService(_unitOfWork, Substitute.For>(), + Substitute.For()); + // Mark 2 as fully read + await readerService.MarkChaptersAsRead(user, 1, + await _unitOfWork.ChapterRepository.GetChaptersByIdsAsync(new List() {2})); + await _unitOfWork.CommitAsync(); + + await _readingListService.RemoveFullyReadItems(1, user); + + + Assert.Equal(2, readingList.Items.Count); + Assert.DoesNotContain(readingList.Items, i => i.Id == 2); + } + + + #endregion + + + #region CalculateAgeRating + + [Fact] + public async Task CalculateAgeRating_ShouldUpdateToUnknown_IfNoneSet() + { + await ResetDb(); + _context.AppUser.Add(new AppUser() + { + UserName = "majora2007", + ReadingLists = new List(), + Libraries = new List() + { + new Library() + { + Name = "Test LIb", + Type = LibraryType.Book, + Series = new List() + { + new Series() + { + Name = "Test", + Metadata = DbFactory.SeriesMetadata(new List()), + Volumes = new List() + { + new Volume() + { + Name = "0", + Chapters = new List() + { + new Chapter() + { + Number = "1", + }, + new Chapter() + { + Number = "2", + } + } + } + } + } + } + }, + } + }); + + await _context.SaveChangesAsync(); + + var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync("majora2007", AppUserIncludes.ReadingLists); + var readingList = new ReadingList(); + user.ReadingLists = new List() + { + readingList + }; + + await _readingListService.AddChaptersToReadingList(1, new List() {1, 2}, readingList); + + + _unitOfWork.UserRepository.Update(user); + await _unitOfWork.CommitAsync(); + + await _readingListService.CalculateReadingListAgeRating(readingList); + Assert.Equal(AgeRating.Unknown, readingList.AgeRating); + } + + [Fact] + public async Task CalculateAgeRating_ShouldUpdateToMax() + { + await ResetDb(); + _context.AppUser.Add(new AppUser() + { + UserName = "majora2007", + ReadingLists = new List(), + Libraries = new List() + { + new Library() + { + Name = "Test LIb", + Type = LibraryType.Book, + Series = new List() + { + new Series() + { + Name = "Test", + Metadata = DbFactory.SeriesMetadata(new List()), + Volumes = new List() + { + new Volume() + { + Name = "0", + Chapters = new List() + { + new Chapter() + { + Number = "1", + }, + new Chapter() + { + Number = "2", + } + } + } + } + } + } + }, + } + }); + + await _context.SaveChangesAsync(); + + var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync("majora2007", AppUserIncludes.ReadingLists); + var readingList = new ReadingList(); + user.ReadingLists = new List() + { + readingList + }; + + await _readingListService.AddChaptersToReadingList(1, new List() {1, 2}, readingList); + + + _unitOfWork.UserRepository.Update(user); + await _unitOfWork.CommitAsync(); + + await _readingListService.CalculateReadingListAgeRating(readingList); + Assert.Equal(AgeRating.Unknown, readingList.AgeRating); + } + + #endregion } diff --git a/API/Constants/PolicyConstants.cs b/API/Constants/PolicyConstants.cs index 403fdd026..546ad4158 100644 --- a/API/Constants/PolicyConstants.cs +++ b/API/Constants/PolicyConstants.cs @@ -27,7 +27,11 @@ public static class PolicyConstants /// Used to give a user ability to bookmark files on the server /// public const string BookmarkRole = "Bookmark"; + /// + /// Used to give a user ability to Change Restrictions on their account + /// + public const string ChangeRestrictionRole = "Change Restriction"; public static readonly ImmutableArray ValidRoles = - ImmutableArray.Create(AdminRole, PlebRole, DownloadRole, ChangePasswordRole, BookmarkRole); + ImmutableArray.Create(AdminRole, PlebRole, DownloadRole, ChangePasswordRole, BookmarkRole, ChangeRestrictionRole); } diff --git a/API/Controllers/AccountController.cs b/API/Controllers/AccountController.cs index 660f962e6..fc72a5111 100644 --- a/API/Controllers/AccountController.cs +++ b/API/Controllers/AccountController.cs @@ -12,7 +12,6 @@ using API.DTOs.Account; using API.DTOs.Email; using API.Entities; using API.Entities.Enums; -using API.Entities.Enums.UserPreferences; using API.Errors; using API.Extensions; using API.Services; @@ -358,6 +357,34 @@ public class AccountController : BaseApiController return Ok(); } + [HttpPost("update/age-restriction")] + public async Task UpdateAgeRestriction(UpdateAgeRestrictionDto dto) + { + var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername()); + if (user == null) return Unauthorized("You do not have permission"); + if (dto == null) return BadRequest("Invalid payload"); + + var isAdmin = await _unitOfWork.UserRepository.IsUserAdminAsync(user); + + user.AgeRestriction = isAdmin ? AgeRating.NotApplicable : dto.AgeRestriction; + _unitOfWork.UserRepository.Update(user); + + if (!_unitOfWork.HasChanges()) return Ok(); + try + { + await _unitOfWork.CommitAsync(); + } + catch (Exception ex) + { + _logger.LogError(ex, "There was an error updating the age restriction"); + return BadRequest("There was an error updating the age restriction"); + } + + await _eventHub.SendMessageToAsync(MessageFactory.UserUpdate, MessageFactory.UserUpdateEvent(user.Id, user.UserName), user.Id); + + return Ok(); + } + /// /// Update the user account. This can only affect Username, Email (will require confirming), Roles, and Library access. /// @@ -428,6 +455,9 @@ public class AccountController : BaseApiController lib.AppUsers.Add(user); } + user.AgeRestriction = hasAdminRole ? AgeRating.NotApplicable : dto.AgeRestriction; + _unitOfWork.UserRepository.Update(user); + if (!_unitOfWork.HasChanges() || await _unitOfWork.CommitAsync()) { await _eventHub.SendMessageToAsync(MessageFactory.UserUpdate, MessageFactory.UserUpdateEvent(user.Id, user.UserName), user.Id); @@ -540,6 +570,8 @@ public class AccountController : BaseApiController lib.AppUsers.Add(user); } + user.AgeRestriction = hasAdminRole ? AgeRating.NotApplicable : dto.AgeRestriction; + var token = await _userManager.GenerateEmailConfirmationTokenAsync(user); if (string.IsNullOrEmpty(token)) { diff --git a/API/Controllers/CollectionController.cs b/API/Controllers/CollectionController.cs index b216ed6c7..33bde22b6 100644 --- a/API/Controllers/CollectionController.cs +++ b/API/Controllers/CollectionController.cs @@ -41,7 +41,8 @@ public class CollectionController : BaseApiController { return await _unitOfWork.CollectionTagRepository.GetAllTagDtosAsync(); } - return await _unitOfWork.CollectionTagRepository.GetAllPromotedTagDtosAsync(); + + return await _unitOfWork.CollectionTagRepository.GetAllPromotedTagDtosAsync(user.Id); } /// @@ -56,9 +57,10 @@ public class CollectionController : BaseApiController { queryString ??= ""; queryString = queryString.Replace(@"%", string.Empty); - if (queryString.Length == 0) return await _unitOfWork.CollectionTagRepository.GetAllTagDtosAsync(); + if (queryString.Length == 0) return await GetAllTags(); - return await _unitOfWork.CollectionTagRepository.SearchTagDtosAsync(queryString); + var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername()); + return await _unitOfWork.CollectionTagRepository.SearchTagDtosAsync(queryString, user.Id); } /// diff --git a/API/Controllers/LibraryController.cs b/API/Controllers/LibraryController.cs index 56ab5a41e..98377c5fd 100644 --- a/API/Controllers/LibraryController.cs +++ b/API/Controllers/LibraryController.cs @@ -319,23 +319,6 @@ public class LibraryController : BaseApiController } - [HttpGet("search")] - public async Task> Search(string queryString) - { - queryString = Uri.UnescapeDataString(queryString).Trim().Replace(@"%", string.Empty).Replace(":", string.Empty); - - var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername()); - // Get libraries user has access to - var libraries = (await _unitOfWork.LibraryRepository.GetLibrariesForUserIdAsync(user.Id)).ToList(); - - if (!libraries.Any()) return BadRequest("User does not have access to any libraries"); - if (!libraries.Any()) return BadRequest("User does not have access to any libraries"); - var isAdmin = await _unitOfWork.UserRepository.IsUserAdminAsync(user); - - var series = await _unitOfWork.SeriesRepository.SearchSeries(user.Id, isAdmin, libraries.Select(l => l.Id).ToArray(), queryString); - - return Ok(series); - } [HttpGet("type")] public async Task> GetLibraryType(int libraryId) diff --git a/API/Controllers/MetadataController.cs b/API/Controllers/MetadataController.cs index ffc0afa64..9c659637f 100644 --- a/API/Controllers/MetadataController.cs +++ b/API/Controllers/MetadataController.cs @@ -76,7 +76,9 @@ public class MetadataController : BaseApiController /// Fetches all age ratings from the instance /// /// String separated libraryIds or null for all ratings + /// This API is cached for 1 hour, varying by libraryIds /// + [ResponseCache(Duration = 60 * 5, Location = ResponseCacheLocation.Any, VaryByQueryKeys = new [] {"libraryIds"})] [HttpGet("age-ratings")] public async Task>> GetAllAgeRatings(string? libraryIds) { @@ -90,14 +92,16 @@ public class MetadataController : BaseApiController { Title = t.ToDescription(), Value = t - })); + }).Where(r => r.Value > AgeRating.NotApplicable)); } /// /// Fetches all publication status' from the instance /// /// String separated libraryIds or null for all publication status + /// This API is cached for 1 hour, varying by libraryIds /// + [ResponseCache(Duration = 60 * 5, Location = ResponseCacheLocation.Any, VaryByQueryKeys = new [] {"libraryIds"})] [HttpGet("publication-status")] public ActionResult> GetAllPublicationStatus(string? libraryIds) { diff --git a/API/Controllers/OPDSController.cs b/API/Controllers/OPDSController.cs index d254ff30e..c13a99079 100644 --- a/API/Controllers/OPDSController.cs +++ b/API/Controllers/OPDSController.cs @@ -196,8 +196,8 @@ public class OpdsController : BaseApiController var user = await _unitOfWork.UserRepository.GetUserByIdAsync(userId); var isAdmin = await _unitOfWork.UserRepository.IsUserAdminAsync(user); - IList tags = isAdmin ? (await _unitOfWork.CollectionTagRepository.GetAllTagDtosAsync()).ToList() - : (await _unitOfWork.CollectionTagRepository.GetAllPromotedTagDtosAsync()).ToList(); + IEnumerable tags = isAdmin ? (await _unitOfWork.CollectionTagRepository.GetAllTagDtosAsync()) + : (await _unitOfWork.CollectionTagRepository.GetAllPromotedTagDtosAsync(userId)); var feed = CreateFeed("All Collections", $"{apiKey}/collections", apiKey); @@ -239,7 +239,7 @@ public class OpdsController : BaseApiController } else { - tags = await _unitOfWork.CollectionTagRepository.GetAllPromotedTagDtosAsync(); + tags = await _unitOfWork.CollectionTagRepository.GetAllPromotedTagDtosAsync(userId); } var tag = tags.SingleOrDefault(t => t.Id == collectionId); diff --git a/API/Controllers/ReadingListController.cs b/API/Controllers/ReadingListController.cs index b150bdc01..0e4970a3e 100644 --- a/API/Controllers/ReadingListController.cs +++ b/API/Controllers/ReadingListController.cs @@ -45,11 +45,11 @@ public class ReadingListController : BaseApiController /// /// Returns reading lists (paginated) for a given user. /// - /// Defaults to true + /// Include Promoted Reading Lists along with user's Reading Lists. Defaults to true /// Pagination parameters /// [HttpPost("lists")] - public async Task>> GetListsForUser([FromQuery] UserParams userParams, [FromQuery] bool includePromoted = true) + public async Task>> GetListsForUser([FromQuery] UserParams userParams, bool includePromoted = true) { var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername()); var items = await _unitOfWork.ReadingListRepository.GetReadingListDtosForUserAsync(userId, includePromoted, @@ -217,9 +217,15 @@ public class ReadingListController : BaseApiController return BadRequest("You do not have permissions on this reading list or the list doesn't exist"); } + dto.Title = dto.Title.Trim(); if (!string.IsNullOrEmpty(dto.Title)) { - readingList.Title = dto.Title; // Should I check if this is unique? + var hasExisting = user.ReadingLists.Any(l => l.Title.Equals(dto.Title)); + if (hasExisting) + { + return BadRequest("A list of this name already exists"); + } + readingList.Title = dto.Title; readingList.NormalizedTitle = Services.Tasks.Scanner.Parser.Parser.Normalize(readingList.Title); } if (!string.IsNullOrEmpty(dto.Title)) diff --git a/API/Controllers/SearchController.cs b/API/Controllers/SearchController.cs new file mode 100644 index 000000000..722a3b310 --- /dev/null +++ b/API/Controllers/SearchController.cs @@ -0,0 +1,67 @@ +using System; +using System.Linq; +using System.Threading.Tasks; +using API.Data; +using API.DTOs; +using API.DTOs.Search; +using API.Extensions; +using Microsoft.AspNetCore.Mvc; + +namespace API.Controllers; + +/// +/// Responsible for the Search interface from the UI +/// +public class SearchController : BaseApiController +{ + private readonly IUnitOfWork _unitOfWork; + + public SearchController(IUnitOfWork unitOfWork) + { + _unitOfWork = unitOfWork; + } + + /// + /// Returns the series for the MangaFile id. If the user does not have access (shouldn't happen by the UI), + /// then null is returned + /// + /// + /// + [HttpGet("series-for-mangafile")] + public async Task> GetSeriesForMangaFile(int mangaFileId) + { + var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername()); + return Ok(await _unitOfWork.SeriesRepository.GetSeriesForMangaFile(mangaFileId, userId)); + } + + /// + /// Returns the series for the Chapter id. If the user does not have access (shouldn't happen by the UI), + /// then null is returned + /// + /// + /// + [HttpGet("series-for-chapter")] + public async Task> GetSeriesForChapter(int chapterId) + { + var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername()); + return Ok(await _unitOfWork.SeriesRepository.GetSeriesForChapter(chapterId, userId)); + } + + [HttpGet("search")] + public async Task> Search(string queryString) + { + queryString = Uri.UnescapeDataString(queryString).Trim().Replace(@"%", string.Empty).Replace(":", string.Empty); + + var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername()); + // Get libraries user has access to + var libraries = (await _unitOfWork.LibraryRepository.GetLibrariesForUserIdAsync(user.Id)).ToList(); + + if (!libraries.Any()) return BadRequest("User does not have access to any libraries"); + if (!libraries.Any()) return BadRequest("User does not have access to any libraries"); + var isAdmin = await _unitOfWork.UserRepository.IsUserAdminAsync(user); + + var series = await _unitOfWork.SeriesRepository.SearchSeries(user.Id, isAdmin, libraries.Select(l => l.Id).ToArray(), queryString); + + return Ok(series); + } +} diff --git a/API/Controllers/SeriesController.cs b/API/Controllers/SeriesController.cs index 3b4c95bb0..2304170e3 100644 --- a/API/Controllers/SeriesController.cs +++ b/API/Controllers/SeriesController.cs @@ -363,10 +363,13 @@ public class SeriesController : BaseApiController /// /// /// + /// This is cached for an hour + [ResponseCache(Duration = 60 * 60, Location = ResponseCacheLocation.Any, VaryByQueryKeys = new [] {"ageRating"})] [HttpGet("age-rating")] public ActionResult GetAgeRating(int ageRating) { var val = (AgeRating) ageRating; + if (val == AgeRating.NotApplicable) return "No Restriction"; return Ok(val.ToDescription()); } @@ -385,31 +388,7 @@ public class SeriesController : BaseApiController return await _seriesService.GetSeriesDetail(seriesId, userId); } - /// - /// Returns the series for the MangaFile id. If the user does not have access (shouldn't happen by the UI), - /// then null is returned - /// - /// - /// - [HttpGet("series-for-mangafile")] - public async Task> GetSeriesForMangaFile(int mangaFileId) - { - var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername()); - return Ok(await _unitOfWork.SeriesRepository.GetSeriesForMangaFile(mangaFileId, userId)); - } - /// - /// Returns the series for the Chapter id. If the user does not have access (shouldn't happen by the UI), - /// then null is returned - /// - /// - /// - [HttpGet("series-for-chapter")] - public async Task> GetSeriesForChapter(int chapterId) - { - var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername()); - return Ok(await _unitOfWork.SeriesRepository.GetSeriesForChapter(chapterId, userId)); - } /// /// Fetches the related series for a given series diff --git a/API/DTOs/Account/InviteUserDto.cs b/API/DTOs/Account/InviteUserDto.cs index 9d0d9416d..86bed4476 100644 --- a/API/DTOs/Account/InviteUserDto.cs +++ b/API/DTOs/Account/InviteUserDto.cs @@ -1,5 +1,6 @@ using System.Collections.Generic; using System.ComponentModel.DataAnnotations; +using API.Entities.Enums; namespace API.DTOs.Account; @@ -16,4 +17,8 @@ public class InviteUserDto /// A list of libraries to grant access to /// public IList Libraries { get; init; } + /// + /// An Age Rating which will limit the account to seeing everything equal to or below said rating. + /// + public AgeRating AgeRestriction { get; set; } } diff --git a/API/DTOs/Account/UpdateAgeRestrictionDto.cs b/API/DTOs/Account/UpdateAgeRestrictionDto.cs new file mode 100644 index 000000000..edd1be9af --- /dev/null +++ b/API/DTOs/Account/UpdateAgeRestrictionDto.cs @@ -0,0 +1,10 @@ +using System.ComponentModel.DataAnnotations; +using API.Entities.Enums; + +namespace API.DTOs.Account; + +public class UpdateAgeRestrictionDto +{ + [Required] + public AgeRating AgeRestriction { get; set; } +} diff --git a/API/DTOs/Account/UpdateUserDto.cs b/API/DTOs/Account/UpdateUserDto.cs index 546e1e4bd..6bf880074 100644 --- a/API/DTOs/Account/UpdateUserDto.cs +++ b/API/DTOs/Account/UpdateUserDto.cs @@ -1,4 +1,7 @@ using System.Collections.Generic; +using System.Text.Json.Serialization; +using API.Entities.Enums; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion.Internal; namespace API.DTOs.Account; @@ -13,5 +16,9 @@ public record UpdateUserDto /// A list of libraries to grant access to /// public IList Libraries { get; init; } + /// + /// An Age Rating which will limit the account to seeing everything equal to or below said rating. + /// + public AgeRating AgeRestriction { get; init; } } diff --git a/API/DTOs/MemberDto.cs b/API/DTOs/MemberDto.cs index e8aa2834c..d2218c9ba 100644 --- a/API/DTOs/MemberDto.cs +++ b/API/DTOs/MemberDto.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using API.Entities.Enums; namespace API.DTOs; @@ -11,6 +12,11 @@ public class MemberDto public int Id { get; init; } public string Username { get; init; } public string Email { get; init; } + + /// + /// The maximum age rating a user has access to. -1 if not applicable + /// + public AgeRating AgeRestriction { get; init; } = AgeRating.NotApplicable; public DateTime Created { get; init; } public DateTime LastActive { get; init; } public IEnumerable Libraries { get; init; } diff --git a/API/DTOs/UserDto.cs b/API/DTOs/UserDto.cs index d2c05e5a8..210eefa09 100644 --- a/API/DTOs/UserDto.cs +++ b/API/DTOs/UserDto.cs @@ -1,4 +1,6 @@  +using API.Entities.Enums; + namespace API.DTOs; public class UserDto @@ -9,4 +11,8 @@ public class UserDto public string RefreshToken { get; set; } public string ApiKey { get; init; } public UserPreferencesDto Preferences { get; set; } + /// + /// The highest age rating the user has access to. Not applicable for admins + /// + public AgeRating AgeRestriction { get; set; } = AgeRating.NotApplicable; } diff --git a/API/Data/MigrateChangeRestrictionRoles.cs b/API/Data/MigrateChangeRestrictionRoles.cs new file mode 100644 index 000000000..25385823b --- /dev/null +++ b/API/Data/MigrateChangeRestrictionRoles.cs @@ -0,0 +1,36 @@ +using System.Threading.Tasks; +using API.Constants; +using API.Entities; +using Microsoft.AspNetCore.Identity; +using Microsoft.Extensions.Logging; + +namespace API.Data; + +/// +/// New role introduced in v0.6. Adds the role to all users. +/// +public static class MigrateChangeRestrictionRoles +{ + /// + /// Will not run if any users have the role already + /// + /// + /// + /// + public static async Task Migrate(IUnitOfWork unitOfWork, UserManager userManager, ILogger logger) + { + var usersWithRole = await userManager.GetUsersInRoleAsync(PolicyConstants.ChangeRestrictionRole); + if (usersWithRole.Count != 0) return; + + logger.LogCritical("Running MigrateChangeRestrictionRoles migration"); + + var allUsers = await unitOfWork.UserRepository.GetAllUsers(); + foreach (var user in allUsers) + { + await userManager.RemoveFromRoleAsync(user, PolicyConstants.ChangeRestrictionRole); + await userManager.AddToRoleAsync(user, PolicyConstants.ChangeRestrictionRole); + } + + logger.LogInformation("MigrateChangeRestrictionRoles migration complete"); + } +} diff --git a/API/Data/MigrateReadingListAgeRating.cs b/API/Data/MigrateReadingListAgeRating.cs new file mode 100644 index 000000000..ebaa1ce03 --- /dev/null +++ b/API/Data/MigrateReadingListAgeRating.cs @@ -0,0 +1,41 @@ +using System; +using System.Threading.Tasks; +using API.Constants; +using API.Services; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using SQLitePCL; + +namespace API.Data; + +/// +/// New role introduced in v0.6. Calculates the Age Rating on all Reading Lists +/// +public static class MigrateReadingListAgeRating +{ + /// + /// Will not run if any above v0.5.6.24 or v0.6.0 + /// + /// + /// + /// + public static async Task Migrate(IUnitOfWork unitOfWork, DataContext context, IReadingListService readingListService, ILogger logger) + { + var settings = await unitOfWork.SettingsRepository.GetSettingsDtoAsync(); + if (Version.Parse(settings.InstallVersion) > new Version(0, 5, 6, 24)) + { + return; + } + + logger.LogInformation("MigrateReadingListAgeRating migration starting"); + var readingLists = await context.ReadingList.Include(r => r.Items).ToListAsync(); + foreach (var readingList in readingLists) + { + await readingListService.CalculateReadingListAgeRating(readingList); + context.ReadingList.Update(readingList); + } + + await context.SaveChangesAsync(); + logger.LogInformation("MigrateReadingListAgeRating migration complete"); + } +} diff --git a/API/Data/Migrations/20221009172653_ReadingListAgeRating.Designer.cs b/API/Data/Migrations/20221009172653_ReadingListAgeRating.Designer.cs new file mode 100644 index 000000000..f93e7a58d --- /dev/null +++ b/API/Data/Migrations/20221009172653_ReadingListAgeRating.Designer.cs @@ -0,0 +1,1667 @@ +// +using System; +using API.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace API.Data.Migrations +{ + [DbContext(typeof(DataContext))] + [Migration("20221009172653_ReadingListAgeRating")] + partial class ReadingListAgeRating + { + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "6.0.9"); + + modelBuilder.Entity("API.Entities.AppRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("API.Entities.AppUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AccessFailedCount") + .HasColumnType("INTEGER"); + + b.Property("ApiKey") + .HasColumnType("TEXT"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("ConfirmationToken") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("EmailConfirmed") + .HasColumnType("INTEGER"); + + b.Property("LastActive") + .HasColumnType("TEXT"); + + b.Property("LockoutEnabled") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnd") + .HasColumnType("TEXT"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .HasColumnType("TEXT"); + + b.Property("PhoneNumber") + .HasColumnType("TEXT"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("SecurityStamp") + .HasColumnType("TEXT"); + + b.Property("TwoFactorEnabled") + .HasColumnType("INTEGER"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("API.Entities.AppUserBookmark", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("FileName") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("Page") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserBookmark"); + }); + + modelBuilder.Entity("API.Entities.AppUserPreferences", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("AutoCloseMenu") + .HasColumnType("INTEGER"); + + b.Property("BackgroundColor") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("#000000"); + + b.Property("BlurUnreadSummaries") + .HasColumnType("INTEGER"); + + b.Property("BookReaderFontFamily") + .HasColumnType("TEXT"); + + b.Property("BookReaderFontSize") + .HasColumnType("INTEGER"); + + b.Property("BookReaderImmersiveMode") + .HasColumnType("INTEGER"); + + b.Property("BookReaderLayoutMode") + .HasColumnType("INTEGER"); + + b.Property("BookReaderLineSpacing") + .HasColumnType("INTEGER"); + + b.Property("BookReaderMargin") + .HasColumnType("INTEGER"); + + b.Property("BookReaderReadingDirection") + .HasColumnType("INTEGER"); + + b.Property("BookReaderTapToPaginate") + .HasColumnType("INTEGER"); + + b.Property("BookThemeName") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("Dark"); + + b.Property("GlobalPageLayoutMode") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("LayoutMode") + .HasColumnType("INTEGER"); + + b.Property("NoTransitions") + .HasColumnType("INTEGER"); + + b.Property("PageSplitOption") + .HasColumnType("INTEGER"); + + b.Property("PromptForDownloadSize") + .HasColumnType("INTEGER"); + + b.Property("ReaderMode") + .HasColumnType("INTEGER"); + + b.Property("ReadingDirection") + .HasColumnType("INTEGER"); + + b.Property("ScalingOption") + .HasColumnType("INTEGER"); + + b.Property("ShowScreenHints") + .HasColumnType("INTEGER"); + + b.Property("ThemeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId") + .IsUnique(); + + b.HasIndex("ThemeId"); + + b.ToTable("AppUserPreferences"); + }); + + modelBuilder.Entity("API.Entities.AppUserProgress", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("BookScrollId") + .HasColumnType("TEXT"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("PagesRead") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserProgresses"); + }); + + modelBuilder.Entity("API.Entities.AppUserRating", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Rating") + .HasColumnType("INTEGER"); + + b.Property("Review") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserRating"); + }); + + modelBuilder.Entity("API.Entities.AppUserRole", b => + { + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.Property("RoleId") + .HasColumnType("INTEGER"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRating") + .HasColumnType("INTEGER"); + + b.Property("AvgHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("Count") + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("IsSpecial") + .HasColumnType("INTEGER"); + + b.Property("Language") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("MaxHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MinHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("Number") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("Range") + .HasColumnType("TEXT"); + + b.Property("ReleaseDate") + .HasColumnType("TEXT"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("TitleName") + .HasColumnType("TEXT"); + + b.Property("TotalCount") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.Property("WordCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("VolumeId"); + + b.ToTable("Chapter"); + }); + + modelBuilder.Entity("API.Entities.CollectionTag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Promoted") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Id", "Promoted") + .IsUnique(); + + b.ToTable("CollectionTag"); + }); + + modelBuilder.Entity("API.Entities.Device", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("EmailAddress") + .HasColumnType("TEXT"); + + b.Property("IpAddress") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastUsed") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Platform") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("Device"); + }); + + modelBuilder.Entity("API.Entities.FolderPath", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("LastScanned") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("Path") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.ToTable("FolderPath"); + }); + + modelBuilder.Entity("API.Entities.Genre", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ExternalTag") + .HasColumnType("INTEGER"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedTitle", "ExternalTag") + .IsUnique(); + + b.ToTable("Genre"); + }); + + modelBuilder.Entity("API.Entities.Library", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastScanned") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("Library"); + }); + + modelBuilder.Entity("API.Entities.MangaFile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("FilePath") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("LastFileAnalysis") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.ToTable("MangaFile"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesMetadata", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRating") + .HasColumnType("INTEGER"); + + b.Property("AgeRatingLocked") + .HasColumnType("INTEGER"); + + b.Property("CharacterLocked") + .HasColumnType("INTEGER"); + + b.Property("ColoristLocked") + .HasColumnType("INTEGER"); + + b.Property("CoverArtistLocked") + .HasColumnType("INTEGER"); + + b.Property("EditorLocked") + .HasColumnType("INTEGER"); + + b.Property("GenresLocked") + .HasColumnType("INTEGER"); + + b.Property("InkerLocked") + .HasColumnType("INTEGER"); + + b.Property("Language") + .HasColumnType("TEXT"); + + b.Property("LanguageLocked") + .HasColumnType("INTEGER"); + + b.Property("LettererLocked") + .HasColumnType("INTEGER"); + + b.Property("MaxCount") + .HasColumnType("INTEGER"); + + b.Property("PencillerLocked") + .HasColumnType("INTEGER"); + + b.Property("PublicationStatus") + .HasColumnType("INTEGER"); + + b.Property("PublicationStatusLocked") + .HasColumnType("INTEGER"); + + b.Property("PublisherLocked") + .HasColumnType("INTEGER"); + + b.Property("ReleaseYear") + .HasColumnType("INTEGER"); + + b.Property("ReleaseYearLocked") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("SummaryLocked") + .HasColumnType("INTEGER"); + + b.Property("TagsLocked") + .HasColumnType("INTEGER"); + + b.Property("TotalCount") + .HasColumnType("INTEGER"); + + b.Property("TranslatorLocked") + .HasColumnType("INTEGER"); + + b.Property("WriterLocked") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId") + .IsUnique(); + + b.HasIndex("Id", "SeriesId") + .IsUnique(); + + b.ToTable("SeriesMetadata"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesRelation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("RelationKind") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("TargetSeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.HasIndex("TargetSeriesId"); + + b.ToTable("SeriesRelation"); + }); + + modelBuilder.Entity("API.Entities.Person", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("Role") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("Person"); + }); + + modelBuilder.Entity("API.Entities.ReadingList", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRating") + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Promoted") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("ReadingList"); + }); + + modelBuilder.Entity("API.Entities.ReadingListItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Order") + .HasColumnType("INTEGER"); + + b.Property("ReadingListId") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.HasIndex("ReadingListId"); + + b.HasIndex("SeriesId"); + + b.HasIndex("VolumeId"); + + b.ToTable("ReadingListItem"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("AvgHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("FolderPath") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("LastChapterAdded") + .HasColumnType("TEXT"); + + b.Property("LastFolderScanned") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("LocalizedName") + .HasColumnType("TEXT"); + + b.Property("LocalizedNameLocked") + .HasColumnType("INTEGER"); + + b.Property("MaxHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MinHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NameLocked") + .HasColumnType("INTEGER"); + + b.Property("NormalizedLocalizedName") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("OriginalName") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("SortName") + .HasColumnType("TEXT"); + + b.Property("SortNameLocked") + .HasColumnType("INTEGER"); + + b.Property("WordCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("LibraryId"); + + b.ToTable("Series"); + }); + + modelBuilder.Entity("API.Entities.ServerSetting", b => + { + b.Property("Key") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("Key"); + + b.ToTable("ServerSetting"); + }); + + modelBuilder.Entity("API.Entities.SiteTheme", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("FileName") + .HasColumnType("TEXT"); + + b.Property("IsDefault") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("SiteTheme"); + }); + + modelBuilder.Entity("API.Entities.Tag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ExternalTag") + .HasColumnType("INTEGER"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedTitle", "ExternalTag") + .IsUnique(); + + b.ToTable("Tag"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AvgHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("MaxHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MinHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Number") + .HasColumnType("INTEGER"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("WordCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.ToTable("Volume"); + }); + + modelBuilder.Entity("AppUserLibrary", b => + { + b.Property("AppUsersId") + .HasColumnType("INTEGER"); + + b.Property("LibrariesId") + .HasColumnType("INTEGER"); + + b.HasKey("AppUsersId", "LibrariesId"); + + b.HasIndex("LibrariesId"); + + b.ToTable("AppUserLibrary"); + }); + + modelBuilder.Entity("ChapterGenre", b => + { + b.Property("ChaptersId") + .HasColumnType("INTEGER"); + + b.Property("GenresId") + .HasColumnType("INTEGER"); + + b.HasKey("ChaptersId", "GenresId"); + + b.HasIndex("GenresId"); + + b.ToTable("ChapterGenre"); + }); + + modelBuilder.Entity("ChapterPerson", b => + { + b.Property("ChapterMetadatasId") + .HasColumnType("INTEGER"); + + b.Property("PeopleId") + .HasColumnType("INTEGER"); + + b.HasKey("ChapterMetadatasId", "PeopleId"); + + b.HasIndex("PeopleId"); + + b.ToTable("ChapterPerson"); + }); + + modelBuilder.Entity("ChapterTag", b => + { + b.Property("ChaptersId") + .HasColumnType("INTEGER"); + + b.Property("TagsId") + .HasColumnType("INTEGER"); + + b.HasKey("ChaptersId", "TagsId"); + + b.HasIndex("TagsId"); + + b.ToTable("ChapterTag"); + }); + + modelBuilder.Entity("CollectionTagSeriesMetadata", b => + { + b.Property("CollectionTagsId") + .HasColumnType("INTEGER"); + + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("CollectionTagsId", "SeriesMetadatasId"); + + b.HasIndex("SeriesMetadatasId"); + + b.ToTable("CollectionTagSeriesMetadata"); + }); + + modelBuilder.Entity("GenreSeriesMetadata", b => + { + b.Property("GenresId") + .HasColumnType("INTEGER"); + + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("GenresId", "SeriesMetadatasId"); + + b.HasIndex("SeriesMetadatasId"); + + b.ToTable("GenreSeriesMetadata"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("ProviderKey") + .HasColumnType("TEXT"); + + b.Property("ProviderDisplayName") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("PersonSeriesMetadata", b => + { + b.Property("PeopleId") + .HasColumnType("INTEGER"); + + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("PeopleId", "SeriesMetadatasId"); + + b.HasIndex("SeriesMetadatasId"); + + b.ToTable("PersonSeriesMetadata"); + }); + + modelBuilder.Entity("SeriesMetadataTag", b => + { + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.Property("TagsId") + .HasColumnType("INTEGER"); + + b.HasKey("SeriesMetadatasId", "TagsId"); + + b.HasIndex("TagsId"); + + b.ToTable("SeriesMetadataTag"); + }); + + modelBuilder.Entity("API.Entities.AppUserBookmark", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Bookmarks") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserPreferences", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithOne("UserPreferences") + .HasForeignKey("API.Entities.AppUserPreferences", "AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.SiteTheme", "Theme") + .WithMany() + .HasForeignKey("ThemeId"); + + b.Navigation("AppUser"); + + b.Navigation("Theme"); + }); + + modelBuilder.Entity("API.Entities.AppUserProgress", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Progresses") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", null) + .WithMany("Progress") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserRating", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Ratings") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", null) + .WithMany("Ratings") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserRole", b => + { + b.HasOne("API.Entities.AppRole", "Role") + .WithMany("UserRoles") + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.AppUser", "User") + .WithMany("UserRoles") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Role"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.HasOne("API.Entities.Volume", "Volume") + .WithMany("Chapters") + .HasForeignKey("VolumeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Volume"); + }); + + modelBuilder.Entity("API.Entities.Device", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Devices") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.FolderPath", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("Folders") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.MangaFile", b => + { + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany("Files") + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesMetadata", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithOne("Metadata") + .HasForeignKey("API.Entities.Metadata.SeriesMetadata", "SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesRelation", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithMany("Relations") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.ClientCascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "TargetSeries") + .WithMany("RelationOf") + .HasForeignKey("TargetSeriesId") + .OnDelete(DeleteBehavior.ClientCascade) + .IsRequired(); + + b.Navigation("Series"); + + b.Navigation("TargetSeries"); + }); + + modelBuilder.Entity("API.Entities.ReadingList", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("ReadingLists") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.ReadingListItem", b => + { + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany() + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.ReadingList", "ReadingList") + .WithMany("Items") + .HasForeignKey("ReadingListId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Volume", "Volume") + .WithMany() + .HasForeignKey("VolumeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + + b.Navigation("ReadingList"); + + b.Navigation("Series"); + + b.Navigation("Volume"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany("WantToRead") + .HasForeignKey("AppUserId"); + + b.HasOne("API.Entities.Library", "Library") + .WithMany("Series") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithMany("Volumes") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("AppUserLibrary", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("AppUsersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Library", null) + .WithMany() + .HasForeignKey("LibrariesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ChapterGenre", b => + { + b.HasOne("API.Entities.Chapter", null) + .WithMany() + .HasForeignKey("ChaptersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Genre", null) + .WithMany() + .HasForeignKey("GenresId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ChapterPerson", b => + { + b.HasOne("API.Entities.Chapter", null) + .WithMany() + .HasForeignKey("ChapterMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Person", null) + .WithMany() + .HasForeignKey("PeopleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ChapterTag", b => + { + b.HasOne("API.Entities.Chapter", null) + .WithMany() + .HasForeignKey("ChaptersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Tag", null) + .WithMany() + .HasForeignKey("TagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("CollectionTagSeriesMetadata", b => + { + b.HasOne("API.Entities.CollectionTag", null) + .WithMany() + .HasForeignKey("CollectionTagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("GenreSeriesMetadata", b => + { + b.HasOne("API.Entities.Genre", null) + .WithMany() + .HasForeignKey("GenresId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("API.Entities.AppRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("PersonSeriesMetadata", b => + { + b.HasOne("API.Entities.Person", null) + .WithMany() + .HasForeignKey("PeopleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SeriesMetadataTag", b => + { + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Tag", null) + .WithMany() + .HasForeignKey("TagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("API.Entities.AppRole", b => + { + b.Navigation("UserRoles"); + }); + + modelBuilder.Entity("API.Entities.AppUser", b => + { + b.Navigation("Bookmarks"); + + b.Navigation("Devices"); + + b.Navigation("Progresses"); + + b.Navigation("Ratings"); + + b.Navigation("ReadingLists"); + + b.Navigation("UserPreferences"); + + b.Navigation("UserRoles"); + + b.Navigation("WantToRead"); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.Navigation("Files"); + }); + + modelBuilder.Entity("API.Entities.Library", b => + { + b.Navigation("Folders"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.ReadingList", b => + { + b.Navigation("Items"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.Navigation("Metadata"); + + b.Navigation("Progress"); + + b.Navigation("Ratings"); + + b.Navigation("RelationOf"); + + b.Navigation("Relations"); + + b.Navigation("Volumes"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.Navigation("Chapters"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/API/Data/Migrations/20221009172653_ReadingListAgeRating.cs b/API/Data/Migrations/20221009172653_ReadingListAgeRating.cs new file mode 100644 index 000000000..dfc69a9cf --- /dev/null +++ b/API/Data/Migrations/20221009172653_ReadingListAgeRating.cs @@ -0,0 +1,26 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace API.Data.Migrations +{ + public partial class ReadingListAgeRating : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "AgeRating", + table: "ReadingList", + type: "INTEGER", + nullable: false, + defaultValue: 0); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "AgeRating", + table: "ReadingList"); + } + } +} diff --git a/API/Data/Migrations/20221009211237_UserAgeRating.Designer.cs b/API/Data/Migrations/20221009211237_UserAgeRating.Designer.cs new file mode 100644 index 000000000..1a9e9fade --- /dev/null +++ b/API/Data/Migrations/20221009211237_UserAgeRating.Designer.cs @@ -0,0 +1,1670 @@ +// +using System; +using API.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace API.Data.Migrations +{ + [DbContext(typeof(DataContext))] + [Migration("20221009211237_UserAgeRating")] + partial class UserAgeRating + { + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "6.0.9"); + + modelBuilder.Entity("API.Entities.AppRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("API.Entities.AppUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AccessFailedCount") + .HasColumnType("INTEGER"); + + b.Property("AgeRestriction") + .HasColumnType("INTEGER"); + + b.Property("ApiKey") + .HasColumnType("TEXT"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("ConfirmationToken") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("EmailConfirmed") + .HasColumnType("INTEGER"); + + b.Property("LastActive") + .HasColumnType("TEXT"); + + b.Property("LockoutEnabled") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnd") + .HasColumnType("TEXT"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .HasColumnType("TEXT"); + + b.Property("PhoneNumber") + .HasColumnType("TEXT"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("SecurityStamp") + .HasColumnType("TEXT"); + + b.Property("TwoFactorEnabled") + .HasColumnType("INTEGER"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("API.Entities.AppUserBookmark", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("FileName") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("Page") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserBookmark"); + }); + + modelBuilder.Entity("API.Entities.AppUserPreferences", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("AutoCloseMenu") + .HasColumnType("INTEGER"); + + b.Property("BackgroundColor") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("#000000"); + + b.Property("BlurUnreadSummaries") + .HasColumnType("INTEGER"); + + b.Property("BookReaderFontFamily") + .HasColumnType("TEXT"); + + b.Property("BookReaderFontSize") + .HasColumnType("INTEGER"); + + b.Property("BookReaderImmersiveMode") + .HasColumnType("INTEGER"); + + b.Property("BookReaderLayoutMode") + .HasColumnType("INTEGER"); + + b.Property("BookReaderLineSpacing") + .HasColumnType("INTEGER"); + + b.Property("BookReaderMargin") + .HasColumnType("INTEGER"); + + b.Property("BookReaderReadingDirection") + .HasColumnType("INTEGER"); + + b.Property("BookReaderTapToPaginate") + .HasColumnType("INTEGER"); + + b.Property("BookThemeName") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("Dark"); + + b.Property("GlobalPageLayoutMode") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("LayoutMode") + .HasColumnType("INTEGER"); + + b.Property("NoTransitions") + .HasColumnType("INTEGER"); + + b.Property("PageSplitOption") + .HasColumnType("INTEGER"); + + b.Property("PromptForDownloadSize") + .HasColumnType("INTEGER"); + + b.Property("ReaderMode") + .HasColumnType("INTEGER"); + + b.Property("ReadingDirection") + .HasColumnType("INTEGER"); + + b.Property("ScalingOption") + .HasColumnType("INTEGER"); + + b.Property("ShowScreenHints") + .HasColumnType("INTEGER"); + + b.Property("ThemeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId") + .IsUnique(); + + b.HasIndex("ThemeId"); + + b.ToTable("AppUserPreferences"); + }); + + modelBuilder.Entity("API.Entities.AppUserProgress", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("BookScrollId") + .HasColumnType("TEXT"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("PagesRead") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserProgresses"); + }); + + modelBuilder.Entity("API.Entities.AppUserRating", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Rating") + .HasColumnType("INTEGER"); + + b.Property("Review") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserRating"); + }); + + modelBuilder.Entity("API.Entities.AppUserRole", b => + { + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.Property("RoleId") + .HasColumnType("INTEGER"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRating") + .HasColumnType("INTEGER"); + + b.Property("AvgHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("Count") + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("IsSpecial") + .HasColumnType("INTEGER"); + + b.Property("Language") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("MaxHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MinHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("Number") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("Range") + .HasColumnType("TEXT"); + + b.Property("ReleaseDate") + .HasColumnType("TEXT"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("TitleName") + .HasColumnType("TEXT"); + + b.Property("TotalCount") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.Property("WordCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("VolumeId"); + + b.ToTable("Chapter"); + }); + + modelBuilder.Entity("API.Entities.CollectionTag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Promoted") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Id", "Promoted") + .IsUnique(); + + b.ToTable("CollectionTag"); + }); + + modelBuilder.Entity("API.Entities.Device", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("EmailAddress") + .HasColumnType("TEXT"); + + b.Property("IpAddress") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastUsed") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Platform") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("Device"); + }); + + modelBuilder.Entity("API.Entities.FolderPath", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("LastScanned") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("Path") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.ToTable("FolderPath"); + }); + + modelBuilder.Entity("API.Entities.Genre", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ExternalTag") + .HasColumnType("INTEGER"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedTitle", "ExternalTag") + .IsUnique(); + + b.ToTable("Genre"); + }); + + modelBuilder.Entity("API.Entities.Library", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastScanned") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("Library"); + }); + + modelBuilder.Entity("API.Entities.MangaFile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("FilePath") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("LastFileAnalysis") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.ToTable("MangaFile"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesMetadata", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRating") + .HasColumnType("INTEGER"); + + b.Property("AgeRatingLocked") + .HasColumnType("INTEGER"); + + b.Property("CharacterLocked") + .HasColumnType("INTEGER"); + + b.Property("ColoristLocked") + .HasColumnType("INTEGER"); + + b.Property("CoverArtistLocked") + .HasColumnType("INTEGER"); + + b.Property("EditorLocked") + .HasColumnType("INTEGER"); + + b.Property("GenresLocked") + .HasColumnType("INTEGER"); + + b.Property("InkerLocked") + .HasColumnType("INTEGER"); + + b.Property("Language") + .HasColumnType("TEXT"); + + b.Property("LanguageLocked") + .HasColumnType("INTEGER"); + + b.Property("LettererLocked") + .HasColumnType("INTEGER"); + + b.Property("MaxCount") + .HasColumnType("INTEGER"); + + b.Property("PencillerLocked") + .HasColumnType("INTEGER"); + + b.Property("PublicationStatus") + .HasColumnType("INTEGER"); + + b.Property("PublicationStatusLocked") + .HasColumnType("INTEGER"); + + b.Property("PublisherLocked") + .HasColumnType("INTEGER"); + + b.Property("ReleaseYear") + .HasColumnType("INTEGER"); + + b.Property("ReleaseYearLocked") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("SummaryLocked") + .HasColumnType("INTEGER"); + + b.Property("TagsLocked") + .HasColumnType("INTEGER"); + + b.Property("TotalCount") + .HasColumnType("INTEGER"); + + b.Property("TranslatorLocked") + .HasColumnType("INTEGER"); + + b.Property("WriterLocked") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId") + .IsUnique(); + + b.HasIndex("Id", "SeriesId") + .IsUnique(); + + b.ToTable("SeriesMetadata"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesRelation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("RelationKind") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("TargetSeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.HasIndex("TargetSeriesId"); + + b.ToTable("SeriesRelation"); + }); + + modelBuilder.Entity("API.Entities.Person", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("Role") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("Person"); + }); + + modelBuilder.Entity("API.Entities.ReadingList", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRating") + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Promoted") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("ReadingList"); + }); + + modelBuilder.Entity("API.Entities.ReadingListItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Order") + .HasColumnType("INTEGER"); + + b.Property("ReadingListId") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.HasIndex("ReadingListId"); + + b.HasIndex("SeriesId"); + + b.HasIndex("VolumeId"); + + b.ToTable("ReadingListItem"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("AvgHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("FolderPath") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("LastChapterAdded") + .HasColumnType("TEXT"); + + b.Property("LastFolderScanned") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("LocalizedName") + .HasColumnType("TEXT"); + + b.Property("LocalizedNameLocked") + .HasColumnType("INTEGER"); + + b.Property("MaxHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MinHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NameLocked") + .HasColumnType("INTEGER"); + + b.Property("NormalizedLocalizedName") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("OriginalName") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("SortName") + .HasColumnType("TEXT"); + + b.Property("SortNameLocked") + .HasColumnType("INTEGER"); + + b.Property("WordCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("LibraryId"); + + b.ToTable("Series"); + }); + + modelBuilder.Entity("API.Entities.ServerSetting", b => + { + b.Property("Key") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("Key"); + + b.ToTable("ServerSetting"); + }); + + modelBuilder.Entity("API.Entities.SiteTheme", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("FileName") + .HasColumnType("TEXT"); + + b.Property("IsDefault") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("SiteTheme"); + }); + + modelBuilder.Entity("API.Entities.Tag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ExternalTag") + .HasColumnType("INTEGER"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedTitle", "ExternalTag") + .IsUnique(); + + b.ToTable("Tag"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AvgHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("MaxHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MinHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Number") + .HasColumnType("INTEGER"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("WordCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.ToTable("Volume"); + }); + + modelBuilder.Entity("AppUserLibrary", b => + { + b.Property("AppUsersId") + .HasColumnType("INTEGER"); + + b.Property("LibrariesId") + .HasColumnType("INTEGER"); + + b.HasKey("AppUsersId", "LibrariesId"); + + b.HasIndex("LibrariesId"); + + b.ToTable("AppUserLibrary"); + }); + + modelBuilder.Entity("ChapterGenre", b => + { + b.Property("ChaptersId") + .HasColumnType("INTEGER"); + + b.Property("GenresId") + .HasColumnType("INTEGER"); + + b.HasKey("ChaptersId", "GenresId"); + + b.HasIndex("GenresId"); + + b.ToTable("ChapterGenre"); + }); + + modelBuilder.Entity("ChapterPerson", b => + { + b.Property("ChapterMetadatasId") + .HasColumnType("INTEGER"); + + b.Property("PeopleId") + .HasColumnType("INTEGER"); + + b.HasKey("ChapterMetadatasId", "PeopleId"); + + b.HasIndex("PeopleId"); + + b.ToTable("ChapterPerson"); + }); + + modelBuilder.Entity("ChapterTag", b => + { + b.Property("ChaptersId") + .HasColumnType("INTEGER"); + + b.Property("TagsId") + .HasColumnType("INTEGER"); + + b.HasKey("ChaptersId", "TagsId"); + + b.HasIndex("TagsId"); + + b.ToTable("ChapterTag"); + }); + + modelBuilder.Entity("CollectionTagSeriesMetadata", b => + { + b.Property("CollectionTagsId") + .HasColumnType("INTEGER"); + + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("CollectionTagsId", "SeriesMetadatasId"); + + b.HasIndex("SeriesMetadatasId"); + + b.ToTable("CollectionTagSeriesMetadata"); + }); + + modelBuilder.Entity("GenreSeriesMetadata", b => + { + b.Property("GenresId") + .HasColumnType("INTEGER"); + + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("GenresId", "SeriesMetadatasId"); + + b.HasIndex("SeriesMetadatasId"); + + b.ToTable("GenreSeriesMetadata"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("ProviderKey") + .HasColumnType("TEXT"); + + b.Property("ProviderDisplayName") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("PersonSeriesMetadata", b => + { + b.Property("PeopleId") + .HasColumnType("INTEGER"); + + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("PeopleId", "SeriesMetadatasId"); + + b.HasIndex("SeriesMetadatasId"); + + b.ToTable("PersonSeriesMetadata"); + }); + + modelBuilder.Entity("SeriesMetadataTag", b => + { + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.Property("TagsId") + .HasColumnType("INTEGER"); + + b.HasKey("SeriesMetadatasId", "TagsId"); + + b.HasIndex("TagsId"); + + b.ToTable("SeriesMetadataTag"); + }); + + modelBuilder.Entity("API.Entities.AppUserBookmark", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Bookmarks") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserPreferences", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithOne("UserPreferences") + .HasForeignKey("API.Entities.AppUserPreferences", "AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.SiteTheme", "Theme") + .WithMany() + .HasForeignKey("ThemeId"); + + b.Navigation("AppUser"); + + b.Navigation("Theme"); + }); + + modelBuilder.Entity("API.Entities.AppUserProgress", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Progresses") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", null) + .WithMany("Progress") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserRating", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Ratings") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", null) + .WithMany("Ratings") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserRole", b => + { + b.HasOne("API.Entities.AppRole", "Role") + .WithMany("UserRoles") + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.AppUser", "User") + .WithMany("UserRoles") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Role"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.HasOne("API.Entities.Volume", "Volume") + .WithMany("Chapters") + .HasForeignKey("VolumeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Volume"); + }); + + modelBuilder.Entity("API.Entities.Device", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Devices") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.FolderPath", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("Folders") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.MangaFile", b => + { + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany("Files") + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesMetadata", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithOne("Metadata") + .HasForeignKey("API.Entities.Metadata.SeriesMetadata", "SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesRelation", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithMany("Relations") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.ClientCascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "TargetSeries") + .WithMany("RelationOf") + .HasForeignKey("TargetSeriesId") + .OnDelete(DeleteBehavior.ClientCascade) + .IsRequired(); + + b.Navigation("Series"); + + b.Navigation("TargetSeries"); + }); + + modelBuilder.Entity("API.Entities.ReadingList", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("ReadingLists") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.ReadingListItem", b => + { + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany() + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.ReadingList", "ReadingList") + .WithMany("Items") + .HasForeignKey("ReadingListId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Volume", "Volume") + .WithMany() + .HasForeignKey("VolumeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + + b.Navigation("ReadingList"); + + b.Navigation("Series"); + + b.Navigation("Volume"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany("WantToRead") + .HasForeignKey("AppUserId"); + + b.HasOne("API.Entities.Library", "Library") + .WithMany("Series") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithMany("Volumes") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("AppUserLibrary", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("AppUsersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Library", null) + .WithMany() + .HasForeignKey("LibrariesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ChapterGenre", b => + { + b.HasOne("API.Entities.Chapter", null) + .WithMany() + .HasForeignKey("ChaptersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Genre", null) + .WithMany() + .HasForeignKey("GenresId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ChapterPerson", b => + { + b.HasOne("API.Entities.Chapter", null) + .WithMany() + .HasForeignKey("ChapterMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Person", null) + .WithMany() + .HasForeignKey("PeopleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ChapterTag", b => + { + b.HasOne("API.Entities.Chapter", null) + .WithMany() + .HasForeignKey("ChaptersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Tag", null) + .WithMany() + .HasForeignKey("TagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("CollectionTagSeriesMetadata", b => + { + b.HasOne("API.Entities.CollectionTag", null) + .WithMany() + .HasForeignKey("CollectionTagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("GenreSeriesMetadata", b => + { + b.HasOne("API.Entities.Genre", null) + .WithMany() + .HasForeignKey("GenresId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("API.Entities.AppRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("PersonSeriesMetadata", b => + { + b.HasOne("API.Entities.Person", null) + .WithMany() + .HasForeignKey("PeopleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SeriesMetadataTag", b => + { + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Tag", null) + .WithMany() + .HasForeignKey("TagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("API.Entities.AppRole", b => + { + b.Navigation("UserRoles"); + }); + + modelBuilder.Entity("API.Entities.AppUser", b => + { + b.Navigation("Bookmarks"); + + b.Navigation("Devices"); + + b.Navigation("Progresses"); + + b.Navigation("Ratings"); + + b.Navigation("ReadingLists"); + + b.Navigation("UserPreferences"); + + b.Navigation("UserRoles"); + + b.Navigation("WantToRead"); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.Navigation("Files"); + }); + + modelBuilder.Entity("API.Entities.Library", b => + { + b.Navigation("Folders"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.ReadingList", b => + { + b.Navigation("Items"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.Navigation("Metadata"); + + b.Navigation("Progress"); + + b.Navigation("Ratings"); + + b.Navigation("RelationOf"); + + b.Navigation("Relations"); + + b.Navigation("Volumes"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.Navigation("Chapters"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/API/Data/Migrations/20221009211237_UserAgeRating.cs b/API/Data/Migrations/20221009211237_UserAgeRating.cs new file mode 100644 index 000000000..a619255ef --- /dev/null +++ b/API/Data/Migrations/20221009211237_UserAgeRating.cs @@ -0,0 +1,27 @@ +using API.Entities.Enums; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace API.Data.Migrations +{ + public partial class UserAgeRating : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "AgeRestriction", + table: "AspNetUsers", + type: "INTEGER", + nullable: false, + defaultValue: AgeRating.NotApplicable); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "AgeRestriction", + table: "AspNetUsers"); + } + } +} diff --git a/API/Data/Migrations/DataContextModelSnapshot.cs b/API/Data/Migrations/DataContextModelSnapshot.cs index 9462617fe..80b841b8b 100644 --- a/API/Data/Migrations/DataContextModelSnapshot.cs +++ b/API/Data/Migrations/DataContextModelSnapshot.cs @@ -53,6 +53,9 @@ namespace API.Data.Migrations b.Property("AccessFailedCount") .HasColumnType("INTEGER"); + b.Property("AgeRestriction") + .HasColumnType("INTEGER"); + b.Property("ApiKey") .HasColumnType("TEXT"); @@ -736,6 +739,9 @@ namespace API.Data.Migrations .ValueGeneratedOnAdd() .HasColumnType("INTEGER"); + b.Property("AgeRating") + .HasColumnType("INTEGER"); + b.Property("AppUserId") .HasColumnType("INTEGER"); diff --git a/API/Data/Repositories/CollectionTagRepository.cs b/API/Data/Repositories/CollectionTagRepository.cs index 7b9398b85..a88a98adf 100644 --- a/API/Data/Repositories/CollectionTagRepository.cs +++ b/API/Data/Repositories/CollectionTagRepository.cs @@ -4,6 +4,7 @@ using System.Linq; using System.Threading.Tasks; using API.DTOs.CollectionTags; using API.Entities; +using API.Extensions; using AutoMapper; using AutoMapper.QueryableExtensions; using Microsoft.EntityFrameworkCore; @@ -15,9 +16,9 @@ public interface ICollectionTagRepository void Add(CollectionTag tag); void Remove(CollectionTag tag); Task> GetAllTagDtosAsync(); - Task> SearchTagDtosAsync(string searchQuery); + Task> SearchTagDtosAsync(string searchQuery, int userId); Task GetCoverImageAsync(int collectionTagId); - Task> GetAllPromotedTagDtosAsync(); + Task> GetAllPromotedTagDtosAsync(int userId); Task GetTagAsync(int tagId); Task GetFullTagAsync(int tagId); void Update(CollectionTag tag); @@ -85,6 +86,7 @@ public class CollectionTagRepository : ICollectionTagRepository public async Task> GetAllTagDtosAsync() { + return await _context.CollectionTag .OrderBy(c => c.NormalizedTitle) .AsNoTracking() @@ -92,10 +94,12 @@ public class CollectionTagRepository : ICollectionTagRepository .ToListAsync(); } - public async Task> GetAllPromotedTagDtosAsync() + public async Task> GetAllPromotedTagDtosAsync(int userId) { + var userRating = (await _context.AppUser.SingleAsync(u => u.Id == userId)).AgeRestriction; return await _context.CollectionTag .Where(c => c.Promoted) + .RestrictAgainstAgeRestriction(userRating) .OrderBy(c => c.NormalizedTitle) .AsNoTracking() .ProjectTo(_mapper.ConfigurationProvider) @@ -118,11 +122,13 @@ public class CollectionTagRepository : ICollectionTagRepository .SingleOrDefaultAsync(); } - public async Task> SearchTagDtosAsync(string searchQuery) + public async Task> SearchTagDtosAsync(string searchQuery, int userId) { + var userRating = (await _context.AppUser.SingleAsync(u => u.Id == userId)).AgeRestriction; return await _context.CollectionTag .Where(s => EF.Functions.Like(s.Title, $"%{searchQuery}%") || EF.Functions.Like(s.NormalizedTitle, $"%{searchQuery}%")) + .RestrictAgainstAgeRestriction(userRating) .OrderBy(s => s.Title) .AsNoTracking() .OrderBy(c => c.NormalizedTitle) diff --git a/API/Data/Repositories/ReadingListRepository.cs b/API/Data/Repositories/ReadingListRepository.cs index 6fde82929..3401205d1 100644 --- a/API/Data/Repositories/ReadingListRepository.cs +++ b/API/Data/Repositories/ReadingListRepository.cs @@ -23,6 +23,7 @@ public interface IReadingListRepository Task> GetReadingListDtosForSeriesAndUserAsync(int userId, int seriesId, bool includePromoted); void Remove(ReadingListItem item); + void Add(ReadingList list); void BulkRemove(IEnumerable items); void Update(ReadingList list); Task Count(); @@ -46,6 +47,11 @@ public class ReadingListRepository : IReadingListRepository _context.Entry(list).State = EntityState.Modified; } + public void Add(ReadingList list) + { + _context.Add(list); + } + public async Task Count() { return await _context.ReadingList.CountAsync(); @@ -82,8 +88,10 @@ public class ReadingListRepository : IReadingListRepository public async Task> GetReadingListDtosForUserAsync(int userId, bool includePromoted, UserParams userParams) { + var userAgeRating = (await _context.AppUser.SingleAsync(u => u.Id == userId)).AgeRestriction; var query = _context.ReadingList .Where(l => l.AppUserId == userId || (includePromoted && l.Promoted )) + .Where(l => l.AgeRating >= userAgeRating) .OrderBy(l => l.LastModified) .ProjectTo(_mapper.ConfigurationProvider) .AsNoTracking(); @@ -97,7 +105,7 @@ public class ReadingListRepository : IReadingListRepository .Where(l => l.AppUserId == userId || (includePromoted && l.Promoted )) .Where(l => l.Items.Any(i => i.SeriesId == seriesId)) .AsSplitQuery() - .OrderBy(l => l.LastModified) + .OrderBy(l => l.Title) .ProjectTo(_mapper.ConfigurationProvider) .AsNoTracking(); diff --git a/API/Data/Repositories/SeriesRepository.cs b/API/Data/Repositories/SeriesRepository.cs index dbb78641a..6edb4caf7 100644 --- a/API/Data/Repositories/SeriesRepository.cs +++ b/API/Data/Repositories/SeriesRepository.cs @@ -51,6 +51,7 @@ internal class RecentlyAddedSeries public string ChapterTitle { get; init; } public bool IsSpecial { get; init; } public int VolumeNumber { get; init; } + public AgeRating AgeRating { get; init; } } public interface ISeriesRepository @@ -118,11 +119,11 @@ public interface ISeriesRepository Task GetSeriesForMangaFile(int mangaFileId, int userId); Task GetSeriesForChapter(int chapterId, int userId); Task> GetWantToReadForUserAsync(int userId, UserParams userParams, FilterDto filter); - Task GetSeriesIdByFolder(string folder); Task GetSeriesByFolderPath(string folder, SeriesIncludes includes = SeriesIncludes.None); Task GetFullSeriesByAnyName(string seriesName, string localizedName, int libraryId, MangaFormat format, bool withFullIncludes = true); Task> RemoveSeriesNotInList(IList seenSeries, int libraryId); Task>> GetFolderPathMap(int libraryId); + Task GetMaxAgeRatingFromSeriesAsync(IEnumerable seriesIds); } public class SeriesRepository : ISeriesRepository @@ -307,9 +308,11 @@ public class SeriesRepository : ISeriesRepository const int maxRecords = 15; var result = new SearchResultGroupDto(); var searchQueryNormalized = Services.Tasks.Scanner.Parser.Parser.Normalize(searchQuery); + var userRating = await GetUserAgeRestriction(userId); var seriesIds = _context.Series .Where(s => libraryIds.Contains(s.LibraryId)) + .RestrictAgainstAgeRestriction(userRating) .Select(s => s.Id) .ToList(); @@ -333,6 +336,7 @@ public class SeriesRepository : ISeriesRepository || EF.Functions.Like(s.LocalizedName, $"%{searchQuery}%") || EF.Functions.Like(s.NormalizedName, $"%{searchQueryNormalized}%") || (hasYearInQuery && s.Metadata.ReleaseYear == yearComparison)) + .RestrictAgainstAgeRestriction(userRating) .Include(s => s.Library) .OrderBy(s => s.SortName) .AsNoTracking() @@ -341,19 +345,20 @@ public class SeriesRepository : ISeriesRepository .ProjectTo(_mapper.ConfigurationProvider) .AsEnumerable(); - result.ReadingLists = await _context.ReadingList .Where(rl => rl.AppUserId == userId || rl.Promoted) .Where(rl => EF.Functions.Like(rl.Title, $"%{searchQuery}%")) + .RestrictAgainstAgeRestriction(userRating) .AsSplitQuery() .Take(maxRecords) .ProjectTo(_mapper.ConfigurationProvider) .ToListAsync(); result.Collections = await _context.CollectionTag - .Where(s => EF.Functions.Like(s.Title, $"%{searchQuery}%") - || EF.Functions.Like(s.NormalizedTitle, $"%{searchQueryNormalized}%")) - .Where(s => s.Promoted || isAdmin) + .Where(c => EF.Functions.Like(c.Title, $"%{searchQuery}%") + || EF.Functions.Like(c.NormalizedTitle, $"%{searchQueryNormalized}%")) + .Where(c => c.Promoted || isAdmin) + .RestrictAgainstAgeRestriction(userRating) .OrderBy(s => s.Title) .AsNoTracking() .AsSplitQuery() @@ -392,7 +397,7 @@ public class SeriesRepository : ISeriesRepository .ToListAsync(); var fileIds = _context.Series - .Where(s => libraryIds.Contains(s.LibraryId)) + .Where(s => seriesIds.Contains(s.Id)) .AsSplitQuery() .SelectMany(s => s.Volumes) .SelectMany(v => v.Chapters) @@ -735,6 +740,8 @@ public class SeriesRepository : ISeriesRepository private async Task> CreateFilteredSearchQueryable(int userId, int libraryId, FilterDto filter) { var userLibraries = await GetUserLibraries(libraryId, userId); + var userRating = await GetUserAgeRestriction(userId); + var formats = ExtractFilters(libraryId, userId, filter, ref userLibraries, out var allPeopleIds, out var hasPeopleFilter, out var hasGenresFilter, out var hasCollectionTagFilter, out var hasRatingFilter, out var hasProgressFilter, @@ -759,8 +766,13 @@ public class SeriesRepository : ISeriesRepository .Where(s => !hasSeriesNameFilter || EF.Functions.Like(s.Name, $"%{filter.SeriesNameQuery}%") || EF.Functions.Like(s.OriginalName, $"%{filter.SeriesNameQuery}%") - || EF.Functions.Like(s.LocalizedName, $"%{filter.SeriesNameQuery}%")) - .AsNoTracking(); + || EF.Functions.Like(s.LocalizedName, $"%{filter.SeriesNameQuery}%")); + if (userRating != AgeRating.NotApplicable) + { + query = query.RestrictAgainstAgeRestriction(userRating); + } + + query = query.AsNoTracking(); // If no sort options, default to using SortName filter.SortOptions ??= new SortOptions() @@ -1033,7 +1045,10 @@ public class SeriesRepository : ISeriesRepository { var seriesMap = new Dictionary(); var index = 0; - foreach (var item in await GetRecentlyAddedChaptersQuery(userId)) + var userRating = await GetUserAgeRestriction(userId); + + var items = (await GetRecentlyAddedChaptersQuery(userId)); + foreach (var item in items.Where(c => c.AgeRating <= userRating)) { if (seriesMap.Keys.Count == pageSize) break; @@ -1061,11 +1076,19 @@ public class SeriesRepository : ISeriesRepository return seriesMap.Values.AsEnumerable(); } + private async Task GetUserAgeRestriction(int userId) + { + return (await _context.AppUser.SingleAsync(u => u.Id == userId)).AgeRestriction; + } + public async Task> GetSeriesForRelationKind(int userId, int seriesId, RelationKind kind) { var libraryIds = GetLibraryIdsForUser(userId); + var userRating = await GetUserAgeRestriction(userId); + var usersSeriesIds = _context.Series .Where(s => libraryIds.Contains(s.LibraryId)) + .RestrictAgainstAgeRestriction(userRating) .Select(s => s.Id); var targetSeries = _context.SeriesRelation @@ -1078,6 +1101,7 @@ public class SeriesRepository : ISeriesRepository return await _context.Series .Where(s => targetSeries.Contains(s.Id)) + .RestrictAgainstAgeRestriction(userRating) .AsSplitQuery() .AsNoTracking() .ProjectTo(_mapper.ConfigurationProvider) @@ -1128,6 +1152,8 @@ public class SeriesRepository : ISeriesRepository public async Task GetSeriesForMangaFile(int mangaFileId, int userId) { var libraryIds = GetLibraryIdsForUser(userId); + var userRating = await GetUserAgeRestriction(userId); + return await _context.MangaFile .Where(m => m.Id == mangaFileId) .AsSplitQuery() @@ -1135,6 +1161,7 @@ public class SeriesRepository : ISeriesRepository .Select(c => c.Volume) .Select(v => v.Series) .Where(s => libraryIds.Contains(s.LibraryId)) + .RestrictAgainstAgeRestriction(userRating) .ProjectTo(_mapper.ConfigurationProvider) .SingleOrDefaultAsync(); } @@ -1142,31 +1169,18 @@ public class SeriesRepository : ISeriesRepository public async Task GetSeriesForChapter(int chapterId, int userId) { var libraryIds = GetLibraryIdsForUser(userId); + var userRating = await GetUserAgeRestriction(userId); return await _context.Chapter .Where(m => m.Id == chapterId) .AsSplitQuery() .Select(c => c.Volume) .Select(v => v.Series) .Where(s => libraryIds.Contains(s.LibraryId)) + .RestrictAgainstAgeRestriction(userRating) .ProjectTo(_mapper.ConfigurationProvider) .SingleOrDefaultAsync(); } - /// - /// Given a folder path return a Series with the that matches. - /// - /// This will apply normalization on the path. - /// - /// - public async Task GetSeriesIdByFolder(string folder) - { - var normalized = Services.Tasks.Scanner.Parser.Parser.NormalizePath(folder); - var series = await _context.Series - .Where(s => s.FolderPath.Equals(normalized)) - .SingleOrDefaultAsync(); - return series?.Id ?? 0; - } - /// /// Return a Series by Folder path. Null if not found. /// @@ -1368,21 +1382,22 @@ public class SeriesRepository : ISeriesRepository { var libraryIds = GetLibraryIdsForUser(userId); var usersSeriesIds = GetSeriesIdsForLibraryIds(libraryIds); + var userRating = await GetUserAgeRestriction(userId); return new RelatedSeriesDto() { SourceSeriesId = seriesId, - Adaptations = await GetRelatedSeriesQuery(seriesId, usersSeriesIds, RelationKind.Adaptation), - Characters = await GetRelatedSeriesQuery(seriesId, usersSeriesIds, RelationKind.Character), - Prequels = await GetRelatedSeriesQuery(seriesId, usersSeriesIds, RelationKind.Prequel), - Sequels = await GetRelatedSeriesQuery(seriesId, usersSeriesIds, RelationKind.Sequel), - Contains = await GetRelatedSeriesQuery(seriesId, usersSeriesIds, RelationKind.Contains), - SideStories = await GetRelatedSeriesQuery(seriesId, usersSeriesIds, RelationKind.SideStory), - SpinOffs = await GetRelatedSeriesQuery(seriesId, usersSeriesIds, RelationKind.SpinOff), - Others = await GetRelatedSeriesQuery(seriesId, usersSeriesIds, RelationKind.Other), - AlternativeSettings = await GetRelatedSeriesQuery(seriesId, usersSeriesIds, RelationKind.AlternativeSetting), - AlternativeVersions = await GetRelatedSeriesQuery(seriesId, usersSeriesIds, RelationKind.AlternativeVersion), - Doujinshis = await GetRelatedSeriesQuery(seriesId, usersSeriesIds, RelationKind.Doujinshi), + Adaptations = await GetRelatedSeriesQuery(seriesId, usersSeriesIds, RelationKind.Adaptation, userRating), + Characters = await GetRelatedSeriesQuery(seriesId, usersSeriesIds, RelationKind.Character, userRating), + Prequels = await GetRelatedSeriesQuery(seriesId, usersSeriesIds, RelationKind.Prequel, userRating), + Sequels = await GetRelatedSeriesQuery(seriesId, usersSeriesIds, RelationKind.Sequel, userRating), + Contains = await GetRelatedSeriesQuery(seriesId, usersSeriesIds, RelationKind.Contains, userRating), + SideStories = await GetRelatedSeriesQuery(seriesId, usersSeriesIds, RelationKind.SideStory, userRating), + SpinOffs = await GetRelatedSeriesQuery(seriesId, usersSeriesIds, RelationKind.SpinOff, userRating), + Others = await GetRelatedSeriesQuery(seriesId, usersSeriesIds, RelationKind.Other, userRating), + AlternativeSettings = await GetRelatedSeriesQuery(seriesId, usersSeriesIds, RelationKind.AlternativeSetting, userRating), + AlternativeVersions = await GetRelatedSeriesQuery(seriesId, usersSeriesIds, RelationKind.AlternativeVersion, userRating), + Doujinshis = await GetRelatedSeriesQuery(seriesId, usersSeriesIds, RelationKind.Doujinshi, userRating), Parent = await _context.Series .SelectMany(s => s.RelationOf.Where(r => r.TargetSeriesId == seriesId @@ -1390,6 +1405,7 @@ public class SeriesRepository : ISeriesRepository && r.RelationKind != RelationKind.Prequel && r.RelationKind != RelationKind.Sequel) .Select(sr => sr.Series)) + .RestrictAgainstAgeRestriction(userRating) .AsSplitQuery() .AsNoTracking() .ProjectTo(_mapper.ConfigurationProvider) @@ -1404,11 +1420,12 @@ public class SeriesRepository : ISeriesRepository .Select(s => s.Id); } - private async Task> GetRelatedSeriesQuery(int seriesId, IEnumerable usersSeriesIds, RelationKind kind) + private async Task> GetRelatedSeriesQuery(int seriesId, IEnumerable usersSeriesIds, RelationKind kind, AgeRating userRating) { return await _context.Series.SelectMany(s => s.Relations.Where(sr => sr.RelationKind == kind && sr.SeriesId == seriesId && usersSeriesIds.Contains(sr.TargetSeriesId)) .Select(sr => sr.TargetSeries)) + .RestrictAgainstAgeRestriction(userRating) .AsSplitQuery() .AsNoTracking() .ProjectTo(_mapper.ConfigurationProvider) @@ -1417,16 +1434,15 @@ public class SeriesRepository : ISeriesRepository private async Task> GetRecentlyAddedChaptersQuery(int userId) { - var libraries = await _context.AppUser + var libraryIds = await _context.AppUser .Where(u => u.Id == userId) .SelectMany(u => u.Libraries.Select(l => new {LibraryId = l.Id, LibraryType = l.Type})) + .Select(l => l.LibraryId) .ToListAsync(); - var libraryIds = libraries.Select(l => l.LibraryId).ToList(); var withinLastWeek = DateTime.Now - TimeSpan.FromDays(12); - var ret = _context.Chapter - .Where(c => c.Created >= withinLastWeek) - .AsNoTracking() + return _context.Chapter + .Where(c => c.Created >= withinLastWeek).AsNoTracking() .Include(c => c.Volume) .ThenInclude(v => v.Series) .ThenInclude(s => s.Library) @@ -1445,12 +1461,12 @@ public class SeriesRepository : ISeriesRepository ChapterRange = c.Range, IsSpecial = c.IsSpecial, VolumeNumber = c.Volume.Number, - ChapterTitle = c.Title + ChapterTitle = c.Title, + AgeRating = c.Volume.Series.Metadata.AgeRating }) .AsSplitQuery() .Where(c => c.Created >= withinLastWeek && libraryIds.Contains(c.LibraryId)) .AsEnumerable(); - return ret; } public async Task> GetWantToReadForUserAsync(int userId, UserParams userParams, FilterDto filter) @@ -1503,6 +1519,21 @@ public class SeriesRepository : ISeriesRepository return map; } + /// + /// Returns the highest Age Rating for a list of Series + /// + /// + /// + public async Task GetMaxAgeRatingFromSeriesAsync(IEnumerable seriesIds) + { + return await _context.Series + .Where(s => seriesIds.Contains(s.Id)) + .Include(s => s.Metadata) + .Select(s => s.Metadata.AgeRating) + .OrderBy(s => s) + .LastOrDefaultAsync(); + } + private static IQueryable AddIncludesToQuery(IQueryable query, SeriesIncludes includeFlags) { if (includeFlags.HasFlag(SeriesIncludes.Library)) diff --git a/API/Data/Repositories/UserRepository.cs b/API/Data/Repositories/UserRepository.cs index 44a01066a..62d802f3a 100644 --- a/API/Data/Repositories/UserRepository.cs +++ b/API/Data/Repositories/UserRepository.cs @@ -396,6 +396,7 @@ public class UserRepository : IUserRepository Created = u.Created, LastActive = u.LastActive, Roles = u.UserRoles.Select(r => r.Role.Name).ToList(), + AgeRestriction = u.AgeRestriction, Libraries = u.Libraries.Select(l => new LibraryDto { Name = l.Name, @@ -429,6 +430,7 @@ public class UserRepository : IUserRepository Created = u.Created, LastActive = u.LastActive, Roles = u.UserRoles.Select(r => r.Role.Name).ToList(), + AgeRestriction = u.AgeRestriction, Libraries = u.Libraries.Select(l => new LibraryDto { Name = l.Name, diff --git a/API/Entities/AppUser.cs b/API/Entities/AppUser.cs index cf8ab3304..53cdefce6 100644 --- a/API/Entities/AppUser.cs +++ b/API/Entities/AppUser.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.ComponentModel.DataAnnotations; +using API.Entities.Enums; using API.Entities.Interfaces; using Microsoft.AspNetCore.Identity; @@ -40,7 +41,10 @@ public class AppUser : IdentityUser, IHasConcurrencyToken /// The confirmation token for the user (invite). This will be set to null after the user confirms. /// public string ConfirmationToken { get; set; } - + /// + /// The highest age rating the user has access to. Not applicable for admins + /// + public AgeRating AgeRestriction { get; set; } = AgeRating.NotApplicable; /// [ConcurrencyCheck] diff --git a/API/Entities/Enums/AgeRating.cs b/API/Entities/Enums/AgeRating.cs index 82dbef7ae..9eefb9fa7 100644 --- a/API/Entities/Enums/AgeRating.cs +++ b/API/Entities/Enums/AgeRating.cs @@ -8,6 +8,11 @@ namespace API.Entities.Enums; /// Based on ComicInfo.xml v2.1 https://github.com/anansi-project/comicinfo/blob/main/drafts/v2.1/ComicInfo.xsd public enum AgeRating { + /// + /// This is for Age Restriction for Restricted Profiles + /// + [Description("Not Applicable")] + NotApplicable = -1, [Description("Unknown")] Unknown = 0, [Description("Rating Pending")] diff --git a/API/Entities/ReadingList.cs b/API/Entities/ReadingList.cs index 8a4ecf96b..6712fe923 100644 --- a/API/Entities/ReadingList.cs +++ b/API/Entities/ReadingList.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using API.Entities.Enums; using API.Entities.Interfaces; namespace API.Entities; @@ -27,6 +28,12 @@ public class ReadingList : IEntityDate public string CoverImage { get; set; } public bool CoverImageLocked { get; set; } + /// + /// The highest age rating from all Series within the reading list + /// + /// Introduced in v0.6 + public AgeRating AgeRating { get; set; } = AgeRating.Unknown; + public ICollection Items { get; set; } public DateTime Created { get; set; } public DateTime LastModified { get; set; } diff --git a/API/Extensions/QueryableExtensions.cs b/API/Extensions/QueryableExtensions.cs new file mode 100644 index 000000000..d92406613 --- /dev/null +++ b/API/Extensions/QueryableExtensions.cs @@ -0,0 +1,23 @@ +using System.Linq; +using API.Entities; +using API.Entities.Enums; + +namespace API.Extensions; + +public static class QueryableExtensions +{ + public static IQueryable RestrictAgainstAgeRestriction(this IQueryable queryable, AgeRating rating) + { + return queryable.Where(s => rating == AgeRating.NotApplicable || s.Metadata.AgeRating <= rating); + } + + public static IQueryable RestrictAgainstAgeRestriction(this IQueryable queryable, AgeRating rating) + { + return queryable.Where(c => c.SeriesMetadatas.All(sm => sm.AgeRating <= rating)); + } + + public static IQueryable RestrictAgainstAgeRestriction(this IQueryable queryable, AgeRating rating) + { + return queryable.Where(rl => rl.AgeRating <= rating); + } +} diff --git a/API/Services/AccountService.cs b/API/Services/AccountService.cs index 1eb50f281..0d8bed66c 100644 --- a/API/Services/AccountService.cs +++ b/API/Services/AccountService.cs @@ -118,4 +118,15 @@ public class AccountService : IAccountService return roles.Contains(PolicyConstants.DownloadRole) || roles.Contains(PolicyConstants.AdminRole); } + /// + /// Does the user have Change Restriction permission or admin rights + /// + /// + /// + public async Task HasChangeRestrictionRole(AppUser user) + { + var roles = await _userManager.GetRolesAsync(user); + return roles.Contains(PolicyConstants.ChangePasswordRole) || roles.Contains(PolicyConstants.AdminRole); + } + } diff --git a/API/Services/ReadingListService.cs b/API/Services/ReadingListService.cs index 60314e3a9..55c842252 100644 --- a/API/Services/ReadingListService.cs +++ b/API/Services/ReadingListService.cs @@ -17,7 +17,7 @@ public interface IReadingListService Task DeleteReadingListItem(UpdateReadingListPosition dto); Task UserHasReadingListAccess(int readingListId, string username); Task DeleteReadingList(int readingListId, AppUser user); - + Task CalculateReadingListAgeRating(ReadingList readingList); Task AddChaptersToReadingList(int seriesId, IList chapterIds, ReadingList readingList); } @@ -41,7 +41,7 @@ public class ReadingListService : IReadingListService /// - /// Removes all entries that are fully read from the reading list + /// Removes all entries that are fully read from the reading list. This commits /// /// If called from API layer, expected for to be called beforehand /// Reading List Id @@ -62,10 +62,12 @@ public class ReadingListService : IReadingListService itemIdsToRemove.Contains(r.Id)); _unitOfWork.ReadingListRepository.BulkRemove(listItems); + var readingList = await _unitOfWork.ReadingListRepository.GetReadingListByIdAsync(readingListId); + await CalculateReadingListAgeRating(readingList); + if (!_unitOfWork.HasChanges()) return true; - await _unitOfWork.CommitAsync(); - return true; + return await _unitOfWork.CommitAsync(); } catch { @@ -97,6 +99,11 @@ public class ReadingListService : IReadingListService return await _unitOfWork.CommitAsync(); } + /// + /// Removes a certain reading list item from a reading list + /// + /// Only ReadingListId and ReadingListItemId are used + /// public async Task DeleteReadingListItem(UpdateReadingListPosition dto) { var readingList = await _unitOfWork.ReadingListRepository.GetReadingListByIdAsync(dto.ReadingListId); @@ -109,11 +116,34 @@ public class ReadingListService : IReadingListService index++; } + await CalculateReadingListAgeRating(readingList); + if (!_unitOfWork.HasChanges()) return true; return await _unitOfWork.CommitAsync(); } + /// + /// Calculates the highest Age Rating from each Reading List Item + /// + /// + public async Task CalculateReadingListAgeRating(ReadingList readingList) + { + await CalculateReadingListAgeRating(readingList, readingList.Items.Select(i => i.SeriesId)); + } + + /// + /// Calculates the highest Age Rating from each Reading List Item + /// + /// This method is used when the ReadingList doesn't have items yet + /// + /// The series ids of all the reading list items + private async Task CalculateReadingListAgeRating(ReadingList readingList, IEnumerable seriesIds) + { + var ageRating = await _unitOfWork.SeriesRepository.GetMaxAgeRatingFromSeriesAsync(seriesIds); + readingList.AgeRating = ageRating; + } + /// /// Validates the user has access to the reading list to perform actions on it /// @@ -167,16 +197,18 @@ public class ReadingListService : IReadingListService var existingChapterExists = readingList.Items.Select(rli => rli.ChapterId).ToHashSet(); var chaptersForSeries = (await _unitOfWork.ChapterRepository.GetChaptersByIdsAsync(chapterIds)) .OrderBy(c => Tasks.Scanner.Parser.Parser.MinNumberFromRange(c.Volume.Name)) - .ThenBy(x => double.Parse(x.Number), _chapterSortComparerForInChapterSorting); + .ThenBy(x => double.Parse(x.Number), _chapterSortComparerForInChapterSorting) + .ToList(); var index = lastOrder + 1; - foreach (var chapter in chaptersForSeries) + foreach (var chapter in chaptersForSeries.Where(chapter => !existingChapterExists.Contains(chapter.Id))) { - if (existingChapterExists.Contains(chapter.Id)) continue; readingList.Items.Add(DbFactory.ReadingListItem(index, seriesId, chapter.VolumeId, chapter.Id)); index += 1; } + await CalculateReadingListAgeRating(readingList, new []{ seriesId }); + return index > lastOrder + 1; } } diff --git a/API/Services/SeriesService.cs b/API/Services/SeriesService.cs index 963c9a472..27f4cbc55 100644 --- a/API/Services/SeriesService.cs +++ b/API/Services/SeriesService.cs @@ -474,6 +474,14 @@ public class SeriesService : ISeriesService if (!libraryIds.Contains(series.LibraryId)) throw new UnauthorizedAccessException("User does not have access to the library this series belongs to"); + var user = await _unitOfWork.UserRepository.GetUserByIdAsync(userId); + if (user.AgeRestriction != AgeRating.NotApplicable) + { + var seriesMetadata = await _unitOfWork.SeriesRepository.GetSeriesMetadata(seriesId); + if (seriesMetadata.AgeRating > user.AgeRestriction) + throw new UnauthorizedAccessException("User is not allowed to view this series due to age restrictions"); + } + var libraryType = await _unitOfWork.LibraryRepository.GetLibraryTypeAsync(series.LibraryId); var volumes = (await _unitOfWork.VolumeRepository.GetVolumesDtoAsync(seriesId, userId)) .OrderBy(v => Tasks.Scanner.Parser.Parser.MinNumberFromRange(v.Name)) diff --git a/API/Services/Tasks/Scanner/ParseScannedFiles.cs b/API/Services/Tasks/Scanner/ParseScannedFiles.cs index 45e598957..d36fb3cff 100644 --- a/API/Services/Tasks/Scanner/ParseScannedFiles.cs +++ b/API/Services/Tasks/Scanner/ParseScannedFiles.cs @@ -84,7 +84,6 @@ public class ParseScannedFiles if (scanDirectoryByDirectory) { // This is used in library scan, so we should check first for a ignore file and use that here as well - // TODO: We need to calculate all folders till library root and see if any kavitaignores var potentialIgnoreFile = _directoryService.FileSystem.Path.Join(folderPath, DirectoryService.KavitaIgnoreFile); var matcher = _directoryService.CreateMatcherFromFile(potentialIgnoreFile); var directories = _directoryService.GetDirectories(folderPath, matcher).ToList(); diff --git a/API/Startup.cs b/API/Startup.cs index 2da14d07f..ed7181816 100644 --- a/API/Startup.cs +++ b/API/Startup.cs @@ -184,16 +184,20 @@ public class Startup var userManager = serviceProvider.GetRequiredService>(); var themeService = serviceProvider.GetRequiredService(); var dataContext = serviceProvider.GetRequiredService(); + var readingListService = serviceProvider.GetRequiredService(); // Only run this if we are upgrading await MigrateChangePasswordRoles.Migrate(unitOfWork, userManager); - await MigrateRemoveExtraThemes.Migrate(unitOfWork, themeService); // only needed for v0.5.4 and v0.6.0 await MigrateNormalizedEverything.Migrate(unitOfWork, dataContext, logger); + // v0.6.0 + await MigrateChangeRestrictionRoles.Migrate(unitOfWork, userManager, logger); + await MigrateReadingListAgeRating.Migrate(unitOfWork, dataContext, readingListService, logger); + // Update the version in the DB after all migrations are run var installVersion = await unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.InstallVersion); installVersion.Value = BuildInfo.Version.ToString(); diff --git a/TestData b/TestData new file mode 160000 index 000000000..4f5750025 --- /dev/null +++ b/TestData @@ -0,0 +1 @@ +Subproject commit 4f5750025a1c0b48cd72eaa6f1b61642c41f147f diff --git a/UI/Web/src/app/_models/member.ts b/UI/Web/src/app/_models/member.ts index 874dba535..eb584b340 100644 --- a/UI/Web/src/app/_models/member.ts +++ b/UI/Web/src/app/_models/member.ts @@ -1,4 +1,5 @@ import { Library } from './library'; +import { AgeRating } from './metadata/age-rating'; export interface Member { id: number; @@ -6,7 +7,10 @@ export interface Member { email: string; lastActive: string; // datetime created: string; // datetime - //isAdmin: boolean; roles: string[]; libraries: Library[]; + /** + * If not applicable, will store a -1 + */ + ageRestriction: AgeRating; } \ No newline at end of file diff --git a/UI/Web/src/app/_models/metadata/age-rating.ts b/UI/Web/src/app/_models/metadata/age-rating.ts index d44a8e250..cbb2e86a5 100644 --- a/UI/Web/src/app/_models/metadata/age-rating.ts +++ b/UI/Web/src/app/_models/metadata/age-rating.ts @@ -1,4 +1,8 @@ export enum AgeRating { + /** + * This is not a valid state for Series/Chapters, but used for Restricted Profiles + */ + NotApplicable = -1, Unknown = 0, AdultsOnly = 1, EarlyChildhood = 2, diff --git a/UI/Web/src/app/_models/user.ts b/UI/Web/src/app/_models/user.ts index ba26206dd..1271b1a17 100644 --- a/UI/Web/src/app/_models/user.ts +++ b/UI/Web/src/app/_models/user.ts @@ -1,3 +1,4 @@ +import { AgeRating } from './metadata/age-rating'; import { Preferences } from './preferences/preferences'; // This interface is only used for login and storing/retreiving JWT from local storage @@ -9,4 +10,5 @@ export interface User { preferences: Preferences; apiKey: string; email: string; + ageRestriction: AgeRating; } \ No newline at end of file diff --git a/UI/Web/src/app/_services/account.service.ts b/UI/Web/src/app/_services/account.service.ts index 71a8b1489..5990fe6d4 100644 --- a/UI/Web/src/app/_services/account.service.ts +++ b/UI/Web/src/app/_services/account.service.ts @@ -10,8 +10,16 @@ import { EVENTS, MessageHubService } from './message-hub.service'; import { ThemeService } from './theme.service'; import { InviteUserResponse } from '../_models/invite-user-response'; import { UserUpdateEvent } from '../_models/events/user-update-event'; -import { DeviceService } from './device.service'; import { UpdateEmailResponse } from '../_models/email/update-email-response'; +import { AgeRating } from '../_models/metadata/age-rating'; + +export enum Role { + Admin = 'Admin', + ChangePassword = 'Change Password', + Bookmark = 'Bookmark', + Download = 'Download', + ChangeRestriction = 'Change Restriction' +} @Injectable({ providedIn: 'root' @@ -49,19 +57,23 @@ export class AccountService implements OnDestroy { } hasAdminRole(user: User) { - return user && user.roles.includes('Admin'); + return user && user.roles.includes(Role.Admin); } hasChangePasswordRole(user: User) { - return user && user.roles.includes('Change Password'); + return user && user.roles.includes(Role.ChangePassword); + } + + hasChangeAgeRestrictionRole(user: User) { + return user && user.roles.includes(Role.ChangeRestriction); } hasDownloadRole(user: User) { - return user && user.roles.includes('Download'); + return user && user.roles.includes(Role.Download); } hasBookmarkRole(user: User) { - return user && user.roles.includes('Bookmark'); + return user && user.roles.includes(Role.Bookmark); } getRoles() { @@ -149,7 +161,7 @@ export class AccountService implements OnDestroy { return this.httpClient.post(this.baseUrl + 'account/resend-confirmation-email?userId=' + userId, {}, {responseType: 'text' as 'json'}); } - inviteUser(model: {email: string, roles: Array, libraries: Array}) { + inviteUser(model: {email: string, roles: Array, libraries: Array, ageRestriction: AgeRating}) { return this.httpClient.post(this.baseUrl + 'account/invite', model); } @@ -186,7 +198,7 @@ export class AccountService implements OnDestroy { return this.httpClient.post(this.baseUrl + 'account/reset-password', {username, password, oldPassword}, {responseType: 'json' as 'text'}); } - update(model: {email: string, roles: Array, libraries: Array, userId: number}) { + update(model: {email: string, roles: Array, libraries: Array, userId: number, ageRestriction: AgeRating}) { return this.httpClient.post(this.baseUrl + 'account/update', model); } @@ -194,6 +206,10 @@ export class AccountService implements OnDestroy { return this.httpClient.post(this.baseUrl + 'account/update/email', {email}); } + updateAgeRestriction(ageRating: AgeRating) { + return this.httpClient.post(this.baseUrl + 'account/update/age-restriction', {ageRating}); + } + /** * This will get latest preferences for a user and cache them into user store * @returns diff --git a/UI/Web/src/app/_services/library.service.ts b/UI/Web/src/app/_services/library.service.ts index 5aac12cfd..00619d5bd 100644 --- a/UI/Web/src/app/_services/library.service.ts +++ b/UI/Web/src/app/_services/library.service.ts @@ -113,12 +113,4 @@ export class LibraryService { return this.libraryTypes[libraryId]; })); } - - search(term: string) { - if (term === '') { - return of(new SearchResultGroup()); - } - return this.httpClient.get(this.baseUrl + 'library/search?queryString=' + encodeURIComponent(term)); - } - } diff --git a/UI/Web/src/app/_services/search.service.ts b/UI/Web/src/app/_services/search.service.ts new file mode 100644 index 000000000..fa989fa35 --- /dev/null +++ b/UI/Web/src/app/_services/search.service.ts @@ -0,0 +1,31 @@ +import { HttpClient } from '@angular/common/http'; +import { Injectable } from '@angular/core'; +import { of } from 'rxjs'; +import { environment } from 'src/environments/environment'; +import { SearchResultGroup } from '../_models/search/search-result-group'; +import { Series } from '../_models/series'; + +@Injectable({ + providedIn: 'root' +}) +export class SearchService { + + baseUrl = environment.apiUrl; + + constructor(private httpClient: HttpClient) { } + + search(term: string) { + if (term === '') { + return of(new SearchResultGroup()); + } + return this.httpClient.get(this.baseUrl + 'search/search?queryString=' + encodeURIComponent(term)); + } + + getSeriesForMangaFile(mangaFileId: number) { + return this.httpClient.get(this.baseUrl + 'search/series-for-mangafile?mangaFileId=' + mangaFileId); + } + + getSeriesForChapter(chapterId: number) { + return this.httpClient.get(this.baseUrl + 'search/series-for-chapter?chapterId=' + chapterId); + } +} diff --git a/UI/Web/src/app/_services/series.service.ts b/UI/Web/src/app/_services/series.service.ts index a5a5f26c7..9a7eb7677 100644 --- a/UI/Web/src/app/_services/series.service.ts +++ b/UI/Web/src/app/_services/series.service.ts @@ -78,14 +78,6 @@ export class SeriesService { return this.httpClient.get(this.baseUrl + 'series/chapter-metadata?chapterId=' + chapterId); } - getSeriesForMangaFile(mangaFileId: number) { - return this.httpClient.get(this.baseUrl + 'series/series-for-mangafile?mangaFileId=' + mangaFileId); - } - - getSeriesForChapter(chapterId: number) { - return this.httpClient.get(this.baseUrl + 'series/series-for-chapter?chapterId=' + chapterId); - } - delete(seriesId: number) { return this.httpClient.delete(this.baseUrl + 'series/' + seriesId); } diff --git a/UI/Web/src/app/admin/edit-user/edit-user.component.html b/UI/Web/src/app/admin/edit-user/edit-user.component.html index 433c200a5..4a5e8a600 100644 --- a/UI/Web/src/app/admin/edit-user/edit-user.component.html +++ b/UI/Web/src/app/admin/edit-user/edit-user.component.html @@ -1,58 +1,66 @@ - -