From 076da9c445b99254d2122b82fc74f44777203046 Mon Sep 17 00:00:00 2001 From: loki Date: Sat, 6 Sep 2025 01:30:54 +0300 Subject: [PATCH 01/34] pre --- blog/domain/__init__.py | 0 blog/domain/category.py | 22 ++++++++ blog/domain/post.py | 35 ++++++++++++ blog/domain/tag.py | 21 +++++++ blog/domain/user.py | 27 +++++++++ blog/repos/__init__.py | 1 + blog/repos/category.py | 85 ++++++++++++++++++++++++++++ blog/repos/post.py | 113 ++++++++++++++++++++++++++++++++++++++ blog/repos/tag.py | 82 +++++++++++++++++++++++++++ blog/repos/user.py | 99 +++++++++++++++++++++++++++++++++ blog/services/__init__.py | 0 blog/services/post.py | 44 +++++++++++++++ 12 files changed, 529 insertions(+) create mode 100644 blog/domain/__init__.py create mode 100644 blog/domain/category.py create mode 100644 blog/domain/post.py create mode 100644 blog/domain/tag.py create mode 100644 blog/domain/user.py create mode 100644 blog/repos/__init__.py create mode 100644 blog/repos/category.py create mode 100644 blog/repos/post.py create mode 100644 blog/repos/tag.py create mode 100644 blog/repos/user.py create mode 100644 blog/services/__init__.py create mode 100644 blog/services/post.py diff --git a/blog/domain/__init__.py b/blog/domain/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/blog/domain/category.py b/blog/domain/category.py new file mode 100644 index 0000000..fd1d78a --- /dev/null +++ b/blog/domain/category.py @@ -0,0 +1,22 @@ +"""Domain models for the Category entity.""" + +from dataclasses import dataclass +import typing + +from blog.post.models import Post + + +@dataclass +class Category: + """Domain model for blog category.""" + + id: int | None = None + title: str = "" + alias: str = "" + template: str | None = None + + posts: list["Post"] | None = None + + @typing.override + def __str__(self): + return f"Category(id={self.id}, title={self.title}, template={self.template})" diff --git a/blog/domain/post.py b/blog/domain/post.py new file mode 100644 index 0000000..591119a --- /dev/null +++ b/blog/domain/post.py @@ -0,0 +1,35 @@ +"""Domain models for the Post entity.""" + +from dataclasses import dataclass +from typing import Optional, List +import datetime + +from blog.category.models import Category +from blog.tags.models import Tag +from blog.user.models import User + + +@dataclass +class Post: + """Domain model for blog post.""" + + id: Optional[int] = None + pagetitle: str = "" + alias: str = "" + content: str = "" + createdon: Optional[datetime.datetime] = None + publishedon: Optional[datetime.datetime] = None + category_id: Optional[int] = None + user_id: Optional[int] = None + + # These would typically be loaded separately in a real implementation + # to avoid circular dependencies + user: Optional[User] = None + category: Optional[Category] = None + tags: Optional[List[Tag]] = None + + def __post_init__(self): + if self.createdon is None: + self.createdon = datetime.datetime.now(datetime.timezone.utc) + if self.publishedon is None: + self.publishedon = datetime.datetime.now(datetime.timezone.utc) diff --git a/blog/domain/tag.py b/blog/domain/tag.py new file mode 100644 index 0000000..e9b48fa --- /dev/null +++ b/blog/domain/tag.py @@ -0,0 +1,21 @@ +"""Domain models for the Tag entity.""" + +from dataclasses import dataclass +import typing + +from blog.post.models import Post + + +@dataclass +class Tag: + """Domain model for blog tag.""" + + id: int | None = None + title: str = "" + alias: str = "" + + posts: list["Post"] | None = None + + @typing.override + def __str__(self): + return f"{self.title}" diff --git a/blog/domain/user.py b/blog/domain/user.py new file mode 100644 index 0000000..5524137 --- /dev/null +++ b/blog/domain/user.py @@ -0,0 +1,27 @@ +"""Domain models for the User entity.""" + +import datetime +import typing +from dataclasses import dataclass + +from blog.post.models import Post + + +@dataclass +class User: + """Domain model for user.""" + + id: int | None = None + name: str = "" + password: str = "" + authenticated: bool = False + createdon: datetime.datetime | None = None + posts: list["Post"] | None = None + + def __post_init__(self): + if self.createdon is None: + self.createdon = datetime.datetime.now(datetime.timezone.utc) + + @typing.override + def __str__(self): + return f"{self.name}" diff --git a/blog/repos/__init__.py b/blog/repos/__init__.py new file mode 100644 index 0000000..1c40bc9 --- /dev/null +++ b/blog/repos/__init__.py @@ -0,0 +1 @@ +"""Repository package for the application.""" diff --git a/blog/repos/category.py b/blog/repos/category.py new file mode 100644 index 0000000..7fd8c3e --- /dev/null +++ b/blog/repos/category.py @@ -0,0 +1,85 @@ +"""Repository for Category entities.""" + +import sqlalchemy as sa +from sqlalchemy.orm import Session + +from blog.extensions import db +from blog.category.models import Category as CategoryORM +from blog.domain.category import Category + + +class CategoryRepository: + """Repository for Category entities.""" + + def __init__(self, session: Session | None = None): + self.session = session or db.session + + def get_by_id(self, category_id: int) -> Category | None: + """Get a category by its ID.""" + stmt = sa.select(CategoryORM).where(CategoryORM.id == category_id) + category_orm = self.session.scalar(stmt) + if category_orm: + return self._to_domain_model(category_orm) + return None + + def get_by_alias(self, alias: str) -> Category | None: + """Get a category by its alias.""" + stmt = sa.select(CategoryORM).where(CategoryORM.alias == alias) + category_orm = self.session.scalar(stmt) + if category_orm: + return self._to_domain_model(category_orm) + return None + + def get_all(self) -> list[Category]: + """Get all categories.""" + stmt = sa.select(CategoryORM) + categories_orm = self.session.scalars(stmt).all() + return [self._to_domain_model(category_orm) for category_orm in categories_orm] + + def get_categories_with_posts(self) -> list[Category]: + """Get all categories with their posts.""" + stmt = sa.select(CategoryORM).options(sa.orm.joinedload(CategoryORM.posts)) + categories_orm = self.session.scalars(stmt).unique().all() + return [self._to_domain_model(category_orm) for category_orm in categories_orm] + + def create(self, category: Category) -> Category: + """Create a new category.""" + category_orm = CategoryORM() + category_orm.title = category.title + category_orm.alias = category.alias + category_orm.template = category.template + self.session.add(category_orm) + self.session.flush() # Get the ID without committing + category.id = category_orm.id + return category + + def update(self, category: Category) -> Category: + """Update an existing category.""" + stmt = sa.select(CategoryORM).where(CategoryORM.id == category.id) + category_orm = self.session.scalar(stmt) + if not category_orm: + raise ValueError(f"Category with id {category.id} not found") + + category_orm.title = category.title + category_orm.alias = category.alias + category_orm.template = category.template + self.session.flush() + return category + + def delete(self, category_id: int) -> bool: + """Delete a category by its ID.""" + stmt = sa.select(CategoryORM).where(CategoryORM.id == category_id) + category_orm = self.session.scalar(stmt) + if category_orm: + self.session.delete(category_orm) + return True + return False + + def _to_domain_model(self, category_orm: CategoryORM) -> Category: + """Convert ORM model to domain model.""" + return Category( + id=category_orm.id, + title=category_orm.title, + alias=category_orm.alias, + template=category_orm.template, + ) diff --git a/blog/repos/post.py b/blog/repos/post.py new file mode 100644 index 0000000..c634ba8 --- /dev/null +++ b/blog/repos/post.py @@ -0,0 +1,113 @@ +"""Repository for Post entities.""" + +from typing import List, Optional + +import sqlalchemy as sa +from sqlalchemy.orm import Session + +from blog.extensions import db +from blog.post.models import Post as PostORM +from blog.domain.post import Post + + +class PostRepository: + """Repository for Post entities.""" + + def __init__(self, session: Optional[Session] = None): + self.session = session or db.session + + def get_by_id(self, post_id: int) -> Optional[Post]: + """Get a post by its ID.""" + stmt = sa.select(PostORM).where(PostORM.id == post_id) + post_orm = self.session.scalar(stmt) + if post_orm: + return self._to_domain_model(post_orm) + return None + + def get_by_alias(self, alias: str) -> Optional[Post]: + """Get a post by its alias.""" + stmt = sa.select(PostORM).where(PostORM.alias == alias) + post_orm = self.session.scalar(stmt) + if post_orm: + return self._to_domain_model(post_orm) + return None + + def get_all(self) -> List[Post]: + """Get all posts.""" + stmt = sa.select(PostORM) + posts_orm = self.session.scalars(stmt).all() + return [self._to_domain_model(post_orm) for post_orm in posts_orm] + + def get_published_posts(self) -> List[Post]: + """Get all published posts ordered by published date.""" + stmt = ( + sa.select(PostORM) + .where( + PostORM.publishedon.isnot(None), + PostORM.category_id.is_(None), + ) + .order_by(PostORM.publishedon.desc()) + ) + posts_orm = self.session.scalars(stmt).all() + return [self._to_domain_model(post_orm) for post_orm in posts_orm] + + def get_page_posts(self, page_category_ids: List[int]) -> List[Post]: + """Get posts that are pages (in specific categories).""" + stmt = sa.select(PostORM).where(PostORM.category_id.in_(page_category_ids)) + posts_orm = self.session.scalars(stmt).all() + return [self._to_domain_model(post_orm) for post_orm in posts_orm] + + def create(self, post: Post) -> Post: + """Create a new post.""" + post_orm = PostORM( + pagetitle=post.pagetitle, + alias=post.alias, + content=post.content, + createdon=post.createdon, + publishedon=post.publishedon, + category_id=post.category_id, + user_id=post.user_id, + ) + self.session.add(post_orm) + self.session.flush() # Get the ID without committing + post.id = post_orm.id + return post + + def update(self, post: Post) -> Post: + """Update an existing post.""" + stmt = sa.select(PostORM).where(PostORM.id == post.id) + post_orm = self.session.scalar(stmt) + if not post_orm: + raise ValueError(f"Post with id {post.id} not found") + + post_orm.pagetitle = post.pagetitle + post_orm.alias = post.alias + post_orm.content = post.content + post_orm.createdon = post.createdon + post_orm.publishedon = post.publishedon + post_orm.category_id = post.category_id + post_orm.user_id = post.user_id + self.session.flush() + return post + + def delete(self, post_id: int) -> bool: + """Delete a post by its ID.""" + stmt = sa.select(PostORM).where(PostORM.id == post_id) + post_orm = self.session.scalar(stmt) + if post_orm: + self.session.delete(post_orm) + return True + return False + + def _to_domain_model(self, post_orm: PostORM) -> Post: + """Convert ORM model to domain model.""" + return Post( + id=post_orm.id, + pagetitle=post_orm.pagetitle, + alias=post_orm.alias, + content=post_orm.content, + createdon=post_orm.createdon, + publishedon=post_orm.publishedon, + category_id=post_orm.category_id, + user_id=post_orm.user_id, + ) diff --git a/blog/repos/tag.py b/blog/repos/tag.py new file mode 100644 index 0000000..6252c3f --- /dev/null +++ b/blog/repos/tag.py @@ -0,0 +1,82 @@ +"""Repository for Tag entities.""" + +from typing import List, Optional + +import sqlalchemy as sa +from sqlalchemy.orm import Session + +from blog.extensions import db +from blog.tags.models import Tag as TagORM +from blog.domain.tag import Tag + + +class TagRepository: + """Repository for Tag entities.""" + + def __init__(self, session: Optional[Session] = None): + self.session = session or db.session + + def get_by_id(self, tag_id: int) -> Optional[Tag]: + """Get a tag by its ID.""" + stmt = sa.select(TagORM).where(TagORM.id == tag_id) + tag_orm = self.session.scalar(stmt) + if tag_orm: + return self._to_domain_model(tag_orm) + return None + + def get_by_alias(self, alias: str) -> Optional[Tag]: + """Get a tag by its alias.""" + stmt = sa.select(TagORM).where(TagORM.alias == alias) + tag_orm = self.session.scalar(stmt) + if tag_orm: + return self._to_domain_model(tag_orm) + return None + + def get_all(self) -> List[Tag]: + """Get all tags.""" + stmt = sa.select(TagORM) + tags_orm = self.session.scalars(stmt).all() + return [self._to_domain_model(tag_orm) for tag_orm in tags_orm] + + def get_tags_with_posts(self) -> List[Tag]: + """Get all tags with their posts.""" + stmt = sa.select(TagORM).options(sa.orm.joinedload(TagORM.posts)) + tags_orm = self.session.scalars(stmt).unique().all() + return [self._to_domain_model(tag_orm) for tag_orm in tags_orm] + + def create(self, tag: Tag) -> Tag: + """Create a new tag.""" + tag_orm = TagORM(title=tag.title, alias=tag.alias) + self.session.add(tag_orm) + self.session.flush() # Get the ID without committing + tag.id = tag_orm.id + return tag + + def update(self, tag: Tag) -> Tag: + """Update an existing tag.""" + stmt = sa.select(TagORM).where(TagORM.id == tag.id) + tag_orm = self.session.scalar(stmt) + if not tag_orm: + raise ValueError(f"Tag with id {tag.id} not found") + + tag_orm.title = tag.title + tag_orm.alias = tag.alias + self.session.flush() + return tag + + def delete(self, tag_id: int) -> bool: + """Delete a tag by its ID.""" + stmt = sa.select(TagORM).where(TagORM.id == tag_id) + tag_orm = self.session.scalar(stmt) + if tag_orm: + self.session.delete(tag_orm) + return True + return False + + def _to_domain_model(self, tag_orm: TagORM) -> Tag: + """Convert ORM model to domain model.""" + return Tag( + id=tag_orm.id, + title=tag_orm.title, + alias=tag_orm.alias, + ) diff --git a/blog/repos/user.py b/blog/repos/user.py new file mode 100644 index 0000000..94236b7 --- /dev/null +++ b/blog/repos/user.py @@ -0,0 +1,99 @@ +"""Repository for User entities.""" + +from typing import List, Optional + +import sqlalchemy as sa +from sqlalchemy.orm import Session + +from blog.extensions import db +from blog.user.models import User as UserORM +from blog.domain.user import User + + +class UserRepository: + """Repository for User entities.""" + + def __init__(self, session: Optional[Session] = None): + self.session = session or db.session + + def get_by_id(self, user_id: int) -> Optional[User]: + """Get a user by its ID.""" + stmt = sa.select(UserORM).where(UserORM.id == user_id) + user_orm = self.session.scalar(stmt) + if user_orm: + return self._to_domain_model(user_orm) + return None + + def get_by_name(self, name: str) -> Optional[User]: + """Get a user by their name.""" + stmt = sa.select(UserORM).where(UserORM.name == name) + user_orm = self.session.scalar(stmt) + if user_orm: + return self._to_domain_model(user_orm) + return None + + def get_all(self) -> List[User]: + """Get all users.""" + stmt = sa.select(UserORM) + users_orm = self.session.scalars(stmt).all() + return [self._to_domain_model(user_orm) for user_orm in users_orm] + + def get_users_with_posts(self) -> List[User]: + """Get all users with their posts.""" + stmt = sa.select(UserORM).options(sa.orm.joinedload(UserORM.posts)) + users_orm = self.session.scalars(stmt).unique().all() + return [self._to_domain_model(user_orm) for user_orm in users_orm] + + def create(self, user: User) -> User: + """Create a new user.""" + user_orm = UserORM( + name=user.name, + password=user.password, + authenticated=user.authenticated, + createdon=user.createdon, + ) + self.session.add(user_orm) + self.session.flush() # Get the ID without committing + user.id = user_orm.id + return user + + def update(self, user: User) -> User: + """Update an existing user.""" + stmt = sa.select(UserORM).where(UserORM.id == user.id) + user_orm = self.session.scalar(stmt) + if not user_orm: + raise ValueError(f"User with id {user.id} not found") + + user_orm.name = user.name + user_orm.password = user.password + user_orm.authenticated = user.authenticated + user_orm.createdon = user.createdon + self.session.flush() + return user + + def delete(self, user_id: int) -> bool: + """Delete a user by its ID.""" + stmt = sa.select(UserORM).where(UserORM.id == user_id) + user_orm = self.session.scalar(stmt) + if user_orm: + self.session.delete(user_orm) + return True + return False + + def authenticate(self, name: str, password: str) -> Optional[User]: + """Authenticate a user by name and password.""" + stmt = sa.select(UserORM).where(UserORM.name == name) + user_orm = self.session.scalar(stmt) + if user_orm and user_orm.check_password(password): + return self._to_domain_model(user_orm) + return None + + def _to_domain_model(self, user_orm: UserORM) -> User: + """Convert ORM model to domain model.""" + return User( + id=user_orm.id, + name=user_orm.name, + password=user_orm.password, + authenticated=user_orm.authenticated, + createdon=user_orm.createdon, + ) diff --git a/blog/services/__init__.py b/blog/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/blog/services/post.py b/blog/services/post.py new file mode 100644 index 0000000..479e54e --- /dev/null +++ b/blog/services/post.py @@ -0,0 +1,44 @@ +"""Service layer for Post entities.""" + +from typing import List, Optional +from blog.repos.post import PostRepository +from blog.domain.post import Post + + +class PostService: + """Service layer for Post entities.""" + + def __init__(self, post_repository: PostRepository): + self.post_repository = post_repository + + def get_post_by_id(self, post_id: int) -> Optional[Post]: + """Get a post by its ID.""" + return self.post_repository.get_by_id(post_id) + + def get_post_by_alias(self, alias: str) -> Optional[Post]: + """Get a post by its alias.""" + return self.post_repository.get_by_alias(alias) + + def get_all_posts(self) -> List[Post]: + """Get all posts.""" + return self.post_repository.get_all() + + def get_published_posts(self) -> List[Post]: + """Get all published posts.""" + return self.post_repository.get_published_posts() + + def get_page_posts(self, page_category_ids: List[int]) -> List[Post]: + """Get posts that are pages (in specific categories).""" + return self.post_repository.get_page_posts(page_category_ids) + + def create_post(self, post: Post) -> Post: + """Create a new post.""" + return self.post_repository.create(post) + + def update_post(self, post: Post) -> Post: + """Update an existing post.""" + return self.post_repository.update(post) + + def delete_post(self, post_id: int) -> bool: + """Delete a post by its ID.""" + return self.post_repository.delete(post_id) From d425cb0ddb7107e88d47f40597617d5fbec25efe Mon Sep 17 00:00:00 2001 From: loki Date: Sat, 6 Sep 2025 01:52:44 +0300 Subject: [PATCH 02/34] passing --- blog/category/models.py | 2 +- blog/domain/category.py | 6 +++-- blog/domain/post.py | 25 +++++++++++---------- blog/domain/tag.py | 6 +++-- blog/domain/user.py | 6 +++-- blog/post/models.py | 10 +++++---- blog/repos/category.py | 8 +++++-- blog/repos/post.py | 49 +++++++++++++++++++++++------------------ blog/repos/tag.py | 16 +++++++------- blog/repos/user.py | 41 ++++++++++++++++++++-------------- blog/tags/models.py | 4 ++-- blog/user/models.py | 6 ++--- 12 files changed, 104 insertions(+), 75 deletions(-) diff --git a/blog/category/models.py b/blog/category/models.py index 5c08ef9..7faf1f3 100644 --- a/blog/category/models.py +++ b/blog/category/models.py @@ -18,7 +18,7 @@ class Category(db.Model): title: Mapped[str] = mapped_column(default="") alias: Mapped[str] = mapped_column(unique=True) posts: Mapped[list["Post"]] = relationship() - template: Mapped[str] = mapped_column(nullable=True) + template: Mapped[str | None] = mapped_column(nullable=True) def __str__(self): return f"Category(id={self.id}, title={self.title}, template={self.template})" diff --git a/blog/domain/category.py b/blog/domain/category.py index fd1d78a..6c145f7 100644 --- a/blog/domain/category.py +++ b/blog/domain/category.py @@ -2,8 +2,10 @@ from dataclasses import dataclass import typing +from typing import TYPE_CHECKING -from blog.post.models import Post +if TYPE_CHECKING: + from blog.post.models import Post @dataclass @@ -15,7 +17,7 @@ class Category: alias: str = "" template: str | None = None - posts: list["Post"] | None = None + posts: "list[Post] | None" = None @typing.override def __str__(self): diff --git a/blog/domain/post.py b/blog/domain/post.py index 591119a..b48624f 100644 --- a/blog/domain/post.py +++ b/blog/domain/post.py @@ -1,32 +1,33 @@ """Domain models for the Post entity.""" from dataclasses import dataclass -from typing import Optional, List import datetime +from typing import TYPE_CHECKING -from blog.category.models import Category -from blog.tags.models import Tag -from blog.user.models import User +if TYPE_CHECKING: + from blog.category.models import Category + from blog.tags.models import Tag + from blog.user.models import User @dataclass class Post: """Domain model for blog post.""" - id: Optional[int] = None + id: int | None = None pagetitle: str = "" alias: str = "" content: str = "" - createdon: Optional[datetime.datetime] = None - publishedon: Optional[datetime.datetime] = None - category_id: Optional[int] = None - user_id: Optional[int] = None + createdon: datetime.datetime | None = None + publishedon: datetime.datetime | None = None + category_id: int | None = None + user_id: int | None = None # These would typically be loaded separately in a real implementation # to avoid circular dependencies - user: Optional[User] = None - category: Optional[Category] = None - tags: Optional[List[Tag]] = None + user: "User | None" = None + category: "Category | None" = None + tags: "list[Tag] | None" = None def __post_init__(self): if self.createdon is None: diff --git a/blog/domain/tag.py b/blog/domain/tag.py index e9b48fa..98c753b 100644 --- a/blog/domain/tag.py +++ b/blog/domain/tag.py @@ -2,8 +2,10 @@ from dataclasses import dataclass import typing +from typing import TYPE_CHECKING -from blog.post.models import Post +if TYPE_CHECKING: + from blog.post.models import Post @dataclass @@ -14,7 +16,7 @@ class Tag: title: str = "" alias: str = "" - posts: list["Post"] | None = None + posts: "list[Post] | None" = None @typing.override def __str__(self): diff --git a/blog/domain/user.py b/blog/domain/user.py index 5524137..0d7aec3 100644 --- a/blog/domain/user.py +++ b/blog/domain/user.py @@ -3,8 +3,10 @@ import datetime import typing from dataclasses import dataclass +from typing import TYPE_CHECKING -from blog.post.models import Post +if TYPE_CHECKING: + from blog.post.models import Post @dataclass @@ -16,7 +18,7 @@ class User: password: str = "" authenticated: bool = False createdon: datetime.datetime | None = None - posts: list["Post"] | None = None + posts: "list[Post] | None" = None def __post_init__(self): if self.createdon is None: diff --git a/blog/post/models.py b/blog/post/models.py index f5dd3d0..1d8e5b5 100644 --- a/blog/post/models.py +++ b/blog/post/models.py @@ -1,7 +1,7 @@ # blog/posts/models.py import datetime -from typing import TYPE_CHECKING import typing +from typing import TYPE_CHECKING import markdown from sqlalchemy import DateTime, ForeignKey, Text @@ -32,13 +32,15 @@ class Post(db.Model): createdon: Mapped[datetime.datetime] = mapped_column( DateTime(timezone=True), server_default=func.now() ) - publishedon: Mapped[datetime.datetime] = mapped_column( + publishedon: Mapped[datetime.datetime | None] = mapped_column( DateTime(timezone=True), server_default=func.now(), nullable=True, ) - category_id: Mapped[int] = mapped_column(ForeignKey("categories.id"), nullable=True) - user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), nullable=True) + category_id: Mapped[int | None] = mapped_column( + ForeignKey("categories.id"), nullable=True + ) + user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True) user: Mapped["User"] = relationship(back_populates="posts") category: Mapped["Category"] = relationship(back_populates="posts") tags: Mapped[list["Tag"]] = relationship( diff --git a/blog/repos/category.py b/blog/repos/category.py index 7fd8c3e..4f889c6 100644 --- a/blog/repos/category.py +++ b/blog/repos/category.py @@ -47,7 +47,9 @@ def create(self, category: Category) -> Category: category_orm = CategoryORM() category_orm.title = category.title category_orm.alias = category.alias - category_orm.template = category.template + # Handle the case where template might be None + if category.template is not None: + category_orm.template = category.template self.session.add(category_orm) self.session.flush() # Get the ID without committing category.id = category_orm.id @@ -62,7 +64,9 @@ def update(self, category: Category) -> Category: category_orm.title = category.title category_orm.alias = category.alias - category_orm.template = category.template + # Handle the case where template might be None + if category.template is not None: + category_orm.template = category.template self.session.flush() return category diff --git a/blog/repos/post.py b/blog/repos/post.py index c634ba8..344d693 100644 --- a/blog/repos/post.py +++ b/blog/repos/post.py @@ -1,7 +1,5 @@ """Repository for Post entities.""" -from typing import List, Optional - import sqlalchemy as sa from sqlalchemy.orm import Session @@ -13,10 +11,10 @@ class PostRepository: """Repository for Post entities.""" - def __init__(self, session: Optional[Session] = None): + def __init__(self, session: Session | None = None): self.session = session or db.session - def get_by_id(self, post_id: int) -> Optional[Post]: + def get_by_id(self, post_id: int) -> Post | None: """Get a post by its ID.""" stmt = sa.select(PostORM).where(PostORM.id == post_id) post_orm = self.session.scalar(stmt) @@ -24,7 +22,7 @@ def get_by_id(self, post_id: int) -> Optional[Post]: return self._to_domain_model(post_orm) return None - def get_by_alias(self, alias: str) -> Optional[Post]: + def get_by_alias(self, alias: str) -> Post | None: """Get a post by its alias.""" stmt = sa.select(PostORM).where(PostORM.alias == alias) post_orm = self.session.scalar(stmt) @@ -32,13 +30,13 @@ def get_by_alias(self, alias: str) -> Optional[Post]: return self._to_domain_model(post_orm) return None - def get_all(self) -> List[Post]: + def get_all(self) -> list[Post]: """Get all posts.""" stmt = sa.select(PostORM) posts_orm = self.session.scalars(stmt).all() return [self._to_domain_model(post_orm) for post_orm in posts_orm] - def get_published_posts(self) -> List[Post]: + def get_published_posts(self) -> list[Post]: """Get all published posts ordered by published date.""" stmt = ( sa.select(PostORM) @@ -51,7 +49,7 @@ def get_published_posts(self) -> List[Post]: posts_orm = self.session.scalars(stmt).all() return [self._to_domain_model(post_orm) for post_orm in posts_orm] - def get_page_posts(self, page_category_ids: List[int]) -> List[Post]: + def get_page_posts(self, page_category_ids: list[int]) -> list[Post]: """Get posts that are pages (in specific categories).""" stmt = sa.select(PostORM).where(PostORM.category_id.in_(page_category_ids)) posts_orm = self.session.scalars(stmt).all() @@ -59,15 +57,19 @@ def get_page_posts(self, page_category_ids: List[int]) -> List[Post]: def create(self, post: Post) -> Post: """Create a new post.""" - post_orm = PostORM( - pagetitle=post.pagetitle, - alias=post.alias, - content=post.content, - createdon=post.createdon, - publishedon=post.publishedon, - category_id=post.category_id, - user_id=post.user_id, - ) + post_orm = PostORM() + post_orm.pagetitle = post.pagetitle + post_orm.alias = post.alias + post_orm.content = post.content + # Handle datetime fields that might be None + if post.createdon is not None: + post_orm.createdon = post.createdon + if post.publishedon is not None: + post_orm.publishedon = post.publishedon + if post.category_id is not None: + post_orm.category_id = post.category_id + if post.user_id is not None: + post_orm.user_id = post.user_id self.session.add(post_orm) self.session.flush() # Get the ID without committing post.id = post_orm.id @@ -83,10 +85,15 @@ def update(self, post: Post) -> Post: post_orm.pagetitle = post.pagetitle post_orm.alias = post.alias post_orm.content = post.content - post_orm.createdon = post.createdon - post_orm.publishedon = post.publishedon - post_orm.category_id = post.category_id - post_orm.user_id = post.user_id + # Handle datetime fields that might be None + if post.createdon is not None: + post_orm.createdon = post.createdon + if post.publishedon is not None: + post_orm.publishedon = post.publishedon + if post.category_id is not None: + post_orm.category_id = post.category_id + if post.user_id is not None: + post_orm.user_id = post.user_id self.session.flush() return post diff --git a/blog/repos/tag.py b/blog/repos/tag.py index 6252c3f..622a316 100644 --- a/blog/repos/tag.py +++ b/blog/repos/tag.py @@ -1,7 +1,5 @@ """Repository for Tag entities.""" -from typing import List, Optional - import sqlalchemy as sa from sqlalchemy.orm import Session @@ -13,10 +11,10 @@ class TagRepository: """Repository for Tag entities.""" - def __init__(self, session: Optional[Session] = None): + def __init__(self, session: Session | None = None): self.session = session or db.session - def get_by_id(self, tag_id: int) -> Optional[Tag]: + def get_by_id(self, tag_id: int) -> Tag | None: """Get a tag by its ID.""" stmt = sa.select(TagORM).where(TagORM.id == tag_id) tag_orm = self.session.scalar(stmt) @@ -24,7 +22,7 @@ def get_by_id(self, tag_id: int) -> Optional[Tag]: return self._to_domain_model(tag_orm) return None - def get_by_alias(self, alias: str) -> Optional[Tag]: + def get_by_alias(self, alias: str) -> Tag | None: """Get a tag by its alias.""" stmt = sa.select(TagORM).where(TagORM.alias == alias) tag_orm = self.session.scalar(stmt) @@ -32,13 +30,13 @@ def get_by_alias(self, alias: str) -> Optional[Tag]: return self._to_domain_model(tag_orm) return None - def get_all(self) -> List[Tag]: + def get_all(self) -> list[Tag]: """Get all tags.""" stmt = sa.select(TagORM) tags_orm = self.session.scalars(stmt).all() return [self._to_domain_model(tag_orm) for tag_orm in tags_orm] - def get_tags_with_posts(self) -> List[Tag]: + def get_tags_with_posts(self) -> list[Tag]: """Get all tags with their posts.""" stmt = sa.select(TagORM).options(sa.orm.joinedload(TagORM.posts)) tags_orm = self.session.scalars(stmt).unique().all() @@ -46,7 +44,9 @@ def get_tags_with_posts(self) -> List[Tag]: def create(self, tag: Tag) -> Tag: """Create a new tag.""" - tag_orm = TagORM(title=tag.title, alias=tag.alias) + tag_orm = TagORM() + tag_orm.title = tag.title + tag_orm.alias = tag.alias self.session.add(tag_orm) self.session.flush() # Get the ID without committing tag.id = tag_orm.id diff --git a/blog/repos/user.py b/blog/repos/user.py index 94236b7..9c42044 100644 --- a/blog/repos/user.py +++ b/blog/repos/user.py @@ -1,7 +1,5 @@ """Repository for User entities.""" -from typing import List, Optional - import sqlalchemy as sa from sqlalchemy.orm import Session @@ -13,10 +11,10 @@ class UserRepository: """Repository for User entities.""" - def __init__(self, session: Optional[Session] = None): + def __init__(self, session: Session | None = None): self.session = session or db.session - def get_by_id(self, user_id: int) -> Optional[User]: + def get_by_id(self, user_id: int) -> User | None: """Get a user by its ID.""" stmt = sa.select(UserORM).where(UserORM.id == user_id) user_orm = self.session.scalar(stmt) @@ -24,7 +22,7 @@ def get_by_id(self, user_id: int) -> Optional[User]: return self._to_domain_model(user_orm) return None - def get_by_name(self, name: str) -> Optional[User]: + def get_by_name(self, name: str) -> User | None: """Get a user by their name.""" stmt = sa.select(UserORM).where(UserORM.name == name) user_orm = self.session.scalar(stmt) @@ -32,13 +30,13 @@ def get_by_name(self, name: str) -> Optional[User]: return self._to_domain_model(user_orm) return None - def get_all(self) -> List[User]: + def get_all(self) -> list[User]: """Get all users.""" stmt = sa.select(UserORM) users_orm = self.session.scalars(stmt).all() return [self._to_domain_model(user_orm) for user_orm in users_orm] - def get_users_with_posts(self) -> List[User]: + def get_users_with_posts(self) -> list[User]: """Get all users with their posts.""" stmt = sa.select(UserORM).options(sa.orm.joinedload(UserORM.posts)) users_orm = self.session.scalars(stmt).unique().all() @@ -46,12 +44,16 @@ def get_users_with_posts(self) -> List[User]: def create(self, user: User) -> User: """Create a new user.""" - user_orm = UserORM( - name=user.name, - password=user.password, - authenticated=user.authenticated, - createdon=user.createdon, + user_orm = UserORM() + user_orm.name = user.name + user_orm.password = user.password + # Handle the case where authenticated might be None + user_orm.authenticated = ( + user.authenticated if user.authenticated is not None else False ) + # Handle datetime field that might be None + if user.createdon is not None: + user_orm.createdon = user.createdon self.session.add(user_orm) self.session.flush() # Get the ID without committing user.id = user_orm.id @@ -66,8 +68,13 @@ def update(self, user: User) -> User: user_orm.name = user.name user_orm.password = user.password - user_orm.authenticated = user.authenticated - user_orm.createdon = user.createdon + # Handle the case where authenticated might be None + user_orm.authenticated = ( + user.authenticated if user.authenticated is not None else False + ) + # Handle datetime field that might be None + if user.createdon is not None: + user_orm.createdon = user.createdon self.session.flush() return user @@ -80,7 +87,7 @@ def delete(self, user_id: int) -> bool: return True return False - def authenticate(self, name: str, password: str) -> Optional[User]: + def authenticate(self, name: str, password: str) -> User | None: """Authenticate a user by name and password.""" stmt = sa.select(UserORM).where(UserORM.name == name) user_orm = self.session.scalar(stmt) @@ -94,6 +101,8 @@ def _to_domain_model(self, user_orm: UserORM) -> User: id=user_orm.id, name=user_orm.name, password=user_orm.password, - authenticated=user_orm.authenticated, + authenticated=user_orm.authenticated + if user_orm.authenticated is not None + else False, createdon=user_orm.createdon, ) diff --git a/blog/tags/models.py b/blog/tags/models.py index d2f69d1..fde0671 100644 --- a/blog/tags/models.py +++ b/blog/tags/models.py @@ -1,5 +1,5 @@ # blog/tags/models.py -from typing import TYPE_CHECKING, List +from typing import TYPE_CHECKING from sqlalchemy.orm import Mapped, mapped_column, relationship @@ -17,7 +17,7 @@ class Tag(db.Model): id: Mapped[int] = mapped_column(primary_key=True) title: Mapped[str] alias: Mapped[str] = mapped_column(unique=True) - posts: Mapped[List["Post"]] = relationship( + posts: Mapped[list["Post"]] = relationship( secondary=posts_tags, back_populates="tags" ) diff --git a/blog/user/models.py b/blog/user/models.py index 271e751..8ce0219 100644 --- a/blog/user/models.py +++ b/blog/user/models.py @@ -1,7 +1,7 @@ """SqlAlchemy models.""" import datetime -from typing import TYPE_CHECKING, List +from typing import TYPE_CHECKING from flask_login import UserMixin from sqlalchemy import DateTime @@ -27,11 +27,11 @@ class User(UserMixin, db.Model): id: Mapped[int] = mapped_column(primary_key=True) name: Mapped[str] = mapped_column(nullable=False) password: Mapped[str] - authenticated: Mapped[bool] = mapped_column(default=False, nullable=True) + authenticated: Mapped[bool | None] = mapped_column(default=False, nullable=True) createdon: Mapped[datetime.datetime] = mapped_column( DateTime(timezone=True), server_default=func.now() ) - posts: Mapped[List["Post"]] = relationship() + posts: Mapped[list["Post"]] = relationship() def is_authenticated(self): # pyright: ignore[ reportIncompatibleMethodOverride] return self.authenticated From 509bcf27bdf36aa5bce1b144bdaaab2545b0114b Mon Sep 17 00:00:00 2001 From: loki Date: Sat, 6 Sep 2025 02:09:20 +0300 Subject: [PATCH 03/34] re orm --- blog/category/models.py | 12 +++---- blog/infrastructure/database.py | 61 +++++++++++++++++++++++++++++++++ blog/post/models.py | 43 ++++++----------------- blog/tags/association.py | 2 ++ blog/tags/models.py | 14 +++----- blog/user/models.py | 16 +++------ 6 files changed, 89 insertions(+), 59 deletions(-) create mode 100644 blog/infrastructure/database.py diff --git a/blog/category/models.py b/blog/category/models.py index 7faf1f3..2789908 100644 --- a/blog/category/models.py +++ b/blog/category/models.py @@ -2,9 +2,10 @@ from typing import TYPE_CHECKING -from sqlalchemy.orm import Mapped, mapped_column, relationship +from sqlalchemy.orm import relationship from blog.extensions import db +from blog.infrastructure.database import get_categories_table if TYPE_CHECKING: from blog.post.models import Post @@ -13,12 +14,9 @@ class Category(db.Model): """orm model for blog post.""" - __tablename__ = "categories" - id: Mapped[int] = mapped_column(primary_key=True) - title: Mapped[str] = mapped_column(default="") - alias: Mapped[str] = mapped_column(unique=True) - posts: Mapped[list["Post"]] = relationship() - template: Mapped[str | None] = mapped_column(nullable=True) + __table__ = get_categories_table(db.metadata) + + posts = relationship("Post", back_populates="category") def __str__(self): return f"Category(id={self.id}, title={self.title}, template={self.template})" diff --git a/blog/infrastructure/database.py b/blog/infrastructure/database.py new file mode 100644 index 0000000..7335bc4 --- /dev/null +++ b/blog/infrastructure/database.py @@ -0,0 +1,61 @@ +"""SQLAlchemy Table Definitions (Infrastructure). + +This module contains the SQLAlchemy table definitions +following the UNION Architecture pattern where infrastructure +is separated from domain models. +""" + +from sqlalchemy import Table, Column, Integer, String, Text, DateTime, ForeignKey + +# Table definitions (Infrastructure layer) +def get_users_table(metadata): + return Table( + 'users', metadata, + Column('id', Integer, primary_key=True), + Column('name', String(50), nullable=False), + Column('password', String(255)), # Adjusted length for password hashes + Column('authenticated', Integer, default=0), # Using Integer for boolean + Column('createdon', DateTime(timezone=True)), + extend_existing=True + ) + +def get_posts_table(metadata): + return Table( + 'posts', metadata, + Column('id', Integer, primary_key=True), + Column('pagetitle', String(255), nullable=False), + Column('alias', String(255), nullable=False, unique=True), + Column('content', Text), + Column('createdon', DateTime(timezone=True)), + Column('publishedon', DateTime(timezone=True)), + Column('category_id', Integer, ForeignKey('categories.id'), nullable=True), + Column('user_id', Integer, ForeignKey('users.id'), nullable=True), + extend_existing=True + ) + +def get_categories_table(metadata): + return Table( + 'categories', metadata, + Column('id', Integer, primary_key=True), + Column('title', String(255)), + Column('alias', String(255), unique=True), + Column('template', String(255), nullable=True), + extend_existing=True + ) + +def get_tags_table(metadata): + return Table( + 'tags', metadata, + Column('id', Integer, primary_key=True), + Column('title', String(255)), + Column('alias', String(255), unique=True), + extend_existing=True + ) + +def get_posts_tags_table(metadata): + return Table( + 'posts_tags', metadata, + Column('post_id', Integer, ForeignKey('posts.id')), + Column('tag_id', Integer, ForeignKey('tags.id')), + extend_existing=True + ) \ No newline at end of file diff --git a/blog/post/models.py b/blog/post/models.py index 1d8e5b5..3f047fc 100644 --- a/blog/post/models.py +++ b/blog/post/models.py @@ -4,12 +4,11 @@ from typing import TYPE_CHECKING import markdown -from sqlalchemy import DateTime, ForeignKey, Text -from sqlalchemy.orm import Mapped, mapped_column, relationship +from sqlalchemy.orm import relationship from sqlalchemy.sql import func from blog.extensions import db -from blog.tags.association import posts_tags +from blog.infrastructure.database import get_posts_table, get_posts_tags_table if TYPE_CHECKING: from blog.category.models import Category @@ -23,29 +22,11 @@ class Post(db.Model): """orm model for blog post.""" - __tablename__ = "posts" # pyright: ignore[reportUnannotatedClassAttribute] - - id: Mapped[int] = mapped_column(primary_key=True) - pagetitle: Mapped[str] = mapped_column(nullable=False, unique=False) - alias: Mapped[str] = mapped_column(nullable=False, unique=True) - content: Mapped[str] = mapped_column(type_=Text) - createdon: Mapped[datetime.datetime] = mapped_column( - DateTime(timezone=True), server_default=func.now() - ) - publishedon: Mapped[datetime.datetime | None] = mapped_column( - DateTime(timezone=True), - server_default=func.now(), - nullable=True, - ) - category_id: Mapped[int | None] = mapped_column( - ForeignKey("categories.id"), nullable=True - ) - user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True) - user: Mapped["User"] = relationship(back_populates="posts") - category: Mapped["Category"] = relationship(back_populates="posts") - tags: Mapped[list["Tag"]] = relationship( - secondary=posts_tags, back_populates="posts" - ) + __table__ = get_posts_table(db.metadata) + + user = relationship("User", back_populates="posts") + category = relationship("Category", back_populates="posts") + tags = relationship("Tag", secondary=get_posts_tags_table(db.metadata), back_populates="posts") @property def markdown(self): @@ -61,12 +42,10 @@ class Icon(db.Model): __tablename__ = "icons" # pyright: ignore[reportUnannotatedClassAttribute] - id: Mapped[int] = mapped_column(primary_key=True) - title: Mapped[str] = mapped_column(nullable=False, unique=True) - - url: Mapped[str] = mapped_column(nullable=False, unique=True) - content: Mapped[str] = mapped_column(type_=Text) + id = db.Column(db.Integer, primary_key=True) + title = db.Column(db.String(255), nullable=False, unique=True) + url = db.Column(db.String(255), nullable=False, unique=True) + content = db.Column(db.Text) - @typing.override def __str__(self): return f"{self.id} {self.title}" diff --git a/blog/tags/association.py b/blog/tags/association.py index 33b3aac..f66001b 100644 --- a/blog/tags/association.py +++ b/blog/tags/association.py @@ -1,6 +1,8 @@ # blog/tags/association.py from sqlalchemy import Column, ForeignKey, Table +# This file is now deprecated as we're using the posts_tags_table from infrastructure.database +# Keeping it for backward compatibility, but it should be removed in future versions from blog.extensions import db posts_tags = Table( diff --git a/blog/tags/models.py b/blog/tags/models.py index fde0671..e138d7c 100644 --- a/blog/tags/models.py +++ b/blog/tags/models.py @@ -1,10 +1,10 @@ # blog/tags/models.py from typing import TYPE_CHECKING -from sqlalchemy.orm import Mapped, mapped_column, relationship +from sqlalchemy.orm import relationship from blog.extensions import db -from blog.tags.association import posts_tags +from blog.infrastructure.database import get_tags_table, get_posts_tags_table if TYPE_CHECKING: from blog.post.models import Post @@ -13,13 +13,9 @@ class Tag(db.Model): """orm model for blog post.""" - __tablename__ = "tags" - id: Mapped[int] = mapped_column(primary_key=True) - title: Mapped[str] - alias: Mapped[str] = mapped_column(unique=True) - posts: Mapped[list["Post"]] = relationship( - secondary=posts_tags, back_populates="tags" - ) + __table__ = get_tags_table(db.metadata) + + posts = relationship("Post", secondary=get_posts_tags_table(db.metadata), back_populates="tags") def __str__(self): return f"{self.title}" diff --git a/blog/user/models.py b/blog/user/models.py index 8ce0219..a3102e5 100644 --- a/blog/user/models.py +++ b/blog/user/models.py @@ -4,12 +4,12 @@ from typing import TYPE_CHECKING from flask_login import UserMixin -from sqlalchemy import DateTime -from sqlalchemy.orm import Mapped, mapped_column, relationship +from sqlalchemy.orm import relationship from sqlalchemy.sql import func from werkzeug.security import check_password_hash, generate_password_hash from blog.extensions import db, login_manager +from blog.infrastructure.database import get_users_table if TYPE_CHECKING: from blog.post.models import Post @@ -23,15 +23,9 @@ def load_user(id): class User(UserMixin, db.Model): """orm model for users.""" - __tablename__ = "users" - id: Mapped[int] = mapped_column(primary_key=True) - name: Mapped[str] = mapped_column(nullable=False) - password: Mapped[str] - authenticated: Mapped[bool | None] = mapped_column(default=False, nullable=True) - createdon: Mapped[datetime.datetime] = mapped_column( - DateTime(timezone=True), server_default=func.now() - ) - posts: Mapped[list["Post"]] = relationship() + __table__ = get_users_table(db.metadata) + + posts = relationship("Post", back_populates="user") def is_authenticated(self): # pyright: ignore[ reportIncompatibleMethodOverride] return self.authenticated From 37b3a2456bbd6c7e17d15245fa4880a99d8b4e4a Mon Sep 17 00:00:00 2001 From: loki Date: Sat, 6 Sep 2025 02:42:56 +0300 Subject: [PATCH 04/34] start integrate service --- blog/category/models.py | 9 ++-- blog/domain/post.py | 3 +- blog/infrastructure/database.py | 76 +++++++++++++++++++-------------- blog/post/models.py | 10 ++--- blog/post/views.py | 58 ++++++++++++++++--------- blog/services/post.py | 36 ++++++++++++++++ blog/tags/models.py | 8 ++-- blog/templates/posts.html | 2 +- blog/user/models.py | 32 +++++--------- blog/user/views.py | 9 +++- tests/test_posts.py | 2 + tests/test_tags.py | 28 ++++++++++-- 12 files changed, 177 insertions(+), 96 deletions(-) diff --git a/blog/category/models.py b/blog/category/models.py index 2789908..24dffb3 100644 --- a/blog/category/models.py +++ b/blog/category/models.py @@ -1,5 +1,4 @@ -"""SqlAlchemy models.""" - +# blog/category/models.py from typing import TYPE_CHECKING from sqlalchemy.orm import relationship @@ -8,15 +7,15 @@ from blog.infrastructure.database import get_categories_table if TYPE_CHECKING: - from blog.post.models import Post + pass class Category(db.Model): """orm model for blog post.""" __table__ = get_categories_table(db.metadata) - + posts = relationship("Post", back_populates="category") def __str__(self): - return f"Category(id={self.id}, title={self.title}, template={self.template})" + return f"{self.title}" diff --git a/blog/domain/post.py b/blog/domain/post.py index b48624f..87c778d 100644 --- a/blog/domain/post.py +++ b/blog/domain/post.py @@ -32,5 +32,4 @@ class Post: def __post_init__(self): if self.createdon is None: self.createdon = datetime.datetime.now(datetime.timezone.utc) - if self.publishedon is None: - self.publishedon = datetime.datetime.now(datetime.timezone.utc) + # Don't set default for publishedon - it should be None for unpublished posts diff --git a/blog/infrastructure/database.py b/blog/infrastructure/database.py index 7335bc4..567f75c 100644 --- a/blog/infrastructure/database.py +++ b/blog/infrastructure/database.py @@ -7,55 +7,65 @@ from sqlalchemy import Table, Column, Integer, String, Text, DateTime, ForeignKey + # Table definitions (Infrastructure layer) def get_users_table(metadata): return Table( - 'users', metadata, - Column('id', Integer, primary_key=True), - Column('name', String(50), nullable=False), - Column('password', String(255)), # Adjusted length for password hashes - Column('authenticated', Integer, default=0), # Using Integer for boolean - Column('createdon', DateTime(timezone=True)), - extend_existing=True + "users", + metadata, + Column("id", Integer, primary_key=True), + Column("name", String(50), nullable=False), + Column("password", String(255)), # Adjusted length for password hashes + Column("authenticated", Integer, default=0), # Using Integer for boolean + Column("createdon", DateTime(timezone=True)), + extend_existing=True, ) + def get_posts_table(metadata): return Table( - 'posts', metadata, - Column('id', Integer, primary_key=True), - Column('pagetitle', String(255), nullable=False), - Column('alias', String(255), nullable=False, unique=True), - Column('content', Text), - Column('createdon', DateTime(timezone=True)), - Column('publishedon', DateTime(timezone=True)), - Column('category_id', Integer, ForeignKey('categories.id'), nullable=True), - Column('user_id', Integer, ForeignKey('users.id'), nullable=True), - extend_existing=True + "posts", + metadata, + Column("id", Integer, primary_key=True), + Column("pagetitle", String(255), nullable=False), + Column("alias", String(255), nullable=False, unique=True), + Column("content", Text), + Column("createdon", DateTime(timezone=True)), + Column("publishedon", DateTime(timezone=True)), + Column("category_id", Integer, ForeignKey("categories.id"), nullable=True), + Column("user_id", Integer, ForeignKey("users.id"), nullable=True), + extend_existing=True, ) + def get_categories_table(metadata): return Table( - 'categories', metadata, - Column('id', Integer, primary_key=True), - Column('title', String(255)), - Column('alias', String(255), unique=True), - Column('template', String(255), nullable=True), - extend_existing=True + "categories", + metadata, + Column("id", Integer, primary_key=True), + Column("title", String(255)), + Column("alias", String(255), unique=True), + Column("template", String(255), nullable=True), + extend_existing=True, ) + def get_tags_table(metadata): return Table( - 'tags', metadata, - Column('id', Integer, primary_key=True), - Column('title', String(255)), - Column('alias', String(255), unique=True), - extend_existing=True + "tags", + metadata, + Column("id", Integer, primary_key=True), + Column("title", String(255)), + Column("alias", String(255), unique=True), + extend_existing=True, ) + def get_posts_tags_table(metadata): return Table( - 'posts_tags', metadata, - Column('post_id', Integer, ForeignKey('posts.id')), - Column('tag_id', Integer, ForeignKey('tags.id')), - extend_existing=True - ) \ No newline at end of file + "posts_tags", + metadata, + Column("post_id", Integer, ForeignKey("posts.id")), + Column("tag_id", Integer, ForeignKey("tags.id")), + extend_existing=True, + ) diff --git a/blog/post/models.py b/blog/post/models.py index 3f047fc..2fc1c7b 100644 --- a/blog/post/models.py +++ b/blog/post/models.py @@ -1,19 +1,15 @@ # blog/posts/models.py -import datetime import typing from typing import TYPE_CHECKING import markdown from sqlalchemy.orm import relationship -from sqlalchemy.sql import func from blog.extensions import db from blog.infrastructure.database import get_posts_table, get_posts_tags_table if TYPE_CHECKING: - from blog.category.models import Category - from blog.tags.models import Tag - from blog.user.models import User + pass MARKDOWN_EXTENSIONS = ["markdown.extensions.fenced_code"] @@ -26,7 +22,9 @@ class Post(db.Model): user = relationship("User", back_populates="posts") category = relationship("Category", back_populates="posts") - tags = relationship("Tag", secondary=get_posts_tags_table(db.metadata), back_populates="posts") + tags = relationship( + "Tag", secondary=get_posts_tags_table(db.metadata), back_populates="posts" + ) @property def markdown(self): diff --git a/blog/post/views.py b/blog/post/views.py index fdc51bc..b74b003 100644 --- a/blog/post/views.py +++ b/blog/post/views.py @@ -12,12 +12,12 @@ request, url_for, ) -from sqlalchemy import or_ from blog.extensions import flask_sitemap from blog import cache, db from blog.post.models import Post, Icon -from blog.category.models import Category +from blog.repos.post import PostRepository +from blog.services.post import PostService post = Blueprint("postb", __name__) @@ -37,19 +37,19 @@ def decorated_function(*args, **kwargs): return decorated_function +def get_post_service(): + """Create and return a PostService instance.""" + post_repository = PostRepository() + return PostService(post_repository) + + @post.route("/") @cache.cached(timeout=50) @pages_gen def index(**kwargs): - post_query = ( - sa.select(Post) - .where( - Post.publishedon.isnot(None), - Post.category_id.is_(None), - ) - .order_by(Post.publishedon.desc()) - ) - posts = db.session.scalars(post_query).all() + # Use service layer instead of direct database access + post_service = get_post_service() + posts = post_service.get_published_posts_orm() return render_template("posts.html", posts=posts, **kwargs) @@ -57,16 +57,34 @@ def index(**kwargs): @cache.cached(timeout=50) @pages_gen def view(alias=None, **kwargs): - page_categories = current_app.config["PAGE_CATEGORY"] # (1,3,) - post_query = sa.select(Post).where( - or_(Post.publishedon.isnot(None), Post.category_id.in_(page_categories)), - Post.alias == alias, - ) - post = db.first_or_404(post_query) + # Use service layer instead of direct database access + post_service = get_post_service() + post = post_service.get_post_by_alias_orm(alias) + if not post: + # Handle 404 case + from flask import abort + + abort(404) + + # For page categories, we need to check if it's a page or a regular post + page_categories = current_app.config["PAGE_CATEGORY"] + is_page = post.category_id is not None and post.category_id in page_categories + is_published = post.publishedon is not None + + if not (is_published or is_page): + from flask import abort + + abort(404) + + # Load category object if needed + page_category_obj = None + if post.category_id: + from blog.category.models import Category + + page_category_obj = db.session.scalars( + sa.select(Category).where(Category.id == post.category_id) + ).first() - page_category_obj = db.session.scalars( - sa.select(Category).where(Category.id == post.category_id) - ).first() if page_category_obj and page_category_obj.template: return render_template(page_category_obj.template, post=post, **kwargs) return render_template("post.html", post=post, **kwargs) diff --git a/blog/services/post.py b/blog/services/post.py index 479e54e..edcc9b7 100644 --- a/blog/services/post.py +++ b/blog/services/post.py @@ -3,6 +3,9 @@ from typing import List, Optional from blog.repos.post import PostRepository from blog.domain.post import Post +from blog.post.models import Post as PostORM +from blog.extensions import db +import sqlalchemy as sa class PostService: @@ -42,3 +45,36 @@ def update_post(self, post: Post) -> Post: def delete_post(self, post_id: int) -> bool: """Delete a post by its ID.""" return self.post_repository.delete(post_id) + + def get_published_posts_orm(self) -> List[PostORM]: + """Get all published posts as ORM models (for compatibility with views).""" + domain_posts = self.get_published_posts() + return [self._to_orm_model(post) for post in domain_posts] + + def get_post_by_alias_orm(self, alias: str) -> Optional[PostORM]: + """Get a post by its alias as ORM model (for compatibility with views).""" + domain_post = self.get_post_by_alias(alias) + if domain_post: + return self._to_orm_model(domain_post) + return None + + def _to_orm_model(self, post: Post) -> PostORM: + """Convert domain model to ORM model by loading from database.""" + if post.id: + # Load the full ORM model from database to get relationships + stmt = sa.select(PostORM).where(PostORM.id == post.id) + post_orm = db.session.scalar(stmt) + if post_orm: + return post_orm + + # If we can't load from database, create a new instance + post_orm = PostORM() + post_orm.id = post.id or 0 + post_orm.pagetitle = post.pagetitle + post_orm.alias = post.alias + post_orm.content = post.content + post_orm.createdon = post.createdon + post_orm.publishedon = post.publishedon + post_orm.category_id = post.category_id + post_orm.user_id = post.user_id + return post_orm diff --git a/blog/tags/models.py b/blog/tags/models.py index e138d7c..a8a9684 100644 --- a/blog/tags/models.py +++ b/blog/tags/models.py @@ -7,15 +7,17 @@ from blog.infrastructure.database import get_tags_table, get_posts_tags_table if TYPE_CHECKING: - from blog.post.models import Post + pass class Tag(db.Model): """orm model for blog post.""" __table__ = get_tags_table(db.metadata) - - posts = relationship("Post", secondary=get_posts_tags_table(db.metadata), back_populates="tags") + + posts = relationship( + "Post", secondary=get_posts_tags_table(db.metadata), back_populates="tags" + ) def __str__(self): return f"{self.title}" diff --git a/blog/templates/posts.html b/blog/templates/posts.html index 30c3d87..46e0c81 100644 --- a/blog/templates/posts.html +++ b/blog/templates/posts.html @@ -5,7 +5,7 @@ {% if tag %}

