Skip to content

Commit 44200ed

Browse files
committed
Added requirement that the latest build be completed
This adds a new backend method to return the latest build. The front end verifies that the latest build is completed and not any other state. This was changed to account for canceled builds. In order to communicate with Serval, the latest build has to be complete (and contain the draft being used).
1 parent d77a363 commit 44200ed

File tree

11 files changed

+388
-5
lines changed

11 files changed

+388
-5
lines changed

src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-generation.service.spec.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,50 @@ describe('DraftGenerationService', () => {
182182
}));
183183
});
184184

185+
describe('getLastPreTranslationBuild', () => {
186+
it('should get last pre-translation build and return an observable of BuildDto', fakeAsync(() => {
187+
// SUT
188+
service.getLastPreTranslationBuild(projectId).subscribe(result => {
189+
expect(result).toEqual(buildDto);
190+
});
191+
tick();
192+
193+
// Setup the HTTP request
194+
const req = httpTestingController.expectOne(
195+
`${MACHINE_API_BASE_URL}translation/engines/project:${projectId}/actions/getLastPreTranslationBuild`
196+
);
197+
expect(req.request.method).toEqual('GET');
198+
req.flush(buildDto);
199+
tick();
200+
}));
201+
202+
it('should return undefined when no build has ever run', fakeAsync(() => {
203+
// SUT
204+
service.getLastPreTranslationBuild(projectId).subscribe(result => {
205+
expect(result).toBeUndefined();
206+
});
207+
tick();
208+
209+
// Setup the HTTP request
210+
const req = httpTestingController.expectOne(
211+
`${MACHINE_API_BASE_URL}translation/engines/project:${projectId}/actions/getLastPreTranslationBuild`
212+
);
213+
expect(req.request.method).toEqual('GET');
214+
req.flush(null, { status: HttpStatusCode.NoContent, statusText: 'No Content' });
215+
tick();
216+
}));
217+
218+
it('should return undefined if offline', fakeAsync(() => {
219+
testOnlineStatusService.setIsOnline(false);
220+
221+
// SUT
222+
service.getLastPreTranslationBuild(projectId).subscribe(result => {
223+
expect(result).toBeUndefined();
224+
});
225+
tick();
226+
}));
227+
});
228+
185229
describe('getBuildHistory', () => {
186230
it('should get project builds and return an observable array of BuildDto', fakeAsync(() => {
187231
// SUT

src/SIL.XForge.Scripture/ClientApp/src/app/translate/draft-generation/draft-generation.service.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,33 @@ export class DraftGenerationService {
131131
);
132132
}
133133

134+
/**
135+
* Gets the last pre-translation build regardless of state (Completed, Running, Queued, Faulted, or Canceled).
136+
* This is a simpler accessor than getLastCompletedBuild() and can be used when the consumer
137+
* wants the most recent build even if it has not yet completed.
138+
* @param projectId The SF project id for the target translation.
139+
* @returns An observable BuildDto for the last pre-translation build, or undefined if no build has ever run.
140+
*/
141+
getLastPreTranslationBuild(projectId: string): Observable<BuildDto | undefined> {
142+
if (!this.onlineStatusService.isOnline) {
143+
return of(undefined);
144+
}
145+
return this.httpClient
146+
.get<BuildDto>(`translation/engines/project:${projectId}/actions/getLastPreTranslationBuild`)
147+
.pipe(
148+
map(res => res.data),
149+
catchError(err => {
150+
// If project doesn't exist on Serval, return undefined
151+
if (err.status === 403 || err.status === 404) {
152+
return of(undefined);
153+
}
154+
155+
this.noticeService.showError(this.i18n.translateStatic('draft_generation.temporarily_unavailable'));
156+
return of(undefined);
157+
})
158+
);
159+
}
160+
134161
/** Gets the build exactly as Serval returns it */
135162
getRawBuild(buildId: string): Observable<Object | undefined> {
136163
if (!this.onlineStatusService.isOnline) {

src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/editor-draft/editor-draft.component.spec.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,11 @@ describe('EditorDraftComponent', () => {
8585
buildProgress$.next({ state: BuildStates.Completed } as BuildDto);
8686
when(mockActivatedProjectService.projectId$).thenReturn(of('targetProjectId'));
8787
when(mockDraftGenerationService.getLastCompletedBuild(anything())).thenReturn(of(undefined));
88+
const defaultProjectDoc: SFProjectProfileDoc = { data: createTestProjectProfile() } as SFProjectProfileDoc;
89+
when(mockActivatedProjectService.projectDoc$).thenReturn(of(defaultProjectDoc));
90+
when(mockDraftGenerationService.getLastPreTranslationBuild(anything())).thenReturn(
91+
of({ state: BuildStates.Completed } as BuildDto)
92+
);
8893

8994
fixture = TestBed.createComponent(EditorDraftComponent);
9095
component = fixture.componentInstance;
@@ -581,8 +586,12 @@ describe('EditorDraftComponent', () => {
581586
})
582587
} as SFProjectProfileDoc;
583588
when(mockDraftGenerationService.draftExists(anything(), anything(), anything())).thenReturn(of(true));
589+
when(mockActivatedProjectService.projectDoc$).thenReturn(of(testProjectDoc));
584590
when(mockActivatedProjectService.changes$).thenReturn(of(testProjectDoc));
585591

592+
when(mockDraftGenerationService.getLastPreTranslationBuild(anything())).thenReturn(
593+
of({ state: BuildStates.Completed } as BuildDto)
594+
);
586595
fixture.detectChanges();
587596
tick(EDITOR_READY_TIMEOUT);
588597

@@ -603,8 +612,12 @@ describe('EditorDraftComponent', () => {
603612
})
604613
} as SFProjectProfileDoc;
605614
when(mockDraftGenerationService.draftExists(anything(), anything(), anything())).thenReturn(of(true));
615+
when(mockActivatedProjectService.projectDoc$).thenReturn(of(testProjectDoc));
606616
when(mockActivatedProjectService.changes$).thenReturn(of(testProjectDoc));
607617

618+
when(mockDraftGenerationService.getLastPreTranslationBuild(anything())).thenReturn(
619+
of({ state: BuildStates.Completed } as BuildDto)
620+
);
608621
fixture.detectChanges();
609622
tick(EDITOR_READY_TIMEOUT);
610623
expect(component.canConfigureFormatting).toBe(true);
@@ -631,11 +644,43 @@ describe('EditorDraftComponent', () => {
631644
})
632645
} as SFProjectProfileDoc;
633646
when(mockDraftGenerationService.draftExists(anything(), anything(), anything())).thenReturn(of(false));
647+
when(mockActivatedProjectService.projectDoc$).thenReturn(of(testProjectDoc));
634648
when(mockActivatedProjectService.changes$).thenReturn(of(testProjectDoc));
635649

650+
when(mockDraftGenerationService.getLastPreTranslationBuild(anything())).thenReturn(
651+
of({ state: BuildStates.Completed } as BuildDto)
652+
);
636653
fixture.detectChanges();
637654
tick(EDITOR_READY_TIMEOUT);
655+
tick();
656+
expect(component.isSelectedDraftLatest).toBe(true);
657+
expect(component.canConfigureFormatting).toBe(false);
658+
flush();
659+
}));
660+
661+
it('should be false when latest build is canceled even if draft exists and selected revision is latest', fakeAsync(() => {
662+
const testProjectDoc: SFProjectProfileDoc = {
663+
data: createTestProjectProfile({
664+
texts: [
665+
{
666+
bookNum: 1,
667+
chapters: [{ number: 1, permissions: { user01: SFProjectRole.ParatextAdministrator }, hasDraft: true }]
668+
}
669+
]
670+
})
671+
} as SFProjectProfileDoc;
672+
when(mockDraftGenerationService.getLastPreTranslationBuild(anything())).thenReturn(
673+
of({ state: BuildStates.Canceled } as BuildDto)
674+
);
675+
when(mockDraftGenerationService.draftExists(anything(), anything(), anything())).thenReturn(of(true));
676+
when(mockActivatedProjectService.projectDoc$).thenReturn(of(testProjectDoc));
677+
when(mockActivatedProjectService.changes$).thenReturn(of(testProjectDoc));
678+
679+
fixture.detectChanges();
680+
tick(EDITOR_READY_TIMEOUT);
681+
638682
expect(component.isSelectedDraftLatest).toBe(true);
683+
expect(component.doesLatestCompletedHaveDraft).toBe(true);
639684
expect(component.canConfigureFormatting).toBe(false);
640685
flush();
641686
}));

src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/editor-draft/editor-draft.component.ts

Lines changed: 40 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import {
2121
startWith,
2222
Subject,
2323
switchMap,
24+
take,
2425
tap,
2526
throttleTime
2627
} from 'rxjs';
@@ -38,6 +39,7 @@ import { isString } from '../../../../type-utils';
3839
import { TextDocId } from '../../../core/models/text-doc';
3940
import { Revision } from '../../../core/paratext.service';
4041
import { SFProjectService } from '../../../core/sf-project.service';
42+
import { BuildDto } from '../../../machine-api/build-dto';
4143
import { BuildStates } from '../../../machine-api/build-states';
4244
import { TextComponent } from '../../../shared/text/text.component';
4345
import { DraftGenerationService } from '../../draft-generation/draft-generation.service';
@@ -91,6 +93,7 @@ export class EditorDraftComponent implements AfterViewInit, OnChanges {
9193

9294
private draftDelta?: Delta;
9395
private targetDelta?: Delta;
96+
private _latestPreTranslationBuild: BuildDto | undefined;
9497

9598
constructor(
9699
private readonly activatedProjectService: ActivatedProjectService,
@@ -121,16 +124,20 @@ export class EditorDraftComponent implements AfterViewInit, OnChanges {
121124
}
122125

123126
get canConfigureFormatting(): boolean {
124-
return this.doesLatestHaveDraft && this.isSelectedDraftLatest;
127+
return this.doesLatestCompletedHaveDraft && this.isSelectedDraftLatest && this.isLatestBuildCompleted;
125128
}
126129

127-
get doesLatestHaveDraft(): boolean {
130+
get doesLatestCompletedHaveDraft(): boolean {
128131
return (
129132
this.targetProject?.texts.find(t => t.bookNum === this.bookNum)?.chapters.find(c => c.number === this.chapter)
130133
?.hasDraft ?? false
131134
);
132135
}
133136

137+
get isLatestBuildCompleted(): boolean {
138+
return this._latestPreTranslationBuild?.state === BuildStates.Completed;
139+
}
140+
134141
get isSelectedDraftLatest(): boolean {
135142
return this.selectedRevision?.timestamp === this._draftRevisions[0].timestamp;
136143
}
@@ -217,9 +224,6 @@ export class EditorDraftComponent implements AfterViewInit, OnChanges {
217224
// Respond to project changes
218225
return this.activatedProjectService.changes$.pipe(
219226
filterNullish(),
220-
tap(projectDoc => {
221-
this.targetProject = projectDoc.data;
222-
}),
223227
distinctUntilChanged(),
224228
map(() => initialTimestamp)
225229
);
@@ -277,6 +281,37 @@ export class EditorDraftComponent implements AfterViewInit, OnChanges {
277281

278282
this.isDraftReady = this.draftCheckState === 'draft-present' || this.draftCheckState === 'draft-legacy';
279283
});
284+
285+
combineLatest([
286+
this.onlineStatusService.onlineStatus$,
287+
this.activatedProjectService.projectDoc$,
288+
this.draftGenerationService.pollBuildProgress(this.textDocId!.projectId),
289+
this.draftText.editorCreated as EventEmitter<any>,
290+
this.inputChanged$.pipe(startWith(undefined))
291+
])
292+
.pipe(
293+
quietTakeUntilDestroyed(this.destroyRef),
294+
filter(([_, projectDoc]) => projectDoc !== undefined),
295+
tap(([_, projectDoc]) => {
296+
this.targetProject = projectDoc!.data;
297+
}),
298+
filter(([isOnline, _]) => {
299+
return isOnline && this.doesLatestCompletedHaveDraft;
300+
})
301+
)
302+
.subscribe(() => this.refreshLastPreTranslationBuild());
303+
}
304+
305+
private refreshLastPreTranslationBuild(): void {
306+
if (this.projectId == null) {
307+
return;
308+
}
309+
this.draftGenerationService
310+
.getLastPreTranslationBuild(this.projectId)
311+
.pipe(quietTakeUntilDestroyed(this.destroyRef), take(1))
312+
.subscribe((build: BuildDto | undefined) => {
313+
this._latestPreTranslationBuild = build;
314+
});
280315
}
281316

282317
navigateToFormatting(): void {

src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/editor.component.spec.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4892,6 +4892,7 @@ class TestEnvironment {
48924892
when(mockedDraftGenerationService.pollBuildProgress(anything())).thenReturn(
48934893
of({ state: BuildStates.Completed } as BuildDto)
48944894
);
4895+
when(mockedDraftGenerationService.getLastPreTranslationBuild(anything())).thenReturn(of(undefined));
48954896
when(
48964897
mockedDraftGenerationService.getGeneratedDraftDeltaOperations(
48974898
anything(),

src/SIL.XForge.Scripture/Controllers/MachineApiController.cs

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -403,6 +403,54 @@ CancellationToken cancellationToken
403403
}
404404
}
405405

406+
/// <summary>
407+
/// Gets the last pre-translation build regardless of state (completed, running, queued, faulted, or canceled).
408+
/// </summary>
409+
/// <param name="sfProjectId">The Scripture Forge project identifier.</param>
410+
/// <param name="cancellationToken">The cancellation token.</param>
411+
/// <response code="200">The last pre-translation build was found.</response>
412+
/// <response code="204">There are no pre-translation builds.</response>
413+
/// <response code="403">You do not have permission to get the builds for this project.</response>
414+
/// <response code="404">The project does not exist or is not configured on the ML server.</response>
415+
/// <response code="503">The ML server is temporarily unavailable or unresponsive.</response>
416+
[HttpGet(MachineApi.GetLastPreTranslationBuild)]
417+
public async Task<ActionResult<ServalBuildDto?>> GetLastPreTranslationBuildAsync(
418+
string sfProjectId,
419+
CancellationToken cancellationToken
420+
)
421+
{
422+
try
423+
{
424+
bool isServalAdmin = _userAccessor.SystemRoles.Contains(SystemRole.ServalAdmin);
425+
ServalBuildDto? build = await _machineApiService.GetLastPreTranslationBuildAsync(
426+
_userAccessor.UserId,
427+
sfProjectId,
428+
isServalAdmin,
429+
cancellationToken
430+
);
431+
432+
if (build is null)
433+
{
434+
return NoContent();
435+
}
436+
437+
return Ok(build);
438+
}
439+
catch (BrokenCircuitException e)
440+
{
441+
_exceptionHandler.ReportException(e);
442+
return StatusCode(StatusCodes.Status503ServiceUnavailable, MachineApiUnavailable);
443+
}
444+
catch (DataNotFoundException)
445+
{
446+
return NotFound();
447+
}
448+
catch (ForbiddenException)
449+
{
450+
return Forbid();
451+
}
452+
}
453+
406454
/// <summary>
407455
/// Gets all the pre-translations for the specified chapter.
408456
/// </summary>

src/SIL.XForge.Scripture/Models/MachineApi.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,8 @@ public static class MachineApi
3535
"translation/engines/project:{sfProjectId}/actions/preTranslate/{bookNum}_{chapterNum}/usx";
3636
public const string GetLastCompletedPreTranslationBuild =
3737
"translation/engines/project:{sfProjectId}/actions/getLastCompletedPreTranslationBuild";
38+
public const string GetLastPreTranslationBuild =
39+
"translation/engines/project:{sfProjectId}/actions/getLastPreTranslationBuild";
3840

3941
public static string GetBuildHref(string sfProjectId, string buildId)
4042
{

src/SIL.XForge.Scripture/Services/IMachineApiService.cs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,12 @@ CancellationToken cancellationToken
8282
bool isServalAdmin,
8383
CancellationToken cancellationToken
8484
);
85+
Task<ServalBuildDto?> GetLastPreTranslationBuildAsync(
86+
string curUserId,
87+
string sfProjectId,
88+
bool isServalAdmin,
89+
CancellationToken cancellationToken
90+
);
8591
Task<PreTranslationDto> GetPreTranslationAsync(
8692
string curUserId,
8793
string sfProjectId,

0 commit comments

Comments
 (0)