backend-lehrer (11 files): - llm_gateway/routes/schools.py (867 → 5), recording_api.py (848 → 6) - messenger_api.py (840 → 5), print_generator.py (824 → 5) - unit_analytics_api.py (751 → 5), classroom/routes/context.py (726 → 4) - llm_gateway/routes/edu_search_seeds.py (710 → 4) klausur-service (12 files): - ocr_labeling_api.py (845 → 4), metrics_db.py (833 → 4) - legal_corpus_api.py (790 → 4), page_crop.py (758 → 3) - mail/ai_service.py (747 → 4), github_crawler.py (767 → 3) - trocr_service.py (730 → 4), full_compliance_pipeline.py (723 → 4) - dsfa_rag_api.py (715 → 4), ocr_pipeline_auto.py (705 → 4) website (6 pages): - audit-checklist (867 → 8), content (806 → 6) - screen-flow (790 → 4), scraper (789 → 5) - zeugnisse (776 → 5), modules (745 → 4) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
465 lines
18 KiB
Python
465 lines
18 KiB
Python
"""
|
|
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],
|
|
)
|