Skip to content

Commit b8413dd

Browse files
IbrahimKhan12claude
andcommitted
Offer nawawi40, qudsi40 and shahwaliullah40 as standalone collections
Their hadiths are stored as books 1-3 of the `forty` collection, so every /v1/collections/{name}/hadiths/{n} request returned 404. Resolve the three names to their `forty` book wherever a collection name enters a query, synthesize their collection resources from the book rows, list them in /v1/collections, and echo the requested name in responses. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent f99eea1 commit b8413dd

2 files changed

Lines changed: 139 additions & 36 deletions

File tree

main.py

Lines changed: 137 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,73 @@
88

99
from models import HadithCollection, Book, Chapter, Hadith
1010

11+
# sunnah.com publishes these as standalone collections, but their hadiths are
12+
# stored as books of `forty`, so every lookup must also match the book.
13+
COLLECTION_ALIASES = {
14+
"nawawi40": ("forty", "1"),
15+
"qudsi40": ("forty", "2"),
16+
"shahwaliullah40": ("forty", "3"),
17+
}
18+
19+
20+
def resolve_collection(name):
21+
"""Return the collection and book that store the hadiths of `name`."""
22+
return COLLECTION_ALIASES.get(name, (name, None))
23+
24+
25+
def serialize_as(name):
26+
"""Echo the requested name so an alias response never reports `forty`."""
27+
return {"collection": name} if name in COLLECTION_ALIASES else {}
28+
29+
30+
def alias_collection(name):
31+
"""Build the collection resource of an alias from its book row."""
32+
collection, book_number = COLLECTION_ALIASES[name]
33+
book = Book.query.filter_by(collection=collection, status=4, ourBookID=book_number).first()
34+
35+
if book is None:
36+
return None
37+
38+
return {
39+
"name": name,
40+
"hasBooks": "no",
41+
"hasChapters": "no",
42+
"collection": [
43+
{"lang": "en", "title": book.englishBookName, "shortIntro": ""},
44+
{"lang": "ar", "title": book.arabicBookName, "shortIntro": ""},
45+
],
46+
"totalHadith": book.totalNumber,
47+
"totalAvailableHadith": book.totalNumber,
48+
}
49+
50+
51+
def resolve_book_collection(name, book_id):
52+
"""Resolve `name`, rejecting a book that is not the alias's own."""
53+
collection, book_number = resolve_collection(name)
54+
55+
if book_number is not None and book_id != book_number:
56+
abort(404)
57+
58+
return collection
59+
60+
61+
def ref_condition(collection, hadith_number):
62+
"""Build the filter matching one `collection:hadithNumber` reference."""
63+
name, book_number = resolve_collection(collection)
64+
condition = and_(Hadith.collection == name, Hadith.hadithNumber == hadith_number)
65+
return condition if book_number is None else and_(condition, Hadith.bookNumber == book_number)
66+
67+
68+
def ref_match(results, collection, hadith_number):
69+
"""Find the hadith matching one reference, scoped to an alias's own book."""
70+
name, book_number = resolve_collection(collection)
71+
72+
for h in results:
73+
if h.collection == name and h.hadithNumber == hadith_number and book_number in (None, h.bookNumber):
74+
return h
75+
76+
return None
77+
1178

1279
@app.before_request
1380
def verify_secret():
@@ -22,15 +89,21 @@ def jsonify_http_error(error):
2289
return jsonify(response), error.code
2390

2491

92+
def unpack_query(result):
93+
"""Allow a route to return a query, or a (query, serialize kwargs) pair."""
94+
return result if isinstance(result, tuple) else (result, {})
95+
96+
2597
def paginate_results(f):
2698
@functools.wraps(f)
2799
def decorated_function(*args, **kwargs):
28100
limit = int(request.args.get("limit", 50))
29101
page = int(request.args.get("page", 1))
30102

