Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -23,4 +23,5 @@ QF_CLIENT_SECRET="your-qf-client-secret"
QF_AUTH_BASE_URL="https://prelive-oauth2.quran.foundation"
QF_API_BASE_URL="https://apis-prelive.quran.foundation"
QF_REDIRECT_URI="http://localhost:8000/auth/qf/callback"
QF_MUSHAF_ID=4
FRONTEND_URL="http://localhost:3000"
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
"""add bookmarks table

Revision ID: 788ac94a8323
Revises: add_qf_oauth_fields_to_users
Create Date: 2026-05-19 17:49:39.129853+00:00

"""

from collections.abc import Sequence

import sqlalchemy as sa
from sqlalchemy.dialects import postgresql

from alembic import op

# revision identifiers, used by Alembic.
revision: str = "788ac94a8323"
down_revision: str | Sequence[str] | None = "add_qf_oauth_fields_to_users"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None


def upgrade() -> None:
op.add_column("bookmarks", sa.Column("surah_number", sa.Integer(), nullable=True))
op.add_column("bookmarks", sa.Column("verse_number", sa.Integer(), nullable=True))

conn = op.get_bind()
conn.execute(
sa.text("""
UPDATE bookmarks
SET surah_number = split_part(ayah_key, ':', 1)::integer,
verse_number = split_part(ayah_key, ':', 2)::integer
""")
)

op.alter_column("bookmarks", "surah_number", nullable=False)
op.alter_column("bookmarks", "verse_number", nullable=False)
op.create_foreign_key(None, "bookmarks", "users", ["user_id"], ["id"])
op.drop_column("bookmarks", "note")
op.drop_column("bookmarks", "extra_data")
op.drop_column("bookmarks", "translation")
op.drop_column("bookmarks", "arabic_text")
op.drop_column("bookmarks", "surah_name")
op.drop_index(op.f("ix_notes_user_id"), table_name="notes")
op.create_foreign_key(
None, "notes", "users", ["user_id"], ["id"], ondelete="CASCADE"
)


def downgrade() -> None:
op.drop_constraint(None, "notes", type_="foreignkey")
op.create_index(op.f("ix_notes_user_id"), "notes", ["user_id"], unique=False)
op.add_column(
"bookmarks",
sa.Column(
"surah_name", sa.VARCHAR(length=100), autoincrement=False, nullable=False
),
)
op.add_column(
"bookmarks",
sa.Column("arabic_text", sa.TEXT(), autoincrement=False, nullable=False),
)
op.add_column(
"bookmarks",
sa.Column("translation", sa.TEXT(), autoincrement=False, nullable=False),
)
op.add_column(
"bookmarks",
sa.Column(
"extra_data",
postgresql.JSON(astext_type=sa.Text()),
autoincrement=False,
nullable=True,
),
)
op.add_column(
"bookmarks", sa.Column("note", sa.TEXT(), autoincrement=False, nullable=True)
)
op.drop_constraint(None, "bookmarks", type_="foreignkey")
op.drop_column("bookmarks", "verse_number")
op.drop_column("bookmarks", "surah_number")
39 changes: 39 additions & 0 deletions apps/api/alembic/versions/20260520_0000-drop_bookmarks_table.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
"""drop bookmarks table

Revision ID: drop_bookmarks_table
Revises: 788ac94a8323
Create Date: 2026-05-20 00:00:00.000000+00:00

"""

from collections.abc import Sequence

import sqlalchemy as sa

from alembic import op

revision: str = "drop_bookmarks_table"
down_revision: str | Sequence[str] | None = "788ac94a8323"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None


def upgrade() -> None:
op.drop_table("bookmarks")


def downgrade() -> None:
op.create_table(
"bookmarks",
sa.Column("id", sa.UUID(), nullable=False),
sa.Column("user_id", sa.UUID(), nullable=False),
sa.Column("ayah_key", sa.String(length=20), nullable=False),
sa.Column("surah_number", sa.Integer(), nullable=False),
sa.Column("verse_number", sa.Integer(), nullable=False),
sa.Column("created_at", sa.DateTime(), nullable=False),
sa.PrimaryKeyConstraint("id"),
sa.ForeignKeyConstraint(["user_id"], ["users.id"]),
)
op.create_index(
op.f("ix_bookmarks_user_id"), "bookmarks", ["user_id"], unique=False
)
2 changes: 1 addition & 1 deletion apps/api/app/api/auth/router.py
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,7 @@ async def qf_authorize(request: Request):
)
)

scope = "openid offline_access user collection"
scope = "openid offline_access user collection bookmark"

