From 1c65da5adef16d5f905fe8c5325ccb1c8a15c235 Mon Sep 17 00:00:00 2001 From: Hamid Reza Mohammadi Date: Tue, 29 Sep 2026 12:35:40 +0330 Subject: [PATCH] people tags #387 --- .../Areas/Admin/Pages/ReviewEdits.cshtml | 1 + .../Admin/Pages/ReviewPersonEdits.cshtml | 168 +++++++++++ .../Admin/Pages/ReviewPersonEdits.cshtml.cs | 178 ++++++++++++ GanjooRazor/Pages/Person.cshtml | 8 +- GanjooRazor/Pages/SuggestPersonEdit.cshtml | 125 +++++++++ GanjooRazor/Pages/SuggestPersonEdit.cshtml.cs | 156 +++++++++++ .../GanjoorRelatedPersonController.cs | 90 +++++- RMuseum/DbContext/RMuseumDbContext.cs | 6 + .../Ganjoor/GanjoorPersonEditSuggestion.cs | 146 ++++++++++ ...PersonEditSuggestionModerationViewModel.cs | 18 ++ RMuseum/RMuseum.xml | 263 +++++++++++++++++- .../Services/IGanjoorRelatedPersonService.cs | 46 ++- .../GanjoorRelatedPersonService.cs | 145 ++++++++++ 13 files changed, 1341 insertions(+), 9 deletions(-) create mode 100644 GanjooRazor/Areas/Admin/Pages/ReviewPersonEdits.cshtml create mode 100644 GanjooRazor/Areas/Admin/Pages/ReviewPersonEdits.cshtml.cs create mode 100644 GanjooRazor/Pages/SuggestPersonEdit.cshtml create mode 100644 GanjooRazor/Pages/SuggestPersonEdit.cshtml.cs create mode 100644 RMuseum/Models/Ganjoor/GanjoorPersonEditSuggestion.cs create mode 100644 RMuseum/Models/Ganjoor/ViewModels/PersonEditSuggestionModerationViewModel.cs diff --git a/GanjooRazor/Areas/Admin/Pages/ReviewEdits.cshtml b/GanjooRazor/Areas/Admin/Pages/ReviewEdits.cshtml index e12b5988..3cc0985e 100644 --- a/GanjooRazor/Areas/Admin/Pages/ReviewEdits.cshtml +++ b/GanjooRazor/Areas/Admin/Pages/ReviewEdits.cshtml @@ -958,6 +958,7 @@ else @if (geoTag.PersonId != null) { شخصیت: @(geoTag.Person != null ? geoTag.Person.Name : $"شخصیت #{geoTag.PersonId}") + ✎ } else if (personGraph?.Person != null) { diff --git a/GanjooRazor/Areas/Admin/Pages/ReviewPersonEdits.cshtml b/GanjooRazor/Areas/Admin/Pages/ReviewPersonEdits.cshtml new file mode 100644 index 00000000..f4b40754 --- /dev/null +++ b/GanjooRazor/Areas/Admin/Pages/ReviewPersonEdits.cshtml @@ -0,0 +1,168 @@ +@page +@model GanjooRazor.Areas.Admin.Pages.ReviewPersonEditsModel +@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers +@using DNTPersianUtils.Core +@{ + Layout = "_AdminLayout"; + ViewData["Title"] = "بازبینی پیشنهادهای ویرایش شخصیت‌ها"; +} + +
+

@ViewData["Title"]

