Improve moderation case page and case partial (#900)

* Add pagination to moderation cases list and tweak case dismissal task

* Clean up case partial and add extended case status indicators

* Redirect back to cases list after dismissing a case

* Fix typo on cases list queue counter

* Fix dismissal queue counter

* Convert dismiss button check into pattern

* Turn down case dismissal task repeat interval to every 1 hour

* Use page 0 for case searching

* Implement pagination on the admin users list <3

* Fix pagination button padding and update colors to match existing role colors

* Fix typo in admin search placeholder

* Make cases searchable by user/slot ID instead of reason

Due to the current state of the moderation case entity, I can't directly query against the affected user name, so I've added the ability to search for the affected user/slot ID instead of reason.

* Actually apply the desired changes instead of just fixing the counts

* Grammatical nitpick in the search box placeholder
This commit is contained in:
koko 2023-09-22 14:53:53 -04:00 committed by GitHub
parent 1ddedae1fb
commit 3a2cdc9afe
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 147 additions and 61 deletions

View file

@ -108,6 +108,6 @@ public class AdminUserController : ControllerBase
return this.Redirect($"/moderation/newCase?type={(int)CaseType.UserBan}&affectedId={id}");
}
return this.Redirect("/admin/users");
return this.Redirect("/admin/users/0");
}
}

View file

@ -34,6 +34,6 @@ public class ModerationCaseController : ControllerBase
await this.database.SaveChangesAsync();
return this.Ok();
return this.Redirect($"/moderation/cases/0");
}
}

View file

@ -27,7 +27,7 @@ public class AdminPanelPage : BaseLayout
if (user == null) return this.Redirect("~/login");
if (!user.IsAdmin) return this.NotFound();
this.Statistics.Add(new AdminPanelStatistic("Users", await StatisticsHelper.UserCount(this.Database), "/admin/users"));
this.Statistics.Add(new AdminPanelStatistic("Users", await StatisticsHelper.UserCount(this.Database), "/admin/users/0"));
this.Statistics.Add(new AdminPanelStatistic("Slots", await StatisticsHelper.SlotCount(this.Database, new SlotQueryBuilder())));
this.Statistics.Add(new AdminPanelStatistic("Photos", await StatisticsHelper.PhotoCount(this.Database)));
this.Statistics.Add(new AdminPanelStatistic("API Keys", await StatisticsHelper.ApiKeyCount(this.Database), "/admin/keys"));

View file

