Added create and update post pages

This commit is contained in:
Aykhan 2023-09-13 19:21:44 +04:00
parent acea33c0b2
commit c0942490c8
12 changed files with 402 additions and 31 deletions

View File

@ -1,4 +1,7 @@
from typing import List from typing import (
List,
Optional
)
from fastapi.encoders import jsonable_encoder from fastapi.encoders import jsonable_encoder
@ -15,7 +18,11 @@ from app.schemas.post import (
class CRUDPost(CRUDBase[Post, PostCreate, PostUpdate]): class CRUDPost(CRUDBase[Post, PostCreate, PostUpdate]):
async def create_with_owner( async def create_with_owner(
self, db: Session, *, obj_in: PostCreate, owner_id: int self,
db: Session,
*,
obj_in: PostCreate,
owner_id: int
) -> Post: ) -> Post:
obj_in_data = jsonable_encoder(obj_in) obj_in_data = jsonable_encoder(obj_in)
@ -26,8 +33,23 @@ class CRUDPost(CRUDBase[Post, PostCreate, PostUpdate]):
return db_obj 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( 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]: ) -> List[Post]:
q = ( q = (

View File

@ -12,7 +12,7 @@ from app.schemas.post import Post, PostCreate, PostUpdate
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.schemas.user import User, UserCreate from app.schemas.user import User, UserCreate
from fastapi.responses import FileResponse, HTMLResponse 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 typing import Annotated, Any
from fastapi.templating import Jinja2Templates from fastapi.templating import Jinja2Templates

View File

@ -22,7 +22,7 @@ class Post(Base):
title = Column(String(100), index=True, nullable=False) title = Column(String(100), index=True, nullable=False)
slug = Column(String(), index=True, nullable=False, unique=True) slug = Column(String(), index=True, nullable=False, unique=True)
text = Column(String(), index=True, nullable=False) 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_id = Column(Integer(), ForeignKey("user.id"))
owner = relationship("User", back_populates="posts") owner = relationship("User", back_populates="posts")

View File

@ -12,7 +12,7 @@ from app.core.config import settings
class PostBase(BaseModel): class PostBase(BaseModel):
title: Optional[str] = None title: Optional[str] = Field(max_length=100)
text: Optional[str] = None text: Optional[str] = None
image_path: Optional[str] = None image_path: Optional[str] = None
@ -23,8 +23,7 @@ class PostCreate(PostBase):
image_path: str image_path: str
class PostUpdate(PostBase): class PostUpdate(PostBase): ...
title: Optional[str] = Field(max_length=100)
class PostInTemplate(PostBase): class PostInTemplate(PostBase):
@ -43,7 +42,7 @@ class PostInDBBase(PostBase):
slug: str slug: str
title: str title: str
text: str text: str
image_path: str image_path: Optional[str] = None
owner_id: int owner_id: int
class Config: class Config:
@ -53,7 +52,10 @@ class PostInDBBase(PostBase):
class Post(PostInDBBase): class Post(PostInDBBase):
@computed_field @computed_field
@property @property
def image_url(self) -> str: def image_url(self) -> str | None:
if self.image_path is None:
return None
return str( return str(
settings.MEDIA_FOLDER / settings.MEDIA_FOLDER /
settings.FILE_FOLDERS['post_images'] / settings.FILE_FOLDERS['post_images'] /

View File

@ -0,0 +1,57 @@
<!DOCTYPE html>
<html data-bs-theme="light" lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, shrink-to-fit=no">
<title>Add Post</title>
<link rel="icon" type="image/png" href="/static/img/shipit.png">
<link rel="stylesheet" href="/static/bootstrap/css/bootstrap.min.css">
</head>
<body>
<style>
/* CKEditor */
#container {
width: 1000px;
margin: 20px auto;
}
.ck-editor__editable[role="textbox"] {
/* editing area */
min-height: 500px;
}
.ck-content .image {
/* block images */
max-width: 80%;
margin: 20px auto;
}
</style>
<div class="container" style="margin-top: 4rem;">
<div class="row">
<div class="col-md-10 col-lg-6 mx-auto">
<form action="/add-post" method="POST" enctype="multipart/form-data">
<div class="form-group">
<input type="text" class="form-control" name="title" id="title" placeholder="Enter title">
</div>
<div class="form-group" style="margin-top: 3rem; margin-bottom: 3rem;">
<textarea name="text" required></textarea>
</div>
<div class="form-group">
<label for="post-image">Image</label>
<input type="file" class="form-control-file" name="image" id="post-image">
</div>
<button type="submit" class="btn btn-primary" style="margin-top: 3rem; margin-bottom: 5rem;">Save</button>
</form>
</div>
</div>
</div>
<script src="https://cdn.ckeditor.com/4.22.1/standard/ckeditor.js"></script>
<script src="/static/bootstrap/js/bootstrap.min.js"></script>
<script>
CKEDITOR.replace('text');
</script>
</body>
</html>

View File

@ -5,8 +5,8 @@
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, shrink-to-fit=no"> <meta name="viewport" content="width=device-width, initial-scale=1.0, shrink-to-fit=no">
<title>Login</title> <title>Login</title>
<link rel="icon" type="image/png" href="static/img/shipit.png"> <link rel="icon" type="image/png" href="/static/img/shipit.png">
<link rel="stylesheet" href="static/bootstrap/css/bootstrap.min.css"> <link rel="stylesheet" href="/static/bootstrap/css/bootstrap.min.css">
</head> </head>
<body> <body>
@ -31,7 +31,7 @@
</div> </div>
</div> </div>
<script src="static/bootstrap/js/bootstrap.min.js"></script> <script src="/static/bootstrap/js/bootstrap.min.js"></script>
<script> <script>
function handleSubmit(event) { function handleSubmit(event) {
event.preventDefault(); event.preventDefault();
@ -60,6 +60,7 @@
var now = new Date(); var now = new Date();
now.setDate(now.getDate() + 30); now.setDate(now.getDate() + 30);
document.cookie = `access_token=${accessToken};expires=${now.toUTCString()};path=/`; document.cookie = `access_token=${accessToken};expires=${now.toUTCString()};path=/`;
window.location.href = window.location.origin;
}) })
.catch(error => { .catch(error => {
document.getElementById('responseError').innerHTML = error.message; document.getElementById('responseError').innerHTML = error.message;

View File

@ -0,0 +1,66 @@
<!DOCTYPE html>
<html data-bs-theme="light" lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, shrink-to-fit=no">
<title>Update Post</title>
<link rel="icon" type="image/png" href="/static/img/shipit.png">
<link rel="stylesheet" href="/static/bootstrap/css/bootstrap.min.css">
</head>
<body>
<style>
/* CKEditor */
#container {
width: 1000px;
margin: 20px auto;
}
.ck-editor__editable[role="textbox"] {
/* editing area */
min-height: 500px;
}
.ck-content .image {
/* block images */
max-width: 80%;
margin: 20px auto;
}
</style>
<div class="container" style="margin-top: 4rem;">
<div class="row">
<div class="col-md-10 col-lg-6 mx-auto">
<form action="/update-post/{{post.slug}}" method="POST" enctype="multipart/form-data" style="margin-bottom: 4.5rem;">
<div class="form-group" style="margin-bottom: 1.5rem;">
<input value="{{post.title}}" type="text" class="form-control" name="title" id="title" placeholder="Enter title" required>
</div>
<button type="submit" class="btn btn-primary">Save</button>
</form>
<form action="/update-post/{{post.slug}}" method="POST" enctype="multipart/form-data" style="margin-bottom: 4.5rem;">
<div class="form-group" style="margin-bottom: 1.5rem;">
<textarea name="text" required>
{{post.text}}
</textarea>
</div>
<button type="submit" class="btn btn-primary">Save</button>
</form>
<form action="/update-post/{{post.slug}}" method="POST" enctype="multipart/form-data" style="margin-bottom: 4.5rem;">
<p style="font-size: medium;">Current: <a href="/{{post.image_url}}" target=”_blank”>{{post.image_url}}</a></p>
<div class="form-group" style="margin-bottom: 1.5rem;">
<label for="post-image">Image</label>
<input type="file" class="form-control-file" name="image" id="post-image">
</div>
<button type="submit" class="btn btn-primary">Save</button>
</form>
</div>
</div>
</div>
<script src="https://cdn.ckeditor.com/4.22.1/standard/ckeditor.js"></script>
<script src="/static/bootstrap/js/bootstrap.min.js"></script>
<script>
CKEDITOR.replace('text');
</script>
</body>
</html>

View File

@ -5,15 +5,15 @@
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, shrink-to-fit=no"> <meta name="viewport" content="width=device-width, initial-scale=1.0, shrink-to-fit=no">
<title>{% block title %}{% endblock title %}</title> <title>{% block title %}{% endblock title %}</title>
<link rel="icon" type="image/png" href="static/img/shipit.png"> <link rel="icon" type="image/png" href="/static/img/shipit.png">
<link rel="stylesheet" href="static/bootstrap/css/bootstrap.min.css"> <link rel="stylesheet" href="/static/bootstrap/css/bootstrap.min.css">
<link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Open+Sans:300italic,400italic,600italic,700italic,800italic,400,300,600,700,800&amp;display=swap"> <link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Open+Sans:300italic,400italic,600italic,700italic,800italic,400,300,600,700,800&amp;display=swap">
<link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Lora:400,700,400italic,700italic&amp;display=swap"> <link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Lora:400,700,400italic,700italic&amp;display=swap">
<link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Abril+Fatface&amp;display=swap"> <link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Abril+Fatface&amp;display=swap">
<link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Aclonica&amp;display=swap"> <link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Aclonica&amp;display=swap">
<link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Alatsi&amp;display=swap"> <link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Alatsi&amp;display=swap">
<link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Alfa+Slab+One&amp;display=swap"> <link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Alfa+Slab+One&amp;display=swap">
<link rel="stylesheet" href="static/fonts/font-awesome.min.css"> <link rel="stylesheet" href="/static/fonts/font-awesome.min.css">
</head> </head>
<body> <body>
@ -48,8 +48,8 @@
</div> </div>
</div> </div>
</footer> </footer>
<script src="static/bootstrap/js/bootstrap.min.js"></script> <script src="/static/bootstrap/js/bootstrap.min.js"></script>
<script src="static/js/clean-blog.js"></script> <script src="/static/js/clean-blog.js"></script>
</body> </body>
</html> </html>

View File

@ -34,7 +34,7 @@
</figure> </figure>
<p>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.</p> <p>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.</p>
<h2 class="section-heading">Reaching for the Stars</h2> <h2 class="section-heading">Reaching for the Stars</h2>
<p>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.</p><a href="#"><img class="img-fluid" src="static/img/post-sample-image.jpg"></a><span class="text-muted caption">To go places and do things that have never been done before thats what living is all about.</span> <p>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.</p><a href="#"><img class="img-fluid" src="/static/img/post-sample-image.jpg"></a><span class="text-muted caption">To go places and do things that have never been done before thats what living is all about.</span>
<p>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.</p> <p>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.</p>
<p>As I stand out here in the wonders of the unknown at Hadley, I sort of realize theres a fundamental truth to our nature, Man must explore, and this is exploration at its greatest.</p> <p>As I stand out here in the wonders of the unknown at Hadley, I sort of realize theres a fundamental truth to our nature, Man must explore, and this is exploration at its greatest.</p>
<p><span>Placeholder text by&nbsp;</span><a href="http://spaceipsum.com">Space Ipsum</a><span>&nbsp;Photographs by&nbsp;</span><a href="https://www.flickr.com/photos/nasacommons/">NASA on The Commons</a>.</p> <p><span>Placeholder text by&nbsp;</span><a href="http://spaceipsum.com">Space Ipsum</a><span>&nbsp;Photographs by&nbsp;</span><a href="https://www.flickr.com/photos/nasacommons/">NASA on The Commons</a>.</p>

View File

@ -1,7 +1,8 @@
import io import io
from typing import ( from typing import (
Annotated, Annotated,
Generator Generator,
Optional
) )
from PIL import Image from PIL import Image
@ -22,6 +23,7 @@ from fastapi import (
from fastapi.security import OAuth2PasswordBearer from fastapi.security import OAuth2PasswordBearer
from app.models.user import User as UserModel from app.models.user import User as UserModel
from app.models.post import Post as PostModel
from app.core import security from app.core import security
from app.core.config import settings from app.core.config import settings
from app.db.session import ( from app.db.session import (
@ -49,16 +51,23 @@ async def get_async_db() -> Generator:
yield async_db 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 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 return access_token
async def get_current_user_or_die( 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: ) -> UserModel:
try: try:
@ -84,7 +93,10 @@ async def get_current_user_or_die(
async def get_current_user_or_none( 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: ) -> UserModel | None:
if token is None: return None if token is None: return None
@ -150,7 +162,56 @@ async def get_current_active_superuser_or_none(
return current_user 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: try:
pil_image = Image.open(io.BytesIO(image.file.read())) pil_image = Image.open(io.BytesIO(image.file.read()))

View File

@ -1,14 +1,17 @@
from datetime import timedelta from datetime import timedelta
from typing import Annotated, Optional
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from fastapi.templating import Jinja2Templates from fastapi.templating import Jinja2Templates
from fastapi.responses import ( from fastapi.responses import (
FileResponse, FileResponse,
HTMLResponse HTMLResponse,
RedirectResponse
) )
from fastapi import ( from fastapi import (
APIRouter, APIRouter,
Form,
HTTPException, HTTPException,
Request, Request,
Depends Depends
@ -17,12 +20,23 @@ from fastapi import (
from app import crud from app import crud
from app.core import security from app.core import security
from app.models.user import User as UserModel 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.core.config import settings
from app.schemas.post import (
PostCreate,
PostUpdate
)
from app.schemas.post import Post as PostSchema
from app.views.depends import ( from app.views.depends import (
get_async_db, get_async_db,
get_current_active_superuser_or_die, 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') 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( async def login(
request: Request 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( async def login(
db: AsyncSession = Depends(get_async_db), db: AsyncSession = Depends(get_async_db),
form_data: LoginForm = Depends() 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") @router.get("/admin")
def admin( def admin(
request: Request request: Request
): ):
return FileResponse(settings.STATIC_FOLDER / 'just_a.gif') return FileResponse(settings.STATIC_FOLDER / 'just_a.gif')

View File

@ -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 ###