diff --git a/api/controllers/admin/students/students.py b/api/controllers/admin/students/students.py index dc8ed7e1a8..95734cb740 100644 --- a/api/controllers/admin/students/students.py +++ b/api/controllers/admin/students/students.py @@ -1,11 +1,15 @@ +from controllers.admin import api +from controllers.admin.wraps import validate_admin_token_and_extract_info from flask import Blueprint -from flask_restful import Api, Resource # type: ignore +from flask_restful import Api, Resource # type: ignore +from models.model import App +from services.end_user_service import EndUserService -from controllers.admin import api class StudentList(Resource): - def get(self): - """Get student list with filters. + @validate_admin_token_and_extract_info + def get(self, app_model: App): + """Get all end_user list related with the app_model with filters with pagination. --- tags: - admin/students @@ -14,32 +18,27 @@ class StudentList(Resource): security: - ApiKeyAuth: [] parameters: - - name: risk_level - in: query - type: string - enum: [high, medium, low] - description: Filter by risk level - - name: last_chat_after + - name: health_status in: query type: string - format: date - description: Filter by last conversation date - - name: topics + enum: [normal, potential, critical] + description: Filter by health status + - name: begin_date in: query - type: array - items: - type: string - description: Filter by conversation topics - - name: is_anonymous + type: integer + format: date-time as integer timestamp + description: Filter by begin date + - name: end_date in: query - type: boolean - description: Filter anonymous users + type: integer + format: date-time as integer timestamp + description: Filter by end date - name: page in: query type: integer default: 1 description: Page number - - name: per_page + - name: limit in: query type: integer default: 20 @@ -68,7 +67,7 @@ class StudentList(Resource): last_chat_at: type: string format: date-time - total_conversations: + total_messages: type: integer active_days: type: integer @@ -76,18 +75,71 @@ class StudentList(Resource): type: array items: type: string - risk_level: + health_status: + type: string + enum: [normal, potential, critical] + topics: + type: array + items: + type: string + summary: + type: string + major: type: string - enum: [high, medium, low] 401: description: Invalid or missing API key 400: description: Invalid filter parameters """ - pass + from datetime import datetime + + from flask import request + + # Get query parameters with defaults + health_status = request.args.get('health_status') + begin_date = request.args.get('begin_date') + end_date = request.args.get('end_date') + page = int(request.args.get('page', 1)) + limit = int(request.args.get('limit', 20)) + + # Validate parameters + if begin_date: + try: + begin_date = datetime.fromtimestamp(int(begin_date)) + except ValueError: + return {"error": "Invalid begin_date format"}, 400 + + if end_date: + try: + end_date = datetime.fromtimestamp(int(end_date)) + except ValueError: + return {"error": "Invalid end_date format"}, 400 + + # Build query filters + filters = {} + if health_status: + if health_status not in ['normal', 'potential', 'critical']: + return {"error": "Invalid health_status"}, 400 + filters['health_status'] = health_status + + if begin_date: + filters['last_chat_at__gte'] = begin_date + + if end_date: + filters['last_chat_at__lte'] = end_date + + # Get students with pagination + offset = (page - 1) * limit + + # Query students from database (implementation will depend on your ORM/database structure) + data = EndUserService.get_user_list(app_model=app_model, filters=filters, offset=offset, limit=limit) + + return {"result": "success", "data": data}, 200 + class StudentConversation(Resource): - def get(self, student_id): + @validate_admin_token_and_extract_info + def get(self, app_model: App): """Get student's conversation history. --- tags: @@ -150,8 +202,10 @@ class StudentConversation(Resource): """ pass + class StudentAnalysis(Resource): - def get(self, student_id): + @validate_admin_token_and_extract_info + def get(self, app_model: App): """Get AI analysis and intervention suggestions. --- tags: @@ -191,8 +245,10 @@ class StudentAnalysis(Resource): """ pass + class StudentStatus(Resource): - def put(self, student_id): + @validate_admin_token_and_extract_info + def put(self, app_model: App): """Update student follow-up status. --- tags: @@ -244,7 +300,8 @@ class StudentStatus(Resource): """ pass - def get(self, student_id): + @validate_admin_token_and_extract_info + def get(self, app_model: App): """Get student follow-up status history. --- tags: @@ -289,8 +346,10 @@ class StudentStatus(Resource): """ pass + class StudentNote(Resource): - def put(self, student_id): + @validate_admin_token_and_extract_info + def put(self, app_model: App): """Update student follow-up note. --- tags: @@ -338,7 +397,8 @@ class StudentNote(Resource): """ pass - def get(self, student_id): + @validate_admin_token_and_extract_info + def get(self, app_model: App): """Get student follow-up note history. --- tags: @@ -381,9 +441,9 @@ class StudentNote(Resource): """ pass + api.add_resource(StudentList, '/students') api.add_resource(StudentConversation, '/students//conversation') api.add_resource(StudentAnalysis, '/students//analysis') api.add_resource(StudentStatus, '/students//status') api.add_resource(StudentNote, '/students//note') - diff --git a/api/controllers/admin/wraps.py b/api/controllers/admin/wraps.py new file mode 100644 index 0000000000..670237e611 --- /dev/null +++ b/api/controllers/admin/wraps.py @@ -0,0 +1,87 @@ +from collections.abc import Callable +from datetime import UTC, datetime, timedelta +from enum import Enum +from functools import wraps +from typing import Optional + +from configs import dify_config +from extensions.ext_database import db +from flask import current_app, request +from flask_login import user_logged_in # type: ignore +from flask_restful import Resource # type: ignore +from libs.login import _get_user +from libs.passport import PassportService +from models.account import Account, AccountStatus, Tenant, TenantAccountJoinRole, TenantStatus +from models.model import ApiToken, App, EndUser +from pydantic import BaseModel # type: ignore +from services.account_service import AccountService +from services.feature_service import FeatureService +from sqlalchemy import select, update # type: ignore +from sqlalchemy.orm import Session # type: ignore +from werkzeug.exceptions import Forbidden, Unauthorized + + +def validate_admin_token_and_extract_info(view: Optional[Callable] = None): + def decorator(view_func): + @wraps(view_func) + def decorated_view(*args, **kwargs): + # Extract user info from Bearer token + auth_header = request.headers.get("Authorization") + if auth_header is None or " " not in auth_header: + raise Unauthorized("Authorization header must be provided and start with 'Bearer'") + + auth_scheme, auth_token = auth_header.split(None, 1) + auth_scheme = auth_scheme.lower() + + if auth_scheme != "bearer": + raise Unauthorized("Authorization scheme must be 'Bearer'") + + # Decode the JWT token to extract user info + try: + decoded = PassportService().verify(auth_token) + user_id = decoded.get("user_id") + except Exception as e: + raise Unauthorized(f"Failed to extract user_id from token: {str(e)}") + + if not user_id: + raise Unauthorized("Invalid token: missing user_id") + + account = AccountService.load_user(user_id) + if account is None: + raise Unauthorized("Invalid token: user not found") + if account.status != AccountStatus.ACTIVE: + raise Unauthorized("Invalid token: account is not active") + if account.current_role != TenantAccountJoinRole.END_ADMIN.value: + raise Unauthorized("Invalid token: account is not end admin") + + app_id = request.headers.get("X-App-Id") + if not app_id: + app_id = dify_config.DEFAULT_APP_ID + + app_model = db.session.query(App).filter(App.id == app_id).first() + + if not app_model: + raise Forbidden("The app no longer exists.") + + if app_model.status != "normal": + raise Forbidden("The app's status is abnormal.") + + if not app_model.enable_api: + raise Forbidden("The app's API service has been disabled.") + + tenant = db.session.query(Tenant).filter(Tenant.id == app_model.tenant_id).first() + if tenant is None: + raise ValueError("Tenant does not exist.") + if tenant.status == TenantStatus.ARCHIVE: + raise Forbidden("The workspace's status is archived.") + + kwargs["app_model"] = app_model + + return view_func(*args, **kwargs) + + return decorated_view + + if view is None: + return decorator + else: + return decorator(view) diff --git a/api/controllers/service_api_with_auth/wraps.py b/api/controllers/service_api_with_auth/wraps.py index 0b7970ddca..753b660d7c 100644 --- a/api/controllers/service_api_with_auth/wraps.py +++ b/api/controllers/service_api_with_auth/wraps.py @@ -11,7 +11,7 @@ from flask_login import user_logged_in # type: ignore from flask_restful import Resource # type: ignore from libs.login import _get_user from libs.passport import PassportService -from models.account import Account, AccountStatus, Tenant, TenantAccountJoin, TenantStatus +from models.account import Account, AccountStatus, Tenant, TenantAccountJoin, TenantAccountJoinRole, TenantStatus from models.model import ApiToken, App, EndUser from pydantic import BaseModel # type: ignore from services.account_service import AccountService @@ -66,6 +66,8 @@ def validate_user_token_and_extract_info(view: Optional[Callable] = None): raise Unauthorized("Invalid token: user not found") if account.status != AccountStatus.ACTIVE: raise Unauthorized("Invalid token: account is not active") + if account.current_role != TenantAccountJoinRole.END_USER: + raise Unauthorized("Invalid token: account is not end user") app_id = request.headers.get("X-App-Id") if not app_id: diff --git a/api/migrations/versions/2025_03_15_1619-78d1c56fd608_add_health_status_for_end_user.py b/api/migrations/versions/2025_03_15_1619-78d1c56fd608_add_health_status_for_end_user.py new file mode 100644 index 0000000000..3b2a3c8e38 --- /dev/null +++ b/api/migrations/versions/2025_03_15_1619-78d1c56fd608_add_health_status_for_end_user.py @@ -0,0 +1,33 @@ +"""add health status for end user + +Revision ID: 78d1c56fd608 +Revises: 2c548baeb73f +Create Date: 2025-03-15 16:19:26.562267 + +""" +from alembic import op +import models as models +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '78d1c56fd608' +down_revision = '2c548baeb73f' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('end_users', schema=None) as batch_op: + batch_op.add_column(sa.Column('health_status', sa.String(length=255), nullable=True)) + + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('end_users', schema=None) as batch_op: + batch_op.drop_column('health_status') + + # ### end Alembic commands ### diff --git a/api/models/model.py b/api/models/model.py index e9c0a1c662..4ad5025fea 100644 --- a/api/models/model.py +++ b/api/models/model.py @@ -140,7 +140,7 @@ class App(db.Model): # type: ignore[name-defined] return False if not app_model_config.agent_mode: return False - if self.app_model_config.agent_mode_dict.get("enabled", False) and self.app_model_config.agent_mode_dict.get( + if app_model_config.agent_mode_dict.get("enabled", False) and app_model_config.agent_mode_dict.get( "strategy", "" ) in {"function_call", "react"}: self.mode = AppMode.AGENT_CHAT.value @@ -1323,12 +1323,65 @@ class EndUser(UserMixin, db.Model): # type: ignore[name-defined] is_anonymous = db.Column(db.Boolean, nullable=False, server_default=db.text("true")) session_id: Mapped[str] = mapped_column() gender = db.Column(db.Integer, nullable=False, server_default=db.text("0")) # 0: unknown, 1: male, 2: female - extra_profile = db.Column(db.JSON, nullable=True) # JSON format, e.g. { "major":"engineer" } - memory = db.Column(db.Text, nullable=True) # Long text to store user memory + memory = db.Column(db.Text, nullable=True) # Long text to store user memory" memory_updated_at = db.Column(db.DateTime, nullable=True) # To record when memory was last updated + health_status = db.Column(db.String(255), nullable=True) # Only accept for "normal", "potential", "critical" + extra_profile = db.Column( + db.JSON, nullable=True + ) # JSON format, e.g. { "major":"engineer", "topics":["math", "physics"], "summary": "This is a summary of the user's profile"} created_at = db.Column(db.DateTime, nullable=False, server_default=func.current_timestamp()) updated_at = db.Column(db.DateTime, nullable=False, server_default=func.current_timestamp()) + @property + def account(self): + return db.session.query(Account).filter(Account.id == self.external_user_id).first() + + @property + def email(self): + if self.account is None: + return None + + return self.account.email + + @property + def summary(self): + if self.extra_profile is None: + return None + return self.extra_profile.get("summary") + + @property + def topics(self): + if self.extra_profile is None: + return [] + return self.extra_profile.get("topics") + + @property + def major(self): + if self.extra_profile is None: + return None + return self.extra_profile.get("major") + + def update_summary(self, summary: str): + self.extra_profile = { + "summary": summary, + "topics": self.topics, + "major": self.major, + } + + def update_topics(self, topics: list[str]): + self.extra_profile = { + "summary": self.summary, + "topics": topics, + "major": self.major, + } + + def update_major(self, major: str): + self.extra_profile = { + "summary": self.summary, + "topics": self.topics, + "major": major, + } + class Site(db.Model): # type: ignore[name-defined] __tablename__ = "sites" diff --git a/api/services/end_user_service.py b/api/services/end_user_service.py index ddf18be6b2..79fcf93335 100644 --- a/api/services/end_user_service.py +++ b/api/services/end_user_service.py @@ -1,12 +1,120 @@ from typing import Any, Dict, Optional, Tuple from extensions.ext_database import db -from models.account import Account -from models.model import EndUser -from services.account_service import AccountService +from models.account import Account, TenantAccountJoin +from models.model import App, Conversation, EndUser, Message +from sqlalchemy import and_, desc, func class EndUserService: + @staticmethod + def get_user_list(app_model: App, filters: Dict[str, Any], offset: int, limit: int) -> Dict[str, Any]: + """ + Get a list of end users with filtering and pagination + + Args: + filters: Dictionary containing filter criteria + offset: Number of records to skip + limit: Maximum number of records to return + + Returns: + Dictionary containing total count and list of end users + """ + # Get message data to calculate active days + message_days_subq = ( + db.session.query( + Message.from_end_user_id, + func.count( + func.distinct(func.date(func.timezone('UTC+8', func.timezone('UTC', Message.created_at)))) + ).label('active_days'), + ) + .filter(Message.app_id == app_model.id) + .group_by(Message.from_end_user_id) + .subquery() + ) + + # Start with base query - using join and subqueries for the chat timing data + subq = ( + db.session.query( + Conversation.from_end_user_id, + func.max(Conversation.created_at).label('last_chat_at'), + func.min(Conversation.created_at).label('first_chat_at'), + func.count(Message.id).label('total_messages'), + ) + .filter(Conversation.app_id == app_model.id) + .join(Message, Message.conversation_id == Conversation.id) + .group_by(Conversation.from_end_user_id) + .subquery() + ) + + query = ( + db.session.query( + EndUser, + subq.c.last_chat_at, + subq.c.first_chat_at, + subq.c.total_messages, + message_days_subq.c.active_days, + ) + .outerjoin(subq, EndUser.id == subq.c.from_end_user_id) + .outerjoin(message_days_subq, EndUser.id == message_days_subq.c.from_end_user_id) + .filter(EndUser.app_id == app_model.id) + .filter(EndUser.external_user_id != None) + ) + + # Apply filters + filter_conditions = [] + + if 'health_status' in filters: + filter_conditions.append(EndUser.health_status == filters['health_status']) + + if 'last_chat_at__gte' in filters: + filter_conditions.append(subq.c.last_chat_at >= filters['last_chat_at__gte']) + + if 'last_chat_at__lte' in filters: + filter_conditions.append(subq.c.last_chat_at <= filters['last_chat_at__lte']) + + # Apply all filter conditions + if filter_conditions: + query = query.filter(and_(*filter_conditions)) + + # Get total count before applying pagination + total_count = query.count() + + # Apply pagination - now ordering by the joined column + query = query.order_by(desc(subq.c.last_chat_at)) + query = query.offset(offset).limit(limit) + + # Execute query + results = query.all() + + # Process results to include the chat timing data + users = [] + for result in results: + end_user = result[0] + end_user.last_chat_at = result[1] + end_user.first_chat_at = result[2] + end_user.total_messages = result[3] if result[3] is not None else 0 + end_user.active_days = result[4] if result[4] is not None else 0 + + # Convert to dictionary for JSON serialization + end_user_dict = { + 'id': end_user.id, + 'email': end_user.email, + 'first_chat_at': end_user.first_chat_at, + 'last_chat_at': end_user.last_chat_at, + 'total_messages': end_user.total_messages, + 'active_days': end_user.active_days, + 'health_status': end_user.health_status, + 'topics': end_user.topics, + 'summary': end_user.summary, + 'major': end_user.major, + } + + users.append(end_user_dict) + + # Format and return results + return {'total': total_count, 'users': users} + @staticmethod def get_user_profile(end_user_id: str) -> Dict[str, Any]: """ @@ -27,20 +135,11 @@ class EndUserService: # Map numeric gender to string representation gender_map = {0: "unknown", 1: "male", 2: "female"} - # Get major from extra_profile if it exists - major = None - if end_user.extra_profile and 'major' in end_user.extra_profile: - major = end_user.extra_profile.get('major') - - # Get email from Account table - account = db.session.query(Account).filter(Account.id == end_user_id).first() - email = account.email if account else None - return { "username": end_user.name, "gender": gender_map.get(end_user.gender, "unknown"), - "major": major, - "email": email, + "major": end_user.major, + "email": end_user.email, } @staticmethod