Skip to content

Commit 98c5418

Browse files
committed
added pagination and forgot password & reset password
1 parent 3314004 commit 98c5418

30 files changed

Lines changed: 1098 additions & 219 deletions

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
### Database Files ###
88
*.sqlite3
99
*.db
10+
populate_db.py
1011

1112
### macOS ###
1213
# General

auth.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55
import jwt
66
from fastapi.security import OAuth2PasswordBearer
77
from pwdlib import PasswordHash
8+
import hashlib
9+
import secrets
810

911
from config import settings
1012

@@ -28,6 +30,12 @@ def hash_password(password: str) -> str:
2830
def verify_password(plain_password: str, hashed_password: str) -> bool:
2931
return password_hash.verify(plain_password, hashed_password)
3032

33+
def generate_reset_token() -> str:
34+
return secrets.token_urlsafe(32)
35+
36+
def hash_reset_token(token: str) -> str:
37+
return hashlib.sha256(token.encode()).hexdigest()
38+
3139

3240
def create_access_token(data: dict, expires_delta: timedelta | None = None) -> str:
3341
"""Create a JWT access token."""

config.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,5 +13,18 @@ class Settings(BaseSettings):
1313

1414
max_upload_size_bytes: int = 5 * 1024 * 1024
1515

16+
posts_per_page: int = 10
17+
18+
reset_token_expire_minutes: int = 60
19+
20+
mail_server: str = "localhost"
21+
mail_port: int = 587
22+
mail_username: str = ""
23+
mail_password: SecretStr = SecretStr("")
24+
mail_from: str = "noreply@example.com"
25+
mail_use_tls: bool = True
26+
27+
frontend_url: str = "http://localhost:8000"
28+
1629

1730
settings = Settings() # Loaded from .env file

email_utils.py

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
from email import message
2+
from email.message import EmailMessage
3+
4+
import aiosmtplib
5+
from fastapi.templating import Jinja2Templates
6+
7+
from config import settings
8+
9+
templates = Jinja2Templates(directory="templates")
10+
11+
12+
async def send_email(
13+
to_email: str,
14+
subject: str,
15+
plain_text: str,
16+
html_content: str | None = None,
17+
) -> None:
18+
message = EmailMessage()
19+
message["From"] = settings.mail_from
20+
message["To"] = to_email
21+
message["Subject"] = subject
22+
23+
message.set_content(plain_text)
24+
25+
if html_content:
26+
message.add_alternative(html_content, subtype="html")
27+
28+
await aiosmtplib.send(
29+
message,
30+
hostname=settings.mail_server,
31+
port=settings.mail_port,
32+
username=settings.mail_username if settings.mail_username else None,
33+
password=settings.mail_password.get_secret_value() or None,
34+
start_tls=settings.mail_use_tls,
35+
)
36+
37+
38+
async def send_password_reset_email(to_email: str, username: str, token: str) -> None:
39+
reset_url = f"{settings.frontend_url}/reset-password?token={token}"
40+
41+
template = templates.env.get_template("email/password_reset.html")
42+
html_content = template.render(reset_url=reset_url, username=username)
43+
44+
plain_text = f"""Hi {username},
45+
46+
You requested to reset your password. Click the link below to set a new password:
47+
48+
{reset_url}
49+
50+
This link will expire in 1 hour.
51+
52+
If you didn't request this, you can safely ignore this email.
53+
54+
Best regards,
55+
The FastAPI Blog Team
56+
"""
57+
58+
await send_email(
59+
to_email=to_email,
60+
subject="Reset Your Password - FastAPI Blog",
61+
plain_text=plain_text,
62+
html_content=html_content,
63+
)

main.py

Lines changed: 53 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,14 +9,13 @@
99
from fastapi.exceptions import RequestValidationError
1010
from fastapi.staticfiles import StaticFiles
1111
from fastapi.templating import Jinja2Templates
12-
from sqlalchemy import select
12+
from sqlalchemy import select, func
1313
from sqlalchemy.ext.asyncio import AsyncSession
1414
from sqlalchemy.orm import selectinload
1515
from starlette.exceptions import HTTPException as StarletteHTTPException
16-
1716
import models
1817
from database import Base, engine, get_db
19-
18+
from config import settings
2019
from routers import users, posts
2120

2221

@@ -44,16 +43,28 @@ async def lifespan(_app: FastAPI):
4443
@app.get("/", include_in_schema=False, name="home")
4544
@app.get("/posts", include_in_schema=False, name="posts")
4645
async def home(request: Request, db: Annotated[AsyncSession, Depends(get_db)]):
46+
count_result = await db.execute(select(func.count()).select_from(models.Post))
47+
total = count_result.scalar() or 0
48+
4749
result = await db.execute(
4850
select(models.Post)
4951
.options(selectinload(models.Post.author))
5052
.order_by(models.Post.date_posted.desc())
53+
.limit(settings.posts_per_page),
5154
)
5255
posts = result.scalars().all()
56+
57+
has_more = len(posts) < total
58+
5359
return templates.TemplateResponse(
5460
request,
5561
"home.html",
56-
{"posts": posts, "title": "Home"},
62+
{
63+
"posts": posts,
64+
"title": "Home",
65+
"limit": settings.posts_per_page,
66+
"has_more": has_more,
67+
},
5768
)
5869

5970

@@ -92,17 +103,35 @@ async def user_posts_page(
92103
status_code=status.HTTP_404_NOT_FOUND,
93104
detail="User not found",
94105
)
106+
107+
count_result = await db.execute(
108+
select(func.count())
109+
.select_from(models.Post)
110+
.where(models.Post.user_id == user_id),
111+
)
112+
total = count_result.scalar() or 0
113+
95114
result = await db.execute(
96115
select(models.Post)
97116
.options(selectinload(models.Post.author))
98117
.where(models.Post.user_id == user_id)
99118
.order_by(models.Post.date_posted.desc())
119+
.limit(settings.posts_per_page),
100120
)
101121
posts = result.scalars().all()
122+
123+
has_more = len(posts) < total
124+
102125
return templates.TemplateResponse(
103126
request,
104127
"user_posts.html",
105-
{"posts": posts, "user": user, "title": f"{user.username}'s Posts"},
128+
{
129+
"posts": posts,
130+
"user": user,
131+
"title": f"{user.username}'s Posts",
132+
"limit": settings.posts_per_page,
133+
"has_more": has_more,
134+
},
106135
)
107136

108137

@@ -131,6 +160,25 @@ async def account_page(request: Request):
131160
{"title": "Account"},
132161
)
133162

163+
@app.get("/forgot-password", include_in_schema=False)
164+
async def forgot_password_page(request: Request):
165+
return templates.TemplateResponse(
166+
request,
167+
"forgot_password.html",
168+
{"title": "Forgot Password"},
169+
)
170+
171+
172+
@app.get("/reset-password", include_in_schema=False)
173+
async def reset_password_page(request: Request):
174+
response = templates.TemplateResponse(
175+
request,
176+
"reset_password.html",
177+
{"title": "Reset Password"},
178+
)
179+
response.headers["Referrer-Policy"] = "no-referrer"
180+
return response
181+
134182

135183
@app.exception_handler(StarletteHTTPException)
136184
async def general_http_exception_handler(
34.1 KB
Loading
16.8 KB
Loading
-19.8 KB
Binary file not shown.
25.4 KB
Loading

media/profile_pics/download.jpeg

-6.26 KB
Binary file not shown.

0 commit comments

Comments
 (0)