mirror of
https://github.com/aykhans/portfolio-blog.git
synced 2025-04-15 02:23:12 +00:00
Added delete post page
This commit is contained in:
parent
c0942490c8
commit
0daa6af899
@ -30,6 +30,10 @@ class Settings(BaseSettings):
|
||||
POSTGRES_PORT: int = 5432
|
||||
POSTGRES_DB: str
|
||||
|
||||
@property
|
||||
def LOGIN_URL(self) -> str:
|
||||
return self.SECRET_KEY[-10:]
|
||||
|
||||
def get_postgres_dsn(self, _async: bool=False) -> PostgresDsn:
|
||||
scheme = 'postgresql+asyncpg' if _async else 'postgresql'
|
||||
|
||||
|
@ -66,4 +66,13 @@ class CRUDPost(CRUDBase[Post, PostCreate, PostUpdate]):
|
||||
def create(self):
|
||||
raise DeprecationWarning("Use create_with_owner instead")
|
||||
|
||||
async def remove_by_slug(self, db: Session, *, slug: str) -> Post:
|
||||
q = select(self.model).where(self.model.slug == slug)
|
||||
obj = await db.execute(q)
|
||||
obj = obj.scalar_one()
|
||||
await db.delete(obj)
|
||||
await db.commit()
|
||||
|
||||
return obj
|
||||
|
||||
post = CRUDPost(Post)
|
@ -1,12 +1,23 @@
|
||||
from typing import Any, Dict, Optional, Union
|
||||
from typing import (
|
||||
Any,
|
||||
Dict,
|
||||
Optional,
|
||||
Union
|
||||
)
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy.future import select
|
||||
|
||||
from app.core.security import get_password_hash, verify_password
|
||||
from app.core.security import (
|
||||
get_password_hash,
|
||||
verify_password
|
||||
)
|
||||
from app.crud.base import CRUDBase
|
||||
from app.models.user import User
|
||||
from app.schemas.user import UserCreate, UserUpdate
|
||||
from app.schemas.user import (
|
||||
UserCreate,
|
||||
UserUpdate
|
||||
)
|
||||
|
||||
|
||||
class CRUDUser(CRUDBase[User, UserCreate, UserUpdate]):
|
||||
|
@ -1,6 +1,9 @@
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy.ext.declarative import as_declarative, declared_attr
|
||||
from sqlalchemy.ext.declarative import (
|
||||
as_declarative,
|
||||
declared_attr
|
||||
)
|
||||
from sqlalchemy import (
|
||||
DateTime,
|
||||
Column
|
||||
|
@ -1,4 +1,9 @@
|
||||
from sqlalchemy import Boolean, Column, Integer, String
|
||||
from sqlalchemy import (
|
||||
Boolean,
|
||||
Column,
|
||||
Integer,
|
||||
String
|
||||
)
|
||||
from sqlalchemy.orm import relationship
|
||||
|
||||
from app.db.base_class import Base
|
||||
|
@ -4,13 +4,16 @@ from pydantic import (
|
||||
BaseModel,
|
||||
Field,
|
||||
PastDatetime,
|
||||
computed_field,
|
||||
TypeAdapter
|
||||
TypeAdapter,
|
||||
field_validator
|
||||
)
|
||||
|
||||
from app.utils.custom_functions import html2text
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
|
||||
|
||||
class PostBase(BaseModel):
|
||||
title: Optional[str] = Field(max_length=100)
|
||||
text: Optional[str] = None
|
||||
@ -26,23 +29,48 @@ class PostCreate(PostBase):
|
||||
class PostUpdate(PostBase): ...
|
||||
|
||||
|
||||
class PostInTemplate(PostBase):
|
||||
class PostInTemplate(BaseModel):
|
||||
title: str
|
||||
text: str
|
||||
created_at: PastDatetime
|
||||
slug: str
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
@field_validator('text', mode='after')
|
||||
@classmethod
|
||||
def html_to_text(cls, v: str) -> str:
|
||||
return html2text(v)[:60]
|
||||
|
||||
|
||||
ListPostInTemplate = TypeAdapter(list[PostInTemplate])
|
||||
|
||||
|
||||
class PostDetail(BaseModel):
|
||||
title: str
|
||||
text: str
|
||||
created_at: PastDatetime
|
||||
image_path: str
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
@field_validator('image_path', mode='after')
|
||||
@classmethod
|
||||
def absolute_image_path(cls, v: str) -> str | None:
|
||||
return str(
|
||||
settings.MEDIA_FOLDER /
|
||||
settings.FILE_FOLDERS['post_images'] /
|
||||
v
|
||||
)
|
||||
|
||||
|
||||
class PostInDBBase(PostBase):
|
||||
slug: str
|
||||
title: str
|
||||
text: str
|
||||
image_path: Optional[str] = None
|
||||
image_path: str
|
||||
owner_id: int
|
||||
|
||||
class Config:
|
||||
@ -50,14 +78,11 @@ class PostInDBBase(PostBase):
|
||||
|
||||
|
||||
class Post(PostInDBBase):
|
||||
@computed_field
|
||||
@property
|
||||
def image_url(self) -> str | None:
|
||||
if self.image_path is None:
|
||||
return None
|
||||
|
||||
@field_validator('image_path', mode='after')
|
||||
@classmethod
|
||||
def absolute_image_path(cls, v: str) -> str | None:
|
||||
return str(
|
||||
settings.MEDIA_FOLDER /
|
||||
settings.FILE_FOLDERS['post_images'] /
|
||||
self.image_path
|
||||
v
|
||||
)
|
31
src/app/templates/admin/delete-post.html
Normal file
31
src/app/templates/admin/delete-post.html
Normal file
@ -0,0 +1,31 @@
|
||||
<!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>
|
||||
|
||||
<div class="container" style="margin-top: 4rem;">
|
||||
<div class="row">
|
||||
<div class="col-md-10 col-lg-6 mx-auto">
|
||||
<h3>Are you sure you want to delete {{post.title}}?</h3>
|
||||
<div style="display: flex; margin-top: 4rem;">
|
||||
<form action="/delete-post/{{post.slug}}" method="POST" style="margin-right: 4rem;">
|
||||
<input type="submit" value="Yes" class="btn btn-danger">
|
||||
</form>
|
||||
<a href="/blog" class="btn btn-primary">No</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/static/bootstrap/js/bootstrap.min.js"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
@ -45,7 +45,7 @@
|
||||
<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>
|
||||
<p style="font-size: medium;">Current: <a href="/{{post.image_path}}" target=”_blank”>{{post.image_path}}</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">
|
||||
|
@ -3,7 +3,7 @@
|
||||
{% block title %}Blog{% endblock title %}
|
||||
|
||||
{% block content %}
|
||||
<header class="masthead" style="background: url("static/img/bryan-goff-f7YQo-eYHdM-unsplash.jpg");">
|
||||
<header class="masthead" style="background: url("/static/img/bryan-goff-f7YQo-eYHdM-unsplash.jpg");">
|
||||
<div class="overlay"></div>
|
||||
<div class="container">
|
||||
<div class="row">
|
||||
@ -21,11 +21,15 @@
|
||||
<div class="col-md-10 col-lg-8 mx-auto">
|
||||
{% for post in posts %}
|
||||
<div class="post-preview">
|
||||
<a href="#">
|
||||
<a href="/blog/{{post.slug}}">
|
||||
<h2 class="post-title">{{post.title}}</h2>
|
||||
<h3 class="post-subtitle">{{post.text}}</h3>
|
||||
</a>
|
||||
<p class="post-meta">Posted at <a href="#">{{post.created_at}}</a></p>
|
||||
<p class="post-meta"><b>Posted at:</b> {{post.created_at.strftime('%Y-%m-%d %H:%M')}}</p>
|
||||
{% if user %}
|
||||
<a href="/update-post/{{post.slug}}" style="text-decoration: none; margin-right: 0.7rem;">Update</a>
|
||||
<a href="/delete-post/{{post.slug}}" style="text-decoration: none;">Delete</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
<hr>
|
||||
{% endfor %}
|
||||
@ -39,6 +43,11 @@
|
||||
</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if user %}
|
||||
<a href="/add-post" class="btn btn-primary" style="text-decoration: none;">
|
||||
Add
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -3,7 +3,7 @@
|
||||
{% block title %}Just a Page{% endblock title %}
|
||||
|
||||
{% block content %}
|
||||
<header class="masthead" style="background: url("static/img/bryan-goff-f7YQo-eYHdM-unsplash.jpg");">
|
||||
<header class="masthead" style="background: url("/static/img/bryan-goff-f7YQo-eYHdM-unsplash.jpg");">
|
||||
<div class="overlay"></div>
|
||||
<div class="container">
|
||||
<div class="row">
|
||||
|
@ -2,14 +2,15 @@
|
||||
|
||||
{% block title %}Blog Post{% endblock title %}
|
||||
|
||||
|
||||
{% block content %}
|
||||
<header class="masthead" style="background: url("static/img/bryan-goff-f7YQo-eYHdM-unsplash.jpg");">
|
||||
<header class="masthead" style="background: url("/static/img/bryan-goff-f7YQo-eYHdM-unsplash.jpg");">
|
||||
<div class="overlay"></div>
|
||||
<div class="container">
|
||||
<div class="row">
|
||||
<div class="col-md-10 col-lg-8 mx-auto position-relative">
|
||||
<div class="site-heading">
|
||||
<h1><strong>Title</strong></h1><span class="subheading">Date</span>
|
||||
<h1><strong>{{post.title}}</strong></h1><span class="subheading">{{post.created_at.strftime('%Y-%m-%d %H:%M')}}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -19,25 +20,8 @@
|
||||
<div class="container">
|
||||
<div class="row">
|
||||
<div class="col-md-10 col-lg-8 mx-auto">
|
||||
<p>Never in all their history have men been able truly to conceive of the world as one: a single sphere, a globe, having the qualities of a globe, a round earth in which all the directions eventually meet, in which there is no center because every point, or none, is center — an equal earth which all men occupy as equals. The airman's earth, if free men make it, will be truly round: a globe in practice, not in theory.</p>
|
||||
<p>Science cuts two ways, of course; its products can be used for both good and evil. But there's no turning back from science. The early warnings about technological dangers also come from science.</p>
|
||||
<p>What was most significant about the lunar voyage was not that man set foot on the Moon but that they set eye on the earth.</p>
|
||||
<p>A Chinese tale tells of some men sent to harm a young girl who, upon seeing her beauty, become her protectors rather than her violators. That's how I felt seeing the Earth for the first time. I could not help but love and cherish her.</p>
|
||||
<p>For those who have seen the Earth from space, and for the hundreds and perhaps thousands more who will, the experience most certainly changes your perspective. The things that we share in our world are far more valuable than those which divide us.</p>
|
||||
<h2 class="section-heading">Heading</h2>
|
||||
<p>There can be no thought of finishing for ‘aiming for the stars.’ Both figuratively and literally, it is a task to occupy the generations. And no matter how much progress one makes, there is always the thrill of just beginning.</p>
|
||||
<p>There can be no thought of finishing for ‘aiming for the stars.’ Both figuratively and literally, it is a task to occupy the generations. And no matter how much progress one makes, there is always the thrill of just beginning.</p>
|
||||
<figure>
|
||||
<blockquote class="blockquote">
|
||||
<p class="mb-0">The dreams of yesterday are the hopes of today and the reality of tomorrow. Science has not yet mastered prophecy. We predict too much for the next year and yet far too little for the next ten.</p>
|
||||
</blockquote>
|
||||
</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 – that’s 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 there’s a fundamental truth to our nature, Man must explore, and this is exploration at its greatest.</p>
|
||||
<p><span>Placeholder text by </span><a href="http://spaceipsum.com">Space Ipsum</a><span> Photographs by </span><a href="https://www.flickr.com/photos/nasacommons/">NASA on The Commons</a>.</p>
|
||||
<img class="img-fluid" src="/{{post.image_path}}">
|
||||
{{post.text|safe}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
9
src/app/utils/custom_functions.py
Normal file
9
src/app/utils/custom_functions.py
Normal file
@ -0,0 +1,9 @@
|
||||
import re
|
||||
|
||||
|
||||
def html2text(html: str) -> str:
|
||||
return re.sub(
|
||||
re.compile('<.*?>'),
|
||||
'',
|
||||
html
|
||||
)
|
@ -15,7 +15,6 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from fastapi import (
|
||||
Cookie,
|
||||
Depends,
|
||||
File,
|
||||
HTTPException,
|
||||
UploadFile,
|
||||
status
|
||||
|
@ -19,6 +19,7 @@ from app import crud
|
||||
from app.core.config import settings
|
||||
from app.schemas import ListPostInTemplate
|
||||
from app.schemas.main import SendEmail
|
||||
from app.schemas.post import PostDetail
|
||||
from app.utils.email_utils import send_email_notification
|
||||
from app.models.user import User as UserModel
|
||||
from app.views.depends import (
|
||||
@ -94,4 +95,28 @@ async def blog(
|
||||
'skip': skip,
|
||||
'user': user
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@router.get('/blog/{slug}', response_class=HTMLResponse)
|
||||
async def blog_post(
|
||||
request: Request,
|
||||
slug: str,
|
||||
db: AsyncSession = Depends(get_async_db)
|
||||
):
|
||||
|
||||
post = await crud.post.get_by_slug(db, slug=slug)
|
||||
|
||||
if post is None:
|
||||
return RedirectResponse(
|
||||
str(request.url_for('blog')),
|
||||
status_code=303
|
||||
)
|
||||
|
||||
return templates.TemplateResponse(
|
||||
'post.html',
|
||||
{
|
||||
'request': request,
|
||||
'post': PostDetail.model_validate(post)
|
||||
}
|
||||
)
|
@ -1,5 +1,8 @@
|
||||
from datetime import timedelta
|
||||
from typing import Annotated, Optional
|
||||
from typing import (
|
||||
Annotated,
|
||||
Optional
|
||||
)
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
@ -46,7 +49,7 @@ templates = Jinja2Templates(directory=settings.APP_PATH / 'templates')
|
||||
|
||||
|
||||
@router.get(
|
||||
f"/{settings.SECRET_KEY[-10:]}",
|
||||
f"/{settings.LOGIN_URL}",
|
||||
response_class=HTMLResponse,
|
||||
include_in_schema=False
|
||||
)
|
||||
@ -58,13 +61,13 @@ async def login(
|
||||
'admin/login.html',
|
||||
{
|
||||
'request': request,
|
||||
'login_url': f'/{settings.SECRET_KEY[-10:]}'
|
||||
'login_url': f'/{settings.LOGIN_URL}'
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
f"/{settings.SECRET_KEY[-10:]}",
|
||||
f"/{settings.LOGIN_URL}",
|
||||
response_model=JWTToken,
|
||||
include_in_schema=False
|
||||
)
|
||||
@ -144,7 +147,7 @@ async def get_update_post(
|
||||
|
||||
if user is None:
|
||||
return RedirectResponse(
|
||||
f'/{settings.SECRET_KEY[-10:]}',
|
||||
f'/{settings.LOGIN_URL}',
|
||||
status_code=303
|
||||
)
|
||||
|
||||
@ -173,7 +176,7 @@ async def update_post(
|
||||
|
||||
if user is None:
|
||||
return RedirectResponse(
|
||||
f'/{settings.SECRET_KEY[-10:]}',
|
||||
f'/{settings.LOGIN_URL}',
|
||||
status_code=303
|
||||
)
|
||||
|
||||
@ -200,6 +203,45 @@ async def update_post(
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@router.get('/delete-post/{slug}')
|
||||
async def get_delete_post(
|
||||
request: Request,
|
||||
user: UserModel = Depends(get_current_active_superuser_or_die),
|
||||
post: str = Depends(get_post_by_slug_or_die)
|
||||
):
|
||||
|
||||
if user.id != post.owner_id:
|
||||
raise HTTPException(status_code=404, detail="Post not found")
|
||||
|
||||
return templates.TemplateResponse(
|
||||
'admin/delete-post.html',
|
||||
{
|
||||
'request': request,
|
||||
'post': PostSchema.model_validate(post)
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@router.post('/delete-post/{slug}')
|
||||
async def delete_post(
|
||||
request: Request,
|
||||
user: UserModel = Depends(get_current_active_superuser_or_die),
|
||||
post: str = Depends(get_post_by_slug_or_die),
|
||||
db: AsyncSession = Depends(get_async_db)
|
||||
):
|
||||
|
||||
if user.id != post.owner_id:
|
||||
raise HTTPException(status_code=404, detail="Post not found")
|
||||
|
||||
await crud.post.remove_by_slug(db, slug=post.slug)
|
||||
|
||||
return RedirectResponse(
|
||||
str(request.url_for('blog')),
|
||||
status_code=303
|
||||
)
|
||||
|
||||
|
||||
@router.get("/admin")
|
||||
def admin(
|
||||
request: Request
|
||||
|
Loading…
x
Reference in New Issue
Block a user