Посты c тэгом: {{tag.title}}

{% endif %} - {% for group in posts|groupby('publishedon.year')|sort(reverse=True) %} + {% for group in posts|selectattr('publishedon')|groupby('publishedon.year')|sort(reverse=True) %}

{{group.grouper}}

diff --git a/blog/user/models.py b/blog/user/models.py index a3102e5..0b197c7 100644 --- a/blog/user/models.py +++ b/blog/user/models.py @@ -1,43 +1,33 @@ """SqlAlchemy models.""" -import datetime from typing import TYPE_CHECKING from flask_login import UserMixin from sqlalchemy.orm import relationship -from sqlalchemy.sql import func from werkzeug.security import check_password_hash, generate_password_hash -from blog.extensions import db, login_manager +from blog.extensions import db from blog.infrastructure.database import get_users_table if TYPE_CHECKING: - from blog.post.models import Post - - -@login_manager.user_loader -def load_user(id): - return db.session.get(User, int(id)) + pass class User(UserMixin, db.Model): - """orm model for users.""" + """SqlAlchemy model for users.""" __table__ = get_users_table(db.metadata) - - posts = relationship("Post", back_populates="user") - def is_authenticated(self): # pyright: ignore[ reportIncompatibleMethodOverride] - return self.authenticated - - def get_id(self): - return str(self.id) + posts = relationship("Post", back_populates="user") - def set_password(self, password): + def set_password(self, password: str) -> None: + """Set password hash.""" self.password = generate_password_hash(password) - def check_password(self, password): - return check_password_hash(self.password, password) + def check_password(self, password: str) -> bool: + """Check password hash.""" + return check_password_hash(self.password or "", password) - def __str__(self): + def __str__(self) -> str: + """String representation.""" return f"{self.name}" diff --git a/blog/user/views.py b/blog/user/views.py index 98a7b10..5ea872f 100644 --- a/blog/user/views.py +++ b/blog/user/views.py @@ -3,13 +3,20 @@ from flask import Blueprint, flash, redirect, render_template, url_for from flask_login import current_user, login_user -from blog.extensions import db +from blog.extensions import db, login_manager from blog.user.forms import LoginForm from blog.user.models import User user_blueprint = Blueprint("userb", __name__) +@login_manager.user_loader +def load_user(user_id): + """Load user by ID for Flask-Login.""" + user_query = sa.select(User).where(User.id == int(user_id)) + return db.session.scalars(user_query).first() + + @user_blueprint.route("/login", methods=["GET", "POST"]) def login(): if current_user.is_authenticated: diff --git a/tests/test_posts.py b/tests/test_posts.py index ec916f7..f18a6f9 100644 --- a/tests/test_posts.py +++ b/tests/test_posts.py @@ -29,6 +29,8 @@ def post_helper(prefix="post", page=False): post.pagetitle = f"{prefix}_title" post.alias = f"{prefix}_alias" post.content = f"{prefix}_content" + # All posts should be published for tests + post.publishedon = db.func.now() if page: post.category_id = 1 return post diff --git a/tests/test_tags.py b/tests/test_tags.py index 8ad65c6..5e89a1d 100644 --- a/tests/test_tags.py +++ b/tests/test_tags.py @@ -25,13 +25,28 @@ def test_get_posts_by_tag(test_client): tag = Tag(title="test-tag", alias="test-tag") # type: ignore db.session.add(tag) - post1 = Post(pagetitle="Post 1", alias="post-1", content="Content 1") # type: ignore + post1 = Post( + pagetitle="Post 1", + alias="post-1", + content="Content 1", + publishedon=db.func.now(), + ) # type: ignore post1.tags.append(tag) - post2 = Post(pagetitle="Post 2", alias="post-2", content="Content 2") # type: ignore + post2 = Post( + pagetitle="Post 2", + alias="post-2", + content="Content 2", + publishedon=db.func.now(), + ) # type: ignore post2.tags.append(tag) - post3 = Post(pagetitle="Post 3", alias="post-3", content="Content 3") # type: ignore + post3 = Post( + pagetitle="Post 3", + alias="post-3", + content="Content 3", + publishedon=db.func.now(), + ) # type: ignore db.session.add(post1) db.session.add(post2) @@ -53,7 +68,12 @@ def test_post_have_tag(test_client): tag2 = Tag(title="test-tag2", alias="test-tag2") # type: ignore db.session.add(tag2) - post1 = Post(pagetitle="Post 1", alias="post-1", content="Content 1") # type: ignore + post1 = Post( + pagetitle="Post 1", + alias="post-1", + content="Content 1", + publishedon=db.func.now(), + ) # type: ignore post1.tags.append(tag1) db.session.add(post1) From 649cc5ab201e0e122c9aa8c551446d4eec8b50f6 Mon Sep 17 00:00:00 2001 From: loki Date: Sat, 6 Sep 2025 03:01:15 +0300 Subject: [PATCH 05/34] continue union layer --- blog/category/models.py | 2 +- blog/domain/icon.py | 16 +++++++ blog/post/views.py | 75 ++++++++++++++++-------------- blog/repos/icon.py | 81 ++++++++++++++++++++++++++++++++ blog/services/category.py | 85 ++++++++++++++++++++++++++++++++++ blog/services/factory.py | 46 +++++++++++++++++++ blog/services/icon.py | 79 +++++++++++++++++++++++++++++++ blog/services/post.py | 29 ++++++++++-- blog/services/tag.py | 89 +++++++++++++++++++++++++++++++++++ blog/services/user.py | 97 +++++++++++++++++++++++++++++++++++++++ blog/tags/models.py | 2 +- blog/tags/views.py | 17 +++++-- blog/user/views.py | 20 +++++--- 13 files changed, 587 insertions(+), 51 deletions(-) create mode 100644 blog/domain/icon.py create mode 100644 blog/repos/icon.py create mode 100644 blog/services/category.py create mode 100644 blog/services/factory.py create mode 100644 blog/services/icon.py create mode 100644 blog/services/tag.py create mode 100644 blog/services/user.py diff --git a/blog/category/models.py b/blog/category/models.py index 24dffb3..ffa231e 100644 --- a/blog/category/models.py +++ b/blog/category/models.py @@ -11,7 +11,7 @@ class Category(db.Model): - """orm model for blog post.""" + """orm model for blog category.""" __table__ = get_categories_table(db.metadata) diff --git a/blog/domain/icon.py b/blog/domain/icon.py new file mode 100644 index 0000000..7fc0a07 --- /dev/null +++ b/blog/domain/icon.py @@ -0,0 +1,16 @@ +"""Domain models for the Icon entity.""" + +from dataclasses import dataclass + + +@dataclass +class Icon: + """Domain model for blog icon.""" + + id: int | None = None + title: str = "" + url: str = "" + content: str | None = None + + def __str__(self): + return f"Icon(id={self.id}, title={self.title})" \ No newline at end of file diff --git a/blog/post/views.py b/blog/post/views.py index b74b003..23df2ab 100644 --- a/blog/post/views.py +++ b/blog/post/views.py @@ -17,7 +17,11 @@ from blog import cache, db from blog.post.models import Post, Icon from blog.repos.post import PostRepository +from blog.repos.category import CategoryRepository +from blog.repos.icon import IconRepository from blog.services.post import PostService +from blog.services.category import CategoryService +from blog.services.icon import IconService post = Blueprint("postb", __name__) @@ -25,30 +29,32 @@ def pages_gen(f): @wraps(f) def decorated_function(*args, **kwargs): + # Use service layer instead of direct database access + from blog.services.factory import ServiceFactory + page_category = current_app.config["PAGE_CATEGORY"] - pages_query = sa.select(Post).where(Post.category_id.in_(page_category)) - pages = db.session.scalars(pages_query).all() + + # Get posts using PostService + post_service = ServiceFactory.create_post_service() + pages = post_service.get_page_posts_orm(page_category) - icons_query = sa.select(Icon) - icons = db.session.scalars(icons_query).all() + # Get icons using IconService + icon_service = ServiceFactory.create_icon_service() + icons = icon_service.get_all_icons_orm() return f(pages=pages, icons=icons, *args, **kwargs) return decorated_function -def get_post_service(): - """Create and return a PostService instance.""" - post_repository = PostRepository() - return PostService(post_repository) - - @post.route("/") @cache.cached(timeout=50) @pages_gen def index(**kwargs): # Use service layer instead of direct database access - post_service = get_post_service() + from blog.services.factory import ServiceFactory + + post_service = ServiceFactory.create_post_service() posts = post_service.get_published_posts_orm() return render_template("posts.html", posts=posts, **kwargs) @@ -58,7 +64,9 @@ def index(**kwargs): @pages_gen def view(alias=None, **kwargs): # Use service layer instead of direct database access - post_service = get_post_service() + from blog.services.factory import ServiceFactory + + post_service = ServiceFactory.create_post_service() post = post_service.get_post_by_alias_orm(alias) if not post: # Handle 404 case @@ -76,14 +84,14 @@ def view(alias=None, **kwargs): abort(404) - # Load category object if needed + # Load category object if needed using service layer page_category_obj = None if post.category_id: from blog.category.models import Category - - page_category_obj = db.session.scalars( - sa.select(Category).where(Category.id == post.category_id) - ).first() + + # Use CategoryService instead of direct database access + category_service = ServiceFactory.create_category_service() + page_category_obj = category_service.get_category_by_id_orm(post.category_id) if page_category_obj and page_category_obj.template: return render_template(page_category_obj.template, post=post, **kwargs) @@ -92,21 +100,19 @@ def view(alias=None, **kwargs): @flask_sitemap.register_generator def site_map_gen(): + # Use service layer instead of direct database access + from blog.services.factory import ServiceFactory + page_category = current_app.config["PAGE_CATEGORY"] - pages_query = sa.select(Post).where(Post.category_id.in_(page_category)) - pages = db.session.scalars(pages_query).all() + + # Get pages using PostService + post_service = ServiceFactory.create_post_service() + pages = post_service.get_page_posts_orm(page_category) for page in pages: yield url_for("postb.view", alias=page.alias) - post_query = ( - sa.select(Post) - .where( - Post.publishedon.isnot(None), - Post.category_id.is_(None), - ) - .order_by(Post.publishedon.desc()) - ) - - posts = db.session.scalars(post_query).all() + + # Get posts using PostService + posts = post_service.get_published_posts_orm() for post in posts: yield url_for("postb.view", alias=post.alias) @@ -132,12 +138,13 @@ def robots(): @post.route("/rss.xml") @cache.cached(timeout=50) def rss(): + # Use service layer instead of direct database access + from blog.services.factory import ServiceFactory + + post_service = ServiceFactory.create_post_service() + list_posts = post_service.get_published_posts_orm() + date = datetime.datetime.now() - post_query = sa.select(Post).where( - Post.publishedon.isnot(None), - Post.category_id.is_(None), - ) - list_posts = db.session.scalars(post_query).all() rss_xml = render_template("rss.xml", posts=list_posts, date=date) response = make_response(rss_xml) response.headers["Content-Type"] = "application/rss+xml" diff --git a/blog/repos/icon.py b/blog/repos/icon.py new file mode 100644 index 0000000..54d5fc1 --- /dev/null +++ b/blog/repos/icon.py @@ -0,0 +1,81 @@ +"""Repository for Icon entities.""" + +import sqlalchemy as sa +from sqlalchemy.orm import Session + +from blog.extensions import db +from blog.post.models import Icon as IconORM +from blog.domain.icon import Icon + + +class IconRepository: + """Repository for Icon entities.""" + + def __init__(self, session: Session | None = None): + self.session = session or db.session + + def get_by_id(self, icon_id: int) -> Icon | None: + """Get an icon by its ID.""" + stmt = sa.select(IconORM).where(IconORM.id == icon_id) + icon_orm = self.session.scalar(stmt) + if icon_orm: + return self._to_domain_model(icon_orm) + return None + + def get_by_title(self, title: str) -> Icon | None: + """Get an icon by its title.""" + stmt = sa.select(IconORM).where(IconORM.title == title) + icon_orm = self.session.scalar(stmt) + if icon_orm: + return self._to_domain_model(icon_orm) + return None + + def get_all(self) -> list[Icon]: + """Get all icons.""" + stmt = sa.select(IconORM) + icons_orm = self.session.scalars(stmt).all() + return [self._to_domain_model(icon_orm) for icon_orm in icons_orm] + + def create(self, icon: Icon) -> Icon: + """Create a new icon.""" + icon_orm = IconORM() + icon_orm.title = icon.title + icon_orm.url = icon.url + if icon.content is not None: + icon_orm.content = icon.content + self.session.add(icon_orm) + self.session.flush() # Get the ID without committing + icon.id = icon_orm.id + return icon + + def update(self, icon: Icon) -> Icon: + """Update an existing icon.""" + stmt = sa.select(IconORM).where(IconORM.id == icon.id) + icon_orm = self.session.scalar(stmt) + if not icon_orm: + raise ValueError(f"Icon with id {icon.id} not found") + + icon_orm.title = icon.title + icon_orm.url = icon.url + if icon.content is not None: + icon_orm.content = icon.content + self.session.flush() + return icon + + def delete(self, icon_id: int) -> bool: + """Delete an icon by its ID.""" + stmt = sa.select(IconORM).where(IconORM.id == icon_id) + icon_orm = self.session.scalar(stmt) + if icon_orm: + self.session.delete(icon_orm) + return True + return False + + def _to_domain_model(self, icon_orm: IconORM) -> Icon: + """Convert ORM model to domain model.""" + return Icon( + id=icon_orm.id, + title=icon_orm.title, + url=icon_orm.url, + content=icon_orm.content, + ) \ No newline at end of file diff --git a/blog/services/category.py b/blog/services/category.py new file mode 100644 index 0000000..5369f0b --- /dev/null +++ b/blog/services/category.py @@ -0,0 +1,85 @@ +"""Service layer for Category entities.""" + +from typing import List, Optional +from blog.repos.category import CategoryRepository +from blog.domain.category import Category +from blog.category.models import Category as CategoryORM +from blog.extensions import db +import sqlalchemy as sa + + +class CategoryServiceError(Exception): + """Base exception for CategoryService errors.""" + pass + + +class CategoryService: + """Service layer for Category entities.""" + + def __init__(self, category_repository: CategoryRepository): + self.category_repository = category_repository + + def get_category_by_id(self, category_id: int) -> Optional[Category]: + """Get a category by its ID.""" + return self.category_repository.get_by_id(category_id) + + def get_category_by_alias(self, alias: str) -> Optional[Category]: + """Get a category by its alias.""" + return self.category_repository.get_by_alias(alias) + + def get_all_categories(self) -> List[Category]: + """Get all categories.""" + return self.category_repository.get_all() + + def get_categories_with_posts(self) -> List[Category]: + """Get all categories with their posts.""" + return self.category_repository.get_categories_with_posts() + + def create_category(self, category: Category) -> Category: + """Create a new category.""" + try: + return self.category_repository.create(category) + except Exception as e: + # Re-raise as a more specific exception for the service layer + raise CategoryServiceError(f"Failed to create category: {str(e)}") from e + + def update_category(self, category: Category) -> Category: + """Update an existing category.""" + try: + return self.category_repository.update(category) + except ValueError as e: + # Re-raise as a more specific exception for the service layer + raise CategoryServiceError(f"Failed to update category: {str(e)}") from e + + def delete_category(self, category_id: int) -> bool: + """Delete a category by its ID.""" + try: + return self.category_repository.delete(category_id) + except Exception as e: + # Log the error and return False to indicate failure + # In a real application, you might want to log this + return False + + def get_category_by_id_orm(self, category_id: int) -> Optional[CategoryORM]: + """Get a category by its ID as ORM model (for compatibility with views).""" + domain_category = self.get_category_by_id(category_id) + if domain_category: + return self._to_orm_model(domain_category) + return None + + def _to_orm_model(self, category: Category) -> CategoryORM: + """Convert domain model to ORM model by loading from database.""" + if category.id: + # Load the full ORM model from database to get relationships + stmt = sa.select(CategoryORM).where(CategoryORM.id == category.id) + category_orm = db.session.scalar(stmt) + if category_orm: + return category_orm + + # If we can't load from database, create a new instance + category_orm = CategoryORM() + category_orm.id = category.id or 0 + category_orm.title = category.title + category_orm.alias = category.alias + category_orm.template = category.template + return category_orm \ No newline at end of file diff --git a/blog/services/factory.py b/blog/services/factory.py new file mode 100644 index 0000000..e84dfa7 --- /dev/null +++ b/blog/services/factory.py @@ -0,0 +1,46 @@ +"""Service factory for creating service instances with proper dependency injection.""" + +from blog.repos.post import PostRepository +from blog.repos.category import CategoryRepository +from blog.repos.icon import IconRepository +from blog.repos.tag import TagRepository +from blog.repos.user import UserRepository +from blog.services.post import PostService +from blog.services.category import CategoryService +from blog.services.icon import IconService +from blog.services.tag import TagService +from blog.services.user import UserService + + +class ServiceFactory: + """Factory for creating service instances with proper dependency injection.""" + + @staticmethod + def create_post_service(): + """Create a PostService instance with its dependencies.""" + post_repository = PostRepository() + return PostService(post_repository) + + @staticmethod + def create_category_service(): + """Create a CategoryService instance with its dependencies.""" + category_repository = CategoryRepository() + return CategoryService(category_repository) + + @staticmethod + def create_icon_service(): + """Create an IconService instance with its dependencies.""" + icon_repository = IconRepository() + return IconService(icon_repository) + + @staticmethod + def create_tag_service(): + """Create a TagService instance with its dependencies.""" + tag_repository = TagRepository() + return TagService(tag_repository) + + @staticmethod + def create_user_service(): + """Create a UserService instance with its dependencies.""" + user_repository = UserRepository() + return UserService(user_repository) \ No newline at end of file diff --git a/blog/services/icon.py b/blog/services/icon.py new file mode 100644 index 0000000..4ec0669 --- /dev/null +++ b/blog/services/icon.py @@ -0,0 +1,79 @@ +"""Service layer for Icon entities.""" + +from typing import List, Optional +from blog.repos.icon import IconRepository +from blog.domain.icon import Icon +from blog.post.models import Icon as IconORM +from blog.extensions import db +import sqlalchemy as sa + + +class IconServiceError(Exception): + """Base exception for IconService errors.""" + pass + + +class IconService: + """Service layer for Icon entities.""" + + def __init__(self, icon_repository: IconRepository): + self.icon_repository = icon_repository + + def get_icon_by_id(self, icon_id: int) -> Optional[Icon]: + """Get an icon by its ID.""" + return self.icon_repository.get_by_id(icon_id) + + def get_icon_by_title(self, title: str) -> Optional[Icon]: + """Get an icon by its title.""" + return self.icon_repository.get_by_title(title) + + def get_all_icons(self) -> List[Icon]: + """Get all icons.""" + return self.icon_repository.get_all() + + def create_icon(self, icon: Icon) -> Icon: + """Create a new icon.""" + try: + return self.icon_repository.create(icon) + except Exception as e: + # Re-raise as a more specific exception for the service layer + raise IconServiceError(f"Failed to create icon: {str(e)}") from e + + def update_icon(self, icon: Icon) -> Icon: + """Update an existing icon.""" + try: + return self.icon_repository.update(icon) + except ValueError as e: + # Re-raise as a more specific exception for the service layer + raise IconServiceError(f"Failed to update icon: {str(e)}") from e + + def delete_icon(self, icon_id: int) -> bool: + """Delete an icon by its ID.""" + try: + return self.icon_repository.delete(icon_id) + except Exception as e: + # Log the error and return False to indicate failure + # In a real application, you might want to log this + return False + + def get_all_icons_orm(self) -> List[IconORM]: + """Get all icons as ORM models (for compatibility with views).""" + domain_icons = self.get_all_icons() + return [self._to_orm_model(icon) for icon in domain_icons] + + def _to_orm_model(self, icon: Icon) -> IconORM: + """Convert domain model to ORM model by loading from database.""" + if icon.id: + # Load the full ORM model from database + stmt = sa.select(IconORM).where(IconORM.id == icon.id) + icon_orm = db.session.scalar(stmt) + if icon_orm: + return icon_orm + + # If we can't load from database, create a new instance + icon_orm = IconORM() + icon_orm.id = icon.id or 0 + icon_orm.title = icon.title + icon_orm.url = icon.url + icon_orm.content = icon.content + return icon_orm \ No newline at end of file diff --git a/blog/services/post.py b/blog/services/post.py index edcc9b7..c044494 100644 --- a/blog/services/post.py +++ b/blog/services/post.py @@ -8,6 +8,11 @@ import sqlalchemy as sa +class PostServiceError(Exception): + """Base exception for PostService errors.""" + pass + + class PostService: """Service layer for Post entities.""" @@ -34,17 +39,35 @@ def get_page_posts(self, page_category_ids: List[int]) -> List[Post]: """Get posts that are pages (in specific categories).""" return self.post_repository.get_page_posts(page_category_ids) + def get_page_posts_orm(self, page_category_ids: List[int]) -> List[PostORM]: + """Get posts that are pages (in specific categories) as ORM models (for compatibility with views).""" + domain_posts = self.get_page_posts(page_category_ids) + return [self._to_orm_model(post) for post in domain_posts] + def create_post(self, post: Post) -> Post: """Create a new post.""" - return self.post_repository.create(post) + try: + return self.post_repository.create(post) + except Exception as e: + # Re-raise as a more specific exception for the service layer + raise PostServiceError(f"Failed to create post: {str(e)}") from e def update_post(self, post: Post) -> Post: """Update an existing post.""" - return self.post_repository.update(post) + try: + return self.post_repository.update(post) + except ValueError as e: + # Re-raise as a more specific exception for the service layer + raise PostServiceError(f"Failed to update post: {str(e)}") from e def delete_post(self, post_id: int) -> bool: """Delete a post by its ID.""" - return self.post_repository.delete(post_id) + try: + return self.post_repository.delete(post_id) + except Exception as e: + # Log the error and return False to indicate failure + # In a real application, you might want to log this + return False def get_published_posts_orm(self) -> List[PostORM]: """Get all published posts as ORM models (for compatibility with views).""" diff --git a/blog/services/tag.py b/blog/services/tag.py new file mode 100644 index 0000000..e8cd07d --- /dev/null +++ b/blog/services/tag.py @@ -0,0 +1,89 @@ +"""Service layer for Tag entities.""" + +from typing import List, Optional +from blog.repos.tag import TagRepository +from blog.domain.tag import Tag +from blog.tags.models import Tag as TagORM +from blog.extensions import db +import sqlalchemy as sa + + +class TagServiceError(Exception): + """Base exception for TagService errors.""" + pass + + +class TagService: + """Service layer for Tag entities.""" + + def __init__(self, tag_repository: TagRepository): + self.tag_repository = tag_repository + + def get_tag_by_id(self, tag_id: int) -> Optional[Tag]: + """Get a tag by its ID.""" + return self.tag_repository.get_by_id(tag_id) + + def get_tag_by_alias(self, alias: str) -> Optional[Tag]: + """Get a tag by its alias.""" + return self.tag_repository.get_by_alias(alias) + + def get_all_tags(self) -> List[Tag]: + """Get all tags.""" + return self.tag_repository.get_all() + + def get_tags_with_posts(self) -> List[Tag]: + """Get all tags with their posts.""" + return self.tag_repository.get_tags_with_posts() + + def create_tag(self, tag: Tag) -> Tag: + """Create a new tag.""" + try: + return self.tag_repository.create(tag) + except Exception as e: + # Re-raise as a more specific exception for the service layer + raise TagServiceError(f"Failed to create tag: {str(e)}") from e + + def update_tag(self, tag: Tag) -> Tag: + """Update an existing tag.""" + try: + return self.tag_repository.update(tag) + except ValueError as e: + # Re-raise as a more specific exception for the service layer + raise TagServiceError(f"Failed to update tag: {str(e)}") from e + + def delete_tag(self, tag_id: int) -> bool: + """Delete a tag by its ID.""" + try: + return self.tag_repository.delete(tag_id) + except Exception as e: + # Log the error and return False to indicate failure + # In a real application, you might want to log this + return False + + def get_tag_by_alias_orm(self, alias: str) -> Optional[TagORM]: + """Get a tag by its alias as ORM model (for compatibility with views).""" + domain_tag = self.get_tag_by_alias(alias) + if domain_tag: + return self._to_orm_model(domain_tag) + return None + + def get_all_tags_orm(self) -> List[TagORM]: + """Get all tags as ORM models (for compatibility with views).""" + domain_tags = self.get_all_tags() + return [self._to_orm_model(tag) for tag in domain_tags] + + def _to_orm_model(self, tag: Tag) -> TagORM: + """Convert domain model to ORM model by loading from database.""" + if tag.id: + # Load the full ORM model from database to get relationships + stmt = sa.select(TagORM).where(TagORM.id == tag.id) + tag_orm = db.session.scalar(stmt) + if tag_orm: + return tag_orm + + # If we can't load from database, create a new instance + tag_orm = TagORM() + tag_orm.id = tag.id or 0 + tag_orm.title = tag.title + tag_orm.alias = tag.alias + return tag_orm \ No newline at end of file diff --git a/blog/services/user.py b/blog/services/user.py new file mode 100644 index 0000000..7316f01 --- /dev/null +++ b/blog/services/user.py @@ -0,0 +1,97 @@ +"""Service layer for User entities.""" + +from typing import List, Optional +from blog.repos.user import UserRepository +from blog.domain.user import User +from blog.user.models import User as UserORM +from blog.extensions import db +import sqlalchemy as sa + + +class UserServiceError(Exception): + """Base exception for UserService errors.""" + pass + + +class UserService: + """Service layer for User entities.""" + + def __init__(self, user_repository: UserRepository): + self.user_repository = user_repository + + def get_user_by_id(self, user_id: int) -> Optional[User]: + """Get a user by its ID.""" + return self.user_repository.get_by_id(user_id) + + def get_user_by_name(self, name: str) -> Optional[User]: + """Get a user by their name.""" + return self.user_repository.get_by_name(name) + + def get_all_users(self) -> List[User]: + """Get all users.""" + return self.user_repository.get_all() + + def get_users_with_posts(self) -> List[User]: + """Get all users with their posts.""" + return self.user_repository.get_users_with_posts() + + def create_user(self, user: User) -> User: + """Create a new user.""" + try: + return self.user_repository.create(user) + except Exception as e: + # Re-raise as a more specific exception for the service layer + raise UserServiceError(f"Failed to create user: {str(e)}") from e + + def update_user(self, user: User) -> User: + """Update an existing user.""" + try: + return self.user_repository.update(user) + except ValueError as e: + # Re-raise as a more specific exception for the service layer + raise UserServiceError(f"Failed to update user: {str(e)}") from e + + def delete_user(self, user_id: int) -> bool: + """Delete a user by its ID.""" + try: + return self.user_repository.delete(user_id) + except Exception as e: + # Log the error and return False to indicate failure + # In a real application, you might want to log this + return False + + def authenticate_user(self, name: str, password: str) -> Optional[User]: + """Authenticate a user by name and password.""" + return self.user_repository.authenticate(name, password) + + def get_user_by_id_orm(self, user_id: int) -> Optional[UserORM]: + """Get a user by its ID as ORM model (for compatibility with views).""" + domain_user = self.get_user_by_id(user_id) + if domain_user: + return self._to_orm_model(domain_user) + return None + + def get_user_by_name_orm(self, name: str) -> Optional[UserORM]: + """Get a user by their name as ORM model (for compatibility with views).""" + domain_user = self.get_user_by_name(name) + if domain_user: + return self._to_orm_model(domain_user) + return None + + def _to_orm_model(self, user: User) -> UserORM: + """Convert domain model to ORM model by loading from database.""" + if user.id: + # Load the full ORM model from database to get relationships + stmt = sa.select(UserORM).where(UserORM.id == user.id) + user_orm = db.session.scalar(stmt) + if user_orm: + return user_orm + + # If we can't load from database, create a new instance + user_orm = UserORM() + user_orm.id = user.id or 0 + user_orm.name = user.name + user_orm.password = user.password + user_orm.authenticated = user.authenticated + user_orm.createdon = user.createdon + return user_orm \ No newline at end of file diff --git a/blog/tags/models.py b/blog/tags/models.py index a8a9684..e8f586c 100644 --- a/blog/tags/models.py +++ b/blog/tags/models.py @@ -11,7 +11,7 @@ class Tag(db.Model): - """orm model for blog post.""" + """orm model for blog tag.""" __table__ = get_tags_table(db.metadata) diff --git a/blog/tags/views.py b/blog/tags/views.py index 7c842bb..daa1cf3 100644 --- a/blog/tags/views.py +++ b/blog/tags/views.py @@ -1,9 +1,10 @@ import sqlalchemy as sa from flask import Blueprint, render_template -from blog import db from blog.post.views import pages_gen -from blog.tags.models import Tag +from blog.repos.tag import TagRepository +from blog.services.tag import TagService +from blog.services.factory import ServiceFactory tagsb = Blueprint("tagsb", __name__, url_prefix="/tags") @@ -11,13 +12,19 @@ @tagsb.route("/") @pages_gen def index(**kwargs): - tags = db.session.scalars(sa.select(Tag)).all() + # Use service layer instead of direct database access + tag_service = ServiceFactory.create_tag_service() + tags = tag_service.get_all_tags_orm() return render_template("tags.html", tags=tags, **kwargs) @tagsb.route("/") @pages_gen def view(alias=None, **kwargs): - tag_query = sa.select(Tag).where(Tag.alias == alias) - tag = db.first_or_404(tag_query) + # Use service layer instead of direct database access + tag_service = ServiceFactory.create_tag_service() + tag = tag_service.get_tag_by_alias_orm(alias) + if not tag: + from flask import abort + abort(404) return render_template("posts.html", posts=tag.posts, tag=tag, **kwargs) diff --git a/blog/user/views.py b/blog/user/views.py index 5ea872f..a984864 100644 --- a/blog/user/views.py +++ b/blog/user/views.py @@ -6,6 +6,8 @@ from blog.extensions import db, login_manager from blog.user.forms import LoginForm from blog.user.models import User +from blog.services.factory import ServiceFactory + user_blueprint = Blueprint("userb", __name__) @@ -13,8 +15,9 @@ @login_manager.user_loader def load_user(user_id): """Load user by ID for Flask-Login.""" - user_query = sa.select(User).where(User.id == int(user_id)) - return db.session.scalars(user_query).first() + # Use service layer instead of direct database access + user_service = ServiceFactory.create_user_service() + return user_service.get_user_by_id_orm(int(user_id)) @user_blueprint.route("/login", methods=["GET", "POST"]) @@ -23,11 +26,14 @@ def login(): return redirect("/") form = LoginForm() if form.validate_on_submit(): - user_query = sa.select(User).where(User.name == form.name.data) - user = db.session.scalars(user_query).first() - - if user and user.check_password(form.password.data): - login_user(user) + # Use service layer instead of direct database access + user_service = ServiceFactory.create_user_service() + user = user_service.authenticate_user(form.name.data, form.password.data) + + if user: + # Convert domain model to ORM model for Flask-Login + user_orm = user_service._to_orm_model(user) + login_user(user_orm) return redirect(url_for("admin.index")) flash("invalid user o password") return redirect(url_for("userb.login")) From eca012d4efe68cb6227eca74fb3ba2aa888afc3b Mon Sep 17 00:00:00 2001 From: loki Date: Sun, 7 Sep 2025 13:21:45 +0300 Subject: [PATCH 06/34] keep scrolling --- blog/category/models.py | 12 +- blog/domain/category.py | 10 +- blog/domain/icon.py | 2 +- blog/domain/post.py | 32 ++- blog/domain/tag.py | 8 +- blog/domain/user.py | 8 +- blog/post/models.py | 33 ++- blog/post/views.py | 71 ++--- blog/repos/category.py | 63 ++++- blog/repos/icon.py | 18 +- blog/repos/post.py | 101 +++++-- blog/repos/tag.py | 61 +++- blog/repos/user.py | 71 ++++- blog/services/category.py | 30 +- blog/services/factory.py | 13 +- blog/services/icon.py | 36 +-- blog/services/post.py | 44 +-- blog/services/tag.py | 34 +-- blog/services/user.py | 46 +--- blog/tags/models.py | 11 +- blog/tags/views.py | 17 +- blog/templates/post.html | 8 +- blog/user/models.py | 14 +- blog/user/views.py | 39 ++- requirements.txt | 5 - tests/test_repositories.py | 552 +++++++++++++++++++++++++++++++++++++ tests/test_services.py | 487 ++++++++++++++++++++++++++++++++ tests/test_tags.py | 92 ++++--- tests/test_views.py | 150 ++++++++++ 29 files changed, 1687 insertions(+), 381 deletions(-) delete mode 100644 requirements.txt create mode 100644 tests/test_repositories.py create mode 100644 tests/test_services.py create mode 100644 tests/test_views.py diff --git a/blog/category/models.py b/blog/category/models.py index ffa231e..8922cc0 100644 --- a/blog/category/models.py +++ b/blog/category/models.py @@ -1,13 +1,13 @@ # blog/category/models.py from typing import TYPE_CHECKING -from sqlalchemy.orm import relationship +from sqlalchemy.orm import Mapped, relationship from blog.extensions import db from blog.infrastructure.database import get_categories_table if TYPE_CHECKING: - pass + from blog.post.models import Post class Category(db.Model): @@ -15,7 +15,13 @@ class Category(db.Model): __table__ = get_categories_table(db.metadata) - posts = relationship("Post", back_populates="category") + # Type annotations for table columns + id: Mapped[int] + title: Mapped[str | None] + alias: Mapped[str | None] + template: Mapped[str | None] + + posts: Mapped[list["Post"]] = relationship("Post", back_populates="category") def __str__(self): return f"{self.title}" diff --git a/blog/domain/category.py b/blog/domain/category.py index 6c145f7..a05d0ae 100644 --- a/blog/domain/category.py +++ b/blog/domain/category.py @@ -2,22 +2,22 @@ from dataclasses import dataclass import typing -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Optional, List if TYPE_CHECKING: - from blog.post.models import Post + from blog.domain.post import Post as PostDomain @dataclass class Category: """Domain model for blog category.""" - id: int | None = None + id: Optional[int] = None title: str = "" alias: str = "" - template: str | None = None + template: Optional[str] = None - posts: "list[Post] | None" = None + posts: "Optional[List[PostDomain]]" = None @typing.override def __str__(self): diff --git a/blog/domain/icon.py b/blog/domain/icon.py index 7fc0a07..12720bc 100644 --- a/blog/domain/icon.py +++ b/blog/domain/icon.py @@ -13,4 +13,4 @@ class Icon: content: str | None = None def __str__(self): - return f"Icon(id={self.id}, title={self.title})" \ No newline at end of file + return f"Icon(id={self.id}, title={self.title})" diff --git a/blog/domain/post.py b/blog/domain/post.py index 87c778d..666468d 100644 --- a/blog/domain/post.py +++ b/blog/domain/post.py @@ -2,34 +2,42 @@ from dataclasses import dataclass import datetime -from typing import TYPE_CHECKING +import markdown +from typing import TYPE_CHECKING, Optional, List if TYPE_CHECKING: - from blog.category.models import Category - from blog.tags.models import Tag - from blog.user.models import User + from blog.domain.category import Category as CategoryDomain + from blog.domain.tag import Tag as TagDomain + from blog.domain.user import User as UserDomain + + +MARKDOWN_EXTENSIONS = ["markdown.extensions.fenced_code"] @dataclass class Post: """Domain model for blog post.""" - id: int | None = None + id: Optional[int] = None pagetitle: str = "" alias: str = "" content: str = "" - createdon: datetime.datetime | None = None - publishedon: datetime.datetime | None = None - category_id: int | None = None - user_id: int | None = None + createdon: Optional[datetime.datetime] = None + publishedon: Optional[datetime.datetime] = None + category_id: Optional[int] = None + user_id: Optional[int] = None # These would typically be loaded separately in a real implementation # to avoid circular dependencies - user: "User | None" = None - category: "Category | None" = None - tags: "list[Tag] | None" = None + user: "Optional[UserDomain]" = None + category: "Optional[CategoryDomain]" = None + tags: "Optional[List[TagDomain]]" = None def __post_init__(self): if self.createdon is None: self.createdon = datetime.datetime.now(datetime.timezone.utc) # Don't set default for publishedon - it should be None for unpublished posts + + @property + def markdown(self): + return markdown.markdown(self.content or "", extensions=MARKDOWN_EXTENSIONS) diff --git a/blog/domain/tag.py b/blog/domain/tag.py index 98c753b..36c6806 100644 --- a/blog/domain/tag.py +++ b/blog/domain/tag.py @@ -2,21 +2,21 @@ from dataclasses import dataclass import typing -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Optional, List if TYPE_CHECKING: - from blog.post.models import Post + from blog.domain.post import Post as PostDomain @dataclass class Tag: """Domain model for blog tag.""" - id: int | None = None + id: Optional[int] = None title: str = "" alias: str = "" - posts: "list[Post] | None" = None + posts: "Optional[List[PostDomain]]" = None @typing.override def __str__(self): diff --git a/blog/domain/user.py b/blog/domain/user.py index 0d7aec3..e4f5478 100644 --- a/blog/domain/user.py +++ b/blog/domain/user.py @@ -6,7 +6,11 @@ from typing import TYPE_CHECKING if TYPE_CHECKING: - from blog.post.models import Post + from blog.domain.post import Post as PostDomain + + Post = PostDomain +else: + Post = "Post" @dataclass @@ -18,7 +22,7 @@ class User: password: str = "" authenticated: bool = False createdon: datetime.datetime | None = None - posts: "list[Post] | None" = None + posts: list[Post] | None = None def __post_init__(self): if self.createdon is None: diff --git a/blog/post/models.py b/blog/post/models.py index 2fc1c7b..89c7bb4 100644 --- a/blog/post/models.py +++ b/blog/post/models.py @@ -1,15 +1,18 @@ # blog/posts/models.py import typing +from datetime import datetime from typing import TYPE_CHECKING import markdown -from sqlalchemy.orm import relationship +from sqlalchemy.orm import Mapped, relationship from blog.extensions import db from blog.infrastructure.database import get_posts_table, get_posts_tags_table if TYPE_CHECKING: - pass + from blog.category.models import Category + from blog.user.models import User + from blog.tags.models import Tag MARKDOWN_EXTENSIONS = ["markdown.extensions.fenced_code"] @@ -20,15 +23,25 @@ class Post(db.Model): __table__ = get_posts_table(db.metadata) - user = relationship("User", back_populates="posts") - category = relationship("Category", back_populates="posts") - tags = relationship( + # Type annotations for table columns + id: Mapped[int] + pagetitle: Mapped[str] + alias: Mapped[str] + content: Mapped[str | None] + createdon: Mapped[datetime | None] + publishedon: Mapped[datetime | None] + category_id: Mapped[int | None] + user_id: Mapped[int | None] + + user: Mapped["User"] = relationship("User", back_populates="posts") + category: Mapped["Category"] = relationship("Category", back_populates="posts") + tags: Mapped[list["Tag"]] = relationship( "Tag", secondary=get_posts_tags_table(db.metadata), back_populates="posts" ) @property def markdown(self): - return markdown.markdown(self.content, extensions=MARKDOWN_EXTENSIONS) + return markdown.markdown(self.content or "", extensions=MARKDOWN_EXTENSIONS) @typing.override def __str__(self): @@ -40,10 +53,10 @@ class Icon(db.Model): __tablename__ = "icons" # pyright: ignore[reportUnannotatedClassAttribute] - id = db.Column(db.Integer, primary_key=True) - title = db.Column(db.String(255), nullable=False, unique=True) - url = db.Column(db.String(255), nullable=False, unique=True) - content = db.Column(db.Text) + id: Mapped[int] = db.Column(db.Integer, primary_key=True) + title: Mapped[str] = db.Column(db.String(255), nullable=False, unique=True) + url: Mapped[str] = db.Column(db.String(255), nullable=False, unique=True) + content: Mapped[str | None] = db.Column(db.Text) def __str__(self): return f"{self.id} {self.title}" diff --git a/blog/post/views.py b/blog/post/views.py index 23df2ab..0c841b1 100644 --- a/blog/post/views.py +++ b/blog/post/views.py @@ -2,7 +2,6 @@ from functools import wraps import markdown -import sqlalchemy as sa from flask import ( Blueprint, current_app, @@ -14,14 +13,8 @@ ) from blog.extensions import flask_sitemap -from blog import cache, db -from blog.post.models import Post, Icon -from blog.repos.post import PostRepository -from blog.repos.category import CategoryRepository -from blog.repos.icon import IconRepository -from blog.services.post import PostService -from blog.services.category import CategoryService -from blog.services.icon import IconService +from blog import cache +from blog.services.factory import ServiceFactory post = Blueprint("postb", __name__) @@ -29,16 +22,15 @@ def pages_gen(f): @wraps(f) def decorated_function(*args, **kwargs): - # Use service layer instead of direct database access - from blog.services.factory import ServiceFactory - + # Use service layer for business logic and domain models + page_category = current_app.config["PAGE_CATEGORY"] - - # Get posts using PostService + + # Get posts using PostService for domain models post_service = ServiceFactory.create_post_service() - pages = post_service.get_page_posts_orm(page_category) + pages = post_service.get_page_posts(page_category) - # Get icons using IconService + # Get icons using IconService for ORM models (needed for specific use cases) icon_service = ServiceFactory.create_icon_service() icons = icon_service.get_all_icons_orm() @@ -51,11 +43,10 @@ def decorated_function(*args, **kwargs): @cache.cached(timeout=50) @pages_gen def index(**kwargs): - # Use service layer instead of direct database access - from blog.services.factory import ServiceFactory - + # Use service layer for domain models + post_service = ServiceFactory.create_post_service() - posts = post_service.get_published_posts_orm() + posts = post_service.get_published_posts() return render_template("posts.html", posts=posts, **kwargs) @@ -63,11 +54,14 @@ def index(**kwargs): @cache.cached(timeout=50) @pages_gen def view(alias=None, **kwargs): - # Use service layer instead of direct database access - from blog.services.factory import ServiceFactory - + # Use service layer for domain models + if alias is None: + from flask import abort + + abort(404) + post_service = ServiceFactory.create_post_service() - post = post_service.get_post_by_alias_orm(alias) + post = post_service.get_post_by_alias(alias) if not post: # Handle 404 case from flask import abort @@ -84,14 +78,11 @@ def view(alias=None, **kwargs): abort(404) - # Load category object if needed using service layer + # Load category object if needed using service page_category_obj = None if post.category_id: - from blog.category.models import Category - - # Use CategoryService instead of direct database access category_service = ServiceFactory.create_category_service() - page_category_obj = category_service.get_category_by_id_orm(post.category_id) + page_category_obj = category_service.get_category_by_id(post.category_id) if page_category_obj and page_category_obj.template: return render_template(page_category_obj.template, post=post, **kwargs) @@ -100,19 +91,18 @@ def view(alias=None, **kwargs): @flask_sitemap.register_generator def site_map_gen(): - # Use service layer instead of direct database access - from blog.services.factory import ServiceFactory - + # Use service layer for domain models + page_category = current_app.config["PAGE_CATEGORY"] - + # Get pages using PostService post_service = ServiceFactory.create_post_service() - pages = post_service.get_page_posts_orm(page_category) + pages = post_service.get_page_posts(page_category) for page in pages: yield url_for("postb.view", alias=page.alias) - + # Get posts using PostService - posts = post_service.get_published_posts_orm() + posts = post_service.get_published_posts() for post in posts: yield url_for("postb.view", alias=post.alias) @@ -138,12 +128,11 @@ def robots(): @post.route("/rss.xml") @cache.cached(timeout=50) def rss(): - # Use service layer instead of direct database access - from blog.services.factory import ServiceFactory - + # Use service layer for domain models + post_service = ServiceFactory.create_post_service() - list_posts = post_service.get_published_posts_orm() - + list_posts = post_service.get_published_posts() + date = datetime.datetime.now() rss_xml = render_template("rss.xml", posts=list_posts, date=date) response = make_response(rss_xml) diff --git a/blog/repos/category.py b/blog/repos/category.py index 4f889c6..190f8e0 100644 --- a/blog/repos/category.py +++ b/blog/repos/category.py @@ -1,20 +1,32 @@ """Repository for Category entities.""" import sqlalchemy as sa -from sqlalchemy.orm import Session +from typing import Any from blog.extensions import db from blog.category.models import Category as CategoryORM -from blog.domain.category import Category +from blog.domain.category import Category as CategoryDomain +from blog.domain.post import Post as PostDomain class CategoryRepository: """Repository for Category entities.""" - def __init__(self, session: Session | None = None): + def __init__(self, session: Any = None): self.session = session or db.session - def get_by_id(self, category_id: int) -> Category | None: + def get_category_orm_with_relationships( + self, category_id: int + ) -> CategoryORM | None: + """Get a category ORM model with all its relationships loaded.""" + stmt = ( + sa.select(CategoryORM) + .where(CategoryORM.id == category_id) + .options(sa.orm.joinedload(CategoryORM.posts)) + ) + return self.session.scalar(stmt) + + def get_by_id(self, category_id: int) -> CategoryDomain | None: """Get a category by its ID.""" stmt = sa.select(CategoryORM).where(CategoryORM.id == category_id) category_orm = self.session.scalar(stmt) @@ -22,7 +34,7 @@ def get_by_id(self, category_id: int) -> Category | None: return self._to_domain_model(category_orm) return None - def get_by_alias(self, alias: str) -> Category | None: + def get_by_alias(self, alias: str) -> CategoryDomain | None: """Get a category by its alias.""" stmt = sa.select(CategoryORM).where(CategoryORM.alias == alias) category_orm = self.session.scalar(stmt) @@ -30,19 +42,19 @@ def get_by_alias(self, alias: str) -> Category | None: return self._to_domain_model(category_orm) return None - def get_all(self) -> list[Category]: + def get_all(self) -> list[CategoryDomain]: """Get all categories.""" stmt = sa.select(CategoryORM) - categories_orm = self.session.scalars(stmt).all() + categories_orm = list(self.session.scalars(stmt).all()) return [self._to_domain_model(category_orm) for category_orm in categories_orm] - def get_categories_with_posts(self) -> list[Category]: + def get_categories_with_posts(self) -> list[CategoryDomain]: """Get all categories with their posts.""" stmt = sa.select(CategoryORM).options(sa.orm.joinedload(CategoryORM.posts)) - categories_orm = self.session.scalars(stmt).unique().all() + categories_orm = list(self.session.scalars(stmt).unique().all()) return [self._to_domain_model(category_orm) for category_orm in categories_orm] - def create(self, category: Category) -> Category: + def create(self, category: CategoryDomain) -> CategoryDomain: """Create a new category.""" category_orm = CategoryORM() category_orm.title = category.title @@ -55,7 +67,7 @@ def create(self, category: Category) -> Category: category.id = category_orm.id return category - def update(self, category: Category) -> Category: + def update(self, category: CategoryDomain) -> CategoryDomain: """Update an existing category.""" stmt = sa.select(CategoryORM).where(CategoryORM.id == category.id) category_orm = self.session.scalar(stmt) @@ -79,11 +91,32 @@ def delete(self, category_id: int) -> bool: return True return False - def _to_domain_model(self, category_orm: CategoryORM) -> Category: + def _to_domain_model(self, category_orm: CategoryORM) -> CategoryDomain: """Convert ORM model to domain model.""" - return Category( + # Convert related posts if they exist + posts = None + if category_orm.posts: + posts = [ + PostDomain( + id=post_orm.id, + pagetitle=post_orm.pagetitle or "", + alias=post_orm.alias or "", + content=post_orm.content or "", + createdon=post_orm.createdon, + publishedon=post_orm.publishedon, + category_id=post_orm.category_id, + user_id=post_orm.user_id, + user=None, # Avoid circular references + category=None, # Avoid circular references + tags=None, # Avoid circular references + ) + for post_orm in category_orm.posts + ] + + return CategoryDomain( id=category_orm.id, - title=category_orm.title, - alias=category_orm.alias, + title=category_orm.title or "", + alias=category_orm.alias or "", template=category_orm.template, + posts=posts, ) diff --git a/blog/repos/icon.py b/blog/repos/icon.py index 54d5fc1..509dba2 100644 --- a/blog/repos/icon.py +++ b/blog/repos/icon.py @@ -1,7 +1,7 @@ """Repository for Icon entities.""" import sqlalchemy as sa -from sqlalchemy.orm import Session +from typing import Any from blog.extensions import db from blog.post.models import Icon as IconORM @@ -11,9 +11,19 @@ class IconRepository: """Repository for Icon entities.""" - def __init__(self, session: Session | None = None): + def __init__(self, session: Any = None): self.session = session or db.session + def get_icon_orm_by_id(self, icon_id: int) -> IconORM | None: + """Get an icon ORM model by its ID. Used for specific use cases requiring ORM models.""" + stmt = sa.select(IconORM).where(IconORM.id == icon_id) + return self.session.scalar(stmt) + + def get_all_icons_orm(self) -> list[IconORM]: + """Get all icons as ORM models. Used for specific use cases requiring ORM models.""" + stmt = sa.select(IconORM) + return list(self.session.scalars(stmt).all()) + def get_by_id(self, icon_id: int) -> Icon | None: """Get an icon by its ID.""" stmt = sa.select(IconORM).where(IconORM.id == icon_id) @@ -33,7 +43,7 @@ def get_by_title(self, title: str) -> Icon | None: def get_all(self) -> list[Icon]: """Get all icons.""" stmt = sa.select(IconORM) - icons_orm = self.session.scalars(stmt).all() + icons_orm = list(self.session.scalars(stmt).all()) return [self._to_domain_model(icon_orm) for icon_orm in icons_orm] def create(self, icon: Icon) -> Icon: @@ -78,4 +88,4 @@ def _to_domain_model(self, icon_orm: IconORM) -> Icon: title=icon_orm.title, url=icon_orm.url, content=icon_orm.content, - ) \ No newline at end of file + ) diff --git a/blog/repos/post.py b/blog/repos/post.py index 344d693..7cbbbcc 100644 --- a/blog/repos/post.py +++ b/blog/repos/post.py @@ -1,20 +1,36 @@ """Repository for Post entities.""" import sqlalchemy as sa -from sqlalchemy.orm import Session +from typing import Any from blog.extensions import db from blog.post.models import Post as PostORM -from blog.domain.post import Post +from blog.domain.post import Post as PostDomain +from blog.domain.user import User as UserDomain +from blog.domain.category import Category as CategoryDomain +from blog.domain.tag import Tag as TagDomain class PostRepository: """Repository for Post entities.""" - def __init__(self, session: Session | None = None): + def __init__(self, session: Any = None): self.session = session or db.session - def get_by_id(self, post_id: int) -> Post | None: + def get_post_orm_with_relationships(self, post_id: int) -> PostORM | None: + """Get a post ORM model with all its relationships loaded.""" + stmt = ( + sa.select(PostORM) + .where(PostORM.id == post_id) + .options( + sa.orm.joinedload(PostORM.user), + sa.orm.joinedload(PostORM.category), + sa.orm.joinedload(PostORM.tags), + ) + ) + return self.session.scalar(stmt) + + def get_by_id(self, post_id: int) -> PostDomain | None: """Get a post by its ID.""" stmt = sa.select(PostORM).where(PostORM.id == post_id) post_orm = self.session.scalar(stmt) @@ -22,7 +38,7 @@ def get_by_id(self, post_id: int) -> Post | None: return self._to_domain_model(post_orm) return None - def get_by_alias(self, alias: str) -> Post | None: + def get_by_alias(self, alias: str) -> PostDomain | None: """Get a post by its alias.""" stmt = sa.select(PostORM).where(PostORM.alias == alias) post_orm = self.session.scalar(stmt) @@ -30,13 +46,13 @@ def get_by_alias(self, alias: str) -> Post | None: return self._to_domain_model(post_orm) return None - def get_all(self) -> list[Post]: + def get_all(self) -> list[PostDomain]: """Get all posts.""" stmt = sa.select(PostORM) - posts_orm = self.session.scalars(stmt).all() + posts_orm = list(self.session.scalars(stmt).all()) return [self._to_domain_model(post_orm) for post_orm in posts_orm] - def get_published_posts(self) -> list[Post]: + def get_published_posts(self) -> list[PostDomain]: """Get all published posts ordered by published date.""" stmt = ( sa.select(PostORM) @@ -46,16 +62,16 @@ def get_published_posts(self) -> list[Post]: ) .order_by(PostORM.publishedon.desc()) ) - posts_orm = self.session.scalars(stmt).all() + posts_orm = list(self.session.scalars(stmt).all()) return [self._to_domain_model(post_orm) for post_orm in posts_orm] - def get_page_posts(self, page_category_ids: list[int]) -> list[Post]: + def get_page_posts(self, page_category_ids: list[int]) -> list[PostDomain]: """Get posts that are pages (in specific categories).""" stmt = sa.select(PostORM).where(PostORM.category_id.in_(page_category_ids)) - posts_orm = self.session.scalars(stmt).all() + posts_orm = list(self.session.scalars(stmt).all()) return [self._to_domain_model(post_orm) for post_orm in posts_orm] - def create(self, post: Post) -> Post: + def create(self, post: PostDomain) -> PostDomain: """Create a new post.""" post_orm = PostORM() post_orm.pagetitle = post.pagetitle @@ -70,12 +86,20 @@ def create(self, post: Post) -> Post: post_orm.category_id = post.category_id if post.user_id is not None: post_orm.user_id = post.user_id + + # Handle tags relationship if provided + if post.tags: + # For now, we'll just set the tags field to an empty list + # In a real implementation, we would need to handle the many-to-many relationship + # This is a limitation of the current domain model design + pass + self.session.add(post_orm) self.session.flush() # Get the ID without committing post.id = post_orm.id return post - def update(self, post: Post) -> Post: + def update(self, post: PostDomain) -> PostDomain: """Update an existing post.""" stmt = sa.select(PostORM).where(PostORM.id == post.id) post_orm = self.session.scalar(stmt) @@ -106,15 +130,56 @@ def delete(self, post_id: int) -> bool: return True return False - def _to_domain_model(self, post_orm: PostORM) -> Post: + def _to_domain_model(self, post_orm: PostORM) -> PostDomain: """Convert ORM model to domain model.""" - return Post( + # Convert related user if it exists + user = None + if post_orm.user: + user = UserDomain( + id=post_orm.user.id, + name=post_orm.user.name or "", + password=post_orm.user.password or "", + authenticated=bool(post_orm.user.authenticated) + if post_orm.user.authenticated is not None + else False, + createdon=post_orm.user.createdon, + posts=None, # Avoid circular references + ) + + # Convert related category if it exists + category = None + if post_orm.category: + category = CategoryDomain( + id=post_orm.category.id, + title=post_orm.category.title or "", + alias=post_orm.category.alias or "", + template=post_orm.category.template, + posts=None, # Avoid circular references + ) + + # Convert related tags if they exist + tags = None + if post_orm.tags: + tags = [ + TagDomain( + id=tag_orm.id, + title=tag_orm.title or "", + alias=tag_orm.alias or "", + posts=None, # Avoid circular references + ) + for tag_orm in post_orm.tags + ] + + return PostDomain( id=post_orm.id, - pagetitle=post_orm.pagetitle, - alias=post_orm.alias, - content=post_orm.content, + pagetitle=post_orm.pagetitle or "", + alias=post_orm.alias or "", + content=post_orm.content or "", createdon=post_orm.createdon, publishedon=post_orm.publishedon, category_id=post_orm.category_id, user_id=post_orm.user_id, + user=user, + category=category, + tags=tags, ) diff --git a/blog/repos/tag.py b/blog/repos/tag.py index 622a316..a1e8047 100644 --- a/blog/repos/tag.py +++ b/blog/repos/tag.py @@ -1,20 +1,30 @@ """Repository for Tag entities.""" import sqlalchemy as sa -from sqlalchemy.orm import Session +from typing import Any from blog.extensions import db from blog.tags.models import Tag as TagORM -from blog.domain.tag import Tag +from blog.domain.tag import Tag as TagDomain +from blog.domain.post import Post as PostDomain class TagRepository: """Repository for Tag entities.""" - def __init__(self, session: Session | None = None): + def __init__(self, session: Any = None): self.session = session or db.session - def get_by_id(self, tag_id: int) -> Tag | None: + def get_tag_orm_with_relationships(self, tag_id: int) -> TagORM | None: + """Get a tag ORM model with all its relationships loaded.""" + stmt = ( + sa.select(TagORM) + .where(TagORM.id == tag_id) + .options(sa.orm.joinedload(TagORM.posts)) + ) + return self.session.scalar(stmt) + + def get_by_id(self, tag_id: int) -> TagDomain | None: """Get a tag by its ID.""" stmt = sa.select(TagORM).where(TagORM.id == tag_id) tag_orm = self.session.scalar(stmt) @@ -22,7 +32,7 @@ def get_by_id(self, tag_id: int) -> Tag | None: return self._to_domain_model(tag_orm) return None - def get_by_alias(self, alias: str) -> Tag | None: + def get_by_alias(self, alias: str) -> TagDomain | None: """Get a tag by its alias.""" stmt = sa.select(TagORM).where(TagORM.alias == alias) tag_orm = self.session.scalar(stmt) @@ -30,19 +40,19 @@ def get_by_alias(self, alias: str) -> Tag | None: return self._to_domain_model(tag_orm) return None - def get_all(self) -> list[Tag]: + def get_all(self) -> list[TagDomain]: """Get all tags.""" stmt = sa.select(TagORM) - tags_orm = self.session.scalars(stmt).all() + tags_orm = list(self.session.scalars(stmt).all()) return [self._to_domain_model(tag_orm) for tag_orm in tags_orm] - def get_tags_with_posts(self) -> list[Tag]: + def get_tags_with_posts(self) -> list[TagDomain]: """Get all tags with their posts.""" stmt = sa.select(TagORM).options(sa.orm.joinedload(TagORM.posts)) - tags_orm = self.session.scalars(stmt).unique().all() + tags_orm = list(self.session.scalars(stmt).unique().all()) return [self._to_domain_model(tag_orm) for tag_orm in tags_orm] - def create(self, tag: Tag) -> Tag: + def create(self, tag: TagDomain) -> TagDomain: """Create a new tag.""" tag_orm = TagORM() tag_orm.title = tag.title @@ -52,7 +62,7 @@ def create(self, tag: Tag) -> Tag: tag.id = tag_orm.id return tag - def update(self, tag: Tag) -> Tag: + def update(self, tag: TagDomain) -> TagDomain: """Update an existing tag.""" stmt = sa.select(TagORM).where(TagORM.id == tag.id) tag_orm = self.session.scalar(stmt) @@ -73,10 +83,31 @@ def delete(self, tag_id: int) -> bool: return True return False - def _to_domain_model(self, tag_orm: TagORM) -> Tag: + def _to_domain_model(self, tag_orm: TagORM) -> TagDomain: """Convert ORM model to domain model.""" - return Tag( + # Convert related posts if they exist + posts = None + if tag_orm.posts: + posts = [ + PostDomain( + id=post_orm.id, + pagetitle=post_orm.pagetitle or "", + alias=post_orm.alias or "", + content=post_orm.content or "", + createdon=post_orm.createdon, + publishedon=post_orm.publishedon, + category_id=post_orm.category_id, + user_id=post_orm.user_id, + user=None, # Avoid circular references + category=None, # Avoid circular references + tags=None, # Avoid circular references + ) + for post_orm in tag_orm.posts + ] + + return TagDomain( id=tag_orm.id, - title=tag_orm.title, - alias=tag_orm.alias, + title=tag_orm.title or "", + alias=tag_orm.alias or "", + posts=posts, ) diff --git a/blog/repos/user.py b/blog/repos/user.py index 9c42044..a9d5d8e 100644 --- a/blog/repos/user.py +++ b/blog/repos/user.py @@ -1,20 +1,40 @@ """Repository for User entities.""" import sqlalchemy as sa -from sqlalchemy.orm import Session +from typing import Any from blog.extensions import db from blog.user.models import User as UserORM -from blog.domain.user import User +from blog.domain.user import User as UserDomain +from blog.domain.post import Post as PostDomain class UserRepository: """Repository for User entities.""" - def __init__(self, session: Session | None = None): + def __init__(self, session: Any = None): self.session = session or db.session - def get_by_id(self, user_id: int) -> User | None: + def get_user_orm_with_relationships(self, user_id: int) -> UserORM | None: + """Get a user ORM model with all its relationships loaded.""" + stmt = ( + sa.select(UserORM) + .where(UserORM.id == user_id) + .options(sa.orm.joinedload(UserORM.posts)) + ) + return self.session.scalar(stmt) + + def get_user_orm_by_id(self, user_id: int) -> UserORM | None: + """Get a user ORM model by its ID. Used for Flask-Login compatibility.""" + stmt = sa.select(UserORM).where(UserORM.id == user_id) + return self.session.scalar(stmt) + + def get_user_orm_by_name(self, name: str) -> UserORM | None: + """Get a user ORM model by their name. Used for Flask-Login compatibility.""" + stmt = sa.select(UserORM).where(UserORM.name == name) + return self.session.scalar(stmt) + + def get_by_id(self, user_id: int) -> UserDomain | None: """Get a user by its ID.""" stmt = sa.select(UserORM).where(UserORM.id == user_id) user_orm = self.session.scalar(stmt) @@ -22,7 +42,7 @@ def get_by_id(self, user_id: int) -> User | None: return self._to_domain_model(user_orm) return None - def get_by_name(self, name: str) -> User | None: + def get_by_name(self, name: str) -> UserDomain | None: """Get a user by their name.""" stmt = sa.select(UserORM).where(UserORM.name == name) user_orm = self.session.scalar(stmt) @@ -30,19 +50,19 @@ def get_by_name(self, name: str) -> User | None: return self._to_domain_model(user_orm) return None - def get_all(self) -> list[User]: + def get_all(self) -> list[UserDomain]: """Get all users.""" stmt = sa.select(UserORM) users_orm = self.session.scalars(stmt).all() return [self._to_domain_model(user_orm) for user_orm in users_orm] - def get_users_with_posts(self) -> list[User]: + def get_users_with_posts(self) -> list[UserDomain]: """Get all users with their posts.""" stmt = sa.select(UserORM).options(sa.orm.joinedload(UserORM.posts)) users_orm = self.session.scalars(stmt).unique().all() return [self._to_domain_model(user_orm) for user_orm in users_orm] - def create(self, user: User) -> User: + def create(self, user: UserDomain) -> UserDomain: """Create a new user.""" user_orm = UserORM() user_orm.name = user.name @@ -59,7 +79,7 @@ def create(self, user: User) -> User: user.id = user_orm.id return user - def update(self, user: User) -> User: + def update(self, user: UserDomain) -> UserDomain: """Update an existing user.""" stmt = sa.select(UserORM).where(UserORM.id == user.id) user_orm = self.session.scalar(stmt) @@ -87,7 +107,7 @@ def delete(self, user_id: int) -> bool: return True return False - def authenticate(self, name: str, password: str) -> User | None: + def authenticate(self, name: str, password: str) -> UserDomain | None: """Authenticate a user by name and password.""" stmt = sa.select(UserORM).where(UserORM.name == name) user_orm = self.session.scalar(stmt) @@ -95,14 +115,35 @@ def authenticate(self, name: str, password: str) -> User | None: return self._to_domain_model(user_orm) return None - def _to_domain_model(self, user_orm: UserORM) -> User: + def _to_domain_model(self, user_orm: UserORM) -> UserDomain: """Convert ORM model to domain model.""" - return User( + # Convert related posts if they exist + posts = None + if user_orm.posts: + posts = [ + PostDomain( + id=post_orm.id, + pagetitle=post_orm.pagetitle or "", + alias=post_orm.alias or "", + content=post_orm.content or "", + createdon=post_orm.createdon, + publishedon=post_orm.publishedon, + category_id=post_orm.category_id, + user_id=post_orm.user_id, + user=None, # Avoid circular references + category=None, # Avoid circular references + tags=None, # Avoid circular references + ) + for post_orm in user_orm.posts + ] + + return UserDomain( id=user_orm.id, - name=user_orm.name, - password=user_orm.password, - authenticated=user_orm.authenticated + name=user_orm.name or "", + password=user_orm.password or "", + authenticated=bool(user_orm.authenticated) if user_orm.authenticated is not None else False, createdon=user_orm.createdon, + posts=posts, ) diff --git a/blog/services/category.py b/blog/services/category.py index 5369f0b..ead20c9 100644 --- a/blog/services/category.py +++ b/blog/services/category.py @@ -3,13 +3,11 @@ from typing import List, Optional from blog.repos.category import CategoryRepository from blog.domain.category import Category -from blog.category.models import Category as CategoryORM -from blog.extensions import db -import sqlalchemy as sa class CategoryServiceError(Exception): """Base exception for CategoryService errors.""" + pass @@ -55,31 +53,7 @@ def delete_category(self, category_id: int) -> bool: """Delete a category by its ID.""" try: return self.category_repository.delete(category_id) - except Exception as e: + except Exception: # Log the error and return False to indicate failure # In a real application, you might want to log this return False - - def get_category_by_id_orm(self, category_id: int) -> Optional[CategoryORM]: - """Get a category by its ID as ORM model (for compatibility with views).""" - domain_category = self.get_category_by_id(category_id) - if domain_category: - return self._to_orm_model(domain_category) - return None - - def _to_orm_model(self, category: Category) -> CategoryORM: - """Convert domain model to ORM model by loading from database.""" - if category.id: - # Load the full ORM model from database to get relationships - stmt = sa.select(CategoryORM).where(CategoryORM.id == category.id) - category_orm = db.session.scalar(stmt) - if category_orm: - return category_orm - - # If we can't load from database, create a new instance - category_orm = CategoryORM() - category_orm.id = category.id or 0 - category_orm.title = category.title - category_orm.alias = category.alias - category_orm.template = category.template - return category_orm \ No newline at end of file diff --git a/blog/services/factory.py b/blog/services/factory.py index e84dfa7..87f8d9c 100644 --- a/blog/services/factory.py +++ b/blog/services/factory.py @@ -1,5 +1,6 @@ """Service factory for creating service instances with proper dependency injection.""" +from blog.extensions import db from blog.repos.post import PostRepository from blog.repos.category import CategoryRepository from blog.repos.icon import IconRepository @@ -18,29 +19,29 @@ class ServiceFactory: @staticmethod def create_post_service(): """Create a PostService instance with its dependencies.""" - post_repository = PostRepository() + post_repository = PostRepository(db.session) return PostService(post_repository) @staticmethod def create_category_service(): """Create a CategoryService instance with its dependencies.""" - category_repository = CategoryRepository() + category_repository = CategoryRepository(db.session) return CategoryService(category_repository) @staticmethod def create_icon_service(): """Create an IconService instance with its dependencies.""" - icon_repository = IconRepository() + icon_repository = IconRepository(db.session) return IconService(icon_repository) @staticmethod def create_tag_service(): """Create a TagService instance with its dependencies.""" - tag_repository = TagRepository() + tag_repository = TagRepository(db.session) return TagService(tag_repository) @staticmethod def create_user_service(): """Create a UserService instance with its dependencies.""" - user_repository = UserRepository() - return UserService(user_repository) \ No newline at end of file + user_repository = UserRepository(db.session) + return UserService(user_repository) diff --git a/blog/services/icon.py b/blog/services/icon.py index 4ec0669..a4f0e87 100644 --- a/blog/services/icon.py +++ b/blog/services/icon.py @@ -3,13 +3,11 @@ from typing import List, Optional from blog.repos.icon import IconRepository from blog.domain.icon import Icon -from blog.post.models import Icon as IconORM -from blog.extensions import db -import sqlalchemy as sa class IconServiceError(Exception): """Base exception for IconService errors.""" + pass @@ -19,6 +17,14 @@ class IconService: def __init__(self, icon_repository: IconRepository): self.icon_repository = icon_repository + def get_icon_orm_by_id(self, icon_id: int): + """Get an icon ORM model by its ID. Used for specific use cases requiring ORM models.""" + return self.icon_repository.get_icon_orm_by_id(icon_id) + + def get_all_icons_orm(self) -> List: + """Get all icons as ORM models. Used for specific use cases requiring ORM models.""" + return self.icon_repository.get_all_icons_orm() + def get_icon_by_id(self, icon_id: int) -> Optional[Icon]: """Get an icon by its ID.""" return self.icon_repository.get_by_id(icon_id) @@ -51,29 +57,7 @@ def delete_icon(self, icon_id: int) -> bool: """Delete an icon by its ID.""" try: return self.icon_repository.delete(icon_id) - except Exception as e: + except Exception: # Log the error and return False to indicate failure # In a real application, you might want to log this return False - - def get_all_icons_orm(self) -> List[IconORM]: - """Get all icons as ORM models (for compatibility with views).""" - domain_icons = self.get_all_icons() - return [self._to_orm_model(icon) for icon in domain_icons] - - def _to_orm_model(self, icon: Icon) -> IconORM: - """Convert domain model to ORM model by loading from database.""" - if icon.id: - # Load the full ORM model from database - stmt = sa.select(IconORM).where(IconORM.id == icon.id) - icon_orm = db.session.scalar(stmt) - if icon_orm: - return icon_orm - - # If we can't load from database, create a new instance - icon_orm = IconORM() - icon_orm.id = icon.id or 0 - icon_orm.title = icon.title - icon_orm.url = icon.url - icon_orm.content = icon.content - return icon_orm \ No newline at end of file diff --git a/blog/services/post.py b/blog/services/post.py index c044494..dd9ebb1 100644 --- a/blog/services/post.py +++ b/blog/services/post.py @@ -3,13 +3,11 @@ from typing import List, Optional from blog.repos.post import PostRepository from blog.domain.post import Post -from blog.post.models import Post as PostORM -from blog.extensions import db -import sqlalchemy as sa class PostServiceError(Exception): """Base exception for PostService errors.""" + pass @@ -39,11 +37,6 @@ def get_page_posts(self, page_category_ids: List[int]) -> List[Post]: """Get posts that are pages (in specific categories).""" return self.post_repository.get_page_posts(page_category_ids) - def get_page_posts_orm(self, page_category_ids: List[int]) -> List[PostORM]: - """Get posts that are pages (in specific categories) as ORM models (for compatibility with views).""" - domain_posts = self.get_page_posts(page_category_ids) - return [self._to_orm_model(post) for post in domain_posts] - def create_post(self, post: Post) -> Post: """Create a new post.""" try: @@ -64,40 +57,7 @@ def delete_post(self, post_id: int) -> bool: """Delete a post by its ID.""" try: return self.post_repository.delete(post_id) - except Exception as e: + except Exception: # Log the error and return False to indicate failure # In a real application, you might want to log this return False - - def get_published_posts_orm(self) -> List[PostORM]: - """Get all published posts as ORM models (for compatibility with views).""" - domain_posts = self.get_published_posts() - return [self._to_orm_model(post) for post in domain_posts] - - def get_post_by_alias_orm(self, alias: str) -> Optional[PostORM]: - """Get a post by its alias as ORM model (for compatibility with views).""" - domain_post = self.get_post_by_alias(alias) - if domain_post: - return self._to_orm_model(domain_post) - return None - - def _to_orm_model(self, post: Post) -> PostORM: - """Convert domain model to ORM model by loading from database.""" - if post.id: - # Load the full ORM model from database to get relationships - stmt = sa.select(PostORM).where(PostORM.id == post.id) - post_orm = db.session.scalar(stmt) - if post_orm: - return post_orm - - # If we can't load from database, create a new instance - post_orm = PostORM() - post_orm.id = post.id or 0 - post_orm.pagetitle = post.pagetitle - post_orm.alias = post.alias - post_orm.content = post.content - post_orm.createdon = post.createdon - post_orm.publishedon = post.publishedon - post_orm.category_id = post.category_id - post_orm.user_id = post.user_id - return post_orm diff --git a/blog/services/tag.py b/blog/services/tag.py index e8cd07d..b361184 100644 --- a/blog/services/tag.py +++ b/blog/services/tag.py @@ -3,13 +3,11 @@ from typing import List, Optional from blog.repos.tag import TagRepository from blog.domain.tag import Tag -from blog.tags.models import Tag as TagORM -from blog.extensions import db -import sqlalchemy as sa class TagServiceError(Exception): """Base exception for TagService errors.""" + pass @@ -55,35 +53,7 @@ def delete_tag(self, tag_id: int) -> bool: """Delete a tag by its ID.""" try: return self.tag_repository.delete(tag_id) - except Exception as e: + except Exception: # Log the error and return False to indicate failure # In a real application, you might want to log this return False - - def get_tag_by_alias_orm(self, alias: str) -> Optional[TagORM]: - """Get a tag by its alias as ORM model (for compatibility with views).""" - domain_tag = self.get_tag_by_alias(alias) - if domain_tag: - return self._to_orm_model(domain_tag) - return None - - def get_all_tags_orm(self) -> List[TagORM]: - """Get all tags as ORM models (for compatibility with views).""" - domain_tags = self.get_all_tags() - return [self._to_orm_model(tag) for tag in domain_tags] - - def _to_orm_model(self, tag: Tag) -> TagORM: - """Convert domain model to ORM model by loading from database.""" - if tag.id: - # Load the full ORM model from database to get relationships - stmt = sa.select(TagORM).where(TagORM.id == tag.id) - tag_orm = db.session.scalar(stmt) - if tag_orm: - return tag_orm - - # If we can't load from database, create a new instance - tag_orm = TagORM() - tag_orm.id = tag.id or 0 - tag_orm.title = tag.title - tag_orm.alias = tag.alias - return tag_orm \ No newline at end of file diff --git a/blog/services/user.py b/blog/services/user.py index 7316f01..d69d6ee 100644 --- a/blog/services/user.py +++ b/blog/services/user.py @@ -3,13 +3,11 @@ from typing import List, Optional from blog.repos.user import UserRepository from blog.domain.user import User -from blog.user.models import User as UserORM -from blog.extensions import db -import sqlalchemy as sa class UserServiceError(Exception): """Base exception for UserService errors.""" + pass @@ -23,6 +21,14 @@ def get_user_by_id(self, user_id: int) -> Optional[User]: """Get a user by its ID.""" return self.user_repository.get_by_id(user_id) + def get_user_orm_by_id(self, user_id: int): + """Get a user ORM model by its ID. Used for Flask-Login compatibility.""" + return self.user_repository.get_user_orm_by_id(user_id) + + def get_user_orm_by_name(self, name: str): + """Get a user ORM model by their name. Used for Flask-Login compatibility.""" + return self.user_repository.get_user_orm_by_name(name) + def get_user_by_name(self, name: str) -> Optional[User]: """Get a user by their name.""" return self.user_repository.get_by_name(name) @@ -55,7 +61,7 @@ def delete_user(self, user_id: int) -> bool: """Delete a user by its ID.""" try: return self.user_repository.delete(user_id) - except Exception as e: + except Exception: # Log the error and return False to indicate failure # In a real application, you might want to log this return False @@ -63,35 +69,3 @@ def delete_user(self, user_id: int) -> bool: def authenticate_user(self, name: str, password: str) -> Optional[User]: """Authenticate a user by name and password.""" return self.user_repository.authenticate(name, password) - - def get_user_by_id_orm(self, user_id: int) -> Optional[UserORM]: - """Get a user by its ID as ORM model (for compatibility with views).""" - domain_user = self.get_user_by_id(user_id) - if domain_user: - return self._to_orm_model(domain_user) - return None - - def get_user_by_name_orm(self, name: str) -> Optional[UserORM]: - """Get a user by their name as ORM model (for compatibility with views).""" - domain_user = self.get_user_by_name(name) - if domain_user: - return self._to_orm_model(domain_user) - return None - - def _to_orm_model(self, user: User) -> UserORM: - """Convert domain model to ORM model by loading from database.""" - if user.id: - # Load the full ORM model from database to get relationships - stmt = sa.select(UserORM).where(UserORM.id == user.id) - user_orm = db.session.scalar(stmt) - if user_orm: - return user_orm - - # If we can't load from database, create a new instance - user_orm = UserORM() - user_orm.id = user.id or 0 - user_orm.name = user.name - user_orm.password = user.password - user_orm.authenticated = user.authenticated - user_orm.createdon = user.createdon - return user_orm \ No newline at end of file diff --git a/blog/tags/models.py b/blog/tags/models.py index e8f586c..93fef64 100644 --- a/blog/tags/models.py +++ b/blog/tags/models.py @@ -1,13 +1,13 @@ # blog/tags/models.py from typing import TYPE_CHECKING -from sqlalchemy.orm import relationship +from sqlalchemy.orm import Mapped, relationship from blog.extensions import db from blog.infrastructure.database import get_tags_table, get_posts_tags_table if TYPE_CHECKING: - pass + from blog.post.models import Post class Tag(db.Model): @@ -15,7 +15,12 @@ class Tag(db.Model): __table__ = get_tags_table(db.metadata) - posts = relationship( + # Type annotations for table columns + id: Mapped[int] + title: Mapped[str | None] + alias: Mapped[str | None] + + posts: Mapped[list["Post"]] = relationship( "Post", secondary=get_posts_tags_table(db.metadata), back_populates="tags" ) diff --git a/blog/tags/views.py b/blog/tags/views.py index daa1cf3..34cfe03 100644 --- a/blog/tags/views.py +++ b/blog/tags/views.py @@ -1,9 +1,6 @@ -import sqlalchemy as sa from flask import Blueprint, render_template from blog.post.views import pages_gen -from blog.repos.tag import TagRepository -from blog.services.tag import TagService from blog.services.factory import ServiceFactory tagsb = Blueprint("tagsb", __name__, url_prefix="/tags") @@ -12,19 +9,25 @@ @tagsb.route("/") @pages_gen def index(**kwargs): - # Use service layer instead of direct database access + # Use service layer for domain models tag_service = ServiceFactory.create_tag_service() - tags = tag_service.get_all_tags_orm() + tags = tag_service.get_all_tags() return render_template("tags.html", tags=tags, **kwargs) @tagsb.route("/") @pages_gen def view(alias=None, **kwargs): - # Use service layer instead of direct database access + # Use service layer for domain models + if alias is None: + from flask import abort + + abort(404) + tag_service = ServiceFactory.create_tag_service() - tag = tag_service.get_tag_by_alias_orm(alias) + tag = tag_service.get_tag_by_alias(alias) if not tag: from flask import abort + abort(404) return render_template("posts.html", posts=tag.posts, tag=tag, **kwargs) diff --git a/blog/templates/post.html b/blog/templates/post.html index 55591df..f0bc673 100644 --- a/blog/templates/post.html +++ b/blog/templates/post.html @@ -21,9 +21,11 @@

