diff --git a/API/Controllers/AccountController.cs b/API/Controllers/AccountController.cs index 3c25974ef..54d304dd6 100644 --- a/API/Controllers/AccountController.cs +++ b/API/Controllers/AccountController.cs @@ -78,6 +78,8 @@ namespace API.Controllers user.LastActive = DateTime.Now; _userRepository.Update(user); await _userRepository.SaveAllAsync(); + + _logger.LogInformation($"{user.UserName} logged in at {user.LastActive}"); return new UserDto { diff --git a/API/Controllers/LibraryController.cs b/API/Controllers/LibraryController.cs index b87e75cbf..64a259503 100644 --- a/API/Controllers/LibraryController.cs +++ b/API/Controllers/LibraryController.cs @@ -1,5 +1,6 @@ using System.Collections.Generic; using System.IO; +using System.Linq; using System.Threading.Tasks; using API.DTOs; using API.Entities; @@ -35,6 +36,47 @@ namespace API.Controllers _taskScheduler = taskScheduler; _seriesRepository = seriesRepository; } + + /// + /// Creates a new Library. Upon library creation, adds new library to all Admin accounts. + /// + /// + /// + [Authorize(Policy = "RequireAdminRole")] + [HttpPost("create")] + public async Task AddLibrary(CreateLibraryDto createLibraryDto) + { + if (await _libraryRepository.LibraryExists(createLibraryDto.Name)) + { + return BadRequest("Library name already exists. Please choose a unique name to the server."); + } + + var admins = (await _userRepository.GetAdminUsersAsync()).ToList(); + + var library = new Library + { + Name = createLibraryDto.Name, + Type = createLibraryDto.Type, + AppUsers = admins, + Folders = createLibraryDto.Folders.Select(x => new FolderPath {Path = x}).ToList() + }; + + foreach (var admin in admins) + { + // If user is null, then set it + admin.Libraries ??= new List(); + admin.Libraries.Add(library); + } + + + if (await _userRepository.SaveAllAsync()) + { + //TODO: Enqueue scan library task + return Ok(); + } + + return BadRequest("There was a critical issue. Please try again."); + } /// /// Returns a list of directories for a given path. If path is empty, returns root drives. @@ -92,6 +134,7 @@ namespace API.Controllers // We have to send a json encoded Library (aka a DTO) to the Background Job thread. // Because we use EF, we have circular dependencies back to Library and it will crap out + // TODO: Refactor this to use libraryId and move Library call in method. BackgroundJob.Enqueue(() => _directoryService.ScanLibrary(library)); return Ok(); } @@ -106,7 +149,6 @@ namespace API.Controllers public async Task>> GetSeriesForLibrary(int libraryId) { return Ok(await _seriesRepository.GetSeriesForLibraryIdAsync(libraryId)); - } } } \ No newline at end of file diff --git a/API/Controllers/UsersController.cs b/API/Controllers/UsersController.cs index a332c88c9..b860316df 100644 --- a/API/Controllers/UsersController.cs +++ b/API/Controllers/UsersController.cs @@ -22,47 +22,6 @@ namespace API.Controllers _libraryRepository = libraryRepository; } - [Authorize(Policy = "RequireAdminRole")] - [HttpPost("add-library")] - public async Task AddLibrary(CreateLibraryDto createLibraryDto) - { - // NOTE: I think we should move this into library controller because it gets added to all admins - - var user = await _userRepository.GetUserByUsernameAsync(User.GetUsername()); - - if (user == null) return BadRequest("Could not validate user"); - - - if (await _libraryRepository.LibraryExists(createLibraryDto.Name)) - { - return BadRequest("Library name already exists. Please choose a unique name to the server."); - } - - var library = new Library - { - Name = createLibraryDto.Name.ToLower(), - Type = createLibraryDto.Type, - AppUsers = new List() { user } - }; - - library.Folders = createLibraryDto.Folders.Select(x => new FolderPath - { - Path = x, - Library = library - }).ToList(); - - user.Libraries ??= new List(); // If user is null, then set it - - user.Libraries.Add(library); - - if (await _userRepository.SaveAllAsync()) - { - return Ok(); - } - - return BadRequest("Not implemented"); - } - [Authorize(Policy = "RequireAdminRole")] [HttpDelete("delete-user")] public async Task DeleteUser(string username) diff --git a/API/DTOs/VolumeDto.cs b/API/DTOs/VolumeDto.cs index 1753d4679..b3dab2834 100644 --- a/API/DTOs/VolumeDto.cs +++ b/API/DTOs/VolumeDto.cs @@ -5,7 +5,8 @@ namespace API.DTOs public class VolumeDto { public int Id { get; set; } - public string Number { get; set; } + public int Number { get; set; } + public string Name { get; set; } public string CoverImage { get; set; } public ICollection Files { get; set; } } diff --git a/API/Data/Migrations/20210102173326_VolumeNumberRefactor.Designer.cs b/API/Data/Migrations/20210102173326_VolumeNumberRefactor.Designer.cs new file mode 100644 index 000000000..1102111fc --- /dev/null +++ b/API/Data/Migrations/20210102173326_VolumeNumberRefactor.Designer.cs @@ -0,0 +1,512 @@ +// +using System; +using API.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +namespace API.Data.Migrations +{ + [DbContext(typeof(DataContext))] + [Migration("20210102173326_VolumeNumberRefactor")] + partial class VolumeNumberRefactor + { + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "5.0.1"); + + 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"); + }); + + modelBuilder.Entity("API.Entities.AppUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AccessFailedCount") + .HasColumnType("INTEGER"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("EmailConfirmed") + .HasColumnType("INTEGER"); + + b.Property("IsAdmin") + .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"); + }); + + 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"); + }); + + modelBuilder.Entity("API.Entities.FolderPath", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("Path") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.ToTable("FolderPath"); + }); + + 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("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("FilePath") + .HasColumnType("TEXT"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("VolumeId"); + + b.ToTable("MangaFile"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("OriginalName") + .HasColumnType("TEXT"); + + b.Property("SortName") + .HasColumnType("TEXT"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.ToTable("Series"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Number") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .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("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"); + }); + + 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"); + }); + + 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"); + }); + + 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"); + }); + + 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.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.Volume", "Volume") + .WithMany("Files") + .HasForeignKey("VolumeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Volume"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + 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("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("API.Entities.AppRole", b => + { + b.Navigation("UserRoles"); + }); + + modelBuilder.Entity("API.Entities.AppUser", b => + { + b.Navigation("UserRoles"); + }); + + modelBuilder.Entity("API.Entities.Library", b => + { + b.Navigation("Folders"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.Navigation("Volumes"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.Navigation("Files"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/API/Data/Migrations/20210102173326_VolumeNumberRefactor.cs b/API/Data/Migrations/20210102173326_VolumeNumberRefactor.cs new file mode 100644 index 000000000..21cc8d42c --- /dev/null +++ b/API/Data/Migrations/20210102173326_VolumeNumberRefactor.cs @@ -0,0 +1,41 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +namespace API.Data.Migrations +{ + public partial class VolumeNumberRefactor : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AlterColumn( + name: "Number", + table: "Volume", + type: "INTEGER", + nullable: false, + defaultValue: 0, + oldClrType: typeof(string), + oldType: "TEXT", + oldNullable: true); + + migrationBuilder.AddColumn( + name: "Name", + table: "Volume", + type: "TEXT", + nullable: true); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "Name", + table: "Volume"); + + migrationBuilder.AlterColumn( + name: "Number", + table: "Volume", + type: "TEXT", + nullable: true, + oldClrType: typeof(int), + oldType: "INTEGER"); + } + } +} diff --git a/API/Data/Migrations/DataContextModelSnapshot.cs b/API/Data/Migrations/DataContextModelSnapshot.cs index f840250c9..5b22f751d 100644 --- a/API/Data/Migrations/DataContextModelSnapshot.cs +++ b/API/Data/Migrations/DataContextModelSnapshot.cs @@ -249,9 +249,12 @@ namespace API.Data.Migrations b.Property("LastModified") .HasColumnType("TEXT"); - b.Property("Number") + b.Property("Name") .HasColumnType("TEXT"); + b.Property("Number") + .HasColumnType("INTEGER"); + b.Property("SeriesId") .HasColumnType("INTEGER"); diff --git a/API/Data/SeriesRepository.cs b/API/Data/SeriesRepository.cs index fd109f45f..a9623275b 100644 --- a/API/Data/SeriesRepository.cs +++ b/API/Data/SeriesRepository.cs @@ -50,6 +50,7 @@ namespace API.Data { return await _context.Series .Where(series => series.LibraryId == libraryId) + .OrderBy(s => s.SortName) .ProjectTo(_mapper.ConfigurationProvider).ToListAsync(); } @@ -57,6 +58,7 @@ namespace API.Data { return await _context.Volume .Where(vol => vol.SeriesId == seriesId) + .OrderBy(volume => volume.Number) .ProjectTo(_mapper.ConfigurationProvider).ToListAsync(); } @@ -64,6 +66,7 @@ namespace API.Data { return _context.Volume .Where(vol => vol.SeriesId == seriesId) + .OrderBy(vol => vol.Number) .ProjectTo(_mapper.ConfigurationProvider).ToList(); } @@ -71,6 +74,7 @@ namespace API.Data { return _context.Volume .Where(vol => vol.SeriesId == seriesId) + .OrderBy(vol => vol.Number) .ToList(); } diff --git a/API/Data/UserRepository.cs b/API/Data/UserRepository.cs index 6c63d31b9..5098b156c 100644 --- a/API/Data/UserRepository.cs +++ b/API/Data/UserRepository.cs @@ -1,6 +1,7 @@ using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; +using API.Constants; using API.DTOs; using API.Entities; using API.Interfaces; @@ -55,6 +56,11 @@ namespace API.Data .SingleOrDefaultAsync(x => x.UserName == username); } + public async Task> GetAdminUsersAsync() + { + return await _userManager.GetUsersInRoleAsync(PolicyConstants.AdminRole); + } + public async Task> GetMembersAsync() { return await _userManager.Users diff --git a/API/Entities/AppUser.cs b/API/Entities/AppUser.cs index ea7ad0245..f5b16890d 100644 --- a/API/Entities/AppUser.cs +++ b/API/Entities/AppUser.cs @@ -1,16 +1,16 @@ using System; using System.Collections.Generic; using System.ComponentModel.DataAnnotations; +using API.Entities.Interfaces; using Microsoft.AspNetCore.Identity; namespace API.Entities { - public class AppUser : IdentityUser + public class AppUser : IdentityUser, IHasConcurrencyToken { public DateTime Created { get; set; } = DateTime.Now; public DateTime LastActive { get; set; } - public bool IsAdmin { get; set; } public ICollection Libraries { get; set; } [ConcurrencyCheck] diff --git a/API/Entities/Volume.cs b/API/Entities/Volume.cs index 80d1f6573..18cadee8d 100644 --- a/API/Entities/Volume.cs +++ b/API/Entities/Volume.cs @@ -7,7 +7,8 @@ namespace API.Entities public class Volume : IEntityDate { public int Id { get; set; } - public string Number { get; set; } + public string Name { get; set; } + public int Number { get; set; } public ICollection Files { get; set; } public DateTime Created { get; set; } public DateTime LastModified { get; set; } diff --git a/API/Interfaces/IUserRepository.cs b/API/Interfaces/IUserRepository.cs index 31be6e52e..26e3b5a11 100644 --- a/API/Interfaces/IUserRepository.cs +++ b/API/Interfaces/IUserRepository.cs @@ -15,5 +15,6 @@ namespace API.Interfaces Task> GetMembersAsync(); Task GetMemberAsync(string username); public void Delete(AppUser user); + Task> GetAdminUsersAsync(); } } \ No newline at end of file diff --git a/API/Services/DirectoryService.cs b/API/Services/DirectoryService.cs index f8ee643ce..9d40069e7 100644 --- a/API/Services/DirectoryService.cs +++ b/API/Services/DirectoryService.cs @@ -151,7 +151,7 @@ namespace API.Services IList existingVolumes = _seriesRepository.GetVolumes(series.Id).ToList(); foreach (var info in infos) { - var existingVolume = existingVolumes.SingleOrDefault(v => v.Number == info.Volumes); + var existingVolume = existingVolumes.SingleOrDefault(v => v.Name == info.Volumes); if (existingVolume != null) { // Temp let's overwrite all files (we need to enhance to update files) @@ -168,7 +168,8 @@ namespace API.Services { var vol = new Volume() { - Number = info.Volumes, + Name = info.Volumes, + Number = Int32.Parse(info.Volumes), Files = new List() { new MangaFile()