| from datetime import datetime |
| from typing import Optional, TYPE_CHECKING, List, Any |
|
|
| from sqlalchemy import ( |
| Integer, |
| DateTime, |
| ForeignKey, |
| Text, |
| Index, |
| UniqueConstraint, |
| JSON |
| ) |
| from sqlalchemy.orm import Mapped, mapped_column, relationship, validates |
| from sqlalchemy.sql import func |
|
|
| from app.models.base import Base |
|
|
| if TYPE_CHECKING: |
| from app.models.user import User |
| from app.models.paper import Paper |
|
|
|
|
| class LibraryItem(Base): |
| """ |
| Represents a paper saved to a user's personal knowledge base. |
| |
| System Role |
| ----------- |
| - Powers the Phase 4 Saved Library dashboard. |
| - Acts as a curated dataset for Phase 8 ProposAI (Grant Generation). |
| """ |
|
|
| __tablename__ = "library_items" |
|
|
| |
| |
| |
| id: Mapped[int] = mapped_column(Integer, primary_key=True) |
|
|
| user_id: Mapped[int] = mapped_column( |
| Integer, |
| ForeignKey("users.id", ondelete="CASCADE"), |
| nullable=False, |
| ) |
|
|
| paper_id: Mapped[int] = mapped_column( |
| Integer, |
| ForeignKey("papers.id", ondelete="CASCADE"), |
| nullable=False, |
| ) |
|
|
| |
| |
| |
| tags: Mapped[Optional[List[str]]] = mapped_column( |
| JSON, |
| nullable=True, |
| default=list, |
| server_default="[]", |
| comment="Native JSON list of researcher-defined categories" |
| ) |
|
|
| notes: Mapped[Optional[str]] = mapped_column( |
| Text, |
| nullable=True, |
| comment="Markdown-supported personal annotations" |
| ) |
|
|
| |
| |
| |
| @validates('tags') |
| def validate_tags(self, key: str, tags: Any) -> List[str]: |
| """ |
| Enforces a hard limit of 20 tags per library item. |
| Prevents metadata bloat and ensures frontend layout stability. |
| """ |
| if tags is not None and len(tags) > 20: |
| raise ValueError("Maximum 20 tags allowed per research artifact.") |
| return tags |
|
|
| |
| |
| |
| created_at: Mapped[datetime] = mapped_column( |
| DateTime(timezone=True), |
| server_default=func.now(), |
| ) |
|
|
| updated_at: Mapped[datetime] = mapped_column( |
| DateTime(timezone=True), |
| server_default=func.now(), |
| onupdate=func.now(), |
| ) |
|
|
| |
| |
| |
| user: Mapped["User"] = relationship( |
| "User", |
| back_populates="library_items", |
| lazy="select", |
| ) |
|
|
| paper: Mapped["Paper"] = relationship( |
| "Paper", |
| lazy="joined", |
| ) |
|
|
| |
| |
| |
| __table_args__ = ( |
| UniqueConstraint( |
| "user_id", |
| "paper_id", |
| name="uq_user_library_paper", |
| ), |
| Index( |
| "idx_library_user_created", |
| "user_id", |
| "created_at", |
| ), |
| ) |
|
|
| def __repr__(self) -> str: |
| return f"<LibraryItem(user={self.user_id}, paper={self.paper_id})>" |
|
|