From feb16d7a8d80bfa31b2531bb421b5708f2625023 Mon Sep 17 00:00:00 2001 From: Denis Bezykornov Date: Tue, 13 Jan 2026 01:27:52 +0300 Subject: [PATCH 1/9] Add basic domain exceptions --- backend/app/base/exceptions.py | 50 +++++++++++++++++++++++++++++++++- 1 file changed, 49 insertions(+), 1 deletion(-) diff --git a/backend/app/base/exceptions.py b/backend/app/base/exceptions.py index fc4ad5b..b806cde 100644 --- a/backend/app/base/exceptions.py +++ b/backend/app/base/exceptions.py @@ -1 +1,49 @@ -class BaseQueryException(Exception): ... +"""Base exceptions for the application.""" + + +class BaseQueryException(Exception): + """Base exception for query layer errors.""" + pass + + +class BaseServiceException(Exception): + """Base exception for service layer errors.""" + + def __init__(self, message: str): + self.message = message + super().__init__(self.message) + + +class EntityNotFound(BaseServiceException): + """Raised when an entity is not found.""" + + def __init__(self, entity_name: str, entity_id: int | str | None = None): + if entity_id is None: + # entity_name is actually the full message + message = entity_name + elif isinstance(entity_id, int): + message = f"{entity_name} with id {entity_id} not found" + else: + message = f"{entity_name} '{entity_id}' not found" + super().__init__(message) + + +class UnauthorizedAccess(BaseServiceException): + """Raised when a user is not authorized to perform an action.""" + + def __init__(self, message: str = "Unauthorized access"): + super().__init__(message) + + +class ValidationError(BaseServiceException): + """Raised when validation fails.""" + + def __init__(self, message: str): + super().__init__(message) + + +class BusinessLogicError(BaseServiceException): + """Raised when business logic validation fails.""" + + def __init__(self, message: str): + super().__init__(message) From 71e5712bf9d6dc0896007ef5b3405c2ad51e07fa Mon Sep 17 00:00:00 2001 From: Denis Bezykornov Date: Tue, 13 Jan 2026 01:28:07 +0300 Subject: [PATCH 2/9] Rework users part to services --- backend/app/routers/user.py | 24 +++--- backend/app/routers/users.py | 50 +++--------- backend/app/services/user_service.py | 111 +++++++++++++++++++++++++++ 3 files changed, 134 insertions(+), 51 deletions(-) create mode 100644 backend/app/services/user_service.py diff --git a/backend/app/routers/user.py b/backend/app/routers/user.py index 6745ca0..f299d83 100644 --- a/backend/app/routers/user.py +++ b/backend/app/routers/user.py @@ -1,8 +1,11 @@ +from typing import Annotated from fastapi import APIRouter, Depends, HTTPException from sqlalchemy.orm import Session -from app import models, schema +from app import models +from app.base.exceptions import EntityNotFound from app.db import get_db +from app.services import UserService from app.user.depends import get_current_user_id router = APIRouter(prefix="/user", tags=["user"]) @@ -10,16 +13,11 @@ @router.get("/") def get_current_user( - user_id: int = Depends(get_current_user_id), db: Session = Depends(get_db) + user_id: Annotated[int, Depends(get_current_user_id)], + db: Annotated[Session, Depends(get_db)], ) -> models.User: - user = db.query(schema.User).filter_by(id=user_id).first() - if not user: - raise HTTPException(status_code=401, detail="Invalid credentials") - - return models.User( - id=user.id, - username=user.username, - email=user.email, - role=models.UserRole(user.role), - disabled=user.disabled, - ) + service = UserService(db) + try: + return service.get_current_user(user_id) + except EntityNotFound as e: + raise HTTPException(status_code=401, detail=str(e)) diff --git a/backend/app/routers/users.py b/backend/app/routers/users.py index 3e828ad..e62463c 100644 --- a/backend/app/routers/users.py +++ b/backend/app/routers/users.py @@ -3,9 +3,10 @@ from fastapi import APIRouter, Depends, HTTPException from sqlalchemy.orm import Session -from app import models, schema +from app import models +from app.base.exceptions import EntityNotFound from app.db import get_db -from app.security import hash_password +from app.services import UserService from app.user.depends import has_admin_role router = APIRouter( @@ -15,51 +16,24 @@ @router.get("/") def get_users(db: Annotated[Session, Depends(get_db)]) -> list[models.User]: - users = db.query(schema.User).order_by(schema.User.id).all() - return [ - models.User( - id=user.id, - username=user.username, - email=user.email, - role=models.UserRole(user.role), - disabled=user.disabled, - ) - for user in users - ] + service = UserService(db) + return service.get_users() @router.post("/") def create_user( data: models.UserToCreate, db: Annotated[Session, Depends(get_db)] ) -> models.User: - fields = data.model_dump() - fields["role"] = fields["role"].value - fields["password"] = hash_password(fields["password"]) - new_user = schema.User(**fields) - db.add(new_user) - db.commit() - return models.User( - id=new_user.id, - username=new_user.username, - email=new_user.email, - role=models.UserRole(new_user.role), - disabled=new_user.disabled, - ) + service = UserService(db) + return service.create_user(data) @router.post("/{user_id}") def update_user( user_id: int, data: models.UserFields, db: Annotated[Session, Depends(get_db)] ) -> models.StatusMessage: - user = db.query(schema.User).filter_by(id=user_id).first() - if not user: - raise HTTPException(status_code=404, detail="User not found") - - user.username = data.username - user.email = data.email - user.role = data.role.value - user.disabled = data.disabled - - db.commit() - - return models.StatusMessage(message="ok") + service = UserService(db) + try: + return service.update_user(user_id, data) + except EntityNotFound as e: + raise HTTPException(status_code=404, detail=str(e)) diff --git a/backend/app/services/user_service.py b/backend/app/services/user_service.py new file mode 100644 index 0000000..4ca526f --- /dev/null +++ b/backend/app/services/user_service.py @@ -0,0 +1,111 @@ +"""User service for user management operations.""" + +from sqlalchemy.orm import Session + +from app import models, schema +from app.base.exceptions import EntityNotFound +from app.security import hash_password + + +class UserService: + """Service for user management operations.""" + + def __init__(self, db: Session): + self.__db = db + + def get_current_user(self, user_id: int) -> models.User: + """ + Get current user by ID. + + Args: + user_id: User ID + + Returns: + User object + + Raises: + EntityNotFound: If user not found + """ + user = self.__db.query(schema.User).filter_by(id=user_id).first() + if not user: + raise EntityNotFound("User", user_id) + + return models.User( + id=user.id, + username=user.username, + email=user.email, + role=models.UserRole(user.role), + disabled=user.disabled, + ) + + def get_users(self) -> list[models.User]: + """ + Get all users. + + Returns: + List of User objects + """ + users = self.__db.query(schema.User).order_by(schema.User.id).all() + return [ + models.User( + id=user.id, + username=user.username, + email=user.email, + role=models.UserRole(user.role), + disabled=user.disabled, + ) + for user in users + ] + + def create_user(self, data: models.UserToCreate) -> models.User: + """ + Create a new user. + + Args: + data: User creation data + + Returns: + Created User object + """ + fields = data.model_dump() + fields["role"] = fields["role"].value + fields["password"] = hash_password(fields["password"]) + new_user = schema.User(**fields) + self.__db.add(new_user) + self.__db.commit() + return models.User( + id=new_user.id, + username=new_user.username, + email=new_user.email, + role=models.UserRole(new_user.role), + disabled=new_user.disabled, + ) + + def update_user( + self, user_id: int, data: models.UserFields + ) -> models.StatusMessage: + """ + Update an existing user. + + Args: + user_id: User ID to update + data: User fields to update + + Returns: + StatusMessage indicating success + + Raises: + EntityNotFound: If user not found + """ + user = self.__db.query(schema.User).filter_by(id=user_id).first() + if not user: + raise EntityNotFound("User", user_id) + + user.username = data.username + user.email = data.email + user.role = data.role.value + user.disabled = data.disabled + + self.__db.commit() + + return models.StatusMessage(message="ok") From 975d7a6528d922ebfaf491c087aec9e135f3aefc Mon Sep 17 00:00:00 2001 From: Denis Bezykornov Date: Tue, 13 Jan 2026 01:37:08 +0300 Subject: [PATCH 3/9] Rework auth part to services --- backend/app/routers/auth.py | 48 +++++++++++---------- backend/app/services/auth_service.py | 62 ++++++++++++++++++++++++++++ 2 files changed, 85 insertions(+), 25 deletions(-) create mode 100644 backend/app/services/auth_service.py diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py index 42e314d..99fa09b 100644 --- a/backend/app/routers/auth.py +++ b/backend/app/routers/auth.py @@ -2,11 +2,11 @@ from fastapi import APIRouter, Depends, HTTPException, Response, status from itsdangerous import URLSafeTimedSerializer -from sqlalchemy.orm import Session -from app import models, schema +from app import models +from app.base.exceptions import BusinessLogicError, UnauthorizedAccess from app.db import get_db -from app.security import verify_password +from app.services import AuthService from app.settings import settings from app.user.depends import has_user_role @@ -17,32 +17,30 @@ def login( data: models.AuthFields, response: Response, - db: Session = Depends(get_db), + db=Depends(get_db), ) -> models.StatusMessage: - if not data.password: - raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED) - - user = db.query(schema.User).filter_by(email=data.email).first() - - if not user or not verify_password(data.password, user.password): - raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED) - - if user.disabled: - raise HTTPException(status_code=status.HTTP_403_FORBIDDEN) - - serializer = URLSafeTimedSerializer(secret_key=settings.secret_key) - response.set_cookie( - "session", - serializer.dumps({"user_id": user.id}), - secure=bool(settings.domain_name), - domain=settings.domain_name, - httponly=True, - expires=datetime.now(UTC) + timedelta(days=21) if data.remember else None, - ) - return models.StatusMessage(message="Logged in") + service = AuthService(db) + try: + user = service.login(data) + # Set session cookie in router (HTTP-specific concern) + serializer = URLSafeTimedSerializer(secret_key=settings.secret_key) + response.set_cookie( + "session", + serializer.dumps({"user_id": user.id}), + secure=bool(settings.domain_name), + domain=settings.domain_name, + httponly=True, + expires=datetime.now(UTC) + timedelta(days=21) if data.remember else None, + ) + return models.StatusMessage(message="Logged in") + except UnauthorizedAccess as e: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=str(e)) + except BusinessLogicError as e: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(e)) @router.post("/logout", dependencies=[Depends(has_user_role)]) def logout(response: Response) -> models.StatusMessage: + # Logout doesn't need database access, just clear the cookie response.delete_cookie("session") return models.StatusMessage(message="Logged out") diff --git a/backend/app/services/auth_service.py b/backend/app/services/auth_service.py new file mode 100644 index 0000000..4979876 --- /dev/null +++ b/backend/app/services/auth_service.py @@ -0,0 +1,62 @@ +"""Authentication service for user login/logout operations.""" + +from itsdangerous import URLSafeTimedSerializer +from sqlalchemy.orm import Session + +from app import models, schema +from app.base.exceptions import BusinessLogicError, UnauthorizedAccess +from app.security import verify_password +from app.settings import settings + + +class AuthService: + """Service for authentication operations.""" + + def __init__(self, db: Session): + self.__db = db + + def login(self, data: models.AuthFields) -> schema.User: + """ + Authenticate user and return user information. + + Args: + data: Authentication fields (email, password) + + Returns: + User object if authentication succeeds + + Raises: + UnauthorizedAccess: If authentication fails or user is disabled + """ + if not data.password: + raise UnauthorizedAccess("Password is required") + + user = self.__db.query(schema.User).filter_by(email=data.email).first() + + if not user or not verify_password(data.password, user.password): + raise UnauthorizedAccess("Invalid email or password") + + if user.disabled: + raise BusinessLogicError("User account is disabled") + + return user + + def verify_session(self, session_token: str) -> schema.User | None: + """ + Verify session token and return user. + + Args: + session_token: Session token from cookie + + Returns: + User object if token is valid, None otherwise + """ + try: + serializer = URLSafeTimedSerializer(secret_key=settings.secret_key) + data = serializer.loads(session_token) + user_id = data.get("user_id") + if user_id: + return self.__db.query(schema.User).filter_by(id=user_id).first() + except Exception: + pass + return None From 23c026ef8416e296d6e9290e1a2c409223da7b02 Mon Sep 17 00:00:00 2001 From: Denis Bezykornov Date: Tue, 13 Jan 2026 02:05:34 +0300 Subject: [PATCH 4/9] Rework TM part to services --- backend/app/documents/query.py | 1 + backend/app/routers/translation_memory.py | 127 ++++------ .../services/translation_memory_service.py | 224 ++++++++++++++++++ 3 files changed, 266 insertions(+), 86 deletions(-) create mode 100644 backend/app/services/translation_memory_service.py diff --git a/backend/app/documents/query.py b/backend/app/documents/query.py index 7e01035..1f00ee5 100644 --- a/backend/app/documents/query.py +++ b/backend/app/documents/query.py @@ -237,6 +237,7 @@ def update_record( repeated_record.target = data.target repeated_record.approved = data.approved + # this should be better put on the service level, not here if data.approved is True: bound_tm = None for memory in record.document.memory_associations: diff --git a/backend/app/routers/translation_memory.py b/backend/app/routers/translation_memory.py index 128cb8c..3a1a079 100644 --- a/backend/app/routers/translation_memory.py +++ b/backend/app/routers/translation_memory.py @@ -1,14 +1,14 @@ -from typing import Annotated, Final +from typing import Annotated from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile, status from fastapi.responses import StreamingResponse from sqlalchemy.orm import Session +from app.base.exceptions import EntityNotFound from app.db import get_db -from app.formats.tmx import TmxData, TmxSegment, extract_tmx_content from app.models import StatusMessage -from app.translation_memory import models, schema -from app.translation_memory.query import TranslationMemoryQuery +from app.translation_memory import schema +from app.services import TranslationMemoryService from app.user.depends import get_current_user_id, has_user_role router = APIRouter( @@ -16,36 +16,23 @@ ) -def get_memory_by_id(db: Session, memory_id: int): - doc = TranslationMemoryQuery(db).get_memory(memory_id) - if not doc: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, detail="Memory not found" - ) - return doc - - @router.get("/") def get_memories( db: Annotated[Session, Depends(get_db)], ) -> list[schema.TranslationMemory]: - return [ - schema.TranslationMemory(id=doc.id, name=doc.name, created_by=doc.created_by) - for doc in TranslationMemoryQuery(db).get_memories() - ] + service = TranslationMemoryService(db) + return service.get_memories() @router.get("/{tm_id}") def get_memory( tm_id: int, db: Annotated[Session, Depends(get_db)] ) -> schema.TranslationMemoryWithRecordsCount: - doc = get_memory_by_id(db, tm_id) - return schema.TranslationMemoryWithRecordsCount( - id=doc.id, - name=doc.name, - created_by=doc.created_by, - records_count=TranslationMemoryQuery(db).get_memory_records_count(tm_id), - ) + service = TranslationMemoryService(db) + try: + return service.get_memory(tm_id) + except EntityNotFound as e: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e)) @router.get("/{tm_id}/records") @@ -55,19 +42,13 @@ def get_memory_records( page: Annotated[int | None, Query(ge=0)] = None, query: Annotated[str | None, Query()] = None, ) -> schema.TranslationMemoryListResponse: - page_records: Final = 100 + service = TranslationMemoryService(db) if not page: page = 0 - - get_memory_by_id(db, tm_id) - records, count = TranslationMemoryQuery(db).get_memory_records_paged( - tm_id, page, page_records, query - ) - return schema.TranslationMemoryListResponse( - records=records, - page=page, - total_records=count, - ) + try: + return service.get_memory_records(tm_id, page, query) + except EntityNotFound as e: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e)) @router.get("/{tm_id}/records/similar") @@ -76,18 +57,11 @@ def get_memory_records_similar( db: Annotated[Session, Depends(get_db)], query: Annotated[str, Query()], ) -> schema.TranslationMemoryListSimilarResponse: - page_records: Final = 20 - - get_memory_by_id(db, tm_id) - records = TranslationMemoryQuery(db).get_memory_records_paged_similar( - tm_id, page_records, query - ) - return schema.TranslationMemoryListSimilarResponse( - records=records, - page=0, - # this is incorrect in general case, but for 20 records is fine - total_records=len(records), - ) + service = TranslationMemoryService(db) + try: + return service.get_memory_records_similar(tm_id, query) + except EntityNotFound as e: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e)) @router.post("/upload") @@ -96,26 +70,11 @@ async def create_memory_from_file( db: Annotated[Session, Depends(get_db)], current_user: Annotated[int, Depends(get_current_user_id)], ) -> schema.TranslationMemory: - name = file.filename - tm_data = await file.read() - segments = extract_tmx_content(tm_data) - - doc = TranslationMemoryQuery(db).add_memory( - name or "", - current_user, - [ - models.TranslationMemoryRecord( - source=segment.original, - target=segment.translation, - creation_date=segment.creation_date, - change_date=segment.change_date, - ) - for segment in segments - ], + service = TranslationMemoryService(db) + return await service.create_memory_from_file( + file.filename, await file.read(), current_user ) - return schema.TranslationMemory(id=doc.id, name=doc.name, created_by=doc.created_by) - @router.post( "/", @@ -127,14 +86,17 @@ def create_translation_memory( db: Annotated[Session, Depends(get_db)], current_user: Annotated[int, Depends(get_current_user_id)], ): - doc = TranslationMemoryQuery(db).add_memory(settings.name, current_user, []) - return schema.TranslationMemory(id=doc.id, name=doc.name, created_by=doc.created_by) + service = TranslationMemoryService(db) + return service.create_memory(settings.name, current_user) @router.delete("/{tm_id}") def delete_memory(tm_id: int, db: Annotated[Session, Depends(get_db)]) -> StatusMessage: - TranslationMemoryQuery(db).delete_memory(get_memory_by_id(db, tm_id)) - return StatusMessage(message="Deleted") + service = TranslationMemoryService(db) + try: + return service.delete_memory(tm_id) + except EntityNotFound as e: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e)) @router.get( @@ -148,20 +110,13 @@ def delete_memory(tm_id: int, db: Annotated[Session, Depends(get_db)]) -> Status }, ) def download_memory(tm_id: int, db: Annotated[Session, Depends(get_db)]): - memory = get_memory_by_id(db, tm_id) - data = TmxData( - [ - TmxSegment( - original=record.source, - translation=record.target, - creation_date=record.creation_date, - change_date=record.change_date, - ) - for record in memory.records - ] - ) - return StreamingResponse( - data.write(), - media_type="application/octet-stream", - headers={"Content-Disposition": f'attachment; filename="{tm_id}.tmx"'}, - ) + service = TranslationMemoryService(db) + try: + data = service.download_memory(tm_id) + return StreamingResponse( + data.content, + media_type="application/octet-stream", + headers={"Content-Disposition": f'attachment; filename="{data.filename}"'}, + ) + except EntityNotFound as e: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e)) diff --git a/backend/app/services/translation_memory_service.py b/backend/app/services/translation_memory_service.py new file mode 100644 index 0000000..ef062c5 --- /dev/null +++ b/backend/app/services/translation_memory_service.py @@ -0,0 +1,224 @@ +"""Translation Memory service for TM operations.""" + +import io +from dataclasses import dataclass + +from sqlalchemy.orm import Session + +from app.formats.tmx import TmxData, TmxSegment, extract_tmx_content +from app.models import StatusMessage +from app.base.exceptions import EntityNotFound +from app.translation_memory import models, schema +from app.translation_memory.query import TranslationMemoryQuery + + +@dataclass +class DownloadMemoryData: + """Data for downloading a translation memory as TMX file.""" + + content: io.BytesIO + filename: str + + +class TranslationMemoryService: + """Service for translation memory operations.""" + + def __init__(self, db: Session): + self.__query = TranslationMemoryQuery(db) + + def get_memories(self) -> list[schema.TranslationMemory]: + """ + Get all translation memories. + + Returns: + List of TranslationMemory objects + """ + return [ + schema.TranslationMemory( + id=doc.id, name=doc.name, created_by=doc.created_by + ) + for doc in self.__query.get_memories() + ] + + def get_memory(self, tm_id: int) -> schema.TranslationMemoryWithRecordsCount: + """ + Get a translation memory by ID. + + Args: + tm_id: Translation memory ID + + Returns: + TranslationMemoryWithRecordsCount object + + Raises: + EntityNotFound: If memory not found + """ + doc = self._get_memory_by_id(tm_id) + return schema.TranslationMemoryWithRecordsCount( + id=doc.id, + name=doc.name, + created_by=doc.created_by, + records_count=self.__query.get_memory_records_count(tm_id), + ) + + def create_memory(self, name: str, user_id: int) -> schema.TranslationMemory: + """ + Create a new translation memory. + + Args: + name: Name for the translation memory + user_id: ID of user creating the memory + + Returns: + Created TranslationMemory object + """ + doc = self.__query.add_memory(name, user_id, []) + return schema.TranslationMemory( + id=doc.id, name=doc.name, created_by=doc.created_by + ) + + async def create_memory_from_file( + self, filename: str | None, content: bytes, user_id: int + ) -> schema.TranslationMemory: + """ + Create a translation memory from an uploaded TMX file. + + Args: + file: Uploaded file + user_id: ID of user creating the memory + + Returns: + Created TranslationMemory object + """ + segments = extract_tmx_content(content) + + doc = self.__query.add_memory( + filename or "", + user_id, + [ + models.TranslationMemoryRecord( + source=segment.original, + target=segment.translation, + creation_date=segment.creation_date, + change_date=segment.change_date, + ) + for segment in segments + ], + ) + + return schema.TranslationMemory( + id=doc.id, name=doc.name, created_by=doc.created_by + ) + + def delete_memory(self, tm_id: int) -> StatusMessage: + """ + Delete a translation memory. + + Args: + tm_id: Translation memory ID + + Returns: + StatusMessage indicating success + + Raises: + EntityNotFound: If memory not found + """ + memory = self._get_memory_by_id(tm_id) + self.__query.delete_memory(memory) + return StatusMessage(message="Deleted") + + def get_memory_records( + self, tm_id: int, page: int, query_str: str | None + ) -> schema.TranslationMemoryListResponse: + """ + Get records from a translation memory. + + Args: + tm_id: Translation memory ID + page: Page number + query_str: Optional search query + + Returns: + TranslationMemoryListResponse object + + Raises: + EntityNotFound: If memory not found + """ + page_records = 100 + self._get_memory_by_id(tm_id) + records, count = self.__query.get_memory_records_paged( + tm_id, page, page_records, query_str + ) + return schema.TranslationMemoryListResponse( + records=records, page=page, total_records=count + ) + + def get_memory_records_similar( + self, tm_id: int, query_str: str + ) -> schema.TranslationMemoryListSimilarResponse: + """ + Get similar records from a translation memory. + + Args: + tm_id: Translation memory ID + query_str: Search query + + Returns: + TranslationMemoryListSimilarResponse object + + Raises: + EntityNotFound: If memory not found + """ + page_records = 20 + self._get_memory_by_id(tm_id) + records = self.__query.get_memory_records_paged_similar( + tm_id, page_records, query_str + ) + return schema.TranslationMemoryListSimilarResponse( + records=records, page=0, total_records=len(records) + ) + + def download_memory(self, tm_id: int) -> DownloadMemoryData: + """ + Prepare translation memory data for download as TMX file. + + Args: + tm_id: Translation memory ID + + Returns: + DownloadMemoryData with content and filename + + Raises: + EntityNotFound: If memory not found + """ + memory = self._get_memory_by_id(tm_id) + data = TmxData( + [ + TmxSegment( + original=record.source, + translation=record.target, + creation_date=record.creation_date, + change_date=record.change_date, + ) + for record in memory.records + ] + ) + return DownloadMemoryData(content=data.write(), filename=f"{tm_id}.tmx") + + def _get_memory_by_id(self, tm_id: int) -> models.TranslationMemory: + """ + Get a translation memory by ID. + + Args: + tm_id: Translation memory ID + + Returns: + TranslationMemory object + + Raises: + EntityNotFound: If memory not found + """ + doc = self.__query.get_memory(tm_id) + if not doc: + raise EntityNotFound("Memory not found") + return doc From 62ef35aa12103c2666e228cac7d8e0b1d63034f1 Mon Sep 17 00:00:00 2001 From: Denis Bezykornov Date: Wed, 14 Jan 2026 00:28:38 +0300 Subject: [PATCH 5/9] Rework comments part to services --- backend/app/routers/comments.py | 56 +++++--------- backend/app/services/comment_service.py | 98 +++++++++++++++++++++++++ 2 files changed, 118 insertions(+), 36 deletions(-) create mode 100644 backend/app/services/comment_service.py diff --git a/backend/app/routers/comments.py b/backend/app/routers/comments.py index a936592..77150dc 100644 --- a/backend/app/routers/comments.py +++ b/backend/app/routers/comments.py @@ -4,10 +4,11 @@ from sqlalchemy.orm import Session from app import schema -from app.comments.query import CommentsQuery +from app.base.exceptions import EntityNotFound, UnauthorizedAccess from app.comments.schema import CommentResponse, CommentUpdate from app.db import get_db from app.models import StatusMessage +from app.services import CommentService from app.user.depends import get_current_user_id, has_user_role router = APIRouter( @@ -15,25 +16,6 @@ ) -def get_comment_by_id(db: Session, comment_id: int): - """Helper function to get comment by ID""" - comment = CommentsQuery(db).get_comment(comment_id) - if not comment: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, detail="Comment not found" - ) - return comment - - -def check_comment_authorship(comment, current_user_id: int, is_admin: bool = False): - """Check if user can modify/delete comment""" - if not is_admin and comment.created_by != current_user_id: - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail="You can only modify your own comments", - ) - - @router.put("/{comment_id}") def update_comment( comment_id: int, @@ -42,15 +24,16 @@ def update_comment( current_user: Annotated[int, Depends(get_current_user_id)], ) -> CommentResponse: """Update an existing comment""" - comment = get_comment_by_id(db, comment_id) - - # Check if user can modify this comment + service = CommentService(db) user = db.query(schema.User).filter_by(id=current_user).one() - is_admin = user.role == "admin" - check_comment_authorship(comment, current_user, is_admin) - - updated_comment = CommentsQuery(db).update_comment(comment_id, comment_data) - return CommentResponse.model_validate(updated_comment) + try: + return service.update_comment( + comment_id, comment_data, current_user, force=user.role == "admin" + ) + except EntityNotFound as e: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e)) + except UnauthorizedAccess as e: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(e)) @router.delete("/{comment_id}") @@ -60,12 +43,13 @@ def delete_comment( current_user: Annotated[int, Depends(get_current_user_id)], ) -> StatusMessage: """Delete a comment""" - comment = get_comment_by_id(db, comment_id) - - # Check if user can delete this comment + service = CommentService(db) user = db.query(schema.User).filter_by(id=current_user).one() - is_admin = user.role == "admin" - check_comment_authorship(comment, current_user, is_admin) - - CommentsQuery(db).delete_comment(comment_id) - return StatusMessage(message="Comment deleted successfully") + try: + return service.delete_comment( + comment_id, current_user, force=user.role == "admin" + ) + except EntityNotFound as e: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e)) + except UnauthorizedAccess as e: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(e)) diff --git a/backend/app/services/comment_service.py b/backend/app/services/comment_service.py new file mode 100644 index 0000000..49214e2 --- /dev/null +++ b/backend/app/services/comment_service.py @@ -0,0 +1,98 @@ +"""Comment service for comment management operations.""" + +from sqlalchemy.orm import Session + +from app.base.exceptions import EntityNotFound, UnauthorizedAccess +from app.comments.models import Comment +from app.comments.query import CommentsQuery +from app.comments.schema import CommentResponse, CommentUpdate +from app.models import StatusMessage + + +class CommentService: + """Service for comment management operations.""" + + def __init__(self, db: Session): + self.__query = CommentsQuery(db) + + def get_comment(self, comment_id: int) -> Comment: + """ + Get a comment by ID. + + Args: + comment_id: Comment ID + + Returns: + Comment object + + Raises: + EntityNotFound: If comment not found + """ + comment = self.__query.get_comment(comment_id) + if not comment: + raise EntityNotFound("Comment not found") + return comment + + def update_comment( + self, comment_id: int, data: CommentUpdate, user_id: int, force: bool + ) -> CommentResponse: + """ + Update an existing comment. + + Args: + comment_id: Comment ID + data: Comment update data + user_id: ID of user updating the comment + force: Whether to force updating + + Returns: + Updated CommentResponse object + + Raises: + EntityNotFound: If comment not found + UnauthorizedAccess: If user lacks permission + """ + comment = self.get_comment(comment_id) + self._check_authorship(comment, user_id, force) + + updated_comment = self.__query.update_comment(comment_id, data) + return CommentResponse.model_validate(updated_comment) + + def delete_comment( + self, comment_id: int, user_id: int, force: bool + ) -> StatusMessage: + """ + Delete a comment. + + Args: + comment_id: Comment ID + user_id: ID of user deleting the comment + force: Whether to force deletion + + Returns: + StatusMessage indicating success + + Raises: + EntityNotFound: If comment not found + UnauthorizedAccess: If user lacks permission + """ + comment = self.get_comment(comment_id) + self._check_authorship(comment, user_id, force) + + self.__query.delete_comment(comment_id) + return StatusMessage(message="Comment deleted successfully") + + def _check_authorship(self, comment: Comment, user_id: int, force: bool) -> None: + """ + Check if user can modify/delete comment. + + Args: + comment: Comment object + user_id: ID of user attempting the action + force: Whether to force the action + + Raises: + UnauthorizedAccess: If user lacks permission + """ + if not force and comment.created_by != user_id: + raise UnauthorizedAccess("You can only modify your own comments") From 092430009c22c3cc5d7a1c1d5b4e4cbe2bfa14b1 Mon Sep 17 00:00:00 2001 From: Denis Bezykornov Date: Wed, 14 Jan 2026 23:30:22 +0300 Subject: [PATCH 6/9] Rework glossary part to services --- backend/app/glossary/controllers.py | 98 ------ backend/app/glossary/query.py | 4 + backend/app/glossary/tasks.py | 68 +--- backend/app/routers/glossary.py | 167 +++++----- backend/app/services/glossary_service.py | 308 ++++++++++++++++++ .../tests/routers/test_routers_glossary.py | 59 ++++ 6 files changed, 470 insertions(+), 234 deletions(-) delete mode 100644 backend/app/glossary/controllers.py create mode 100644 backend/app/services/glossary_service.py diff --git a/backend/app/glossary/controllers.py b/backend/app/glossary/controllers.py deleted file mode 100644 index 65aa329..0000000 --- a/backend/app/glossary/controllers.py +++ /dev/null @@ -1,98 +0,0 @@ -import io - -import openpyxl -from fastapi import UploadFile -from sqlalchemy.orm import Session - -from app.glossary.query import ( - GlossaryQuery, - NotFoundGlossaryExc, - NotFoundGlossaryRecordExc, -) -from app.glossary.schema import ( - GlossaryRecordCreate, - GlossaryRecordSchema, - GlossaryRecordUpdate, - GlossaryResponse, - GlossarySchema, -) -from app.models import StatusMessage - - -def create_glossary_from_file_controller( - db: Session, file: UploadFile, user_id: int, glossary_name: str -): - content = file.file.read() - xlsx = io.BytesIO(content) - workbook = openpyxl.load_workbook(xlsx) - sheet = workbook["Sheet1"] - glossary_scheme = GlossarySchema(name=glossary_name) - glossary_doc = GlossaryQuery(db).create_glossary( - user_id=user_id, glossary=glossary_scheme - ) - return sheet, glossary_doc - - -def list_glossary_controller(db: Session): - glossaries = GlossaryQuery(db).list_glossary() - return [GlossaryResponse.model_validate(glossary) for glossary in glossaries] - - -def list_glossary_records_controller( - db: Session, - glossary_id: int, - page: int, - page_records: int, - search: str | None = None, -): - records, total_rows = GlossaryQuery(db).list_glossary_records( - glossary_id, page, page_records, search - ) - return [ - GlossaryRecordSchema.model_validate(record) for record in records - ], total_rows - - -def retrieve_glossary_controller(glossary_doc_id: int, db: Session): - try: - doc = GlossaryQuery(db).get_glossary(glossary_doc_id) - return GlossaryResponse.model_validate(doc) - except NotFoundGlossaryExc: - return None - - -def update_glossary_controller(glossary: GlossarySchema, glossary_id: int, db: Session): - try: - return GlossaryQuery(db).update_glossary(glossary_id, glossary) - except NotFoundGlossaryExc: - return None - - -def delete_glossary_controller(glossary_id: int, db: Session): - if GlossaryQuery(db).delete_glossary(glossary_id): - return StatusMessage(message="Deleted") - - -def update_glossary_record_controller( - record_id: int, record: GlossaryRecordUpdate, db: Session -): - try: - return GlossaryQuery(db).update_record(record_id, record) - except NotFoundGlossaryRecordExc: - return None - - -def create_glossary_record_controller( - user_id: int, glossary_id: int, record: GlossaryRecordCreate, db: Session -): - try: - return GlossaryQuery(db).create_glossary_record( - user_id=user_id, glossary_id=glossary_id, record=record - ) - except NotFoundGlossaryExc: - return None - - -def delete_glossary_record_controller(record_id: int, db: Session): - if GlossaryQuery(db).delete_record(record_id): - return StatusMessage(message="Deleted") diff --git a/backend/app/glossary/query.py b/backend/app/glossary/query.py index fc28435..63017bd 100644 --- a/backend/app/glossary/query.py +++ b/backend/app/glossary/query.py @@ -120,6 +120,10 @@ def create_glossary_record( record: GlossaryRecordCreate, glossary_id: int, ) -> GlossaryRecord: + # Check if glossary exists first + if not self.db.query(Glossary).filter(Glossary.id == glossary_id).first(): + raise NotFoundGlossaryExc() + glossary_record = GlossaryRecord( glossary_id=glossary_id, created_by=user_id, diff --git a/backend/app/glossary/tasks.py b/backend/app/glossary/tasks.py index 96184af..a0c057b 100644 --- a/backend/app/glossary/tasks.py +++ b/backend/app/glossary/tasks.py @@ -1,61 +1,19 @@ -from dataclasses import dataclass -from datetime import datetime -from typing import Optional, Self +"""Background tasks for glossary processing.""" from sqlalchemy.orm import Session -from app.glossary.models import GlossaryRecord -from app.glossary.query import GlossaryQuery -from app.linguistic.utils import postprocess_stemmed_segment, stem_sentence - - -@dataclass -class GlossaryRowRecord: - comment: Optional[str] - created_at: datetime - author: str - updated_at: datetime - source: str - target: str - - @classmethod - def from_tuple(cls, data_tuple: tuple[str, ...]) -> Self: - comment, created_at, author, updated_at, _, source, target = data_tuple - created_at = datetime.strptime(created_at, "%m/%d/%Y %H:%M:%S") - updated_at = datetime.strptime(updated_at, "%m/%d/%Y %H:%M:%S") - return cls(comment, created_at, author, updated_at, source, target) +from app.services import GlossaryService def create_glossary_from_file_tasks(user_id: int, sheet, db: Session, glossary_id: int): - record_for_save = extract_from_xlsx(user_id, sheet, glossary_id) - bulk_save_glossaries_update_processing_status( - db=db, record_for_save=record_for_save, glossary_id=glossary_id - ) - - -def extract_from_xlsx(user_id: int, sheet, glossary_id: int) -> list[GlossaryRecord]: - record_for_save = [] - for cells in sheet.iter_rows(min_row=2, values_only=True): - parsed_record = GlossaryRowRecord.from_tuple(cells) - record = GlossaryRecord( - created_by=user_id, - created_at=parsed_record.created_at, - updated_at=parsed_record.updated_at, - comment=parsed_record.comment, - source=parsed_record.source, - target=parsed_record.target, - glossary_id=glossary_id, - stemmed_source=" ".join( - postprocess_stemmed_segment(stem_sentence(parsed_record.source)) - ), - ) - record_for_save.append(record) - return record_for_save - - -def bulk_save_glossaries_update_processing_status( - db: Session, record_for_save: list[GlossaryRecord], glossary_id: int -): - glossary_query = GlossaryQuery(db) - glossary_query.bulk_create_glossary_record(record_for_save) - glossary_query.update_glossary_processing_status(glossary_id) + """ + Background task to process glossary file and create records. + + Args: + user_id: ID of user who uploaded the file + sheet: XLSX sheet object + db: Database session + glossary_id: Glossary ID to add records to + """ + service = GlossaryService(db) + service.process_glossary_file(sheet, user_id, glossary_id) diff --git a/backend/app/routers/glossary.py b/backend/app/routers/glossary.py index c3527b5..2c98f56 100644 --- a/backend/app/routers/glossary.py +++ b/backend/app/routers/glossary.py @@ -11,20 +11,9 @@ ) from sqlalchemy.orm import Session +from app.base.exceptions import EntityNotFound from app.db import get_db -from app.glossary.controllers import ( - create_glossary_from_file_controller, - create_glossary_record_controller, - delete_glossary_controller, - delete_glossary_record_controller, - list_glossary_controller, - list_glossary_records_controller, - retrieve_glossary_controller, - update_glossary_controller, - update_glossary_record_controller, -) from app.glossary.models import ProcessingStatuses -from app.glossary.query import GlossaryQuery from app.glossary.schema import ( GlossaryLoadFileResponse, GlossaryRecordCreate, @@ -36,6 +25,7 @@ ) from app.glossary.tasks import create_glossary_from_file_tasks from app.models import StatusMessage +from app.services import GlossaryService from app.user.depends import get_current_user_id, has_user_role router = APIRouter( @@ -45,12 +35,13 @@ @router.get( "/", - description="Get list glossary", + description="Get a glossary list", response_model=list[GlossaryResponse], status_code=status.HTTP_200_OK, ) -def list_glossary(db: Session = Depends(get_db)): - return list_glossary_controller(db) +def list_glossary(db: Annotated[Session, Depends(get_db)]): + service = GlossaryService(db) + return service.list_glossaries() @router.get( @@ -62,18 +53,20 @@ def list_glossary(db: Session = Depends(get_db)): 404: { "description": "Glossary requested by id", "content": { - "application/json": {"example": {"detail": "Glossary id: 1, not found"}} + "application/json": {"example": {"detail": "Glossary with id 1 not found"}} }, }, }, ) -def retrieve_glossary(glossary_id: int, db: Session = Depends(get_db)): - if response := retrieve_glossary_controller(glossary_id, db): - return response - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail=f"Glossary id:{glossary_id}, not found", - ) +def retrieve_glossary(glossary_id: int, db: Annotated[Session, Depends(get_db)]): + service = GlossaryService(db) + try: + return service.get_glossary(glossary_id) + except EntityNotFound as e: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=str(e), + ) @router.post( @@ -84,11 +77,12 @@ def retrieve_glossary(glossary_id: int, db: Session = Depends(get_db)): ) def create_glossary( glossary: GlossarySchema, - user_id: int = Depends(get_current_user_id), - db: Session = Depends(get_db), + user_id: Annotated[int, Depends(get_current_user_id)], + db: Annotated[Session, Depends(get_db)], ): - return GlossaryQuery(db).create_glossary( - glossary=glossary, + service = GlossaryService(db) + return service.create_glossary( + data=glossary, processing_status=ProcessingStatuses.DONE, user_id=user_id, ) @@ -103,22 +97,22 @@ def create_glossary( 404: { "description": "Glossary requested by id", "content": { - "application/json": {"example": {"detail": "Glossary id: 1, not found"}} + "application/json": {"example": {"detail": "Glossary with id 1 not found"}} }, }, }, ) def update_glossary( - glossary_id: int, glossary: GlossarySchema, db: Session = Depends(get_db) + glossary_id: int, glossary: GlossarySchema, db: Annotated[Session, Depends(get_db)] ): - if response := update_glossary_controller( - db=db, glossary_id=glossary_id, glossary=glossary - ): - return response - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail=f"Glossary id:{glossary_id}, not found", - ) + service = GlossaryService(db) + try: + return service.update_glossary(glossary_id, glossary) + except EntityNotFound as e: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=str(e), + ) @router.delete( @@ -130,18 +124,20 @@ def update_glossary( 404: { "description": "Glossary requested by id", "content": { - "application/json": {"example": {"detail": "Glossary id: 1, not found"}} + "application/json": {"example": {"detail": "Glossary with id 1 not found"}} }, }, }, ) -def delete_glossary(glossary_id: int, db: Session = Depends(get_db)): - if response := delete_glossary_controller(glossary_id, db): - return response - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail=f"Glossary id:{glossary_id}, not found", - ) +def delete_glossary(glossary_id: int, db: Annotated[Session, Depends(get_db)]): + service = GlossaryService(db) + try: + return service.delete_glossary(glossary_id) + except EntityNotFound as e: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=str(e), + ) @router.get( @@ -152,7 +148,7 @@ def delete_glossary(glossary_id: int, db: Session = Depends(get_db)): ) def list_records( glossary_id: int, - db: Session = Depends(get_db), + db: Annotated[Session, Depends(get_db)], page: Annotated[int | None, Query(ge=0)] = None, search: Annotated[str | None, Query()] = None, ): @@ -160,10 +156,14 @@ def list_records( if not page: page = 0 - records, total_rows = list_glossary_records_controller( - db, glossary_id, page, page_records, search - ) - return GlossaryRecordResponse(records=records, total_rows=total_rows) + service = GlossaryService(db) + try: + return service.list_glossary_records(glossary_id, page, page_records, search) + except EntityNotFound as e: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=str(e), + ) @router.post( @@ -175,15 +175,17 @@ def list_records( def create_glossary_record( glossary_id: int, record: GlossaryRecordCreate, - user_id: int = Depends(get_current_user_id), - db: Session = Depends(get_db), + user_id: Annotated[int, Depends(get_current_user_id)], + db: Annotated[Session, Depends(get_db)], ): - if response := create_glossary_record_controller(user_id, glossary_id, record, db): - return response - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail=f"Glossary id:{glossary_id}, not found", - ) + service = GlossaryService(db) + try: + return service.create_glossary_record(glossary_id, record, user_id) + except EntityNotFound as e: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=str(e), + ) @router.put( @@ -195,22 +197,24 @@ def create_glossary_record( 404: { "description": "Glossary record requested by id", "content": { - "application/json": { - "example": {"detail": "Glossary record id: 1, not found"} - } + "application/json": {"example": {"detail": "Glossary record with id 1 not found"}} }, }, }, ) def update_glossary_record( - record_id: int, record: GlossaryRecordUpdate, db: Session = Depends(get_db) + record_id: int, + record: GlossaryRecordUpdate, + db: Annotated[Session, Depends(get_db)], ): - if response := update_glossary_record_controller(record_id, record, db): - return response - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail=f"Glossary record id:{record_id}, not found", - ) + service = GlossaryService(db) + try: + return service.update_glossary_record(record_id, record) + except EntityNotFound as e: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=str(e), + ) @router.delete( @@ -222,20 +226,20 @@ def update_glossary_record( 404: { "description": "Glossary record requested by id", "content": { - "application/json": { - "example": {"detail": "Glossary record id: 1, not found"} - } + "application/json": {"example": {"detail": "Glossary record with id 1 not found"}} }, }, }, ) -def delete_glossary_record(record_id: int, db: Session = Depends(get_db)): - if response := delete_glossary_record_controller(record_id, db): - return response - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail=f"Glossary record id:{record_id}, not found", - ) +def delete_glossary_record(record_id: int, db: Annotated[Session, Depends(get_db)]): + service = GlossaryService(db) + try: + return service.delete_glossary_record(record_id) + except EntityNotFound as e: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=str(e), + ) @router.post( @@ -249,10 +253,11 @@ def create_glossary_from_file( glossary_name: str, background_tasks: BackgroundTasks, file: UploadFile, - db: Session = Depends(get_db), + db: Annotated[Session, Depends(get_db)], ): - sheet, glossary = create_glossary_from_file_controller( - db=db, file=file, user_id=user_id, glossary_name=glossary_name + service = GlossaryService(db) + sheet, glossary = service.create_glossary_from_file( + file=file, user_id=user_id, glossary_name=glossary_name ) background_tasks.add_task( create_glossary_from_file_tasks, diff --git a/backend/app/services/glossary_service.py b/backend/app/services/glossary_service.py new file mode 100644 index 0000000..edef80c --- /dev/null +++ b/backend/app/services/glossary_service.py @@ -0,0 +1,308 @@ +"""Glossary service for glossary and glossary record operations.""" + +from datetime import datetime +import io +from typing import Optional, Self + + +import openpyxl +from dataclasses import dataclass +from fastapi import UploadFile +from sqlalchemy.orm import Session + +from app import Glossary, GlossaryRecord +from app.base.exceptions import EntityNotFound +from app.glossary.models import ProcessingStatuses +from app.glossary.query import ( + GlossaryQuery, + NotFoundGlossaryExc, + NotFoundGlossaryRecordExc, +) +from app.glossary.schema import ( + GlossaryRecordCreate, + GlossaryRecordResponse, + GlossaryRecordSchema, + GlossaryRecordUpdate, + GlossaryResponse, + GlossarySchema, +) +from app.linguistic.utils import postprocess_stemmed_segment, stem_sentence +from app.models import StatusMessage + + +class GlossaryService: + """Service for glossary and glossary record operations.""" + + def __init__(self, db: Session): + self.__query = GlossaryQuery(db) + + def list_glossaries(self) -> list[GlossaryResponse]: + """ + Get list of all glossaries. + + Returns: + List of GlossaryResponse objects + """ + glossaries = self.__query.list_glossary() + return [GlossaryResponse.model_validate(glossary) for glossary in glossaries] + + def get_glossary(self, glossary_id: int) -> GlossaryResponse: + """ + Get a single glossary by ID. + + Args: + glossary_id: Glossary ID + + Returns: + GlossaryResponse object + + Raises: + EntityNotFound: If glossary not found + """ + try: + doc = self.__query.get_glossary(glossary_id) + return GlossaryResponse.model_validate(doc) + except NotFoundGlossaryExc: + raise EntityNotFound("Glossary", glossary_id) + + def create_glossary( + self, + data: GlossarySchema, + user_id: int, + processing_status: str = ProcessingStatuses.DONE, + ) -> GlossaryResponse: + """ + Create a new glossary. + + Args: + data: Glossary schema data + user_id: ID of user creating the glossary + processing_status: Initial processing status + + Returns: + Created GlossaryResponse object + """ + glossary = self.__query.create_glossary( + user_id=user_id, glossary=data, processing_status=processing_status + ) + return GlossaryResponse.model_validate(glossary) + + def update_glossary( + self, glossary_id: int, data: GlossarySchema + ) -> GlossaryResponse: + """ + Update a glossary. + + Args: + glossary_id: Glossary ID + data: Updated glossary schema data + + Returns: + Updated GlossaryResponse object + + Raises: + EntityNotFound: If glossary not found + """ + try: + glossary = self.__query.update_glossary(glossary_id, data) + return GlossaryResponse.model_validate(glossary) + except NotFoundGlossaryExc: + raise EntityNotFound("Glossary", glossary_id) + + def delete_glossary(self, glossary_id: int) -> StatusMessage: + """ + Delete a glossary. + + Args: + glossary_id: Glossary ID + + Returns: + StatusMessage indicating success + + Raises: + EntityNotFound: If glossary not found + """ + if not self.__query.delete_glossary(glossary_id): + raise EntityNotFound("Glossary", glossary_id) + return StatusMessage(message="Deleted") + + def list_glossary_records( + self, + glossary_id: int, + page: int, + page_records: int, + search: str | None = None, + ) -> GlossaryRecordResponse: + """ + Get list of glossary records for a glossary. + + Args: + glossary_id: Glossary ID + page: Page number + page_records: Number of records per page + search: Optional search query + + Returns: + GlossaryRecordResponse object with records and total count + """ + try: + self.__query.get_glossary(glossary_id) + except NotFoundGlossaryExc: + raise EntityNotFound("Glossary", glossary_id) + records, total_rows = self.__query.list_glossary_records( + glossary_id, page, page_records, search + ) + return GlossaryRecordResponse( + records=[GlossaryRecordSchema.model_validate(record) for record in records], + total_rows=total_rows, + ) + + def create_glossary_record( + self, glossary_id: int, data: GlossaryRecordCreate, user_id: int + ) -> GlossaryRecordSchema: + """ + Create a new glossary record. + + Args: + glossary_id: Glossary ID + data: Glossary record creation data + user_id: ID of user creating the record + + Returns: + Created GlossaryRecordSchema object + + Raises: + EntityNotFound: If glossary not found + """ + try: + record = self.__query.create_glossary_record( + user_id=user_id, glossary_id=glossary_id, record=data + ) + return GlossaryRecordSchema.model_validate(record) + except NotFoundGlossaryExc: + raise EntityNotFound("Glossary not found") + + def update_glossary_record( + self, record_id: int, data: GlossaryRecordUpdate + ) -> GlossaryRecordSchema: + """ + Update a glossary record. + + Args: + record_id: Record ID + data: Updated glossary record data + + Returns: + Updated GlossaryRecordSchema object + + Raises: + EntityNotFound: If record not found + """ + try: + record = self.__query.update_record(record_id, data) + return GlossaryRecordSchema.model_validate(record) + except NotFoundGlossaryRecordExc: + raise EntityNotFound("Glossary record", record_id) + + def delete_glossary_record(self, record_id: int) -> StatusMessage: + """ + Delete a glossary record. + + Args: + record_id: Record ID + + Returns: + StatusMessage indicating success + + Raises: + EntityNotFound: If record not found + """ + if not self.__query.delete_record(record_id): + raise EntityNotFound("Glossary record", record_id) + return StatusMessage(message="Deleted") + + def create_glossary_from_file( + self, file: UploadFile, user_id: int, glossary_name: str + ) -> tuple[object, Glossary]: + """ + Create a glossary from an uploaded XLSX file. + + Args: + file: Uploaded file + user_id: ID of user creating the glossary + glossary_name: Name for the glossary + + Returns: + Tuple of (workbook sheet, created glossary) + """ + content = file.file.read() + xlsx = io.BytesIO(content) + workbook = openpyxl.load_workbook(xlsx) + sheet = workbook["Sheet1"] + glossary_scheme = GlossarySchema(name=glossary_name) + glossary_doc = self.__query.create_glossary( + user_id=user_id, glossary=glossary_scheme + ) + return sheet, glossary_doc + + def process_glossary_file(self, sheet, user_id: int, glossary_id: int) -> None: + """ + Process glossary file and create records. + + Args: + sheet: XLSX sheet object + user_id: ID of user who uploaded the file + glossary_id: Glossary ID to add records to + """ + record_for_save = self._extract_from_xlsx(user_id, sheet, glossary_id) + self.__query.bulk_create_glossary_record(record_for_save) + self.__query.update_glossary_processing_status(glossary_id) + + def _extract_from_xlsx( + self, user_id: int, sheet, glossary_id: int + ) -> list[GlossaryRecord]: + """ + Extract glossary records from XLSX sheet. + + Args: + user_id: ID of user who uploaded the file + sheet: XLSX sheet object + glossary_id: Glossary ID to add records to + + Returns: + List of GlossaryRecord objects + """ + + @dataclass + class GlossaryRowRecord: + comment: Optional[str] + created_at: datetime + author: str + updated_at: datetime + source: str + target: str + + @classmethod + def from_tuple(cls, data_tuple: tuple[str, ...]) -> Self: + comment, created_at, author, updated_at, _, source, target = data_tuple + created_at = datetime.strptime(created_at, "%m/%d/%Y %H:%M:%S") + updated_at = datetime.strptime(updated_at, "%m/%d/%Y %H:%M:%S") + return cls(comment, created_at, author, updated_at, source, target) + + record_for_save = [] + for cells in sheet.iter_rows(min_row=2, values_only=True): + parsed_record = GlossaryRowRecord.from_tuple(cells) + record = GlossaryRecord( + created_by=user_id, + created_at=parsed_record.created_at, + updated_at=parsed_record.updated_at, + comment=parsed_record.comment, + source=parsed_record.source, + target=parsed_record.target, + glossary_id=glossary_id, + stemmed_source=" ".join( + postprocess_stemmed_segment(stem_sentence(parsed_record.source)) + ), + ) + record_for_save.append(record) + return record_for_save diff --git a/backend/tests/routers/test_routers_glossary.py b/backend/tests/routers/test_routers_glossary.py index 81e0292..9632f01 100644 --- a/backend/tests/routers/test_routers_glossary.py +++ b/backend/tests/routers/test_routers_glossary.py @@ -378,3 +378,62 @@ def test_create_glossary_record(user_logged_client: TestClient, session: Session assert response_json["glossary_id"] == glossary_id assert repo.get_glossary_record_by_id(response_json["id"]).stemmed_source == "test" + + +def test_get_glossary_returns_404_for_nonexistent_glossary(user_logged_client: TestClient): + """GET /glossary/{glossary_id}/ - 404 for non-existent glossary""" + response = user_logged_client.get("/glossary/999") + assert response.status_code == status.HTTP_404_NOT_FOUND + assert response.json()["detail"] == "Glossary with id 999 not found" + + +def test_update_glossary_returns_404_for_nonexistent_glossary(user_logged_client: TestClient): + """PUT /glossary/{glossary_id}/ - 404 for non-existent glossary""" + response = user_logged_client.put("/glossary/999", json={"name": "Updated name"}) + assert response.status_code == status.HTTP_404_NOT_FOUND + assert response.json()["detail"] == "Glossary with id 999 not found" + + +def test_delete_glossary_returns_404_for_nonexistent_glossary(user_logged_client: TestClient): + """DELETE /glossary/{glossary_id}/ - 404 for non-existent glossary""" + response = user_logged_client.delete("/glossary/999") + assert response.status_code == status.HTTP_404_NOT_FOUND + assert response.json()["detail"] == "Glossary with id 999 not found" + + +def test_list_glossary_records_returns_404_for_nonexistent_glossary(user_logged_client: TestClient): + """GET /glossary/{glossary_id}/records/ - 404 for non-existent glossary""" + response = user_logged_client.get("/glossary/999/records") + assert response.status_code == status.HTTP_404_NOT_FOUND + assert response.json()["detail"] == "Glossary with id 999 not found" + + +def test_create_glossary_record_returns_404_for_nonexistent_glossary(user_logged_client: TestClient): + """POST /glossary/{glossary_id}/records - 404 for non-existent glossary""" + record_data = { + "comment": "Test comment", + "source": "Test source", + "target": "Test target", + } + response = user_logged_client.post("/glossary/999/records", json=record_data) + assert response.status_code == status.HTTP_404_NOT_FOUND + assert response.json()["detail"] == "Glossary not found" + + +def test_update_glossary_record_returns_404_for_nonexistent_record(user_logged_client: TestClient): + """PUT /glossary/records/{record_id}/ - 404 for non-existent record""" + record_data = { + "comment": "Updated comment", + "source": "Updated source", + "target": "Updated target", + } + response = user_logged_client.put("/glossary/records/999", json=record_data) + assert response.status_code == status.HTTP_404_NOT_FOUND + assert response.json()["detail"] == "Glossary record with id 999 not found" + + +def test_delete_glossary_record_returns_404_for_nonexistent_record(user_logged_client: TestClient): + """DELETE /glossary/records/{record_id}/ - 404 for non-existent record""" + response = user_logged_client.delete("/glossary/records/999") + assert response.status_code == status.HTTP_404_NOT_FOUND + assert response.json()["detail"] == "Glossary record with id 999 not found" From 3286dc80a7a54ec9f1d6b08a215a50a4f9e72383 Mon Sep 17 00:00:00 2001 From: Denis Bezykornov Date: Thu, 15 Jan 2026 00:08:56 +0300 Subject: [PATCH 7/9] Rework document part to services --- backend/app/routers/document.py | 463 ++++----------- backend/app/services/document_service.py | 694 +++++++++++++++++++++++ 2 files changed, 792 insertions(+), 365 deletions(-) create mode 100644 backend/app/services/document_service.py diff --git a/backend/app/routers/document.py b/backend/app/routers/document.py index e9e35e9..95565ee 100644 --- a/backend/app/routers/document.py +++ b/backend/app/routers/document.py @@ -1,30 +1,18 @@ -from datetime import datetime, timedelta from typing import Annotated from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile, status from fastapi.responses import StreamingResponse from sqlalchemy.orm import Session -from app import models, schema -from app.comments.query import CommentsQuery +from app import models +from app.base.exceptions import BusinessLogicError, EntityNotFound from app.comments.schema import CommentCreate, CommentResponse from app.db import get_db from app.documents import schema as doc_schema -from app.documents.models import ( - Document, - DocumentType, - TmMode, - XliffRecord, -) -from app.documents.query import GenericDocsQuery, NotFoundDocumentRecordExc -from app.formats.txt import extract_txt_content -from app.formats.xliff import SegmentState, extract_xliff_content -from app.glossary.query import GlossaryQuery, NotFoundGlossaryExc -from app.glossary.schema import GlossaryRecordSchema, GlossaryResponse -from app.translation_memory.query import TranslationMemoryQuery +from app.glossary.schema import GlossaryRecordSchema +from app.services import DocumentService from app.translation_memory.schema import ( MemorySubstitution, - TranslationMemory, TranslationMemoryListResponse, TranslationMemoryListSimilarResponse, ) @@ -35,68 +23,23 @@ ) -def get_doc_by_id(db: Session, document_id: int) -> Document: - doc = GenericDocsQuery(db).get_document(document_id) - if not doc: - raise HTTPException(status_code=404, detail="Document not found") - return doc - - -def get_doc_record_by_id(db: Session, record_id: int): - """Helper function to get document record by ID""" - record = GenericDocsQuery(db).get_record(record_id) - if not record: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, detail="Document record not found" - ) - return record - - @router.get("/") def get_docs( db: Annotated[Session, Depends(get_db)], ) -> list[doc_schema.DocumentWithRecordsCount]: - query = GenericDocsQuery(db) - docs = query.get_documents_list() - output = [] - for doc in docs: - records = query.get_document_records_count_with_approved(doc) - words = query.get_document_word_count_with_approved(doc) - output.append( - doc_schema.DocumentWithRecordsCount( - id=doc.id, - name=doc.name, - status=models.DocumentStatus(doc.processing_status), - created_by=doc.created_by, - type=doc.type.value, - approved_records_count=records[0], - records_count=records[1], - approved_word_count=words[0], - total_word_count=words[1], - ) - ) - return output + service = DocumentService(db) + return service.get_documents() @router.get("/{doc_id}") def get_doc( doc_id: int, db: Annotated[Session, Depends(get_db)] ) -> doc_schema.DocumentWithRecordsCount: - doc = get_doc_by_id(db, doc_id) - query = GenericDocsQuery(db) - records = query.get_document_records_count_with_approved(doc) - words = query.get_document_word_count_with_approved(doc) - return doc_schema.DocumentWithRecordsCount( - id=doc.id, - name=doc.name, - status=models.DocumentStatus(doc.processing_status), - created_by=doc.created_by, - type=doc.type.value, - approved_records_count=records[0], - records_count=records[1], - approved_word_count=words[0], - total_word_count=words[1], - ) + service = DocumentService(db) + try: + return service.get_document(doc_id) + except EntityNotFound as e: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e)) @router.get("/{doc_id}/records") @@ -120,30 +63,11 @@ def get_doc_records( source_filter=source, target_filter=target ) - doc = get_doc_by_id(db, doc_id) - query = GenericDocsQuery(db) - total_records = query.get_document_records_count_filtered(doc, filters) - records = query.get_document_records_paged(doc, page, filters=filters) - record_list = [ - doc_schema.DocumentRecord( - id=record.id, - source=record.source, - target=record.target, - approved=record.approved, - repetitions_count=repetitions_count, - has_comments=has_comments, - translation_src=( - record.target_source.value if record.target_source else None - ), - ) - for record, repetitions_count, has_comments in records - ] - - return doc_schema.DocumentRecordListResponse( - records=record_list, - page=page, - total_records=total_records, - ) + service = DocumentService(db) + try: + return service.get_document_records(doc_id, page, filters) + except EntityNotFound as e: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e)) @router.get("/{doc_id}/glossary_search") @@ -152,18 +76,11 @@ def doc_glossary_search( db: Annotated[Session, Depends(get_db)], query: Annotated[str, Query()], ) -> list[GlossaryRecordSchema]: - doc = get_doc_by_id(db, doc_id) - glossary_ids = [gl.id for gl in doc.glossaries] - return ( - [ - GlossaryRecordSchema.model_validate(record) - for record in GlossaryQuery(db).get_glossary_records_for_phrase( - query, glossary_ids - ) - ] - if glossary_ids - else [] - ) + service = DocumentService(db) + try: + return service.doc_glossary_search(doc_id, query) + except EntityNotFound as e: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e)) @router.get("/records/{record_id}/comments") @@ -172,11 +89,11 @@ def get_comments( db: Annotated[Session, Depends(get_db)], ) -> list[CommentResponse]: """Get all comments for a document record""" - # Verify document record exists - get_doc_record_by_id(db, record_id) - - comments = CommentsQuery(db).get_comments_by_document_record(record_id) - return [CommentResponse.model_validate(comment) for comment in comments] + service = DocumentService(db) + try: + return service.get_comments(record_id) + except EntityNotFound as e: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e)) @router.post("/records/{record_id}/comments") @@ -187,52 +104,33 @@ def create_comment( current_user: Annotated[int, Depends(get_current_user_id)], ) -> CommentResponse: """Create a new comment for a document record""" - # Verify document record exists - get_doc_record_by_id(db, record_id) - - comment = CommentsQuery(db).create_comment(comment_data, current_user, record_id) - return CommentResponse.model_validate(comment) + service = DocumentService(db) + try: + return service.create_comment(record_id, comment_data, current_user) + except EntityNotFound as e: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e)) @router.get("/records/{record_id}/substitutions") def get_record_substitutions( record_id: int, db: Annotated[Session, Depends(get_db)] ) -> list[MemorySubstitution]: - original_segment = GenericDocsQuery(db).get_record(record_id) - if not original_segment: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, detail="Segment not found" - ) - - tm_ids = [tm.id for tm in original_segment.document.memories] - return ( - TranslationMemoryQuery(db).get_substitutions(original_segment.source, tm_ids) - if tm_ids - else [] - ) + service = DocumentService(db) + try: + return service.get_record_substitutions(record_id) + except EntityNotFound as e: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e)) @router.get("/records/{record_id}/glossary_records") def get_record_glossary_records( record_id: int, db: Annotated[Session, Depends(get_db)] ) -> list[GlossaryRecordSchema]: - original_segment = GenericDocsQuery(db).get_record(record_id) - if not original_segment: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, detail="Segment not found" - ) - - glossary_ids = [gl.id for gl in original_segment.document.glossaries] - return ( - [ - GlossaryRecordSchema.model_validate(record) - for record in GlossaryQuery(db).get_glossary_records_for_phrase( - original_segment.source, glossary_ids - ) - ] - if glossary_ids - else [] - ) + service = DocumentService(db) + try: + return service.get_record_glossary_records(record_id) + except EntityNotFound as e: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e)) @router.put("/records/{record_id}") @@ -241,36 +139,22 @@ def update_doc_record( record: doc_schema.DocumentRecordUpdate, db: Annotated[Session, Depends(get_db)], ) -> doc_schema.DocumentRecordUpdateResponse: + service = DocumentService(db) try: - updated_record = GenericDocsQuery(db).update_record(record_id, record) - return doc_schema.DocumentRecordUpdateResponse( - id=updated_record.id, - source=updated_record.source, - target=updated_record.target, - approved=updated_record.approved, - ) - except NotFoundDocumentRecordExc as e: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, detail="Record not found" - ) from e + return service.update_record(record_id, record) + except EntityNotFound as e: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e)) @router.get("/{doc_id}/memories") def get_translation_memories( doc_id: int, db: Annotated[Session, Depends(get_db)] ) -> list[doc_schema.DocTranslationMemory]: - return [ - doc_schema.DocTranslationMemory( - document_id=doc_id, - memory=TranslationMemory( - id=association.memory.id, - name=association.memory.name, - created_by=association.memory.created_by, - ), - mode=association.mode, - ) - for association in get_doc_by_id(db, doc_id).memory_associations - ] + service = DocumentService(db) + try: + return service.get_translation_memories(doc_id) + except EntityNotFound as e: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e)) @router.post("/{doc_id}/memories") @@ -279,38 +163,13 @@ def set_translation_memories( settings: doc_schema.DocTranslationMemoryUpdate, db: Annotated[Session, Depends(get_db)], ) -> models.StatusMessage: - # check writes count - write_count = 0 - for memory in settings.memories: - write_count += memory.mode == TmMode.write - - if write_count > 1: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="Not all memories were found", - ) - - # check that all memories are available - doc = get_doc_by_id(db, doc_id) - memory_ids = {memory.id for memory in settings.memories} - memories = list(TranslationMemoryQuery(db).get_memories_by_id(memory_ids)) - if len(memory_ids) != len(memories): - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail="Not all memories were found", - ) - - def find_memory(id_: int, memories): - for memory in memories: - if memory.id == id_: - return memory - return None - - mem_to_mode = [ - (find_memory(memory.id, memories), memory.mode) for memory in settings.memories - ] - GenericDocsQuery(db).set_document_memories(doc, mem_to_mode) - return models.StatusMessage(message="Memory list updated") + service = DocumentService(db) + try: + return service.set_translation_memories(doc_id, settings) + except EntityNotFound as e: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e)) + except BusinessLogicError as e: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) @router.get("/{doc_id}/tm/exact") @@ -319,24 +178,11 @@ def search_tm_exact( db: Annotated[Session, Depends(get_db)], source: Annotated[str, Query(description="Source text to search for")], ) -> TranslationMemoryListResponse: - doc = get_doc_by_id(db, doc_id) - tm_ids = [tm.id for tm in doc.memories] - - if not tm_ids: - return TranslationMemoryListResponse(records=[], page=0, total_records=0) - - records, count = TranslationMemoryQuery(db).get_memory_records_paged( - memory_ids=tm_ids, - page=0, - page_records=20, - query=source, - ) - - return TranslationMemoryListResponse( - records=records, - page=0, - total_records=count, - ) + service = DocumentService(db) + try: + return service.search_tm_exact(doc_id, source) + except EntityNotFound as e: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e)) @router.get("/{doc_id}/tm/similar") @@ -345,37 +191,22 @@ def search_tm_similar( db: Annotated[Session, Depends(get_db)], source: Annotated[str, Query(description="Source text to search for")], ) -> TranslationMemoryListSimilarResponse: - doc = get_doc_by_id(db, doc_id) - tm_ids = [tm.id for tm in doc.memories] - - if not tm_ids: - return TranslationMemoryListSimilarResponse(records=[], page=0, total_records=0) - - records = TranslationMemoryQuery(db).get_memory_records_paged_similar( - memory_ids=tm_ids, - page_records=20, - query=source, - ) - - return TranslationMemoryListSimilarResponse( - records=records, - page=0, - total_records=len(records), - ) + service = DocumentService(db) + try: + return service.search_tm_similar(doc_id, source) + except EntityNotFound as e: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e)) @router.get("/{doc_id}/glossaries") def get_glossaries( doc_id: int, db: Annotated[Session, Depends(get_db)] ) -> list[doc_schema.DocGlossary]: - doc = get_doc_by_id(db, doc_id) - return [ - doc_schema.DocGlossary( - document_id=doc.id, - glossary=GlossaryResponse.model_validate(x.glossary), - ) - for x in doc.glossary_associations - ] + service = DocumentService(db) + try: + return service.get_glossaries(doc_id) + except EntityNotFound as e: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e)) @router.post("/{doc_id}/glossaries") @@ -384,31 +215,22 @@ def set_glossaries( settings: doc_schema.DocGlossaryUpdate, db: Annotated[Session, Depends(get_db)], ) -> models.StatusMessage: - # check that all glossaries exist - doc = get_doc_by_id(db, doc_id) - glossary_ids = {g.id for g in settings.glossaries} + service = DocumentService(db) try: - glossaries = list(GlossaryQuery(db).get_glossaries(list(glossary_ids))) - except NotFoundGlossaryExc as exc: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, detail="Glossary not found" - ) from exc - - if len(glossary_ids) != len(glossaries): - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail="Not all glossaries were found", - ) - GenericDocsQuery(db).set_document_glossaries(doc, glossaries) - return models.StatusMessage(message="Glossary list updated") + return service.set_glossaries(doc_id, settings) + except EntityNotFound as e: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e)) @router.delete("/{doc_id}") def delete_doc( doc_id: int, db: Annotated[Session, Depends(get_db)] ) -> models.StatusMessage: - GenericDocsQuery(db).delete_document(get_doc_by_id(db, doc_id)) - return models.StatusMessage(message="Deleted") + service = DocumentService(db) + try: + return service.delete_document(doc_id) + except EntityNotFound as e: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e)) @router.post("/") @@ -417,44 +239,13 @@ async def create_doc( db: Annotated[Session, Depends(get_db)], current_user: Annotated[int, Depends(get_current_user_id)], ) -> doc_schema.Document: - # Create an XLIFF file and upload it to the server - cutoff_date = datetime.now() - timedelta(days=1) - - # Remove outdated files when adding a new one - query = GenericDocsQuery(db) - outdated_docs = query.get_outdated_documents(cutoff_date) - query.bulk_delete_documents(outdated_docs) - - name = str(file.filename) - file_data = await file.read() - original_document = file_data.decode("utf-8") - - # quite simple logic, but it is fine for now - ext = name.lower().split(".")[-1] - if ext == "xliff": - doc_type = DocumentType.xliff - elif ext == "txt": - doc_type = DocumentType.txt - else: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, detail="Unsupported file type" - ) - - doc = Document( - name=name, - type=doc_type, - processing_status=models.DocumentStatus.UPLOADED.value, - upload_time=datetime.now(), - created_by=current_user, - ) - query.add_document(doc, original_document) - return doc_schema.Document( - id=doc.id, - name=doc.name, - status=models.DocumentStatus(doc.processing_status), - created_by=doc.created_by, - type=doc.type.value, - ) + service = DocumentService(db) + try: + return await service.create_document(file, current_user) + except EntityNotFound as e: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e)) + except BusinessLogicError as e: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) @router.post("/{doc_id}/process") @@ -463,19 +254,11 @@ def process_doc( settings: doc_schema.DocumentProcessingSettings, db: Annotated[Session, Depends(get_db)], ) -> models.StatusMessage: - doc = get_doc_by_id(db, doc_id) - GenericDocsQuery(db).enqueue_document(doc) - - task_config = doc_schema.DocumentTaskDescription( - type=doc.type.value, document_id=doc_id, settings=settings - ) - db.add( - schema.DocumentTask( - data=task_config.model_dump_json(), status=models.TaskStatus.PENDING.value - ) - ) - db.commit() - return models.StatusMessage(message="Ok") + service = DocumentService(db) + try: + return service.process_document(doc_id, settings) + except EntityNotFound as e: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e)) @router.get( @@ -489,58 +272,8 @@ def process_doc( }, ) def download_doc(doc_id: int, db: Annotated[Session, Depends(get_db)]): - def encode_to_latin_1(original: str): - output = "" - for c in original: - output += c if (c.isalnum() or c in "'().[] -") else "_" - return output - - doc = get_doc_by_id(db, doc_id) - if doc.type == DocumentType.xliff: - if not doc.xliff: - raise HTTPException(status_code=404, detail="No XLIFF file found") - - original_document = doc.xliff.original_document.encode("utf-8") - processed_document = extract_xliff_content(original_document) - - for segment in processed_document.segments: - record = db.query(XliffRecord).filter_by(segment_id=segment.id_).first() - if record and not segment.approved: - segment.translation = record.parent.target - segment.approved = record.parent.approved - segment.state = SegmentState(record.state) - - processed_document.commit() - file = processed_document.write() - return StreamingResponse( - file, - media_type="application/octet-stream", - headers={ - "Content-Disposition": f'attachment; filename="{encode_to_latin_1(doc.name)}"' - }, - ) - - if doc.type == DocumentType.txt: - if not doc.txt: - raise HTTPException(status_code=404, detail="No TXT file found") - - original_document = doc.txt.original_document - processed_document = extract_txt_content(original_document) - - txt_records = doc.txt.records - for i, segment in enumerate(processed_document.segments): - record = txt_records[i] - if record: - segment.translation = record.parent.target - - processed_document.commit() - file = processed_document.write() - return StreamingResponse( - file, - media_type="application/octet-stream", - headers={ - "Content-Disposition": f'attachment; filename="{encode_to_latin_1(doc.name)}"' - }, - ) - - raise HTTPException(status_code=404, detail="Unknown document type") + service = DocumentService(db) + try: + return service.download_document(doc_id) + except EntityNotFound as e: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e)) diff --git a/backend/app/services/document_service.py b/backend/app/services/document_service.py new file mode 100644 index 0000000..3ef5109 --- /dev/null +++ b/backend/app/services/document_service.py @@ -0,0 +1,694 @@ +"""Document service for document and document record operations.""" + +from dataclasses import dataclass +from datetime import datetime, timedelta + +from fastapi import UploadFile, status +from fastapi.responses import StreamingResponse +from sqlalchemy.orm import Session + +from app import models, schema +from app.base.exceptions import BusinessLogicError, EntityNotFound +from app.comments.query import CommentsQuery +from app.comments.schema import CommentCreate, CommentResponse +from app.documents import schema as doc_schema +from app.documents.models import Document, DocumentType, TmMode, XliffRecord +from app.documents.query import GenericDocsQuery, NotFoundDocumentRecordExc +from app.formats.txt import extract_txt_content +from app.formats.xliff import SegmentState, extract_xliff_content +from app.glossary.query import GlossaryQuery, NotFoundGlossaryExc +from app.glossary.schema import GlossaryRecordSchema, GlossaryResponse +from app.translation_memory.query import TranslationMemoryQuery +from app.translation_memory.schema import ( + MemorySubstitution, + TranslationMemory, + TranslationMemoryListResponse, + TranslationMemoryListSimilarResponse, +) + + +@dataclass +class DownloadMemoryData: + """Data for downloading translation memory as TMX file.""" + + content: StreamingResponse + filename: str + + +class DocumentService: + """Service for document and document record operations.""" + + def __init__(self, db: Session): + self.__db = db + self.__query = GenericDocsQuery(db) + self.__comments_query = CommentsQuery(db) + self.__glossary_query = GlossaryQuery(db) + self.__tm_query = TranslationMemoryQuery(db) + + def get_documents(self) -> list[doc_schema.DocumentWithRecordsCount]: + """ + Get list of all documents. + + Returns: + List of DocumentWithRecordsCount objects + """ + docs = self.__query.get_documents_list() + output = [] + for doc in docs: + records = self.__query.get_document_records_count_with_approved(doc) + words = self.__query.get_document_word_count_with_approved(doc) + output.append( + doc_schema.DocumentWithRecordsCount( + id=doc.id, + name=doc.name, + status=models.DocumentStatus(doc.processing_status), + created_by=doc.created_by, + type=doc.type.value, + approved_records_count=records[0], + records_count=records[1], + approved_word_count=words[0], + total_word_count=words[1], + ) + ) + return output + + def get_document(self, doc_id: int) -> doc_schema.DocumentWithRecordsCount: + """ + Get a single document by ID. + + Args: + doc_id: Document ID + + Returns: + DocumentWithRecordsCount object + + Raises: + EntityNotFound: If document not found + """ + doc = self.__query.get_document(doc_id) + if not doc: + raise EntityNotFound("Document not found") + records = self.__query.get_document_records_count_with_approved(doc) + words = self.__query.get_document_word_count_with_approved(doc) + return doc_schema.DocumentWithRecordsCount( + id=doc.id, + name=doc.name, + status=models.DocumentStatus(doc.processing_status), + created_by=doc.created_by, + type=doc.type.value, + approved_records_count=records[0], + records_count=records[1], + approved_word_count=words[0], + total_word_count=words[1], + ) + + async def create_document( + self, file: UploadFile, user_id: int + ) -> doc_schema.Document: + """ + Create a new document from uploaded file. + + Args: + file: Uploaded file + user_id: ID of user creating the document + + Returns: + Created Document object + + Raises: + EntityNotFound: If file type is unsupported + """ + cutoff_date = datetime.now() - timedelta(days=1) + + # Remove outdated files when adding a new one + outdated_docs = self.__query.get_outdated_documents(cutoff_date) + self.__query.bulk_delete_documents(outdated_docs) + + name = str(file.filename) + file_data = await file.read() + original_document = file_data.decode("utf-8") + + # quite simple logic, but it is fine for now + ext = name.lower().split(".")[-1] + if ext == "xliff": + doc_type = DocumentType.xliff + elif ext == "txt": + doc_type = DocumentType.txt + else: + raise BusinessLogicError("Unsupported file type") + + doc = Document( + name=name, + type=doc_type, + processing_status=models.DocumentStatus.UPLOADED.value, + upload_time=datetime.now(), + created_by=user_id, + ) + self.__query.add_document(doc, original_document) + return doc_schema.Document( + id=doc.id, + name=doc.name, + status=models.DocumentStatus(doc.processing_status), + created_by=doc.created_by, + type=doc.type.value, + ) + + def delete_document(self, doc_id: int) -> models.StatusMessage: + """ + Delete a document. + + Args: + doc_id: Document ID + + Returns: + StatusMessage indicating success + + Raises: + EntityNotFound: If document not found + """ + doc = self._get_document_by_id(doc_id) + self.__query.delete_document(doc) + return models.StatusMessage(message="Deleted") + + def process_document( + self, doc_id: int, settings: doc_schema.DocumentProcessingSettings + ) -> models.StatusMessage: + """ + Process a document. + + Args: + doc_id: Document ID + settings: Processing settings + + Returns: + StatusMessage indicating success + + Raises: + EntityNotFound: If document not found + """ + doc = self._get_document_by_id(doc_id) + self.__query.enqueue_document(doc) + + task_config = doc_schema.DocumentTaskDescription( + type=doc.type.value, document_id=doc_id, settings=settings + ) + self.__db.add( + schema.DocumentTask( + data=task_config.model_dump_json(), + status=models.TaskStatus.PENDING.value, + ) + ) + self.__db.commit() + return models.StatusMessage(message="Ok") + + def download_document(self, doc_id: int) -> StreamingResponse: + """ + Download a document. + + Args: + doc_id: Document ID + + Returns: + StreamingResponse with document file + + Raises: + EntityNotFound: If document not found or file not available + """ + doc = self._get_document_by_id(doc_id) + if doc.type == DocumentType.xliff: + if not doc.xliff: + raise EntityNotFound("No XLIFF file found") + + original_document = doc.xliff.original_document.encode("utf-8") + processed_document = extract_xliff_content(original_document) + + for segment in processed_document.segments: + record = ( + self.__db.query(XliffRecord) + .filter_by(segment_id=segment.id_) + .first() + ) + if record and not segment.approved: + segment.translation = record.parent.target + segment.approved = record.parent.approved + segment.state = SegmentState(record.state) + + processed_document.commit() + file = processed_document.write() + return StreamingResponse( + file, + media_type="application/octet-stream", + headers={ + "Content-Disposition": f'attachment; filename="{self.encode_to_latin_1(doc.name)}"' + }, + ) + if doc.type == DocumentType.txt: + if not doc.txt: + raise EntityNotFound("No TXT file found") + + original_document = doc.txt.original_document + processed_document = extract_txt_content(original_document) + + txt_records = doc.txt.records + for i, segment in enumerate(processed_document.segments): + record = txt_records[i] + if record: + segment.translation = record.parent.target + + processed_document.commit() + file = processed_document.write() + return StreamingResponse( + file, + media_type="application/octet-stream", + headers={ + "Content-Disposition": f'attachment; filename="{self.encode_to_latin_1(doc.name)}"' + }, + ) + + raise EntityNotFound("Unknown document type") + + def get_document_records( + self, + doc_id: int, + page: int, + filters: doc_schema.DocumentRecordFilter | None = None, + ) -> doc_schema.DocumentRecordListResponse: + """ + Get records from a document. + + Args: + doc_id: Document ID + page: Page number + filters: Optional filters for source/target text + + Returns: + DocumentRecordListResponse object + + Raises: + EntityNotFound: If document not found + """ + doc = self._get_document_by_id(doc_id) + total_records = self.__query.get_document_records_count_filtered(doc, filters) + records = self.__query.get_document_records_paged(doc, page, filters=filters) + + record_list = [ + doc_schema.DocumentRecord( + id=record.id, + source=record.source, + target=record.target, + approved=record.approved, + repetitions_count=repetitions_count, + has_comments=has_comments, + translation_src=( + record.target_source.value if record.target_source else None + ), + ) + for record, repetitions_count, has_comments in records + ] + + return doc_schema.DocumentRecordListResponse( + records=record_list, + page=page, + total_records=total_records, + ) + + def update_record( + self, record_id: int, data: doc_schema.DocumentRecordUpdate + ) -> doc_schema.DocumentRecordUpdateResponse: + """ + Update a document record. + + Args: + record_id: Record ID + data: Updated record data + + Returns: + DocumentRecordUpdateResponse object + + Raises: + EntityNotFound: If record not found + """ + try: + updated_record = self.__query.update_record(record_id, data) + return doc_schema.DocumentRecordUpdateResponse( + id=updated_record.id, + source=updated_record.source, + target=updated_record.target, + approved=updated_record.approved, + ) + except NotFoundDocumentRecordExc: + raise EntityNotFound("Record not found") + + def get_glossaries(self, doc_id: int) -> list[doc_schema.DocGlossary]: + """ + Get glossaries associated with a document. + + Args: + doc_id: Document ID + + Returns: + List of DocGlossary objects + + Raises: + EntityNotFound: If document not found + """ + doc = self._get_document_by_id(doc_id) + return [ + doc_schema.DocGlossary( + document_id=doc.id, + glossary=GlossaryResponse.model_validate(x.glossary), + ) + for x in doc.glossary_associations + ] + + def set_glossaries( + self, doc_id: int, settings: doc_schema.DocGlossaryUpdate + ) -> models.StatusMessage: + """ + Set glossaries for a document. + + Args: + doc_id: Document ID + settings: Glossary settings + + Returns: + StatusMessage indicating success + + Raises: + EntityNotFound: If document or glossary not found + """ + doc = self._get_document_by_id(doc_id) + glossary_ids = {g.id for g in settings.glossaries} + try: + glossaries = list(self.__glossary_query.get_glossaries(list(glossary_ids))) + except NotFoundGlossaryExc: + raise EntityNotFound("Glossary not found") + + if len(glossary_ids) != len(glossaries): + raise EntityNotFound("Not all glossaries were found") + self.__query.set_document_glossaries(doc, glossaries) + return models.StatusMessage(message="Glossary list updated") + + def get_translation_memories( + self, doc_id: int + ) -> list[doc_schema.DocTranslationMemory]: + """ + Get translation memories associated with a document. + + Args: + doc_id: Document ID + + Returns: + List of DocTranslationMemory objects + + Raises: + EntityNotFound: If document not found + """ + doc = self._get_document_by_id(doc_id) + return [ + doc_schema.DocTranslationMemory( + document_id=doc.id, + memory=TranslationMemory( + id=association.memory.id, + name=association.memory.name, + created_by=association.memory.created_by, + ), + mode=association.mode, + ) + for association in self._get_document_by_id(doc_id).memory_associations + ] + + def set_translation_memories( + self, doc_id: int, settings: doc_schema.DocTranslationMemoryUpdate + ) -> models.StatusMessage: + """ + Set translation memories for a document. + + Args: + doc_id: Document ID + settings: Translation memory settings + + Returns: + StatusMessage indicating success + + Raises: + EntityNotFound: If document or memory not found, or invalid settings + """ + # check writes count + write_count = 0 + for memory in settings.memories: + write_count += memory.mode == TmMode.write + + if write_count > 1: + raise BusinessLogicError( + "Only one translation memory can be set to write mode" + ) + + doc = self._get_document_by_id(doc_id) + memory_ids = {memory.id for memory in settings.memories} + memories = list(self.__tm_query.get_memories_by_id(memory_ids)) + if len(memory_ids) != len(memories): + raise EntityNotFound("Not all memories were found") + + # Create list of (memory, mode) tuples + memory_modes = [] + for setting in settings.memories: + memory = next((m for m in memories if m.id == setting.id), None) + if memory: + memory_modes.append((memory, setting.mode)) + + self.__query.set_document_memories(doc, memory_modes) + return models.StatusMessage(message="Memory list updated") + + def search_tm_exact( + self, doc_id: int, source: str + ) -> TranslationMemoryListResponse: + """ + Search translation memories for exact match. + + Args: + doc_id: Document ID + source: Source text to search for + + Returns: + TranslationMemoryListResponse object + + Raises: + EntityNotFound: If document not found + """ + doc = self._get_document_by_id(doc_id) + tm_ids = [tm.id for tm in doc.memories] + + if not tm_ids: + return TranslationMemoryListResponse(records=[], page=0, total_records=0) + + records, count = self.__tm_query.get_memory_records_paged( + memory_ids=tm_ids, + page=0, + page_records=20, + query=source, + ) + + return TranslationMemoryListResponse( + records=records, + page=0, + total_records=count, + ) + + def search_tm_similar( + self, doc_id: int, source: str + ) -> TranslationMemoryListSimilarResponse: + """ + Search translation memories for similar matches. + + Args: + doc_id: Document ID + source: Source text to search for + + Returns: + TranslationMemoryListSimilarResponse object + + Raises: + EntityNotFound: If document not found + """ + doc = self._get_document_by_id(doc_id) + tm_ids = [tm.id for tm in doc.memories] + + if not tm_ids: + return TranslationMemoryListSimilarResponse( + records=[], page=0, total_records=0 + ) + + records = self.__tm_query.get_memory_records_paged_similar( + memory_ids=tm_ids, + page_records=20, + query=source, + ) + + return TranslationMemoryListSimilarResponse( + records=records, + page=0, + total_records=len(records), + ) + + def get_comments(self, record_id: int) -> list[CommentResponse]: + """ + Get all comments for a document record. + + Args: + record_id: Document record ID + + Returns: + List of CommentResponse objects + + Raises: + EntityNotFound: If record not found + """ + # Verify document record exists + self._get_record_by_id(record_id) + + comments = self.__comments_query.get_comments_by_document_record(record_id) + return [CommentResponse.model_validate(comment) for comment in comments] + + def create_comment( + self, record_id: int, comment_data: CommentCreate, user_id: int + ) -> CommentResponse: + """ + Create a new comment for a document record. + + Args: + record_id: Document record ID + comment_data: Comment creation data + user_id: ID of user creating the comment + + Returns: + Created CommentResponse object + + Raises: + EntityNotFound: If record not found + """ + # Verify document record exists + self._get_record_by_id(record_id) + + comment = self.__comments_query.create_comment(comment_data, user_id, record_id) + return CommentResponse.model_validate(comment) + + def get_record_substitutions(self, record_id: int) -> list[MemorySubstitution]: + """ + Get substitution suggestions for a document record. + + Args: + record_id: Document record ID + + Returns: + List of MemorySubstitution objects + + Raises: + EntityNotFound: If record not found + """ + original_segment = self._get_record_by_id(record_id) + + tm_ids = [tm.id for tm in original_segment.document.memories] + return ( + self.__tm_query.get_substitutions(original_segment.source, tm_ids) + if tm_ids + else [] + ) + + def get_record_glossary_records(self, record_id: int) -> list[GlossaryRecordSchema]: + """ + Get glossary records matching a document record. + + Args: + record_id: Document record ID + + Returns: + List of GlossaryRecordSchema objects + + Raises: + EntityNotFound: If record not found + """ + original_segment = self._get_record_by_id(record_id) + glossary_ids = [gl.id for gl in original_segment.document.glossaries] + return ( + [ + GlossaryRecordSchema.model_validate(record) + for record in self.__glossary_query.get_glossary_records_for_phrase( + original_segment.source, glossary_ids + ) + ] + if glossary_ids + else [] + ) + + def doc_glossary_search( + self, doc_id: int, query: str + ) -> list[GlossaryRecordSchema]: + """ + Search glossaries for a phrase within a document. + + Args: + doc_id: Document ID + query: Search query + + Returns: + List of GlossaryRecordSchema objects + + Raises: + EntityNotFound: If document not found + """ + doc = self._get_document_by_id(doc_id) + glossary_ids = [gl.id for gl in doc.glossaries] + + return ( + [ + GlossaryRecordSchema.model_validate(record) + for record in self.__glossary_query.get_glossary_records_for_phrase( + query, glossary_ids + ) + ] + if glossary_ids + else [] + ) + + def _get_document_by_id(self, doc_id: int) -> Document: + """ + Get a document by ID. + + Args: + doc_id: Document ID + + Returns: + Document object + + Raises: + EntityNotFound: If document not found + """ + doc = self.__query.get_document(doc_id) + if not doc: + raise EntityNotFound("Document not found") + return doc + + def _get_record_by_id(self, record_id: int): + """ + Get a document record by ID. + + Args: + record_id: Record ID + + Returns: + DocumentRecord object + + Raises: + EntityNotFound: If record not found + """ + record = self.__query.get_record(record_id) + if not record: + raise EntityNotFound("Document record not found") + return record + + def encode_to_latin_1(self, original: str): + output = "" + for c in original: + output += c if (c.isalnum() or c in "'().[] -") else "_" + return output From 76f1c7ffdfd23c69a3e1c0292e4ecb0a115b1ae3 Mon Sep 17 00:00:00 2001 From: Denis Bezykornov Date: Thu, 15 Jan 2026 00:14:24 +0300 Subject: [PATCH 8/9] Export services in a convenient way --- backend/app/services/__init__.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 backend/app/services/__init__.py diff --git a/backend/app/services/__init__.py b/backend/app/services/__init__.py new file mode 100644 index 0000000..5bb4081 --- /dev/null +++ b/backend/app/services/__init__.py @@ -0,0 +1,17 @@ +"""Service layer module for business logic operations.""" + +from app.services.auth_service import AuthService +from app.services.comment_service import CommentService +from app.services.document_service import DocumentService +from app.services.glossary_service import GlossaryService +from app.services.translation_memory_service import TranslationMemoryService +from app.services.user_service import UserService + +__all__ = [ + "AuthService", + "CommentService", + "DocumentService", + "GlossaryService", + "TranslationMemoryService", + "UserService", +] From 77865aa2d0c24cf09d88aa648905fdb0a4c5678f Mon Sep 17 00:00:00 2001 From: Denis Bezykornov Date: Thu, 15 Jan 2026 00:36:13 +0300 Subject: [PATCH 9/9] Fix Ruff issues --- backend/app/base/exceptions.py | 1 + backend/app/routers/glossary.py | 20 ++++++++++++++----- backend/app/routers/translation_memory.py | 2 +- backend/app/routers/user.py | 1 + backend/app/services/document_service.py | 2 +- backend/app/services/glossary_service.py | 5 ++--- .../services/translation_memory_service.py | 2 +- 7 files changed, 22 insertions(+), 11 deletions(-) diff --git a/backend/app/base/exceptions.py b/backend/app/base/exceptions.py index b806cde..589614e 100644 --- a/backend/app/base/exceptions.py +++ b/backend/app/base/exceptions.py @@ -3,6 +3,7 @@ class BaseQueryException(Exception): """Base exception for query layer errors.""" + pass diff --git a/backend/app/routers/glossary.py b/backend/app/routers/glossary.py index 2c98f56..9c8b3e5 100644 --- a/backend/app/routers/glossary.py +++ b/backend/app/routers/glossary.py @@ -53,7 +53,9 @@ def list_glossary(db: Annotated[Session, Depends(get_db)]): 404: { "description": "Glossary requested by id", "content": { - "application/json": {"example": {"detail": "Glossary with id 1 not found"}} + "application/json": { + "example": {"detail": "Glossary with id 1 not found"} + } }, }, }, @@ -97,7 +99,9 @@ def create_glossary( 404: { "description": "Glossary requested by id", "content": { - "application/json": {"example": {"detail": "Glossary with id 1 not found"}} + "application/json": { + "example": {"detail": "Glossary with id 1 not found"} + } }, }, }, @@ -124,7 +128,9 @@ def update_glossary( 404: { "description": "Glossary requested by id", "content": { - "application/json": {"example": {"detail": "Glossary with id 1 not found"}} + "application/json": { + "example": {"detail": "Glossary with id 1 not found"} + } }, }, }, @@ -197,7 +203,9 @@ def create_glossary_record( 404: { "description": "Glossary record requested by id", "content": { - "application/json": {"example": {"detail": "Glossary record with id 1 not found"}} + "application/json": { + "example": {"detail": "Glossary record with id 1 not found"} + } }, }, }, @@ -226,7 +234,9 @@ def update_glossary_record( 404: { "description": "Glossary record requested by id", "content": { - "application/json": {"example": {"detail": "Glossary record with id 1 not found"}} + "application/json": { + "example": {"detail": "Glossary record with id 1 not found"} + } }, }, }, diff --git a/backend/app/routers/translation_memory.py b/backend/app/routers/translation_memory.py index 3a1a079..2199cda 100644 --- a/backend/app/routers/translation_memory.py +++ b/backend/app/routers/translation_memory.py @@ -7,8 +7,8 @@ from app.base.exceptions import EntityNotFound from app.db import get_db from app.models import StatusMessage -from app.translation_memory import schema from app.services import TranslationMemoryService +from app.translation_memory import schema from app.user.depends import get_current_user_id, has_user_role router = APIRouter( diff --git a/backend/app/routers/user.py b/backend/app/routers/user.py index f299d83..f386721 100644 --- a/backend/app/routers/user.py +++ b/backend/app/routers/user.py @@ -1,4 +1,5 @@ from typing import Annotated + from fastapi import APIRouter, Depends, HTTPException from sqlalchemy.orm import Session diff --git a/backend/app/services/document_service.py b/backend/app/services/document_service.py index 3ef5109..c670be2 100644 --- a/backend/app/services/document_service.py +++ b/backend/app/services/document_service.py @@ -3,7 +3,7 @@ from dataclasses import dataclass from datetime import datetime, timedelta -from fastapi import UploadFile, status +from fastapi import UploadFile from fastapi.responses import StreamingResponse from sqlalchemy.orm import Session diff --git a/backend/app/services/glossary_service.py b/backend/app/services/glossary_service.py index edef80c..e46b447 100644 --- a/backend/app/services/glossary_service.py +++ b/backend/app/services/glossary_service.py @@ -1,12 +1,11 @@ """Glossary service for glossary and glossary record operations.""" -from datetime import datetime import io +from dataclasses import dataclass +from datetime import datetime from typing import Optional, Self - import openpyxl -from dataclasses import dataclass from fastapi import UploadFile from sqlalchemy.orm import Session diff --git a/backend/app/services/translation_memory_service.py b/backend/app/services/translation_memory_service.py index ef062c5..5797eae 100644 --- a/backend/app/services/translation_memory_service.py +++ b/backend/app/services/translation_memory_service.py @@ -5,9 +5,9 @@ from sqlalchemy.orm import Session +from app.base.exceptions import EntityNotFound from app.formats.tmx import TmxData, TmxSegment, extract_tmx_content from app.models import StatusMessage -from app.base.exceptions import EntityNotFound from app.translation_memory import models, schema from app.translation_memory.query import TranslationMemoryQuery