"""Flask app that exposes health, training, and attendance endpoints.""" import cv2 import numpy as np from flask import Flask, jsonify, request from flask_cors import CORS from pathlib import Path from datetime import datetime import requests from io import BytesIO from config import AppConfig from database import Database, DatabaseError from services.attendance import AttendanceService from services.camera_absensi import CameraAttendanceRunner from services.inference import FacePredictor from services.preprocess import preprocess_dataset from services.train_cnn import train_model from services.fetch_laravel_dataset import LaravelDatasetFetcher from services.attendance_utils import calculate_attendance_status, format_attendance_status def _decode_uploaded_image() -> np.ndarray: """Read the uploaded file and convert it into an OpenCV image.""" if "image" not in request.files: raise ValueError("Field file 'image' wajib diisi.") file = request.files["image"] image_bytes = file.read() img_array = np.frombuffer(image_bytes, np.uint8) frame = cv2.imdecode(img_array, cv2.IMREAD_COLOR) if frame is None: raise ValueError("Gagal decode file gambar.") return frame def _save_attendance_picture(frame: np.ndarray, config: AppConfig) -> str: """Save attendance picture to Laravel storage and return filename. Args: - frame: OpenCV image (BGR) - config: App configuration Returns: Relative path for database storage (e.g., 'daily_attendance_pictures/2026-02-05_14-30-45_123.jpg') """ try: # Generate filename timestamp = datetime.now().strftime("%Y-%m-%d_%H-%M-%S-%f")[:-3] filename = f"{timestamp}.jpg" # Encode frame to JPG success, jpg_buffer = cv2.imencode(".jpg", frame) if not success: raise ValueError("Gagal encode gambar ke JPG") jpg_bytes = jpg_buffer.tobytes() # Upload to Laravel via API url = f"{config.laravel_url}/api/attendance/upload-picture" files = {"image": (filename, BytesIO(jpg_bytes), "image/jpeg")} data = {"storage_path": config.laravel_attendance_pictures_path} print(f"[UPLOAD] Sending to: {url}") print(f"[UPLOAD] Storage path: {config.laravel_attendance_pictures_path}") print(f"[UPLOAD] Filename: {filename}") response = requests.post(url, files=files, data=data, timeout=10) print(f"[UPLOAD] Response status: {response.status_code}") print(f"[UPLOAD] Response headers: {response.headers}") print(f"[UPLOAD] Response body (first 500 chars): {response.text[:500]}") if response.status_code == 200: try: result = response.json() print(f"[UPLOAD] Response JSON: {result}") uploaded_path = result.get("path", f"{config.laravel_attendance_pictures_path}/{filename}") print(f"[UPLOAD] Success! Path: {uploaded_path}") return uploaded_path except Exception as json_error: print(f"[UPLOAD] JSON parse error: {str(json_error)}") print(f"[UPLOAD] Fallback to local storage") return _save_attendance_picture_locally(frame, config, filename) else: # Log error response print(f"[UPLOAD] Error response: {response.text}") print(f"[UPLOAD] Fallback to local storage") return _save_attendance_picture_locally(frame, config, filename) except requests.exceptions.ConnectionError as e: print(f"[UPLOAD] Connection error to Laravel: {str(e)}") print(f"[UPLOAD] Fallback to local storage") timestamp = datetime.now().strftime("%Y-%m-%d_%H-%M-%S-%f")[:-3] filename = f"{timestamp}.jpg" return _save_attendance_picture_locally(frame, config, filename) except Exception as e: print(f"[UPLOAD] Unexpected error: {str(e)}") print(f"[UPLOAD] Fallback to local storage") timestamp = datetime.now().strftime("%Y-%m-%d_%H-%M-%S-%f")[:-3] filename = f"{timestamp}.jpg" return _save_attendance_picture_locally(frame, config, filename) def _save_attendance_picture_locally( frame: np.ndarray, config: AppConfig, filename: str = None ) -> str: """Fallback: Save attendance picture to local disk. Args: - frame: OpenCV image (BGR) - config: App configuration - filename: Optional filename (default: timestamp) Returns: Relative path (e.g., 'attendance_pictures/2026-02-05_14-30-45_123.jpg') """ if filename is None: timestamp = datetime.now().strftime("%Y-%m-%d_%H-%M-%S-%f")[:-3] filename = f"{timestamp}.jpg" picture_dir = config.project_root / "attendance_pictures" picture_dir.mkdir(exist_ok=True) filepath = picture_dir / filename cv2.imwrite(str(filepath), frame) # Return relative path for database storage return f"attendance_pictures/{filename}" def _read_pipeline_options(config: AppConfig) -> dict: """Collect pipeline options from the request body.""" payload = request.get_json(silent=True) or {} return { "fetch_from_laravel": bool(payload.get("fetch_from_laravel", False)), "run_preprocess": bool(payload.get("preprocess", True)), "run_train": bool(payload.get("train", True)), "overwrite": bool(payload.get("overwrite", False)), "epochs": int(payload.get("epochs", config.epochs)), "min_images": int(payload.get("min_images_per_class", config.min_images_per_class)), "train_ratio": float(payload.get("train_ratio", config.train_ratio)), "val_ratio": float(payload.get("val_ratio", config.val_ratio)), "test_ratio": float(payload.get("test_ratio", config.test_ratio)), } def create_app() -> Flask: app = Flask(__name__) CORS(app, origins=[ "https://presensiku.site", "http://presensiku.site", "http://127.0.0.1:8000", "http://localhost:8000", "http://127.0.0.1:3000", ]) # Izinkan origin yang terdaftar config = AppConfig.from_env() db = Database(config) # Note: Database schema already exists in Laravel database # No need to call db.init_schema_if_needed() predictor = FacePredictor( model_path=config.model_path, class_names_path=config.class_names_path, image_size=config.image_size, ) attendance_service = AttendanceService(db) camera_runner = CameraAttendanceRunner(config, predictor, attendance_service) @app.get("/health") def health(): status = { "status": "ok", "model_exists": config.model_path.exists(), "dataset_root": str(config.dataset_root), } return jsonify(status) @app.post("/pipeline/run") def run_pipeline(): options = _read_pipeline_options(config) results = {} # Fetch dataset from Laravel if requested if options["fetch_from_laravel"]: try: fetcher = LaravelDatasetFetcher( db=db, laravel_url=config.laravel_url, storage_path=config.laravel_storage_path, ) fetch_result = fetcher.fetch_and_organize( output_dir=config.raw_dir, overwrite=options["overwrite"], ) results["fetch_laravel"] = fetch_result if not fetch_result["success"]: return jsonify({ "status": "error", "message": "Gagal fetch dataset dari Laravel", "details": fetch_result, }), 400 # Cleanup and reorganize cleanup_result = fetcher.cleanup_and_reorganize( dataset_dir=config.raw_dir, target_size=(config.image_size, config.image_size), ) results["cleanup"] = cleanup_result except Exception as e: return jsonify({ "status": "error", "message": f"Error fetch dari Laravel: {str(e)}", }), 500 if options["run_preprocess"]: results["preprocess"] = preprocess_dataset( source_dir=config.raw_dir, output_dir=config.preprocessed_dir, target_size=config.image_size, min_images_per_class=options["min_images"], seed=config.random_seed, overwrite=options["overwrite"], ) if options["run_train"]: results["train"] = train_model( dataset_dir=config.preprocessed_dir, model_path=config.model_path, class_names_path=config.class_names_path, logs_dir=config.logs_dir, image_size=config.image_size, batch_size=config.batch_size, epochs=options["epochs"], learning_rate=config.learning_rate, seed=config.random_seed, train_ratio=options["train_ratio"], val_ratio=options["val_ratio"], test_ratio=options["test_ratio"], ) return jsonify({"message": "pipeline selesai", "results": results}) @app.post("/attendance/recognize") def recognize_and_attend(): frame = _decode_uploaded_image() prediction = predictor.predict(frame) if prediction is None: return jsonify({"status": "no_face", "message": "Wajah tidak terdeteksi."}), 200 if prediction.confidence < config.threshold: unknown_response = { "status": "unknown", "name": prediction.recognized_name, "confidence": prediction.confidence, "message": "Prediksi di bawah threshold.", } return jsonify(unknown_response), 200 # Calculate attendance status (tepat_waktu or terlambat) # Batas masuk berdasarkan config (default: jam 7:00) attendance_status = calculate_attendance_status( cutoff_hour=config.attendance_cutoff_hour, cutoff_minute=config.attendance_cutoff_minute, ) # Save the picture picture_filename = _save_attendance_picture(frame, config) attendance = attendance_service.mark_attendance( recognized_name=prediction.recognized_name, confidence=prediction.confidence, source="upload", picture_filename=picture_filename, status=attendance_status, ) return jsonify( { "status": attendance.status, "message": attendance.message, "name": prediction.recognized_name, "confidence": prediction.confidence, "student_name": attendance.student_name, "attendance_id": attendance.attendance_id, "student_id": attendance.student_id, "class_id": attendance.class_id, "attendance_status": attendance_status, "attendance_status_display": format_attendance_status(attendance_status), "picture": picture_filename, } ) @app.post("/attendance/camera/start") def start_camera_attendance(): """Start camera attendance (background mode).""" camera_runner.start() return jsonify({ "status": "started", "mode": "background", "message": "Camera attendance dimulai (background mode)" }) @app.post("/attendance/camera/stop") def stop_camera_attendance(): """Stop camera attendance.""" camera_runner.stop() return jsonify({"status": "stopped", "message": "Camera attendance dihentikan"}) @app.get("/attendance/camera/status") def camera_status(): state = camera_runner.state() return jsonify( { "running": state.running, "last_identity": state.last_identity, "last_confidence": state.last_confidence, "last_message": state.last_message, } ) @app.route('/tes-konek', methods=['GET']) def test_db_connection(): try: # Mengetes koneksi menggunakan context manager with db.connection() bawaan kodemu with db.connection() as conn: # Kita buat cursor untuk ngetes query basic if db.driver == "mysql": cur = conn.cursor() cur.execute("SELECT VERSION();") version = cur.fetchone() cur.close() db_version = version[0] else: # Jika sewaktu-waktu beralih ke sqlite row = conn.execute("SELECT sqlite_version();").fetchone() db_version = row[0] return jsonify({ "status": "success", "message": f"Gokil! Flask faceapi berhasil konek ke database {db.driver} Shared Hosting!", "database_version": db_version, "target_table_student": config.student_table }), 200 except Exception as e: # Jika gagal handshake (IP diblokir, password keliru, dsb) return jsonify({ "status": "error", "message": "Aduh, gagal konek ke database shared hosting!", "error_detail": str(e) }), 500 @app.errorhandler(DatabaseError) def handle_db_error(err): return jsonify({"error": str(err)}), 500 @app.errorhandler(ValueError) def handle_value_error(err): return jsonify({"error": str(err)}), 400 @app.post("/dataset/fetch-from-laravel") def fetch_dataset_from_laravel(): """Fetch student photos from Laravel storage and organize into dataset.""" try: payload = request.get_json(silent=True) or {} overwrite = bool(payload.get("overwrite", False)) fetcher = LaravelDatasetFetcher( db=db, laravel_url=config.laravel_url, storage_path=config.laravel_storage_path, ) # Fetch and organize fetch_result = fetcher.fetch_and_organize( output_dir=config.raw_dir, overwrite=overwrite, ) if not fetch_result["success"]: return jsonify({ "status": "error", "message": "Gagal fetch dataset dari Laravel", "details": fetch_result, }), 400 # Cleanup and reorganize images cleanup_result = fetcher.cleanup_and_reorganize( dataset_dir=config.raw_dir, target_size=(config.image_size, config.image_size), ) return jsonify({ "status": "success", "message": "Dataset berhasil di-fetch dari Laravel", "fetch_result": fetch_result, "cleanup_result": cleanup_result, }), 200 except Exception as e: return jsonify({ "status": "error", "message": f"Error fetch dataset: {str(e)}", }), 500 @app.errorhandler(FileNotFoundError) def handle_not_found(err): return jsonify({"error": str(err)}), 404 @app.errorhandler(Exception) def handle_unexpected(err): return jsonify({"error": f"Unexpected error: {err}"}), 500 return app if __name__ == "__main__": flask_app = create_app() cfg = AppConfig.from_env() flask_app.run(host="0.0.0.0", port=5000, debug=cfg.debug)