Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions src/SIL.XForge.Scripture/Models/SyncMetrics.cs
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,11 @@ public record SyncMetrics : IIdentifiable
/// </summary>
public SyncMetricInfo ParatextNotes { get; set; } = new SyncMetricInfo();

/// <summary>
/// Gets or sets the info for changes to permissions outgoing to Paratext.
/// </summary>
public SyncMetricInfo ParatextPermissions { get; set; } = new SyncMetricInfo();

/// <summary>
/// Gets or sets the info for changes to questions incoming from Paratext.
/// </summary>
Expand Down
2 changes: 2 additions & 0 deletions src/SIL.XForge.Scripture/Models/SyncPhase.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
namespace SIL.XForge.Scripture.Models;

/// <summary>
/// The sync phase.
/// </summary>
Expand Down
6 changes: 6 additions & 0 deletions src/SIL.XForge.Scripture/Services/IParatextService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,12 @@ SyncMetricInfo UpdateBiblicalTerms(
string paratextId,
IReadOnlyList<BiblicalTerm> biblicalTerms
);
Task<SyncMetricInfo> UpdateParatextPermissionsForNewBooksAsync(
UserSecret userSecret,
string paratextId,
IDocument<SFProject> projectDoc,
bool writeToParatext
);
string? GetLatestSharedVersion(UserSecret userSecret, string paratextId);
string GetRepoRevision(UserSecret userSecret, string paratextId);
void SetRepoToRevision(UserSecret userSecret, string paratextId, string desiredRevision);
Expand Down
42 changes: 28 additions & 14 deletions src/SIL.XForge.Scripture/Services/MachineApiService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,13 @@ CancellationToken cancellationToken
cancellationToken
);

// Retrieve the user secret
Attempt<UserSecret> attempt = await userSecrets.TryGetAsync(curUserId, cancellationToken);
if (!attempt.TryResult(out UserSecret userSecret))
{
throw new DataNotFoundException("The user does not exist.");
}

// Connect to the realtime server
await using IConnection connection = await realtimeService.ConnectAsync(curUserId);

Expand All @@ -126,13 +133,6 @@ CancellationToken cancellationToken
List<(ChapterDelta chapterDelta, int bookNum)> chapterDeltas = [];
try
{
// Retrieve the user secret
Attempt<UserSecret> attempt = await userSecrets.TryGetAsync(curUserId, cancellationToken);
if (!attempt.TryResult(out UserSecret userSecret))
{
throw new DataNotFoundException("The user does not exist.");
}

// Load the target project
targetProjectDoc = await connection.FetchAsync<SFProject>(targetProjectId);
if (!targetProjectDoc.IsLoaded)
Expand Down Expand Up @@ -404,13 +404,27 @@ await hubContext.NotifyDraftApplyProgress(
sfProjectId,
new DraftApplyState { State = "Loading permissions from Paratext." }
);
await projectService.UpdatePermissionsAsync(
curUserId,
targetProjectDoc,
users: null,
books: chapterDeltas.Select(c => c.bookNum).Distinct().ToList(),
cancellationToken
);
if (createdBooks.Count == 0)
{
// Update books for which chapters were added
await projectService.UpdatePermissionsAsync(
curUserId,
targetProjectDoc,
users: null,
books: chapterDeltas.Select(c => c.bookNum).Distinct().ToList(),
cancellationToken
);
}
else
{
// Update permissions for new books
await paratextService.UpdateParatextPermissionsForNewBooksAsync(
userSecret,
targetProjectDoc.Data.ParatextId,
targetProjectDoc,
writeToParatext: false
);
}
}

// Create the text data documents, using the permissions matrix calculated above for permissions
Expand Down
103 changes: 103 additions & 0 deletions src/SIL.XForge.Scripture/Services/ParatextService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@
using SIL.XForge.Services;
using SIL.XForge.Utils;
using StringUtils = SIL.XForge.Utils.StringUtils;
using TextInfo = SIL.XForge.Scripture.Models.TextInfo;

namespace SIL.XForge.Scripture.Services;

Expand Down Expand Up @@ -331,6 +332,7 @@ SyncMetrics syncMetrics
syncMetrics.ParatextBooks != new SyncMetricInfo()
|| syncMetrics.ParatextNotes != new SyncMetricInfo()
|| syncMetrics.ParatextBiblicalTerms != new SyncMetricInfo()
|| syncMetrics.ParatextPermissions != new SyncMetricInfo()
)
)
{
Expand Down Expand Up @@ -1690,6 +1692,107 @@ IReadOnlyList<BiblicalTerm> biblicalTerms
return syncMetricInfo;
}