+
+ +@if (!string.IsNullOrEmpty(Model.FatalError)) +{ +
@Model.FatalError
+} +else if (Model.Suggestion == null) +{ + +} +else +{ + var s = Model.Suggestion; + var p = s.Person; + +

+ @p.Name +

+ + + + + + + @if (!string.IsNullOrEmpty(s.SuggestionNote)) + { + + + + + } +
پیشنهاد دهنده: + @(s.User != null ? s.User.NickName : s.UserId.ToString()) + — @s.Date.ToFriendlyPersianDateTextify() +
یادداشت پیشنهاددهنده:@s.SuggestionNote
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
فیلدمقدار فعلیپیشنهاد
نام@p.Name@s.SuggestedName
نشانی ویکی‌پدیا@p.WikiUrl@s.SuggestedWikiUrl
سال تولد (قمری)@(p.BirthYearInLHijri == 0 ? "" : p.BirthYearInLHijri.ToPersianNumbers())@(s.SuggestedBirthYearInLHijri == 0 ? "" : s.SuggestedBirthYearInLHijri.ToPersianNumbers())
تاریخ تولد مستند است@(p.ValidBirthDate ? "بله" : "خیر")@(s.SuggestedValidBirthDate ? "بله" : "خیر")
زادگاه@Model.LocationName(p.BirthLocationId)@(s.SuggestedBirthLocation != null ? s.SuggestedBirthLocation.Name : "")
سال وفات (قمری)@(p.DeathYearInLHijri == 0 ? "" : p.DeathYearInLHijri.ToPersianNumbers())@(s.SuggestedDeathYearInLHijri == 0 ? "" : s.SuggestedDeathYearInLHijri.ToPersianNumbers())
تاریخ وفات مستند است@(p.ValidDeathDate ? "بله" : "خیر")@(s.SuggestedValidDeathDate ? "بله" : "خیر")
آرامگاه@Model.LocationName(p.DeathLocationId)@(s.SuggestedDeathLocation != null ? s.SuggestedDeathLocation.Name : "")
عنوان شجره‌نامه@p.FamilyTreeCaption@s.SuggestedFamilyTreeCaption
توضیحات@p.Description@s.SuggestedDescription
+ +
 
+ + + + + + + + + + + + +
+ +   + +
+ یادداشت برای پیشنهاددهنده:
+ +
+ بررسی + +
+ @Html.AntiForgeryToken() + +
+
+ +

@Model.Skip.ToPersianNumbers() از @Model.TotalCount.ToPersianNumbers()

+ + +} diff --git a/GanjooRazor/Areas/Admin/Pages/ReviewPersonEdits.cshtml.cs b/GanjooRazor/Areas/Admin/Pages/ReviewPersonEdits.cshtml.cs new file mode 100644 index 00000000..09a820d6 --- /dev/null +++ b/GanjooRazor/Areas/Admin/Pages/ReviewPersonEdits.cshtml.cs @@ -0,0 +1,178 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net.Http; +using System.Text; +using System.Threading.Tasks; +using GanjooRazor.Utils; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.RazorPages; +using Newtonsoft.Json; +using RMuseum.Models.Ganjoor; +using RMuseum.Models.Ganjoor.ViewModels; +using RSecurityBackend.Models.Generic; + +namespace GanjooRazor.Areas.Admin.Pages +{ + /// + /// moderator review queue for pending GanjoorPersonEditSuggestion entries - counterpart of + /// ReviewEdits.cshtml for poem corrections, but much simpler: one suggestion, one overall + /// Result, no per-field branching. + /// + public class ReviewPersonEditsModel : PageModel + { + /// + /// the pending suggestion currently shown + /// + public GanjoorPersonEditSuggestion Suggestion { get; set; } + + /// + /// fatal error + /// + public string FatalError { get; set; } + + /// + /// skip + /// + public int Skip { get; set; } + + /// + /// total count + /// + public int TotalCount { get; set; } + + /// + /// full location catalog, used only to resolve the CURRENT person's BirthLocationId/ + /// DeathLocationId into names for the before/after diff (the suggestion's own Suggested* + /// locations already arrive populated via the backend's Include) + /// + public List Locations { get; set; } + + public string LocationName(int? id) + { + if (id == null || Locations == null) + return ""; + var loc = Locations.Where(l => l.Id == id).FirstOrDefault(); + return loc == null ? "" : loc.Name; + } + + public IActionResult OnPost() + { + Skip = string.IsNullOrEmpty(Request.Query["skip"]) ? 0 : int.Parse(Request.Query["skip"]); + if (Request.Form["next"].Count == 1) + { + return Redirect($"/Admin/ReviewPersonEdits/?skip={Skip + 1}"); + } + return Page(); + } + + public async Task OnGetAsync() + { + if (string.IsNullOrEmpty(Request.Cookies["Token"])) + return Redirect("/"); + + FatalError = ""; + TotalCount = 0; + Skip = string.IsNullOrEmpty(Request.Query["skip"]) ? 0 : int.Parse(Request.Query["skip"]); + + using (HttpClient secureClient = new HttpClient(new GanjoorReloginHandler(Request, Response))) + { + if (await GanjoorSessionChecker.PrepareClient(secureClient, Request, Response)) + { + var locationsResponse = await secureClient.GetAsync($"{APIRoot.Url}/api/locations"); + if (!locationsResponse.IsSuccessStatusCode) + { + FatalError = JsonConvert.DeserializeObject(await locationsResponse.Content.ReadAsStringAsync()); + return Page(); + } + Locations = JsonConvert.DeserializeObject>(await locationsResponse.Content.ReadAsStringAsync()); + + var nextResponse = await secureClient.GetAsync($"{APIRoot.Url}/api/people/editsuggestions/next?skip={Skip}"); + if (!nextResponse.IsSuccessStatusCode) + { + FatalError = JsonConvert.DeserializeObject(await nextResponse.Content.ReadAsStringAsync()); + return Page(); + } + + string paginationMetadata = nextResponse.Headers.GetValues("paging-headers").FirstOrDefault(); + if (!string.IsNullOrEmpty(paginationMetadata)) + { + TotalCount = JsonConvert.DeserializeObject(paginationMetadata).totalCount; + } + + Suggestion = JsonConvert.DeserializeObject(await nextResponse.Content.ReadAsStringAsync()); + + return Page(); + } + else + { + return Redirect("/"); + } + } + } + + public async Task OnPostModerateAsync([FromBody] PersonEditSuggestionModerationRequest pms) + { + if (string.IsNullOrEmpty(Request.Cookies["Token"])) + return new BadRequestObjectResult("لطفاً از گنجور خارج و مجدداً به آن وارد شوید."); + + if (pms == null || string.IsNullOrEmpty(pms.Result)) + { + return new BadRequestObjectResult("لطفاً تکلیف بررسی این پیشنهاد را مشخص کنید."); + } + + using (HttpClient secureClient = new HttpClient(new GanjoorReloginHandler(Request, Response))) + { + if (await GanjoorSessionChecker.PrepareClient(secureClient, Request, Response)) + { + var moderation = new PersonEditSuggestionModerationViewModel() + { + Result = (CorrectionReviewResult)Enum.Parse(typeof(CorrectionReviewResult), pms.Result), + ReviewNote = pms.ReviewNote, + }; + + var moderationResponse = await secureClient.PostAsync($"{APIRoot.Url}/api/people/editsuggestions/{pms.Id}/moderate", + new StringContent(JsonConvert.SerializeObject(moderation), Encoding.UTF8, "application/json")); + + if (!moderationResponse.IsSuccessStatusCode) + { + string err = await moderationResponse.Content.ReadAsStringAsync(); + if (string.IsNullOrEmpty(err)) + { + if (!string.IsNullOrEmpty(moderationResponse.ReasonPhrase)) + { + err = moderationResponse.ReasonPhrase; + } + else + { + err = $"Error Code: {moderationResponse.StatusCode}"; + } + } + else + { + err = JsonConvert.DeserializeObject(err); + } + return new BadRequestObjectResult(err); + } + + return new OkObjectResult(true); + } + else + { + return new BadRequestObjectResult("لطفاً از گنجور خارج و مجدداً به آن وارد شوید."); + } + } + } + } + + /// + /// JSON body shape posted by ReviewPersonEdits.cshtml's moderatePersonEditSuggestion() ajax call - + /// kept local to this page since it's only ever used to bind that one handler's [FromBody] parameter + /// + public class PersonEditSuggestionModerationRequest + { + public int Id { get; set; } + public string Result { get; set; } + public string ReviewNote { get; set; } + } +} diff --git a/GanjooRazor/Pages/Person.cshtml b/GanjooRazor/Pages/Person.cshtml index cc621e10..9288e510 100644 --- a/GanjooRazor/Pages/Person.cshtml +++ b/GanjooRazor/Pages/Person.cshtml @@ -24,7 +24,13 @@ else
 
-

@Model.Person.Name

+

+ @Model.Person.Name + @if (Model.LoggedIn) + { + ✎ + } +

@if (!string.IsNullOrEmpty(Model.Person.FamilyTreeCaption)) { diff --git a/GanjooRazor/Pages/SuggestPersonEdit.cshtml b/GanjooRazor/Pages/SuggestPersonEdit.cshtml new file mode 100644 index 00000000..a3539e83 --- /dev/null +++ b/GanjooRazor/Pages/SuggestPersonEdit.cshtml @@ -0,0 +1,125 @@ +@page "{id:int}" +@model GanjooRazor.Pages.SuggestPersonEditModel +@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers +@{ + Layout = "_Layout"; + ViewData["Title"] = "گنجور » پیشنهاد ویرایش شخصیت"; +} +@section Head { + +} +@if (!string.IsNullOrEmpty(Model.LastError)) +{ + @Model.LastError +} +else +{ + + +
+ +
+ + + +
 
+ +
+

پیشنهاد ویرایش: @Model.Person.Name

+ +

+ این پیشنهاد مستقیماً اعمال نمی‌شود؛ پس از بررسی و تأیید توسط مدیران، در مشخصات + @Model.Person.Name اعمال خواهد شد. +

+ + @if (!string.IsNullOrEmpty(Model.LastResult)) + { +

@Html.Raw(Model.LastResult)

+ } + +
+ @Html.AntiForgeryToken() + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
نام:
نشانی ویکی‌پدیا:
زاده: (قمری)
تاریخ تولد مستند است:
زادگاه: + +
درگذشته: (قمری)
تاریخ وفات مستند است:
آرامگاه: + +
+ عنوان شجره‌نامه: +

پُر کنید تا این شخصیت روی صفحهٔ شجره‌نامه‌ها با این عنوان فهرست شود.

+
توضیحات:
یادداشت برای مدیر بررسی‌کننده:
+ + + انصراف +
+
+ +
 
+ +
+ + + +
+ + @if (ViewData["TrackingScript"] != null && !string.IsNullOrEmpty(ViewData["TrackingScript"].ToString())) + { + @Html.Raw(ViewData["TrackingScript"]) + } +} diff --git a/GanjooRazor/Pages/SuggestPersonEdit.cshtml.cs b/GanjooRazor/Pages/SuggestPersonEdit.cshtml.cs new file mode 100644 index 00000000..563e6a48 --- /dev/null +++ b/GanjooRazor/Pages/SuggestPersonEdit.cshtml.cs @@ -0,0 +1,156 @@ +using System; +using System.Collections.Generic; +using System.Net.Http; +using System.Text; +using System.Threading.Tasks; +using GanjooRazor.Utils; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Configuration; +using Newtonsoft.Json; +using RMuseum.Models.Ganjoor; + +namespace GanjooRazor.Pages +{ + /// + /// contributor-facing form for suggesting an edit to an already-approved GanjoorRelatedPerson. + /// Any logged-in user may submit here (same bar as suggesting a poem correction); nothing is + /// changed until a moderator approves it via Admin/ReviewPersonEdits. Reachable both from a + /// person's own public page (Person.cshtml) and from the poem-correction moderation page + /// (ReviewEdits.cshtml, when a correction links to an existing person). + /// + public class SuggestPersonEditModel : LoginPartialEnabledPageModel + { + public SuggestPersonEditModel(HttpClient httpClient, IConfiguration configuration) : base(httpClient, configuration) + { + } + + public string LastError { get; set; } + + public string LastResult { get; set; } + + public GanjoorRelatedPerson Person { get; set; } + + [BindProperty] + public GanjoorPersonEditSuggestion Suggestion { get; set; } + + public List Locations { get; set; } + + private async Task ReadLocationsAsync() + { + var response = await _httpClient.GetAsync($"{APIRoot.Url}/api/locations"); + if (!response.IsSuccessStatusCode) + { + LastError = await ReadErrorMessageAsync(response); + return; + } + + Locations = new List + { + new GanjoorGeoLocation() { Id = 0, Latitude = 0, Longitude = 0, Name = "" } + }; + Locations.AddRange(JsonConvert.DeserializeObject(await response.Content.ReadAsStringAsync())); + } + + private async Task PreparePersonAsync(int id) + { + var response = await _httpClient.GetAsync($"{APIRoot.Url}/api/people/{id}"); + if (!response.IsSuccessStatusCode) + { + LastError = await ReadErrorMessageAsync(response); + return false; + } + + Person = JsonConvert.DeserializeObject(await response.Content.ReadAsStringAsync()); + if (Person == null) + { + LastError = "شخصیتی با این کد پیدا نشد."; + return false; + } + + return true; + } + + private void FillSuggestionFromCurrentPerson() + { + // the form is pre-filled with the person's CURRENT values, so submitting without + // changing anything is a no-op suggestion (harmless, just not useful) rather than + // accidentally blanking fields the contributor didn't mean to touch + Suggestion = new GanjoorPersonEditSuggestion() + { + PersonId = Person.Id, + SuggestedName = Person.Name, + SuggestedDescription = Person.Description, + SuggestedWikiUrl = Person.WikiUrl, + SuggestedBirthYearInLHijri = Person.BirthYearInLHijri, + SuggestedDeathYearInLHijri = Person.DeathYearInLHijri, + SuggestedValidBirthDate = Person.ValidBirthDate, + SuggestedValidDeathDate = Person.ValidDeathDate, + SuggestedBirthLocationId = Person.BirthLocationId, + SuggestedDeathLocationId = Person.DeathLocationId, + SuggestedFamilyTreeCaption = Person.FamilyTreeCaption, + }; + } + + public async Task OnGetAsync(int id) + { + InitializeCommonPageState(); + + if (!LoggedIn) + { + return Redirect($"/login?redirect={Uri.EscapeDataString(Request.Path)}"); + } + + await ReadLocationsAsync(); + + if (!await PreparePersonAsync(id)) + { + return Page(); + } + + FillSuggestionFromCurrentPerson(); + + return Page(); + } + + public async Task OnPostSuggestEditAsync(int id) + { + InitializeCommonPageState(); + + if (!LoggedIn) + { + return Redirect($"/login?redirect={Uri.EscapeDataString(Request.Path)}"); + } + + LastResult = ""; + + await ReadLocationsAsync(); + + if (!await PreparePersonAsync(id)) + { + return Page(); + } + + using (HttpClient secureClient = new HttpClient(new GanjoorReloginHandler(Request, Response))) + { + await GanjoorSessionChecker.PrepareClient(secureClient, Request, Response); + + HttpResponseMessage response = await secureClient.PostAsync( + $"{APIRoot.Url}/api/people/{id}/editsuggestion", + new StringContent(JsonConvert.SerializeObject(Suggestion), Encoding.UTF8, "application/json")); + + if (!response.IsSuccessStatusCode) + { + LastResult = JsonConvert.DeserializeObject(await response.Content.ReadAsStringAsync()); + FillSuggestionFromCurrentPerson(); + return Page(); + } + + LastResult = $"پیشنهاد ویرایش شما ثبت شد و پس از بررسی توسط مدیران اعمال خواهد شد. بازگشت به صفحهٔ شخصیت"; + + FillSuggestionFromCurrentPerson(); + + return Page(); + } + } + } +} diff --git a/RMuseum/Controllers/GanjoorRelatedPersonController.cs b/RMuseum/Controllers/GanjoorRelatedPersonController.cs index 603c75f4..fc3d5fd4 100644 --- a/RMuseum/Controllers/GanjoorRelatedPersonController.cs +++ b/RMuseum/Controllers/GanjoorRelatedPersonController.cs @@ -1,15 +1,25 @@ using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; +using Newtonsoft.Json; +using RMuseum.Models.Auth.Memory; using RMuseum.Models.Ganjoor; using RMuseum.Models.Ganjoor.ViewModels; using RMuseum.Services; +using RSecurityBackend.Models.Auth.Memory; +using RSecurityBackend.Models.Generic; +using System; +using System.Linq; using System.Net; using System.Threading.Tasks; namespace RMuseum.Controllers { /// - /// related people (family tree / person tagging) - read-only for now, see IGanjoorRelatedPersonService + /// related people (family tree / person tagging) - reads are anonymous; the only write paths are + /// the suggestion queue below (any logged-in user may suggest an edit to an existing person, + /// same as suggesting a poem correction; a moderator reviews it the same way a poem correction is + /// reviewed). There is no direct-edit endpoint for GanjoorRelatedPerson. /// [Produces("application/json")] [Route("api/people")] @@ -101,6 +111,84 @@ namespace RMuseum.Controllers return Ok(res.Result); } + /// + /// suggest an edit to an already-approved person's own fields - any logged-in user, same as + /// suggesting a poem correction. Goes into the pending queue; does not change the person. + /// + /// + /// + /// + [HttpPost("{id:int}/editsuggestion")] + [Authorize] + [ProducesResponseType((int)HttpStatusCode.OK, Type = typeof(GanjoorPersonEditSuggestion))] + [ProducesResponseType((int)HttpStatusCode.BadRequest, Type = typeof(string))] + public async Task SuggestPersonEditAsync(int id, [FromBody] GanjoorPersonEditSuggestion suggestion) + { + suggestion.PersonId = id; + suggestion.UserId = new Guid(User.Claims.First(c => c.Type == "UserId").Value); + var res = await _personService.SuggestPersonEditAsync(suggestion); + if (!string.IsNullOrEmpty(res.ExceptionString)) + return BadRequest(res.ExceptionString); + return Ok(res.Result); + } + + /// + /// get the next unreviewed person-edit suggestion, for the moderator queue - same permission + /// as reviewing a poem correction + /// + /// + /// + [HttpGet("editsuggestions/next")] + [Authorize(Policy = RMuseumSecurableItem.GanjoorEntityShortName + ":" + SecurableItem.ModifyOperationShortName)] + [ProducesResponseType((int)HttpStatusCode.OK, Type = typeof(GanjoorPersonEditSuggestion))] + [ProducesResponseType((int)HttpStatusCode.BadRequest, Type = typeof(string))] + public async Task GetNextUnreviewedPersonEditSuggestionAsync(int skip = 0) + { + var res = await _personService.GetNextUnreviewedPersonEditSuggestionAsync(skip); + if (!string.IsNullOrEmpty(res.ExceptionString)) + return BadRequest(res.ExceptionString); + + var resCount = await _personService.GetUnreviewedPersonEditSuggestionCountAsync(); + if (!string.IsNullOrEmpty(resCount.ExceptionString)) + return BadRequest(resCount.ExceptionString); + + // Paging Header - same shape ReviewEdits.cshtml.cs already knows how to read for poem corrections + HttpContext.Response.Headers.Append("paging-headers", + JsonConvert.SerializeObject( + new PaginationMetadata() + { + totalCount = resCount.Result, + pageSize = -1, + currentPage = -1, + hasNextPage = false, + hasPreviousPage = false, + totalPages = -1 + }) + ); + + return Ok(res.Result);//might be null + } + + /// + /// apply a moderator's decision to a pending person-edit suggestion - same permission as + /// moderating a poem correction + /// + /// + /// + /// + [HttpPost("editsuggestions/{id:int}/moderate")] + [Authorize(Policy = RMuseumSecurableItem.GanjoorEntityShortName + ":" + SecurableItem.ModifyOperationShortName)] + [ProducesResponseType((int)HttpStatusCode.OK, Type = typeof(GanjoorPersonEditSuggestion))] + [ProducesResponseType((int)HttpStatusCode.BadRequest, Type = typeof(string))] + public async Task ModeratePersonEditSuggestionAsync(int id, [FromBody] PersonEditSuggestionModerationViewModel moderation) + { + Guid userId = new Guid(User.Claims.First(c => c.Type == "UserId").Value); + var res = await _personService.ModeratePersonEditSuggestionAsync(userId, id, moderation.Result, moderation.ReviewNote); + if (!string.IsNullOrEmpty(res.ExceptionString)) + return BadRequest(res.ExceptionString); + return Ok(res.Result); + } + /// /// person service /// diff --git a/RMuseum/DbContext/RMuseumDbContext.cs b/RMuseum/DbContext/RMuseumDbContext.cs index deb84d38..645bd3e5 100644 --- a/RMuseum/DbContext/RMuseumDbContext.cs +++ b/RMuseum/DbContext/RMuseumDbContext.cs @@ -619,6 +619,12 @@ namespace RMuseum.DbContext /// public DbSet GanjoorPersonAffiliations { get; set; } + /// + /// pending/reviewed suggested edits to an already-approved GanjoorRelatedPerson's own fields - + /// see GanjoorPersonEditSuggestion + /// + public DbSet GanjoorPersonEditSuggestions { get; set; } + /// /// Books (PDF Library) /// diff --git a/RMuseum/Models/Ganjoor/GanjoorPersonEditSuggestion.cs b/RMuseum/Models/Ganjoor/GanjoorPersonEditSuggestion.cs new file mode 100644 index 00000000..fd444d5a --- /dev/null +++ b/RMuseum/Models/Ganjoor/GanjoorPersonEditSuggestion.cs @@ -0,0 +1,146 @@ +using RSecurityBackend.Models.Auth.Db; +using System; + +namespace RMuseum.Models.Ganjoor +{ + /// + /// a contributor's suggested edit to an existing, already-approved GanjoorRelatedPerson's own + /// fields (name, bio, birth/death info, family tree caption, ...). Reviewed/approved the same + /// way a GanjoorPoemGeoDateTagCorrection is - see GanjoorService-ModeratePersonEditSuggestion.cs - + /// but standalone: it targets a person directly, not a poem/couplet. There is no admin-only + /// direct-edit path for GanjoorRelatedPerson; this suggestion queue is the only way any of its + /// fields change after it is first created. + /// + public class GanjoorPersonEditSuggestion + { + /// + /// suggestion id + /// + public int Id { get; set; } + + /// + /// the person this suggestion proposes changes for + /// + public int PersonId { get; set; } + + /// + /// the person this suggestion proposes changes for (their CURRENT, still-unchanged fields - + /// for showing a before/after diff to the moderator) + /// + public virtual GanjoorRelatedPerson Person { get; set; } + + /// + /// date + /// + public DateTime Date { get; set; } + + /// + /// suggester's user id + /// + public Guid UserId { get; set; } + + /// + /// suggester + /// + public virtual RAppUser User { get; set; } + + // suggested full replacement state of every editable field - the contributor's form is + // pre-filled with the person's CURRENT values (see Person above), so these represent what + // the record ends up with after their edits, not a partial diff of only changed fields + + /// + /// suggested name + /// + public string SuggestedName { get; set; } + + /// + /// suggested description + /// + public string SuggestedDescription { get; set; } + + /// + /// suggested wikipedia url + /// + public string SuggestedWikiUrl { get; set; } + + /// + /// suggested birth year in lunar hijri + /// + public int SuggestedBirthYearInLHijri { get; set; } + + /// + /// suggested death year in lunar hijri + /// + public int SuggestedDeathYearInLHijri { get; set; } + + /// + /// suggested ValidBirthDate + /// + public bool SuggestedValidBirthDate { get; set; } + + /// + /// suggested ValidDeathDate + /// + public bool SuggestedValidDeathDate { get; set; } + + /// + /// suggested birth location id + /// + public int? SuggestedBirthLocationId { get; set; } + + /// + /// suggested birth location + /// + public virtual GanjoorGeoLocation SuggestedBirthLocation { get; set; } + + /// + /// suggested death location id + /// + public int? SuggestedDeathLocationId { get; set; } + + /// + /// suggested death location + /// + public virtual GanjoorGeoLocation SuggestedDeathLocation { get; set; } + + /// + /// suggested family tree caption + /// + public string SuggestedFamilyTreeCaption { get; set; } + + /// + /// suggester's note to the moderator + /// + public string SuggestionNote { get; set; } + + /// + /// reviewed + /// + public bool Reviewed { get; set; } + + /// + /// review result + /// + public CorrectionReviewResult Result { get; set; } + + /// + /// review date + /// + public DateTime ReviewDate { get; set; } + + /// + /// review note + /// + public string ReviewNote { get; set; } + + /// + /// reviewer's user id + /// + public Guid? ReviewerUserId { get; set; } + + /// + /// reviewer + /// + public virtual RAppUser ReviewerUser { get; set; } + } +} diff --git a/RMuseum/Models/Ganjoor/ViewModels/PersonEditSuggestionModerationViewModel.cs b/RMuseum/Models/Ganjoor/ViewModels/PersonEditSuggestionModerationViewModel.cs new file mode 100644 index 00000000..4506588b --- /dev/null +++ b/RMuseum/Models/Ganjoor/ViewModels/PersonEditSuggestionModerationViewModel.cs @@ -0,0 +1,18 @@ +namespace RMuseum.Models.Ganjoor.ViewModels +{ + /// + /// a moderator's decision on a pending GanjoorPersonEditSuggestion + /// + public class PersonEditSuggestionModerationViewModel + { + /// + /// review result + /// + public CorrectionReviewResult Result { get; set; } + + /// + /// review note + /// + public string ReviewNote { get; set; } + } +} diff --git a/RMuseum/RMuseum.xml b/RMuseum/RMuseum.xml index 315681a6..d42cfca9 100644 --- a/RMuseum/RMuseum.xml +++ b/RMuseum/RMuseum.xml @@ -2818,7 +2818,10 @@ - related people (family tree / person tagging) - read-only for now, see IGanjoorRelatedPersonService + related people (family tree / person tagging) - reads are anonymous; the only write paths are + the suggestion queue below (any logged-in user may suggest an edit to an existing person, + same as suggesting a poem correction; a moderator reviews it the same way a poem correction is + reviewed). There is no direct-edit endpoint for GanjoorRelatedPerson. @@ -2857,6 +2860,32 @@ + + + suggest an edit to an already-approved person's own fields - any logged-in user, same as + suggesting a poem correction. Goes into the pending queue; does not change the person. + + + + + + + + get the next unreviewed person-edit suggestion, for the moderator queue - same permission + as reviewing a poem correction + + + + + + + apply a moderator's decision to a pending person-edit suggestion - same permission as + moderating a poem correction + + + + + person service @@ -3987,6 +4016,12 @@ approved non-family ties between people (e.g. minister-to-king) - see GanjoorPersonAffiliation + + + pending/reviewed suggested edits to an already-approved GanjoorRelatedPerson's own fields - + see GanjoorPersonEditSuggestion + + Books (PDF Library) @@ -9240,6 +9275,142 @@ free-text note (e.g. sourcing/reasoning, or what the tie actually is when AffiliationType is Other) + + + a contributor's suggested edit to an existing, already-approved GanjoorRelatedPerson's own + fields (name, bio, birth/death info, family tree caption, ...). Reviewed/approved the same + way a GanjoorPoemGeoDateTagCorrection is - see GanjoorService-ModeratePersonEditSuggestion.cs - + but standalone: it targets a person directly, not a poem/couplet. There is no admin-only + direct-edit path for GanjoorRelatedPerson; this suggestion queue is the only way any of its + fields change after it is first created. + + + + + suggestion id + + + + + the person this suggestion proposes changes for + + + + + the person this suggestion proposes changes for (their CURRENT, still-unchanged fields - + for showing a before/after diff to the moderator) + + + + + date + + + + + suggester's user id + + + + + suggester + + + + + suggested name + + + + + suggested description + + + + + suggested wikipedia url + + + + + suggested birth year in lunar hijri + + + + + suggested death year in lunar hijri + + + + + suggested ValidBirthDate + + + + + suggested ValidDeathDate + + + + + suggested birth location id + + + + + suggested birth location + + + + + suggested death location id + + + + + suggested death location + + + + + suggested family tree caption + + + + + suggester's note to the moderator + + + + + reviewed + + + + + review result + + + + + review date + + + + + review note + + + + + reviewer's user id + + + + + reviewer + + an approved kinship edge between two people (GanjoorRelatedPerson rows) - the live/materialized @@ -14276,6 +14447,21 @@ model (not an EF entity), so no [NotMapped] or migration concerns here. + + + a moderator's decision on a pending GanjoorPersonEditSuggestion + + + + + review result + + + + + review note + + deserialized shape of GanjoorPoemGeoDateTagCorrection.SuggestedPersonGraphJson - a brand new @@ -17681,9 +17867,13 @@ - related people (family tree / person tagging) service - read-only for now: the only way a - new person is created is via the geo/date/person tag correction's SuggestedPersonGraphJson, - materialized on moderator approval (see GanjoorService-ModeratePoemCorrection.cs) + related people (family tree / person tagging) service. A new person is normally created via + the geo/date/person tag correction's SuggestedPersonGraphJson, materialized on moderator + approval (see GanjoorService-ModeratePoemCorrection.cs). Editing an already-approved person's + own fields (e.g. adding a FamilyTreeCaption after the fact) goes through the suggest/review + queue below (SuggestPersonEditAsync / ModeratePersonEditSuggestionAsync) - there is no + direct-edit path; nothing ever writes to a GanjoorRelatedPerson's fields except that approval + step and the original creation-on-approval in GanjoorService-ModeratePoemCorrection.cs. @@ -17722,6 +17912,41 @@ + + + submit a suggested edit to an already-approved person's own fields - goes into the + pending queue, does not change the person itself until a moderator approves it + + + + + + + get the next unreviewed person-edit suggestion (for the moderator queue), including the + target person's current fields (for a before/after diff) and the suggester's nickname + + + + + + + unreviewed person-edit suggestion count + + + + + + apply a moderator's decision to a pending person-edit suggestion. On Approved, copies the + suggestion's Suggested* fields onto the target GanjoorRelatedPerson (Id and + MachineGenerated on the person are left untouched); any other result just marks the + suggestion reviewed/rejected without touching the person. + + + + + + + Ganjoor Poems Content Privider Service @@ -21191,6 +21416,36 @@ + + + submit a suggested edit to an already-approved person's own fields + + + + + + + get the next unreviewed person-edit suggestion for the moderator queue + + + + + + + unreviewed person-edit suggestion count + + + + + + apply a moderator's decision to a pending person-edit suggestion + + + + + + + Database Context diff --git a/RMuseum/Services/IGanjoorRelatedPersonService.cs b/RMuseum/Services/IGanjoorRelatedPersonService.cs index 9696c30c..b7934e37 100644 --- a/RMuseum/Services/IGanjoorRelatedPersonService.cs +++ b/RMuseum/Services/IGanjoorRelatedPersonService.cs @@ -1,14 +1,19 @@ using RMuseum.Models.Ganjoor; using RMuseum.Models.Ganjoor.ViewModels; using RSecurityBackend.Models.Generic; +using System; using System.Threading.Tasks; namespace RMuseum.Services { /// - /// related people (family tree / person tagging) service - read-only for now: the only way a - /// new person is created is via the geo/date/person tag correction's SuggestedPersonGraphJson, - /// materialized on moderator approval (see GanjoorService-ModeratePoemCorrection.cs) + /// related people (family tree / person tagging) service. A new person is normally created via + /// the geo/date/person tag correction's SuggestedPersonGraphJson, materialized on moderator + /// approval (see GanjoorService-ModeratePoemCorrection.cs). Editing an already-approved person's + /// own fields (e.g. adding a FamilyTreeCaption after the fact) goes through the suggest/review + /// queue below (SuggestPersonEditAsync / ModeratePersonEditSuggestionAsync) - there is no + /// direct-edit path; nothing ever writes to a GanjoorRelatedPerson's fields except that approval + /// step and the original creation-on-approval in GanjoorService-ModeratePoemCorrection.cs. /// public interface IGanjoorRelatedPersonService { @@ -47,5 +52,40 @@ namespace RMuseum.Services /// /// Task> GetPoemsByPersonAsync(int id); + + /// + /// submit a suggested edit to an already-approved person's own fields - goes into the + /// pending queue, does not change the person itself until a moderator approves it + /// + /// + /// + Task> SuggestPersonEditAsync(GanjoorPersonEditSuggestion suggestion); + + /// + /// get the next unreviewed person-edit suggestion (for the moderator queue), including the + /// target person's current fields (for a before/after diff) and the suggester's nickname + /// + /// + /// + Task> GetNextUnreviewedPersonEditSuggestionAsync(int skip); + + /// + /// unreviewed person-edit suggestion count + /// + /// + Task> GetUnreviewedPersonEditSuggestionCountAsync(); + + /// + /// apply a moderator's decision to a pending person-edit suggestion. On Approved, copies the + /// suggestion's Suggested* fields onto the target GanjoorRelatedPerson (Id and + /// MachineGenerated on the person are left untouched); any other result just marks the + /// suggestion reviewed/rejected without touching the person. + /// + /// + /// + /// + /// + /// + Task> ModeratePersonEditSuggestionAsync(Guid moderatorUserId, int suggestionId, CorrectionReviewResult result, string reviewNote); } } diff --git a/RMuseum/Services/Implementation/GanjoorRelatedPersonService.cs b/RMuseum/Services/Implementation/GanjoorRelatedPersonService.cs index 317044b2..3372b8b7 100644 --- a/RMuseum/Services/Implementation/GanjoorRelatedPersonService.cs +++ b/RMuseum/Services/Implementation/GanjoorRelatedPersonService.cs @@ -172,6 +172,151 @@ namespace RMuseum.Services.Implementation } } + /// + /// submit a suggested edit to an already-approved person's own fields + /// + /// + /// + public async Task> SuggestPersonEditAsync(GanjoorPersonEditSuggestion suggestion) + { + try + { + if (suggestion == null || string.IsNullOrWhiteSpace(suggestion.SuggestedName)) + { + return new RServiceResult(null, "نام شخصیت نمی‌تواند خالی باشد."); + } + + var personExists = await _context.GanjoorRelatedPersons.Where(p => p.Id == suggestion.PersonId).AnyAsync(); + if (!personExists) + { + return new RServiceResult(null, "شخصیت پیدا نشد."); + } + + suggestion.Id = 0; + suggestion.Date = DateTime.Now; + suggestion.SuggestedName = suggestion.SuggestedName.Trim(); + suggestion.SuggestedDescription = string.IsNullOrWhiteSpace(suggestion.SuggestedDescription) ? null : suggestion.SuggestedDescription.Trim(); + suggestion.SuggestedWikiUrl = string.IsNullOrWhiteSpace(suggestion.SuggestedWikiUrl) ? null : suggestion.SuggestedWikiUrl.Trim(); + suggestion.SuggestedFamilyTreeCaption = string.IsNullOrWhiteSpace(suggestion.SuggestedFamilyTreeCaption) ? null : suggestion.SuggestedFamilyTreeCaption.Trim(); + suggestion.Reviewed = false; + suggestion.Result = CorrectionReviewResult.NotReviewed; + suggestion.ReviewNote = null; + suggestion.ReviewerUserId = null; + + _context.GanjoorPersonEditSuggestions.Add(suggestion); + await _context.SaveChangesAsync(); + + return new RServiceResult(suggestion); + } + catch (Exception exp) + { + return new RServiceResult(null, exp.ToString()); + } + } + + /// + /// get the next unreviewed person-edit suggestion for the moderator queue + /// + /// + /// + public async Task> GetNextUnreviewedPersonEditSuggestionAsync(int skip) + { + try + { + var suggestion = await _context.GanjoorPersonEditSuggestions + .Include(s => s.Person) + .Include(s => s.User) + .Include(s => s.SuggestedBirthLocation) + .Include(s => s.SuggestedDeathLocation) + .Where(s => s.Reviewed == false) + .OrderBy(s => s.Id) + .Skip(skip) + .FirstOrDefaultAsync(); + + return new RServiceResult(suggestion); + } + catch (Exception exp) + { + return new RServiceResult(null, exp.ToString()); + } + } + + /// + /// unreviewed person-edit suggestion count + /// + /// + public async Task> GetUnreviewedPersonEditSuggestionCountAsync() + { + try + { + return new RServiceResult(await _context.GanjoorPersonEditSuggestions.Where(s => s.Reviewed == false).CountAsync()); + } + catch (Exception exp) + { + return new RServiceResult(0, exp.ToString()); + } + } + + /// + /// apply a moderator's decision to a pending person-edit suggestion + /// + /// + /// + /// + /// + /// + public async Task> ModeratePersonEditSuggestionAsync(Guid moderatorUserId, int suggestionId, CorrectionReviewResult result, string reviewNote) + { + try + { + var suggestion = await _context.GanjoorPersonEditSuggestions.Where(s => s.Id == suggestionId).SingleOrDefaultAsync(); + if (suggestion == null) + { + return new RServiceResult(null, "پیشنهاد ویرایش پیدا نشد."); + } + + if (suggestion.Reviewed) + { + return new RServiceResult(null, "این پیشنهاد پیش‌تر بررسی شده است."); + } + + if (result == CorrectionReviewResult.Approved) + { + var person = await _context.GanjoorRelatedPersons.Where(p => p.Id == suggestion.PersonId).SingleOrDefaultAsync(); + if (person == null) + { + return new RServiceResult(null, "شخصیت مقصد این پیشنهاد پیدا نشد."); + } + + person.Name = suggestion.SuggestedName; + person.Description = suggestion.SuggestedDescription; + person.WikiUrl = suggestion.SuggestedWikiUrl; + person.BirthYearInLHijri = suggestion.SuggestedBirthYearInLHijri; + person.DeathYearInLHijri = suggestion.SuggestedDeathYearInLHijri; + person.ValidBirthDate = suggestion.SuggestedValidBirthDate; + person.ValidDeathDate = suggestion.SuggestedValidDeathDate; + person.BirthLocationId = suggestion.SuggestedBirthLocationId; + person.DeathLocationId = suggestion.SuggestedDeathLocationId; + person.FamilyTreeCaption = suggestion.SuggestedFamilyTreeCaption; + // Id and MachineGenerated on the person are intentionally left untouched + } + + suggestion.Reviewed = true; + suggestion.Result = result; + suggestion.ReviewNote = reviewNote; + suggestion.ReviewDate = DateTime.Now; + suggestion.ReviewerUserId = moderatorUserId; + + await _context.SaveChangesAsync(); + + return new RServiceResult(suggestion); + } + catch (Exception exp) + { + return new RServiceResult(null, exp.ToString()); + } + } + /// /// Database Context ///