@ -1,4 +1,4 @@
@page "/admin/users"
@page "/admin/users/{pageNumber:int}"
@using LBPUnion.ProjectLighthouse.Types.Entities.Profile
@using LBPUnion.ProjectLighthouse.Types.Users
@model LBPUnion.ProjectLighthouse.Servers.Website.Pages.Admin.AdminPanelUsersPage
@ -11,6 +11,15 @@
<p>There are currently @Model.UserCount users registered to your instance.</p>
<p><b>Note:</b> Users are ordered by their permissions, then by most-recent-first.</p>
<form action="/admin/users/0">
<div class="ui icon input">
<input type="text" autocomplete="off" name="name" placeholder="Search users..." value="@Model.SearchValue">
<i class="search icon"></i>
</div>
</form>
<div class="ui divider"></div>
<div class="ui grid">
@foreach (UserEntity user in Model.Users)
{
@ -19,22 +28,21 @@
switch (user.PermissionLevel)
{
// jank but works
case PermissionLevel.Banned:
{
color = "red";
color = "grey";
subtitle = $"Banned user! Reason: {user.BannedReason}";
break;
}
case PermissionLevel.Moderator:
{
color = "green";
color = "orange";
subtitle = "Moderator";
break;
}
case PermissionLevel.Administrator:
{
color = "yellow";
color = "red";
subtitle = "Admin";
break;
}
@ -73,3 +81,15 @@
</div>
}
</div>
<br />
@if (Model.PageNumber != 0)
{
<a href="/admin/users/@(Model.PageNumber - 1)@(Model.SearchValue?.Length == 0 ? "" : "?name=" + Model.SearchValue)">Previous Page</a>
}
@(Model.PageNumber + 1) / @(Model.PageAmount)
@if (Model.PageNumber < Model.PageAmount - 1)
{
<a href="/admin/users/@(Model.PageNumber + 1)@(Model.SearchValue?.Length == 0 ? "" : "?name=" + Model.SearchValue)">Next Page</a>
}

View file

@ -1,4 +1,5 @@
#nullable enable
using LBPUnion.ProjectLighthouse.Configuration;
using LBPUnion.ProjectLighthouse.Database;
using LBPUnion.ProjectLighthouse.Servers.Website.Pages.Layouts;
using LBPUnion.ProjectLighthouse.Types.Entities.Profile;
@ -12,22 +13,40 @@ public class AdminPanelUsersPage : BaseLayout
public int UserCount;
public List<UserEntity> Users = new();
public int PageAmount;
public int PageNumber;
public string SearchValue = "";
public AdminPanelUsersPage(DatabaseContext database) : base(database)
{}
public async Task<IActionResult> OnGet()
public async Task<IActionResult> OnGet([FromRoute] int pageNumber, [FromQuery] string? name)
{
UserEntity? user = this.Database.UserFromWebRequest(this.Request);
if (user == null) return this.Redirect("~/login");
if (!user.IsAdmin) return this.NotFound();
if (string.IsNullOrWhiteSpace(name)) name = "";
this.SearchValue = name.Replace(" ", string.Empty);
this.UserCount = await this.Database.Users.CountAsync(u => u.Username.Contains(this.SearchValue));
this.PageNumber = pageNumber;
this.PageAmount = Math.Max(1, (int)Math.Ceiling((double)this.UserCount / ServerStatics.PageSize));
if (this.PageNumber < 0 || this.PageNumber >= this.PageAmount)
return this.Redirect($"/admin/users/{Math.Clamp(this.PageNumber, 0, this.PageAmount - 1)}");
this.Users = await this.Database.Users
.OrderByDescending(u => u.PermissionLevel)
.ThenByDescending(u => u.UserId)
.Where(u => u.Username.Contains(this.SearchValue))
.Skip(pageNumber * ServerStatics.PageSize)
.Take(ServerStatics.PageSize)
.ToListAsync();
this.UserCount = this.Users.Count;
return this.Page();
}
}

View file

@ -10,11 +10,11 @@
string timeZone = Model.GetTimeZone();
}
<p>There are @Model.CaseCount total cases, @Model.DismissedCaseCount of which have been dismissed.</p>
<p>There are @Model.CaseCount total cases, @Model.ExpiredCaseCount of which are queued for dismissal, and @Model.DismissedCaseCount of which have been dismissed.</p>
<form action="/moderation/cases/0">
<div class="ui icon input">
<input type="text" autocomplete="off" name="name" placeholder="Search cases..." value="@Model.SearchValue">
<input type="text" autocomplete="off" name="name" placeholder="Search by affected ID..." value="@Model.SearchValue">
<i class="search icon"></i>
</div>
</form>
@ -25,3 +25,13 @@
{
@(await Html.PartialAsync("Partials/ModerationCasePartial", @case, ViewData.WithTime(timeZone)))
}
@if (Model.PageNumber != 0)
{
<a href="/moderation/cases/@(Model.PageNumber - 1)@(Model.SearchValue?.Length == 0 ? "" : "?name=" + Model.SearchValue)">Previous Page</a>
}
@(Model.PageNumber + 1) / @(Model.PageAmount)
@if (Model.PageNumber < Model.PageAmount - 1)
{
<a href="/moderation/cases/@(Model.PageNumber + 1)@(Model.SearchValue?.Length == 0 ? "" : "?name=" + Model.SearchValue)">Next Page</a>
}

