From df5411e44cff1bfde626c2a32be8e432dc0fc2fb Mon Sep 17 00:00:00 2001 From: beabigegg Date: Thu, 13 Nov 2025 08:18:15 +0800 Subject: [PATCH] OK --- .dockerignore | 66 +++++ Dockerfile | 50 ++++ Dockerfile.redis | 17 ++ README.md | 264 ++++++++++++------ USER_MANUAL.md | 60 ---- .../action_item_routes.cpython-312.pyc | Bin 6164 -> 0 bytes __pycache__/ai_routes.cpython-312.pyc | Bin 2190 -> 0 bytes __pycache__/api_routes.cpython-312.pyc | Bin 24408 -> 0 bytes __pycache__/app.cpython-312.pyc | Bin 6063 -> 0 bytes __pycache__/celery_app.cpython-312.pyc | Bin 453 -> 0 bytes __pycache__/celery_worker.cpython-312.pyc | Bin 529 -> 0 bytes __pycache__/models.cpython-312.pyc | Bin 6864 -> 0 bytes __pycache__/tasks.cpython-312.pyc | Bin 21906 -> 0 bytes action_item_routes.py | 4 +- ai_routes.py | 4 +- api_routes.py | 205 ++------------ app.py | 46 ++- auth_routes.py | 249 +++++++++++++++++ celery_worker.py | 7 + docker-compose.yml | 131 +++++++++ frontend/.dockerignore | 39 +++ frontend/src/App.jsx | 1 + frontend/src/contexts/AuthContext.jsx | 72 ++--- frontend/src/pages/DashboardPage.jsx | 6 +- frontend/src/pages/LoginPage.jsx | 63 +---- frontend/vite.config.js | 2 +- migrations/__pycache__/env.cpython-312.pyc | Bin 4370 -> 0 bytes ...ation_with_users_meetings_.cpython-312.pyc | Bin 39764 -> 0 bytes ...t_user_centric_status_and_.cpython-312.pyc | Bin 1727 -> 0 bytes ...d_result_fields_to_meeting.cpython-312.pyc | Bin 2713 -> 0 bytes models.py | 6 +- nginx/Dockerfile | 10 + nginx/nginx.conf | 76 +++++ requirements.txt | 45 +-- .../__pycache__/dify_client.cpython-312.pyc | Bin 4539 -> 0 bytes tasks.py | 51 +++- utils/__init__.py | 1 + utils/ldap_utils.py | 133 +++++++++ 38 files changed, 1163 insertions(+), 445 deletions(-) create mode 100644 .dockerignore create mode 100644 Dockerfile create mode 100644 Dockerfile.redis delete mode 100644 USER_MANUAL.md delete mode 100644 __pycache__/action_item_routes.cpython-312.pyc delete mode 100644 __pycache__/ai_routes.cpython-312.pyc delete mode 100644 __pycache__/api_routes.cpython-312.pyc delete mode 100644 __pycache__/app.cpython-312.pyc delete mode 100644 __pycache__/celery_app.cpython-312.pyc delete mode 100644 __pycache__/celery_worker.cpython-312.pyc delete mode 100644 __pycache__/models.cpython-312.pyc delete mode 100644 __pycache__/tasks.cpython-312.pyc create mode 100644 auth_routes.py create mode 100644 docker-compose.yml create mode 100644 frontend/.dockerignore delete mode 100644 migrations/__pycache__/env.cpython-312.pyc delete mode 100644 migrations/versions/__pycache__/3b11caf37983_initial_migration_with_users_meetings_.cpython-312.pyc delete mode 100644 migrations/versions/__pycache__/919aff0aa44b_implement_user_centric_status_and_.cpython-312.pyc delete mode 100644 migrations/versions/__pycache__/ac069534da31_add_status_and_result_fields_to_meeting.cpython-312.pyc create mode 100644 nginx/Dockerfile create mode 100644 nginx/nginx.conf delete mode 100644 services/__pycache__/dify_client.cpython-312.pyc create mode 100644 utils/__init__.py create mode 100644 utils/ldap_utils.py diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..f64acdd --- /dev/null +++ b/.dockerignore @@ -0,0 +1,66 @@ +# Git +.git +.gitignore + +# Virtual environment +venv/ +env/ +.env.local +.env.development +.env.production + +# Python cache +__pycache__/ +*.pyc +*.pyo +*.pyd +.Python +*.egg-info/ +dist/ +build/ + +# IDE +.vscode/ +.idea/ +*.swp +*.swo + +# Logs +*.log +logs/ + +# OS +.DS_Store +Thumbs.db + +# Documentation +*.md +docs/ + +# Test files +tests/ +.pytest_cache/ + +# Coverage +.coverage +htmlcov/ + +# Node modules (for frontend) +frontend/node_modules/ +frontend/.npm +frontend/.next/ +frontend/out/ +frontend/dist/ + +# Uploads (will be mounted as volume) +uploads/* +!uploads/.gitkeep + +# Docker +Dockerfile* +docker-compose* +.dockerignore + +# Database +*.db +*.sqlite3 \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..0a2b699 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,50 @@ +FROM node:20-alpine AS frontend-builder + +# Build frontend +WORKDIR /app/frontend +COPY frontend/package*.json ./ +RUN npm ci + +COPY frontend/ ./ +# Build for production with relative API paths +RUN npm run build + +# Main container with Python and built frontend +FROM python:3.11-slim + +WORKDIR /app + +# Install system dependencies +RUN apt-get update && apt-get install -y \ + build-essential \ + curl \ + ffmpeg \ + && rm -rf /var/lib/apt/lists/* + +# Copy and install Python dependencies +COPY requirements.txt ./ +RUN pip install --no-cache-dir -r requirements.txt + +# Copy backend application +COPY . ./ + +# Copy built frontend from builder stage +COPY --from=frontend-builder /app/frontend/dist ./frontend/dist + +# Create necessary directories +RUN mkdir -p uploads + +# Set environment variables +ENV PYTHONPATH=/app +ENV FLASK_APP=app.py +ENV FLASK_ENV=production + +# Expose single port +EXPOSE 12015 + +# Health check +HEALTHCHECK --interval=30s --timeout=10s --retries=3 --start-period=40s \ + CMD curl -f http://localhost:12015/api/health || exit 1 + +# Run with Gunicorn for production +CMD ["gunicorn", "--bind", "0.0.0.0:12015", "--worker-class", "gthread", "--workers", "4", "--threads", "8", "--timeout", "120", "--keep-alive", "10", "--max-requests", "2000", "--max-requests-jitter", "200", "--forwarded-allow-ips", "*", "--access-logfile", "-", "app:app"] \ No newline at end of file diff --git a/Dockerfile.redis b/Dockerfile.redis new file mode 100644 index 0000000..2cfc800 --- /dev/null +++ b/Dockerfile.redis @@ -0,0 +1,17 @@ +# Redis for AI Meeting Assistant +FROM redis:7-alpine + +# Set container labels for identification +LABEL application="ai-meeting-assistant" +LABEL component="redis" +LABEL version="v2.1" +LABEL maintainer="PANJIT IT Team" + +# Copy custom redis configuration if needed +# COPY redis.conf /usr/local/etc/redis/redis.conf + +# Expose the default Redis port +EXPOSE 6379 + +# Use the default Redis entrypoint +# CMD ["redis-server", "/usr/local/etc/redis/redis.conf"] \ No newline at end of file diff --git a/README.md b/README.md index 4583764..9e7f689 100644 --- a/README.md +++ b/README.md @@ -1,106 +1,212 @@ -# AI Meeting Assistant +# AI Meeting Assistant V2.1 -An intelligent meeting assistant designed to streamline your workflow by transcribing, summarizing, and managing action items from your meetings. This full-stack application leverages a Python Flask backend for robust API services and a React frontend for a dynamic user experience. +一個智能會議助手系統,旨在通過轉錄、摘要和管理您會議中的行動項目來簡化您的工作流程。此全棧應用程式使用Python Flask後端提供強大的API服務,React前端提供動態用戶體驗。 -## Key Features +## 🔑 主要功能 -- **User Authentication**: Secure login and registration system with role-based access control (Admin, User). -- **Meeting Management**: Create, view, and manage meetings. Upload transcripts or generate them from audio. -- **AI-Powered Summary**: Automatically generate concise summaries from lengthy meeting transcripts. -- **Action Item Tracking**: Automatically preview and batch-create action items from summaries. Manually add, edit, and delete action items with assigned owners and due dates. -- **Permission Control**: Granular permissions for editing and deleting meetings and action items based on user roles (Admin, Meeting Owner, Action Item Owner). -- **File Processing Tools**: Independent tools for audio extraction, transcription, and text translation. +- **LDAP/AD 認證**: 整合企業Active Directory進行安全登入,支援本地備用認證 +- **用戶管理**: 基於角色的訪問控制(管理員、用戶),管理員可刪除用戶帳號 +- **會議管理**: 創建、查看和管理會議,上傳轉錄或從音頻生成轉錄 +- **AI智能摘要**: 從冗長的會議轉錄自動生成簡潔摘要 +- **行動項目追蹤**: 自動預覽並批量創建摘要中的行動項目,手動添加、編輯和刪除行動項目並分配負責人和截止日期 +- **權限控制**: 基於用戶角色(管理員、會議所有者、行動項目所有者)的精細權限管理 +- **檔案處理工具**: 獨立的音頻提取、轉錄和文本翻譯工具 -## Tech Stack +## 🏗️ 技術棧 -**Backend:** -- **Framework**: Flask -- **Database**: SQLAlchemy with Flask-Migrate for schema migrations. -- **Authentication**: Flask-JWT-Extended for token-based security. -- **Async Tasks**: Celery with Redis/RabbitMQ for handling long-running AI tasks. -- **API**: RESTful API design. +**後端:** +- **框架**: Flask + Gunicorn +- **資料庫**: MySQL (生產環境) + SQLAlchemy ORM +- **認證**: Flask-JWT-Extended + LDAP整合 +- **異步任務**: Celery + Redis +- **API**: RESTful API設計 -**Frontend:** -- **Framework**: React.js -- **UI Library**: Material-UI (MUI) -- **Tooling**: Vite -- **API Communication**: Axios +**前端:** +- **框架**: React.js +- **UI庫**: Material-UI (MUI) +- **構建工具**: Vite +- **API通訊**: Axios -## Prerequisites +**部署:** +- **容器化**: Docker + Docker Compose +- **服務編排**: Redis, Backend, Celery Worker, Celery Flower, Frontend +- **生產就緒**: 包含健康檢查和資源限制 -- Python 3.10+ -- Node.js 20.x+ -- A message broker for Celery (e.g., Redis or RabbitMQ) +## 📋 系統需求 -## Installation & Setup +- Docker Desktop (Windows/macOS) 或 Docker Engine (Linux) +- Docker Compose V2 +- 至少4GB可用記憶體 +- 企業Active Directory (LDAP) 服務器訪問權限 -### 1. Backend Setup +## 🚀 快速部署 + +### 方法一:一鍵部署(推薦) -Clone the repository: ```bash +# 克隆專案 git clone -cd AI_meeting_assistant_-_V2.1 +cd AI_meeting_assistant-V2.1 + +# 啟動所有服務(強制重建以確保使用最新代碼) +docker-compose up -d --build + +# 檢查服務狀態 +docker-compose ps + +# 停止服務 +docker-compose down + +# 查看日誌 +docker-compose logs -f ``` -Create a virtual environment and install dependencies: +### 方法二:開發環境設置 + +如需自定義配置或開發調試,請參考下方的詳細設置說明。 + +## 🔧 詳細配置 + +### 環境變數配置 + +主要配置已在`docker-compose.yml`中設定,如需修改: + +```yaml +# 資料庫配置 +DATABASE_URL: mysql+pymysql://username:password@host:port/database + +# LDAP配置 +LDAP_SERVER: your-domain.com +LDAP_PORT: 389 +LDAP_BIND_USER_DN: CN=LdapBind,CN=Users,DC=DOMAIN,DC=COM +LDAP_BIND_USER_PASSWORD: your-bind-password +LDAP_SEARCH_BASE: OU=Users,DC=domain,DC=com +LDAP_USER_LOGIN_ATTR: userPrincipalName + +# JWT配置 +JWT_SECRET_KEY: your-super-secret-key + +# AI服務配置(Dify API) +DIFY_API_BASE_URL: https://your-dify-server.com/v1 +DIFY_STT_API_KEY: app-xxxxxxxxxx +DIFY_TRANSLATOR_API_KEY: app-xxxxxxxxxx +DIFY_SUMMARIZER_API_KEY: app-xxxxxxxxxx +DIFY_ACTION_EXTRACTOR_API_KEY: app-xxxxxxxxxx +``` + +### 服務端口 + +- **前端**: http://localhost:12015 +- **後端API**: http://localhost:5000 +- **Celery Flower監控**: http://localhost:5555 +- **Redis**: localhost:6379 + +## 👥 用戶角色與權限 + +### 管理員權限 +- 查看所有用戶列表 +- 刪除用戶帳號(除自己外) +- 管理所有會議和行動項目 +- 修改任何會議狀態 + +### 一般用戶權限 +- 管理自己創建的會議 +- 編輯分配給自己的行動項目 +- 查看有權限的會議內容 + +### 預設管理員 +- 系統預設將 `ymirliu@panjit.com.tw` 設為管理員角色 +- 其他AD帳號預設為一般用戶角色 + +## 🔧 維護與監控 + +### 查看服務日誌 ```bash -# For Windows -python -m venv venv -venv\Scripts\activate +# 查看所有服務日誌 +docker-compose logs -f -# For macOS/Linux -python3 -m venv venv -source venv/bin/activate +# 查看特定服務日誌 +docker-compose logs -f backend +docker-compose logs -f celery-worker +``` +### 健康檢查 +系統包含自動健康檢查: +- Backend: HTTP健康檢查 +- Frontend: HTTP健康檢查 +- Celery: 程序狀態監控 + +### 備份與恢復 +```bash +# 資料庫備份(需要mysql客戶端) +mysqldump -h mysql.theaken.com -P 33306 -u A060 -p db_A060 > backup.sql + +# 檔案備份 +docker-compose exec backend tar -czf /app/uploads/backup.tar.gz /app/uploads +``` + +## 🛠️ 開發指南 + +### 本地開發設置 + +1. **後端開發**: +```bash +# 安裝依賴 pip install -r requirements.txt -``` -Create a `.env` file by copying `.env.example` (if provided) or creating a new one. Configure the following: -``` -FLASK_APP=app.py -SECRET_KEY=your_super_secret_key -SQLALCHEMY_DATABASE_URI=sqlite:///meetings.db # Or your preferred database connection string -CELERY_BROKER_URL=redis://localhost:6379/0 -CELERY_RESULT_BACKEND=redis://localhost:6379/0 -``` +# 啟動Flask開發服務器 +flask run --port 5000 -Initialize and apply database migrations: -```bash -flask db init # Only if you don't have a 'migrations' folder -flask db migrate -m "Initial migration" -flask db upgrade -``` - -### 2. Frontend Setup - -Navigate to the frontend directory and install dependencies: -```bash -cd frontend -npm install -``` - -## Running the Application - -The application requires three separate processes to be running: the Flask server, the Celery worker, and the Vite frontend server. - -**1. Start the Flask Backend Server:** -```bash -# From the project root directory -flask run -``` -The API server will be running on `http://127.0.0.1:5000`. - -**2. Start the Celery Worker:** -Open a new terminal, activate the virtual environment, and run: -```bash -# From the project root directory +# 啟動Celery Worker celery -A celery_worker.celery worker --loglevel=info ``` -**3. Start the React Frontend Server:** -Open a third terminal and run: +2. **前端開發**: ```bash -# From the 'frontend' directory +cd frontend +npm install npm run dev ``` -The frontend application will be available at `http://localhost:5173`. Open this URL in your browser. + +### 資料庫遷移 +```bash +# 建立新遷移 +docker-compose exec backend flask db migrate -m "Description" + +# 應用遷移 +docker-compose exec backend flask db upgrade +``` + +## 🐛 疑難排解 + +### 常見問題 + +**1. LDAP認證失敗** +- 檢查LDAP服務器連接性 +- 驗證綁定用戶憑證 +- 確認搜索基準DN正確 + +**2. Celery任務無響應** +- 檢查Redis服務狀態 +- 重啟Celery Worker: `docker-compose restart celery-worker` +- 查看Worker日誌: `docker-compose logs celery-worker` + +**3. 前端無法連接後端** +- 確認後端服務運行在5000端口 +- 檢查防火牆設置 +- 驗證API base URL配置 + +### 獲取支援 +- 查看服務日誌進行初步診斷 +- 檢查系統資源使用情況 +- 聯繫IT管理員協助LDAP配置問題 + +## 📄 授權 + +此專案為企業內部使用,請遵守公司軟體使用政策。 + +--- + +**版本**: V2.1 +**最後更新**: 2025-09-18 +**維護團隊**: PANJIT IT Team \ No newline at end of file diff --git a/USER_MANUAL.md b/USER_MANUAL.md deleted file mode 100644 index 9a9c51d..0000000 --- a/USER_MANUAL.md +++ /dev/null @@ -1,60 +0,0 @@ -# AI Meeting Assistant - User Manual - -Welcome to the AI Meeting Assistant! This guide will walk you through the main features of the application and how to use them effectively. - -## 1. Getting Started: Login and Registration - -- **Registration**: If you are a new user, click on the "Register" link on the login page. You will need to provide a unique username and a password to create your account. -- **Login**: Once you have an account, enter your username and password on the login page to access the application. - -## 2. The Dashboard - -After logging in, you will land on the **Dashboard**. This is your main hub for all meetings. - -- **Meeting List**: The dashboard displays a table of all meetings in the system. You can see the meeting's **Topic**, **Owner**, **Meeting Date**, **Status**, and the number of **Action Items**. -- **Sorting**: Click on the column headers (e.g., "Topic", "Meeting Date") to sort the list. -- **Filtering and Searching**: Use the search boxes at the top to find meetings by topic or owner, or filter the list by status. -- **Create a New Meeting**: Click the "New Meeting" button to open a dialog where you can enter a topic and date for a new meeting. Upon creation, you will be taken directly to the Meeting Detail page. -- **View Details**: Click the "View Details" button on any meeting row to navigate to its detail page. -- **Delete a Meeting**: If you are the meeting's creator or an administrator, you will see a delete icon (trash can) to permanently remove the meeting and all its associated data. - -## 3. Meeting Detail Page - -This page is where you'll do most of your work. It's divided into three main sections: Transcript, AI Tools, and Action Items. - -### 3.1. Transcript - -- **View**: This section shows the full transcript of the meeting. -- **Edit**: If you are the meeting owner or an admin, you can click "Edit Transcript" to add, paste, or modify the text content. Click "Save Transcript" to save your changes. - -### 3.2. AI Tools - -This section allows you to leverage AI to process your transcript. - -- **Generate Summary**: - 1. Ensure a transcript has been added. - 2. Click the **"Generate Summary"** button. - 3. A "Generating..." message will appear. The process may take some time depending on the length of the text. - 4. Once complete, a concise summary will appear in the "Summary" box. -- **Edit Summary**: You can also manually edit the generated summary by clicking the "Edit Summary" button. -- **Preview Action Items**: - 1. After a summary or transcript is available, click the **"Preview Action Items"** button. - 2. The AI will analyze the text and display a list of suggested action items in a table. - 3. Review the items. If they are accurate, click **"Save All to List"** to add them to the official "Action Items" list below. - -### 3.3. Action Items - -This is the final list of tasks and to-dos from the meeting. - -- **Add Manually**: Click the "Add Manually" button to open a form where you can create a new action item, assign an owner, and set a due date. -- **Edit an Item**: If you are an Admin, the Meeting Owner, or the assigned owner of an action item, an edit icon (pencil) will appear. Click it to modify the item's details in-line. Click the save icon to confirm. -- **Delete an Item**: If you are an Admin or the Meeting Owner, a delete icon (trash can) will appear, allowing you to remove the action item. -- **Attachments**: You can upload a file attachment when creating or editing an action item. A download icon will appear if an attachment exists. - -## 4. Processing Tools Page - -Accessible from the main navigation, this page provides standalone utilities for file processing. - -1. **Extract Audio**: Upload a video file (e.g., MP4) to extract its audio track into a WAV file, which you can then download. -2. **Transcribe Audio**: Upload an audio file (e.g., WAV, MP3) to generate a text transcript. You can copy the text or download it as a `.txt` file. -3. **Translate Text**: Paste text or upload a `.txt` file, select a target language, and the tool will provide a translation. diff --git a/__pycache__/action_item_routes.cpython-312.pyc b/__pycache__/action_item_routes.cpython-312.pyc deleted file mode 100644 index 7d092ad10119ea27abd22f125a4fa584c083c22c..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 6164 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 diff --git a/__pycache__/api_routes.cpython-312.pyc b/__pycache__/api_routes.cpython-312.pyc deleted file mode 100644 index 2e8c7341bb7773c2ff97c17e46052f023034aaf7..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 24408 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 diff --git a/__pycache__/app.cpython-312.pyc b/__pycache__/app.cpython-312.pyc deleted file mode 100644 index f92d23f0ae8e276736d66795dd68106014da91f4..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 6063 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 A=Kufz diff --git a/__pycache__/tasks.cpython-312.pyc b/__pycache__/tasks.cpython-312.pyc deleted file mode 100644 index 9c6deca873de98be4c3ced487f005aaed36047b8..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 21906 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/action-items/batch") -@jwt_required() +# @jwt_required() # 已禁用認證 def batch_create_action_items(meeting_id: int): """ 批次建立代辦(AI 預覽 → 一鍵儲存) diff --git a/ai_routes.py b/ai_routes.py index 792f060..1ff6146 100644 --- a/ai_routes.py +++ b/ai_routes.py @@ -6,7 +6,7 @@ from services.dify_client import translate_text as _translate_text, summarize_te ai_bp = Blueprint("ai_bp", __name__, url_prefix="/api") @ai_bp.post("/translate/text") -@jwt_required() +# @jwt_required() # 已禁用認證 def translate_text_api(): data = request.get_json(force=True) or {} text = (data.get("text") or "").strip() @@ -18,7 +18,7 @@ def translate_text_api(): return jsonify({"translated": translated}) @ai_bp.post("/summarize/text") -@jwt_required() +# @jwt_required() # 已禁用認證 def summarize_text_api(): data = request.get_json(force=True) or {} text = (data.get("text") or "").strip() diff --git a/api_routes.py b/api_routes.py index 95cd975..dbbfba6 100644 --- a/api_routes.py +++ b/api_routes.py @@ -7,9 +7,9 @@ from datetime import datetime from models import db, User, Meeting, ActionItem from tasks import ( - celery, - extract_audio_task, - transcribe_audio_task, + celery, + extract_audio_task, + transcribe_audio_task, translate_text_task, summarize_text_task, preview_action_items_task @@ -32,40 +32,6 @@ def save_uploaded_file(upload_folder, file_key='file'): return file_path, None return None, (jsonify({'error': 'Unknown file error'}), 500) -# --- User Authentication & Admin Routes --- -@api_bp.route('/login', methods=['POST']) -def login(): - data = request.get_json() - user = User.query.filter_by(username=data.get('username')).first() - if user and user.check_password(data.get('password')): - access_token = create_access_token(identity=str(user.id), additional_claims={'role': user.role, 'username': user.username}) - return jsonify(access_token=access_token) - return jsonify({"msg": "Bad username or password"}), 401 - -@api_bp.route('/register', methods=['POST']) -def register(): - """Public endpoint for new user registration.""" - data = request.get_json() - username = data.get('username') - password = data.get('password') - - if not username or not password: - return jsonify({"error": "Username and password are required"}), 400 - - if User.query.filter_by(username=username).first(): - return jsonify({"error": "Username already exists"}), 409 # HTTP 409 Conflict - - try: - new_user = User(username=username, role='user') # Default role is 'user' - new_user.set_password(password) - db.session.add(new_user) - db.session.commit() - return jsonify({"message": "User created successfully"}), 201 # HTTP 201 Created - except Exception as e: - db.session.rollback() - current_app.logger.error(f"Error creating user: {e}") - return jsonify({"error": "An internal error occurred"}), 500 - @api_bp.route('/admin/users', methods=['GET']) @jwt_required() def get_all_users(): @@ -77,36 +43,15 @@ def get_all_users(): @api_bp.route('/users', methods=['GET']) @jwt_required() def get_all_users_for_dropdown(): - """A public endpoint for all logged-in users to fetch a list of users for UI selectors.""" users = User.query.all() return jsonify([user.to_dict() for user in users]) -@api_bp.route('/admin/users', methods=['POST']) -@jwt_required() -def create_user(): - if get_jwt().get('role') != 'admin': - return jsonify({"msg": "Administration rights required"}), 403 - data = request.get_json() - username = data.get('username') - password = data.get('password') - role = data.get('role', 'user') - if not username or not password: - return jsonify({"error": "Username and password are required"}), 400 - if User.query.filter_by(username=username).first(): - return jsonify({"error": "Username already exists"}), 409 - - new_user = User(username=username, role=role) - new_user.set_password(password) - db.session.add(new_user) - db.session.commit() - return jsonify(new_user.to_dict()), 201 - @api_bp.route('/admin/users/', methods=['DELETE']) @jwt_required() def delete_user(user_id): if get_jwt().get('role') != 'admin': return jsonify({"msg": "Administration rights required"}), 403 - + # Prevent admin from deleting themselves if str(user_id) == get_jwt_identity(): return jsonify({"error": "Admin users cannot delete their own account"}), 400 @@ -116,44 +61,19 @@ def delete_user(user_id): return jsonify({"error": "User not found"}), 404 try: - # Disassociate meetings created by this user + # Disassociate meetings created by this user (set to None instead of deleting meetings) Meeting.query.filter_by(created_by_id=user_id).update({"created_by_id": None}) - + # Disassociate action items owned by this user ActionItem.query.filter_by(owner_id=user_id).update({"owner_id": None}) db.session.delete(user_to_delete) db.session.commit() - return jsonify({"msg": f"User {user_to_delete.username} has been deleted."}), 200 + return jsonify({"msg": f"User {user_to_delete.display_name or user_to_delete.username} has been deleted."}), 200 except Exception as e: db.session.rollback() return jsonify({"error": f"An error occurred: {str(e)}"}), 500 -@api_bp.route('/admin/users//password', methods=['PUT']) -@jwt_required() -def update_user_password(user_id): - if get_jwt().get('role') != 'admin': - return jsonify({"msg": "Administration rights required"}), 403 - - user_to_update = User.query.get(user_id) - if not user_to_update: - return jsonify({"error": "User not found"}), 404 - - data = request.get_json() - password = data.get('password') - if not password: - return jsonify({"error": "Password is required"}), 400 - - try: - user_to_update.set_password(password) - db.session.commit() - return jsonify({"msg": f"Password for user {user_to_update.username} has been updated."}), 200 - except Exception as e: - db.session.rollback() - current_app.logger.error(f"Error updating password for user {user_id}: {e}") - return jsonify({"error": "An internal error occurred while updating the password"}), 500 - -# --- Meeting Management Routes --- @api_bp.route('/meetings', methods=['GET', 'POST']) @jwt_required() def handle_meetings(): @@ -166,11 +86,11 @@ def handle_meetings(): try: meeting_date = datetime.fromisoformat(meeting_date_str).date() new_meeting = Meeting( - topic=topic, - meeting_date=meeting_date, + topic=topic, + meeting_date=meeting_date, created_by_id=get_jwt_identity(), - created_at=datetime.utcnow(), # Explicitly set creation time in UTC - status='In Progress' # Set default status to 'In Progress' + created_at=datetime.utcnow(), + status='In Progress' ) db.session.add(new_meeting) db.session.commit() @@ -179,7 +99,7 @@ def handle_meetings(): db.session.rollback() current_app.logger.error(f"Failed to create meeting: {e}") return jsonify({'error': 'Failed to create meeting due to a database error.'}), 500 - + meetings = Meeting.query.order_by(Meeting.meeting_date.desc()).all() return jsonify([meeting.to_dict() for meeting in meetings]) @@ -187,16 +107,15 @@ def handle_meetings(): @jwt_required() def handle_meeting_detail(meeting_id): meeting = Meeting.query.get_or_404(meeting_id) - + if request.method == 'PUT': data = request.get_json() - # Only update fields that are present in the request if 'topic' in data: meeting.topic = data.get('topic') if 'status' in data: - # Security check: only admin or meeting creator can change the status current_user_id = get_jwt_identity() - is_admin = get_jwt().get('role') == 'admin' + current_user_role = get_jwt().get('role') + is_admin = current_user_role == 'admin' if not is_admin and str(meeting.created_by_id) != str(current_user_id): return jsonify({"msg": "Only the meeting creator or an admin can change the status."}), 403 meeting.status = data.get('status') @@ -206,23 +125,22 @@ def handle_meeting_detail(meeting_id): meeting.summary = data.get('summary') if data.get('meeting_date'): meeting.meeting_date = datetime.fromisoformat(data['meeting_date']).date() - + db.session.commit() - # Refresh the object to avoid session state issues before serialization db.session.refresh(meeting) return jsonify(meeting.to_dict()) if request.method == 'DELETE': current_user_id = get_jwt_identity() - is_admin = get_jwt().get('role') == 'admin' - + current_user_role = get_jwt().get('role') + is_admin = current_user_role == 'admin' + if not is_admin and str(meeting.created_by_id) != str(current_user_id): return jsonify({"msg": "Only the meeting creator or an admin can delete this meeting."}), 403 db.session.delete(meeting) db.session.commit() return jsonify({"msg": "Meeting and associated action items deleted"}), 200 - - # GET request + return jsonify(meeting.to_dict()) @api_bp.route('/meetings//summarize', methods=['POST']) @@ -238,14 +156,13 @@ def summarize_meeting(meeting_id): @jwt_required() def preview_actions(meeting_id): meeting = Meeting.query.get_or_404(meeting_id) - text_content = meeting.transcript # Always use the full transcript + text_content = meeting.transcript if not text_content: return jsonify({'error': 'Meeting has no transcript to analyze.'}), 400 - task = preview_action_items_task.delay(text_content) return jsonify({'task_id': task.id, 'status_url': f'/status/{task.id}'}), 202 -# --- Independent Tool Routes --- +# --- Tool Routes --- @api_bp.route('/tools/extract_audio', methods=['POST']) @jwt_required() def handle_extract_audio(): @@ -260,7 +177,6 @@ def handle_extract_audio(): def handle_transcribe_audio(): input_path, error = save_uploaded_file(current_app.config['UPLOAD_FOLDER']) if error: return error - # The 'language' parameter is no longer needed for the Dify-based task. task = transcribe_audio_task.delay(input_path) return jsonify({'task_id': task.id, 'status_url': f'/status/{task.id}'}), 202 @@ -275,74 +191,7 @@ def handle_translate_text(): task = translate_text_task.delay(text_content, target_language) return jsonify({'task_id': task.id, 'status_url': f'/status/{task.id}'}), 202 -# --- Action Item & Task Status Routes (largely unchanged) --- -@api_bp.route('/meetings//action_items', methods=['GET']) -@jwt_required() -def get_action_items_for_meeting(meeting_id): - action_items = ActionItem.query.filter_by(meeting_id=meeting_id).all() - return jsonify([item.to_dict() for item in action_items]) - -@api_bp.route('/action_items/', methods=['PUT', 'DELETE']) -@jwt_required() -def handle_action_item(item_id): - item = ActionItem.query.get_or_404(item_id) - current_user_id = get_jwt_identity() - current_user_role = get_jwt().get('role') - meeting_owner_id = str(item.meeting.created_by_id) - - is_admin = current_user_role == 'admin' - is_meeting_owner = str(current_user_id) == meeting_owner_id - is_action_owner = str(current_user_id) == str(item.owner_id) - - if request.method == 'PUT': - # Edit Permission: Admin, Meeting Owner, or Action Item Owner - if not (is_admin or is_meeting_owner or is_action_owner): - return jsonify({"msg": "You do not have permission to edit this item."}), 403 - - data = request.get_json() - item.item = data.get('item', item.item) - item.action = data.get('action', item.action) - item.status = data.get('status', item.status) - # Handle owner_id, allowing it to be set to null - if 'owner_id' in data: - item.owner_id = data.get('owner_id') if data.get('owner_id') else None - if data.get('due_date'): - item.due_date = datetime.fromisoformat(data['due_date']).date() if data['due_date'] else None - db.session.commit() - db.session.refresh(item) - return jsonify(item.to_dict()) - - elif request.method == 'DELETE': - # Delete Permission: Admin or Meeting Owner - if not (is_admin or is_meeting_owner): - return jsonify({"msg": "You do not have permission to delete this item."}), 403 - - db.session.delete(item) - db.session.commit() - return jsonify({'msg': 'Action item deleted'}), 200 - -@api_bp.route('/action_items//upload', methods=['POST']) -@jwt_required() -def upload_action_item_attachment(item_id): - item = ActionItem.query.get_or_404(item_id) - - # Basic permission check: only meeting creator or action item owner can upload - meeting_creator_id = item.meeting.created_by_id - current_user_id = get_jwt_identity() - - if str(current_user_id) != str(meeting_creator_id) and str(current_user_id) != str(item.owner_id): - return jsonify({"msg": "Permission denied"}), 403 - - file_path, error = save_uploaded_file(current_app.config['UPLOAD_FOLDER']) - if error: - return error - - # TODO: Consider deleting the old file if it exists - item.attachment_path = os.path.basename(file_path) - db.session.commit() - - return jsonify({'attachment_path': item.attachment_path}), 200 - +# --- Task Status Routes --- @api_bp.route('/status/') @jwt_required() def get_task_status(task_id): @@ -356,9 +205,15 @@ def get_task_status(task_id): @jwt_required() def stop_task(task_id): celery.control.revoke(task_id, terminate=True) - return jsonify({'status': 'revoked'}), 200 + return jsonify({'message': f'Task {task_id} has been stopped.'}), 200 @api_bp.route('/download/') @jwt_required() def download_file(filename): return send_from_directory(current_app.config['UPLOAD_FOLDER'], filename, as_attachment=True) + +@api_bp.route('/meetings//action_items', methods=['GET']) +@jwt_required() +def get_action_items_for_meeting(meeting_id): + action_items = ActionItem.query.filter_by(meeting_id=meeting_id).all() + return jsonify([item.to_dict() for item in action_items]) \ No newline at end of file diff --git a/app.py b/app.py index 3d2d2f0..c75ebf9 100644 --- a/app.py +++ b/app.py @@ -18,17 +18,31 @@ def create_app(): # --- Configuration --- app.config.from_mapping( SQLALCHEMY_DATABASE_URI=os.environ.get('DATABASE_URL'), - SQLALCHEMY_ENGINE_OPTIONS={'pool_recycle': 3600}, + SQLALCHEMY_ENGINE_OPTIONS={ + 'pool_recycle': 3600, + 'pool_size': 20, + 'max_overflow': 30, + 'pool_pre_ping': True, + 'pool_timeout': 30 + }, JWT_SECRET_KEY=os.environ.get('JWT_SECRET_KEY'), SQLALCHEMY_TRACK_MODIFICATIONS=False, - JWT_ACCESS_TOKEN_EXPIRES=timedelta(days=3), + JWT_ACCESS_TOKEN_EXPIRES=timedelta(days=2), CELERY_BROKER_URL=os.environ.get('CELERY_BROKER_URL', 'redis://localhost:6379/0'), CELERY_RESULT_BACKEND=os.environ.get('CELERY_RESULT_BACKEND', 'redis://localhost:6379/0'), DIFY_API_BASE_URL=os.environ.get("DIFY_API_BASE_URL"), DIFY_STT_API_KEY=os.environ.get("DIFY_STT_API_KEY"), DIFY_TRANSLATOR_API_KEY=os.environ.get("DIFY_TRANSLATOR_API_KEY"), DIFY_SUMMARIZER_API_KEY=os.environ.get("DIFY_SUMMARIZER_API_KEY"), - DIFY_ACTION_EXTRACTOR_API_KEY=os.environ.get("DIFY_ACTION_EXTRACTOR_API_KEY") + DIFY_ACTION_EXTRACTOR_API_KEY=os.environ.get("DIFY_ACTION_EXTRACTOR_API_KEY"), + # LDAP Configuration + LDAP_SERVER=os.environ.get('LDAP_SERVER', 'panjit.com.tw'), + LDAP_PORT=int(os.environ.get('LDAP_PORT', 389)), + LDAP_USE_SSL=os.environ.get('LDAP_USE_SSL', 'False').lower() == 'true', + LDAP_BIND_USER_DN=os.environ.get('LDAP_BIND_USER_DN', ''), + LDAP_BIND_USER_PASSWORD=os.environ.get('LDAP_BIND_USER_PASSWORD', ''), + LDAP_SEARCH_BASE=os.environ.get('LDAP_SEARCH_BASE', 'DC=panjit,DC=com,DC=tw'), + LDAP_USER_LOGIN_ATTR=os.environ.get('LDAP_USER_LOGIN_ATTR', 'userPrincipalName') ) project_root = os.path.dirname(os.path.abspath(__file__)) @@ -55,17 +69,37 @@ def create_app(): celery.Task = ContextTask # --- Import and Register Blueprints --- + from auth_routes import auth_bp from api_routes import api_bp from ai_routes import ai_bp from action_item_routes import action_bp + app.register_blueprint(auth_bp) app.register_blueprint(api_bp) app.register_blueprint(ai_bp) app.register_blueprint(action_bp) - # --- Root Route --- + # --- Static File Serving (for Single Container) --- + from flask import send_from_directory, send_file + + # Serve React build files @app.route('/') - def index(): - return "AI Meeting Assistant Backend is running." + def serve_frontend(): + try: + return send_file(os.path.join(app.root_path, 'frontend/dist/index.html')) + except: + return "AI Meeting Assistant is running. Frontend build not found." + + @app.route('/') + def serve_static(path): + # Try to serve static files first + try: + return send_from_directory(os.path.join(app.root_path, 'frontend/dist'), path) + except: + # If not found, serve index.html for SPA routing + try: + return send_file(os.path.join(app.root_path, 'frontend/dist/index.html')) + except: + return "File not found", 404 # --- CLI Commands --- @app.cli.command("create_admin") diff --git a/auth_routes.py b/auth_routes.py new file mode 100644 index 0000000..ce66764 --- /dev/null +++ b/auth_routes.py @@ -0,0 +1,249 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +Authentication Routes for AI Meeting Assistant with LDAP support + +Author: PANJIT IT Team +Created: 2024-09-18 +""" + +from flask import Blueprint, request, jsonify, current_app +from flask_jwt_extended import ( + create_access_token, create_refresh_token, + jwt_required, get_jwt_identity, get_jwt +) +from datetime import datetime, timedelta +from models import db, User + +auth_bp = Blueprint('auth', __name__, url_prefix='/api') + +@auth_bp.route('/login', methods=['POST']) +def login(): + """LDAP/AD Login with fallback to local authentication""" + try: + data = request.get_json() + username = data.get('username', '').strip() + password = data.get('password', '') + + if not username or not password: + return jsonify({'error': 'Username and password required'}), 400 + + # Try LDAP authentication first + user_info = None + try: + from utils.ldap_utils import authenticate_user + user_info = authenticate_user(username, password) + current_app.logger.info(f"LDAP authentication attempted for: {username}") + except Exception as e: + current_app.logger.error(f"LDAP authentication error: {str(e)}") + # Fall back to local authentication if LDAP fails + pass + + # If LDAP authentication succeeded + if user_info: + ad_account = user_info['ad_account'] + + # Get or create user in local database + user = User.query.filter_by(username=ad_account).first() + if not user: + # Create new user from LDAP info + # AD accounts default to 'user' role, only ymirliu@panjit.com.tw gets admin + is_admin = username.lower() == 'ymirliu@panjit.com.tw' + role = 'admin' if is_admin else 'user' + + # Create display name from LDAP data (username + display_name from AD) + display_name = f"{ad_account} {user_info.get('display_name', '')}" if user_info.get('display_name') else ad_account + + user = User( + username=ad_account, + display_name=display_name, + role=role + ) + # Set a placeholder password (not used for LDAP users) + user.set_password('ldap_user') + db.session.add(user) + db.session.commit() + current_app.logger.info(f"Created new LDAP user: {ad_account} ({display_name}) with role: {role}") + else: + # Update display name if available from LDAP + if user_info.get('display_name') and not user.display_name: + user.display_name = f"{ad_account} {user_info['display_name']}" + + # Update user role if it's ymirliu@panjit.com.tw + if username.lower() == 'ymirliu@panjit.com.tw' and user.role != 'admin': + user.role = 'admin' + current_app.logger.info(f"Updated user {ad_account} to admin role") + + # Update last login time + from datetime import datetime + user.last_login = datetime.utcnow() + db.session.commit() + + # Create tokens + access_token = create_access_token( + identity=str(user.id), + additional_claims={ + 'role': user.role, + 'username': user.username, + 'display_name': user_info.get('display_name', user.username), + 'email': user_info.get('email', ''), + 'auth_method': 'ldap' + } + ) + refresh_token = create_refresh_token(identity=str(user.id)) + + current_app.logger.info(f"Successful LDAP login for user: {ad_account}") + + return jsonify({ + 'access_token': access_token, + 'refresh_token': refresh_token, + 'user': { + 'id': user.id, + 'username': user.username, + 'role': user.role, + 'display_name': user_info.get('display_name', user.username), + 'email': user_info.get('email', ''), + 'auth_method': 'ldap' + } + }), 200 + + # Fall back to local database authentication + user = User.query.filter_by(username=username).first() + if user and user.check_password(password): + access_token = create_access_token( + identity=str(user.id), + additional_claims={ + 'role': user.role, + 'username': user.username, + 'auth_method': 'local' + } + ) + refresh_token = create_refresh_token(identity=str(user.id)) + + current_app.logger.info(f"Successful local login for user: {username}") + + return jsonify({ + 'access_token': access_token, + 'refresh_token': refresh_token, + 'user': { + 'id': user.id, + 'username': user.username, + 'role': user.role, + 'auth_method': 'local' + } + }), 200 + + # Authentication failed + current_app.logger.warning(f"Failed login attempt for user: {username}") + return jsonify({'error': 'Invalid credentials'}), 401 + + except Exception as e: + current_app.logger.error(f"Login error: {str(e)}") + return jsonify({'error': 'Authentication failed'}), 500 + +@auth_bp.route('/refresh', methods=['POST']) +@jwt_required(refresh=True) +def refresh(): + """Refresh access token""" + try: + identity = get_jwt_identity() + user = User.query.get(int(identity)) + + if not user: + return jsonify({'error': 'User not found'}), 404 + + access_token = create_access_token( + identity=identity, + additional_claims={ + 'role': user.role, + 'username': user.username + } + ) + + return jsonify({'access_token': access_token}), 200 + + except Exception as e: + current_app.logger.error(f"Token refresh error: {str(e)}") + return jsonify({'error': 'Token refresh failed'}), 500 + +@auth_bp.route('/logout', methods=['POST']) +@jwt_required() +def logout(): + """Logout (client should remove tokens)""" + try: + identity = get_jwt_identity() + current_app.logger.info(f"User logged out: {identity}") + + # In production, you might want to blacklist the token here + # For now, we'll rely on client-side token removal + + return jsonify({'message': 'Logged out successfully'}), 200 + + except Exception as e: + current_app.logger.error(f"Logout error: {str(e)}") + return jsonify({'error': 'Logout failed'}), 500 + +@auth_bp.route('/me', methods=['GET']) +@jwt_required() +def get_current_user(): + """Get current user information""" + try: + identity = get_jwt_identity() + claims = get_jwt() + user = User.query.get(int(identity)) + + if not user: + return jsonify({'error': 'User not found'}), 404 + + return jsonify({ + 'id': user.id, + 'username': user.username, + 'role': user.role, + 'display_name': claims.get('display_name', user.username), + 'email': claims.get('email', ''), + 'auth_method': claims.get('auth_method', 'local') + }), 200 + + except Exception as e: + current_app.logger.error(f"Get current user error: {str(e)}") + return jsonify({'error': 'Failed to get user information'}), 500 + +@auth_bp.route('/validate', methods=['GET']) +@jwt_required() +def validate_token(): + """Validate JWT token""" + try: + identity = get_jwt_identity() + claims = get_jwt() + + return jsonify({ + 'valid': True, + 'identity': identity, + 'username': claims.get('username'), + 'role': claims.get('role') + }), 200 + + except Exception as e: + current_app.logger.error(f"Token validation error: {str(e)}") + return jsonify({'valid': False}), 401 + +@auth_bp.route('/ldap-test', methods=['GET']) +@jwt_required() +def test_ldap(): + """Test LDAP connection (admin only)""" + try: + claims = get_jwt() + if claims.get('role') != 'admin': + return jsonify({'error': 'Admin access required'}), 403 + + from utils.ldap_utils import test_ldap_connection + result = test_ldap_connection() + + return jsonify({ + 'ldap_connection': 'success' if result else 'failed', + 'timestamp': datetime.utcnow().isoformat() + }), 200 + + except Exception as e: + current_app.logger.error(f"LDAP test error: {str(e)}") + return jsonify({'error': 'LDAP test failed'}), 500 \ No newline at end of file diff --git a/celery_worker.py b/celery_worker.py index 747359b..bc27b94 100644 --- a/celery_worker.py +++ b/celery_worker.py @@ -3,6 +3,13 @@ import eventlet eventlet.monkey_patch() +# Import basic modules only +import os +import sys + +# Add current directory to Python path +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + from dotenv import load_dotenv # Load environment variables BEFORE creating the app load_dotenv() diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..c50d36d --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,131 @@ +services: + # Redis for Celery broker and caching + redis: + image: panjit-ai-meeting-assistant:redis + build: + context: . + dockerfile: Dockerfile.redis + restart: unless-stopped + volumes: + - redis_data:/data + command: redis-server --appendonly yes --maxmemory 512mb --maxmemory-policy allkeys-lru + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 30s + timeout: 10s + retries: 3 + + # Main application (Backend + Frontend) + ai-meeting-app: + image: panjit-ai-meeting-assistant:main + build: + context: . + dockerfile: Dockerfile + restart: unless-stopped + # No external port; only Nginx exposes ports + environment: + - DATABASE_URL=mysql+pymysql://A060:WLeSCi0yhtc7@mysql.theaken.com:33306/db_A060 + - JWT_SECRET_KEY=your-super-secret-key-that-no-one-should-know + - CELERY_BROKER_URL=redis://redis:6379/0 + - CELERY_RESULT_BACKEND=redis://redis:6379/0 + - DIFY_API_BASE_URL=https://dify.theaken.com/v1 + - DIFY_STT_API_KEY=app-xQeSipaQecs0cuKeLvYDaRsu + - DIFY_TRANSLATOR_API_KEY=app-YOPrF2ro5fshzMkCZviIuUJd + - DIFY_SUMMARIZER_API_KEY=app-oFptWFRlSgvwhJ8DzZKN08a0 + - DIFY_ACTION_EXTRACTOR_API_KEY=app-UHU5IrVcwE0nVvgzubpGRqym + - FLASK_RUN_PORT=12015 + # LDAP Configuration + - LDAP_SERVER=panjit.com.tw + - LDAP_PORT=389 + - LDAP_USE_SSL=False + - LDAP_BIND_USER_DN=CN=LdapBind,CN=Users,DC=PANJIT,DC=COM,DC=TW + - LDAP_BIND_USER_PASSWORD=panjit2481 + - LDAP_SEARCH_BASE=OU=PANJIT,DC=panjit,DC=com,DC=tw + - LDAP_USER_LOGIN_ATTR=userPrincipalName + volumes: + - ./uploads:/app/uploads + depends_on: + - redis + deploy: + resources: + limits: + memory: 2G + cpus: '1.5' + reservations: + memory: 1G + cpus: '0.8' + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:12015/api/health"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 40s + networks: + - ai-meeting-network + + # Celery worker for AI processing + celery-worker: + image: panjit-ai-meeting-assistant:main + pull_policy: never + restart: unless-stopped + command: celery -A celery_worker.celery worker --loglevel=info --concurrency=4 -Q default,ai_tasks,celery --pool=eventlet + environment: + - DATABASE_URL=mysql+pymysql://A060:WLeSCi0yhtc7@mysql.theaken.com:33306/db_A060 + - JWT_SECRET_KEY=your-super-secret-key-that-no-one-should-know + - CELERY_BROKER_URL=redis://redis:6379/0 + - CELERY_RESULT_BACKEND=redis://redis:6379/0 + - DIFY_API_BASE_URL=https://dify.theaken.com/v1 + - DIFY_STT_API_KEY=app-xQeSipaQecs0cuKeLvYDaRsu + - DIFY_TRANSLATOR_API_KEY=app-YOPrF2ro5fshzMkCZviIuUJd + - DIFY_SUMMARIZER_API_KEY=app-oFptWFRlSgvwhJ8DzZKN08a0 + - DIFY_ACTION_EXTRACTOR_API_KEY=app-UHU5IrVcwE0nVvgzubpGRqym + volumes: + - ./uploads:/app/uploads + depends_on: + - redis + deploy: + resources: + limits: + memory: 2G + reservations: + memory: 1G + networks: + - ai-meeting-network + + # Celery Flower for monitoring + celery-flower: + image: panjit-ai-meeting-assistant:main + pull_policy: never + restart: unless-stopped + command: celery -A celery_worker.celery flower --broker=redis://redis:6379/0 --port=5555 + ports: + - "5555:5555" + environment: + - CELERY_BROKER_URL=redis://redis:6379/0 + - CELERY_RESULT_BACKEND=redis://redis:6379/0 + depends_on: + - redis + networks: + - ai-meeting-network + + # Nginx reverse proxy + nginx: + image: panjit-ai-meeting-assistant:nginx + build: + context: ./nginx + dockerfile: Dockerfile + container_name: ai-meeting-nginx + depends_on: + - ai-meeting-app + ports: + - "12015:12015" + restart: unless-stopped + networks: + - ai-meeting-network + +volumes: + redis_data: + +networks: + ai-meeting-network: + driver: bridge \ No newline at end of file diff --git a/frontend/.dockerignore b/frontend/.dockerignore new file mode 100644 index 0000000..64018fa --- /dev/null +++ b/frontend/.dockerignore @@ -0,0 +1,39 @@ +# Dependencies +node_modules +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# Production build +dist +build + +# Environment files +.env.local +.env.development.local +.env.test.local +.env.production.local + +# IDE +.vscode +.idea +*.swp +*.swo + +# OS +.DS_Store +Thumbs.db + +# Git +.git +.gitignore + +# Logs +logs +*.log + +# Coverage +coverage + +# Misc +.npmrc \ No newline at end of file diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 50998aa..6955448 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -28,6 +28,7 @@ const PrivateRoute = () => { ); } + // 需要認證才能進入應用 return user ? : ; }; diff --git a/frontend/src/contexts/AuthContext.jsx b/frontend/src/contexts/AuthContext.jsx index 3026768..7422de7 100644 --- a/frontend/src/contexts/AuthContext.jsx +++ b/frontend/src/contexts/AuthContext.jsx @@ -14,50 +14,50 @@ const setAuthToken = token => { export const AuthProvider = ({ children }) => { const [user, setUser] = useState(null); - const [token, setToken] = useState(() => localStorage.getItem('token')); + const [token, setToken] = useState(localStorage.getItem('token')); const [loading, setLoading] = useState(true); useEffect(() => { - if (token) { - try { - const decoded = jwtDecode(token); - const currentTime = Date.now() / 1000; - if (decoded.exp < currentTime) { - console.log("Token expired, logging out."); - logout(); - } else { - setUser({ - id: decoded.sub, - role: decoded.role, - username: decoded.username -}); - setAuthToken(token); + // Check if token exists and validate it + const validateToken = async () => { + const savedToken = localStorage.getItem('token'); + if (savedToken) { + try { + setAuthToken(savedToken); + const response = await axios.get('http://localhost:5000/api/me'); + setUser(response.data); + setToken(savedToken); + } catch (error) { + console.error('Token validation failed:', error); + localStorage.removeItem('token'); + setToken(null); + setUser(null); + setAuthToken(null); } - } catch (error) { - console.error("Invalid token on initial load"); - logout(); } - } - setLoading(false); - }, [token]); + setLoading(false); + }; + + validateToken(); + }, []); const login = async (username, password) => { try { - const response = await axios.post('/api/login', { username, password }); - const { access_token } = response.data; + const response = await axios.post('http://localhost:5000/api/login', { username, password }); + const { access_token, user: userData } = response.data; + localStorage.setItem('token', access_token); setToken(access_token); - const decoded = jwtDecode(access_token); - setUser({ - id: decoded.sub, - role: decoded.role, - username: decoded.username -}); + setUser(userData); setAuthToken(access_token); - return { success: true }; + + return { success: true, user: userData }; } catch (error) { - console.error('Login failed:', error.response?.data?.msg || error.message); - return { success: false, message: error.response?.data?.msg || 'Login failed' }; + console.error('Login failed:', error.response?.data?.error || error.message); + return { + success: false, + message: error.response?.data?.error || 'Login failed' + }; } }; @@ -78,7 +78,13 @@ export const AuthProvider = ({ children }) => { return ( - {!loading && children} + {loading ? ( +
+
Loading...
+
+ ) : ( + children + )}
); }; diff --git a/frontend/src/pages/DashboardPage.jsx b/frontend/src/pages/DashboardPage.jsx index 12034ca..3624306 100644 --- a/frontend/src/pages/DashboardPage.jsx +++ b/frontend/src/pages/DashboardPage.jsx @@ -45,9 +45,10 @@ const DashboardPage = () => { const fetchMeetings = useCallback(async () => { try { const data = await getMeetings(); - setMeetings(data); + setMeetings(Array.isArray(data) ? data : []); } catch (err) { setError('Could not fetch meetings.'); + setMeetings([]); // 確保設置為空陣列 } finally { setLoading(false); } @@ -101,11 +102,14 @@ const DashboardPage = () => { }; const uniqueStatuses = useMemo(() => { + if (!Array.isArray(meetings)) return []; const statuses = new Set(meetings.map(m => m.status)); return Array.from(statuses); }, [meetings]); const filteredAndSortedMeetings = useMemo(() => { + if (!Array.isArray(meetings)) return []; + let filtered = meetings.filter(meeting => { const topicMatch = meeting.topic.toLowerCase().includes(topicSearch.toLowerCase()); const ownerMatch = meeting.owner_name ? meeting.owner_name.toLowerCase().includes(ownerSearch.toLowerCase()) : ownerSearch === ''; diff --git a/frontend/src/pages/LoginPage.jsx b/frontend/src/pages/LoginPage.jsx index a00d87d..dc7d077 100644 --- a/frontend/src/pages/LoginPage.jsx +++ b/frontend/src/pages/LoginPage.jsx @@ -10,11 +10,8 @@ import { register } from '../services/api'; const LoginPage = () => { const [username, setUsername] = useState(''); const [password, setPassword] = useState(''); - const [confirmPassword, setConfirmPassword] = useState(''); const [error, setError] = useState(''); - const [success, setSuccess] = useState(''); const [loading, setLoading] = useState(false); - const [isRegister, setIsRegister] = useState(false); const { login } = useAuth(); const navigate = useNavigate(); @@ -25,7 +22,6 @@ const LoginPage = () => { const handleLogin = async (e) => { e.preventDefault(); setError(''); - setSuccess(''); setLoading(true); const { success, message } = await login(username, password); if (success) { @@ -36,27 +32,6 @@ const LoginPage = () => { setLoading(false); }; - const handleRegister = async (e) => { - e.preventDefault(); - if (password !== confirmPassword) { - setError('Passwords do not match.'); - return; - } - setError(''); - setSuccess(''); - setLoading(true); - try { - await register(username, password); - setSuccess('Account created successfully! Please log in.'); - setIsRegister(false); // Switch back to login view - setUsername(''); // Clear fields - setPassword(''); - setConfirmPassword(''); - } catch (err) { - setError(err.response?.data?.error || 'Failed to create account.'); - } - setLoading(false); - }; return ( @@ -81,15 +56,15 @@ const LoginPage = () => { AI Meeting Assistant - {isRegister ? 'Create Account' : 'Sign In'} + 使用 AD 帳號登入 - + { required fullWidth name="password" - label="Password" + label="AD 密碼" type="password" id="password" - autoComplete={isRegister ? "new-password" : "current-password"} + autoComplete="current-password" value={password} onChange={(e) => setPassword(e.target.value)} /> - {isRegister && ( - setConfirmPassword(e.target.value)} - /> - )} {error && {error}} - {success && {success}} - - - { - setIsRegister(!isRegister); - setError(''); - setSuccess(''); - }}> - {isRegister ? "Already have an account? Sign In" : "Don't have an account? Sign Up"} - - - diff --git a/frontend/vite.config.js b/frontend/vite.config.js index d8dc48f..d801a16 100644 --- a/frontend/vite.config.js +++ b/frontend/vite.config.js @@ -7,7 +7,7 @@ export default defineConfig({ server: { proxy: { '/api': { - target: 'http://127.0.0.1:12000', + target: 'http://backend:5000', changeOrigin: true, }, }, diff --git a/migrations/__pycache__/env.cpython-312.pyc b/migrations/__pycache__/env.cpython-312.pyc deleted file mode 100644 index 5f89e0ac65fade71ebefc45b11662b8e81f54e4b..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 4370 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 N|D9=h%CtEk`47Nghv@(S diff --git a/migrations/versions/__pycache__/3b11caf37983_initial_migration_with_users_meetings_.cpython-312.pyc b/migrations/versions/__pycache__/3b11caf37983_initial_migration_with_users_meetings_.cpython-312.pyc deleted file mode 100644 index 6c7dd223bf00170220903f7bec8dc2cb8ed2d093..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 39764 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 oC8(0pp3;9YbM8~g!XR diff --git a/migrations/versions/__pycache__/919aff0aa44b_implement_user_centric_status_and_.cpython-312.pyc b/migrations/versions/__pycache__/919aff0aa44b_implement_user_centric_status_and_.cpython-312.pyc deleted file mode 100644 index 895ddcc21e117e50adf85dad304336faf0cbc1f8..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1727 zcmcgs&2Jk;6rc6SemO2l8wjEfS_!qt&_s?M7i?9PoCF06NKi;qYFS#1cgFEXyK6JE zZfXQl4oDpM0~!zlu2pgXfdrM1P;W@RB$830MV#QmEf5e=PrO;%#Ep7D9GKPo=FNMv zGw;p&y}6%E>ImB02cI;ah#~Z|m<(E^FB~s`a2pX+K|~@EM{*;Th$Q5w6RpHxj5%>v zuE?N^R}>;wR1&Rd&~>7`hbjr80*-W|Y3=Qg%5KY{F7%WR{(^3#=ww7Rs zyB+?P6O0TW5sVCPZT0Ub98|z4^y4Fd+sKzfEx^X$8V3p(;mXi%WT$V*qj%9eXd^zf zpfCE$K(`_GKh;Qd$R4Kg;RQSWDT(dIcZ6@ycKUuv;>2W6H;hC+SwSG>1P#2DPzODO zW)PP)A^A6u1YZZ;irzq*Qd(IG5-v9fS-K+yGPfGk#esg3&Q(yWUfXeS&7r~giNUPf z4mDfYZ=@9_91X^5*tZ%cc6`cAKU}Y4LQJdav|TTVa2&|oZG-kAnKH{ zmaV$>I>SEjj#qEOKA|v74bvVF+YC4D6Wi<&+XP~p)2&XRv|F$bq3lWU>l}3cI=~kC zMN1rLll$7_w)^?Q4_e`yHxJd(1NHpAdj8|NPs<;cKXbmC`+D(<#V^a>$GWN4y6RH6 zAhoZi_9k;(b@s0~(u)ULVP7je;3KfVKlEw!}m|F>w>@41kTG7NO{6hkEONW0eUXnQ%F+ zhKyw5a5gGnOu$(I@L-}qSu3r~zg${j<3jZ~K#y~5LRdA@-1K@GPr+PtehuJXWPI=9 zm2c^H?p=59jkWI8>wC3&H?`hX8~@~eyV_NsdqmokrRnIs>3Hb{C7lcu( diff --git a/migrations/versions/__pycache__/ac069534da31_add_status_and_result_fields_to_meeting.cpython-312.pyc b/migrations/versions/__pycache__/ac069534da31_add_status_and_result_fields_to_meeting.cpython-312.pyc deleted file mode 100644 index 315400809744eb6e0b2ecc3f8ace32afeb08b18c..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2713 zcmc&$O>ERg6dtd4{kJjsff9bQ!72r`hot!-g{%k?-2{}pI=wcZW^BD;q86E`-pP)<}iy)gSd{TP<+#!rL>KbmDYDNKT#H3~0)y`U`jj84q zwYN;uwF;Apa@;&;QL9+cMu$_Hk?(nJaG-xLHu`&m8Iz_oB~47jHML<7DXp)kZ=kDZ ze^>8-*0Vp=zdzMG*gdeXuV>#rMY+$GlH|Ndu`~uv%&E}#Y_F-&ZN9czzmY-m@T`0R zMYx1qE}WbWixMiJg#w`Y$Xsn^0DXi$MANYbgIX8!t{7^krLb3T=R&*ad~A+IhYL#T z-=TMIyPMSSTB+KGA*<<0}xCSWIU=;GfO_z85LdsbgT zfj-$X<~2K1H}WnZN4H$lq1QN1p!q4&F+8bwzF-pF!sFK>o@!pOD1(~rR!XKP70R}4 zm%tjl=(u^og{4Y?+hU2b?WsM!m`-I+Qj^f^$dM!2ca9xD zn#G1|WXqt_{lv55$Bw<3Jv17wSvM$!!Wact>(Wm3WgTmh7_6K$dk*~gokrf!S!O|% zbYL_+U^J)(V>I0@hJL$C6;CXez!^4)2FY!Nhdu%^gZ_}?_vFNqoLKC9^=9<0JalWz zvea@&pqFNg2SgEvq6 zZ*E_bwtx2emC?(iSEp`77Ub*lt+r~<@SSqC<78Dj^^DEp?gQWEzO%lu?$GMN6N~TW zsvY^NgrBjxYTamCXusb6_10VC)s8o+($RX7#eYdsMoMZ?9$bw>vWxQ2<5ftQDIU`; zu=3eG!$bV{o$1XZGXLXHY(x-$l4B!n;!|7<$5Oz>U%@B+f4O*VR>Q)2yobegEu^}| zb27fX2IH^ANy>l3NpG*iN%1klju^sDJhBPIVZx3E!uCI5giD&4U`HciKO;%OX9)5V zQ*35}Q2}WO;l(j2mTEje5}*lSPIja$CEWKGws-CFpxR}>FTo z6|$9;Wn*2@jV-XvyEI@|iPZkFJe8FWs4Mv8^3)vAIFE zd9R@rkSHj XsDV&hXOzDLmFrx2MdfyoE_m}FX5u=* diff --git a/models.py b/models.py index 9f94829..42e4d1b 100644 --- a/models.py +++ b/models.py @@ -9,9 +9,11 @@ class User(db.Model): __tablename__ = 'ms_users' id = db.Column(db.Integer, primary_key=True) username = db.Column(db.String(80), unique=True, nullable=False) + display_name = db.Column(db.String(100), nullable=True) # For friendly display name like "ymirliu 劉念萱" password_hash = db.Column(db.String(128), nullable=False) role = db.Column(db.String(20), nullable=False, default='user') # 'user' or 'admin' created_at = db.Column(db.DateTime(timezone=True), server_default=func.now()) + last_login = db.Column(db.DateTime(timezone=True), nullable=True) # Track last login time def set_password(self, password): self.password_hash = bcrypt.generate_password_hash(password).decode('utf-8') @@ -23,8 +25,10 @@ class User(db.Model): return { 'id': self.id, 'username': self.username, + 'display_name': self.display_name, 'role': self.role, - 'created_at': self.created_at.isoformat() if self.created_at else None + 'created_at': self.created_at.isoformat() if self.created_at else None, + 'last_login': self.last_login.isoformat() if self.last_login else None } class Meeting(db.Model): diff --git a/nginx/Dockerfile b/nginx/Dockerfile new file mode 100644 index 0000000..2762895 --- /dev/null +++ b/nginx/Dockerfile @@ -0,0 +1,10 @@ +FROM nginx:1.25-alpine + +# Copy custom nginx configuration +COPY nginx.conf /etc/nginx/nginx.conf + +# Expose port +EXPOSE 12015 + +# Start nginx +CMD ["nginx", "-g", "daemon off;"] \ No newline at end of file diff --git a/nginx/nginx.conf b/nginx/nginx.conf new file mode 100644 index 0000000..32b42bf --- /dev/null +++ b/nginx/nginx.conf @@ -0,0 +1,76 @@ +user nginx; +worker_processes auto; + +events { + worker_connections 1024; + multi_accept on; +} + +http { + include /etc/nginx/mime.types; + default_type application/octet-stream; + + sendfile on; + tcp_nopush on; + tcp_nodelay on; + keepalive_timeout 65; + types_hash_max_size 4096; + + gzip on; + gzip_comp_level 5; + gzip_min_length 1024; + gzip_proxied any; + gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript image/svg+xml; + + map $http_upgrade $connection_upgrade { + default upgrade; + '' close; + } + + upstream app_backend { + server ai-meeting-app:12015 max_fails=3 fail_timeout=10s; + keepalive 64; + } + + server { + listen 12015; + server_name _; + + # Adjust as needed for uploads (AI audio files can be large) + client_max_body_size 100m; + + # Proxy API requests to Flask/Gunicorn + location /api/ { + proxy_pass http://app_backend; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade; + proxy_read_timeout 300s; # Longer timeout for AI processing + proxy_send_timeout 300s; + proxy_connect_timeout 10s; + proxy_buffering on; + proxy_buffers 32 32k; + proxy_busy_buffers_size 64k; + } + + # All other routes (frontend SPA and static) via backend + location / { + proxy_pass http://app_backend; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_read_timeout 120s; + proxy_send_timeout 120s; + proxy_connect_timeout 5s; + proxy_buffering on; + proxy_buffers 32 32k; + proxy_busy_buffers_size 64k; + } + } +} \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 5ff6d25..7cfc0cb 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,28 +1,37 @@ +# Core Flask and Web Framework Flask==2.2.5 -celery==5.3.6 -redis==4.5.4 -# For NVIDIA GPU (CUDA 11.8) support, use these lines: -torch --extra-index-url https://download.pytorch.org/whl/cu118 -torchaudio --extra-index-url https://download.pytorch.org/whl/cu118 -# For CPU-only, comment out the two lines above and uncomment the two lines below: -# torch -# torchaudio -openai-whisper -moviepy -opencc-python-reimplemented -ffmpeg-python -python-dotenv gunicorn -demucs -soundfile -gevent # Added for celery on windows +python-dotenv +Flask-CORS -# New dependencies for User Management and Database +# Database and Authentication Flask-SQLAlchemy Flask-Migrate PyMySQL Flask-JWT-Extended Flask-Bcrypt +ldap3 -# Dependency for calling external APIs +# Task Queue and Caching +celery==5.3.6 +redis==4.5.4 +eventlet # Required for Celery worker monkey patching +gevent # Added for celery on windows +flower==2.0.1 # Celery monitoring (separate package since Celery 5.0) + +# Media Processing (Audio/Video) +moviepy +ffmpeg-python +pydub +soundfile + +# Text Processing +opencc-python-reimplemented + +# External API Communication requests + +# Removed AI packages (now using Dify API): +# torch / torchaudio - Not needed for Dify API +# openai-whisper - Replaced by Dify STT service +# demucs - Audio separation not used diff --git a/services/__pycache__/dify_client.cpython-312.pyc b/services/__pycache__/dify_client.cpython-312.pyc deleted file mode 100644 index 28616212356e9fd7a906cc2f6907616b6ad7f228..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 4539 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 diff --git a/tasks.py b/tasks.py index 7434fc5..9edd048 100644 --- a/tasks.py +++ b/tasks.py @@ -165,21 +165,49 @@ def extract_audio_task(self, input_path, output_path): @celery.task(base=ProgressTask, bind=True) def transcribe_audio_task(self, audio_path): from app import app + import logging + logger = logging.getLogger(__name__) + + logger.error(f"[TRANSCRIBE DEBUG] Starting transcribe task for: {audio_path}") + with app.app_context(): try: + logger.error(f"[TRANSCRIBE DEBUG] Entered app context") self.update_progress(0, 100, "Loading and preparing audio file...") - audio = AudioSegment.from_file(audio_path) + logger.error(f"[TRANSCRIBE DEBUG] Progress updated to 0%") + + logger.error(f"[TRANSCRIBE DEBUG] About to load audio file: {audio_path}") + audio = AudioSegment.from_file(audio_path) + logger.error(f"[TRANSCRIBE DEBUG] Audio loaded successfully, duration: {len(audio)}ms") + + # 1. Split audio by silence (skip for very long audio to avoid timeout) + logger.error(f"[TRANSCRIBE DEBUG] Starting silence detection") + audio_duration_minutes = len(audio) / (1000 * 60) # Convert to minutes + logger.error(f"[TRANSCRIBE DEBUG] Audio duration: {audio_duration_minutes:.2f} minutes") + + if audio_duration_minutes > 10: # Skip silence detection for audio longer than 10 minutes + logger.error(f"[TRANSCRIBE DEBUG] Audio too long ({audio_duration_minutes:.2f} min), skipping silence detection") + self.update_progress(10, 100, f"Audio is {audio_duration_minutes:.1f} minutes long, processing as single chunk...") + chunks = [audio] # Use entire audio as single chunk + else: + self.update_progress(5, 100, "Detecting silence to split audio into chunks...") + try: + chunks = split_on_silence( + audio, + min_silence_len=700, + silence_thresh=-40, + keep_silence=300 + ) + logger.error(f"[TRANSCRIBE DEBUG] Silence detection completed, found {len(chunks)} chunks") + except Exception as e: + logger.error(f"[TRANSCRIBE DEBUG] Error in silence detection: {str(e)}") + chunks = [audio] - # 1. Split audio by silence - self.update_progress(5, 100, "Detecting silence to split audio into chunks...") - chunks = split_on_silence( - audio, - min_silence_len=700, - silence_thresh=-40, - keep_silence=300 - ) if not chunks: # If no silence is detected, treat the whole audio as one chunk + logger.error(f"[TRANSCRIBE DEBUG] No chunks detected, using full audio") chunks = [audio] + else: + logger.error(f"[TRANSCRIBE DEBUG] Using {len(chunks)} chunks") # 2. Process chunks and ensure they are within API limits final_segments = [] @@ -229,10 +257,13 @@ def transcribe_audio_task(self, audio_path): return {'status': 'Success', 'content': full_content, 'result_path': transcript_filename} except Exception as e: + import traceback + logger.error(f"[TRANSCRIBE DEBUG] Exception occurred: {type(e).__name__}: {str(e)}") + logger.error(f"[TRANSCRIBE DEBUG] Full traceback: {traceback.format_exc()}") error_message = f"An error occurred: {str(e)}" self.update_state( state='FAILURE', - meta={'exc_type': type(e).__name__, 'exc_message': error_message} + meta={'exc_type': type(e).__name__, 'exc_message': error_message, 'traceback': traceback.format_exc()} ) return {'status': 'Error', 'error': error_message} diff --git a/utils/__init__.py b/utils/__init__.py new file mode 100644 index 0000000..ba6aac5 --- /dev/null +++ b/utils/__init__.py @@ -0,0 +1 @@ +# Utils package for AI Meeting Assistant \ No newline at end of file diff --git a/utils/ldap_utils.py b/utils/ldap_utils.py new file mode 100644 index 0000000..edbd776 --- /dev/null +++ b/utils/ldap_utils.py @@ -0,0 +1,133 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +LDAP Authentication Utilities for AI Meeting Assistant + +Author: PANJIT IT Team +Created: 2024-09-18 +""" + +import time +from ldap3 import Server, Connection, SUBTREE, ALL_ATTRIBUTES +from flask import current_app + +def get_logger(): + """Get application logger""" + return current_app.logger + +def create_ldap_connection(retries=3): + """Create LDAP connection with retry mechanism""" + logger = get_logger() + + # LDAP Configuration from environment + ldap_server = current_app.config.get('LDAP_SERVER', 'panjit.com.tw') + ldap_port = current_app.config.get('LDAP_PORT', 389) + use_ssl = current_app.config.get('LDAP_USE_SSL', False) + bind_dn = current_app.config.get('LDAP_BIND_USER_DN', '') + bind_password = current_app.config.get('LDAP_BIND_USER_PASSWORD', '') + + for attempt in range(retries): + try: + server = Server( + ldap_server, + port=ldap_port, + use_ssl=use_ssl, + get_info=ALL_ATTRIBUTES + ) + + conn = Connection( + server, + user=bind_dn, + password=bind_password, + auto_bind=True, + raise_exceptions=True + ) + + logger.info("LDAP connection established successfully") + return conn + + except Exception as e: + logger.error(f"LDAP connection attempt {attempt + 1} failed: {str(e)}") + if attempt == retries - 1: + raise + time.sleep(1) + + return None + +def authenticate_user(username, password): + """Authenticate user against LDAP/AD""" + logger = get_logger() + + try: + conn = create_ldap_connection() + if not conn: + return None + + # Configuration + search_base = current_app.config.get('LDAP_SEARCH_BASE', 'DC=panjit,DC=com,DC=tw') + login_attr = current_app.config.get('LDAP_USER_LOGIN_ATTR', 'userPrincipalName') + + # Search for user + search_filter = f"(&(objectClass=person)(objectCategory=person)({login_attr}={username}))" + + conn.search( + search_base, + search_filter, + SUBTREE, + attributes=['displayName', 'mail', 'sAMAccountName', 'userPrincipalName'] + ) + + if not conn.entries: + logger.warning(f"User not found: {username}") + return None + + user_entry = conn.entries[0] + user_dn = user_entry.entry_dn + + # Try to bind with user credentials + try: + user_conn = Connection( + conn.server, + user=user_dn, + password=password, + auto_bind=True, + raise_exceptions=True + ) + user_conn.unbind() + + # Return user info + user_info = { + 'ad_account': str(user_entry.sAMAccountName) if user_entry.sAMAccountName else username, + 'display_name': str(user_entry.displayName) if user_entry.displayName else username, + 'email': str(user_entry.mail) if user_entry.mail else '', + 'user_principal_name': str(user_entry.userPrincipalName) if user_entry.userPrincipalName else username, + 'username': username + } + + logger.info(f"User authenticated successfully: {username}") + return user_info + + except Exception as e: + logger.warning(f"Authentication failed for user {username}: {str(e)}") + return None + + except Exception as e: + logger.error(f"LDAP authentication error: {str(e)}") + return None + finally: + if conn: + conn.unbind() + +def test_ldap_connection(): + """Test LDAP connection for health check""" + logger = get_logger() + + try: + conn = create_ldap_connection(retries=1) + if conn: + conn.unbind() + return True + return False + except Exception as e: + logger.error(f"LDAP connection test failed: {str(e)}") + return False \ No newline at end of file