31-
queryset = f(*args, **kwargs).paginate(page=page, per_page=limit, max_per_page=100)
103+
query, opts = unpack_query(f(*args, **kwargs))
104+
queryset = query.paginate(page=page, per_page=limit, max_per_page=100)
32105
result = {
33-
"data": [x.serialize() for x in queryset.items],
106+
"data": [x.serialize(**opts) for x in queryset.items],
34107
"total": queryset.total,
35108
"limit": queryset.per_page,
36109
"previous": queryset.prev_num,
@@ -44,67 +117,109 @@ def decorated_function(*args, **kwargs):
44117
def single_resource(f):
45118
@functools.wraps(f)
46119
def decorated_function(*args, **kwargs):
47-
result = f(*args, **kwargs).first_or_404()
48-
result = result.serialize()
120+
query, opts = unpack_query(f(*args, **kwargs))
121+
result = query.first_or_404().serialize(**opts)
49122
return jsonify(result)
50123

51124
return decorated_function
52125

53126

127+
def paginate_items(items):
128+
"""Paginate a serialized list the way `paginate_results` paginates a query."""
129+
limit = min(int(request.args.get("limit", 50)), 100)
130+
page = int(request.args.get("page", 1))
131+
start = (page - 1) * limit
132+
window = items[start:][:limit]
133+
134+
if limit < 1 or page < 1 or (not window and page != 1):
135+
abort(404)
136+
137+
return jsonify(
138+
{
139+
"data": window,
140+
"total": len(items),
141+
"limit": limit,
142+
"previous": page - 1 if page > 1 else None,
143+
"next": page + 1 if start + limit < len(items) else None,
144+
}
145+
)
146+
147+
54148
@app.route("/", methods=["GET"])
55149
def home():
56150
return "<h1>Welcome to sunnah.com API.</h1>"
57151

58152

59153
@app.route("/v1/collections", methods=["GET"])
60-
@paginate_results
61154
def api_collections():
62-
return HadithCollection.query.order_by(HadithCollection.collectionID)
155+
items = [x.serialize() for x in HadithCollection.query.order_by(HadithCollection.collectionID)]
156+
stored = {x["name"] for x in items}
157+
aliases = [alias_collection(name) for name in COLLECTION_ALIASES if name not in stored]
158+
return paginate_items(items + [x for x in aliases if x is not None])
63159

64160

65161
@app.route("/v1/collections/<string:name>", methods=["GET"])
66-
@single_resource
67162
def api_collection(name):
68-
return HadithCollection.query.filter_by(name=name)
163+
row = HadithCollection.query.filter_by(name=name).first()
164+
result = row.serialize() if row is not None else alias_collection(name) if name in COLLECTION_ALIASES else None
165+
166+
if result is None:
167+
abort(404)
168+
169+
return jsonify(result)
69170

70171

71172
@app.route("/v1/collections/<string:name>/books", methods=["GET"])
72173
@paginate_results
73174
def api_collection_books(name):
74-
return Book.query.filter_by(collection=name, status=4).order_by(func.abs(Book.ourBookID))
175+
collection, book_number = resolve_collection(name)
176+
query = Book.query.filter_by(collection=collection, status=4).order_by(func.abs(Book.ourBookID))
177+
return query if book_number is None else query.filter_by(ourBookID=book_number)
75178

76179

77180
@app.route("/v1/collections/<string:name>/books/<string:bookNumber>", methods=["GET"])
78181
@single_resource
79182
def api_collection_book(name, bookNumber):
80183
book_id = Book.get_id_from_number(bookNumber)
81-
return Book.query.filter_by(collection=name, status=4, ourBookID=book_id)
184+
collection = resolve_book_collection(name, book_id)
185+
return Book.query.filter_by(collection=collection, status=4, ourBookID=book_id)
82186

83187

84188
@app.route("/v1/collections/<string:collection_name>/books/<string:bookNumber>/hadiths", methods=["GET"])
85189
@paginate_results
86190
def api_collection_book_hadiths(collection_name, bookNumber):
87-
return Hadith.query.filter_by(collection=collection_name, bookNumber=bookNumber).order_by(Hadith.englishURN)
191+
collection = resolve_book_collection(collection_name, bookNumber)
192+
query = Hadith.query.filter_by(collection=collection, bookNumber=bookNumber).order_by(Hadith.englishURN)
193+
return query, serialize_as(collection_name)
88194

89195

90196
@app.route("/v1/collections/<string:collection_name>/hadiths/<string:hadithNumber>", methods=["GET"])
91197
@single_resource
92198
def api_collection_hadith(collection_name, hadithNumber):
93-
return Hadith.query.filter_by(collection=collection_name, hadithNumber=hadithNumber)
199+
collection, book_number = resolve_collection(collection_name)
200+
# `forty` numbers each of its books from 1, so order to keep the pick stable
201+
query = Hadith.query.filter_by(collection=collection, hadithNumber=hadithNumber).order_by(Hadith.englishURN)
202+
203+
if book_number is not None:
204+
query = query.filter_by(bookNumber=book_number)
205+
206+
return query, serialize_as(collection_name)
94207

95208

96209
@app.route("/v1/collections/<string:collection_name>/books/<string:bookNumber>/chapters", methods=["GET"])
97210
@paginate_results
98211
def api_collection_book_chapters(collection_name, bookNumber):
99212
book_id = Book.get_id_from_number(bookNumber)
100-
return Chapter.query.filter_by(collection=collection_name, arabicBookID=book_id).order_by(Chapter.babID)
213+
collection = resolve_book_collection(collection_name, book_id)
214+
return Chapter.query.filter_by(collection=collection, arabicBookID=book_id).order_by(Chapter.babID)
101215

102216

103217
@app.route("/v1/collections/<string:collection_name>/books/<string:bookNumber>/chapters/<float:chapterId>", methods=["GET"])
104218
@single_resource
105219
def api_collection_book_chapter(collection_name, bookNumber, chapterId):
106220
book_id = Book.get_id_from_number(bookNumber)
107-
return Chapter.query.filter_by(collection=collection_name, arabicBookID=book_id, babID=chapterId)
221+
collection = resolve_book_collection(collection_name, book_id)
222+
return Chapter.query.filter_by(collection=collection, arabicBookID=book_id, babID=chapterId)
108223

109224

110225
@app.route("/v1/hadiths", methods=["GET"])
@@ -115,7 +230,10 @@ def api_hadiths():
115230
# Apply filters based on query parameters
116231
collection = request.args.get("collection")
117232
if collection:
118-
query = query.filter_by(collection=collection)
233+
name, book_number = resolve_collection(collection)
234+
query = query.filter_by(collection=name)
235+
if book_number is not None:
236+
query = query.filter_by(bookNumber=book_number)
119237

120238
book_number = request.args.get("bookNumber")
121239
if book_number:
@@ -130,7 +248,7 @@ def api_hadiths():
130248
query = query.filter_by(hadithNumber=hadith_number)
131249

132250
# Order by URN for consistent results
133-
return query.order_by(Hadith.englishURN)
251+
return query.order_by(Hadith.englishURN), serialize_as(collection)
134252

135253

136254
@app.route("/v1/hadiths/<int:urn>", methods=["GET"])
@@ -243,31 +361,16 @@ def api_hadiths_by_refs():
243361
if len(refs) > MAX_REFS:
244362
abort(400, f"Too many refs (max {MAX_REFS}).")
245363

246-
results = (
247-
Hadith.query.filter(
248-
or_(
249-
*[
250-
and_(
251-
Hadith.collection == collection,
252-
Hadith.hadithNumber == hadith_number,
253-
)
254-
for collection, hadith_number in refs
255-
]
256-
)
257-
)
258-
.all()
259-
)
260-
261-
by_ref = {(h.collection, h.hadithNumber): h for h in results}
364+
results = Hadith.query.filter(or_(*[ref_condition(c, n) for c, n in refs])).order_by(Hadith.englishURN).all()
262365

263366
data = []
264367
missing = []
265368
for collection, hadith_number in refs:
266-
h = by_ref.get((collection, hadith_number))
369+
h = ref_match(results, collection, hadith_number)
267370
if h is None:
268371
missing.append(f"{collection}:{hadith_number}")
269372
else:
270-
data.append(h.serialize())
373+
data.append(h.serialize(**serialize_as(collection)))
271374

272375
return jsonify({"count": len(data), "missing": missing, "data": data})
273376

models.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -113,11 +113,11 @@ def get_grade(self, field_name):
113113
except ValueError:
114114
return [{"graded_by": getattr(self.rel_collection, field_name), "grade": grade_val}]
115115

116-
def serialize(self):
116+
def serialize(self, collection=None):
117117
grades = {"en": self.get_grade("englishgrade1"), "ar": self.get_grade("arabicgrade1")}
118118

119119
return {
120-
"collection": self.collection,
120+
"collection": collection or self.collection,
121121
"bookNumber": self.bookNumber,
122122
"chapterId": str(self.babID),
123123
"hadithNumber": self.hadithNumber,

0 commit comments

Comments
 (0)