From 69fa09baa300fe39bb75768b062e3583f623cdcd Mon Sep 17 00:00:00 2001 From: "Noel.Sudhish" Date: Thu, 29 Jan 2026 14:50:37 +0530 Subject: [PATCH 01/87] commentary table pk changed --- backend/app/db_models.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/backend/app/db_models.py b/backend/app/db_models.py index e9876408..2bfb5638 100644 --- a/backend/app/db_models.py +++ b/backend/app/db_models.py @@ -118,11 +118,10 @@ class Commentary(Base): """Corresponds to table commentary in vachan DB(postgres)""" __tablename__ = "commentary" commentary_id = Column(Integer, primary_key=True, autoincrement=True) - resource_id = Column(Integer, ForeignKey("resource.resource_id"), - primary_key=True) - book_id = Column(Integer,ForeignKey("book_lookup.book_id"), primary_key=True) - chapter = Column(Integer, primary_key=True ,nullable=False) - verse = Column(String, primary_key=True) + resource_id = Column(Integer, ForeignKey("resource.resource_id")) + book_id = Column(Integer,ForeignKey("book_lookup.book_id")) + chapter = Column(Integer ,nullable=False) + verse = Column(String) text = Column(Text,nullable=False) class Dictionary(Base): From d33a4f1f0f52ee6d7215d64806e79029d4415387 Mon Sep 17 00:00:00 2001 From: KetanKBaboo Date: Fri, 30 Jan 2026 11:09:35 +0530 Subject: [PATCH 02/87] role check added for reading plan and verse of the day page --- frontend/src/components/Datatable.tsx | 6 ++++- frontend/src/pages/ReadingPlans.tsx | 32 ++++++++++++++++----------- frontend/src/pages/VerseOfTheDay.tsx | 30 +++++++++++++++---------- 3 files changed, 42 insertions(+), 26 deletions(-) diff --git a/frontend/src/components/Datatable.tsx b/frontend/src/components/Datatable.tsx index d8c3613b..61d15780 100644 --- a/frontend/src/components/Datatable.tsx +++ b/frontend/src/components/Datatable.tsx @@ -21,6 +21,7 @@ import { RotateCcw, } from "lucide-react"; import { DataTablePagination } from "./DataTablePagination"; +import { useUserRole } from "@/hooks/useUserRole"; // ---- Alignment helper (default = center) ---- type Align = "left" | "center" | "right" | "number"; @@ -104,6 +105,8 @@ export function DataTable({ onTableReady, onClose, }: DataTableProps) { + const { isAdmin, isEditor } = useUserRole(); + const canEdit = isAdmin || isEditor; const [sorting, setSorting] = React.useState([]); const [globalFilter, setGlobalFilter] = React.useState(""); const [debouncedGlobalFilter, setDebouncedGlobalFilter] = useState(""); @@ -241,11 +244,12 @@ export function DataTable({ )} )} - {addButton && ( + {addButton && canEdit && ( , - ], - [isDeleteDisabled], + () => + canEdit + ? [ + , + ] + : [], + [canEdit, isDeleteDisabled], ); return ( diff --git a/frontend/src/pages/VerseOfTheDay.tsx b/frontend/src/pages/VerseOfTheDay.tsx index 7cabf4c4..600c2c53 100644 --- a/frontend/src/pages/VerseOfTheDay.tsx +++ b/frontend/src/pages/VerseOfTheDay.tsx @@ -14,6 +14,7 @@ import type { VerseOfTheDayRow, ApiVerseOfTheDay } from "@/utils/types"; import { Button } from "@/components/ui/button"; import { DeleteDialog } from "@/components/DeleteDialog"; import { BOOK_CODE_TO_NAME } from "@/utils/books"; +import { useUserRole } from "@/hooks/useUserRole"; const MONTHS = [ "January", @@ -73,6 +74,8 @@ function mapApiToTableRow(item: ApiVerseOfTheDay): VerseOfTheDayRow { const VerseOfTheDay = () => { const { data, isLoading, error } = useVerseOfTheDay(); + const { isAdmin, isEditor } = useUserRole(); + const canEdit = isAdmin || isEditor; const uploadMutation = useUploadVerseOfTheDay(); const deleteMutation = useDeleteVerseOfTheDay(); @@ -142,18 +145,21 @@ const VerseOfTheDay = () => { const isDeleteDisabled = deleteMutation.isPending || tableData.length === 0; const customFilters = useMemo( - () => [ - , - ], - [isDeleteDisabled], + () => + canEdit + ? [ + , + ] + : [], + [canEdit, isDeleteDisabled], ); return ( From 495518e1f3874c599c2c3fd678fe251703691a0e Mon Sep 17 00:00:00 2001 From: KetanKBaboo Date: Fri, 30 Jan 2026 11:17:02 +0530 Subject: [PATCH 03/87] changed the upload tooltip for other pages same like bible --- frontend/src/components/ContentTypeAction.tsx | 2 +- frontend/src/components/Datatable.tsx | 2 +- frontend/src/pages/OBS.tsx | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/frontend/src/components/ContentTypeAction.tsx b/frontend/src/components/ContentTypeAction.tsx index 6acd3a8d..3bf2f85f 100644 --- a/frontend/src/components/ContentTypeAction.tsx +++ b/frontend/src/components/ContentTypeAction.tsx @@ -129,7 +129,7 @@ export default function ContentTypeAction({ title={ (isAdmin || isEditor) ? `No ${contentType} data added\nClick to upload CSV file` - : `No ${contentType} data added` + : `Only admin or editor can upload ${contentType} data` } > diff --git a/frontend/src/components/Datatable.tsx b/frontend/src/components/Datatable.tsx index 61d15780..e9a590d2 100644 --- a/frontend/src/components/Datatable.tsx +++ b/frontend/src/components/Datatable.tsx @@ -248,7 +248,7 @@ export function DataTable({ From 45f4eb218644f23e23a5f67fa73e13aa4d47de67 Mon Sep 17 00:00:00 2001 From: KetanKBaboo Date: Fri, 30 Jan 2026 12:05:16 +0530 Subject: [PATCH 04/87] hide check remote data button in view dialog for reporter --- frontend/src/components/AudioBible.tsx | 2 +- frontend/src/components/UploadOrViewDialog.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/src/components/AudioBible.tsx b/frontend/src/components/AudioBible.tsx index c1f76f60..190e5561 100644 --- a/frontend/src/components/AudioBible.tsx +++ b/frontend/src/components/AudioBible.tsx @@ -526,7 +526,7 @@ export const AudioBible = ({
- {hasAnyAudio && ( + {hasAnyAudio && (isAdmin || isEditor) && (
diff --git a/frontend/src/pages/Commentaries.tsx b/frontend/src/pages/Commentaries.tsx index e6f6eda2..07af8503 100644 --- a/frontend/src/pages/Commentaries.tsx +++ b/frontend/src/pages/Commentaries.tsx @@ -15,7 +15,6 @@ import { useUploadCommentaries, useUpdateCommentaries, useDeleteCommentaries, - } from "@/hooks/useAPI"; import { extractErrorMessage } from "@/utils/errorUtils"; @@ -184,6 +183,9 @@ const CommentariesAction = ({ resource }: { resource: Resource }) => { requiredHeaders={REQUIRED_HEADERS} columns={dialogColumns} compareKeys={["bookId", "chapter", "verse"]} + compareKeyMap={{ + bookCode: "bookId", + }} identityKey="commentary_id" normalizeCsvRow={(r) => { const bookId = Number(r.bookId); @@ -272,8 +274,6 @@ function CommentaryPublishAction({ } - - const Commentaries = () => { const { data, isLoading, error } = useResources(); const { isAdmin, isEditor } = useUserRole(); diff --git a/frontend/src/utils/types.ts b/frontend/src/utils/types.ts index 4a151211..6f995440 100644 --- a/frontend/src/utils/types.ts +++ b/frontend/src/utils/types.ts @@ -323,6 +323,7 @@ export interface ContentTypeActionProps { contentType: string; compareKeys: (keyof T | string)[]; + compareKeyMap?: Record; identityKey: string; normalizeApiRow?: (row: any) => E; remoteTestConfig?: RemoteTestConfig; @@ -352,6 +353,7 @@ export interface UploadOrViewDialogProps { contentType: string; onRequestFilePick?: () => void; compareKeys: (keyof T | string)[]; + compareKeyMap?: Record; identityKey: string; normalizeApiRow?: (row: any) => any; clearPreview?: () => void; From 648a11ca089d8f0b4e3413b442e7b2e1cff84438 Mon Sep 17 00:00:00 2001 From: KetanKBaboo Date: Fri, 30 Jan 2026 17:38:56 +0530 Subject: [PATCH 08/87] fix word breaking issue of paragraphs in details dialog --- frontend/src/components/DetailsDialog.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/components/DetailsDialog.tsx b/frontend/src/components/DetailsDialog.tsx index 3cdcc81c..709da462 100644 --- a/frontend/src/components/DetailsDialog.tsx +++ b/frontend/src/components/DetailsDialog.tsx @@ -90,7 +90,7 @@ export function DetailsDialog({
-
+
{value || "No details available."}
From 05fca76695361639e55bd48866be6c20ecbe69b6 Mon Sep 17 00:00:00 2001 From: KetanKBaboo Date: Mon, 2 Feb 2026 12:49:12 +0530 Subject: [PATCH 09/87] fixed the auto-scrolling issue in the side bar menu --- frontend/src/components/Datatable.tsx | 5 ++- frontend/src/components/layout/Sidebar.tsx | 52 ++++++++++++++++++++-- 2 files changed, 52 insertions(+), 5 deletions(-) diff --git a/frontend/src/components/Datatable.tsx b/frontend/src/components/Datatable.tsx index e9a590d2..accd160e 100644 --- a/frontend/src/components/Datatable.tsx +++ b/frontend/src/components/Datatable.tsx @@ -229,6 +229,7 @@ export function DataTable({ value={globalFilter} onChange={(e) => setGlobalFilter(e.target.value)} className="px-10 py-2 w-50 border border-gray-400" + autoFocus={false} /> {globalFilter && (
{/* CONTENT AREA */} -
+
{!editMode && (
{previewMode ? ( From cc81ec04f41fdf056c87cbbe75f1b76687e8f5bb Mon Sep 17 00:00:00 2001 From: KetanKBaboo Date: Thu, 5 Feb 2026 17:18:55 +0530 Subject: [PATCH 22/87] fix scroll thumb not moving issue in sidebar and code refactoring --- frontend/src/components/layout/Sidebar.tsx | 40 ++++++++-------------- 1 file changed, 14 insertions(+), 26 deletions(-) diff --git a/frontend/src/components/layout/Sidebar.tsx b/frontend/src/components/layout/Sidebar.tsx index 14755c9b..7aae1a47 100644 --- a/frontend/src/components/layout/Sidebar.tsx +++ b/frontend/src/components/layout/Sidebar.tsx @@ -15,49 +15,37 @@ import { CalendarDays, } from "lucide-react"; import { useUserRole } from "@/hooks/useUserRole"; -import { memo, useEffect, useRef } from "react"; +import { memo, useLayoutEffect, useRef } from "react"; const Sidebar = () => { const location = useLocation(); const sidebarRef = useRef(null); - const savedScrollRef = useRef(0); - const isUserScrollingRef = useRef(false); const { isAdmin } = useUserRole(); - // Prevent browser / router focus-induced auto scrolling in sidebar - useEffect(() => { + const scrollPositionRef = useRef(0); + + // Preserve scroll position on every render + useLayoutEffect(() => { const sidebar = sidebarRef.current; if (!sidebar) return; - let scrollTimeout: NodeJS.Timeout; + // Restore the saved scroll position immediately before paint + sidebar.scrollTop = scrollPositionRef.current; + }); - const markUserScroll = () => { - isUserScrollingRef.current = true; - clearTimeout(scrollTimeout); - scrollTimeout = setTimeout(() => { - isUserScrollingRef.current = false; - }, 150); - }; + // Save scroll position as user scrolls (only set up once) + useLayoutEffect(() => { + const sidebar = sidebarRef.current; + if (!sidebar) return; const handleScroll = () => { - if (isUserScrollingRef.current) { - // user scroll → save - savedScrollRef.current = sidebar.scrollTop; - } else { - // programmatic scroll → restore - sidebar.scrollTop = savedScrollRef.current; - } + scrollPositionRef.current = sidebar.scrollTop; }; - sidebar.addEventListener("wheel", markUserScroll, { passive: true }); - sidebar.addEventListener("touchstart", markUserScroll, { passive: true }); - sidebar.addEventListener("scroll", handleScroll); + sidebar.addEventListener("scroll", handleScroll, { passive: true }); return () => { - sidebar.removeEventListener("wheel", markUserScroll); - sidebar.removeEventListener("touchstart", markUserScroll); sidebar.removeEventListener("scroll", handleScroll); - clearTimeout(scrollTimeout); }; }, []); From c4a9b9e19f8c5292faf0603aad2d489cb8a3d736 Mon Sep 17 00:00:00 2001 From: KetanKBaboo Date: Thu, 5 Feb 2026 17:43:47 +0530 Subject: [PATCH 23/87] minor enhancements --- frontend/src/components/Datatable.tsx | 56 +++++++++++++++------------ frontend/src/pages/Bibles.tsx | 3 ++ frontend/src/pages/Commentaries.tsx | 1 + frontend/src/pages/Dictionaries.tsx | 1 + frontend/src/pages/ISL.tsx | 1 + frontend/src/pages/Infographics.tsx | 1 + frontend/src/pages/OBS.tsx | 4 +- frontend/src/pages/Videos.tsx | 1 + 8 files changed, 42 insertions(+), 26 deletions(-) diff --git a/frontend/src/components/Datatable.tsx b/frontend/src/components/Datatable.tsx index 86f6b0e8..acdaf99e 100644 --- a/frontend/src/components/Datatable.tsx +++ b/frontend/src/components/Datatable.tsx @@ -78,6 +78,7 @@ interface DataTableProps { readonly customFilters?: React.ReactNode[]; readonly onTableReady?: (table: any) => void; readonly onClose?: () => void; + readonly getRowHoverText?: (row: TData) => string | undefined; } export function DataTable({ @@ -106,6 +107,7 @@ export function DataTable({ customFilters = [], onTableReady, onClose, + getRowHoverText }: DataTableProps) { const { isAdmin, isEditor } = useUserRole(); const canEdit = isAdmin || isEditor; @@ -373,31 +375,35 @@ export function DataTable({ : "" } > - {table.getRowModel().rows.map((row) => ( - - {row.getVisibleCells().map((cell) => { - const cls = classesFor( - cell.column.columnDef.meta?.align as Align | undefined, - ); - return ( - -
- {flexRender( - cell.column.columnDef.cell, - cell.getContext(), - )} -
- - ); - })} - - ))} + {table.getRowModel().rows.map((row) => { + const hoverText = getRowHoverText?.(row.original); + return ( + + {row.getVisibleCells().map((cell) => { + const cls = classesFor( + cell.column.columnDef.meta?.align as Align | undefined, + ); + return ( + +
+ {flexRender( + cell.column.columnDef.cell, + cell.getContext(), + )} +
+ + ); + })} + + ); + })}
diff --git a/frontend/src/pages/Bibles.tsx b/frontend/src/pages/Bibles.tsx index 56faf511..4417cb84 100644 --- a/frontend/src/pages/Bibles.tsx +++ b/frontend/src/pages/Bibles.tsx @@ -65,6 +65,7 @@ function BibleTextAction({ resource }: { resource: Resource }) { size="sm" className="h-8 cursor-pointer" onClick={() => setOpen(true)} + title={isAdmin || isEditor ? "View or upload text bible data" : "View text bible data"} > View data @@ -129,6 +130,7 @@ function BibleAudioAction({ resource }: { resource: Resource }) { size="sm" className="h-8 cursor-pointer" onClick={() => setOpen(true)} + title={isAdmin || isEditor ? "View or upload audio bible data" : "View audio bible data"} > View data @@ -560,6 +562,7 @@ const Bibles = () => { isLoading={isLoading} error={error ? extractErrorMessage(error) : null} heading="Bibles" + getRowHoverText={(row) => `Resource ID: ${row.resourceId}`} /> {/* Edit Resource Dialog */} diff --git a/frontend/src/pages/Commentaries.tsx b/frontend/src/pages/Commentaries.tsx index 07af8503..e4adca3d 100644 --- a/frontend/src/pages/Commentaries.tsx +++ b/frontend/src/pages/Commentaries.tsx @@ -593,6 +593,7 @@ const Commentaries = () => { isLoading={isLoading} error={error ? extractErrorMessage(error) : null} heading="Commentaries" + getRowHoverText={(row) => `Resource ID: ${row.resourceId}`} /> {editDialogOpen && selectedResource && ( diff --git a/frontend/src/pages/Dictionaries.tsx b/frontend/src/pages/Dictionaries.tsx index 042ef3fa..a6cf0cd3 100644 --- a/frontend/src/pages/Dictionaries.tsx +++ b/frontend/src/pages/Dictionaries.tsx @@ -579,6 +579,7 @@ const Dictionaries = () => { isLoading={isLoading} error={error ? extractErrorMessage(error) : null} heading="Dictionaries" + getRowHoverText={(row) => `Resource ID: ${row.resourceId}`} /> {editDialogOpen && selectedResource && ( diff --git a/frontend/src/pages/ISL.tsx b/frontend/src/pages/ISL.tsx index 349cd661..5092a012 100644 --- a/frontend/src/pages/ISL.tsx +++ b/frontend/src/pages/ISL.tsx @@ -593,6 +593,7 @@ const ISL = () => { isLoading={isLoading} error={error ? extractErrorMessage(error) : null} heading="ISL Bible" + getRowHoverText={(row) => `Resource ID: ${row.resourceId}`} /> {editDialogOpen && selectedResource && ( diff --git a/frontend/src/pages/Infographics.tsx b/frontend/src/pages/Infographics.tsx index 56dd5f03..d0b6f34c 100644 --- a/frontend/src/pages/Infographics.tsx +++ b/frontend/src/pages/Infographics.tsx @@ -533,6 +533,7 @@ const Infographics = () => { isLoading={isLoading} error={error ? extractErrorMessage(error) : null} heading="Infographics" + getRowHoverText={(row) => `Resource ID: ${row.resourceId}`} /> {editDialogOpen && selectedResource && ( diff --git a/frontend/src/pages/OBS.tsx b/frontend/src/pages/OBS.tsx index 69715ae4..9ea523d7 100644 --- a/frontend/src/pages/OBS.tsx +++ b/frontend/src/pages/OBS.tsx @@ -209,7 +209,8 @@ const OBSDataAction = ({ // view data return ( <> - @@ -543,6 +544,7 @@ const OBS = () => { isLoading={isLoading} error={error ? extractErrorMessage(error) : null} heading="OBS" + getRowHoverText={(row) => `Resource ID: ${row.resourceId}`} /> {editDialogOpen && selectedResource && ( diff --git a/frontend/src/pages/Videos.tsx b/frontend/src/pages/Videos.tsx index f37e140b..2519a71e 100644 --- a/frontend/src/pages/Videos.tsx +++ b/frontend/src/pages/Videos.tsx @@ -636,6 +636,7 @@ const Videos = () => { isLoading={isLoading} error={error ? extractErrorMessage(error) : null} heading="Videos" + getRowHoverText={(row) => `Resource ID: ${row.resourceId}`} /> {editDialogOpen && selectedResource && ( From 63b3a20e49e4ffbd9e1dcb4e10f3da07d3eca12c Mon Sep 17 00:00:00 2001 From: Tejaswini Rai Date: Thu, 5 Feb 2026 18:14:10 +0530 Subject: [PATCH 24/87] gateway time out error fixed for bible upload and update --- backend/app/crud.py | 11637 -------------------- backend/app/crud/content_bible.py | 32 +- backend/app/crud/remote_filecheck_crud.py | 56 +- backend/app/router.py | 4534 -------- backend/app/router/content_bible.py | 27 +- backend/app/router/format_checker.py | 3 +- 6 files changed, 100 insertions(+), 16189 deletions(-) delete mode 100644 backend/app/crud.py delete mode 100644 backend/app/router.py diff --git a/backend/app/crud.py b/backend/app/crud.py deleted file mode 100644 index 0ebea20a..00000000 --- a/backend/app/crud.py +++ /dev/null @@ -1,11637 +0,0 @@ -"""CRUD operations for the model.""" -import os -import re -import csv -import json -import zipfile -import io -import asyncio -import unicodedata -from collections import Counter -from datetime import datetime, time, timezone -from io import StringIO -from typing import Optional, Tuple, List, Dict, Any -from bs4 import BeautifulSoup -import sqlalchemy -from sqlalchemy import text, or_, func, cast, Integer -from sqlalchemy.orm import Session, aliased -from sqlalchemy.exc import IntegrityError, SQLAlchemyError -from fastapi import HTTPException, UploadFile -from fastapi.concurrency import run_in_threadpool -from fastapi.responses import FileResponse, StreamingResponse -from usfm_grammar import USFMParser -import requests -import httpx -from dependencies import logger -import db_models -import schema -import tempfile -from schema import BibleEntrySchema -from custom_exceptions import ( - AlreadyExistsException, - NotAvailableException, - UnprocessableException, - DatabaseException, - BadRequestException, - TypeException, - MultiStatus, - GenericException -) -# Known USFM markers (basic subset; you can expand this) -VALID_MARKERS = { - "\\id", "\\usfm", "\\c", "\\v", "\\p", "\\q1", "\\s", "\\m", "\\b", "\\nb", "\\toc1", "\\toc2", "\\toc3" -} - -REQUEST_TIMEOUT = 10 -RETRY_DELAY = 0.15 # seconds - -def utcnow(): - """Returns current UTC datetime""" - return datetime.now(timezone.utc) - -# if os.environ.get("DOCKER_RUN")=='True': -# LOG_DIR = "/app/logs" # will be mounted to docker volume -# else: -# LOG_DIR = os.path.join(os.path.dirname(__file__), "logs") - -# os.makedirs(LOG_DIR, exist_ok=True) - -# #--- Content Type CRUD -# # def get_all_content_types(db_session: Session): -# # """Retrieve all content types from the database.""" -# # return db_session.query(db_models.ContentType).order_by(db_models.ContentType.content_id).all() - -# # def get_content_type(db_session: Session, content_id: int): -# # """Retrieve a single content type by ID.""" -# # return db_session.query(db_models.ContentType).filter(db_models.ContentType.content_id == content_id).first() - -# # def create_content_type(db_session: Session, content: schema.ContentTypeCreate): -# # """Create a new content type if it does not already exist.""" -# # existing = db_session.query(db_models.ContentType).filter( -# # db_models.ContentType.content_name == content.content_name -# # ).first() -# # if existing: -# # raise HTTPException(status_code=400, detail="Content type already exists") -# # db_obj = db_models.ContentType(**content.model_dump()) -# # db_session.add(db_obj) -# # db_session.commit() -# # db_session.refresh(db_obj) -# # return db_obj - -# # def update_content_type(db_session: Session, content_id: int, content: schema.ContentTypeUpdate): -# # """Update an existing content type by ID with duplicate name check.""" -# # db_obj = db_session.query(db_models.ContentType).filter(db_models.ContentType.content_id == content_id).first() -# # if not db_obj: -# # raise HTTPException(status_code=404, detail="Content type not found") -# # # Check for name duplication in other rows -# # duplicate = db_session.query(db_models.ContentType).filter( -# # db_models.ContentType.content_name == content.content_name, -# # db_models.ContentType.content_id != content_id -# # ).first() -# # if duplicate: -# # logger.error("Content type name already exists") -# # raise HTTPException(status_code=400, detail="Content type name already exists") -# # db_obj.content_name = content.content_name -# # db_session.commit() -# # db_session.refresh(db_obj) -# # return db_obj - -# # def delete_content_type(db_session: Session, content_id: int): -# # """Delete a content type by ID if it exists.""" -# # db_obj = db_session.query(db_models.ContentType).filter(db_models.ContentType.content_id == content_id).first() -# # if not db_obj: -# # logger.error("Content type not found") -# # raise HTTPException(status_code=404, detail="Content type not found") -# # # if db_session.query(db_models.Resource).filter_by(content_id=content_id).first(): -# # # raise HTTPException(status_code=400, detail="Content type is in use and cannot be deleted") -# # if db_obj: -# # db_session.delete(db_obj) -# # db_session.commit() -# # return db_obj - -# # --- Version CRUD --- -# # def get_all_versions(db_session: Session): -# # """Retrieve all versions from the database.""" -# # return db_session.query(db_models.Version).order_by(db_models.Version.version_id).all() - -# # def get_version(db_session: Session, version_id: int,abbreviation: Optional[str] = None): -# # """Retrieve a single version by ID.""" -# # query = db_session.query(db_models.Version) -# # if version_id is not None: -# # query = query.filter(db_models.Version.version_id == version_id) - -# # if abbreviation is not None: -# # query = query.filter(db_models.Version.abbreviation == abbreviation) - -# # return query.first() - -# # def create_version(db_session: Session, version: schema.VersionCreate): -# # """Create a new version with checks for duplicate name and abbreviation.""" -# # # Check for duplicate name -# # if db_session.query(db_models.Version).filter( -# # func.lower(db_models.Version.name) == func.lower(version.name)).first(): -# # logger.error("Version with the same name already exists") -# # raise AlreadyExistsException(detail="Version with the same name already exists") - -# # # Check for duplicate abbreviation -# # existing = db_session.query(db_models.Version).filter( -# # func.lower(db_models.Version.abbreviation) == func.lower(version.abbreviation)).first() -# # if existing: -# # logger.error("Version with the same abbreviation already exists") -# # raise AlreadyExistsException(detail="Version with the same abbreviation already exists") -# # # db_obj = db_models.Version(**version.model_dump(by_alias=True)) -# # db_obj = db_models.Version( -# # name=version.name, -# # abbreviation=version.abbreviation, -# # meta_data=version.metadata -# # ) -# # db_session.add(db_obj) -# # db_session.commit() -# # db_session.refresh(db_obj) -# # return db_obj - -# # def update_version(db_session: Session, version_id: int, version: schema.VersionUpdate): -# # """Update an existing version by ID with detailed duplicate checks.""" -# # db_obj = get_version(db_session, version_id) -# # if not db_obj: -# # logger.error("Version not found") -# # raise NotAvailableException(detail="Version not found") -# # # Check for duplicate name -# # if db_session.query(db_models.Version).filter( -# # func.lower(db_models.Version.name) == func.lower(version.name), -# # db_models.Version.version_id != version_id -# # ).first(): -# # logger.error("Version with the same name already exists") -# # raise AlreadyExistsException(detail="Version with the same name already exists") -# # # Check for duplicate abbreviation -# # if db_session.query(db_models.Version).filter( -# # func.lower(db_models.Version.abbreviation)== func.lower(version.abbreviation), -# # db_models.Version.version_id != version_id -# # ).first(): -# # logger.error("Version with the same abbreviation already exists") -# # raise AlreadyExistsException(detail="Version with the same abbreviation already exists") -# # db_obj.name = version.name -# # db_obj.abbreviation = version.abbreviation -# # db_obj.meta_data = version.metadata -# # db_session.commit() -# # db_session.refresh(db_obj) -# # return db_obj - -# def _get_version_usage_details(db_session: Session, version_id: int): -# """Get detailed resource usage information for a version.""" -# lang_alias = aliased(db_models.Language) -# version_alias = aliased(db_models.Version) -# license_alias = aliased(db_models.License) - -# resources_with_details = ( -# db_session.query(db_models.Resource, lang_alias, version_alias, license_alias) -# .join(lang_alias, lang_alias.language_id == db_models.Resource.language_id) -# .join(version_alias, version_alias.version_id == db_models.Resource.version_id) -# .join(license_alias, license_alias.license_id == db_models.Resource.license_id) -# .filter(db_models.Resource.version_id == version_id) -# .all() -# ) - -# used_by = [] -# for resource, language, version, _ in resources_with_details: -# version_code = getattr(version, "abbreviation", getattr(version, "code", None)) -# content_val = schema.ContentTypeEnum(resource.content_type).value - -# resource_name = _build_resource_name( -# language_code=language.language_code, -# version_code=version_code, -# revision=resource.revision, -# content_type_value=content_val, -# ) - -# used_by.append( -# schema.ResourceUsageDetail( -# resourceId=resource.resource_id, -# resourceName=resource_name, -# ) -# ) - -# return used_by -# def delete_versions_bulk(db_session: Session, version_ids: List[int]): -# deleted_ids = [] -# errors = [] - -# for vid in version_ids: -# obj = get_version(db_session, vid) -# if not obj: -# errors.append(f"Version {vid} not found") -# continue - -# # used_resources = ( -# # db_session.query(db_models.Resource) -# # .filter_by(version_id=vid) -# # .all() -# # ) - -# if used_resources: -# errors.append(f"Version {vid} is in use and cannot be deleted") -# continue - -# try: -# db_session.delete(obj) -# deleted_ids.append(vid) -# except Exception as exc: -# errors.append(f"Version {vid} could not be deleted: {str(exc)}") - -# # db_session.commit() - -# # Follow exact pattern of delete_videos -# all_failed = len(deleted_ids) == 0 and len(errors) > 0 -# has_errors = len(errors) > 0 - -# return { -# "data": { -# "deletedCount": len(deleted_ids), -# "deletedIds": deleted_ids, -# "errors": errors if errors else None, -# }, -# "all_failed": all_failed, -# "has_errors": has_errors, -# } - - - -# # --- Language CRUD --- -# # def get_languages_with_pagination( -# # db_session: Session, -# # page: int = 0, -# # page_size: int = 100, -# # language_name: Optional[str] = None, -# # language_code: Optional[str] = None -# # ) -> Tuple[List[db_models.Language], int]: -# # """Retrieve languages with pagination and optional filtering.""" -# # query = db_session.query(db_models.Language) -# # # Apply filters if provided -# # filters = [] -# # if language_name: -# # filters.append(db_models.Language.language_name.ilike(f"%{language_name}%")) -# # if language_code: -# # filters.append(db_models.Language.language_code.ilike(f"%{language_code}%")) -# # if filters: -# # query = query.filter(or_(*filters)) - -# # # Get total count before pagination -# # total_items = query.count() -# # # Apply ordering and pagination -# # offset = page * page_size -# # languages = query.order_by( -# # db_models.Language.language_id.asc()).offset(offset).limit(page_size).all() - -# # return languages, total_items -# # def get_language(db_session: Session, language_id: int): -# # """Retrieve a single language by ID.""" -# # return db_session.query(db_models.Language).filter( -# # db_models.Language.language_id == language_id -# # ).first() - -# # def create_language(db_session: Session, lang: schema.LanguageCreate): -# # """Create a new language if it does not already exist.""" -# # # Additional validation for required fields -# # if not lang.language_name or lang.language_name.strip() == "": -# # logger.error("Language name is required") -# # raise UnprocessableException(detail="Language name is required") -# # if not lang.language_code or lang.language_code.strip() == "": -# # logger.error("Language code is required") -# # raise UnprocessableException(detail="Language code is required") - -# # # Check for existing language code -# # if db_session.query(db_models.Language).filter( -# # db_models.Language.language_code == lang.language_code -# # ).first(): -# # raise AlreadyExistsException(detail="Language code already exists") - -# # # Check for existing language name -# # if db_session.query(db_models.Language).filter( -# # db_models.Language.language_name == lang.language_name -# # ).first(): -# # raise AlreadyExistsException(detail="Language name already exists") - -# # # Create new language object -# # db_obj = db_models.Language( -# # language_code=lang.language_code, -# # language_name=lang.language_name, -# # meta_data=lang.metadata -# # ) -# # db_session.add(db_obj) -# # db_session.commit() -# # db_session.refresh(db_obj) -# # return db_obj - -# # def update_language(db_session: Session, language_id: int, lang: schema.LanguageUpdate): -# # """Update an existing language by ID with duplicate code check.""" -# # db_obj = get_language(db_session, language_id) -# # if not db_obj: -# # raise NotAvailableException(detail="Language not found") - -# # # Additional validation for required fields -# # if not lang.language_name or lang.language_name.strip() == "": -# # raise UnprocessableException(detail="Language name is required") -# # if not lang.language_code or lang.language_code.strip() == "": -# # raise UnprocessableException(detail="Language code is required") - -# # # Check duplicate code (exclude current record) -# # if db_session.query(db_models.Language).filter( -# # db_models.Language.language_code == lang.language_code, -# # db_models.Language.language_id != language_id -# # ).first(): -# # raise AlreadyExistsException(detail="Language code already exists") - -# # # Check duplicate name (exclude current record) -# # if db_session.query(db_models.Language).filter( -# # db_models.Language.language_name == lang.language_name, -# # db_models.Language.language_id != language_id -# # ).first(): -# # raise AlreadyExistsException(detail="Language name already exists") - -# # # Update fields -# # db_obj.language_code = lang.language_code -# # db_obj.language_name = lang.language_name -# # db_obj.meta_data = lang.metadata -# # db_session.commit() -# # db_session.refresh(db_obj) -# # return db_obj -# def _get_language_usage_details(db_session: Session, language_id: int): -# """Get detailed information about resources using a language.""" -# lang_alias = aliased(db_models.Language) -# version_alias = aliased(db_models.Version) -# license_alias = aliased(db_models.License) - -# resources_with_details = ( -# db_session.query(db_models.Resource, lang_alias, version_alias, license_alias) -# .join(lang_alias, lang_alias.language_id == db_models.Resource.language_id) -# .join(version_alias, version_alias.version_id == db_models.Resource.version_id) -# .join(license_alias, license_alias.license_id == db_models.Resource.license_id) -# .filter(db_models.Resource.language_id == language_id) -# .all() -# ) - -# used_by = [] -# for resource, language, version,_ in resources_with_details: -# version_code = getattr(version, "abbreviation", getattr(version, "code", None)) -# content_val = schema.ContentTypeEnum(resource.content_type).value -# resource_name = _build_resource_name( -# language_code=language.language_code, -# version_code=version_code, -# revision=resource.revision, -# content_type_value=content_val, -# ) -# used_by.append(schema.ResourceUsageDetail( -# resourceId=resource.resource_id, -# resourceName=resource_name -# )) - -# return used_by - - -# # def delete_languages_bulk(db_session: Session, language_ids: List[int]): -# # deleted_ids = [] -# # errors = [] - -# # for lid in language_ids: -# # try: -# # db_obj = get_language(db_session, lid) -# # if not db_obj: -# # errors.append(f"Language {lid} not found") -# # continue - -# # used_resources = ( -# # db_session.query(db_models.Resource) -# # .filter_by(language_id=lid) -# # .all() -# # ) - -# # if used_resources: -# # errors.append( -# # f"Language {lid} ('{db_obj.language_name}') is in use and cannot be deleted" -# # ) -# # continue - -# # db_session.delete(db_obj) -# # deleted_ids.append(lid) - -# # except Exception as exc: -# # errors.append(f"Error deleting language {lid}: {str(exc)}") - -# # db_session.commit() - -# # # Consistent structure like delete_videos & delete_versions_bulk -# # all_failed = len(deleted_ids) == 0 and len(errors) > 0 -# # has_errors = len(errors) > 0 - -# # return { -# # "data": { -# # "deletedCount": len(deleted_ids), -# # "deletedIds": deleted_ids, -# # "errors": errors if errors else None, -# # }, -# # "all_failed": all_failed, -# # "has_errors": has_errors, -# # } - - - -# # --- License CRUD --- -# # def get_licenses_with_filters( -# # db_session: Session, -# # license_id: Optional[int] = None, -# # name: Optional[str] = None -# # ) -> List[db_models.License]: -# # """Retrieve licenses with optional filtering.""" -# # query = db_session.query(db_models.License) - -# # # Apply filters if provided -# # if license_id is not None: -# # query = query.filter(db_models.License.license_id == license_id) - -# # if name: -# # query = query.filter(db_models.License.license_name.ilike(f"%{name}%")) - -# # # Order by license_id for consistent results -# # return query.order_by(db_models.License.license_id.asc()).all() - -# # def get_license(db_session: Session, license_id: int): -# # """Retrieve a single license by ID.""" -# # return db_session.query(db_models.License).filter( -# # db_models.License.license_id == license_id -# # ).first() - -# # def create_license(db_session: Session, license_: schema.LicenseCreate): -# # """Create a new license if it does not already exist.""" -# # # Additional validation for required fields -# # if not license_.license_name or license_.license_name.strip() == "": -# # raise UnprocessableException(detail="License name is required") -# # if not hasattr(license_, 'details') or not license_.details or license_.details.strip() == "": -# # raise UnprocessableException(detail="License details are required") - -# # existing = db_session.query(db_models.License).filter( -# # db_models.License.license_name == license_.license_name -# # ).first() -# # if existing: -# # raise AlreadyExistsException(detail="License already exists") - -# # db_obj = db_models.License( -# # license_name=license_.license_name, -# # details=license_.details -# # ) -# # db_session.add(db_obj) -# # db_session.commit() -# # db_session.refresh(db_obj) -# # return db_obj - -# # def update_license(db_session: Session, license_id: int, license_: schema.LicenseUpdate): -# # """Update an existing license by ID with duplicate name check.""" -# # db_obj = get_license(db_session, license_id) -# # if not db_obj: -# # raise NotAvailableException(detail="License not found") - -# # # Additional validation for required fields -# # if not license_.license_name or license_.license_name.strip() == "": -# # raise UnprocessableException(detail="License name is required") -# # if not hasattr(license_, 'details') or not license_.details or license_.details.strip() == "": -# # raise UnprocessableException(detail="License details are required") - -# # # Check duplicate name (exclude current record) -# # duplicate = db_session.query(db_models.License).filter( -# # db_models.License.license_name == license_.license_name, -# # db_models.License.license_id != license_id -# # ).first() -# # if duplicate: -# # raise AlreadyExistsException(detail="License name already exists") - -# # # Update fields -# # db_obj.license_name = license_.license_name -# # db_obj.details = license_.details - -# # db_session.commit() -# # db_session.refresh(db_obj) -# # return db_obj - -# def _get_resource_usage_details(db_session: Session, filter_field: str, filter_value: int): -# """Get detailed information about resources using a specific entity (language/version/license). - -# Args: -# db_session: Database session -# filter_field: Field name to filter on ('language_id', 'version_id', or 'license_id') -# filter_value: Value to filter by - -# Returns: -# List of ResourceUsageDetail objects -# """ -# lang_alias = aliased(db_models.Language) -# version_alias = aliased(db_models.Version) -# license_alias = aliased(db_models.License) - -# resources_with_details = ( -# db_session.query(db_models.Resource, lang_alias, version_alias, license_alias) -# .join(lang_alias, lang_alias.language_id == db_models.Resource.language_id) -# .join(version_alias, version_alias.version_id == db_models.Resource.version_id) -# .join(license_alias, license_alias.license_id == db_models.Resource.license_id) -# .filter(getattr(db_models.Resource, filter_field) == filter_value) -# .all() -# ) - -# used_by = [] -# for resource, language, version, _ in resources_with_details: -# version_code = getattr(version, "abbreviation", getattr(version, "code", None)) -# content_val = schema.ContentTypeEnum(resource.content_type).value -# resource_name = _build_resource_name( -# language_code=language.language_code, -# version_code=version_code, -# revision=resource.revision, -# content_type_value=content_val, -# ) -# used_by.append(schema.ResourceUsageDetail( -# resourceId=resource.resource_id, -# resourceName=resource_name -# )) - -# return used_by - - -# # def delete_licenses_bulk(db_session: Session, license_ids: List[int]): -# # deleted_ids = [] -# # errors = [] - -# # for lid in license_ids: -# # try: -# # db_obj = get_license(db_session, lid) -# # if not db_obj: -# # errors.append(f"License {lid} not found") -# # continue - -# # used_resources = ( -# # db_session.query(db_models.Resource) -# # .filter_by(license_id=lid) -# # .all() -# # ) - -# # if used_resources: -# # errors.append( -# # f"License {lid} ('{db_obj.license_name}') is in use and cannot be deleted" -# # ) -# # continue - -# # db_session.delete(db_obj) -# # deleted_ids.append(lid) - -# # except Exception as exc: -# # errors.append(f"Error deleting license {lid}: {str(exc)}") - -# # db_session.commit() - -# # # Consistent response structure -# # all_failed = len(deleted_ids) == 0 and len(errors) > 0 -# # has_errors = len(errors) > 0 - -# # return { -# # "data": { -# # "deletedCount": len(deleted_ids), -# # "deletedIds": deleted_ids, -# # "errors": errors if errors else None, -# # }, -# # "all_failed": all_failed, -# # "has_errors": has_errors, -# # } - -# # --- Resource CRUD --- - -# # def _build_resource_name(language_code: str, -# # version_code: str | None, -# # revision: str | None, -# # content_type_value: str) -> str: -# # """ -# # Format: ___ -# # e.g., "hin_HINREV_1.1_bible" - -# # - Skips empty parts. -# # - All separated by underscores. -# # """ -# # parts = [language_code or "", version_code or "", revision or "", content_type_value] -# # return "_".join([p for p in parts if p]) - - -# # def get_resources( -# # db: Session, -# # filters: schema.ResourceFilter -# # ) -> List[schema.LanguageGroupOut]: -# # """ -# # Retrieve a list of resources grouped by language. -# # """ - -# # lang_alias = aliased(db_models.Language) -# # ver_alias = aliased(db_models.Version) -# # lic_alias = aliased(db_models.License) - -# # query = ( -# # db.query(db_models.Resource, lang_alias, ver_alias, lic_alias) -# # .join(lang_alias, lang_alias.language_id == db_models.Resource.language_id) -# # .join(ver_alias, ver_alias.version_id == db_models.Resource.version_id) -# # .join(lic_alias, lic_alias.license_id == db_models.Resource.license_id) -# # ) - -# # if filters.resource_id: -# # query = query.filter(db_models.Resource.resource_id == filters.resource_id) - -# # if filters.content_type: -# # query = query.filter(db_models.Resource.content_type == filters.content_type.lower()) - -# # if filters.published is not None: -# # query = query.filter(db_models.Resource.published == filters.published) - -# # rows = ( -# # query.order_by(db_models.Resource.resource_id.asc()) -# # .offset(filters.page * filters.page_size) -# # .limit(filters.page_size) -# # .all() -# # ) - -# # if filters.resource_id and not rows: -# # raise NotAvailableException(detail="Resource not found") - -# # return _group_resources(rows) -# # def _group_resources(rows: List[tuple]) -> List[schema.LanguageGroupOut]: -# # groups: Dict[int, Dict[str, Any]] = {} - -# # for resource, lang, version, lic in rows: -# # lid = lang.language_id - -# # if lid not in groups: -# # groups[lid] = { -# # "language": schema.LanguageBrief( -# # id=lid, -# # code=lang.language_code, -# # name=lang.language_name, -# # ), -# # "versions": [] -# # } - -# # version_code = getattr(version, "abbreviation", getattr(version, "code", None)) -# # content_val = schema.ContentTypeEnum(resource.content_type).value - -# # resource_name = _build_resource_name( -# # language_code=lang.language_code, -# # version_code=version_code, -# # revision=resource.revision, -# # content_type_value=content_val, -# # ) - -# # groups[lid]["versions"].append( -# # schema.ResourceRowResponse( -# # resourceId=resource.resource_id, -# # resourceName=resource_name, -# # revision=resource.revision, -# # version=schema.VersionRef( -# # id=version.version_id, -# # name=version.name, -# # code=version_code, -# # ), -# # content=schema.ContentRef( -# # contentType=schema.ContentTypeEnum(resource.content_type) -# # ), -# # license=schema.LicenseRef( -# # id=lic.license_id, -# # name=lic.license_name -# # ), -# # language=schema.LanguageBrief( -# # id=lang.language_id, -# # code=lang.language_code, -# # name=lang.language_name -# # ), -# # metadata=json.loads(resource.meta_data) if resource.meta_data else None, -# # published=bool(resource.published), -# # createdBy=resource.created_by, -# # createdTime=resource.created_at, -# # updatedBy=resource.updated_by, -# # updatedTime=resource.updated_at -# # ) -# # ) - -# # return [schema.LanguageGroupOut(**g) for g in groups.values()] - - -# # def create_resource( -# # db: Session, -# # payload: schema.ResourceCreate, -# # created_by: Optional[int] = None) -> schema.ResourceResponse: -# # """Create a new resource and return response schema.""" -# # # Check if resource already exists -# # ct = payload.content_type.value.lower() -# # now = utcnow() -# # existing = ( -# # db.query(db_models.Resource) -# # .filter_by( -# # version_id=payload.version_id, -# # language_id=payload.language_id, -# # license_id=payload.license_id, -# # revision=payload.revision, -# # content_type=payload.content_type.value.lower(), -# # ) -# # .first() -# # ) -# # if existing: -# # raise AlreadyExistsException(detail="Resource already exists") -# # version = db.query(db_models.Version).filter_by(version_id=payload.version_id).first() -# # if not version: -# # raise NotAvailableException(detail="versionId not found") -# # language = db.query(db_models.Language).filter_by(language_id=payload.language_id).first() -# # if not language: -# # raise NotAvailableException(detail="languageId not found") -# # license_ = db.query(db_models.License).filter_by(license_id=payload.license_id).first() -# # if not license_: -# # raise NotAvailableException(detail="licenseId not found") -# # ct = payload.content_type.value.lower() - -# # db_obj = db_models.Resource( -# # version_id=payload.version_id, -# # revision=payload.revision, -# # content_type=ct, -# # language_id=payload.language_id, -# # license_id=payload.license_id, -# # meta_data=json.dumps(payload.metadata, ensure_ascii=False) if payload.metadata else None, -# # created_by=created_by, -# # created_at=now, -# # published=bool(getattr(payload, "published", False)), -# # # published=False, # always default to False on POST -# # ) -# # db.add(db_obj) -# # db.commit() -# # db.refresh(db_obj) -# # version_code = getattr(version, "abbreviation", getattr(version, "code", None)) -# # resource_name = _build_resource_name( -# # language_code=language.language_code, -# # version_code=version_code, -# # revision=db_obj.revision, -# # content_type_value=ct, -# # ) -# # now = utcnow() - -# # return schema.ResourceResponse( -# # resourceId=db_obj.resource_id, -# # resourceName=resource_name, -# # revision=db_obj.revision, -# # version=schema.VersionRef( -# # id=version.version_id, -# # name=version.name, -# # code=getattr(version, "abbreviation", getattr(version, "code", "")), -# # ), -# # language=schema.LanguageBrief( -# # id=language.language_id, -# # code=language.language_code, -# # name=language.language_name -# # ), -# # content=schema.ContentRef(contentType=schema.ContentTypeEnum(db_obj.content_type)), -# # license=schema.LicenseRef(id=license_.license_id, name=license_.license_name), -# # metadata=json.loads(db_obj.meta_data) if db_obj.meta_data else None, -# # published=bool(db_obj.published), -# # createdBy=db_obj.created_by, -# # createdTime=db_obj.created_at, -# # updatedBy=None, -# # updatedTime=None, -# # ) - - -# # def update_resource( -# # db: Session, -# # payload: schema.ResourceUpdate, -# # user_id: Optional[int] = None -# # ) -> schema.ResourceResponse: -# # """Update resource and return response schema.""" -# # db_obj = _get_resource_or_404(db, payload.resource_id) - -# # # Determine final values for uniqueness validation -# # final_values = _resolve_final_values(db_obj, payload) - -# # _validate_uniqueness(db, payload.resource_id, final_values) - -# # # Update main resource fields -# # version, language, license_ = _apply_updates(db, db_obj, payload) - -# # # Extra fields -# # if payload.metadata is not None: -# # db_obj.meta_data = json.dumps(payload.metadata, ensure_ascii=False) -# # if payload.published is not None: -# # db_obj.published = bool(payload.published) - -# # db_obj.updated_by = user_id -# # db_obj.updated_at = utcnow() - -# # db.commit() -# # db.refresh(db_obj) - -# # return _build_response(db_obj, version, language, license_) -# # def _get_resource_or_404(db: Session, resource_id: int): -# # obj = db.query(db_models.Resource).filter_by(resource_id=resource_id).first() -# # if not obj: -# # raise NotAvailableException(detail="Resource not found") -# # return obj -# # def _resolve_final_values(db_obj, payload): -# # return { -# # "version_id": payload.version_id or db_obj.version_id, -# # "language_id": payload.language_id or db_obj.language_id, -# # "license_id": payload.license_id or db_obj.license_id, -# # "revision": payload.revision or db_obj.revision, -# # "content_type": payload.content_type.value.lower() -# # if payload.content_type else db_obj.content_type, -# # } -# # def _validate_uniqueness(db: Session, current_id: int, vals: dict): -# # existing = ( -# # db.query(db_models.Resource) -# # .filter( -# # db_models.Resource.version_id == vals["version_id"], -# # db_models.Resource.language_id == vals["language_id"], -# # db_models.Resource.license_id == vals["license_id"], -# # db_models.Resource.revision == vals["revision"], -# # db_models.Resource.content_type == vals["content_type"], -# # db_models.Resource.resource_id != current_id, -# # ) -# # .first() -# # ) -# # if existing: -# # raise AlreadyExistsException(detail="Resource already exists") -# # def _apply_updates(db: Session, db_obj, payload): -# # # Version -# # version = _validate_and_get( -# # db, -# # db_models.Version, -# # payload.version_id or db_obj.version_id, "versionId") -# # db_obj.version_id = version.version_id - -# # # Language -# # language = _validate_and_get( -# # db, -# # db_models.Language, -# # payload.language_id or db_obj.language_id, "languageId" -# # ) -# # db_obj.language_id = language.language_id - -# # # License -# # license_ = _validate_and_get( -# # db, -# # db_models.License, -# # payload.license_id or db_obj.license_id, "licenseId" -# # ) -# # db_obj.license_id = license_.license_id - -# # # Revision & content type -# # if payload.revision is not None: -# # db_obj.revision = payload.revision -# # if payload.content_type is not None: -# # db_obj.content_type = payload.content_type.value.lower() - -# # return version, language, license_ -# # def _validate_and_get(db, model, id_value, field_name): -# # pk_column = model.__mapper__.primary_key[0].name - -# # obj = db.query(model).filter(getattr(model, pk_column) == id_value).first() -# # if not obj: -# # raise NotAvailableException(detail=f"{field_name} not found") -# # return obj - -# # def _build_response(db_obj, version, language, license_): -# # resource_name = _build_resource_name( -# # language_code=language.language_code, -# # version_code=getattr(version, "abbreviation", getattr(version, "code", None)), -# # revision=db_obj.revision, -# # content_type_value=schema.ContentTypeEnum(db_obj.content_type).value, -# # ) - -# # return schema.ResourceResponse( -# # resourceId=db_obj.resource_id, -# # resourceName=resource_name, -# # revision=db_obj.revision, -# # version=schema.VersionRef( -# # id=version.version_id, -# # name=version.name, -# # code=getattr(version, "abbreviation", getattr(version, "code", "")), -# # ), -# # language=schema.LanguageBrief( -# # id=language.language_id, -# # code=language.language_code, -# # name=language.language_name, -# # ), -# # content=schema.ContentRef(contentType=schema.ContentTypeEnum(db_obj.content_type)), -# # license=schema.LicenseRef(id=license_.license_id, name=license_.license_name), -# # metadata=json.loads(db_obj.meta_data) if db_obj.meta_data else None, -# # published=bool(db_obj.published), -# # createdBy=db_obj.created_by, -# # createdTime=db_obj.created_at, -# # updatedBy=db_obj.updated_by, -# # updatedTime=db_obj.updated_at, -# # ) - -# # def delete_resources_bulk(db: Session, resource_ids: List[int]): -# # deleted_ids = [] -# # errors = [] - -# # related_models = [ -# # db_models.Bible, -# # db_models.CleanBible, -# # db_models.Video, -# # db_models.Commentary, -# # db_models.Dictionary, -# # db_models.AudioBible, -# # db_models.Obs, -# # db_models.Infographic, -# # ] - -# # for rid in resource_ids: -# # try: -# # db_obj = ( -# # db.query(db_models.Resource) -# # .filter_by(resource_id=rid) -# # .first() -# # ) - -# # if not db_obj: -# # errors.append(f"Resource {rid} not found") -# # continue - -# # # Delete dependent entities first -# # for model in related_models: -# # db.query(model).filter_by(resource_id=rid).delete( -# # synchronize_session=False -# # ) - -# # # Delete main resource -# # db.delete(db_obj) -# # deleted_ids.append(rid) - -# # except IntegrityError: -# # db.rollback() -# # errors.append( -# # f"Resource {rid} could not be deleted due to database constraints" -# # ) - -# # except Exception as exc: -# # db.rollback() -# # errors.append(f"Error deleting resource {rid}: {str(exc)}") - -# # db.commit() - -# # # ---- Consistent structure ---- -# # all_failed = len(deleted_ids) == 0 and len(errors) > 0 -# # has_errors = len(errors) > 0 - -# # return { -# # "data": { -# # "deletedCount": len(deleted_ids), -# # "deletedIds": deleted_ids, -# # "errors": errors if errors else None, -# # }, -# # "all_failed": all_failed, -# # "has_errors": has_errors, -# # } - -# # # --- Log files CRUD --- - -# # def latest_log_file(): -# # """Get latest log file""" -# # # Find newest log file -# # files = sorted( -# # [f for f in os.listdir(LOG_DIR) if f.startswith("vachan_admin_app.log")], -# # key=lambda x: os.path.getmtime(os.path.join(LOG_DIR, x)), -# # reverse=True -# # ) -# # if not files: -# # raise NotAvailableException(detail="No log files found") -# # path = os.path.join(LOG_DIR, files[0]) -# # return FileResponse( -# # path=path, -# # media_type="text/plain", -# # filename=files[0] -# # ) - - -# # def get_logfile_by_number(log_file_no): -# # """Get log file by number""" -# # if log_file_no < 0 or log_file_no > 10: -# # raise BadRequestException(detail="log_file_no must be 0–10") -# # filename = "vachan_admin_app.log" if log_file_no == 0 else f"vachan_admin_app.log.{log_file_no}" -# # path = os.path.join(LOG_DIR, filename) -# # if not os.path.exists(path): -# # raise NotAvailableException(detail=f"Log file {filename} not found") -# # return FileResponse( -# # path=path, -# # media_type="text/plain", -# # filename=filename -# # ) - -# def latest_log_file(): -# """Get latest log file""" -# # Find newest log file -# files = sorted( -# [f for f in os.listdir(LOG_DIR) if f.startswith("vachan_admin_app.log")], -# key=lambda x: os.path.getmtime(os.path.join(LOG_DIR, x)), -# reverse=True -# ) -# if not files: -# raise NotAvailableException(detail="No log files found") -# return FileResponse(os.path.join(LOG_DIR, files[0]), media_type='text/plain') - -# # tmp = tempfile.NamedTemporaryFile(delete=False, suffix=".zip") -# # tmp_path = tmp.name -# # tmp.close() - -# def get_logfile_by_number(log_file_no): -# """Get log file by number""" -# if log_file_no < 0 or log_file_no > 10: -# raise BadRequestException(detail="log_file_no must be 0–10") -# filename = "vachan_admin_app.log" if log_file_no == 0 else f"vachan_admin_app.log.{log_file_no}" -# path = os.path.join(LOG_DIR, filename) -# if not os.path.exists(path): -# raise NotAvailableException(detail=f"Log file {filename} not found") -# return FileResponse(path, media_type='text/plain') - -# def get_all_logfiles(): -# """Get all log files in a zip format""" -# # Zip all logs into memory -# buf = io.BytesIO() -# with zipfile.ZipFile(buf, 'w') as zf: -# for fname in os.listdir(LOG_DIR): -# if fname.startswith("vachan_admin_app.log"): -# zf.write(os.path.join(LOG_DIR, fname), arcname=fname) -# buf.seek(0) -# return StreamingResponse(buf, media_type='application/zip', -# headers={"Content-Disposition": "attachment; filename=logs.zip"}) - -# # def create_videos(db: Session, data: schema.VideoBulkCreate, actor_user_id: int): -# # """Create a new video entry""" -# # # Resource must exist -# # resource = db.query(db_models.Resource).filter_by(resource_id=data.resourceId).first() -# # if not resource: -# # raise NotAvailableException(detail=f"Resource {data.resourceId} not found") -# # # Resource content_type must be 'video' -# # if resource.content_type.lower() != "video": -# # raise BadRequestException( -# # f"Resource {data.resourceId} is not of type 'video' (found '{resource.content_type}')" -# # ) -# # created = [] -# # for v in data.videos: -# # # Check duplicates (resource_id + book + chapter + url) -# # existing = ( -# # db.query(db_models.Video) -# # .filter_by(resource_id=data.resourceId, book=v.book, chapter=v.chapter, title=v.title) -# # .first() -# # ) -# # if existing: -# # raise AlreadyExistsException( -# # detail=( -# # f"Video {v.book} {v.chapter} {v.title} " -# # f"already exists in resource {data.resourceId}" -# # ) -# # ) -# # # Create new video record -# # video = db_models.Video( -# # resource_id=data.resourceId, -# # book=v.book, -# # chapter=v.chapter, -# # url=v.url, -# # title=v.title, -# # description=v.description, -# # ) -# # db.add(video) -# # created.append(video) - -# # touch_resource(db, data.resourceId, actor_user_id) -# # db.commit() - -# # # Return structured response -# # return { -# # "resource_id": data.resourceId, -# # "videos": created -# # } - -# def create_videos(db: Session, data: schema.VideoBulkCreate, actor_user_id: int): -# """Create a new video entry""" -# # Resource must exist -# resource = db.query(db_models.Resource).filter_by(resource_id=data.resourceId).first() -# if not resource: -# raise NotAvailableException(detail=f"Resource {data.resourceId} not found") -# # Resource content_type must be 'video' -# if resource.content_type.lower() != "video": -# raise BadRequestException( -# f"Resource {data.resourceId} is not of type 'video' (found '{resource.content_type}')" -# ) -# created = [] -# for v in data.videos: -# # Check duplicates (resource_id + book + chapter + url) -# existing = ( -# db.query(db_models.Video) -# .filter_by(resource_id=data.resourceId, book=v.book, chapter=v.chapter, url=v.url) -# .first() -# ) -# if existing: -# raise AlreadyExistsException( -# detail=( -# f"Video {v.book} {v.chapter} {v.url} " -# f"already exists in resource {data.resourceId}" -# ) -# ) -# # Title must be unique per resource -# title_clash = ( -# db.query(db_models.Video) -# .filter_by(resource_id=data.resourceId, title=v.title) -# .first() -# ) -# if title_clash: -# raise AlreadyExistsException( -# detail=f"Video title '{v.title}' already exists in resource {data.resourceId}" -# ) -# # Create new video record -# video = db_models.Video( -# resource_id=data.resourceId, -# book=v.book, -# chapter=v.chapter, -# url=v.url, -# title=v.title, -# description=v.description, -# ) -# db.add(video) -# created.append(video) - - -# # def update_videos(db: Session, data: schema.VideoBulkUpdate, actor_user_id: int): -# # """Update a video entry""" -# # # Resource must exist -# # resource = db.query(db_models.Resource).filter_by(resource_id=data.resourceId).first() -# # if not resource: -# # raise NotAvailableException(detail=f"Resource {data.resourceId} not found") - -# # # Resource content_type must be 'video' -# # if resource.content_type.lower() != "video": -# # raise BadRequestException( -# # detail=( -# # f"Resource {data.resourceId} is not of type 'video'" -# # f" (found '{resource.content_type}')" -# # ) -# # ) - -# # updated = [] -# # for v in data.videos: -# # # Find the video by id + resource -# # video = ( -# # db.query(db_models.Video) -# # .filter_by(video_id=v.id, resource_id=data.resourceId) -# # .first() -# # ) -# # if not video: -# # raise NotAvailableException( -# # detail=f"Video {v.id} not found in resource {data.resourceId}" -# # ) - -# def update_videos(db: Session, data: schema.VideoBulkUpdate, actor_user_id: int): -# """Update a video entry""" -# # Resource must exist -# resource = db.query(db_models.Resource).filter_by(resource_id=data.resourceId).first() -# if not resource: -# raise NotAvailableException(detail=f"Resource {data.resourceId} not found") - -# # Resource content_type must be 'video' -# if resource.content_type.lower() != "video": -# raise BadRequestException( -# detail=( -# f"Resource {data.resourceId} is not of type 'video'" -# f" (found '{resource.content_type}')" -# ) -# ) - -# updated = [] -# for v in data.videos: -# # Find the video by id + resource -# video = ( -# db.query(db_models.Video) -# .filter_by(video_id=v.id, resource_id=data.resourceId) -# .first() -# ) -# if not video: -# raise NotAvailableException( -# detail=f"Video {v.id} not found in resource {data.resourceId}" -# ) - -# # Check duplicates (exclude the current video itself) -# duplicate = ( -# db.query(db_models.Video) -# .filter( -# db_models.Video.resource_id == data.resourceId, -# db_models.Video.book == v.book, -# db_models.Video.chapter == v.chapter, -# db_models.Video.url == v.url, -# db_models.Video.video_id != v.id, # exclude self -# ) -# .first() -# ) -# if duplicate: -# raise AlreadyExistsException( -# detail= ( -# f"Video {v.book} {v.chapter} {v.url} already exists" -# f" in resource {data.resourceId}" -# ) -# ) -# # Title unique per resource -# title_clash = ( -# db.query(db_models.Video) -# .filter( -# db_models.Video.resource_id == data.resourceId, -# db_models.Video.title == v.title, -# db_models.Video.video_id != v.id, -# ) -# .first() -# ) -# if title_clash: -# raise AlreadyExistsException( -# detail=f"Video title '{v.title}' already exists in resource {data.resourceId}" -# ) - -# # Update fields -# video.book = v.book -# video.chapter = v.chapter -# video.url = v.url -# video.title = v.title -# video.description = v.description -# updated.append(video) -# touch_resource(db, data.resourceId, actor_user_id) -# db.commit() -# return { -# "resource_id": data.resourceId, -# "videos": updated -# } - -# # # Update fields -# # video.book = v.book -# # video.chapter = v.chapter -# # video.url = v.url -# # video.title = v.title -# # video.description = v.description -# # updated.append(video) -# # touch_resource(db, data.resourceId, actor_user_id) -# # db.commit() -# # return { -# # "resource_id": data.resourceId, -# # "videos": updated -# # } - -# # def get_videos_filtered( -# # db: Session, -# # resource_id: int = None, -# # language_code: str = None, -# # book_code: str = None, -# # chapter: int = None -# # ): -# # """Get videos filtered by resource, language, book, and chapter.""" - -# # # Validate resource if provided -# # if resource_id: -# # resource = db.query(db_models.Resource).filter( -# # db_models.Resource.resource_id == resource_id -# # ).first() -# # if not resource: -# # raise NotAvailableException(detail=f"Resource {resource_id} not found") - -# # if resource.content_type.lower() != "video": -# # raise BadRequestException( -# # detail=f"Resource {resource_id} is not of type 'video' (found '{resource.content_type}')" -# # ) - -# # # Validate language if provided -# # if language_code: -# # language = db.query(db_models.Language).filter( -# # db_models.Language.language_code == language_code -# # ).first() -# # if not language: -# # raise NotAvailableException(detail=f"Language '{language_code}' not found") - - -# # if book_code: -# # book_code_lower = book_code.lower() - -# # # Check if book exists in video table -# # book_exists = db.query(db_models.Video).filter( -# # func.lower(db_models.Video.book) == book_code_lower -# # ).first() - -# # if not book_exists: -# # raise NotAvailableException( -# # detail=f"Book code '{book_code}' not found in videos" -# # ) - -# # # Chapter validation -# # if chapter is not None: -# # chapter_exists = db.query(db_models.Video).filter( -# # func.lower(db_models.Video.book) == book_code_lower, -# # db_models.Video.chapter == chapter -# # ).first() - -# # if not chapter_exists: -# # raise NotAvailableException( -# # detail=f"Chapter {chapter} not found for book '{book_code}'" -# # ) - -# # query = db.query(db_models.Video) - -# # if resource_id: -# # query = query.filter(db_models.Video.resource_id == resource_id) - -# # if book_code: -# # query = query.filter(func.lower(db_models.Video.book) == book_code_lower) - -# # if chapter is not None: -# # query = query.filter(db_models.Video.chapter == chapter) - -# # videos = query.all() - -# # result = {"books": {}} - -# # for v in videos: -# # book_key = (v.book or "").lower().strip() -# # chapter_key = str(v.chapter) - -# # if book_key not in result["books"]: -# # result["books"][book_key] = {} - -# # if chapter_key not in result["books"][book_key]: -# # result["books"][book_key][chapter_key] = [] - -# # result["books"][book_key][chapter_key].append({ -# # "video_id": v.video_id, -# # "title": v.title, -# # "description": v.description, -# # "url": v.url -# # }) - -# # return result - -# # def delete_videos(db: Session, resource_id: int, video_ids: List[int]): -# # """Delete multiple videos from a resource""" -# # # Check if resource exists -# # resource = db.query(db_models.Resource).filter_by(resource_id=resource_id).first() -# # if not resource: -# # raise NotAvailableException(detail=f"Resource {resource_id} not found") - -# # deleted_ids = [] -# # invalid_ids = [] - -# # for video_id in video_ids: -# # video = ( -# # db.query(db_models.Video) -# # .filter( -# # db_models.Video.video_id == video_id, -# # db_models.Video.resource_id == resource_id, -# # ) -# # .first() -# # ) - -# # if video: -# # db.delete(video) -# # deleted_ids.append(video_id) -# # else: -# # invalid_ids.append(video_id) - -# # db.commit() - -# # response = { -# # "deletedCount": len(deleted_ids), -# # "deletedIds": deleted_ids, -# # "message": ( -# # f"Successfully deleted {len(deleted_ids)}" -# # f" video{'s' if len(deleted_ids) != 1 else ''}" -# # ) -# # } - -# # if invalid_ids: -# # response["error"] = f"Invalid video_ids: {', '.join(map(str, invalid_ids))}" - -# # # Return response with status code indicator -# # return { -# # "data": response, -# # "has_errors": len(invalid_ids) > 0, -# # "all_failed": len(deleted_ids) == 0 -# # } -# # ---- Bibles ---- - - -# # # Utility functions for API operations - -# # def extract_book_code_from_usfm(usfm_content: str) -> str: -# # """Extract book code from USFM content using usfm-grammar""" -# # try: -# # parser = USFMParser(usfm_content) -# # usj_data = parser.to_usj() - -# # for item in usj_data.get("content", []): -# # if item.get("type") == "book" and item.get("marker") == "id": -# # return item.get("code") - -# # raise UnprocessableException("No book code found in USFM content") - -# # except Exception as e: -# # raise UnprocessableException( -# # f"USFM parsing error: {str(e)}" -# # ) from e - -# def validate_chapter_count(db_session: Session, -# book_code: str, chapter_count: int): -# """Validate chapter count against DB table""" -# book = ( -# db_session.query(db_models.BookLookup) -# .filter(db_models.BookLookup.book_code == book_code.lower()) -# .first() -# ) - -# if not book: -# raise NotAvailableException(detail=f"Book {book_code} not found") - -# if chapter_count > book.chapter_count: -# raise BadRequestException( -# detail=( -# f"Invalid chapter count {chapter_count} for book {book_code}. " -# f"Max allowed: {book.chapter_count}" -# ) -# ) - -# # def parse_verse_number(verse_str: str) -> List[int]: -# # """ -# # Parse verse number strings that might contain ranges. - -# # Examples: -# # - "1" -> [1] -# # - "23-24" -> [23, 24] -# # """ -# # verses = [] - -# # if not verse_str: -# # return verses - -# # verse_str = str(verse_str).strip() - -# # try: -# # # Split by comma for multiple groups -# # groups = verse_str.split(',') - -# # for group in groups: -# # group = group.strip() - -# # if '-' in group: -# # # Handle ranges like "23-24" -# # parts = group.split('-') -# # if len(parts) == 2: -# # try: -# # start = int(parts[0].strip()) -# # end = int(parts[1].strip()) -# # verses.extend(range(start, end + 1)) -# # except ValueError: -# # # Fallback: treat as single verse -# # verses.append(int(group)) -# # else: -# # verses.append(int(group)) -# # else: -# # # Single verse -# # verses.append(int(group)) - -# # return sorted(set(verses)) # Remove duplicates and sort - -# # except (ValueError, AttributeError) as e: -# # raise ValueError(f"Could not parse verse number: {verse_str}") from e - - -# # def parse_usfm_to_clean_verses(usj_data: Dict[str, Any]) -> List[Dict[str, Any]]: -# # """Parse USJ data to extract clean verse-by-verse content, handling verse ranges""" -# # verses = [] -# # chapter = None - -# # for item in usj_data.get("content", []): -# # item_type = item.get("type") - -# # if item_type == "chapter": -# # chapter = item.get("number") -# # continue - -# # if item_type == "para": -# # _process_paragraph(item.get("content", []), chapter, verses) - -# # return verses - -# # def _process_paragraph(para_content: List[Any], chapter: int, verses: List[Dict[str, Any]]) -> None: -# # """Process paragraph and extract individual verses.""" -# # i = 0 -# # length = len(para_content) - -# # while i < length: -# # element = para_content[i] - -# # if not _is_verse_marker(element): -# # i += 1 -# # continue - -# # verse_str = element.get("number") -# # i += 1 - -# # verse_text, i = _collect_verse_text(para_content, i) - -# # if verse_text: -# # _expand_and_add_verses(verse_str, verse_text, chapter, verses) - -# # def _is_verse_marker(element: Any) -> bool: -# # return isinstance(element, dict) and element.get("type") == "verse" - -# # def _collect_verse_text(para_content: List[Any], index: int) -> tuple[str, int]: -# # parts = [] -# # length = len(para_content) - -# # while index < length and isinstance(para_content[index], str): -# # parts.append(para_content[index]) -# # index += 1 - -# # return " ".join(parts).strip(), index - -# # def _expand_and_add_verses( -# # verse_str: str, -# # verse_text: str, -# # chapter: int, -# # verses: List[Dict[str, Any]] -# # ): -# # try: -# # verse_numbers = parse_verse_number(verse_str) -# # for number in verse_numbers: -# # verses.append({ -# # "chapter": chapter, -# # "verse": number, -# # "text": verse_text, -# # }) -# # except ValueError as e: -# # print(f"Warning: Could not parse verse '{verse_str}' in chapter {chapter}: {e}") - - -# # CRUD Operations -# def upload_bible_book( -# db_session: Session, -# resource_id: int, -# usfm_file: UploadFile, -# actor_user_id: int -# ) -> Dict[str, str]: -# """Upload and process a new bible book""" - -# _get_resource(db_session, resource_id) -# usfm_content = _read_usfm_file(usfm_file) -# book_code = extract_book_code_from_usfm(usfm_content) -# book = _lookup_book_or_404(db_session, book_code) - -# _check_book_not_exists(db_session, resource_id, book.book_id) - -# usj_data = _parse_usfm_to_usj(usfm_content) - -# content_items = usj_data.get("content", []) -# chapter_count = _count_chapters(content_items) -# validate_chapter_count(db_session, book_code, chapter_count) - -# entry_data = BibleEntrySchema( -# resource_id=resource_id, -# book_id=book.book_id, -# usfm_content=usfm_content, -# usj_data=usj_data, -# chapter_count=chapter_count -# ) - -# bible_record = _create_bible_entry(db_session, entry_data) - -# _save_clean_verses( -# db_session=db_session, -# resource_id=resource_id, -# book_id=book.book_id, -# usj_data=usj_data -# ) - -# touch_resource(db_session, resource_id=bible_record.resource_id, actor_user_id=actor_user_id) -# db_session.commit() - -# return { -# "message": "Bible book uploaded successfully", -# "bible_book_id": bible_record.bible_book_id -# } -# def _get_resource(db_session: Session, resource_id: int): -# resource = db_session.query(db_models.Resource).filter_by(resource_id=resource_id).first() -# if not resource: -# raise NotAvailableException(detail="Resource not found") -# return resource -# def _read_usfm_file(usfm_file: UploadFile) -> str: -# return usfm_file.file.read().decode("utf-8") - - -# def _lookup_book_or_404(db_session: Session, book_code: str): -# book = db_session.query(db_models.BookLookup).filter( -# func.lower(db_models.BookLookup.book_code) == book_code.lower() -# ).first() -# if not book: -# raise NotAvailableException(detail=f"Book {book_code} not found") -# return book - - -# def _check_book_not_exists(db_session: Session, resource_id: int, book_id: int): -# existing = db_session.query(db_models.Bible).filter_by( -# resource_id=resource_id, -# book_id=book_id -# ).first() -# if existing: -# raise AlreadyExistsException( -# detail=f"Book already exists for resource {resource_id}" -# ) - - -# # def _parse_usfm_to_usj(usfm_content: str) -> Dict[str, Any]: -# # try: -# # return USFMParser(usfm_content).to_usj() -# # except Exception as e: -# # raise UnprocessableException(detail=f"Error parsing USFM: {str(e)}") from e - - -# # def _count_chapters(content_items: List[Dict[str, Any]]) -> int: -# # return len([item for item in content_items if item.get("type") == "chapter"]) - - -# def _create_bible_entry(db_session: Session, data: BibleEntrySchema): -# bible_record = db_models.Bible( -# resource_id=data.resource_id, -# book_id=data.book_id, -# usfm=data.usfm_content, -# json=data.usj_data, -# chapters=data.chapter_count, -# ) -# db_session.add(bible_record) -# db_session.flush() -# return bible_record - -# def _save_clean_verses( -# db_session: Session, -# resource_id: int, -# book_id: int, -# usj_data: Dict[str, Any] -# ): -# verses = parse_usfm_to_clean_verses(usj_data) -# for verse in verses: -# db_session.add( -# db_models.CleanBible( -# resource_id=resource_id, -# book_id=book_id, -# chapter=verse["chapter"], -# verse=verse["verse"], -# text=verse["text"], -# ) -# ) - - -# def update_bible_book( -# db_session: Session, -# bible_book_id: int, -# usfm_file: UploadFile, -# actor_user_id: int -# ) -> Dict[str, str]: -# """Update an existing bible book""" - -# bible_record = _get_bible_record_or_404(db_session, bible_book_id) -# usfm_content = _read_usfm_file(usfm_file) - -# book_code = extract_book_code_from_usfm(usfm_content) -# _validate_book_code_matches(db_session, bible_record.book_id, book_code) - -# usj_data = _parse_usfm_to_usj(usfm_content) - -# content_items = usj_data.get("content", []) -# chapter_count = _count_chapters(content_items) -# validate_chapter_count(db_session, book_code, chapter_count) - -# _update_bible_entry( -# bible_record=bible_record, -# usfm_content=usfm_content, -# usj_data=usj_data, -# chapter_count=chapter_count, -# ) - -# _replace_clean_verses( -# db_session=db_session, -# resource_id=bible_record.resource_id, -# book_id=bible_record.book_id, -# usj_data=usj_data -# ) - -# touch_resource(db_session, resource_id=bible_record.resource_id, actor_user_id=actor_user_id) -# db_session.commit() - -# return {"message": "Bible book updated successfully"} -# def _get_bible_record_or_404(db_session: Session, bible_book_id: int): -# record = db_session.query(db_models.Bible).filter_by(bible_book_id=bible_book_id).first() -# if not record: -# raise NotAvailableException(detail=f"Bible book {bible_book_id} not found") -# return record -# def _validate_book_code_matches(db_session: Session, book_id: int, new_code: str): -# book = db_session.query(db_models.BookLookup).filter_by(book_id=book_id).first() -# if book.book_code.lower() != new_code.lower(): -# raise BadRequestException( -# detail=f"Book code mismatch: {new_code} != {book.book_code}" -# ) -# def _update_bible_entry( -# bible_record, -# usfm_content: str, -# usj_data: Dict[str, Any], -# chapter_count: int -# ): -# bible_record.usfm = usfm_content -# bible_record.json = usj_data -# bible_record.chapters = chapter_count -# def _replace_clean_verses( -# db_session: Session, -# resource_id: int, -# book_id: int, -# usj_data: Dict[str, Any] -# ): -# db_session.query(db_models.CleanBible).filter_by( -# resource_id=resource_id, -# book_id=book_id -# ).delete() - -# verses = parse_usfm_to_clean_verses(usj_data) -# for v in verses: -# db_session.add( -# db_models.CleanBible( -# resource_id=resource_id, -# book_id=book_id, -# chapter=v["chapter"], -# verse=v["verse"], -# text=v["text"] -# ) -# ) -# def delete_bible_books( -# db_session: Session, -# resource_id: int, -# book_codes: List[str] -# ): -# """Delete multiple Bible books in a standardized structure.""" - -# # Check if resource exists -# resource = ( -# db_session.query(db_models.Resource) -# .filter_by(resource_id=resource_id) -# .first() -# ) -# if not resource: -# raise NotAvailableException(detail=f"Resource {resource_id} not found") - -# def delete_content_type(db_session: Session, content_id: int): -# """Delete a content type by ID if it exists.""" -# db_obj = db_session.query(db_models.ContentType).filter(db_models.ContentType.content_id == content_id).first() -# if not db_obj: -# logger.error("Content type not found") -# raise HTTPException(status_code=404, detail="Content type not found") -# # if db_session.query(db_models.Resource).filter_by(content_id=content_id).first(): -# # raise HTTPException(status_code=400, detail="Content type is in use and cannot be deleted") -# if db_obj: -# db_session.delete(db_obj) -# db_session.commit() -# return db_obj - -# --- Version CRUD --- -def get_all_versions(db_session: Session): - """Retrieve all versions from the database.""" - return db_session.query(db_models.Version).order_by(db_models.Version.version_id).all() - -def get_version(db_session: Session, version_id: int,abbreviation: Optional[str] = None): - """Retrieve a single version by ID.""" - query = db_session.query(db_models.Version) - if version_id is not None: - query = query.filter(db_models.Version.version_id == version_id) - - if abbreviation is not None: - query = query.filter(db_models.Version.abbreviation == abbreviation) - - return query.first() - -def create_version(db_session: Session, version: schema.VersionCreate): - """Create a new version with checks for duplicate name and abbreviation.""" - # Check for duplicate name - if db_session.query(db_models.Version).filter( - func.lower(db_models.Version.name) == func.lower(version.name)).first(): - logger.error("Version with the same name already exists") - raise AlreadyExistsException(detail="Version with the same name already exists") - - # Check for duplicate abbreviation - existing = db_session.query(db_models.Version).filter( - func.lower(db_models.Version.abbreviation) == func.lower(version.abbreviation)).first() - if existing: - logger.error("Version with the same abbreviation already exists") - raise AlreadyExistsException(detail="Version with the same abbreviation already exists") - # db_obj = db_models.Version(**version.model_dump(by_alias=True)) - db_obj = db_models.Version( - name=version.name, - abbreviation=version.abbreviation, - meta_data=version.metadata - ) - db_session.add(db_obj) - db_session.commit() - db_session.refresh(db_obj) - return db_obj - -def update_version(db_session: Session, version_id: int, version: schema.VersionUpdate): - """Update an existing version by ID with detailed duplicate checks.""" - db_obj = get_version(db_session, version_id) - if not db_obj: - logger.error("Version not found") - raise NotAvailableException(detail="Version not found") - # Check for duplicate name - if db_session.query(db_models.Version).filter( - func.lower(db_models.Version.name) == func.lower(version.name), - db_models.Version.version_id != version_id - ).first(): - logger.error("Version with the same name already exists") - raise AlreadyExistsException(detail="Version with the same name already exists") - # Check for duplicate abbreviation - if db_session.query(db_models.Version).filter( - func.lower(db_models.Version.abbreviation)== func.lower(version.abbreviation), - db_models.Version.version_id != version_id - ).first(): - logger.error("Version with the same abbreviation already exists") - raise AlreadyExistsException(detail="Version with the same abbreviation already exists") - db_obj.name = version.name - db_obj.abbreviation = version.abbreviation - db_obj.meta_data = version.metadata - db_session.commit() - db_session.refresh(db_obj) - return db_obj - -def _get_version_usage_details(db_session: Session, version_id: int): - """Get detailed resource usage information for a version.""" - lang_alias = aliased(db_models.Language) - version_alias = aliased(db_models.Version) - license_alias = aliased(db_models.License) - - resources_with_details = ( - db_session.query(db_models.Resource, lang_alias, version_alias, license_alias) - .join(lang_alias, lang_alias.language_id == db_models.Resource.language_id) - .join(version_alias, version_alias.version_id == db_models.Resource.version_id) - .join(license_alias, license_alias.license_id == db_models.Resource.license_id) - .filter(db_models.Resource.version_id == version_id) - .all() - ) - - used_by = [] - for resource, language, version, _ in resources_with_details: - version_code = getattr(version, "abbreviation", getattr(version, "code", None)) - content_val = schema.ContentTypeEnum(resource.content_type).value - - resource_name = _build_resource_name( - language_code=language.language_code, - version_code=version_code, - revision=resource.revision, - content_type_value=content_val, - ) - - used_by.append( - schema.ResourceUsageDetail( - resourceId=resource.resource_id, - resourceName=resource_name, - ) - ) - - return used_by -def delete_versions_bulk(db_session: Session, version_ids: List[int]): - deleted_ids = [] - errors = [] - not_found = [] - conflicts = [] - - for vid in version_ids: - obj = get_version(db_session, vid) - if not obj: - not_found.append(vid) - errors.append(f"Version {vid} not found") - continue - - used_resources = ( - db_session.query(db_models.Resource) - .filter_by(version_id=vid) - .all() - ) - - if used_resources: - conflicts.append(vid) - errors.append( - f"Version {obj.name} (id: {vid}) is in use and cannot be deleted" - ) - continue - - try: - db_session.delete(obj) - deleted_ids.append(vid) - except Exception as exc: - errors.append( - f"Version {obj.name} (id: {vid}) could not be deleted: {str(exc)}" - ) - - db_session.commit() - - return { - "data": { - "deletedCount": len(deleted_ids), - "deletedIds": deleted_ids, - "errors": errors if errors else None, - }, - "meta": { - "not_found": not_found, - "conflicts": conflicts, - } - } - - -# --- Language CRUD --- -def get_languages_with_pagination( - db_session: Session, - page: int = 0, - page_size: int = 100, - language_name: Optional[str] = None, - language_code: Optional[str] = None -) -> Tuple[List[db_models.Language], int]: - """Retrieve languages with pagination and optional filtering.""" - query = db_session.query(db_models.Language) - # Apply filters if provided - filters = [] - if language_name: - filters.append(db_models.Language.language_name.ilike(f"%{language_name}%")) - if language_code: - filters.append(db_models.Language.language_code.ilike(f"%{language_code}%")) - if filters: - query = query.filter(or_(*filters)) - - # Get total count before pagination - total_items = query.count() - # Apply ordering and pagination - offset = page * page_size - languages = query.order_by( - db_models.Language.language_id.asc()).offset(offset).limit(page_size).all() - - return languages, total_items -def get_language(db_session: Session, language_id: int): - """Retrieve a single language by ID.""" - return db_session.query(db_models.Language).filter( - db_models.Language.language_id == language_id - ).first() - -def create_language(db_session: Session, lang: schema.LanguageCreate): - """Create a new language if it does not already exist.""" - # Additional validation for required fields - if not lang.language_name or lang.language_name.strip() == "": - logger.error("Language name is required") - raise UnprocessableException(detail="Language name is required") - if not lang.language_code or lang.language_code.strip() == "": - logger.error("Language code is required") - raise UnprocessableException(detail="Language code is required") - - # Check for existing language code - if db_session.query(db_models.Language).filter( - db_models.Language.language_code == lang.language_code - ).first(): - raise AlreadyExistsException(detail="Language code already exists") - - # Check for existing language name - if db_session.query(db_models.Language).filter( - db_models.Language.language_name == lang.language_name - ).first(): - raise AlreadyExistsException(detail="Language name already exists") - - # Create new language object - db_obj = db_models.Language( - language_code=lang.language_code, - language_name=lang.language_name, - meta_data=lang.metadata - ) - db_session.add(db_obj) - db_session.commit() - db_session.refresh(db_obj) - return db_obj - -def update_language(db_session: Session, language_id: int, lang: schema.LanguageUpdate): - """Update an existing language by ID with duplicate code check.""" - db_obj = get_language(db_session, language_id) - if not db_obj: - raise NotAvailableException(detail="Language not found") - - # Additional validation for required fields - if not lang.language_name or lang.language_name.strip() == "": - raise UnprocessableException(detail="Language name is required") - if not lang.language_code or lang.language_code.strip() == "": - raise UnprocessableException(detail="Language code is required") - - # Check duplicate code (exclude current record) - if db_session.query(db_models.Language).filter( - db_models.Language.language_code == lang.language_code, - db_models.Language.language_id != language_id - ).first(): - raise AlreadyExistsException(detail="Language code already exists") - - # Check duplicate name (exclude current record) - if db_session.query(db_models.Language).filter( - db_models.Language.language_name == lang.language_name, - db_models.Language.language_id != language_id - ).first(): - raise AlreadyExistsException(detail="Language name already exists") - - # Update fields - db_obj.language_code = lang.language_code - db_obj.language_name = lang.language_name - db_obj.meta_data = lang.metadata - db_session.commit() - db_session.refresh(db_obj) - return db_obj -def _get_language_usage_details(db_session: Session, language_id: int): - """Get detailed information about resources using a language.""" - lang_alias = aliased(db_models.Language) - version_alias = aliased(db_models.Version) - license_alias = aliased(db_models.License) - - resources_with_details = ( - db_session.query(db_models.Resource, lang_alias, version_alias, license_alias) - .join(lang_alias, lang_alias.language_id == db_models.Resource.language_id) - .join(version_alias, version_alias.version_id == db_models.Resource.version_id) - .join(license_alias, license_alias.license_id == db_models.Resource.license_id) - .filter(db_models.Resource.language_id == language_id) - .all() - ) - - used_by = [] - for resource, language, version,_ in resources_with_details: - version_code = getattr(version, "abbreviation", getattr(version, "code", None)) - content_val = schema.ContentTypeEnum(resource.content_type).value - resource_name = _build_resource_name( - language_code=language.language_code, - version_code=version_code, - revision=resource.revision, - content_type_value=content_val, - ) - used_by.append(schema.ResourceUsageDetail( - resourceId=resource.resource_id, - resourceName=resource_name - )) - - return used_by - - -def delete_languages_bulk(db_session: Session, language_ids: List[int]): - deleted_ids = [] - errors = [] - - for lid in language_ids: - try: - db_obj = get_language(db_session, lid) - if not db_obj: - errors.append(f"Language {lid} not found") - continue - - used_resources = ( - db_session.query(db_models.Resource) - .filter_by(language_id=lid) - .all() - ) - - if used_resources: - errors.append( - f"Language {lid} ('{db_obj.language_name}') is in use and cannot be deleted" - ) - continue - - db_session.delete(db_obj) - deleted_ids.append(lid) - - except Exception as exc: - errors.append(f"Error deleting language {lid}: {str(exc)}") - - db_session.commit() - - # Consistent structure like delete_videos & delete_versions_bulk - all_failed = len(deleted_ids) == 0 and len(errors) > 0 - has_errors = len(errors) > 0 - - return { - "data": { - "deletedCount": len(deleted_ids), - "deletedIds": deleted_ids, - "errors": errors if errors else None, - }, - "all_failed": all_failed, - "has_errors": has_errors, - } - - - -# --- License CRUD --- -def get_licenses_with_filters( - db_session: Session, - license_id: Optional[int] = None, - name: Optional[str] = None -) -> List[db_models.License]: - """Retrieve licenses with optional filtering.""" - query = db_session.query(db_models.License) - - # Apply filters if provided - if license_id is not None: - query = query.filter(db_models.License.license_id == license_id) - - if name: - query = query.filter(db_models.License.license_name.ilike(f"%{name}%")) - - # Order by license_id for consistent results - return query.order_by(db_models.License.license_id.asc()).all() - -def get_license(db_session: Session, license_id: int): - """Retrieve a single license by ID.""" - return db_session.query(db_models.License).filter( - db_models.License.license_id == license_id - ).first() - -def create_license(db_session: Session, license_: schema.LicenseCreate): - """Create a new license if it does not already exist.""" - # Additional validation for required fields - if not license_.license_name or license_.license_name.strip() == "": - raise UnprocessableException(detail="License name is required") - if not hasattr(license_, 'details') or not license_.details or license_.details.strip() == "": - raise UnprocessableException(detail="License details are required") - - existing = db_session.query(db_models.License).filter( - db_models.License.license_name == license_.license_name - ).first() - if existing: - raise AlreadyExistsException(detail="License already exists") - - db_obj = db_models.License( - license_name=license_.license_name, - details=license_.details - ) - db_session.add(db_obj) - db_session.commit() - db_session.refresh(db_obj) - return db_obj - -def update_license(db_session: Session, license_id: int, license_: schema.LicenseUpdate): - """Update an existing license by ID with duplicate name check.""" - db_obj = get_license(db_session, license_id) - if not db_obj: - raise NotAvailableException(detail="License not found") - - # Additional validation for required fields - if not license_.license_name or license_.license_name.strip() == "": - raise UnprocessableException(detail="License name is required") - if not hasattr(license_, 'details') or not license_.details or license_.details.strip() == "": - raise UnprocessableException(detail="License details are required") - - # Check duplicate name (exclude current record) - duplicate = db_session.query(db_models.License).filter( - db_models.License.license_name == license_.license_name, - db_models.License.license_id != license_id - ).first() - if duplicate: - raise AlreadyExistsException(detail="License name already exists") - - # Update fields - db_obj.license_name = license_.license_name - db_obj.details = license_.details - - db_session.commit() - db_session.refresh(db_obj) - return db_obj - -def _get_resource_usage_details(db_session: Session, filter_field: str, filter_value: int): - """Get detailed information about resources using a specific entity (language/version/license). - - Args: - db_session: Database session - filter_field: Field name to filter on ('language_id', 'version_id', or 'license_id') - filter_value: Value to filter by - - Returns: - List of ResourceUsageDetail objects - """ - lang_alias = aliased(db_models.Language) - version_alias = aliased(db_models.Version) - license_alias = aliased(db_models.License) - - resources_with_details = ( - db_session.query(db_models.Resource, lang_alias, version_alias, license_alias) - .join(lang_alias, lang_alias.language_id == db_models.Resource.language_id) - .join(version_alias, version_alias.version_id == db_models.Resource.version_id) - .join(license_alias, license_alias.license_id == db_models.Resource.license_id) - .filter(getattr(db_models.Resource, filter_field) == filter_value) - .all() - ) - - used_by = [] - for resource, language, version, _ in resources_with_details: - version_code = getattr(version, "abbreviation", getattr(version, "code", None)) - content_val = schema.ContentTypeEnum(resource.content_type).value - resource_name = _build_resource_name( - language_code=language.language_code, - version_code=version_code, - revision=resource.revision, - content_type_value=content_val, - ) - used_by.append(schema.ResourceUsageDetail( - resourceId=resource.resource_id, - resourceName=resource_name - )) - - return used_by - - -def delete_licenses_bulk(db_session: Session, license_ids: List[int]): - deleted_ids = [] - errors = [] - - for lid in license_ids: - try: - db_obj = get_license(db_session, lid) - if not db_obj: - errors.append(f"License {lid} not found") - continue - - used_resources = ( - db_session.query(db_models.Resource) - .filter_by(license_id=lid) - .all() - ) - - if used_resources: - errors.append( - f"License {lid} ('{db_obj.license_name}') is in use and cannot be deleted" - ) - continue - - db_session.delete(db_obj) - deleted_ids.append(lid) - - except Exception as exc: - errors.append(f"Error deleting license {lid}: {str(exc)}") - - db_session.commit() - - # Consistent response structure - all_failed = len(deleted_ids) == 0 and len(errors) > 0 - has_errors = len(errors) > 0 - - return { - "data": { - "deletedCount": len(deleted_ids), - "deletedIds": deleted_ids, - "errors": errors if errors else None, - }, - "all_failed": all_failed, - "has_errors": has_errors, - } - -# --- Resource CRUD --- - -def _build_resource_name(language_code: str, - version_code: str | None, - revision: str | None, - content_type_value: str) -> str: - """ - Format: ___ - e.g., "hin_HINREV_1.1_bible" - - - Skips empty parts. - - All separated by underscores. - """ - parts = [language_code or "", version_code or "", revision or "", content_type_value] - return "_".join([p for p in parts if p]) - - -def get_resources( - db: Session, - filters: schema.ResourceFilter -) -> List[schema.LanguageGroupOut]: - """ - Retrieve a list of resources grouped by language. - """ - - lang_alias = aliased(db_models.Language) - ver_alias = aliased(db_models.Version) - lic_alias = aliased(db_models.License) - - query = ( - db.query(db_models.Resource, lang_alias, ver_alias, lic_alias) - .join(lang_alias, lang_alias.language_id == db_models.Resource.language_id) - .join(ver_alias, ver_alias.version_id == db_models.Resource.version_id) - .join(lic_alias, lic_alias.license_id == db_models.Resource.license_id) - ) - - if filters.resource_id: - query = query.filter(db_models.Resource.resource_id == filters.resource_id) - - if filters.content_type: - query = query.filter(db_models.Resource.content_type == filters.content_type.lower()) - - if filters.published is not None: - query = query.filter(db_models.Resource.published == filters.published) - - rows = ( - query.order_by(db_models.Resource.resource_id.asc()) - .offset(filters.page * filters.page_size) - .limit(filters.page_size) - .all() - ) - - if filters.resource_id and not rows: - raise NotAvailableException(detail="Resource not found") - - return _group_resources(rows) -def _group_resources(rows: List[tuple]) -> List[schema.LanguageGroupOut]: - groups: Dict[int, Dict[str, Any]] = {} - - for resource, lang, version, lic in rows: - lid = lang.language_id - - if lid not in groups: - groups[lid] = { - "language": schema.LanguageBrief( - id=lid, - code=lang.language_code, - name=lang.language_name, - ), - "versions": [] - } - - version_code = getattr(version, "abbreviation", getattr(version, "code", None)) - content_val = schema.ContentTypeEnum(resource.content_type).value - - resource_name = _build_resource_name( - language_code=lang.language_code, - version_code=version_code, - revision=resource.revision, - content_type_value=content_val, - ) - - groups[lid]["versions"].append( - schema.ResourceRowResponse( - resourceId=resource.resource_id, - resourceName=resource_name, - revision=resource.revision, - version=schema.VersionRef( - id=version.version_id, - name=version.name, - code=version_code, - ), - content=schema.ContentRef( - contentType=schema.ContentTypeEnum(resource.content_type) - ), - license=schema.LicenseRef( - id=lic.license_id, - name=lic.license_name - ), - language=schema.LanguageBrief( - id=lang.language_id, - code=lang.language_code, - name=lang.language_name - ), - metadata=json.loads(resource.meta_data) if resource.meta_data else None, - published=bool(resource.published), - createdBy=resource.created_by, - createdTime=resource.created_at, - updatedBy=resource.updated_by, - updatedTime=resource.updated_at - ) - ) - - return [schema.LanguageGroupOut(**g) for g in groups.values()] - - -def create_resource( - db: Session, - payload: schema.ResourceCreate, - created_by: Optional[int] = None) -> schema.ResourceResponse: - """Create a new resource and return response schema.""" - # Check if resource already exists - ct = payload.content_type.value.lower() - now = utcnow() - existing = ( - db.query(db_models.Resource) - .filter_by( - version_id=payload.version_id, - language_id=payload.language_id, - license_id=payload.license_id, - revision=payload.revision, - content_type=payload.content_type.value.lower(), - ) - .first() - ) - if existing: - raise AlreadyExistsException(detail="Resource already exists") - version = db.query(db_models.Version).filter_by(version_id=payload.version_id).first() - if not version: - raise NotAvailableException(detail="versionId not found") - language = db.query(db_models.Language).filter_by(language_id=payload.language_id).first() - if not language: - raise NotAvailableException(detail="languageId not found") - license_ = db.query(db_models.License).filter_by(license_id=payload.license_id).first() - if not license_: - raise NotAvailableException(detail="licenseId not found") - ct = payload.content_type.value.lower() - - db_obj = db_models.Resource( - version_id=payload.version_id, - revision=payload.revision, - content_type=ct, - language_id=payload.language_id, - license_id=payload.license_id, - meta_data=json.dumps(payload.metadata, ensure_ascii=False) if payload.metadata else None, - created_by=created_by, - created_at=now, - published=bool(getattr(payload, "published", False)), - # published=False, # always default to False on POST - ) - db.add(db_obj) - db.commit() - db.refresh(db_obj) - version_code = getattr(version, "abbreviation", getattr(version, "code", None)) - resource_name = _build_resource_name( - language_code=language.language_code, - version_code=version_code, - revision=db_obj.revision, - content_type_value=ct, - ) - now = utcnow() - - return schema.ResourceResponse( - resourceId=db_obj.resource_id, - resourceName=resource_name, - revision=db_obj.revision, - version=schema.VersionRef( - id=version.version_id, - name=version.name, - code=getattr(version, "abbreviation", getattr(version, "code", "")), - ), - language=schema.LanguageBrief( - id=language.language_id, - code=language.language_code, - name=language.language_name - ), - content=schema.ContentRef(contentType=schema.ContentTypeEnum(db_obj.content_type)), - license=schema.LicenseRef(id=license_.license_id, name=license_.license_name), - metadata=json.loads(db_obj.meta_data) if db_obj.meta_data else None, - published=bool(db_obj.published), - createdBy=db_obj.created_by, - createdTime=db_obj.created_at, - updatedBy=None, - updatedTime=None, - ) - - -def update_resource( - db: Session, - payload: schema.ResourceUpdate, - user_id: Optional[int] = None -) -> schema.ResourceResponse: - """Update resource and return response schema.""" - db_obj = _get_resource_or_404(db, payload.resource_id) - - # Determine final values for uniqueness validation - final_values = _resolve_final_values(db_obj, payload) - - _validate_uniqueness(db, payload.resource_id, final_values) - - # Update main resource fields - version, language, license_ = _apply_updates(db, db_obj, payload) - - # Extra fields - if payload.metadata is not None: - db_obj.meta_data = json.dumps(payload.metadata, ensure_ascii=False) - if payload.published is not None: - db_obj.published = bool(payload.published) - - db_obj.updated_by = user_id - db_obj.updated_at = utcnow() - - db.commit() - db.refresh(db_obj) - - return _build_response(db_obj, version, language, license_) -def _get_resource_or_404(db: Session, resource_id: int): - obj = db.query(db_models.Resource).filter_by(resource_id=resource_id).first() - if not obj: - raise NotAvailableException(detail="Resource not found") - return obj -def _resolve_final_values(db_obj, payload): - return { - "version_id": payload.version_id or db_obj.version_id, - "language_id": payload.language_id or db_obj.language_id, - "license_id": payload.license_id or db_obj.license_id, - "revision": payload.revision or db_obj.revision, - "content_type": payload.content_type.value.lower() - if payload.content_type else db_obj.content_type, - } -def _validate_uniqueness(db: Session, current_id: int, vals: dict): - existing = ( - db.query(db_models.Resource) - .filter( - db_models.Resource.version_id == vals["version_id"], - db_models.Resource.language_id == vals["language_id"], - db_models.Resource.license_id == vals["license_id"], - db_models.Resource.revision == vals["revision"], - db_models.Resource.content_type == vals["content_type"], - db_models.Resource.resource_id != current_id, - ) - .first() - ) - if existing: - raise AlreadyExistsException(detail="Resource already exists") -def _apply_updates(db: Session, db_obj, payload): - # Version - version = _validate_and_get( - db, - db_models.Version, - payload.version_id or db_obj.version_id, "versionId") - db_obj.version_id = version.version_id - - # Language - language = _validate_and_get( - db, - db_models.Language, - payload.language_id or db_obj.language_id, "languageId" - ) - db_obj.language_id = language.language_id - - # License - license_ = _validate_and_get( - db, - db_models.License, - payload.license_id or db_obj.license_id, "licenseId" - ) - db_obj.license_id = license_.license_id - - # Revision & content type - if payload.revision is not None: - db_obj.revision = payload.revision - if payload.content_type is not None: - db_obj.content_type = payload.content_type.value.lower() - - return version, language, license_ -def _validate_and_get(db, model, id_value, field_name): - pk_column = model.__mapper__.primary_key[0].name - - obj = db.query(model).filter(getattr(model, pk_column) == id_value).first() - if not obj: - raise NotAvailableException(detail=f"{field_name} not found") - return obj - -def _build_response(db_obj, version, language, license_): - resource_name = _build_resource_name( - language_code=language.language_code, - version_code=getattr(version, "abbreviation", getattr(version, "code", None)), - revision=db_obj.revision, - content_type_value=schema.ContentTypeEnum(db_obj.content_type).value, - ) - - return schema.ResourceResponse( - resourceId=db_obj.resource_id, - resourceName=resource_name, - revision=db_obj.revision, - version=schema.VersionRef( - id=version.version_id, - name=version.name, - code=getattr(version, "abbreviation", getattr(version, "code", "")), - ), - language=schema.LanguageBrief( - id=language.language_id, - code=language.language_code, - name=language.language_name, - ), - content=schema.ContentRef(contentType=schema.ContentTypeEnum(db_obj.content_type)), - license=schema.LicenseRef(id=license_.license_id, name=license_.license_name), - metadata=json.loads(db_obj.meta_data) if db_obj.meta_data else None, - published=bool(db_obj.published), - createdBy=db_obj.created_by, - createdTime=db_obj.created_at, - updatedBy=db_obj.updated_by, - updatedTime=db_obj.updated_at, - ) - -def delete_resources_bulk(db: Session, resource_ids: List[int]): - deleted_ids = [] - errors = [] - - related_models = [ - db_models.Bible, - db_models.CleanBible, - db_models.Video, - db_models.Commentary, - db_models.Dictionary, - db_models.AudioBible, - db_models.Obs, - db_models.Infographic, - ] - - for rid in resource_ids: - try: - db_obj = ( - db.query(db_models.Resource) - .filter_by(resource_id=rid) - .first() - ) - - if not db_obj: - errors.append(f"Resource {rid} not found") - continue - - # Delete dependent entities first - for model in related_models: - db.query(model).filter_by(resource_id=rid).delete( - synchronize_session=False - ) - - # Delete main resource - db.delete(db_obj) - deleted_ids.append(rid) - - except IntegrityError: - db.rollback() - errors.append( - f"Resource {rid} could not be deleted due to database constraints" - ) - - except Exception as exc: - db.rollback() - errors.append(f"Error deleting resource {rid}: {str(exc)}") - - db.commit() - - # ---- Consistent structure ---- - all_failed = len(deleted_ids) == 0 and len(errors) > 0 - has_errors = len(errors) > 0 - - return { - "data": { - "deletedCount": len(deleted_ids), - "deletedIds": deleted_ids, - "errors": errors if errors else None, - }, - "all_failed": all_failed, - "has_errors": has_errors, - } - -# --- Log files CRUD --- - -def latest_log_file(): - """Get latest log file""" - # Find newest log file - files = sorted( - [f for f in os.listdir(LOG_DIR) if f.startswith("vachan_admin_app.log")], - key=lambda x: os.path.getmtime(os.path.join(LOG_DIR, x)), - reverse=True - ) - if not files: - raise NotAvailableException(detail="No log files found") - path = os.path.join(LOG_DIR, files[0]) - return FileResponse( - path=path, - media_type="text/plain", - filename=files[0] - ) - - -def get_logfile_by_number(log_file_no): - """Get log file by number""" - if log_file_no < 0 or log_file_no > 10: - raise BadRequestException(detail="log_file_no must be 0–10") - filename = "vachan_admin_app.log" if log_file_no == 0 else f"vachan_admin_app.log.{log_file_no}" - path = os.path.join(LOG_DIR, filename) - if not os.path.exists(path): - raise NotAvailableException(detail=f"Log file {filename} not found") - return FileResponse( - path=path, - media_type="text/plain", - filename=filename - ) - -def get_all_logfiles(): - """Get all log files in a zip format""" - - tmp = tempfile.NamedTemporaryFile(delete=False, suffix=".zip") - tmp_path = tmp.name - tmp.close() - - with zipfile.ZipFile(tmp_path, "w", zipfile.ZIP_DEFLATED) as zf: - for fname in os.listdir(LOG_DIR): - if fname.startswith("vachan_admin_app.log"): - zf.write(os.path.join(LOG_DIR, fname), arcname=fname) - - return FileResponse( - path=tmp_path, - media_type="application/zip", - filename="logs.zip" - ) - - -def touch_resource(db: Session, resource_id: int, actor_user_id: Optional[int]) -> None: - """ - Update only the resource row's updated_by/updated_at. - Call this from child-table CRUD whenever they modify rows. - """ - res = db.query(db_models.Resource).filter_by(resource_id=resource_id).first() - if not res: - # If this is ever hit, caller already verified resource existence; keep consistent - raise NotAvailableException(detail=f"Resource {resource_id} not found") - res.updated_by = actor_user_id - res.updated_at = utcnow() - db.add(res) - -# --- Video CRUD --- - -def create_videos(db: Session, data: schema.VideoBulkCreate, actor_user_id: int): - """Create a new video entry""" - # Resource must exist - resource = db.query(db_models.Resource).filter_by(resource_id=data.resourceId).first() - if not resource: - raise NotAvailableException(detail=f"Resource {data.resourceId} not found") - # Resource content_type must be 'video' - if resource.content_type.lower() != "video": - raise BadRequestException( - f"Resource {data.resourceId} is not of type 'video' (found '{resource.content_type}')" - ) - created = [] - for v in data.videos: - # Check duplicates (resource_id + book + chapter + url) - existing = ( - db.query(db_models.Video) - .filter_by(resource_id=data.resourceId, book=v.book, chapter=v.chapter, title=v.title) - .first() - ) - if existing: - raise AlreadyExistsException( - detail=( - f"Video {v.book} {v.chapter} {v.title} " - f"already exists in resource {data.resourceId}" - ) - ) - # Create new video record - video = db_models.Video( - resource_id=data.resourceId, - book=v.book, - chapter=v.chapter, - url=v.url, - title=v.title, - description=v.description, - ) - db.add(video) - created.append(video) - - touch_resource(db, data.resourceId, actor_user_id) - db.commit() - - # Return structured response - return { - "resource_id": data.resourceId, - "videos": created - } - - - -def update_videos(db: Session, data: schema.VideoBulkUpdate, actor_user_id: int): - """Update a video entry""" - # Resource must exist - resource = db.query(db_models.Resource).filter_by(resource_id=data.resourceId).first() - if not resource: - raise NotAvailableException(detail=f"Resource {data.resourceId} not found") - - # Resource content_type must be 'video' - if resource.content_type.lower() != "video": - raise BadRequestException( - detail=( - f"Resource {data.resourceId} is not of type 'video'" - f" (found '{resource.content_type}')" - ) - ) - - updated = [] - for v in data.videos: - # Find the video by id + resource - video = ( - db.query(db_models.Video) - .filter_by(video_id=v.id, resource_id=data.resourceId) - .first() - ) - if not video: - raise NotAvailableException( - detail=f"Video {v.id} not found in resource {data.resourceId}" - ) - - # Check duplicates (exclude the current video itself) - duplicate = ( - db.query(db_models.Video) - .filter( - db_models.Video.resource_id == data.resourceId, - db_models.Video.book == v.book, - db_models.Video.chapter == v.chapter, - db_models.Video.url == v.title, - db_models.Video.video_id != v.id, # exclude self - ) - .first() - ) - if duplicate: - raise AlreadyExistsException( - detail= ( - f"Video {v.book} {v.chapter} {v.url} already exists" - f" in resource {data.resourceId}" - ) - ) - - # Update fields - video.book = v.book - video.chapter = v.chapter - video.url = v.url - video.title = v.title - video.description = v.description - updated.append(video) - touch_resource(db, data.resourceId, actor_user_id) - db.commit() - return { - "resource_id": data.resourceId, - "videos": updated - } - -def get_videos_filtered( - db: Session, - resource_id: int = None, - language_code: str = None, - book_code: str = None, - chapter: int = None -): - """Get videos filtered by resource, language, book, and chapter.""" - - # Validate resource if provided - if resource_id: - resource = db.query(db_models.Resource).filter( - db_models.Resource.resource_id == resource_id - ).first() - if not resource: - raise NotAvailableException(detail=f"Resource {resource_id} not found") - - if resource.content_type.lower() != "video": - raise BadRequestException( - detail=f"Resource {resource_id} is not of type 'video' (found '{resource.content_type}')" - ) - - # Validate language if provided - if language_code: - language = db.query(db_models.Language).filter( - db_models.Language.language_code == language_code - ).first() - if not language: - raise NotAvailableException(detail=f"Language '{language_code}' not found") - - - if book_code: - book_code_lower = book_code.lower() - - # Check if book exists in video table - book_exists = db.query(db_models.Video).filter( - func.lower(db_models.Video.book) == book_code_lower - ).first() - - if not book_exists: - raise NotAvailableException( - detail=f"Book code '{book_code}' not found in videos" - ) - - # Chapter validation - if chapter is not None: - chapter_exists = db.query(db_models.Video).filter( - func.lower(db_models.Video.book) == book_code_lower, - db_models.Video.chapter == chapter - ).first() - - if not chapter_exists: - raise NotAvailableException( - detail=f"Chapter {chapter} not found for book '{book_code}'" - ) - - query = db.query(db_models.Video) - - if resource_id: - query = query.filter(db_models.Video.resource_id == resource_id) - - if book_code: - query = query.filter(func.lower(db_models.Video.book) == book_code_lower) - - if chapter is not None: - query = query.filter(db_models.Video.chapter == chapter) - - videos = query.all() - - result = {"books": {}} - - for v in videos: - book_key = (v.book or "").lower().strip() - chapter_key = str(v.chapter) - - if book_key not in result["books"]: - result["books"][book_key] = {} - - if chapter_key not in result["books"][book_key]: - result["books"][book_key][chapter_key] = [] - - result["books"][book_key][chapter_key].append({ - "video_id": v.video_id, - "title": v.title, - "description": v.description, - "url": v.url - }) - - return result - -def delete_videos(db: Session, resource_id: int, video_ids: List[int]): - """Delete multiple videos from a resource""" - # Check if resource exists - resource = db.query(db_models.Resource).filter_by(resource_id=resource_id).first() - if not resource: - raise NotAvailableException(detail=f"Resource {resource_id} not found") - - deleted_ids = [] - invalid_ids = [] - - for video_id in video_ids: - video = ( - db.query(db_models.Video) - .filter( - db_models.Video.video_id == video_id, - db_models.Video.resource_id == resource_id, - ) - .first() - ) - - if video: - db.delete(video) - deleted_ids.append(video_id) - else: - invalid_ids.append(video_id) - - db.commit() - - response = { - "deletedCount": len(deleted_ids), - "deletedIds": deleted_ids, - "message": ( - f"Successfully deleted {len(deleted_ids)}" - f" video{'s' if len(deleted_ids) != 1 else ''}" - ) - } - - if invalid_ids: - response["error"] = f"Invalid video_ids: {', '.join(map(str, invalid_ids))}" - - # Return response with status code indicator - return { - "data": response, - "has_errors": len(invalid_ids) > 0, - "all_failed": len(deleted_ids) == 0 - } -# ---- Bibles ---- - - -# Utility functions for API operations - -def extract_book_code_from_usfm(usfm_content: str) -> str: - """Extract book code from USFM content using usfm-grammar""" - try: - parser = USFMParser(usfm_content) - usj_data = parser.to_usj() - - for item in usj_data.get("content", []): - if item.get("type") == "book" and item.get("marker") == "id": - return item.get("code") - - raise UnprocessableException("No book code found in USFM content") - - except Exception as e: - raise UnprocessableException( - f"USFM parsing error: {str(e)}" - ) from e - -def validate_chapter_count(db_session: Session, - book_code: str, chapter_count: int): - """Validate chapter count against DB table""" - book = ( - db_session.query(db_models.BookLookup) - .filter(db_models.BookLookup.book_code == book_code.lower()) - .first() - ) - - if not book: - raise NotAvailableException(detail=f"Book {book_code} not found") - - if chapter_count > book.chapter_count: - raise BadRequestException( - detail=( - f"Invalid chapter count {chapter_count} for book {book_code}. " - f"Max allowed: {book.chapter_count}" - ) - ) - -def parse_verse_number(verse_str: str) -> List[int]: - """ - Parse verse number strings that might contain ranges. - - Examples: - - "1" -> [1] - - "23-24" -> [23, 24] - """ - verses = [] - - if not verse_str: - return verses - - verse_str = str(verse_str).strip() - - try: - # Split by comma for multiple groups - groups = verse_str.split(',') - - for group in groups: - group = group.strip() - - if '-' in group: - # Handle ranges like "23-24" - parts = group.split('-') - if len(parts) == 2: - try: - start = int(parts[0].strip()) - end = int(parts[1].strip()) - verses.extend(range(start, end + 1)) - except ValueError: - # Fallback: treat as single verse - verses.append(int(group)) - else: - verses.append(int(group)) - else: - # Single verse - verses.append(int(group)) - - return sorted(set(verses)) # Remove duplicates and sort - - except (ValueError, AttributeError) as e: - raise ValueError(f"Could not parse verse number: {verse_str}") from e - - -def parse_usfm_to_clean_verses(usj_data: Dict[str, Any]) -> List[Dict[str, Any]]: - """Parse USJ data to extract clean verse-by-verse content, handling verse ranges""" - verses = [] - chapter = None - - for item in usj_data.get("content", []): - item_type = item.get("type") - - if item_type == "chapter": - chapter = item.get("number") - continue - - if item_type == "para": - _process_paragraph(item.get("content", []), chapter, verses) - - return verses - -def _process_paragraph(para_content: List[Any], chapter: int, verses: List[Dict[str, Any]]) -> None: - """Process paragraph and extract individual verses.""" - i = 0 - length = len(para_content) - - while i < length: - element = para_content[i] - - if not _is_verse_marker(element): - i += 1 - continue - - verse_str = element.get("number") - i += 1 - - verse_text, i = _collect_verse_text(para_content, i) - - if verse_text: - _expand_and_add_verses(verse_str, verse_text, chapter, verses) - -def _is_verse_marker(element: Any) -> bool: - return isinstance(element, dict) and element.get("type") == "verse" - -def _collect_verse_text(para_content: List[Any], index: int) -> tuple[str, int]: - parts = [] - length = len(para_content) - - while index < length and isinstance(para_content[index], str): - parts.append(para_content[index]) - index += 1 - - return " ".join(parts).strip(), index - -def _expand_and_add_verses( - verse_str: str, - verse_text: str, - chapter: int, - verses: List[Dict[str, Any]] -): - try: - verse_numbers = parse_verse_number(verse_str) - for number in verse_numbers: - verses.append({ - "chapter": chapter, - "verse": number, - "text": verse_text, - }) - except ValueError as e: - print(f"Warning: Could not parse verse '{verse_str}' in chapter {chapter}: {e}") - - -# CRUD Operations -def upload_bible_book( - db_session: Session, - resource_id: int, - usfm_file: UploadFile, - actor_user_id: int -) -> Dict[str, str]: - """Upload and process a new bible book""" - - _get_resource(db_session, resource_id) - usfm_content = _read_usfm_file(usfm_file) - book_code = extract_book_code_from_usfm(usfm_content) - book = _lookup_book_or_404(db_session, book_code) - - _check_book_not_exists(db_session, resource_id, book.book_id) - - usj_data = _parse_usfm_to_usj(usfm_content) - - content_items = usj_data.get("content", []) - chapter_count = _count_chapters(content_items) - validate_chapter_count(db_session, book_code, chapter_count) - - entry_data = BibleEntrySchema( - resource_id=resource_id, - book_id=book.book_id, - usfm_content=usfm_content, - usj_data=usj_data, - chapter_count=chapter_count - ) - - bible_record = _create_bible_entry(db_session, entry_data) - - _save_clean_verses( - db_session=db_session, - resource_id=resource_id, - book_id=book.book_id, - usj_data=usj_data - ) - - touch_resource(db_session, resource_id=bible_record.resource_id, actor_user_id=actor_user_id) - db_session.commit() - - return { - "message": "Bible book uploaded successfully", - "bible_book_id": bible_record.bible_book_id - } -def _get_resource(db_session: Session, resource_id: int): - resource = db_session.query(db_models.Resource).filter_by(resource_id=resource_id).first() - if not resource: - raise NotAvailableException(detail="Resource not found") - return resource -def _read_usfm_file(usfm_file: UploadFile) -> str: - return usfm_file.file.read().decode("utf-8") - - -def _lookup_book_or_404(db_session: Session, book_code: str): - book = db_session.query(db_models.BookLookup).filter( - func.lower(db_models.BookLookup.book_code) == book_code.lower() - ).first() - if not book: - raise NotAvailableException(detail=f"Book {book_code} not found") - return book - - -def _check_book_not_exists(db_session: Session, resource_id: int, book_id: int): - existing = db_session.query(db_models.Bible).filter_by( - resource_id=resource_id, - book_id=book_id - ).first() - if existing: - raise AlreadyExistsException( - detail=f"Book already exists for resource {resource_id}" - ) - - -def _parse_usfm_to_usj(usfm_content: str) -> Dict[str, Any]: - try: - return USFMParser(usfm_content).to_usj() - except Exception as e: - raise UnprocessableException(detail=f"Error parsing USFM: {str(e)}") from e - - -def _count_chapters(content_items: List[Dict[str, Any]]) -> int: - return len([item for item in content_items if item.get("type") == "chapter"]) - - -def _create_bible_entry(db_session: Session, data: BibleEntrySchema): - bible_record = db_models.Bible( - resource_id=data.resource_id, - book_id=data.book_id, - usfm=data.usfm_content, - json=data.usj_data, - chapters=data.chapter_count, - ) - db_session.add(bible_record) - db_session.flush() - return bible_record - -def _save_clean_verses( - db_session: Session, - resource_id: int, - book_id: int, - usj_data: Dict[str, Any] -): - verses = parse_usfm_to_clean_verses(usj_data) - for verse in verses: - db_session.add( - db_models.CleanBible( - resource_id=resource_id, - book_id=book_id, - chapter=verse["chapter"], - verse=verse["verse"], - text=verse["text"], - ) - ) - - -def update_bible_book( - db_session: Session, - bible_book_id: int, - usfm_file: UploadFile, - actor_user_id: int -) -> Dict[str, str]: - """Update an existing bible book""" - - bible_record = _get_bible_record_or_404(db_session, bible_book_id) - usfm_content = _read_usfm_file(usfm_file) - - book_code = extract_book_code_from_usfm(usfm_content) - _validate_book_code_matches(db_session, bible_record.book_id, book_code) - - usj_data = _parse_usfm_to_usj(usfm_content) - - content_items = usj_data.get("content", []) - chapter_count = _count_chapters(content_items) - validate_chapter_count(db_session, book_code, chapter_count) - - _update_bible_entry( - bible_record=bible_record, - usfm_content=usfm_content, - usj_data=usj_data, - chapter_count=chapter_count, - ) - - _replace_clean_verses( - db_session=db_session, - resource_id=bible_record.resource_id, - book_id=bible_record.book_id, - usj_data=usj_data - ) - - touch_resource(db_session, resource_id=bible_record.resource_id, actor_user_id=actor_user_id) - db_session.commit() - - return {"message": "Bible book updated successfully"} -def _get_bible_record_or_404(db_session: Session, bible_book_id: int): - record = db_session.query(db_models.Bible).filter_by(bible_book_id=bible_book_id).first() - if not record: - raise NotAvailableException(detail=f"Bible book {bible_book_id} not found") - return record -def _validate_book_code_matches(db_session: Session, book_id: int, new_code: str): - book = db_session.query(db_models.BookLookup).filter_by(book_id=book_id).first() - if book.book_code.lower() != new_code.lower(): - raise BadRequestException( - detail=f"Book code mismatch: {new_code} != {book.book_code}" - ) -def _update_bible_entry( - bible_record, - usfm_content: str, - usj_data: Dict[str, Any], - chapter_count: int -): - bible_record.usfm = usfm_content - bible_record.json = usj_data - bible_record.chapters = chapter_count -def _replace_clean_verses( - db_session: Session, - resource_id: int, - book_id: int, - usj_data: Dict[str, Any] -): - db_session.query(db_models.CleanBible).filter_by( - resource_id=resource_id, - book_id=book_id - ).delete() - - verses = parse_usfm_to_clean_verses(usj_data) - for v in verses: - db_session.add( - db_models.CleanBible( - resource_id=resource_id, - book_id=book_id, - chapter=v["chapter"], - verse=v["verse"], - text=v["text"] - ) - ) -def delete_bible_books( - db_session: Session, - resource_id: int, - book_codes: List[str] -): - """Delete multiple Bible books in a standardized structure.""" - - # Check if resource exists - resource = ( - db_session.query(db_models.Resource) - .filter_by(resource_id=resource_id) - .first() - ) - if not resource: - raise NotAvailableException(detail=f"Resource {resource_id} not found") - - deleted_ids = [] - errors = [] - processed = set() - - for code in book_codes: - code_lower = code.lower() - - # Duplicate check - if code_lower in processed: - errors.append(f"Duplicate book code: {code}") - continue - - processed.add(code_lower) - - # Lookup the book - book = ( - db_session.query(db_models.BookLookup) - .filter(func.lower(db_models.BookLookup.book_code) == code_lower) - .first() - ) - - if not book: - errors.append(f"Book code '{code}' not found in lookup") - continue - - # Check bible table - bible_row = ( - db_session.query(db_models.Bible) - .filter_by(resource_id=resource_id, book_id=book.book_id) - .first() - ) - - if not bible_row: - errors.append(f"Book '{code}' not found for resource {resource_id}") - continue - - # Delete clean bible rows - db_session.query(db_models.CleanBible).filter_by( - resource_id=resource_id, book_id=book.book_id - ).delete() - - # Delete bible entry - db_session.delete(bible_row) - deleted_ids.append(code) - - if deleted_ids: - db_session.commit() - - # Standardized flags - all_failed = len(deleted_ids) == 0 and len(errors) > 0 - has_errors = len(errors) > 0 - - return { - "data": { - "deletedCount": len(deleted_ids), - "deletedIds": deleted_ids, - "errors": errors if errors else None, - }, - "all_failed": all_failed, - "has_errors": has_errors, - } - - - -def get_bible_books(db_session: Session, resource_id: int) -> schema.BibleBooksListResponse: - """Get list of books for a bible resource""" - # Find resource - resource = db_session.query(db_models.Resource).filter_by(resource_id=resource_id).first() - if not resource: - raise NotAvailableException(detail=f"Resource {resource_id} not found") - - books = db_session.query(db_models.Bible, db_models.BookLookup).join( - db_models.BookLookup, db_models.Bible.book_id == db_models.BookLookup.book_id - ).filter(db_models.Bible.resource_id == resource_id).all() - - book_responses = [] - for bible, book_lookup in books: - book_responses.append(schema.BibleBookResponse( - bible_book_id=bible.bible_book_id, - book_code=book_lookup.book_code, - book_id=book_lookup.book_id, - short=book_lookup.book_code, # You may want to add these fields to BookLookup - long=book_lookup.book_name, - abbr=book_lookup.book_code[:3] - )) - - return schema.BibleBooksListResponse( - resource_id=resource_id, - books=book_responses - ) - -def get_full_bible_content( - db_session: Session, - resource_id: int, - output_format: str -) -> schema.BibleFullContentResponse: - """Get full content of all books in a resource in specified format""" - - # Find resource - resource = db_session.query(db_models.Resource).filter_by(resource_id=resource_id).first() - if not resource: - raise NotAvailableException(detail=f"Resource {resource_id} not found") - - # Find all bible records for this resource, ordered by book_id - bible_records = db_session.query(db_models.Bible).join( - db_models.BookLookup, db_models.Bible.book_id == db_models.BookLookup.book_id - ).filter( - db_models.Bible.resource_id == resource_id - ).order_by(db_models.BookLookup.book_id).all() - - if not bible_records: - raise NotAvailableException(detail=f"No bible records found for resource {resource_id}") - - # Prepare books data - books = [] - for bible_record in bible_records: - # Get book details - book = db_session.query(db_models.BookLookup).filter_by( - book_id=bible_record.book_id - ).first() - - if output_format.lower() == "json": - content = bible_record.json - elif output_format.lower() == "usfm": - content = bible_record.usfm - else: - raise BadRequestException(detail=f"Unsupported format: {format}") - - books.append({ - "bible_book_id": bible_record.bible_book_id, - "book_id": bible_record.book_id, - "book_code": book.book_code, - "book_name": book.book_name, - "chapters": bible_record.chapters, - "content": content - }) - - return schema.BibleFullContentResponse( - resource_id=resource_id, - total_books=len(books), - books=books - ) - - -def get_bible_book_content( - db_session: Session, - resource_id: int, - book_code: str, - output_format: str -) -> schema.BibleBookContentResponse: - """Get full content of a book in specified format""" - - # Find resource - resource = db_session.query(db_models.Resource).filter_by(resource_id=resource_id).first() - if not resource: - raise NotAvailableException(detail=f"Resource {resource_id} not found") - - # Find book - book = db_session.query(db_models.BookLookup).filter( - func.lower(db_models.BookLookup.book_code) == book_code.lower() - ).first() - - if not book: - raise NotAvailableException(detail=f"Book {book_code} not found") - - # Find bible record - bible_record = db_session.query(db_models.Bible).filter_by( - resource_id=resource_id, - book_id=book.book_id - ).first() - - if not bible_record: - raise NotAvailableException( - detail=f"Book {book_code} not found for resource {resource_id}" - ) - - - if output_format.lower() == "json": - content = bible_record.json - elif output_format.lower() == "usfm": - content = bible_record.usfm - else: - raise TypeException("Format must be 'json' or 'usfm'") - - - return schema.BibleBookContentResponse( - resource_id=resource_id, - book_id=bible_record.bible_book_id, - book_code=book_code, - book_content=content - ) - - - -def get_available_books(db_session: Session, resource_id: int): - """Get all available books for a resource, ordered by book_id""" - return db_session.query(db_models.BookLookup).join( - db_models.Bible, db_models.BookLookup.book_id == db_models.Bible.book_id - ).filter( - db_models.Bible.resource_id == resource_id - ).order_by(db_models.BookLookup.book_id).all() - -def get_available_clean_books(db_session: Session, resource_id: int): - """Get all available books for a resource from clean_bible, ordered by book_id""" - return db_session.query(db_models.BookLookup).join( - db_models.CleanBible, db_models.BookLookup.book_id == db_models.CleanBible.book_id - ).filter( - db_models.CleanBible.resource_id == resource_id - ).distinct().order_by(db_models.BookLookup.book_id).all() - -def get_bible_chapter( - db_session: Session, - resource_id: int, - book_code: str, - chapter: int -) -> schema.BibleChapterResponse: - """Get chapter content from bible table with cross-book navigation""" - - # Helper: Resource - def get_resource(): - res = db_session.query(db_models.Resource).filter_by(resource_id=resource_id).first() - if not res: - raise NotAvailableException(detail=f"Resource {resource_id} not found") - return res - - # Helper: Book - def get_book(): - book_obj = db_session.query(db_models.BookLookup).filter( - func.lower(db_models.BookLookup.book_code) == book_code.lower() - ).first() - if not book_obj: - raise NotAvailableException(detail=f"Book {book_code} not found") - return book_obj - - # Helper: Bible record - def get_bible_record(book_id): - record = db_session.query(db_models.Bible).filter_by( - resource_id=resource_id, - book_id=book_id - ).first() - if not record: - raise NotAvailableException( - detail=f"Book {book_code} not found for resource {resource_id}" - ) - return record - - # Helper: Extract chapter content - def extract_chapter_content(usj_content): - chapter_items = [] - current_ch_num = None - chapter_found = False - - for item in usj_content: - if item.get("type") == "chapter": - num = item.get("number") - current_ch_num = int(num) if num and num.isdigit() else None - - if current_ch_num == chapter: - chapter_found = True - chapter_items = [] - elif chapter_found: - break - elif chapter_found: - chapter_items.append(item) - - if not chapter_found: - raise NotAvailableException(detail=f"Chapter {chapter} not found") - - return chapter_items - - # Helper: Build navigation links - def build_navigation(book, bible_record, available_books): - current_index = next( - (i for i, b in enumerate(available_books) if b.book_id == book.book_id), - None - ) - - # Previous - if chapter > 1: - previous = { - "resourceId": str(resource_id), - "bibleBookCode": book_code, - "chapterId": chapter - 1 - } - elif current_index and current_index > 0: - prev_book = available_books[current_index - 1] - previous = { - "resourceId": str(resource_id), - "bibleBookCode": prev_book.book_code, - "chapterId": prev_book.chapter_count - } - else: - previous = None - - # Next - if chapter < bible_record.chapters: - nxt = { - "resourceId": str(resource_id), - "bibleBookCode": book_code, - "chapterId": chapter + 1 - } - elif current_index is not None and current_index < len(available_books) - 1: - next_book = available_books[current_index + 1] - nxt = { - "resourceId": str(resource_id), - "bibleBookCode": next_book.book_code, - "chapterId": 1 - } - else: - nxt = None - - return previous, nxt - - # Main logic - get_resource() - book = get_book() - bible_record = get_bible_record(book.book_id) - - chapter_items = extract_chapter_content(bible_record.json.get("content", [])) - available_books = get_available_books(db_session, resource_id) - previous, nxt = build_navigation(book, bible_record, available_books) - - return schema.BibleChapterResponse( - resource_id=resource_id, - bible_book_code=book_code, - chapter=chapter, - previous=previous, - next=nxt, - chapter_content=chapter_items - ) - -def _clean_get_resource(db_session, resource_id): - resource = db_session.query(db_models.Resource).filter_by(resource_id=resource_id).first() - if not resource: - raise NotAvailableException(detail=f"Resource {resource_id} not found") - return resource - - -def _clean_get_book(db_session, book_code): - book = db_session.query(db_models.BookLookup).filter( - func.lower(db_models.BookLookup.book_code) == book_code.lower() - ).first() - if not book: - raise NotAvailableException(detail=f"Book {book_code} not found") - return book - - -def _clean_get_verses(db_session, resource_id, book_id, chapter, book_code): - verses = db_session.query(db_models.CleanBible).filter_by( - resource_id=resource_id, - book_id=book_id, - chapter=chapter - ).order_by(db_models.CleanBible.verse).all() - - if not verses: - raise NotAvailableException( - detail=f"Chapter {chapter} not found for book {book_code}" - ) - return verses - - -def _clean_get_bible_record(db_session, resource_id, book_id): - return db_session.query(db_models.Bible).filter_by( - resource_id=resource_id, - book_id=book_id - ).first() - - -def _clean_find_book_index(available_books, book_id): - for i, b in enumerate(available_books): - if b.book_id == book_id: - return i - return None - - -def _clean_build_navigation(data: schema.CleanNavigationInput): - """Build navigation links""" - resource_id = data.resource_id - book_code = data.book_code - chapter = data.chapter - bible_record = data.bible_record - available_books = data.available_books - idx = data.idx - - previous = None - next_chapter = None - - - # Previous - if chapter > 1: - previous = { - "resourceId": str(resource_id), - "bibleBookCode": book_code, - "chapterId": chapter - 1 - } - elif idx is not None and idx > 0: - prev_book = available_books[idx - 1] - previous = { - "resourceId": str(resource_id), - "bibleBookCode": prev_book.book_code, - "chapterId": prev_book.chapter_count - } - - # Next - if bible_record and chapter < bible_record.chapters: - next_chapter = { - "resourceId": str(resource_id), - "bibleBookCode": book_code, - "chapterId": chapter + 1 - } - elif idx is not None and idx < len(available_books) - 1: - next_book = available_books[idx + 1] - next_chapter = { - "resourceId": str(resource_id), - "bibleBookCode": next_book.book_code, - "chapterId": 1 - } - - return previous, next_chapter - - -def get_clean_bible_chapter( - db_session: Session, - resource_id: int, - book_code: str, - chapter: int -) -> schema.CleanBibleChapterResponse: - """Get full content of a chapter with book intro.""" - - _clean_get_resource(db_session, resource_id) - book = _clean_get_book(db_session, book_code) - verses = _clean_get_verses(db_session, resource_id, book.book_id, chapter, book_code) - bible_record = _clean_get_bible_record(db_session, resource_id, book.book_id) - - available_books = get_available_clean_books(db_session, resource_id) - idx = _clean_find_book_index(available_books, book.book_id) - - nav_input = schema.CleanNavigationInput( - resource_id=resource_id, - book_code=book_code, - chapter=chapter, - bible_record=bible_record, - available_books=available_books, - idx=idx - ) - - previous, next_chapter = _clean_build_navigation(nav_input) - - - verse_content = [ - schema.CleanVerseContent(verse=v.verse, text=v.text) - for v in verses - ] - - return schema.CleanBibleChapterResponse( - resource_id=resource_id, - bible_book_code=book_code, - chapter=chapter, - previous=previous, - next=next_chapter, - chapter_content=verse_content - ) - -def get_bible_verse( - db_session: Session, - resource_id: int, - book_code: str, - chapter: int, - verse: int -) -> schema.BibleVerseResponse: - """Get specific verse content with enhanced navigation""" - - _get_resource(db_session, resource_id) - book = _get_book_or_404(db_session, book_code) - verse_record = _get_clean_verse_or_404(db_session, resource_id, book.book_id, chapter, verse) - bible_record = _get_bible_record(db_session, resource_id, book.book_id) - - available_books = get_available_clean_books(db_session, resource_id) - current_book_index = _find_book_index(available_books, book.book_id) - - prev_input = schema.CleanPreviousVerseInput( - db_session=db_session, - resource_id=resource_id, - book=book, - chapter=chapter, - verse=verse, - available_books=available_books, - book_index=current_book_index - ) - - previous = _compute_previous_verse(prev_input) - - - next_input = schema.CleanNextVerseInput( - db_session=db_session, - resource_id=resource_id, - book=book, - chapter=chapter, - verse=verse, - available_books=available_books, - book_index=current_book_index, - bible_record=bible_record - ) - - next_verse = _compute_next_verse(next_input) - - - return schema.BibleVerseResponse( - resource_id=resource_id, - bible_book_code=book_code, - chapter=chapter, - verse_number=verse, - previous=previous, - next=next_verse, - verse_content=verse_record.text - ) - -def _get_book_or_404(db_session: Session, book_code: str): - book = db_session.query(db_models.BookLookup).filter( - func.lower(db_models.BookLookup.book_code) == book_code.lower() - ).first() - if not book: - raise NotAvailableException(detail=f"Book {book_code} not found") - return book - - -def _get_clean_verse_or_404( - db_session: Session, - resource_id: int, - book_id: int, - chapter: int, - verse: int -): - verse_record = db_session.query(db_models.CleanBible).filter_by( - resource_id=resource_id, - book_id=book_id, - chapter=chapter, - verse=verse - ).first() - - if not verse_record: - raise NotAvailableException( - detail=f"Verse {book_id}.{chapter}.{verse} not found" - ) - return verse_record - - -def _get_bible_record(db_session: Session, resource_id: int, book_id: int): - return db_session.query(db_models.Bible).filter_by( - resource_id=resource_id, - book_id=book_id - ).first() - - -def _find_book_index(available_books, book_id): - for i, book in enumerate(available_books): - if book.book_id == book_id: - return i - return None - -def _compute_previous_verse(payload: schema.CleanPreviousVerseInput): - db_session = payload.db_session - resource_id = payload.resource_id - book = payload.book - chapter = payload.chapter - verse = payload.verse - available_books = payload.available_books - book_index = payload.book_index - - # Case 1: Previous verse in same chapter - if verse > 1: - return { - "resourceId": str(resource_id), - "bibleBookCode": book.book_code, - "chapterId": chapter, - "verse": verse - 1 - } - - # Case 2: Last verse of previous chapter - if chapter > 1: - prev_chap_last = db_session.query(db_models.CleanBible).filter_by( - resource_id=resource_id, - book_id=book.book_id, - chapter=chapter - 1 - ).order_by(db_models.CleanBible.verse.desc()).first() - - if prev_chap_last: - return { - "resourceId": str(resource_id), - "bibleBookCode": book.book_code, - "chapterId": chapter - 1, - "verse": prev_chap_last.verse - } - - # Case 3: Last verse of previous book - if book_index is not None and book_index > 0: - prev_book = available_books[book_index - 1] - last_verse = db_session.query(db_models.CleanBible).filter_by( - resource_id=resource_id, - book_id=prev_book.book_id, - chapter=prev_book.chapter_count - ).order_by(db_models.CleanBible.verse.desc()).first() - - if last_verse: - return { - "resourceId": str(resource_id), - "bibleBookCode": prev_book.book_code, - "chapterId": prev_book.chapter_count, - "verse": last_verse.verse - } - - return None - - -def _compute_next_verse(payload: schema.CleanNextVerseInput): - db_session = payload.db_session - resource_id = payload.resource_id - book = payload.book - chapter = payload.chapter - verse = payload.verse - available_books = payload.available_books - book_index = payload.book_index - bible_record = payload.bible_record - - # CASE 1: Next verse exists in same chapter - next_verse_record = db_session.query(db_models.CleanBible).filter_by( - resource_id=resource_id, - book_id=book.book_id, - chapter=chapter, - verse=verse + 1 - ).first() - - if next_verse_record: - return { - "resourceId": str(resource_id), - "bibleBookCode": book.book_code, - "chapterId": chapter, - "verse": verse + 1 - } - - # CASE 2: Next chapter exists in same book - if bible_record and chapter < bible_record.chapters: - return { - "resourceId": str(resource_id), - "bibleBookCode": book.book_code, - "chapterId": chapter + 1, - "verse": 1 - } - - # CASE 3: Next book exists - if book_index is not None and book_index < len(available_books) - 1: - next_book = available_books[book_index + 1] - return { - "resourceId": str(resource_id), - "bibleBookCode": next_book.book_code, - "chapterId": 1, - "verse": 1 - } - - return None - - -# --- Commentary CRUD --- - -def _ensure_commentary_resource(db: Session, resource_id: int): - resource = db.query(db_models.Resource).filter_by(resource_id=resource_id).first() - if not resource: - raise NotAvailableException(detail=f"Resource {resource_id} not found") - if (resource.content_type or "").lower() != "commentary": - raise NotAvailableException( - detail= ( - f"Resource {resource_id} is not of type 'commentary'" - f"(found '{resource.content_type}')" - ) - ) - return resource - -def _get_book_by_code(db: Session, book_code: str): - return db.query(db_models.BookLookup)\ - .filter(db_models.BookLookup.book_code.ilike(book_code)).first() - - - -def _intro_text(db: Session, resource_id: int, book_id: int) -> str: - row = ( - db.query(db_models.Commentary.text) - .filter( - db_models.Commentary.resource_id == resource_id, - db_models.Commentary.book_id == book_id, - db_models.Commentary.chapter == 0, - db_models.Commentary.verse == "0", # string - ) - .first() - ) - return row[0] if row else "" - - -def create_commentaries( - db: Session, - payload: schema.CommentaryBulkCreate, - actor_user_id: int -) -> schema.CommentaryCreateResponse: - """ - Create commentary rows. - """ - #now = utcnow() - - resource = db.query(db_models.Resource).filter_by(resource_id=payload.resource_id).first() - if not resource: - raise NotAvailableException(detail=f"Resource {payload.resource_id} not found") - - if resource.content_type.lower() != "commentary": - raise NotAvailableException( - detail=( - f"Resource {payload.resource_id} is not of type 'commentary'" - f"(found '{resource.content_type}')" - ) - ) - - created_rows = [] - - for item in payload.commentary: - verse_str = str(item.verse).strip() - - exists_book = ( - db.query(db_models.BookLookup.book_id) - .filter_by(book_id=item.book_id) - .first() - ) - if not exists_book: - raise NotAvailableException(detail=f"BookId {item.book_id} not found") - - exists_row = db.query(db_models.Commentary.commentary_id).filter_by( - resource_id=payload.resource_id, - book_id=item.book_id, - chapter=item.chapter, - verse=verse_str, - ).first() - if exists_row: - raise AlreadyExistsException( - detail=( - "Row already exists. Use PUT to update: " - f"(resource_id={payload.resource_id}, book_id={item.book_id}, " - f"chapter={item.chapter}, verse={item.verse})" - ) - ) - - row = db_models.Commentary( - resource_id=payload.resource_id, - book_id=item.book_id, - chapter=item.chapter, - verse=verse_str, - text=item.text.strip(), - ) - db.add(row) - created_rows.append(row) - - touch_resource(db, resource_id=payload.resource_id, actor_user_id=actor_user_id) - - try: - db.commit() - except IntegrityError as exc: - db.rollback() - raise AlreadyExistsException("Unique constraint violated (row already exists).") from exc - - - return schema.CommentaryCreateResponse( - resource_id=payload.resource_id, - created=[ - schema.CommentaryCreatedItem( - commentary_id=r.commentary_id, - book_id=r.book_id, - chapter=r.chapter, - verse=r.verse, - text=r.text, - ) - for r in created_rows - ], - ) - - - -# --- PUT --- -def update_commentaries( - db: Session, - payload: schema.CommentaryBulkUpdate, - actor_user_id: int -) -> schema.CommentaryUpdateResponse: - """Update commentary rows and return same response shape as POST.""" - resource = db.query(db_models.Resource).filter_by(resource_id=payload.resource_id).first() - if not resource: - raise NotAvailableException(detail=f"Resource {payload.resource_id} not found") - #now = utcnow() - # Resource content_type must be 'commentary' - if resource.content_type.lower() != "commentary": - raise BadRequestException( - detail=( - f"Resource {payload.resource_id} is not of type 'commentary' " - f"(found '{resource.content_type}')" - ) - ) - updated_rows: List[db_models.Commentary] = [] - - for item in payload.commentary: - # Find row - row = db.query(db_models.Commentary).filter_by( - commentary_id=item.commentary_id, resource_id=payload.resource_id - ).first() - if not row: - raise NotAvailableException( - detail=( - f"commentary_id {item.commentary_id} not found for resource " - f"{payload.resource_id}" - ) - ) - # Target values (keep existing if field omitted) - tgt_book_id = item.book_id if item.book_id is not None else row.book_id - tgt_chapter = item.chapter if item.chapter is not None else row.chapter - verse_str = str(item.verse).strip() if item.verse is not None else row.verse - # Validate book_id - exists_book = ( - db.query(db_models.BookLookup.book_id) - .filter_by(book_id=tgt_book_id) - .first() - ) - if not exists_book: - raise NotAvailableException(detail=f"BookId {tgt_book_id} not found") - # Uniqueness against other rows - exists_row = ( - db.query(db_models.Commentary.commentary_id) - .filter_by( - resource_id=payload.resource_id, - book_id=tgt_book_id, - chapter=tgt_chapter, - verse=verse_str, - ) - .filter(db_models.Commentary.commentary_id != item.commentary_id) - .first() - ) - if exists_row: - raise AlreadyExistsException( - detail=( - "Update would violate uniqueness: " - f"(resource_id={payload.resource_id}, book_id={tgt_book_id}, " - f"chapter={tgt_chapter}, verse={verse_str}) already exists." - ) - ) - - row.book_id = tgt_book_id - row.chapter = tgt_chapter - row.verse = verse_str - if item.text is not None: - row.text = item.text.strip() - updated_rows.append(row) - touch_resource(db, resource_id=payload.resource_id, actor_user_id=actor_user_id) - try: - db.commit() - except IntegrityError as e: - db.rollback() - raise AlreadyExistsException( - detail=( - "Update would violate uniqueness of " - f"(resource_id={payload.resource_id}, " - f"book_id={tgt_book_id}, chapter={tgt_chapter}, verse={verse_str})." - ) - ) from e - - # Reuse the POST response schema - return schema.CommentaryUpdateResponse( - resource_id=payload.resource_id, - updated=[ - schema.CommentaryUpdatedItem( - commentary_id=r.commentary_id, - book_id=r.book_id, - chapter=r.chapter, - verse=r.verse, - text=r.text, - ) - for r in updated_rows - ], - ) - - - -# --- GET: full content for a resource --- -def get_full_commentary(db: Session, resource_id: int) -> schema.CommentaryListResponse: - """Get full content of a resource.""" - _ensure_commentary_resource(db, resource_id) - - v_start = cast(func.split_part(db_models.Commentary.verse, '-', 1), Integer) - v_end = cast( - func.coalesce( - func.nullif(func.split_part(db_models.Commentary.verse, '-', 2), ''), - func.split_part(db_models.Commentary.verse, '-', 1) - ), - Integer - ) - # If verse/chapter are INT in DB, you can drop the cast() calls below. - q = ( - db.query( - db_models.Commentary, - db_models.BookLookup.book_code.label("book_code"), - ) - .join(db_models.BookLookup, db_models.BookLookup.book_id == db_models.Commentary.book_id) - .filter(db_models.Commentary.resource_id == resource_id) - .order_by( - db_models.Commentary.book_id, - cast(db_models.Commentary.chapter, Integer), - v_start, - v_end, - ) - .all() - ) - content = [ - schema.CommentaryRow( - commentary_id=cm.commentary_id, - bookCode=book_code, - chapter=int(cm.chapter), - verse=str(cm.verse), - text=cm.text, - ) - for cm, book_code in q - ] - return schema.CommentaryListResponse(resourceId=resource_id, content=content) - - - -# --- GET: full content of a chapter with book intro --- -def get_commentary_chapter( - db: Session, - resource_id: int, - book_code: str, - chapter: int -) -> schema.ChapterResponse: - """Get full content of a chapter with book intro.""" - _ensure_commentary_resource(db, resource_id) - book = _get_book_by_code(db, book_code) - if not book: - raise NotAvailableException(detail=f"Unknown book_code '{book_code}'") - # book intro = chapter 0, verse 0 (single text) - book_intro = _intro_text(db, resource_id, book.book_id) - # chapter content: chapter=N, include verse >= 0 (0 is chapter intro) - rows = ( - db.query(db_models.Commentary) - .filter( - db_models.Commentary.resource_id == resource_id, - db_models.Commentary.book_id == book.book_id, - db_models.Commentary.chapter == chapter, - ) - .order_by(db_models.Commentary.verse.asc()) - .all() - ) - # If chapter truly doesn’t exist in commentary, raise 404 - if not rows: - raise NotAvailableException( - detail=( - f"Chapter {chapter} not found in commentary for book_code '" - f"{book.book_code}' (resource {resource_id})" - ), - ) - content = [schema.ChapterContentItem(verse=str(r.verse), text=r.text) for r in rows] - return schema.ChapterResponse( - bookCode=book.book_code, - bookIntro=book_intro, - chapter=chapter, - resourceId=resource_id, - content=content, - ) - - -# --- DELETE --- -def delete_commentary_bulk(db: Session, commentary_ids: List[int]): - deleted_ids = [] - errors = [] - - for cid in commentary_ids: - row = ( - db.query(db_models.Commentary) - .filter_by(commentary_id=cid) - .first() - ) - - if not row: - errors.append(f"commentary_id {cid} not found") - continue - - try: - db.delete(row) - deleted_ids.append(cid) - - except Exception as exc: - db.rollback() - errors.append(f"Failed to delete commentary_id {cid}: {str(exc)}") - - # Commit all successful deletes - try: - db.commit() - except Exception as exc: - db.rollback() - errors.append(f"Bulk commit failed: {str(exc)}") - - return deleted_ids, errors - -# ---Dictionary CRUD------------- - -def normalize_unicode(value: str) -> str: - """Normalize Unicode characters for dictionary words.""" - if value is None or value == "": - return value - return unicodedata.normalize('NFC', value) -def upload_dictionary_words(db_: Session, resource_id: int, dictionary_words: List,actor_id: int): - '''Adds rows to the dictionary table for the specified resource_id. - Throws error if resource_id + keyword combination already exists.''' - - # Verify resource exists - resource_db_content = db_.query(db_models.Resource).filter( - db_models.Resource.resource_id == resource_id).first() - if not resource_db_content: - raise NotAvailableException(detail=f"Resource {resource_id} not found") - for item in dictionary_words: - # Check duplicates (resource_id + dictionary items) - existing = ( - db_.query(db_models.Dictionary) - .filter_by(resource_id=resource_id, - keyword=normalize_unicode(item.keyword)) - .first() - ) - if existing: - raise AlreadyExistsException( - detail=f"resoure {resource_id} with keyword {item.keyword} already exists" - ) - # Verify resource is of type DICTIONARY - if (resource_db_content.content_type or "").lower() != "dictionary": - raise BadRequestException( - detail=( - f"Resource {resource_id} is not of type 'dictionary'" - f"(found '{resource_db_content.content_type}')" - ) - ) - model_cls = db_models.Dictionary # Assuming Dictionary model exists - db_content = [] - - for item in dictionary_words: - # Check if keyword already exists for this resource - existing_word = db_.query(model_cls).filter( - model_cls.resource_id == resource_id, - model_cls.keyword == normalize_unicode(item.keyword) - ).first() - - if existing_word: - raise AlreadyExistsException( - detail= f"Keyword '{item.keyword}' already exists for resource {resource_id}." - ) - - row = model_cls( - resource_id=resource_id, - keyword=normalize_unicode(item.keyword), - word_forms=item.wordForms, - strongs=item.strongs, - definition=item.definition, - translation_help=item.translationHelp, - see_also=item.seeAlso, - ref=item.ref, - examples=item.examples - ) - db_.add(row) - db_.flush() - - db_content.append({ - 'wordId': row.word_id, - 'keyword': row.keyword, - 'wordForms': row.word_forms, - 'strongs': row.strongs, - 'definition': row.definition, - 'translationHelp': row.translation_help, - 'seeAlso': row.see_also, - 'ref': row.ref, - 'examples': row.examples - }) - touch_resource(db_, resource_id=resource_db_content.resource_id, actor_user_id=actor_id) - db_.commit() - - response = { - 'db_content': db_content, - 'resource_content': resource_db_content - } - return response - - -def update_dictionary_words(db_: Session, resource_id: int, dictionary_words: List, actor_id: int): - '''Updates rows in the dictionary table using wordId. - All fields except wordId can be updated.''' - - # Verify resource exists and is of type DICTIONARY - resource_db_content = _validate_dictionary_resource(db_, resource_id) - - # Check for duplicate keywords (AFTER resource validation) - #_check_duplicate_keywords(db_, resource_id, dictionary_words) - - # Update dictionary words - db_content = _update_dictionary_entries(db_, resource_id, dictionary_words) - - # Touch resource and commit - touch_resource(db_, resource_id=resource_db_content.resource_id, actor_user_id=actor_id) - db_.commit() - - # Build response - return _build_dictionary_response(db_content, resource_db_content) - - -def _validate_dictionary_resource(db_: Session, resource_id: int): - """Verify resource exists and is of type DICTIONARY.""" - resource = db_.query(db_models.Resource).filter( - db_models.Resource.resource_id == resource_id - ).first() - - if not resource: - raise NotAvailableException( - detail=f'Resource {resource_id} not found in database' - ) - - if (resource.content_type or "").lower() != "dictionary": - raise BadRequestException( - detail=( - f"Resource {resource_id} is not of type 'dictionary'" - f" (found '{resource.content_type}')" - ) - ) - - return resource - - -def _check_duplicate_keywords(db_: Session, resource_id: int, dictionary_words: List): - """Check for duplicate keywords in the resource.""" - for item in dictionary_words: - existing = ( - db_.query(db_models.Dictionary) - .filter_by( - resource_id=resource_id, - keyword=normalize_unicode(item.keyword) - ) - .first() - ) - if existing: - raise AlreadyExistsException( - detail=f"Resource {resource_id} with keyword {item.keyword} already exists" - ) - - -def _update_dictionary_entries(db_: Session, resource_id: int, dictionary_words: List): - """Update dictionary entries with provided data.""" - db_content = [] - - for item in dictionary_words: - # Find row by wordId and resourceId - row = db_.query(db_models.Dictionary).filter( - db_models.Dictionary.word_id == item.wordId, - db_models.Dictionary.resource_id == resource_id - ).first() - - if not row: - raise NotAvailableException( - detail=f"Dictionary word with id {item.wordId} not found in resource {resource_id}" - ) - - # Update fields if provided (preserves original logic exactly) - if item.keyword is not None: - row.keyword = normalize_unicode(item.keyword) - if item.wordForms is not None: - row.word_forms = item.wordForms - if item.strongs is not None: - row.strongs = item.strongs - if item.definition is not None: - row.definition = item.definition - if item.translationHelp is not None: - row.translation_help = item.translationHelp - if item.seeAlso is not None: - row.see_also = item.seeAlso - if item.ref is not None: - row.ref = item.ref - if item.examples is not None: - row.examples = item.examples - - db_.flush() - db_content.append(row) - - return db_content - - -def _build_dictionary_response(db_content: List, resource_db_content): - """Build the response dictionary.""" - response_data = [ - { - "wordId": row.word_id, - "keyword": row.keyword, - "wordForms": row.word_forms, - "strongs": row.strongs, - "definition": row.definition, - "translationHelp": row.translation_help, - "seeAlso": row.see_also, - "ref": row.ref, - "examples": row.examples - } - for row in db_content - ] - - return { - 'db_content': response_data, - 'resource_content': resource_db_content - } - - -def get_dictionary_words(db_: Session, resource_id: int, skip: int = 0, limit: int = 100): - '''Fetches all dictionary words for a given resource_id.''' - - # Verify resource exists - resource_db_content = db_.query(db_models.Resource).filter( - db_models.Resource.resource_id == resource_id).first() - if not resource_db_content: - raise NotAvailableException(detail=f"Resource {resource_id} not found") - - # Verify resource is of type DICTIONARY - if (resource_db_content.content_type or "").lower() != "dictionary": - raise BadRequestException( - detail= ( - f"Resource {resource_id} is not of type 'dictionary' " - f"(found '{resource_db_content.content_type}')" - ) - ) - model_cls = db_models.Dictionary - query = db_.query(model_cls).filter(model_cls.resource_id == resource_id) - query = query.order_by(model_cls.keyword) - - if skip is not None: - query = query.offset(skip) - if limit is not None: - query = query.limit(limit) - rows = query.all() - content = [ - { - "wordId": row.word_id, - "keyword": row.keyword, - "wordForms": row.word_forms, - "strongs": row.strongs, - "definition": row.definition, - "translationHelp": row.translation_help, - "seeAlso": row.see_also, - "ref": row.ref, - "examples": row.examples - } - for row in rows - ] - - return { - "resourceId": resource_id, - "content": content - } - - -def get_dictionary_index(db_: Session, resource_id: int): - '''Fetches dictionary index grouped by first letter of keywords.''' - - # Verify resource exists - resource_db_content = db_.query(db_models.Resource).filter( - db_models.Resource.resource_id == resource_id).first() - if not resource_db_content: - raise NotAvailableException(detail=f"Resource {resource_id} not found") - - # Verify resource is of type DICTIONARY - if (resource_db_content.content_type or "").lower() != "dictionary": - raise BadRequestException( - detail=( - f"Resource {resource_id} is not of type 'dictionary' " - f"(found '{resource_db_content.content_type}')" - ) - ) - - model_cls = db_models.Dictionary - words = db_.query(model_cls.word_id, model_cls.keyword).filter( - model_cls.resource_id == resource_id - ).order_by(model_cls.keyword).all() - - # Group by first letter - index_dict = {} - for word in words: - if word.keyword: - first_letter = word.keyword[0].upper() - if first_letter not in index_dict: - index_dict[first_letter] = [] - index_dict[first_letter].append({ - 'wordId': word.word_id, - 'word': word.keyword - }) - - # Convert to list format - index_list = [] - for letter in sorted(index_dict.keys()): - index_list.append({ - 'letter': letter, - 'words': index_dict[letter] - }) - - return { - 'index': index_list, - 'resource_content': resource_db_content - } - - -def get_dictionary_word_by_id(db_: Session, resource_id: int, word_id: int): - '''Fetches a specific dictionary word by wordId and resource_id.''' - - # Verify resource exists - resource_db_content = db_.query(db_models.Resource).filter( - db_models.Resource.resource_id == resource_id).first() - if not resource_db_content: - raise NotAvailableException(detail=f"Resource {resource_id} not found") - - # Verify resource is of type DICTIONARY - if (resource_db_content.content_type or "").lower() != "dictionary": - raise BadRequestException( - detail=( - f"Resource {resource_id} is not of type 'dictionary' " - f"(found '{resource_db_content.content_type}')" - ) - ) - model_cls = db_models.Dictionary - word = db_.query(model_cls).filter( - model_cls.resource_id == resource_id, - model_cls.word_id == word_id - ).first() - - return { - 'db_content': word, - 'resource_content': resource_db_content - } - -def delete_dictionary_words(db_: Session, resource_id: int, word_ids: List[int], user_id=None): - """ - Deletes multiple dictionary words by their wordIds. - Returns count, deleted IDs, and errors (for duplicates or invalid IDs). - """ - - # Verify resource exists - resource_db_content = ( - db_.query(db_models.Resource) - .filter(db_models.Resource.resource_id == resource_id) - .first() - ) - if not resource_db_content: - raise NotAvailableException(detail=f"Resource {resource_id} not found") - - # Verify dictionary - if (resource_db_content.content_type or "").lower() != "dictionary": - raise BadRequestException( - detail=( - f"Resource {resource_id} is not of type 'dictionary'" - f"(found '{resource_db_content.content_type}')" - ) - ) - - model_cls = db_models.Dictionary - deleted_ids = [] - errors = [] - processed = set() - - for word_id in word_ids: - - # Duplicate within same request - if word_id in processed: - errors.append({"id": word_id, "error": "already_deleted"}) - continue - processed.add(word_id) - - # Check existence - word = ( - db_.query(model_cls) - .filter( - model_cls.resource_id == resource_id, - model_cls.word_id == word_id, - ) - .first() - ) - - if not word: - errors.append({"id": word_id, "error": "not_found"}) - continue - - # Delete word - db_.delete(word) - deleted_ids.append(word_id) - - # Commit all successful deletes - db_.commit() - - # Construct error message - error_msg = None - if errors: - not_found = [e["id"] for e in errors if e["error"] == "not_found"] - dup = [e["id"] for e in errors if e["error"] == "already_deleted"] - parts = [] - if not_found: - parts.append(f"Invalid word IDs: {not_found}") - if dup: - parts.append(f"Already deleted IDs: {dup}") - error_msg = "; ".join(parts) - - return { - "message": f"Successfully deleted {len(deleted_ids)} words", - "deleted_count": len(deleted_ids), - "deleted_ids": deleted_ids, - "error": error_msg, - "has_errors": bool(errors), - } - - -# --- AudioBible CRUD --- - -def validate_books(db: Session, books: dict): - """ - Validate that all book codes exist in BookLookup table - and chapter counts are within valid range - """ - # Get all valid book codes and their chapter counts - book_lookup = db.query( - db_models.BookLookup.book_code, - db_models.BookLookup.chapter_count - ).all() - - # Create a dictionary for easy lookup (case-insensitive) - valid_books = { - row.book_code.lower(): row.chapter_count - for row in book_lookup - } - - # Validate each book in the input - for book_code, chapter_count in books.items(): - book_code_lower = book_code.lower() - - # Check if book code exists - if book_code_lower not in valid_books: - raise BadRequestException( - detail=( - f"Invalid book code '{book_code}'. " - f"Must match book_code from BookLookup table." - ) - ) - - # Check if chapter count is within valid range - max_chapters = valid_books[book_code_lower] - if chapter_count > max_chapters: - raise BadRequestException( - detail=f"Invalid chapter count for '{book_code}': {chapter_count}. " - f"Maximum chapters for this book is {max_chapters}." - ) - -def create_audio_bible(db: Session, data: schema.AudioBibleCreate, actor_user_id: int): - """Create a new audio bible entry""" - # Check if resource exists - resource = db.query(db_models.Resource).filter_by(resource_id=data.resource_id).first() - if not resource: - raise NotAvailableException( - detail=f"Resource with id {data.resource_id} does not exist." - ) - - # Resource content_type must be 'bible' - if resource.content_type.lower() != "bible": - raise BadRequestException( - detail=( - f"Resource {data.resource_id} is not of type 'bible' " - f"(found '{resource.content_type}')" - ) - ) - - # Check if audio bible already exists for this resource - existing = db.query(db_models.AudioBible).filter_by(resource_id=data.resource_id).first() - if existing: - raise AlreadyExistsException( - detail=( - f"AudioBible with resource_id {data.resource_id} already exists." - f" Use PUT to update." - ) - ) - - # Validate book codes - validate_books(db, data.books) - - # Create audio bible - audio_bible = db_models.AudioBible(**data.model_dump()) - db.add(audio_bible) - touch_resource(db, data.resource_id, actor_user_id) - db.commit() - db.refresh(audio_bible) - return audio_bible - -def _is_files_missing_empty(obj) -> bool: - """ - Return True if files_missing is empty ({} or None), - False if it contains missing entries. - """ - if obj is None: - return True - # if empty mapping - try: - if isinstance(obj, dict) and len(obj) == 0: - return True - # JSONB may also arrive as string by accident; handle that defensively - return False - except Exception: - return False - -def list_audio_bibles( - db: Session, - resource_id: Optional[int] = None, - limit: int = 50, - offset: int = 0, - files_missing: Optional[bool] = None, - test_date: Optional[datetime] = None -) -> List[dict]: - """List audio bibles with optional filtering and pagination""" - query = db.query(db_models.AudioBible) - - if resource_id is not None: - query = query.filter(db_models.AudioBible.resource_id == resource_id) - - query = query.limit(limit).offset(offset) - rows = query.all() - out = [] - for ab in rows: - fm = ab.files_missing # could be None, {}, or dict - td = ab.test_date # could be None or datetime - - # files_missing filter - if files_missing is not None: - has_missing = not _is_files_missing_empty(fm) - if files_missing and not has_missing: - # caller wants only audio bibles that *have* missing files - continue - if (not files_missing) and has_missing: - # caller wants only audio bibles with no missing files - continue - - # test_date filter (keep rows tested ON or AFTER the given timestamp) - if test_date is not None: - if td is None: - # row has no test_date -> skip when filtering by test_date - continue - # ensure timezone-aware comparision: convert to UTC if naive - # assume incoming test_date is timezone-aware (FastAPI will parse RFC datetimes) - if td.tzinfo is None: - td = td.replace(tzinfo=timezone.utc) - if test_date.tzinfo is None: - test_date = test_date.replace(tzinfo=timezone.utc) - if td < test_date: - continue - out.append({ - "resourceId": ab.resource_id, - "name": ab.name, - "url": ab.base_url, - "books": ab.books, - "format": ab.format, - # normalize empty dict -> {} ; None left as None - "files_missing": ( - ab.files_missing - if ab.files_missing - else {} - ), - "test_date": ab.test_date, - }) - - return out - - -def update_audio_bible( - db: Session, - resource_id: int, - update_data: schema.AudioBibleUpdate, - actor_user_id: int -): - """Update an existing audio bible""" - # Check if resource exists - resource = db.query(db_models.Resource).filter_by(resource_id=resource_id).first() - if not resource: - raise NotAvailableException( - detail=f"Resource with id {resource_id} does not exist." - ) - # Resource content_type must be 'bible' - if resource.content_type.lower() != "bible": - raise BadRequestException( - detail=( - f"Resource {resource_id} is not of type 'bible' " - f"(found '{resource.content_type}')" - ) - ) - - # Validate book codes if books are being updated - update_dict = update_data.model_dump(exclude_unset=True) - if "books" in update_dict and update_dict["books"] is not None: - validate_books(db, update_dict["books"]) - - audio_bible = db.query(db_models.AudioBible).filter_by(resource_id=resource_id).first() - # Update fields - for field, value in update_dict.items(): - if value is not None: - setattr(audio_bible, field, value) - touch_resource(db, resource_id=resource_id, actor_user_id=actor_user_id) - db.commit() - db.refresh(audio_bible) - return audio_bible - - -def delete_audio_bible(db: Session, resource_id: int): - """Delete an audio bible""" - audio_bible = db.query(db_models.AudioBible).filter( - db_models.AudioBible.resource_id == resource_id - ).first() - - if not audio_bible: - return None - - db.delete(audio_bible) - db.commit() - return audio_bible -# def bulk_delete_audio_bibles(db: Session, resource_id: int, audio_bible_ids: List[int]): -# deleted_ids = [] -# errors = [] -# processed = set() - -# for code in book_codes: -# code_lower = code.lower() - -# # Duplicate check -# if code_lower in processed: -# errors.append(f"Duplicate book code: {code}") -# continue - -# processed.add(code_lower) - -# # Lookup the book -# book = ( -# db_session.query(db_models.BookLookup) -# .filter(func.lower(db_models.BookLookup.book_code) == code_lower) -# .first() -# ) - -# if not book: -# errors.append(f"Book code '{code}' not found in lookup") -# continue - -# # Check bible table -# bible_row = ( -# db_session.query(db_models.Bible) -# .filter_by(resource_id=resource_id, book_id=book.book_id) -# .first() -# ) - -# if not bible_row: -# errors.append(f"Book '{code}' not found for resource {resource_id}") -# continue - -# # Delete clean bible rows -# db_session.query(db_models.CleanBible).filter_by( -# resource_id=resource_id, book_id=book.book_id -# ).delete() - -# # Delete bible entry -# db_session.delete(bible_row) -# deleted_ids.append(code) - -# if deleted_ids: -# db_session.commit() - -# # Standardized flags -# all_failed = len(deleted_ids) == 0 and len(errors) > 0 -# has_errors = len(errors) > 0 - -# return { -# "data": { -# "deletedCount": len(deleted_ids), -# "deletedIds": deleted_ids, -# "errors": errors if errors else None, -# }, -# "all_failed": all_failed, -# "has_errors": has_errors, -# } - - - -# def get_bible_books(db_session: Session, resource_id: int) -> schema.BibleBooksListResponse: -# """Get list of books for a bible resource""" -# # Find resource -# resource = db_session.query(db_models.Resource).filter_by(resource_id=resource_id).first() -# if not resource: -# raise NotAvailableException(detail=f"Resource {resource_id} not found") - -# books = db_session.query(db_models.Bible, db_models.BookLookup).join( -# db_models.BookLookup, db_models.Bible.book_id == db_models.BookLookup.book_id -# ).filter(db_models.Bible.resource_id == resource_id).all() - -# book_responses = [] -# for bible, book_lookup in books: -# book_responses.append(schema.BibleBookResponse( -# bible_book_id=bible.bible_book_id, -# book_code=book_lookup.book_code, -# book_id=book_lookup.book_id, -# short=book_lookup.book_code, # You may want to add these fields to BookLookup -# long=book_lookup.book_name, -# abbr=book_lookup.book_code[:3] -# )) - -# return schema.BibleBooksListResponse( -# resource_id=resource_id, -# books=book_responses -# ) - -# def get_full_bible_content( -# db_session: Session, -# resource_id: int, -# output_format: str -# ) -> schema.BibleFullContentResponse: -# """Get full content of all books in a resource in specified format""" - -# # Find resource -# resource = db_session.query(db_models.Resource).filter_by(resource_id=resource_id).first() -# if not resource: -# raise NotAvailableException(detail=f"Resource {resource_id} not found") - -# # Find all bible records for this resource, ordered by book_id -# bible_records = db_session.query(db_models.Bible).join( -# db_models.BookLookup, db_models.Bible.book_id == db_models.BookLookup.book_id -# ).filter( -# db_models.Bible.resource_id == resource_id -# ).order_by(db_models.BookLookup.book_id).all() - -# if not bible_records: -# raise NotAvailableException(detail=f"No bible records found for resource {resource_id}") - -# # Prepare books data -# books = [] -# for bible_record in bible_records: -# # Get book details -# book = db_session.query(db_models.BookLookup).filter_by( -# book_id=bible_record.book_id -# ).first() - -# if output_format.lower() == "json": -# content = bible_record.json -# elif output_format.lower() == "usfm": -# content = bible_record.usfm -# else: -# raise BadRequestException(detail=f"Unsupported format: {format}") - -# books.append({ -# "bible_book_id": bible_record.bible_book_id, -# "book_id": bible_record.book_id, -# "book_code": book.book_code, -# "book_name": book.book_name, -# "chapters": bible_record.chapters, -# "content": content -# }) - -# return schema.BibleFullContentResponse( -# resource_id=resource_id, -# total_books=len(books), -# books=books -# ) - - -# def get_bible_book_content( -# db_session: Session, -# resource_id: int, -# book_code: str, -# output_format: str -# ) -> schema.BibleBookContentResponse: -# """Get full content of a book in specified format""" - -# # Find resource -# resource = db_session.query(db_models.Resource).filter_by(resource_id=resource_id).first() -# if not resource: -# raise NotAvailableException(detail=f"Resource {resource_id} not found") - -# # Find book -# book = db_session.query(db_models.BookLookup).filter( -# func.lower(db_models.BookLookup.book_code) == book_code.lower() -# ).first() - -# if not book: -# raise NotAvailableException(detail=f"Book {book_code} not found") - -# # Find bible record -# bible_record = db_session.query(db_models.Bible).filter_by( -# resource_id=resource_id, -# book_id=book.book_id -# ).first() - -# if not bible_record: -# raise NotAvailableException( -# detail=f"Book {book_code} not found for resource {resource_id}" -# ) - - -# if output_format.lower() == "json": -# content = bible_record.json -# elif output_format.lower() == "usfm": -# content = bible_record.usfm -# else: -# raise TypeException("Format must be 'json' or 'usfm'") - - -# return schema.BibleBookContentResponse( -# resource_id=resource_id, -# book_id=bible_record.bible_book_id, -# book_code=book_code, -# book_content=content -# ) - - - -# def get_available_books(db_session: Session, resource_id: int): -# """Get all available books for a resource, ordered by book_id""" -# return db_session.query(db_models.BookLookup).join( -# db_models.Bible, db_models.BookLookup.book_id == db_models.Bible.book_id -# ).filter( -# db_models.Bible.resource_id == resource_id -# ).order_by(db_models.BookLookup.book_id).all() - -# def get_available_clean_books(db_session: Session, resource_id: int): -# """Get all available books for a resource from clean_bible, ordered by book_id""" -# return db_session.query(db_models.BookLookup).join( -# db_models.CleanBible, db_models.BookLookup.book_id == db_models.CleanBible.book_id -# ).filter( -# db_models.CleanBible.resource_id == resource_id -# ).distinct().order_by(db_models.BookLookup.book_id).all() - -# def get_bible_chapter( -# db_session: Session, -# resource_id: int, -# book_code: str, -# chapter: int -# ) -> schema.BibleChapterResponse: -# """Get chapter content from bible table with cross-book navigation""" - -# # Helper: Resource -# def get_resource(): -# res = db_session.query(db_models.Resource).filter_by(resource_id=resource_id).first() -# if not res: -# raise NotAvailableException(detail=f"Resource {resource_id} not found") -# return res - -# # Helper: Book -# def get_book(): -# book_obj = db_session.query(db_models.BookLookup).filter( -# func.lower(db_models.BookLookup.book_code) == book_code.lower() -# ).first() -# if not book_obj: -# raise NotAvailableException(detail=f"Book {book_code} not found") -# return book_obj - -# # Helper: Bible record -# def get_bible_record(book_id): -# record = db_session.query(db_models.Bible).filter_by( -# resource_id=resource_id, -# book_id=book_id -# ).first() -# if not record: -# raise NotAvailableException( -# detail=f"Book {book_code} not found for resource {resource_id}" -# ) -# return record - -# # Helper: Extract chapter content -# def extract_chapter_content(usj_content): -# chapter_items = [] -# current_ch_num = None -# chapter_found = False - -# for item in usj_content: -# if item.get("type") == "chapter": -# num = item.get("number") -# current_ch_num = int(num) if num and num.isdigit() else None - -# if current_ch_num == chapter: -# chapter_found = True -# chapter_items = [] -# elif chapter_found: -# break -# elif chapter_found: -# chapter_items.append(item) - -# if not chapter_found: -# raise NotAvailableException(detail=f"Chapter {chapter} not found") - -# return chapter_items - -# # Helper: Build navigation links -# def build_navigation(book, bible_record, available_books): -# current_index = next( -# (i for i, b in enumerate(available_books) if b.book_id == book.book_id), -# None -# ) - -# # Previous -# if chapter > 1: -# previous = { -# "resourceId": str(resource_id), -# "bibleBookCode": book_code, -# "chapterId": chapter - 1 -# } -# elif current_index and current_index > 0: -# prev_book = available_books[current_index - 1] -# previous = { -# "resourceId": str(resource_id), -# "bibleBookCode": prev_book.book_code, -# "chapterId": prev_book.chapter_count -# } -# else: -# previous = None - -# # Next -# if chapter < bible_record.chapters: -# nxt = { -# "resourceId": str(resource_id), -# "bibleBookCode": book_code, -# "chapterId": chapter + 1 -# } -# elif current_index is not None and current_index < len(available_books) - 1: -# next_book = available_books[current_index + 1] -# nxt = { -# "resourceId": str(resource_id), -# "bibleBookCode": next_book.book_code, -# "chapterId": 1 -# } -# else: -# nxt = None - -# return previous, nxt - -# # Main logic -# get_resource() -# book = get_book() -# bible_record = get_bible_record(book.book_id) - -# chapter_items = extract_chapter_content(bible_record.json.get("content", [])) -# available_books = get_available_books(db_session, resource_id) -# previous, nxt = build_navigation(book, bible_record, available_books) - -# return schema.BibleChapterResponse( -# resource_id=resource_id, -# bible_book_code=book_code, -# chapter=chapter, -# previous=previous, -# next=nxt, -# chapter_content=chapter_items -# ) - -# def _clean_get_resource(db_session, resource_id): -# resource = db_session.query(db_models.Resource).filter_by(resource_id=resource_id).first() -# if not resource: -# raise NotAvailableException(detail=f"Resource {resource_id} not found") -# return resource - - -# def _clean_get_book(db_session, book_code): -# book = db_session.query(db_models.BookLookup).filter( -# func.lower(db_models.BookLookup.book_code) == book_code.lower() -# ).first() -# if not book: -# raise NotAvailableException(detail=f"Book {book_code} not found") -# return book - - -# def _clean_get_verses(db_session, resource_id, book_id, chapter, book_code): -# verses = db_session.query(db_models.CleanBible).filter_by( -# resource_id=resource_id, -# book_id=book_id, -# chapter=chapter -# ).order_by(db_models.CleanBible.verse).all() - -# if not verses: -# raise NotAvailableException( -# detail=f"Chapter {chapter} not found for book {book_code}" -# ) -# return verses - - -# def _clean_get_bible_record(db_session, resource_id, book_id): -# return db_session.query(db_models.Bible).filter_by( -# resource_id=resource_id, -# book_id=book_id -# ).first() - - -# def _clean_find_book_index(available_books, book_id): -# for i, b in enumerate(available_books): -# if b.book_id == book_id: -# return i -# return None - - -# # def _clean_build_navigation(data: schema.CleanNavigationInput): -# # """Build navigation links""" -# # resource_id = data.resource_id -# # book_code = data.book_code -# # chapter = data.chapter -# # bible_record = data.bible_record -# # available_books = data.available_books -# # idx = data.idx - -# # previous = None -# # next_chapter = None - - -# # # Previous -# # if chapter > 1: -# # previous = { -# # "resourceId": str(resource_id), -# # "bibleBookCode": book_code, -# # "chapterId": chapter - 1 -# # } -# # elif idx is not None and idx > 0: -# # prev_book = available_books[idx - 1] -# # previous = { -# # "resourceId": str(resource_id), -# # "bibleBookCode": prev_book.book_code, -# # "chapterId": prev_book.chapter_count -# # } - -# # # Next -# # if bible_record and chapter < bible_record.chapters: -# # next_chapter = { -# # "resourceId": str(resource_id), -# # "bibleBookCode": book_code, -# # "chapterId": chapter + 1 -# # } -# # elif idx is not None and idx < len(available_books) - 1: -# # next_book = available_books[idx + 1] -# # next_chapter = { -# # "resourceId": str(resource_id), -# # "bibleBookCode": next_book.book_code, -# # "chapterId": 1 -# # } - -# # return previous, next_chapter - - -# def get_clean_bible_chapter( -# db_session: Session, -# resource_id: int, -# book_code: str, -# chapter: int -# ) -> schema.CleanBibleChapterResponse: -# """Get full content of a chapter with book intro.""" - -# _clean_get_resource(db_session, resource_id) -# book = _clean_get_book(db_session, book_code) -# verses = _clean_get_verses(db_session, resource_id, book.book_id, chapter, book_code) -# bible_record = _clean_get_bible_record(db_session, resource_id, book.book_id) - -# available_books = get_available_clean_books(db_session, resource_id) -# idx = _clean_find_book_index(available_books, book.book_id) - -# nav_input = schema.CleanNavigationInput( -# resource_id=resource_id, -# book_code=book_code, -# chapter=chapter, -# bible_record=bible_record, -# available_books=available_books, -# idx=idx -# ) - -# previous, next_chapter = _clean_build_navigation(nav_input) - - -# verse_content = [ -# schema.CleanVerseContent(verse=v.verse, text=v.text) -# for v in verses -# ] - -# return schema.CleanBibleChapterResponse( -# resource_id=resource_id, -# bible_book_code=book_code, -# chapter=chapter, -# previous=previous, -# next=next_chapter, -# chapter_content=verse_content -# ) - -# def get_bible_verse( -# db_session: Session, -# resource_id: int, -# book_code: str, -# chapter: int, -# verse: int -# ) -> schema.BibleVerseResponse: -# """Get specific verse content with enhanced navigation""" - -# _get_resource(db_session, resource_id) -# book = _get_book_or_404(db_session, book_code) -# verse_record = _get_clean_verse_or_404(db_session, resource_id, book.book_id, chapter, verse) -# bible_record = _get_bible_record(db_session, resource_id, book.book_id) - -# available_books = get_available_clean_books(db_session, resource_id) -# current_book_index = _find_book_index(available_books, book.book_id) - -# prev_input = schema.CleanPreviousVerseInput( -# db_session=db_session, -# resource_id=resource_id, -# book=book, -# chapter=chapter, -# verse=verse, -# available_books=available_books, -# book_index=current_book_index -# ) - -# previous = _compute_previous_verse(prev_input) - - -# next_input = schema.CleanNextVerseInput( -# db_session=db_session, -# resource_id=resource_id, -# book=book, -# chapter=chapter, -# verse=verse, -# available_books=available_books, -# book_index=current_book_index, -# bible_record=bible_record -# ) - -# next_verse = _compute_next_verse(next_input) - - -# return schema.BibleVerseResponse( -# resource_id=resource_id, -# bible_book_code=book_code, -# chapter=chapter, -# verse_number=verse, -# previous=previous, -# next=next_verse, -# verse_content=verse_record.text -# ) - -# def _get_book_or_404(db_session: Session, book_code: str): -# book = db_session.query(db_models.BookLookup).filter( -# func.lower(db_models.BookLookup.book_code) == book_code.lower() -# ).first() -# if not book: -# raise NotAvailableException(detail=f"Book {book_code} not found") -# return book - - -# def _get_clean_verse_or_404( -# db_session: Session, -# resource_id: int, -# book_id: int, -# chapter: int, -# verse: int -# ): -# verse_record = db_session.query(db_models.CleanBible).filter_by( -# resource_id=resource_id, -# book_id=book_id, -# chapter=chapter, -# verse=verse -# ).first() - -# if not verse_record: -# raise NotAvailableException( -# detail=f"Verse {book_id}.{chapter}.{verse} not found" -# ) -# return verse_record - - -# def _get_bible_record(db_session: Session, resource_id: int, book_id: int): -# return db_session.query(db_models.Bible).filter_by( -# resource_id=resource_id, -# book_id=book_id -# ).first() - - -# def _find_book_index(available_books, book_id): -# for i, book in enumerate(available_books): -# if book.book_id == book_id: -# return i -# return None - -# # def _compute_previous_verse(payload: schema.CleanPreviousVerseInput): -# # db_session = payload.db_session -# # resource_id = payload.resource_id -# # book = payload.book -# # chapter = payload.chapter -# # verse = payload.verse -# # available_books = payload.available_books -# # book_index = payload.book_index - -# # # Case 1: Previous verse in same chapter -# # if verse > 1: -# # return { -# # "resourceId": str(resource_id), -# # "bibleBookCode": book.book_code, -# # "chapterId": chapter, -# # "verse": verse - 1 -# # } - -# # # Case 2: Last verse of previous chapter -# # if chapter > 1: -# # prev_chap_last = db_session.query(db_models.CleanBible).filter_by( -# # resource_id=resource_id, -# # book_id=book.book_id, -# # chapter=chapter - 1 -# # ).order_by(db_models.CleanBible.verse.desc()).first() - -# # if prev_chap_last: -# # return { -# # "resourceId": str(resource_id), -# # "bibleBookCode": book.book_code, -# # "chapterId": chapter - 1, -# # "verse": prev_chap_last.verse -# # } - -# # # Case 3: Last verse of previous book -# # if book_index is not None and book_index > 0: -# # prev_book = available_books[book_index - 1] -# # last_verse = db_session.query(db_models.CleanBible).filter_by( -# # resource_id=resource_id, -# # book_id=prev_book.book_id, -# # chapter=prev_book.chapter_count -# # ).order_by(db_models.CleanBible.verse.desc()).first() - -# # if last_verse: -# # return { -# # "resourceId": str(resource_id), -# # "bibleBookCode": prev_book.book_code, -# # "chapterId": prev_book.chapter_count, -# # "verse": last_verse.verse -# # } - -# # return None - - -# # def _compute_next_verse(payload: schema.CleanNextVerseInput): -# # db_session = payload.db_session -# # resource_id = payload.resource_id -# # book = payload.book -# # chapter = payload.chapter -# # verse = payload.verse -# # available_books = payload.available_books -# # book_index = payload.book_index -# # bible_record = payload.bible_record - -# # # CASE 1: Next verse exists in same chapter -# # next_verse_record = db_session.query(db_models.CleanBible).filter_by( -# # resource_id=resource_id, -# # book_id=book.book_id, -# # chapter=chapter, -# # verse=verse + 1 -# # ).first() - -# # if next_verse_record: -# # return { -# # "resourceId": str(resource_id), -# # "bibleBookCode": book.book_code, -# # "chapterId": chapter, -# # "verse": verse + 1 -# # } - -# # # CASE 2: Next chapter exists in same book -# # if bible_record and chapter < bible_record.chapters: -# # return { -# # "resourceId": str(resource_id), -# # "bibleBookCode": book.book_code, -# # "chapterId": chapter + 1, -# # "verse": 1 -# # } - -# # # CASE 3: Next book exists -# # if book_index is not None and book_index < len(available_books) - 1: -# # next_book = available_books[book_index + 1] -# # return { -# # "resourceId": str(resource_id), -# # "bibleBookCode": next_book.book_code, -# # "chapterId": 1, -# # "verse": 1 -# # } - -# # return None - - -# # # --- Commentary CRUD --- - -# # def _ensure_commentary_resource(db: Session, resource_id: int): -# # resource = db.query(db_models.Resource).filter_by(resource_id=resource_id).first() -# # if not resource: -# # raise NotAvailableException(detail=f"Resource {resource_id} not found") -# # if (resource.content_type or "").lower() != "commentary": -# # raise NotAvailableException( -# # detail= ( -# # f"Resource {resource_id} is not of type 'commentary'" -# # f"(found '{resource.content_type}')" -# # ) -# # ) -# # return resource - -# # def _get_book_by_code(db: Session, book_code: str): -# # return db.query(db_models.BookLookup)\ -# # .filter(db_models.BookLookup.book_code.ilike(book_code)).first() - - - -# # def _intro_text(db: Session, resource_id: int, book_id: int) -> str: -# # row = ( -# # db.query(db_models.Commentary.text) -# # .filter( -# # db_models.Commentary.resource_id == resource_id, -# # db_models.Commentary.book_id == book_id, -# # db_models.Commentary.chapter == 0, -# # db_models.Commentary.verse == "0", # string -# # ) -# # .first() -# # ) -# # return row[0] if row else "" - - -# # def create_commentaries( -# # db: Session, -# # payload: schema.CommentaryBulkCreate, -# # actor_user_id: int -# # ) -> schema.CommentaryCreateResponse: -# # """ -# # Create commentary rows. -# # """ -# # #now = utcnow() - -# # resource = db.query(db_models.Resource).filter_by(resource_id=payload.resource_id).first() -# # if not resource: -# # raise NotAvailableException(detail=f"Resource {payload.resource_id} not found") - -# # if resource.content_type.lower() != "commentary": -# # raise NotAvailableException( -# # detail=( -# # f"Resource {payload.resource_id} is not of type 'commentary'" -# # f"(found '{resource.content_type}')" -# # ) -# # ) - -# # created_rows = [] - -# # for item in payload.commentary: -# # verse_str = str(item.verse).strip() - -# # exists_book = ( -# # db.query(db_models.BookLookup.book_id) -# # .filter_by(book_id=item.book_id) -# # .first() -# # ) -# # if not exists_book: -# # raise NotAvailableException(detail=f"BookId {item.book_id} not found") - -# # exists_row = db.query(db_models.Commentary.commentary_id).filter_by( -# # resource_id=payload.resource_id, -# # book_id=item.book_id, -# # chapter=item.chapter, -# # verse=verse_str, -# # ).first() -# # if exists_row: -# # raise AlreadyExistsException( -# # detail=( -# # "Row already exists. Use PUT to update: " -# # f"(resource_id={payload.resource_id}, book_id={item.book_id}, " -# # f"chapter={item.chapter}, verse={item.verse})" -# # ) -# # ) - -# # row = db_models.Commentary( -# # resource_id=payload.resource_id, -# # book_id=item.book_id, -# # chapter=item.chapter, -# # verse=verse_str, -# # text=item.text.strip(), -# # ) -# # db.add(row) -# # created_rows.append(row) - -# # touch_resource(db, resource_id=payload.resource_id, actor_user_id=actor_user_id) - -# # try: -# # db.commit() -# # except IntegrityError as exc: -# # db.rollback() -# # raise AlreadyExistsException("Unique constraint violated (row already exists).") from exc - - -# # return schema.CommentaryCreateResponse( -# # resource_id=payload.resource_id, -# # created=[ -# # schema.CommentaryCreatedItem( -# # commentary_id=r.commentary_id, -# # book_id=r.book_id, -# # chapter=r.chapter, -# # verse=r.verse, -# # text=r.text, -# # ) -# # for r in created_rows -# # ], -# # ) - - - -# # # --- PUT --- -# # def update_commentaries( -# # db: Session, -# # payload: schema.CommentaryBulkUpdate, -# # actor_user_id: int -# # ) -> schema.CommentaryUpdateResponse: -# # """Update commentary rows and return same response shape as POST.""" -# # resource = db.query(db_models.Resource).filter_by(resource_id=payload.resource_id).first() -# # if not resource: -# # raise NotAvailableException(detail=f"Resource {payload.resource_id} not found") -# # #now = utcnow() -# # # Resource content_type must be 'commentary' -# # if resource.content_type.lower() != "commentary": -# # raise BadRequestException( -# # detail=( -# # f"Resource {payload.resource_id} is not of type 'commentary' " -# # f"(found '{resource.content_type}')" -# # ) -# # ) -# # updated_rows: List[db_models.Commentary] = [] - -# # for item in payload.commentary: -# # # Find row -# # row = db.query(db_models.Commentary).filter_by( -# # commentary_id=item.commentary_id, resource_id=payload.resource_id -# # ).first() -# # if not row: -# # raise NotAvailableException( -# # detail=( -# # f"commentary_id {item.commentary_id} not found for resource " -# # f"{payload.resource_id}" -# # ) -# # ) -# # # Target values (keep existing if field omitted) -# # tgt_book_id = item.book_id if item.book_id is not None else row.book_id -# # tgt_chapter = item.chapter if item.chapter is not None else row.chapter -# # verse_str = str(item.verse).strip() if item.verse is not None else row.verse -# # # Validate book_id -# # exists_book = ( -# # db.query(db_models.BookLookup.book_id) -# # .filter_by(book_id=tgt_book_id) -# # .first() -# # ) -# # if not exists_book: -# # raise NotAvailableException(detail=f"BookId {tgt_book_id} not found") -# # # Uniqueness against other rows -# # exists_row = ( -# # db.query(db_models.Commentary.commentary_id) -# # .filter_by( -# # resource_id=payload.resource_id, -# # book_id=tgt_book_id, -# # chapter=tgt_chapter, -# # verse=verse_str, -# # ) -# # .filter(db_models.Commentary.commentary_id != item.commentary_id) -# # .first() -# # ) -# # if exists_row: -# # raise AlreadyExistsException( -# # detail=( -# # "Update would violate uniqueness: " -# # f"(resource_id={payload.resource_id}, book_id={tgt_book_id}, " -# # f"chapter={tgt_chapter}, verse={verse_str}) already exists." -# # ) -# # ) - -# # row.book_id = tgt_book_id -# # row.chapter = tgt_chapter -# # row.verse = verse_str -# # if item.text is not None: -# # row.text = item.text.strip() -# # updated_rows.append(row) -# # touch_resource(db, resource_id=payload.resource_id, actor_user_id=actor_user_id) -# # try: -# # db.commit() -# # except IntegrityError as e: -# # db.rollback() -# # raise AlreadyExistsException( -# # detail=( -# # "Update would violate uniqueness of " -# # f"(resource_id={payload.resource_id}, " -# # f"book_id={tgt_book_id}, chapter={tgt_chapter}, verse={verse_str})." -# # ) -# # ) from e - -# # # Reuse the POST response schema -# # return schema.CommentaryUpdateResponse( -# # resource_id=payload.resource_id, -# # updated=[ -# # schema.CommentaryUpdatedItem( -# # commentary_id=r.commentary_id, -# # book_id=r.book_id, -# # chapter=r.chapter, -# # verse=r.verse, -# # text=r.text, -# # ) -# # for r in updated_rows -# # ], -# # ) - - - -# # # --- GET: full content for a resource --- -# # def get_full_commentary(db: Session, resource_id: int) -> schema.CommentaryListResponse: -# # """Get full content of a resource.""" -# # _ensure_commentary_resource(db, resource_id) - -# # v_start = cast(func.split_part(db_models.Commentary.verse, '-', 1), Integer) -# # v_end = cast( -# # func.coalesce( -# # func.nullif(func.split_part(db_models.Commentary.verse, '-', 2), ''), -# # func.split_part(db_models.Commentary.verse, '-', 1) -# # ), -# # Integer -# # ) -# # # If verse/chapter are INT in DB, you can drop the cast() calls below. -# # q = ( -# # db.query( -# # db_models.Commentary, -# # db_models.BookLookup.book_code.label("book_code"), -# # ) -# # .join(db_models.BookLookup, db_models.BookLookup.book_id == db_models.Commentary.book_id) -# # .filter(db_models.Commentary.resource_id == resource_id) -# # .order_by( -# # db_models.Commentary.book_id, -# # cast(db_models.Commentary.chapter, Integer), -# # v_start, -# # v_end, -# # ) -# # .all() -# # ) -# # content = [ -# # schema.CommentaryRow( -# # commentary_id=cm.commentary_id, -# # bookCode=book_code, -# # chapter=int(cm.chapter), -# # verse=str(cm.verse), -# # text=cm.text, -# # ) -# # for cm, book_code in q -# # ] -# # return schema.CommentaryListResponse(resourceId=resource_id, content=content) - - - -# # # --- GET: full content of a chapter with book intro --- -# # def get_commentary_chapter( -# # db: Session, -# # resource_id: int, -# # book_code: str, -# # chapter: int -# # ) -> schema.ChapterResponse: -# # """Get full content of a chapter with book intro.""" -# # _ensure_commentary_resource(db, resource_id) -# # book = _get_book_by_code(db, book_code) -# # if not book: -# # raise NotAvailableException(detail=f"Unknown book_code '{book_code}'") -# # # book intro = chapter 0, verse 0 (single text) -# # book_intro = _intro_text(db, resource_id, book.book_id) -# # # chapter content: chapter=N, include verse >= 0 (0 is chapter intro) -# # rows = ( -# # db.query(db_models.Commentary) -# # .filter( -# # db_models.Commentary.resource_id == resource_id, -# # db_models.Commentary.book_id == book.book_id, -# # db_models.Commentary.chapter == chapter, -# # ) -# # .order_by(db_models.Commentary.verse.asc()) -# # .all() -# # ) -# # # If chapter truly doesn’t exist in commentary, raise 404 -# # if not rows: -# # raise NotAvailableException( -# # detail=( -# # f"Chapter {chapter} not found in commentary for book_code '" -# # f"{book.book_code}' (resource {resource_id})" -# # ), -# # ) -# # content = [schema.ChapterContentItem(verse=str(r.verse), text=r.text) for r in rows] -# # return schema.ChapterResponse( -# # bookCode=book.book_code, -# # bookIntro=book_intro, -# # chapter=chapter, -# # resourceId=resource_id, -# # content=content, -# # ) - - -# # # --- DELETE --- -# # def delete_commentary_bulk(db: Session, commentary_ids: List[int]): -# # deleted_ids = [] -# # errors = [] - -# # for cid in commentary_ids: -# # row = ( -# # db.query(db_models.Commentary) -# # .filter_by(commentary_id=cid) -# # .first() -# # ) - -# # if not row: -# # errors.append(f"commentary_id {cid} not found") -# # continue - -# # try: -# # db.delete(row) -# # deleted_ids.append(cid) - -# # except Exception as exc: -# # db.rollback() -# # errors.append(f"Failed to delete commentary_id {cid}: {str(exc)}") - -# row.book_id = tgt_book_id -# row.chapter = tgt_chapter -# row.verse = verse_str -# if item.text is not None: -# row.text = item.text.strip() -# updated_rows.append(row) -# touch_resource(db, resource_id=payload.resource_id, actor_user_id=actor_user_id) -# try: -# db.commit() -# except IntegrityError as e: -# db.rollback() -# raise AlreadyExistsException( -# detail=( -# "Update would violate uniqueness of " -# f"(resource_id={payload.resource_id}, " -# f"book_id={book_id}, chapter={chapter}, verse={verse})." -# ) -# ) from e - -# # Reuse the POST response schema -# return schema.CommentaryUpdateResponse( -# resource_id=payload.resource_id, -# updated=[ -# schema.CommentaryUpdatedItem( -# commentary_id=r.commentary_id, -# book_id=r.book_id, -# chapter=r.chapter, -# verse=r.verse, -# text=r.text, -# ) -# for r in updated_rows -# ], -# ) - -# # return deleted_ids, errors - -# # ---Dictionary CRUD------------- - -# # def normalize_unicode(value: str) -> str: -# # """Normalize Unicode characters for dictionary words.""" -# # if value is None or value == "": -# # return value -# # return unicodedata.normalize('NFC', value) -# # def upload_dictionary_words(db_: Session, resource_id: int, dictionary_words: List,actor_id: int): -# # '''Adds rows to the dictionary table for the specified resource_id. -# # Throws error if resource_id + keyword combination already exists.''' - -# # # Verify resource exists -# # resource_db_content = db_.query(db_models.Resource).filter( -# # db_models.Resource.resource_id == resource_id).first() -# # if not resource_db_content: -# # raise NotAvailableException(detail=f"Resource {resource_id} not found") -# # for item in dictionary_words: -# # # Check duplicates (resource_id + dictionary items) -# # existing = ( -# # db_.query(db_models.Dictionary) -# # .filter_by(resource_id=resource_id, -# # keyword=normalize_unicode(item.keyword)) -# # .first() -# # ) -# # if existing: -# # raise AlreadyExistsException( -# # detail=f"resoure {resource_id} with keyword {item.keyword} already exists" -# # ) -# # # Verify resource is of type DICTIONARY -# # if (resource_db_content.content_type or "").lower() != "dictionary": -# # raise BadRequestException( -# # detail=( -# # f"Resource {resource_id} is not of type 'dictionary'" -# # f"(found '{resource_db_content.content_type}')" -# # ) -# # ) -# # model_cls = db_models.Dictionary # Assuming Dictionary model exists -# # db_content = [] - -# # for item in dictionary_words: -# # # Check if keyword already exists for this resource -# # existing_word = db_.query(model_cls).filter( -# # model_cls.resource_id == resource_id, -# # model_cls.keyword == normalize_unicode(item.keyword) -# # ).first() - -# # if existing_word: -# # raise AlreadyExistsException( -# # detail= f"Keyword '{item.keyword}' already exists for resource {resource_id}." -# # ) - -# # row = model_cls( -# # resource_id=resource_id, -# # keyword=normalize_unicode(item.keyword), -# # word_forms=item.wordForms, -# # strongs=item.strongs, -# # definition=item.definition, -# # translation_help=item.translationHelp, -# # see_also=item.seeAlso, -# # ref=item.ref, -# # examples=item.examples -# # ) -# # db_.add(row) -# # db_.flush() - -# # db_content.append({ -# # 'wordId': row.word_id, -# # 'keyword': row.keyword, -# # 'wordForms': row.word_forms, -# # 'strongs': row.strongs, -# # 'definition': row.definition, -# # 'translationHelp': row.translation_help, -# # 'seeAlso': row.see_also, -# # 'ref': row.ref, -# # 'examples': row.examples -# # }) -# # touch_resource(db_, resource_id=resource_db_content.resource_id, actor_user_id=actor_id) -# # db_.commit() - -# # response = { -# # 'db_content': db_content, -# # 'resource_content': resource_db_content -# # } -# # return response - - -# # def update_dictionary_words(db_: Session, resource_id: int, dictionary_words: List, actor_id: int): -# # '''Updates rows in the dictionary table using wordId. -# # All fields except wordId can be updated.''' - -# # # Verify resource exists and is of type DICTIONARY -# # resource_db_content = _validate_dictionary_resource(db_, resource_id) - -# # # Check for duplicate keywords (AFTER resource validation) -# # #_check_duplicate_keywords(db_, resource_id, dictionary_words) - -# # # Update dictionary words -# # db_content = _update_dictionary_entries(db_, resource_id, dictionary_words) - -# # # Touch resource and commit -# # touch_resource(db_, resource_id=resource_db_content.resource_id, actor_user_id=actor_id) -# # db_.commit() - -# # # Build response -# # return _build_dictionary_response(db_content, resource_db_content) - - -# # def _validate_dictionary_resource(db_: Session, resource_id: int): -# # """Verify resource exists and is of type DICTIONARY.""" -# # resource = db_.query(db_models.Resource).filter( -# # db_models.Resource.resource_id == resource_id -# # ).first() - -# # if not resource: -# # raise NotAvailableException( -# # detail=f'Resource {resource_id} not found in database' -# # ) - -# # if (resource.content_type or "").lower() != "dictionary": -# # raise BadRequestException( -# # detail=( -# # f"Resource {resource_id} is not of type 'dictionary'" -# # f" (found '{resource.content_type}')" -# # ) -# # ) - -# # return resource - - -# # def _check_duplicate_keywords(db_: Session, resource_id: int, dictionary_words: List): -# # """Check for duplicate keywords in the resource.""" -# # for item in dictionary_words: -# # existing = ( -# # db_.query(db_models.Dictionary) -# # .filter_by( -# # resource_id=resource_id, -# # keyword=normalize_unicode(item.keyword) -# # ) -# # .first() -# # ) -# # if existing: -# # raise AlreadyExistsException( -# # detail=f"Resource {resource_id} with keyword {item.keyword} already exists" -# # ) - - -# # def _update_dictionary_entries(db_: Session, resource_id: int, dictionary_words: List): -# # """Update dictionary entries with provided data.""" -# # db_content = [] - -# # for item in dictionary_words: -# # # Find row by wordId and resourceId -# # row = db_.query(db_models.Dictionary).filter( -# # db_models.Dictionary.word_id == item.wordId, -# # db_models.Dictionary.resource_id == resource_id -# # ).first() - -# # if not row: -# # raise NotAvailableException( -# # detail=f"Dictionary word with id {item.wordId} not found in resource {resource_id}" -# # ) - -# # # Update fields if provided (preserves original logic exactly) -# # if item.keyword is not None: -# # row.keyword = normalize_unicode(item.keyword) -# # if item.wordForms is not None: -# # row.word_forms = item.wordForms -# # if item.strongs is not None: -# # row.strongs = item.strongs -# # if item.definition is not None: -# # row.definition = item.definition -# # if item.translationHelp is not None: -# # row.translation_help = item.translationHelp -# # if item.seeAlso is not None: -# # row.see_also = item.seeAlso -# # if item.ref is not None: -# # row.ref = item.ref -# # if item.examples is not None: -# # row.examples = item.examples - -# # db_.flush() -# # db_content.append(row) - -# # return db_content - - -# # def _build_dictionary_response(db_content: List, resource_db_content): -# # """Build the response dictionary.""" -# # response_data = [ -# # { -# # "wordId": row.word_id, -# # "keyword": row.keyword, -# # "wordForms": row.word_forms, -# # "strongs": row.strongs, -# # "definition": row.definition, -# # "translationHelp": row.translation_help, -# # "seeAlso": row.see_also, -# # "ref": row.ref, -# # "examples": row.examples -# # } -# # for row in db_content -# # ] - -# # return { -# # 'db_content': response_data, -# # 'resource_content': resource_db_content -# # } - - -# # def get_dictionary_words(db_: Session, resource_id: int, skip: int = 0, limit: int = 100): -# # '''Fetches all dictionary words for a given resource_id.''' - -# # # Verify resource exists -# # resource_db_content = db_.query(db_models.Resource).filter( -# # db_models.Resource.resource_id == resource_id).first() -# # if not resource_db_content: -# # raise NotAvailableException(detail=f"Resource {resource_id} not found") - -# # # Verify resource is of type DICTIONARY -# # if (resource_db_content.content_type or "").lower() != "dictionary": -# # raise BadRequestException( -# # detail= ( -# # f"Resource {resource_id} is not of type 'dictionary' " -# # f"(found '{resource_db_content.content_type}')" -# # ) -# # ) -# # model_cls = db_models.Dictionary -# # query = db_.query(model_cls).filter(model_cls.resource_id == resource_id) -# # query = query.order_by(model_cls.keyword) - -# # if skip is not None: -# # query = query.offset(skip) -# # if limit is not None: -# # query = query.limit(limit) -# # rows = query.all() -# # content = [ -# # { -# # "wordId": row.word_id, -# # "keyword": row.keyword, -# # "wordForms": row.word_forms, -# # "strongs": row.strongs, -# # "definition": row.definition, -# # "translationHelp": row.translation_help, -# # "seeAlso": row.see_also, -# # "ref": row.ref, -# # "examples": row.examples -# # } -# # for row in rows -# # ] - -# # return { -# # "resourceId": resource_id, -# # "content": content -# # } - -# if not resource: -# raise NotAvailableException( -# detail=f'Resource {resource_id} not found in database' -# ) - -# if (resource.content_type or "").lower() != "dictionary": -# raise BadRequestException( -# detail=( -# f"Resource {resource_id} is not of type 'dictionary'" -# f" (found '{resource.content_type}')" -# ) -# ) - -# return resource - - -# def _check_duplicate_keywords(db_: Session, resource_id: int, dictionary_words: List): -# """Check for duplicate keywords in the resource.""" -# for item in dictionary_words: -# existing = ( -# db_.query(db_models.Dictionary) -# .filter_by( -# resource_id=resource_id, -# keyword=normalize_unicode(item.keyword) -# ) -# .first() -# ) -# if existing: -# raise AlreadyExistsException( -# detail=f"Resource {resource_id} with keyword {item.keyword} already exists" -# ) - - -# def _update_dictionary_entries(db_: Session, resource_id: int, dictionary_words: List): -# """Update dictionary entries with provided data.""" -# db_content = [] - -# for item in dictionary_words: -# # Find row by wordId and resourceId -# row = db_.query(db_models.Dictionary).filter( -# db_models.Dictionary.word_id == item.wordId, -# db_models.Dictionary.resource_id == resource_id -# ).first() - -# if not row: -# raise NotAvailableException( -# detail=f"Dictionary word with id {item.wordId} not found in resource {resource_id}" -# ) - -# # Update fields if provided (preserves original logic exactly) -# if item.keyword is not None: -# row.keyword = normalize_unicode(item.keyword) -# if item.wordForms is not None: -# row.wordForms = item.wordForms -# if item.strongs is not None: -# row.strongs = item.strongs -# if item.definition is not None: -# row.definition = item.definition -# if item.translationHelp is not None: -# row.translationHelp = item.translationHelp -# if item.seeAlso is not None: -# row.seeAlso = item.seeAlso -# if item.ref is not None: -# row.ref = item.ref -# if item.examples is not None: -# row.examples = item.examples - -# db_.flush() -# db_content.append(row) - -# return db_content - - -# def _build_dictionary_response(db_content: List, resource_db_content): -# """Build the response dictionary.""" -# response_data = [ -# { -# "wordId": row.word_id, -# "keyword": row.keyword, -# "wordForms": row.word_forms, -# "strongs": row.strongs, -# "definition": row.definition, -# "translationHelp": row.translation_help, -# "seeAlso": row.see_also, -# "ref": row.ref, -# "examples": row.examples -# } -# for row in db_content -# ] - -# return { -# 'db_content': response_data, -# 'resource_content': resource_db_content -# } - -# # def get_dictionary_index(db_: Session, resource_id: int): -# # '''Fetches dictionary index grouped by first letter of keywords.''' - -# # # Verify resource exists -# # resource_db_content = db_.query(db_models.Resource).filter( -# # db_models.Resource.resource_id == resource_id).first() -# # if not resource_db_content: -# # raise NotAvailableException(detail=f"Resource {resource_id} not found") - -# # # Verify resource is of type DICTIONARY -# # if (resource_db_content.content_type or "").lower() != "dictionary": -# # raise BadRequestException( -# # detail=( -# # f"Resource {resource_id} is not of type 'dictionary' " -# # f"(found '{resource_db_content.content_type}')" -# # ) -# # ) - -# # model_cls = db_models.Dictionary -# # words = db_.query(model_cls.word_id, model_cls.keyword).filter( -# # model_cls.resource_id == resource_id -# # ).order_by(model_cls.keyword).all() - -# # # Group by first letter -# # index_dict = {} -# # for word in words: -# # if word.keyword: -# # first_letter = word.keyword[0].upper() -# # if first_letter not in index_dict: -# # index_dict[first_letter] = [] -# # index_dict[first_letter].append({ -# # 'wordId': word.word_id, -# # 'word': word.keyword -# # }) - -# # # Convert to list format -# # index_list = [] -# # for letter in sorted(index_dict.keys()): -# # index_list.append({ -# # 'letter': letter, -# # 'words': index_dict[letter] -# # }) - -# # return { -# # 'index': index_list, -# # 'resource_content': resource_db_content -# # } - - -# # def get_dictionary_word_by_id(db_: Session, resource_id: int, word_id: int): -# # '''Fetches a specific dictionary word by wordId and resource_id.''' - -# # # Verify resource exists -# # resource_db_content = db_.query(db_models.Resource).filter( -# # db_models.Resource.resource_id == resource_id).first() -# # if not resource_db_content: -# # raise NotAvailableException(detail=f"Resource {resource_id} not found") - -# # # Verify resource is of type DICTIONARY -# # if (resource_db_content.content_type or "").lower() != "dictionary": -# # raise BadRequestException( -# # detail=( -# # f"Resource {resource_id} is not of type 'dictionary' " -# # f"(found '{resource_db_content.content_type}')" -# # ) -# # ) -# # model_cls = db_models.Dictionary -# # word = db_.query(model_cls).filter( -# # model_cls.resource_id == resource_id, -# # model_cls.word_id == word_id -# # ).first() - -# # return { -# # 'db_content': word, -# # 'resource_content': resource_db_content -# # } - -# # def delete_dictionary_words(db_: Session, resource_id: int, word_ids: List[int], user_id=None): -# # """ -# # Deletes multiple dictionary words by their wordIds. -# # Returns count, deleted IDs, and errors (for duplicates or invalid IDs). -# # """ - -# # # Verify resource exists -# # resource_db_content = ( -# # db_.query(db_models.Resource) -# # .filter(db_models.Resource.resource_id == resource_id) -# # .first() -# # ) -# # if not resource_db_content: -# # raise NotAvailableException(detail=f"Resource {resource_id} not found") - -# # # Verify dictionary -# # if (resource_db_content.content_type or "").lower() != "dictionary": -# # raise BadRequestException( -# # detail=( -# # f"Resource {resource_id} is not of type 'dictionary'" -# # f"(found '{resource_db_content.content_type}')" -# # ) -# # ) - -# # model_cls = db_models.Dictionary -# # deleted_ids = [] -# # errors = [] -# # processed = set() - -# # for word_id in word_ids: - -# # # Duplicate within same request -# # if word_id in processed: -# # errors.append({"id": word_id, "error": "already_deleted"}) -# # continue -# # processed.add(word_id) - -# # # Check existence -# # word = ( -# # db_.query(model_cls) -# # .filter( -# # model_cls.resource_id == resource_id, -# # model_cls.word_id == word_id, -# # ) -# # .first() -# # ) - -# # if not word: -# # errors.append({"id": word_id, "error": "not_found"}) -# # continue - -# # # Delete word -# # db_.delete(word) -# # deleted_ids.append(word_id) - -# # # Commit all successful deletes -# # db_.commit() - -# # # Construct error message -# # error_msg = None -# # if errors: -# # not_found = [e["id"] for e in errors if e["error"] == "not_found"] -# # dup = [e["id"] for e in errors if e["error"] == "already_deleted"] -# # parts = [] -# # if not_found: -# # parts.append(f"Invalid word IDs: {not_found}") -# # if dup: -# # parts.append(f"Already deleted IDs: {dup}") -# # error_msg = "; ".join(parts) - -# # return { -# # "message": f"Successfully deleted {len(deleted_ids)} words", -# # "deleted_count": len(deleted_ids), -# # "deleted_ids": deleted_ids, -# # "error": error_msg, -# # "has_errors": bool(errors), -# # } - - -# # # --- AudioBible CRUD --- - -# # def validate_books(db: Session, books: dict): -# # """ -# # Validate that all book codes exist in BookLookup table -# # and chapter counts are within valid range -# # """ -# # # Get all valid book codes and their chapter counts -# # book_lookup = db.query( -# # db_models.BookLookup.book_code, -# # db_models.BookLookup.chapter_count -# # ).all() - -# # # Create a dictionary for easy lookup (case-insensitive) -# # valid_books = { -# # row.book_code.lower(): row.chapter_count -# # for row in book_lookup -# # } - -# # # Validate each book in the input -# # for book_code, chapter_count in books.items(): -# # book_code_lower = book_code.lower() - -# # # Check if book code exists -# # if book_code_lower not in valid_books: -# # raise BadRequestException( -# # detail=( -# # f"Invalid book code '{book_code}'. " -# # f"Must match book_code from BookLookup table." -# # ) -# # ) - -# # # Check if chapter count is within valid range -# # max_chapters = valid_books[book_code_lower] -# # if chapter_count > max_chapters: -# # raise BadRequestException( -# # detail=f"Invalid chapter count for '{book_code}': {chapter_count}. " -# # f"Maximum chapters for this book is {max_chapters}." -# # ) - -# # def create_audio_bible(db: Session, data: schema.AudioBibleCreate, actor_user_id: int): -# # """Create a new audio bible entry""" -# # # Check if resource exists -# # resource = db.query(db_models.Resource).filter_by(resource_id=data.resource_id).first() -# # if not resource: -# # raise NotAvailableException( -# # detail=f"Resource with id {data.resource_id} does not exist." -# # ) - -# # # Resource content_type must be 'bible' -# # if resource.content_type.lower() != "bible": -# # raise BadRequestException( -# # detail=( -# # f"Resource {data.resource_id} is not of type 'bible' " -# # f"(found '{resource.content_type}')" -# # ) -# # ) - -# # # Check if audio bible already exists for this resource -# # existing = db.query(db_models.AudioBible).filter_by(resource_id=data.resource_id).first() -# # if existing: -# # raise AlreadyExistsException( -# # detail=( -# # f"AudioBible with resource_id {data.resource_id} already exists." -# # f" Use PUT to update." -# # ) -# # ) - -# # # Validate book codes -# # validate_books(db, data.books) - -# # # Create audio bible -# # audio_bible = db_models.AudioBible(**data.model_dump()) -# # db.add(audio_bible) -# # touch_resource(db, data.resource_id, actor_user_id) -# # db.commit() -# # db.refresh(audio_bible) -# # return audio_bible - -# # def _is_files_missing_empty(obj) -> bool: -# # """ -# # Return True if files_missing is empty ({} or None), -# # False if it contains missing entries. -# # """ -# # if obj is None: -# # return True -# # # if empty mapping -# # try: -# # if isinstance(obj, dict) and len(obj) == 0: -# # return True -# # # JSONB may also arrive as string by accident; handle that defensively -# # return False -# # except Exception: -# # return False - -# # def list_audio_bibles( -# # db: Session, -# # resource_id: Optional[int] = None, -# # limit: int = 50, -# # offset: int = 0, -# # files_missing: Optional[bool] = None, -# # test_date: Optional[datetime] = None -# # ) -> List[dict]: -# # """List audio bibles with optional filtering and pagination""" -# # query = db.query(db_models.AudioBible) - -# # if resource_id is not None: -# # query = query.filter(db_models.AudioBible.resource_id == resource_id) - -# # query = query.limit(limit).offset(offset) -# # rows = query.all() -# # out = [] -# # for ab in rows: -# # fm = ab.files_missing # could be None, {}, or dict -# # td = ab.test_date # could be None or datetime - -# # # files_missing filter -# # if files_missing is not None: -# # has_missing = not _is_files_missing_empty(fm) -# # if files_missing and not has_missing: -# # # caller wants only audio bibles that *have* missing files -# # continue -# # if (not files_missing) and has_missing: -# # # caller wants only audio bibles with no missing files -# # continue - -# # # test_date filter (keep rows tested ON or AFTER the given timestamp) -# # if test_date is not None: -# # if td is None: -# # # row has no test_date -> skip when filtering by test_date -# # continue -# # # ensure timezone-aware comparision: convert to UTC if naive -# # # assume incoming test_date is timezone-aware (FastAPI will parse RFC datetimes) -# # if td.tzinfo is None: -# # td = td.replace(tzinfo=timezone.utc) -# # if test_date.tzinfo is None: -# # test_date = test_date.replace(tzinfo=timezone.utc) -# # if td < test_date: -# # continue -# # out.append({ -# # "resourceId": ab.resource_id, -# # "name": ab.name, -# # "url": ab.base_url, -# # "books": ab.books, -# # "format": ab.format, -# # # normalize empty dict -> {} ; None left as None -# # "files_missing": ( -# # ab.files_missing -# # if ab.files_missing -# # else {} -# # ), -# # "test_date": ab.test_date, -# # }) - -# # return out - - -# # def update_audio_bible( -# # db: Session, -# # resource_id: int, -# # update_data: schema.AudioBibleUpdate, -# # actor_user_id: int -# # ): -# # """Update an existing audio bible""" -# # # Check if resource exists -# # resource = db.query(db_models.Resource).filter_by(resource_id=resource_id).first() -# # if not resource: -# # raise NotAvailableException( -# # detail=f"Resource with id {resource_id} does not exist." -# # ) -# # # Resource content_type must be 'bible' -# # if resource.content_type.lower() != "bible": -# # raise BadRequestException( -# # detail=( -# # f"Resource {resource_id} is not of type 'bible' " -# # f"(found '{resource.content_type}')" -# # ) -# # ) - -# # # Validate book codes if books are being updated -# # update_dict = update_data.model_dump(exclude_unset=True) -# # if "books" in update_dict and update_dict["books"] is not None: -# # validate_books(db, update_dict["books"]) - -# # audio_bible = db.query(db_models.AudioBible).filter_by(resource_id=resource_id).first() -# # # Update fields -# # for field, value in update_dict.items(): -# # if value is not None: -# # setattr(audio_bible, field, value) -# # touch_resource(db, resource_id=resource_id, actor_user_id=actor_user_id) -# # db.commit() -# # db.refresh(audio_bible) -# # return audio_bible - -# def _is_files_missing_empty(obj) -> bool: -# """ -# Return True if files_missing is empty ({} or None), -# False if it contains missing entries. -# """ -# if obj is None: -# return True -# # if empty mapping -# try: -# if isinstance(obj, dict) and len(obj) == 0: -# return True -# # JSONB may also arrive as string by accident; handle that defensively -# return False -# except Exception: -# return False - -# def list_audio_bibles( -# db: Session, -# resource_id: Optional[int] = None, -# limit: int = 50, -# offset: int = 0, -# files_missing: Optional[bool] = None, -# test_date: Optional[datetime] = None -# ) -> List[dict]: -# """List audio bibles with optional filtering and pagination""" -# query = db.query(db_models.AudioBible) - -# if resource_id is not None: -# query = query.filter(db_models.AudioBible.resource_id == resource_id) - -# query = query.limit(limit).offset(offset) -# rows = query.all() -# out = [] -# for ab in rows: -# fm = ab.files_missing # could be None, {}, or dict -# td = ab.test_date # could be None or datetime - -# # files_missing filter -# if files_missing is not None: -# has_missing = not _is_files_missing_empty(fm) -# if files_missing and not has_missing: -# # caller wants only audio bibles that *have* missing files -# continue -# if (not files_missing) and has_missing: -# # caller wants only audio bibles with no missing files -# continue - -# # test_date filter (keep rows tested ON or AFTER the given timestamp) -# if test_date is not None: -# if td is None: -# # row has no test_date -> skip when filtering by test_date -# continue -# # ensure timezone-aware comparision: convert to UTC if naive -# # assume incoming test_date is timezone-aware (FastAPI will parse RFC datetimes) -# if td.tzinfo is None: -# td = td.replace(tzinfo=timezone.utc) -# if test_date.tzinfo is None: -# test_date = test_date.replace(tzinfo=timezone.utc) -# if td < test_date: -# continue -# out.append({ -# "resourceId": ab.resource_id, -# "name": ab.name, -# "url": ab.base_url, -# "books": ab.books, -# "format": ab.format, -# # normalize empty dict -> {} ; None left as None -# "files_missing": ( -# ab.files_missing -# if ab.files_missing -# else {} -# ), -# }) - -# return out - - -# def update_audio_bible( -# db: Session, -# resource_id: int, -# update_data: schema.AudioBibleUpdate, -# actor_user_id: int -# ): -# """Update an existing audio bible""" -# # Check if resource exists -# resource = db.query(db_models.Resource).filter_by(resource_id=resource_id).first() -# if not resource: -# raise NotAvailableException( -# detail=f"Resource with id {resource_id} does not exist." -# ) -# # Resource content_type must be 'bible' -# if resource.content_type.lower() != "bible": -# raise BadRequestException( -# detail=( -# f"Resource {resource_id} is not of type 'bible' " -# f"(found '{resource.content_type}')" -# ) -# ) - -# # def delete_audio_bible(db: Session, resource_id: int): -# # """Delete an audio bible""" -# # audio_bible = db.query(db_models.AudioBible).filter( -# # db_models.AudioBible.resource_id == resource_id -# # ).first() - -# # if not audio_bible: -# # return None - -# # db.delete(audio_bible) -# # db.commit() -# # return audio_bible -# # def bulk_delete_audio_bibles(db: Session, resource_id: int, audio_bible_ids: List[int]): -# # deleted_ids = [] -# # errors = [] -# # processed = set() - -# # # Ensure resource exists -# # resource = ( -# # db.query(db_models.Resource) -# # .filter(db_models.Resource.resource_id == resource_id) -# # .first() -# # ) -# # if not resource: -# # raise NotAvailableException(detail=f"Resource {resource_id} not found") - -# # # Ensure content type = audio_bible -# # if (resource.content_type or "").lower() != "audiobible": -# # raise BadRequestException( -# # detail=( -# # f"Resource {resource_id} is not of type 'audiobible'" -# # f" (found '{resource.content_type}')" -# # ) -# # ) - -# # for ab_id in audio_bible_ids: - -# # # Prevent duplicates in the same request -# # if ab_id in processed: -# # errors.append({"id": ab_id, "error": "already_deleted"}) -# # continue -# # processed.add(ab_id) - -# # # Check existence -# # row = ( -# # db.query(db_models.AudioBible) -# # .filter( -# # db_models.AudioBible.audio_bible_id == ab_id, -# # db_models.AudioBible.resource_id == resource_id -# # ) -# # .first() -# # ) - -# # if not row: -# # errors.append({"id": ab_id, "error": "not_found"}) -# # continue - -# # # Delete the row -# # db.delete(row) -# # deleted_ids.append(ab_id) - -# # # Commit once -# # db.commit() - -# # # Build error message -# # error_msg = None -# # if errors: -# # nf = [e["id"] for e in errors if e["error"] == "not_found"] -# # dup = [e["id"] for e in errors if e["error"] == "already_deleted"] -# # parts = [] -# # if nf: -# # parts.append(f"Invalid audio_bible_ids: {nf}") -# # if dup: -# # parts.append(f"Duplicate IDs in request: {dup}") -# # error_msg = "; ".join(parts) - -# # return { -# # "deletedCount": len(deleted_ids), -# # "deletedIds": deleted_ids, -# # "error": error_msg, -# # "message": f"Successfully deleted {len(deleted_ids)} audio bible(s)" -# # } - - - -# # ---- OBS ---- -# # def create_obs_story( -# # db_session: Session, -# # language_code: int, -# # story_data: schema.OBSStoryCreate -# # ) -> dict: -# # """ -# # Create a new OBS story for a specific language. - -# # Args: -# # db_session: Database session -# # language_code: Language identifier -# # story_data: Story creation data - -# # Returns: -# # Dictionary with success, message, and data - -# # Raises: -# # HTTPException: If validation fails or resource doesn't exist -# # """ - -# # # 1. Validate language exists -# # language = db_session.query(db_models.Language).filter_by( -# # language_code=language_code -# # ).first() - -# # if not language: -# # logger.error("Language code %s not found", language_code) -# # raise NotAvailableException( -# # detail=f"Language with code {language_code} not found" -# # ) - -# # # 2. Validate resource (resource_id) exists -# # resource = db_session.query(db_models.Resource).filter_by( -# # resource_id=story_data.resource_id -# # ).first() - -# # if not resource: -# # logger.error("Resource ID %s not found", story_data.resource_id) -# # raise NotAvailableException( -# # detail=f"Resource with ID {story_data.resource_id} not found" -# # ) - -# # # 3. Validate resource belongs to the specified language -# # if resource.language_id != language.language_id: -# # logger.error( -# # "Resource %s does not belong to language %s", -# # story_data.resource_id, -# # language_code -# # ) - -# # raise BadRequestException( -# # detail=f"Resource {story_data.resource_id} does not belong to language {language_code}" -# # ) - -# # # 4. Validate resource content type is 'obs' -# # if resource.content_type.lower() != "obs": -# # logger.error("Resource %s is not of type 'obs'", story_data.resource_id) -# # raise BadRequestException( -# # detail=( -# # f"Resource {story_data.resource_id} is not of type 'obs' " -# # f"(found '{resource.content_type}')" -# # ) -# # ) - -# # # 5. Check for duplicate story_no within the same resource -# # existing_story = db_session.query(db_models.Obs).filter_by( -# # resource_id=story_data.resource_id, -# # story_no=story_data.story_no -# # ).first() - -# # if existing_story: -# # logger.error( -# # "Story number %s already exists for resource %s", -# # story_data.story_no, -# # story_data.resource_id -# # ) -# # raise AlreadyExistsException( -# # detail=( -# # f"Story number {story_data.story_no} already exists for resource " -# # f"{story_data.resource_id}" -# # ) -# # ) - -# # # 6. Create new OBS story -# # new_story = db_models.Obs( -# # resource_id=story_data.resource_id, -# # story_no=story_data.story_no, -# # title=story_data.title.strip(), -# # url=story_data.url.strip() if story_data.url else None, -# # text=story_data.text.strip() -# # ) - -# # db_session.add(new_story) -# # db_session.commit() -# # db_session.refresh(new_story) - -# # logger.info( -# # "Successfully created OBS story %s for resource %s", -# # new_story.obs_id, -# # story_data.resource_id -# # ) - -# # # Return formatted response -# # return { -# # "success": True, -# # "message": "Story created successfully", -# # "data": { -# # "id": new_story.obs_id, -# # "resource_id": new_story.resource_id, -# # "story_no": new_story.story_no, -# # "title": new_story.title, -# # "url": new_story.url, -# # "text": new_story.text -# # } -# # } - -# # def get_languages_with_obs(db_session: Session) -> schema.OBSLanguageListResponse: -# # """ -# # Get all languages that have OBS stories available. -# # Returns formatted response with languages and their story counts. -# # """ -# # # Query to get languages with OBS resources and count their stories -# # result = ( -# # db_session.query( -# # db_models.Language.language_code, -# # db_models.Language.language_name, -# # func.count(db_models.Obs.obs_id).label("story_count"), #pylint: disable=not-callable - -# # ) -# # .join( -# # db_models.Resource, -# # db_models.Resource.language_id == db_models.Language.language_id, -# # ) -# # .join( -# # db_models.Obs, -# # db_models.Obs.resource_id == db_models.Resource.resource_id, -# # ) -# # .filter(db_models.Resource.content_type == "obs") -# # .group_by( -# # db_models.Language.language_code, -# # db_models.Language.language_name, -# # ) -# # .order_by(db_models.Language.language_name) -# # .all() -# # ) - -# # # Transform result into response schema -# # language_list = [ -# # schema.LanguageWithStoryCount( -# # language_code=row.language_code, -# # language_name=row.language_name, -# # story_count=row.story_count, -# # ) -# # for row in result -# # ] - -# # return schema.OBSLanguageListResponse( -# # success=True, -# # data=language_list, -# # count=len(language_list), -# # ) - - - -# # def get_obs_stories_by_language( -# # db_session: Session, -# # language_code: int, -# # page: int = 1, -# # limit: int = 50 -# # ) -> schema.OBSStoriesListResponse: -# # """ -# # Get all OBS stories for a specific language with pagination. -# # Returns formatted response with stories list and pagination info. -# # """ -# # # 1. Validate language exists -# # language = db_session.query(db_models.Language).filter_by( -# # language_code=language_code -# # ).first() - -# # if not language: -# # logger.error("Language ID %s not found", language_code) -# # raise NotAvailableException( -# # detail=f"Language with ID {language_code} not found" -# # ) -# # language_id = language.language_id -# # # 2. Get total count of stories for this language -# # total_count = ( -# # db_session.query(func.count(db_models.Obs.obs_id).label("total")) #pylint: disable=not-callable -# # .join( -# # db_models.Resource, -# # db_models.Resource.resource_id == db_models.Obs.resource_id -# # ) -# # .filter( -# # db_models.Resource.language_id == language_id, -# # db_models.Resource.content_type == "obs" -# # ).scalar() -# # ) -# # # 3. Get paginated stories -# # offset = (page - 1) * limit -# # stories = db_session.query(db_models.Obs).join( -# # db_models.Resource, -# # db_models.Resource.resource_id == db_models.Obs.resource_id -# # ).filter( -# # db_models.Resource.language_id == language_id, -# # db_models.Resource.content_type == 'obs' -# # ).order_by( -# # db_models.Obs.story_no -# # ).offset(offset).limit(limit).all() - -# # # 4. Build story list -# # story_list = [ -# # schema.OBSStoryBrief( -# # id=story.obs_id, -# # resource_id=story.resource_id, -# # story_no=story.story_no, -# # title=story.title, -# # url=story.url, -# # text=story.text -# # ) -# # for story in stories -# # ] - -# # # 5. Return formatted response -# # return schema.OBSStoriesListResponse( -# # success=True, -# # data=schema.OBSStoriesListData( -# # language_code=language.language_code, -# # language_name=language.language_name, -# # stories=story_list -# # ), -# # pagination=schema.PaginationInfo( -# # page=page, -# # limit=limit, -# # total=total_count -# # ) -# # ) - - -# # def get_obs_story_by_id( -# # db_session: Session, -# # language_code: int, -# # story_id: int -# # ) -> schema.OBSStoryDetailResponse: -# # """ -# # Get a specific OBS story by ID and language. -# # Returns formatted response with full story details. -# # """ -# # # 1. Validate language exists -# # language = db_session.query(db_models.Language).filter_by( -# # language_code=language_code -# # ).first() - -# # if not language: -# # logger.error("Language code %s not found", language_code) -# # raise NotAvailableException( -# # detail=f"Language with code {language_code} not found" -# # ) - -# # # 2. Get story with language validation -# # language_id = language.language_id -# # story = db_session.query(db_models.Obs).join( -# # db_models.Resource, -# # db_models.Resource.resource_id == db_models.Obs.resource_id -# # ).filter( -# # db_models.Obs.obs_id == story_id, -# # db_models.Resource.language_id == language_id, -# # db_models.Resource.content_type == 'obs' -# # ).first() - -# # if not story: -# # logger.error("Story ID %s not found for language %s", story_id, language_code) -# # raise NotAvailableException( -# # detail=f"Story with ID {story_id} not found for language {language_code}" -# # ) - -# # # 3. Return formatted response -# # return schema.OBSStoryDetailResponse( -# # success=True, -# # data=schema.OBSStoryResponse( -# # id=story.obs_id, -# # resource_id=story.resource_id, -# # story_no=story.story_no, -# # title=story.title, -# # url=story.url, -# # text=story.text -# # ) -# # ) - -# # def _get_language_or_404(db_session, language_code): -# # language = ( -# # db_session.query(db_models.Language) -# # .filter_by(language_code=language_code) -# # .first() -# # ) -# # if not language: -# # logger.error("Language code %s not found", language_code) -# # raise NotAvailableException( -# # detail=f"Language with code {language_code} not found", -# # ) -# # return language - - -# # def _get_story_or_404(db_session, story_id, language_id, language_code): -# # story = ( -# # db_session.query(db_models.Obs) -# # .join( -# # db_models.Resource, -# # db_models.Resource.resource_id == db_models.Obs.resource_id, -# # ) -# # .filter( -# # db_models.Obs.obs_id == story_id, -# # db_models.Resource.language_id == language_id, -# # db_models.Resource.content_type == "obs", -# # ) -# # .first() -# # ) -# # if not story: -# # logger.error( -# # "Story ID %s not found for language %s", story_id, language_code -# # ) -# # raise NotAvailableException( -# # detail=( -# # f"Story with ID {story_id} not found for language {language_code}" -# # ), -# # ) -# # return story - - -# # def _validate_resource_update(db_session, story_data, language): -# # if story_data.resource_id is None: -# # return None - -# # new_resource = ( -# # db_session.query(db_models.Resource) -# # .filter_by(resource_id=story_data.resource_id) -# # .first() -# # ) - -# # if not new_resource: -# # logger.error( -# # "Resource ID %s not found", story_data.resource_id -# # ) -# # raise NotAvailableException( -# # detail=f"Resource with ID {story_data.resource_id} not found", -# # ) - -# # if new_resource.language_id != language.language_id: -# # logger.error( -# # "Resource %s does not belong to language %s", -# # story_data.resource_id, -# # language.language_code, -# # ) -# # raise BadRequestException( -# # detail=( -# # f"Resource {story_data.resource_id} does not belong to language " -# # f"{language.language_code}" -# # ), -# # ) - -# # if new_resource.content_type.lower() != "obs": -# # logger.error( -# # "Resource %s is not of type 'obs'", story_data.resource_id -# # ) -# # raise BadRequestException( -# # detail=( -# # f"Resource {story_data.resource_id} is not of type 'obs' " -# # f"(found '{new_resource.content_type}')" -# # ), -# # ) - -# # return new_resource - - -# # def _ensure_story_no_unique(db_session, story_data, story): -# # if story_data.story_no is None: -# # return - -# # target_resource_id = ( -# # story_data.resource_id -# # if story_data.resource_id is not None -# # else story.resource_id -# # ) - -# # duplicate_story = ( -# # db_session.query(db_models.Obs) -# # .filter( -# # db_models.Obs.resource_id == target_resource_id, -# # db_models.Obs.story_no == story_data.story_no, -# # db_models.Obs.obs_id != story.obs_id, -# # ) -# # .first() -# # ) - -# # if duplicate_story: -# # logger.error( -# # "Story number %s already exists for resource %s", -# # story_data.story_no, -# # target_resource_id, -# # ) -# # raise AlreadyExistsException( -# # detail=( -# # f"Story number {story_data.story_no} already exists for resource " -# # f"{target_resource_id}" -# # ), -# # ) - - -# # def update_obs_story( -# # db_session: Session, -# # language_code: int, -# # story_id: int, -# # story_data: schema.OBSStoryUpdate, -# # ) -> schema.OBSStoryUpdateResponse: -# # """Update an existing OBS story.""" - -# # language = _get_language_or_404(db_session, language_code) -# # story = _get_story_or_404( -# # db_session, story_id, language.language_id, language_code -# # ) - -# # # Validate resource update -# # _ = _validate_resource_update(db_session, story_data, language) - -# # # Validate duplicate story number -# # _ensure_story_no_unique(db_session, story_data, story) - -# # # Apply updates -# # if story_data.resource_id is not None: -# # story.resource_id = story_data.resource_id -# # if story_data.story_no is not None: -# # story.story_no = story_data.story_no -# # if story_data.title is not None: -# # story.title = story_data.title.strip() -# # if story_data.url is not None: -# # story.url = story_data.url.strip() if story_data.url else None -# # if story_data.text is not None: -# # story.text = story_data.text.strip() - -# # db_session.commit() -# # db_session.refresh(story) - -# # logger.info("Successfully updated OBS story %s", story_id) - -# # return schema.OBSStoryUpdateResponse( -# # success=True, -# # message="Story updated successfully", -# # data=schema.OBSStoryUpdate( -# # id=story.obs_id, -# # resource_id=story.resource_id, -# # story_no=story.story_no, -# # title=story.title, -# # url=story.url, -# # text=story.text, -# # ), -# # ) - -# # # ===== DELETE ===== -# # def delete_obs_story( -# # db_session: Session, -# # language_code: str, -# # story_nos: List[int] -# # ) -> dict: -# # """ -# # Bulk delete OBS stories by story numbers and language. -# # Returns a response containing deleted & invalid story numbers. -# # """ - -# # # 1. Validate language exists -# # language = db_session.query(db_models.Language).filter_by( -# # language_code=language_code -# # ).first() - -# # if not language: -# # raise NotAvailableException( -# # detail=f"Language with code {language_code} not found" -# # ) - -# # language_id = language.language_id - -# # deleted = [] -# # invalid = [] -# # processed = set() - -# # # 2. Iterate through story numbers -# # for story_no in story_nos: -# # if story_no in processed: -# # invalid.append(story_no) -# # continue -# # processed.add(story_no) - -# # story = ( -# # db_session.query(db_models.Obs) -# # .join(db_models.Resource, db_models.Resource.resource_id == db_models.Obs.resource_id) -# # .filter( -# # db_models.Obs.story_no == story_no, -# # db_models.Resource.language_id == language_id, -# # db_models.Resource.content_type == "obs" -# # ) -# # .first() -# # ) - -# # if not story: -# # invalid.append(story_no) -# # continue - -# # db_session.delete(story) -# # deleted.append(story_no) - -# # # 3. Commit all deletes -# # db_session.commit() - -# # # 4. Prepare response -# # response = { -# # "deletedCount": len(deleted), -# # "deletedStoryNos": deleted, -# # "message": f"Successfully deleted {len(deleted)} stories", -# # } - -# # if invalid: -# # response["error"] = f"Invalid story_nos: {', '.join(map(str, invalid))}" - -# # return response - - -# ### CRUD for InfoGraphics - -# # ALLOWED_EXT = {".jpg", ".jpeg", ".png", ".gif", ".svg", ".webp"} - -# # def validate_item(idx: int, item) -> Optional[dict]: -# # """Return an error dict if invalid, else None. Collects *all* field errors.""" -# # msgs = [] -# # data_dump = item.model_dump(by_alias=True) -# # # 1. book_id validation -# # if not isinstance(item.book_id, int) or not 1 <= item.book_id <= 67: -# # msgs.append("book_id must be between 1 to 67") -# # # title -# # title = (item.title or "").strip() -# # if not title: -# # msgs.append("title cannot be empty") -# # fn = (item.file_name or "").strip() -# # if not fn: -# # msgs.append("filename cannot be empty") -# # else: -# # m = re.search(r"(\.[a-zA-Z0-9]+)$", fn) -# # if not m or m.group(1).lower() not in ALLOWED_EXT: -# # msgs.append("file_name must end with one of: .jpg, .jpeg, .png, .gif, .svg, .webp") -# # if ".." in fn or fn.startswith("/"): -# # msgs.append("file_name must not contain path traversal") -# # if not msgs: -# # return None -# # return { -# # "index": idx, -# # "data": data_dump, -# # "error": { -# # "code": "VALIDATION_ERROR", -# # "message": msgs, -# # }, -# # } - -# # def _extract_base_url(resource) -> Optional[str]: -# # """Extract base_url from resource.meta_data JSON.""" -# # if not resource or not getattr(resource, "meta_data", None): -# # return None - -# # try: -# # raw = resource.meta_data -# # data = raw if isinstance(raw, dict) else json.loads(raw) - -# # base_url = data.get("base_url") -# # if isinstance(base_url, dict): -# # base_url = base_url.get("base_url") - -# # return base_url.rstrip("/") if base_url else None - -# # except (json.JSONDecodeError, TypeError, AttributeError): -# # return None - -# # def _image_url(resource, file_name: str) -> Optional[str]: -# # """Combine base_url and file_name.""" -# # base = _extract_base_url(resource) -# # return f"{base}/{file_name}" if base else None - - - -# # --- CRUD operations --- - -# # def create_infographic_batch( -# # db: Session, -# # payload: schema.BatchInfographicCreateIn, -# # actor_user_id: int -# # ): -# # """Create a batch of infographics.""" -# # resource_id = payload.resource_id - -# # # --- 1. Validate resource --- -# # res = db.query(db_models.Resource).filter_by(resource_id=resource_id).first() -# # if not res: -# # raise NotAvailableException(detail=f"Resource {resource_id} not found") - -# # if (res.content_type or "").lower() != "infographics": -# # raise BadRequestException( -# # detail=( -# # f"Resource {resource_id} is not of type 'infographics'" -# # f" (found '{res.content_type}')" -# # ) -# # ) - -# # # --- 2. Validate book ids --- -# # bk_ids = {i.book_id for i in payload.infographics} -# # valid_books = { -# # b.book_id for b in db.query(db_models.BookLookup) -# # .filter(db_models.BookLookup.book_id.in_(bk_ids)).all() -# # } - -# # created_rows = [] -# # errors = [] - -# # # --- 3. Process each row --- -# # for idx, item in enumerate(payload.infographics): - -# # # Validate title, file extension, filename rules -# # err = validate_item(idx, item) -# # if err: -# # errors.append(err) -# # continue - -# # # Check valid book id -# # if item.book_id not in valid_books: -# # errors.append({ -# # "index": idx, -# # "data": item.model_dump(), -# # "error": { -# # "code": "INVALID_BOOK", -# # "message": f"BookId {item.book_id} not found" -# # } -# # }) -# # continue - -# # # Duplicate check -# # duplicate = db.query(db_models.Infographic).filter( -# # db_models.Infographic.resource_id == resource_id, -# # db_models.Infographic.book_id == item.book_id, -# # db_models.Infographic.title == item.title, -# # db_models.Infographic.file_name == item.file_name, -# # ).first() - -# # if duplicate: -# # errors.append({ -# # "index": idx, -# # "data": item.model_dump(), -# # "error": { -# # "code": "DUPLICATE_ENTRY", -# # "message": "Infographic with same book_id, title, and file_name already exists" -# # } -# # }) -# # continue - -# # # Create row (in-memory only) -# # row = db_models.Infographic( -# # resource_id=resource_id, -# # book_id=item.book_id, -# # title=item.title, -# # file_name=item.file_name, -# # ) - -# # db.add(row) -# # db.flush() - -# # created_rows.append({ -# # "id": row.id, -# # "resource_id": row.resource_id, -# # "book_id": row.book_id, -# # "title": row.title, -# # "file_name": row.file_name, -# # "image_url": _image_url(res, row.file_name), -# # }) - -# # # --- 4. Commit ONCE --- -# # touch_resource(db, resource_id, actor_user_id) -# # db.commit() - -# # return {"created": created_rows}, errors - - -# # def list_infographic_items( -# # db: Session, -# # params: schema.InfographicListParams -# # ) -> Tuple[List[schema.InfographicOut], schema.Pagination, int]: - -# # """List infographic items.""" -# # page = params.page -# # limit = params.limit -# # book_id = params.book_id -# # resource_id = params.resource_id -# # search = params.search - -# # if resource_id is not None: -# # res = db.query(db_models.Resource).filter_by(resource_id=resource_id).first() -# # if not res: -# # raise ValueError("RESOURCE_NOT_FOUND") -# # if (res.content_type or "").lower() != "infographics": -# # raise ValueError("INVALID_RESOURCE_TYPE") - -# # q = db.query(db_models.Infographic) - -# # if book_id: -# # q = q.filter(db_models.Infographic.book_id == book_id) - -# # if resource_id: -# # q = q.filter(db_models.Infographic.resource_id == resource_id) - -# # if search: -# # q = q.filter( -# # sqlalchemy.func.lower(db_models.Infographic.title) -# # .like(f"%{search.lower()}%") -# # ) - -# # total = q.count() -# # items = q.offset((page - 1) * limit).limit(limit).all() - -# # res_map = { -# # r.resource_id: r -# # for r in db.query(db_models.Resource) -# # .filter(db_models.Resource.resource_id.in_({i.resource_id for i in items})) -# # .all() -# # } - -# # data = [ -# # schema.InfographicOut( -# # id=i.id, -# # resource_id=i.resource_id, -# # book_id=i.book_id, -# # title=i.title, -# # file_name=i.file_name, -# # image_url=_image_url(res_map.get(i.resource_id), i.file_name), -# # ) -# # for i in items -# # ] - -# # total_pages = (total + limit - 1) // limit - -# # pagination = schema.Pagination( -# # current_page=page, -# # total_pages=total_pages, -# # total_items=total, -# # items_per_page=limit, -# # has_next=page < total_pages, -# # has_previous=page > 1, -# # ) - -# # return data, pagination, total - - -# # def get_one_infographics( -# # db: Session, -# # infographic_id: int -# # ) -> Optional[schema.InfographicOut]: -# # """ -# # Get single infographic by ID. -# # """ -# # row = db.query(db_models.Infographic).filter_by(id=infographic_id).first() -# # if not row: -# # return None - -# # res = ( -# # db.query(db_models.Resource) -# # .filter_by(resource_id=row.resource_id) -# # .first() -# # ) - -# # return schema.InfographicOut( -# # id=row.id, -# # resource_id=row.resource_id, -# # book_id=row.book_id, -# # title=row.title, -# # file_name=row.file_name, -# # image_url=_image_url(res, row.file_name), -# # ) - - -# # def _validate_resource(db: Session, resource_id: int) -> db_models.Resource: -# # """Validate resource exists and is of type 'infographics'.""" -# # res = db.query(db_models.Resource).filter_by(resource_id=resource_id).first() -# # if not res: -# # raise ValueError("RESOURCE_NOT_FOUND") -# # if (res.content_type or "").lower() != "infographics": -# # raise ValueError("INVALID_RESOURCE_TYPE") -# # return res - -# # def _get_valid_book_ids(db: Session, book_ids: set) -> set: -# # """Return set of valid book IDs from BookLookup.""" -# # if not book_ids: -# # return set() -# # result = db.query(db_models.BookLookup.book_id).filter( -# # db_models.BookLookup.book_id.in_(book_ids) -# # ).all() -# # return {b.book_id for b in result} - -# # def _process_infographic_item( -# # item: schema.InfographicUpdateItem, -# # ctx: schema.InfographicProcessContext -# # ) -> tuple[dict | None, dict | None]: -# # """ -# # Process one infographic item. -# # Returns tuple: (updated_item_dict, error_dict) -# # Only one of the two is non-None. -# # """ -# # try: -# # row = ctx.db.query(db_models.Infographic).filter( -# # db_models.Infographic.id == item.id, -# # db_models.Infographic.resource_id == ctx.resource_id -# # ).with_for_update(nowait=False).first() - -# # if not row: -# # return None, { -# # "index": ctx.idx, -# # "data": item.model_dump(by_alias=True), -# # "error": { -# # "code": "NOT_FOUND", -# # "message": f"Infographic id {item.id} not found for resource {ctx.resource_id}" -# # }, -# # } - -# # tgt_book = item.book_id if item.book_id is not None else row.book_id -# # tgt_title = item.title if item.title is not None else row.title -# # tgt_file = item.file_name if item.file_name is not None else row.file_name - -# # tmp_obj = schema.InfographicUpdateItem( -# # id=item.id, -# # book_id=tgt_book, -# # title=tgt_title, -# # file_name=tgt_file, -# # ) -# # v_err = validate_item(ctx.idx, tmp_obj) -# # if v_err: -# # return None, v_err - -# # if tgt_book not in ctx.valid_books and ctx.valid_books: -# # return None, { -# # "index": ctx.idx, -# # "data": item.model_dump(by_alias=True), -# # "error": {"code": "INVALID_BOOK", "message": f"Invalid book_id {tgt_book}"} -# # } - -# # dup = ctx.db.query(db_models.Infographic).filter( -# # db_models.Infographic.resource_id == ctx.resource_id, -# # db_models.Infographic.book_id == tgt_book, -# # db_models.Infographic.title == tgt_title, -# # db_models.Infographic.file_name == tgt_file, -# # db_models.Infographic.id != row.id -# # ).first() -# # if dup: -# # return None, { -# # "index": ctx.idx, -# # "data": item.model_dump(by_alias=True), -# # "error": {"code": "DUPLICATE_ENTRY", "message": "Duplicate infographic exists"} -# # } - -# # row.book_id = tgt_book -# # row.title = tgt_title -# # row.file_name = tgt_file -# # row.updated_by = ctx.actor_user_id -# # ctx.db.add(row) -# # ctx.db.flush() - -# # updated_item = { -# # "id": row.id, -# # "resource_id": row.resource_id, -# # "book_id": row.book_id, -# # "title": row.title, -# # "file_name": row.file_name, -# # "image_url": _image_url(ctx.res, row.file_name), -# # } -# # return updated_item, None - -# # except (IntegrityError, SQLAlchemyError, ValueError, TypeError) as ex: -# # ctx.db.rollback() -# # return None, { -# # "index": ctx.idx, -# # "data": item.model_dump(by_alias=True), -# # "error": {"code": "INTERNAL_SERVER_ERROR", "message": str(ex)} -# # } - - -# # def update_infographic_batch( -# # db: Session, -# # payload: schema.BatchInfographicUpdateIn, -# # actor_user_id: int -# # ): -# # """ -# # Update infographic batch using ctx pattern. -# # """ -# # resource_id = payload.resource_id - -# # # --- 1. Validate resource --- -# # res = _validate_resource(db, resource_id) - -# # # --- 2. Preload valid book ids --- -# # all_book_ids = {i.book_id for i in payload.infographics if i.book_id is not None} -# # valid_books = _get_valid_book_ids(db, all_book_ids) - -# # updated = [] -# # errors = [] - -# # # --- 3. Process each infographic item --- -# # for idx, item in enumerate(payload.infographics): -# # # Build context object -# # ctx = schema.InfographicProcessContext( -# # db=db, -# # res=res, -# # resource_id=resource_id, -# # valid_books=valid_books, -# # actor_user_id=actor_user_id, -# # idx=idx -# # ) -# # u, e = _process_infographic_item(item, ctx) -# # if u: -# # updated.append(u) -# # if e: -# # errors.append(e) - -# # # --- 4. Commit updates if any --- -# # if updated: -# # try: -# # touch_resource(db, resource_id, actor_user_id) -# # db.commit() -# # except (SQLAlchemyError, ValueError, TypeError) as ex: -# # db.rollback() -# # # convert all updated rows into errors -# # for idx, item in enumerate(updated): -# # errors.append({ -# # "index": idx, -# # "data": item, -# # "error": {"code": "INTERNAL_SERVER_ERROR", "message": str(ex)} -# # }) -# # updated = [] - -# # # --- 5. Return normalized shape --- -# # return {"updated": updated, "errors": errors} - -# # def delete_bulk(db: Session, ids: List[int]) -> List[int]: -# # """ -# # Delete multiple infographics by IDs. -# # """ -# # rows = db.query(db_models.Infographic).filter(db_models.Infographic.id.in_(ids)).all() -# # if not rows: -# # return [] -# # deleted = [r.id for r in rows] -# # for r in rows: -# # db.delete(r) -# # db.commit() -# # return deleted - -# # def delete_bulk_details(db: Session, ids: List[int]) -> dict: -# # """Delete infographics in bulk, return response with deleted & invalid IDs.""" -# # deleted_ids = [] -# # invalid_ids = [] - -# # # fetch existing rows -# # rows = db.query(db_models.Infographic).filter(db_models.Infographic.id.in_(ids)).all() -# # existing_ids = {r.id for r in rows} - -# # # delete existing rows -# # for r in rows: -# # db.delete(r) -# # deleted_ids.append(r.id) -# # db.commit() - -# # # collect invalid / not found IDs -# # invalid_ids = [i for i in ids if i not in existing_ids] - -# # response = { -# # "deletedCount": len(deleted_ids), -# # "deletedIds": deleted_ids, -# # "message": f"Successfully deleted {len(deleted_ids)} infographic(s)" -# # } - -# # if invalid_ids: -# # response["error"] = f"Invalid infographic_ids: {', '.join(map(str, invalid_ids))}" - -# # return response - - - -# # verse of the day CRUD functions - - - - -# # def get_all_verse_of_the_day(db_session: Session): -# # """ -# # Fetch all verses from the VerseOfTheDay table. -# # Return year, month, date, book_code, chapter, verse, id from DB. -# # """ -# # verses = ( -# # db_session.query(db_models.VerseOfTheDay) -# # .order_by(db_models.VerseOfTheDay.year, -# # db_models.VerseOfTheDay.month, -# # db_models.VerseOfTheDay.day) -# # .all() -# # ) - -# # verse_list = [ -# # { -# # "id": str(v.id), -# # "year": v.year, -# # "month": v.month, -# # "date": v.day, -# # "book_code": v.book_code, -# # "chapter": v.chapter, -# # "verse": v.verse -# # } -# # for v in verses -# # ] -# # return { -# # "success": True, -# # "data": { -# # "verses": verse_list, -# # "total": len(verse_list) -# # } -# # } - - -# # def get_verse_for_date(db_session: Session, year: int, month: int, day: int): -# # """ -# # Get one verse (with id) for a specific date from VerseOfTheDay table. -# # """ -# # verse_entry = ( -# # db_session.query(db_models.VerseOfTheDay) -# # .filter_by(year=year, month=month, day=day) -# # .first() -# # ) - -# # if not verse_entry: -# # raise NotAvailableException(detail=f"No verse found for {year}-{month}-{day}") - -# # return { -# # "success": True, -# # "data": { -# # "id": str(verse_entry.id), -# # "year": verse_entry.year, -# # "month": verse_entry.month, -# # "day": verse_entry.day, -# # "book_code": verse_entry.book_code, -# # "chapter": verse_entry.chapter, -# # "verse": verse_entry.verse -# # } -# # } - - -# # def upload_verse_of_the_day_csv(db_session: Session, file: UploadFile): -# # """ -# # Deletes all old entries and uploads CSV for Verse Of The Day. -# # Returns 200, 207, or 400 depending on outcome. -# # """ - -# # # --- Step 1: Read + Validate CSV --- -# # reader = _read_votd_csv(file) - -# # # --- Step 2: Delete old records + Reset sequence --- -# # deleted_count = _reset_votd_table(db_session) - -# # created_count = 0 -# # failed_count = 0 -# # errors = [] - -# # # --- Step 3: Process CSV rows --- -# # for row_num, row in enumerate(reader, start=2): -# # try: -# # parsed = _parse_votd_row(row) -# # _validate_votd_row(db_session, parsed) - -# # new_entry = db_models.VerseOfTheDay(**parsed) -# # db_session.add(new_entry) -# # created_count += 1 - -# # except (ValueError, KeyError, IntegrityError) as e: -# # failed_count += 1 -# # errors.append({"row": row_num, "reason": str(e)}) - -# # db_session.commit() - -# # # --- Step 4: Build Response --- -# # return _build_votd_response( -# # deleted_count=deleted_count, -# # created_count=created_count, -# # failed_count=failed_count, -# # errors=errors -# # ) -# # def _read_votd_csv(file: UploadFile): -# # try: -# # content = file.file.read().decode("utf-8") -# # reader = csv.DictReader(StringIO(content)) - -# # required = {"year", "month", "date", "book_code", "chapter", "verse"} -# # if not reader.fieldnames or not required.issubset(set(reader.fieldnames)): -# # raise ValueError("Invalid CSV file format or missing required columns") - -# # return reader - -# # except Exception as exc: -# # raise TypeException( -# # detail={ -# # "success": False, -# # "error": { -# # "code": "INVALID_FILE", -# # "message": "Could not parse the CSV file" -# # } -# # } -# # ) from exc - -# # def _reset_votd_table(db_session: Session): -# # deleted = db_session.query(db_models.VerseOfTheDay).delete() -# # db_session.commit() - -# # seq = db_session.execute( -# # text("SELECT pg_get_serial_sequence('verse_of_the_day', 'id');") -# # ).scalar() - -# # if seq: -# # db_session.execute(text(f"ALTER SEQUENCE {seq} RESTART WITH 1;")) -# # db_session.commit() - -# # return deleted -# # def _parse_votd_row(row: dict) -> dict: -# # try: -# # return { -# # "year": int(row["year"]), -# # "month": int(row["month"]), -# # "day": int(row["date"]), -# # "book_code": row["book_code"].strip().lower(), -# # "chapter": int(row["chapter"]), -# # "verse": int(row["verse"]), -# # } -# # except Exception as ex: -# # raise ValueError(f"Invalid row values: {ex}") from ex - -# # def _validate_votd_row(db_session: Session, parsed: dict): -# # if not 1 <= parsed["month"] <= 12: -# # raise ValueError(f"Invalid month: {parsed['month']}") - -# # if not 1 <= parsed["day"] <= 31: -# # raise ValueError(f"Invalid date: {parsed['day']}") - -# # book_exists = db_session.query(db_models.BookLookup).filter( -# # db_models.BookLookup.book_code.ilike(parsed["book_code"]) -# # ).first() - -# # if not book_exists: -# # raise ValueError( -# # f"Invalid book code: '{parsed['book_code']}' not found in book_lookup table") -# # def _build_votd_response(deleted_count, created_count, failed_count, errors): -# # if failed_count == 0: -# # return { -# # "success": True, -# # "data": { -# # "deleted_count": deleted_count, -# # "created_count": created_count, -# # "failed_count": 0, -# # "errors": [], -# # }, -# # "message": "CSV uploaded. Old entries cleared and new ones created.", -# # } - -# # if created_count > 0: -# # raise MultiStatus( -# # detail={ -# # "success": False, -# # "data": { -# # "deleted_count": deleted_count, -# # "created_count": created_count, -# # "failed_count": failed_count, -# # "errors": errors, -# # }, -# # "message": "CSV uploaded with some errors. Check errors array for details.", -# # }, -# # ) - -# # raise UnprocessableException( -# # detail={ -# # "success": False, -# # "error": { -# # "code": "INVALID_DATA", -# # "message": "All rows in CSV invalid or could not be processed", -# # "errors": errors, -# # }, -# # }, -# # ) - - - -# # def delete_all_verse_of_the_day(db_session: Session): -# # """ -# # Deletes all entries from verse_of_the_day table. -# # """ -# # try: -# # deleted_count = db_session.query(db_models.VerseOfTheDay).delete() -# # db_session.commit() -# # # Reset sequence dynamically -# # seq_name = db_session.execute( -# # text("SELECT pg_get_serial_sequence('verse_of_the_day', 'id');") -# # ).scalar() - -# # if seq_name: -# # db_session.execute(text(f"ALTER SEQUENCE {seq_name} RESTART WITH 1;")) -# # db_session.commit() -# # return { -# # "success": True, -# # "data": { -# # "deleted_count": deleted_count -# # }, -# # "message": "All verse of the day entries deleted successfully" -# # } -# # except SQLAlchemyError as e: -# # db_session.rollback() -# # raise GenericException( -# # detail={ -# # "success": False, -# # "error": { -# # "code": "INTERNAL_ERROR", -# # "message": "Failed to delete entries" -# # } -# # }, -# # ) from e - - -# # # --- Reading plan CRUD --- -# # def parse_json_file(content: bytes) -> List[Dict[str, Any]]: -# # """ -# # Parse a JSON file and return a list of entries.""" -# # try: -# # raw = json.loads(content.decode("utf-8")) -# # except json.JSONDecodeError as exc: -# # raise TypeException( -# # detail=f"Invalid JSON: {str(exc)}", -# # ) from exc - -# # if not isinstance(raw, list): -# # raise TypeException( -# # detail="Invalid JSON: expected list of entries", -# # ) - -# # if not raw: -# # raise TypeException( -# # detail="JSON file is empty", -# # ) - -# # return raw - - -# # def parse_csv_file(content: bytes) -> List[Dict[str, Any]]: -# # """ -# # Parse a CSV file and return a list of entries.""" -# # try: -# # csv_text = content.decode("utf-8") -# # except UnicodeDecodeError as exc: -# # raise TypeException( -# # detail="File must be UTF-8 encoded", -# # ) from exc - -# # rows = [] -# # reader = csv.DictReader(io.StringIO(csv_text)) - -# # for row in reader: -# # if "date" not in row or "reading" not in row: -# # raise BadRequestException( -# # detail="CSV must contain 'date' and 'reading' columns", -# # ) - -# # try: -# # readings = ( -# # json.loads(row["reading"]) -# # if isinstance(row["reading"], str) -# # else row["reading"] -# # ) -# # except json.JSONDecodeError as exc: -# # raise TypeException( -# # detail=f"Invalid JSON in reading field for date {row.get('date')}", -# # ) from exc - -# # rows.append({"date": row["date"], "reading": readings}) - -# # if not rows: -# # raise TypeException( -# # detail="CSV contains no valid rows", -# # ) - -# # return rows - - -# # def validate_and_process_entry(entry, index, db, counts): -# # """ -# # Validate and process a single entry from the input file.""" -# # created, updated, skipped = counts - -# # if not isinstance(entry, dict): -# # logger.warning("Entry %s is not a dict; skipping", index) -# # return (created, updated, skipped + 1) - -# # date_str = entry.get("date") -# # readings = entry.get("reading") - -# # if not date_str or not isinstance(readings, list) or not readings: -# # logger.warning("Invalid entry at index %s; skipping", index) -# # return (created, updated, skipped + 1) - -# # try: -# # month, day = map(int, date_str.split("-")) -# # datetime(2024, month, day) -# # except (ValueError, TypeError): -# # logger.warning("Invalid date format at index %s: %s", index, date_str) -# # return (created, updated, skipped + 1) - -# # existing = ( -# # db.query(db_models.ReadingPlan) -# # .filter_by(month=month, day=day) -# # .first() -# # ) - -# # if existing: -# # existing.readings = readings -# # updated += 1 -# # else: -# # db.add(db_models.ReadingPlan(month=month, day=day, readings=readings)) -# # created += 1 - -# # return (created, updated, skipped) -# # def upload_reading_plans( -# # db: Session, -# # file_content: bytes, -# # file_type: str -# # ) -> Dict[str, int]: -# # """ -# # Upload reading plans from JSON or CSV file.""" -# # try: -# # # Parse input file -# # if file_type == "json": -# # data = parse_json_file(file_content) -# # elif file_type == "csv": -# # data = parse_csv_file(file_content) -# # else: -# # raise TypeException( -# # detail="Unsupported file type; must be JSON or CSV", -# # ) - -# # counts = (0, 0, 0) # created, updated, skipped - -# # # Process each entry -# # for idx, entry in enumerate(data): -# # counts = validate_and_process_entry(entry, idx, db, counts) - -# # created_count, updated_count, skipped_count = counts - -# # # Error if no valid entries -# # if created_count == 0 and updated_count == 0: -# # if skipped_count > 0: -# # raise TypeException( -# # detail=( -# # f"All {skipped_count} entries were invalid. " -# # "Expected format: {'date': 'MM-DD', 'reading': [...]}." -# # ), -# # ) -# # raise TypeException( -# # detail="No valid entries found", -# # ) - -# # db.commit() - -# # return { -# # "created": created_count, -# # "updated": updated_count, -# # "skipped": skipped_count, -# # "total": created_count + updated_count, -# # } - -# # except HTTPException: -# # raise -# # except Exception as exc: -# # db.rollback() -# # raise GenericException( -# # detail=f"Unexpected error: {str(exc)}", -# # ) from exc - -# # def get_reading_plans( -# # db: Session, -# # month: Optional[int] = None, -# # day: Optional[int] = None -# # ) -> List[db_models.ReadingPlan]: -# # """ -# # Get reading plans. If month and day are provided, return specific date. -# # Otherwise, return all reading plans. -# # """ -# # query = db.query(db_models.ReadingPlan) - -# # if month is not None and day is not None: -# # # Validate date -# # try: -# # datetime(2024, month, day) -# # except ValueError as exc: -# # raise TypeException( -# # detail=f"Invalid date: month={month}, day={day}" -# # ) from exc - - -# # query = query.filter_by(month=month, day=day) -# # result = query.first() - -# # if not result: -# # raise NotAvailableException( -# # detail=f"No reading plan found for {month:02d}-{day:02d}" -# # ) - -# # return [result] - -# # # Return all plans ordered by month and day -# # return query.order_by( -# # db_models.ReadingPlan.month, -# # db_models.ReadingPlan.day -# # ).all() - - -# # def delete_all_reading_plans(db: Session) -> int: -# # """ -# # Delete all reading plans from the database. -# # Returns the count of deleted records. -# # """ -# # try: -# # count = db.query(db_models.ReadingPlan).count() -# # db.query(db_models.ReadingPlan).delete() -# # db.commit() -# # return count -# # except Exception as e: -# # db.rollback() -# # logger.error("Error deleting reading plans: %s", e) -# # raise GenericException( -# # detail=f"Error deleting reading plans: {str(e)}" -# # ) from e - - -# #validate_html for commentaries -# # def validate_html(html_text: str): -# # """ -# # Validates commentary HTML with strict rules: -# # - No unclosed tags -# # - No broken tags like -# # - No missing closing ,

, etc. -# # - Only allowed tags are permitted -# # """ -# # if not html_text or not html_text.strip(): -# # return - -# # if "<" not in html_text and ">" not in html_text: -# # raise UnprocessableException( -# # detail="no html tags found" -# # ) - -# # allowed_tags = {"p", "strong", "img", "br", "sup", "em", "b", "i", "u"} -# # void_tags = {"br", "img", "hr", "meta", "link", "input"} - -# # tag_pattern = re.compile(r"]*>") - -# # # ---- Helper function for stack-based tag validation ---- -# # def _check_tag_stack(content: str): -# # stack = [] -# # for match in tag_pattern.finditer(content): -# # tag = match.group(1).lower() -# # full_tag = match.group(0) - -# # if tag in void_tags: -# # continue - -# # if full_tag.startswith(" is mismatched or missing opener" -# # ) -# # stack.pop() -# # else: -# # stack.append(tag) - -# # if stack: -# # raise UnprocessableException( -# # detail=f"Unclosed tag(s): {stack}" -# # ) - -# # # ---- Validate each tag individually ---- -# # for match in tag_pattern.finditer(html_text): -# # tag = match.group(1).lower() -# # full_tag = match.group(0) - -# # if tag not in allowed_tags: -# # raise UnprocessableException( -# # detail=f"Invalid HTML tag '{full_tag}'" -# # ) - -# # if full_tag.startswith(f"<{tag}") and not re.match(rf" str: -# # """Convert GitHub tree/blob URLs into raw.githubusercontent.com URLs.""" -# # if not url: -# # return None - -# # if "github.com" in url: -# # url = url.replace("github.com", "raw.githubusercontent.com") -# # url = url.replace("/tree/", "/") -# # url = url.replace("/blob/", "/") - -# # return url.rstrip("/") - -# # # Extract base_url from resource.meta_data -# # def extract_base_url(resource): -# # """Extract base_url from resource.meta_data JSON.""" -# # try: -# # raw = resource.meta_data -# # data = raw if isinstance(raw, dict) else json.loads(raw) - -# # base = None - -# # # meta_data can have nested structure -# # if isinstance(data.get("base_url"), dict): -# # base = data["base_url"].get("base_url") -# # else: -# # base = data.get("base_url") - -# # if not base: -# # raise ValueError("base_url missing") - - -# # base = convert_to_raw_url(base) -# # return base - -# # except (ValueError, TypeError): -# # return None -# # async def check_infographics_by_language(db: Session, language_code: str): -# # """ -# # For a given language_code: -# # - Collect ALL resources of type 'infographics' -# # - For each resource: -# # - If base_url is missing → mark ALL its infographic entries as missing -# # - If base_url exists → perform URL HEAD checks for each file -# # """ - -# # # Fetch ALL infographic resources for this language -# # resources = ( -# # db.query(db_models.Resource) -# # .join(db_models.Language) -# # .filter( -# # db_models.Language.language_code == language_code, -# # db_models.Resource.content_type == "infographics" -# # ) -# # .all() -# # ) - -# # if not resources: -# # raise NotAvailableException( -# # detail="No infographic resource found for this language" -# # ) - -# # missing_list = [] -# # total_checked = 0 - -# # async with httpx.AsyncClient(timeout=10.0) as client: - -# # for resource in resources: - -# # # Get all infographic entries for this resource -# # entries = ( -# # db.query(db_models.Infographic) -# # .filter(db_models.Infographic.resource_id == resource.resource_id) -# # .all() -# # ) - -# # total_checked += len(entries) - -# # # CASE 1: NO BASE_URL -# # base_url = extract_base_url(resource) - -# # if not base_url: -# # for e in entries: -# # missing_list.append( -# # schema.MissingInfographic( -# # id=e.id, -# # resource_id=e.resource_id, -# # file_name=e.file_name, -# # missing_full=True, -# # missing_thumb=True, -# # reason="base_url not found in resource metadata" -# # ) -# # ) -# # continue # move to next resource - -# # # CASE 2: BASE_URL EXISTS → check remote URLs -# # async def check_file(entry, base_url=base_url): -# # file_path = entry.file_name -# # full_url = f"{base_url}/{file_path}" -# # thumb_url = f"{base_url}/thumbs/{file_path}" - - -# # try: -# # full_head = await client.head(full_url) -# # except httpx.RequestError: -# # full_head = httpx.Response(status_code=404) - -# # try: -# # thumb_head = await client.head(thumb_url) -# # except httpx.RequestError: -# # thumb_head = httpx.Response(status_code=404) - -# # missing_full = full_head.status_code != 200 -# # missing_thumb = thumb_head.status_code != 200 - -# # if missing_full or missing_thumb: -# # missing_list.append( -# # schema.MissingInfographic( -# # id=entry.id, -# # resource_id=entry.resource_id, -# # file_name=entry.file_name, -# # missing_full=missing_full, -# # missing_thumb=missing_thumb, -# # reason=None -# # ) -# # ) - -# # await asyncio.gather(*(check_file(e) for e in entries)) - -# # # Final combined response -# # return schema.InfographicCheckResponse( -# # success=True, -# # language_code=language_code, -# # checked=total_checked, -# # missing=missing_list -# # ) - -# # def get_audit_logs( -# # db_session: Session, -# # page: int = 0, -# # page_size: int = 100, -# # **filters -# # ) -> tuple[list[db_models.AuditLog], int]: -# # """ -# # Retrieve audit logs with optional filtering and pagination. - -# # Filters supported via kwargs: -# # - user_id: int -# # - method: str -# # - path: str (partial match) -# # - status_code: str or int -# # - date_from: datetime -# # - date_to: datetime - -# # Returns: -# # tuple of (list of AuditLog rows, total count) -# # """ -# # query = db_session.query(db_models.AuditLog) - -# # user_id = filters.get("user_id") -# # method = filters.get("method") -# # path = filters.get("path") -# # status_code = filters.get("status_code") -# # date_from = filters.get("date_from") -# # date_to = filters.get("date_to") - -# # if user_id is not None: -# # query = query.filter(db_models.AuditLog.user_id == user_id) -# # if method: -# # query = query.filter(db_models.AuditLog.method == method.upper()) -# # if path: -# # query = query.filter(db_models.AuditLog.path.ilike(f"%{path}%")) -# # if status_code: -# # query = query.filter(db_models.AuditLog.status_code == int(status_code)) -# # if date_from is not None: -# # query = query.filter(db_models.AuditLog.created_at >= date_from) -# # if date_to is not None: -# # query = query.filter(db_models.AuditLog.created_at <= date_to) - -# # total = query.count() - -# # rows = ( -# # query.order_by(db_models.AuditLog.created_at.desc()) -# # .offset(page * page_size) -# # .limit(page_size) -# # .all() -# # ) - -# # return rows, total - - -# # def validate_video_item(db, item): -# # """ -# # Validate a single video item.""" -# # errors = [] - -# # book_val = (item.book or "").strip().lower() - -# # if book_val in ("ot", "nt"): -# # # chapter is allowed to be anything (or None) -# # if item.chapter is not None and item.chapter > 175: -# # errors.append(f"Invalid chapter {item.chapter} for book {item.book}.For OT/NT, chapter cannot be greater than 175.") -# # return errors - -# # # Check against book_code or book_name (case-insensitive) -# # book_obj = ( -# # db.query(db_models.BookLookup) -# # .filter( -# # (db_models.BookLookup.book_code.ilike(book_val)) | -# # (db_models.BookLookup.book_name.ilike(book_val)) -# # ) -# # .first() -# # ) - -# # if not book_obj: -# # errors.append( -# # f"Invalid book '{item.book}'. Must be OT, NT, or a valid Bible book " -# # f"(book_code or book_name)." -# # ) -# # return errors - -# # # --- 3) Chapter validation --- -# # if item.chapter is not None: -# # try: -# # chapter_int = int(item.chapter) -# # except ValueError: -# # errors.append("chapter must be a number") -# # return errors - -# # if chapter_int < 0: -# # errors.append("chapter must be ≥ 0") -# # elif chapter_int > book_obj.chapter_count: -# # errors.append( -# # f"chapter {chapter_int} exceeds max chapters " -# # f"({book_obj.chapter_count}) for '{book_obj.book_name}'." -# # ) - -# # return errors -# # def test_commentary_images(db, resource_id: int): -# # """ -# # Test if all commentary images exist. -# # """ - -# # # 1. Fetch resource -# # res = db.query(db_models.Resource).filter_by(resource_id=resource_id).first() -# # if not res: -# # raise NotAvailableException(detail="Resource not found") - -# # base_url = extract_base_url(res) -# # if not base_url: -# # raise UnprocessableException(detail="Base URL missing in resource metadata") - -# # # Helper: extract all image filenames -# # def collect_filenames(): -# # filenames = set() -# # for row in ( -# # db.query(db_models.Commentary) -# # .filter(db_models.Commentary.resource_id == resource_id) -# # .all() -# # ): -# # soup = BeautifulSoup(row.text, "html.parser") -# # for img in soup.find_all("img"): -# # src = img.get("src", "") -# # fname = ( -# # src.split("/")[-1] -# # if src.startswith(("http://", "https://")) -# # else src.strip() -# # ) -# # filenames.add((row.book_id, row.chapter, row.verse, fname)) -# # return filenames - -# # # Helper: remote check -# # def remote_exists(url: str) -> bool: -# # try: -# # response = requests.head(url, timeout=5) -# # return response.status_code == 200 -# # except requests.RequestException: -# # return False - -# # all_filenames = collect_filenames() - -# # report = [] -# # for book_id, chapter, verse, fname in all_filenames: -# # url = f"{base_url}/{fname}" -# # exists = remote_exists(url) -# # report.append( -# # { -# # "bookId": book_id, -# # "chapter": chapter, -# # "verse": verse, -# # "file_name": fname, -# # "present": exists, -# # } -# # ) - -# # images_present = sum(1 for item in report if item["present"]) - -# # return { -# # "success": True, -# # "resource_id": resource_id, -# # "base_url": base_url, -# # "total_images": len(all_filenames), -# # "images_present": images_present, -# # "images": report, -# # } - -# # def validate_commentary_book_and_chapter(db: Session, book_id: int, chapter: int): -# # """ -# # Validates that: -# # - book_id exists in BookLookup table -# # - chapter does NOT exceed chapter_count for that book -# # """ - -# # # 1. Check book exists -# # book = ( -# # db.query(db_models.BookLookup) -# # .filter(db_models.BookLookup.book_id == book_id) -# # .first() -# # ) - -# # if not book: -# # raise NotAvailableException( -# # detail=f"Invalid book_id {book_id}. Book does not exist in BookLookup." -# # ) - -# # if chapter < 0: -# # raise BadRequestException( -# # detail=f"Chapter must be >= 0 for book_id {book_id}." -# # ) - -# # if chapter > book.chapter_count: -# # raise BadRequestException( -# # detail=( -# # f"Invalid chapter {chapter} for book_id {book_id}. " -# # f"Max allowed chapter is {book.chapter_count}." -# # ) -# # ) - -# # def validate_audio_bible_books(db, books: dict): -# # """ -# # Validate Audio Bible books: -# # - book_code must exist in BookLookup (case-insensitive) -# # - chapter count must be <= chapter_count in BookLookup -# # """ - -# # errors = [] - -# # for book_code, chapter_count in books.items(): -# # bc = book_code.strip().lower() - -# # # --- 1. Check if book code exists in BookLookup --- -# # book_obj = ( -# # db.query(db_models.BookLookup) -# # .filter(db_models.BookLookup.book_code.ilike(bc)) -# # .first() -# # ) - -# # if not book_obj: -# # errors.append( -# # f"Invalid book code '{book_code}'. Must match book_code in BookLookup table." -# # ) -# # continue - -# # # --- 2. Validate chapter_count is integer --- -# # if not isinstance(chapter_count, int) or chapter_count <= 0: -# # errors.append( -# # f"Chapter count for '{book_code}' must be a positive integer." -# # ) -# # continue - -# # # --- 3. Validate chapter_count does not exceed Bible max --- -# # if chapter_count > book_obj.chapter_count: -# # errors.append( -# # f"Chapter count {chapter_count} exceeds max chapters " -# # f"({book_obj.chapter_count}) for '{book_obj.book_code}'." -# # ) - -# # return errors -# # def check_audio_bible_remote(db, resource_id: int): -# # """Checks remote DigitalOcean Spaces for missing audio files for a given Audio Bible.""" - -# # ab = db.query(db_models.AudioBible).filter_by(resource_id=resource_id).first() -# # if not ab: -# # return {"error": f"Audio Bible not found for resource_id {resource_id}"} - -# # base = ab.base_url.rstrip("/") -# # books = ab.books -# # fmt = ab.format - -# # results = [] -# # books_found = len(books) -# # full_books_present = 0 -# # all_missing_files = {} - -# # for book_code, total_chapters in books.items(): -# # missing = [] -# # present = 0 - -# # for chap in range(1, total_chapters + 1): -# # url = f"{base}/{book_code}/{chap}.{fmt}" - -# # # --- Step 1: First GET attempt --- -# # file_exists = False -# # try: -# # r = requests.get(url, timeout=10, stream=True) -# # if r.status_code == 200: -# # file_exists = True -# # except: -# # pass - -# # # --- Step 2: Retry GET once if failed --- -# # if not file_exists: -# # try: -# # time.sleep(0.15) # 150 ms CDN warm-up -# # r = requests.get(url, timeout=10, stream=True) -# # if r.status_code == 200: -# # file_exists = True -# # except: -# # pass - -# # # --- Step 3: Fallback to HEAD request --- -# # if not file_exists: -# # try: -# # h = requests.head(url, timeout=10, allow_redirects=True) -# # if h.status_code == 200: -# # file_exists = True -# # except: -# # pass - -# # # --- Final Result --- -# # if file_exists: -# # present += 1 -# # else: -# # missing.append(chap) - -# # if len(missing) == 0: -# # full_books_present += 1 - -# # results.append({ -# # "book": book_code, -# # "total_chapters": total_chapters, -# # "present": present, -# # "missing_chapters": missing -# # }) - -# # if missing: -# # all_missing_files[book_code] = missing - -# # # Save results -# # ab.files_missing = all_missing_files -# # ab.test_date = datetime.now(timezone.utc) -# # db.commit() -# # return { -# # "success": True, -# # "resource_id": resource_id, -# # "books_found": books_found, -# # "full_books_present": full_books_present, -# # "base_url": base, -# # "test_date": ab.test_date.isoformat(), -# # "audio_files": results -# # } -# # def _is_public_url(url: str) -> bool: -# # try: -# # r = requests.get(url, timeout=8, allow_redirects=True) - -# # # If non-YouTube URL → fallback logic -# # if "youtube.com" not in r.url and "youtu.be" not in url: -# # return r.status_code < 400 - -# # # ---- YouTube-specific validation ---- - -# # html = r.text.lower() - -# # # YouTube invalid-video markers -# # if "video unavailable" in html or "this video is unavailable" in html: -# # return False - -# # # If YouTube returns a watch page but without player → invalid -# # if "player-unavailable" in html: -# # return False - -# # # If YouTube returns 410, 404, 429 etc. -# # if r.status_code >= 400: -# # return False - -# # return True - -# # except requests.RequestException: -# # return False - -# return { -# "success": True, -# "resource_id": resource_id, -# "videos_found": len(videos), -# "videos_public": public_count, -# "videos": out_items -# } -# def validate_usfm_file(file: UploadFile) -> Dict[str, Any]: -# """ -# Validates USFM file structure and returns validation result. -# Returns dict with 'valid' (bool) and optional 'error' (str) keys. -# """ -# try: -# # 1. VALIDATE FILE EXTENSION (NEW) -# if not file.filename: -# raise HTTPException(status_code=422, detail="No filename provided") -# filename_lower = file.filename.lower() -# ACCEPTED_EXTENSIONS = ('.usfm', '.sfm') -# if not filename_lower.endswith(ACCEPTED_EXTENSIONS): -# raise HTTPException(status_code=422, detail=f"Invalid file type. Expected .usfm or .sfm file, got '{file.filename}'. Please upload a valid USFM file.") - -# # 2. READ FILE CONTENT -# content = file.file.read() - -# # 3. CHECK FILE SIZE (NEW - prevent huge files) -# max_size = 10 * 1024 * 1024 # 10 MB -# if len(content) > max_size: -# file.file.seek(0) -# raise HTTPException(status_code=422, detail=f"File too large ({len(content)} bytes). Maximum allowed: {max_size} bytes") - -# # 4. RESET FILE POINTER -# file.file.seek(0) - -# # 5. DECODE CONTENT (with better error handling) -# try: -# usfm_content = content.decode('utf-8') -# except UnicodeDecodeError as e: -# raise HTTPException(status_code=422, detail="File encoding error. USFM files must be UTF-8 encoded plain text. This may be a binary file or use unsupported encoding.") - -# # 6. CHECK FOR EMPTY/WHITESPACE-ONLY FILES -# if not usfm_content.strip(): -# raise HTTPException(status_code=422, detail="USFM file is empty or contains only whitespace") - -# # 7. CHECK FOR BINARY CONTENT (NEW - detect non-text files) -# # Look for null bytes or excessive control characters -# null_count = usfm_content.count('\x00') -# if null_count > 0: -# raise HTTPException(status_code=422, detail="File appears to be binary, not text. USFM files must be plain text files.") - -# # 8. CHECK FOR REQUIRED \id MARKER -# if '\\id' not in usfm_content: -# raise HTTPException(status_code=422, detail="Not a valid USFM file. Missing required \\id marker. Please ensure this is a properly formatted USFM file.") - -# # 9. PARSE WITH USFM-GRAMMAR -# try: -# parser = USFMParser(usfm_content) -# usj_data = parser.to_usj() - -# # Verify USJ structure -# if not isinstance(usj_data, dict) or 'content' not in usj_data: -# raise HTTPException(status_code=422, detail="Invalid USFM structure: Cannot parse to valid USJ format") - -# # Check for book code -# book_code = None -# for item in usj_data.get("content", []): -# if item.get("type") == "book" and item.get("marker") == "id": -# book_code = item.get("code") -# break - -# if not book_code: -# raise HTTPException(status_code=422, detail="USFM file must contain a valid book code in \\id marker") - -# # Check for chapters -# chapter_count = len([ -# item for item in usj_data.get("content", []) -# if item.get("type") == "chapter" -# ]) - -# if chapter_count == 0: -# raise HTTPException(status_code=422, detail="USFM file must contain at least one chapter (\\c marker)") - -# return { -# "valid": True, -# "book_code": book_code, -# "chapter_count": chapter_count -# } -# except HTTPException: -# raise -# except Exception as parse_error: -# raise HTTPException(status_code=422, detail=f"USFM parsing error: {str(parse_error)}. Please ensure this is a valid USFM file.") - -# except HTTPException: -# raise -# except Exception as e: -# raise HTTPException(status_code=422, detail=f"File validation error: {str(e)}") - -# def _resolve_book_id(db, book_code: str): -# """Return BookLookup row (object) for given book_code (case-insensitive) or raise 422.""" -# if not book_code: -# raise HTTPException(status_code=422, detail="book code is required") - -# book = ( -# db.query(db_models.BookLookup) -# .filter(db_models.BookLookup.book_code.ilike(book_code)) -# .first() -# ) -# if not book: -# raise HTTPException(status_code=422, detail=f"Invalid book code '{book_code}'") -# return book - -# def create_isl_videos(db, payload: schema.IslVideoCreateRequest): -# resource = ( -# db.query(db_models.Resource) -# .filter(db_models.Resource.resource_id == payload.resourceId) -# .first() -# ) -# if not resource: -# raise NotAvailableException(detail=f"Resource {payload.resourceId} not found") -# # Resource content_type must be 'isl' -# if resource.content_type.lower() != "isl": -# raise BadRequestException( -# detail=f"Resource {payload.resourceId} is not of type 'video' (found '{resource.content_type}')" -# ) -# created: List[schema.IslVideoResponseItem] = [] - -# for item in payload.videos: -# # resolve book -# book_obj = _resolve_book_id(db, item.book) -# # chapter validation: allow 0 .. chapter_count -# if item.chapter < 0 or item.chapter > book_obj.chapter_count: -# raise HTTPException( -# status_code=422, -# detail=f"Invalid chapter {item.chapter} for book '{item.book}'. Allowed 0..{book_obj.chapter_count}", -# ) - -# # check duplicate in DB -# if item.title: -# dup = ( -# db.query(db_models.IslVideo) -# .filter_by(resource_id=payload.resourceId, book_id=book_obj.book_id, title=item.title) -# .first() -# ) -# if dup: -# raise HTTPException( -# status_code=409, -# detail=f"Duplicate entry for resource_id={payload.resourceId}, book={item.book}, title={item.title}", -# ) - -# row = db_models.IslVideo( -# resource_id=payload.resourceId, -# book_id=book_obj.book_id, -# chapter=item.chapter, -# url=item.url, -# title=item.title, -# description=item.description, -# ) -# db.add(row) -# db.flush() - -# created.append(schema.IslVideoResponseItem( -# video_id=row.id, -# book=book_obj.book_code, -# chapter=row.chapter, -# url=row.url, -# title=row.title, -# description=row.description -# )) - -# try: -# db.commit() -# except SQLAlchemyError as exc: -# db.rollback() -# raise HTTPException(status_code=500, detail=str(exc)) - -# return {"resource_id": payload.resourceId, "videos": created} - -# # def _resolve_book_id(db, book_code: str): -# # """Return BookLookup row (object) for given book_code (case-insensitive) or raise 422.""" -# # if not book_code: -# # raise UnprocessableException( -# # detail="book code is required") -# # book = ( -# # db.query(db_models.BookLookup) -# # .filter(db_models.BookLookup.book_code.ilike(book_code)) -# # .first() -# # ) -# # if not book: -# # raise UnprocessableException( -# # detail=f"Invalid book code '{book_code}'") -# # return book - -# def update_isl_videos(db, payload: schema.IslVideoUpdateRequest): -# resource = ( -# db.query(db_models.Resource) -# .filter(db_models.Resource.resource_id == payload.resourceId) -# .first() -# ) -# if not resource: -# raise NotAvailableException(detail=f"Resource {payload.resourceId} not found") -# # Resource content_type must be 'isl' -# if resource.content_type.lower() != "isl": -# raise BadRequestException( -# detail=f"Resource {payload.resourceId} is not of type 'video' (found '{resource.content_type}')" -# ) -# updated: List[schema.IslVideoResponseItem] = [] - -# for item in payload.videos: -# row = db.query(db_models.IslVideo).filter_by(id=item.id).first() -# if not row: -# raise HTTPException(status_code=404, detail=f"ISL Video id {item.id} not found") - -# book_obj = _resolve_book_id(db, item.book) - -# # chapter validation: allow 0 .. chapter_count -# if item.chapter < 0 or item.chapter > book_obj.chapter_count: -# raise HTTPException( -# status_code=422, -# detail=f"Invalid chapter {item.chapter} for book '{item.book}'. Allowed 0..{book_obj.chapter_count}", -# ) - -# # uniqueness check against other rows -# if item.title: -# conflict = ( -# db.query(db_models.IslVideo) -# .filter( -# db_models.IslVideo.resource_id == payload.resourceId, -# db_models.IslVideo.book_id == book_obj.book_id, -# db_models.IslVideo.title == item.title, -# db_models.IslVideo.id != row.id -# ) -# .first() -# ) -# if conflict: -# raise HTTPException( -# status_code=409, -# detail=f"Update would violate uniqueness for resource_id={payload.resourceId}, book={item.book}, title={item.title}" -# ) - -# # apply updates -# row.resource_id = payload.resourceId -# row.book_id = book_obj.book_id -# row.chapter = item.chapter -# row.url = item.url -# row.title = item.title -# row.description = item.description - -# updated.append(schema.IslVideoResponseItem( -# video_id=row.id, -# book=book_obj.book_code, -# chapter=row.chapter, -# url=row.url, -# title=row.title, -# description=row.description -# )) - -# try: -# db.commit() -# except SQLAlchemyError as exc: -# db.rollback() -# raise HTTPException(status_code=500, detail=str(exc)) - -# return {"resource_id": payload.resourceId, "videos": updated} - -# # # chapter validation: allow 0 .. chapter_count -# # if item.chapter < 0 or item.chapter > book_obj.chapter_count: -# # raise UnprocessableException(detail=f"Invalid chapter {item.chapter} for book '{item.book}'. Allowed 0 to {book_obj.chapter_count}") - -# def get_isl_videos(db: Session, resource_id: int, -# book_code: Optional[str] = None, -# chapter: Optional[int] = None): -# """ -# Fetch ISL Bible videos grouped by book_code → chapter. -# """ - -# # Base query with join to BookLookup -# query = ( -# db.query(db_models.IslVideo, db_models.BookLookup.book_code) -# .join(db_models.BookLookup, db_models.BookLookup.book_id == db_models.IslVideo.book_id) -# .filter(db_models.IslVideo.resource_id == resource_id) -# ) - -# # Filter by book_code (optional) -# if book_code: -# query = query.filter(db_models.BookLookup.book_code.ilike(book_code)) - -# # Filter by chapter (optional) -# if chapter is not None: -# query = query.filter(db_models.IslVideo.chapter == chapter) - -# rows = query.all() - -# if not rows: -# raise HTTPException(status_code=404, detail="No ISL videos found") - -# # Build nested output -# result = {"books": {}} - -# for video, bk_code in rows: - -# if bk_code not in result["books"]: -# result["books"][bk_code] = {} - -# chap = str(video.chapter) - -# if chap not in result["books"][bk_code]: -# result["books"][bk_code][chap] = [] - -# result["books"][bk_code][chap].append({ -# "video_id": video.id, -# "title": video.title, -# "description": video.description, -# "url": video.url -# }) - -# return result - - -# def delete_isl_videos(db: Session, resource_id: int, ids: List[int]) -> dict: -# resource = ( -# db.query(db_models.Resource) -# .filter(db_models.Resource.resource_id == resource_id) -# .first() -# ) -# if not resource: -# raise NotAvailableException(detail=f"Resource {resource_id} not found") -# # Resource content_type must be 'isl' -# if resource.content_type.lower() != "isl": -# raise BadRequestException( -# detail=f"Resource {resource_id} is not of type 'video' (found '{resource.content_type}')" -# ) - -# for ab_id in audio_bible_ids: - -# # Prevent duplicates in the same request -# if ab_id in processed: -# errors.append({"id": ab_id, "error": "already_deleted"}) -# continue -# processed.add(ab_id) - -# # Check existence -# row = ( -# db.query(db_models.AudioBible) -# .filter( -# db_models.AudioBible.audio_bible_id == ab_id, -# db_models.AudioBible.resource_id == resource_id -# ) -# .first() -# ) - -# if not row: -# errors.append({"id": ab_id, "error": "not_found"}) -# continue - -# # Delete the row -# db.delete(row) -# deleted_ids.append(ab_id) - -# # Commit once -# db.commit() - -# # Build error message -# error_msg = None -# if errors: -# nf = [e["id"] for e in errors if e["error"] == "not_found"] -# dup = [e["id"] for e in errors if e["error"] == "already_deleted"] -# parts = [] -# if nf: -# parts.append(f"Invalid audio_bible_ids: {nf}") -# if dup: -# parts.append(f"Duplicate IDs in request: {dup}") -# error_msg = "; ".join(parts) - -# return { -# "deletedCount": len(deleted_ids), -# "deletedIds": deleted_ids, -# "error": error_msg, -# "message": f"Successfully deleted {len(deleted_ids)} audio bible(s)" -# } - - - -# ---- OBS ---- -def create_obs_bulk( - db: Session, - payload: schema.OBSBulkCreate, - actor_user_id: int, -): - resource = ( - db.query(db_models.Resource) - .filter(db_models.Resource.resource_id == payload.resource_id) - .first() - ) - if not resource: - raise NotAvailableException( - detail=f"Resource {payload.resource_id} not found" - ) - - if (resource.content_type or "").lower() != "obs": - raise BadRequestException( - detail=( - f"Resource {payload.resource_id} is not of type 'obs' " - f"(found '{resource.content_type}')" - ) - ) - - created_rows: list[db_models.Obs] = [] - - for item in payload.obs: - exists = ( - db.query(db_models.Obs.obs_id) - .filter( - db_models.Obs.resource_id == payload.resource_id, - db_models.Obs.story_no == item.story_no, - ) - .first() - ) - if exists: - raise AlreadyExistsException( - detail=( - f"OBS story_no {item.story_no} already exists " - f"for resource {payload.resource_id}" - ) - ) - - row = db_models.Obs( - resource_id=payload.resource_id, - story_no=item.story_no, - title=item.title.strip(), - text=item.text.strip(), - url=item.url.strip() if item.url else None, - ) - - db.add(row) - created_rows.append(row) - - # IMPORTANT: assign IDs before response - db.flush() - - touch_resource(db, payload.resource_id, actor_user_id) - db.commit() - - return schema.OBSBulkCreateFullResponse( - resource_id=payload.resource_id, - createdCount=len(created_rows), - stories=[ - schema.OBSBulkCreateStoryOut( - id=row.obs_id, - story_no=row.story_no, - title=row.title, - url=row.url, - text=row.text, - ) - for row in created_rows - ], - ) - -def get_languages_with_obs(db_session: Session) -> schema.OBSLanguageListResponse: - """ - Get all languages that have OBS stories available. - Returns formatted response with languages and their story counts. - """ - # Query to get languages with OBS resources and count their stories - result = ( - db_session.query( - db_models.Language.language_code, - db_models.Language.language_name, - func.count(db_models.Obs.obs_id).label("story_count"), #pylint: disable=not-callable - - ) - .join( - db_models.Resource, - db_models.Resource.language_id == db_models.Language.language_id, - ) - .join( - db_models.Obs, - db_models.Obs.resource_id == db_models.Resource.resource_id, - ) - .filter(db_models.Resource.content_type == "obs") - .group_by( - db_models.Language.language_code, - db_models.Language.language_name, - ) - .order_by(db_models.Language.language_name) - .all() - ) - - # Transform result into response schema - language_list = [ - schema.LanguageWithStoryCount( - language_code=row.language_code, - language_name=row.language_name, - story_count=row.story_count, - ) - for row in result - ] - - return schema.OBSLanguageListResponse( - success=True, - data=language_list, - count=len(language_list), - ) - - - -def get_obs_stories_by_language( - db_session: Session, - language_code: int, - page: int = 1, - limit: int = 50 -) -> schema.OBSStoriesListResponse: - """ - Get all OBS stories for a specific language with pagination. - Returns formatted response with stories list and pagination info. - """ - # 1. Validate language exists - language = db_session.query(db_models.Language).filter_by( - language_code=language_code - ).first() - - if not language: - logger.error("Language ID %s not found", language_code) - raise NotAvailableException( - detail=f"Language with ID {language_code} not found" - ) - language_id = language.language_id - # 2. Get total count of stories for this language - total_count = ( - db_session.query(func.count(db_models.Obs.obs_id).label("total")) #pylint: disable=not-callable - .join( - db_models.Resource, - db_models.Resource.resource_id == db_models.Obs.resource_id - ) - .filter( - db_models.Resource.language_id == language_id, - db_models.Resource.content_type == "obs" - ).scalar() -) - # 3. Get paginated stories - offset = (page - 1) * limit - stories = db_session.query(db_models.Obs).join( - db_models.Resource, - db_models.Resource.resource_id == db_models.Obs.resource_id - ).filter( - db_models.Resource.language_id == language_id, - db_models.Resource.content_type == 'obs' - ).order_by( - db_models.Obs.story_no - ).offset(offset).limit(limit).all() - - # 4. Build story list - story_list = [ - schema.OBSStoryBrief( - id=story.obs_id, - resource_id=story.resource_id, - story_no=story.story_no, - title=story.title, - url=story.url, - text=story.text - ) - for story in stories - ] - - # 5. Return formatted response - return schema.OBSStoriesListResponse( - success=True, - data=schema.OBSStoriesListData( - language_code=language.language_code, - language_name=language.language_name, - stories=story_list - ), - pagination=schema.PaginationInfo( - page=page, - limit=limit, - total=total_count - ) - ) - - -def get_obs_by_resource( - db: Session, - resource_id: int -) -> schema.OBSGetResponse: - # 1. Validate resource - resource = ( - db.query(db_models.Resource) - .filter(db_models.Resource.resource_id == resource_id) - .first() - ) - if not resource: - raise NotAvailableException(detail=f"Resource {resource_id} not found") - - if (resource.content_type or "").lower() != "obs": - raise BadRequestException( - detail=( - f"Resource {resource_id} is not of type 'obs' " - f"(found '{resource.content_type}')" - ) - ) - - # 2. Fetch stories - rows = ( - db.query(db_models.Obs) - .filter(db_models.Obs.resource_id == resource_id) - .order_by(db_models.Obs.story_no.asc()) - .all() - ) - - # 3. Build response - stories = [ - schema.OBSStoryOut( - id=row.obs_id, - story_no=row.story_no, - title=row.title, - url=row.url, - text=row.text, - ) - for row in rows - ] - - return schema.OBSGetResponse( - resource_id=resource_id, - stories=stories - ) - - - -def _validate_resource_update(db_session, resource_id): - if resource_id is None: - return None - - new_resource = ( - db_session.query(db_models.Resource) - .filter_by(resource_id=resource_id) - .first() - ) - - if not new_resource: - logger.error( - "Resource ID %s not found", resource_id - ) - raise NotAvailableException( - detail=f"Resource with ID {resource_id} not found", - ) - - - if new_resource.content_type.lower() != "obs": - logger.error( - "Resource %s is not of type 'obs'", resource_id - ) - raise BadRequestException( - detail=( - f"Resource {resource_id} is not of type 'obs' " - f"(found '{new_resource.content_type}')" - ), - ) - - return new_resource - - -def _ensure_story_no_unique(db_session, resource_id, story_data, story): - if story_data.story_no is None: - return - - target_resource_id = ( - resource_id - if resource_id is not None - else story.resource_id - ) - - duplicate_story = ( - db_session.query(db_models.Obs) - .filter( - db_models.Obs.resource_id == target_resource_id, - db_models.Obs.story_no == story_data.story_no, - db_models.Obs.obs_id != story.obs_id, - ) - .first() - ) - - if duplicate_story: - logger.error( - "Story number %s already exists for resource %s", - story_data.story_no, - target_resource_id, - ) - raise AlreadyExistsException( - detail=( - f"Story number {story_data.story_no} already exists for resource " - f"{target_resource_id}" - ), - ) - - -def update_obs_story( - db_session: Session, - resource_id: int, - story_id: int, - story_data: schema.OBSStoryUpdate, -): - """Update an existing OBS story.""" - - # Validate resource update - _ = _validate_resource_update(db_session, resource_id) - - story = ( - db_session.query(db_models.Obs) - .filter( - db_models.Obs.resource_id == resource_id, - db_models.Obs.obs_id == story_id, - ) - .first() - ) - if not story: - raise NotAvailableException( - detail=f"OBS story {story_id} not found for resource {resource_id}" - ) - - # Validate duplicate story number - _ensure_story_no_unique(db_session, resource_id,story_data, story) - - # Apply updates - if resource_id is not None: - story.resource_id = resource_id - if story_data.story_no is not None: - story.story_no = story_data.story_no - if story_data.title is not None: - story.title = story_data.title.strip() - if story_data.url is not None: - story.url = story_data.url.strip() if story_data.url else None - if story_data.text is not None: - story.text = story_data.text.strip() - - db_session.commit() - db_session.refresh(story) - - logger.info("Successfully updated OBS story %s", story_id) - - return schema.OBSStoryUpdateResponse( - message="Story updated successfully", - data=schema.OBSStoryUpdate( - id=story.obs_id, - resource_id=story.resource_id, - story_no=story.story_no, - title=story.title, - url=story.url, - text=story.text, - ), - ) - -# ===== DELETE ===== -def delete_obs_bulk( - db: Session, - resource_id: int, - story_nos: list[int], -): - # Validate resource - resource = ( - db.query(db_models.Resource) - .filter(db_models.Resource.resource_id == resource_id) - .first() - ) - if not resource: - raise NotAvailableException(detail=f"Resource {resource_id} not found") - - if (resource.content_type or "").lower() != "obs": - raise BadRequestException( - detail=( - f"Resource {resource_id} is not of type 'obs' " - f"(found '{resource.content_type}')" - ) - ) - - deleted = [] - invalid = [] - processed = set() - - for story_no in story_nos: - # duplicate in request - if story_no in processed: - invalid.append(story_no) - continue - processed.add(story_no) - - row = ( - db.query(db_models.Obs) - .filter( - db_models.Obs.resource_id == resource_id, - db_models.Obs.story_no == story_no, - ) - .first() - ) - - if not row: - invalid.append(story_no) - continue - - db.delete(row) - deleted.append(story_no) - - db.commit() - - return { - "deletedStoryNos": deleted, - "invalidStoryNos": invalid, - } - -### CRUD for InfoGraphics - -ALLOWED_EXT = {".jpg", ".jpeg", ".png", ".gif", ".svg", ".webp"} - -def validate_item(idx: int, item) -> Optional[dict]: - """Return an error dict if invalid, else None. Collects *all* field errors.""" - msgs = [] - data_dump = item.model_dump(by_alias=True) - # 1. book_id validation - if not isinstance(item.book_id, int) or not 1 <= item.book_id <= 67: - msgs.append("book_id must be between 1 to 67") - # title - title = (item.title or "").strip() - if not title: - msgs.append("title cannot be empty") - fn = (item.file_name or "").strip() - if not fn: - msgs.append("filename cannot be empty") - else: - m = re.search(r"(\.[a-zA-Z0-9]+)$", fn) - if not m or m.group(1).lower() not in ALLOWED_EXT: - msgs.append("file_name must end with one of: .jpg, .jpeg, .png, .gif, .svg, .webp") - if ".." in fn or fn.startswith("/"): - msgs.append("file_name must not contain path traversal") - if not msgs: - return None - return { - "index": idx, - "data": data_dump, - "error": { - "code": "VALIDATION_ERROR", - "message": msgs, - }, - } - -def _extract_base_url(resource) -> Optional[str]: - """Extract base_url from resource.meta_data JSON.""" - if not resource or not getattr(resource, "meta_data", None): - return None - - try: - raw = resource.meta_data - data = raw if isinstance(raw, dict) else json.loads(raw) - - base_url = data.get("base_url") - if isinstance(base_url, dict): - base_url = base_url.get("base_url") - - return base_url.rstrip("/") if base_url else None - - except (json.JSONDecodeError, TypeError, AttributeError): - return None - -def _image_url(resource, file_name: str) -> Optional[str]: - """Combine base_url and file_name.""" - base = _extract_base_url(resource) - return f"{base}/{file_name}" if base else None - - - -# --- CRUD operations --- - -def create_infographic_batch( - db: Session, - payload: schema.BatchInfographicCreateIn, - actor_user_id: int -): - """Create a batch of infographics.""" - resource_id = payload.resource_id - - # --- 1. Validate resource --- - res = db.query(db_models.Resource).filter_by(resource_id=resource_id).first() - if not res: - raise NotAvailableException(detail=f"Resource {resource_id} not found") - - if (res.content_type or "").lower() != "infographics": - raise BadRequestException( - detail=( - f"Resource {resource_id} is not of type 'infographics'" - f" (found '{res.content_type}')" - ) - ) - - # --- 2. Validate book ids --- - bk_ids = {i.book_id for i in payload.infographics} - valid_books = { - b.book_id for b in db.query(db_models.BookLookup) - .filter(db_models.BookLookup.book_id.in_(bk_ids)).all() - } - - created_rows = [] - errors = [] - - # --- 3. Process each row --- - for idx, item in enumerate(payload.infographics): - - # Validate title, file extension, filename rules - err = validate_item(idx, item) - if err: - errors.append(err) - continue - - # Check valid book id - if item.book_id not in valid_books: - errors.append({ - "index": idx, - "data": item.model_dump(), - "error": { - "code": "INVALID_BOOK", - "message": f"BookId {item.book_id} not found" - } - }) - continue - - # Duplicate check - duplicate = db.query(db_models.Infographic).filter( - db_models.Infographic.resource_id == resource_id, - db_models.Infographic.book_id == item.book_id, - db_models.Infographic.title == item.title, - db_models.Infographic.file_name == item.file_name, - ).first() - - if duplicate: - errors.append({ - "index": idx, - "data": item.model_dump(), - "error": { - "code": "DUPLICATE_ENTRY", - "message": "Infographic with same book_id, title, and file_name already exists" - } - }) - continue - - # Create row (in-memory only) - row = db_models.Infographic( - resource_id=resource_id, - book_id=item.book_id, - title=item.title, - file_name=item.file_name, - ) - - db.add(row) - db.flush() - - created_rows.append({ - "id": row.id, - "resource_id": row.resource_id, - "book_id": row.book_id, - "title": row.title, - "file_name": row.file_name, - "image_url": _image_url(res, row.file_name), - }) - - # --- 4. Commit ONCE --- - touch_resource(db, resource_id, actor_user_id) - db.commit() - - return {"created": created_rows}, errors - - -def list_infographic_items( - db: Session, - params: schema.InfographicListParams -) -> Tuple[List[schema.InfographicOut], schema.Pagination, int]: - - """List infographic items.""" - page = params.page - limit = params.limit - book_id = params.book_id - resource_id = params.resource_id - search = params.search - - if resource_id is not None: - res = db.query(db_models.Resource).filter_by(resource_id=resource_id).first() - if not res: - raise ValueError("RESOURCE_NOT_FOUND") - if (res.content_type or "").lower() != "infographics": - raise ValueError("INVALID_RESOURCE_TYPE") - - q = db.query(db_models.Infographic) - - if book_id: - q = q.filter(db_models.Infographic.book_id == book_id) - - if resource_id: - q = q.filter(db_models.Infographic.resource_id == resource_id) - - if search: - q = q.filter( - sqlalchemy.func.lower(db_models.Infographic.title) - .like(f"%{search.lower()}%") - ) - - total = q.count() - items = q.offset((page - 1) * limit).limit(limit).all() - - res_map = { - r.resource_id: r - for r in db.query(db_models.Resource) - .filter(db_models.Resource.resource_id.in_({i.resource_id for i in items})) - .all() - } - - data = [ - schema.InfographicOut( - id=i.id, - resource_id=i.resource_id, - book_id=i.book_id, - title=i.title, - file_name=i.file_name, - image_url=_image_url(res_map.get(i.resource_id), i.file_name), - ) - for i in items - ] - - total_pages = (total + limit - 1) // limit - - pagination = schema.Pagination( - current_page=page, - total_pages=total_pages, - total_items=total, - items_per_page=limit, - has_next=page < total_pages, - has_previous=page > 1, - ) - - return data, pagination, total - - -def get_one_infographics( - db: Session, - infographic_id: int -) -> Optional[schema.InfographicOut]: - """ - Get single infographic by ID. - """ - row = db.query(db_models.Infographic).filter_by(id=infographic_id).first() - if not row: - return None - - res = ( - db.query(db_models.Resource) - .filter_by(resource_id=row.resource_id) - .first() - ) - - return schema.InfographicOut( - id=row.id, - resource_id=row.resource_id, - book_id=row.book_id, - title=row.title, - file_name=row.file_name, - image_url=_image_url(res, row.file_name), - ) - - -def _validate_resource(db: Session, resource_id: int) -> db_models.Resource: - """Validate resource exists and is of type 'infographics'.""" - res = db.query(db_models.Resource).filter_by(resource_id=resource_id).first() - if not res: - raise ValueError("RESOURCE_NOT_FOUND") - if (res.content_type or "").lower() != "infographics": - raise ValueError("INVALID_RESOURCE_TYPE") - return res - -def _get_valid_book_ids(db: Session, book_ids: set) -> set: - """Return set of valid book IDs from BookLookup.""" - if not book_ids: - return set() - result = db.query(db_models.BookLookup.book_id).filter( - db_models.BookLookup.book_id.in_(book_ids) - ).all() - return {b.book_id for b in result} - -def _process_infographic_item( - item: schema.InfographicUpdateItem, - ctx: schema.InfographicProcessContext -) -> tuple[dict | None, dict | None]: - """ - Process one infographic item. - Returns tuple: (updated_item_dict, error_dict) - Only one of the two is non-None. - """ - try: - row = ctx.db.query(db_models.Infographic).filter( - db_models.Infographic.id == item.id, - db_models.Infographic.resource_id == ctx.resource_id - ).with_for_update(nowait=False).first() - - if not row: - return None, { - "index": ctx.idx, - "data": item.model_dump(by_alias=True), - "error": { - "code": "NOT_FOUND", - "message": f"Infographic id {item.id} not found for resource {ctx.resource_id}" - }, - } - - tgt_book = item.book_id if item.book_id is not None else row.book_id - tgt_title = item.title if item.title is not None else row.title - tgt_file = item.file_name if item.file_name is not None else row.file_name - - tmp_obj = schema.InfographicUpdateItem( - id=item.id, - book_id=tgt_book, - title=tgt_title, - file_name=tgt_file, - ) - v_err = validate_item(ctx.idx, tmp_obj) - if v_err: - return None, v_err - - if tgt_book not in ctx.valid_books and ctx.valid_books: - return None, { - "index": ctx.idx, - "data": item.model_dump(by_alias=True), - "error": {"code": "INVALID_BOOK", "message": f"Invalid book_id {tgt_book}"} - } - - dup = ctx.db.query(db_models.Infographic).filter( - db_models.Infographic.resource_id == ctx.resource_id, - db_models.Infographic.book_id == tgt_book, - db_models.Infographic.title == tgt_title, - db_models.Infographic.file_name == tgt_file, - db_models.Infographic.id != row.id - ).first() - if dup: - return None, { - "index": ctx.idx, - "data": item.model_dump(by_alias=True), - "error": {"code": "DUPLICATE_ENTRY", "message": "Duplicate infographic exists"} - } - - row.book_id = tgt_book - row.title = tgt_title - row.file_name = tgt_file - row.updated_by = ctx.actor_user_id - ctx.db.add(row) - ctx.db.flush() - - updated_item = { - "id": row.id, - "resource_id": row.resource_id, - "book_id": row.book_id, - "title": row.title, - "file_name": row.file_name, - "image_url": _image_url(ctx.res, row.file_name), - } - return updated_item, None - - except (IntegrityError, SQLAlchemyError, ValueError, TypeError) as ex: - ctx.db.rollback() - return None, { - "index": ctx.idx, - "data": item.model_dump(by_alias=True), - "error": {"code": "INTERNAL_SERVER_ERROR", "message": str(ex)} - } - - -def update_infographic_batch( - db: Session, - payload: schema.BatchInfographicUpdateIn, - actor_user_id: int -): - """ - Update infographic batch using ctx pattern. - """ - resource_id = payload.resource_id - - # --- 1. Validate resource --- - res = _validate_resource(db, resource_id) - - # --- 2. Preload valid book ids --- - all_book_ids = {i.book_id for i in payload.infographics if i.book_id is not None} - valid_books = _get_valid_book_ids(db, all_book_ids) - - updated = [] - errors = [] - - # --- 3. Process each infographic item --- - for idx, item in enumerate(payload.infographics): - # Build context object - ctx = schema.InfographicProcessContext( - db=db, - res=res, - resource_id=resource_id, - valid_books=valid_books, - actor_user_id=actor_user_id, - idx=idx - ) - u, e = _process_infographic_item(item, ctx) - if u: - updated.append(u) - if e: - errors.append(e) - - # --- 4. Commit updates if any --- - if updated: - try: - touch_resource(db, resource_id, actor_user_id) - db.commit() - except (SQLAlchemyError, ValueError, TypeError) as ex: - db.rollback() - # convert all updated rows into errors - for idx, item in enumerate(updated): - errors.append({ - "index": idx, - "data": item, - "error": {"code": "INTERNAL_SERVER_ERROR", "message": str(ex)} - }) - updated = [] - - # --- 5. Return normalized shape --- - return {"updated": updated, "errors": errors} - -# def delete_bulk(db: Session, ids: List[int]) -> List[int]: -# """ -# Delete multiple infographics by IDs. -# """ -# rows = db.query(db_models.Infographic).filter(db_models.Infographic.id.in_(ids)).all() -# if not rows: -# return [] -# deleted = [r.id for r in rows] -# for r in rows: -# db.delete(r) -# db.commit() -# return deleted - -def delete_bulk_details(db: Session, ids: List[int]) -> dict: - """Delete infographics in bulk, return response with deleted & invalid IDs.""" - deleted_ids = [] - invalid_ids = [] - - # fetch existing rows - rows = db.query(db_models.Infographic).filter(db_models.Infographic.id.in_(ids)).all() - existing_ids = {r.id for r in rows} - - # delete existing rows - for r in rows: - db.delete(r) - deleted_ids.append(r.id) - db.commit() - - # collect invalid / not found IDs - invalid_ids = [i for i in ids if i not in existing_ids] - - response = { - "deletedCount": len(deleted_ids), - "deletedIds": deleted_ids, - "message": f"Successfully deleted {len(deleted_ids)} infographic(s)" - } - - if invalid_ids: - response["error"] = f"Invalid infographic_ids: {', '.join(map(str, invalid_ids))}" - - return response - - - -# verse of the day CRUD functions - - - - -def get_all_verse_of_the_day(db_session: Session): - """ - Fetch all verses from the VerseOfTheDay table. - Return year, month, date, book_code, chapter, verse, id from DB. - """ - verses = ( - db_session.query(db_models.VerseOfTheDay) - .order_by(db_models.VerseOfTheDay.year, - db_models.VerseOfTheDay.month, - db_models.VerseOfTheDay.day) - .all() - ) - - verse_list = [ - { - "id": str(v.id), - "year": v.year, - "month": v.month, - "date": v.day, - "book_code": v.book_code, - "chapter": v.chapter, - "verse": v.verse - } - for v in verses - ] - return { - "success": True, - "data": { - "verses": verse_list, - "total": len(verse_list) - } - } - - -def get_verse_for_date(db_session: Session, year: int, month: int, day: int): - """ - Get one verse (with id) for a specific date from VerseOfTheDay table. - """ - verse_entry = ( - db_session.query(db_models.VerseOfTheDay) - .filter_by(year=year, month=month, day=day) - .first() - ) - - if not verse_entry: - raise NotAvailableException(detail=f"No verse found for {year}-{month}-{day}") - - return { - "success": True, - "data": { - "id": str(verse_entry.id), - "year": verse_entry.year, - "month": verse_entry.month, - "day": verse_entry.day, - "book_code": verse_entry.book_code, - "chapter": verse_entry.chapter, - "verse": verse_entry.verse - } - } - - -def upload_verse_of_the_day_csv(db_session: Session, file: UploadFile): - """ - Deletes all old entries and uploads CSV for Verse Of The Day. - Returns 200, 207, or 400 depending on outcome. - """ - - # --- Step 1: Read + Validate CSV --- - reader = _read_votd_csv(file) - - # --- Step 2: Delete old records + Reset sequence --- - deleted_count = _reset_votd_table(db_session) - - created_count = 0 - failed_count = 0 - errors = [] - - # --- Step 3: Process CSV rows --- - for row_num, row in enumerate(reader, start=2): - try: - parsed = _parse_votd_row(row) - _validate_votd_row(db_session, parsed) - - new_entry = db_models.VerseOfTheDay(**parsed) - db_session.add(new_entry) - created_count += 1 - - except (ValueError, KeyError, IntegrityError) as e: - failed_count += 1 - errors.append({"row": row_num, "reason": str(e)}) - - db_session.commit() - - # --- Step 4: Build Response --- - return _build_votd_response( - deleted_count=deleted_count, - created_count=created_count, - failed_count=failed_count, - errors=errors - ) -def _read_votd_csv(file: UploadFile): - try: - content = file.file.read().decode("utf-8") - reader = csv.DictReader(StringIO(content)) - - required = {"year", "month", "date", "book_code", "chapter", "verse"} - if not reader.fieldnames or not required.issubset(set(reader.fieldnames)): - raise ValueError("Invalid CSV file format or missing required columns") - - return reader - - except Exception as exc: - raise TypeException( - detail={ - "success": False, - "error": { - "code": "INVALID_FILE", - "message": "Could not parse the CSV file" - } - } - ) from exc - -def _reset_votd_table(db_session: Session): - deleted = db_session.query(db_models.VerseOfTheDay).delete() - db_session.commit() - - seq = db_session.execute( - text("SELECT pg_get_serial_sequence('verse_of_the_day', 'id');") - ).scalar() - - if seq: - db_session.execute(text(f"ALTER SEQUENCE {seq} RESTART WITH 1;")) - db_session.commit() - - return deleted -def _parse_votd_row(row: dict) -> dict: - try: - return { - "year": int(row["year"]), - "month": int(row["month"]), - "day": int(row["date"]), - "book_code": row["book_code"].strip().lower(), - "chapter": int(row["chapter"]), - "verse": int(row["verse"]), - } - except Exception as ex: - raise ValueError(f"Invalid row values: {ex}") from ex - -def _validate_votd_row(db_session: Session, parsed: dict): - if not 1 <= parsed["month"] <= 12: - raise ValueError(f"Invalid month: {parsed['month']}") - - if not 1 <= parsed["day"] <= 31: - raise ValueError(f"Invalid date: {parsed['day']}") - - book_exists = db_session.query(db_models.BookLookup).filter( - db_models.BookLookup.book_code.ilike(parsed["book_code"]) - ).first() - - if not book_exists: - raise ValueError( - f"Invalid book code: '{parsed['book_code']}' not found in book_lookup table") -def _build_votd_response(deleted_count, created_count, failed_count, errors): - if failed_count == 0: - return { - "success": True, - "data": { - "deleted_count": deleted_count, - "created_count": created_count, - "failed_count": 0, - "errors": [], - }, - "message": "CSV uploaded. Old entries cleared and new ones created.", - } - - if created_count > 0: - raise MultiStatus( - detail={ - "success": False, - "data": { - "deleted_count": deleted_count, - "created_count": created_count, - "failed_count": failed_count, - "errors": errors, - }, - "message": "CSV uploaded with some errors. Check errors array for details.", - }, - ) - - raise UnprocessableException( - detail={ - "success": False, - "error": { - "code": "INVALID_DATA", - "message": "All rows in CSV invalid or could not be processed", - "errors": errors, - }, - }, - ) - - - -def delete_all_verse_of_the_day(db_session: Session): - """ - Deletes all entries from verse_of_the_day table. - """ - try: - deleted_count = db_session.query(db_models.VerseOfTheDay).delete() - db_session.commit() - # Reset sequence dynamically - seq_name = db_session.execute( - text("SELECT pg_get_serial_sequence('verse_of_the_day', 'id');") - ).scalar() - - if seq_name: - db_session.execute(text(f"ALTER SEQUENCE {seq_name} RESTART WITH 1;")) - db_session.commit() - return { - "success": True, - "data": { - "deleted_count": deleted_count - }, - "message": "All verse of the day entries deleted successfully" - } - except SQLAlchemyError as e: - db_session.rollback() - raise GenericException( - detail={ - "success": False, - "error": { - "code": "INTERNAL_ERROR", - "message": "Failed to delete entries" - } - }, - ) from e - - -# --- Reading plan CRUD --- -def parse_json_file(content: bytes) -> List[Dict[str, Any]]: - """ - Parse a JSON file and return a list of entries.""" - try: - raw = json.loads(content.decode("utf-8")) - except json.JSONDecodeError as exc: - raise TypeException( - detail=f"Invalid JSON: {str(exc)}", - ) from exc - - if not isinstance(raw, list): - raise TypeException( - detail="Invalid JSON: expected list of entries", - ) - - if not raw: - raise TypeException( - detail="JSON file is empty", - ) - - return raw - - -def parse_csv_file(content: bytes) -> List[Dict[str, Any]]: - """ - Parse a CSV file and return a list of entries.""" - try: - csv_text = content.decode("utf-8") - except UnicodeDecodeError as exc: - raise TypeException( - detail="File must be UTF-8 encoded", - ) from exc - - rows = [] - reader = csv.DictReader(io.StringIO(csv_text)) - - for row in reader: - if "date" not in row or "reading" not in row: - raise BadRequestException( - detail="CSV must contain 'date' and 'reading' columns", - ) - - try: - readings = ( - json.loads(row["reading"]) - if isinstance(row["reading"], str) - else row["reading"] - ) - except json.JSONDecodeError as exc: - raise TypeException( - detail=f"Invalid JSON in reading field for date {row.get('date')}", - ) from exc - - rows.append({"date": row["date"], "reading": readings}) - - if not rows: - raise TypeException( - detail="CSV contains no valid rows", - ) - - return rows - - -def validate_and_process_entry(entry, index, db, counts): - """ - Validate and process a single entry from the input file.""" - created, updated, skipped = counts - - if not isinstance(entry, dict): - logger.warning("Entry %s is not a dict; skipping", index) - return (created, updated, skipped + 1) - - date_str = entry.get("date") - readings = entry.get("reading") - - if not date_str or not isinstance(readings, list) or not readings: - logger.warning("Invalid entry at index %s; skipping", index) - return (created, updated, skipped + 1) - - try: - month, day = map(int, date_str.split("-")) - datetime(2024, month, day) - except (ValueError, TypeError): - logger.warning("Invalid date format at index %s: %s", index, date_str) - return (created, updated, skipped + 1) - - existing = ( - db.query(db_models.ReadingPlan) - .filter_by(month=month, day=day) - .first() - ) - - if existing: - existing.readings = readings - updated += 1 - else: - db.add(db_models.ReadingPlan(month=month, day=day, readings=readings)) - created += 1 - - return (created, updated, skipped) -def upload_reading_plans( - db: Session, - file_content: bytes, - file_type: str -) -> Dict[str, int]: - """ - Upload reading plans from JSON or CSV file.""" - try: - # Parse input file - if file_type == "json": - data = parse_json_file(file_content) - elif file_type == "csv": - data = parse_csv_file(file_content) - else: - raise TypeException( - detail="Unsupported file type; must be JSON or CSV", - ) - - counts = (0, 0, 0) # created, updated, skipped - - # Process each entry - for idx, entry in enumerate(data): - counts = validate_and_process_entry(entry, idx, db, counts) - - created_count, updated_count, skipped_count = counts - - # Error if no valid entries - if created_count == 0 and updated_count == 0: - if skipped_count > 0: - raise TypeException( - detail=( - f"All {skipped_count} entries were invalid. " - "Expected format: {'date': 'MM-DD', 'reading': [...]}." - ), - ) - raise TypeException( - detail="No valid entries found", - ) - - db.commit() - - return { - "created": created_count, - "updated": updated_count, - "skipped": skipped_count, - "total": created_count + updated_count, - } - - except HTTPException: - raise - except Exception as exc: - db.rollback() - raise GenericException( - detail=f"Unexpected error: {str(exc)}", - ) from exc - -def get_reading_plans( - db: Session, - month: Optional[int] = None, - day: Optional[int] = None -) -> List[db_models.ReadingPlan]: - """ - Get reading plans. If month and day are provided, return specific date. - Otherwise, return all reading plans. - """ - query = db.query(db_models.ReadingPlan) - - if month is not None and day is not None: - # Validate date - try: - datetime(2024, month, day) - except ValueError as exc: - raise TypeException( - detail=f"Invalid date: month={month}, day={day}" - ) from exc - - - query = query.filter_by(month=month, day=day) - result = query.first() - - if not result: - raise NotAvailableException( - detail=f"No reading plan found for {month:02d}-{day:02d}" - ) - - return [result] - - # Return all plans ordered by month and day - return query.order_by( - db_models.ReadingPlan.month, - db_models.ReadingPlan.day - ).all() - - -def delete_all_reading_plans(db: Session) -> int: - """ - Delete all reading plans from the database. - Returns the count of deleted records. - """ - try: - count = db.query(db_models.ReadingPlan).count() - db.query(db_models.ReadingPlan).delete() - db.commit() - return count - except Exception as e: - db.rollback() - logger.error("Error deleting reading plans: %s", e) - raise GenericException( - detail=f"Error deleting reading plans: {str(e)}" - ) from e - - -#validate_html for commentaries -def validate_html(html_text: str): - """ - Validates commentary HTML with strict rules: - - No unclosed tags - - No broken tags like - - No missing closing ,

, etc. - - Only allowed tags are permitted - """ - if not html_text or not html_text.strip(): - return - - if "<" not in html_text and ">" not in html_text: - raise UnprocessableException( - detail="no html tags found" - ) - - allowed_tags = {"p", "strong", "img", "br", "sup", "em", "b", "i", "u"} - void_tags = {"br", "img", "hr", "meta", "link", "input"} - - tag_pattern = re.compile(r"]*>") - - # ---- Helper function for stack-based tag validation ---- - def _check_tag_stack(content: str): - stack = [] - for match in tag_pattern.finditer(content): - tag = match.group(1).lower() - full_tag = match.group(0) - - if tag in void_tags: - continue - - if full_tag.startswith(" is mismatched or missing opener" - ) - stack.pop() - else: - stack.append(tag) - - if stack: - raise UnprocessableException( - detail=f"Unclosed tag(s): {stack}" - ) - - # ---- Validate each tag individually ---- - for match in tag_pattern.finditer(html_text): - tag = match.group(1).lower() - full_tag = match.group(0) - - if tag not in allowed_tags: - raise UnprocessableException( - detail=f"Invalid HTML tag '{full_tag}'" - ) - - if full_tag.startswith(f"<{tag}") and not re.match(rf" str: - """Convert GitHub tree/blob URLs into raw.githubusercontent.com URLs.""" - if not url: - return None - - if "github.com" in url: - url = url.replace("github.com", "raw.githubusercontent.com") - url = url.replace("/tree/", "/") - url = url.replace("/blob/", "/") - - return url.rstrip("/") - -# Extract base_url from resource.meta_data -def extract_base_url(resource): - """Extract base_url from resource.meta_data JSON.""" - try: - raw = resource.meta_data - data = raw if isinstance(raw, dict) else json.loads(raw) - - base = None - - # meta_data can have nested structure - if isinstance(data.get("base_url"), dict): - base = data["base_url"].get("base_url") - else: - base = data.get("base_url") - - if not base: - raise ValueError("base_url missing") - - - base = convert_to_raw_url(base) - return base - - except (ValueError, TypeError): - return None - - -def check_infographics_by_resource(db: Session, resource_id: int): - """ - Remote validation for Infographics by resource_id. - - - Validates resource exists - - Ensures resource content_type is infographics - - Extracts base_url from resource.meta_data - - Checks existence of each infographic file remotely - - Returns detailed missing/present info - """ - - # 1. Fetch and validate resource - resource = ( - db.query(db_models.Resource) - .filter(db_models.Resource.resource_id == resource_id) - .first() - ) - - if not resource: - raise NotAvailableException( - detail=f"Resource not found for resource_id {resource_id}" - ) - - if resource.content_type.lower() != "infographics": - raise BadRequestException( - detail=( - f"Resource {resource_id} is not of type 'infographics' " - f"(found '{resource.content_type}')" - ) - ) - - # 2. Extract base URL from resource.meta_data - base_url = extract_base_url(resource) - - if not base_url: - raise BadRequestException( - detail=f"base_url missing or invalid in resource.meta_data " - f"for resource_id {resource_id}" - ) - - base_url = base_url.rstrip("/") - - # 3. Fetch infographics for this resource - infographics = ( - db.query(db_models.Infographic) - .filter(db_models.Infographic.resource_id == resource_id) - .all() - ) - - if not infographics: - raise NotAvailableException( - detail=f"No infographics found for resource_id {resource_id}" - ) - - # 4. Remote file existence check - results: List[Dict] = [] - missing_files: List[str] = [] - present_count = 0 - - for idx, info in enumerate(infographics, start=1): - filename = info.file_name - url = f"{base_url}/{filename}" - - file_exists = False - - # --- Attempt 1: GET --- - try: - r = requests.get(url, timeout=REQUEST_TIMEOUT, stream=True) - if r.status_code == 200: - file_exists = True - except Exception: - pass - - # --- Attempt 2: Retry GET --- - if not file_exists: - try: - time.sleep(RETRY_DELAY) - r = requests.get(url, timeout=REQUEST_TIMEOUT, stream=True) - if r.status_code == 200: - file_exists = True - except Exception: - pass - - # --- Attempt 3: HEAD fallback --- - if not file_exists: - try: - h = requests.head(url, timeout=REQUEST_TIMEOUT, allow_redirects=True) - if h.status_code == 200: - file_exists = True - except Exception: - pass - - if file_exists: - present_count += 1 - else: - missing_files.append(filename) - - results.append( - { - "id": info.id, - "book_id": info.book_id, - "title": info.title, - "file_name": filename, - "url": url, - "exists": file_exists, - } - ) - - return schema.InfographicCheckResponse( - success=True, - resource_id=resource_id, - base_url=base_url, - total_infographics=len(infographics), - present_files=present_count, - missing_files_count=len(missing_files), - checked_at=datetime.now(timezone.utc).isoformat(), - infographics=results, - ) - - -def get_audit_logs( - db_session: Session, - page: int = 0, - page_size: int = 100, - **filters -) -> tuple[list[db_models.AuditLog], int]: - """ - Retrieve audit logs with optional filtering and pagination. - - Filters supported via kwargs: - - user_id: int - - method: str - - path: str (partial match) - - status_code: str or int - - date_from: datetime - - date_to: datetime - - Returns: - tuple of (list of AuditLog rows, total count) - """ - query = db_session.query(db_models.AuditLog) - - user_id = filters.get("user_id") - method = filters.get("method") - path = filters.get("path") - status_code = filters.get("status_code") - date_from = filters.get("date_from") - date_to = filters.get("date_to") - - if user_id is not None: - query = query.filter(db_models.AuditLog.user_id == user_id) - if method: - query = query.filter(db_models.AuditLog.method == method.upper()) - if path: - query = query.filter(db_models.AuditLog.path.ilike(f"%{path}%")) - if status_code: - query = query.filter(db_models.AuditLog.status_code == int(status_code)) - if date_from is not None: - query = query.filter(db_models.AuditLog.created_at >= date_from) - if date_to is not None: - query = query.filter(db_models.AuditLog.created_at <= date_to) - - total = query.count() - - rows = ( - query.order_by(db_models.AuditLog.created_at.desc()) - .offset(page * page_size) - .limit(page_size) - .all() - ) - - return rows, total - - -def validate_video_item(db, item): - """ - Validate a single video item.""" - errors = [] - - book_val = (item.book or "").strip().lower() - - if book_val in ("ot", "nt"): - # chapter is allowed to be anything (or None) - if item.chapter is not None and item.chapter > 175: - errors.append(f"Invalid chapter {item.chapter} for book {item.book}.For OT/NT, chapter cannot be greater than 175.") - return errors - - # Check against book_code or book_name (case-insensitive) - book_obj = ( - db.query(db_models.BookLookup) - .filter( - (db_models.BookLookup.book_code.ilike(book_val)) | - (db_models.BookLookup.book_name.ilike(book_val)) - ) - .first() - ) - - if not book_obj: - errors.append( - f"Invalid book '{item.book}'. Must be OT, NT, or a valid Bible book " - f"(book_code or book_name)." - ) - return errors - - # --- 3) Chapter validation --- - if item.chapter is not None: - try: - chapter_int = int(item.chapter) - except ValueError: - errors.append("chapter must be a number") - return errors - - if chapter_int < 0: - errors.append("chapter must be ≥ 0") - elif chapter_int > book_obj.chapter_count: - errors.append( - f"chapter {chapter_int} exceeds max chapters " - f"({book_obj.chapter_count}) for '{book_obj.book_name}'." - ) - - return errors -def test_commentary_images(db, resource_id: int): - """ - Test if all commentary images exist. - """ - - # 1. Fetch resource - res = db.query(db_models.Resource).filter_by(resource_id=resource_id).first() - if not res: - raise NotAvailableException(detail="Resource not found") - - base_url = extract_base_url(res) - if not base_url: - raise UnprocessableException(detail="Base URL missing in resource metadata") - - # Helper: extract all image filenames - def collect_filenames(): - filenames = set() - for row in ( - db.query(db_models.Commentary) - .filter(db_models.Commentary.resource_id == resource_id) - .all() - ): - soup = BeautifulSoup(row.text, "html.parser") - for img in soup.find_all("img"): - src = (img.get("src") or "").strip() - if not src: - continue - - # Always extract the filename only - fname = src.rstrip("/").split("/")[-1] - - filenames.add((row.book_id, row.chapter, row.verse, fname)) - return filenames - - - # Helper: remote check - def remote_exists(url: str) -> bool: - try: - response = requests.head(url, timeout=5) - return response.status_code == 200 - except requests.RequestException: - return False - - all_filenames = collect_filenames() - - report = [] - for book_id, chapter, verse, fname in all_filenames: - url = f"{base_url}/{fname}" - exists = remote_exists(url) - report.append( - { - "bookId": book_id, - "chapter": chapter, - "verse": verse, - "file_name": fname, - "present": exists, - } - ) - - images_present = sum(1 for item in report if item["present"]) - - return { - "success": True, - "resource_id": resource_id, - "base_url": base_url, - "total_images": len(all_filenames), - "images_present": images_present, - "images": report, - } - -def validate_commentary_book_and_chapter(db: Session, book_id: int, chapter: int): - """ - Validates that: - - book_id exists in BookLookup table - - chapter does NOT exceed chapter_count for that book - """ - - # 1. Check book exists - book = ( - db.query(db_models.BookLookup) - .filter(db_models.BookLookup.book_id == book_id) - .first() - ) - - if not book: - raise NotAvailableException( - detail=f"Invalid book_id {book_id}. Book does not exist in BookLookup." - ) - - if chapter < 0: - raise BadRequestException( - detail=f"Chapter must be >= 0 for book_id {book_id}." - ) - - if chapter > book.chapter_count: - raise BadRequestException( - detail=( - f"Invalid chapter {chapter} for book_id {book_id}. " - f"Max allowed chapter is {book.chapter_count}." - ) - ) - -def validate_audio_bible_books(db, books: dict): - """ - Validate Audio Bible books: - - book_code must exist in BookLookup (case-insensitive) - - chapter count must be <= chapter_count in BookLookup - """ - - errors = [] - - for book_code, chapter_count in books.items(): - bc = book_code.strip().lower() - - # --- 1. Check if book code exists in BookLookup --- - book_obj = ( - db.query(db_models.BookLookup) - .filter(db_models.BookLookup.book_code.ilike(bc)) - .first() - ) - - if not book_obj: - errors.append( - f"Invalid book code '{book_code}'. Must match book_code in BookLookup table." - ) - continue - - # --- 2. Validate chapter_count is integer --- - if not isinstance(chapter_count, int) or chapter_count <= 0: - errors.append( - f"Chapter count for '{book_code}' must be a positive integer." - ) - continue - - # --- 3. Validate chapter_count does not exceed Bible max --- - if chapter_count > book_obj.chapter_count: - errors.append( - f"Chapter count {chapter_count} exceeds max chapters " - f"({book_obj.chapter_count}) for '{book_obj.book_code}'." - ) - - return errors -def check_audio_bible_remote(db, resource_id: int): - """Checks remote DigitalOcean Spaces for missing audio files for a given Audio Bible.""" - - ab = db.query(db_models.AudioBible).filter_by(resource_id=resource_id).first() - if not ab: - return {"error": f"Audio Bible not found for resource_id {resource_id}"} - - base = ab.base_url.rstrip("/") - books = ab.books - fmt = ab.format - - results = [] - books_found = len(books) - full_books_present = 0 - all_missing_files = {} - - for book_code, total_chapters in books.items(): - missing = [] - present = 0 - - for chap in range(1, total_chapters + 1): - url = f"{base}/{book_code}/{chap}.{fmt}" - - # --- Step 1: First GET attempt --- - file_exists = False - try: - r = requests.get(url, timeout=10, stream=True) - if r.status_code == 200: - file_exists = True - except: - pass - - # --- Step 2: Retry GET once if failed --- - if not file_exists: - try: - time.sleep(0.15) # 150 ms CDN warm-up - r = requests.get(url, timeout=10, stream=True) - if r.status_code == 200: - file_exists = True - except: - pass - - # --- Step 3: Fallback to HEAD request --- - if not file_exists: - try: - h = requests.head(url, timeout=10, allow_redirects=True) - if h.status_code == 200: - file_exists = True - except: - pass - - # --- Final Result --- - if file_exists: - present += 1 - else: - missing.append(chap) - - if len(missing) == 0: - full_books_present += 1 - - results.append({ - "book": book_code, - "total_chapters": total_chapters, - "present": present, - "missing_chapters": missing - }) - - if missing: - all_missing_files[book_code] = missing - - # Save results - ab.files_missing = all_missing_files - ab.test_date = datetime.now(timezone.utc) - db.commit() - return { - "success": True, - "resource_id": resource_id, - "books_found": books_found, - "full_books_present": full_books_present, - "base_url": base, - "test_date": ab.test_date.isoformat(), - "audio_files": results - } -def _is_public_url(url: str) -> bool: - try: - r = requests.get(url, timeout=8, allow_redirects=True) - - # If non-YouTube URL → fallback logic - if "youtube.com" not in r.url and "youtu.be" not in url: - return r.status_code < 400 - - # ---- YouTube-specific validation ---- - - html = r.text.lower() - - # YouTube invalid-video markers - if "video unavailable" in html or "this video is unavailable" in html: - return False - - # If YouTube returns a watch page but without player → invalid - if "player-unavailable" in html: - return False - - # If YouTube returns 410, 404, 429 etc. - if r.status_code >= 400: - return False - - return True - - except requests.RequestException: - return False - - -def test_videos_for_resource(db: Session, resource_id: int): - """Test videos for resource""" - # Validate resource - res = db.query(db_models.Resource).filter_by(resource_id=resource_id).first() - if not res: - raise NotAvailableException(detail="Resource not found") - if (res.content_type or "").lower() != "video": - raise TypeException( - detail="Resource is not of type 'video'" - ) - - videos = db.query(db_models.Video).filter_by(resource_id=resource_id).all() - - out_items = [] - public_count = 0 - - for vid in videos: - is_public = _is_public_url(vid.url) - - if is_public: - public_count += 1 - - out_items.append({ - "videoId": vid.video_id, - "book": vid.book, - "chapter": vid.chapter, - "url": vid.url, - "public": is_public - }) - - return { - "success": True, - "resource_id": resource_id, - "videos_found": len(videos), - "videos_public": public_count, - "videos": out_items - } -def test_isl_bible_videos_for_resource(db: Session, resource_id: int): - """Test isl bible videos for resource""" - # Validate resource - res = db.query(db_models.Resource).filter_by(resource_id=resource_id).first() - if not res: - raise NotAvailableException(detail="Resource not found") - if (res.content_type or "").lower() != "isl_bible": - raise TypeException( - detail="Resource is not of type 'isl_bible'" - ) - - isl_videos = db.query(db_models.IslVideo).filter_by(resource_id=resource_id).all() - - out_items = [] - public_count = 0 - - for vid in isl_videos: - is_public = _is_public_url(vid.url) - - if is_public: - public_count += 1 - - out_items.append({ - "islvideoId": vid.id, - "book": vid.book_id, - "chapter": vid.chapter, - "url": vid.url, - "public": is_public - }) - - return { - "success": True, - "resource_id": resource_id, - "isl_videos_found": len(isl_videos), - "isl_videos_public": public_count, - "isl_videos": out_items - } -async def validate_usfm_file(file: UploadFile) -> Dict[str, Any]: - """ - Validates USFM file structure and returns validation result. - Returns dict with 'valid' (bool) and optional metadata keys. - """ - try: - # 1. VALIDATE FILE EXTENSION - if not file.filename: - raise UnprocessableException(detail="No filename provided") - - filename_lower = file.filename.lower() - if not filename_lower.endswith((".usfm", ".sfm")): - raise UnprocessableException( - detail=( - f"Invalid file type. Expected .usfm or .sfm file, " - f"got '{file.filename}'. Please upload a valid USFM file." - ) - ) - - # 2. READ FILE CONTENT (ASYNC SAFE) - content = await file.read() - - # 3. CHECK FILE SIZE - max_size = 10 * 1024 * 1024 # 10 MB - if len(content) > max_size: - await file.seek(0) - raise UnprocessableException( - detail=( - f"File too large ({len(content)} bytes). " - f"Maximum allowed: {max_size} bytes" - ) - ) - - # 4. RESET FILE POINTER - await file.seek(0) - - # 5. DECODE CONTENT - try: - usfm_content = content.decode("utf-8") - except UnicodeDecodeError: - raise UnprocessableException( - detail=( - "File encoding error. USFM files must be UTF-8 encoded " - "plain text." - ) - ) - - # 6. EMPTY FILE CHECK - if not usfm_content.strip(): - raise UnprocessableException( - detail="USFM file is empty or contains only whitespace" - ) - - # 7. BINARY FILE CHECK - if "\x00" in usfm_content: - raise UnprocessableException( - detail=( - "File appears to be binary, not text. " - "USFM files must be plain text files." - ) - ) - - # 8. REQUIRED \id MARKER - if "\\id" not in usfm_content: - raise UnprocessableException( - detail=( - "Not a valid USFM file. Missing required \\id marker. " - "Please ensure this is a properly formatted USFM file." - ) - ) - - # 9. PARSE WITH USFM-GRAMMAR (THREADPOOL SAFE) - try: - parser = USFMParser(usfm_content) - usj_data = await run_in_threadpool(parser.to_usj) - - # Validate USJ structure - if not isinstance(usj_data, dict) or "content" not in usj_data: - raise UnprocessableException( - detail="Invalid USFM structure: Cannot parse to valid USJ format" - ) - - # Extract book code - book_code = None - for item in usj_data.get("content", []): - if item.get("type") == "book" and item.get("marker") == "id": - book_code = item.get("code") - break - - if not book_code: - raise UnprocessableException( - detail="USFM file must contain a valid book code in \\id marker" - ) - - # Count chapters - chapter_count = sum( - 1 for item in usj_data.get("content", []) - if item.get("type") == "chapter" - ) - - if chapter_count == 0: - raise UnprocessableException( - detail="USFM file must contain at least one chapter (\\c marker)" - ) - - return { - "valid": True, - "book_code": book_code, - "chapter_count": chapter_count, - } - - except HTTPException: - raise - except Exception as parse_error: - raise UnprocessableException( - detail=( - f"USFM parsing error: {str(parse_error)}. " - "Please ensure this is a valid USFM file." - ) - ) - - except HTTPException: - raise - except Exception as exc: - raise UnprocessableException( - detail=f"File validation error: {str(exc)}" - ) - -def _resolve_book_id(db, book_code: str): - """Return BookLookup row (object) for given book_code (case-insensitive) or raise 422.""" - if not book_code: - raise UnprocessableException( - detail="book code is required") - book = ( - db.query(db_models.BookLookup) - .filter(db_models.BookLookup.book_code.ilike(book_code)) - .first() - ) - if not book: - raise UnprocessableException( - detail=f"Invalid book code '{book_code}'") - return book - -def create_isl_videos(db, payload: schema.IslVideoCreateRequest): - resource = ( - db.query(db_models.Resource) - .filter(db_models.Resource.resource_id == payload.resourceId) - .first() - ) - if not resource: - raise NotAvailableException(detail=f"Resource {payload.resourceId} not found") - # Resource content_type must be 'isl_bible' - if resource.content_type.lower() != "isl_bible": - raise BadRequestException( - detail=f"Resource {payload.resourceId} is not of type 'isl_bible' (found '{resource.content_type}')" - ) - created: List[schema.IslVideoResponseItem] = [] - - for item in payload.videos: - # resolve book - book_obj = _resolve_book_id(db, item.book) - # chapter validation: allow 0 .. chapter_count - if item.chapter < 0 or item.chapter > book_obj.chapter_count: - raise UnprocessableException( - detail=f"Invalid chapter {item.chapter} for book '{item.book}'. Allowed 0 to {book_obj.chapter_count}") - - # check duplicate in DB - dup = ( - db.query(db_models.IslVideo) - .filter_by(resource_id=payload.resourceId, book_id=book_obj.book_id, chapter=item.chapter) - .first() - ) - if dup: - raise AlreadyExistsException( - detail=f"Duplicate entry for resource_id={payload.resourceId}, book={item.book}, chapter={item.chapter}") - - row = db_models.IslVideo( - resource_id=payload.resourceId, - book_id=book_obj.book_id, - chapter=item.chapter, - url=item.url, - title=item.title, - description=item.description, - ) - db.add(row) - db.flush() - - created.append(schema.IslVideoResponseItem( - video_id=row.id, - book=book_obj.book_code, - chapter=row.chapter, - url=row.url, - title=row.title, - description=row.description - )) - - try: - db.commit() - except SQLAlchemyError as exc: - db.rollback() - raise GenericException(detail=str(exc)) - return {"resource_id": payload.resourceId, "videos": created} - - -def update_isl_videos(db, payload: schema.IslVideoUpdateRequest): - resource = ( - db.query(db_models.Resource) - .filter(db_models.Resource.resource_id == payload.resourceId) - .first() - ) - if not resource: - raise NotAvailableException(detail=f"Resource {payload.resourceId} not found") - # Resource content_type must be 'isl_bible' - if resource.content_type.lower() != "isl_bible": - raise BadRequestException( - detail=f"Resource {payload.resourceId} is not of type 'isl_bible' (found '{resource.content_type}')" - ) - updated: List[schema.IslVideoResponseItem] = [] - - for item in payload.videos: - row = db.query(db_models.IslVideo).filter_by(id=item.id).first() - if not row: - raise NotAvailableException(detail="ISL Video id {item.id} not found") - book_obj = _resolve_book_id(db, item.book) - - # chapter validation: allow 0 .. chapter_count - if item.chapter < 0 or item.chapter > book_obj.chapter_count: - raise UnprocessableException(detail=f"Invalid chapter {item.chapter} for book '{item.book}'. Allowed 0 to {book_obj.chapter_count}") - - # uniqueness check against other rows - conflict = ( - db.query(db_models.IslVideo) - .filter( - db_models.IslVideo.resource_id == payload.resourceId, - db_models.IslVideo.book_id == book_obj.book_id, - db_models.IslVideo.chapter == item.chapter, - db_models.IslVideo.id != row.id - ) - .first() - ) - if conflict: - raise AlreadyExistsException(detail=f"Update would violate uniqueness for resource_id={payload.resourceId}, book={item.book}, chapter={item.chapter}") - - # apply updates - row.resource_id = payload.resourceId - row.book_id = book_obj.book_id - row.chapter = item.chapter - row.url = item.url - row.title = item.title - row.description = item.description - - updated.append(schema.IslVideoResponseItem( - video_id=row.id, - book=book_obj.book_code, - chapter=row.chapter, - url=row.url, - title=row.title, - description=row.description - )) - - try: - db.commit() - except SQLAlchemyError as exc: - db.rollback() - raise GenericException(detail=str(exc)) - return {"resource_id": payload.resourceId, "videos": updated} - - -def get_isl_videos(db: Session, resource_id: int, book_code: Optional[str] = None, chapter: Optional[int] = None): - """ - Return grouped videos as: - { "books": { "gen": [ {video_id, chapter, title, description, url}, ... ], ... } } - """ - # Validate resource exists (optional) - # Fetch rows, join with BookLookup to get book_code - q = db.query(db_models.IslVideo, db_models.BookLookup).join( - db_models.BookLookup, - db_models.IslVideo.book_id == db_models.BookLookup.book_id, - ).filter(db_models.IslVideo.resource_id == resource_id) - - if book_code: - # normalize and validate input book_code - bval = book_code.strip().lower() - bl = _resolve_book_id(db, bval) - # if book matched BookLookup, filter by book_id - if bl: - q = q.filter(db_models.IslVideo.book_id == bl.book_id) - if chapter is not None: - # chapter int validation happens elsewhere; here just filter - q = q.filter(db_models.IslVideo.chapter == chapter) - - rows = q.order_by(db_models.BookLookup.book_code, db_models.IslVideo.chapter).all() - if not rows: - raise NotAvailableException( - detail="No ISL videos found") - result = {} - for row, bl in rows: - code = bl.book_code - entry = { - "video_id": row.id, - "chapter": row.chapter, - "title": row.title, - "description": row.description, - "url": row.url, - } - result.setdefault(code, []).append(entry) - - return {"books": result} - - -def delete_isl_videos(db: Session, resource_id: int, ids: List[int]) -> dict: - resource = ( - db.query(db_models.Resource) - .filter(db_models.Resource.resource_id == resource_id) - .first() - ) - if not resource: - raise NotAvailableException(detail=f"Resource {resource_id} not found") - # Resource content_type must be 'isl_bible' - if resource.content_type.lower() != "isl_bible": - raise BadRequestException( - detail=f"Resource {resource_id} is not of type 'isl_bible' (found '{resource.content_type}')" - ) - if not ids: - return { - "deleted_count": 0, - "deleted_ids": [], - "invalid_ids": [] - } - - # Get rows that belong to this resource_id - rows = db.query(db_models.IslVideo).filter( - db_models.IslVideo.id.in_(ids), - db_models.IslVideo.resource_id == resource_id - ).all() - - existing_ids = [r.id for r in rows] - - # IDs provided but not found or not belonging to this resource - invalid_ids = list(set(ids) - set(existing_ids)) - - # Delete the valid ones - deleted_count = ( - db.query(db_models.IslVideo) - .filter(db_models.IslVideo.id.in_(existing_ids)) - .delete(synchronize_session=False) - ) - - db.commit() - - return { - "deleted_count": deleted_count, - "deleted_ids": existing_ids, - "invalid_ids": invalid_ids - } diff --git a/backend/app/crud/content_bible.py b/backend/app/crud/content_bible.py index 8345fec7..5d5bdbeb 100644 --- a/backend/app/crud/content_bible.py +++ b/backend/app/crud/content_bible.py @@ -48,14 +48,15 @@ def upload_bible_book( db_session: Session, resource_id: int, usfm_file: UploadFile, - actor_user_id: int + actor_user_id: int, + pre_parsed_usj_data: Dict[str, Any] = None, + usfm_content: str = None ) -> Dict[str, str]: """Upload and process a new bible book""" # Check if resource exists and validate content type resource = _get_resource(db_session, resource_id) - # Resource content_type must be 'bible' if resource.content_type.lower() != "bible": raise BadRequestException( detail=( @@ -64,13 +65,21 @@ def upload_bible_book( ) ) - usfm_content = _read_usfm_file(usfm_file) + # Read file if not already provided + if usfm_content is None: + usfm_content = _read_usfm_file(usfm_file) + + # Extract book code book_code = extract_book_code_from_usfm(usfm_content) book = _lookup_book_or_404(db_session, book_code) _check_book_not_exists(db_session, resource_id, book.book_id) - usj_data = _parse_usfm_to_usj(usfm_content) + # Parse USFM if not already parsed + if pre_parsed_usj_data is not None: + usj_data = pre_parsed_usj_data + else: + usj_data = _parse_usfm_to_usj(usfm_content) content_items = usj_data.get("content", []) chapter_count = _count_chapters(content_items) @@ -163,17 +172,26 @@ def update_bible_book( db_session: Session, bible_book_id: int, usfm_file: UploadFile, - actor_user_id: int + actor_user_id: int, + pre_parsed_usj_data: Dict[str, Any] = None, + usfm_content: str = None ) -> Dict[str, str]: """Update an existing bible book""" bible_record = _get_bible_record_or_404(db_session, bible_book_id) - usfm_content = _read_usfm_file(usfm_file) + + # Read file if not already provided + if usfm_content is None: + usfm_content = _read_usfm_file(usfm_file) book_code = extract_book_code_from_usfm(usfm_content) _validate_book_code_matches(db_session, bible_record.book_id, book_code) - usj_data = _parse_usfm_to_usj(usfm_content) + # Parse USFM if not already parsed + if pre_parsed_usj_data is not None: + usj_data = pre_parsed_usj_data + else: + usj_data = _parse_usfm_to_usj(usfm_content) content_items = usj_data.get("content", []) chapter_count = _count_chapters(content_items) diff --git a/backend/app/crud/remote_filecheck_crud.py b/backend/app/crud/remote_filecheck_crud.py index 1b1f5734..cd5c989d 100644 --- a/backend/app/crud/remote_filecheck_crud.py +++ b/backend/app/crud/remote_filecheck_crud.py @@ -373,10 +373,18 @@ async def check_one(vid): "isl_videos": out_items } -async def validate_usfm_file(file: UploadFile) -> Dict[str, Any]: +async def validate_usfm_file_internal(file: UploadFile) -> Dict[str, Any]: """ - Validates USFM file structure and returns validation result. - Returns dict with 'valid' (bool) and optional metadata keys. + INTERNAL VERSION: Validates USFM file and returns full parsed data. + Used by upload_bible_book() and update_bible_book() to avoid re-parsing. + + Returns: { + 'valid': True, + 'book_code': 'PSA', + 'chapter_count': 150, + 'usj_data': {...}, ← Complex nested object + 'usfm_content': '\\id PSA...' ← Raw USFM string + } """ try: # 1. VALIDATE FILE EXTENSION @@ -482,6 +490,8 @@ async def validate_usfm_file(file: UploadFile) -> Dict[str, Any]: "valid": True, "book_code": book_code, "chapter_count": chapter_count, + "usj_data": usj_data, + "usfm_content": usfm_content, } except HTTPException: @@ -491,6 +501,13 @@ async def validate_usfm_file(file: UploadFile) -> Dict[str, Any]: detail=f"File validation error: {str(exc)}" ) from exc + except HTTPException: + raise + except Exception as exc: + raise UnprocessableException( + detail=f"File validation error: {str(exc)}" + ) from exc + except HTTPException: raise @@ -498,3 +515,36 @@ async def validate_usfm_file(file: UploadFile) -> Dict[str, Any]: raise UnprocessableException( detail=f"File validation error: {str(exc)}" ) from exc +async def validate_usfm_file_api(file: UploadFile) -> Dict[str, Any]: + """ + API VERSION: Validates USFM file and returns only JSON-serializable data. + Used by the /bible/usfm/validate endpoint. + + Returns: { + 'valid': True, + 'book_code': 'PSA', + 'chapter_count': 150, + 'message': 'USFM file is valid' + } + """ + try: + result = await validate_usfm_file_internal(file) + + return { + "valid": result["valid"], + "book_code": result["book_code"], + "chapter_count": result["chapter_count"], + } + + except UnprocessableException as e: + return { + "valid": False, + "error": e.detail, + "message": "USFM file validation failed" + } + except Exception as e: + return { + "valid": False, + "error": str(e), + "message": "USFM file validation failed" + } \ No newline at end of file diff --git a/backend/app/router.py b/backend/app/router.py deleted file mode 100644 index 7c445039..00000000 --- a/backend/app/router.py +++ /dev/null @@ -1,4534 +0,0 @@ -# """API routes for CRUD operations.""" -# from typing import Optional, Union, List -# from fastapi import ( -# APIRouter, -# Depends, -# HTTPException, -# Query, -# File, -# UploadFile, -# Form, -# Path, -# Body, -# Response, -# Request -# ) -# from fastapi.responses import JSONResponse -# from supertokens_python.recipe.session import SessionContainer -# from supertokens_python.recipe.session.framework.fastapi import verify_session -# from sqlalchemy.orm import Session -# import schema -# from schema import BibleVersePathParams -# import crud -# from dependencies import get_db, logger -# from auth import ( -# validate_admin_only, -# validate_admin_editor, -# validate_all_roles, -# ensure_user_from_session_async, -# ) -# import db_models -# from custom_exceptions import ( -# NotAvailableException, -# BadRequestException, -# UnprocessableException, -# TypeException, -# ) - -# router = APIRouter() - -# verify_session_data=verify_session() - -# # # --- Version Endpoints --- -# # @router.get( -# # "/versions", -# # response_model=Union[schema.VersionResponse, List[schema.VersionResponse]], -# # tags=["Version"] -# # ) - -# # async def get_versions( -# # version_id: Optional[int] = Query(None), -# # abbreviation: Optional[str] = Query(None), -# # db_session: Session = Depends(get_db), -# # session: SessionContainer = Depends(verify_session_data) -# # ): -# # """Get all versions or a single version by ID.""" -# # logger.info("GET Version API") -# # validate_admin_only(session) -# # _, _ = await ensure_user_from_session_async(db_session, session) -# # if version_id is not None or abbreviation is not None: -# # db_obj = crud.get_version(db_session, version_id,abbreviation) -# # if not db_obj: -# # logger.error("Version not found") -# # raise NotAvailableException(detail="Version not found") -# # return db_obj -# # return crud.get_all_versions(db_session) - - -# # @router.post("/versions", response_model=schema.VersionResponse,tags=["Version"]) -# # async def create_version( -# # version: schema.VersionCreate, -# # db_session: Session = Depends(get_db), -# # session: SessionContainer = Depends(verify_session_data) -# # ): -# # """Create a new version.""" -# # logger.info("POST Version API") -# # validate_admin_only(session) -# # _, _ = await ensure_user_from_session_async(db_session, session) -# # db_obj = crud.create_version(db_session, version) -# # return db_obj - - -# # @router.put("/versions/{version_id}", response_model=schema.VersionResponse,tags=["Version"]) -# # async def update_version( -# # version_id: int, -# # version: schema.VersionUpdate, -# # db_session: Session = Depends(get_db), -# # session: SessionContainer = Depends(verify_session_data) -# # ): -# # """Update an existing version by ID.""" -# # logger.info("PUT Version API") -# # validate_admin_only(session) -# # _, _ = await ensure_user_from_session_async(db_session, session) -# # db_obj = crud.update_version(db_session,version_id, version) -# # return db_obj - -# # @router.delete( -# # "/versions/bulk-delete", -# # tags=["Version"], -# # response_model=schema.VersionBulkDeleteResponse -# # ) -# # async def delete_versions_bulk( -# # request: schema.VersionBulkDelete, -# # db_session: Session = Depends(get_db), -# # session: SessionContainer = Depends(verify_session_data), -# # ): -# # logger.info("DELETE BULK Version API") - -# # # ---- Auth & role checks ---- -# # validate_admin_only(session) -# # await ensure_user_from_session_async(db_session, session) - -# # # ---- Business logic ---- -# # result = crud.delete_versions_bulk(db_session, request.version_ids) - -# # data = result["data"] -# # meta = result.get("meta", {}) - -# # deleted_count = data["deletedCount"] -# # deleted_ids = data["deletedIds"] -# # errors = data.get("errors") - -# # not_found = meta.get("not_found", []) -# # conflicts = meta.get("conflicts", []) - -# # # ---- Status code logic ---- -# # if conflicts and not deleted_ids: -# # status_code = 409 # Conflict: versions exist but are in use -# # elif not_found and not deleted_ids: -# # status_code = 404 # Not Found: versions don’t exist -# # elif conflicts or not_found: -# # status_code = 207 # Multi-Status: partial success -# # else: -# # status_code = 200 # All deleted successfully - -# # # ---- Message ---- -# # if deleted_count > 0: -# # message = f"Successfully deleted {deleted_count} version(s)" -# # else: -# # message = "No versions were deleted" - -# # response_data = { -# # "deletedCount": deleted_count, -# # "deletedIds": deleted_ids, -# # "errors": errors, -# # "message": message, -# # } - -# # return JSONResponse( -# # status_code=status_code, -# # content=response_data, -# # ) - -# # # --- Language Endpoints --- - -# # @router.get( -# # "/language", -# # response_model=schema.LanguageResponse, -# # tags=["Language"] -# # ) -# # async def get_languages( -# # params: schema.LanguageQueryParams = Depends(), -# # db_session: Session = Depends(get_db), -# # session: SessionContainer = Depends(verify_session_data) -# # ): -# # """Get languages with pagination and optional filtering.""" -# # logger.info("GET Languages API") - -# # validate_admin_only(session) -# # _, _roles = await ensure_user_from_session_async(db_session, session) - -# # languages, total_items = crud.get_languages_with_pagination( -# # db_session=db_session, -# # page=params.page, -# # page_size=params.page_size, -# # language_name=params.language_name, -# # language_code=params.language_code, -# # ) - -# async def get_versions( -# version_id: Optional[int] = Query(None), -# abbreviation: Optional[str] = Query(None), -# db_session: Session = Depends(get_db), -# session: SessionContainer = Depends(verify_session_data) -# ): -# """Get all versions or a single version by ID.""" -# logger.info("GET Version API") -# validate_admin_only(session) -# _, _ = await ensure_user_from_session_async(db_session, session) -# if version_id is not None or abbreviation is not None: -# db_obj = crud.get_version(db_session, version_id,abbreviation) -# if not db_obj: -# logger.error("Version not found") -# raise NotAvailableException(detail="Version not found") -# return db_obj -# return crud.get_all_versions(db_session) - - -# @router.post("/versions", response_model=schema.VersionResponse,tags=["Version"]) -# async def create_version( -# version: schema.VersionCreate, -# db_session: Session = Depends(get_db), -# session: SessionContainer = Depends(verify_session_data) -# ): -# """Create a new version.""" -# logger.info("POST Version API") -# validate_admin_only(session) -# _, _ = await ensure_user_from_session_async(db_session, session) -# db_obj = crud.create_version(db_session, version) -# return db_obj - - -# @router.put("/versions/{version_id}", response_model=schema.VersionResponse,tags=["Version"]) -# async def update_version( -# version_id: int, -# version: schema.VersionUpdate, -# db_session: Session = Depends(get_db), -# session: SessionContainer = Depends(verify_session_data) -# ): -# """Update an existing version by ID.""" -# logger.info("PUT Version API") -# validate_admin_only(session) -# _, _ = await ensure_user_from_session_async(db_session, session) -# db_obj = crud.update_version(db_session,version_id, version) -# return db_obj - - -# from fastapi.responses import JSONResponse - -# --- Version Endpoints --- -@router.get( - "/versions", - response_model=Union[schema.VersionResponse, List[schema.VersionResponse]], - tags=["Version"] -) - -async def get_versions( - version_id: Optional[int] = Query(None), - abbreviation: Optional[str] = Query(None), - db_session: Session = Depends(get_db), - session: SessionContainer = Depends(verify_session_data) -): - """Get all versions or a single version by ID.""" - logger.info("GET Version API") - validate_admin_only(session) - _, _ = await ensure_user_from_session_async(db_session, session) - if version_id is not None or abbreviation is not None: - db_obj = crud.get_version(db_session, version_id,abbreviation) - if not db_obj: - logger.error("Version not found") - raise NotAvailableException(detail="Version not found") - return db_obj - return crud.get_all_versions(db_session) - - -@router.post("/versions", response_model=schema.VersionResponse,tags=["Version"]) -async def create_version( - version: schema.VersionCreate, - db_session: Session = Depends(get_db), - session: SessionContainer = Depends(verify_session_data) -): - """Create a new version.""" - logger.info("POST Version API") - validate_admin_only(session) - _, _ = await ensure_user_from_session_async(db_session, session) - db_obj = crud.create_version(db_session, version) - return db_obj - - -@router.put("/versions/{version_id}", response_model=schema.VersionResponse,tags=["Version"]) -async def update_version( - version_id: int, - version: schema.VersionUpdate, - db_session: Session = Depends(get_db), - session: SessionContainer = Depends(verify_session_data) -): - """Update an existing version by ID.""" - logger.info("PUT Version API") - validate_admin_only(session) - _, _ = await ensure_user_from_session_async(db_session, session) - db_obj = crud.update_version(db_session,version_id, version) - return db_obj - -@router.delete( - "/versions/bulk-delete", - tags=["Version"], - response_model=schema.VersionBulkDeleteResponse -) -async def delete_versions_bulk( - request: schema.VersionBulkDelete, - db_session: Session = Depends(get_db), - session: SessionContainer = Depends(verify_session_data), -): - logger.info("DELETE BULK Version API") - - # ---- Auth & role checks ---- - validate_admin_only(session) - await ensure_user_from_session_async(db_session, session) - - # ---- Business logic ---- - result = crud.delete_versions_bulk(db_session, request.version_ids) - - data = result["data"] - meta = result.get("meta", {}) - - deleted_count = data["deletedCount"] - deleted_ids = data["deletedIds"] - errors = data.get("errors") - - not_found = meta.get("not_found", []) - conflicts = meta.get("conflicts", []) - - # ---- Status code logic ---- - if conflicts and not deleted_ids: - status_code = 409 # Conflict: versions exist but are in use - elif not_found and not deleted_ids: - status_code = 404 # Not Found: versions don’t exist - elif conflicts or not_found: - status_code = 207 # Multi-Status: partial success - else: - status_code = 200 # All deleted successfully - - # ---- Message ---- - if deleted_count > 0: - message = f"Successfully deleted {deleted_count} version(s)" - else: - message = "No versions were deleted" - - response_data = { - "deletedCount": deleted_count, - "deletedIds": deleted_ids, - "errors": errors, - "message": message, - } - - return JSONResponse( - status_code=status_code, - content=response_data, - ) - -# --- Language Endpoints --- - -@router.get( - "/language", - response_model=schema.LanguageResponse, - tags=["Language"] -) -async def get_languages( - params: schema.LanguageQueryParams = Depends(), - db_session: Session = Depends(get_db), - session: SessionContainer = Depends(verify_session_data) -): - """Get languages with pagination and optional filtering.""" - logger.info("GET Languages API") - - validate_admin_only(session) - _, _roles = await ensure_user_from_session_async(db_session, session) - - languages, total_items = crud.get_languages_with_pagination( - db_session=db_session, - page=params.page, - page_size=params.page_size, - language_name=params.language_name, - language_code=params.language_code, - ) - - if (params.language_name or params.language_code) and total_items == 0: - logger.error("Language ID or Language doesn't exist") - raise NotAvailableException(detail="Language ID or Language doesn't exist") - - language_items = [ - schema.LanguageResponseItem( - language_id=lang.language_id, - language_name=lang.language_name, - language_code=lang.language_code, - metadata=lang.meta_data - ) - for lang in languages - ] - - return schema.LanguageResponse( - total_items=total_items, - current_page=params.page, - items=language_items - ) - -@router.post( - "/language", - response_model=schema.LanguageResponseItem, - tags=["Language"] -) -async def create_language( - lang: schema.LanguageCreate, - db_session: Session = Depends(get_db), - session: SessionContainer = Depends(verify_session_data) -): - """Create a new language.""" - logger.info("POST Languages API") - validate_admin_only(session) - _, _ = await ensure_user_from_session_async(db_session, session) - db_obj = crud.create_language(db_session, lang) - - return schema.LanguageResponseItem( - language_id=db_obj.language_id, - language_name=db_obj.language_name, - language_code=db_obj.language_code, - metadata=db_obj.meta_data - ) - -@router.put( - "/language/{language_id}", - response_model=schema.LanguageResponseItem, - tags=["Language"] -) -async def update_language( - language_id: int, - lang: schema.LanguageUpdate, - db_session: Session = Depends(get_db), - session: SessionContainer = Depends(verify_session_data), -): - """Update an existing language.""" - logger.info("PUT Languages API") - validate_admin_only(session) - _, _ = await ensure_user_from_session_async(db_session, session) - # Check if language exists before attempting to update - language_obj = crud.get_language(db_session, language_id) - if not language_obj: - logger.error("Language ID or Language doesn't exist") - raise NotAvailableException(detail="Language ID or Language doesn't exist") - - db_obj = crud.update_language(db_session, language_id, lang) - - return schema.LanguageResponseItem( - language_id=db_obj.language_id, - language_name=db_obj.language_name, - language_code=db_obj.language_code, - metadata=db_obj.meta_data - ) - -@router.delete( - "/languages/bulk-delete", - tags=["Language"], - response_model=schema.LanguageBulkDeleteResponse -) -async def delete_languages_bulk( - request: schema.LanguageBulkDelete, - db_session: Session = Depends(get_db), - session: SessionContainer = Depends(verify_session_data) -): - logger.info("DELETE BULK Language API") - - validate_admin_only(session) - _, _ = await ensure_user_from_session_async(db_session, session) - - result = crud.delete_languages_bulk(db_session, request.language_ids) - - deleted_count = result["data"]["deletedCount"] - errors = result["data"]["errors"] - - # ---- Status logic (same as videos & versions) ---- - if result["all_failed"]: - status_code = 404 - elif result["has_errors"]: - status_code = 207 - else: - status_code = 200 - - # Add message - message = ( - f"Successfully deleted {deleted_count} language(s)" - if deleted_count > 0 - else "No languages were deleted" - ) - - response_data = { - **result["data"], - "message": message - } - - return JSONResponse( - status_code=status_code, - content=response_data - ) - -# --- License Endpoints --- -@router.get( - "/license", - response_model=List[schema.LicenseResponseItem], - tags=["License"] -) -async def get_licenses( - license_id: Optional[int] = Query(None, description="Filter by license ID"), - name: Optional[str] = Query(None, description="Filter by license name (partial match)"), - db_session: Session = Depends(get_db), - session: SessionContainer = Depends(verify_session_data) -): - """Get licenses with optional filtering.""" - logger.info("GET License API") - validate_admin_only(session) - _, _ = await ensure_user_from_session_async(db_session, session) - licenses = crud.get_licenses_with_filters( - db_session=db_session, - license_id=license_id, - name=name - ) - - # Transform to response format - return [schema.LicenseResponseItem.model_validate(license) for license in licenses] - -@router.post( - "/license", - response_model=schema.LicenseResponseItem, - tags=["License"] -) -async def create_license( - license_: schema.LicenseCreate, - db_session: Session = Depends(get_db), - session: SessionContainer = Depends(verify_session_data) -): - """Create a new license.""" - logger.info("POST License API") - validate_admin_only(session) - _, _ = await ensure_user_from_session_async(db_session, session) - db_obj = crud.create_license(db_session, license_) - - return schema.LicenseResponseItem.model_validate(db_obj) - -@router.put( - "/license/{license_id}", - response_model=schema.LicenseResponseItem, - tags=["License"] -) -async def update_license( - license_id: int, - license_: schema.LicenseUpdate, - db_session: Session = Depends(get_db), - session: SessionContainer = Depends(verify_session_data) -): - """Update an existing license.""" - logger.info("PUT License API") - validate_admin_only(session) - _, _ = await ensure_user_from_session_async(db_session, session) - # Check if license exists before attempting to update - license_obj = crud.get_license(db_session, license_id) - if not license_obj: - logger.error("License ID doesn't exist") - raise NotAvailableException(detail="License ID doesn't exist") - - db_obj = crud.update_license(db_session, license_id, license_) - - return schema.LicenseResponseItem.model_validate(db_obj) - -@router.delete( - "/license/bulk-delete", - tags=["License"], - response_model=schema.LicenseBulkDeleteResponse -) -async def delete_licenses_bulk( - request: schema.LicenseBulkDelete, - db_session: Session = Depends(get_db), - session: SessionContainer = Depends(verify_session_data) -): - logger.info("DELETE BULK License API") - - validate_admin_only(session) - _, _ = await ensure_user_from_session_async(db_session, session) - - result = crud.delete_licenses_bulk(db_session, request.license_ids) - - deleted_count = result["data"]["deletedCount"] - errors = result["data"]["errors"] - - # ---- Status code logic ---- - if result["all_failed"]: - status_code = 404 - elif result["has_errors"]: - status_code = 207 - else: - status_code = 200 - - # ---- Message ---- - message = ( - f"Successfully deleted {deleted_count} license(s)" - if deleted_count > 0 - else "No licenses were deleted" - ) - - response_data = { - **result["data"], - "message": message - } - - return JSONResponse( - status_code=status_code, - content=response_data - ) - - -# --- Resource Endpoints --- -@router.get( - "/resources", - response_model=List[schema.LanguageGroupOut], - tags=["Resource"] -) -async def list_resources_route( - params: schema.ResourceQueryParams = Depends(), - db: Session = Depends(get_db), - session: SessionContainer = Depends(verify_session_data), -): - """Get resources with pagination and optional filtering.""" - logger.info("GET Resource API") - validate_all_roles(session) - _, _ = await ensure_user_from_session_async(db, session) - - filters = schema.ResourceFilter( - resource_id=params.resource_id, - page=params.page, - page_size=params.page_size, - published=params.published, - content_type=params.content_type.value.lower() if params.content_type else None, - ) - - return crud.get_resources(db, filters) - -@router.post("/resources", response_model=schema.ResourceResponse, tags=["Resource"]) -async def create_resource_route( - payload: schema.ResourceCreate, - db: Session = Depends(get_db), - session: SessionContainer = Depends(verify_session_data - ) -): - """ API endpoint to create a new resource.""" - logger.info("POST Resource API") - validate_admin_only(session) - user_id, _ = await ensure_user_from_session_async(db, session) - return crud.create_resource(db, payload, created_by=user_id) - - - -@router.put("/resources", response_model=schema.ResourceResponse, tags=["Resource"]) -async def update_resource_route( - payload: schema.ResourceUpdate, - db: Session = Depends(get_db), - session: SessionContainer = Depends(verify_session_data) -): - """ API endpoint to update a resource.""" - logger.info("PUT Resource API") - validate_admin_only(session) - user_id, _ = await ensure_user_from_session_async(db, session) - return crud.update_resource(db, payload, user_id=user_id) - -@router.delete( - "/resources/bulk-delete", - tags=["Resource"], - response_model=schema.ResourceBulkDeleteResponse -) -async def delete_resources_bulk( - request: schema.ResourceBulkDelete, - db: Session = Depends(get_db), - session: SessionContainer = Depends(verify_session_data) -): - logger.info("DELETE BULK Resource API") - - validate_admin_only(session) - _, _ = await ensure_user_from_session_async(db, session) - - result = crud.delete_resources_bulk(db, request.resource_ids) - - deleted_count = result["data"]["deletedCount"] - errors = result["data"]["errors"] - - # ---- Status Code Logic ---- - if result["all_failed"]: - status_code = 404 - elif result["has_errors"]: - status_code = 207 - else: - status_code = 200 - - # ---- Message ---- - message = ( - f"Successfully deleted {deleted_count} resource(s)" - if deleted_count > 0 - else "No resources were deleted" - ) - - response_data = { - **result["data"], - "message": message - } - - return JSONResponse( - status_code=status_code, - content=response_data - ) - - -# ----Logs Endpoints------- - -@router.get("/log",tags=["logs"]) -def get_latest_log(session: SessionContainer = Depends(verify_session_data)): - """ - current/activate log file - """ - validate_admin_only(session) - return crud.latest_log_file() - - - - -@router.get("/log/{log_file_no}",tags=["logs"]) -def get_log_by_number(log_file_no: int, - session: SessionContainer = Depends(verify_session_data)): - """ - View rotated log files - * The handler keeps up to 10 old files - * vachan_admin_app.log,vachan_admin_app.log.1,vachan_admin_app.log.1 ... vachan_admin_app.log.10 - * log_file_no must be 0–10 - * current log file no is 0 - """ - validate_admin_only(session) - return crud.get_logfile_by_number(log_file_no) - - -@router.get("/logs",tags=["logs"]) -def get_all_logs(session: SessionContainer = Depends(verify_session_data)): - """ - get all log files in a zip format - """ - validate_admin_only(session) - return crud.get_all_logfiles() - - - -# --- Bible Endpoints --- - - -# --- Bible Book Management Endpoints --- - -@router.post( - "/bible", - response_model=dict, - tags=["Bible"] -) -async def upload_bible_book( - resource_id: int = Form(...), - usfm: UploadFile = File(...), - db_session: Session = Depends(get_db), - session: SessionContainer = Depends(verify_session_data) -): - """Upload a new bible book USFM file""" - validate_admin_editor(session) - - # Get user ID from session - actor_id, _ = await ensure_user_from_session_async(db_session, session) - # Validate USFM file before processing - validation_result = await crud.validate_usfm_file(usfm) - if not validation_result["valid"]: - raise UnprocessableException(detail=validation_result["error"]) - return crud.upload_bible_book( - db_session=db_session, - resource_id=resource_id, - usfm_file=usfm, - actor_user_id=actor_id - ) - -@router.put( - "/bible", - response_model=dict, - tags=["Bible"] -) -async def update_bible_book( - bible_book_id: int = Form(...), - usfm: UploadFile = File(...), - db_session: Session = Depends(get_db), - session: SessionContainer = Depends(verify_session_data) -): - """Update an existing bible book""" - validate_admin_editor(session) - - # Get user ID from session - actor_id, _ = await ensure_user_from_session_async(db_session, session) - validation_result = await crud.validate_usfm_file(usfm) - if not validation_result["valid"]: - raise UnprocessableException(detail=validation_result["error"]) - return crud.update_bible_book( - db_session=db_session, - bible_book_id=bible_book_id, - usfm_file=usfm, - actor_user_id=actor_id - ) - -@router.delete( - "/bible/{resource_id}/books", - response_model=schema.BulkDeleteResponse, - response_model_exclude_none=True, - tags=["Bible"] -) -async def delete_bible_books_endpoint( - resource_id: int, - delete_request: schema.BulkDeleteRequest, - db_session: Session = Depends(get_db), - session: SessionContainer = Depends(verify_session_data) -): - """Bulk delete Bible books by book codes""" - validate_admin_editor(session) - _, _ = await ensure_user_from_session_async(db_session, session) - - result = crud.delete_bible_books( - db_session=db_session, - resource_id=resource_id, - book_codes=delete_request.bookIds, - ) - - deleted_count = result["data"]["deletedCount"] - errors = result["data"]["errors"] - - # ---- Standard status code logic ---- - if result["all_failed"]: - status_code = 404 - elif result["has_errors"]: - status_code = 207 - else: - status_code = 200 - - # ---- Message ---- - message = ( - f"Successfully deleted {deleted_count} book(s)" - if deleted_count > 0 - else "No books were deleted" - ) - - response_data = { - **result["data"], - "message": message, - } - - return JSONResponse(status_code=status_code, content=response_data) - - - -# --- Bible Content Retrieval Endpoints --- - -@router.get( - "/bible/{resource_id}/books", - response_model=schema.BibleBooksListResponse, - tags=["Bible"] -) -async def get_bible_books( - resource_id: int, - db_session: Session = Depends(get_db), - session: SessionContainer = Depends(verify_session_data) -): - """Get list of books for a bible resource""" - validate_all_roles(session) - _, _ = await ensure_user_from_session_async(db_session, session) - return crud.get_bible_books(db_session, resource_id) - - - -@router.get( - "/bible/{resource_id}/content/{output_format}", - response_model=schema.BibleFullContentResponse, - tags=["Bible"] -) -async def get_full_bible_content( - resource_id: int, - output_format: str, - db_session: Session = Depends(get_db), - session: SessionContainer = Depends(verify_session_data) -): - """Get full content of all books in a resource in specified format (json/usfm)""" - validate_all_roles(session) - _, _ = await ensure_user_from_session_async(db_session, session) - - if output_format.lower() not in ["json", "usfm"]: - raise BadRequestException("Format must be 'json' or 'usfm'") - - return crud.get_full_bible_content( - db_session=db_session, - resource_id=resource_id, - output_format=output_format - ) - - - -@router.get( - "/bible/{resource_id}/book/{book_code}/{output_format}", - response_model=schema.BibleBookContentResponse, - tags=["Bible"] -) -async def get_bible_book_content( - resource_id: int, - book_code: str, - output_format: str, - db_session: Session = Depends(get_db), - session: SessionContainer = Depends(verify_session_data) -): - """Get full content of a book in specified format (json/usfm)""" - validate_all_roles(session) - _, _ = await ensure_user_from_session_async(db_session, session) - - if output_format.lower() not in ["json", "usfm"]: - raise BadRequestException("Format must be 'json' or 'usfm'") - - return crud.get_bible_book_content( - db_session=db_session, - resource_id=resource_id, - book_code=book_code, - output_format=output_format - ) - -@router.get( - "/bible/{resource_id}/chapter/{book_code}.{chapter}", - response_model=schema.BibleChapterResponse, - tags=["Bible"] -) -async def get_bible_chapter( - resource_id: int, - book_code: str, - chapter: int, - db_session: Session = Depends(get_db), - session: SessionContainer = Depends(verify_session_data) -): - """Get chapter content from bible table""" - validate_all_roles(session) - _, _ = await ensure_user_from_session_async(db_session, session) - - return crud.get_bible_chapter( - db_session=db_session, - resource_id=resource_id, - book_code=book_code, - chapter=chapter - ) - -@router.get( - "/bible/{resource_id}/cleaned/chapter/{book_code}.{chapter}", - response_model=schema.CleanBibleChapterResponse, - tags=["Bible"] -) -async def get_clean_bible_chapter( - resource_id: int, - book_code: str, - chapter: int, - db_session: Session = Depends(get_db), - session: SessionContainer = Depends(verify_session_data) -): - """Get cleaned chapter content from clean_bible table""" - validate_all_roles(session) - _, _ = await ensure_user_from_session_async(db_session, session) - - return crud.get_clean_bible_chapter( - db_session=db_session, - resource_id=resource_id, - book_code=book_code, - chapter=chapter - ) - -async def get_bible_verse_params( - resource_id: int, - book_code: str, - chapter: int, - verse: int -) -> BibleVersePathParams: - """Get specific verse content""" - return BibleVersePathParams( - resource_id=resource_id, - book_code=book_code, - chapter=chapter, - verse=verse, - ) -@router.get( - "/bible/{resource_id}/verse/{book_code}.{chapter}.{verse}", - response_model=schema.BibleVerseResponse, - tags=["Bible"] -) -async def get_bible_verse( - params: schema.BibleVersePathParams = Depends(get_bible_verse_params), - db_session: Session = Depends(get_db), - session: SessionContainer = Depends(verify_session_data), -): - """Get specific verse content""" - - validate_all_roles(session) - _, _ = await ensure_user_from_session_async(db_session, session) - - return crud.get_bible_verse( - db_session=db_session, - resource_id=params.resource_id, - book_code=params.book_code, - chapter=params.chapter, - verse=params.verse, - ) - -# --- Video Endpoints --- - -@router.post("/videos", tags=["Video"], response_model=schema.VideoBulkCreateResponse) -async def create_videos( - data: schema.VideoBulkCreate, - db: Session = Depends(get_db), - session: SessionContainer = Depends(verify_session_data) -): - """Create videos""" - validate_admin_editor(session) - actor_id, _ = await ensure_user_from_session_async(db, session) - all_errors = [] - for idx, vid in enumerate(data.videos): - errs = crud.validate_video_item(db, vid) - if errs: - all_errors.append({ - "index": idx, - "data": vid.model_dump(), - "errors": errs - }) - if all_errors: - raise UnprocessableException( - detail=( - { - "code": "VALIDATION_ERROR", - "message": "Invalid video entries", - "errors": all_errors, - } - ) - ) - return crud.create_videos(db, data, actor_user_id=actor_id) - -@router.put("/videos", tags=["Video"], response_model=schema.VideoBulkCreateResponse) -async def update_videos( - data: schema.VideoBulkUpdate, - db: Session = Depends(get_db), - session: SessionContainer = Depends(verify_session_data) -): - """Update videos""" - validate_admin_editor(session) - actor_id, _ = await ensure_user_from_session_async(db, session) - all_errors = [] - for idx, vid in enumerate(data.videos): - errs = crud.validate_video_item(db, vid) - if errs: - all_errors.append({ - "index": idx, - "data": vid.model_dump(), - "errors": errs - }) - - if all_errors: - raise UnprocessableException( - detail=( - { - "code": "VALIDATION_ERROR", - "message": "Invalid video entries", - "errors": all_errors - } - ) - ) - return crud.update_videos(db, data, actor_user_id=actor_id) - -@router.get("/videos", tags=["Video"], response_model=schema.VideoGetOut) -async def get_videos( - resource_id: Optional[int] = None, - book_code: Optional[str] = None, - chapter: Optional[int] = None, - db: Session = Depends(get_db), - session: SessionContainer = Depends(verify_session_data) -): - """Get videos filtered by resource_id, book_code, and chapter""" - validate_all_roles(session) - _, _ = await ensure_user_from_session_async(db, session) - return crud.get_videos_filtered( - db=db, - resource_id=resource_id, - book_code=book_code, - chapter=chapter - ) - - -@router.delete("/videos/{resource_id}", tags=["Video"], response_model=schema.VideoBulkDeleteResponse) -async def delete_videos( - resource_id: int, - data: schema.VideoBulkDelete, - response: Response, - db: Session = Depends(get_db), - session: SessionContainer = Depends(verify_session_data), -): - """Delete multiple videos""" - validate_admin_editor(session) - _, _ = await ensure_user_from_session_async(db, session) - - result = crud.delete_videos(db, resource_id, data.video_id) - - # Set appropriate status code - if result["all_failed"]: - response.status_code = 404 # All videos not found - elif result["has_errors"]: - response.status_code = 207 # Partial success (Multi-Status) - else: - response.status_code = 200 # All successful - - return result["data"] - -# ---Commentary Endpoints --- - -# --- POST --- -@router.post( - "/commentary", - tags=["Commentary"], - response_model=schema.CommentaryCreateResponse, - openapi_extra={ - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "resource_id": {"type": "integer", "example": 0}, - "commentary": { - "type": "array", - "items": { - "type": "object", - "properties": { - "book_id": {"type": "integer", "example": 0}, - "chapter": {"type": "integer", "example": 0}, - "verse": { - "type": "string", - "description": "Must be a positive integer or a range like '4-25'" - }, - "text": { - "type": "string", - "example": "

Commentary text here

" - } - }, - "required": ["book_id", "chapter", "verse", "text"] - } - } - }, - "required": ["resource_id", "commentary"], - "example": { - "resource_id": 0, - "commentary": [ - { - "book_id": 0, - "chapter": 0, - "verse": "string", - "text": "

Sample commentary text

" - } - ] - } - } - } - }, - "required": True - } - } -) -async def create_commentary( - request: Request, - session: SessionContainer = Depends(verify_session()), -): - """ - Create commentary - - Requires admin or editor role. Authorization is checked before request validation. - - The verse field must be either: - - A positive integer (e.g., "5") - - A range (e.g., "4-25") - """ - - # Call the AuthFirstBody dependency manually - auth_body = schema.AuthFirstBody(schema.CommentaryBulkCreate) - payload, actor_id, db = await auth_body(request, session) - - try: - # Validate the payload content - for item in payload.commentary: - crud.validate_html(item.text) - crud.validate_commentary_book_and_chapter(db, item.book_id, item.chapter) - - result = crud.create_commentaries(db, payload, actor_user_id=actor_id) - return result - finally: - db.close() - -# --- PUT --- -@router.put( - "/commentary", - tags=["Commentary"], - response_model=schema.CommentaryUpdateResponse, - openapi_extra={ - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "resource_id": {"type": "integer"}, - "commentary": { - "type": "array", - "items": { - "type": "object", - "properties": { - "commentary_id": {"type": "integer"}, - "book_id": {"type": "integer"}, - "chapter": {"type": "integer"}, - "verse": {"type": "string"}, - "text": {"type": "string"} - }, - "required": ["commentary_id", "book_id", "chapter", "verse", "text"] - } - } - }, - "required": ["commentary"] - } - } - }, - "required": True - } - } -) -async def update_commentary( - request: Request, - session: SessionContainer = Depends(verify_session()), -): - """ - Update commentary - - Requires admin or editor role. Authorization is checked before request validation. - """ - - # Call the AuthFirstBody dependency manually - auth_body = schema.AuthFirstBody(schema.CommentaryBulkUpdate) - payload, actor_id, db = await auth_body(request, session) - - try: - # Validate the payload content - for item in payload.commentary: - crud.validate_html(item.text) - crud.validate_commentary_book_and_chapter(db, item.book_id, item.chapter) - - result = crud.update_commentaries(db, payload, actor_user_id=actor_id) - return result - finally: - db.close() - -# --- GET (full content for a resource) --- -@router.get("/commentary/{resource_id}",tags=["Commentary"]) -async def get_full( - resource_id: int = Path(..., ge=1), - db: Session = Depends(get_db), - session: SessionContainer = Depends(verify_session_data) -): - """Get full commentary""" - validate_all_roles(session) - _, _ = await ensure_user_from_session_async(db, session) - return crud.get_full_commentary(db, resource_id) - -# --- GET (full content of a chapter; supports path like book_code.chapter) --- -@router.get("/commentary/{resource_id}/chapter/{book_code}.{chapter}",tags=["Commentary"]) -async def get_chapter( - resource_id: int = Path(..., ge=1), - book_code: str = Path(..., description="Book code, e.g., 'mat'"), - chapter: int = Path(..., ge=0, description="Chapter number (0 allowed)"), - db: Session = Depends(get_db), - session: SessionContainer = Depends(verify_session_data) -): - """Get chapter commentary""" - validate_all_roles(session) - _, _ = await ensure_user_from_session_async(db, session) - # 1) Resolve book_code -> bookId (404 if unknown code entirely) - book = ( - db.query(db_models.BookLookup) - .filter(db_models.BookLookup.book_code.ilike(book_code.strip())) - .first() - ) - if not book: - raise NotAvailableException(detail=f"Book with code '{book_code}' not found") - - # 2) Ensure this resource has ANY commentary for that book - #(book-level existence in commentary data) - has_book_commentary = ( - db.query(db_models.Commentary.commentary_id) - .filter( - db_models.Commentary.resource_id == resource_id, - db_models.Commentary.book_id == book.book_id, - ) - .first() - ) - if not has_book_commentary: - raise NotAvailableException( - detail=f"No commentary found for book_code '{book_code}' in resource {resource_id}" - ) - - # 3) Ensure this chapter exists in commentary data for that book & resource - has_chapter_commentary = ( - db.query(db_models.Commentary.commentary_id) - .filter( - db_models.Commentary.resource_id == resource_id, - db_models.Commentary.book_id == book.book_id, - db_models.Commentary.chapter == chapter, - ) - .first() - ) - if not has_chapter_commentary: - raise NotAvailableException( - detail=( - f"Chapter {chapter} not found in commentary for book_code '" - f"{book_code}' (resource {resource_id})" - - ) - ) - - # 4) Return the chapter payload (this includes the book intro if present) - return crud.get_commentary_chapter(db, resource_id, book.book_code, chapter) - -# --- DELETE by commentary_id --- -@router.delete( - "/commentary/bulk-delete", - tags=["Commentary"], - response_model=schema.CommentaryBulkDeleteResponse -) -async def delete_commentary_bulk( - request: schema.CommentaryBulkDelete, - db: Session = Depends(get_db), - session: SessionContainer = Depends(verify_session_data) -): - logger.info("DELETE BULK Commentary API") - - validate_admin_editor(session) - _, _ = await ensure_user_from_session_async(db, session) - - deleted_ids, errors = crud.delete_commentary_bulk(db, request.commentary_ids) - - # --- Status Code Rules --- - if len(deleted_ids) == 0 and errors: # All failed → 404 - status_code = 404 - elif errors: # Partial success → 207 - status_code = 207 - else: # All succeeded → 200 - status_code = 200 - - return JSONResponse( - status_code=status_code, - content={ - "deletedCount": len(deleted_ids), - "deletedIds": deleted_ids, - "errors": errors if errors else None, - "message": f"Successfully deleted {len(deleted_ids)} commentary(s)" - } - ) - - - - -# ---Dictionary Endpoints --- - -@router.post('/dictionary', - response_model=schema.DictionaryCreateResponse, - status_code=201, tags=["Dictionary"]) -async def add_dictionary_words( - dictionary_data: schema.DictionaryCreate = Body(...), - db_: Session = Depends(get_db), - session: SessionContainer = Depends(verify_session_data ) - ): - '''Uploads dictionary words and their details. - Returns all dictionary words with complete details''' - logger.info('In add_dictionary_words') - logger.debug( - 'resource_id: %s, dictionary_words: %s', - dictionary_data.resource_id, - dictionary_data.dictionary - ) - validate_admin_editor(session) - actor_id, _ = await ensure_user_from_session_async(db_, session) - result_data = crud.upload_dictionary_words( - db_=db_, - resource_id=dictionary_data.resource_id, - dictionary_words=dictionary_data.dictionary, - actor_id=actor_id - ) - dictionary_content = result_data['db_content'] - - return { - 'message': "Dictionary words added successfully", - 'data': dictionary_content - } - - -@router.put('/dictionary', - response_model=schema.DictionaryUpdateResponse, - status_code=200, tags=["Dictionary"]) -async def edit_dictionary_words( - dictionary_data: schema.DictionaryUpdate = Body(...), - db_: Session = Depends(get_db), - session: SessionContainer = Depends(verify_session_data ) - ): - '''Updates dictionary words using wordId. - All fields except wordId can be updated.''' - logger.info('In edit_dictionary_words') - logger.debug( - 'resource_id: %s, dictionary_words: %s', - dictionary_data.resource_id, - dictionary_data.dictionary - ) - validate_admin_editor(session) - actor_id, _ = await ensure_user_from_session_async(db_, session) - result_data = crud.update_dictionary_words( - db_=db_, - resource_id=dictionary_data.resource_id, - dictionary_words=dictionary_data.dictionary, - actor_id=actor_id - ) - dictionary_content = result_data['db_content'] - - return { - 'message': "Dictionary words updated successfully", - 'data': dictionary_content - } - - -@router.get( - "/dictionary/{resource_id}", - response_model=schema.DictionaryFullResponse, - status_code=200, - tags=["Dictionary"], -) -async def get_dictionary_full_content( - resource_id: int = Path(..., examples=1), - params: schema.DictionaryQueryParams = Depends(), - db_: Session = Depends(get_db), - session: SessionContainer = Depends(verify_session_data), -): - """Fetches full content of a dictionary by resource_id. - Returns all dictionary words with complete details.""" - - logger.info("In get_dictionary_full_content") - logger.debug( - "resource_id: %s, skip: %s, limit: %s", - resource_id, - params.skip, - params.limit, - ) - - validate_all_roles(session) - _, _ = await ensure_user_from_session_async(db_, session) - - response = crud.get_dictionary_words( - db_, - resource_id=resource_id, - skip=params.skip, - limit=params.limit - #actor_id=actor_id - ) - - return { - "resourceId": resource_id, - "content": response["content"], - } - -@router.get('/dictionary/{resource_id}/index', - response_model=schema.DictionaryIndexResponse, - status_code=200, tags=["Dictionary"]) -async def get_dictionary_index( - resource_id: int = Path(..., examples=1), - db_: Session = Depends(get_db), - session: SessionContainer = Depends(verify_session_data ) - ): - '''Fetches index of a dictionary grouped by first letter. - Returns wordId and keyword organized by starting letter.''' - logger.info('In get_dictionary_index') - logger.debug('resource_id: %s', resource_id) - validate_all_roles(session) - _, _ = await ensure_user_from_session_async(db_, session) - response = crud.get_dictionary_index( - db_, - resource_id=resource_id - ) - - return { - 'resourceId': resource_id, - 'index': response['index'] - } - - -@router.get('/dictionary/{resource_id}/word/{word_id}', - response_model=schema.DictionaryWordDetailResponse, - status_code=200, tags=["Dictionary"]) -async def get_dictionary_word( - resource_id: int = Path(..., examples=1), - word_id: int = Path(..., examples=100001), - db_: Session = Depends(get_db), - session: SessionContainer = Depends(verify_session_data ) - ): - '''Fetches details of a specific dictionary word by wordId.''' - logger.info('In get_dictionary_word') - logger.debug('resource_id: %s, word_id: %s', resource_id, word_id) - validate_all_roles(session) - _, _ = await ensure_user_from_session_async(db_, session) - response = crud.get_dictionary_word_by_id( - db_, - resource_id=resource_id, - word_id=word_id - ) - - if not response['db_content']: - raise NotAvailableException( - detail=f"Word id {word_id} not found in resource {resource_id}" - ) - - word_data = response['db_content'] - return { - 'resourceId': resource_id, - 'wordId': word_data.word_id, - 'keyword': word_data.keyword, - 'wordForms': word_data.word_forms, - 'strongs': word_data.strongs, - 'definition': word_data.definition, - 'translationHelp': word_data.translation_help, - 'seeAlso': word_data.see_also, - 'ref': word_data.ref, - 'examples': word_data.examples - } - - -@router.delete( - "/dictionary/{resource_id}/word", - response_model=schema.DictionaryDeleteResponse, - tags=["Dictionary"] -) -async def delete_dictionary_words( - resource_id: int = Path(..., examples=1), - delete_request: schema.DictionaryDeleteRequest = Body(...), - db_: Session = Depends(get_db), - session: SessionContainer = Depends(verify_session_data) -): - """Delete multiple dictionary words by their wordIds.""" - logger.info('In delete_dictionary_words') - validate_admin_editor(session) - _, _ = await ensure_user_from_session_async(db_, session) - - result = crud.delete_dictionary_words( - db_=db_, - resource_id=resource_id, - word_ids=delete_request.wordIds - ) - - deleted = result["deleted_ids"] - has_errors = result["has_errors"] - - # ------------------------- - # Status Code Logic - # ------------------------- - if len(deleted) == 0 and has_errors: - status_code = 404 # All invalid - elif has_errors: - status_code = 207 # Partial success - else: - status_code = 200 # Full success - - return JSONResponse( - status_code=status_code, - content={ - "message": result["message"], - "deletedCount": result["deleted_count"], - "deletedIds": result["deleted_ids"], - "error": result.get("error"), - } - ) - - -# --- AudioBible Endpoints --- - - -@router.post("/audio-bible", tags=["Audio Bible"], response_model=schema.AudioBibleOut) -async def create_audio_bible(data: schema.AudioBibleCreate, db: Session = Depends(get_db), - session: SessionContainer = Depends(verify_session_data)): - """Create a new audio bible entry""" - validate_admin_editor(session) - actor_id, _ = await ensure_user_from_session_async(db, session) - errors = crud.validate_audio_bible_books(db, data.books) - if errors: - raise UnprocessableException( - detail={"code": "VALIDATION_ERROR", "errors": errors} - ) - return crud.create_audio_bible(db, data, actor_user_id=actor_id) - - -@router.get( - "/audio-bible", - tags=["Audio Bible"], - response_model=List[schema.AudioBibleListItem] -) -async def list_audio_bibles( - params: schema.AudioBibleQueryParams = Depends(), - db: Session = Depends(get_db), - session: SessionContainer = Depends(verify_session_data) -): - """ - List all audio bibles with optional filtering and pagination. - - - resource_id: Optional filter for specific audio bible - - limit: Max items to return - - offset: Pagination offset - - files_missing: Filter by missing/available files - - test_date: Return audio bibles tested on/after the timestamp - """ - validate_all_roles(session) - _, _ = await ensure_user_from_session_async(db, session) - - return crud.list_audio_bibles( - db=db, - resource_id=params.resource_id, - limit=params.limit, - offset=params.offset, - files_missing=params.files_missing, - test_date=params.test_date - ) - - -@router.put("/audio-bible/{resource_id}", tags=["Audio Bible"], response_model=schema.AudioBibleOut) -async def update_audio_bible( - resource_id: int, - update_data: schema.AudioBibleUpdate, - db: Session = Depends(get_db), - session: SessionContainer = Depends(verify_session_data) -): - """Update an existing audio bible""" - validate_admin_editor(session) - actor_id,_ = await ensure_user_from_session_async(db, session) - result = crud.update_audio_bible(db, resource_id, update_data, actor_user_id=actor_id) - if not result: - raise NotAvailableException(detail="Audio Bible not found") - if update_data.books is not None: - errors = crud.validate_audio_bible_books(db, update_data.books) - if errors: - raise UnprocessableException( - detail={"code": "VALIDATION_ERROR", "errors": errors} - ) - return result - - -@router.delete("/audio-bible/{resource_id}", tags=["Audio Bible"]) -async def delete_audio_bible(resource_id: int, db: Session = Depends(get_db), - session: SessionContainer = Depends(verify_session_data)): - """Delete an audio bible""" - validate_admin_editor(session) - _, _ = await ensure_user_from_session_async(db, session) - result = crud.delete_audio_bible(db, resource_id) - if not result: - raise NotAvailableException(detail="Audio Bible not found") - return {"detail": f"Audio Bible deleted successfully for resource_id {resource_id}"} -# @router.delete( -# "/versions/bulk-delete", -# tags=["Version"], -# response_model=schema.VersionBulkDeleteResponse -# ) -# async def delete_versions_bulk( -# request: schema.VersionBulkDelete, -# db_session: Session = Depends(get_db), -# session: SessionContainer = Depends(verify_session_data) -# ): -# logger.info("DELETE BULK Version API") - -# validate_admin_only(session) -# _, _ = await ensure_user_from_session_async(db_session, session) - -# result = crud.delete_versions_bulk(db_session, request.version_ids) - -# deleted_count = result["data"]["deletedCount"] -# errors = result["data"]["errors"] - -# # ---- Status Code Logic ---- -# if result["all_failed"]: -# status_code = 404 -# elif result["has_errors"]: -# status_code = 207 -# else: -# status_code = 200 - -# message = ( -# f"Successfully deleted {deleted_count} version(s)" -# if deleted_count > 0 -# else "No versions were deleted" -# ) - -# response_data = { -# **result["data"], -# "message": message -# } - -# return JSONResponse( -# status_code=status_code, -# content=response_data -# ) - - - -# # --- Language Endpoints --- - -# @router.get( -# "/language", -# response_model=schema.LanguageResponse, -# tags=["Language"] -# ) -# async def get_languages( -# params: schema.LanguageQueryParams = Depends(), -# db_session: Session = Depends(get_db), -# session: SessionContainer = Depends(verify_session_data) -# ): -# """Get languages with pagination and optional filtering.""" -# logger.info("GET Languages API") - -# validate_admin_only(session) -# _, _roles = await ensure_user_from_session_async(db_session, session) - -# languages, total_items = crud.get_languages_with_pagination( -# db_session=db_session, -# page=params.page, -# page_size=params.page_size, -# language_name=params.language_name, -# language_code=params.language_code, -# ) - -# if (params.language_name or params.language_code) and total_items == 0: -# logger.error("Language ID or Language doesn't exist") -# raise NotAvailableException(detail="Language ID or Language doesn't exist") - -# language_items = [ -# schema.LanguageResponseItem( -# language_id=lang.language_id, -# language_name=lang.language_name, -# language_code=lang.language_code, -# metadata=lang.meta_data -# ) -# for lang in languages -# ] - -# return schema.LanguageResponse( -# total_items=total_items, -# current_page=params.page, -# items=language_items -# ) - -# @router.post( -# "/language", -# response_model=schema.LanguageResponseItem, -# tags=["Language"] -# ) -# async def create_language( -# lang: schema.LanguageCreate, -# db_session: Session = Depends(get_db), -# session: SessionContainer = Depends(verify_session_data) -# ): -# """Create a new language.""" -# logger.info("POST Languages API") -# validate_admin_only(session) -# _, _ = await ensure_user_from_session_async(db_session, session) -# db_obj = crud.create_language(db_session, lang) - -# return schema.LanguageResponseItem( -# language_id=db_obj.language_id, -# language_name=db_obj.language_name, -# language_code=db_obj.language_code, -# metadata=db_obj.meta_data -# ) - -# @router.put( -# "/language/{language_id}", -# response_model=schema.LanguageResponseItem, -# tags=["Language"] -# ) -# async def update_language( -# language_id: int, -# lang: schema.LanguageUpdate, -# db_session: Session = Depends(get_db), -# session: SessionContainer = Depends(verify_session_data), -# ): -# """Update an existing language.""" -# logger.info("PUT Languages API") -# validate_admin_only(session) -# _, _ = await ensure_user_from_session_async(db_session, session) -# # Check if language exists before attempting to update -# language_obj = crud.get_language(db_session, language_id) -# if not language_obj: -# logger.error("Language ID or Language doesn't exist") -# raise NotAvailableException(detail="Language ID or Language doesn't exist") - -# db_obj = crud.update_language(db_session, language_id, lang) - -# return schema.LanguageResponseItem( -# language_id=db_obj.language_id, -# language_name=db_obj.language_name, -# language_code=db_obj.language_code, -# metadata=db_obj.meta_data -# ) - -# @router.delete( -# "/languages/bulk-delete", -# tags=["Language"], -# response_model=schema.LanguageBulkDeleteResponse -# ) -# async def delete_languages_bulk( -# request: schema.LanguageBulkDelete, -# db_session: Session = Depends(get_db), -# session: SessionContainer = Depends(verify_session_data) -# ): -# logger.info("DELETE BULK Language API") - -# validate_admin_only(session) -# _, _ = await ensure_user_from_session_async(db_session, session) - -# result = crud.delete_languages_bulk(db_session, request.language_ids) - -# deleted_count = result["data"]["deletedCount"] -# errors = result["data"]["errors"] - -# # ---- Status logic (same as videos & versions) ---- -# if result["all_failed"]: -# status_code = 404 -# elif result["has_errors"]: -# status_code = 207 -# else: -# status_code = 200 - -# # Add message -# message = ( -# f"Successfully deleted {deleted_count} language(s)" -# if deleted_count > 0 -# else "No languages were deleted" -# ) - -# response_data = { -# **result["data"], -# "message": message -# } - -# return JSONResponse( -# status_code=status_code, -# content=response_data -# ) - -# # --- License Endpoints --- -# @router.get( -# "/license", -# response_model=List[schema.LicenseResponseItem], -# tags=["License"] -# ) -# async def get_licenses( -# license_id: Optional[int] = Query(None, description="Filter by license ID"), -# name: Optional[str] = Query(None, description="Filter by license name (partial match)"), -# db_session: Session = Depends(get_db), -# session: SessionContainer = Depends(verify_session_data) -# ): -# """Get licenses with optional filtering.""" -# logger.info("GET License API") -# validate_admin_only(session) -# _, _ = await ensure_user_from_session_async(db_session, session) -# licenses = crud.get_licenses_with_filters( -# db_session=db_session, -# license_id=license_id, -# name=name -# ) - -# # Transform to response format -# return [schema.LicenseResponseItem.model_validate(license) for license in licenses] - -# @router.post( -# "/license", -# response_model=schema.LicenseResponseItem, -# tags=["License"] -# ) -# async def create_license( -# license_: schema.LicenseCreate, -# db_session: Session = Depends(get_db), -# session: SessionContainer = Depends(verify_session_data) -# ): -# """Create a new license.""" -# logger.info("POST License API") -# validate_admin_only(session) -# _, _ = await ensure_user_from_session_async(db_session, session) -# db_obj = crud.create_license(db_session, license_) - -# return schema.LicenseResponseItem.model_validate(db_obj) - -# @router.put( -# "/license/{license_id}", -# response_model=schema.LicenseResponseItem, -# tags=["License"] -# ) -# async def update_license( -# license_id: int, -# license_: schema.LicenseUpdate, -# db_session: Session = Depends(get_db), -# session: SessionContainer = Depends(verify_session_data) -# ): -# """Update an existing license.""" -# logger.info("PUT License API") -# validate_admin_only(session) -# _, _ = await ensure_user_from_session_async(db_session, session) -# # Check if license exists before attempting to update -# license_obj = crud.get_license(db_session, license_id) -# if not license_obj: -# logger.error("License ID doesn't exist") -# raise NotAvailableException(detail="License ID doesn't exist") - -# db_obj = crud.update_license(db_session, license_id, license_) - -# return schema.LicenseResponseItem.model_validate(db_obj) - -# @router.delete( -# "/license/bulk-delete", -# tags=["License"], -# response_model=schema.LicenseBulkDeleteResponse -# ) -# async def delete_licenses_bulk( -# request: schema.LicenseBulkDelete, -# db_session: Session = Depends(get_db), -# session: SessionContainer = Depends(verify_session_data) -# ): -# logger.info("DELETE BULK License API") - -# validate_admin_only(session) -# _, _ = await ensure_user_from_session_async(db_session, session) - -# result = crud.delete_licenses_bulk(db_session, request.license_ids) - -# deleted_count = result["data"]["deletedCount"] -# errors = result["data"]["errors"] - -# # ---- Status code logic ---- -# if result["all_failed"]: -# status_code = 404 -# elif result["has_errors"]: -# status_code = 207 -# else: -# status_code = 200 - -# # ---- Message ---- -# message = ( -# f"Successfully deleted {deleted_count} license(s)" -# if deleted_count > 0 -# else "No licenses were deleted" -# ) - -# response_data = { -# **result["data"], -# "message": message -# } - -# return JSONResponse( -# status_code=status_code, -# content=response_data -# ) - - -# # --- Resource Endpoints --- -# @router.get( -# "/resources", -# response_model=List[schema.LanguageGroupOut], -# tags=["Resource"] -# ) -# async def list_resources_route( -# params: schema.ResourceQueryParams = Depends(), -# db: Session = Depends(get_db), -# session: SessionContainer = Depends(verify_session_data), -# ): -# """Get resources with pagination and optional filtering.""" -# logger.info("GET Resource API") -# validate_all_roles(session) -# _, _ = await ensure_user_from_session_async(db, session) - -# filters = schema.ResourceFilter( -# resource_id=params.resource_id, -# page=params.page, -# page_size=params.page_size, -# published=params.published, -# content_type=params.content_type.value.lower() if params.content_type else None, -# ) - -# return crud.get_resources(db, filters) - -# @router.post("/resources", response_model=schema.ResourceResponse, tags=["Resource"]) -# async def create_resource_route( -# payload: schema.ResourceCreate, -# db: Session = Depends(get_db), -# session: SessionContainer = Depends(verify_session_data -# ) -# ): -# """ API endpoint to create a new resource.""" -# logger.info("POST Resource API") -# validate_admin_only(session) -# user_id, _ = await ensure_user_from_session_async(db, session) -# return crud.create_resource(db, payload, created_by=user_id) - - - -# @router.put("/resources", response_model=schema.ResourceResponse, tags=["Resource"]) -# async def update_resource_route( -# payload: schema.ResourceUpdate, -# db: Session = Depends(get_db), -# session: SessionContainer = Depends(verify_session_data) -# ): -# """ API endpoint to update a resource.""" -# logger.info("PUT Resource API") -# validate_admin_only(session) -# user_id, _ = await ensure_user_from_session_async(db, session) -# return crud.update_resource(db, payload, user_id=user_id) - -# @router.delete( -# "/resources/bulk-delete", -# tags=["Resource"], -# response_model=schema.ResourceBulkDeleteResponse -# ) -# async def delete_resources_bulk( -# request: schema.ResourceBulkDelete, -# db: Session = Depends(get_db), -# session: SessionContainer = Depends(verify_session_data) -# ): -# logger.info("DELETE BULK Resource API") - -# validate_admin_only(session) -# _, _ = await ensure_user_from_session_async(db, session) - -# result = crud.delete_resources_bulk(db, request.resource_ids) - -# deleted_count = result["data"]["deletedCount"] -# errors = result["data"]["errors"] - -# # ---- Status Code Logic ---- -# if result["all_failed"]: -# status_code = 404 -# elif result["has_errors"]: -# status_code = 207 -# else: -# status_code = 200 - -# # ---- Message ---- -# message = ( -# f"Successfully deleted {deleted_count} resource(s)" -# if deleted_count > 0 -# else "No resources were deleted" -# ) - -# response_data = { -# **result["data"], -# "message": message -# } - -# return JSONResponse( -# status_code=status_code, -# content=response_data -# ) - -# # @router.post( -# # "/language", -# # response_model=schema.LanguageResponseItem, -# # tags=["Language"] -# # ) -# # async def create_language( -# # lang: schema.LanguageCreate, -# # db_session: Session = Depends(get_db), -# # session: SessionContainer = Depends(verify_session_data) -# # ): -# # """Create a new language.""" -# # logger.info("POST Languages API") -# # validate_admin_only(session) -# # _, _ = await ensure_user_from_session_async(db_session, session) -# # db_obj = crud.create_language(db_session, lang) - -# # return schema.LanguageResponseItem( -# # language_id=db_obj.language_id, -# # language_name=db_obj.language_name, -# # language_code=db_obj.language_code, -# # metadata=db_obj.meta_data -# # ) - -# # @router.put( -# # "/language/{language_id}", -# # response_model=schema.LanguageResponseItem, -# # tags=["Language"] -# # ) -# # async def update_language( -# # language_id: int, -# # lang: schema.LanguageUpdate, -# # db_session: Session = Depends(get_db), -# # session: SessionContainer = Depends(verify_session_data), -# # ): -# # """Update an existing language.""" -# # logger.info("PUT Languages API") -# # validate_admin_only(session) -# # _, _ = await ensure_user_from_session_async(db_session, session) -# # # Check if language exists before attempting to update -# # language_obj = crud.get_language(db_session, language_id) -# # if not language_obj: -# # logger.error("Language ID or Language doesn't exist") -# # raise NotAvailableException(detail="Language ID or Language doesn't exist") - -# # db_obj = crud.update_language(db_session, language_id, lang) - -# # return schema.LanguageResponseItem( -# # language_id=db_obj.language_id, -# # language_name=db_obj.language_name, -# # language_code=db_obj.language_code, -# # metadata=db_obj.meta_data -# # ) - -# # @router.delete( -# # "/languages/bulk-delete", -# # tags=["Language"], -# # response_model=schema.LanguageBulkDeleteResponse -# # ) -# # async def delete_languages_bulk( -# # request: schema.LanguageBulkDelete, -# # db_session: Session = Depends(get_db), -# # session: SessionContainer = Depends(verify_session_data) -# # ): -# # logger.info("DELETE BULK Language API") - -# # validate_admin_only(session) -# # _, _ = await ensure_user_from_session_async(db_session, session) - -# # result = crud.delete_languages_bulk(db_session, request.language_ids) - -# # deleted_count = result["data"]["deletedCount"] -# # errors = result["data"]["errors"] - -# # # ---- Status logic (same as videos & versions) ---- -# # if result["all_failed"]: -# # status_code = 404 -# # elif result["has_errors"]: -# # status_code = 207 -# # else: -# # status_code = 200 - -# # # Add message -# # message = ( -# # f"Successfully deleted {deleted_count} language(s)" -# # if deleted_count > 0 -# # else "No languages were deleted" -# # ) - -# # response_data = { -# # **result["data"], -# # "message": message -# # } - -# # return JSONResponse( -# # status_code=status_code, -# # content=response_data -# # ) - -# # # --- License Endpoints --- -# # @router.get( -# # "/license", -# # response_model=List[schema.LicenseResponseItem], -# # tags=["License"] -# # ) -# # async def get_licenses( -# # license_id: Optional[int] = Query(None, description="Filter by license ID"), -# # name: Optional[str] = Query(None, description="Filter by license name (partial match)"), -# # db_session: Session = Depends(get_db), -# # session: SessionContainer = Depends(verify_session_data) -# # ): -# # """Get licenses with optional filtering.""" -# # logger.info("GET License API") -# # validate_admin_only(session) -# # _, _ = await ensure_user_from_session_async(db_session, session) -# # licenses = crud.get_licenses_with_filters( -# # db_session=db_session, -# # license_id=license_id, -# # name=name -# # ) - -# # # Transform to response format -# # return [schema.LicenseResponseItem.model_validate(license) for license in licenses] - -# # @router.post( -# # "/license", -# # response_model=schema.LicenseResponseItem, -# # tags=["License"] -# # ) -# # async def create_license( -# # license_: schema.LicenseCreate, -# # db_session: Session = Depends(get_db), -# # session: SessionContainer = Depends(verify_session_data) -# # ): -# # """Create a new license.""" -# # logger.info("POST License API") -# # validate_admin_only(session) -# # _, _ = await ensure_user_from_session_async(db_session, session) -# # db_obj = crud.create_license(db_session, license_) - -# # return schema.LicenseResponseItem.model_validate(db_obj) - -# # @router.put( -# # "/license/{license_id}", -# # response_model=schema.LicenseResponseItem, -# # tags=["License"] -# # ) -# # async def update_license( -# # license_id: int, -# # license_: schema.LicenseUpdate, -# # db_session: Session = Depends(get_db), -# # session: SessionContainer = Depends(verify_session_data) -# # ): -# # """Update an existing license.""" -# # logger.info("PUT License API") -# # validate_admin_only(session) -# # _, _ = await ensure_user_from_session_async(db_session, session) -# # # Check if license exists before attempting to update -# # license_obj = crud.get_license(db_session, license_id) -# # if not license_obj: -# # logger.error("License ID doesn't exist") -# # raise NotAvailableException(detail="License ID doesn't exist") - -# # db_obj = crud.update_license(db_session, license_id, license_) - -# # return schema.LicenseResponseItem.model_validate(db_obj) - -# # @router.delete( -# # "/license/bulk-delete", -# # tags=["License"], -# # response_model=schema.LicenseBulkDeleteResponse -# # ) -# # async def delete_licenses_bulk( -# # request: schema.LicenseBulkDelete, -# # db_session: Session = Depends(get_db), -# # session: SessionContainer = Depends(verify_session_data) -# # ): -# # logger.info("DELETE BULK License API") - -# # validate_admin_only(session) -# # _, _ = await ensure_user_from_session_async(db_session, session) - -# # result = crud.delete_licenses_bulk(db_session, request.license_ids) - -# # deleted_count = result["data"]["deletedCount"] -# # errors = result["data"]["errors"] - -# # # ---- Status code logic ---- -# # if result["all_failed"]: -# # status_code = 404 -# # elif result["has_errors"]: -# # status_code = 207 -# # else: -# # status_code = 200 - -# @router.post( -# "/bible", -# response_model=dict, -# tags=["Bible"] -# ) -# async def upload_bible_book( -# resource_id: int = Form(...), -# usfm: UploadFile = File(...), -# db_session: Session = Depends(get_db), -# session: SessionContainer = Depends(verify_session_data) -# ): -# """Upload a new bible book USFM file""" -# validate_admin_editor(session) - -# # Get user ID from session -# actor_id, _ = await ensure_user_from_session_async(db_session, session) -# # Validate USFM file before processing -# validation_result = crud.validate_usfm_file(usfm) -# if not validation_result["valid"]: -# raise UnprocessableException(detail=validation_result["error"]) -# return crud.upload_bible_book( -# db_session=db_session, -# resource_id=resource_id, -# usfm_file=usfm, -# actor_user_id=actor_id -# ) - -# @router.put( -# "/bible", -# response_model=dict, -# tags=["Bible"] -# ) -# async def update_bible_book( -# bible_book_id: int = Form(...), -# usfm: UploadFile = File(...), -# db_session: Session = Depends(get_db), -# session: SessionContainer = Depends(verify_session_data) -# ): -# """Update an existing bible book""" -# validate_admin_editor(session) - -# # Get user ID from session -# actor_id, _ = await ensure_user_from_session_async(db_session, session) -# validation_result = crud.validate_usfm_file(usfm) -# if not validation_result["valid"]: -# raise UnprocessableException(detail=validation_result["error"]) -# return crud.update_bible_book( -# db_session=db_session, -# bible_book_id=bible_book_id, -# usfm_file=usfm, -# actor_user_id=actor_id -# ) - -# @router.delete( -# "/bible/{resource_id}/books", -# response_model=schema.BulkDeleteResponse, -# response_model_exclude_none=True, -# tags=["Bible"] -# ) -# async def delete_bible_books_endpoint( -# resource_id: int, -# delete_request: schema.BulkDeleteRequest, -# db_session: Session = Depends(get_db), -# session: SessionContainer = Depends(verify_session_data) -# ): -# """Bulk delete Bible books by book codes""" -# validate_admin_editor(session) -# _, _ = await ensure_user_from_session_async(db_session, session) - -# result = crud.delete_bible_books( -# db_session=db_session, -# resource_id=resource_id, -# book_codes=delete_request.bookIds, -# ) - -# deleted_count = result["data"]["deletedCount"] -# errors = result["data"]["errors"] - -# # ---- Standard status code logic ---- -# if result["all_failed"]: -# status_code = 404 -# elif result["has_errors"]: -# status_code = 207 -# else: -# status_code = 200 - -# # ---- Message ---- -# message = ( -# f"Successfully deleted {deleted_count} book(s)" -# if deleted_count > 0 -# else "No books were deleted" -# ) - -# response_data = { -# **result["data"], -# "message": message, -# } - -# return JSONResponse(status_code=status_code, content=response_data) - - - -# # --- Bible Content Retrieval Endpoints --- - -# @router.get( -# "/bible/{resource_id}/books", -# response_model=schema.BibleBooksListResponse, -# tags=["Bible"] -# ) -# async def get_bible_books( -# resource_id: int, -# db_session: Session = Depends(get_db), -# session: SessionContainer = Depends(verify_session_data) -# ): -# """Get list of books for a bible resource""" -# validate_all_roles(session) -# _, _ = await ensure_user_from_session_async(db_session, session) -# return crud.get_bible_books(db_session, resource_id) - - - -# @router.get( -# "/bible/{resource_id}/content/{output_format}", -# response_model=schema.BibleFullContentResponse, -# tags=["Bible"] -# ) -# async def get_full_bible_content( -# resource_id: int, -# output_format: str, -# db_session: Session = Depends(get_db), -# session: SessionContainer = Depends(verify_session_data) -# ): -# """Get full content of all books in a resource in specified format (json/usfm)""" -# validate_all_roles(session) -# _, _ = await ensure_user_from_session_async(db_session, session) - -# if output_format.lower() not in ["json", "usfm"]: -# raise BadRequestException("Format must be 'json' or 'usfm'") - -# return crud.get_full_bible_content( -# db_session=db_session, -# resource_id=resource_id, -# output_format=output_format -# ) - - - -# @router.get( -# "/bible/{resource_id}/book/{book_code}/{output_format}", -# response_model=schema.BibleBookContentResponse, -# tags=["Bible"] -# ) -# async def get_bible_book_content( -# resource_id: int, -# book_code: str, -# output_format: str, -# db_session: Session = Depends(get_db), -# session: SessionContainer = Depends(verify_session_data) -# ): -# """Get full content of a book in specified format (json/usfm)""" -# validate_all_roles(session) -# _, _ = await ensure_user_from_session_async(db_session, session) - -# if output_format.lower() not in ["json", "usfm"]: -# raise BadRequestException("Format must be 'json' or 'usfm'") - -# return crud.get_bible_book_content( -# db_session=db_session, -# resource_id=resource_id, -# book_code=book_code, -# output_format=output_format -# ) - -# @router.get( -# "/bible/{resource_id}/chapter/{book_code}.{chapter}", -# response_model=schema.BibleChapterResponse, -# tags=["Bible"] -# ) -# async def get_bible_chapter( -# resource_id: int, -# book_code: str, -# chapter: int, -# db_session: Session = Depends(get_db), -# session: SessionContainer = Depends(verify_session_data) -# ): -# """Get chapter content from bible table""" -# validate_all_roles(session) -# _, _ = await ensure_user_from_session_async(db_session, session) - -# return crud.get_bible_chapter( -# db_session=db_session, -# resource_id=resource_id, -# book_code=book_code, -# chapter=chapter -# ) - -# @router.get( -# "/bible/{resource_id}/cleaned/chapter/{book_code}.{chapter}", -# response_model=schema.CleanBibleChapterResponse, -# tags=["Bible"] -# ) -# async def get_clean_bible_chapter( -# resource_id: int, -# book_code: str, -# chapter: int, -# db_session: Session = Depends(get_db), -# session: SessionContainer = Depends(verify_session_data) -# ): -# """Get cleaned chapter content from clean_bible table""" -# validate_all_roles(session) -# _, _ = await ensure_user_from_session_async(db_session, session) - -# return crud.get_clean_bible_chapter( -# db_session=db_session, -# resource_id=resource_id, -# book_code=book_code, -# chapter=chapter -# ) - -# async def get_bible_verse_params( -# resource_id: int, -# book_code: str, -# chapter: int, -# verse: int -# ) -> BibleVersePathParams: -# """Get specific verse content""" -# return BibleVersePathParams( -# resource_id=resource_id, -# book_code=book_code, -# chapter=chapter, -# verse=verse, -# ) -# @router.get( -# "/bible/{resource_id}/verse/{book_code}.{chapter}.{verse}", -# response_model=schema.BibleVerseResponse, -# tags=["Bible"] -# ) -# async def get_bible_verse( -# params: schema.BibleVersePathParams = Depends(get_bible_verse_params), -# db_session: Session = Depends(get_db), -# session: SessionContainer = Depends(verify_session_data), -# ): -# """Get specific verse content""" - -# validate_all_roles(session) -# _, _ = await ensure_user_from_session_async(db_session, session) - -# return crud.get_bible_verse( -# db_session=db_session, -# resource_id=params.resource_id, -# book_code=params.book_code, -# chapter=params.chapter, -# verse=params.verse, -# ) - -# # --- Video Endpoints --- - -# @router.post("/videos", tags=["Video"], response_model=schema.VideoBulkCreateResponse) -# async def create_videos( -# data: schema.VideoBulkCreate, -# db: Session = Depends(get_db), -# session: SessionContainer = Depends(verify_session_data) -# ): -# """Create videos""" -# validate_admin_editor(session) -# actor_id, _ = await ensure_user_from_session_async(db, session) -# all_errors = [] -# for idx, vid in enumerate(data.videos): -# errs = crud.validate_video_item(db, vid) -# if errs: -# all_errors.append({ -# "index": idx, -# "data": vid.model_dump(), -# "errors": errs -# }) -# if all_errors: -# raise UnprocessableException( -# detail=( -# { -# "code": "VALIDATION_ERROR", -# "message": "Invalid video entries", -# "errors": all_errors, -# } -# ) -# ) -# return crud.create_videos(db, data, actor_user_id=actor_id) - -# @router.put("/videos", tags=["Video"], response_model=schema.VideoBulkCreateResponse) -# async def update_videos( -# data: schema.VideoBulkUpdate, -# db: Session = Depends(get_db), -# session: SessionContainer = Depends(verify_session_data) -# ): -# """Update videos""" -# validate_admin_editor(session) -# actor_id, _ = await ensure_user_from_session_async(db, session) -# all_errors = [] -# for idx, vid in enumerate(data.videos): -# errs = crud.validate_video_item(db, vid) -# if errs: -# all_errors.append({ -# "index": idx, -# "data": vid.model_dump(), -# "errors": errs -# }) - -# if all_errors: -# raise UnprocessableException( -# detail=( -# { -# "code": "VALIDATION_ERROR", -# "message": "Invalid video entries", -# "errors": all_errors -# } -# ) -# ) -# return crud.update_videos(db, data, actor_user_id=actor_id) - -# @router.get("/videos", tags=["Video"], response_model=schema.VideoGetOut) -# async def get_videos( -# resource_id: Optional[int] = None, -# book_code: Optional[str] = None, -# chapter: Optional[int] = None, -# db: Session = Depends(get_db), -# session: SessionContainer = Depends(verify_session_data) -# ): -# """Get videos filtered by resource_id, book_code, and chapter""" -# validate_all_roles(session) -# _, _ = await ensure_user_from_session_async(db, session) -# return crud.get_videos_filtered( -# db=db, -# resource_id=resource_id, -# book_code=book_code, -# chapter=chapter -# ) - - -# @router.delete("/videos/{resource_id}", tags=["Video"], response_model=schema.VideoBulkDeleteResponse) -# async def delete_videos( -# resource_id: int, -# data: schema.VideoBulkDelete, -# response: Response, -# db: Session = Depends(get_db), -# session: SessionContainer = Depends(verify_session_data), -# ): -# """Delete multiple videos""" -# validate_admin_editor(session) -# _, _ = await ensure_user_from_session_async(db, session) - -# result = crud.delete_videos(db, resource_id, data.video_id) - -# # Set appropriate status code -# if result["all_failed"]: -# response.status_code = 404 # All videos not found -# elif result["has_errors"]: -# response.status_code = 207 # Partial success (Multi-Status) -# else: -# response.status_code = 200 # All successful - -# return result["data"] - -# # ---Commentary Endpoints --- - -# # --- POST --- -# @router.post( -# "/commentary", -# tags=["Commentary"], -# response_model=schema.CommentaryCreateResponse, -# openapi_extra={ -# "requestBody": { -# "content": { -# "application/json": { -# "schema": { -# "type": "object", -# "properties": { -# "resource_id": {"type": "integer", "example": 0}, -# "commentary": { -# "type": "array", -# "items": { -# "type": "object", -# "properties": { -# "book_id": {"type": "integer", "example": 0}, -# "chapter": {"type": "integer", "example": 0}, -# "verse": { -# "type": "string", -# "description": "Must be a positive integer or a range like '4-25'" -# }, -# "text": { -# "type": "string", -# "example": "

Commentary text here

" -# } -# }, -# "required": ["book_id", "chapter", "verse", "text"] -# } -# } -# }, -# "required": ["resource_id", "commentary"], -# "example": { -# "resource_id": 0, -# "commentary": [ -# { -# "book_id": 0, -# "chapter": 0, -# "verse": "string", -# "text": "

Sample commentary text

" -# } -# ] -# } -# } -# } -# }, -# "required": True -# } -# } -# ) -# async def create_commentary( -# request: Request, -# session: SessionContainer = Depends(verify_session()), -# ): -# """ -# Create commentary - -# Requires admin or editor role. Authorization is checked before request validation. - -# The verse field must be either: -# - A positive integer (e.g., "5") -# - A range (e.g., "4-25") -# """ - -# # Call the AuthFirstBody dependency manually -# auth_body = schema.AuthFirstBody(schema.CommentaryBulkCreate) -# payload, actor_id, db = await auth_body(request, session) - -# try: -# # Validate the payload content -# for item in payload.commentary: -# crud.validate_html(item.text) -# crud.validate_commentary_book_and_chapter(db, item.book_id, item.chapter) - -# result = crud.create_commentaries(db, payload, actor_user_id=actor_id) -# return result -# finally: -# db.close() - -# # --- PUT --- -# @router.put( -# "/commentary", -# tags=["Commentary"], -# response_model=schema.CommentaryUpdateResponse, -# openapi_extra={ -# "requestBody": { -# "content": { -# "application/json": { -# "schema": { -# "type": "object", -# "properties": { -# "resource_id": {"type": "integer"}, -# "commentary": { -# "type": "array", -# "items": { -# "type": "object", -# "properties": { -# "commentary_id": {"type": "integer"}, -# "book_id": {"type": "integer"}, -# "chapter": {"type": "integer"}, -# "verse": {"type": "string"}, -# "text": {"type": "string"} -# }, -# "required": ["commentary_id", "book_id", "chapter", "verse", "text"] -# } -# } -# }, -# "required": ["commentary"] -# } -# } -# }, -# "required": True -# } -# } -# ) -# async def update_commentary( -# request: Request, -# session: SessionContainer = Depends(verify_session()), -# ): -# """ -# Update commentary - -# Requires admin or editor role. Authorization is checked before request validation. -# """ - -# # Call the AuthFirstBody dependency manually -# auth_body = schema.AuthFirstBody(schema.CommentaryBulkUpdate) -# payload, actor_id, db = await auth_body(request, session) - -# try: -# # Validate the payload content -# for item in payload.commentary: -# crud.validate_html(item.text) -# crud.validate_commentary_book_and_chapter(db, item.book_id, item.chapter) - -# result = crud.update_commentaries(db, payload, actor_user_id=actor_id) -# return result -# finally: -# db.close() - -# # --- GET (full content for a resource) --- -# @router.get("/commentary/{resource_id}",tags=["Commentary"]) -# async def get_full( -# resource_id: int = Path(..., ge=1), -# db: Session = Depends(get_db), -# session: SessionContainer = Depends(verify_session_data) -# ): -# """Get full commentary""" -# validate_all_roles(session) -# _, _ = await ensure_user_from_session_async(db, session) -# return crud.get_full_commentary(db, resource_id) - -# # --- GET (full content of a chapter; supports path like book_code.chapter) --- -# @router.get("/commentary/{resource_id}/chapter/{book_code}.{chapter}",tags=["Commentary"]) -# async def get_chapter( -# resource_id: int = Path(..., ge=1), -# book_code: str = Path(..., description="Book code, e.g., 'mat'"), -# chapter: int = Path(..., ge=0, description="Chapter number (0 allowed)"), -# db: Session = Depends(get_db), -# session: SessionContainer = Depends(verify_session_data) -# ): -# """Get chapter commentary""" -# validate_all_roles(session) -# _, _ = await ensure_user_from_session_async(db, session) -# # 1) Resolve book_code -> bookId (404 if unknown code entirely) -# book = ( -# db.query(db_models.BookLookup) -# .filter(db_models.BookLookup.book_code.ilike(book_code.strip())) -# .first() -# ) -# if not book: -# raise NotAvailableException(detail=f"Book with code '{book_code}' not found") - -# # 2) Ensure this resource has ANY commentary for that book -# #(book-level existence in commentary data) -# has_book_commentary = ( -# db.query(db_models.Commentary.commentary_id) -# .filter( -# db_models.Commentary.resource_id == resource_id, -# db_models.Commentary.book_id == book.book_id, -# ) -# .first() -# ) -# if not has_book_commentary: -# raise NotAvailableException( -# detail=f"No commentary found for book_code '{book_code}' in resource {resource_id}" -# ) - -# # 3) Ensure this chapter exists in commentary data for that book & resource -# has_chapter_commentary = ( -# db.query(db_models.Commentary.commentary_id) -# .filter( -# db_models.Commentary.resource_id == resource_id, -# db_models.Commentary.book_id == book.book_id, -# db_models.Commentary.chapter == chapter, -# ) -# .first() -# ) -# if not has_chapter_commentary: -# raise NotAvailableException( -# detail=( -# f"Chapter {chapter} not found in commentary for book_code '" -# f"{book_code}' (resource {resource_id})" - -# ) -# ) - -# # 4) Return the chapter payload (this includes the book intro if present) -# return crud.get_commentary_chapter(db, resource_id, book.book_code, chapter) - -# # --- DELETE by commentary_id --- -# @router.delete( -# "/commentary/bulk-delete", -# tags=["Commentary"], -# response_model=schema.CommentaryBulkDeleteResponse -# ) -# async def delete_commentary_bulk( -# request: schema.CommentaryBulkDelete, -# db: Session = Depends(get_db), -# session: SessionContainer = Depends(verify_session_data) -# ): -# logger.info("DELETE BULK Commentary API") - -# # response_data = { -# # **result["data"], -# # "message": message -# # } - -# # return JSONResponse( -# # status_code=status_code, -# # content=response_data -# # ) - - -# # # --- Resource Endpoints --- -# # @router.get( -# # "/resources", -# # response_model=List[schema.LanguageGroupOut], -# # tags=["Resource"] -# # ) -# # async def list_resources_route( -# # params: schema.ResourceQueryParams = Depends(), -# # db: Session = Depends(get_db), -# # session: SessionContainer = Depends(verify_session_data), -# # ): -# # """Get resources with pagination and optional filtering.""" -# # logger.info("GET Resource API") -# # validate_all_roles(session) -# # _, _ = await ensure_user_from_session_async(db, session) - -# # filters = schema.ResourceFilter( -# # resource_id=params.resource_id, -# # page=params.page, -# # page_size=params.page_size, -# # published=params.published, -# # content_type=params.content_type.value.lower() if params.content_type else None, -# # ) - -# # return crud.get_resources(db, filters) - -# # @router.post("/resources", response_model=schema.ResourceResponse, tags=["Resource"]) -# # async def create_resource_route( -# # payload: schema.ResourceCreate, -# # db: Session = Depends(get_db), -# # session: SessionContainer = Depends(verify_session_data -# # ) -# # ): -# # """ API endpoint to create a new resource.""" -# # logger.info("POST Resource API") -# # validate_admin_only(session) -# # user_id, _ = await ensure_user_from_session_async(db, session) -# # return crud.create_resource(db, payload, created_by=user_id) - - - -# # @router.put("/resources", response_model=schema.ResourceResponse, tags=["Resource"]) -# # async def update_resource_route( -# # payload: schema.ResourceUpdate, -# # db: Session = Depends(get_db), -# # session: SessionContainer = Depends(verify_session_data) -# # ): -# # """ API endpoint to update a resource.""" -# # logger.info("PUT Resource API") -# # validate_admin_only(session) -# # user_id, _ = await ensure_user_from_session_async(db, session) -# # return crud.update_resource(db, payload, user_id=user_id) - -# # @router.delete( -# # "/resources/bulk-delete", -# # tags=["Resource"], -# # response_model=schema.ResourceBulkDeleteResponse -# # ) -# # async def delete_resources_bulk( -# # request: schema.ResourceBulkDelete, -# # db: Session = Depends(get_db), -# # session: SessionContainer = Depends(verify_session_data) -# # ): -# # logger.info("DELETE BULK Resource API") - -# # validate_admin_only(session) -# # _, _ = await ensure_user_from_session_async(db, session) - -# # result = crud.delete_resources_bulk(db, request.resource_ids) - -# # deleted_count = result["data"]["deletedCount"] -# # errors = result["data"]["errors"] - -# # # ---- Status Code Logic ---- -# # if result["all_failed"]: -# # status_code = 404 -# # elif result["has_errors"]: -# # status_code = 207 -# # else: -# # status_code = 200 - -# # # ---- Message ---- -# # message = ( -# # f"Successfully deleted {deleted_count} resource(s)" -# # if deleted_count > 0 -# # else "No resources were deleted" -# # ) - -# # response_data = { -# # **result["data"], -# # "message": message -# # } - -# # return JSONResponse( -# # status_code=status_code, -# # content=response_data -# # ) - - -# # ----Logs Endpoints------- - -# # @router.get("/log",tags=["logs"]) -# # def get_latest_log(session: SessionContainer = Depends(verify_session_data)): -# # """ -# # current/activate log file -# # """ -# # validate_admin_only(session) -# # return crud.latest_log_file() - - - - -# # @router.get("/log/{log_file_no}",tags=["logs"]) -# # def get_log_by_number(log_file_no: int, -# # session: SessionContainer = Depends(verify_session_data)): -# # """ -# # View rotated log files -# # * The handler keeps up to 10 old files -# # * vachan_admin_app.log,vachan_admin_app.log.1,vachan_admin_app.log.1 ... vachan_admin_app.log.10 -# # * log_file_no must be 0–10 -# # * current log file no is 0 -# # """ -# # validate_admin_only(session) -# # return crud.get_logfile_by_number(log_file_no) - - -# # @router.get("/logs",tags=["logs"]) -# # def get_all_logs(session: SessionContainer = Depends(verify_session_data)): -# # """ -# # get all log files in a zip format -# # """ -# # validate_admin_only(session) -# # return crud.get_all_logfiles() - -# # # --- Bible Book Management Endpoints --- - -# # @router.post( -# # "/bible", -# # response_model=dict, -# # tags=["Bible"] -# # ) -# # async def upload_bible_book( -# # resource_id: int = Form(...), -# # usfm: UploadFile = File(...), -# # db_session: Session = Depends(get_db), -# # session: SessionContainer = Depends(verify_session_data) -# # ): -# # """Upload a new bible book USFM file""" -# # validate_admin_editor(session) - -# # # Get user ID from session -# # actor_id, _ = await ensure_user_from_session_async(db_session, session) -# # # Validate USFM file before processing -# # validation_result = await crud.validate_usfm_file(usfm) -# # if not validation_result["valid"]: -# # raise UnprocessableException(detail=validation_result["error"]) -# # return crud.upload_bible_book( -# # db_session=db_session, -# # resource_id=resource_id, -# # usfm_file=usfm, -# # actor_user_id=actor_id -# # ) - -# # @router.put( -# # "/bible", -# # response_model=dict, -# # tags=["Bible"] -# # ) -# # async def update_bible_book( -# # bible_book_id: int = Form(...), -# # usfm: UploadFile = File(...), -# # db_session: Session = Depends(get_db), -# # session: SessionContainer = Depends(verify_session_data) -# # ): -# # """Update an existing bible book""" -# # validate_admin_editor(session) - -# # # Get user ID from session -# # actor_id, _ = await ensure_user_from_session_async(db_session, session) -# # validation_result = await crud.validate_usfm_file(usfm) -# # if not validation_result["valid"]: -# # raise UnprocessableException(detail=validation_result["error"]) -# # return crud.update_bible_book( -# # db_session=db_session, -# # bible_book_id=bible_book_id, -# # usfm_file=usfm, -# # actor_user_id=actor_id -# # ) - -# # @router.delete( -# # "/bible/{resource_id}/books", -# # response_model=schema.BulkDeleteResponse, -# # response_model_exclude_none=True, -# # tags=["Bible"] -# # ) -# # async def delete_bible_books_endpoint( -# # resource_id: int, -# # delete_request: schema.BulkDeleteRequest, -# # db_session: Session = Depends(get_db), -# # session: SessionContainer = Depends(verify_session_data) -# # ): -# # """Bulk delete Bible books by book codes""" -# # validate_admin_editor(session) -# # _, _ = await ensure_user_from_session_async(db_session, session) - -# # result = crud.delete_bible_books( -# # db_session=db_session, -# # resource_id=resource_id, -# # book_codes=delete_request.bookIds, -# # ) - -# # deleted_count = result["data"]["deletedCount"] -# # errors = result["data"]["errors"] - -# # # ---- Standard status code logic ---- -# # if result["all_failed"]: -# # status_code = 404 -# # elif result["has_errors"]: -# # status_code = 207 -# # else: -# # status_code = 200 - -# # # ---- Message ---- -# # message = ( -# # f"Successfully deleted {deleted_count} book(s)" -# # if deleted_count > 0 -# # else "No books were deleted" -# # ) - -# # response_data = { -# # **result["data"], -# # "message": message, -# # } - -# # return JSONResponse(status_code=status_code, content=response_data) - - - -# # # --- Bible Content Retrieval Endpoints --- - -# # @router.get( -# # "/bible/{resource_id}/books", -# # response_model=schema.BibleBooksListResponse, -# # tags=["Bible"] -# # ) -# # async def get_bible_books( -# # resource_id: int, -# # db_session: Session = Depends(get_db), -# # session: SessionContainer = Depends(verify_session_data) -# # ): -# # """Get list of books for a bible resource""" -# # validate_all_roles(session) -# # _, _ = await ensure_user_from_session_async(db_session, session) -# # return crud.get_bible_books(db_session, resource_id) - - -# # ===== GET - List of Stories for a Language ===== -# @router.get( -# "/obs/language/{language_code}", -# response_model=schema.OBSStoriesListResponse, -# tags=["OBS"] -# ) -# def get_obs_stories_for_language( -# language_code: str = Path(..., description="Language identifier"), -# page: int = Query(1, ge=1, description="Page number"), -# limit: int = Query(50, ge=1, le=100, description="Results per page"), -# db_session: Session = Depends(get_db), -# session: SessionContainer = Depends(verify_session_data) -# ): -# """ -# Get all Open Bible Stories for a specific language with pagination. - -# Returns stories without full text content (use story detail endpoint for full content). - -# **Authorization:** Admin, Editor, Viewer -# """ -# logger.info( -# "GET OBS Stories API - Language: %s, Page: %s, Limit: %s", -# language_code, -# page, -# limit -# ) -# validate_all_roles(session) - -# return crud.get_obs_stories_by_language(db_session, language_code, page, limit) - - -# # ===== GET - Specific Story Details ===== -# @router.get( -# "/obs/language/{language_code}/story/{story_id}", -# response_model=schema.OBSStoryDetailResponse, -# tags=["OBS"] -# ) -# def get_obs_story_detail( -# language_code: str = Path(..., description="Language identifier"), -# story_id: int = Path(..., ge=1, description="Story identifier"), -# db_session: Session = Depends(get_db), -# session: SessionContainer = Depends(verify_session_data) -# ): -# """ -# Get full details of a specific Open Bible Story. - -# Returns complete story information including full text content. - -# **Authorization:** Admin, Editor, Viewer -# """ -# logger.info( -# "GET OBS Story Detail API - Language: %s, Story: %s", -# language_code, -# story_id -# ) -# validate_all_roles(session) - -# return crud.get_obs_story_by_id(db_session, language_code, story_id) - -# # ===== PUT - Update Story ===== -# @router.put( -# "/obs/language/{language_code}/story/{story_id}", -# response_model=schema.OBSStoryUpdateResponse, -# tags=["OBS"] -# ) -# def update_obs_story_endpoint( -# params: schema.OBSStoryUpdateParams = Depends(), -# story_data: schema.OBSStoryUpdate = Body(...), -# db_session: Session = Depends(get_db), -# session: SessionContainer = Depends(verify_session_data), -# ): -# """ -# Update an existing Open Bible Story. - -# All fields are optional – only provided fields will be updated. - -# **Path Parameters** -# - `language_code`: Language identifier (from language table) -# - `story_id`: Story identifier to update - -# **Body Fields (optional)** -# - `resource_id`: New resource ID (must be of type 'obs') -# - `story_no`: New story number (must be unique within resource) -# - `title`: New story title (max 255 characters) -# - `url`: New external URL -# - `text`: New story content or video description - -# **Authorization:** Admin, Editor -# """ - -# logger.info( -# "PUT OBS Story API - Language: %s, Story: %s", -# params.language_code, -# params.story_id -# ) - -# validate_admin_editor(session) - -# return crud.update_obs_story( -# db_session, -# params.language_code, -# params.story_id, -# story_data -# ) - -# # ===== DELETE - Delete Story ===== - -# @router.delete( -# "/obs/language/{language_code}/story", -# tags=["OBS"], -# response_model=schema.OBSBulkDeleteResponse -# ) -# def delete_obs_story_endpoint( -# language_code: str = Path(..., description="Language identifier"), -# body: schema.OBSBulkDeleteRequest = Body(...), -# db_session: Session = Depends(get_db), -# session: SessionContainer = Depends(verify_session_data) -# ): -# """ -# Bulk delete OBS stories by story numbers. -# Body: -# { -# "story_nos": [1, 2, 3] -# } -# """ - -# # 1. Validate permissions -# validate_admin_editor(session) - -# # 2. Call CRUD -# result = crud.delete_obs_story(db_session, language_code, body.story_nos) - -# if not result.get("deletedStoryNos"): -# status = 404 -# elif result.get("invalidStoryNos"): -# status = 207 # Partial success -# else: -# status = 200 # All deleted successfully - -# # 4. Return response -# return JSONResponse(status_code=status, content=result) - - -# ### ENDPOINTS INFOGRAPHICS -# # ---------- CREATE ---------- -# @router.post("/infographics", -# response_model=schema.CreateInfographicResponse, -# status_code=201, -# tags=["Infographic"] -# ) -# async def create_infographics(payload: -# schema.BatchInfographicCreateIn, -# db: Session = Depends(get_db), -# session: SessionContainer = Depends(verify_session_data) -# ): -# """Create infographics""" -# validate_admin_editor(session) -# actor_id, _ = await ensure_user_from_session_async(db, session) -# created, errors = crud.create_infographic_batch(db, payload, actor_user_id=actor_id) - -# if not created["created"] and errors: -# raise UnprocessableException( -# detail={ -# "code": "UNPROCESSABLE_ENTITY", -# "message": "No records created", -# "errors": errors, -# }, -# ) - -# if errors: -# # Partial success (207-like body) -# return JSONResponse( -# status_code=207, -# content={ -# "status": "partial_success", -# "data": { -# "created_count": len(created["created"]), -# "failed_count": len(errors), -# "infographics": created["created"], -# "errors": errors, -# }, -# }) - -# return JSONResponse( -# status_code=201, -# content={ -# "status": "success", -# "data": { -# "created_count": len(created["created"]), -# "infographics": created["created"], -# }, -# }) - - -# # ---------- LIST ---------- -# @router.get( -# "/infographics", -# response_model=schema.ListInfographicResponse, -# tags=["Infographic"] -# ) -# async def list_infographics( -# db: Session = Depends(get_db), -# params: schema.InfographicListParams = Depends(), -# session: SessionContainer = Depends(verify_session_data), -# ): -# """ -# List infographics with pagination and optional filtering.""" -# validate_all_roles(session) -# _, _ = await ensure_user_from_session_async(db, session) - -# try: -# data, pagination, _ = crud.list_infographic_items(db, params) -# except ValueError as e: -# msg = str(e) - -# if msg == "RESOURCE_NOT_FOUND": -# raise NotAvailableException( -# detail={ -# "code": "RESOURCE_NOT_FOUND", -# "message": "resource_id not found" -# }, -# ) from e - -# if msg == "INVALID_RESOURCE_TYPE": -# raise TypeException( -# detail={ -# "code": "INVALID_RESOURCE_TYPE", -# "message": "Only resources with content_type 'infographics' can be listed here", -# }, -# ) from e - -# raise BadRequestException( -# detail={"code": "INVALID_QUERY_PARAMETER", "message": msg}, -# ) from e - -# return {"status": "success", "data": data, "pagination": pagination} - - -# # ---------- GET ONE ---------- -# @router.get( -# "/infographics/{infographic_id}", -# response_model=schema.InfographicOut, -# tags=["Infographic"] -# ) -# async def get_one_infographic( -# infographic_id: int, -# db: Session = Depends(get_db), -# session: SessionContainer = Depends(verify_session_data), -# ): -# """Get one infographic""" -# validate_all_roles(session) -# _, _ = await ensure_user_from_session_async(db, session) - -# row = crud.get_one_infographics(db, infographic_id) -# if not row: -# raise NotAvailableException( -# detail={"code": "NOT_FOUND", "message": "Infographic not found"}, -# ) - -# return row - -# # ---------- UPDATE ---------- -# @router.put( -# "/infographics", -# response_model=schema.UpdateInfographicBatchResponse, -# tags=["Infographic"], -# ) -# async def update_one( -# body: schema.BatchInfographicUpdateIn, -# db: Session = Depends(get_db), -# session: SessionContainer = Depends(verify_session_data), -# ): -# """Update infographics""" -# # --- 0. Validate session and get actor --- -# validate_admin_editor(session) -# actor_id, _ = await ensure_user_from_session_async(db, session) - -# # --- 1. Pre-validate each item (structure + types) --- -# errors = [] -# for idx, item in enumerate(body.infographics): -# tmp_obj = schema.InfographicUpdateItem( -# id=item.id, -# book_id=item.book_id, -# title=item.title, -# file_name=item.file_name, -# ) -# err = crud.validate_item(idx, tmp_obj) -# if err: -# errors.append(err) - -# if errors: -# raise UnprocessableException( -# detail={ -# "code": "VALIDATION_ERROR", -# "message": "Validation failed", -# "errors": errors, -# }, -# ) - -# # --- 2. Call CRUD batch update (ctx style handles resource/books internally) --- -# try: -# out = crud.update_infographic_batch(db, body, actor_user_id=actor_id) -# except ValueError as e: -# msg = str(e) -# if msg == "RESOURCE_NOT_FOUND": -# raise NotAvailableException( -# detail={"code": "RESOURCE_NOT_FOUND", "message": "resource_id not found"}, -# ) from e -# if msg == "INVALID_RESOURCE_TYPE": -# raise TypeException( -# detail={ -# "code": "INVALID_RESOURCE_TYPE", -# "message": ( -# "Only resources with content_type 'infographics' " -# "can hold infographic records" -# ), -# }, -# ) from e -# if msg == "INVALID_BOOK": -# raise UnprocessableException( -# detail={"code": "VALIDATION_ERROR", "message": "book_id must be between 1 and 66"}, -# ) from e -# # generic -# raise UnprocessableException( -# detail={ -# "code": "VALIDATION_ERROR", -# "message": msg} -# )from e - -# updated = out.get("updated", []) -# errs = out.get("errors", []) - -# # --- 3. Return proper status code based on outcome --- -# if not updated and errs: -# # all failed -# raise UnprocessableException( -# detail={ -# "code": "UNPROCESSABLE_ENTITY", -# "message": "No records updated", -# "errors": errs, -# }, -# ) - -# if updated and errs: -# # partial success -# return JSONResponse( -# status_code=207, -# content={"updated": updated, "errors": errs}, -# ) - -# # all succeeded -# return JSONResponse(status_code=200, content={"updated": updated, "errors": []}) - - - - -# # ---------- DELETE ---------- -# @router.delete( -# "/infographics", -# response_model=schema.BulkInfographicDeleteResponse, -# tags=["Infographic"], -# ) -# async def delete_bulk( -# body: schema.BulkInfographicDeleteIn, -# db: Session = Depends(get_db), -# session: SessionContainer = Depends(verify_session_data), -# ): -# """Bulk delete infographics""" -# validate_admin_editor(session) -# _, _ = await ensure_user_from_session_async(db, session) - -# if not body.ids: -# raise HTTPException(status_code=400, detail="No IDs provided") - -# result = crud.delete_bulk_details(db, body.ids) - -# # Determine status code -# if not result["deletedIds"]: -# status_code = 404 # all invalid -# elif result.get("invalidIds"): -# status_code = 207 # partial success -# else: -# status_code = 200 # all deleted - -# return JSONResponse(status_code=status_code, content=result) - - -# # --- Verse of the Day / Endpoints --- -# @router.get("/verse_of_the_day", -# response_model=schema.VerseOfTheDayListResponse, -# tags=["Verse Of The Day"]) -# async def get_all_verse_of_the_day(db: Session = Depends(get_db), -# session: SessionContainer = Depends(verify_session_data)): -# """Get all daily verse references for all years""" -# validate_all_roles(session) -# return crud.get_all_verse_of_the_day(db) - -# @router.get( -# "/verse_of_the_day/{year}/{month}/{day}", -# response_model=schema.VerseOfTheDaySingleResponse, -# tags=["Verse Of The Day"] -# ) -# async def get_verse_by_date(year: int, month: int, day: int, -# db: Session = Depends(get_db), -# session: SessionContainer = Depends(verify_session_data)): -# """Get the verse of the day for a specific date""" -# validate_all_roles(session) -# return crud.get_verse_for_date(db,year, month, day) - -# @router.post( -# "/verse_of_the_day", -# response_model=schema.VerseUploadResponse, -# tags=["Verse Of The Day"] -# ) -# async def upload_verse_of_the_day( -# file: UploadFile = File(...), -# db_session: Session = Depends(get_db), -# session: SessionContainer = Depends(verify_session_data) -# ): -# """ -# Upload a CSV file to replace Verse of the Day table content. -# Deletes existing entries and adds new ones from CSV. -# """ -# validate_admin_editor(session) -# return crud.upload_verse_of_the_day_csv(db_session, file) - -# @router.delete("/verse_of_the_day",tags=["Verse Of The Day"]) -# def delete_all_verse_of_the_day(db_session: Session = Depends(get_db), -# session: SessionContainer = Depends(verify_session_data)): -# """ -# Delete all entries in verse_of_the_day table. -# """ -# validate_admin_editor(session) -# return crud.delete_all_verse_of_the_day(db_session) - -# # --- Reading Plan Endpoints --- - -# @router.post( -# "/reading-plans/upload", -# response_model=schema.ReadingPlanUploadResponse, -# tags=["Reading Plan"] -# ) -# async def upload_reading_plans( -# file: UploadFile = File(..., description="JSON or CSV file containing reading plans"), -# db_session: Session = Depends(get_db), -# session: SessionContainer = Depends(verify_session_data) -# ): -# """ -# Upload reading plans from JSON or CSV file. - -# **File Format:** -# - JSON: Array of objects with 'date' (MM-DD) and 'reading' (array of reading items) -# - CSV: Columns 'date' (MM-DD) and 'reading' (JSON string of reading items) - -# **Example JSON:** -# ```json -# [ -# { -# "date": "01-01", -# "reading": [ -# {"ref": "gen 1", "text": "Genesis 1"}, -# {"ref": "mat 1", "text": "Matthew 1"} -# ] -# } -# ] -# ``` - -# # **Example CSV:** -# # ```csv -# # date,reading -# # 01-01,"[{""ref"": ""gen 1"", ""text"": ""Genesis 1""}]" -# # 01-02,"[{""ref"": ""gen 2"", ""text"": ""Genesis 2""}]" -# # ``` -# """ -# logger.info("POST Reading Plans Upload API") -# validate_admin_editor(session) -# _, _ = await ensure_user_from_session_async(db_session, session) - -# # Validate file type -# file_ext = file.filename.lower().split('.')[-1] -# if file_ext not in ['json', 'csv']: -# raise TypeException( -# detail="Invalid file type. Only JSON and CSV files are supported." -# ) - -# # Read file content -# try: -# file_content = await file.read() -# except Exception as e: -# logger.error("Error reading file: %s", str(e)) -# raise BadRequestException(detail="Error reading file") from e - -# # Check if file is empty -# if len(file_content) == 0: -# raise BadRequestException(detail="Uploaded file is empty") - -# # Process file -# result = crud.upload_reading_plans( -# db=db_session, -# file_content=file_content, -# file_type=file_ext -# ) - -# deleted = result.get("deletedIds", []) -# has_errors = bool(result.get("error")) -# if len(deleted) == 0 and has_errors: -# status = 404 # all invalid -# elif has_errors: -# status = 207 # partial success -# else: -# status = 200 # full success - -# return JSONResponse(status_code=status, content=result) - -# --- obs Endpoints --- - - -@router.post( - "/obs", - response_model=schema.OBSBulkCreateFullResponse, - status_code=201, - tags=["OBS"] -) -async def create_obs_story_endpoint( - story_data: schema.OBSBulkCreate = Body(...), - db: Session = Depends(get_db), - session: SessionContainer = Depends(verify_session_data) -): - """ - Create a new Open Bible Story for a specific language. - - - **resource_id**: Resource ID (must be of type 'obs') - - **story_no**: Story number (1-50, must be unique within resource) - - **title**: Story title (max 255 characters) - - **url**: External URL for video OBS (optional) - - **text**: Story content for text OBS or video description for video OBS - - **Authorization:** Admin, Editor - """ - logger.info( - "POST OBS Story API - Resource_id: %s, Story_nos: %s", - story_data.resource_id, - [item.story_no for item in story_data.obs] - ) - validate_admin_editor(session) - actor_id,_ = await ensure_user_from_session_async(db, session) - # CRUD handles everything - validation, creation, and response formatting - return crud.create_obs_bulk(db, story_data,actor_id) - -# # ===== GET - List of Languages with OBS ===== -# @router.get( -# "/obs", -# response_model=schema.OBSLanguageListResponse, -# tags=["OBS"] -# ) -# def get_obs_languages( -# db_session: Session = Depends(get_db), -# session: SessionContainer = Depends(verify_session_data) -# ): -# """ -# Get list of all languages that have Open Bible Stories available. - -# Returns language ID, name, and story count for each language. - -# **Authorization:** Admin, Editor, Viewer -# """ -# logger.info("GET OBS Languages API") -# validate_all_roles(session) - -# return crud.get_languages_with_obs(db_session) - - -# # ===== GET - List of Stories for a Language ===== -# @router.get( -# "/obs/language/{language_code}", -# response_model=schema.OBSStoriesListResponse, -# tags=["OBS"] -# ) -# def get_obs_stories_for_language( -# language_code: str = Path(..., description="Language identifier"), -# page: int = Query(1, ge=1, description="Page number"), -# limit: int = Query(50, ge=1, le=100, description="Results per page"), -# db_session: Session = Depends(get_db), -# session: SessionContainer = Depends(verify_session_data) -# ): -# """ -# Get all Open Bible Stories for a specific language with pagination. - -# Returns stories without full text content (use story detail endpoint for full content). - -# **Authorization:** Admin, Editor, Viewer -# """ -# logger.info( -# "GET OBS Stories API - Language: %s, Page: %s, Limit: %s", -# language_code, -# page, -# limit -# ) -# validate_all_roles(session) - -# return crud.get_obs_stories_by_language(db_session, language_code, page, limit) - - -# ===== GET - Specific Story Details ===== -@router.get( - "/obs/{resource_id}", - response_model=schema.OBSGetResponse, - tags=["OBS"] -) -def get_obs_story_detail( - resource_id: int = Path(...), - db_session: Session = Depends(get_db), - session: SessionContainer = Depends(verify_session_data) -): - """ - Get full details of Open Bible Story. - - Returns complete story information including full text content. - - **Authorization:** Admin, Editor, Viewer - """ - logger.info( - "GET OBS Story Detail API - resource_id: %s", - resource_id - ) - validate_all_roles(session) - - return crud.get_obs_by_resource(db_session, resource_id) - -# ===== PUT - Update Story ===== -@router.put( - "/obs/{resource_id}/story/{story_id}", - response_model=schema.OBSStoryUpdateResponse, - tags=["OBS"] -) -def update_obs_story_endpoint( - params: schema.OBSStoryUpdateParams = Depends(), - story_data: schema.OBSStoryUpdate = Body(...), - db_session: Session = Depends(get_db), - session: SessionContainer = Depends(verify_session_data), -): - """ - Update an existing Open Bible Story. - - All fields are optional – only provided fields will be updated. - - **Path Parameters** - - `resource_id`: resource ID of type 'obs' - - `story_id`: Story identifier to update - - **Body Fields (optional)** - - `story_no`: New story number (must be unique within resource) - - `title`: New story title (max 255 characters) - - `url`: New external URL - - `text`: New story content or video description - - **Authorization:** Admin, Editor - """ - - logger.info( - "PUT OBS Story API - resource_id: %s, Story: %s", - params.resource_id, - params.story_id - ) - - validate_admin_editor(session) - - return crud.update_obs_story( - db_session, - params.resource_id, - params.story_id, - story_data - ) - -# ===== DELETE - Delete Story ===== - -@router.delete( - "/obs/{resource_id}", - tags=["OBS"], - response_model=schema.OBSBulkDeleteResponse -) -def delete_obs_story_endpoint( - resource_id: int = Path(...), - body: schema.OBSBulkDeleteRequest = Body(...), - db_session: Session = Depends(get_db), - session: SessionContainer = Depends(verify_session_data) -): - """ - Bulk delete OBS stories by story numbers. - Body: - { - "story_nos": [1, 2, 3] - } - """ - - # 1. Validate permissions - validate_admin_editor(session) - - # 2. Call CRUD - result = crud.delete_obs_bulk(db_session, resource_id, body.story_nos) - - if not result.get("deletedStoryNos"): - status = 404 - elif result.get("invalidStoryNos"): - status = 207 # Partial success - else: - status = 200 # All deleted successfully - - # 4. Return response - return JSONResponse( - status_code=status, - content={ - "deletedCount": len(result["deletedStoryNos"]), - "deletedStoryNos": result["deletedStoryNos"], - "error": ( - f"Invalid story_nos: {result['invalidStoryNos']}" - if result.get("invalidStoryNos") - else None - ), - }, - ) - - -### ENDPOINTS INFOGRAPHICS -# ---------- CREATE ---------- -@router.post("/infographics", -response_model=schema.CreateInfographicResponse, -status_code=201, -tags=["Infographic"] -) -async def create_infographics(payload: - schema.BatchInfographicCreateIn, - db: Session = Depends(get_db), - session: SessionContainer = Depends(verify_session_data) -): - """Create infographics""" - validate_admin_editor(session) - actor_id, _ = await ensure_user_from_session_async(db, session) - created, errors = crud.create_infographic_batch(db, payload, actor_user_id=actor_id) - - if not created["created"] and errors: - raise UnprocessableException( - detail={ - "code": "UNPROCESSABLE_ENTITY", - "message": "No records created", - "errors": errors, - }, - ) - - if errors: - # Partial success (207-like body) - return JSONResponse( - status_code=207, - content={ - "status": "partial_success", - "data": { - "created_count": len(created["created"]), - "failed_count": len(errors), - "infographics": created["created"], - "errors": errors, - }, - }) - - return JSONResponse( - status_code=201, - content={ - "status": "success", - "data": { - "created_count": len(created["created"]), - "infographics": created["created"], - }, - }) - - -# ---------- LIST ---------- -@router.get( - "/infographics", - response_model=schema.ListInfographicResponse, - tags=["Infographic"] -) -async def list_infographics( - db: Session = Depends(get_db), - params: schema.InfographicListParams = Depends(), - session: SessionContainer = Depends(verify_session_data), -): - """ - List infographics with pagination and optional filtering.""" - validate_all_roles(session) - _, _ = await ensure_user_from_session_async(db, session) - - try: - data, pagination, _ = crud.list_infographic_items(db, params) - except ValueError as e: - msg = str(e) - - if msg == "RESOURCE_NOT_FOUND": - raise NotAvailableException( - detail={ - "code": "RESOURCE_NOT_FOUND", - "message": "resource_id not found" - }, - ) from e - - if msg == "INVALID_RESOURCE_TYPE": - raise TypeException( - detail={ - "code": "INVALID_RESOURCE_TYPE", - "message": "Only resources with content_type 'infographics' can be listed here", - }, - ) from e - - raise BadRequestException( - detail={"code": "INVALID_QUERY_PARAMETER", "message": msg}, - ) from e - - return {"status": "success", "data": data, "pagination": pagination} - - -# ---------- GET ONE ---------- -@router.get( - "/infographics/{infographic_id}", - response_model=schema.InfographicOut, - tags=["Infographic"] -) -async def get_one_infographic( - infographic_id: int, - db: Session = Depends(get_db), - session: SessionContainer = Depends(verify_session_data), -): - """Get one infographic""" - validate_all_roles(session) - _, _ = await ensure_user_from_session_async(db, session) - - row = crud.get_one_infographics(db, infographic_id) - if not row: - raise NotAvailableException( - detail={"code": "NOT_FOUND", "message": "Infographic not found"}, - ) - - return row - -# ---------- UPDATE ---------- -@router.put( - "/infographics", - response_model=schema.UpdateInfographicBatchResponse, - tags=["Infographic"], -) -async def update_one( - body: schema.BatchInfographicUpdateIn, - db: Session = Depends(get_db), - session: SessionContainer = Depends(verify_session_data), -): - """Update infographics""" - # --- 0. Validate session and get actor --- - validate_admin_editor(session) - actor_id, _ = await ensure_user_from_session_async(db, session) - - # --- 1. Pre-validate each item (structure + types) --- - errors = [] - for idx, item in enumerate(body.infographics): - tmp_obj = schema.InfographicUpdateItem( - id=item.id, - book_id=item.book_id, - title=item.title, - file_name=item.file_name, - ) - err = crud.validate_item(idx, tmp_obj) - if err: - errors.append(err) - - if errors: - raise UnprocessableException( - detail={ - "code": "VALIDATION_ERROR", - "message": "Validation failed", - "errors": errors, - }, - ) - - # --- 2. Call CRUD batch update (ctx style handles resource/books internally) --- - try: - out = crud.update_infographic_batch(db, body, actor_user_id=actor_id) - except ValueError as e: - msg = str(e) - if msg == "RESOURCE_NOT_FOUND": - raise NotAvailableException( - detail={"code": "RESOURCE_NOT_FOUND", "message": "resource_id not found"}, - ) from e - if msg == "INVALID_RESOURCE_TYPE": - raise TypeException( - detail={ - "code": "INVALID_RESOURCE_TYPE", - "message": ( - "Only resources with content_type 'infographics' " - "can hold infographic records" - ), - }, - ) from e - if msg == "INVALID_BOOK": - raise UnprocessableException( - detail={"code": "VALIDATION_ERROR", "message": "book_id must be between 1 and 66"}, - ) from e - # generic - raise UnprocessableException( - detail={ - "code": "VALIDATION_ERROR", - "message": msg} - )from e - - updated = out.get("updated", []) - errs = out.get("errors", []) - - # --- 3. Return proper status code based on outcome --- - if not updated and errs: - # all failed - raise UnprocessableException( - detail={ - "code": "UNPROCESSABLE_ENTITY", - "message": "No records updated", - "errors": errs, - }, - ) - - if updated and errs: - # partial success - return JSONResponse( - status_code=207, - content={"updated": updated, "errors": errs}, - ) - - # all succeeded - return JSONResponse(status_code=200, content={"updated": updated, "errors": []}) - - - - -# ---------- DELETE ---------- -@router.delete( - "/infographics", - response_model=schema.BulkInfographicDeleteResponse, - tags=["Infographic"], -) -async def delete_bulk( - body: schema.BulkInfographicDeleteIn, - db: Session = Depends(get_db), - session: SessionContainer = Depends(verify_session_data), -): - """Bulk delete infographics""" - validate_admin_editor(session) - _, _ = await ensure_user_from_session_async(db, session) - - if not body.ids: - raise HTTPException(status_code=400, detail="No IDs provided") - - result = crud.delete_bulk_details(db, body.ids) - - # Determine status code - if not result["deletedIds"]: - status_code = 404 # all invalid - elif result.get("invalidIds"): - status_code = 207 # partial success - else: - status_code = 200 # all deleted - - return JSONResponse(status_code=status_code, content=result) - - -# --- Verse of the Day / Endpoints --- -@router.get("/verse_of_the_day", -response_model=schema.VerseOfTheDayListResponse, -tags=["Verse Of The Day"]) -async def get_all_verse_of_the_day(db: Session = Depends(get_db), - session: SessionContainer = Depends(verify_session_data)): - """Get all daily verse references for all years""" - validate_all_roles(session) - return crud.get_all_verse_of_the_day(db) - -@router.get( - "/verse_of_the_day/{year}/{month}/{day}", - response_model=schema.VerseOfTheDaySingleResponse, - tags=["Verse Of The Day"] -) -async def get_verse_by_date(year: int, month: int, day: int, - db: Session = Depends(get_db), - session: SessionContainer = Depends(verify_session_data)): - """Get the verse of the day for a specific date""" - validate_all_roles(session) - return crud.get_verse_for_date(db,year, month, day) - -@router.post( - "/verse_of_the_day", - response_model=schema.VerseUploadResponse, - tags=["Verse Of The Day"] -) -async def upload_verse_of_the_day( - file: UploadFile = File(...), - db_session: Session = Depends(get_db), - session: SessionContainer = Depends(verify_session_data) -): - """ - Upload a CSV file to replace Verse of the Day table content. - Deletes existing entries and adds new ones from CSV. - """ - validate_admin_editor(session) - return crud.upload_verse_of_the_day_csv(db_session, file) - -@router.delete("/verse_of_the_day",tags=["Verse Of The Day"]) -def delete_all_verse_of_the_day(db_session: Session = Depends(get_db), - session: SessionContainer = Depends(verify_session_data)): - """ - Delete all entries in verse_of_the_day table. - """ - validate_admin_editor(session) - return crud.delete_all_verse_of_the_day(db_session) - -# --- Reading Plan Endpoints --- - -@router.post( - "/reading-plans/upload", - response_model=schema.ReadingPlanUploadResponse, - tags=["Reading Plan"] -) -async def upload_reading_plans( - file: UploadFile = File(..., description="JSON or CSV file containing reading plans"), - db_session: Session = Depends(get_db), - session: SessionContainer = Depends(verify_session_data) -): - """ - Upload reading plans from JSON or CSV file. - - **File Format:** - - JSON: Array of objects with 'date' (MM-DD) and 'reading' (array of reading items) - - CSV: Columns 'date' (MM-DD) and 'reading' (JSON string of reading items) - - **Example JSON:** -```json - [ - { - "date": "01-01", - "reading": [ - {"ref": "gen 1", "text": "Genesis 1"}, - {"ref": "mat 1", "text": "Matthew 1"} - ] - } - ] -``` - -# **Example CSV:** -# ```csv -# date,reading -# 01-01,"[{""ref"": ""gen 1"", ""text"": ""Genesis 1""}]" -# 01-02,"[{""ref"": ""gen 2"", ""text"": ""Genesis 2""}]" -# ``` - """ - logger.info("POST Reading Plans Upload API") - validate_admin_editor(session) - _, _ = await ensure_user_from_session_async(db_session, session) - - # Validate file type - file_ext = file.filename.lower().split('.')[-1] - if file_ext not in ['json', 'csv']: - raise TypeException( - detail="Invalid file type. Only JSON and CSV files are supported." - ) - - # Read file content - try: - file_content = await file.read() - except Exception as e: - logger.error("Error reading file: %s", str(e)) - raise BadRequestException(detail="Error reading file") from e - - # Check if file is empty - if len(file_content) == 0: - raise BadRequestException(detail="Uploaded file is empty") - - # Process file - result = crud.upload_reading_plans( - db=db_session, - file_content=file_content, - file_type=file_ext - ) - - return schema.ReadingPlanUploadResponse( - message="Reading plans uploaded successfully", - total_uploaded=result["total"], - total_updated=result["updated"], - total_created=result["created"], - skipped=result.get("skipped", 0) - ) - - -@router.get( - "/reading-plans", - response_model=List[schema.ReadingPlanResponse], - tags=["Reading Plan"] -) -async def get_reading_plans( - month: Optional[int] = Query(None, ge=1, le=12, description="Month (1-12)"), - day: Optional[int] = Query(None, ge=1, le=31, description="Day (1-31)"), - db_session: Session = Depends(get_db), - session: SessionContainer = Depends(verify_session_data) -): - """ - Get reading plans. - - - Without parameters: Returns all 365 reading plans - - With month and day: Returns reading plan for specific date - - **Example:** - - Get all: `/reading-plans` - - Get specific date: `/reading-plans?month=11&day=23` - - """ - logger.info("GET Reading Plans API") - validate_all_roles(session) - _, _ = await ensure_user_from_session_async(db_session, session) - - # Validate that both month and day are provided together or neither - if (month is None) != (day is None): - raise BadRequestException( - detail="Both month and day must be provided together, or neither" - ) - - plans = crud.get_reading_plans(db_session, month=month, day=day) - - return [ - schema.ReadingPlanResponse( - id=plan.id, - month=plan.month, - day=plan.day, - readings=plan.readings - ) - for plan in plans - ] - - -@router.delete( - "/reading-plans", - response_model=schema.ReadingPlanDeleteResponse, - tags=["Reading Plan"] -) -async def delete_reading_plans( - db_session: Session = Depends(get_db), - session: SessionContainer = Depends(verify_session_data) -): - """ - Delete all reading plans from the database. - - **Warning:** This action cannot be undone. - """ - logger.info("DELETE Reading Plans API") - validate_admin_editor(session) - _, _ = await ensure_user_from_session_async(db_session, session) - - deleted_count = crud.delete_all_reading_plans(db_session) - - return schema.ReadingPlanDeleteResponse( - message="All reading plans deleted successfully", - deleted_count=deleted_count - ) - -@router.get("/infographics/test/{resource_id}", - response_model=schema.InfographicCheckResponse, - tags=["Check remote data"]) -async def check_infographics_remote_data(resource_id: int, - db: Session = Depends(get_db), - session: SessionContainer = Depends(verify_session_data)): - """ - Test if infographic files exist in remote repository for the given resource. - Verifies full image and its thumb version using remote HEAD requests. - """ - validate_admin_only(session) - return crud.check_infographics_by_resource(db, resource_id) - -@router.get("/commentary/test/{resource_id}", tags=["Check remote data"], - response_model=schema.CommentaryImageCheckResponse) -async def test_commentary_images( - resource_id: int, - db: Session = Depends(get_db), - session: SessionContainer = Depends(verify_session_data) -): - """ - Test if commentary images exist in remote repository for the given resource.""" - validate_admin_only(session) - _, _ = await ensure_user_from_session_async(db, session) - - return crud.test_commentary_images(db, resource_id) - -@router.get( - "/audit-logs", - response_model=schema.AuditLogListResponse, - tags=["AuditLog"], -) -async def list_audit_logs( - params: schema.AuditLogQueryParams = Depends(), - db: Session = Depends(get_db), - session: SessionContainer = Depends(verify_session_data), -): - """ - List all audit logs with optional filtering and pagination. - - - **user_id**: Optional filter to get logs for a specific user - - **path**: Optional substring match on path - - **status_code**: Optional filter based on status_code - - **date_from** / **date_to**: Optional date range filtering - - **page**: Page number (default 0) - - **page_size**: Number of results per page (default 100, max 500) - """ - - # Validate session - validate_admin_only(session) - _, _ = await ensure_user_from_session_async(db, session) - - # Validate user_id if provided - if params.user_id is not None: - exists = ( - db.query(db_models.User.id) - .filter(db_models.User.id == params.user_id) - .first() - ) - if exists is None: - raise NotAvailableException( - detail="User_id does not exist", - ) - - # Fetch logs - logs, total = crud.get_audit_logs( - db, - user_id=params.user_id, - path=params.path, - status_code=params.status_code, - date_from=params.date_from, - date_to=params.date_to, - page=params.page, - page_size=params.page_size, - ) - - # Standardized response - return schema.AuditLogListResponse( - items=logs, - total=total, - page=params.page, - page_size=params.page_size, - ) - -@router.get("/audio-bible/test/{resource_id}",tags=["Check remote data"], - response_model=schema.AudioBibleTestResponse) -async def test_audio_bible_files( - resource_id: int, - db: Session = Depends(get_db), - session: SessionContainer = Depends(verify_session_data) -): - """Check DigitalOcean Spaces for missing audio bible files.""" - validate_admin_only(session) - _, _ = await ensure_user_from_session_async(db, session) - out = crud.check_audio_bible_remote(db, resource_id) - if "error" in out: - raise BadRequestException(detail=out["error"]) - return out - -@router.post("/bible/usfm/validate", tags=["Usfm Format Checker"]) -async def validate_usfm_api(file: UploadFile = File(...), - db: Session = Depends(get_db), - session: SessionContainer = Depends(verify_session_data)): - """Validate USFM file""" - validate_admin_editor(session) - _, _ = await ensure_user_from_session_async(db, session) - return await crud.validate_usfm_file(file) -@router.get("/videos/test/{resource_id}", - tags=["Check remote data"],response_model=schema.VideoRemoteTestResponse -) -async def test_videos_remote_data( - resource_id: int, - db: Session = Depends(get_db), - session: SessionContainer = Depends(verify_session_data)): - """test videos remote data""" - validate_admin_only(session) - _, _ = await ensure_user_from_session_async(db, session) - return crud.test_videos_for_resource(db, resource_id) - -@router.get("/isl-bible/test/{resource_id}", - tags=["Check remote data"],response_model=schema.IslVideoTestResponse -) -async def test_isl_bible_remote_data( - resource_id: int, - db: Session = Depends(get_db), - session: SessionContainer = Depends(verify_session_data)): - """test isl-bible remote data""" - validate_admin_only(session) - _, _ = await ensure_user_from_session_async(db, session) - return crud.test_isl_bible_videos_for_resource(db, resource_id) -@router.post("/isl-bible", response_model=schema.IslVideoListResponse, tags=["ISL Videos"]) -async def api_create_isl_videos( - payload: schema.IslVideoCreateRequest, - db: Session = Depends(get_db), - session: SessionContainer = Depends(verify_session_data) -): - """Create isl bible""" - validate_admin_editor(session) - _, _ = await ensure_user_from_session_async(db, session) - return crud.create_isl_videos(db, payload) - - -@router.put("/isl-bible", response_model=schema.IslVideoListResponse, tags=["ISL Videos"]) -async def api_update_isl_videos( - payload: schema.IslVideoUpdateRequest, - db: Session = Depends(get_db), - session: SessionContainer = Depends(verify_session_data) -): - """Create isl bible""" - validate_admin_editor(session) - _, _ = await ensure_user_from_session_async(db, session) - return crud.update_isl_videos(db, payload) - - -@router.get("/isl-bible/{resource_id}", - response_model=schema.IslVideoGetResponse, tags=["ISL Videos"]) -async def api_get_isl_videos( - resource_id: int, - #make this noptionl bookcode - book_code: Optional[str] = Query(None, description="book code, e.g. 'gen'"), - chapter: Optional[int] = Query(None, description="chapter number (0 for whole book)"), - db: Session = Depends(get_db), - session: SessionContainer = Depends(verify_session_data) -): - """get isl bible according to resource id,book code,chapter""" - validate_all_roles(session) - _, _ = await ensure_user_from_session_async(db, session) - return crud.get_isl_videos(db, resource_id, book_code, chapter) - - - -@router.delete( - "/isl-bible/{resource_id}", - tags=["ISL Videos"], - response_model=schema.IslVideoDeleteResponse -) -async def api_delete_isl_videos( - resource_id: int, - payload: schema.IslVideoDeleteRequest, - db: Session = Depends(get_db), - session: SessionContainer = Depends(verify_session_data) -): - """delete isl bible""" - validate_admin_editor(session) - _, _ = await ensure_user_from_session_async(db, session) - - result = crud.delete_isl_videos(db, resource_id, payload.videoIds) - - deleted = result["deleted_count"] - deleted_ids = result["deleted_ids"] - invalid_ids = result["invalid_ids"] - - # Case 1: All invalid → 422 - if deleted == 0 and invalid_ids: - raise HTTPException( - status_code=422, - detail={ - "deletedCount": 0, - "deletedIds": [], - "invalidIds": invalid_ids, - "message": "No valid video_ids found" - } - ) - - # Case 2: Partial success → 207 - if deleted > 0 and invalid_ids: - return JSONResponse( - status_code=207, - content={ - "deletedCount": deleted, - "deletedIds": deleted_ids, - "invalidIds": invalid_ids, - "message": "Partially deleted videos" - } - ) - - # Case 3: Full success → 200 - return { - "deletedCount": deleted, - "deletedIds": deleted_ids, - "invalidIds": [], - "message": f"Successfully deleted {deleted} videos" - } - - -@router.get("/error-log", response_model=schema.ErrorLogListResponse, tags=["ErrorLog"]) -async def get_error_logs( - params: schema.ErrorLogQueryParams = Depends(), - db: Session = Depends(get_db), - session: SessionContainer = Depends(verify_session_data), -): - """ - Retrieve error logs (admin only). - - Filters: - - user_id: filter logs by user - - start_time, end_time: filter between these datetimes (inclusive) - - limit: max number of rows (<= 1000) - """ - validate_admin_only(session) - _, _ = await ensure_user_from_session_async(db, session) - - query = db.query(db_models.ErrorLog) - - # Validate user_id if provided - if params.user_id is not None: - exists = ( - db.query(db_models.User.id) - .filter(db_models.User.id == params.user_id) - .first() - ) - if exists is None: - raise NotAvailableException( - detail="User_id does not exist", - ) - query = query.filter(db_models.ErrorLog.user_id == params.user_id) - # --- Filter by time range --- - if params.start_time and params.end_time: - query = query.filter( - db_models.ErrorLog.time.between(params.start_time, params.end_time) - ) - elif params.start_time: - query = query.filter(db_models.ErrorLog.time >= params.start_time) - elif params.end_time: - query = query.filter(db_models.ErrorLog.time <= params.end_time) - - query = query.order_by(db_models.ErrorLog.time.desc()).limit(params.limit) - - items = query.all() - total = len(items) - - return schema.ErrorLogListResponse(items=items, total=total) diff --git a/backend/app/router/content_bible.py b/backend/app/router/content_bible.py index f09d5cf1..41c328af 100644 --- a/backend/app/router/content_bible.py +++ b/backend/app/router/content_bible.py @@ -7,6 +7,7 @@ Form, ) from fastapi.responses import JSONResponse +from fastapi.concurrency import run_in_threadpool from supertokens_python.recipe.session import SessionContainer from supertokens_python.recipe.session.framework.fastapi import verify_session from sqlalchemy.orm import Session @@ -50,12 +51,17 @@ async def upload_bible_book( # Validate USFM file before processing validation_result = await remote_filecheck_crud.validate_usfm_file(usfm) if not validation_result["valid"]: - raise UnprocessableException(detail=validation_result["error"]) - return content_bible.upload_bible_book( + raise UnprocessableException(detail=validation_result.get("error")) + + # Pass pre-parsed data to avoid re-parsing + return await run_in_threadpool( + content_bible.upload_bible_book, db_session=db_session, resource_id=resource_id, usfm_file=usfm, - actor_user_id=actor_id + actor_user_id=actor_id, + pre_parsed_usj_data=validation_result.get("usj_data"), + usfm_content=validation_result.get("usfm_content"), ) @router.put( @@ -74,14 +80,21 @@ async def update_bible_book( # Get user ID from session actor_id, _ = await ensure_user_from_session_async(db_session, session) - validation_result = await remote_filecheck_crud.validate_usfm_file(usfm) + + # Validate USFM file AND get parsed data + validation_result = await remote_filecheck_crud.validate_usfm_file_internal(usfm) if not validation_result["valid"]: - raise UnprocessableException(detail=validation_result["error"]) - return content_bible.update_bible_book( + raise UnprocessableException(detail=validation_result.get("error")) + + # Pass pre-parsed data to avoid re-parsing + return await run_in_threadpool( + content_bible.update_bible_book, db_session=db_session, bible_book_id=bible_book_id, usfm_file=usfm, - actor_user_id=actor_id + actor_user_id=actor_id, + pre_parsed_usj_data=validation_result.get("usj_data"), + usfm_content=validation_result.get("usfm_content"), ) @router.delete( diff --git a/backend/app/router/format_checker.py b/backend/app/router/format_checker.py index 66b85b91..fbd267b6 100644 --- a/backend/app/router/format_checker.py +++ b/backend/app/router/format_checker.py @@ -72,7 +72,8 @@ async def validate_usfm_api(file: UploadFile = File(...), """Validate USFM file""" validate_admin_editor(session) _, _ = await ensure_user_from_session_async(db, session) - return await remote_filecheck_crud.validate_usfm_file(file) + + return await remote_filecheck_crud.validate_usfm_file_api(file) @router.get("/videos/test/{resource_id}", tags=["Check remote data"],response_model=schema.VideoRemoteTestResponse ) From 9ebc4af1a544ec02cddd6de0577435b787cdf83a Mon Sep 17 00:00:00 2001 From: jeaneselinasm Date: Thu, 5 Feb 2026 23:04:30 +0900 Subject: [PATCH 25/87] fix: disable check remote data button for editor role --- frontend/src/components/UploadOrViewDialog.tsx | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/frontend/src/components/UploadOrViewDialog.tsx b/frontend/src/components/UploadOrViewDialog.tsx index 28617ffb..67f51fab 100644 --- a/frontend/src/components/UploadOrViewDialog.tsx +++ b/frontend/src/components/UploadOrViewDialog.tsx @@ -316,13 +316,13 @@ export default function UploadOrViewDialog({ const filteredRows: Record[] = requiredHeaders?.length ? rows.map((row) => - Object.fromEntries( - requiredHeaders.map((key) => [ - key, - (row as Record)[key] ?? "", - ]), - ), - ) + Object.fromEntries( + requiredHeaders.map((key) => [ + key, + (row as Record)[key] ?? "", + ]), + ), + ) : (rows as Record[]); const csv = Papa.unparse(filteredRows, { @@ -427,7 +427,7 @@ export default function UploadOrViewDialog({ {mode === "view" && (
- {remoteTestConfig && (isAdmin || isEditor) && ( + {remoteTestConfig && (isAdmin) && (