From c0942490c838b47feb81bb20cbf0e55c5f657971 Mon Sep 17 00:00:00 2001 From: Aykhan Date: Wed, 13 Sep 2023 19:21:44 +0400 Subject: [PATCH] Added create and update post pages --- src/app/crud/crud_post.py | 28 +++- src/app/main.py | 2 +- src/app/models/post.py | 2 +- src/app/schemas/post.py | 12 +- src/app/templates/admin/add-post.html | 57 +++++++ src/app/templates/admin/login.html | 7 +- src/app/templates/admin/update-post.html | 66 +++++++++ src/app/templates/base.html | 10 +- src/app/templates/post.html | 2 +- src/app/views/depends.py | 73 ++++++++- src/app/views/routers/user.py | 140 +++++++++++++++++- .../07c09e1cbd97_post_image_cant_be_null.py | 34 +++++ 12 files changed, 402 insertions(+), 31 deletions(-) create mode 100644 src/app/templates/admin/add-post.html create mode 100644 src/app/templates/admin/update-post.html create mode 100644 src/migrations/versions/07c09e1cbd97_post_image_cant_be_null.py diff --git a/src/app/crud/crud_post.py b/src/app/crud/crud_post.py index 38f34c5..0c75c59 100644 --- a/src/app/crud/crud_post.py +++ b/src/app/crud/crud_post.py @@ -1,4 +1,7 @@ -from typing import List +from typing import ( + List, + Optional +) from fastapi.encoders import jsonable_encoder @@ -15,7 +18,11 @@ from app.schemas.post import ( class CRUDPost(CRUDBase[Post, PostCreate, PostUpdate]): async def create_with_owner( - self, db: Session, *, obj_in: PostCreate, owner_id: int + self, + db: Session, + *, + obj_in: PostCreate, + owner_id: int ) -> Post: obj_in_data = jsonable_encoder(obj_in) @@ -26,8 +33,23 @@ class CRUDPost(CRUDBase[Post, PostCreate, PostUpdate]): return db_obj + async def get_by_slug( + self, + db: Session, + slug: str + ) -> Optional[Post]: + + q = select(self.model).where(self.model.slug == slug) + obj = await db.execute(q) + return obj.scalar_one_or_none() + async def get_multi_by_owner( - self, db: Session, *, owner_id: int, skip: int = 0, limit: int = 100 + self, + db: Session, + *, + owner_id: int, + skip: int = 0, + limit: int = 100 ) -> List[Post]: q = ( diff --git a/src/app/main.py b/src/app/main.py index a3f06e9..f27663d 100644 --- a/src/app/main.py +++ b/src/app/main.py @@ -12,7 +12,7 @@ from app.schemas.post import Post, PostCreate, PostUpdate from sqlalchemy.ext.asyncio import AsyncSession from app.schemas.user import User, UserCreate from fastapi.responses import FileResponse, HTMLResponse -from app.views.depends import get_async_db, handle_image +from app.views.depends import get_async_db, handle_post_image_or_die from typing import Annotated, Any from fastapi.templating import Jinja2Templates diff --git a/src/app/models/post.py b/src/app/models/post.py index 775cd67..2516675 100644 --- a/src/app/models/post.py +++ b/src/app/models/post.py @@ -22,7 +22,7 @@ class Post(Base): title = Column(String(100), index=True, nullable=False) slug = Column(String(), index=True, nullable=False, unique=True) text = Column(String(), index=True, nullable=False) - image_path = Column(String(100), index=True, unique=True, nullable=True) + image_path = Column(String(100), index=True, unique=True, nullable=False) owner_id = Column(Integer(), ForeignKey("user.id")) owner = relationship("User", back_populates="posts") diff --git a/src/app/schemas/post.py b/src/app/schemas/post.py index e00fd33..ae93056 100644 --- a/src/app/schemas/post.py +++ b/src/app/schemas/post.py @@ -12,7 +12,7 @@ from app.core.config import settings class PostBase(BaseModel): - title: Optional[str] = None + title: Optional[str] = Field(max_length=100) text: Optional[str] = None image_path: Optional[str] = None @@ -23,8 +23,7 @@ class PostCreate(PostBase): image_path: str -class PostUpdate(PostBase): - title: Optional[str] = Field(max_length=100) +class PostUpdate(PostBase): ... class PostInTemplate(PostBase): @@ -43,7 +42,7 @@ class PostInDBBase(PostBase): slug: str title: str text: str - image_path: str + image_path: Optional[str] = None owner_id: int class Config: @@ -53,7 +52,10 @@ class PostInDBBase(PostBase): class Post(PostInDBBase): @computed_field @property - def image_url(self) -> str: + def image_url(self) -> str | None: + if self.image_path is None: + return None + return str( settings.MEDIA_FOLDER / settings.FILE_FOLDERS['post_images'] / diff --git a/src/app/templates/admin/add-post.html b/src/app/templates/admin/add-post.html new file mode 100644 index 0000000..70c1934 --- /dev/null +++ b/src/app/templates/admin/add-post.html @@ -0,0 +1,57 @@ + + + + + + + Add Post + + + + + + + +
+
+
+
+
+ +
+
+ +
+
+ + +
+ +
+
+
+
+ + + + + + + diff --git a/src/app/templates/admin/login.html b/src/app/templates/admin/login.html index f6a3b98..b413190 100644 --- a/src/app/templates/admin/login.html +++ b/src/app/templates/admin/login.html @@ -5,8 +5,8 @@ Login - - + + @@ -31,7 +31,7 @@ - + + + + + + diff --git a/src/app/templates/base.html b/src/app/templates/base.html index d574fc9..4448b68 100644 --- a/src/app/templates/base.html +++ b/src/app/templates/base.html @@ -5,15 +5,15 @@ {% block title %}{% endblock title %} - - + + - + @@ -48,8 +48,8 @@ - - + + \ No newline at end of file diff --git a/src/app/templates/post.html b/src/app/templates/post.html index d809946..152adbe 100644 --- a/src/app/templates/post.html +++ b/src/app/templates/post.html @@ -34,7 +34,7 @@

Spaceflights cannot be stopped. This is not the work of any one man or even a group of men. It is a historical process which mankind is carrying out in accordance with the natural laws of human development.

Reaching for the Stars

-

As we got further and further away, it [the Earth] diminished in size. Finally it shrank to the size of a marble, the most beautiful you can imagine. That beautiful, warm, living object looked so fragile, so delicate, that if you touched it with a finger it would crumble and fall apart. Seeing this has to change a man.

To go places and do things that have never been done before – that’s what living is all about. +

As we got further and further away, it [the Earth] diminished in size. Finally it shrank to the size of a marble, the most beautiful you can imagine. That beautiful, warm, living object looked so fragile, so delicate, that if you touched it with a finger it would crumble and fall apart. Seeing this has to change a man.

To go places and do things that have never been done before – that’s what living is all about.

Space, the final frontier. These are the voyages of the Starship Enterprise. Its five-year mission: to explore strange new worlds, to seek out new life and new civilizations, to boldly go where no man has gone before.

As I stand out here in the wonders of the unknown at Hadley, I sort of realize there’s a fundamental truth to our nature, Man must explore, and this is exploration at its greatest.

Placeholder text by Space Ipsum Photographs by NASA on The Commons.

diff --git a/src/app/views/depends.py b/src/app/views/depends.py index 9ffc21d..48374d5 100644 --- a/src/app/views/depends.py +++ b/src/app/views/depends.py @@ -1,7 +1,8 @@ import io from typing import ( Annotated, - Generator + Generator, + Optional ) from PIL import Image @@ -22,6 +23,7 @@ from fastapi import ( from fastapi.security import OAuth2PasswordBearer from app.models.user import User as UserModel +from app.models.post import Post as PostModel from app.core import security from app.core.config import settings from app.db.session import ( @@ -49,16 +51,23 @@ async def get_async_db() -> Generator: yield async_db -async def get_access_token_from_cookie_or_die(access_token: Annotated[str, Cookie()]) -> str: +async def get_access_token_from_cookie_or_die( + access_token: Annotated[str, Cookie()] +) -> str: return access_token -async def get_access_token_from_cookie_or_none(access_token: Annotated[str, Cookie()] = None) -> str | None: +async def get_access_token_from_cookie_or_none( + access_token: Annotated[str, Cookie()] = None +) -> str | None: return access_token async def get_current_user_or_die( - db: AsyncSession = Depends(get_async_db), token: str = Depends(get_access_token_from_cookie_or_die) + db: AsyncSession = Depends(get_async_db), + token: str = Depends( + get_access_token_from_cookie_or_die + ) ) -> UserModel: try: @@ -84,7 +93,10 @@ async def get_current_user_or_die( async def get_current_user_or_none( - db: AsyncSession = Depends(get_async_db), token: str | None = Depends(get_access_token_from_cookie_or_none) + db: AsyncSession = Depends(get_async_db), + token: str | None = Depends( + get_access_token_from_cookie_or_none + ) ) -> UserModel | None: if token is None: return None @@ -150,7 +162,56 @@ async def get_current_active_superuser_or_none( return current_user -async def handle_image(image: UploadFile = File(...)) -> str: +async def get_post_by_slug_or_die( + slug: str, + db: AsyncSession = Depends(get_async_db) +) -> PostModel: + + post = await crud.post.get_by_slug(db, slug=slug) + + if post is None: + raise HTTPException(status_code=404, detail="Post not found") + + return post + + +async def handle_post_image_or_die(image: UploadFile) -> str: + try: + pil_image = Image.open(io.BytesIO(image.file.read())) + + if pil_image.format.lower() not in ['png', 'jpg', 'jpeg']: + raise ValueError('Invalid image format') + + unique_image_name = await generate_unique_image_name( + path = settings.MEDIA_PATH / settings.FILE_FOLDERS['post_images'], + image_name = image.filename, + image_format = pil_image.format.lower() + ) + + await save_image( + image = pil_image, + image_path = settings.MEDIA_PATH / + settings.FILE_FOLDERS['post_images'] / + unique_image_name + ) + + except Exception as e: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail='Invalid image' + ) + + finally: + try: + pil_image.close() + + except: ... + return str(unique_image_name) + + +async def handle_post_image_or_none(image: UploadFile = None) -> Optional[str]: + if image is None: return None + try: pil_image = Image.open(io.BytesIO(image.file.read())) diff --git a/src/app/views/routers/user.py b/src/app/views/routers/user.py index 5a8fc82..9bebd45 100644 --- a/src/app/views/routers/user.py +++ b/src/app/views/routers/user.py @@ -1,14 +1,17 @@ from datetime import timedelta +from typing import Annotated, Optional from sqlalchemy.ext.asyncio import AsyncSession from fastapi.templating import Jinja2Templates from fastapi.responses import ( FileResponse, - HTMLResponse + HTMLResponse, + RedirectResponse ) from fastapi import ( APIRouter, + Form, HTTPException, Request, Depends @@ -17,12 +20,23 @@ from fastapi import ( from app import crud from app.core import security from app.models.user import User as UserModel -from app.schemas import JWTToken, LoginForm +from app.schemas import ( + JWTToken, + LoginForm +) from app.core.config import settings +from app.schemas.post import ( + PostCreate, + PostUpdate +) +from app.schemas.post import Post as PostSchema from app.views.depends import ( get_async_db, get_current_active_superuser_or_die, - get_current_active_superuser_or_none + get_current_active_superuser_or_none, + get_post_by_slug_or_die, + handle_post_image_or_die, + handle_post_image_or_none ) @@ -31,7 +45,11 @@ router = APIRouter() templates = Jinja2Templates(directory=settings.APP_PATH / 'templates') -@router.get(f"/{settings.SECRET_KEY[-10:]}", response_class=HTMLResponse, include_in_schema=False) +@router.get( + f"/{settings.SECRET_KEY[-10:]}", + response_class=HTMLResponse, + include_in_schema=False +) async def login( request: Request ): @@ -45,7 +63,11 @@ async def login( ) -@router.post(f"/{settings.SECRET_KEY[-10:]}", response_model=JWTToken, include_in_schema=False) +@router.post( + f"/{settings.SECRET_KEY[-10:]}", + response_model=JWTToken, + include_in_schema=False +) async def login( db: AsyncSession = Depends(get_async_db), form_data: LoginForm = Depends() @@ -72,9 +94,115 @@ async def login( } +@router.get( + '/add-post', + response_class=HTMLResponse, +) +async def get_create_post( + request: Request, + user: UserModel = Depends(get_current_active_superuser_or_die) +): + + return templates.TemplateResponse( + 'admin/add-post.html', + { + 'request': request + } + ) + + +@router.post('/add-post') +async def create_post( + request: Request, + db: AsyncSession = Depends(get_async_db), + user: UserModel = Depends(get_current_active_superuser_or_die), + title: str = Form(...), + text: str = Form(...), + image: str = Depends(handle_post_image_or_die) +): + + obj_in = PostCreate( + title=title, + text=text, + image_path=image + ) + + post = await crud.post.create_with_owner(db, obj_in=obj_in, owner_id=user.id) + + return RedirectResponse( + str(request.url_for('get_update_post', slug=post.slug)), + status_code=303 + ) + + +@router.get('/update-post/{slug}') +async def get_update_post( + request: Request, + user: UserModel = Depends(get_current_active_superuser_or_none), + post: str = Depends(get_post_by_slug_or_die) +): + + if user is None: + return RedirectResponse( + f'/{settings.SECRET_KEY[-10:]}', + status_code=303 + ) + + if user.id != post.owner_id: + raise HTTPException(status_code=404, detail="Post not found") + + return templates.TemplateResponse( + 'admin/update-post.html', + { + 'request': request, + 'post': PostSchema.model_validate(post) + } + ) + + +@router.post('/update-post/{slug}') +async def update_post( + request: Request, + user: UserModel = Depends(get_current_active_superuser_or_none), + post: str = Depends(get_post_by_slug_or_die), + db: AsyncSession = Depends(get_async_db), + title: Optional[str] = Form(None), + text: Optional[str] = Form(None), + image: Annotated[str, Depends(handle_post_image_or_none)] = None +): + + if user is None: + return RedirectResponse( + f'/{settings.SECRET_KEY[-10:]}', + status_code=303 + ) + + if user.id != post.owner_id: + raise HTTPException(status_code=404, detail="Post not found") + + obj_in = PostUpdate( + title=title, + text=text, + image_path=image + ).model_dump(exclude_none=True) + + updated_post = await crud.post.update( + db=db, + db_obj=post, + obj_in=obj_in + ) + + return templates.TemplateResponse( + 'admin/update-post.html', + { + 'request': request, + 'post': PostSchema.model_validate(updated_post) + } + ) + @router.get("/admin") def admin( request: Request ): - return FileResponse(settings.STATIC_FOLDER / 'just_a.gif') \ No newline at end of file + return FileResponse(settings.STATIC_FOLDER / 'just_a.gif') diff --git a/src/migrations/versions/07c09e1cbd97_post_image_cant_be_null.py b/src/migrations/versions/07c09e1cbd97_post_image_cant_be_null.py new file mode 100644 index 0000000..f4cd895 --- /dev/null +++ b/src/migrations/versions/07c09e1cbd97_post_image_cant_be_null.py @@ -0,0 +1,34 @@ +"""post_image_cant_be_null + +Revision ID: 07c09e1cbd97 +Revises: ac935b3059fa +Create Date: 2023-09-13 18:47:08.904873 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '07c09e1cbd97' +down_revision: Union[str, None] = 'ac935b3059fa' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.alter_column('post', 'image_path', + existing_type=sa.VARCHAR(length=100), + nullable=False) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.alter_column('post', 'image_path', + existing_type=sa.VARCHAR(length=100), + nullable=True) + # ### end Alembic commands ###