""" Schools API - School CRUD & Stats Routes. List, get, stats, and bulk-import endpoints for schools. """ import logging from typing import Optional from fastapi import APIRouter, HTTPException, Query from .schools_db import get_db_pool from .schools_models import ( SchoolResponse, SchoolsListResponse, SchoolStatsResponse, SchoolTypeResponse, BulkImportRequest, BulkImportResponse, ) logger = logging.getLogger(__name__) router = APIRouter(tags=["schools"]) # ============================================================================= # School Type Endpoints # ============================================================================= @router.get("/types", response_model=list[SchoolTypeResponse]) async def list_school_types(): """List all school types.""" pool = await get_db_pool() async with pool.acquire() as conn: rows = await conn.fetch(""" SELECT id, name, name_short, category, description FROM school_types ORDER BY category, name """) return [ SchoolTypeResponse( id=str(row["id"]), name=row["name"], name_short=row["name_short"], category=row["category"], description=row["description"], ) for row in rows ] # ============================================================================= # School Endpoints # ============================================================================= @router.get("", response_model=SchoolsListResponse) async def list_schools( state: Optional[str] = Query(None, description="Filter by state code (BW, BY, etc.)"), school_type: Optional[str] = Query(None, description="Filter by school type name"), city: Optional[str] = Query(None, description="Filter by city"), district: Optional[str] = Query(None, description="Filter by district"), postal_code: Optional[str] = Query(None, description="Filter by postal code prefix"), search: Optional[str] = Query(None, description="Search in name, city"), has_email: Optional[bool] = Query(None, description="Filter schools with email"), has_website: Optional[bool] = Query(None, description="Filter schools with website"), is_public: Optional[bool] = Query(None, description="Filter public/private schools"), page: int = Query(1, ge=1), page_size: int = Query(50, ge=1, le=200), ): """List schools with optional filtering and pagination.""" pool = await get_db_pool() async with pool.acquire() as conn: # Build WHERE clause conditions = ["s.is_active = TRUE"] params = [] param_idx = 1 if state: conditions.append(f"s.state = ${param_idx}") params.append(state.upper()) param_idx += 1 if school_type: conditions.append(f"st.name = ${param_idx}") params.append(school_type) param_idx += 1 if city: conditions.append(f"LOWER(s.city) = LOWER(${param_idx})") params.append(city) param_idx += 1 if district: conditions.append(f"LOWER(s.district) LIKE LOWER(${param_idx})") params.append(f"%{district}%") param_idx += 1 if postal_code: conditions.append(f"s.postal_code LIKE ${param_idx}") params.append(f"{postal_code}%") param_idx += 1 if search: conditions.append(f""" (LOWER(s.name) LIKE LOWER(${param_idx}) OR LOWER(s.city) LIKE LOWER(${param_idx}) OR LOWER(s.district) LIKE LOWER(${param_idx})) """) params.append(f"%{search}%") param_idx += 1 if has_email is not None: if has_email: conditions.append("s.email IS NOT NULL") else: conditions.append("s.email IS NULL") if has_website is not None: if has_website: conditions.append("s.website IS NOT NULL") else: conditions.append("s.website IS NULL") if is_public is not None: conditions.append(f"s.is_public = ${param_idx}") params.append(is_public) param_idx += 1 where_clause = " AND ".join(conditions) # Count total count_query = f""" SELECT COUNT(*) FROM schools s LEFT JOIN school_types st ON s.school_type_id = st.id WHERE {where_clause} """ total = await conn.fetchval(count_query, *params) # Fetch schools offset = (page - 1) * page_size query = f""" SELECT s.id, s.name, s.school_number, s.state, s.district, s.city, s.postal_code, s.street, s.address_full, s.latitude, s.longitude, s.website, s.email, s.phone, s.fax, s.principal_name, s.principal_email, s.student_count, s.teacher_count, s.is_public, s.is_all_day, s.source, s.crawled_at, s.is_active, s.created_at, s.updated_at, st.name as school_type, st.name_short as school_type_short, st.category as school_category, (SELECT COUNT(*) FROM school_staff ss WHERE ss.school_id = s.id AND ss.is_active = TRUE) as staff_count FROM schools s LEFT JOIN school_types st ON s.school_type_id = st.id WHERE {where_clause} ORDER BY s.state, s.city, s.name LIMIT ${param_idx} OFFSET ${param_idx + 1} """ params.extend([page_size, offset]) rows = await conn.fetch(query, *params) schools = [ SchoolResponse( id=str(row["id"]), name=row["name"], school_number=row["school_number"], school_type=row["school_type"], school_type_short=row["school_type_short"], school_category=row["school_category"], state=row["state"], district=row["district"], city=row["city"], postal_code=row["postal_code"], street=row["street"], address_full=row["address_full"], latitude=row["latitude"], longitude=row["longitude"], website=row["website"], email=row["email"], phone=row["phone"], fax=row["fax"], principal_name=row["principal_name"], principal_email=row["principal_email"], student_count=row["student_count"], teacher_count=row["teacher_count"], is_public=row["is_public"], is_all_day=row["is_all_day"], staff_count=row["staff_count"], source=row["source"], crawled_at=row["crawled_at"], is_active=row["is_active"], created_at=row["created_at"], updated_at=row["updated_at"], ) for row in rows ] return SchoolsListResponse( schools=schools, total=total, page=page, page_size=page_size, ) @router.get("/stats", response_model=SchoolStatsResponse) async def get_school_stats(): """Get school statistics.""" pool = await get_db_pool() async with pool.acquire() as conn: # Total schools and staff totals = await conn.fetchrow(""" SELECT (SELECT COUNT(*) FROM schools WHERE is_active = TRUE) as total_schools, (SELECT COUNT(*) FROM school_staff WHERE is_active = TRUE) as total_staff, (SELECT COUNT(*) FROM schools WHERE is_active = TRUE AND website IS NOT NULL) as with_website, (SELECT COUNT(*) FROM schools WHERE is_active = TRUE AND email IS NOT NULL) as with_email, (SELECT COUNT(*) FROM schools WHERE is_active = TRUE AND principal_name IS NOT NULL) as with_principal, (SELECT COALESCE(SUM(student_count), 0) FROM schools WHERE is_active = TRUE) as total_students, (SELECT COALESCE(SUM(teacher_count), 0) FROM schools WHERE is_active = TRUE) as total_teachers, (SELECT MAX(crawled_at) FROM schools) as last_crawl """) # By state state_rows = await conn.fetch(""" SELECT state, COUNT(*) as count FROM schools WHERE is_active = TRUE GROUP BY state ORDER BY state """) schools_by_state = {row["state"]: row["count"] for row in state_rows} # By type type_rows = await conn.fetch(""" SELECT COALESCE(st.name, 'Unbekannt') as type_name, COUNT(*) as count FROM schools s LEFT JOIN school_types st ON s.school_type_id = st.id WHERE s.is_active = TRUE GROUP BY st.name ORDER BY count DESC """) schools_by_type = {row["type_name"]: row["count"] for row in type_rows} return SchoolStatsResponse( total_schools=totals["total_schools"], total_staff=totals["total_staff"], schools_by_state=schools_by_state, schools_by_type=schools_by_type, schools_with_website=totals["with_website"], schools_with_email=totals["with_email"], schools_with_principal=totals["with_principal"], total_students=totals["total_students"], total_teachers=totals["total_teachers"], last_crawl_time=totals["last_crawl"], ) @router.get("/{school_id}", response_model=SchoolResponse) async def get_school(school_id: str): """Get a single school by ID.""" pool = await get_db_pool() async with pool.acquire() as conn: row = await conn.fetchrow(""" SELECT s.id, s.name, s.school_number, s.state, s.district, s.city, s.postal_code, s.street, s.address_full, s.latitude, s.longitude, s.website, s.email, s.phone, s.fax, s.principal_name, s.principal_email, s.student_count, s.teacher_count, s.is_public, s.is_all_day, s.source, s.crawled_at, s.is_active, s.created_at, s.updated_at, st.name as school_type, st.name_short as school_type_short, st.category as school_category, (SELECT COUNT(*) FROM school_staff ss WHERE ss.school_id = s.id AND ss.is_active = TRUE) as staff_count FROM schools s LEFT JOIN school_types st ON s.school_type_id = st.id WHERE s.id = $1 """, school_id) if not row: raise HTTPException(status_code=404, detail="School not found") return SchoolResponse( id=str(row["id"]), name=row["name"], school_number=row["school_number"], school_type=row["school_type"], school_type_short=row["school_type_short"], school_category=row["school_category"], state=row["state"], district=row["district"], city=row["city"], postal_code=row["postal_code"], street=row["street"], address_full=row["address_full"], latitude=row["latitude"], longitude=row["longitude"], website=row["website"], email=row["email"], phone=row["phone"], fax=row["fax"], principal_name=row["principal_name"], principal_email=row["principal_email"], student_count=row["student_count"], teacher_count=row["teacher_count"], is_public=row["is_public"], is_all_day=row["is_all_day"], staff_count=row["staff_count"], source=row["source"], crawled_at=row["crawled_at"], is_active=row["is_active"], created_at=row["created_at"], updated_at=row["updated_at"], ) @router.post("/bulk-import", response_model=BulkImportResponse) async def bulk_import_schools(request: BulkImportRequest): """Bulk import schools. Updates existing schools based on school_number + state.""" pool = await get_db_pool() imported = 0 updated = 0 skipped = 0 errors = [] async with pool.acquire() as conn: # Get school type mapping type_rows = await conn.fetch("SELECT id, name FROM school_types") type_map = {row["name"].lower(): str(row["id"]) for row in type_rows} for school in request.schools: try: # Find school type ID school_type_id = None if school.school_type_raw: school_type_id = type_map.get(school.school_type_raw.lower()) # Check if school exists (by school_number + state, or by name + city + state) existing = None if school.school_number: existing = await conn.fetchrow( "SELECT id FROM schools WHERE school_number = $1 AND state = $2", school.school_number, school.state ) if not existing and school.city: existing = await conn.fetchrow( "SELECT id FROM schools WHERE LOWER(name) = LOWER($1) AND LOWER(city) = LOWER($2) AND state = $3", school.name, school.city, school.state ) if existing: # Update existing school await conn.execute(""" UPDATE schools SET name = $2, school_type_id = COALESCE($3, school_type_id), school_type_raw = COALESCE($4, school_type_raw), district = COALESCE($5, district), city = COALESCE($6, city), postal_code = COALESCE($7, postal_code), street = COALESCE($8, street), address_full = COALESCE($9, address_full), latitude = COALESCE($10, latitude), longitude = COALESCE($11, longitude), website = COALESCE($12, website), email = COALESCE($13, email), phone = COALESCE($14, phone), fax = COALESCE($15, fax), principal_name = COALESCE($16, principal_name), principal_title = COALESCE($17, principal_title), principal_email = COALESCE($18, principal_email), principal_phone = COALESCE($19, principal_phone), student_count = COALESCE($20, student_count), teacher_count = COALESCE($21, teacher_count), is_public = $22, source = COALESCE($23, source), source_url = COALESCE($24, source_url), updated_at = NOW() WHERE id = $1 """, existing["id"], school.name, school_type_id, school.school_type_raw, school.district, school.city, school.postal_code, school.street, school.address_full, school.latitude, school.longitude, school.website, school.email, school.phone, school.fax, school.principal_name, school.principal_title, school.principal_email, school.principal_phone, school.student_count, school.teacher_count, school.is_public, school.source, school.source_url, ) updated += 1 else: # Insert new school await conn.execute(""" INSERT INTO schools ( name, school_number, school_type_id, school_type_raw, state, district, city, postal_code, street, address_full, latitude, longitude, website, email, phone, fax, principal_name, principal_title, principal_email, principal_phone, student_count, teacher_count, is_public, source, source_url, crawled_at ) VALUES ( $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, NOW() ) """, school.name, school.school_number, school_type_id, school.school_type_raw, school.state, school.district, school.city, school.postal_code, school.street, school.address_full, school.latitude, school.longitude, school.website, school.email, school.phone, school.fax, school.principal_name, school.principal_title, school.principal_email, school.principal_phone, school.student_count, school.teacher_count, school.is_public, school.source, school.source_url, ) imported += 1 except Exception as e: errors.append(f"Error importing {school.name}: {str(e)}") if len(errors) > 100: errors.append("... (more errors truncated)") break return BulkImportResponse( imported=imported, updated=updated, skipped=skipped, errors=errors[:100], )