Skip to content

Commit a3f22b0

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 62cc516 commit a3f22b0

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
@@ -183,6 +183,50 @@ describe('DraftGenerationService', () => {
183183
}));
184184
});
185185

186+
describe('getLastPreTranslationBuild', () => {
187+
it('should get last pre-translation build and return an observable of BuildDto', fakeAsync(() => {
188+
// SUT
189+
service.getLastPreTranslationBuild(projectId).subscribe(result => {
190+
expect(result).toEqual(buildDto);
191+
});
192+
tick();
193+
194+
// Setup the HTTP request
195+
const req = httpTestingController.expectOne(
196+
`${MACHINE_API_BASE_URL}translation/engines/project:${projectId}/actions/getLastPreTranslationBuild`
197+
);
198+
expect(req.request.method).toEqual('GET');
199+
req.flush(buildDto);
200+
tick();
201+
}));
202+
203+
it('should return undefined when no build has ever run', fakeAsync(() => {
204+
// SUT
205+
service.getLastPreTranslationBuild(projectId).subscribe(result => {
206+
expect(result).toBeUndefined();
207+
});
208+
tick();
209+
210+
// Setup the HTTP request
211+
const req = httpTestingController.expectOne(
212+
`${MACHINE_API_BASE_URL}translation/engines/project:${projectId}/actions/getLastPreTranslationBuild`
213+
);
214+
expect(req.request.method).toEqual('GET');
215+
req.flush(null, { status: HttpStatusCode.NoContent, statusText: 'No Content' });
216+
tick();
217+
}));
218+
219+
it('should return undefined if offline', fakeAsync(() => {
220+
testOnlineStatusService.setIsOnline(false);
221+
222+
// SUT
223+
service.getLastPreTranslationBuild(projectId).subscribe(result => {
224+
expect(result).toBeUndefined();
225+
});
226+
tick();
227+
}));
228+
});
229+
186230
describe('getBuildHistory', () => {
187231
it('should get project builds and return an observable array of BuildDto', fakeAsync(() => {
188232
// 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
@@ -86,6 +86,11 @@ describe('EditorDraftComponent', () => {
8686
buildProgress$.next({ state: BuildStates.Completed } as BuildDto);
8787
when(mockActivatedProjectService.projectId$).thenReturn(of('targetProjectId'));
8888
when(mockDraftGenerationService.getLastCompletedBuild(anything())).thenReturn(of(undefined));
89+
const defaultProjectDoc: SFProjectProfileDoc = { data: createTestProjectProfile() } as SFProjectProfileDoc;
90+
when(mockActivatedProjectService.projectDoc$).thenReturn(of(defaultProjectDoc));
91+
when(mockDraftGenerationService.getLastPreTranslationBuild(anything())).thenReturn(
92+
of({ state: BuildStates.Completed } as BuildDto)
93+
);
8994

9095
fixture = TestBed.createComponent(EditorDraftComponent);
9196
component = fixture.componentInstance;
@@ -582,8 +587,12 @@ describe('EditorDraftComponent', () => {
582587
})
583588
} as SFProjectProfileDoc;
584589
when(mockDraftGenerationService.draftExists(anything(), anything(), anything())).thenReturn(of(true));
590+
when(mockActivatedProjectService.projectDoc$).thenReturn(of(testProjectDoc));
585591
when(mockActivatedProjectService.changes$).thenReturn(of(testProjectDoc));
586592

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

@@ -604,8 +613,12 @@ describe('EditorDraftComponent', () => {
604613
})
605614
} as SFProjectProfileDoc;
606615
when(mockDraftGenerationService.draftExists(anything(), anything(), anything())).thenReturn(of(true));
616+
when(mockActivatedProjectService.projectDoc$).thenReturn(of(testProjectDoc));
607617
when(mockActivatedProjectService.changes$).thenReturn(of(testProjectDoc));
608618

619+
when(mockDraftGenerationService.getLastPreTranslationBuild(anything())).thenReturn(
620+
of({ state: BuildStates.Completed } as BuildDto)
621+
);
609622
fixture.detectChanges();
610623
tick(EDITOR_READY_TIMEOUT);
611624
expect(component.canConfigureFormatting).toBe(true);
@@ -632,11 +645,43 @@ describe('EditorDraftComponent', () => {
632645
})
633646
} as SFProjectProfileDoc;
634647
when(mockDraftGenerationService.draftExists(anything(), anything(), anything())).thenReturn(of(false));
648+
when(mockActivatedProjectService.projectDoc$).thenReturn(of(testProjectDoc));
635649
when(mockActivatedProjectService.changes$).thenReturn(of(testProjectDoc));
636650

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

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
@@ -30,6 +30,7 @@ import {
3030
startWith,
3131
Subject,
3232
switchMap,
33+
take,
3334
tap,
3435
throttleTime
3536
} from 'rxjs';
@@ -47,6 +48,7 @@ import { isString } from '../../../../type-utils';
4748
import { TextDocId } from '../../../core/models/text-doc';
4849
import { Revision } from '../../../core/paratext.service';
4950
import { SFProjectService } from '../../../core/sf-project.service';
51+
import { BuildDto } from '../../../machine-api/build-dto';
5052
import { BuildStates } from '../../../machine-api/build-states';
5153
import { NoticeComponent } from '../../../shared/notice/notice.component';
5254
import { TextComponent } from '../../../shared/text/text.component';
@@ -121,6 +123,7 @@ export class EditorDraftComponent implements AfterViewInit, OnChanges {
121123

122124
private draftDelta?: Delta;
123125
private targetDelta?: Delta;
126+
private _latestPreTranslationBuild: BuildDto | undefined;
124127

125128
constructor(
126129
private readonly activatedProjectService: ActivatedProjectService,
@@ -151,16 +154,20 @@ export class EditorDraftComponent implements AfterViewInit, OnChanges {
151154
}
152155

153156
get canConfigureFormatting(): boolean {
154-
return this.doesLatestHaveDraft && this.isSelectedDraftLatest;
157+
return this.doesLatestCompletedHaveDraft && this.isSelectedDraftLatest && this.isLatestBuildCompleted;
155158
}
156159

157-
get doesLatestHaveDraft(): boolean {
160+
get doesLatestCompletedHaveDraft(): boolean {
158161
return (
159162
this.targetProject?.texts.find(t => t.bookNum === this.bookNum)?.chapters.find(c => c.number === this.chapter)
160163
?.hasDraft ?? false
161164
);
162165
}
163166

167+
get isLatestBuildCompleted(): boolean {
168+
return this._latestPreTranslationBuild?.state === BuildStates.Completed;
169+
}
170+
164171
get isSelectedDraftLatest(): boolean {
165172
return this.selectedRevision?.timestamp === this._draftRevisions[0].timestamp;
166173
}
@@ -247,9 +254,6 @@ export class EditorDraftComponent implements AfterViewInit, OnChanges {
247254
// Respond to project changes
248255
return this.activatedProjectService.changes$.pipe(
249256
filterNullish(),
250-
tap(projectDoc => {
251-
this.targetProject = projectDoc.data;
252-
}),
253257
distinctUntilChanged(),
254258
map(() => initialTimestamp)
255259
);
@@ -307,6 +311,37 @@ export class EditorDraftComponent implements AfterViewInit, OnChanges {
307311

308312
this.isDraftReady = this.draftCheckState === 'draft-present' || this.draftCheckState === 'draft-legacy';
309313
});
314+
315+
combineLatest([
316+
this.onlineStatusService.onlineStatus$,
317+
this.activatedProjectService.projectDoc$,
318+
this.draftGenerationService.pollBuildProgress(this.textDocId!.projectId),
319+
this.draftText.editorCreated as EventEmitter<any>,
320+
this.inputChanged$.pipe(startWith(undefined))
321+
])
322+
.pipe(
323+
quietTakeUntilDestroyed(this.destroyRef),
324+
filter(([_, projectDoc]) => projectDoc !== undefined),
325+
tap(([_, projectDoc]) => {
326+
this.targetProject = projectDoc!.data;
327+
}),
328+
filter(([isOnline, _]) => {
329+
return isOnline && this.doesLatestCompletedHaveDraft;
330+
})
331+
)
332+
.subscribe(() => this.refreshLastPreTranslationBuild());
333+
}
334+
335+
private refreshLastPreTranslationBuild(): void {
336+
if (this.projectId == null) {
337+
return;
338+
}
339+
this.draftGenerationService
340+
.getLastPreTranslationBuild(this.projectId)
341+
.pipe(quietTakeUntilDestroyed(this.destroyRef), take(1))
342+
.subscribe((build: BuildDto | undefined) => {
343+
this._latestPreTranslationBuild = build;
344+
});
310345
}
311346

312347
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
@@ -4905,6 +4905,7 @@ class TestEnvironment {
49054905
when(mockedDraftGenerationService.pollBuildProgress(anything())).thenReturn(
49064906
of({ state: BuildStates.Completed } as BuildDto)
49074907
);
4908+
when(mockedDraftGenerationService.getLastPreTranslationBuild(anything())).thenReturn(of(undefined));
49084909
when(
49094910
mockedDraftGenerationService.getGeneratedDraftDeltaOperations(
49104911
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)