diff --git a/chafan_core/app/crud/crud_user.py b/chafan_core/app/crud/crud_user.py index d5cf97b..876672c 100644 --- a/chafan_core/app/crud/crud_user.py +++ b/chafan_core/app/crud/crud_user.py @@ -24,9 +24,6 @@ from chafan_core.app.schemas.security import IntlPhoneNumber from chafan_core.app.schemas.user import UserCreate, UserUpdate from chafan_core.app.security import get_password_hash, verify_password -from chafan_core.utils.validators import StrippedNonEmptyBasicStr - - class CRUDUser(CRUDBase[User, UserCreate, UserUpdate]): def get_by_email(self, db: Session, *, email: str) -> Optional[User]: return db.query(User).filter_by(email=email).first() @@ -66,9 +63,7 @@ def get_all_active_users(self, db: Session) -> List[User]: # 2025-Dec-11 This function is synchronized for now. We define it as async to facilitate further improvement async def create(self, db: Session, *, obj_in: UserCreate) -> User: if obj_in.handle is None: - handle = StrippedNonEmptyBasicStr( - self._generate_handle(db, obj_in.email.split("@")[0]) - ) + handle = self._generate_handle(db, obj_in.email.split("@")[0]) else: handle = obj_in.handle initial_coins = 0 diff --git a/chafan_core/app/materialize.py b/chafan_core/app/materialize.py index a40c25d..8ab7f1c 100644 --- a/chafan_core/app/materialize.py +++ b/chafan_core/app/materialize.py @@ -47,11 +47,9 @@ unknown_user_handle, unknown_user_uuid, ) -from chafan_core.utils.validators import StrippedNonEmptyBasicStr - _ANONYMOUS_USER_PREVIEW = schemas.UserPreview( uuid=unknown_user_uuid, - handle=StrippedNonEmptyBasicStr(unknown_user_handle), + handle=unknown_user_handle, full_name=unknown_user_full_name, ) diff --git a/chafan_core/app/models/answer.py b/chafan_core/app/models/answer.py index a6895e7..babdf70 100644 --- a/chafan_core/app/models/answer.py +++ b/chafan_core/app/models/answer.py @@ -1,4 +1,5 @@ -from typing import TYPE_CHECKING, List +import datetime +from typing import TYPE_CHECKING, Any, List, Optional from sqlalchemy import ( CHAR, @@ -12,7 +13,7 @@ Table, UniqueConstraint, ) -from sqlalchemy.orm import backref, relationship +from sqlalchemy.orm import Mapped, backref, relationship from sqlalchemy.sql.sqltypes import JSON, Enum from chafan_core.db.base_class import Base @@ -29,11 +30,13 @@ class Answer_Upvotes(Base): PrimaryKeyConstraint("answer_id", "voter_id"), ) - cancelled = Column(Boolean, server_default="false", default=False, nullable=False) - answer_id = Column(Integer, ForeignKey("answer.id"), index=True) - answer = relationship("Answer", foreign_keys=[answer_id]) - voter_id = Column(Integer, ForeignKey("user.id"), index=True) - voter = relationship("User", foreign_keys=[voter_id]) + cancelled: Mapped[bool] = Column( + Boolean, server_default="false", default=False, nullable=False + ) + answer_id: Mapped[int] = Column(Integer, ForeignKey("answer.id"), index=True) + answer: Mapped["Answer"] = relationship("Answer", foreign_keys=[answer_id]) + voter_id: Mapped[int] = Column(Integer, ForeignKey("user.id"), index=True) + voter: Mapped["User"] = relationship("User", foreign_keys=[voter_id]) answer_contributors = Table( @@ -45,57 +48,77 @@ class Answer_Upvotes(Base): class Answer(Base): - id = Column(Integer, primary_key=True, index=True) - uuid = Column(CHAR(length=UUID_LENGTH), index=True, unique=True, nullable=False) - author_id = Column(Integer, ForeignKey("user.id"), nullable=False, index=True) - author: "User" = relationship("User", back_populates="answers") # type: ignore - site_id = Column(Integer, ForeignKey("site.id"), nullable=False, index=True) - site: "Site" = relationship("Site", back_populates="answers") # type: ignore - question_id = Column(Integer, ForeignKey("question.id"), nullable=False, index=True) - question: "Question" = relationship("Question", back_populates="answers") # type: ignore + id: Mapped[int] = Column(Integer, primary_key=True, index=True) + uuid: Mapped[str] = Column( + CHAR(length=UUID_LENGTH), index=True, unique=True, nullable=False + ) + author_id: Mapped[int] = Column( + Integer, ForeignKey("user.id"), nullable=False, index=True + ) + author: Mapped["User"] = relationship("User", back_populates="answers") + site_id: Mapped[int] = Column( + Integer, ForeignKey("site.id"), nullable=False, index=True + ) + site: Mapped["Site"] = relationship("Site", back_populates="answers") + question_id: Mapped[int] = Column( + Integer, ForeignKey("question.id"), nullable=False, index=True + ) + question: Mapped["Question"] = relationship("Question", back_populates="answers") - upvotes_count = Column(Integer, default=0, nullable=False) + upvotes_count: Mapped[int] = Column(Integer, default=0, nullable=False) - is_hidden_by_moderator = Column(Boolean, server_default="false", nullable=False) + is_hidden_by_moderator: Mapped[bool] = Column( + Boolean, server_default="false", nullable=False + ) # If not `is_published`, `body` is the author-only draft. # Otherwise, `body` is the latest published text. - body = Column(String, nullable=False) + body: Mapped[str] = Column(String, nullable=False) - body_prerendered_text = Column(String, nullable=False) - keywords = Column(JSON) + body_prerendered_text: Mapped[str] = Column(String, nullable=False) + keywords: Mapped[Optional[Any]] = Column(JSON) # Not null only if is_published is `True`, in which case it might contain a working draft version. - body_draft = Column(String) + body_draft: Mapped[Optional[str]] = Column(String) - editor: editor_T = Column(String, nullable=False, server_default="wysiwyg") # type: ignore + editor: Mapped[str] = Column(String, nullable=False, server_default="wysiwyg") - draft_saved_at = Column(DateTime(timezone=True)) - draft_editor: editor_T = Column(String, nullable=False, server_default="wysiwyg") # type: ignore + draft_saved_at: Mapped[Optional[datetime.datetime]] = Column(DateTime(timezone=True)) + draft_editor: Mapped[str] = Column(String, nullable=False, server_default="wysiwyg") # Whether `body` contains the latest published version - is_published = Column( + is_published: Mapped[bool] = Column( Boolean, default=False, server_default="false", nullable=False ) # Whether `body` contains the latest published version - is_deleted = Column(Boolean, default=False, server_default="false", nullable=False) + is_deleted: Mapped[bool] = Column( + Boolean, default=False, server_default="false", nullable=False + ) # Time of the latest publication - updated_at = Column(DateTime(timezone=True), nullable=False) + updated_at: Mapped[datetime.datetime] = Column(DateTime(timezone=True), nullable=False) - archives: List["Archive"] = relationship("Archive", back_populates="answer", order_by="Archive.created_at.desc()") # type: ignore + archives: Mapped[List["Archive"]] = relationship( + "Archive", back_populates="answer", order_by="Archive.created_at.desc()" + ) - comments: List["Comment"] = relationship("Comment", back_populates="answer", order_by="Comment.created_at.asc()") # type: ignore + comments: Mapped[List["Comment"]] = relationship( + "Comment", back_populates="answer", order_by="Comment.created_at.asc()" + ) # If in public site: World visible > registered user visible > [my friends visible -- in future] # If in private site: site members visible > [my friends visible -- in future] # https://stackoverflow.com/questions/37848815/sqlalchemy-postgresql-enum-does-not-create-type-on-db-migrate - visibility: ContentVisibility = Column( + visibility: Mapped[ContentVisibility] = Column( Enum(ContentVisibility), nullable=False, server_default="ANYONE" - ) # type: ignore + ) - suggest_edits: List["AnswerSuggestEdit"] = relationship("AnswerSuggestEdit", back_populates="answer", order_by="AnswerSuggestEdit.created_at.desc()") # type: ignore + suggest_edits: Mapped[List["AnswerSuggestEdit"]] = relationship( + "AnswerSuggestEdit", + back_populates="answer", + order_by="AnswerSuggestEdit.created_at.desc()", + ) - contributors: List["User"] = relationship( # type: ignore + contributors: Mapped[List["User"]] = relationship( "User", secondary=answer_contributors, backref=backref( @@ -105,6 +128,8 @@ class Answer(Base): ), ) - featured_at = Column(DateTime(timezone=True)) + featured_at: Mapped[Optional[datetime.datetime]] = Column(DateTime(timezone=True)) - reports: List["Report"] = relationship("Report", back_populates="answer", order_by="Report.created_at.asc()") # type: ignore + reports: Mapped[List["Report"]] = relationship( + "Report", back_populates="answer", order_by="Report.created_at.asc()" + ) diff --git a/chafan_core/app/models/article.py b/chafan_core/app/models/article.py index 45d684c..f85aafc 100644 --- a/chafan_core/app/models/article.py +++ b/chafan_core/app/models/article.py @@ -1,4 +1,5 @@ -from typing import TYPE_CHECKING, List, Optional +import datetime +from typing import TYPE_CHECKING, Any, List, Optional from sqlalchemy import ( CHAR, @@ -12,7 +13,7 @@ Table, UniqueConstraint, ) -from sqlalchemy.orm import backref, relationship +from sqlalchemy.orm import Mapped, backref, relationship from sqlalchemy.sql.sqltypes import JSON, Enum from chafan_core.db.base_class import Base @@ -37,26 +38,34 @@ class ArticleUpvotes(Base): PrimaryKeyConstraint("article_id", "voter_id"), ) - cancelled = Column(Boolean, server_default="false", default=False, nullable=False) - article_id = Column(Integer, ForeignKey("article.id"), index=True) - article = relationship("Article", foreign_keys=[article_id]) - voter_id = Column(Integer, ForeignKey("user.id"), index=True) - voter = relationship("User", foreign_keys=[voter_id]) + cancelled: Mapped[bool] = Column( + Boolean, server_default="false", default=False, nullable=False + ) + article_id: Mapped[int] = Column(Integer, ForeignKey("article.id"), index=True) + article: Mapped["Article"] = relationship("Article", foreign_keys=[article_id]) + voter_id: Mapped[int] = Column(Integer, ForeignKey("user.id"), index=True) + voter: Mapped["User"] = relationship("User", foreign_keys=[voter_id]) class Article(Base): - id = Column(Integer, primary_key=True, index=True) - uuid = Column(CHAR(length=UUID_LENGTH), index=True, unique=True, nullable=False) - author_id = Column(Integer, ForeignKey("user.id"), nullable=False, index=True) - author = relationship("User", back_populates="articles", foreign_keys=[author_id]) - article_column_id = Column( + id: Mapped[int] = Column(Integer, primary_key=True, index=True) + uuid: Mapped[str] = Column( + CHAR(length=UUID_LENGTH), index=True, unique=True, nullable=False + ) + author_id: Mapped[int] = Column( + Integer, ForeignKey("user.id"), nullable=False, index=True + ) + author: Mapped["User"] = relationship( + "User", back_populates="articles", foreign_keys=[author_id] + ) + article_column_id: Mapped[int] = Column( Integer, ForeignKey("articlecolumn.id"), nullable=False, index=True ) - article_column: "ArticleColumn" = relationship( + article_column: Mapped["ArticleColumn"] = relationship( "ArticleColumn", back_populates="articles", foreign_keys=[article_column_id] - ) # type: ignore + ) - topics: List["Topic"] = relationship( # type: ignore + topics: Mapped[List["Topic"]] = relationship( "Topic", secondary=article_topics, backref=backref( @@ -65,46 +74,56 @@ class Article(Base): ) # content fields - title = Column(String, nullable=False) - title_draft: Optional[StrippedNonEmptyStr] = Column(String) # type: ignore + title: Mapped[str] = Column(String, nullable=False) + title_draft: Mapped[Optional[str]] = Column(String) - body = Column(String, nullable=False) - body_text = Column(String) + body: Mapped[str] = Column(String, nullable=False) + body_text: Mapped[Optional[str]] = Column(String) # Not null only if is_published is `True`, in which case it might contain a working draft version. - body_draft = Column(String) + body_draft: Mapped[Optional[str]] = Column(String) - editor: editor_T = Column(String, nullable=False, server_default="wysiwyg") # type: ignore + editor: Mapped[str] = Column(String, nullable=False, server_default="wysiwyg") - created_at = Column(DateTime(timezone=True), nullable=False) - initial_published_at = Column(DateTime(timezone=True)) - updated_at = Column(DateTime(timezone=True)) # published_at - draft_saved_at = Column(DateTime(timezone=True)) - draft_editor: editor_T = Column(String, nullable=False, server_default="wysiwyg") # type: ignore + created_at: Mapped[datetime.datetime] = Column(DateTime(timezone=True), nullable=False) + initial_published_at: Mapped[Optional[datetime.datetime]] = Column( + DateTime(timezone=True) + ) + updated_at: Mapped[Optional[datetime.datetime]] = Column( + DateTime(timezone=True) + ) # published_at + draft_saved_at: Mapped[Optional[datetime.datetime]] = Column(DateTime(timezone=True)) + draft_editor: Mapped[str] = Column(String, nullable=False, server_default="wysiwyg") - is_published = Column( + is_published: Mapped[bool] = Column( + Boolean, default=False, server_default="false", nullable=False + ) + is_deleted: Mapped[bool] = Column( Boolean, default=False, server_default="false", nullable=False ) - is_deleted = Column(Boolean, default=False, server_default="false", nullable=False) - comments: List["Comment"] = relationship( # type: ignore + comments: Mapped[List["Comment"]] = relationship( "Comment", back_populates="article", order_by="Comment.created_at.asc()" ) - upvotes_count = Column(Integer, default=0, server_default="0", nullable=False) + upvotes_count: Mapped[int] = Column( + Integer, default=0, server_default="0", nullable=False + ) - archives: List["ArticleArchive"] = relationship( + archives: Mapped[List["ArticleArchive"]] = relationship( "ArticleArchive", back_populates="article", order_by="ArticleArchive.created_at.desc()", - ) # type: ignore + ) - visibility = Column( + visibility: Mapped[ContentVisibility] = Column( Enum(ContentVisibility), nullable=False, server_default="ANYONE" ) - keywords: List[str] = Column(JSON) # type: ignore + keywords: Mapped[Optional[Any]] = Column(JSON) - featured_at = Column(DateTime(timezone=True)) + featured_at: Mapped[Optional[datetime.datetime]] = Column(DateTime(timezone=True)) - reports: List["Report"] = relationship("Report", back_populates="article", order_by="Report.created_at.asc()") # type: ignore + reports: Mapped[List["Report"]] = relationship( + "Report", back_populates="article", order_by="Report.created_at.asc()" + ) diff --git a/chafan_core/app/models/comment.py b/chafan_core/app/models/comment.py index 0eba82a..bd2d284 100644 --- a/chafan_core/app/models/comment.py +++ b/chafan_core/app/models/comment.py @@ -1,3 +1,4 @@ +import datetime from typing import TYPE_CHECKING, List, Optional from sqlalchemy import ( @@ -11,7 +12,7 @@ String, UniqueConstraint, ) -from sqlalchemy.orm import relationship +from sqlalchemy.orm import Mapped, relationship from chafan_core.db.base_class import Base from chafan_core.utils.base import UUID_LENGTH @@ -27,55 +28,87 @@ class CommentUpvotes(Base): PrimaryKeyConstraint("comment_id", "voter_id"), ) - cancelled = Column(Boolean, server_default="false", default=False, nullable=False) - comment_id = Column(Integer, ForeignKey("comment.id"), index=True) - comment = relationship("Comment", foreign_keys=[comment_id]) - voter_id = Column(Integer, ForeignKey("user.id"), index=True) - voter = relationship("User", foreign_keys=[voter_id]) + cancelled: Mapped[bool] = Column( + Boolean, server_default="false", default=False, nullable=False + ) + comment_id: Mapped[int] = Column(Integer, ForeignKey("comment.id"), index=True) + comment: Mapped["Comment"] = relationship("Comment", foreign_keys=[comment_id]) + voter_id: Mapped[int] = Column(Integer, ForeignKey("user.id"), index=True) + voter: Mapped["User"] = relationship("User", foreign_keys=[voter_id]) class Comment(Base): - id = Column(Integer, primary_key=True, index=True) - uuid = Column(CHAR(length=UUID_LENGTH), index=True, unique=True, nullable=False) + id: Mapped[int] = Column(Integer, primary_key=True, index=True) + uuid: Mapped[str] = Column( + CHAR(length=UUID_LENGTH), index=True, unique=True, nullable=False + ) - editor: editor_T = Column(String, nullable=False, default="tiptap") # type: ignore + editor: Mapped[str] = Column(String, nullable=False, default="tiptap") - author_id = Column(Integer, ForeignKey("user.id"), nullable=False, index=True) - author = relationship("User", back_populates="comments") + author_id: Mapped[int] = Column( + Integer, ForeignKey("user.id"), nullable=False, index=True + ) + author: Mapped["User"] = relationship("User", back_populates="comments") - site_id = Column(Integer, ForeignKey("site.id"), index=True) - site: Optional["Site"] = relationship("Site", back_populates="comments") # type: ignore + site_id: Mapped[Optional[int]] = Column(Integer, ForeignKey("site.id"), index=True) + site: Mapped[Optional["Site"]] = relationship("Site", back_populates="comments") - question_id = Column(Integer, ForeignKey("question.id"), index=True) - question: Optional["Question"] = relationship("Question", back_populates="comments") # type: ignore + question_id: Mapped[Optional[int]] = Column( + Integer, ForeignKey("question.id"), index=True + ) + question: Mapped[Optional["Question"]] = relationship( + "Question", back_populates="comments" + ) - submission_id = Column(Integer, ForeignKey("submission.id"), index=True) - submission: Optional["Submission"] = relationship("Submission", back_populates="comments") # type: ignore + submission_id: Mapped[Optional[int]] = Column( + Integer, ForeignKey("submission.id"), index=True + ) + submission: Mapped[Optional["Submission"]] = relationship( + "Submission", back_populates="comments" + ) - article_id = Column(Integer, ForeignKey("article.id"), index=True) - article: Optional["Article"] = relationship("Article", back_populates="comments") # type: ignore + article_id: Mapped[Optional[int]] = Column( + Integer, ForeignKey("article.id"), index=True + ) + article: Mapped[Optional["Article"]] = relationship( + "Article", back_populates="comments" + ) - answer_id = Column(Integer, ForeignKey("answer.id"), index=True) - answer: Optional["Answer"] = relationship("Answer", back_populates="comments") # type: ignore + answer_id: Mapped[Optional[int]] = Column( + Integer, ForeignKey("answer.id"), index=True + ) + answer: Mapped[Optional["Answer"]] = relationship( + "Answer", back_populates="comments" + ) - parent_comment_id = Column(Integer, ForeignKey("comment.id"), index=True) - parent_comment: Optional["Comment"] = relationship( + parent_comment_id: Mapped[Optional[int]] = Column( + Integer, ForeignKey("comment.id"), index=True + ) + parent_comment: Mapped[Optional["Comment"]] = relationship( "Comment", back_populates="child_comments", remote_side=[id] - ) # type: ignore + ) # content fields - body = Column(String, nullable=False) - body_text = Column(String, nullable=False) + body: Mapped[str] = Column(String, nullable=False) + body_text: Mapped[str] = Column(String, nullable=False) - created_at = Column(DateTime(timezone=True), nullable=False) - updated_at = Column(DateTime(timezone=True), nullable=False) - is_deleted = Column(Boolean, default=False, server_default="false", nullable=False) + created_at: Mapped[datetime.datetime] = Column(DateTime(timezone=True), nullable=False) + updated_at: Mapped[datetime.datetime] = Column(DateTime(timezone=True), nullable=False) + is_deleted: Mapped[bool] = Column( + Boolean, default=False, server_default="false", nullable=False + ) - child_comments: List["Comment"] = relationship("Comment", back_populates="parent_comment", order_by="Comment.created_at.asc()") # type: ignore - reports: List["Report"] = relationship("Report", back_populates="comment", order_by="Report.created_at.asc()") # type: ignore + child_comments: Mapped[List["Comment"]] = relationship( + "Comment", back_populates="parent_comment", order_by="Comment.created_at.asc()" + ) + reports: Mapped[List["Report"]] = relationship( + "Report", back_populates="comment", order_by="Report.created_at.asc()" + ) - shared_to_timeline = Column( + shared_to_timeline: Mapped[bool] = Column( Boolean, server_default="false", nullable=False, default=False ) - upvotes_count = Column(Integer, default=0, server_default="0", nullable=False) + upvotes_count: Mapped[int] = Column( + Integer, default=0, server_default="0", nullable=False + ) diff --git a/chafan_core/app/models/profile.py b/chafan_core/app/models/profile.py index c5f5006..0ae172c 100644 --- a/chafan_core/app/models/profile.py +++ b/chafan_core/app/models/profile.py @@ -7,7 +7,7 @@ PrimaryKeyConstraint, UniqueConstraint, ) -from sqlalchemy.orm import relationship +from sqlalchemy.orm import Mapped, relationship from chafan_core.db.base_class import Base @@ -21,8 +21,10 @@ class Profile(Base): PrimaryKeyConstraint("owner_id", "site_id"), ) - karma = Column(Integer, nullable=False, server_default="0") - owner_id = Column(Integer, ForeignKey("user.id"), primary_key=True) - owner = relationship("User", back_populates="profiles") - site_id = Column(Integer, ForeignKey("site.id"), primary_key=True, nullable=False) - site: "Site" = relationship("Site", back_populates="profiles") # type: ignore + karma: Mapped[int] = Column(Integer, nullable=False, server_default="0") + owner_id: Mapped[int] = Column(Integer, ForeignKey("user.id"), primary_key=True) + owner: Mapped["User"] = relationship("User", back_populates="profiles") + site_id: Mapped[int] = Column( + Integer, ForeignKey("site.id"), primary_key=True, nullable=False + ) + site: Mapped["Site"] = relationship("Site", back_populates="profiles") diff --git a/chafan_core/app/models/question.py b/chafan_core/app/models/question.py index 23b6df6..f858a19 100644 --- a/chafan_core/app/models/question.py +++ b/chafan_core/app/models/question.py @@ -1,4 +1,5 @@ -from typing import TYPE_CHECKING, List +import datetime +from typing import TYPE_CHECKING, Any, List, Optional from sqlalchemy import ( CHAR, @@ -12,7 +13,7 @@ Table, UniqueConstraint, ) -from sqlalchemy.orm import backref, relationship +from sqlalchemy.orm import Mapped, backref, relationship from sqlalchemy.sql.sqltypes import JSON from chafan_core.db.base_class import Base @@ -36,26 +37,40 @@ class QuestionUpvotes(Base): PrimaryKeyConstraint("question_id", "voter_id"), ) - cancelled = Column(Boolean, server_default="false", default=False, nullable=False) - question_id = Column(Integer, ForeignKey("question.id"), index=True) - question = relationship("Question", foreign_keys=[question_id]) - voter_id = Column(Integer, ForeignKey("user.id"), index=True) - voter = relationship("User", foreign_keys=[voter_id]) + cancelled: Mapped[bool] = Column( + Boolean, server_default="false", default=False, nullable=False + ) + question_id: Mapped[int] = Column(Integer, ForeignKey("question.id"), index=True) + question: Mapped["Question"] = relationship("Question", foreign_keys=[question_id]) + voter_id: Mapped[int] = Column(Integer, ForeignKey("user.id"), index=True) + voter: Mapped["User"] = relationship("User", foreign_keys=[voter_id]) class Question(Base): - id = Column(Integer, primary_key=True, index=True) - uuid = Column(CHAR(length=UUID_LENGTH), index=True, unique=True, nullable=False) + id: Mapped[int] = Column(Integer, primary_key=True, index=True) + uuid: Mapped[str] = Column( + CHAR(length=UUID_LENGTH), index=True, unique=True, nullable=False + ) - site_id = Column(Integer, ForeignKey("site.id"), nullable=False, index=True) - site: "Site" = relationship("Site", back_populates="questions") # type: ignore - author_id = Column(Integer, ForeignKey("user.id"), nullable=False, index=True) - author: "User" = relationship("User", back_populates="questions", foreign_keys=[author_id]) # type: ignore + site_id: Mapped[int] = Column( + Integer, ForeignKey("site.id"), nullable=False, index=True + ) + site: Mapped["Site"] = relationship("Site", back_populates="questions") + author_id: Mapped[int] = Column( + Integer, ForeignKey("user.id"), nullable=False, index=True + ) + author: Mapped["User"] = relationship( + "User", back_populates="questions", foreign_keys=[author_id] + ) - editor_id = Column(Integer, ForeignKey("user.id"), nullable=True, index=True) - editor = relationship("User", back_populates="questions", foreign_keys=[editor_id]) + editor_id: Mapped[Optional[int]] = Column( + Integer, ForeignKey("user.id"), nullable=True, index=True + ) + editor: Mapped[Optional["User"]] = relationship( + "User", back_populates="questions", foreign_keys=[editor_id] + ) - topics: List["Topic"] = relationship( # type: ignore + topics: Mapped[List["Topic"]] = relationship( "Topic", secondary=question_topics, backref=backref( @@ -64,27 +79,29 @@ class Question(Base): ) # content fields - title = Column(String, nullable=False) - description = Column(String) - description_text = Column(String) - description_editor: editor_T = Column(String, nullable=False, default="tiptap") # type: ignore + title: Mapped[str] = Column(String, nullable=False) + description: Mapped[Optional[str]] = Column(String) + description_text: Mapped[Optional[str]] = Column(String) + description_editor: Mapped[str] = Column(String, nullable=False, default="tiptap") - created_at = Column(DateTime(timezone=True), nullable=False) - updated_at = Column(DateTime(timezone=True), nullable=False) + created_at: Mapped[datetime.datetime] = Column(DateTime(timezone=True), nullable=False) + updated_at: Mapped[datetime.datetime] = Column(DateTime(timezone=True), nullable=False) # unlisted - is_hidden = Column(Boolean, server_default="false", nullable=False, default=False) + is_hidden: Mapped[bool] = Column( + Boolean, server_default="false", nullable=False, default=False + ) - keywords = Column(JSON) + keywords: Mapped[Optional[Any]] = Column(JSON) - answers: List["Answer"] = relationship( # type: ignore + answers: Mapped[List["Answer"]] = relationship( "Answer", back_populates="question", order_by="Answer.updated_at.desc()" ) - comments: List["Comment"] = relationship( # type: ignore + comments: Mapped[List["Comment"]] = relationship( "Comment", back_populates="question", order_by="Comment.created_at.asc()" ) - is_placed_at_home = Column( + is_placed_at_home: Mapped[bool] = Column( Boolean, server_default="false", default=False, @@ -92,8 +109,16 @@ class Question(Base): index=True, ) - upvotes_count = Column(Integer, default=0, server_default="0", nullable=False) + upvotes_count: Mapped[int] = Column( + Integer, default=0, server_default="0", nullable=False + ) - archives: List["QuestionArchive"] = relationship("QuestionArchive", back_populates="question", order_by="QuestionArchive.created_at.desc()") # type: ignore + archives: Mapped[List["QuestionArchive"]] = relationship( + "QuestionArchive", + back_populates="question", + order_by="QuestionArchive.created_at.desc()", + ) - reports: List["Report"] = relationship("Report", back_populates="question", order_by="Report.created_at.asc()") # type: ignore + reports: Mapped[List["Report"]] = relationship( + "Report", back_populates="question", order_by="Report.created_at.asc()" + ) diff --git a/chafan_core/app/models/site.py b/chafan_core/app/models/site.py index aac5d04..00e23d5 100644 --- a/chafan_core/app/models/site.py +++ b/chafan_core/app/models/site.py @@ -1,4 +1,5 @@ -from typing import TYPE_CHECKING, List, Optional +import datetime +from typing import TYPE_CHECKING, Any, List, Optional from sqlalchemy import ( CHAR, @@ -10,7 +11,7 @@ String, Table, ) -from sqlalchemy.orm import relationship +from sqlalchemy.orm import Mapped, relationship from sqlalchemy.sql.sqltypes import JSON from chafan_core.db.base_class import Base @@ -28,88 +29,92 @@ class Site(Base): - id = Column(Integer, primary_key=True, index=True) - uuid = Column(CHAR(length=UUID_LENGTH), index=True, unique=True, nullable=False) - subdomain = Column(String, nullable=False, unique=True, index=True) - name = Column(String, unique=True, nullable=False) - created_at = Column(DateTime(timezone=True), nullable=False) - description = Column(String) - topics: List["Topic"] = relationship("Topic", secondary=site_topics) # type: ignore + id: Mapped[int] = Column(Integer, primary_key=True, index=True) + uuid: Mapped[str] = Column( + CHAR(length=UUID_LENGTH), index=True, unique=True, nullable=False + ) + subdomain: Mapped[str] = Column(String, nullable=False, unique=True, index=True) + name: Mapped[str] = Column(String, unique=True, nullable=False) + created_at: Mapped[datetime.datetime] = Column(DateTime(timezone=True), nullable=False) + description: Mapped[Optional[str]] = Column(String) + topics: Mapped[List["Topic"]] = relationship("Topic", secondary=site_topics) - category_topic_id = Column( + category_topic_id: Mapped[Optional[int]] = Column( Integer, ForeignKey("topic.id"), index=True, nullable=True ) - category_topic: Optional["Topic"] = relationship("Topic") # type: ignore + category_topic: Mapped[Optional["Topic"]] = relationship("Topic") # Site policies - public_readable = Column(Boolean, default=False) - public_writable_question = Column( + public_readable: Mapped[bool] = Column(Boolean, default=False) + public_writable_question: Mapped[bool] = Column( Boolean, default=False, server_default="false", nullable=False ) - public_writable_submission = Column( + public_writable_submission: Mapped[bool] = Column( Boolean, default=False, server_default="false", nullable=False ) - public_writable_answer = Column( + public_writable_answer: Mapped[bool] = Column( Boolean, default=False, server_default="false", nullable=False ) - public_writable_comment = Column( + public_writable_comment: Mapped[bool] = Column( Boolean, default=False, server_default="false", nullable=False ) - addable_member = Column( + addable_member: Mapped[bool] = Column( Boolean, default=True, server_default="true", nullable=False ) - create_question_coin_deduction = Column( + create_question_coin_deduction: Mapped[int] = Column( Integer, default=2, server_default="2", nullable=False ) - create_submission_coin_deduction = Column( + create_submission_coin_deduction: Mapped[int] = Column( Integer, default=2, server_default="2", nullable=False ) - create_suggestion_coin_deduction = Column( + create_suggestion_coin_deduction: Mapped[int] = Column( Integer, default=1, server_default="1", nullable=False ) - upvote_answer_coin_deduction = Column( + upvote_answer_coin_deduction: Mapped[int] = Column( Integer, default=2, server_default="2", nullable=False ) - upvote_question_coin_deduction = Column( + upvote_question_coin_deduction: Mapped[int] = Column( Integer, default=1, server_default="1", nullable=False ) - upvote_submission_coin_deduction = Column( + upvote_submission_coin_deduction: Mapped[int] = Column( Integer, default=1, server_default="1", nullable=False ) - moderator_id = Column( + moderator_id: Mapped[int] = Column( Integer, ForeignKey("user.id"), nullable=False, server_default="1" ) - moderator = relationship("User", back_populates="moderated_sites") - questions: List["Question"] = relationship( # type: ignore + moderator: Mapped["User"] = relationship("User", back_populates="moderated_sites") + questions: Mapped[List["Question"]] = relationship( "Question", back_populates="site", order_by="desc(Question.created_at)", lazy="dynamic", ) - submissions: List["Submission"] = relationship( # type: ignore + submissions: Mapped[List["Submission"]] = relationship( "Submission", back_populates="site", order_by="desc(Submission.created_at)", lazy="dynamic", ) - profiles: List["Profile"] = relationship("Profile", back_populates="site") # type: ignore - comments: List["Comment"] = relationship("Comment", back_populates="site") # type: ignore - answers: List["Answer"] = relationship("Answer", back_populates="site") # type: ignore + profiles: Mapped[List["Profile"]] = relationship("Profile", back_populates="site") + comments: Mapped[List["Comment"]] = relationship("Comment", back_populates="site") + answers: Mapped[List["Answer"]] = relationship("Answer", back_populates="site") - applications: List["Application"] = relationship( # type: ignore + applications: Mapped[List["Application"]] = relationship( "Application", back_populates="applied_site", order_by="Application.created_at.desc()", ) # Approval conditions - auto_approval = Column(Boolean, default=True, server_default="true", nullable=False) - min_karma_for_application = Column(Integer) - email_domain_suffix_for_application = Column(String) + auto_approval: Mapped[bool] = Column( + Boolean, default=True, server_default="true", nullable=False + ) + min_karma_for_application: Mapped[Optional[int]] = Column(Integer) + email_domain_suffix_for_application: Mapped[Optional[str]] = Column(String) - webhooks: List["Webhook"] = relationship( # type: ignore + webhooks: Mapped[List["Webhook"]] = relationship( "Webhook", back_populates="site", order_by="Webhook.updated_at.desc()" ) - keywords: List[str] = Column(JSON) # type: ignore + keywords: Mapped[Optional[Any]] = Column(JSON) diff --git a/chafan_core/app/models/submission.py b/chafan_core/app/models/submission.py index 140e03f..fbb21b9 100644 --- a/chafan_core/app/models/submission.py +++ b/chafan_core/app/models/submission.py @@ -1,4 +1,5 @@ -from typing import TYPE_CHECKING, List +import datetime +from typing import TYPE_CHECKING, Any, List, Optional from sqlalchemy import ( CHAR, @@ -12,7 +13,7 @@ Table, UniqueConstraint, ) -from sqlalchemy.orm import backref, relationship +from sqlalchemy.orm import Mapped, backref, relationship from sqlalchemy.sql.sqltypes import JSON from chafan_core.db.base_class import Base @@ -43,26 +44,38 @@ class SubmissionUpvotes(Base): PrimaryKeyConstraint("submission_id", "voter_id"), ) - cancelled = Column(Boolean, server_default="false", default=False, nullable=False) - submission_id = Column(Integer, ForeignKey("submission.id"), index=True) - submission = relationship("Submission", foreign_keys=[submission_id]) - voter_id = Column(Integer, ForeignKey("user.id"), index=True) - voter = relationship("User", foreign_keys=[voter_id]) + cancelled: Mapped[bool] = Column( + Boolean, server_default="false", default=False, nullable=False + ) + submission_id: Mapped[int] = Column( + Integer, ForeignKey("submission.id"), index=True + ) + submission: Mapped["Submission"] = relationship( + "Submission", foreign_keys=[submission_id] + ) + voter_id: Mapped[int] = Column(Integer, ForeignKey("user.id"), index=True) + voter: Mapped["User"] = relationship("User", foreign_keys=[voter_id]) class Submission(Base): - id = Column(Integer, primary_key=True, index=True) - uuid = Column(CHAR(length=UUID_LENGTH), index=True, unique=True, nullable=False) + id: Mapped[int] = Column(Integer, primary_key=True, index=True) + uuid: Mapped[str] = Column( + CHAR(length=UUID_LENGTH), index=True, unique=True, nullable=False + ) - site_id = Column(Integer, ForeignKey("site.id"), nullable=False, index=True) - site: "Site" = relationship("Site", back_populates="submissions") # type: ignore + site_id: Mapped[int] = Column( + Integer, ForeignKey("site.id"), nullable=False, index=True + ) + site: Mapped["Site"] = relationship("Site", back_populates="submissions") - author_id = Column(Integer, ForeignKey("user.id"), nullable=False, index=True) - author: "User" = relationship( + author_id: Mapped[int] = Column( + Integer, ForeignKey("user.id"), nullable=False, index=True + ) + author: Mapped["User"] = relationship( "User", back_populates="submissions", foreign_keys=[author_id] - ) # type: ignore + ) - contributors: List["User"] = relationship( # type: ignore + contributors: Mapped[List["User"]] = relationship( "User", secondary=submission_contributors, backref=backref( @@ -72,7 +85,7 @@ class Submission(Base): ), ) - topics: List["Topic"] = relationship( # type: ignore + topics: Mapped[List["Topic"]] = relationship( "Topic", secondary=submission_topics, backref=backref( @@ -80,28 +93,42 @@ class Submission(Base): ), ) - title = Column(String, nullable=False) + title: Mapped[str] = Column(String, nullable=False) # description XOR url -- see HackerNews - description = Column(String) - description_text = Column(String) - description_editor: editor_T = Column(String, nullable=False, default="tiptap") # type: ignore + description: Mapped[Optional[str]] = Column(String) + description_text: Mapped[Optional[str]] = Column(String) + description_editor: Mapped[str] = Column(String, nullable=False, default="tiptap") - url = Column(String) + url: Mapped[Optional[str]] = Column(String) - keywords = Column(JSON) + keywords: Mapped[Optional[Any]] = Column(JSON) - created_at = Column(DateTime(timezone=True), nullable=False) - updated_at = Column(DateTime(timezone=True), nullable=False) - is_hidden = Column(Boolean, server_default="false", nullable=False, default=False) + created_at: Mapped[datetime.datetime] = Column(DateTime(timezone=True), nullable=False) + updated_at: Mapped[datetime.datetime] = Column(DateTime(timezone=True), nullable=False) + is_hidden: Mapped[bool] = Column( + Boolean, server_default="false", nullable=False, default=False + ) - comments: List["Comment"] = relationship( # type: ignore + comments: Mapped[List["Comment"]] = relationship( "Comment", back_populates="submission", order_by="Comment.created_at.asc()" ) - upvotes_count = Column(Integer, default=0, server_default="0", nullable=False) + upvotes_count: Mapped[int] = Column( + Integer, default=0, server_default="0", nullable=False + ) - archives: List["SubmissionArchive"] = relationship("SubmissionArchive", back_populates="submission", order_by="SubmissionArchive.created_at.desc()") # type: ignore - submission_suggestions: List["SubmissionSuggestion"] = relationship("SubmissionSuggestion", back_populates="submission", order_by="SubmissionSuggestion.created_at.desc()") # type: ignore + archives: Mapped[List["SubmissionArchive"]] = relationship( + "SubmissionArchive", + back_populates="submission", + order_by="SubmissionArchive.created_at.desc()", + ) + submission_suggestions: Mapped[List["SubmissionSuggestion"]] = relationship( + "SubmissionSuggestion", + back_populates="submission", + order_by="SubmissionSuggestion.created_at.desc()", + ) - reports: List["Report"] = relationship("Report", back_populates="submission", order_by="Report.created_at.asc()") # type: ignore + reports: Mapped[List["Report"]] = relationship( + "Report", back_populates="submission", order_by="Report.created_at.asc()" + ) diff --git a/chafan_core/app/models/topic.py b/chafan_core/app/models/topic.py index 52f7209..82c13f1 100644 --- a/chafan_core/app/models/topic.py +++ b/chafan_core/app/models/topic.py @@ -1,7 +1,7 @@ -from typing import TYPE_CHECKING, List +from typing import TYPE_CHECKING, List, Optional from sqlalchemy import CHAR, Column, ForeignKey, Integer, String -from sqlalchemy.orm import relationship +from sqlalchemy.orm import Mapped, relationship from sqlalchemy.sql.sqltypes import Boolean from chafan_core.db.base_class import Base @@ -12,15 +12,21 @@ class Topic(Base): - id = Column(Integer, primary_key=True, index=True) - uuid = Column(CHAR(length=UUID_LENGTH), index=True, unique=True, nullable=False) - name = Column(String, nullable=False) - description = Column(String) - is_category = Column(Boolean, server_default="false") + id: Mapped[int] = Column(Integer, primary_key=True, index=True) + uuid: Mapped[str] = Column( + CHAR(length=UUID_LENGTH), index=True, unique=True, nullable=False + ) + name: Mapped[str] = Column(String, nullable=False) + description: Mapped[Optional[str]] = Column(String) + is_category: Mapped[Optional[bool]] = Column(Boolean, server_default="false") - parent_topic_id = Column(Integer, ForeignKey("topic.id"), index=True) - parent_topic = relationship( + parent_topic_id: Mapped[Optional[int]] = Column( + Integer, ForeignKey("topic.id"), index=True + ) + parent_topic: Mapped[Optional["Topic"]] = relationship( "Topic", back_populates="child_topics", remote_side=[id] ) - child_topics: List["Topic"] = relationship("Topic", back_populates="parent_topic") # type: ignore + child_topics: Mapped[List["Topic"]] = relationship( + "Topic", back_populates="parent_topic" + ) diff --git a/chafan_core/app/models/user.py b/chafan_core/app/models/user.py index 771aab7..526c93b 100755 --- a/chafan_core/app/models/user.py +++ b/chafan_core/app/models/user.py @@ -1,6 +1,6 @@ -from typing import TYPE_CHECKING, List, Literal, Optional +import datetime +from typing import TYPE_CHECKING, Any, List, Optional -from pydantic import AnyHttpUrl from sqlalchemy import ( CHAR, Boolean, @@ -11,7 +11,7 @@ String, Table, ) -from sqlalchemy.orm import backref, relationship +from sqlalchemy.orm import Mapped, backref, relationship from sqlalchemy.sql.sqltypes import JSON from chafan_core.app.models.answer_suggest_edit import AnswerSuggestEdit @@ -27,7 +27,6 @@ from chafan_core.app.models.task import Task from chafan_core.db.base_class import Base from chafan_core.utils.base import UUID_LENGTH -from chafan_core.utils.validators import StrippedNonEmptyBasicStr if TYPE_CHECKING: from . import * # noqa: F401, F403 @@ -98,25 +97,31 @@ class User(Base): - id = Column(Integer, primary_key=True, index=True) - uuid = Column(CHAR(length=UUID_LENGTH), index=True, unique=True, nullable=False) - full_name = Column(String) - handle: "StrippedNonEmptyBasicStr" = Column( - String, unique=True, index=True, nullable=False - ) # type: ignore - email = Column(String, unique=True, index=True, nullable=False) - secondary_emails = Column(JSON, server_default="[]", nullable=False) - - phone_number_country_code = Column(String, unique=True, index=True) - phone_number_subscriber_number = Column(String, unique=True, index=True) - - hashed_password = Column(String, nullable=False) - is_active = Column(Boolean(), server_default="true", nullable=False, default=True) - is_superuser = Column(Boolean(), default=False) - created_at = Column(DateTime(timezone=True), nullable=False) - verified_telegram_user_id = Column(String, nullable=True) - - subscribed_article_columns: List["ArticleColumn"] = relationship( # type: ignore + id: Mapped[int] = Column(Integer, primary_key=True, index=True) + uuid: Mapped[str] = Column( + CHAR(length=UUID_LENGTH), index=True, unique=True, nullable=False + ) + full_name: Mapped[Optional[str]] = Column(String) + handle: Mapped[str] = Column(String, unique=True, index=True, nullable=False) + email: Mapped[str] = Column(String, unique=True, index=True, nullable=False) + secondary_emails: Mapped[Any] = Column(JSON, server_default="[]", nullable=False) + + phone_number_country_code: Mapped[Optional[str]] = Column( + String, unique=True, index=True + ) + phone_number_subscriber_number: Mapped[Optional[str]] = Column( + String, unique=True, index=True + ) + + hashed_password: Mapped[str] = Column(String, nullable=False) + is_active: Mapped[bool] = Column( + Boolean(), server_default="true", nullable=False, default=True + ) + is_superuser: Mapped[bool] = Column(Boolean(), default=False) + created_at: Mapped[datetime.datetime] = Column(DateTime(timezone=True), nullable=False) + verified_telegram_user_id: Mapped[Optional[str]] = Column(String, nullable=True) + + subscribed_article_columns: Mapped[List["ArticleColumn"]] = relationship( "ArticleColumn", secondary=subscribed_article_columns_table, backref=backref("subscribers", lazy="dynamic"), @@ -124,7 +129,7 @@ class User(Base): order_by="ArticleColumn.created_at.desc()", ) - bookmarked_articles: List["Article"] = relationship( # type: ignore + bookmarked_articles: Mapped[List["Article"]] = relationship( "Article", secondary=bookmarked_articles_table, backref=backref("bookmarkers", lazy="dynamic"), @@ -132,7 +137,7 @@ class User(Base): order_by="Article.initial_published_at.desc()", ) - subscribed_questions: List["Question"] = relationship( # type: ignore + subscribed_questions: Mapped[List["Question"]] = relationship( "Question", secondary=subscribed_questions_table, backref=backref("subscribers", lazy="dynamic"), @@ -140,7 +145,7 @@ class User(Base): order_by="Question.created_at.desc()", ) - subscribed_submissions: List["Submission"] = relationship( # type: ignore + subscribed_submissions: Mapped[List["Submission"]] = relationship( "Submission", secondary=subscribed_submissions_table, backref=backref("subscribers", lazy="dynamic"), @@ -148,7 +153,7 @@ class User(Base): order_by="Submission.created_at.desc()", ) - bookmarked_answers: List["Answer"] = relationship( # type: ignore + bookmarked_answers: Mapped[List["Answer"]] = relationship( "Answer", secondary=bookmarked_answers_table, backref=backref("bookmarkers", lazy="dynamic"), @@ -156,40 +161,40 @@ class User(Base): order_by="Answer.updated_at.desc()", ) - subscribed_topics: List["Topic"] = relationship( # type: ignore + subscribed_topics: Mapped[List["Topic"]] = relationship( "Topic", secondary=subscribed_topics_table, backref=backref("subscribers", lazy="dynamic"), ) - residency_topics: List["Topic"] = relationship( # type: ignore + residency_topics: Mapped[List["Topic"]] = relationship( "Topic", secondary=residency_topics_table, backref=backref("residents", lazy="dynamic"), ) - profession_topics: List["Topic"] = relationship( # type: ignore + profession_topics: Mapped[List["Topic"]] = relationship( "Topic", secondary=profession_topics_table, backref=backref("professionals", lazy="dynamic"), ) # TODO: deprecate this - profession_topic_id = Column(Integer, ForeignKey("topic.id")) + profession_topic_id: Mapped[Optional[int]] = Column(Integer, ForeignKey("topic.id")) - work_experiences = Column(JSON) - education_experiences = Column(JSON) - personal_introduction = Column(String) - about = Column(String) # TODO: Add about_text + work_experiences: Mapped[Optional[Any]] = Column(JSON) + education_experiences: Mapped[Optional[Any]] = Column(JSON) + personal_introduction: Mapped[Optional[str]] = Column(String) + about: Mapped[Optional[str]] = Column(String) # TODO: Add about_text # social links - github_username = Column(String) - twitter_username = Column(String) - zhihu_url: Optional[AnyHttpUrl] = Column(String) # type: ignore - linkedin_url: Optional[AnyHttpUrl] = Column(String) # type: ignore - homepage_url: Optional[AnyHttpUrl] = Column(String) # type: ignore + github_username: Mapped[Optional[str]] = Column(String) + twitter_username: Mapped[Optional[str]] = Column(String) + zhihu_url: Mapped[Optional[str]] = Column(String) + linkedin_url: Mapped[Optional[str]] = Column(String) + homepage_url: Mapped[Optional[str]] = Column(String) - followed: List["User"] = relationship( # type: ignore + followed: Mapped[List["User"]] = relationship( "User", secondary=followers, primaryjoin=(followers.c.follower_id == id), @@ -198,151 +203,161 @@ class User(Base): lazy="dynamic", ) - moderated_sites: List["Site"] = relationship("Site", back_populates="moderator") # type: ignore - profiles: List["Profile"] = relationship("Profile", back_populates="owner") # type: ignore + moderated_sites: Mapped[List["Site"]] = relationship( + "Site", back_populates="moderator" + ) + profiles: Mapped[List["Profile"]] = relationship("Profile", back_populates="owner") - feedbacks: List["Feedback"] = relationship( # type: ignore + feedbacks: Mapped[List["Feedback"]] = relationship( "Feedback", back_populates="user", order_by="Feedback.created_at.desc()", foreign_keys=[Feedback.user_id], ) - questions: List["Question"] = relationship( # type: ignore + questions: Mapped[List["Question"]] = relationship( "Question", back_populates="author", order_by="Question.created_at.desc()", foreign_keys=[Question.author_id], ) - submissions: List["Submission"] = relationship( # type: ignore + submissions: Mapped[List["Submission"]] = relationship( "Submission", back_populates="author", order_by="Submission.created_at.desc()", foreign_keys=[Submission.author_id], ) - submission_suggestions: List["SubmissionSuggestion"] = relationship( # type: ignore + submission_suggestions: Mapped[List["SubmissionSuggestion"]] = relationship( "SubmissionSuggestion", back_populates="author", order_by="SubmissionSuggestion.created_at.desc()", foreign_keys=[SubmissionSuggestion.author_id], ) - answer_suggest_edits: List["AnswerSuggestEdit"] = relationship( # type: ignore + answer_suggest_edits: Mapped[List["AnswerSuggestEdit"]] = relationship( "AnswerSuggestEdit", back_populates="author", order_by="AnswerSuggestEdit.created_at.desc()", foreign_keys=[AnswerSuggestEdit.author_id], ) - answers: List["Answer"] = relationship( # type: ignore + answers: Mapped[List["Answer"]] = relationship( "Answer", back_populates="author", order_by="Answer.updated_at.desc()" ) - articles: List["Article"] = relationship( # type: ignore + articles: Mapped[List["Article"]] = relationship( "Article", back_populates="author", order_by="Article.updated_at.desc()" ) - applications: List["Application"] = relationship( # type: ignore + applications: Mapped[List["Application"]] = relationship( "Application", back_populates="applicant", order_by="Application.created_at.desc()", ) - audit_logs: List["AuditLog"] = relationship( # type: ignore + audit_logs: Mapped[List["AuditLog"]] = relationship( "AuditLog", back_populates="user", order_by="AuditLog.created_at.desc()", ) - initiated_tasks: List["Task"] = relationship( # type: ignore + initiated_tasks: Mapped[List["Task"]] = relationship( "Task", back_populates="initiator", order_by="Task.created_at.desc()", ) - article_columns: List["ArticleColumn"] = relationship( # type: ignore + article_columns: Mapped[List["ArticleColumn"]] = relationship( "ArticleColumn", back_populates="owner", order_by="ArticleColumn.created_at.desc()", ) - comments: List["Comment"] = relationship( # type: ignore + comments: Mapped[List["Comment"]] = relationship( "Comment", back_populates="author", order_by="Comment.updated_at.desc()" ) - authored_reports: List["Report"] = relationship( # type: ignore + authored_reports: Mapped[List["Report"]] = relationship( "Report", back_populates="author", order_by="Report.created_at.desc()" ) - messages: List["Message"] = relationship( # type: ignore + messages: Mapped[List["Message"]] = relationship( "Message", back_populates="author", order_by="Message.updated_at.desc()" ) - forms: List["Form"] = relationship( # type: ignore + forms: Mapped[List["Form"]] = relationship( "Form", back_populates="author", order_by="Form.created_at.desc()" ) - form_responses: List["FormResponse"] = relationship( # type: ignore + form_responses: Mapped[List["FormResponse"]] = relationship( "FormResponse", back_populates="response_author", order_by="FormResponse.created_at.desc()", foreign_keys=[FormResponse.response_author_id], ) - notifications: List["Notification"] = relationship( # type: ignore + notifications: Mapped[List["Notification"]] = relationship( "Notification", back_populates="receiver", order_by="Notification.created_at.desc()", ) - outgoing_rewards: List["Reward"] = relationship( # type: ignore + outgoing_rewards: Mapped[List["Reward"]] = relationship( "Reward", back_populates="giver", foreign_keys=[Reward.giver_id], order_by="Reward.created_at.asc()", ) - incoming_rewards: List["Reward"] = relationship( # type: ignore + incoming_rewards: Mapped[List["Reward"]] = relationship( "Reward", back_populates="receiver", foreign_keys=[Reward.receiver_id], order_by="Reward.created_at.asc()", ) - out_coin_payments: List["CoinPayment"] = relationship( # type: ignore + out_coin_payments: Mapped[List["CoinPayment"]] = relationship( "CoinPayment", back_populates="payer", foreign_keys=[CoinPayment.payer_id] ) - in_coin_payments: List["CoinPayment"] = relationship( # type: ignore + in_coin_payments: Mapped[List["CoinPayment"]] = relationship( "CoinPayment", back_populates="payee", foreign_keys=[CoinPayment.payee_id] ) - in_coin_deposits: List["CoinDeposit"] = relationship( # type: ignore + in_coin_deposits: Mapped[List["CoinDeposit"]] = relationship( "CoinDeposit", back_populates="payee", foreign_keys=[CoinDeposit.payee_id] ) - authorized_deposits: List["CoinDeposit"] = relationship( # type: ignore + authorized_deposits: Mapped[List["CoinDeposit"]] = relationship( "CoinDeposit", back_populates="authorizer", foreign_keys=[CoinDeposit.authorizer_id], ) - remaining_coins = Column(Integer, server_default="0", default=0, nullable=False) + remaining_coins: Mapped[int] = Column( + Integer, server_default="0", default=0, nullable=False + ) # Behavior information - sent_new_user_invitataions = Column( + sent_new_user_invitataions: Mapped[int] = Column( Integer, server_default="0", nullable=False, default=0 ) - flags = Column(String) + flags: Mapped[Optional[str]] = Column(String) - avatar_url = Column(String) - gif_avatar_url: Optional[AnyHttpUrl] = Column(String) # type: ignore + avatar_url: Mapped[Optional[str]] = Column(String) + gif_avatar_url: Mapped[Optional[str]] = Column(String) - unsubscribe_token = Column(String) + unsubscribe_token: Mapped[Optional[str]] = Column(String) - karma = Column(Integer, nullable=False, server_default="0") + karma: Mapped[int] = Column(Integer, nullable=False, server_default="0") - claimed_welcome_test_rewards_with_form_response_id = Column( + claimed_welcome_test_rewards_with_form_response_id: Mapped[Optional[int]] = Column( Integer, ForeignKey("formresponse.id"), nullable=True ) # functionality preferences - enable_deliver_unread_notifications = Column( + enable_deliver_unread_notifications: Mapped[bool] = Column( Boolean(), server_default="true", nullable=False, default=True ) - default_editor_mode = Column(String, nullable=False, server_default="wysiwyg") - locale_preference: Optional[Literal["en", "zh"]] = Column(String) # type: ignore + default_editor_mode: Mapped[str] = Column( + String, nullable=False, server_default="wysiwyg" + ) + locale_preference: Mapped[Optional[str]] = Column(String) - feed_settings = Column(JSON, nullable=True) + feed_settings: Mapped[Optional[Any]] = Column(JSON, nullable=True) ######### Derived fields ######### - keywords: Optional[List[str]] = Column(JSON, nullable=True) # type: ignore + keywords: Mapped[Optional[Any]] = Column(JSON, nullable=True) # top-N interesting questions - interesting_question_ids: Optional[List[int]] = Column(JSON, nullable=True) # type: ignore - interesting_question_ids_updated_at = Column(DateTime(timezone=True), nullable=True) + interesting_question_ids: Mapped[Optional[Any]] = Column(JSON, nullable=True) + interesting_question_ids_updated_at: Mapped[Optional[datetime.datetime]] = Column( + DateTime(timezone=True), nullable=True + ) # top-N interesting users - interesting_user_ids: Optional[List[int]] = Column(JSON, nullable=True) # type: ignore - interesting_user_ids_updated_at = Column(DateTime(timezone=True), nullable=True) + interesting_user_ids: Mapped[Optional[Any]] = Column(JSON, nullable=True) + interesting_user_ids_updated_at: Mapped[Optional[datetime.datetime]] = Column( + DateTime(timezone=True), nullable=True + ) diff --git a/chafan_core/app/schemas/article.py b/chafan_core/app/schemas/article.py index 93688ab..8691ed5 100644 --- a/chafan_core/app/schemas/article.py +++ b/chafan_core/app/schemas/article.py @@ -1,7 +1,7 @@ import datetime from typing import List, Optional -from pydantic import BaseModel, validator +from pydantic import BaseModel from chafan_core.app.schemas.article_column import ArticleColumn from chafan_core.app.schemas.comment import Comment, CommentForVisitor @@ -9,7 +9,7 @@ from chafan_core.app.schemas.richtext import RichText from chafan_core.app.schemas.topic import Topic from chafan_core.utils.base import ContentVisibility -from chafan_core.utils.validators import StrippedNonEmptyStr, validate_article_title +from chafan_core.utils.validators import ArticleTitle, StrippedNonEmptyStr # Shared properties @@ -19,32 +19,21 @@ class ArticleBase(BaseModel): # Properties to receive via API on creation class ArticleCreate(ArticleBase): - title: StrippedNonEmptyStr + title: ArticleTitle content: RichText article_column_uuid: str is_published: bool writing_session_uuid: str visibility: ContentVisibility - @validator("title") - def _valid_title(cls, v: str) -> str: - validate_article_title(v) - return v - # Properties to receive via API on update class ArticleUpdate(ArticleBase): - updated_title: StrippedNonEmptyStr + updated_title: ArticleTitle updated_content: RichText is_draft: bool visibility: ContentVisibility - @validator("updated_title") - def _valid_title(cls, v: Optional[str]) -> Optional[str]: - if v is not None: - validate_article_title(v) - return v - # Properties to receive via API on update class ArticleTopicsUpdate(ArticleBase): diff --git a/chafan_core/app/schemas/form.py b/chafan_core/app/schemas/form.py index 1384735..b66e604 100644 --- a/chafan_core/app/schemas/form.py +++ b/chafan_core/app/schemas/form.py @@ -1,7 +1,7 @@ import datetime from typing import List, Literal, Optional, Union -from pydantic import BaseModel, validator +from pydantic import BaseModel, field_validator from chafan_core.app.schemas.preview import UserPreview @@ -63,9 +63,11 @@ class FormCreate(FormBase): title: str form_fields: List[FormField] - @validator("form_fields") + @field_validator("form_fields") + @classmethod def _valid_form_fields(cls, v: List[FormField]) -> List[FormField]: - assert len(set(f.unique_name for f in v)) == len(v) + if len(set(f.unique_name for f in v)) != len(v): + raise ValueError("Form field names must be unique") return v diff --git a/chafan_core/app/schemas/form_response.py b/chafan_core/app/schemas/form_response.py index d17ea62..ff6bda2 100644 --- a/chafan_core/app/schemas/form_response.py +++ b/chafan_core/app/schemas/form_response.py @@ -1,7 +1,7 @@ import datetime from typing import List, Literal, Union -from pydantic import BaseModel, validator +from pydantic import BaseModel, field_validator from chafan_core.app.schemas.form import Form from chafan_core.app.schemas.preview import UserPreview @@ -48,11 +48,13 @@ class FormResponseCreate(FormResponseBase): form_uuid: str response_fields: List[FormResponseField] - @validator("response_fields") + @field_validator("response_fields") + @classmethod def _valid_response_fields( cls, v: List[FormResponseField] ) -> List[FormResponseField]: - assert len(set(f.unique_name for f in v)) == len(v) + if len(set(f.unique_name for f in v)) != len(v): + raise ValueError("Response field names must be unique") return v diff --git a/chafan_core/app/schemas/message.py b/chafan_core/app/schemas/message.py index 20cd356..31b6ea7 100644 --- a/chafan_core/app/schemas/message.py +++ b/chafan_core/app/schemas/message.py @@ -1,20 +1,15 @@ import datetime -from pydantic import BaseModel, validator +from pydantic import BaseModel from chafan_core.app.schemas.preview import UserPreview -from chafan_core.utils.validators import validate_message_body +from chafan_core.utils.validators import MessageBody # Shared properties class MessageBase(BaseModel): channel_id: int - body: str - - @validator("body") - def _valid_body(cls, v: str) -> str: - validate_message_body(v) - return v + body: MessageBody # Properties to receive via API on creation diff --git a/chafan_core/app/schemas/question.py b/chafan_core/app/schemas/question.py index 70383eb..ebfaa62 100644 --- a/chafan_core/app/schemas/question.py +++ b/chafan_core/app/schemas/question.py @@ -1,14 +1,14 @@ import datetime from typing import List, Optional -from pydantic import BaseModel, validator +from pydantic import BaseModel from chafan_core.app.schemas.comment import Comment, CommentForVisitor from chafan_core.app.schemas.preview import UserPreview from chafan_core.app.schemas.richtext import RichText from chafan_core.app.schemas.site import Site from chafan_core.app.schemas.topic import Topic -from chafan_core.utils.validators import StrippedNonEmptyStr, validate_question_title +from chafan_core.utils.validators import QuestionTitle, StrippedNonEmptyStr # Shared properties @@ -19,28 +19,15 @@ class QuestionBase(BaseModel): # Properties to receive via API on creation class QuestionCreate(QuestionBase): site_uuid: str - title: StrippedNonEmptyStr - - @validator("title") - def _valid_title(cls, v: StrippedNonEmptyStr) -> StrippedNonEmptyStr: - validate_question_title(v) - return v + title: QuestionTitle # Properties to receive via API on update class QuestionUpdate(QuestionBase): - title: Optional[StrippedNonEmptyStr] = None + title: Optional[QuestionTitle] = None desc: Optional[RichText] = None topic_uuids: Optional[List[str]] = None - @validator("title") - def _valid_title( - cls, v: Optional[StrippedNonEmptyStr] - ) -> Optional[StrippedNonEmptyStr]: - if v is not None: - validate_question_title(v) - return v - class QuestionInDBBase(QuestionBase): uuid: str diff --git a/chafan_core/app/schemas/reward.py b/chafan_core/app/schemas/reward.py index a6571ec..91e5abf 100644 --- a/chafan_core/app/schemas/reward.py +++ b/chafan_core/app/schemas/reward.py @@ -1,7 +1,7 @@ import datetime -from typing import Literal, Optional +from typing import Annotated, Literal, Optional -from pydantic import BaseModel, validator +from pydantic import BaseModel, Field from chafan_core.app.schemas.preview import UserPreview @@ -22,24 +22,12 @@ class RewardCondition(BaseModel): # Properties to receive via API on creation class RewardCreate(RewardBase): - expired_after_days: int + expired_after_days: Annotated[int, Field(gt=0, description="Expiry days")] receiver_uuid: str - coin_amount: int + coin_amount: Annotated[int, Field(gt=0, description="Coin amount")] note_to_receiver: Optional[str] = None condition: Optional[RewardCondition] = None - @validator("coin_amount") - def _valid_coin_amount(cls, v: int) -> int: - if v <= 0: - raise ValueError("Invalid coin amount.") - return v - - @validator("expired_after_days") - def _valid_expired_after_days(cls, v: int) -> int: - if v <= 0: - raise ValueError("Invalid expiry days.") - return v - class RewardUpdate(BaseModel): pass diff --git a/chafan_core/app/schemas/security.py b/chafan_core/app/schemas/security.py index 675d424..2e4da2f 100644 --- a/chafan_core/app/schemas/security.py +++ b/chafan_core/app/schemas/security.py @@ -1,25 +1,17 @@ from typing import Optional -from pydantic import BaseModel, validator +from pydantic import BaseModel -from chafan_core.utils.validators import CaseInsensitiveEmailStr +from chafan_core.utils.validators import ( + CaseInsensitiveEmailStr, + CountryCode, + SubscriberNumber, +) class IntlPhoneNumber(BaseModel): - country_code: str - subscriber_number: str - - @validator("country_code") - def _valid_country_code(cls, v: str) -> str: - if v.isdigit() and len(v) >= 1 and len(v) <= 3: - return v - raise ValueError(f"Invalid country code: {v}") - - @validator("subscriber_number") - def _valid_subscriber_number(cls, v: str) -> str: - if v.isdigit() and len(v) >= 1 and len(v) <= 12: - return v - raise ValueError(f"Invalid subscriber number: {v}") + country_code: CountryCode + subscriber_number: SubscriberNumber def format_e164(self) -> str: return f"+{self.country_code}{self.subscriber_number}" diff --git a/chafan_core/app/schemas/site.py b/chafan_core/app/schemas/site.py index 027866d..7e41d71 100644 --- a/chafan_core/app/schemas/site.py +++ b/chafan_core/app/schemas/site.py @@ -1,12 +1,12 @@ -from typing import Any, Dict, List, Literal, Optional +import logging +from typing import Any, List, Literal, Optional, Self -from pydantic import BaseModel, validator +from pydantic import BaseModel, model_validator from chafan_core.app.schemas.preview import UserPreview from chafan_core.app.schemas.topic import Topic from chafan_core.utils.validators import StrippedNonEmptyBasicStr, StrippedNonEmptyStr -import logging logger = logging.getLogger(__name__) # Shared properties @@ -63,25 +63,30 @@ class Site(SiteInDBBase): members_count: int category_topic: Optional[Topic] = None - @validator("permission_type") - def get_permission_type(cls, v: Optional[str], values: Dict[str, Any]) -> str: + @model_validator(mode="after") + def compute_permission_type(self) -> Self: if ( - values["public_readable"] - and values["public_writable_question"] - and values["public_writable_answer"] - and values["public_writable_comment"] + self.public_readable + and self.public_writable_question + and self.public_writable_answer + and self.public_writable_comment ): - return "public" - if ( - (not values["public_readable"]) - and (not values["public_writable_question"]) - and (not values["public_writable_answer"]) - and (not values["public_writable_comment"]) + self.permission_type = "public" + elif ( + (not self.public_readable) + and (not self.public_writable_question) + and (not self.public_writable_answer) + and (not self.public_writable_comment) ): - return "private" - logger.error("site permission broken, name={},subdomain={}".format(values["name"], values["subdomain"])) - return "private" - #raise Exception(f"Incompatible site flags: {values}") + self.permission_type = "private" + else: + logger.error( + "site permission broken, name={},subdomain={}".format( + self.name, self.subdomain + ) + ) + self.permission_type = "private" + return self # Additional properties stored in DB diff --git a/chafan_core/app/schemas/submission.py b/chafan_core/app/schemas/submission.py index 1f0e772..da56427 100644 --- a/chafan_core/app/schemas/submission.py +++ b/chafan_core/app/schemas/submission.py @@ -1,7 +1,7 @@ import datetime from typing import List, Optional -from pydantic import BaseModel, validator +from pydantic import BaseModel from pydantic.networks import AnyHttpUrl from chafan_core.app.schemas.comment import Comment, CommentForVisitor @@ -9,7 +9,7 @@ from chafan_core.app.schemas.richtext import RichText from chafan_core.app.schemas.site import Site from chafan_core.app.schemas.topic import Topic -from chafan_core.utils.validators import StrippedNonEmptyStr, validate_submission_title +from chafan_core.utils.validators import StrippedNonEmptyStr, SubmissionTitle # Shared properties @@ -20,29 +20,16 @@ class SubmissionBase(BaseModel): # Properties to receive via API on creation class SubmissionCreate(SubmissionBase): site_uuid: str - title: StrippedNonEmptyStr + title: SubmissionTitle url: Optional[AnyHttpUrl] = None - @validator("title") - def _valid_title(cls, v: str) -> str: - validate_submission_title(v) - return v - # Properties to receive via API on update class SubmissionUpdate(SubmissionBase): - title: Optional[StrippedNonEmptyStr] = None + title: Optional[SubmissionTitle] = None desc: Optional[RichText] = None topic_uuids: Optional[List[str]] = None - @validator("title") - def _valid_title( - cls, v: Optional[StrippedNonEmptyStr] - ) -> Optional[StrippedNonEmptyStr]: - if v is not None: - validate_submission_title(v) - return v - class SubmissionInDB(SubmissionBase): uuid: str diff --git a/chafan_core/app/schemas/user.py b/chafan_core/app/schemas/user.py index c1ef76b..9302cb5 100644 --- a/chafan_core/app/schemas/user.py +++ b/chafan_core/app/schemas/user.py @@ -1,7 +1,7 @@ import datetime from typing import List, Literal, Optional -from pydantic import BaseModel, validator +from pydantic import BaseModel from pydantic.networks import AnyHttpUrl from pydantic.types import SecretStr @@ -17,7 +17,7 @@ CaseInsensitiveEmailStr, StrippedNonEmptyBasicStr, StrippedNonEmptyStr, - validate_password, + ValidPassword, ) @@ -31,14 +31,9 @@ class UserBase(BaseModel): # Properties to receive via API on creation class UserCreate(UserBase): email: CaseInsensitiveEmailStr - password: SecretStr + password: ValidPassword handle: Optional[StrippedNonEmptyBasicStr] - @validator("password") - def _valid_password(cls, v: SecretStr) -> SecretStr: - validate_password(v) - return v - class UserInvite(BaseModel): user_uuid: str @@ -67,15 +62,9 @@ class InviteIn(BaseModel): # Properties to receive via API on update class UserUpdate(UserBase): - password: Optional[SecretStr] = None + password: Optional[ValidPassword] = None handle: Optional[StrippedNonEmptyStr] = None - @validator("password") - def _valid_password(cls, v: Optional[SecretStr]) -> Optional[SecretStr]: - if v is not None: - validate_password(v) - return v - class UserWorkExperience(BaseModel): company_topic: Topic @@ -223,7 +212,7 @@ class UserUpdateMe(BaseModel): handle: Optional[str] = None about: Optional[str] = None default_editor_mode: Optional[editor_T] = None - password: Optional[SecretStr] = None + password: Optional[ValidPassword] = None residency_topic_uuids: Optional[List[str]] = None profession_topic_uuids: Optional[List[str]] = None education_experiences: Optional[List[UserEducationExperienceInternal]] = None @@ -238,9 +227,3 @@ class UserUpdateMe(BaseModel): avatar_url: Optional[AnyHttpUrl] = None gif_avatar_url: Optional[AnyHttpUrl] = None enable_deliver_unread_notifications: Optional[bool] = None - - @validator("password") - def _valid_password(cls, v: Optional[SecretStr]) -> Optional[SecretStr]: - if v is not None: - validate_password(v) - return v diff --git a/chafan_core/db/base_class.py b/chafan_core/db/base_class.py index d41a965..f368135 100644 --- a/chafan_core/db/base_class.py +++ b/chafan_core/db/base_class.py @@ -1,13 +1,13 @@ # flake8: noqa +from typing import Any from sqlalchemy.ext.declarative import declared_attr -from sqlalchemy.orm import DeclarativeBase # type: ignore +from sqlalchemy.orm import DeclarativeBase class Base(DeclarativeBase): - __allow_unmapped__ = True - # Generate __tablename__ automatically - @declared_attr + @declared_attr.directive + @classmethod def __tablename__(cls) -> str: return cls.__name__.lower() diff --git a/chafan_core/db/init_db.py b/chafan_core/db/init_db.py index a6d4529..aec9f80 100644 --- a/chafan_core/db/init_db.py +++ b/chafan_core/db/init_db.py @@ -4,9 +4,6 @@ from chafan_core.app import crud, schemas from chafan_core.app.config import settings from chafan_core.db import base # noqa: F401 -from chafan_core.utils.validators import StrippedNonEmptyBasicStr # noqa: F401 -from chafan_core.utils.validators import StrippedNonEmptyStr - # make sure all SQL Alchemy models are imported (app.db.base) before initializing DB # otherwise, SQL Alchemy might fail to initialize relationships properly # for more details: https://github.com/tiangolo/full-stack-fastapi-postgresql/issues/28 @@ -24,8 +21,8 @@ def init_db(db: Session) -> None: user_in = schemas.UserCreate( email=settings.FIRST_SUPERUSER, password=settings.FIRST_SUPERUSER_PASSWORD, - full_name=StrippedNonEmptyStr("Admin"), + full_name="Admin", is_superuser=True, - handle=StrippedNonEmptyBasicStr("super"), + handle="super", ) user = asyncio.run(crud.user.create(db, obj_in=user_in)) # noqa: F841 diff --git a/chafan_core/tests/utils/user.py b/chafan_core/tests/utils/user.py index 5080322..69b1866 100644 --- a/chafan_core/tests/utils/user.py +++ b/chafan_core/tests/utils/user.py @@ -13,10 +13,7 @@ random_password, random_short_lower_string, ) -from chafan_core.utils.validators import ( - CaseInsensitiveEmailStr, - StrippedNonEmptyBasicStr, -) +from chafan_core.utils.validators import CaseInsensitiveEmailStr def user_authentication_headers( @@ -55,7 +52,7 @@ async def authentication_token_from_email( user_in_create = UserCreate( email=email, password=password, - handle=StrippedNonEmptyBasicStr(email.split("@")[0]), + handle=email.split("@")[0], ) user = await crud.user.create(db, obj_in=user_in_create) else: diff --git a/chafan_core/tests/utils/utils.py b/chafan_core/tests/utils/utils.py index 9de4515..5309e66 100644 --- a/chafan_core/tests/utils/utils.py +++ b/chafan_core/tests/utils/utils.py @@ -8,21 +8,16 @@ from chafan_core.app.config import settings from chafan_core.utils.base import unwrap -from chafan_core.utils.validators import ( - CaseInsensitiveEmailStr, - StrippedNonEmptyBasicStr, -) +from chafan_core.utils.validators import CaseInsensitiveEmailStr EMAIL_TEST_USER = "test@example.com" EMAIL_TEST_MODERATOR = "mod@example.com" -def random_short_lower_string() -> StrippedNonEmptyBasicStr: +def random_short_lower_string() -> str: # Use UUID suffix to ensure uniqueness across parallel tests unique_suffix = uuid.uuid4().hex[:8] - return StrippedNonEmptyBasicStr( - "".join(random.choices(string.ascii_lowercase, k=4)) + unique_suffix - ) + return "".join(random.choices(string.ascii_lowercase, k=4)) + unique_suffix def random_lower_string() -> str: diff --git a/chafan_core/utils/validators.py b/chafan_core/utils/validators.py index 4b47e31..c88101f 100644 --- a/chafan_core/utils/validators.py +++ b/chafan_core/utils/validators.py @@ -63,7 +63,8 @@ def check_password(password: SecretStr) -> None: def validate_StrippedNonEmptyStr(value: str) -> str: stripped = value.strip() - assert len(stripped) > 0, "must be non-empty string" + if len(stripped) == 0: + raise ValueError("must be non-empty string") return stripped @@ -72,7 +73,8 @@ def validate_StrippedNonEmptyStr(value: str) -> str: def validate_StrippedNonEmptyBasicStr(value: str) -> str: stripped = value.strip() - assert len(stripped) > 0, "must be non-empty string" + if len(stripped) == 0: + raise ValueError("must be non-empty string") if not re.fullmatch(r"[a-zA-Z0-9-_]+", stripped): raise ValueError("Only alphanumeric, underscore or hyphen is allowed in ID.") return stripped @@ -87,9 +89,50 @@ def validate_StrippedNonEmptyBasicStr(value: str) -> str: def validate_UUID(value: str) -> str: - assert len(value) == UUID_LENGTH, "invalid UUID length" - assert all([c in _uuid_alphabet for c in value]) + if len(value) != UUID_LENGTH: + raise ValueError("invalid UUID length") + if not all(c in _uuid_alphabet for c in value): + raise ValueError("invalid UUID character") return value -UUID = Annotated[str, validate_UUID] +UUID = Annotated[str, AfterValidator(validate_UUID)] + + +# Title validators as Annotated types +ArticleTitle = Annotated[str, AfterValidator(validate_article_title)] +QuestionTitle = Annotated[str, AfterValidator(validate_question_title)] +SubmissionTitle = Annotated[str, AfterValidator(validate_submission_title)] + +# Body validators as Annotated types +MessageBody = Annotated[str, AfterValidator(validate_message_body)] + +# Password validator as Annotated type +ValidPassword = Annotated[SecretStr, AfterValidator(validate_password)] + + +# Phone number validators +def validate_country_code(v: str) -> str: + if v.isdigit() and len(v) >= 1 and len(v) <= 3: + return v + raise ValueError(f"Invalid country code: {v}") + + +def validate_subscriber_number(v: str) -> str: + if v.isdigit() and len(v) >= 1 and len(v) <= 12: + return v + raise ValueError(f"Invalid subscriber number: {v}") + + +CountryCode = Annotated[str, AfterValidator(validate_country_code)] +SubscriberNumber = Annotated[str, AfterValidator(validate_subscriber_number)] + + +# Positive integer validator +def validate_positive_int(v: int) -> int: + if v <= 0: + raise ValueError("Value must be positive.") + return v + + +PositiveInt = Annotated[int, AfterValidator(validate_positive_int)] diff --git a/mypy.ini b/mypy.ini index 66d514f..6056012 100644 --- a/mypy.ini +++ b/mypy.ini @@ -1,17 +1,14 @@ [mypy] -plugins = pydantic.mypy -;, sqlmypy +plugins = pydantic.mypy strict_optional = True follow_imports = silent warn_redundant_casts = True warn_unused_ignores = True -; disallow_any_generics = True check_untyped_defs = True -; no_implicit_reexport = True -# for strict mypy: (this is the tricky one :-)) +# for strict mypy disallow_untyped_defs = True [pydantic-mypy] @@ -19,3 +16,27 @@ init_forbid_extra = True init_typed = True warn_required_dynamic_aliases = True warn_untyped_fields = True + +# Ignore missing stubs for third-party libraries +[mypy-pytz.*] +ignore_missing_imports = True + +[mypy-boto3.*] +ignore_missing_imports = True + +[mypy-feedgen.*] +ignore_missing_imports = True + +[mypy-redis.*] +ignore_missing_imports = True + +[mypy-jose.*] +ignore_missing_imports = True + +[mypy-apscheduler.*] +ignore_missing_imports = True + +# Relax type checking for test files +[mypy-chafan_core.tests.*] +disallow_untyped_defs = False +check_untyped_defs = False diff --git a/scripts/static_analysis/lint.sh b/scripts/static_analysis/lint.sh index 0711000..8713122 100755 --- a/scripts/static_analysis/lint.sh +++ b/scripts/static_analysis/lint.sh @@ -2,7 +2,7 @@ set -xe -mypy chafan_core || true -black chafan_core --check || true -isort --check-only --skip chafan_core/e2e_tests/main.py chafan_core || true -flake8 chafan_core --max-line-length 99 --select=E9,E63,F7,F82 || true +mypy chafan_core +black chafan_core --check +isort --check-only --skip chafan_core/e2e_tests/main.py chafan_core +flake8 chafan_core --max-line-length 99 --select=E9,E63,F7,F82