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
@ -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 = (

View File

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

View File

@ -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")

View File

@ -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'] /

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 name="viewport" content="width=device-width, initial-scale=1.0, shrink-to-fit=no">
<title>Login</title>
<link rel="icon" type="image/png" href="static/img/shipit.png">
<link rel="stylesheet" href="static/bootstrap/css/bootstrap.min.css">
<link rel="icon" type="image/png" href="/static/img/shipit.png">
<link rel="stylesheet" href="/static/bootstrap/css/bootstrap.min.css">
</head>
<body>
@ -31,7 +31,7 @@
</div>
</div>
<script src="static/bootstrap/js/bootstrap.min.js"></script>
<script src="/static/bootstrap/js/bootstrap.min.js"></script>
<script>
function handleSubmit(event) {
event.preventDefault();
@ -60,6 +60,7 @@
var now = new Date();
now.setDate(now.getDate() + 30);
document.cookie = `access_token=${accessToken};expires=${now.toUTCString()};path=/`;
window.location.href = window.location.origin;
})
.catch(error => {
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 name="viewport" content="width=device-width, initial-scale=1.0, shrink-to-fit=no">
<title>{% block title %}{% endblock title %}</title>
<link rel="icon" type="image/png" href="static/img/shipit.png">
<link rel="stylesheet" href="static/bootstrap/css/bootstrap.min.css">
<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="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=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=Alatsi&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>
<body>
@ -48,8 +48,8 @@
</div>
</div>
</footer>
<script src="static/bootstrap/js/bootstrap.min.js"></script>
<script src="static/js/clean-blog.js"></script>
<script src="/static/bootstrap/js/bootstrap.min.js"></script>
<script src="/static/js/clean-blog.js"></script>
</body>
</html>

View File

@ -34,7 +34,7 @@
</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>
<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>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>

View File

@ -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()))

View File

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