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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions backend/alembic/versions/71a5eef94341_add_projects_table.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
"""Add projects table

Revision ID: 71a5eef94341
Revises: c12df3eda4a2
Create Date: 2026-01-25 21:09:10.356746

"""
from typing import Sequence, Union

from alembic import op
import sqlalchemy as sa


# pylint: disable=E1101

# revision identifiers, used by Alembic.
revision: str = '71a5eef94341'
down_revision: Union[str, None] = 'c12df3eda4a2'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None


def upgrade() -> None:
op.create_table('projects',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('name', sa.String(), nullable=False),
sa.Column('created_by', sa.Integer(), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.Column('updated_at', sa.DateTime(), nullable=False),
sa.ForeignKeyConstraint(['created_by'], ['user.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.add_column('document', sa.Column('project_id', sa.Integer(), nullable=True))
op.create_foreign_key('document_project_id_fkey', 'document', 'projects', ['project_id'], ['id'])


def downgrade() -> None:
op.drop_constraint('document_project_id_fkey', 'document', type_='foreignkey')
op.drop_column('document', 'project_id')
op.drop_table('projects')
2 changes: 2 additions & 0 deletions backend/app/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
XliffRecord,
)
from app.glossary.models import Glossary, GlossaryRecord
from app.projects.models import Project
from app.schema import (
DocumentTask,
User,
Expand All @@ -32,4 +33,5 @@
"DocumentType",
"TxtDocument",
"TxtRecord",
"Project",
]
5 changes: 5 additions & 0 deletions backend/app/documents/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
from app.comments.models import Comment
from app.glossary.models import Glossary
from app.models import User
from app.projects.models import Project
from app.translation_memory.models import TranslationMemory


Expand Down Expand Up @@ -75,13 +76,17 @@ class Document(Base):
created_by: Mapped[int] = mapped_column(ForeignKey("user.id"))
processing_status: Mapped[str] = mapped_column()
upload_time: Mapped[datetime] = mapped_column(default=utc_time)
project_id: Mapped[int | None] = mapped_column(
ForeignKey("projects.id"), nullable=True
)

records: Mapped[list["DocumentRecord"]] = relationship(
back_populates="document",
cascade="all, delete-orphan",
order_by="DocumentRecord.id",
)
user: Mapped["User"] = relationship("User", back_populates="documents")
project: Mapped["Project"] = relationship(back_populates="documents")
xliff: Mapped["XliffDocument"] = relationship(
back_populates="parent", cascade="all, delete-orphan"
)
Expand Down
36 changes: 16 additions & 20 deletions backend/app/documents/query.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
from app.base.exceptions import BaseQueryException
from app.comments.models import Comment
from app.documents.models import DocumentRecordHistory, DocumentRecordHistoryChangeType
from app.documents.schema import DocumentRecordFilter, DocumentRecordUpdate
from app.documents.schema import DocumentRecordFilter
from app.glossary.models import Glossary
from app.models import DocumentStatus
from app.translation_memory.models import TranslationMemory
Expand All @@ -24,8 +24,8 @@
)


class NotFoundDocumentRecordExc(BaseQueryException):
"""Exception raised when document record not found"""
class NotFoundDocumentExc(BaseQueryException):
"""Exception raised when document not found"""


class GenericDocsQuery:
Expand Down Expand Up @@ -202,24 +202,20 @@ def get_document_records_paged(
.limit(page_records)
).all()

def get_record(self, record_id: int) -> DocumentRecord | None:
return self.__db.execute(
select(DocumentRecord).filter(DocumentRecord.id == record_id)
).scalar_one_or_none()

def update_record(
self, record_id: int, data: DocumentRecordUpdate
) -> DocumentRecord:
record = self.get_record(record_id)
if not record:
raise NotFoundDocumentRecordExc()

record.target = data.target
if data.approved is not None:
record.approved = data.approved

def update_document(
self, doc_id: int, name: str | None, project_id: int | None
) -> Document:
document = self.get_document(doc_id)
if not document:
raise NotFoundDocumentExc()

if name is not None:
document.name = name
if project_id is not None:
document.project_id = project_id if project_id != -1 else None
self.__db.commit()
return record
self.__db.refresh(document)
return document

def get_record_ids_by_source(self, doc_id: int, source: str) -> list[int]:
return list(
Expand Down
26 changes: 25 additions & 1 deletion backend/app/documents/schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,12 @@ class Document(Identified):
status: DocumentStatus
created_by: int
type: Literal["xliff", "txt"]
project_id: int | None


class DocumentWithRecordsCount(Document):
approved_records_count: int
records_count: int
total_records_count: int
approved_word_count: int
total_word_count: int

Expand Down Expand Up @@ -100,3 +101,26 @@ class DocumentRecordHistory(BaseModel):

class DocumentRecordHistoryListResponse(BaseModel):
history: list[DocumentRecordHistory]


class DocumentUpdate(BaseModel):
name: str | None = Field(
default=None,
description="New name for the document.",
min_length=1,
max_length=255,
)
project_id: int | None = Field(
default=None,
description="ID of project to assign document to. Set to -1 to unassign.",
)

model_config = ConfigDict(from_attributes=True)


class DocumentUpdateResponse(BaseModel):
id: int
name: str
project_id: int | None

model_config = ConfigDict(from_attributes=True)
30 changes: 30 additions & 0 deletions backend/app/projects/models.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
from datetime import UTC, datetime
from typing import TYPE_CHECKING

from sqlalchemy import ForeignKey
from sqlalchemy.orm import Mapped, mapped_column, relationship

from app.db import Base

if TYPE_CHECKING:
from app.documents.models import Document
from app.schema import User


def utc_time():
return datetime.now(UTC)


class Project(Base):
__tablename__ = "projects"

id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column()
created_by: Mapped[int] = mapped_column(ForeignKey("user.id"))
created_at: Mapped[datetime] = mapped_column(default=utc_time)
updated_at: Mapped[datetime] = mapped_column(default=utc_time)

user: Mapped["User"] = relationship(back_populates="projects")
documents: Mapped[list["Document"]] = relationship(
back_populates="project", cascade="all, delete-orphan"
)
107 changes: 107 additions & 0 deletions backend/app/projects/query.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
from datetime import UTC, datetime
from typing import Sequence

from sqlalchemy import case, func, select, update
from sqlalchemy.orm import Session

from app.base.exceptions import BaseQueryException
from app.documents.models import Document, DocumentRecord
from app.projects.models import Project
from app.projects.schema import ProjectCreate, ProjectUpdate


class NotFoundProjectExc(BaseQueryException):
"""Not found project"""


class ProjectQuery:
"""Contain query to Project"""

def __init__(self, db: Session):
self.__db = db

def _get_project(self, project_id: int) -> Project:
project = self.__db.execute(
select(Project).where(Project.id == project_id)
).scalar_one_or_none()
if project:
return project
raise NotFoundProjectExc()

def list_projects(self, user_id: int) -> list[Project]:
return list(
self.__db.execute(select(Project).order_by(Project.id)).scalars().all()
)

def create_project(self, user_id: int, data: ProjectCreate) -> Project:
project = Project(created_by=user_id, name=data.name)
self.__db.add(project)
self.__db.commit()
return project

def update_project(self, project_id: int, data: ProjectUpdate) -> Project:
dump = data.model_dump()
dump["updated_at"] = datetime.now(UTC)
self.__db.execute(update(Project).where(Project.id == project_id).values(dump))
self.__db.commit()
return self._get_project(project_id)

def delete_project(self, project_id: int) -> bool:
project = self.__db.execute(
select(Project).where(Project.id == project_id)
).scalar_one_or_none()
if not project:
return False

self.__db.delete(project)
self.__db.commit()
return True

def get_project_documents(self, project_id: int | None) -> Sequence[Document]:
docs = (
self.__db.execute(select(Document).where(Document.project_id == project_id))
.scalars()
.all()
)
return docs

def get_project_aggregates(self, project_id: int | None):
"""
Get aggregate metrics for a project.

Returns a tuple containing:
- doc_id: Document ID
- approved_segments_count: Total approved segments across all documents
- total_segments_count: Total segments across all documents
- approved_words_count: Total approved words across all documents
- total_words_count: Total words across all documents

Args:
project_id: ID of the project

Returns:
List of tuples of five integers: (doc_id, approved_segments_count,
total_segments_count, approved_words_count, total_words_count)
"""
stmt = (
select(
DocumentRecord.document_id.label("doc_id"),
func.sum(case((DocumentRecord.approved.is_(True), 1), else_=0)).label(
"approved_segments"
),
func.count(DocumentRecord.id).label("total_segments"),
func.sum(
case(
(DocumentRecord.approved.is_(True), DocumentRecord.word_count),
else_=0,
)
).label("approved_words"),
func.sum(DocumentRecord.word_count).label("total_words"),
)
.select_from(DocumentRecord)
.join(Document, DocumentRecord.document_id == Document.id)
.where(Document.project_id == project_id)
.group_by("doc_id")
)

return self.__db.execute(stmt).all()
33 changes: 33 additions & 0 deletions backend/app/projects/schema.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
from pydantic import BaseModel, ConfigDict, Field

from app.base.schema import IdentifiedTimestampedModel
from app.documents.schema import DocumentWithRecordsCount


class ProjectCreate(BaseModel):
name: str = Field(min_length=1, max_length=255)

model_config = ConfigDict(from_attributes=True)


class ProjectUpdate(BaseModel):
name: str = Field(min_length=1, max_length=255)

model_config = ConfigDict(from_attributes=True)


class ProjectResponse(IdentifiedTimestampedModel):
name: str
created_by: int

model_config = ConfigDict(from_attributes=True)


class DetailedProjectResponse(ProjectResponse):
documents: list[DocumentWithRecordsCount]
approved_records_count: int
total_records_count: int
approved_words_count: int
total_words_count: int

model_config = ConfigDict(from_attributes=True)
34 changes: 34 additions & 0 deletions backend/app/records/query.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
from sqlalchemy import select
from sqlalchemy.orm import Session

from app.base.exceptions import BaseQueryException
from app.documents.models import DocumentRecord
from app.documents.schema import DocumentRecordUpdate


class NotFoundDocumentRecordExc(BaseQueryException):
"""Exception raised when document record not found"""


class RecordsQuery:
def __init__(self, db: Session) -> None:
self.__db = db

def get_record(self, record_id: int) -> DocumentRecord | None:
return self.__db.execute(
select(DocumentRecord).filter(DocumentRecord.id == record_id)
).scalar_one_or_none()

def update_record(
self, record_id: int, data: DocumentRecordUpdate
) -> DocumentRecord:
record = self.get_record(record_id)
if not record:
raise NotFoundDocumentRecordExc()

record.target = data.target
if data.approved is not None:
record.approved = data.approved

self.__db.commit()
return record
Loading