{{post.pagetitle}}

{% if post.category_id != config['PAGE_CATEGORY'] %} {% endif %}
diff --git a/blog/user/models.py b/blog/user/models.py index 0b197c7..426d8b2 100644 --- a/blog/user/models.py +++ b/blog/user/models.py @@ -1,16 +1,17 @@ """SqlAlchemy models.""" +from datetime import datetime from typing import TYPE_CHECKING from flask_login import UserMixin -from sqlalchemy.orm import relationship +from sqlalchemy.orm import Mapped, relationship from werkzeug.security import check_password_hash, generate_password_hash from blog.extensions import db from blog.infrastructure.database import get_users_table if TYPE_CHECKING: - pass + from blog.post.models import Post class User(UserMixin, db.Model): @@ -18,7 +19,14 @@ class User(UserMixin, db.Model): __table__ = get_users_table(db.metadata) - posts = relationship("Post", back_populates="user") + # Type annotations for table columns + id: Mapped[int] + name: Mapped[str] + password: Mapped[str | None] + authenticated: Mapped[int | None] + createdon: Mapped[datetime | None] + + posts: Mapped[list["Post"]] = relationship("Post", back_populates="user") def set_password(self, password: str) -> None: """Set password hash.""" diff --git a/blog/user/views.py b/blog/user/views.py index a984864..3a77e6a 100644 --- a/blog/user/views.py +++ b/blog/user/views.py @@ -1,11 +1,9 @@ import flask_login -import sqlalchemy as sa from flask import Blueprint, flash, redirect, render_template, url_for from flask_login import current_user, login_user -from blog.extensions import db, login_manager +from blog.extensions import login_manager from blog.user.forms import LoginForm -from blog.user.models import User from blog.services.factory import ServiceFactory @@ -15,9 +13,15 @@ @login_manager.user_loader def load_user(user_id): """Load user by ID for Flask-Login.""" - # Use service layer instead of direct database access + # Use service layer to get user (but Flask-Login needs ORM model) user_service = ServiceFactory.create_user_service() - return user_service.get_user_by_id_orm(int(user_id)) + user = user_service.get_user_by_id(int(user_id)) + if user: + # For Flask-Login compatibility, we need to return an ORM model + # This is one of the few places where we directly access the service layer + # to get an ORM model because Flask-Login requires an ORM model + return user_service.get_user_orm_by_id(int(user_id)) + return None @user_blueprint.route("/login", methods=["GET", "POST"]) @@ -26,15 +30,22 @@ def login(): return redirect("/") form = LoginForm() if form.validate_on_submit(): - # Use service layer instead of direct database access - user_service = ServiceFactory.create_user_service() - user = user_service.authenticate_user(form.name.data, form.password.data) - - if user: - # Convert domain model to ORM model for Flask-Login - user_orm = user_service._to_orm_model(user) - login_user(user_orm) - return redirect(url_for("admin.index")) + # Check that form data is not None before using it + name = form.name.data + password = form.password.data + if name is not None and password is not None: + # Use service layer to authenticate user (returns domain model) + user_service = ServiceFactory.create_user_service() + user = user_service.authenticate_user(name, password) + + if user: + # Get the ORM model directly from service layer for Flask-Login + # This is one of the few places where we directly access the service layer + # to get an ORM model because Flask-Login requires an ORM model + user_orm = user_service.get_user_orm_by_name(name) + if user_orm: + login_user(user_orm) + return redirect(url_for("admin.index")) flash("invalid user o password") return redirect(url_for("userb.login")) return render_template("login.html", form=form) diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index cd7168e..0000000 --- a/requirements.txt +++ /dev/null @@ -1,5 +0,0 @@ -gunicorn -psycopg2-binary -gevent - -python-dotenv diff --git a/tests/test_repositories.py b/tests/test_repositories.py new file mode 100644 index 0000000..b29a427 --- /dev/null +++ b/tests/test_repositories.py @@ -0,0 +1,552 @@ +"""Unit tests for the repository layer.""" + +import pytest +import os +import datetime + +from blog import create_app +from blog.extensions import db +from blog.domain.post import Post as PostDomain +from blog.domain.category import Category as CategoryDomain +from blog.domain.tag import Tag as TagDomain +from blog.domain.user import User as UserDomain +from blog.domain.icon import Icon as IconDomain +from blog.repos.post import PostRepository +from blog.repos.category import CategoryRepository +from blog.repos.tag import TagRepository +from blog.repos.user import UserRepository +from blog.repos.icon import IconRepository +from blog.post.models import Post as PostORM +from blog.category.models import Category as CategoryORM +from blog.tags.models import Tag as TagORM +from blog.user.models import User as UserORM +from blog.post.models import Icon as IconORM + + +@pytest.fixture() +def app(): + os.environ["FLASK_ENV"] = "testing" + app = create_app() + with app.app_context(): + db.create_all() + yield app + db.session.remove() + db.drop_all() + + +@pytest.fixture() +def post_repository(app): + """Create a PostRepository instance for testing.""" + with app.app_context(): + return PostRepository(db.session) + + +@pytest.fixture() +def category_repository(app): + """Create a CategoryRepository instance for testing.""" + with app.app_context(): + return CategoryRepository(db.session) + + +@pytest.fixture() +def tag_repository(app): + """Create a TagRepository instance for testing.""" + with app.app_context(): + return TagRepository(db.session) + + +@pytest.fixture() +def user_repository(app): + """Create a UserRepository instance for testing.""" + with app.app_context(): + return UserRepository(db.session) + + +@pytest.fixture() +def icon_repository(app): + """Create an IconRepository instance for testing.""" + with app.app_context(): + return IconRepository(db.session) + + +class TestPostRepository: + """Test cases for PostRepository.""" + + def test_create_post(self, app, post_repository): + """Test creating a new post.""" + with app.app_context(): + # Create a post domain model + post_domain = PostDomain( + pagetitle="Test Post", + alias="test-post", + content="This is a test post", + createdon=datetime.datetime.now(datetime.timezone.utc), + publishedon=datetime.datetime.now(datetime.timezone.utc), + ) + + # Create the post using the repository + created_post = post_repository.create(post_domain) + + # Verify the post was created + assert created_post.id is not None + assert created_post.pagetitle == "Test Post" + assert created_post.alias == "test-post" + + # Verify the ORM model was created + stmt = db.select(PostORM).where(PostORM.id == created_post.id) + post_orm = db.session.scalar(stmt) + assert post_orm is not None + assert post_orm.pagetitle == "Test Post" + assert post_orm.alias == "test-post" + + def test_get_by_id(self, app, post_repository): + """Test getting a post by ID.""" + with app.app_context(): + # Create a post first + post_domain = PostDomain( + pagetitle="Test Post", + alias="test-post", + content="This is a test post", + createdon=datetime.datetime.now(datetime.timezone.utc), + publishedon=datetime.datetime.now(datetime.timezone.utc), + ) + created_post = post_repository.create(post_domain) + + # Get the post by ID + retrieved_post = post_repository.get_by_id(created_post.id) + + # Verify the post was retrieved + assert retrieved_post is not None + assert retrieved_post.id == created_post.id + assert retrieved_post.pagetitle == "Test Post" + + # Verify it's a domain model, not an ORM model + assert isinstance(retrieved_post, PostDomain) + assert not hasattr(retrieved_post, "_sa_instance_state") + + def test_to_domain_model_mapping(self, app, post_repository): + """Test that _to_domain_model correctly maps ORM to domain model.""" + with app.app_context(): + # Create an ORM model directly + post_orm = PostORM() + post_orm.pagetitle = "Test Post" + post_orm.alias = "test-post" + post_orm.content = "This is a test post" + post_orm.createdon = datetime.datetime.now(datetime.timezone.utc) + post_orm.publishedon = datetime.datetime.now(datetime.timezone.utc) + db.session.add(post_orm) + db.session.flush() + + # Convert to domain model + domain_model = post_repository._to_domain_model(post_orm) + + # Verify the mapping is correct + assert isinstance(domain_model, PostDomain) + assert domain_model.id == post_orm.id + assert domain_model.pagetitle == post_orm.pagetitle + assert domain_model.alias == post_orm.alias + assert domain_model.content == post_orm.content + assert domain_model.createdon == post_orm.createdon + assert domain_model.publishedon == post_orm.publishedon + assert domain_model.category_id == post_orm.category_id + assert domain_model.user_id == post_orm.user_id + + def test_domain_to_orm_mapping(self, app, post_repository): + """Test that domain model properties correctly map to ORM model.""" + with app.app_context(): + # Create a domain model + post_domain = PostDomain( + id=1, + pagetitle="Test Post", + alias="test-post", + content="This is a test post", + createdon=datetime.datetime.now(datetime.timezone.utc), + publishedon=datetime.datetime.now(datetime.timezone.utc), + category_id=1, + user_id=1, + ) + + # Create an ORM model and map properties + post_orm = PostORM() + post_orm.pagetitle = post_domain.pagetitle + post_orm.alias = post_domain.alias + post_orm.content = post_domain.content + post_orm.createdon = post_domain.createdon + post_orm.publishedon = post_domain.publishedon + post_orm.category_id = post_domain.category_id + post_orm.user_id = post_domain.user_id + + # Verify the mapping is correct + assert post_orm.pagetitle == post_domain.pagetitle + assert post_orm.alias == post_domain.alias + assert post_orm.content == post_domain.content + assert post_orm.createdon == post_domain.createdon + assert post_orm.publishedon == post_domain.publishedon + assert post_orm.category_id == post_domain.category_id + assert post_orm.user_id == post_domain.user_id + + +class TestCategoryRepository: + """Test cases for CategoryRepository.""" + + def test_create_category(self, app, category_repository): + """Test creating a new category.""" + with app.app_context(): + # Create a category domain model + category_domain = CategoryDomain( + title="Test Category", alias="test-category" + ) + + # Create the category using the repository + created_category = category_repository.create(category_domain) + + # Verify the category was created + assert created_category.id is not None + assert created_category.title == "Test Category" + assert created_category.alias == "test-category" + + # Verify the ORM model was created + stmt = db.select(CategoryORM).where(CategoryORM.id == created_category.id) + category_orm = db.session.scalar(stmt) + assert category_orm is not None + assert category_orm.title == "Test Category" + assert category_orm.alias == "test-category" + + def test_get_by_id(self, app, category_repository): + """Test getting a category by ID.""" + with app.app_context(): + # Create a category first + category_domain = CategoryDomain( + title="Test Category", alias="test-category" + ) + created_category = category_repository.create(category_domain) + + # Get the category by ID + retrieved_category = category_repository.get_by_id(created_category.id) + + # Verify the category was retrieved correctly + assert retrieved_category is not None + assert retrieved_category.id == created_category.id + assert retrieved_category.title == "Test Category" + + # Verify it's a domain model, not an ORM model + assert isinstance(retrieved_category, CategoryDomain) + assert not hasattr(retrieved_category, "_sa_instance_state") + + def test_to_domain_model_mapping(self, app, category_repository): + """Test that _to_domain_model correctly maps ORM to domain model.""" + with app.app_context(): + # Create an ORM model directly + category_orm = CategoryORM() + category_orm.title = "Test Category" + category_orm.alias = "test-category" + category_orm.template = "test-template" + db.session.add(category_orm) + db.session.flush() + + # Convert to domain model + domain_model = category_repository._to_domain_model(category_orm) + + # Verify the mapping is correct + assert isinstance(domain_model, CategoryDomain) + assert domain_model.id == category_orm.id + assert domain_model.title == category_orm.title + assert domain_model.alias == category_orm.alias + assert domain_model.template == category_orm.template + + def test_domain_to_orm_mapping(self, app, category_repository): + """Test that domain model properties correctly map to ORM model.""" + with app.app_context(): + # Create a domain model + category_domain = CategoryDomain( + id=1, + title="Test Category", + alias="test-category", + template="test-template", + ) + + # Create an ORM model and map properties + category_orm = CategoryORM() + category_orm.title = category_domain.title + category_orm.alias = category_domain.alias + category_orm.template = category_domain.template + + # Verify the mapping is correct + assert category_orm.title == category_domain.title + assert category_orm.alias == category_domain.alias + assert category_orm.template == category_domain.template + + +class TestTagRepository: + """Test cases for TagRepository.""" + + def test_create_tag(self, app, tag_repository): + """Test creating a new tag.""" + with app.app_context(): + # Create a tag domain model + tag_domain = TagDomain(title="Test Tag", alias="test-tag") + + # Create the tag using the repository + created_tag = tag_repository.create(tag_domain) + + # Verify the tag was created + assert created_tag.id is not None + assert created_tag.title == "Test Tag" + assert created_tag.alias == "test-tag" + + # Verify the ORM model was created + stmt = db.select(TagORM).where(TagORM.id == created_tag.id) + tag_orm = db.session.scalar(stmt) + assert tag_orm is not None + assert tag_orm.title == "Test Tag" + assert tag_orm.alias == "test-tag" + + def test_get_by_id(self, app, tag_repository): + """Test getting a tag by ID.""" + with app.app_context(): + # Create a tag first + tag_domain = TagDomain(title="Test Tag", alias="test-tag") + created_tag = tag_repository.create(tag_domain) + + # Get the tag by ID + retrieved_tag = tag_repository.get_by_id(created_tag.id) + + # Verify the tag was retrieved correctly + assert retrieved_tag is not None + assert retrieved_tag.id == created_tag.id + assert retrieved_tag.title == "Test Tag" + + # Verify it's a domain model, not an ORM model + assert isinstance(retrieved_tag, TagDomain) + assert not hasattr(retrieved_tag, "_sa_instance_state") + + def test_to_domain_model_mapping(self, app, tag_repository): + """Test that _to_domain_model correctly maps ORM to domain model.""" + with app.app_context(): + # Create an ORM model directly + tag_orm = TagORM() + tag_orm.title = "Test Tag" + tag_orm.alias = "test-tag" + db.session.add(tag_orm) + db.session.flush() + + # Convert to domain model + domain_model = tag_repository._to_domain_model(tag_orm) + + # Verify the mapping is correct + assert isinstance(domain_model, TagDomain) + assert domain_model.id == tag_orm.id + assert domain_model.title == tag_orm.title + assert domain_model.alias == tag_orm.alias + + def test_domain_to_orm_mapping(self, app, tag_repository): + """Test that domain model properties correctly map to ORM model.""" + with app.app_context(): + # Create a domain model + tag_domain = TagDomain( + id=1, + title="Test Tag", + alias="test-tag", + ) + + # Create an ORM model and map properties + tag_orm = TagORM() + tag_orm.title = tag_domain.title + tag_orm.alias = tag_domain.alias + + # Verify the mapping is correct + assert tag_orm.title == tag_domain.title + assert tag_orm.alias == tag_domain.alias + + +class TestUserRepository: + """Test cases for UserRepository.""" + + def test_create_user(self, app, user_repository): + """Test creating a new user.""" + with app.app_context(): + # Create a user domain model + user_domain = UserDomain(name="testuser", password="testpassword") + + # Create the user using the repository + created_user = user_repository.create(user_domain) + + # Verify the user was created + assert created_user.id is not None + assert created_user.name == "testuser" + + # Verify the ORM model was created + stmt = db.select(UserORM).where(UserORM.id == created_user.id) + user_orm = db.session.scalar(stmt) + assert user_orm is not None + assert user_orm.name == "testuser" + + def test_get_by_id(self, app, user_repository): + """Test getting a user by ID.""" + with app.app_context(): + # Create a user first + user_domain = UserDomain(name="testuser", password="testpassword") + created_user = user_repository.create(user_domain) + + # Get the user by ID + retrieved_user = user_repository.get_by_id(created_user.id) + + # Verify the user was retrieved correctly + assert retrieved_user is not None + assert retrieved_user.id == created_user.id + assert retrieved_user.name == "testuser" + + # Verify it's a domain model, not an ORM model + assert isinstance(retrieved_user, UserDomain) + assert not hasattr(retrieved_user, "_sa_instance_state") + + def test_to_domain_model_mapping(self, app, user_repository): + """Test that _to_domain_model correctly maps ORM to domain model.""" + with app.app_context(): + # Create an ORM model directly + user_orm = UserORM() + user_orm.name = "testuser" + user_orm.password = "testpassword" + user_orm.authenticated = 1 + user_orm.createdon = datetime.datetime.now(datetime.timezone.utc) + db.session.add(user_orm) + db.session.flush() + + # Convert to domain model + domain_model = user_repository._to_domain_model(user_orm) + + # Verify the mapping is correct + assert isinstance(domain_model, UserDomain) + assert domain_model.id == user_orm.id + assert domain_model.name == user_orm.name + assert domain_model.password == user_orm.password + assert domain_model.authenticated == bool(user_orm.authenticated) + assert domain_model.createdon == user_orm.createdon + + def test_domain_to_orm_mapping(self, app, user_repository): + """Test that domain model properties correctly map to ORM model.""" + with app.app_context(): + # Create a domain model + user_domain = UserDomain( + id=1, + name="testuser", + password="testpassword", + authenticated=True, + createdon=datetime.datetime.now(datetime.timezone.utc), + ) + + # Create an ORM model and map properties + user_orm = UserORM() + user_orm.name = user_domain.name + user_orm.password = user_domain.password + user_orm.authenticated = ( + int(user_domain.authenticated) + if user_domain.authenticated is not None + else 0 + ) + user_orm.createdon = user_domain.createdon + + # Verify the mapping is correct + assert user_orm.name == user_domain.name + assert user_orm.password == user_domain.password + assert ( + user_orm.authenticated == int(user_domain.authenticated) + if user_domain.authenticated is not None + else 0 + ) + assert user_orm.createdon == user_domain.createdon + + +class TestIconRepository: + """Test cases for IconRepository.""" + + def test_create_icon(self, app, icon_repository): + """Test creating a new icon.""" + with app.app_context(): + # Create an icon domain model + icon_domain = IconDomain( + title="Test Icon", + url="http://example.com/icon.png", + content="Icon content", + ) + + # Create the icon using the repository + created_icon = icon_repository.create(icon_domain) + + # Verify the icon was created + assert created_icon.id is not None + assert created_icon.title == "Test Icon" + assert created_icon.url == "http://example.com/icon.png" + + # Verify the ORM model was created + stmt = db.select(IconORM).where(IconORM.id == created_icon.id) + icon_orm = db.session.scalar(stmt) + assert icon_orm is not None + assert icon_orm.title == "Test Icon" + assert icon_orm.url == "http://example.com/icon.png" + + def test_get_by_id(self, app, icon_repository): + """Test getting an icon by ID.""" + with app.app_context(): + # Create an icon first + icon_domain = IconDomain( + title="Test Icon", + url="http://example.com/icon.png", + content="Icon content", + ) + created_icon = icon_repository.create(icon_domain) + + # Get the icon by ID + retrieved_icon = icon_repository.get_by_id(created_icon.id) + + # Verify the icon was retrieved correctly + assert retrieved_icon is not None + assert retrieved_icon.id == created_icon.id + assert retrieved_icon.title == "Test Icon" + + # Verify it's a domain model, not an ORM model + assert isinstance(retrieved_icon, IconDomain) + assert not hasattr(retrieved_icon, "_sa_instance_state") + + def test_to_domain_model_mapping(self, app, icon_repository): + """Test that _to_domain_model correctly maps ORM to domain model.""" + with app.app_context(): + # Create an ORM model directly + icon_orm = IconORM() + icon_orm.title = "Test Icon" + icon_orm.url = "http://example.com/icon.png" + icon_orm.content = "Icon content" + db.session.add(icon_orm) + db.session.flush() + + # Convert to domain model + domain_model = icon_repository._to_domain_model(icon_orm) + + # Verify the mapping is correct + assert isinstance(domain_model, IconDomain) + assert domain_model.id == icon_orm.id + assert domain_model.title == icon_orm.title + assert domain_model.url == icon_orm.url + assert domain_model.content == icon_orm.content + + def test_domain_to_orm_mapping(self, app, icon_repository): + """Test that domain model properties correctly map to ORM model.""" + with app.app_context(): + # Create a domain model + icon_domain = IconDomain( + id=1, + title="Test Icon", + url="http://example.com/icon.png", + content="Icon content", + ) + + # Create an ORM model and map properties + icon_orm = IconORM() + icon_orm.title = icon_domain.title + icon_orm.url = icon_domain.url + icon_orm.content = icon_domain.content + + # Verify the mapping is correct + assert icon_orm.title == icon_domain.title + assert icon_orm.url == icon_domain.url + assert icon_orm.content == icon_domain.content diff --git a/tests/test_services.py b/tests/test_services.py new file mode 100644 index 0000000..395e335 --- /dev/null +++ b/tests/test_services.py @@ -0,0 +1,487 @@ +"""Unit tests for the service layer.""" + +import pytest +import os +import datetime + +from blog import create_app +from blog.extensions import db +from blog.domain.post import Post as PostDomain +from blog.domain.category import Category as CategoryDomain +from blog.domain.tag import Tag as TagDomain +from blog.domain.user import User as UserDomain +from blog.domain.icon import Icon as IconDomain +from blog.services.post import PostService, PostServiceError +from blog.services.category import CategoryService +from blog.services.tag import TagService +from blog.services.user import UserService +from blog.services.icon import IconService +from blog.repos.post import PostRepository +from blog.repos.category import CategoryRepository +from blog.repos.tag import TagRepository +from blog.repos.user import UserRepository +from blog.repos.icon import IconRepository + + +@pytest.fixture() +def app(): + os.environ["FLASK_ENV"] = "testing" + app = create_app() + with app.app_context(): + db.create_all() + yield app + db.session.remove() + db.drop_all() + + +@pytest.fixture() +def post_service(app): + """Create a PostService instance for testing.""" + with app.app_context(): + repository = PostRepository(db.session) + return PostService(repository) + + +@pytest.fixture() +def category_service(app): + """Create a CategoryService instance for testing.""" + with app.app_context(): + repository = CategoryRepository(db.session) + return CategoryService(repository) + + +@pytest.fixture() +def tag_service(app): + """Create a TagService instance for testing.""" + with app.app_context(): + repository = TagRepository(db.session) + return TagService(repository) + + +@pytest.fixture() +def user_service(app): + """Create a UserService instance for testing.""" + with app.app_context(): + repository = UserRepository(db.session) + return UserService(repository) + + +@pytest.fixture() +def icon_service(app): + """Create an IconService instance for testing.""" + with app.app_context(): + repository = IconRepository(db.session) + return IconService(repository) + + +class TestPostService: + """Test cases for PostService.""" + + def test_create_post(self, app, post_service): + """Test creating a new post.""" + # Create a post domain model + post_domain = PostDomain( + pagetitle="Test Post", + alias="test-post", + content="This is a test post", + createdon=datetime.datetime.now(datetime.timezone.utc), + publishedon=datetime.datetime.now(datetime.timezone.utc), + ) + + # Create the post using the service + created_post = post_service.create_post(post_domain) + + # Verify the post was created + assert created_post.id is not None + assert created_post.pagetitle == "Test Post" + assert created_post.alias == "test-post" + + def test_service_returns_domain_models_not_orm_models(self, app, post_service): + """Test that service layer methods return domain models, not ORM models.""" + # Create a post first + post_domain = PostDomain( + pagetitle="Test Post", + alias="test-post", + content="This is a test post", + createdon=datetime.datetime.now(datetime.timezone.utc), + publishedon=datetime.datetime.now(datetime.timezone.utc), + ) + created_post = post_service.create_post(post_domain) + + # Verify all service methods return domain models + # 1. get_post_by_id should return a domain model + retrieved_post = post_service.get_post_by_id(created_post.id) + assert isinstance(retrieved_post, PostDomain) + assert not hasattr( + retrieved_post, "_sa_instance_state" + ) # ORM models have this attribute + + # 2. get_post_by_alias should return a domain model + retrieved_post = post_service.get_post_by_alias("test-post") + assert isinstance(retrieved_post, PostDomain) + assert not hasattr(retrieved_post, "_sa_instance_state") + + # 3. get_all_posts should return domain models + posts = post_service.get_all_posts() + assert all(isinstance(post, PostDomain) for post in posts) + assert all(not hasattr(post, "_sa_instance_state") for post in posts) + + # 4. get_published_posts should return domain models + published_posts = post_service.get_published_posts() + assert all(isinstance(post, PostDomain) for post in published_posts) + assert all(not hasattr(post, "_sa_instance_state") for post in published_posts) + + def test_get_post_by_id(self, app, post_service): + """Test getting a post by ID.""" + # Create a post first + post_domain = PostDomain( + pagetitle="Test Post", + alias="test-post", + content="This is a test post", + createdon=datetime.datetime.now(datetime.timezone.utc), + publishedon=datetime.datetime.now(datetime.timezone.utc), + ) + + created_post = post_service.create_post(post_domain) + + # Get the post by ID + retrieved_post = post_service.get_post_by_id(created_post.id) + + # Verify the post was retrieved + assert retrieved_post is not None + assert retrieved_post.id == created_post.id + assert retrieved_post.pagetitle == "Test Post" + + def test_update_post(self, app, post_service): + """Test updating a post.""" + # Create a post first + post_domain = PostDomain( + pagetitle="Test Post", + alias="test-post", + content="This is a test post", + createdon=datetime.datetime.now(datetime.timezone.utc), + publishedon=datetime.datetime.now(datetime.timezone.utc), + ) + created_post = post_service.create_post(post_domain) + + # Update the post + created_post.pagetitle = "Updated Post" + updated_post = post_service.update_post(created_post) + + # Verify the post was updated + assert updated_post.pagetitle == "Updated Post" + + def test_update_nonexistent_post_raises_error(self, app, post_service): + """Test that updating a nonexistent post raises an error.""" + # Try to update a post that doesn't exist + post_domain = PostDomain( + id=99999, # Nonexistent ID + pagetitle="Test Post", + alias="test-post", + content="This is a test post", + ) + + # Verify that updating a nonexistent post raises an error + with pytest.raises(PostServiceError): + post_service.update_post(post_domain) + + def test_delete_post(self, app, post_service): + """Test deleting a post.""" + # Create a post first + post_domain = PostDomain( + pagetitle="Test Post", + alias="test-post", + content="This is a test post", + createdon=datetime.datetime.now(datetime.timezone.utc), + publishedon=datetime.datetime.now(datetime.timezone.utc), + ) + created_post = post_service.create_post(post_domain) + + # Delete the post + result = post_service.delete_post(created_post.id) + + # Verify the post was deleted + assert result is True + + # Verify the post no longer exists + retrieved_post = post_service.get_post_by_id(created_post.id) + assert retrieved_post is None + + def test_delete_nonexistent_post(self, app, post_service): + """Test deleting a nonexistent post.""" + # Try to delete a post that doesn't exist + result = post_service.delete_post(99999) # Nonexistent ID + + # Verify the result is False + assert result is False + + def test_get_post_by_alias(self, app, post_service): + """Test getting a post by alias.""" + # Create a post first + post_domain = PostDomain( + pagetitle="Test Post", + alias="test-post", + content="This is a test post", + createdon=datetime.datetime.now(datetime.timezone.utc), + publishedon=datetime.datetime.now(datetime.timezone.utc), + ) + created_post = post_service.create_post(post_domain) + + # Get the post by alias + retrieved_post = post_service.get_post_by_alias("test-post") + + # Verify the post was retrieved + assert retrieved_post is not None + assert retrieved_post.id == created_post.id + assert retrieved_post.alias == "test-post" + + def test_get_all_posts(self, app, post_service): + """Test getting all posts.""" + # Create a few posts + post1_domain = PostDomain( + pagetitle="Test Post 1", + alias="test-post-1", + content="This is test post 1", + createdon=datetime.datetime.now(datetime.timezone.utc), + publishedon=datetime.datetime.now(datetime.timezone.utc), + ) + post_service.create_post(post1_domain) + + post2_domain = PostDomain( + pagetitle="Test Post 2", + alias="test-post-2", + content="This is test post 2", + createdon=datetime.datetime.now(datetime.timezone.utc), + publishedon=datetime.datetime.now(datetime.timezone.utc), + ) + post_service.create_post(post2_domain) + + # Get all posts + posts = post_service.get_all_posts() + + # Verify we got the posts + assert len(posts) >= 2 + aliases = [post.alias for post in posts] + assert "test-post-1" in aliases + assert "test-post-2" in aliases + + def test_get_published_posts(self, app, post_service): + """Test getting published posts.""" + # Create a published post + published_post_domain = PostDomain( + pagetitle="Published Post", + alias="published-post", + content="This is a published post", + createdon=datetime.datetime.now(datetime.timezone.utc), + publishedon=datetime.datetime.now(datetime.timezone.utc), + ) + post_service.create_post(published_post_domain) + + # Create an unpublished post + unpublished_post_domain = PostDomain( + pagetitle="Unpublished Post", + alias="unpublished-post", + content="This is an unpublished post", + createdon=datetime.datetime.now(datetime.timezone.utc), + # No publishedon field, so it's unpublished + ) + post_service.create_post(unpublished_post_domain) + + # Get published posts + published_posts = post_service.get_published_posts() + + # Verify we only got the published post + assert len(published_posts) >= 1 + aliases = [post.alias for post in published_posts] + assert "published-post" in aliases + assert "unpublished-post" not in aliases + + +class TestCategoryService: + """Test cases for CategoryService.""" + + def test_create_category(self, app, category_service): + """Test creating a new category.""" + with app.app_context(): + # Create a category domain model + category_domain = CategoryDomain( + title="Test Category", alias="test-category" + ) + + # Create the category using the service + created_category = category_service.create_category(category_domain) + + # Verify the category was created + assert created_category.id is not None + assert created_category.title == "Test Category" + assert created_category.alias == "test-category" + + def test_get_category_by_id(self, app, category_service): + """Test getting a category by ID.""" + with app.app_context(): + # Create a category first + category_domain = CategoryDomain( + title="Test Category", alias="test-category" + ) + created_category = category_service.create_category(category_domain) + + # Get the category by ID + retrieved_category = category_service.get_category_by_id( + created_category.id + ) + + # Verify the category was retrieved correctly + assert retrieved_category is not None + assert retrieved_category.id == created_category.id + assert retrieved_category.title == "Test Category" + + def test_service_returns_domain_models_not_orm_models(self, app, category_service): + """Test that service layer methods return domain models, not ORM models.""" + with app.app_context(): + # Create a category first + category_domain = CategoryDomain( + title="Test Category", alias="test-category" + ) + created_category = category_service.create_category(category_domain) + + # Verify all service methods return domain models + # get_category_by_id should return a domain model + retrieved_category = category_service.get_category_by_id( + created_category.id + ) + assert isinstance(retrieved_category, CategoryDomain) + assert not hasattr( + retrieved_category, "_sa_instance_state" + ) # ORM models have this attribute + + +class TestTagService: + """Test cases for TagService.""" + + def test_create_tag(self, app, tag_service): + """Test creating a new tag.""" + with app.app_context(): + # Create a tag domain model + tag_domain = TagDomain(title="Test Tag", alias="test-tag") + + # Create the tag using the service + created_tag = tag_service.create_tag(tag_domain) + + # Verify the tag was created + assert created_tag.id is not None + assert created_tag.title == "Test Tag" + assert created_tag.alias == "test-tag" + + def test_service_returns_domain_models_not_orm_models(self, app, tag_service): + """Test that service layer methods return domain models, not ORM models.""" + with app.app_context(): + # Create a tag first + tag_domain = TagDomain(title="Test Tag", alias="test-tag") + created_tag = tag_service.create_tag(tag_domain) + + # Verify all service methods return domain models + # get_tag_by_id should return a domain model + # Tags don't have a get_by_id method in the current implementation + # But we can verify that the created tag is a domain model + assert isinstance(created_tag, TagDomain) + assert not hasattr( + created_tag, "_sa_instance_state" + ) # ORM models have this attribute + + +class TestUserService: + """Test cases for UserService.""" + + def test_create_user(self, app, user_service): + """Test creating a new user.""" + with app.app_context(): + # Create a user domain model + user_domain = UserDomain(name="testuser", password="testpassword") + + # Create the user using the service + created_user = user_service.create_user(user_domain) + + # Verify the user was created + assert created_user.id is not None + assert created_user.name == "testuser" + + def test_authenticate_user(self, app, user_service): + """Test authenticating a user.""" + with app.app_context(): + # Create a user first + user_domain = UserDomain(name="testuser", password="testpassword") + created_user = user_service.create_user(user_domain) + # Set the password properly (this would normally be done in the repo) + from blog.user.models import User as UserORM + + user_orm = db.session.get(UserORM, created_user.id) + if user_orm: + user_orm.set_password("testpassword") + db.session.commit() + + # Authenticate the user + authenticated_user = user_service.authenticate_user( + "testuser", "testpassword" + ) + + # Verify the user was authenticated + assert authenticated_user is not None + assert authenticated_user.name == "testuser" + + def test_service_returns_domain_models_not_orm_models(self, app, user_service): + """Test that service layer methods return domain models, not ORM models.""" + with app.app_context(): + # Create a user first + user_domain = UserDomain(name="testuser", password="testpassword") + created_user = user_service.create_user(user_domain) + + # Verify all service methods return domain models + # get_user_by_id should return a domain model + # Users don't have a get_by_id method in the current implementation + # But we can verify that the created user is a domain model + assert isinstance(created_user, UserDomain) + assert not hasattr( + created_user, "_sa_instance_state" + ) # ORM models have this attribute + + +class TestIconService: + """Test cases for IconService.""" + + def test_create_icon(self, app, icon_service): + """Test creating a new icon.""" + with app.app_context(): + # Create an icon domain model + icon_domain = IconDomain( + title="Test Icon", + url="http://example.com/icon.png", + content="Icon content", + ) + + # Create the icon using the service + created_icon = icon_service.create_icon(icon_domain) + + # Verify the icon was created + assert created_icon.id is not None + assert created_icon.title == "Test Icon" + assert created_icon.url == "http://example.com/icon.png" + + def test_service_returns_domain_models_not_orm_models(self, app, icon_service): + """Test that service layer methods return domain models, not ORM models.""" + with app.app_context(): + # Create an icon first + icon_domain = IconDomain( + title="Test Icon", + url="http://example.com/icon.png", + content="Icon content", + ) + created_icon = icon_service.create_icon(icon_domain) + + # Verify all service methods return domain models + # get_icon_by_id should return a domain model + # Icons don't have a get_by_id method in the current implementation + # But we can verify that the created icon is a domain model + assert isinstance(created_icon, IconDomain) + assert not hasattr( + created_icon, "_sa_instance_state" + ) # ORM models have this attribute diff --git a/tests/test_tags.py b/tests/test_tags.py index 5e89a1d..736ff2d 100644 --- a/tests/test_tags.py +++ b/tests/test_tags.py @@ -1,10 +1,14 @@ import pytest import os +import datetime from blog import create_app -from blog import db -from blog.post.models import Post -from blog.tags.models import Tag +from blog.extensions import db +from blog.domain.post import Post as PostDomain +from blog.domain.tag import Tag as TagDomain +from blog.services.factory import ServiceFactory +from blog.post.models import Post as PostORM +from blog.tags.models import Tag as TagORM @pytest.fixture() @@ -22,36 +26,49 @@ def test_client(): def test_get_posts_by_tag(test_client): with test_client.application.app_context(): - tag = Tag(title="test-tag", alias="test-tag") # type: ignore - db.session.add(tag) + # Use service layer to create domain models + tag_service = ServiceFactory.create_tag_service() + post_service = ServiceFactory.create_post_service() - post1 = Post( + # Create a tag using domain model + tag_domain = TagDomain(title="test-tag", alias="test-tag") + tag = tag_service.create_tag(tag_domain) + + # Create posts using domain models + post1_domain = PostDomain( pagetitle="Post 1", alias="post-1", content="Content 1", - publishedon=db.func.now(), - ) # type: ignore - post1.tags.append(tag) + publishedon=datetime.datetime.now(datetime.timezone.utc), + ) + post1 = post_service.create_post(post1_domain) - post2 = Post( + post2_domain = PostDomain( pagetitle="Post 2", alias="post-2", content="Content 2", - publishedon=db.func.now(), - ) # type: ignore - post2.tags.append(tag) + publishedon=datetime.datetime.now(datetime.timezone.utc), + ) + post2 = post_service.create_post(post2_domain) - post3 = Post( + post3_domain = PostDomain( pagetitle="Post 3", alias="post-3", content="Content 3", - publishedon=db.func.now(), - ) # type: ignore + publishedon=datetime.datetime.now(datetime.timezone.utc), + ) + post_service.create_post(post3_domain) + + # Manually create the tag-post relationships at the ORM level + # This is a workaround for the current limitation in the domain model approach + tag_orm = db.session.get(TagORM, tag.id) + post1_orm = db.session.get(PostORM, post1.id) + post2_orm = db.session.get(PostORM, post2.id) - db.session.add(post1) - db.session.add(post2) - db.session.add(post3) - db.session.commit() + if tag_orm and post1_orm and post2_orm: + post1_orm.tags.append(tag_orm) + post2_orm.tags.append(tag_orm) + db.session.commit() response = test_client.get("/tags/test-tag") assert response.status_code == 200 @@ -63,21 +80,34 @@ def test_get_posts_by_tag(test_client): def test_post_have_tag(test_client): with test_client.application.app_context(): - tag1 = Tag(title="test-tag1", alias="test-tag1") # type: ignore - db.session.add(tag1) - tag2 = Tag(title="test-tag2", alias="test-tag2") # type: ignore - db.session.add(tag2) + # Use service layer to create domain models + tag_service = ServiceFactory.create_tag_service() + post_service = ServiceFactory.create_post_service() + + # Create tags using domain models + tag1_domain = TagDomain(title="test-tag1", alias="test-tag1") + tag1 = tag_service.create_tag(tag1_domain) - post1 = Post( + tag2_domain = TagDomain(title="test-tag2", alias="test-tag2") + tag_service.create_tag(tag2_domain) + + # Create a post + post1_domain = PostDomain( pagetitle="Post 1", alias="post-1", content="Content 1", - publishedon=db.func.now(), - ) # type: ignore - post1.tags.append(tag1) - - db.session.add(post1) - db.session.commit() + publishedon=datetime.datetime.now(datetime.timezone.utc), + ) + post1 = post_service.create_post(post1_domain) + + # Manually create the tag-post relationship at the ORM level + # This is a workaround for the current limitation in the domain model approach + tag1_orm = db.session.get(TagORM, tag1.id) + post1_orm = db.session.get(PostORM, post1.id) + + if tag1_orm and post1_orm: + post1_orm.tags.append(tag1_orm) + db.session.commit() response = test_client.get("/post-1") assert response.status_code == 200 diff --git a/tests/test_views.py b/tests/test_views.py new file mode 100644 index 0000000..6ee1e1d --- /dev/null +++ b/tests/test_views.py @@ -0,0 +1,150 @@ +"""Integration tests for view functions using domain models.""" + +import pytest +import os +import datetime + +from blog import create_app +from blog.extensions import db +from blog.domain.post import Post as PostDomain +from blog.domain.category import Category as CategoryDomain +from blog.domain.tag import Tag as TagDomain +from blog.services.factory import ServiceFactory + + +@pytest.fixture() +def test_client(): + """Create a test client for the Flask app.""" + os.environ["FLASK_ENV"] = "testing" + app = create_app() + with app.test_client() as client: + with app.app_context(): + db.create_all() + # Create a page category for testing + category_service = ServiceFactory.create_category_service() + page_category = CategoryDomain(id=1, title="page", alias="page") + category_service.create_category(page_category) + yield client + with app.app_context(): + db.session.remove() + db.drop_all() + + +def create_test_post( + title="Test Post", alias="test-post", content="Test content", page=False +): + """Helper function to create a test post.""" + post_service = ServiceFactory.create_post_service() + post = PostDomain( + pagetitle=title, + alias=alias, + content=content, + createdon=datetime.datetime.now(datetime.timezone.utc), + publishedon=datetime.datetime.now(datetime.timezone.utc) if not page else None, + category_id=1 if page else None, + ) + return post_service.create_post(post) + + +def create_test_tag(title="Test Tag", alias="test-tag"): + """Helper function to create a test tag.""" + tag_service = ServiceFactory.create_tag_service() + tag = TagDomain( + title=title, + alias=alias, + ) + return tag_service.create_tag(tag) + + +def test_post_view(test_client): + """Test that post view works with domain models.""" + # Create a test post + post = create_test_post() + + # Request the post page + response = test_client.get(f"/{post.alias}") + assert response.status_code == 200 + assert post.pagetitle.encode() in response.data + assert post.content.encode() in response.data + + +def test_page_view(test_client): + """Test that page view works with domain models.""" + # Create a test page + page = create_test_post( + title="Test Page", alias="test-page", content="Page content", page=True + ) + + # Request the page + response = test_client.get(f"/{page.alias}") + assert response.status_code == 200 + assert page.pagetitle.encode() in response.data + assert page.content.encode() in response.data + + +def test_index_view(test_client): + """Test that index view works with domain models.""" + # Create a test post + post = create_test_post() + + # Request the index page + response = test_client.get("/") + assert response.status_code == 200 + assert post.pagetitle.encode() in response.data + + +def test_tag_index_view(test_client): + """Test that tag index view works with domain models.""" + # Create a test tag + tag = create_test_tag() + + # Request the tags index page + response = test_client.get("/tags/") + assert response.status_code == 200 + assert tag.title.encode() in response.data + + +def test_tag_view(test_client): + """Test that tag view works with domain models.""" + # Create a test tag + tag = create_test_tag() + + # Request the tag page + response = test_client.get(f"/tags/{tag.alias}") + assert response.status_code == 200 + assert tag.title.encode() in response.data + + +def test_404_view(test_client): + """Test that 404 view works correctly.""" + # Request a non-existent page + response = test_client.get("/non-existent-page") + assert response.status_code == 404 + + +def test_rss_view(test_client): + """Test that RSS view works with domain models.""" + # Create a test post + post = create_test_post() + + # Request the RSS feed + response = test_client.get("/rss.xml") + assert response.status_code == 200 + assert response.content_type == "application/rss+xml" + assert post.pagetitle.encode() in response.data + + +def test_robots_view(test_client): + """Test that robots.txt view works.""" + # Request the robots.txt file + response = test_client.get("/robots.txt") + assert response.status_code == 200 + assert b"User-agent: *" in response.data + + +def test_markdown_view(test_client): + """Test that markdown conversion works.""" + # Post markdown data + response = test_client.post("/md/", data={"data": "# Test Header"}) + assert response.status_code == 200 + assert b"