public async Task<SyncMetricInfo> UpdateParatextPermissionsForNewBooksAsync(
UserSecret userSecret,
string paratextId,
IDocument<SFProject> projectDoc,
bool writeToParatext
)
{
var syncMetricInfo = new SyncMetricInfo();
using ScrText scrText = ScrTextCollection.FindById(GetParatextUsername(userSecret)!, paratextId);
if (scrText is null)
{
return syncMetricInfo;
}

if (!projectDoc.Data.Editable)
{
return syncMetricInfo;
}

// Get all projects that are not on disk
for (int i = 0; i < projectDoc.Data.Texts.Count; i++)
{
TextInfo text = projectDoc.Data.Texts[i];
int bookNum = text.BookNum;
if (scrText.BookPresent(bookNum))
{
// Book is on disk, skip to the next book
continue;
}

// Add any users to the book who would have the ability to access it
foreach (var user in projectDoc.Data.ParatextUsers)
{
// If there is no SF user id or PT username, ignore this user
if (string.IsNullOrEmpty(user.SFUserId) || string.IsNullOrEmpty(user.Username))
{
continue;
}

bool hasPermissionInParatext = scrText.Permissions.CanEdit(bookNum, chapterNum: 0, user.Username);
bool hasPermissionInMongo =
text.Permissions.TryGetValue(user.SFUserId, out string permission)
&& permission == TextInfoPermission.Write;
bool userIsAdministrator = scrText.Permissions.GetUser(user.Username)?.Role == UserRoles.Administrator;

if (writeToParatext)
{
// Grant the user access to edit the new book, if we granted them access in Mongo,
// they are an administrator, but they do not have access in Paratext.
//
// This is based on ParatextData.ImportSfmText.GrantBookPermissions()
if (hasPermissionInMongo && !hasPermissionInParatext && userIsAdministrator)
{
scrText.Permissions.SetPermission(user.Username, bookNum, PermissionSet.Manual, true);
syncMetricInfo.Updated++;
}
}
else
{
// If the user can edit the book and doesn't have permission,
// or they are the current user and an administrator,
// update the Scripture Forge permissions to allow writing.
bool currentUserIsAdministrator = userIsAdministrator && user.SFUserId == userSecret.Id;
if (!hasPermissionInMongo && (currentUserIsAdministrator || hasPermissionInParatext))
{
// Add the user to the new book in SF with book permissions and available chapter permissions
// This will be empty if the current user is an administrator but has no permissions for the book
int[] editableChapters =
[
.. scrText.Permissions.GetEditableChapters(
bookNum,
scrText.Settings.Versification,
user.Username
) ?? [],
];
await projectDoc.SubmitJson0OpAsync(op =>
{
int textIndex = i;
op.Set(p => p.Texts[textIndex].Permissions[user.SFUserId], TextInfoPermission.Write);
for (int j = 0; j < text.Chapters.Count; j++)
{
int chapterIndex = j;
int chapterNumber = text.Chapters[chapterIndex].Number;
if (editableChapters.Contains(chapterNumber) || currentUserIsAdministrator)
{
op.Set(
p => p.Texts[textIndex].Chapters[chapterIndex].Permissions[user.SFUserId],
TextInfoPermission.Write
);
}
}
});
syncMetricInfo.Updated++;
}
}
}
}

return syncMetricInfo;
}

