Skip to content

Commit e64f033

Browse files
authored
Merge pull request #7 from Quenary/async-backend
Async backend
2 parents 0b14c49 + f073bd4 commit e64f033

File tree

19 files changed

+393
-250
lines changed

19 files changed

+393
-250
lines changed

.env.example

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,11 +20,15 @@ ACCESS_TOKEN_LIFETIME_MIN=5
2020
REFRESH_TOKEN_LIFETIME_MIN=43200
2121
PASSWORD_RECOVERY_CODE_LIFETIME_MIN=15
2222

23-
# Default is sqlite file located in container's /cardholder_pwa,
24-
# mounted to $HOME/.cardholder_pwa if using provided docker command or docker-compose
25-
# DB_URL=sqlite:////cardholder_pwa/cardholder_pwa.db
23+
# Default is sqlite file located in container's "/cardholder_pwa/cardholder_pwa.db",
24+
# mounted to host's "$HOME/.cardholder_pwa" if using provided docker command or docker-compose
25+
# DB_URL=sqlite+aiosqlite:////cardholder_pwa/cardholder_pwa.db
26+
# Supported drivers are:
27+
# "sqlite+aiosqlite:" for sqlite
28+
# "postgresql+asyncpg:" for postgresql
29+
# "mysql+asyncmy:" for mysql or mariadb
2630
DB_URL=
27-
DB_CLEANUP_INTERVAL_MIN=60
31+
DB_CLEANUP_INTERVAL_MIN=360
2832

2933
# SMTP client configuration.
3034
# It is used to send password recovery emails.

backend/alembic/env.py

Lines changed: 16 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,6 @@
1-
from app.db import Base, engine
2-
3-
1+
from app.db import Base, async_engine
42
from logging.config import fileConfig
5-
6-
from sqlalchemy import engine_from_config
7-
from sqlalchemy import pool
8-
3+
import asyncio
94
from alembic import context
105

116
# this is the Alembic Config object, which provides
@@ -53,27 +48,22 @@ def run_migrations_offline() -> None:
5348
context.run_migrations()
5449

5550

56-
def run_migrations_online() -> None:
57-
"""Run migrations in 'online' mode.
51+
def do_run_migrations(connection):
52+
context.configure(connection=connection, target_metadata=target_metadata)
53+
54+
with context.begin_transaction():
55+
context.run_migrations()
5856

59-
In this scenario we need to create an Engine
60-
and associate a connection with the context.
6157

62-
"""
63-
# connectable = engine_from_config(
64-
# config.get_section(config.config_ini_section, {}),
65-
# prefix="sqlalchemy.",
66-
# poolclass=pool.NullPool,
67-
# )
68-
connectable = engine
69-
70-
with connectable.connect() as connection:
71-
context.configure(
72-
connection=connection, target_metadata=target_metadata
73-
)
74-
75-
with context.begin_transaction():
76-
context.run_migrations()
58+
async def run_migrations_async():
59+
connectable = async_engine
60+
async with async_engine.connect() as connection:
61+
await connection.run_sync(do_run_migrations)
62+
await connectable.dispose()
63+
64+
65+
def run_migrations_online() -> None:
66+
asyncio.run(run_migrations_async())
7767

7868

7969
if context.is_offline_mode():

backend/app/api/admin.py

Lines changed: 55 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -2,71 +2,91 @@
22
import app.schemas as schemas, app.enums as enums
33
from app.db import models
44
from app.core import auth
5-
from sqlalchemy.orm import Session
6-
from app.db import get_db
5+
from app.db import get_async_session
76
from app.core.user import delete_user
87
from app.core.smtp import EmailSender
8+
from sqlalchemy.ext.asyncio import AsyncSession
9+
from sqlalchemy import select
910

1011
router = APIRouter(tags=["admin"], prefix="/admin")
1112

1213