View file

@ -11,10 +11,11 @@ namespace LBPUnion.ProjectLighthouse.Servers.Website.Pages.Moderation;
public class CasePage : BaseLayout
{
public CasePage(DatabaseContext database) : base(database)
{}
{ }
public List<ModerationCaseEntity> Cases = new();
public int CaseCount;
public int ExpiredCaseCount;
public int DismissedCaseCount;
public int PageAmount;
@ -31,18 +32,30 @@ public class CasePage : BaseLayout
this.SearchValue = name.Replace(" ", string.Empty);
this.Cases = await this.Database.Cases
.Include(c => c.Creator)
.Include(c => c.Dismisser)
.OrderByDescending(c => c.CaseId)
.ToListAsync();
this.CaseCount = await this.Database.Cases.CountAsync(c => c.Reason.Contains(this.SearchValue));
this.DismissedCaseCount = await this.Database.Cases.CountAsync(c => c.Reason.Contains(this.SearchValue) && c.DismissedAt != null);
this.CaseCount =
await this.Database.Cases.CountAsync(c =>
c.AffectedId.ToString().Contains(this.SearchValue));
this.ExpiredCaseCount =
await this.Database.Cases.CountAsync(c =>
c.AffectedId.ToString().Contains(this.SearchValue) && c.DismissedAt == null && c.ExpiresAt < DateTime.UtcNow);
this.DismissedCaseCount =
await this.Database.Cases.CountAsync(c =>
c.AffectedId.ToString().Contains(this.SearchValue)&& c.DismissedAt != null);
this.PageNumber = pageNumber;
this.PageAmount = Math.Max(1, (int)Math.Ceiling((double)this.CaseCount / ServerStatics.PageSize));
if (this.PageNumber < 0 || this.PageNumber >= this.PageAmount)
return this.Redirect($"/moderation/cases/{Math.Clamp(this.PageNumber, 0, this.PageAmount - 1)}");
this.Cases = await this.Database.Cases.Include(c => c.Creator)
.Include(c => c.Dismisser)
.Where(c => c.AffectedId.ToString().Contains(this.SearchValue))
.OrderByDescending(c => c.CaseId)
.Skip(pageNumber * ServerStatics.PageSize)
.Take(ServerStatics.PageSize)
.ToListAsync();
return this.Page();
}
}

View file

