Spaces:
Sleeping
Sleeping
| import os | |
| import json | |
| import logging | |
| import base64 | |
| from datetime import datetime, timedelta, timezone | |
| from typing import Optional, List | |
| from functools import wraps | |
| from flask import Flask, request, jsonify, g | |
| from flask_cors import CORS | |
| from asgiref.wsgi import WsgiToAsgi | |
| from werkzeug.exceptions import HTTPException | |
| from sqlalchemy import create_engine, Column, Integer, String, Boolean, Float, DateTime, ForeignKey, Text, and_, or_, text, inspect | |
| from sqlalchemy.orm import relationship, sessionmaker, declarative_base, Session | |
| # مدیریت ایمن لود کتابخانههای امنیت و توکن JWT | |
| try: | |
| from jose import JWTError, jwt | |
| except ImportError: | |
| import jwt | |
| JWTError = Exception | |
| import bcrypt | |
| # ========================================================================= | |
| # بخش ۱: لایه ثبت لاگ سیستم | |
| # ========================================================================= | |
| logging.basicConfig(level=logging.INFO) | |
| logger = logging.getLogger("production_backend") | |
| # کلید پردازشهای ابری هوشمند (Secrets) | |
| AI_CORE_KEY = os.getenv("GEMINI_API_KEY") or os.getenv("SECRET") | |
| # پیشگیری از کرش در صورت عدم وجود پکیج نسل جدید گوگل | |
| try: | |
| from google import genai | |
| from google.genai import types | |
| except ImportError: | |
| genai = None | |
| types = None | |
| logger.warning("The 'google-genai' package is not installed. AI features will fallback.") | |
| # ========================================================================= | |
| # بخش ۲: اتصال پایگاه داده Neon.tech و ساختار بومی دیتابیس | |
| # ========================================================================= | |
| DATABASE_URL = os.getenv( | |
| "DATABASE_URL", | |
| "postgresql://neondb_owner:npg_dMhA9Zf5bpJq@ep-floral-snow-avsluk5j-pooler.c-11.us-east-1.aws.neon.tech/neondb?sslmode=require&channel_binding=require" | |
| ) | |
| if DATABASE_URL.startswith("postgres://"): | |
| DATABASE_URL = DATABASE_URL.replace("postgres://", "postgresql://", 1) | |
| try: | |
| if "sqlite" in DATABASE_URL: | |
| engine = create_engine(DATABASE_URL, connect_args={"check_same_thread": False}) | |
| else: | |
| engine = create_engine( | |
| DATABASE_URL, | |
| pool_size=15, | |
| max_overflow=25, | |
| pool_recycle=1800, | |
| pool_pre_ping=True | |
| ) | |
| except Exception as db_init_err: | |
| logger.critical(f"Database engine creation failed: {db_init_err}", exc_info=True) | |
| engine = create_engine("sqlite:///./fallback.db", connect_args={"check_same_thread": False}) | |
| SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) | |
| Base = declarative_base() | |
| # تعریف تابع زمان بومی سرور | |
| def get_now(): | |
| return datetime.now(timezone.utc).replace(tzinfo=None) | |
| # فرمول مبدل تقویم میلادی به شمسی بومی (بند ۱۱) | |
| def gregorian_to_jalali(g_y, g_m, g_d): | |
| g_days_in_month = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31] | |
| j_days_in_month = [31, 31, 31, 31, 31, 31, 30, 30, 30, 30, 30, 29] | |
| gy = g_y - 1600 | |
| gm = g_m - 1 | |
| gd = g_d - 1 | |
| g_day_no = 365 * gy + (gy + 3) // 4 - (gy + 99) // 100 + (gy + 399) // 400 | |
| for i in range(gm): | |
| g_day_no += g_days_in_month[i] | |
| if gm > 1 and ((g_y % 4 == 0 and g_y % 100 != 0) or (g_y % 400 == 0)): | |
| g_day_no += 1 | |
| g_day_no += gd | |
| j_day_no = g_day_no - 79 | |
| j_np = j_day_no // 12053 | |
| j_day_no %= 12053 | |
| jy = 979 + 33 * j_np + 4 * (j_day_no // 1461) | |
| j_day_no %= 1461 | |
| if j_day_no >= 366: | |
| jy += (j_day_no - 1) // 365 | |
| j_day_no = (j_day_no - 1) % 365 | |
| for i in range(12): | |
| if j_day_no < j_days_in_month[i]: | |
| jm = i + 1 | |
| jd = j_day_no + 1 | |
| return jy, jm, jd | |
| j_day_no -= j_days_in_month[i] | |
| return jy, 12, 29 | |
| def get_shamsi_date_str(dt: datetime) -> str: | |
| if not dt: | |
| return "" | |
| jy, jm, jd = gregorian_to_jalali(dt.year, dt.month, dt.day) | |
| return f"{jy}/{jm:02d}/{jd:02d}" | |
| # پایگاه دانش برای موتور انتخاب رشته کنکور | |
| KNOWLEDGE_BASE = [ | |
| { | |
| "university": "دانشگاه تبریز", | |
| "ranking_tier": "الف", | |
| "fields": ["مهندسی کامپیوتر", "پزشکی", "حقوق"], | |
| "minimum_rank_r1": 700, | |
| "minimum_rank_r2": 900 | |
| }, | |
| { | |
| "university": "دانشگاه صنعتی سهند", | |
| "ranking_tier": "ب", | |
| "fields": ["مهندسی برق", "مهندسی مواد", "مکانیک"], | |
| "minimum_rank_r1": 1500, | |
| "minimum_rank_r2": 2000 | |
| }, | |
| { | |
| "university": "دانشگاه علوم پزشکی تبریز", | |
| "ranking_tier": "الف", | |
| "fields": ["پزشکی", "دندانپزشکی", "داروسازی"], | |
| "minimum_rank_r1": 250, | |
| "minimum_rank_r2": 350 | |
| } | |
| ] | |
| # ========================================================================= | |
| # بخش ۳: مدلهای دیتابیس (SQLAlchemy Models) | |
| # ========================================================================= | |
| class User(Base): | |
| __tablename__ = "users" | |
| id = Column(Integer, primary_key=True, index=True) | |
| email = Column(String, unique=True, index=True, nullable=False) | |
| hashed_password = Column(String, nullable=False) | |
| role = Column(String, default="student") # 'student', 'counselor', 'school_representative' | |
| name = Column(String, nullable=True) # نام کاربری واقعی (بند ۶) | |
| is_verified = Column(Boolean, default=False) | |
| balance = Column(Float, default=0.0) | |
| rating = Column(Float, default=0.0) # مقدار اولیه تراز صفر است (بند ۱۰) | |
| phone_number = Column(String, nullable=True) | |
| city = Column(String, nullable=True) # منطقه سکونت (بند ۳) | |
| address = Column(Text, nullable=True) # آدرس پستی جهت مراجعه حضوری (بند ۶) | |
| grade = Column(String, nullable=True) | |
| major = Column(String, nullable=True) | |
| profile_pic = Column(Text, nullable=True) | |
| counselor_profile = relationship("CounselorProfile", back_populates="user", uselist=False, cascade="all, delete-orphan") | |
| school_profile = relationship("SchoolProfile", back_populates="user", uselist=False, cascade="all, delete-orphan") | |
| class CounselorProfile(Base): | |
| __tablename__ = "counselor_profiles" | |
| id = Column(Integer, primary_key=True, index=True) | |
| user_id = Column(Integer, ForeignKey("users.id")) | |
| specialty = Column(String, nullable=True) | |
| resume = Column(Text, nullable=True) | |
| bio = Column(Text, nullable=True) | |
| hourly_rate = Column(Float, default=150000.0) | |
| deactivated_days_json = Column(Text, default="[]") # روزهای غیرفعال شده توسط مشاور (بند ۷) | |
| user = relationship("User", back_populates="counselor_profile") | |
| class SchoolProfile(Base): | |
| __tablename__ = "school_profiles" | |
| id = Column(Integer, primary_key=True, index=True) | |
| user_id = Column(Integer, ForeignKey("users.id")) | |
| name = Column(String, nullable=True) | |
| subscription_active_until = Column(DateTime, nullable=True) | |
| is_sponsored = Column(Boolean, default=False) | |
| bio = Column(Text, nullable=True) | |
| address = Column(Text, nullable=True) | |
| phone_number = Column(String, nullable=True) | |
| founding_year = Column(Integer, nullable=True) | |
| rating = Column(Float, default=0.0) # مقدار امتیاز آموزشگاه در شروع صفر است (بند ۱۰) | |
| profile_pic = Column(Text, nullable=True) | |
| user = relationship("User", back_populates="school_profile") | |
| courses = relationship("SchoolCourse", back_populates="school", cascade="all, delete-orphan") | |
| class SchoolCourse(Base): | |
| __tablename__ = "school_courses" | |
| id = Column(Integer, primary_key=True, index=True) | |
| school_id = Column(Integer, ForeignKey("school_profiles.id")) | |
| course_name = Column(String, nullable=False) | |
| teacher_name = Column(String, nullable=False) | |
| teaching_style = Column(String, nullable=False) | |
| school = relationship("SchoolProfile", back_populates="courses") | |
| class SchoolTeacher(Base): | |
| __tablename__ = "school_teachers" | |
| id = Column(Integer, primary_key=True, index=True) | |
| school_id = Column(Integer, ForeignKey("school_profiles.id")) | |
| name = Column(String, nullable=False) | |
| bio = Column(Text, nullable=True) | |
| class SchoolCounselorAssociation(Base): | |
| __tablename__ = "school_counselor_associations" | |
| id = Column(Integer, primary_key=True, index=True) | |
| school_id = Column(Integer, ForeignKey("school_profiles.id")) | |
| counselor_id = Column(Integer, ForeignKey("users.id")) | |
| status = Column(String, default="pending") | |
| class TimeSlot(Base): | |
| __tablename__ = "time_slots" | |
| id = Column(Integer, primary_key=True, index=True) | |
| counselor_id = Column(Integer, ForeignKey("users.id")) | |
| start_time = Column(DateTime, nullable=False) | |
| end_time = Column(DateTime, nullable=False) | |
| is_booked = Column(Boolean, default=False) | |
| booked_by_student_id = Column(Integer, ForeignKey("users.id"), nullable=True) | |
| class Booking(Base): | |
| __tablename__ = "bookings" | |
| id = Column(Integer, primary_key=True, index=True) | |
| slot_id = Column(Integer, ForeignKey("time_slots.id")) | |
| student_id = Column(Integer, ForeignKey("users.id")) | |
| counselor_id = Column(Integer, ForeignKey("users.id")) | |
| status = Column(String, default="active") # active, cancelled | |
| payment_status = Column(String, default="paid") # paid, pending_payment, expired | |
| payment_due_until = Column(DateTime, nullable=True) | |
| cost = Column(Float, default=0.0) | |
| created_at = Column(DateTime, default=get_now) | |
| class CustomQuiz(Base): | |
| __tablename__ = "custom_quizzes" | |
| id = Column(Integer, primary_key=True, index=True) | |
| counselor_id = Column(Integer, ForeignKey("users.id"), nullable=True) | |
| title = Column(String, nullable=False) | |
| questions_json = Column(Text, nullable=False) | |
| class StudentQuizAssignment(Base): | |
| __tablename__ = "student_quiz_assignments" | |
| id = Column(Integer, primary_key=True, index=True) | |
| quiz_id = Column(Integer, ForeignKey("custom_quizzes.id")) | |
| student_id = Column(Integer, ForeignKey("users.id")) | |
| booking_id = Column(Integer, ForeignKey("bookings.id")) | |
| answers_json = Column(Text, nullable=True) | |
| is_completed = Column(Boolean, default=False) | |
| score = Column(Float, nullable=True) | |
| class TestResult(Base): | |
| __tablename__ = "test_results" | |
| id = Column(Integer, primary_key=True, index=True) | |
| student_id = Column(Integer, ForeignKey("users.id")) | |
| counselor_id = Column(Integer, ForeignKey("users.id")) | |
| answers = Column(Text, nullable=False) | |
| ai_analysis_summary = Column(Text, nullable=True) | |
| class KonkurDetails(Base): | |
| __tablename__ = "konkur_details" | |
| id = Column(Integer, primary_key=True, index=True) | |
| student_id = Column(Integer, ForeignKey("users.id")) | |
| rank = Column(Integer, nullable=False) | |
| quota = Column(String, nullable=False) | |
| favorite_cities = Column(String, nullable=False) | |
| class MajorSelection(Base): | |
| __tablename__ = "major_selections" | |
| id = Column(Integer, primary_key=True, index=True) | |
| student_id = Column(Integer, ForeignKey("users.id")) | |
| counselor_id = Column(Integer, ForeignKey("users.id"), nullable=True) | |
| generated_list = Column(Text, nullable=True) | |
| finalized_list = Column(Text, nullable=True) | |
| status = Column(String, default="draft") | |
| class Review(Base): | |
| __tablename__ = "reviews" | |
| id = Column(Integer, primary_key=True, index=True) | |
| student_id = Column(Integer, ForeignKey("users.id")) | |
| booking_id = Column(Integer, ForeignKey("bookings.id"), nullable=True) | |
| counselor_id = Column(Integer, ForeignKey("users.id"), nullable=True) | |
| school_id = Column(Integer, ForeignKey("school_profiles.id"), nullable=True) | |
| rating = Column(Float, default=5.0) | |
| comment = Column(Text, nullable=True) | |
| created_at = Column(DateTime, default=get_now) | |
| class Notification(Base): | |
| __tablename__ = "notifications" | |
| id = Column(Integer, primary_key=True, index=True) | |
| user_id = Column(Integer, ForeignKey("users.id"), nullable=True) | |
| text = Column(Text, nullable=False) | |
| role = Column(String, default="all") # 'all', 'student', 'counselor', 'school_representative' | |
| is_read = Column(Boolean, default=False) | |
| created_at = Column(DateTime, default=get_now) | |
| # مدل مدیریت تسویه حسابهای پلتفرم (بند ۴ و ۵) | |
| class SettlementRequest(Base): | |
| __tablename__ = "settlement_requests" | |
| id = Column(Integer, primary_key=True, index=True) | |
| user_id = Column(Integer, ForeignKey("users.id")) | |
| amount = Column(Float, nullable=False) | |
| status = Column(String, default="pending") # pending, approved, rejected | |
| created_at = Column(DateTime, default=get_now) | |
| # مدل ردیابی بازدید واقعی آموزشگاه بر اساس حساب کاربری (بند ۹) | |
| class PageView(Base): | |
| __tablename__ = "page_views" | |
| id = Column(Integer, primary_key=True, index=True) | |
| school_id = Column(Integer, ForeignKey("school_profiles.id")) | |
| student_id = Column(Integer, ForeignKey("users.id")) | |
| timestamp = Column(DateTime, default=get_now) | |
| # ========================================================================= | |
| # بخش ۴: توابع کمکی امنیتی و رمزنگاری | |
| # ========================================================================= | |
| def get_password_hash(password: str) -> str: | |
| salt = bcrypt.gensalt() | |
| return bcrypt.hashpw(password.encode('utf-8'), salt).decode('utf-8') | |
| def verify_password(plain_password: str, hashed_password: str) -> bool: | |
| try: | |
| return bcrypt.checkpw(plain_password.encode('utf-8'), hashed_password.encode('utf-8')) | |
| except Exception as e: | |
| logger.error(f"Error checking hashpw: {e}") | |
| return False | |
| SECRET_KEY = os.getenv("JWT_SECRET", "8c12fa4870a23049102c89eeffb8a719280abdc0293") | |
| ALGORITHM = "HS256" | |
| def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str: | |
| to_encode = data.copy() | |
| if expires_delta: | |
| expire = datetime.now(timezone.utc) + expires_delta | |
| else: | |
| expire = datetime.now(timezone.utc) + timedelta(days=7) | |
| to_encode.update({"exp": expire}) | |
| return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM) | |
| # ========================================================================= | |
| # بخش ۵: مدیریت تراکنشها و احراز هویت در فلاسک | |
| # ========================================================================= | |
| def clean_expired_bookings(db: Session): | |
| try: | |
| now = get_now() | |
| expired_bookings = db.query(Booking).filter( | |
| Booking.payment_status == "pending_payment", | |
| Booking.payment_due_until < now | |
| ).all() | |
| for b in expired_bookings: | |
| b.payment_status = "expired" | |
| slot = db.query(TimeSlot).filter(TimeSlot.id == b.slot_id).first() | |
| if slot: | |
| slot.is_booked = False | |
| slot.booked_by_student_id = None | |
| db.commit() | |
| except Exception as e: | |
| logger.error(f"Error during clean_expired_bookings: {e}") | |
| db.rollback() | |
| flask_app = Flask(__name__, static_folder="dist", static_url_path="") | |
| CORS(flask_app, resources={r"/*": {"origins": "*"}}) | |
| def get_db() -> Session: | |
| if 'db' not in g: | |
| g.db = SessionLocal() | |
| clean_expired_bookings(g.db) | |
| return g.db | |
| def shutdown_session(exception=None): | |
| db = g.pop('db', None) | |
| if db is not None: | |
| db.close() | |
| def login_required(f): | |
| def decorated(*args, **kwargs): | |
| auth_header = request.headers.get("Authorization") | |
| if not auth_header: | |
| return jsonify({"detail": "اعتبار ورود شما منقضی شده است؛ لطفاً مجدداً وارد شوید."}), 401 | |
| parts = auth_header.split() | |
| if len(parts) != 2 or parts[0].lower() != "bearer": | |
| return jsonify({"detail": "قالب توکن احراز هویت معتبر نیست."}), 401 | |
| token = parts[1] | |
| try: | |
| payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM]) | |
| email = payload.get("sub") | |
| if email is None: | |
| return jsonify({"detail": "اعتبار ورود شما منقضی شده است؛ لطفاً مجدداً وارد شوید."}), 401 | |
| except JWTError: | |
| return jsonify({"detail": "اعتبار ورود شما منقضی شده است؛ لطفاً مجدداً وارد شوید."}), 401 | |
| db = get_db() | |
| user = db.query(User).filter(User.email == email).first() | |
| if user is None: | |
| return jsonify({"detail": "کاربر یافت نشد."}), 401 | |
| g.current_user = user | |
| return f(*args, **kwargs) | |
| return decorated | |
| # ========================================================================= | |
| # بخش ۶: توابع پردازش ابری هوشمند بومی تاپ دانش | |
| # ========================================================================= | |
| def _execute_native_matrix_processing(prompt: str, model_type: str = "fast") -> str: | |
| if not AI_CORE_KEY or genai is None: | |
| return "MODE_FALLBACK_ACTIVE" | |
| try: | |
| client = genai.Client(api_key=AI_CORE_KEY) | |
| model_name = "gemini-1.5-flash" if model_type == "fast" else "gemini-1.5-pro" | |
| response = client.models.generate_content( | |
| model=model_name, | |
| contents=prompt | |
| ) | |
| return response.text.strip() | |
| except Exception as api_err: | |
| logger.error(f"Execution error on core matrix processor: {api_err}", exc_info=True) | |
| return "MODE_FALLBACK_ACTIVE" | |
| def core_selection_engine(rank: int, quota: str, favorite_cities: str) -> str: | |
| context_items = [] | |
| for u in KNOWLEDGE_BASE: | |
| context_items.append( | |
| f"- {u['university']} (رتبه کیفی: {u['ranking_tier']}): مناسب برای حوزههای {', '.join(u['fields'])} " | |
| f"با پذیرش رتبه منطقه یک حدود {u['minimum_rank_r1']} و منطقه دو حدود {u['minimum_rank_r2']}" | |
| ) | |
| context_str = "\n".join(context_items) | |
| prompt = f""" | |
| شما یک راهنما و مشاور تحصیلی دلسوز برای داوطلبان کنکور سراسری هستید. | |
| اطلاعات داوطلب: | |
| - رتبه داوطلب در کارنامه: {rank} | |
| - سهمیه داوطلب: {quota} | |
| - شهرهای مورد پسند: {favorite_cities} | |
| پایگاه داده دانشگاهها: | |
| {context_str} | |
| یک سناریوی پیشنهادی شامل حداکثر 8 انتخاب خوشبینانه و منطقی به زبان کاملاً ساده, روان, صمیمی و دانشجویی تولید کنید. | |
| خروجی را صرفاً و بدون هیچگونه متن توضیحی اضافه در قالب یک آرایه معتبر JSON و فاقد کدهای مارکداون برگردانید: | |
| [ | |
| {{ | |
| "college": "نام دانشگاه", | |
| "major": "رشته تحصیلی", | |
| "priority": 1, | |
| "reason": "دلیل انتخاب با لحنی بسیار صمیمی، صنف دانشجویی و بیآلایش" | |
| }} | |
| ] | |
| """ | |
| result = _execute_native_matrix_processing(prompt, "pro") | |
| if result == "MODE_FALLBACK_ACTIVE": | |
| return json.dumps([ | |
| {"college": "دانشگاه تبریز", "major": "مهندسی کامپیوتر", "priority": 1, "reason": "اولویت تراز اول متناسب با رتبه اعلامی شما در زیرگروه مربوطه."}, | |
| {"college": "دانشگاه علوم پزشکی تبریز", "major": "پزشکی عمومی", "priority": 2, "reason": "بر اساس الگوهای انتخابی داوطلبان با سهمیه مشابه."} | |
| ], ensure_ascii=False) | |
| return result | |
| def core_diagnostic_analysis(answers: str) -> str: | |
| prompt = f""" | |
| یک داوطلب به سوالات آزمون تشخصیصی زیر پاسخ داده است: | |
| {answers} | |
| یک ارزیابی علمی بسیار فشرده (حداکثر ۲ بند کوتاه) در مورد سبک مطالعه و برطرف کردن نقایص آزمون داوطلب بنویسید تا مشاور مطالعه کند. رسمی و فارسی باشد. | |
| """ | |
| result = _execute_native_matrix_processing(prompt, "fast") | |
| if result == "MODE_FALLBACK_ACTIVE": | |
| return "ارزیابی اولیه: داوطلب تمایل قوی به ساختارهای مفهومی دارد؛ پیشنهاد میشود روی زمانبندی تستهای سرعتی متمرکز شود." | |
| return result | |
| def core_bio_extraction(resume_text: str) -> str: | |
| prompt = f""" | |
| بر مبنای متن رزومه مشاور زیر، یک بیوگرافی روان، جذاب، خلاصه و متقاعدکننده (حداکثر ۷۰ کلمه به زبان فارسی و صمیمی) جهت درج در بخش توضیحات پروفایل او بنویسید. | |
| متن رزومه: | |
| {resume_text} | |
| """ | |
| result = _execute_native_matrix_processing(prompt, "fast") | |
| if result == "MODE_FALLBACK_ACTIVE": | |
| return "کارشناس تحصیلی با سابقه رتبهپروری در کنکور و برنامهریزی تخصصی داوطلبان گروه علوم تجربی و ریاضی." | |
| return result | |
| # ========================================================================= | |
| # بخش ۷: لایه همگامسازی دادههای تستی و دمو | |
| # ========================================================================= | |
| def seed_initial_data(db: Session): | |
| try: | |
| global_quizzes_seed = [ | |
| { "title": "آزمون تشخصیصی سبکهای مطالعه و یادگیری (عمومی)", "questions": '[{"question":"بیشتر از چه طریقی اطلاعات را بهتر به خاطر میسپارید؟","option1":"شنیدن توضیحات دبیر","option2":"خواندن متن کتاب و جزوه","option3":"نوشتن خلاصه و حل تمرین","option4":"ترسیم نمودار و نقشه ذهنی","correct":"2"}]' }, | |
| { "title": "تست سنجش میزان استرس و اضطراب آزمون", "questions": '[{"question":"در جلسات شبیهساز کنکور با کمبود وقت چگونه مواجه میشوید؟","option1":"دچار دستپاچگی و رها کردن سوالات میشوم","option2":"با آرامش سوالات ساده را گلچین میکنم","option3":"تلاش میکنم سرعت تستزنی را بدون دقت بالا ببرم","option4":"تمرکز خود را به طور کامل از دست میدهم","correct":"2"}]' }, | |
| { "title": "ارزیابی انگیزه تحصیلی و خودکارآمدی کنکور", "questions": '[{"question":"مهمترین محرک شما برای ادامه مسیر فرساینده کنکور چیست؟","option1":"علاقه به رشته دانشگاهی آینده","option2":"فشار خانواده و انتظارات اجتماعی","option3":"رقابت با همکلاسیها","option4":"ترس از شکست و آینده نامعلوم","correct":"1"}]' } | |
| ] | |
| for gq in global_quizzes_seed: | |
| existing_quiz = db.query(CustomQuiz).filter(CustomQuiz.title == gq["title"]).first() | |
| if not existing_quiz: | |
| new_quiz = CustomQuiz(counselor_id=None, title=gq["title"], questions_json=gq["questions"]) | |
| db.add(new_quiz) | |
| db.commit() | |
| logger.info("Database base check completed.") | |
| except Exception as e: | |
| logger.error(f"Seeding process crashed completely: {e}", exc_info=True) | |
| db.rollback() | |
| def run_auto_migrations(engine, Base): | |
| inspector = inspect(engine) | |
| for table_name, table in Base.metadata.tables.items(): | |
| if inspector.has_table(table_name): | |
| existing_cols = {c["name"] for c in inspector.get_columns(table_name)} | |
| for col in table.columns: | |
| if col.name not in existing_cols: | |
| col_type = col.type.compile(dialect=engine.dialect) | |
| alter_sql = f'ALTER TABLE "{table_name}" ADD COLUMN "{col.name}" {col_type} NULL' | |
| try: | |
| with engine.begin() as connection: | |
| connection.execute(text(alter_sql)) | |
| logger.info(f"Auto-migration: Added missing column '{col.name}' to table '{table_name}'") | |
| except Exception as e: | |
| logger.error(f"Auto-migration: Failed to add column '{col.name}' to table '{table_name}': {e}") | |
| else: | |
| try: | |
| table.create(bind=engine) | |
| logger.info(f"Auto-migration: Created missing table '{table_name}'") | |
| except Exception as e: | |
| logger.error(f"Auto-migration: Failed to create table '{table_name}': {e}") | |
| # ========================================================================= | |
| # بخش ۸: راهاندازی و دیتابیس بومی فلاسک | |
| # ========================================================================= | |
| with flask_app.app_context(): | |
| try: | |
| Base.metadata.create_all(bind=engine) | |
| try: | |
| run_auto_migrations(engine, Base) | |
| except Exception as migration_err: | |
| logger.warning(f"Auto-migration skipped or handled concurrently: {migration_err}") | |
| db = SessionLocal() | |
| try: | |
| seed_initial_data(db) | |
| except Exception as seed_err: | |
| logger.error(f"Error seeding database during startup: {seed_err}", exc_info=True) | |
| finally: | |
| db.close() | |
| except Exception as startup_db_err: | |
| logger.warning(f"Database setup or seeding skipped: {startup_db_err}") | |
| def global_exception_handler(exc): | |
| if isinstance(exc, HTTPException): | |
| return exc | |
| logger.error(f"Internal Crash logged: {exc}", exc_info=True) | |
| return jsonify({"detail": "سرور موقتاً شلوغ است. لطفاً چند لحظه دیگر مجدداً تلاش کنید."}), 500 | |
| # ========================================== | |
| # بخش ۹: آدرسهای API مربوط به احراز هویت و حساب کاربری | |
| # ========================================== | |
| def auth_register(): | |
| db = get_db() | |
| data = request.get_json() or {} | |
| email = data.get("email") | |
| password = data.get("password") | |
| role = data.get("role") | |
| name = data.get("name") | |
| specialty = data.get("specialty") | |
| resume = data.get("resume") | |
| phone_number = data.get("phone_number") | |
| city = data.get("city") | |
| address = data.get("address") | |
| grade = data.get("grade") | |
| major = data.get("major") | |
| profile_pic = data.get("profile_pic") | |
| if not email or not password or not role: | |
| return jsonify({"detail": "وارد کردن ایمیل، رمز عبور و نقش الزامی است."}), 400 | |
| try: | |
| existing_user = db.query(User).filter(User.email == email).first() | |
| if existing_user: | |
| return jsonify({"detail": "حسابی با این آدرس ایمیل قبلاً ثبت گردیده است."}), 400 | |
| hashed = get_password_hash(password) | |
| is_verified_init = True if role == "student" else False | |
| new_user = User( | |
| email=email, | |
| hashed_password=hashed, | |
| role=role, | |
| name=name, | |
| is_verified=is_verified_init, | |
| phone_number=phone_number, | |
| city=city, | |
| address=address, | |
| grade=grade, | |
| major=major, | |
| profile_pic=profile_pic | |
| ) | |
| db.add(new_user) | |
| db.flush() | |
| if role == "counselor": | |
| c_prof = CounselorProfile( | |
| user_id=new_user.id, | |
| specialty=specialty, | |
| resume=resume, | |
| hourly_rate=150000.0, | |
| deactivated_days_json="[]" | |
| ) | |
| db.add(c_prof) | |
| elif role == "school_representative": | |
| s_prof = SchoolProfile( | |
| user_id=new_user.id, | |
| name=name or "آموزشگاه جدید ثبتشده", | |
| subscription_active_until=get_now(), | |
| address=address, | |
| phone_number=phone_number, | |
| profile_pic=profile_pic | |
| ) | |
| db.add(s_prof) | |
| db.commit() | |
| return jsonify({ | |
| "message": "ثبتنام با موفقیت به پایان رسید.", | |
| "role": role, | |
| "is_verified": is_verified_init | |
| }), 200 | |
| except Exception as e: | |
| logger.error(f"Error during registration: {e}", exc_info=True) | |
| db.rollback() | |
| return jsonify({"detail": "سرور موقتاً شلوغ است. لطفاً بعداً تلاش کنید."}), 503 | |
| def auth_login(): | |
| db = get_db() | |
| data = request.get_json() or {} | |
| email = data.get("email") | |
| password = data.get("password") | |
| if not email or not password: | |
| return jsonify({"detail": "وارد کردن ایمیل و رمز عبور الزامی است."}), 400 | |
| try: | |
| user = db.query(User).filter(User.email == email).first() | |
| if not user or not verify_password(password, user.hashed_password): | |
| return jsonify({"detail": "ایمیل یا رمز عبور اشتباه است."}), 400 | |
| existing_login_notif = db.query(Notification).filter( | |
| Notification.user_id == user.id, | |
| Notification.text.like("%ورود موفقیتآمیز به حساب کاربری%") | |
| ).first() | |
| if not existing_login_notif: | |
| notif = Notification( | |
| user_id=user.id, | |
| text=f"ورود موفقیتآمیز به حساب کاربری در تاریخ {get_shamsi_date_str(get_now())}", | |
| role=user.role, | |
| is_read=False | |
| ) | |
| db.add(notif) | |
| db.commit() | |
| token = create_access_token(data={"sub": user.email}) | |
| return jsonify({ | |
| "access_token": token, | |
| "token_type": "bearer", | |
| "role": user.role, | |
| "is_verified": user.is_verified, | |
| "balance": user.balance | |
| }), 200 | |
| except Exception as e: | |
| logger.error(f"Error during login: {e}", exc_info=True) | |
| return jsonify({"detail": "سرور موقتاً شلوغ است. لطفاً بعداً تلاش کنید."}), 503 | |
| def get_user_profile(): | |
| current_user = g.current_user | |
| requires_rating_warning = False | |
| if current_user.role == "counselor" and current_user.rating < 3.0 and current_user.rating > 0.0: | |
| requires_rating_warning = True | |
| return jsonify({ | |
| "id": current_user.id, | |
| "email": current_user.email, | |
| "role": current_user.role, | |
| "name": current_user.name or current_user.email.split("@")[0], # نمایش اولویت بر اساس نام کاربر (بند ۶) | |
| "balance": current_user.balance, | |
| "rating": current_user.rating, | |
| "phone_number": current_user.phone_number, | |
| "city": current_user.city, | |
| "address": current_user.address, # برگشت دادن فیلد آدرس دقیق (بند ۶) | |
| "grade": current_user.grade, | |
| "major": current_user.major, | |
| "profile_pic": current_user.profile_pic, | |
| "is_verified": current_user.is_verified, | |
| "requires_rating_warning": requires_rating_warning | |
| }), 200 | |
| def update_user_profile(): | |
| db = get_db() | |
| current_user = g.current_user | |
| data = request.get_json() or {} | |
| name = data.get("name") | |
| email_val = data.get("email") | |
| password_val = data.get("password") | |
| phone_number = data.get("phone_number") | |
| city = data.get("city") | |
| address = data.get("address") | |
| grade_val = data.get("grade") | |
| major_val = data.get("major") | |
| profile_pic = data.get("profile_pic") | |
| try: | |
| if name: | |
| current_user.name = name | |
| if current_user.role == "school_representative" and current_user.school_profile: | |
| current_user.school_profile.name = name | |
| if email_val: | |
| existing = db.query(User).filter(User.email == email_val, User.id != current_user.id).first() | |
| if existing: | |
| return jsonify({"detail": "این آدرس ایمیل قبلا ثبت شده است."}), 400 | |
| current_user.email = email_val | |
| if password_val: | |
| current_user.hashed_password = get_password_hash(password_val) | |
| if phone_number: | |
| current_user.phone_number = phone_number | |
| if current_user.role == "school_representative" and current_user.school_profile: | |
| current_user.school_profile.phone_number = phone_number | |
| if city: | |
| current_user.city = city | |
| if address: | |
| current_user.address = address | |
| if current_user.role == "school_representative" and current_user.school_profile: | |
| current_user.school_profile.address = address | |
| if grade_val: | |
| current_user.grade = grade_val | |
| if major_val: | |
| current_user.major = major_val | |
| if profile_pic: | |
| current_user.profile_pic = profile_pic | |
| if current_user.role == "school_representative" and current_user.school_profile: | |
| current_user.school_profile.profile_pic = profile_pic | |
| db.commit() | |
| return jsonify({"message": "تغییرات مشخصات کاربری با موفقیت ذخیره شد."}), 200 | |
| except Exception as e: | |
| logger.error(f"Error updating profile: {e}") | |
| db.rollback() | |
| return jsonify({"detail": "سرور موقتاً شلوغ است. لطفاً بعداً تلاش کنید."}), 503 | |
| # ========================================== | |
| # بخش ۱۰: دایرکتوری مشاوران و فیلتر منطقهای هوشمند (بند ۳) | |
| # ========================================== | |
| def list_active_counselors(): | |
| db = get_db() | |
| name = request.args.get("name") | |
| city = request.args.get("city") | |
| student_city = request.args.get("student_city") | |
| try: | |
| query = db.query(User).filter(User.role == "counselor", User.is_verified == True) | |
| target_city = city or student_city | |
| is_fallback = False | |
| # فالبک دایرکتوری به سایر شهرها در صورت نبود مشاور در منطقه داوطلب (بند ۳) | |
| if target_city: | |
| regional_query = query.filter(User.city.ilike(f"%{target_city}%")) | |
| counselors = regional_query.all() | |
| if not counselors: | |
| is_fallback = True | |
| counselors = query.all() | |
| else: | |
| counselors = query.all() | |
| if name: | |
| counselors = [c for c in counselors if (name.lower() in (c.name or "").lower() or name.lower() in c.email.lower() or name.lower() in (c.counselor_profile.specialty or "").lower())] | |
| output = [] | |
| for c in counselors: | |
| output.append({ | |
| "counselor_id": c.id, | |
| "email": c.email, | |
| "name": c.name or c.email.split("@")[0], | |
| "rating": c.rating, | |
| "city": c.city or "تبریز", | |
| "address": c.address or "ثبتنشده", # ارسال آدرس جهت نمایش در فرانتاند | |
| "phone_number": c.phone_number or "ثبتنشده", # ارسال تلفن کاری جهت نمایش در فرانتاند | |
| "specialty": c.counselor_profile.specialty if c.counselor_profile else None, | |
| "resume": c.counselor_profile.resume if c.counselor_profile else None, | |
| "bio": c.counselor_profile.bio if c.counselor_profile else None, | |
| "hourly_rate": c.counselor_profile.hourly_rate if c.counselor_profile else 150000.0, | |
| "profile_pic": c.profile_pic, | |
| "is_fallback": is_fallback | |
| }) | |
| return jsonify(output), 200 | |
| except Exception as e: | |
| logger.error(f"Error listing counselors: {e}", exc_info=True) | |
| return jsonify({"detail": "سرور موقتاً شلوغ است. لطفاً بعداً تلاش کنید."}), 503 | |
| def list_counselor_available_slots(counselor_id): | |
| db = get_db() | |
| try: | |
| slots = db.query(TimeSlot).filter( | |
| TimeSlot.counselor_id == counselor_id, | |
| TimeSlot.is_booked == False | |
| ).all() | |
| output = [] | |
| for s in slots: | |
| output.append({ | |
| "id": s.id, | |
| "counselor_id": s.counselor_id, | |
| "start_time": s.start_time.isoformat(), | |
| "end_time": s.end_time.isoformat(), | |
| "is_booked": s.is_booked, | |
| "booked_by_student_id": s.booked_by_student_id | |
| }) | |
| return jsonify(output), 200 | |
| except Exception as e: | |
| logger.error(f"Error listing slots: {e}", exc_info=True) | |
| return jsonify({"detail": "سرور موقتاً شلوغ است. لطفاً بعداً تلاش کنید."}), 503 | |
| # ========================================== | |
| # بخش ۱۱: فرآیند تراکنش رزرو با کسر ۷٪ کارمزد پلتفرم (بند ۴) | |
| # ========================================== | |
| def book_session(): | |
| db = get_db() | |
| current_user = g.current_user | |
| data = request.get_json() or {} | |
| slot_id = data.get("slot_id") | |
| if not slot_id: | |
| return jsonify({"detail": "شناسه بازه زمانی الزامی است."}), 400 | |
| try: | |
| if current_user.role != "student": | |
| return jsonify({"detail": "تنها کاربران با نقش داوطلب مجاز به رزرو هستند."}), 403 | |
| slot = db.query(TimeSlot).filter(TimeSlot.id == slot_id).first() | |
| if not slot or slot.is_booked: | |
| return jsonify({"detail": "این زمان قبلاً رزرو شده است یا در دسترس نیست."}), 400 | |
| counselor = db.query(User).filter(User.id == slot.counselor_id).first() | |
| if not counselor or not counselor.counselor_profile: | |
| return jsonify({"detail": "مشاور انتخابی در سیستم یافت نشد."}), 404 | |
| cost = counselor.counselor_profile.hourly_rate | |
| if current_user.balance < cost: | |
| return jsonify({"detail": "موجودی حساب کافی نیست. لطفاً کیف پول خود را شارژ نمایید."}), 400 | |
| # کسر کارمزد ۷٪ پلتفرم به صورت خودکار (بند ۴) | |
| platform_fee = cost * 0.07 | |
| net_to_counselor = cost - platform_fee | |
| current_user.balance -= cost | |
| counselor.balance += net_to_counselor | |
| slot.is_booked = True | |
| slot.booked_by_student_id = current_user.id | |
| booking = Booking( | |
| slot_id=slot.id, | |
| student_id=current_user.id, | |
| counselor_id=counselor.id, | |
| status="active", | |
| cost=cost | |
| ) | |
| db.add(booking) | |
| db.commit() | |
| return jsonify({ | |
| "message": "رزرو جلسه با موفقیت تایید و سهم مشاور به کیف پول واریز شد.", | |
| "remaining_balance": current_user.balance, | |
| "session_start": slot.start_time.isoformat() | |
| }), 200 | |
| except Exception as e: | |
| logger.error(f"Error during booking creation: {e}", exc_info=True) | |
| db.rollback() | |
| return jsonify({"detail": "سرور موقتاً شلوغ است. لطفاً بعداً تلاش کنید."}), 503 | |
| def get_student_bookings(): | |
| db = get_db() | |
| current_user = g.current_user | |
| try: | |
| if current_user.role != "student": | |
| return jsonify({"detail": "دسترسی غیرمجاز"}), 403 | |
| bookings = db.query(Booking).filter( | |
| Booking.student_id == current_user.id, | |
| Booking.status == "active" | |
| ).order_by(Booking.id.desc()).all() | |
| output = [] | |
| for b in bookings: | |
| counselor = db.query(User).filter(User.id == b.counselor_id).first() | |
| slot = db.query(TimeSlot).filter(TimeSlot.id == b.slot_id).first() | |
| output.append({ | |
| "id": b.id, | |
| "counselor_name": counselor.name if counselor else "مشاور تحصیلی", | |
| "date": get_shamsi_date_str(slot.start_time) if slot else get_shamsi_date_str(b.created_at), | |
| "time": slot.start_time.strftime("%H:%M") if slot else "۱۶:۰۰", | |
| "amount": b.cost, | |
| "status": "موفق" | |
| }) | |
| return jsonify(output), 200 | |
| except Exception as e: | |
| logger.error(f"Error fetching student bookings: {e}") | |
| return jsonify({"detail": "خطا در دریافت لیست نوبتهای داوطلب."}), 500 | |
| def cancel_session_by_counselor(booking_id): | |
| db = get_db() | |
| current_user = g.current_user | |
| try: | |
| if current_user.role != "counselor": | |
| return jsonify({"detail": "تنها مشاوران مجاز به استفاده از این متد هستند."}), 403 | |
| booking = db.query(Booking).filter( | |
| Booking.id == booking_id, | |
| Booking.counselor_id == current_user.id | |
| ).first() | |
| if not booking or booking.status == "cancelled": | |
| return jsonify({"detail": "این رزرو قبلاً لغو شده یا وجود ندارد."}), 404 | |
| student = db.query(User).filter(User.id == booking.student_id).first() | |
| counselor = current_user | |
| if student: | |
| student.balance += booking.cost | |
| student_notif = Notification( | |
| user_id=student.id, | |
| text=f"پیام سیستم: جلسه مشاوره شما با مشاور {counselor.name} لغو گردید و کل مبلغ {booking.cost:,.0f} ریال به کیف پول شما عودت داده شد.", | |
| role="student" | |
| ) | |
| db.add(student_notif) | |
| # جریمه انضباطی ۱۰ درصدی لغو یکطرفه مشاور بر اساس قوانین پلتفرم (بند ۴) | |
| penalty_fee = booking.cost * 0.10 | |
| counselor.balance = counselor.balance - (booking.cost + penalty_fee) | |
| counselor_notif = Notification( | |
| user_id=counselor.id, | |
| text=f"پیام سیستم: رزرو شماره {booking.id} توسط شما لغو گردید و جریمه ۱۰ درصدی ({penalty_fee:,.0f} ریال) در حساب شما ثبت شد.", | |
| role="counselor" | |
| ) | |
| db.add(counselor_notif) | |
| slot = db.query(TimeSlot).filter(TimeSlot.id == booking.slot_id).first() | |
| if slot: | |
| slot.is_booked = False | |
| slot.booked_by_student_id = None | |
| booking.status = "cancelled" | |
| db.commit() | |
| return jsonify({"message": "جلسه مشاوره لغو، مبالغ عودت و جریمه کسر گردید."}), 200 | |
| except Exception as e: | |
| logger.error(f"Error cancelling booking by counselor: {e}", exc_info=True) | |
| db.rollback() | |
| return jsonify({"detail": "خطا در فرآیند لغو رزرو مشاور."}), 500 | |
| # ======================================================== | |
| # بخش ۱۲: روت پیشرفته استخراج و تحلیل رزومه با هوش مصنوعی جمینای (بند ۵) | |
| # ======================================================== | |
| def extract_resume_ai(): | |
| db = get_db() | |
| current_user = g.current_user | |
| if current_user.role != "counselor": | |
| return jsonify({"detail": "دسترسی غیرمجاز"}), 403 | |
| data = request.get_json() or {} | |
| file_data = data.get("file_data") # رشته رمزگذاری شده به صورت Base64 | |
| if not file_data: | |
| return jsonify({"detail": "بارگذاری فایل رزومه جهت استخراج الزامی است."}), 400 | |
| try: | |
| # دیکودر بیس ۶۴ و تفکیک هدر چندرسانهای | |
| if "," in file_data: | |
| header, base64_str = file_data.split(",", 1) | |
| mime_type = header.split(";")[0].split(":")[1] | |
| else: | |
| base64_str = file_data | |
| mime_type = "image/png" # پیشفرض در صورت نبود هدر | |
| file_bytes = base64.b64decode(base64_str) | |
| except Exception as decode_err: | |
| logger.error(f"Error decoding base64: {decode_err}") | |
| return jsonify({"detail": "فرمت یا کدهای فایل رزومه نامعتبر است."}), 400 | |
| extracted_text = "" | |
| # اتصال مستقیم به SDK نسل جدید هوش مصنوعی گوگل (گوگل جمینای) | |
| if AI_CORE_KEY and genai: | |
| try: | |
| client = genai.Client(api_key=AI_CORE_KEY) | |
| model_name = "gemini-2.5-flash" # استفاده از قویترین مدل پردازش تصویر و متن | |
| contents = [ | |
| types.Content( | |
| role="user", | |
| parts=[ | |
| types.Part.from_bytes( | |
| mime_type=mime_type, | |
| data=file_bytes | |
| ), | |
| types.Part.from_text(text="شما یک تحلیلگر و پردازشگر رزومه هستید. لطفاً این رزومه علمی مشاور را بررسی کرده و اطلاعات آن شامل سوابق تحصیلی، سوابق کاری، جوایز و حوزههای تخصصی او را به زبان فارسی بسیار روان و خلاصه (حداکثر ۱۰۰ کلمه) استخراج کرده و ارائه دهید.") | |
| ] | |
| ) | |
| ] | |
| response = client.models.generate_content( | |
| model=model_name, | |
| contents=contents | |
| ) | |
| extracted_text = response.text.strip() | |
| except Exception as ai_err: | |
| logger.error(f"Gemini API processing failed: {ai_err}") | |
| extracted_text = "خطا در برقراری ارتباط با جمینای. تحلیل متنی: مشاور هدایت تحصیلی و کنکور در آذربایجان شرقی با رویکرد ارزیابی ترازهای آماری." | |
| else: | |
| extracted_text = "تحلیل رزومه (حالت آفلاین): مشاور کنکور سراسری، با سابقه رتبهپروری و هدایت رتبههای تکرقمی و دو رقمی در تبریز." | |
| return jsonify({ | |
| "extracted_text": extracted_text | |
| }), 200 | |
| # ======================================================== | |
| # بخش ۱۳: درگاههای مدیریت تسویه حساب و کیف پول (بند ۴) | |
| # ======================================================== | |
| def counselor_request_settlement(): | |
| db = get_db() | |
| current_user = g.current_user | |
| try: | |
| if current_user.role != "counselor": | |
| return jsonify({"detail": "دسترسی غیرمجاز"}), 403 | |
| if current_user.balance <= 0: | |
| return jsonify({"detail": "موجودی قابل تسویه وجود ندارد."}), 400 | |
| req = SettlementRequest( | |
| user_id=current_user.id, | |
| amount=current_user.balance, | |
| status="pending" | |
| ) | |
| db.add(req) | |
| current_user.balance = 0.0 | |
| db.commit() | |
| return jsonify({"message": "درخواست تسویه با موفقیت ثبت شد."}), 200 | |
| except Exception as e: | |
| logger.error(f"Error requesting settlement: {e}") | |
| db.rollback() | |
| return jsonify({"detail": "بروز خطا در ثبت تسویه مالی."}), 500 | |
| # ======================================================== | |
| # گزارش مالی و تفکیک محاسباتی سهگانه (بند ۵) | |
| # ======================================================== | |
| def get_role_financial_summary(): | |
| db = get_db() | |
| current_user = g.current_user | |
| try: | |
| if current_user.role == "student": | |
| total_spent = db.query(text("SUM(cost)")).from_statement( | |
| text("SELECT cost FROM bookings WHERE student_id = :sid AND status = 'active'") | |
| ).params(sid=current_user.id).scalar() or 0.0 | |
| refunds = db.query(text("SUM(cost)")).from_statement( | |
| text("SELECT cost FROM bookings WHERE student_id = :sid AND status = 'cancelled'") | |
| ).params(sid=current_user.id).scalar() or 0.0 | |
| return jsonify({ | |
| "balance": current_user.balance, | |
| "total_spent": total_spent, | |
| "refunds": refunds | |
| }), 200 | |
| elif current_user.role == "counselor": | |
| gross = db.query(text("SUM(cost)")).from_statement( | |
| text("SELECT cost FROM bookings WHERE counselor_id = :cid") | |
| ).params(cid=current_user.id).scalar() or 0.0 | |
| platform_fee = gross * 0.07 | |
| net_earnings = gross - platform_fee | |
| settlements = db.query(text("SUM(amount)")).from_statement( | |
| text("SELECT amount FROM settlement_requests WHERE user_id = :uid AND status = 'approved'") | |
| ).params(uid=current_user.id).scalar() or 0.0 | |
| pending_settlements = db.query(text("SUM(amount)")).from_statement( | |
| text("SELECT amount FROM settlement_requests WHERE user_id = :uid AND status = 'pending'") | |
| ).params(uid=current_user.id).scalar() or 0.0 | |
| return jsonify({ | |
| "balance": current_user.balance, | |
| "gross_earnings": gross, | |
| "platform_fee": platform_fee, | |
| "net_earnings": net_earnings, | |
| "settled_amount": settlements, | |
| "pending_settlements": pending_settlements | |
| }), 200 | |
| elif current_user.role == "school_representative": | |
| spent_ads = db.query(text("SUM(amount)")).from_statement( | |
| text("SELECT amount FROM settlement_requests WHERE user_id = :uid") | |
| ).params(uid=current_user.id).scalar() or 0.0 | |
| return jsonify({ | |
| "balance": current_user.balance, | |
| "total_spent_ads": spent_ads, | |
| "refunds": 0.0 | |
| }), 200 | |
| except Exception as e: | |
| logger.error(f"Error building financial summary: {e}") | |
| return jsonify({"detail": "خطا در دریافت وضعیت تراز مالی."}), 500 | |
| # ======================================================== | |
| # ردیابی ترافیک و آمار بازدید آموزشگاه (بند ۹) | |
| # ======================================================== | |
| def track_school_page_view(school_id): | |
| db = get_db() | |
| current_user = g.current_user | |
| try: | |
| recent = db.query(PageView).filter( | |
| PageView.school_id == school_id, | |
| PageView.student_id == current_user.id, | |
| PageView.timestamp > get_now() - timedelta(minutes=30) | |
| ).first() | |
| if not recent: | |
| view = PageView( | |
| school_id=school_id, | |
| student_id=current_user.id | |
| ) | |
| db.add(view) | |
| db.commit() | |
| return jsonify({"status": "success"}), 200 | |
| except Exception as e: | |
| logger.error(f"Error tracking visit: {e}") | |
| db.rollback() | |
| return jsonify({"detail": "خطا در ثبت آمار ترافیک."}), 500 | |
| def get_school_analytics_metrics(school_id): | |
| db = get_db() | |
| try: | |
| total_views = db.query(PageView).filter(PageView.school_id == school_id).count() | |
| reviews = db.query(Review).filter(Review.school_id == school_id).all() | |
| distribution = [0, 0, 0, 0, 0] | |
| avg_rating = 0.0 | |
| if reviews: | |
| for r in reviews: | |
| r_val = int(round(r.rating)) | |
| if 1 <= r_val <= 5: | |
| distribution[r_val - 1] += 1 | |
| avg_rating = sum([r.rating for r in reviews]) / len(reviews) | |
| return jsonify({ | |
| "page_views": total_views, | |
| "average_rating": avg_rating, | |
| "ratings_distribution": distribution | |
| }), 200 | |
| except Exception as e: | |
| logger.error(f"Error fetching school analytics: {e}") | |
| return jsonify({"detail": "خطا در بارگذاری اطلاعات آموزشگاه."}), 500 | |
| # ======================================================== | |
| # امتیازدهی اتوماتیک ۲۴ ساعت پس از نوبت (بند ۱۰) | |
| # ======================================================== | |
| def get_student_pending_reviews_for_counselors(): | |
| db = get_db() | |
| current_user = g.current_user | |
| try: | |
| if current_user.role != "student": | |
| return jsonify({"detail": "دسترسی غیرمجاز"}), 403 | |
| boundary_time = get_now() - timedelta(hours=24) | |
| bookings = db.query(Booking).join(TimeSlot).filter( | |
| Booking.student_id == current_user.id, | |
| Booking.status == "active", | |
| TimeSlot.start_time < boundary_time | |
| ).all() | |
| pending_list = [] | |
| for b in bookings: | |
| reviewed = db.query(Review).filter(Review.booking_id == b.id).first() | |
| if not reviewed: | |
| counselor = db.query(User).filter(User.id == b.counselor_id).first() | |
| pending_list.append({ | |
| "booking_id": b.id, | |
| "counselor_id": b.counselor_id, | |
| "counselor_name": counselor.name if counselor else "مشاور تحصیلی", | |
| "date": get_shamsi_date_str(b.created_at) | |
| }) | |
| return jsonify(pending_list), 200 | |
| except Exception as e: | |
| logger.error(f"Error getting pending reviews: {e}") | |
| return jsonify({"detail": "خطا در ارزیابی نوبتهای فعال داوطلب."}), 500 | |
| # ========================================== | |
| # دایرکتوری آموزشگاههای فعال با فیلتر بومی (بند ۳) | |
| # ========================================== | |
| def list_and_filter_schools(): | |
| db = get_db() | |
| name = request.args.get("name") | |
| city = request.args.get("city") | |
| student_city = request.args.get("student_city") | |
| try: | |
| now = get_now() | |
| query = db.query(SchoolProfile).filter(SchoolProfile.subscription_active_until > now) | |
| target_city = city or student_city | |
| is_fallback = False | |
| if target_city: | |
| regional_query = query.join(User, User.id == SchoolProfile.user_id).filter(User.city.ilike(f"%{target_city}%")) | |
| schools = regional_query.all() | |
| if not schools: | |
| is_fallback = True | |
| schools = query.all() | |
| else: | |
| schools = query.all() | |
| if name: | |
| schools = [s for s in schools if name.lower() in (s.name or "").lower()] | |
| output = [] | |
| for s in schools: | |
| output.append({ | |
| "school_id": s.id, | |
| "school_name": s.name, | |
| "is_sponsored": s.is_sponsored, | |
| "rating": s.rating, | |
| "bio": s.bio, | |
| "address": s.address, | |
| "phone_number": s.phone_number, | |
| "founding_year": s.founding_year, | |
| "city": s.user.city if s.user else "تبریز", # ارسال شهر به ازای ادرس آموزشگاه (بند ۳) | |
| "is_fallback": is_fallback, | |
| "courses": [ | |
| { | |
| "course_name": c.course_name, | |
| "teacher_name": c.teacher_name, | |
| "teaching_style": c.teaching_style | |
| } for c in s.courses | |
| ] | |
| }) | |
| return jsonify(output), 200 | |
| except Exception as e: | |
| logger.error(f"Error querying schools: {e}") | |
| return jsonify({"detail": "سرور موقتاً شلوغ است. لطفاً بعداً تلاش کنید."}), 503 | |
| def create_session_review(): | |
| db = get_db() | |
| current_user = g.current_user | |
| data = request.get_json() or {} | |
| booking_id = data.get("booking_id") | |
| counselor_id = data.get("counselor_id") | |
| school_id = data.get("school_id") | |
| rating = data.get("rating", 5.0) | |
| comment = data.get("comment") | |
| try: | |
| if current_user.role != "student": | |
| return jsonify({"detail": "دسترسی غیرمجاز"}), 403 | |
| review = Review( | |
| student_id=current_user.id, | |
| booking_id=booking_id, | |
| counselor_id=counselor_id, | |
| school_id=school_id, | |
| rating=rating, | |
| comment=comment | |
| ) | |
| db.add(review) | |
| if counselor_id: | |
| counselor = db.query(User).filter(User.id == counselor_id).first() | |
| if counselor: | |
| counselor.rating = (counselor.rating + rating) / 2.0 if counselor.rating > 0.0 else rating | |
| elif school_id: | |
| school = db.query(SchoolProfile).filter(SchoolProfile.id == school_id).first() | |
| if school: | |
| school.rating = (school.rating + rating) / 2.0 if school.rating > 0.0 else rating | |
| db.commit() | |
| return jsonify({"message": "امتیاز با موفقیت در دیتابیس ثبت شد."}), 200 | |
| except Exception as e: | |
| logger.error(f"Error creating review: {e}") | |
| db.rollback() | |
| return jsonify({"detail": "سرور موقتاً شلوغ است. لطفاً بعداً تلاش کنید."}), 503 | |
| def update_school_course_portfolio(): | |
| db = get_db() | |
| current_user = g.current_user | |
| data = request.get_json() or {} | |
| name = data.get("name") | |
| bio = data.get("bio") | |
| address = data.get("address") | |
| phone_number = data.get("phone_number") | |
| founding_year = data.get("founding_year") | |
| courses_list = data.get("courses", []) | |
| profile_pic = data.get("profile_pic") | |
| if not name: | |
| return jsonify({"detail": "نام موسسه یا آموزشگاه الزامی است."}), 400 | |
| try: | |
| if current_user.role != "school_representative": | |
| return jsonify({"detail": "دسترسی غیرمجاز"}), 403 | |
| profile = db.query(SchoolProfile).filter(SchoolProfile.user_id == current_user.id).first() | |
| if not profile: | |
| profile = SchoolProfile( | |
| user_id=current_user.id, | |
| name=name, | |
| bio=bio, | |
| address=address, | |
| phone_number=phone_number, | |
| founding_year=founding_year, | |
| profile_pic=profile_pic | |
| ) | |
| db.add(profile) | |
| db.flush() | |
| else: | |
| profile.name = name | |
| profile.bio = bio | |
| profile.address = address | |
| profile.phone_number = phone_number | |
| profile.founding_year = founding_year | |
| if profile_pic: | |
| profile.profile_pic = profile_pic | |
| db.query(SchoolCourse).filter(SchoolCourse.school_id == profile.id).delete() | |
| for c in courses_list: | |
| db_course = SchoolCourse( | |
| school_id=profile.id, | |
| course_name=c.get("course_name"), | |
| teacher_name=c.get("teacher_name"), | |
| teaching_style=c.get("teaching_style") | |
| ) | |
| db.add(db_course) | |
| db.commit() | |
| return jsonify({"message": "رزومه کادر اساتید و آموزشگاه با موفقیت بروز گردید."}), 200 | |
| except Exception as e: | |
| logger.error(f"Error during school portfolio update: {e}", exc_info=True) | |
| db.rollback() | |
| return jsonify({"detail": "سرور موقتاً شلوغ است. لطفاً بعداً تلاش کنید."}), 503 | |
| def purchase_school_subscription(): | |
| db = get_db() | |
| current_user = g.current_user | |
| data = request.get_json() or {} | |
| months = data.get("months") | |
| is_sponsored = data.get("is_sponsored", False) | |
| if not months: | |
| return jsonify({"detail": "تعداد ماهها معتبر نیست."}), 400 | |
| try: | |
| if current_user.role != "school_representative": | |
| return jsonify({"detail": "دسترسی غیرمجاز"}), 403 | |
| profile = db.query(SchoolProfile).filter(SchoolProfile.user_id == current_user.id).first() | |
| if not profile: | |
| return jsonify({"detail": "مشخصات آموزشگاه یافت نشد."}), 404 | |
| now = get_now() | |
| if profile.subscription_active_until and profile.subscription_active_until > now: | |
| profile.subscription_active_until += timedelta(days=30 * months) | |
| else: | |
| profile.subscription_active_until = now + timedelta(days=30 * months) | |
| profile.is_sponsored = is_sponsored | |
| db.commit() | |
| return jsonify({ | |
| "message": f"عضویت ویژه فعال گردید.", | |
| "active_until": profile.subscription_active_until.isoformat(), | |
| "is_sponsored": profile.is_sponsored | |
| }), 200 | |
| except Exception as e: | |
| logger.error(f"Error purchasing school subscription: {e}", exc_info=True) | |
| db.rollback() | |
| return jsonify({"detail": "سرور موقتاً شلوغ است. لطفاً بعداً تلاش کنید."}), 503 | |
| def top_up_student_wallet(): | |
| db = get_db() | |
| current_user = g.current_user | |
| data = request.get_json() or {} | |
| amount = data.get("amount") | |
| if amount is None: | |
| return jsonify({"detail": "مبلغ تراکنش نامعتبر است."}), 400 | |
| try: | |
| if current_user.role != "student": | |
| return jsonify({"detail": "دسترسی غیرمجاز"}), 403 | |
| current_user.balance += amount | |
| db.commit() | |
| return jsonify({"message": "شارژ حساب کاربری با موفقیت تکمیل شد.", "new_balance": current_user.balance}), 200 | |
| except Exception as e: | |
| logger.error(f"Error topup wallet: {e}", exc_info=True) | |
| db.rollback() | |
| return jsonify({"detail": "سرور موقتاً شلوغ است. لطفاً بعداً تلاش کنید."}), 503 | |
| def submit_diagnostic_prep_test(): | |
| db = get_db() | |
| current_user = g.current_user | |
| data = request.get_json() or {} | |
| counselor_id = data.get("counselor_id") | |
| answers = data.get("answers") | |
| if not counselor_id or not answers: | |
| return jsonify({"detail": "اطلاعات مشاور و پاسخنامه تشخیصی الزامی است."}), 400 | |
| try: | |
| if current_user.role != "student": | |
| return jsonify({"detail": "دسترسی غیرمجاز"}), 403 | |
| analysis_report = core_diagnostic_analysis(answers) | |
| test_res = TestResult( | |
| student_id=current_user.id, | |
| counselor_id=counselor_id, | |
| answers=answers, | |
| ai_analysis_summary=analysis_report | |
| ) | |
| db.add(test_res) | |
| db.commit() | |
| return jsonify({ | |
| "message": "پاسخنامه ثبت و برای مشاور ارسال شد.", | |
| "evaluation_report": analysis_report | |
| }), 200 | |
| except Exception as e: | |
| logger.error(f"Error submitting prep test: {e}", exc_info=True) | |
| db.rollback() | |
| return jsonify({"detail": "سرور موقتاً شلوغ است. لطفاً بعداً تلاش کنید."}), 503 | |
| def create_time_slots(): | |
| db = get_db() | |
| current_user = g.current_user | |
| data = request.get_json() or [] | |
| if not isinstance(data, list): | |
| data = [data] | |
| try: | |
| if current_user.role != "counselor": | |
| return jsonify({"detail": "دسترسی مخصوص مشاورین"}), 403 | |
| for slot in data: | |
| start_time_str = slot.get("start_time") | |
| end_time_str = slot.get("end_time") | |
| if not start_time_str or not end_time_str: | |
| continue | |
| try: | |
| start_time = datetime.fromisoformat(start_time_str.replace("Z", "+00:00")) | |
| end_time = datetime.fromisoformat(end_time_str.replace("Z", "+00:00")) | |
| except ValueError: | |
| continue | |
| new_slot = TimeSlot( | |
| counselor_id=current_user.id, | |
| start_time=start_time, | |
| end_time=end_time, | |
| is_booked=False | |
| ) | |
| db.add(new_slot) | |
| db.commit() | |
| return jsonify({"message": "تقویم زمانی ثبت گردید."}), 200 | |
| except Exception as e: | |
| logger.error(f"Error adding slots: {e}", exc_info=True) | |
| db.rollback() | |
| return jsonify({"detail": "سرور موقتاً شلوغ است. لطفاً بعداً تلاش کنید."}), 503 | |
| def get_counselor_dash_clients(): | |
| db = get_db() | |
| current_user = g.current_user | |
| try: | |
| if current_user.role != "counselor": | |
| return jsonify({"detail": "دسترسی غیرمجاز"}), 403 | |
| booked_slots = db.query(TimeSlot).filter( | |
| TimeSlot.counselor_id == current_user.id, | |
| TimeSlot.is_booked == True | |
| ).all() | |
| student_ids = list(set([s.booked_by_student_id for s in booked_slots if s.booked_by_student_id])) | |
| results = [] | |
| for s_id in student_ids: | |
| student_user = db.query(User).filter(User.id == s_id).first() | |
| if not student_user: | |
| continue | |
| tests = db.query(TestResult).filter(TestResult.student_id == s_id, TestResult.counselor_id == current_user.id).all() | |
| selections = db.query(MajorSelection).filter(MajorSelection.student_id == s_id, MajorSelection.counselor_id == current_user.id).all() | |
| parsed_selections = [] | |
| for sel in selections: | |
| try: | |
| gen_list = json.loads(sel.generated_list) if sel.generated_list else [] | |
| fin_list = json.loads(sel.finalized_list) if sel.finalized_list else [] | |
| except Exception: | |
| gen_list, fin_list = sel.generated_list, sel.finalized_list | |
| parsed_selections.append({ | |
| "selection_id": sel.id, | |
| "status": sel.status, | |
| "ai_generated_list": gen_list, | |
| "counselor_finalized_list": fin_list | |
| }) | |
| results.append({ | |
| "student_id": s_id, | |
| "student_email": student_user.email, | |
| "student_name": student_user.name or student_user.email.split("@")[0], | |
| "grade": student_user.grade or "یازدهم", # فیلد مقطع تحصیلی داوطلب (بند ۱۲) | |
| "major": student_user.major or "علوم تجربی", # فیلد رشته تحصیلی داوطلب (بند ۱۲) | |
| "tests": [{"test_id": t.id, "answers": t.answers, "ai_analysis": t.ai_analysis_summary} for t in tests], | |
| "selections": parsed_selections | |
| }) | |
| return jsonify(results), 200 | |
| except Exception as e: | |
| logger.error(f"Error fetching dashboard clients: {e}", exc_info=True) | |
| return jsonify({"detail": "سرور موقتاً شلوغ است. لطفاً بعداً تلاش کنید."}), 503 | |
| def get_student_visited_counselors(): | |
| db = get_db() | |
| current_user = g.current_user | |
| try: | |
| if current_user.role != "student": | |
| return jsonify({"detail": "دسترسی غیرمجاز"}), 403 | |
| bookings = db.query(Booking).filter( | |
| Booking.student_id == current_user.id, | |
| Booking.status == "active" | |
| ).all() | |
| c_ids = list(set([b.counselor_id for b in bookings])) | |
| results = [] | |
| for cid in c_ids: | |
| c = db.query(User).filter(User.id == cid).first() | |
| if c: | |
| results.append({ | |
| "counselor_id": c.id, | |
| "name": c.name or c.email.split("@")[0], | |
| "specialty": c.counselor_profile.specialty if c.counselor_profile else "کنکور سراسری", | |
| "rating": c.rating, | |
| "city": c.city or "تبریز" | |
| }) | |
| return jsonify(results), 200 | |
| except Exception as e: | |
| logger.error(f"Error getting visited counselors: {e}") | |
| return jsonify({"detail": "خطا در دریافت لیست مشاوران من."}), 500 | |
| def mark_single_notification_as_read(notif_id): | |
| db = get_db() | |
| current_user = g.current_user | |
| try: | |
| notif = db.query(Notification).filter( | |
| Notification.id == notif_id, | |
| or_( | |
| Notification.user_id == current_user.id, | |
| Notification.role == current_user.role, | |
| Notification.role == "all" | |
| ) | |
| ).first() | |
| if notif: | |
| notif.is_read = True | |
| db.commit() | |
| return jsonify({"status": "success"}), 200 | |
| return jsonify({"detail": "اعلان یافت نشد."}), 404 | |
| except Exception as e: | |
| logger.error(f"Error marking notification: {e}") | |
| db.rollback() | |
| return jsonify({"detail": "خطا در بهروزرسانی اعلان."}), 500 | |
| def mark_notifications_as_read(): | |
| db = get_db() | |
| current_user = g.current_user | |
| try: | |
| notifs = db.query(Notification).filter( | |
| or_( | |
| Notification.user_id == current_user.id, | |
| Notification.role == current_user.role, | |
| Notification.role == "all" | |
| ) | |
| ).all() | |
| for n in notifs: | |
| n.is_read = True | |
| db.commit() | |
| return jsonify({"status": "success"}), 200 | |
| except Exception as e: | |
| logger.error(f"Error marking notifications as read: {e}") | |
| db.rollback() | |
| return jsonify({"detail": "خطا در بهروزرسانی اعلانها."}), 500 | |
| def create_manual_booking_by_counselor(): | |
| db = get_db() | |
| current_user = g.current_user | |
| data = request.get_json() or {} | |
| student_id = data.get("student_id") | |
| start_time_str = data.get("start_time") | |
| end_time_str = data.get("end_time") | |
| cost = data.get("cost") | |
| if not student_id or not start_time_str or not end_time_str or cost is None: | |
| return jsonify({"detail": "تمام فیلدها الزامی است."}), 400 | |
| try: | |
| if current_user.role != "counselor": | |
| return jsonify({"detail": "تنها مشاوران مجاز به ثبت نوبت هستند."}), 403 | |
| try: | |
| start_time = datetime.fromisoformat(start_time_str) | |
| end_time = datetime.fromisoformat(end_time_str) | |
| except ValueError: | |
| return jsonify({"detail": "فرمت تاریخ نامعتبر است."}), 400 | |
| slot = TimeSlot( | |
| counselor_id=current_user.id, | |
| start_time=start_time, | |
| end_time=end_time, | |
| is_booked=True, | |
| booked_by_student_id=student_id | |
| ) | |
| db.add(slot) | |
| db.flush() | |
| due_time = get_now() + timedelta(hours=24) | |
| booking = Booking( | |
| slot_id=slot.id, | |
| student_id=student_id, | |
| counselor_id=current_user.id, | |
| status="active", | |
| payment_status="pending_payment", | |
| payment_due_until=due_time, | |
| cost=cost | |
| ) | |
| db.add(booking) | |
| db.commit() | |
| return jsonify({ | |
| "message": "نوبت صادر و فاکتور ۲۴ ساعته ارسال گردید.", | |
| "booking_id": booking.id | |
| }), 200 | |
| except Exception as e: | |
| logger.error(f"Error manual booking: {e}") | |
| db.rollback() | |
| return jsonify({"detail": "خطا در ایجاد نوبت دستی."}), 503 | |
| def home_webview(): | |
| return flask_app.send_static_file("index.html") | |
| app = WsgiToAsgi(flask_app) | |
| if __name__ == "__main__": | |
| flask_app.run(host="0.0.0.0", port=7860, debug=True) |