finish get user list

pull/21891/head
ytqh 1 year ago
parent 02589846f5
commit 5c04969714

@ -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 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): class StudentList(Resource):
def get(self): @validate_admin_token_and_extract_info
"""Get student list with filters. def get(self, app_model: App):
"""Get all end_user list related with the app_model with filters with pagination.
--- ---
tags: tags:
- admin/students - admin/students
@ -14,32 +18,27 @@ class StudentList(Resource):
security: security:
- ApiKeyAuth: [] - ApiKeyAuth: []
parameters: parameters:
- name: risk_level - name: health_status
in: query
type: string
enum: [high, medium, low]
description: Filter by risk level
- name: last_chat_after
in: query in: query
type: string type: string
format: date enum: [normal, potential, critical]
description: Filter by last conversation date description: Filter by health status
- name: topics - name: begin_date
in: query in: query
type: array type: integer
items: format: date-time as integer timestamp
type: string description: Filter by begin date
description: Filter by conversation topics - name: end_date
- name: is_anonymous
in: query in: query
type: boolean type: integer
description: Filter anonymous users format: date-time as integer timestamp
description: Filter by end date
- name: page - name: page
in: query in: query
type: integer type: integer
default: 1 default: 1
description: Page number description: Page number
- name: per_page - name: limit
in: query in: query
type: integer type: integer
default: 20 default: 20
@ -68,7 +67,7 @@ class StudentList(Resource):
last_chat_at: last_chat_at:
type: string type: string
format: date-time format: date-time
total_conversations: total_messages:
type: integer type: integer
active_days: active_days:
type: integer type: integer
@ -76,18 +75,71 @@ class StudentList(Resource):
type: array type: array
items: items:
type: string type: string
risk_level: health_status:
type: string
enum: [normal, potential, critical]
topics:
type: array
items:
type: string
summary:
type: string
major:
type: string type: string
enum: [high, medium, low]
401: 401:
description: Invalid or missing API key description: Invalid or missing API key
400: 400:
description: Invalid filter parameters 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): 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. """Get student's conversation history.
--- ---
tags: tags:
@ -150,8 +202,10 @@ class StudentConversation(Resource):
""" """
pass pass
class StudentAnalysis(Resource): 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. """Get AI analysis and intervention suggestions.
--- ---
tags: tags:
@ -191,8 +245,10 @@ class StudentAnalysis(Resource):
""" """
pass pass
class StudentStatus(Resource): 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. """Update student follow-up status.
--- ---
tags: tags:
@ -244,7 +300,8 @@ class StudentStatus(Resource):
""" """
pass pass
def get(self, student_id): @validate_admin_token_and_extract_info
def get(self, app_model: App):
"""Get student follow-up status history. """Get student follow-up status history.
--- ---
tags: tags:
@ -289,8 +346,10 @@ class StudentStatus(Resource):
""" """
pass pass
class StudentNote(Resource): 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. """Update student follow-up note.
--- ---
tags: tags:
@ -338,7 +397,8 @@ class StudentNote(Resource):
""" """
pass pass
def get(self, student_id): @validate_admin_token_and_extract_info
def get(self, app_model: App):
"""Get student follow-up note history. """Get student follow-up note history.
--- ---
tags: tags:
@ -381,9 +441,9 @@ class StudentNote(Resource):
""" """
pass pass
api.add_resource(StudentList, '/students') api.add_resource(StudentList, '/students')
api.add_resource(StudentConversation, '/students/<string:student_id>/conversation') api.add_resource(StudentConversation, '/students/<string:student_id>/conversation')
api.add_resource(StudentAnalysis, '/students/<string:student_id>/analysis') api.add_resource(StudentAnalysis, '/students/<string:student_id>/analysis')
api.add_resource(StudentStatus, '/students/<string:student_id>/status') api.add_resource(StudentStatus, '/students/<string:student_id>/status')
api.add_resource(StudentNote, '/students/<string:student_id>/note') api.add_resource(StudentNote, '/students/<string:student_id>/note')

@ -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)

@ -11,7 +11,7 @@ from flask_login import user_logged_in # type: ignore
from flask_restful import Resource # type: ignore from flask_restful import Resource # type: ignore
from libs.login import _get_user from libs.login import _get_user
from libs.passport import PassportService 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 models.model import ApiToken, App, EndUser
from pydantic import BaseModel # type: ignore from pydantic import BaseModel # type: ignore
from services.account_service import AccountService 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") raise Unauthorized("Invalid token: user not found")
if account.status != AccountStatus.ACTIVE: if account.status != AccountStatus.ACTIVE:
raise Unauthorized("Invalid token: account is not 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") app_id = request.headers.get("X-App-Id")
if not app_id: if not app_id:

@ -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 ###

@ -140,7 +140,7 @@ class App(db.Model): # type: ignore[name-defined]
return False return False
if not app_model_config.agent_mode: if not app_model_config.agent_mode:
return False 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", "" "strategy", ""
) in {"function_call", "react"}: ) in {"function_call", "react"}:
self.mode = AppMode.AGENT_CHAT.value 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")) is_anonymous = db.Column(db.Boolean, nullable=False, server_default=db.text("true"))
session_id: Mapped[str] = mapped_column() session_id: Mapped[str] = mapped_column()
gender = db.Column(db.Integer, nullable=False, server_default=db.text("0")) # 0: unknown, 1: male, 2: female 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 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()) 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()) 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] class Site(db.Model): # type: ignore[name-defined]
__tablename__ = "sites" __tablename__ = "sites"

@ -1,12 +1,120 @@
from typing import Any, Dict, Optional, Tuple from typing import Any, Dict, Optional, Tuple
from extensions.ext_database import db from extensions.ext_database import db
from models.account import Account from models.account import Account, TenantAccountJoin
from models.model import EndUser from models.model import App, Conversation, EndUser, Message
from services.account_service import AccountService from sqlalchemy import and_, desc, func
class EndUserService: 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 @staticmethod
def get_user_profile(end_user_id: str) -> Dict[str, Any]: def get_user_profile(end_user_id: str) -> Dict[str, Any]:
""" """
@ -27,20 +135,11 @@ class EndUserService:
# Map numeric gender to string representation # Map numeric gender to string representation
gender_map = {0: "unknown", 1: "male", 2: "female"} 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 { return {
"username": end_user.name, "username": end_user.name,
"gender": gender_map.get(end_user.gender, "unknown"), "gender": gender_map.get(end_user.gender, "unknown"),
"major": major, "major": end_user.major,
"email": email, "email": end_user.email,
} }
@staticmethod @staticmethod

Loading…
Cancel
Save