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
This commit is contained in:
parent
0ad1638ec0
commit
442af965c6
63 changed files with 4638 additions and 262 deletions
|
|
@ -27,7 +27,11 @@ public static class PolicyConstants
|
|||
/// Used to give a user ability to bookmark files on the server
|
||||
/// </summary>
|
||||
public const string BookmarkRole = "Bookmark";
|
||||
/// <summary>
|
||||
/// Used to give a user ability to Change Restrictions on their account
|
||||
/// </summary>
|
||||
public const string ChangeRestrictionRole = "Change Restriction";
|
||||
|
||||
public static readonly ImmutableArray<string> ValidRoles =
|
||||
ImmutableArray.Create(AdminRole, PlebRole, DownloadRole, ChangePasswordRole, BookmarkRole);
|
||||
ImmutableArray.Create(AdminRole, PlebRole, DownloadRole, ChangePasswordRole, BookmarkRole, ChangeRestrictionRole);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<ActionResult> 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();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Update the user account. This can only affect Username, Email (will require confirming), Roles, and Library access.
|
||||
/// </summary>
|
||||
|
|
@ -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))
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
|
|||
|
|
@ -319,23 +319,6 @@ public class LibraryController : BaseApiController
|
|||
|
||||
}
|
||||
|
||||
[HttpGet("search")]
|
||||
public async Task<ActionResult<SearchResultGroupDto>> 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<ActionResult<LibraryType>> GetLibraryType(int libraryId)
|
||||
|
|
|
|||
|
|
@ -76,7 +76,9 @@ public class MetadataController : BaseApiController
|
|||
/// Fetches all age ratings from the instance
|
||||
/// </summary>
|
||||
/// <param name="libraryIds">String separated libraryIds or null for all ratings</param>
|
||||
/// <remarks>This API is cached for 1 hour, varying by libraryIds</remarks>
|
||||
/// <returns></returns>
|
||||
[ResponseCache(Duration = 60 * 5, Location = ResponseCacheLocation.Any, VaryByQueryKeys = new [] {"libraryIds"})]
|
||||
[HttpGet("age-ratings")]
|
||||
public async Task<ActionResult<IList<AgeRatingDto>>> GetAllAgeRatings(string? libraryIds)
|
||||
{
|
||||
|
|
@ -90,14 +92,16 @@ public class MetadataController : BaseApiController
|
|||
{
|
||||
Title = t.ToDescription(),
|
||||
Value = t
|
||||
}));
|
||||
}).Where(r => r.Value > AgeRating.NotApplicable));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fetches all publication status' from the instance
|
||||
/// </summary>
|
||||
/// <param name="libraryIds">String separated libraryIds or null for all publication status</param>
|
||||
/// <remarks>This API is cached for 1 hour, varying by libraryIds</remarks>
|
||||
/// <returns></returns>
|
||||
[ResponseCache(Duration = 60 * 5, Location = ResponseCacheLocation.Any, VaryByQueryKeys = new [] {"libraryIds"})]
|
||||
[HttpGet("publication-status")]
|
||||
public ActionResult<IList<AgeRatingDto>> GetAllPublicationStatus(string? libraryIds)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -196,8 +196,8 @@ public class OpdsController : BaseApiController
|
|||
var user = await _unitOfWork.UserRepository.GetUserByIdAsync(userId);
|
||||
var isAdmin = await _unitOfWork.UserRepository.IsUserAdminAsync(user);
|
||||
|
||||
IList<CollectionTagDto> tags = isAdmin ? (await _unitOfWork.CollectionTagRepository.GetAllTagDtosAsync()).ToList()
|
||||
: (await _unitOfWork.CollectionTagRepository.GetAllPromotedTagDtosAsync()).ToList();
|
||||
IEnumerable<CollectionTagDto> 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);
|
||||
|
|
|
|||
|
|
@ -45,11 +45,11 @@ public class ReadingListController : BaseApiController
|
|||
/// <summary>
|
||||
/// Returns reading lists (paginated) for a given user.
|
||||
/// </summary>
|
||||
/// <param name="includePromoted">Defaults to true</param>
|
||||
/// <param name="includePromoted">Include Promoted Reading Lists along with user's Reading Lists. Defaults to true</param>
|
||||
/// <param name="userParams">Pagination parameters</param>
|
||||
/// <returns></returns>
|
||||
[HttpPost("lists")]
|
||||
public async Task<ActionResult<IEnumerable<ReadingListDto>>> GetListsForUser([FromQuery] UserParams userParams, [FromQuery] bool includePromoted = true)
|
||||
public async Task<ActionResult<IEnumerable<ReadingListDto>>> 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))
|
||||
|
|
|
|||
67
API/Controllers/SearchController.cs
Normal file
67
API/Controllers/SearchController.cs
Normal file
|
|
@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Responsible for the Search interface from the UI
|
||||
/// </summary>
|
||||
public class SearchController : BaseApiController
|
||||
{
|
||||
private readonly IUnitOfWork _unitOfWork;
|
||||
|
||||
public SearchController(IUnitOfWork unitOfWork)
|
||||
{
|
||||
_unitOfWork = unitOfWork;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the series for the MangaFile id. If the user does not have access (shouldn't happen by the UI),
|
||||
/// then null is returned
|
||||
/// </summary>
|
||||
/// <param name="mangaFileId"></param>
|
||||
/// <returns></returns>
|
||||
[HttpGet("series-for-mangafile")]
|
||||
public async Task<ActionResult<SeriesDto>> GetSeriesForMangaFile(int mangaFileId)
|
||||
{
|
||||
var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername());
|
||||
return Ok(await _unitOfWork.SeriesRepository.GetSeriesForMangaFile(mangaFileId, userId));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the series for the Chapter id. If the user does not have access (shouldn't happen by the UI),
|
||||
/// then null is returned
|
||||
/// </summary>
|
||||
/// <param name="chapterId"></param>
|
||||
/// <returns></returns>
|
||||
[HttpGet("series-for-chapter")]
|
||||
public async Task<ActionResult<SeriesDto>> GetSeriesForChapter(int chapterId)
|
||||
{
|
||||
var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername());
|
||||
return Ok(await _unitOfWork.SeriesRepository.GetSeriesForChapter(chapterId, userId));
|
||||
}
|
||||
|
||||
[HttpGet("search")]
|
||||
public async Task<ActionResult<SearchResultGroupDto>> 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -363,10 +363,13 @@ public class SeriesController : BaseApiController
|
|||
/// </summary>
|
||||
/// <param name="ageRating"></param>
|
||||
/// <returns></returns>
|
||||
/// <remarks>This is cached for an hour</remarks>
|
||||
[ResponseCache(Duration = 60 * 60, Location = ResponseCacheLocation.Any, VaryByQueryKeys = new [] {"ageRating"})]
|
||||
[HttpGet("age-rating")]
|
||||
public ActionResult<string> 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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the series for the MangaFile id. If the user does not have access (shouldn't happen by the UI),
|
||||
/// then null is returned
|
||||
/// </summary>
|
||||
/// <param name="mangaFileId"></param>
|
||||
/// <returns></returns>
|
||||
[HttpGet("series-for-mangafile")]
|
||||
public async Task<ActionResult<SeriesDto>> GetSeriesForMangaFile(int mangaFileId)
|
||||
{
|
||||
var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername());
|
||||
return Ok(await _unitOfWork.SeriesRepository.GetSeriesForMangaFile(mangaFileId, userId));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the series for the Chapter id. If the user does not have access (shouldn't happen by the UI),
|
||||
/// then null is returned
|
||||
/// </summary>
|
||||
/// <param name="chapterId"></param>
|
||||
/// <returns></returns>
|
||||
[HttpGet("series-for-chapter")]
|
||||
public async Task<ActionResult<SeriesDto>> GetSeriesForChapter(int chapterId)
|
||||
{
|
||||
var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername());
|
||||
return Ok(await _unitOfWork.SeriesRepository.GetSeriesForChapter(chapterId, userId));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fetches the related series for a given series
|
||||
|
|
|
|||
|
|
@ -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
|
||||
/// </summary>
|
||||
public IList<int> Libraries { get; init; }
|
||||
/// <summary>
|
||||
/// An Age Rating which will limit the account to seeing everything equal to or below said rating.
|
||||
/// </summary>
|
||||
public AgeRating AgeRestriction { get; set; }
|
||||
}
|
||||
|
|
|
|||
10
API/DTOs/Account/UpdateAgeRestrictionDto.cs
Normal file
10
API/DTOs/Account/UpdateAgeRestrictionDto.cs
Normal file
|
|
@ -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; }
|
||||
}
|
||||
|
|
@ -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
|
||||
/// </summary>
|
||||
public IList<int> Libraries { get; init; }
|
||||
/// <summary>
|
||||
/// An Age Rating which will limit the account to seeing everything equal to or below said rating.
|
||||
/// </summary>
|
||||
public AgeRating AgeRestriction { get; init; }
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
||||
/// <summary>
|
||||
/// The maximum age rating a user has access to. -1 if not applicable
|
||||
/// </summary>
|
||||
public AgeRating AgeRestriction { get; init; } = AgeRating.NotApplicable;
|
||||
public DateTime Created { get; init; }
|
||||
public DateTime LastActive { get; init; }
|
||||
public IEnumerable<LibraryDto> Libraries { get; init; }
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
/// <summary>
|
||||
/// The highest age rating the user has access to. Not applicable for admins
|
||||
/// </summary>
|
||||
public AgeRating AgeRestriction { get; set; } = AgeRating.NotApplicable;
|
||||
}
|
||||
|
|
|
|||
36
API/Data/MigrateChangeRestrictionRoles.cs
Normal file
36
API/Data/MigrateChangeRestrictionRoles.cs
Normal file
|
|
@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// New role introduced in v0.6. Adds the role to all users.
|
||||
/// </summary>
|
||||
public static class MigrateChangeRestrictionRoles
|
||||
{
|
||||
/// <summary>
|
||||
/// Will not run if any users have the <see cref="PolicyConstants.ChangeRestrictionRole"/> role already
|
||||
/// </summary>
|
||||
/// <param name="unitOfWork"></param>
|
||||
/// <param name="userManager"></param>
|
||||
/// <param name="logger"></param>
|
||||
public static async Task Migrate(IUnitOfWork unitOfWork, UserManager<AppUser> userManager, ILogger<Program> 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");
|
||||
}
|
||||
}
|
||||
41
API/Data/MigrateReadingListAgeRating.cs
Normal file
41
API/Data/MigrateReadingListAgeRating.cs
Normal file
|
|
@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// New role introduced in v0.6. Calculates the Age Rating on all Reading Lists
|
||||
/// </summary>
|
||||
public static class MigrateReadingListAgeRating
|
||||
{
|
||||
/// <summary>
|
||||
/// Will not run if any above v0.5.6.24 or v0.6.0
|
||||
/// </summary>
|
||||
/// <param name="context"></param>
|
||||
/// <param name="readingListService"></param>
|
||||
/// <param name="logger"></param>
|
||||
public static async Task Migrate(IUnitOfWork unitOfWork, DataContext context, IReadingListService readingListService, ILogger<Program> 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");
|
||||
}
|
||||
}
|
||||
1667
API/Data/Migrations/20221009172653_ReadingListAgeRating.Designer.cs
generated
Normal file
1667
API/Data/Migrations/20221009172653_ReadingListAgeRating.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load diff
26
API/Data/Migrations/20221009172653_ReadingListAgeRating.cs
Normal file
26
API/Data/Migrations/20221009172653_ReadingListAgeRating.cs
Normal file
|
|
@ -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<int>(
|
||||
name: "AgeRating",
|
||||
table: "ReadingList",
|
||||
type: "INTEGER",
|
||||
nullable: false,
|
||||
defaultValue: 0);
|
||||
}
|
||||
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "AgeRating",
|
||||
table: "ReadingList");
|
||||
}
|
||||
}
|
||||
}
|
||||
1670
API/Data/Migrations/20221009211237_UserAgeRating.Designer.cs
generated
Normal file
1670
API/Data/Migrations/20221009211237_UserAgeRating.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load diff
27
API/Data/Migrations/20221009211237_UserAgeRating.cs
Normal file
27
API/Data/Migrations/20221009211237_UserAgeRating.cs
Normal file
|
|
@ -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<int>(
|
||||
name: "AgeRestriction",
|
||||
table: "AspNetUsers",
|
||||
type: "INTEGER",
|
||||
nullable: false,
|
||||
defaultValue: AgeRating.NotApplicable);
|
||||
}
|
||||
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "AgeRestriction",
|
||||
table: "AspNetUsers");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -53,6 +53,9 @@ namespace API.Data.Migrations
|
|||
b.Property<int>("AccessFailedCount")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("AgeRestriction")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("ApiKey")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
|
|
@ -736,6 +739,9 @@ namespace API.Data.Migrations
|
|||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("AgeRating")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("AppUserId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
|
|
|
|||
|
|
@ -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<IEnumerable<CollectionTagDto>> GetAllTagDtosAsync();
|
||||
Task<IEnumerable<CollectionTagDto>> SearchTagDtosAsync(string searchQuery);
|
||||
Task<IEnumerable<CollectionTagDto>> SearchTagDtosAsync(string searchQuery, int userId);
|
||||
Task<string> GetCoverImageAsync(int collectionTagId);
|
||||
Task<IEnumerable<CollectionTagDto>> GetAllPromotedTagDtosAsync();
|
||||
Task<IEnumerable<CollectionTagDto>> GetAllPromotedTagDtosAsync(int userId);
|
||||
Task<CollectionTag> GetTagAsync(int tagId);
|
||||
Task<CollectionTag> GetFullTagAsync(int tagId);
|
||||
void Update(CollectionTag tag);
|
||||
|
|
@ -85,6 +86,7 @@ public class CollectionTagRepository : ICollectionTagRepository
|
|||
|
||||
public async Task<IEnumerable<CollectionTagDto>> GetAllTagDtosAsync()
|
||||
{
|
||||
|
||||
return await _context.CollectionTag
|
||||
.OrderBy(c => c.NormalizedTitle)
|
||||
.AsNoTracking()
|
||||
|
|
@ -92,10 +94,12 @@ public class CollectionTagRepository : ICollectionTagRepository
|
|||
.ToListAsync();
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<CollectionTagDto>> GetAllPromotedTagDtosAsync()
|
||||
public async Task<IEnumerable<CollectionTagDto>> 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<CollectionTagDto>(_mapper.ConfigurationProvider)
|
||||
|
|
@ -118,11 +122,13 @@ public class CollectionTagRepository : ICollectionTagRepository
|
|||
.SingleOrDefaultAsync();
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<CollectionTagDto>> SearchTagDtosAsync(string searchQuery)
|
||||
public async Task<IEnumerable<CollectionTagDto>> 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)
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ public interface IReadingListRepository
|
|||
Task<IEnumerable<ReadingListDto>> GetReadingListDtosForSeriesAndUserAsync(int userId, int seriesId,
|
||||
bool includePromoted);
|
||||
void Remove(ReadingListItem item);
|
||||
void Add(ReadingList list);
|
||||
void BulkRemove(IEnumerable<ReadingListItem> items);
|
||||
void Update(ReadingList list);
|
||||
Task<int> 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<int> Count()
|
||||
{
|
||||
return await _context.ReadingList.CountAsync();
|
||||
|
|
@ -82,8 +88,10 @@ public class ReadingListRepository : IReadingListRepository
|
|||
|
||||
public async Task<PagedList<ReadingListDto>> 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<ReadingListDto>(_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<ReadingListDto>(_mapper.ConfigurationProvider)
|
||||
.AsNoTracking();
|
||||
|
||||
|
|
|
|||
|
|
@ -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<SeriesDto> GetSeriesForMangaFile(int mangaFileId, int userId);
|
||||
Task<SeriesDto> GetSeriesForChapter(int chapterId, int userId);
|
||||
Task<PagedList<SeriesDto>> GetWantToReadForUserAsync(int userId, UserParams userParams, FilterDto filter);
|
||||
Task<int> GetSeriesIdByFolder(string folder);
|
||||
Task<Series> GetSeriesByFolderPath(string folder, SeriesIncludes includes = SeriesIncludes.None);
|
||||
Task<Series> GetFullSeriesByAnyName(string seriesName, string localizedName, int libraryId, MangaFormat format, bool withFullIncludes = true);
|
||||
Task<List<Series>> RemoveSeriesNotInList(IList<ParsedSeries> seenSeries, int libraryId);
|
||||
Task<IDictionary<string, IList<SeriesModified>>> GetFolderPathMap(int libraryId);
|
||||
Task<AgeRating> GetMaxAgeRatingFromSeriesAsync(IEnumerable<int> 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<SearchResultDto>(_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<ReadingListDto>(_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<IQueryable<Series>> 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<string, GroupedSeriesDto>();
|
||||
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<AgeRating> GetUserAgeRestriction(int userId)
|
||||
{
|
||||
return (await _context.AppUser.SingleAsync(u => u.Id == userId)).AgeRestriction;
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<SeriesDto>> 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<SeriesDto>(_mapper.ConfigurationProvider)
|
||||
|
|
@ -1128,6 +1152,8 @@ public class SeriesRepository : ISeriesRepository
|
|||
public async Task<SeriesDto> 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<SeriesDto>(_mapper.ConfigurationProvider)
|
||||
.SingleOrDefaultAsync();
|
||||
}
|
||||
|
|
@ -1142,31 +1169,18 @@ public class SeriesRepository : ISeriesRepository
|
|||
public async Task<SeriesDto> 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<SeriesDto>(_mapper.ConfigurationProvider)
|
||||
.SingleOrDefaultAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Given a folder path return a Series with the <see cref="Series.FolderPath"/> that matches.
|
||||
/// </summary>
|
||||
/// <remarks>This will apply normalization on the path.</remarks>
|
||||
/// <param name="folder"></param>
|
||||
/// <returns></returns>
|
||||
public async Task<int> 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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Return a Series by Folder path. Null if not found.
|
||||
/// </summary>
|
||||
|
|
@ -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<SeriesDto>(_mapper.ConfigurationProvider)
|
||||
|
|
@ -1404,11 +1420,12 @@ public class SeriesRepository : ISeriesRepository
|
|||
.Select(s => s.Id);
|
||||
}
|
||||
|
||||
private async Task<IEnumerable<SeriesDto>> GetRelatedSeriesQuery(int seriesId, IEnumerable<int> usersSeriesIds, RelationKind kind)
|
||||
private async Task<IEnumerable<SeriesDto>> GetRelatedSeriesQuery(int seriesId, IEnumerable<int> 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<SeriesDto>(_mapper.ConfigurationProvider)
|
||||
|
|
@ -1417,16 +1434,15 @@ public class SeriesRepository : ISeriesRepository
|
|||
|
||||
private async Task<IEnumerable<RecentlyAddedSeries>> 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<PagedList<SeriesDto>> GetWantToReadForUserAsync(int userId, UserParams userParams, FilterDto filter)
|
||||
|
|
@ -1503,6 +1519,21 @@ public class SeriesRepository : ISeriesRepository
|
|||
return map;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the highest Age Rating for a list of Series
|
||||
/// </summary>
|
||||
/// <param name="seriesIds"></param>
|
||||
/// <returns></returns>
|
||||
public async Task<AgeRating> GetMaxAgeRatingFromSeriesAsync(IEnumerable<int> 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<Series> AddIncludesToQuery(IQueryable<Series> query, SeriesIncludes includeFlags)
|
||||
{
|
||||
if (includeFlags.HasFlag(SeriesIncludes.Library))
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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<int>, IHasConcurrencyToken
|
|||
/// The confirmation token for the user (invite). This will be set to null after the user confirms.
|
||||
/// </summary>
|
||||
public string ConfirmationToken { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The highest age rating the user has access to. Not applicable for admins
|
||||
/// </summary>
|
||||
public AgeRating AgeRestriction { get; set; } = AgeRating.NotApplicable;
|
||||
|
||||
/// <inheritdoc />
|
||||
[ConcurrencyCheck]
|
||||
|
|
|
|||
|
|
@ -8,6 +8,11 @@ namespace API.Entities.Enums;
|
|||
/// <remarks>Based on ComicInfo.xml v2.1 https://github.com/anansi-project/comicinfo/blob/main/drafts/v2.1/ComicInfo.xsd</remarks>
|
||||
public enum AgeRating
|
||||
{
|
||||
/// <summary>
|
||||
/// This is for Age Restriction for Restricted Profiles
|
||||
/// </summary>
|
||||
[Description("Not Applicable")]
|
||||
NotApplicable = -1,
|
||||
[Description("Unknown")]
|
||||
Unknown = 0,
|
||||
[Description("Rating Pending")]
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
||||
/// <summary>
|
||||
/// The highest age rating from all Series within the reading list
|
||||
/// </summary>
|
||||
/// <remarks>Introduced in v0.6</remarks>
|
||||
public AgeRating AgeRating { get; set; } = AgeRating.Unknown;
|
||||
|
||||
public ICollection<ReadingListItem> Items { get; set; }
|
||||
public DateTime Created { get; set; }
|
||||
public DateTime LastModified { get; set; }
|
||||
|
|
|
|||
23
API/Extensions/QueryableExtensions.cs
Normal file
23
API/Extensions/QueryableExtensions.cs
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
using System.Linq;
|
||||
using API.Entities;
|
||||
using API.Entities.Enums;
|
||||
|
||||
namespace API.Extensions;
|
||||
|
||||
public static class QueryableExtensions
|
||||
{
|
||||
public static IQueryable<Series> RestrictAgainstAgeRestriction(this IQueryable<Series> queryable, AgeRating rating)
|
||||
{
|
||||
return queryable.Where(s => rating == AgeRating.NotApplicable || s.Metadata.AgeRating <= rating);
|
||||
}
|
||||
|
||||
public static IQueryable<CollectionTag> RestrictAgainstAgeRestriction(this IQueryable<CollectionTag> queryable, AgeRating rating)
|
||||
{
|
||||
return queryable.Where(c => c.SeriesMetadatas.All(sm => sm.AgeRating <= rating));
|
||||
}
|
||||
|
||||
public static IQueryable<ReadingList> RestrictAgainstAgeRestriction(this IQueryable<ReadingList> queryable, AgeRating rating)
|
||||
{
|
||||
return queryable.Where(rl => rl.AgeRating <= rating);
|
||||
}
|
||||
}
|
||||
|
|
@ -118,4 +118,15 @@ public class AccountService : IAccountService
|
|||
return roles.Contains(PolicyConstants.DownloadRole) || roles.Contains(PolicyConstants.AdminRole);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Does the user have Change Restriction permission or admin rights
|
||||
/// </summary>
|
||||
/// <param name="user"></param>
|
||||
/// <returns></returns>
|
||||
public async Task<bool> HasChangeRestrictionRole(AppUser user)
|
||||
{
|
||||
var roles = await _userManager.GetRolesAsync(user);
|
||||
return roles.Contains(PolicyConstants.ChangePasswordRole) || roles.Contains(PolicyConstants.AdminRole);
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ public interface IReadingListService
|
|||
Task<bool> DeleteReadingListItem(UpdateReadingListPosition dto);
|
||||
Task<AppUser?> UserHasReadingListAccess(int readingListId, string username);
|
||||
Task<bool> DeleteReadingList(int readingListId, AppUser user);
|
||||
|
||||
Task CalculateReadingListAgeRating(ReadingList readingList);
|
||||
Task<bool> AddChaptersToReadingList(int seriesId, IList<int> chapterIds,
|
||||
ReadingList readingList);
|
||||
}
|
||||
|
|
@ -41,7 +41,7 @@ public class ReadingListService : IReadingListService
|
|||
|
||||
|
||||
/// <summary>
|
||||
/// Removes all entries that are fully read from the reading list
|
||||
/// Removes all entries that are fully read from the reading list. This commits
|
||||
/// </summary>
|
||||
/// <remarks>If called from API layer, expected for <see cref="UserHasReadingListAccess"/> to be called beforehand</remarks>
|
||||
/// <param name="readingListId">Reading List Id</param>
|
||||
|
|
@ -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();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes a certain reading list item from a reading list
|
||||
/// </summary>
|
||||
/// <param name="dto">Only ReadingListId and ReadingListItemId are used</param>
|
||||
/// <returns></returns>
|
||||
public async Task<bool> 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();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Calculates the highest Age Rating from each Reading List Item
|
||||
/// </summary>
|
||||
/// <param name="readingList"></param>
|
||||
public async Task CalculateReadingListAgeRating(ReadingList readingList)
|
||||
{
|
||||
await CalculateReadingListAgeRating(readingList, readingList.Items.Select(i => i.SeriesId));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Calculates the highest Age Rating from each Reading List Item
|
||||
/// </summary>
|
||||
/// <remarks>This method is used when the ReadingList doesn't have items yet</remarks>
|
||||
/// <param name="readingList"></param>
|
||||
/// <param name="seriesIds">The series ids of all the reading list items</param>
|
||||
private async Task CalculateReadingListAgeRating(ReadingList readingList, IEnumerable<int> seriesIds)
|
||||
{
|
||||
var ageRating = await _unitOfWork.SeriesRepository.GetMaxAgeRatingFromSeriesAsync(seriesIds);
|
||||
readingList.AgeRating = ageRating;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates the user has access to the reading list to perform actions on it
|
||||
/// </summary>
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -184,16 +184,20 @@ public class Startup
|
|||
var userManager = serviceProvider.GetRequiredService<UserManager<AppUser>>();
|
||||
var themeService = serviceProvider.GetRequiredService<IThemeService>();
|
||||
var dataContext = serviceProvider.GetRequiredService<DataContext>();
|
||||
var readingListService = serviceProvider.GetRequiredService<IReadingListService>();
|
||||
|
||||
|
||||
// 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();
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue