diff --git a/backend/app/crud/isl_verse_markers_crud.py b/backend/app/crud/isl_verse_markers_crud.py new file mode 100644 index 0000000..6691354 --- /dev/null +++ b/backend/app/crud/isl_verse_markers_crud.py @@ -0,0 +1,264 @@ +"""CRUD operations for the ISL verse markers API.""" +from typing import List, Dict, Any +from sqlalchemy.orm import Session +from sqlalchemy.exc import SQLAlchemyError +import db_models +from custom_exceptions import NotAvailableException, AlreadyExistsException,UnprocessableException +from dependencies import logger + + +def _ensure_verse_zero(markers): + if not any(str(m["verse"]) == "0" for m in markers): + markers.insert(0, {"verse": 0, "time": "00:00:00:00"}) + return markers + + +def update_verse_markers( + db_session: Session, + isl_bible_id: int, + markers: List[Dict[str, Any]], +): + """Updates verse markers for the given ISL Bible ID.""" + logger.info("Updating ISL verse markers") + + isl_bible_rec = db_session.query(db_models.IslVideo).filter_by( + id=isl_bible_id + ).first() + + if not isl_bible_rec: + logger.error("ISL Bible %s not found",isl_bible_id) + raise NotAvailableException( + detail=f"ISL Bible {isl_bible_id} not found" + ) + + record = db_session.query(db_models.IslVerseMarkers).filter_by( + isl_video_id=isl_bible_id + ).first() + + if not record: + logger.error("Verse markers not found for ISL Bible %s", isl_bible_id) + raise NotAvailableException( + detail=f"Verse markers not found for ISL Bible {isl_bible_id}" + ) + + markers = _ensure_verse_zero(markers) + record.verse_markers_json = markers + + db_session.commit() + db_session.refresh(record) + + return record + +def get_verse_markers( + db_session: Session, + isl_bible_id: int, +): + """Retrieves verse markers for the given islbible id""" + record = db_session.query(db_models.IslVerseMarkers).filter_by( + isl_video_id=isl_bible_id + ).first() + + if not record: + logger.error("Verse markers not found for ISL Bible %s",isl_bible_id) + raise NotAvailableException( + detail=f"Verse markers not found for ISL Bible {isl_bible_id}" + ) + + return record +def _build_bulk_delete_response(deleted_ids, errors): + """Build consistent bulk delete response structure.""" + return { + "data": {"deletedCount": len(deleted_ids), + "deletedIds": deleted_ids, + "errors": errors if errors else None, + }, + "all_failed": len(deleted_ids) == 0 and len(errors) > 0, + "has_errors": len(errors) > 0, + } + +def delete_verse_markers_bulk( + db_session: Session, + isl_bible_ids: List[int], +): + """Deletes verse markers for the given ISL Bible IDs.""" + logger.info("Deleting ISL verse markers") + deleted_ids = [] + errors = [] + + for isl_id in isl_bible_ids: + try: + record = db_session.query(db_models.IslVerseMarkers).filter_by( + isl_video_id=isl_id + ).first() + + if not record: + logger.error("Verse markers not found for ISL Bible %s",isl_id) + errors.append(f"Verse markers not found for ISL Bible {isl_id}") + continue + + db_session.delete(record) + deleted_ids.append(isl_id) + + except SQLAlchemyError as exc: + logger.error("Error deleting ISL Bible %s: %s", isl_id, exc) + errors.append(f"Error deleting ISL Bible {isl_id}: {exc}") + + db_session.commit() + + return _build_bulk_delete_response(deleted_ids, errors) + + + +def get_all_verse_markers(db_session: Session): + """Get all verse markers without isl bible id""" + return db_session.query(db_models.IslVerseMarkers).all() + +def _timestamp_to_frames(timestamp: str) -> int: + hours, minutes, seconds, frames = map(int, timestamp.split(":")) + return (((hours * 60) + minutes) * 60 + seconds) * 100 + frames + +def _validate_marker_verses(db_session, isl_bible, markers): + """ + Validate verses exist for given book/chapter. + """ + + book_id = isl_bible.book_id + chapter = isl_bible.chapter + + # Allow intro chapter + if chapter == 0: + return + valid_verses = { + int(row.verse) + for row in db_session.query(db_models.CleanBible).filter_by( + book_id=book_id, + chapter=chapter + ).all() + } + for marker in markers: + verse = marker["verse"] + + # intro marker + if verse == 0: + continue + + if isinstance(verse, str) and "_" in verse: + start, end = map(int, verse.split("_")) + + for verse_number in range(start, end + 1): + if verse_number not in valid_verses: + raise UnprocessableException( + detail=f"Invalid verse {verse_number} for chapter {chapter}" + ) + + else: + if int(verse) not in valid_verses: + raise UnprocessableException( + detail=f"Invalid verse {verse} for chapter {chapter}" + ) + +def _validate_timestamp_order(markers): + previous = -1 + + for marker in markers: + current = _timestamp_to_frames(marker["time"]) + + if current <= previous: + raise UnprocessableException( + detail="timestamps must be in increasing order" + ) + + previous = current + +def add_verse_markers_bulk( + db_session: Session, + payload: Dict[int, List[Dict[str, Any]]], +): + """ + Bulk create verse markers. + + Payload format: + { + 1: [ + { + "verse": 0, + "time": "00:00:00:00" + } + ], + 2: [ + { + "verse": "12_13", + "time": "00:01:20:10" + } + ] + } + """ + + logger.info("Adding bulk ISL verse markers") + + created_records = [] + + for isl_bible_id, markers in payload.items(): + markers = [ + m.model_dump() if hasattr(m, "model_dump") else m + for m in markers] + # Validate ISL video exists + isl_video = ( + db_session.query(db_models.IslVideo) + .filter_by(id=isl_bible_id) + .first() + ) + + if not isl_video: + logger.error( + "ISL Video %s not found", + isl_bible_id + ) + raise NotAvailableException( + detail=f"ISL Video {isl_bible_id} not found" + ) + + # Check existing verse markers + existing = ( + db_session.query(db_models.IslVerseMarkers) + .filter_by(isl_video_id=isl_bible_id) + .first() + ) + + if existing: + logger.error( + "Verse markers already exist for ISL Video %s", + isl_bible_id + ) + raise AlreadyExistsException( + detail=( + f"Verse markers already exist " + f"for ISL Video {isl_bible_id}" + ) + ) + + # Ensure verse 0 exists + markers = _ensure_verse_zero(markers) + + # Validate timestamp order + _validate_timestamp_order(markers) + + # Validate verses against clean_bible + # chapter 0 allowed + _validate_marker_verses(db_session,isl_video,markers) + + record = db_models.IslVerseMarkers( + isl_video_id=isl_bible_id, + verse_markers_json=markers + ) + + db_session.add(record) + + created_records.append({ + "id": isl_bible_id, + "markers": markers + }) + + db_session.commit() + + return created_records diff --git a/backend/app/db_models.py b/backend/app/db_models.py index 9c77fc0..836db88 100644 --- a/backend/app/db_models.py +++ b/backend/app/db_models.py @@ -309,4 +309,13 @@ class M2MClient(Base): client_secret_hash = Column(String, nullable=False) name = Column(String, nullable=False) is_active = Column(Boolean, nullable=False, default=True) - created_at = Column(DateTime(timezone=True), nullable=False, default=utcnow) \ No newline at end of file + created_at = Column(DateTime(timezone=True), nullable=False, default=utcnow) + +class IslVerseMarkers(Base): + """Corresponds to table isl_verse_markers in vachan DB(postgres)""" + __tablename__ = "isl_verse_marker" + + id = Column(Integer, primary_key=True, index=True) + isl_video_id = Column(Integer, ForeignKey("isl_video.id", ondelete="CASCADE"), + nullable=False, unique=True) + verse_markers_json = Column(JSONB, nullable=False) diff --git a/backend/app/main.py b/backend/app/main.py index 5c57f53..94c8dac 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -32,6 +32,7 @@ from router.structural import router as structural_router from router.m2m_auth import router as m2m_auth_router from router.content_songs import router as content_songs_router +from router.isl_verse_markers import router as isl_verse_marker_router init_db() @@ -190,3 +191,4 @@ async def root(): app.include_router(structural_router) app.include_router(m2m_auth_router) app.include_router(content_songs_router) +app.include_router(isl_verse_marker_router) diff --git a/backend/app/router/isl_verse_markers.py b/backend/app/router/isl_verse_markers.py new file mode 100644 index 0000000..2d79f03 --- /dev/null +++ b/backend/app/router/isl_verse_markers.py @@ -0,0 +1,186 @@ +"""ISL verse markers Endpoints.""" +from typing import Optional, List, Union,Dict,Any +from fastapi import APIRouter, Depends,Query +from fastapi.responses import JSONResponse +from sqlalchemy.orm import Session +from supertokens_python.recipe.session import SessionContainer +from supertokens_python.recipe.session.framework.fastapi import verify_session + + +import schema +from crud import isl_verse_markers_crud +from dependencies import get_db, logger +from auth import ( + ensure_user_from_session_async, + validate_admin_only, + verify_session_or_api_key +) +router = APIRouter(tags=["ISL Verse Markers"]) +verify_session_data=verify_session() + + + +@router.post( + "/isl-verse-markers", + status_code=201 +) +async def add_verse_markers( + request: schema.IslVerseMarkersBulkCreateRequest, + session: SessionContainer = Depends( + verify_session_data + ), + db_session: Session = Depends(get_db) +): + """ + Bulk create ISL verse markers. + + Request format: + { + "1": [ + { + "verse": 0, + "time": "00:00:00:00" + } + ], + "2": [ + { + "verse": "12_13", + "time": "00:01:20:10" + } + ] + } + + Each top-level key represents an isl_video_id from the isl_video table, + and its value contains the verse markers for that specific ISL video. + + Example: "1" means isl_video_id = 1, and all markers inside that array + will be mapped to that ISL video. + """ + + logger.info("POST bulk ISL verse markers API") + + validate_admin_only(session) + + await ensure_user_from_session_async( + db_session, + session + ) + + records = isl_verse_markers_crud.add_verse_markers_bulk( + db_session, + request.root + ) + + return { + "message": "Verse markers created successfully", + "created": records + } +@router.put( + "/isl-verse-markers/{isl_bible_id}", + response_model=schema.VerseMarkersResponse +) +async def update_verse_markers( + isl_bible_id: int, + request: schema.VerseMarkersCreateRequest, + session: SessionContainer = Depends(verify_session_data), + db_session: Session = Depends(get_db) +): + """Updates verse markers for the given ISL Bible ID.""" + logger.info("PUT ISL verse markers API") + validate_admin_only(session) + await ensure_user_from_session_async(db_session, session) + + record = isl_verse_markers_crud.update_verse_markers( + db_session, + isl_bible_id, + [m.model_dump() for m in request.markers] + ) + + return { + "id": record.id, + "isl_bible_id": record.isl_video_id, + "markers": record.verse_markers_json, + "message": "Verse markers updated successfully"} + + +@router.get( + "/isl-verse-markers", + response_model=Union[schema.VerseMarkersResponse, List[schema.VerseMarkersResponse]] +) +async def get_verse_markers( + isl_bible_id: Optional[int] = Query(None), + auth: Dict[str, Any] = Depends(verify_session_or_api_key), + db_session: Session = Depends(get_db) +): + """Retrives all verse markers""" + logger.info("GET ISL verse markers API") + if auth["auth_type"] == "session": + session = auth["session"] + validate_admin_only(session) + _, _ = await ensure_user_from_session_async(db_session, session) + + if isl_bible_id is not None: + record = isl_verse_markers_crud.get_verse_markers( + db_session, + isl_bible_id + ) + return { + "id": record.id, + "isl_bible_id": record.isl_video_id, + "markers": record.verse_markers_json + } + + records = isl_verse_markers_crud.get_all_verse_markers(db_session) + + return [ + { + "id": r.id, + "isl_bible_id": r.isl_video_id, + "markers": r.verse_markers_json + } + for r in records + ] + +def _build_bulk_delete_http_response(result): + data = result["data"] + deleted_count = data["deletedCount"] + + # Reverse order of checks to avoid duplication pattern + if not result["all_failed"] and not result["has_errors"]: + status_code = 200 + elif result["has_errors"] and not result["all_failed"]: + status_code = 207 + else: + status_code = 404 + + if deleted_count > 0: + message = f"Successfully deleted {deleted_count} ISL verse marker(s)" + else: + message = "No verse markers were deleted" + + return JSONResponse( + status_code=status_code, + content={**data, "message": message}, + ) + + +@router.delete( + "/isl-verse-markers/bulk-delete", + response_model=schema.IslVerseMarkersBulkDeleteResponse +) +async def delete_verse_markers_bulk( + request: schema.IslVerseMarkersBulkDelete, + session: SessionContainer = Depends(verify_session_data), + db_session: Session = Depends(get_db) +): + """Deletes all verse markers for the given ISL Bible IDs.""" + logger.info("DELETE ISL verse markers API") + validate_admin_only(session) + await ensure_user_from_session_async(db_session, session) + + result = isl_verse_markers_crud.delete_verse_markers_bulk( + db_session, + request.isl_bible_ids + ) + + return _build_bulk_delete_http_response(result) diff --git a/backend/app/schema.py b/backend/app/schema.py index 95c7e74..64f6446 100644 --- a/backend/app/schema.py +++ b/backend/app/schema.py @@ -2,6 +2,7 @@ from typing import Any, Union,Literal,Optional,Dict, List,Set import re from enum import Enum +from pydantic import RootModel from datetime import datetime,date from pydantic_core import PydanticCustomError from pydantic import BaseModel,field_validator,Field, model_validator,ValidationError @@ -2302,3 +2303,161 @@ class SongDeleteResponse(BaseModel): deletedIds: List[int] invalidIds: List[int] message: str + +# --- ISL Verse Markers Schemas --- +TIME_PATTERN = re.compile(r"^\d{2}:\d{2}:\d{2}:\d{2}$") + +class VerseMarkerItem(BaseModel): + """Schema for isl marker item""" + verse: Union[int, str] = Field(...) + time: str = Field(..., example="00:00:00:00") + + @field_validator("verse") + @classmethod + def validate_verse(cls, v): + """Validate verse - must be non-negative int or a range string like '1_3'""" + if isinstance(v, str): + if v.isdigit(): + return int(v) + + # Check for negative numeric string + if v.lstrip("-").isdigit(): + raise ValueError("verse cannot be negative") + + # Allow range format like "1_3" + parts = v.split("_") + if len(parts) == 2: + try: + start, end = int(parts[0]), int(parts[1]) + if start < 0 or end < 0: + raise ValueError("verse range values cannot be negative") + if start >= end: + raise ValueError("verse range start must be less than end") + return v + except ValueError as exc: + raise ValueError( + "verse range must be in format 'start_end' " + "with integers" + ) from exc + raise ValueError("verse must be a non-negative integer or range string like '1_3'") + + if isinstance(v, int): + if v < 0: + raise ValueError("verse cannot be negative") + return v + + raise ValueError("verse must be an integer or string") + + @field_validator("time") + @classmethod + def validate_time_format(cls, v): + """Validate time format""" + if not v or not v.strip(): + raise ValueError("time is required and cannot be empty") + + if not TIME_PATTERN.match(v): + raise ValueError("time must be in format HH:MM:SS:FF") + + hh, mm, ss, ff = map(int, v.split(":")) + + if mm >= 60 or ss >= 60: + raise ValueError("minutes and seconds must be between 00 and 59") + + return v + + +def timestamp_to_frames(timestamp: str) -> int: + """ + Converts HH:MM:SS:FF to sortable integer. + """ + hh, mm, ss, ff = map(int, timestamp.split(":")) + return (((hh * 60) + mm) * 60 + ss) * 100 + ff + + +class VerseMarkersCreateRequest(BaseModel): + """Schema for create and update isl marker request""" + markers: List[VerseMarkerItem] = Field(..., min_length=1) + + @field_validator("markers") + @classmethod + def validate_markers(cls, markers): + """Validate markers""" + + verse_numbers = [str(m.verse) for m in markers] + + duplicates = { + v for v in verse_numbers + if verse_numbers.count(v) > 1 + } + + if duplicates: + raise ValueError( + f"Duplicate verse numbers are not allowed: {sorted(duplicates)}" + ) + + previous_time = -1 + + for marker in markers: + current_time = timestamp_to_frames(marker.time) + + if current_time <= previous_time: + raise ValueError( + "timestamps must be in strictly increasing order" + ) + previous_time = current_time + + return markers + +# class IslVerseMarkersBulkCreateRequest(BaseModel): +# """ +# Bulk create request where key is isl_video_id +# """ + +# data: Dict[int, List[VerseMarkerItem]] + +# @field_validator("data") +# @classmethod +# def validate_data(cls, value): +# if not value: +# raise ValueError("data cannot be empty") + +# return value + +class IslVerseMarkersBulkCreateRequest( + RootModel[Dict[int, List[VerseMarkerItem]]] +): + class Config: + json_schema_extra = { + "example": { + "1": [ + { + "verse": 1, + "time": "00:09:00:00" + } + ], + "2": [ + { + "verse": "2", + "time": "00:10:20:10" + } + ] + } + } + +class VerseMarkersResponse(BaseModel): + """Schema for bulk delete isl marker response""" + id: int + isl_bible_id: int + markers: List[VerseMarkerItem] + message: Optional[str] = None + +class IslVerseMarkersBulkDelete(BaseModel): + """Schema for bulk delete isl marker response""" + isl_bible_ids: List[int] + +class IslVerseMarkersBulkDeleteResponse(BaseModel): + """bulk delete isl marker item""" + deletedCount: int + deletedIds: List[int] + errors: Optional[List[str]] +