Test Header

" in response.data From cda2ce7a62e17c31020da9a70f1daa4aceec96fd Mon Sep 17 00:00:00 2001 From: loki Date: Mon, 8 Sep 2025 13:35:24 +0300 Subject: [PATCH 07/34] clear --- Makefile | 6 ++---- dev.txt | 4 ---- 2 files changed, 2 insertions(+), 8 deletions(-) delete mode 100644 dev.txt diff --git a/Makefile b/Makefile index 7beecc4..8f31659 100644 --- a/Makefile +++ b/Makefile @@ -10,6 +10,7 @@ ruff-lint: uvx ruff check . ruff-lint-format-check: + uvx ruff format . uvx ruff format --check . lint-types: @@ -21,10 +22,7 @@ test: test-coverage: - ( \ - . venv/bin/activate;\ - FLASK_ENV=testing FLASK_APP=blog pytest --cov=blog --cov-report xml\ - ) + FLASK_ENV=testing FLASK_APP=blog uv run pytest --cov=blog --cov-report xml check: lint test diff --git a/dev.txt b/dev.txt deleted file mode 100644 index 3e63e2b..0000000 --- a/dev.txt +++ /dev/null @@ -1,4 +0,0 @@ -pytest -pytest-cov -flake8 -pre-commit From 85477ec69bf70549ae8de915dcd9fe13a49f95bf Mon Sep 17 00:00:00 2001 From: loki Date: Mon, 8 Sep 2025 13:35:44 +0300 Subject: [PATCH 08/34] remove commented --- Dockerfile | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/Dockerfile b/Dockerfile index 1b24f38..dcde297 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM python:3.10-alpine AS test-image +FROM python:3.12-alpine AS test-image ENV PYTHONDONTWRITEBYTECODE=1 ENV PYTHONUNBUFFERED=1 @@ -7,12 +7,12 @@ ENV SQLALCHEMY_DATABASE_URI="sqlite:////app/tmp/dev.db" WORKDIR /app -RUN apk update && apk add --no-cache nodejs npm make uv +RUN apk update && apk add --no-cache make uv COPY . . RUN uv sync && make check && make test -FROM python:3.10-alpine +FROM python:3.12-alpine ENV PYTHONDONTWRITEBYTECODE=1 ENV PYTHONUNBUFFERED=1 @@ -31,9 +31,6 @@ RUN adduser \ --shell "/sbin/nologin" \ --uid "${UID}" \ appuser -# --no-create-home \ -# appuser - # Copy the source code into the container. COPY . . @@ -43,9 +40,6 @@ EXPOSE 5000 ENV PATH=/app/venv/bin:$PATH -#USER appuser - # Run the application. ENTRYPOINT [ "./entrypoint.sh" ] -#CMD [ "uv", "run gunicorn -c gunicorn.py "] From 0696d681e904be455aec39a0780ade5279beccba Mon Sep 17 00:00:00 2001 From: loki Date: Mon, 8 Sep 2025 13:36:41 +0300 Subject: [PATCH 09/34] continue refactorign --- blog/__init__.py | 10 +++++----- blog/domain/category.py | 8 ++++---- blog/domain/post.py | 18 +++++++++--------- blog/domain/tag.py | 6 +++--- blog/post/views.py | 10 +++++----- blog/repos/post.py | 20 +++++++++++++------- blog/services/category.py | 9 ++++----- blog/services/icon.py | 9 ++++----- blog/services/post.py | 31 +++++++++++++++++++------------ blog/services/tag.py | 9 ++++----- blog/services/user.py | 11 +++++------ blog/tags/views.py | 6 +++--- blog/templates/layout.html | 4 ++-- blog/templates/post.html | 2 +- blog/templates/posts.html | 2 +- blog/templates/tags.html | 2 +- blog/user/views.py | 10 +++++----- 17 files changed, 88 insertions(+), 79 deletions(-) diff --git a/blog/__init__.py b/blog/__init__.py index 1dbfacc..b85234d 100644 --- a/blog/__init__.py +++ b/blog/__init__.py @@ -10,8 +10,8 @@ from blog.config import config from blog.extensions import admin_ext, cache, db, login_manager, migrate, flask_sitemap from blog.post.views import post -from blog.tags.views import tagsb -from blog.user.views import user_blueprint +from blog.tags.views import tags +from blog.user.views import user load_dotenv() logger = logging.getLogger(__name__) @@ -24,7 +24,7 @@ def configure_extensions(app): cache.init_app(app) migrate.init_app(app=app, db=db) login_manager.init_app(app=app) - login_manager.login_view = "userb.index" # type: ignore + login_manager.login_view = "user.index" # type: ignore flask_sitemap.init_app(app) @@ -37,6 +37,6 @@ def create_app(): if not app.config["TESTING"]: create_admin(admin_ext) app.register_blueprint(post) - app.register_blueprint(tagsb) - app.register_blueprint(user_blueprint) + app.register_blueprint(tags) + app.register_blueprint(user) return app diff --git a/blog/domain/category.py b/blog/domain/category.py index a05d0ae..47797a2 100644 --- a/blog/domain/category.py +++ b/blog/domain/category.py @@ -2,7 +2,7 @@ from dataclasses import dataclass import typing -from typing import TYPE_CHECKING, Optional, List +from typing import TYPE_CHECKING if TYPE_CHECKING: from blog.domain.post import Post as PostDomain @@ -12,12 +12,12 @@ class Category: """Domain model for blog category.""" - id: Optional[int] = None + id: int | None = None title: str = "" alias: str = "" - template: Optional[str] = None + template: str | None = None - posts: "Optional[List[PostDomain]]" = None + posts: "list[PostDomain] | None" = None @typing.override def __str__(self): diff --git a/blog/domain/post.py b/blog/domain/post.py index 666468d..e285a90 100644 --- a/blog/domain/post.py +++ b/blog/domain/post.py @@ -3,7 +3,7 @@ from dataclasses import dataclass import datetime import markdown -from typing import TYPE_CHECKING, Optional, List +from typing import TYPE_CHECKING if TYPE_CHECKING: from blog.domain.category import Category as CategoryDomain @@ -18,20 +18,20 @@ class Post: """Domain model for blog post.""" - id: Optional[int] = None + id: int | None = None pagetitle: str = "" alias: str = "" content: str = "" - createdon: Optional[datetime.datetime] = None - publishedon: Optional[datetime.datetime] = None - category_id: Optional[int] = None - user_id: Optional[int] = None + createdon: datetime.datetime | None = None + publishedon: datetime.datetime | None = None + category_id: int | None = None + user_id: int | None = None # These would typically be loaded separately in a real implementation # to avoid circular dependencies - user: "Optional[UserDomain]" = None - category: "Optional[CategoryDomain]" = None - tags: "Optional[List[TagDomain]]" = None + user: "UserDomain | None" = None + category: "CategoryDomain | None" = None + tags: "list[TagDomain] | None" = None def __post_init__(self): if self.createdon is None: diff --git a/blog/domain/tag.py b/blog/domain/tag.py index 36c6806..f287f32 100644 --- a/blog/domain/tag.py +++ b/blog/domain/tag.py @@ -2,7 +2,7 @@ from dataclasses import dataclass import typing -from typing import TYPE_CHECKING, Optional, List +from typing import TYPE_CHECKING if TYPE_CHECKING: from blog.domain.post import Post as PostDomain @@ -12,11 +12,11 @@ class Tag: """Domain model for blog tag.""" - id: Optional[int] = None + id: int | None = None title: str = "" alias: str = "" - posts: "Optional[List[PostDomain]]" = None + posts: "list[PostDomain] | None" = None @typing.override def __str__(self): diff --git a/blog/post/views.py b/blog/post/views.py index 0c841b1..3944204 100644 --- a/blog/post/views.py +++ b/blog/post/views.py @@ -16,7 +16,7 @@ from blog import cache from blog.services.factory import ServiceFactory -post = Blueprint("postb", __name__) +post = Blueprint("post", __name__) def pages_gen(f): @@ -30,9 +30,9 @@ def decorated_function(*args, **kwargs): post_service = ServiceFactory.create_post_service() pages = post_service.get_page_posts(page_category) - # Get icons using IconService for ORM models (needed for specific use cases) + # Get icons using IconService for domain models icon_service = ServiceFactory.create_icon_service() - icons = icon_service.get_all_icons_orm() + icons = icon_service.get_all_icons() return f(pages=pages, icons=icons, *args, **kwargs) @@ -99,12 +99,12 @@ def site_map_gen(): post_service = ServiceFactory.create_post_service() pages = post_service.get_page_posts(page_category) for page in pages: - yield url_for("postb.view", alias=page.alias) + yield url_for("post.view", alias=page.alias) # Get posts using PostService posts = post_service.get_published_posts() for post in posts: - yield url_for("postb.view", alias=post.alias) + yield url_for("post.view", alias=post.alias) @post.route("/md/", methods=["POST", "GET"]) diff --git a/blog/repos/post.py b/blog/repos/post.py index 7cbbbcc..bd605bc 100644 --- a/blog/repos/post.py +++ b/blog/repos/post.py @@ -5,6 +5,7 @@ from blog.extensions import db from blog.post.models import Post as PostORM +from blog.tags.models import Tag as TagORM from blog.domain.post import Post as PostDomain from blog.domain.user import User as UserDomain from blog.domain.category import Category as CategoryDomain @@ -17,8 +18,8 @@ class PostRepository: def __init__(self, session: Any = None): self.session = session or db.session - def get_post_orm_with_relationships(self, post_id: int) -> PostORM | None: - """Get a post ORM model with all its relationships loaded.""" + def get_post_with_relationships(self, post_id: int) -> PostDomain | None: + """Get a post domain model with all its relationships loaded.""" stmt = ( sa.select(PostORM) .where(PostORM.id == post_id) @@ -28,7 +29,10 @@ def get_post_orm_with_relationships(self, post_id: int) -> PostORM | None: sa.orm.joinedload(PostORM.tags), ) ) - return self.session.scalar(stmt) + post_orm = self.session.scalar(stmt) + if post_orm: + return self._to_domain_model(post_orm) + return None def get_by_id(self, post_id: int) -> PostDomain | None: """Get a post by its ID.""" @@ -89,10 +93,12 @@ def create(self, post: PostDomain) -> PostDomain: # Handle tags relationship if provided if post.tags: - # For now, we'll just set the tags field to an empty list - # In a real implementation, we would need to handle the many-to-many relationship - # This is a limitation of the current domain model design - pass + # Find existing Tag ORM models based on the domain Tag models + tag_ids = [tag.id for tag in post.tags if tag.id is not None] + if tag_ids: + stmt = sa.select(TagORM).where(TagORM.id.in_(tag_ids)) + existing_tags = list(self.session.scalars(stmt).all()) + post_orm.tags = existing_tags self.session.add(post_orm) self.session.flush() # Get the ID without committing diff --git a/blog/services/category.py b/blog/services/category.py index ead20c9..1ed128e 100644 --- a/blog/services/category.py +++ b/blog/services/category.py @@ -1,6 +1,5 @@ """Service layer for Category entities.""" -from typing import List, Optional from blog.repos.category import CategoryRepository from blog.domain.category import Category @@ -17,19 +16,19 @@ class CategoryService: def __init__(self, category_repository: CategoryRepository): self.category_repository = category_repository - def get_category_by_id(self, category_id: int) -> Optional[Category]: + def get_category_by_id(self, category_id: int) -> Category | None: """Get a category by its ID.""" return self.category_repository.get_by_id(category_id) - def get_category_by_alias(self, alias: str) -> Optional[Category]: + def get_category_by_alias(self, alias: str) -> Category | None: """Get a category by its alias.""" return self.category_repository.get_by_alias(alias) - def get_all_categories(self) -> List[Category]: + def get_all_categories(self) -> list[Category]: """Get all categories.""" return self.category_repository.get_all() - def get_categories_with_posts(self) -> List[Category]: + def get_categories_with_posts(self) -> list[Category]: """Get all categories with their posts.""" return self.category_repository.get_categories_with_posts() diff --git a/blog/services/icon.py b/blog/services/icon.py index a4f0e87..7af6c50 100644 --- a/blog/services/icon.py +++ b/blog/services/icon.py @@ -1,6 +1,5 @@ """Service layer for Icon entities.""" -from typing import List, Optional from blog.repos.icon import IconRepository from blog.domain.icon import Icon @@ -21,19 +20,19 @@ def get_icon_orm_by_id(self, icon_id: int): """Get an icon ORM model by its ID. Used for specific use cases requiring ORM models.""" return self.icon_repository.get_icon_orm_by_id(icon_id) - def get_all_icons_orm(self) -> List: + def get_all_icons_orm(self) -> list: """Get all icons as ORM models. Used for specific use cases requiring ORM models.""" return self.icon_repository.get_all_icons_orm() - def get_icon_by_id(self, icon_id: int) -> Optional[Icon]: + def get_icon_by_id(self, icon_id: int) -> Icon | None: """Get an icon by its ID.""" return self.icon_repository.get_by_id(icon_id) - def get_icon_by_title(self, title: str) -> Optional[Icon]: + def get_icon_by_title(self, title: str) -> Icon | None: """Get an icon by its title.""" return self.icon_repository.get_by_title(title) - def get_all_icons(self) -> List[Icon]: + def get_all_icons(self) -> list[Icon]: """Get all icons.""" return self.icon_repository.get_all() diff --git a/blog/services/post.py b/blog/services/post.py index dd9ebb1..aee3001 100644 --- a/blog/services/post.py +++ b/blog/services/post.py @@ -1,10 +1,12 @@ -"""Service layer for Post entities.""" - -from typing import List, Optional +import logging from blog.repos.post import PostRepository from blog.domain.post import Post +# Set up logger +logger = logging.getLogger(__name__) + + class PostServiceError(Exception): """Base exception for PostService errors.""" @@ -17,23 +19,23 @@ class PostService: def __init__(self, post_repository: PostRepository): self.post_repository = post_repository - def get_post_by_id(self, post_id: int) -> Optional[Post]: + def get_post_by_id(self, post_id: int) -> Post | None: """Get a post by its ID.""" return self.post_repository.get_by_id(post_id) - def get_post_by_alias(self, alias: str) -> Optional[Post]: + def get_post_by_alias(self, alias: str) -> Post | None: """Get a post by its alias.""" return self.post_repository.get_by_alias(alias) - def get_all_posts(self) -> List[Post]: + def get_all_posts(self) -> list[Post]: """Get all posts.""" return self.post_repository.get_all() - def get_published_posts(self) -> List[Post]: + def get_published_posts(self) -> list[Post]: """Get all published posts.""" return self.post_repository.get_published_posts() - def get_page_posts(self, page_category_ids: List[int]) -> List[Post]: + def get_page_posts(self, page_category_ids: list[int]) -> list[Post]: """Get posts that are pages (in specific categories).""" return self.post_repository.get_page_posts(page_category_ids) @@ -57,7 +59,12 @@ def delete_post(self, post_id: int) -> bool: """Delete a post by its ID.""" try: return self.post_repository.delete(post_id) - except Exception: - # Log the error and return False to indicate failure - # In a real application, you might want to log this - return False + except Exception as e: + # Log the error with details + logger.error( + f"Failed to delete post with id {post_id}: {str(e)}", exc_info=True + ) + # Re-raise as a more specific exception for the service layer + raise PostServiceError( + f"Failed to delete post with id {post_id}: {str(e)}" + ) from e diff --git a/blog/services/tag.py b/blog/services/tag.py index b361184..7793199 100644 --- a/blog/services/tag.py +++ b/blog/services/tag.py @@ -1,6 +1,5 @@ """Service layer for Tag entities.""" -from typing import List, Optional from blog.repos.tag import TagRepository from blog.domain.tag import Tag @@ -17,19 +16,19 @@ class TagService: def __init__(self, tag_repository: TagRepository): self.tag_repository = tag_repository - def get_tag_by_id(self, tag_id: int) -> Optional[Tag]: + def get_tag_by_id(self, tag_id: int) -> Tag | None: """Get a tag by its ID.""" return self.tag_repository.get_by_id(tag_id) - def get_tag_by_alias(self, alias: str) -> Optional[Tag]: + def get_tag_by_alias(self, alias: str) -> Tag | None: """Get a tag by its alias.""" return self.tag_repository.get_by_alias(alias) - def get_all_tags(self) -> List[Tag]: + def get_all_tags(self) -> list[Tag]: """Get all tags.""" return self.tag_repository.get_all() - def get_tags_with_posts(self) -> List[Tag]: + def get_tags_with_posts(self) -> list[Tag]: """Get all tags with their posts.""" return self.tag_repository.get_tags_with_posts() diff --git a/blog/services/user.py b/blog/services/user.py index d69d6ee..a46ae48 100644 --- a/blog/services/user.py +++ b/blog/services/user.py @@ -1,6 +1,5 @@ """Service layer for User entities.""" -from typing import List, Optional from blog.repos.user import UserRepository from blog.domain.user import User @@ -17,7 +16,7 @@ class UserService: def __init__(self, user_repository: UserRepository): self.user_repository = user_repository - def get_user_by_id(self, user_id: int) -> Optional[User]: + def get_user_by_id(self, user_id: int) -> User | None: """Get a user by its ID.""" return self.user_repository.get_by_id(user_id) @@ -29,15 +28,15 @@ def get_user_orm_by_name(self, name: str): """Get a user ORM model by their name. Used for Flask-Login compatibility.""" return self.user_repository.get_user_orm_by_name(name) - def get_user_by_name(self, name: str) -> Optional[User]: + def get_user_by_name(self, name: str) -> User | None: """Get a user by their name.""" return self.user_repository.get_by_name(name) - def get_all_users(self) -> List[User]: + def get_all_users(self) -> list[User]: """Get all users.""" return self.user_repository.get_all() - def get_users_with_posts(self) -> List[User]: + def get_users_with_posts(self) -> list[User]: """Get all users with their posts.""" return self.user_repository.get_users_with_posts() @@ -66,6 +65,6 @@ def delete_user(self, user_id: int) -> bool: # In a real application, you might want to log this return False - def authenticate_user(self, name: str, password: str) -> Optional[User]: + def authenticate_user(self, name: str, password: str) -> User | None: """Authenticate a user by name and password.""" return self.user_repository.authenticate(name, password) diff --git a/blog/tags/views.py b/blog/tags/views.py index 34cfe03..272ea3a 100644 --- a/blog/tags/views.py +++ b/blog/tags/views.py @@ -3,10 +3,10 @@ from blog.post.views import pages_gen from blog.services.factory import ServiceFactory -tagsb = Blueprint("tagsb", __name__, url_prefix="/tags") +tags = Blueprint("tags", __name__, url_prefix="/tags") -@tagsb.route("/") +@tags.route("/") @pages_gen def index(**kwargs): # Use service layer for domain models @@ -15,7 +15,7 @@ def index(**kwargs): return render_template("tags.html", tags=tags, **kwargs) -@tagsb.route("/") +@tags.route("/") @pages_gen def view(alias=None, **kwargs): # Use service layer for domain models diff --git a/blog/templates/layout.html b/blog/templates/layout.html index e986033..9b606f6 100644 --- a/blog/templates/layout.html +++ b/blog/templates/layout.html @@ -33,9 +33,9 @@
diff --git a/blog/templates/post.html b/blog/templates/post.html index f0bc673..40374ee 100644 --- a/blog/templates/post.html +++ b/blog/templates/post.html @@ -23,7 +23,7 @@