@ -7,43 +7,20 @@
@inject DatabaseContext Database
@{
string color = "blue";
string timeZone = (string?)ViewData["TimeZone"] ?? TimeZoneInfo.Local.Id;
TimeZoneInfo timeZoneInfo = TimeZoneInfo.FindSystemTimeZoneById(timeZone);
if (Model.Expired) color = "yellow";
if (Model.Dismissed) color = "green";
string color = "red";
if (Model.Expired)
color = "yellow";
if (Model.Dismissed)
color = "green";
}
<div class="ui @color segment">
<h2>Case #@Model.CaseId: @Model.Type</h2>
@if (Model.Dismissed)
{
Debug.Assert(Model.DismissedAt != null);
@if (Model.Dismisser != null)
{
<h3 class="ui @color header">
This case was dismissed by <a href="/user/@Model.Dismisser.UserId">@Model.DismisserUsername</a> on @TimeZoneInfo.ConvertTime(Model.DismissedAt.Value, timeZoneInfo).ToString("M/d/yyyy @ h:mm tt").
</h3>
}
else
{
<h3 class="ui @color header">
This case was dismissed by @Model.DismisserUsername on @TimeZoneInfo.ConvertTime(Model.DismissedAt.Value, timeZoneInfo).ToString("M/d/yyyy @ h:mm tt").
</h3>
}
}
else if (Model.Expired)
{
<h3 class="ui @color header">
This case expired on @TimeZoneInfo.ConvertTime(Model.ExpiresAt!.Value, timeZoneInfo).ToString("M/d/yyyy @ h:mm tt").
</h3>
}
@if (Model.Creator != null && Model.Creator.Username.Length != 0)
{
<span>
@ -67,13 +44,14 @@
</span><br>
}
@if (Model.Type.AffectsLevel())
{
SlotEntity? slot = await Model.GetSlotAsync(Database);
if (slot != null)
{
<p><strong>Affected level:</strong> <a href="/slot/@slot.SlotId">@slot.Name</a></p>
<p>
<strong>Affected level:</strong> <a href="/slot/@slot.SlotId">@slot.Name (@slot.SlotId)</a>
</p>
}
}
else if (Model.Type.AffectsUser())
@ -81,10 +59,55 @@
UserEntity? user = await Model.GetUserAsync(Database);
if (user != null)
{
<p><strong>Affected user:</strong> <a href="/user/@user.UserId">@user.Username</a></p>
<p>
<strong>Affected user:</strong> <a href="/user/@user.UserId">@user.Username (@user.UserId)</a>
</p>
}
}
<h3>Case Status</h3>
@if (Model.Dismissed)
{
Debug.Assert(Model.DismissedAt != null);
@if (Model.Dismisser != null)
{
<div>
<i class="ui green icon check"></i>
<span class="ui green text">
This case was dismissed by <a href="/user/@Model.Dismisser.UserId">@Model.DismisserUsername</a> on @TimeZoneInfo.ConvertTime(Model.DismissedAt.Value, timeZoneInfo).ToString("M/d/yyyy @ h:mm tt").
</span>
</div>
}
else
{
<div>
<i class="ui green icon check"></i>
<span class="ui green text">
This case was dismissed by @Model.DismisserUsername on @TimeZoneInfo.ConvertTime(Model.DismissedAt.Value, timeZoneInfo).ToString("M/d/yyyy @ h:mm tt").
</span>
</div>
}
}
else if (Model.Expired)
{
<div>
<i class="ui orange icon clock"></i>
<span class="ui orange text">
This case expired on @TimeZoneInfo.ConvertTime(Model.ExpiresAt!.Value, timeZoneInfo).ToString("M/d/yyyy @ h:mm tt") and has been queued for dismissal.
</span>
</div>
}
else
{
<div>
<i class="ui red icon times"></i>
<span class="ui red text">
This case is currently active and will expire on @TimeZoneInfo.ConvertTime(Model.ExpiresAt!.Value, timeZoneInfo).ToString("M/d/yyyy @ h:mm tt").
</span>
</div>
}
<h3>Reason</h3>
@if (!string.IsNullOrWhiteSpace(Model.Reason))
{
@ -105,7 +128,7 @@
<pre><b>No notes were provided.</b></pre>
}
@if (!Model.Dismissed)
@if (Model is { Dismissed: false, Expired: false, })
{
<a class="ui green small button" href="/moderation/case/@Model.CaseId/dismiss">
<i class="checkmark icon"></i>

View file

@ -14,7 +14,7 @@ namespace LBPUnion.ProjectLighthouse.Administration.Maintenance.RepeatingTasks;
public class DismissExpiredCasesTask : IRepeatingTask
{
public string Name => "Dismiss Expired Cases";
public TimeSpan RepeatInterval => TimeSpan.FromHours(4);
public TimeSpan RepeatInterval => TimeSpan.FromHours(1);
public DateTime LastRan { get; set; }
public async Task Run(DatabaseContext database)
@ -31,7 +31,8 @@ public class DismissExpiredCasesTask : IRepeatingTask
foreach (ModerationCaseEntity @case in expiredCases)
{
@case.DismissedAt = DateTime.Now;
@case.DismissedAt = DateTime.UtcNow;
@case.DismisserUsername = "maintenance task";
Logger.Info($"Dismissed expired case {@case.CaseId}", LogArea.Maintenance);
}