1314
@router.get("/users", response_model=list[schemas.User])
14-
def get_users_list(
15-
db: Session = Depends(get_db), admin: models.User = Depends(auth.is_user_admin)
15+
async def get_users_list(
16+
session: AsyncSession = Depends(get_async_session),
17+
_: models.User = Depends(auth.is_user_admin),
1618
):
17-
return db.query(models.User).all()
19+
stmt = select(models.User)
20+
result = await session.execute(stmt)
21+
return result.scalars().all()
1822

1923

2024
@router.delete("/users/{user_id}")
21-
def admin_delete_user(
25+
async def admin_delete_user(
2226
user_id: int,
23-
db: Session = Depends(get_db),
27+
session: AsyncSession = Depends(get_async_session),
2428
admin: models.User = Depends(auth.is_user_admin),
2529
):
2630
if user_id == admin.id:
2731
raise HTTPException(
2832
403, "Admin cannot delete his own account with this function."
2933
)
3034

31-
user = db.query(models.User).filter_by(id=user_id).first()
35+
stmt = select(models.User).where(models.User.id == user_id).limit(1)
36+
result = await session.execute(stmt)
37+
user = result.scalar_one_or_none()
38+
3239
if not user:
3340
raise HTTPException(404, "User not found")
3441

35-
return delete_user(db, user)
42+
if (
43+
admin.role_code == enums.EUserRole.ADMIN
44+
and user.role_code == enums.EUserRole.ADMIN
45+
):
46+
raise HTTPException(403, "Admin cannot delete admin.")
47+
48+
return await delete_user(session, user)
3649

3750

3851
@router.put(
3952
"/users/role", description="Change the user role. Only owner can change roles."
4053
)
41-
def owner_change_user_role(
54+
async def owner_change_user_role(
4255
user_id: int,
4356
role_code: enums.EUserRole,
44-
db: Session = Depends(get_db),
45-
admin: models.User = Depends(auth.is_user_owner),
57+
session: AsyncSession = Depends(get_async_session),
58+
owner: models.User = Depends(auth.is_user_owner),
4659
):
60+
owner_assign_error = HTTPException(403, "Owner role cannot be reassigned.")
61+
4762
if role_code == enums.EUserRole.OWNER:
48-
raise HTTPException(403, "Owner role cannot be assigned.")
63+
raise owner_assign_error
4964

50-
if user_id == admin.id:
51-
raise HTTPException(403, "Owner cannot change his own role.")
65+
if user_id == owner.id:
66+
raise owner_assign_error
67+
68+
stmt = select(models.User).where(models.User.id == user_id).limit(1)
69+
result = await session.execute(stmt)
70+
user = result.scalar_one_or_none()
5271

53-
user = db.query(models.User).filter_by(id=user_id).first()
5472
if not user:
5573
raise HTTPException(404, "User not found")
5674

57-
if user.role_code == enums.EUserRole.OWNER:
58-
raise HTTPException(403, "Owner role cannot be reassigned.")
59-
6075
user.role_code = role_code
61-
db.commit()
76+
await session.commit()
77+
await session.refresh(user)
78+
return {"detail": "User role has been changed"}
6279

6380

6481
@router.get("/settings", response_model=schemas.GetSettingsRequest)
65-
def get_system_settings(
66-
db: Session = Depends(get_db), admin: models.User = Depends(auth.is_user_admin)
82+
async def get_system_settings(
83+
session: AsyncSession = Depends(get_async_session),
84+
_: models.User = Depends(auth.is_user_admin),
6785
):
68-
settings = db.query(models.Setting).all()
69-
result = []
86+
stmt = select(models.Setting)
87+
result = await session.execute(stmt)
88+
settings = result.scalars()
89+
res = []
7090
for s in settings:
7191
val = s.value
7292
try:
@@ -79,7 +99,7 @@ def get_system_settings(
7999
except Exception:
80100
pass
81101

82-
result.append(
102+
res.append(
83103
{
84104
"key": s.key,
85105
"value": val,
@@ -88,17 +108,19 @@ def get_system_settings(
88108
}
89109
)
90110

91-
return result
111+
return res
92112

93113

94114
@router.patch("/settings")
95-
def change_system_settings(
115+
async def change_system_settings(
96116
request: list[schemas.PatchSettingsRequestItem],
97-
db: Session = Depends(get_db),
98-
admin: models.User = Depends(auth.is_user_admin),
117+
session: AsyncSession = Depends(get_async_session),
118+
_: models.User = Depends(auth.is_user_admin),
99119
):
100120
for s in request:
101-
setting = db.query(models.Setting).filter_by(key=s.key).first()
121+
stmt = select(models.Setting).where(models.Setting.key == s.key).limit(1)
122+
result = await session.execute(stmt)
123+
setting = result.scalar_one_or_none()
102124
if not setting:
103125
raise HTTPException(status_code=404, detail=f"Setting '{s.key}' not found")
104126
if setting.value_type != type(s.value).__name__:
@@ -108,17 +130,17 @@ def change_system_settings(
108130

109131
setting.value = str(s.value)
110132

111-
db.commit()
133+
await session.commit()
112134
return {"status": "updated", "count": len(request)}
113135

114136

115137
@router.get("/smtp/status", response_model=bool)
116-
def get_smtp_status(_: models.User = Depends(auth.is_user_admin)):
138+
async def get_smtp_status(_: models.User = Depends(auth.is_user_admin)):
117139
return EmailSender.status()
118140

119141

120142
@router.post("/smtp/test")
121-
def send_test_email(admin: models.User = Depends(auth.is_user_admin)):
143+
async def send_test_email(admin: models.User = Depends(auth.is_user_admin)):
122144
EmailSender.send_email(
123145
admin.email,
124146
"Test email from Cardholder PWA",

backend/app/api/auth.py

Lines changed: 48 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,63 +1,89 @@
11
from fastapi import APIRouter, Depends, HTTPException, status, Request
2-
from sqlalchemy.orm import Session
32
import app.db.models as models, app.db as db, app.schemas as schemas, app.core.auth as auth
43
from fastapi.security import OAuth2PasswordRequestForm
5-
from typing import cast
6-
from starlette.datastructures import Address
74
from app.helpers import now
5+
from sqlalchemy.ext.asyncio import AsyncSession
6+
from sqlalchemy import select
7+
from sqlalchemy.orm import selectinload
88

99
router = APIRouter(tags=["auth"])
1010

1111

1212
@router.post("/token", response_model=schemas.TokenResponse)
13-
def login(
13+
async def login(
1414
request: Request,
1515
form_data: OAuth2PasswordRequestForm = Depends(),
16-
db: Session = Depends(db.get_db),
16+
session: AsyncSession = Depends(db.get_async_session),
1717
):
18-
user = auth.authenticate_user(db, form_data.username, form_data.password)
18+
user = await auth.authenticate_user(session, form_data.username, form_data.password)
1919
if not user:
2020
raise HTTPException(
2121
status_code=status.HTTP_401_UNAUTHORIZED,
2222
detail="Incorrect username or password",
2323
headers={"WWW-Authenticate": "Bearer"},
2424
)
2525
access_token, access_exp = auth.create_access_token({"sub": user.username})
26-
refresh_token = auth.create_refresh_token(
26+
refresh_token = await auth.create_refresh_token(
2727
user.id,
28-
db,
28+
session,
2929
request.headers.get("user-agent"),
30-
cast(Address, request.client).host,
30+
request.client.host if request.client else None,
3131
)
3232
return auth.build_token_response(access_token, access_exp, refresh_token)
3333

3434

3535
@router.post("/token/refresh", response_model=schemas.TokenResponse)
36-
def refresh_token(
36+
async def refresh_token(
3737
request: Request,
3838
form: schemas.RefreshRequest,
39-
db: Session = Depends(db.get_db),
39+
session: AsyncSession = Depends(db.get_async_session),
4040
):
41-
db_token = (
42-
db.query(models.RefreshToken)
43-
.filter_by(token=form.refresh_token, revoked=False)
44-
.first()
41+
stmt = (
42+
select(models.RefreshToken)
43+
.where(
44+
models.RefreshToken.token == form.refresh_token,
45+
models.RefreshToken.revoked == False,
46+
models.RefreshToken.expires_at > now(),
47+
)
48+
.options(selectinload(models.RefreshToken.user))
49+
.limit(1)
4550
)
46-
if not db_token or db_token.expires_at < now():
51+
result = await session.execute(stmt)
52+
db_token = result.scalar_one_or_none()
53+
if not db_token:
4754
raise HTTPException(status_code=401, detail="Invalid or expired refresh token")
55+
56+
db_token.revoked = True
57+
await session.commit()
58+
4859
user = db_token.user
49-
auth.revoke_refresh_token(db, db_token.token)
5060
access_token, access_exp = auth.create_access_token({"sub": user.username})
51-
new_refresh = auth.create_refresh_token(
61+
new_refresh = await auth.create_refresh_token(
5262
user.id,
53-
db,
63+
session,
5464
request.headers.get("user-agent"),
55-
cast(Address, request.client).host,
65+
request.client.host if request.client else None,
5666
)
5767
return auth.build_token_response(access_token, access_exp, new_refresh)
5868

5969

6070
@router.post("/logout")
61-
def logout(form: schemas.RevokeRequest, db: Session = Depends(db.get_db)):
62-
auth.revoke_refresh_token(db, form.refresh_token)
71+
async def logout(
72+
form: schemas.RevokeRequest,
73+
session: AsyncSession = Depends(db.get_async_session),
74+
_=Depends(auth.is_user),
75+
):
76+
stmt = (
77+
select(models.RefreshToken)
78+
.where(
79+
models.RefreshToken.token == form.refresh_token,
80+
models.RefreshToken.revoked == False,
81+
)
82+
.limit(1)
83+
)
84+
result = await session.execute(stmt)
85+
db_token = result.scalar_one_or_none()
86+
if db_token:
87+
db_token.revoked = True
88+
await session.commit()
6389
return {"detail": "Logged out successfully."}

0 commit comments

Comments
 (0)