{{post.pagetitle}}

diff --git a/blog/templates/posts.html b/blog/templates/posts.html index 46e0c81..7d09f5a 100644 --- a/blog/templates/posts.html +++ b/blog/templates/posts.html @@ -11,7 +11,7 @@

{{group.grouper}}

{% for post in group.list %}
- + {{post.pagetitle}} diff --git a/blog/templates/tags.html b/blog/templates/tags.html index 58f0637..484af91 100644 --- a/blog/templates/tags.html +++ b/blog/templates/tags.html @@ -5,7 +5,7 @@

Tags

{% for tag in tags %} diff --git a/blog/user/views.py b/blog/user/views.py index 3a77e6a..2dd3acc 100644 --- a/blog/user/views.py +++ b/blog/user/views.py @@ -7,7 +7,7 @@ from blog.services.factory import ServiceFactory -user_blueprint = Blueprint("userb", __name__) +user = Blueprint("user", __name__) @login_manager.user_loader @@ -24,7 +24,7 @@ def load_user(user_id): return None -@user_blueprint.route("/login", methods=["GET", "POST"]) +@user.route("/login", methods=["GET", "POST"]) def login(): if current_user.is_authenticated: return redirect("/") @@ -47,11 +47,11 @@ def login(): login_user(user_orm) return redirect(url_for("admin.index")) flash("invalid user o password") - return redirect(url_for("userb.login")) + return redirect(url_for("user.login")) return render_template("login.html", form=form) -@user_blueprint.route("/logout") +@user.route("/logout") def logout(): flask_login.logout_user() - return redirect(url_for("postb.index")) + return redirect(url_for("post.index")) From 085be62145b5f48846f9dcdb928efbb866656a87 Mon Sep 17 00:00:00 2001 From: loki Date: Mon, 8 Sep 2025 14:09:30 +0300 Subject: [PATCH 10/34] remove comments --- blog/domain/post.py | 1 - blog/post/views.py | 15 --------------- blog/repos/category.py | 9 --------- blog/repos/icon.py | 7 ------- blog/repos/post.py | 10 ---------- blog/repos/tag.py | 9 --------- blog/repos/user.py | 10 ---------- blog/services/category.py | 7 ------- blog/services/icon.py | 6 ------ blog/services/post.py | 9 --------- blog/services/tag.py | 7 ------- blog/services/user.py | 8 -------- blog/tags/views.py | 2 -- blog/user/views.py | 4 +--- 14 files changed, 1 insertion(+), 103 deletions(-) diff --git a/blog/domain/post.py b/blog/domain/post.py index e285a90..158e4d0 100644 --- a/blog/domain/post.py +++ b/blog/domain/post.py @@ -36,7 +36,6 @@ class Post: def __post_init__(self): if self.createdon is None: self.createdon = datetime.datetime.now(datetime.timezone.utc) - # Don't set default for publishedon - it should be None for unpublished posts @property def markdown(self): diff --git a/blog/post/views.py b/blog/post/views.py index 3944204..ada3764 100644 --- a/blog/post/views.py +++ b/blog/post/views.py @@ -22,15 +22,11 @@ def pages_gen(f): @wraps(f) def decorated_function(*args, **kwargs): - # Use service layer for business logic and domain models - page_category = current_app.config["PAGE_CATEGORY"] - # Get posts using PostService for domain models post_service = ServiceFactory.create_post_service() pages = post_service.get_page_posts(page_category) - # Get icons using IconService for domain models icon_service = ServiceFactory.create_icon_service() icons = icon_service.get_all_icons() @@ -43,8 +39,6 @@ def decorated_function(*args, **kwargs): @cache.cached(timeout=50) @pages_gen def index(**kwargs): - # Use service layer for domain models - post_service = ServiceFactory.create_post_service() posts = post_service.get_published_posts() return render_template("posts.html", posts=posts, **kwargs) @@ -54,7 +48,6 @@ def index(**kwargs): @cache.cached(timeout=50) @pages_gen def view(alias=None, **kwargs): - # Use service layer for domain models if alias is None: from flask import abort @@ -63,7 +56,6 @@ def view(alias=None, **kwargs): post_service = ServiceFactory.create_post_service() post = post_service.get_post_by_alias(alias) if not post: - # Handle 404 case from flask import abort abort(404) @@ -78,7 +70,6 @@ def view(alias=None, **kwargs): abort(404) - # Load category object if needed using service page_category_obj = None if post.category_id: category_service = ServiceFactory.create_category_service() @@ -91,17 +82,13 @@ def view(alias=None, **kwargs): @flask_sitemap.register_generator def site_map_gen(): - # Use service layer for domain models - page_category = current_app.config["PAGE_CATEGORY"] - # Get pages using PostService post_service = ServiceFactory.create_post_service() pages = post_service.get_page_posts(page_category) for page in pages: yield url_for("post.view", alias=page.alias) - # Get posts using PostService posts = post_service.get_published_posts() for post in posts: yield url_for("post.view", alias=post.alias) @@ -128,8 +115,6 @@ def robots(): @post.route("/rss.xml") @cache.cached(timeout=50) def rss(): - # Use service layer for domain models - post_service = ServiceFactory.create_post_service() list_posts = post_service.get_published_posts() diff --git a/blog/repos/category.py b/blog/repos/category.py index 190f8e0..dbbbfbf 100644 --- a/blog/repos/category.py +++ b/blog/repos/category.py @@ -18,7 +18,6 @@ def __init__(self, session: Any = None): def get_category_orm_with_relationships( self, category_id: int ) -> CategoryORM | None: - """Get a category ORM model with all its relationships loaded.""" stmt = ( sa.select(CategoryORM) .where(CategoryORM.id == category_id) @@ -27,7 +26,6 @@ def get_category_orm_with_relationships( return self.session.scalar(stmt) def get_by_id(self, category_id: int) -> CategoryDomain | None: - """Get a category by its ID.""" stmt = sa.select(CategoryORM).where(CategoryORM.id == category_id) category_orm = self.session.scalar(stmt) if category_orm: @@ -35,7 +33,6 @@ def get_by_id(self, category_id: int) -> CategoryDomain | None: return None def get_by_alias(self, alias: str) -> CategoryDomain | None: - """Get a category by its alias.""" stmt = sa.select(CategoryORM).where(CategoryORM.alias == alias) category_orm = self.session.scalar(stmt) if category_orm: @@ -43,19 +40,16 @@ def get_by_alias(self, alias: str) -> CategoryDomain | None: return None def get_all(self) -> list[CategoryDomain]: - """Get all categories.""" stmt = sa.select(CategoryORM) categories_orm = list(self.session.scalars(stmt).all()) return [self._to_domain_model(category_orm) for category_orm in categories_orm] def get_categories_with_posts(self) -> list[CategoryDomain]: - """Get all categories with their posts.""" stmt = sa.select(CategoryORM).options(sa.orm.joinedload(CategoryORM.posts)) categories_orm = list(self.session.scalars(stmt).unique().all()) return [self._to_domain_model(category_orm) for category_orm in categories_orm] def create(self, category: CategoryDomain) -> CategoryDomain: - """Create a new category.""" category_orm = CategoryORM() category_orm.title = category.title category_orm.alias = category.alias @@ -68,7 +62,6 @@ def create(self, category: CategoryDomain) -> CategoryDomain: return category def update(self, category: CategoryDomain) -> CategoryDomain: - """Update an existing category.""" stmt = sa.select(CategoryORM).where(CategoryORM.id == category.id) category_orm = self.session.scalar(stmt) if not category_orm: @@ -83,7 +76,6 @@ def update(self, category: CategoryDomain) -> CategoryDomain: return category def delete(self, category_id: int) -> bool: - """Delete a category by its ID.""" stmt = sa.select(CategoryORM).where(CategoryORM.id == category_id) category_orm = self.session.scalar(stmt) if category_orm: @@ -92,7 +84,6 @@ def delete(self, category_id: int) -> bool: return False def _to_domain_model(self, category_orm: CategoryORM) -> CategoryDomain: - """Convert ORM model to domain model.""" # Convert related posts if they exist posts = None if category_orm.posts: diff --git a/blog/repos/icon.py b/blog/repos/icon.py index 509dba2..138eebc 100644 --- a/blog/repos/icon.py +++ b/blog/repos/icon.py @@ -25,7 +25,6 @@ def get_all_icons_orm(self) -> list[IconORM]: return list(self.session.scalars(stmt).all()) def get_by_id(self, icon_id: int) -> Icon | None: - """Get an icon by its ID.""" stmt = sa.select(IconORM).where(IconORM.id == icon_id) icon_orm = self.session.scalar(stmt) if icon_orm: @@ -33,7 +32,6 @@ def get_by_id(self, icon_id: int) -> Icon | None: return None def get_by_title(self, title: str) -> Icon | None: - """Get an icon by its title.""" stmt = sa.select(IconORM).where(IconORM.title == title) icon_orm = self.session.scalar(stmt) if icon_orm: @@ -41,13 +39,11 @@ def get_by_title(self, title: str) -> Icon | None: return None def get_all(self) -> list[Icon]: - """Get all icons.""" stmt = sa.select(IconORM) icons_orm = list(self.session.scalars(stmt).all()) return [self._to_domain_model(icon_orm) for icon_orm in icons_orm] def create(self, icon: Icon) -> Icon: - """Create a new icon.""" icon_orm = IconORM() icon_orm.title = icon.title icon_orm.url = icon.url @@ -59,7 +55,6 @@ def create(self, icon: Icon) -> Icon: return icon def update(self, icon: Icon) -> Icon: - """Update an existing icon.""" stmt = sa.select(IconORM).where(IconORM.id == icon.id) icon_orm = self.session.scalar(stmt) if not icon_orm: @@ -73,7 +68,6 @@ def update(self, icon: Icon) -> Icon: return icon def delete(self, icon_id: int) -> bool: - """Delete an icon by its ID.""" stmt = sa.select(IconORM).where(IconORM.id == icon_id) icon_orm = self.session.scalar(stmt) if icon_orm: @@ -82,7 +76,6 @@ def delete(self, icon_id: int) -> bool: return False def _to_domain_model(self, icon_orm: IconORM) -> Icon: - """Convert ORM model to domain model.""" return Icon( id=icon_orm.id, title=icon_orm.title, diff --git a/blog/repos/post.py b/blog/repos/post.py index bd605bc..384274a 100644 --- a/blog/repos/post.py +++ b/blog/repos/post.py @@ -19,7 +19,6 @@ def __init__(self, session: Any = None): self.session = session or db.session def get_post_with_relationships(self, post_id: int) -> PostDomain | None: - """Get a post domain model with all its relationships loaded.""" stmt = ( sa.select(PostORM) .where(PostORM.id == post_id) @@ -35,7 +34,6 @@ def get_post_with_relationships(self, post_id: int) -> PostDomain | None: return None def get_by_id(self, post_id: int) -> PostDomain | None: - """Get a post by its ID.""" stmt = sa.select(PostORM).where(PostORM.id == post_id) post_orm = self.session.scalar(stmt) if post_orm: @@ -43,7 +41,6 @@ def get_by_id(self, post_id: int) -> PostDomain | None: return None def get_by_alias(self, alias: str) -> PostDomain | None: - """Get a post by its alias.""" stmt = sa.select(PostORM).where(PostORM.alias == alias) post_orm = self.session.scalar(stmt) if post_orm: @@ -51,13 +48,11 @@ def get_by_alias(self, alias: str) -> PostDomain | None: return None def get_all(self) -> list[PostDomain]: - """Get all posts.""" stmt = sa.select(PostORM) posts_orm = list(self.session.scalars(stmt).all()) return [self._to_domain_model(post_orm) for post_orm in posts_orm] def get_published_posts(self) -> list[PostDomain]: - """Get all published posts ordered by published date.""" stmt = ( sa.select(PostORM) .where( @@ -70,13 +65,11 @@ def get_published_posts(self) -> list[PostDomain]: return [self._to_domain_model(post_orm) for post_orm in posts_orm] def get_page_posts(self, page_category_ids: list[int]) -> list[PostDomain]: - """Get posts that are pages (in specific categories).""" stmt = sa.select(PostORM).where(PostORM.category_id.in_(page_category_ids)) posts_orm = list(self.session.scalars(stmt).all()) return [self._to_domain_model(post_orm) for post_orm in posts_orm] def create(self, post: PostDomain) -> PostDomain: - """Create a new post.""" post_orm = PostORM() post_orm.pagetitle = post.pagetitle post_orm.alias = post.alias @@ -106,7 +99,6 @@ def create(self, post: PostDomain) -> PostDomain: return post def update(self, post: PostDomain) -> PostDomain: - """Update an existing post.""" stmt = sa.select(PostORM).where(PostORM.id == post.id) post_orm = self.session.scalar(stmt) if not post_orm: @@ -128,7 +120,6 @@ def update(self, post: PostDomain) -> PostDomain: return post def delete(self, post_id: int) -> bool: - """Delete a post by its ID.""" stmt = sa.select(PostORM).where(PostORM.id == post_id) post_orm = self.session.scalar(stmt) if post_orm: @@ -137,7 +128,6 @@ def delete(self, post_id: int) -> bool: return False def _to_domain_model(self, post_orm: PostORM) -> PostDomain: - """Convert ORM model to domain model.""" # Convert related user if it exists user = None if post_orm.user: diff --git a/blog/repos/tag.py b/blog/repos/tag.py index a1e8047..bf32272 100644 --- a/blog/repos/tag.py +++ b/blog/repos/tag.py @@ -16,7 +16,6 @@ def __init__(self, session: Any = None): self.session = session or db.session def get_tag_orm_with_relationships(self, tag_id: int) -> TagORM | None: - """Get a tag ORM model with all its relationships loaded.""" stmt = ( sa.select(TagORM) .where(TagORM.id == tag_id) @@ -25,7 +24,6 @@ def get_tag_orm_with_relationships(self, tag_id: int) -> TagORM | None: return self.session.scalar(stmt) def get_by_id(self, tag_id: int) -> TagDomain | None: - """Get a tag by its ID.""" stmt = sa.select(TagORM).where(TagORM.id == tag_id) tag_orm = self.session.scalar(stmt) if tag_orm: @@ -33,7 +31,6 @@ def get_by_id(self, tag_id: int) -> TagDomain | None: return None def get_by_alias(self, alias: str) -> TagDomain | None: - """Get a tag by its alias.""" stmt = sa.select(TagORM).where(TagORM.alias == alias) tag_orm = self.session.scalar(stmt) if tag_orm: @@ -41,19 +38,16 @@ def get_by_alias(self, alias: str) -> TagDomain | None: return None def get_all(self) -> list[TagDomain]: - """Get all tags.""" stmt = sa.select(TagORM) tags_orm = list(self.session.scalars(stmt).all()) return [self._to_domain_model(tag_orm) for tag_orm in tags_orm] def get_tags_with_posts(self) -> list[TagDomain]: - """Get all tags with their posts.""" stmt = sa.select(TagORM).options(sa.orm.joinedload(TagORM.posts)) tags_orm = list(self.session.scalars(stmt).unique().all()) return [self._to_domain_model(tag_orm) for tag_orm in tags_orm] def create(self, tag: TagDomain) -> TagDomain: - """Create a new tag.""" tag_orm = TagORM() tag_orm.title = tag.title tag_orm.alias = tag.alias @@ -63,7 +57,6 @@ def create(self, tag: TagDomain) -> TagDomain: return tag def update(self, tag: TagDomain) -> TagDomain: - """Update an existing tag.""" stmt = sa.select(TagORM).where(TagORM.id == tag.id) tag_orm = self.session.scalar(stmt) if not tag_orm: @@ -75,7 +68,6 @@ def update(self, tag: TagDomain) -> TagDomain: return tag def delete(self, tag_id: int) -> bool: - """Delete a tag by its ID.""" stmt = sa.select(TagORM).where(TagORM.id == tag_id) tag_orm = self.session.scalar(stmt) if tag_orm: @@ -84,7 +76,6 @@ def delete(self, tag_id: int) -> bool: return False def _to_domain_model(self, tag_orm: TagORM) -> TagDomain: - """Convert ORM model to domain model.""" # Convert related posts if they exist posts = None if tag_orm.posts: diff --git a/blog/repos/user.py b/blog/repos/user.py index a9d5d8e..c0b0947 100644 --- a/blog/repos/user.py +++ b/blog/repos/user.py @@ -16,7 +16,6 @@ def __init__(self, session: Any = None): self.session = session or db.session def get_user_orm_with_relationships(self, user_id: int) -> UserORM | None: - """Get a user ORM model with all its relationships loaded.""" stmt = ( sa.select(UserORM) .where(UserORM.id == user_id) @@ -35,7 +34,6 @@ def get_user_orm_by_name(self, name: str) -> UserORM | None: return self.session.scalar(stmt) def get_by_id(self, user_id: int) -> UserDomain | None: - """Get a user by its ID.""" stmt = sa.select(UserORM).where(UserORM.id == user_id) user_orm = self.session.scalar(stmt) if user_orm: @@ -43,7 +41,6 @@ def get_by_id(self, user_id: int) -> UserDomain | None: return None def get_by_name(self, name: str) -> UserDomain | None: - """Get a user by their name.""" stmt = sa.select(UserORM).where(UserORM.name == name) user_orm = self.session.scalar(stmt) if user_orm: @@ -51,19 +48,16 @@ def get_by_name(self, name: str) -> UserDomain | None: return None def get_all(self) -> list[UserDomain]: - """Get all users.""" stmt = sa.select(UserORM) users_orm = self.session.scalars(stmt).all() return [self._to_domain_model(user_orm) for user_orm in users_orm] def get_users_with_posts(self) -> list[UserDomain]: - """Get all users with their posts.""" stmt = sa.select(UserORM).options(sa.orm.joinedload(UserORM.posts)) users_orm = self.session.scalars(stmt).unique().all() return [self._to_domain_model(user_orm) for user_orm in users_orm] def create(self, user: UserDomain) -> UserDomain: - """Create a new user.""" user_orm = UserORM() user_orm.name = user.name user_orm.password = user.password @@ -80,7 +74,6 @@ def create(self, user: UserDomain) -> UserDomain: return user def update(self, user: UserDomain) -> UserDomain: - """Update an existing user.""" stmt = sa.select(UserORM).where(UserORM.id == user.id) user_orm = self.session.scalar(stmt) if not user_orm: @@ -99,7 +92,6 @@ def update(self, user: UserDomain) -> UserDomain: return user def delete(self, user_id: int) -> bool: - """Delete a user by its ID.""" stmt = sa.select(UserORM).where(UserORM.id == user_id) user_orm = self.session.scalar(stmt) if user_orm: @@ -108,7 +100,6 @@ def delete(self, user_id: int) -> bool: return False def authenticate(self, name: str, password: str) -> UserDomain | None: - """Authenticate a user by name and password.""" stmt = sa.select(UserORM).where(UserORM.name == name) user_orm = self.session.scalar(stmt) if user_orm and user_orm.check_password(password): @@ -116,7 +107,6 @@ def authenticate(self, name: str, password: str) -> UserDomain | None: return None def _to_domain_model(self, user_orm: UserORM) -> UserDomain: - """Convert ORM model to domain model.""" # Convert related posts if they exist posts = None if user_orm.posts: diff --git a/blog/services/category.py b/blog/services/category.py index 1ed128e..ab53596 100644 --- a/blog/services/category.py +++ b/blog/services/category.py @@ -17,23 +17,18 @@ def __init__(self, category_repository: CategoryRepository): self.category_repository = category_repository def get_category_by_id(self, category_id: int) -> Category | None: - """Get a category by its ID.""" return self.category_repository.get_by_id(category_id) def get_category_by_alias(self, alias: str) -> Category | None: - """Get a category by its alias.""" return self.category_repository.get_by_alias(alias) def get_all_categories(self) -> list[Category]: - """Get all categories.""" return self.category_repository.get_all() def get_categories_with_posts(self) -> list[Category]: - """Get all categories with their posts.""" return self.category_repository.get_categories_with_posts() def create_category(self, category: Category) -> Category: - """Create a new category.""" try: return self.category_repository.create(category) except Exception as e: @@ -41,7 +36,6 @@ def create_category(self, category: Category) -> Category: raise CategoryServiceError(f"Failed to create category: {str(e)}") from e def update_category(self, category: Category) -> Category: - """Update an existing category.""" try: return self.category_repository.update(category) except ValueError as e: @@ -49,7 +43,6 @@ def update_category(self, category: Category) -> Category: raise CategoryServiceError(f"Failed to update category: {str(e)}") from e def delete_category(self, category_id: int) -> bool: - """Delete a category by its ID.""" try: return self.category_repository.delete(category_id) except Exception: diff --git a/blog/services/icon.py b/blog/services/icon.py index 7af6c50..23fdc45 100644 --- a/blog/services/icon.py +++ b/blog/services/icon.py @@ -25,19 +25,15 @@ def get_all_icons_orm(self) -> list: return self.icon_repository.get_all_icons_orm() def get_icon_by_id(self, icon_id: int) -> Icon | None: - """Get an icon by its ID.""" return self.icon_repository.get_by_id(icon_id) def get_icon_by_title(self, title: str) -> Icon | None: - """Get an icon by its title.""" return self.icon_repository.get_by_title(title) def get_all_icons(self) -> list[Icon]: - """Get all icons.""" return self.icon_repository.get_all() def create_icon(self, icon: Icon) -> Icon: - """Create a new icon.""" try: return self.icon_repository.create(icon) except Exception as e: @@ -45,7 +41,6 @@ def create_icon(self, icon: Icon) -> Icon: raise IconServiceError(f"Failed to create icon: {str(e)}") from e def update_icon(self, icon: Icon) -> Icon: - """Update an existing icon.""" try: return self.icon_repository.update(icon) except ValueError as e: @@ -53,7 +48,6 @@ def update_icon(self, icon: Icon) -> Icon: raise IconServiceError(f"Failed to update icon: {str(e)}") from e def delete_icon(self, icon_id: int) -> bool: - """Delete an icon by its ID.""" try: return self.icon_repository.delete(icon_id) except Exception: diff --git a/blog/services/post.py b/blog/services/post.py index aee3001..1384e62 100644 --- a/blog/services/post.py +++ b/blog/services/post.py @@ -3,7 +3,6 @@ from blog.domain.post import Post -# Set up logger logger = logging.getLogger(__name__) @@ -20,27 +19,21 @@ def __init__(self, post_repository: PostRepository): self.post_repository = post_repository def get_post_by_id(self, post_id: int) -> Post | None: - """Get a post by its ID.""" return self.post_repository.get_by_id(post_id) def get_post_by_alias(self, alias: str) -> Post | None: - """Get a post by its alias.""" return self.post_repository.get_by_alias(alias) def get_all_posts(self) -> list[Post]: - """Get all posts.""" return self.post_repository.get_all() def get_published_posts(self) -> list[Post]: - """Get all published posts.""" return self.post_repository.get_published_posts() def get_page_posts(self, page_category_ids: list[int]) -> list[Post]: - """Get posts that are pages (in specific categories).""" return self.post_repository.get_page_posts(page_category_ids) def create_post(self, post: Post) -> Post: - """Create a new post.""" try: return self.post_repository.create(post) except Exception as e: @@ -48,7 +41,6 @@ def create_post(self, post: Post) -> Post: raise PostServiceError(f"Failed to create post: {str(e)}") from e def update_post(self, post: Post) -> Post: - """Update an existing post.""" try: return self.post_repository.update(post) except ValueError as e: @@ -56,7 +48,6 @@ def update_post(self, post: Post) -> Post: raise PostServiceError(f"Failed to update post: {str(e)}") from e def delete_post(self, post_id: int) -> bool: - """Delete a post by its ID.""" try: return self.post_repository.delete(post_id) except Exception as e: diff --git a/blog/services/tag.py b/blog/services/tag.py index 7793199..28dc6d6 100644 --- a/blog/services/tag.py +++ b/blog/services/tag.py @@ -17,23 +17,18 @@ def __init__(self, tag_repository: TagRepository): self.tag_repository = tag_repository def get_tag_by_id(self, tag_id: int) -> Tag | None: - """Get a tag by its ID.""" return self.tag_repository.get_by_id(tag_id) def get_tag_by_alias(self, alias: str) -> Tag | None: - """Get a tag by its alias.""" return self.tag_repository.get_by_alias(alias) def get_all_tags(self) -> list[Tag]: - """Get all tags.""" return self.tag_repository.get_all() def get_tags_with_posts(self) -> list[Tag]: - """Get all tags with their posts.""" return self.tag_repository.get_tags_with_posts() def create_tag(self, tag: Tag) -> Tag: - """Create a new tag.""" try: return self.tag_repository.create(tag) except Exception as e: @@ -41,7 +36,6 @@ def create_tag(self, tag: Tag) -> Tag: raise TagServiceError(f"Failed to create tag: {str(e)}") from e def update_tag(self, tag: Tag) -> Tag: - """Update an existing tag.""" try: return self.tag_repository.update(tag) except ValueError as e: @@ -49,7 +43,6 @@ def update_tag(self, tag: Tag) -> Tag: raise TagServiceError(f"Failed to update tag: {str(e)}") from e def delete_tag(self, tag_id: int) -> bool: - """Delete a tag by its ID.""" try: return self.tag_repository.delete(tag_id) except Exception: diff --git a/blog/services/user.py b/blog/services/user.py index a46ae48..0c127e6 100644 --- a/blog/services/user.py +++ b/blog/services/user.py @@ -17,7 +17,6 @@ def __init__(self, user_repository: UserRepository): self.user_repository = user_repository def get_user_by_id(self, user_id: int) -> User | None: - """Get a user by its ID.""" return self.user_repository.get_by_id(user_id) def get_user_orm_by_id(self, user_id: int): @@ -29,19 +28,15 @@ def get_user_orm_by_name(self, name: str): return self.user_repository.get_user_orm_by_name(name) def get_user_by_name(self, name: str) -> User | None: - """Get a user by their name.""" return self.user_repository.get_by_name(name) def get_all_users(self) -> list[User]: - """Get all users.""" return self.user_repository.get_all() def get_users_with_posts(self) -> list[User]: - """Get all users with their posts.""" return self.user_repository.get_users_with_posts() def create_user(self, user: User) -> User: - """Create a new user.""" try: return self.user_repository.create(user) except Exception as e: @@ -49,7 +44,6 @@ def create_user(self, user: User) -> User: raise UserServiceError(f"Failed to create user: {str(e)}") from e def update_user(self, user: User) -> User: - """Update an existing user.""" try: return self.user_repository.update(user) except ValueError as e: @@ -57,7 +51,6 @@ def update_user(self, user: User) -> User: raise UserServiceError(f"Failed to update user: {str(e)}") from e def delete_user(self, user_id: int) -> bool: - """Delete a user by its ID.""" try: return self.user_repository.delete(user_id) except Exception: @@ -66,5 +59,4 @@ def delete_user(self, user_id: int) -> bool: return False def authenticate_user(self, name: str, password: str) -> User | None: - """Authenticate a user by name and password.""" return self.user_repository.authenticate(name, password) diff --git a/blog/tags/views.py b/blog/tags/views.py index 272ea3a..d94e400 100644 --- a/blog/tags/views.py +++ b/blog/tags/views.py @@ -9,7 +9,6 @@ @tags.route("/") @pages_gen def index(**kwargs): - # Use service layer for domain models tag_service = ServiceFactory.create_tag_service() tags = tag_service.get_all_tags() return render_template("tags.html", tags=tags, **kwargs) @@ -18,7 +17,6 @@ def index(**kwargs): @tags.route("/") @pages_gen def view(alias=None, **kwargs): - # Use service layer for domain models if alias is None: from flask import abort diff --git a/blog/user/views.py b/blog/user/views.py index 2dd3acc..ed3044e 100644 --- a/blog/user/views.py +++ b/blog/user/views.py @@ -13,11 +13,10 @@ @login_manager.user_loader def load_user(user_id): """Load user by ID for Flask-Login.""" - # Use service layer to get user (but Flask-Login needs ORM model) + # For Flask-Login compatibility, we need to return an ORM model user_service = ServiceFactory.create_user_service() user = user_service.get_user_by_id(int(user_id)) if user: - # For Flask-Login compatibility, we need to return an ORM model # This is one of the few places where we directly access the service layer # to get an ORM model because Flask-Login requires an ORM model return user_service.get_user_orm_by_id(int(user_id)) @@ -34,7 +33,6 @@ def login(): name = form.name.data password = form.password.data if name is not None and password is not None: - # Use service layer to authenticate user (returns domain model) user_service = ServiceFactory.create_user_service() user = user_service.authenticate_user(name, password) From a6c36cb6403e688bcf27d8aaef956c5165002d1d Mon Sep 17 00:00:00 2001 From: loki Date: Mon, 8 Sep 2025 15:36:30 +0300 Subject: [PATCH 11/34] added user admin --- blog/__init__.py | 12 ++++++- blog/admin/__init__.py | 10 +++--- blog/cli.py | 39 +++++++++++++++++++++++ blog/commands.py | 38 ++++++++++++++++++++++ blog/user/models.py | 7 +++++ create_admin.py | 71 ++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 171 insertions(+), 6 deletions(-) create mode 100644 blog/cli.py create mode 100644 blog/commands.py create mode 100755 create_admin.py diff --git a/blog/__init__.py b/blog/__init__.py index b85234d..771c371 100644 --- a/blog/__init__.py +++ b/blog/__init__.py @@ -1,4 +1,4 @@ -"""[summary].""" +"""Blog application initialization.""" import logging import os @@ -28,6 +28,12 @@ def configure_extensions(app): flask_sitemap.init_app(app) +def register_commands(app): + """Register custom CLI commands.""" + from blog.commands import init_app + init_app(app) + + def create_app(): app = Flask(__name__) env = os.environ.get("FLASK_ENV", "development") @@ -39,4 +45,8 @@ def create_app(): app.register_blueprint(post) app.register_blueprint(tags) app.register_blueprint(user) + + # Register CLI commands + register_commands(app) + return app diff --git a/blog/admin/__init__.py b/blog/admin/__init__.py index cb6c034..f23a70f 100644 --- a/blog/admin/__init__.py +++ b/blog/admin/__init__.py @@ -36,10 +36,10 @@ def is_accessible(self): def create_admin(config_admin): - config_admin.add_view(PostView(Post, db.session, endpoint="")) - config_admin.add_view(UserView(Category, db.session, endpoint="")) - config_admin.add_view(UserView(Tag, db.session, endpoint="")) - config_admin.add_view(UserView(User, db.session, endpoint="")) - config_admin.add_view(UserView(Icon, db.session, endpoint="")) + config_admin.add_view(PostView(Post, db.session, endpoint="admin_post")) + config_admin.add_view(UserView(Category, db.session, endpoint="admin_category")) + config_admin.add_view(UserView(Tag, db.session, endpoint="admin_tag")) + config_admin.add_view(UserView(User, db.session, endpoint="admin_user")) + config_admin.add_view(UserView(Icon, db.session, endpoint="admin_icon")) path = os.path.join(os.path.dirname(__file__), "../static/upload") config_admin.add_view(MyFileAdmin(path, "/static/upload", name="files")) diff --git a/blog/cli.py b/blog/cli.py new file mode 100644 index 0000000..da6c685 --- /dev/null +++ b/blog/cli.py @@ -0,0 +1,39 @@ +"""CLI commands for the blog application.""" + +import click +from flask.cli import with_appcontext +from blog import create_app +from blog.extensions import db +from blog.user.models import User + + +@click.command() +@click.option('--name', prompt='Username', help='The username for the admin user') +@click.option('--password', prompt=True, hide_input=True, confirmation_prompt=True, + help='The password for the admin user') +@with_appcontext +def create_admin(name, password): + """Create an admin user with the given username and password.""" + # Check if user already exists + existing_user = db.session.execute( + db.select(User).where(User.name == name) + ).scalar_one_or_none() + + if existing_user: + click.echo(f"User '{name}' already exists.") + return + + # Create new user + user = User(name=name) + user.set_password(password) + + # Add to database + db.session.add(user) + db.session.commit() + + click.echo(f"Admin user '{name}' created successfully.") + + +def register_commands(app): + """Register CLI commands with the Flask app.""" + app.cli.add_command(create_admin) \ No newline at end of file diff --git a/blog/commands.py b/blog/commands.py new file mode 100644 index 0000000..fdeea8e --- /dev/null +++ b/blog/commands.py @@ -0,0 +1,38 @@ +"""Custom CLI commands for the blog application.""" + +import click +from flask.cli import with_appcontext +from blog.extensions import db +from blog.user.models import User + + +@click.command() +@click.option('--name', prompt='Username', help='The username for the admin user') +@click.option('--password', prompt=True, hide_input=True, confirmation_prompt=True, + help='The password for the admin user') +@with_appcontext +def create_admin(name, password): + """Create an admin user with the given username and password.""" + # Check if user already exists + existing_user = db.session.execute( + db.select(User).where(User.name == name) + ).scalar_one_or_none() + + if existing_user: + click.echo("User '{}' already exists.".format(name)) + return + + # Create new user + user = User(name=name) + user.set_password(password) + + # Add to database + db.session.add(user) + db.session.commit() + + click.echo("Admin user '{}' created successfully.".format(name)) + + +def init_app(app): + """Initialize the CLI commands with the Flask app.""" + app.cli.add_command(create_admin) \ No newline at end of file diff --git a/blog/user/models.py b/blog/user/models.py index 426d8b2..d3db2c7 100644 --- a/blog/user/models.py +++ b/blog/user/models.py @@ -28,6 +28,13 @@ class User(UserMixin, db.Model): posts: Mapped[list["Post"]] = relationship("Post", back_populates="user") + def __init__(self, **kwargs): + """Initialize a new User instance.""" + super().__init__(**kwargs) + # Set default createdon if not provided + if self.createdon is None: + self.createdon = datetime.now() + def set_password(self, password: str) -> None: """Set password hash.""" self.password = generate_password_hash(password) diff --git a/create_admin.py b/create_admin.py new file mode 100755 index 0000000..c4d6e51 --- /dev/null +++ b/create_admin.py @@ -0,0 +1,71 @@ +#!/usr/bin/env python3 +""" +CLI tool to create admin users for the blog application. +This version creates a minimal Flask app context to avoid blueprint conflicts. +""" + +import sys +import os +import getpass + +# Add the project root to the Python path +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +def create_admin_user(name, password): + """Create an admin user with minimal app context.""" + # Import only what we need + from flask import Flask + from blog.config import config + from blog.extensions import db + from blog.user.models import User + + # Create a minimal app + app = Flask(__name__) + app.config.from_object(config.get('development')) + + # Initialize only the database + db.init_app(app) + + with app.app_context(): + # Check if user already exists + existing_user = db.session.execute( + db.select(User).where(User.name == name) + ).scalar_one_or_none() + + if existing_user: + print("User '{}' already exists.".format(name)) + return False + + # Create new user + user = User(name=name) + user.set_password(password) + + # Add to database + db.session.add(user) + db.session.commit() + + print("Admin user '{}' created successfully.".format(name)) + return True + + +if __name__ == "__main__": + import argparse + + parser = argparse.ArgumentParser(description="Create an admin user for the blog.") + parser.add_argument("name", help="Username for the admin user") + parser.add_argument("--password", help="Password for the admin user (will prompt if not provided)") + + args = parser.parse_args() + + # Get password from command line or prompt + if args.password: + password = args.password + else: + password = getpass.getpass("Password: ") + confirm_password = getpass.getpass("Confirm Password: ") + if password != confirm_password: + print("Passwords do not match.") + sys.exit(1) + + success = create_admin_user(args.name, password) + sys.exit(0 if success else 1) \ No newline at end of file From 2a4fc8ff5a3ab01000cebe92d1e9b7e55c16a88e Mon Sep 17 00:00:00 2001 From: loki Date: Mon, 8 Sep 2025 15:40:09 +0300 Subject: [PATCH 12/34] fix admin basic --- blog/__init__.py | 2 +- blog/templates/admin/index.html | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/blog/__init__.py b/blog/__init__.py index 771c371..4b4ae46 100644 --- a/blog/__init__.py +++ b/blog/__init__.py @@ -24,7 +24,7 @@ def configure_extensions(app): cache.init_app(app) migrate.init_app(app=app, db=db) login_manager.init_app(app=app) - login_manager.login_view = "user.index" # type: ignore + login_manager.login_view = "user.login" # type: ignore flask_sitemap.init_app(app) diff --git a/blog/templates/admin/index.html b/blog/templates/admin/index.html index c20e04c..4ce8bb9 100644 --- a/blog/templates/admin/index.html +++ b/blog/templates/admin/index.html @@ -14,9 +14,9 @@ {% endblock %} From 7037e76cd7b5485f9cb8147dbc306a5a8dbbbe7d Mon Sep 17 00:00:00 2001 From: loki Date: Mon, 8 Sep 2025 21:50:40 +0300 Subject: [PATCH 13/34] auth --- blog/__init__.py | 5 +- blog/auth/adapter.py | 108 +++++++++++++++++++++++++++++++ blog/cli.py | 22 ++++--- blog/commands.py | 21 +++--- blog/repos/user.py | 18 ------ blog/services/user.py | 8 --- blog/user/views.py | 28 +++----- create_admin.py | 31 +++++---- tests/test_auth.py | 144 ++++++++++++++++++++++++++++++++++++++++++ 9 files changed, 306 insertions(+), 79 deletions(-) create mode 100644 blog/auth/adapter.py create mode 100644 tests/test_auth.py diff --git a/blog/__init__.py b/blog/__init__.py index 4b4ae46..cc7d845 100644 --- a/blog/__init__.py +++ b/blog/__init__.py @@ -31,6 +31,7 @@ def configure_extensions(app): def register_commands(app): """Register custom CLI commands.""" from blog.commands import init_app + init_app(app) @@ -45,8 +46,8 @@ def create_app(): app.register_blueprint(post) app.register_blueprint(tags) app.register_blueprint(user) - + # Register CLI commands register_commands(app) - + return app diff --git a/blog/auth/adapter.py b/blog/auth/adapter.py new file mode 100644 index 0000000..f199fb6 --- /dev/null +++ b/blog/auth/adapter.py @@ -0,0 +1,108 @@ +"""Authentication adapter for Flask-Login integration.""" + +import sqlalchemy as sa +from flask_login import UserMixin + +from blog.domain.user import User as UserDomain +from blog.extensions import db, login_manager +from blog.services.factory import ServiceFactory +from blog.user.models import User as UserORM + + +class FlaskLoginUser(UserORM, UserMixin): + """Extended User ORM model that includes Flask-Login's UserMixin. + + This class is used specifically for Flask-Login compatibility. + It inherits from both the User ORM model and Flask-Login's UserMixin. + """ + pass + + +class AuthenticationAdapter: + """Adapter for handling authentication with Flask-Login. + + This adapter handles the conversion between domain models and ORM models + specifically for Flask-Login integration, keeping the service layer clean. + """ + + def __init__(self): + self.user_service = ServiceFactory.create_user_service() + + def load_user(self, user_id: int) -> FlaskLoginUser | None: + """Load user by ID for Flask-Login. + + Args: + user_id: The ID of the user to load + + Returns: + FlaskLoginUser instance if user exists, None otherwise + """ + # First check if user exists using the service layer (domain model) + user_domain = self.user_service.get_user_by_id(user_id) + if not user_domain: + return None + + # Get the ORM model for Flask-Login compatibility + stmt = sa.select(UserORM).where(UserORM.id == user_id) + user_orm = db.session.scalar(stmt) + if not user_orm: + return None + + # Return a FlaskLoginUser instance + return self._to_flask_login_user(user_orm) + + def authenticate_and_login(self, name: str, password: str) -> FlaskLoginUser | None: + """Authenticate user and return FlaskLoginUser instance for login. + + Args: + name: The username + password: The password + + Returns: + FlaskLoginUser instance if authentication successful, None otherwise + """ + # Authenticate using the service layer (domain model) + user_domain = self.user_service.authenticate_user(name, password) + if not user_domain: + return None + + # Get the ORM model for Flask-Login compatibility + stmt = sa.select(UserORM).where(UserORM.name == name) + user_orm = db.session.scalar(stmt) + if not user_orm: + return None + + # Return a FlaskLoginUser instance + return self._to_flask_login_user(user_orm) + + def _to_flask_login_user(self, user_orm: UserORM) -> FlaskLoginUser: + """Convert User ORM model to FlaskLoginUser. + + Args: + user_orm: The User ORM model + + Returns: + FlaskLoginUser instance + """ + # Create a FlaskLoginUser instance with the same attributes + flask_login_user = FlaskLoginUser() + flask_login_user.id = user_orm.id + flask_login_user.name = user_orm.name + flask_login_user.password = user_orm.password + flask_login_user.authenticated = user_orm.authenticated + flask_login_user.createdon = user_orm.createdon + return flask_login_user + + +# Initialize the authentication adapter +auth_adapter = AuthenticationAdapter() + + +@login_manager.user_loader +def load_user(user_id): + """Load user by ID for Flask-Login. + + This function uses the authentication adapter to handle the Flask-Login + integration while keeping the service layer clean. + """ + return auth_adapter.load_user(int(user_id)) \ No newline at end of file diff --git a/blog/cli.py b/blog/cli.py index da6c685..47415b4 100644 --- a/blog/cli.py +++ b/blog/cli.py @@ -2,15 +2,19 @@ import click from flask.cli import with_appcontext -from blog import create_app from blog.extensions import db from blog.user.models import User @click.command() -@click.option('--name', prompt='Username', help='The username for the admin user') -@click.option('--password', prompt=True, hide_input=True, confirmation_prompt=True, - help='The password for the admin user') +@click.option("--name", prompt="Username", help="The username for the admin user") +@click.option( + "--password", + prompt=True, + hide_input=True, + confirmation_prompt=True, + help="The password for the admin user", +) @with_appcontext def create_admin(name, password): """Create an admin user with the given username and password.""" @@ -18,22 +22,22 @@ def create_admin(name, password): existing_user = db.session.execute( db.select(User).where(User.name == name) ).scalar_one_or_none() - + if existing_user: click.echo(f"User '{name}' already exists.") return - + # Create new user user = User(name=name) user.set_password(password) - + # Add to database db.session.add(user) db.session.commit() - + click.echo(f"Admin user '{name}' created successfully.") def register_commands(app): """Register CLI commands with the Flask app.""" - app.cli.add_command(create_admin) \ No newline at end of file + app.cli.add_command(create_admin) diff --git a/blog/commands.py b/blog/commands.py index fdeea8e..9b55a5d 100644 --- a/blog/commands.py +++ b/blog/commands.py @@ -7,9 +7,14 @@ @click.command() -@click.option('--name', prompt='Username', help='The username for the admin user') -@click.option('--password', prompt=True, hide_input=True, confirmation_prompt=True, - help='The password for the admin user') +@click.option("--name", prompt="Username", help="The username for the admin user") +@click.option( + "--password", + prompt=True, + hide_input=True, + confirmation_prompt=True, + help="The password for the admin user", +) @with_appcontext def create_admin(name, password): """Create an admin user with the given username and password.""" @@ -17,22 +22,22 @@ def create_admin(name, password): existing_user = db.session.execute( db.select(User).where(User.name == name) ).scalar_one_or_none() - + if existing_user: click.echo("User '{}' already exists.".format(name)) return - + # Create new user user = User(name=name) user.set_password(password) - + # Add to database db.session.add(user) db.session.commit() - + click.echo("Admin user '{}' created successfully.".format(name)) def init_app(app): """Initialize the CLI commands with the Flask app.""" - app.cli.add_command(create_admin) \ No newline at end of file + app.cli.add_command(create_admin) diff --git a/blog/repos/user.py b/blog/repos/user.py index c0b0947..aa32307 100644 --- a/blog/repos/user.py +++ b/blog/repos/user.py @@ -15,24 +15,6 @@ class UserRepository: def __init__(self, session: Any = None): self.session = session or db.session - def get_user_orm_with_relationships(self, user_id: int) -> UserORM | None: - stmt = ( - sa.select(UserORM) - .where(UserORM.id == user_id) - .options(sa.orm.joinedload(UserORM.posts)) - ) - return self.session.scalar(stmt) - - def get_user_orm_by_id(self, user_id: int) -> UserORM | None: - """Get a user ORM model by its ID. Used for Flask-Login compatibility.""" - stmt = sa.select(UserORM).where(UserORM.id == user_id) - return self.session.scalar(stmt) - - def get_user_orm_by_name(self, name: str) -> UserORM | None: - """Get a user ORM model by their name. Used for Flask-Login compatibility.""" - stmt = sa.select(UserORM).where(UserORM.name == name) - return self.session.scalar(stmt) - def get_by_id(self, user_id: int) -> UserDomain | None: stmt = sa.select(UserORM).where(UserORM.id == user_id) user_orm = self.session.scalar(stmt) diff --git a/blog/services/user.py b/blog/services/user.py index 0c127e6..d0f55b7 100644 --- a/blog/services/user.py +++ b/blog/services/user.py @@ -19,14 +19,6 @@ def __init__(self, user_repository: UserRepository): def get_user_by_id(self, user_id: int) -> User | None: return self.user_repository.get_by_id(user_id) - def get_user_orm_by_id(self, user_id: int): - """Get a user ORM model by its ID. Used for Flask-Login compatibility.""" - return self.user_repository.get_user_orm_by_id(user_id) - - def get_user_orm_by_name(self, name: str): - """Get a user ORM model by their name. Used for Flask-Login compatibility.""" - return self.user_repository.get_user_orm_by_name(name) - def get_user_by_name(self, name: str) -> User | None: return self.user_repository.get_by_name(name) diff --git a/blog/user/views.py b/blog/user/views.py index ed3044e..8b05f1f 100644 --- a/blog/user/views.py +++ b/blog/user/views.py @@ -2,9 +2,9 @@ from flask import Blueprint, flash, redirect, render_template, url_for from flask_login import current_user, login_user +from blog.auth.adapter import auth_adapter from blog.extensions import login_manager from blog.user.forms import LoginForm -from blog.services.factory import ServiceFactory user = Blueprint("user", __name__) @@ -13,14 +13,8 @@ @login_manager.user_loader def load_user(user_id): """Load user by ID for Flask-Login.""" - # For Flask-Login compatibility, we need to return an ORM model - user_service = ServiceFactory.create_user_service() - user = user_service.get_user_by_id(int(user_id)) - if user: - # This is one of the few places where we directly access the service layer - # to get an ORM model because Flask-Login requires an ORM model - return user_service.get_user_orm_by_id(int(user_id)) - return None + # Use the authentication adapter for Flask-Login integration + return auth_adapter.load_user(int(user_id)) @user.route("/login", methods=["GET", "POST"]) @@ -33,17 +27,11 @@ def login(): name = form.name.data password = form.password.data if name is not None and password is not None: - user_service = ServiceFactory.create_user_service() - user = user_service.authenticate_user(name, password) - - if user: - # Get the ORM model directly from service layer for Flask-Login - # This is one of the few places where we directly access the service layer - # to get an ORM model because Flask-Login requires an ORM model - user_orm = user_service.get_user_orm_by_name(name) - if user_orm: - login_user(user_orm) - return redirect(url_for("admin.index")) + # Use the authentication adapter for Flask-Login integration + user_orm = auth_adapter.authenticate_and_login(name, password) + if user_orm: + login_user(user_orm) + return redirect(url_for("admin.index")) flash("invalid user o password") return redirect(url_for("user.login")) return render_template("login.html", form=form) diff --git a/create_admin.py b/create_admin.py index c4d6e51..f2d1d12 100755 --- a/create_admin.py +++ b/create_admin.py @@ -11,6 +11,7 @@ # Add the project root to the Python path sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + def create_admin_user(name, password): """Create an admin user with minimal app context.""" # Import only what we need @@ -18,45 +19,47 @@ def create_admin_user(name, password): from blog.config import config from blog.extensions import db from blog.user.models import User - + # Create a minimal app app = Flask(__name__) - app.config.from_object(config.get('development')) - + app.config.from_object(config.get("development")) + # Initialize only the database db.init_app(app) - + with app.app_context(): # Check if user already exists existing_user = db.session.execute( db.select(User).where(User.name == name) ).scalar_one_or_none() - + if existing_user: print("User '{}' already exists.".format(name)) return False - + # Create new user user = User(name=name) user.set_password(password) - + # Add to database db.session.add(user) db.session.commit() - + print("Admin user '{}' created successfully.".format(name)) return True if __name__ == "__main__": import argparse - + parser = argparse.ArgumentParser(description="Create an admin user for the blog.") parser.add_argument("name", help="Username for the admin user") - parser.add_argument("--password", help="Password for the admin user (will prompt if not provided)") - + parser.add_argument( + "--password", help="Password for the admin user (will prompt if not provided)" + ) + args = parser.parse_args() - + # Get password from command line or prompt if args.password: password = args.password @@ -66,6 +69,6 @@ def create_admin_user(name, password): if password != confirm_password: print("Passwords do not match.") sys.exit(1) - + success = create_admin_user(args.name, password) - sys.exit(0 if success else 1) \ No newline at end of file + sys.exit(0 if success else 1) diff --git a/tests/test_auth.py b/tests/test_auth.py new file mode 100644 index 0000000..89c7f50 --- /dev/null +++ b/tests/test_auth.py @@ -0,0 +1,144 @@ +"""Unit tests for the authentication adapter.""" + +import pytest +import os +import datetime + +from blog import create_app +from blog.extensions import db +from blog.domain.user import User as UserDomain +from blog.auth.adapter import AuthenticationAdapter +from blog.user.models import User as UserORM +from blog.services.factory import ServiceFactory + + +@pytest.fixture() +def app(): + os.environ["FLASK_ENV"] = "testing" + app = create_app() + with app.app_context(): + db.create_all() + yield app + db.session.remove() + db.drop_all() + + +@pytest.fixture() +def auth_adapter(app): + """Create an AuthenticationAdapter instance for testing.""" + return AuthenticationAdapter() + + +@pytest.fixture() +def user_service(app): + """Create a UserService instance for testing.""" + return ServiceFactory.create_user_service() + + +class TestAuthenticationAdapter: + """Test cases for AuthenticationAdapter.""" + + def test_load_user_existing_user(self, app, auth_adapter, user_service): + """Test loading an existing user by ID.""" + with app.app_context(): + # Create a user first + user_domain = UserDomain(name="testuser", password="testpassword") + created_user = user_service.create_user(user_domain) + + # Set the password properly + user_orm = db.session.get(UserORM, created_user.id) + if user_orm: + user_orm.set_password("testpassword") + db.session.commit() + + # Load the user using the adapter + loaded_user = auth_adapter.load_user(created_user.id) + + # Verify the user was loaded correctly + assert loaded_user is not None + assert loaded_user.id == created_user.id + assert loaded_user.name == "testuser" + + def test_load_user_nonexistent_user(self, app, auth_adapter): + """Test loading a nonexistent user by ID.""" + with app.app_context(): + # Try to load a user that doesn't exist + loaded_user = auth_adapter.load_user(99999) + + # Verify None is returned + assert loaded_user is None + + def test_authenticate_and_login_valid_credentials(self, app, auth_adapter, user_service): + """Test authenticating with valid credentials.""" + with app.app_context(): + # Create a user first + user_domain = UserDomain(name="testuser", password="testpassword") + created_user = user_service.create_user(user_domain) + + # Set the password properly + user_orm = db.session.get(UserORM, created_user.id) + if user_orm: + user_orm.set_password("testpassword") + db.session.commit() + + # Authenticate the user using the adapter + authenticated_user = auth_adapter.authenticate_and_login("testuser", "testpassword") + + # Verify the user was authenticated correctly + assert authenticated_user is not None + assert authenticated_user.id == created_user.id + assert authenticated_user.name == "testuser" + + def test_authenticate_and_login_invalid_credentials(self, app, auth_adapter, user_service): + """Test authenticating with invalid credentials.""" + with app.app_context(): + # Create a user first + user_domain = UserDomain(name="testuser", password="testpassword") + created_user = user_service.create_user(user_domain) + + # Set the password properly + user_orm = db.session.get(UserORM, created_user.id) + if user_orm: + user_orm.set_password("testpassword") + db.session.commit() + + # Try to authenticate with wrong password + authenticated_user = auth_adapter.authenticate_and_login("testuser", "wrongpassword") + + # Verify None is returned + assert authenticated_user is None + + def test_authenticate_and_login_nonexistent_user(self, app, auth_adapter): + """Test authenticating a nonexistent user.""" + with app.app_context(): + # Try to authenticate a user that doesn't exist + authenticated_user = auth_adapter.authenticate_and_login("nonexistent", "password") + + # Verify None is returned + assert authenticated_user is None + + def test_to_flask_login_user_conversion(self, app, auth_adapter, user_service): + """Test converting User ORM model to FlaskLoginUser.""" + with app.app_context(): + # Create a user first + user_domain = UserDomain(name="testuser", password="testpassword") + created_user = user_service.create_user(user_domain) + + # Set the password properly + user_orm = db.session.get(UserORM, created_user.id) + if user_orm: + user_orm.set_password("testpassword") + db.session.commit() + + # Get the ORM model directly from the database + user_orm = db.session.get(UserORM, created_user.id) + assert user_orm is not None + + # Convert to FlaskLoginUser + flask_login_user = auth_adapter._to_flask_login_user(user_orm) + + # Verify the conversion was correct + assert flask_login_user.id == user_orm.id + assert flask_login_user.name == user_orm.name + assert flask_login_user.password == user_orm.password + assert flask_login_user.authenticated == user_orm.authenticated \ No newline at end of file From fb426bcdfe8f12139499a5791d3f07f2807f41ee Mon Sep 17 00:00:00 2001 From: loki Date: Mon, 8 Sep 2025 22:03:53 +0300 Subject: [PATCH 14/34] more abstract to gods of abstracts --- blog/auth/adapter.py | 30 ++++++++-------- blog/repos/base.py | 36 ++++++++++++++++++++ blog/repos/category.py | 45 ++++++++++++------------ blog/repos/icon.py | 45 ++++++++++++------------ blog/repos/post.py | 77 +++++++++++++++++++++--------------------- blog/repos/tag.py | 37 ++++++++++---------- blog/repos/user.py | 49 ++++++++++++++------------- 7 files changed, 180 insertions(+), 139 deletions(-) create mode 100644 blog/repos/base.py diff --git a/blog/auth/adapter.py b/blog/auth/adapter.py index f199fb6..27a2fef 100644 --- a/blog/auth/adapter.py +++ b/blog/auth/adapter.py @@ -3,7 +3,6 @@ import sqlalchemy as sa from flask_login import UserMixin -from blog.domain.user import User as UserDomain from blog.extensions import db, login_manager from blog.services.factory import ServiceFactory from blog.user.models import User as UserORM @@ -11,16 +10,17 @@ class FlaskLoginUser(UserORM, UserMixin): """Extended User ORM model that includes Flask-Login's UserMixin. - + This class is used specifically for Flask-Login compatibility. It inherits from both the User ORM model and Flask-Login's UserMixin. """ + pass class AuthenticationAdapter: """Adapter for handling authentication with Flask-Login. - + This adapter handles the conversion between domain models and ORM models specifically for Flask-Login integration, keeping the service layer clean. """ @@ -30,10 +30,10 @@ def __init__(self): def load_user(self, user_id: int) -> FlaskLoginUser | None: """Load user by ID for Flask-Login. - + Args: user_id: The ID of the user to load - + Returns: FlaskLoginUser instance if user exists, None otherwise """ @@ -41,23 +41,23 @@ def load_user(self, user_id: int) -> FlaskLoginUser | None: user_domain = self.user_service.get_user_by_id(user_id) if not user_domain: return None - + # Get the ORM model for Flask-Login compatibility stmt = sa.select(UserORM).where(UserORM.id == user_id) user_orm = db.session.scalar(stmt) if not user_orm: return None - + # Return a FlaskLoginUser instance return self._to_flask_login_user(user_orm) def authenticate_and_login(self, name: str, password: str) -> FlaskLoginUser | None: """Authenticate user and return FlaskLoginUser instance for login. - + Args: name: The username password: The password - + Returns: FlaskLoginUser instance if authentication successful, None otherwise """ @@ -65,22 +65,22 @@ def authenticate_and_login(self, name: str, password: str) -> FlaskLoginUser | N user_domain = self.user_service.authenticate_user(name, password) if not user_domain: return None - + # Get the ORM model for Flask-Login compatibility stmt = sa.select(UserORM).where(UserORM.name == name) user_orm = db.session.scalar(stmt) if not user_orm: return None - + # Return a FlaskLoginUser instance return self._to_flask_login_user(user_orm) def _to_flask_login_user(self, user_orm: UserORM) -> FlaskLoginUser: """Convert User ORM model to FlaskLoginUser. - + Args: user_orm: The User ORM model - + Returns: FlaskLoginUser instance """ @@ -101,8 +101,8 @@ def _to_flask_login_user(self, user_orm: UserORM) -> FlaskLoginUser: @login_manager.user_loader def load_user(user_id): """Load user by ID for Flask-Login. - + This function uses the authentication adapter to handle the Flask-Login integration while keeping the service layer clean. """ - return auth_adapter.load_user(int(user_id)) \ No newline at end of file + return auth_adapter.load_user(int(user_id)) diff --git a/blog/repos/base.py b/blog/repos/base.py new file mode 100644 index 0000000..f4b2461 --- /dev/null +++ b/blog/repos/base.py @@ -0,0 +1,36 @@ +"""Base repository interface for the application.""" + +from abc import ABC, abstractmethod +from typing import TypeVar, Generic, List, Optional + +T = TypeVar("T") # Domain model type +ID = TypeVar("ID") # ID type + + +class BaseRepository(ABC, Generic[T, ID]): + """Abstract base class for all repositories.""" + + @abstractmethod + def get_by_id(self, id: ID) -> Optional[T]: + """Get an entity by its ID.""" + pass + + @abstractmethod + def get_all(self) -> List[T]: + """Get all entities.""" + pass + + @abstractmethod + def create(self, entity: T) -> T: + """Create a new entity.""" + pass + + @abstractmethod + def update(self, entity: T) -> T: + """Update an existing entity.""" + pass + + @abstractmethod + def delete(self, id: ID) -> bool: + """Delete an entity by its ID.""" + pass diff --git a/blog/repos/category.py b/blog/repos/category.py index dbbbfbf..2ed62e6 100644 --- a/blog/repos/category.py +++ b/blog/repos/category.py @@ -1,15 +1,16 @@ """Repository for Category entities.""" import sqlalchemy as sa -from typing import Any +from typing import Any, List, Optional from blog.extensions import db from blog.category.models import Category as CategoryORM from blog.domain.category import Category as CategoryDomain from blog.domain.post import Post as PostDomain +from blog.repos.base import BaseRepository -class CategoryRepository: +class CategoryRepository(BaseRepository[CategoryDomain, int]): """Repository for Category entities.""" def __init__(self, session: Any = None): @@ -25,8 +26,8 @@ def get_category_orm_with_relationships( ) return self.session.scalar(stmt) - def get_by_id(self, category_id: int) -> CategoryDomain | None: - stmt = sa.select(CategoryORM).where(CategoryORM.id == category_id) + def get_by_id(self, id: int) -> Optional[CategoryDomain]: + stmt = sa.select(CategoryORM).where(CategoryORM.id == id) category_orm = self.session.scalar(stmt) if category_orm: return self._to_domain_model(category_orm) @@ -39,7 +40,7 @@ def get_by_alias(self, alias: str) -> CategoryDomain | None: return self._to_domain_model(category_orm) return None - def get_all(self) -> list[CategoryDomain]: + def get_all(self) -> List[CategoryDomain]: stmt = sa.select(CategoryORM) categories_orm = list(self.session.scalars(stmt).all()) return [self._to_domain_model(category_orm) for category_orm in categories_orm] @@ -49,34 +50,34 @@ def get_categories_with_posts(self) -> list[CategoryDomain]: categories_orm = list(self.session.scalars(stmt).unique().all()) return [self._to_domain_model(category_orm) for category_orm in categories_orm] - def create(self, category: CategoryDomain) -> CategoryDomain: + def create(self, entity: CategoryDomain) -> CategoryDomain: category_orm = CategoryORM() - category_orm.title = category.title - category_orm.alias = category.alias + category_orm.title = entity.title + category_orm.alias = entity.alias # Handle the case where template might be None - if category.template is not None: - category_orm.template = category.template + if entity.template is not None: + category_orm.template = entity.template self.session.add(category_orm) self.session.flush() # Get the ID without committing - category.id = category_orm.id - return category + entity.id = category_orm.id + return entity - def update(self, category: CategoryDomain) -> CategoryDomain: - stmt = sa.select(CategoryORM).where(CategoryORM.id == category.id) + def update(self, entity: CategoryDomain) -> CategoryDomain: + stmt = sa.select(CategoryORM).where(CategoryORM.id == entity.id) category_orm = self.session.scalar(stmt) if not category_orm: - raise ValueError(f"Category with id {category.id} not found") + raise ValueError(f"Category with id {entity.id} not found") - category_orm.title = category.title - category_orm.alias = category.alias + category_orm.title = entity.title + category_orm.alias = entity.alias # Handle the case where template might be None - if category.template is not None: - category_orm.template = category.template + if entity.template is not None: + category_orm.template = entity.template self.session.flush() - return category + return entity - def delete(self, category_id: int) -> bool: - stmt = sa.select(CategoryORM).where(CategoryORM.id == category_id) + def delete(self, id: int) -> bool: + stmt = sa.select(CategoryORM).where(CategoryORM.id == id) category_orm = self.session.scalar(stmt) if category_orm: self.session.delete(category_orm) diff --git a/blog/repos/icon.py b/blog/repos/icon.py index 138eebc..ebe85fa 100644 --- a/blog/repos/icon.py +++ b/blog/repos/icon.py @@ -1,14 +1,15 @@ """Repository for Icon entities.""" import sqlalchemy as sa -from typing import Any +from typing import Any, List, Optional from blog.extensions import db from blog.post.models import Icon as IconORM from blog.domain.icon import Icon +from blog.repos.base import BaseRepository -class IconRepository: +class IconRepository(BaseRepository[Icon, int]): """Repository for Icon entities.""" def __init__(self, session: Any = None): @@ -24,8 +25,8 @@ def get_all_icons_orm(self) -> list[IconORM]: stmt = sa.select(IconORM) return list(self.session.scalars(stmt).all()) - def get_by_id(self, icon_id: int) -> Icon | None: - stmt = sa.select(IconORM).where(IconORM.id == icon_id) + def get_by_id(self, id: int) -> Optional[Icon]: + stmt = sa.select(IconORM).where(IconORM.id == id) icon_orm = self.session.scalar(stmt) if icon_orm: return self._to_domain_model(icon_orm) @@ -38,37 +39,37 @@ def get_by_title(self, title: str) -> Icon | None: return self._to_domain_model(icon_orm) return None - def get_all(self) -> list[Icon]: + def get_all(self) -> List[Icon]: stmt = sa.select(IconORM) icons_orm = list(self.session.scalars(stmt).all()) return [self._to_domain_model(icon_orm) for icon_orm in icons_orm] - def create(self, icon: Icon) -> Icon: + def create(self, entity: Icon) -> Icon: icon_orm = IconORM() - icon_orm.title = icon.title - icon_orm.url = icon.url - if icon.content is not None: - icon_orm.content = icon.content + icon_orm.title = entity.title + icon_orm.url = entity.url + if entity.content is not None: + icon_orm.content = entity.content self.session.add(icon_orm) self.session.flush() # Get the ID without committing - icon.id = icon_orm.id - return icon + entity.id = icon_orm.id + return entity - def update(self, icon: Icon) -> Icon: - stmt = sa.select(IconORM).where(IconORM.id == icon.id) + def update(self, entity: Icon) -> Icon: + stmt = sa.select(IconORM).where(IconORM.id == entity.id) icon_orm = self.session.scalar(stmt) if not icon_orm: - raise ValueError(f"Icon with id {icon.id} not found") + raise ValueError(f"Icon with id {entity.id} not found") - icon_orm.title = icon.title - icon_orm.url = icon.url - if icon.content is not None: - icon_orm.content = icon.content + icon_orm.title = entity.title + icon_orm.url = entity.url + if entity.content is not None: + icon_orm.content = entity.content self.session.flush() - return icon + return entity - def delete(self, icon_id: int) -> bool: - stmt = sa.select(IconORM).where(IconORM.id == icon_id) + def delete(self, id: int) -> bool: + stmt = sa.select(IconORM).where(IconORM.id == id) icon_orm = self.session.scalar(stmt) if icon_orm: self.session.delete(icon_orm) diff --git a/blog/repos/post.py b/blog/repos/post.py index 384274a..9f86151 100644 --- a/blog/repos/post.py +++ b/blog/repos/post.py @@ -1,7 +1,7 @@ """Repository for Post entities.""" import sqlalchemy as sa -from typing import Any +from typing import Any, List, Optional from blog.extensions import db from blog.post.models import Post as PostORM @@ -10,9 +10,10 @@ from blog.domain.user import User as UserDomain from blog.domain.category import Category as CategoryDomain from blog.domain.tag import Tag as TagDomain +from blog.repos.base import BaseRepository -class PostRepository: +class PostRepository(BaseRepository[PostDomain, int]): """Repository for Post entities.""" def __init__(self, session: Any = None): @@ -33,8 +34,8 @@ def get_post_with_relationships(self, post_id: int) -> PostDomain | None: return self._to_domain_model(post_orm) return None - def get_by_id(self, post_id: int) -> PostDomain | None: - stmt = sa.select(PostORM).where(PostORM.id == post_id) + def get_by_id(self, id: int) -> Optional[PostDomain]: + stmt = sa.select(PostORM).where(PostORM.id == id) post_orm = self.session.scalar(stmt) if post_orm: return self._to_domain_model(post_orm) @@ -47,7 +48,7 @@ def get_by_alias(self, alias: str) -> PostDomain | None: return self._to_domain_model(post_orm) return None - def get_all(self) -> list[PostDomain]: + def get_all(self) -> List[PostDomain]: stmt = sa.select(PostORM) posts_orm = list(self.session.scalars(stmt).all()) return [self._to_domain_model(post_orm) for post_orm in posts_orm] @@ -69,25 +70,25 @@ def get_page_posts(self, page_category_ids: list[int]) -> list[PostDomain]: posts_orm = list(self.session.scalars(stmt).all()) return [self._to_domain_model(post_orm) for post_orm in posts_orm] - def create(self, post: PostDomain) -> PostDomain: + def create(self, entity: PostDomain) -> PostDomain: post_orm = PostORM() - post_orm.pagetitle = post.pagetitle - post_orm.alias = post.alias - post_orm.content = post.content + post_orm.pagetitle = entity.pagetitle + post_orm.alias = entity.alias + post_orm.content = entity.content # Handle datetime fields that might be None - if post.createdon is not None: - post_orm.createdon = post.createdon - if post.publishedon is not None: - post_orm.publishedon = post.publishedon - if post.category_id is not None: - post_orm.category_id = post.category_id - if post.user_id is not None: - post_orm.user_id = post.user_id + if entity.createdon is not None: + post_orm.createdon = entity.createdon + if entity.publishedon is not None: + post_orm.publishedon = entity.publishedon + if entity.category_id is not None: + post_orm.category_id = entity.category_id + if entity.user_id is not None: + post_orm.user_id = entity.user_id # Handle tags relationship if provided - if post.tags: + if entity.tags: # Find existing Tag ORM models based on the domain Tag models - tag_ids = [tag.id for tag in post.tags if tag.id is not None] + tag_ids = [tag.id for tag in entity.tags if tag.id is not None] if tag_ids: stmt = sa.select(TagORM).where(TagORM.id.in_(tag_ids)) existing_tags = list(self.session.scalars(stmt).all()) @@ -95,32 +96,32 @@ def create(self, post: PostDomain) -> PostDomain: self.session.add(post_orm) self.session.flush() # Get the ID without committing - post.id = post_orm.id - return post + entity.id = post_orm.id + return entity - def update(self, post: PostDomain) -> PostDomain: - stmt = sa.select(PostORM).where(PostORM.id == post.id) + def update(self, entity: PostDomain) -> PostDomain: + stmt = sa.select(PostORM).where(PostORM.id == entity.id) post_orm = self.session.scalar(stmt) if not post_orm: - raise ValueError(f"Post with id {post.id} not found") + raise ValueError(f"Post with id {entity.id} not found") - post_orm.pagetitle = post.pagetitle - post_orm.alias = post.alias - post_orm.content = post.content + post_orm.pagetitle = entity.pagetitle + post_orm.alias = entity.alias + post_orm.content = entity.content # Handle datetime fields that might be None - if post.createdon is not None: - post_orm.createdon = post.createdon - if post.publishedon is not None: - post_orm.publishedon = post.publishedon - if post.category_id is not None: - post_orm.category_id = post.category_id - if post.user_id is not None: - post_orm.user_id = post.user_id + if entity.createdon is not None: + post_orm.createdon = entity.createdon + if entity.publishedon is not None: + post_orm.publishedon = entity.publishedon + if entity.category_id is not None: + post_orm.category_id = entity.category_id + if entity.user_id is not None: + post_orm.user_id = entity.user_id self.session.flush() - return post + return entity - def delete(self, post_id: int) -> bool: - stmt = sa.select(PostORM).where(PostORM.id == post_id) + def delete(self, id: int) -> bool: + stmt = sa.select(PostORM).where(PostORM.id == id) post_orm = self.session.scalar(stmt) if post_orm: self.session.delete(post_orm) diff --git a/blog/repos/tag.py b/blog/repos/tag.py index bf32272..94bbc2d 100644 --- a/blog/repos/tag.py +++ b/blog/repos/tag.py @@ -1,15 +1,16 @@ """Repository for Tag entities.""" import sqlalchemy as sa -from typing import Any +from typing import Any, List, Optional from blog.extensions import db from blog.tags.models import Tag as TagORM from blog.domain.tag import Tag as TagDomain from blog.domain.post import Post as PostDomain +from blog.repos.base import BaseRepository -class TagRepository: +class TagRepository(BaseRepository[TagDomain, int]): """Repository for Tag entities.""" def __init__(self, session: Any = None): @@ -23,8 +24,8 @@ def get_tag_orm_with_relationships(self, tag_id: int) -> TagORM | None: ) return self.session.scalar(stmt) - def get_by_id(self, tag_id: int) -> TagDomain | None: - stmt = sa.select(TagORM).where(TagORM.id == tag_id) + def get_by_id(self, id: int) -> Optional[TagDomain]: + stmt = sa.select(TagORM).where(TagORM.id == id) tag_orm = self.session.scalar(stmt) if tag_orm: return self._to_domain_model(tag_orm) @@ -37,7 +38,7 @@ def get_by_alias(self, alias: str) -> TagDomain | None: return self._to_domain_model(tag_orm) return None - def get_all(self) -> list[TagDomain]: + def get_all(self) -> List[TagDomain]: stmt = sa.select(TagORM) tags_orm = list(self.session.scalars(stmt).all()) return [self._to_domain_model(tag_orm) for tag_orm in tags_orm] @@ -47,28 +48,28 @@ def get_tags_with_posts(self) -> list[TagDomain]: tags_orm = list(self.session.scalars(stmt).unique().all()) return [self._to_domain_model(tag_orm) for tag_orm in tags_orm] - def create(self, tag: TagDomain) -> TagDomain: + def create(self, entity: TagDomain) -> TagDomain: tag_orm = TagORM() - tag_orm.title = tag.title - tag_orm.alias = tag.alias + tag_orm.title = entity.title + tag_orm.alias = entity.alias self.session.add(tag_orm) self.session.flush() # Get the ID without committing - tag.id = tag_orm.id - return tag + entity.id = tag_orm.id + return entity - def update(self, tag: TagDomain) -> TagDomain: - stmt = sa.select(TagORM).where(TagORM.id == tag.id) + def update(self, entity: TagDomain) -> TagDomain: + stmt = sa.select(TagORM).where(TagORM.id == entity.id) tag_orm = self.session.scalar(stmt) if not tag_orm: - raise ValueError(f"Tag with id {tag.id} not found") + raise ValueError(f"Tag with id {entity.id} not found") - tag_orm.title = tag.title - tag_orm.alias = tag.alias + tag_orm.title = entity.title + tag_orm.alias = entity.alias self.session.flush() - return tag + return entity - def delete(self, tag_id: int) -> bool: - stmt = sa.select(TagORM).where(TagORM.id == tag_id) + def delete(self, id: int) -> bool: + stmt = sa.select(TagORM).where(TagORM.id == id) tag_orm = self.session.scalar(stmt) if tag_orm: self.session.delete(tag_orm) diff --git a/blog/repos/user.py b/blog/repos/user.py index aa32307..34290e0 100644 --- a/blog/repos/user.py +++ b/blog/repos/user.py @@ -1,22 +1,23 @@ """Repository for User entities.""" import sqlalchemy as sa -from typing import Any +from typing import Any, List, Optional from blog.extensions import db from blog.user.models import User as UserORM from blog.domain.user import User as UserDomain from blog.domain.post import Post as PostDomain +from blog.repos.base import BaseRepository -class UserRepository: +class UserRepository(BaseRepository[UserDomain, int]): """Repository for User entities.""" def __init__(self, session: Any = None): self.session = session or db.session - def get_by_id(self, user_id: int) -> UserDomain | None: - stmt = sa.select(UserORM).where(UserORM.id == user_id) + def get_by_id(self, id: int) -> Optional[UserDomain]: + stmt = sa.select(UserORM).where(UserORM.id == id) user_orm = self.session.scalar(stmt) if user_orm: return self._to_domain_model(user_orm) @@ -29,7 +30,7 @@ def get_by_name(self, name: str) -> UserDomain | None: return self._to_domain_model(user_orm) return None - def get_all(self) -> list[UserDomain]: + def get_all(self) -> List[UserDomain]: stmt = sa.select(UserORM) users_orm = self.session.scalars(stmt).all() return [self._to_domain_model(user_orm) for user_orm in users_orm] @@ -39,42 +40,42 @@ def get_users_with_posts(self) -> list[UserDomain]: users_orm = self.session.scalars(stmt).unique().all() return [self._to_domain_model(user_orm) for user_orm in users_orm] - def create(self, user: UserDomain) -> UserDomain: + def create(self, entity: UserDomain) -> UserDomain: user_orm = UserORM() - user_orm.name = user.name - user_orm.password = user.password + user_orm.name = entity.name + user_orm.password = entity.password # Handle the case where authenticated might be None user_orm.authenticated = ( - user.authenticated if user.authenticated is not None else False + entity.authenticated if entity.authenticated is not None else False ) # Handle datetime field that might be None - if user.createdon is not None: - user_orm.createdon = user.createdon + if entity.createdon is not None: + user_orm.createdon = entity.createdon self.session.add(user_orm) self.session.flush() # Get the ID without committing - user.id = user_orm.id - return user + entity.id = user_orm.id + return entity - def update(self, user: UserDomain) -> UserDomain: - stmt = sa.select(UserORM).where(UserORM.id == user.id) + def update(self, entity: UserDomain) -> UserDomain: + stmt = sa.select(UserORM).where(UserORM.id == entity.id) user_orm = self.session.scalar(stmt) if not user_orm: - raise ValueError(f"User with id {user.id} not found") + raise ValueError(f"User with id {entity.id} not found") - user_orm.name = user.name - user_orm.password = user.password + user_orm.name = entity.name + user_orm.password = entity.password # Handle the case where authenticated might be None user_orm.authenticated = ( - user.authenticated if user.authenticated is not None else False + entity.authenticated if entity.authenticated is not None else False ) # Handle datetime field that might be None - if user.createdon is not None: - user_orm.createdon = user.createdon + if entity.createdon is not None: + user_orm.createdon = entity.createdon self.session.flush() - return user + return entity - def delete(self, user_id: int) -> bool: - stmt = sa.select(UserORM).where(UserORM.id == user_id) + def delete(self, id: int) -> bool: + stmt = sa.select(UserORM).where(UserORM.id == id) user_orm = self.session.scalar(stmt) if user_orm: self.session.delete(user_orm) From 6d859e86de4f4c9fe55391c0f3de25f11a0497a8 Mon Sep 17 00:00:00 2001 From: loki Date: Mon, 8 Sep 2025 22:04:14 +0300 Subject: [PATCH 15/34] auth fix --- tests/test_auth.py | 31 ++++++++++++++++++++----------- 1 file changed, 20 insertions(+), 11 deletions(-) diff --git a/tests/test_auth.py b/tests/test_auth.py index 89c7f50..2171088 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -2,7 +2,6 @@ import pytest import os -import datetime from blog import create_app from blog.extensions import db @@ -44,7 +43,7 @@ def test_load_user_existing_user(self, app, auth_adapter, user_service): # Create a user first user_domain = UserDomain(name="testuser", password="testpassword") created_user = user_service.create_user(user_domain) - + # Set the password properly user_orm = db.session.get(UserORM, created_user.id) if user_orm: @@ -68,13 +67,15 @@ def test_load_user_nonexistent_user(self, app, auth_adapter): # Verify None is returned assert loaded_user is None - def test_authenticate_and_login_valid_credentials(self, app, auth_adapter, user_service): + def test_authenticate_and_login_valid_credentials( + self, app, auth_adapter, user_service + ): """Test authenticating with valid credentials.""" with app.app_context(): # Create a user first user_domain = UserDomain(name="testuser", password="testpassword") created_user = user_service.create_user(user_domain) - + # Set the password properly user_orm = db.session.get(UserORM, created_user.id) if user_orm: @@ -82,20 +83,24 @@ def test_authenticate_and_login_valid_credentials(self, app, auth_adapter, user_ db.session.commit() # Authenticate the user using the adapter - authenticated_user = auth_adapter.authenticate_and_login("testuser", "testpassword") + authenticated_user = auth_adapter.authenticate_and_login( + "testuser", "testpassword" + ) # Verify the user was authenticated correctly assert authenticated_user is not None assert authenticated_user.id == created_user.id assert authenticated_user.name == "testuser" - def test_authenticate_and_login_invalid_credentials(self, app, auth_adapter, user_service): + def test_authenticate_and_login_invalid_credentials( + self, app, auth_adapter, user_service + ): """Test authenticating with invalid credentials.""" with app.app_context(): # Create a user first user_domain = UserDomain(name="testuser", password="testpassword") created_user = user_service.create_user(user_domain) - + # Set the password properly user_orm = db.session.get(UserORM, created_user.id) if user_orm: @@ -103,7 +108,9 @@ def test_authenticate_and_login_invalid_credentials(self, app, auth_adapter, use db.session.commit() # Try to authenticate with wrong password - authenticated_user = auth_adapter.authenticate_and_login("testuser", "wrongpassword") + authenticated_user = auth_adapter.authenticate_and_login( + "testuser", "wrongpassword" + ) # Verify None is returned assert authenticated_user is None @@ -112,7 +119,9 @@ def test_authenticate_and_login_nonexistent_user(self, app, auth_adapter): """Test authenticating a nonexistent user.""" with app.app_context(): # Try to authenticate a user that doesn't exist - authenticated_user = auth_adapter.authenticate_and_login("nonexistent", "password") + authenticated_user = auth_adapter.authenticate_and_login( + "nonexistent", "password" + ) # Verify None is returned assert authenticated_user is None @@ -123,7 +132,7 @@ def test_to_flask_login_user_conversion(self, app, auth_adapter, user_service): # Create a user first user_domain = UserDomain(name="testuser", password="testpassword") created_user = user_service.create_user(user_domain) - + # Set the password properly user_orm = db.session.get(UserORM, created_user.id) if user_orm: @@ -141,4 +150,4 @@ def test_to_flask_login_user_conversion(self, app, auth_adapter, user_service): assert flask_login_user.id == user_orm.id assert flask_login_user.name == user_orm.name assert flask_login_user.password == user_orm.password - assert flask_login_user.authenticated == user_orm.authenticated \ No newline at end of file + assert flask_login_user.authenticated == user_orm.authenticated From 8749d6f2cd62cfa9a91c928b53f8313ef68169d1 Mon Sep 17 00:00:00 2001 From: loki Date: Mon, 8 Sep 2025 22:17:26 +0300 Subject: [PATCH 16/34] repo implementation --- blog/domain/category.py | 6 --- blog/domain/post.py | 12 ------ blog/domain/tag.py | 6 --- blog/domain/user.py | 9 ----- blog/post/views.py | 7 +++- blog/repos/category.py | 27 +++----------- blog/repos/post.py | 77 +++++++++++++-------------------------- blog/repos/tag.py | 35 +++++++----------- blog/repos/user.py | 27 +++----------- blog/services/post.py | 9 +++++ blog/tags/views.py | 6 ++- blog/templates/about.html | 8 ++-- blog/templates/post.html | 4 +- 13 files changed, 74 insertions(+), 159 deletions(-) diff --git a/blog/domain/category.py b/blog/domain/category.py index 47797a2..7212f79 100644 --- a/blog/domain/category.py +++ b/blog/domain/category.py @@ -2,10 +2,6 @@ from dataclasses import dataclass import typing -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - from blog.domain.post import Post as PostDomain @dataclass @@ -17,8 +13,6 @@ class Category: alias: str = "" template: str | None = None - posts: "list[PostDomain] | None" = None - @typing.override def __str__(self): return f"Category(id={self.id}, title={self.title}, template={self.template})" diff --git a/blog/domain/post.py b/blog/domain/post.py index 158e4d0..e4b436b 100644 --- a/blog/domain/post.py +++ b/blog/domain/post.py @@ -3,12 +3,6 @@ from dataclasses import dataclass import datetime import markdown -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - from blog.domain.category import Category as CategoryDomain - from blog.domain.tag import Tag as TagDomain - from blog.domain.user import User as UserDomain MARKDOWN_EXTENSIONS = ["markdown.extensions.fenced_code"] @@ -27,12 +21,6 @@ class Post: category_id: int | None = None user_id: int | None = None - # These would typically be loaded separately in a real implementation - # to avoid circular dependencies - user: "UserDomain | None" = None - category: "CategoryDomain | None" = None - tags: "list[TagDomain] | None" = None - def __post_init__(self): if self.createdon is None: self.createdon = datetime.datetime.now(datetime.timezone.utc) diff --git a/blog/domain/tag.py b/blog/domain/tag.py index f287f32..c517f7f 100644 --- a/blog/domain/tag.py +++ b/blog/domain/tag.py @@ -2,10 +2,6 @@ from dataclasses import dataclass import typing -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - from blog.domain.post import Post as PostDomain @dataclass @@ -16,8 +12,6 @@ class Tag: title: str = "" alias: str = "" - posts: "list[PostDomain] | None" = None - @typing.override def __str__(self): return f"{self.title}" diff --git a/blog/domain/user.py b/blog/domain/user.py index e4f5478..6df223b 100644 --- a/blog/domain/user.py +++ b/blog/domain/user.py @@ -3,14 +3,6 @@ import datetime import typing from dataclasses import dataclass -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - from blog.domain.post import Post as PostDomain - - Post = PostDomain -else: - Post = "Post" @dataclass @@ -22,7 +14,6 @@ class User: password: str = "" authenticated: bool = False createdon: datetime.datetime | None = None - posts: list[Post] | None = None def __post_init__(self): if self.createdon is None: diff --git a/blog/post/views.py b/blog/post/views.py index ada3764..77abfe2 100644 --- a/blog/post/views.py +++ b/blog/post/views.py @@ -70,14 +70,17 @@ def view(alias=None, **kwargs): abort(404) + # Get tags for the post + tags = post_service.get_tags_for_post(post.id) if post.id else [] + page_category_obj = None if post.category_id: category_service = ServiceFactory.create_category_service() page_category_obj = category_service.get_category_by_id(post.category_id) if page_category_obj and page_category_obj.template: - return render_template(page_category_obj.template, post=post, **kwargs) - return render_template("post.html", post=post, **kwargs) + return render_template("post.html", post=post, tags=tags, **kwargs) + return render_template("post.html", post=post, tags=tags, **kwargs) @flask_sitemap.register_generator diff --git a/blog/repos/category.py b/blog/repos/category.py index 2ed62e6..39ae093 100644 --- a/blog/repos/category.py +++ b/blog/repos/category.py @@ -6,7 +6,6 @@ from blog.extensions import db from blog.category.models import Category as CategoryORM from blog.domain.category import Category as CategoryDomain -from blog.domain.post import Post as PostDomain from blog.repos.base import BaseRepository @@ -46,6 +45,11 @@ def get_all(self) -> List[CategoryDomain]: return [self._to_domain_model(category_orm) for category_orm in categories_orm] def get_categories_with_posts(self) -> list[CategoryDomain]: + """Get all categories with their posts loaded. + + Note: This method loads relationships but doesn't include them in the domain model + since we've removed relationship fields to avoid circular dependencies. + """ stmt = sa.select(CategoryORM).options(sa.orm.joinedload(CategoryORM.posts)) categories_orm = list(self.session.scalars(stmt).unique().all()) return [self._to_domain_model(category_orm) for category_orm in categories_orm] @@ -85,30 +89,9 @@ def delete(self, id: int) -> bool: return False def _to_domain_model(self, category_orm: CategoryORM) -> CategoryDomain: - # Convert related posts if they exist - posts = None - if category_orm.posts: - posts = [ - PostDomain( - id=post_orm.id, - pagetitle=post_orm.pagetitle or "", - alias=post_orm.alias or "", - content=post_orm.content or "", - createdon=post_orm.createdon, - publishedon=post_orm.publishedon, - category_id=post_orm.category_id, - user_id=post_orm.user_id, - user=None, # Avoid circular references - category=None, # Avoid circular references - tags=None, # Avoid circular references - ) - for post_orm in category_orm.posts - ] - return CategoryDomain( id=category_orm.id, title=category_orm.title or "", alias=category_orm.alias or "", template=category_orm.template, - posts=posts, ) diff --git a/blog/repos/post.py b/blog/repos/post.py index 9f86151..f1e2949 100644 --- a/blog/repos/post.py +++ b/blog/repos/post.py @@ -7,8 +7,6 @@ from blog.post.models import Post as PostORM from blog.tags.models import Tag as TagORM from blog.domain.post import Post as PostDomain -from blog.domain.user import User as UserDomain -from blog.domain.category import Category as CategoryDomain from blog.domain.tag import Tag as TagDomain from blog.repos.base import BaseRepository @@ -20,6 +18,11 @@ def __init__(self, session: Any = None): self.session = session or db.session def get_post_with_relationships(self, post_id: int) -> PostDomain | None: + """Get a post by ID with its relationships loaded. + + Note: This method loads relationships but doesn't include them in the domain model + since we've removed relationship fields to avoid circular dependencies. + """ stmt = ( sa.select(PostORM) .where(PostORM.id == post_id) @@ -34,6 +37,26 @@ def get_post_with_relationships(self, post_id: int) -> PostDomain | None: return self._to_domain_model(post_orm) return None + def get_tags_for_post(self, post_id: int) -> List[TagDomain]: + """Get all tags associated with a specific post.""" + stmt = sa.select(TagORM).join(TagORM.posts).where(PostORM.id == post_id) + tags_orm = list(self.session.scalars(stmt).all()) + return [self._tag_to_domain_model(tag_orm) for tag_orm in tags_orm] + + def get_posts_by_tag(self, tag_id: int) -> List[PostDomain]: + """Get all posts associated with a specific tag.""" + stmt = sa.select(PostORM).join(PostORM.tags).where(TagORM.id == tag_id) + posts_orm = list(self.session.scalars(stmt).all()) + return [self._to_domain_model(post_orm) for post_orm in posts_orm] + + def _tag_to_domain_model(self, tag_orm: TagORM) -> TagDomain: + """Convert Tag ORM model to Tag domain model.""" + return TagDomain( + id=tag_orm.id, + title=tag_orm.title or "", + alias=tag_orm.alias or "", + ) + def get_by_id(self, id: int) -> Optional[PostDomain]: stmt = sa.select(PostORM).where(PostORM.id == id) post_orm = self.session.scalar(stmt) @@ -85,15 +108,6 @@ def create(self, entity: PostDomain) -> PostDomain: if entity.user_id is not None: post_orm.user_id = entity.user_id - # Handle tags relationship if provided - if entity.tags: - # Find existing Tag ORM models based on the domain Tag models - tag_ids = [tag.id for tag in entity.tags if tag.id is not None] - if tag_ids: - stmt = sa.select(TagORM).where(TagORM.id.in_(tag_ids)) - existing_tags = list(self.session.scalars(stmt).all()) - post_orm.tags = existing_tags - self.session.add(post_orm) self.session.flush() # Get the ID without committing entity.id = post_orm.id @@ -129,44 +143,6 @@ def delete(self, id: int) -> bool: return False def _to_domain_model(self, post_orm: PostORM) -> PostDomain: - # Convert related user if it exists - user = None - if post_orm.user: - user = UserDomain( - id=post_orm.user.id, - name=post_orm.user.name or "", - password=post_orm.user.password or "", - authenticated=bool(post_orm.user.authenticated) - if post_orm.user.authenticated is not None - else False, - createdon=post_orm.user.createdon, - posts=None, # Avoid circular references - ) - - # Convert related category if it exists - category = None - if post_orm.category: - category = CategoryDomain( - id=post_orm.category.id, - title=post_orm.category.title or "", - alias=post_orm.category.alias or "", - template=post_orm.category.template, - posts=None, # Avoid circular references - ) - - # Convert related tags if they exist - tags = None - if post_orm.tags: - tags = [ - TagDomain( - id=tag_orm.id, - title=tag_orm.title or "", - alias=tag_orm.alias or "", - posts=None, # Avoid circular references - ) - for tag_orm in post_orm.tags - ] - return PostDomain( id=post_orm.id, pagetitle=post_orm.pagetitle or "", @@ -176,7 +152,4 @@ def _to_domain_model(self, post_orm: PostORM) -> PostDomain: publishedon=post_orm.publishedon, category_id=post_orm.category_id, user_id=post_orm.user_id, - user=user, - category=category, - tags=tags, ) diff --git a/blog/repos/tag.py b/blog/repos/tag.py index 94bbc2d..75d3ed2 100644 --- a/blog/repos/tag.py +++ b/blog/repos/tag.py @@ -6,7 +6,6 @@ from blog.extensions import db from blog.tags.models import Tag as TagORM from blog.domain.tag import Tag as TagDomain -from blog.domain.post import Post as PostDomain from blog.repos.base import BaseRepository @@ -44,10 +43,23 @@ def get_all(self) -> List[TagDomain]: return [self._to_domain_model(tag_orm) for tag_orm in tags_orm] def get_tags_with_posts(self) -> list[TagDomain]: + """Get all tags with their posts loaded. + + Note: This method loads relationships but doesn't include them in the domain model + since we've removed relationship fields to avoid circular dependencies. + """ stmt = sa.select(TagORM).options(sa.orm.joinedload(TagORM.posts)) tags_orm = list(self.session.scalars(stmt).unique().all()) return [self._to_domain_model(tag_orm) for tag_orm in tags_orm] + def get_tags_for_post(self, post_id: int) -> List[TagDomain]: + """Get all tags associated with a specific post.""" + from blog.post.models import Post as PostORM + + stmt = sa.select(TagORM).join(TagORM.posts).where(PostORM.id == post_id) + tags_orm = list(self.session.scalars(stmt).all()) + return [self._to_domain_model(tag_orm) for tag_orm in tags_orm] + def create(self, entity: TagDomain) -> TagDomain: tag_orm = TagORM() tag_orm.title = entity.title @@ -77,29 +89,8 @@ def delete(self, id: int) -> bool: return False def _to_domain_model(self, tag_orm: TagORM) -> TagDomain: - # Convert related posts if they exist - posts = None - if tag_orm.posts: - posts = [ - PostDomain( - id=post_orm.id, - pagetitle=post_orm.pagetitle or "", - alias=post_orm.alias or "", - content=post_orm.content or "", - createdon=post_orm.createdon, - publishedon=post_orm.publishedon, - category_id=post_orm.category_id, - user_id=post_orm.user_id, - user=None, # Avoid circular references - category=None, # Avoid circular references - tags=None, # Avoid circular references - ) - for post_orm in tag_orm.posts - ] - return TagDomain( id=tag_orm.id, title=tag_orm.title or "", alias=tag_orm.alias or "", - posts=posts, ) diff --git a/blog/repos/user.py b/blog/repos/user.py index 34290e0..90701f3 100644 --- a/blog/repos/user.py +++ b/blog/repos/user.py @@ -6,7 +6,6 @@ from blog.extensions import db from blog.user.models import User as UserORM from blog.domain.user import User as UserDomain -from blog.domain.post import Post as PostDomain from blog.repos.base import BaseRepository @@ -36,6 +35,11 @@ def get_all(self) -> List[UserDomain]: return [self._to_domain_model(user_orm) for user_orm in users_orm] def get_users_with_posts(self) -> list[UserDomain]: + """Get all users with their posts loaded. + + Note: This method loads relationships but doesn't include them in the domain model + since we've removed relationship fields to avoid circular dependencies. + """ stmt = sa.select(UserORM).options(sa.orm.joinedload(UserORM.posts)) users_orm = self.session.scalars(stmt).unique().all() return [self._to_domain_model(user_orm) for user_orm in users_orm] @@ -90,26 +94,6 @@ def authenticate(self, name: str, password: str) -> UserDomain | None: return None def _to_domain_model(self, user_orm: UserORM) -> UserDomain: - # Convert related posts if they exist - posts = None - if user_orm.posts: - posts = [ - PostDomain( - id=post_orm.id, - pagetitle=post_orm.pagetitle or "", - alias=post_orm.alias or "", - content=post_orm.content or "", - createdon=post_orm.createdon, - publishedon=post_orm.publishedon, - category_id=post_orm.category_id, - user_id=post_orm.user_id, - user=None, # Avoid circular references - category=None, # Avoid circular references - tags=None, # Avoid circular references - ) - for post_orm in user_orm.posts - ] - return UserDomain( id=user_orm.id, name=user_orm.name or "", @@ -118,5 +102,4 @@ def _to_domain_model(self, user_orm: UserORM) -> UserDomain: if user_orm.authenticated is not None else False, createdon=user_orm.createdon, - posts=posts, ) diff --git a/blog/services/post.py b/blog/services/post.py index 1384e62..c5a03c8 100644 --- a/blog/services/post.py +++ b/blog/services/post.py @@ -1,6 +1,7 @@ import logging from blog.repos.post import PostRepository from blog.domain.post import Post +from blog.domain.tag import Tag logger = logging.getLogger(__name__) @@ -33,6 +34,14 @@ def get_published_posts(self) -> list[Post]: def get_page_posts(self, page_category_ids: list[int]) -> list[Post]: return self.post_repository.get_page_posts(page_category_ids) + def get_posts_by_tag(self, tag_id: int) -> list[Post]: + """Get all posts associated with a specific tag.""" + return self.post_repository.get_posts_by_tag(tag_id) + + def get_tags_for_post(self, post_id: int) -> list[Tag]: + """Get all tags associated with a specific post.""" + return self.post_repository.get_tags_for_post(post_id) + def create_post(self, post: Post) -> Post: try: return self.post_repository.create(post) diff --git a/blog/tags/views.py b/blog/tags/views.py index d94e400..f631080 100644 --- a/blog/tags/views.py +++ b/blog/tags/views.py @@ -28,4 +28,8 @@ def view(alias=None, **kwargs): from flask import abort abort(404) - return render_template("posts.html", posts=tag.posts, tag=tag, **kwargs) + + # Get posts for this tag through the post service + post_service = ServiceFactory.create_post_service() + posts = post_service.get_posts_by_tag(tag.id) if tag.id else [] + return render_template("posts.html", posts=posts, tag=tag, **kwargs) diff --git a/blog/templates/about.html b/blog/templates/about.html index df938cf..1d4a723 100644 --- a/blog/templates/about.html +++ b/blog/templates/about.html @@ -17,9 +17,11 @@

{{post.pagetitle}}

{% if post.category_id != config['PAGE_CATEGORY'] %} {% endif %}
diff --git a/blog/templates/post.html b/blog/templates/post.html index 40374ee..d4473e0 100644 --- a/blog/templates/post.html +++ b/blog/templates/post.html @@ -21,8 +21,8 @@

{{post.pagetitle}}

{% if post.category_id != config['PAGE_CATEGORY'] %}