/// <summary>
/// Get the most recent revision id of a commit from the last push or pull with the PT send/receive server.
/// </summary>
Expand Down
6 changes: 6 additions & 0 deletions src/SIL.XForge.Scripture/Services/ParatextSyncRunner.cs
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,12 @@ CancellationToken token
// Update target Paratext books, notes and biblical terms, if this is not a resource
if (!_paratextService.IsResource(targetParatextId))
{
_syncMetrics.ParatextPermissions += await _paratextService.UpdateParatextPermissionsForNewBooksAsync(
_userSecret,
targetParatextId,
_projectDoc,
writeToParatext: true
);
await GetAndUpdateParatextBooksAndNotes(
SyncPhase.Phase2,
targetParatextId,
Expand Down
97 changes: 69 additions & 28 deletions test/SIL.XForge.Scripture.Tests/Services/MachineApiServiceTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -224,29 +224,28 @@ public async Task ApplyPreTranslationToProjectAsync_ExceptionFromParatext()
// Set up test environment
var env = new TestEnvironment();
env.ConfigureDraft(
Project01,
Project02,
bookNum: 39,
numberOfChapters: 3,
bookExists: true,
draftExists: true,
canWriteBook: true,
writeChapters: 3
);
env.ProjectService.UpdatePermissionsAsync(
env.ParatextService.UpdateParatextPermissionsForNewBooksAsync(
Arg.Any<UserSecret>(),
Arg.Any<string>(),
Arg.Any<IDocument<SFProject>>(),
users: null,
books: Arg.Any<IReadOnlyList<int>>(),
CancellationToken.None
writeToParatext: false
)
.ThrowsAsync(new NotSupportedException());

// SUT
DraftApplyResult actual = await env.Service.ApplyPreTranslationToProjectAsync(
User01,
Project01,
Project02,
scriptureRange: "MAL",
targetProjectId: Project02,
targetProjectId: Project01,
DateTime.UtcNow,
CancellationToken.None
);
Expand Down Expand Up @@ -354,19 +353,16 @@ public async Task ApplyPreTranslationToProjectAsync_MissingUserSecret()
await env.UserSecrets.DeleteAllAsync(_ => true);

// SUT
DraftApplyResult actual = await env.Service.ApplyPreTranslationToProjectAsync(
User01,
Project01,
scriptureRange: "GEN",
Project02,
DateTime.UtcNow,
CancellationToken.None
Assert.ThrowsAsync<DataNotFoundException>(() =>
env.Service.ApplyPreTranslationToProjectAsync(
User01,
Project01,
scriptureRange: "GEN",
Project02,
DateTime.UtcNow,
CancellationToken.None
)
);

env.MockLogger.AssertHasEvent(logEvent => logEvent.Exception?.GetType() == typeof(DataNotFoundException));
env.ExceptionHandler.Received().ReportException(Arg.Is<DataNotFoundException>(e => e.Message.Contains("user")));
Assert.That(actual.Log, Is.Not.Empty);
Assert.That(actual.ChangesSaved, Is.False);
}

[Test]
Expand Down Expand Up @@ -4930,6 +4926,36 @@ int writeChapters
}
}
});

// Update the permissions for the user applying the draft
ParatextService
.When(x =>
x.UpdateParatextPermissionsForNewBooksAsync(
Arg.Any<UserSecret>(),
Arg.Any<string>(),
Arg.Any<IDocument<SFProject>>(),
writeToParatext: false
)
)
.Do(callInfo =>
{
UserSecret userSecret = callInfo.ArgAt<UserSecret>(0);
var projectDoc = callInfo.ArgAt<IDocument<SFProject>>(2);
foreach (var text in projectDoc.Data.Texts)
{
text.Permissions.TryAdd(
userSecret.Id,
canWriteBook ? TextInfoPermission.Write : TextInfoPermission.Read
);
foreach (var chapter in text.Chapters)
{
chapter.Permissions.TryAdd(
userSecret.Id,
chapter.Number <= writeChapters ? TextInfoPermission.Write : TextInfoPermission.Read
);
}
}
});
}

public async Task VerifyDraftAsync(
Expand All @@ -4941,15 +4967,30 @@ public async Task VerifyDraftAsync(
int writeChapters
)
{
await ProjectService
.Received()
.UpdatePermissionsAsync(
User01,
Arg.Any<IDocument<SFProject>>(),
users: null,
books: Arg.Any<IReadOnlyList<int>>(),
CancellationToken.None
);
if (targetProjectId == Project02)
{
await ParatextService
.Received()
.UpdateParatextPermissionsForNewBooksAsync(
Arg.Any<UserSecret>(),
Arg.Any<string>(),
Arg.Any<IDocument<SFProject>>(),
writeToParatext: false
);
}
else
{
await ProjectService
.Received()
.UpdatePermissionsAsync(
User01,
Arg.Any<IDocument<SFProject>>(),
users: null,
books: Arg.Any<IReadOnlyList<int>>(),
CancellationToken.None
);
}

ExceptionHandler.DidNotReceive().ReportException(Arg.Any<Exception>());

await Assert.ThatAsync(
Expand Down
Loading
Loading