oauth_data = {
"code_verifier": code_verifier,
Expand Down
1 change: 1 addition & 0 deletions apps/api/app/api/bookmarks/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@

148 changes: 26 additions & 122 deletions apps/api/app/api/bookmarks/router.py
Original file line number Diff line number Diff line change
@@ -1,146 +1,50 @@
from uuid import UUID
from typing import Annotated

from fastapi import APIRouter, Body, Depends, HTTPException, status
from fastapi import APIRouter, Depends, status
from sqlalchemy.ext.asyncio import AsyncSession

from app.api.auth.router import get_current_user
from app.api.bookmarks.serializer import (
BookmarkCreate,
BookmarkListResponse,
BookmarkResponse,
NoteCreate,
NoteListResponse,
NoteResponse,
)
from app.core.database import get_db
from app.models.user import User
from app.modules.bookmarks import service

router = APIRouter(prefix="/bookmarks", tags=["bookmarks"])


@router.post("", status_code=status.HTTP_201_CREATED, response_model=BookmarkResponse)
async def create_bookmark(
body: BookmarkCreate,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
bookmark = await service.create_bookmark(
db,
user_id=current_user.id,
ayah_key=body.ayah_key,
surah_name=body.surah_name,
arabic_text=body.arabic_text,
translation=body.translation,
note=body.note,
extra_data=body.extra_data,
)
return bookmark
CurrentUserDep = Annotated[User, Depends(get_current_user)]
DbDep = Annotated[AsyncSession, Depends(get_db)]


@router.get("", response_model=BookmarkListResponse)
async def get_bookmarks(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
bookmarks = await service.get_bookmarks(db, current_user.id)
return BookmarkListResponse(
bookmarks=[
BookmarkResponse(
id=b.id,
user_id=b.user_id,
ayah_key=b.ayah_key,
surah_name=b.surah_name,
arabic_text=b.arabic_text,
translation=b.translation,
note=b.note,
extra_data=b.extra_data,
created_at=b.created_at,
)
for b in bookmarks
]
)
db: DbDep,
current_user: CurrentUserDep,
) -> BookmarkListResponse:
bookmarks = await service.list_bookmarks(db, current_user)
return BookmarkListResponse(bookmarks=bookmarks)


@router.post(
"",
status_code=status.HTTP_201_CREATED,
response_model=BookmarkResponse,
)
async def create_bookmark(
body: BookmarkCreate,
db: DbDep,
current_user: CurrentUserDep,
) -> BookmarkResponse:
return await service.create_bookmark(db, current_user, body.ayah_key)


@router.delete("/{bookmark_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_bookmark(
bookmark_id: str,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
success = await service.delete_bookmark(db, UUID(bookmark_id), current_user.id)
if not success:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Bookmark not found",
)


@router.post("/notes", status_code=status.HTTP_201_CREATED, response_model=NoteResponse)
async def create_note(
body: NoteCreate,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
note = await service.create_note(
db,
user_id=current_user.id,
topic=body.topic,
content=body.content,
verses=body.verses,
)
return note


@router.get("/notes", response_model=NoteListResponse)
async def get_notes(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
notes = await service.get_notes(db, current_user.id)
return NoteListResponse(
notes=[
NoteResponse(
id=n.id,
user_id=n.user_id,
topic=n.topic,
content=n.content,
verses=n.verses,
created_at=n.created_at,
updated_at=n.updated_at,
)
for n in notes
]
)


@router.patch("/notes/{note_id}", response_model=NoteResponse)
async def update_note(
note_id: str,
content: str = Body(...),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
note = await service.update_note(
db, UUID(note_id), current_user.id, content=content
)
if not note:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Note not found",
)
return note


@router.delete("/notes/{note_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_note(
note_id: str,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
success = await service.delete_note(db, UUID(note_id), current_user.id)
if not success:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Note not found",
)
db: DbDep,
current_user: CurrentUserDep,
) -> None:
await service.delete_bookmark(db, current_user, bookmark_id)
44 changes: 10 additions & 34 deletions apps/api/app/api/bookmarks/serializer.py
Original file line number Diff line number Diff line change
@@ -1,51 +1,27 @@
from datetime import datetime
from uuid import UUID

from pydantic import BaseModel


class BookmarkCreate(BaseModel):
ayah_key: str
surah_name: str
arabic_text: str
translation: str
note: str | None = None
extra_data: dict | None = None


class BookmarkResponse(BaseModel):
id: UUID
id: str
ayah_key: str
type: str
surah_number: int
surah_name: str
arabic_text: str
translation: str
note: str | None = None
extra_data: dict | None = None
verse_number: int
group: str | None = None
is_in_default_collection: bool = True
is_reading: bool | None = None
collections_count: int | None = None
created_at: datetime

model_config = {"from_attributes": True}
arabic_text: str = ""
translation: str = ""


class BookmarkListResponse(BaseModel):
bookmarks: list[BookmarkResponse]


class NoteCreate(BaseModel):
topic: str
content: str
verses: list[dict] | None = None


class NoteResponse(BaseModel):
id: UUID
topic: str
content: str
verses: list[dict] | None = None
created_at: datetime
updated_at: datetime

model_config = {"from_attributes": True}


class NoteListResponse(BaseModel):
notes: list[NoteResponse]
Empty file.
Loading
Loading