diff --git a/backend/app/documents/query.py b/backend/app/documents/query.py index 2cbaa2a..ed8b52a 100644 --- a/backend/app/documents/query.py +++ b/backend/app/documents/query.py @@ -1,7 +1,7 @@ from datetime import datetime from typing import Iterable -from sqlalchemy import case, func, select +from sqlalchemy import case, func, select, Row from sqlalchemy.orm import Session from app.base.exceptions import BaseQueryException @@ -91,14 +91,32 @@ def get_document_records_count(self, doc: Document) -> tuple[int, int]: def get_document_records_paged( self, doc: Document, page: int, page_records=100 - ) -> Iterable[DocumentRecord]: + ) -> Iterable[Row[tuple[DocumentRecord, int]]]: + # Subquery to count repetitions for each source text within the document + repetitions_subquery = ( + select( + DocumentRecord.source, + func.count(DocumentRecord.id).label('repetitions_count') + ) + .filter(DocumentRecord.document_id == doc.id) + .group_by(DocumentRecord.source) + .subquery() + ) + return self.__db.execute( - select(DocumentRecord) + select( + DocumentRecord, + func.coalesce(repetitions_subquery.c.repetitions_count, 0).label('repetitions_count') + ) .filter(DocumentRecord.document_id == doc.id) + .outerjoin( + repetitions_subquery, + DocumentRecord.source == repetitions_subquery.c.source + ) .order_by(DocumentRecord.id) .offset(page_records * page) .limit(page_records) - ).scalars() + ).all() def get_record(self, record_id: int) -> DocumentRecord | None: return self.__db.execute( diff --git a/backend/app/documents/schema.py b/backend/app/documents/schema.py index 502cef2..1c07cab 100644 --- a/backend/app/documents/schema.py +++ b/backend/app/documents/schema.py @@ -24,6 +24,13 @@ class DocumentRecord(Identified): source: str target: str approved: bool + repetitions_count: int + + +class DocumentRecordUpdateResponse(Identified): + source: str + target: str + approved: bool class DocumentRecordUpdate(BaseModel): diff --git a/backend/app/routers/document.py b/backend/app/routers/document.py index b423a36..58a175e 100644 --- a/backend/app/routers/document.py +++ b/backend/app/routers/document.py @@ -96,8 +96,9 @@ def get_doc_records( source=record.source, target=record.target, approved=record.approved, + repetitions_count=repetitions_count, ) - for record in records + for record, repetitions_count in records ] @@ -149,7 +150,7 @@ def update_doc_record( record_id: int, record: doc_schema.DocumentRecordUpdate, db: Annotated[Session, Depends(get_db)], -) -> doc_schema.DocumentRecord: +) -> doc_schema.DocumentRecordUpdateResponse: try: record = GenericDocsQuery(db).update_record(record_id, record) return record diff --git a/backend/tests/routers/test_routes_doc_records.py b/backend/tests/routers/test_routes_doc_records.py index c4b8e11..773fa6d 100644 --- a/backend/tests/routers/test_routes_doc_records.py +++ b/backend/tests/routers/test_routes_doc_records.py @@ -40,12 +40,14 @@ def test_can_get_doc_records(user_logged_client: TestClient, session: Session): "source": "Regional Effects", "target": "Translation", "approved": False, + "repetitions_count": 1, }, { "id": 2, "source": "User Interface", "target": "UI", "approved": True, + "repetitions_count": 1, }, ] @@ -81,6 +83,7 @@ def test_doc_records_returns_second_page( "source": "line100", "target": "line100", "approved": False, + "repetitions_count": 1, } diff --git a/backend/tests/routers/test_routes_documents.py b/backend/tests/routers/test_routes_documents.py index 8476cfb..3e423c9 100644 --- a/backend/tests/routers/test_routes_documents.py +++ b/backend/tests/routers/test_routes_documents.py @@ -915,3 +915,39 @@ def test_setting_glossaries_returns_404_for_non_existing_glossaries( json={"glossaries": [{"id": 99}]}, ) assert response.status_code == 404 + + +def test_get_doc_records_with_repetitions(user_logged_client: TestClient, session: Session): + """Test that document records endpoint returns repetition counts""" + with session as s: + records = [ + DocumentRecord(source="Hello World", target="Привет Мир"), + DocumentRecord(source="Goodbye", target="Пока"), + DocumentRecord(source="Hello World", target="Здравствуйте Мир"), + DocumentRecord(source="Test", target="Тест"), + DocumentRecord(source="Hello World", target="Хелло Ворлд"), + ] + s.add( + Document( + name="test_doc.txt", + type=DocumentType.txt, + records=records, + processing_status="pending", + created_by=1, + ) + ) + s.commit() + + response = user_logged_client.get("/document/1/records") + assert response.status_code == 200 + response_json = response.json() + + # Should return all 5 records + assert len(response_json) == 5 + + # Check that repetition counts are correct + # "Hello World" appears 3 times, others appear once + record_counts = {record["source"]: record["repetitions_count"] for record in response_json} + assert record_counts["Hello World"] == 3 + assert record_counts["Goodbye"] == 1 + assert record_counts["Test"] == 1 diff --git a/frontend/mocks/documentMocks.ts b/frontend/mocks/documentMocks.ts index f180f0b..8251a74 100644 --- a/frontend/mocks/documentMocks.ts +++ b/frontend/mocks/documentMocks.ts @@ -1,19 +1,69 @@ import {http, HttpResponse} from 'msw' import {AwaitedReturnType} from './utils' -import {getDocs} from '../src/client/services/DocumentService' +import { + getDoc, + getDocRecords, + getDocs, +} from '../src/client/services/DocumentService' +import {DocumentStatus} from '../src/client/schemas/DocumentStatus' + +const docs = [ + { + id: 1, + created_by: 12, + records_count: 3, + approved_records_count: 1, + name: 'Some document', + status: 'done' as DocumentStatus, + type: 'XLIFF', + }, +] export const documentMocks = [ http.get('http://localhost:8000/document/', () => - HttpResponse.json>([ - { - id: 1, - created_by: 12, - records_count: 450, - approved_records_count: 247, - name: 'Some document', - status: 'done', - type: 'XLIFF', - }, - ]) + HttpResponse.json>(docs) + ), + http.get<{id: string}>('http://localhost:8000/document/:id', ({params}) => { + const doc = docs.find((doc) => doc.id === Number(params.id)) + if (doc !== undefined) { + return HttpResponse.json>(doc) + } else { + new HttpResponse(null, {status: 404}) + } + }), + http.get<{id: string}>( + 'http://localhost:8000/document/:id/records', + ({params}) => { + const doc = docs.find((doc) => doc.id === Number(params.id)) + if (doc !== undefined) { + return HttpResponse.json>([ + { + id: 1, + approved: false, + source: 'Adventure Hooks', + target: 'Зацепки приключения', + repetitions_count: 2, + }, + { + id: 2, + approved: true, + source: 'Adventure Hooks', + target: 'Зацепки приключения', + repetitions_count: 2, + }, + { + id: 3, + approved: false, + source: + 'The moment the Cynidiceans pried the horn from the monolith, their city was doomed.', + target: + 'В тот момент, когда кинидийцы извлекли рог из монолита, их город был обречен.', + repetitions_count: 1, + }, + ]) + } else { + new HttpResponse(null, {status: 404}) + } + } ), ] diff --git a/frontend/src/client/schemas/DocumentRecord.ts b/frontend/src/client/schemas/DocumentRecord.ts index 02d98ea..db1df65 100644 --- a/frontend/src/client/schemas/DocumentRecord.ts +++ b/frontend/src/client/schemas/DocumentRecord.ts @@ -5,4 +5,5 @@ export interface DocumentRecord { source: string target: string approved: boolean + repetitions_count: number } diff --git a/frontend/src/components/DocSegment.test.ts b/frontend/src/components/DocSegment.test.ts new file mode 100644 index 0000000..e0c688b --- /dev/null +++ b/frontend/src/components/DocSegment.test.ts @@ -0,0 +1,78 @@ +import {mount} from '@vue/test-utils' +import {describe, it, expect} from 'vitest' +import DocSegment from './DocSegment.vue' + +describe('DocSegment', () => { + it('renders segment ID correctly', () => { + const wrapper = mount(DocSegment, { + props: { + id: 123, + source: 'Test source', + target: 'Test target', + editable: true, + }, + }) + + expect(wrapper.text()).toContain('123') + }) + + it('shows repetition dot when repetitionsCount > 1 and editable', () => { + const wrapper = mount(DocSegment, { + props: { + id: 123, + source: 'Test source', + target: 'Test target', + editable: true, + repetitionsCount: 3, + }, + }) + + const dot = wrapper.find('.bg-orange-500') + expect(dot.exists()).toBe(true) + expect(dot.attributes('title')).toBe('Repeated 3 times') + }) + + it('does not show repetition dot when repetitionsCount = 1', () => { + const wrapper = mount(DocSegment, { + props: { + id: 123, + source: 'Test source', + target: 'Test target', + editable: true, + repetitionsCount: 1, + }, + }) + + const dot = wrapper.find('.bg-orange-500') + expect(dot.exists()).toBe(false) + }) + + it('does not show repetition dot when not editable', () => { + const wrapper = mount(DocSegment, { + props: { + id: 123, + source: 'Test source', + target: 'Test target', + editable: false, + repetitionsCount: 3, + }, + }) + + const dot = wrapper.find('.bg-orange-500') + expect(dot.exists()).toBe(false) + }) + + it('does not show repetition dot when repetitionsCount is undefined', () => { + const wrapper = mount(DocSegment, { + props: { + id: 123, + source: 'Test source', + target: 'Test target', + editable: true, + }, + }) + + const dot = wrapper.find('.bg-orange-500') + expect(dot.exists()).toBe(false) + }) +}) diff --git a/frontend/src/components/DocSegment.vue b/frontend/src/components/DocSegment.vue index b8d3b77..dd4115f 100644 --- a/frontend/src/components/DocSegment.vue +++ b/frontend/src/components/DocSegment.vue @@ -13,6 +13,7 @@ const props = defineProps<{ editable?: boolean disabled?: boolean approved?: boolean + repetitionsCount?: number }>() const emit = defineEmits<{ @@ -67,8 +68,13 @@ watchEffect(() => {