diff --git a/app/forms/auth_forms.py b/app/forms/auth_forms.py index b28b2ccfc633058096e24df08e07662887e99e1e..13b21f01f65977605f9e5e296f5cbc72db606de8 100644 --- a/app/forms/auth_forms.py +++ b/app/forms/auth_forms.py @@ -59,6 +59,14 @@ class ChangePasswordForm(FlaskForm): ]) submit = SubmitField('Thay đổi mật khẩu') +class ResetPasswordRequestForm(FlaskForm): + """Form yêu cầu đặt lại mật khẩu""" + email = StringField('Email', validators=[ + DataRequired(message="Email không được để trống"), + Email(message="Vui lòng nhập địa chỉ email hợp lệ") + ]) + submit = SubmitField('Gửi yêu cầu đặt lại mật khẩu') + class NotificationSettingsForm(FlaskForm): """Form cài đặt thông báo""" email_notifications = BooleanField('Nhận thông báo qua email') diff --git a/app/forms/dietitian_forms.py b/app/forms/dietitian_forms.py new file mode 100644 index 0000000000000000000000000000000000000000..c2fd31e77e232b2457afe06c27c6f2ad62da2204 --- /dev/null +++ b/app/forms/dietitian_forms.py @@ -0,0 +1,18 @@ +from flask_wtf import FlaskForm +from wtforms import StringField, PasswordField, TextAreaField, SubmitField, ValidationError +from wtforms.validators import DataRequired, Email, Length, EqualTo +from app.models import User + +class DietitianForm(FlaskForm): + firstName = StringField('Họ', validators=[DataRequired(), Length(max=50)]) + lastName = StringField('Tên', validators=[DataRequired(), Length(max=50)]) + email = StringField('Email', validators=[DataRequired(), Email(), Length(max=100)]) + password = PasswordField('Mật khẩu', validators=[DataRequired(), Length(min=6)]) + phone = StringField('Số điện thoại', validators=[Length(max=20)]) + specialization = StringField('Chuyên môn', validators=[Length(max=100)]) + notes = TextAreaField('Ghi chú') + submit = SubmitField('Tạo tài khoản') + + def validate_email(self, field): + if User.query.filter_by(email=field.data).first(): + raise ValidationError('Email này đã được sử dụng.') \ No newline at end of file diff --git a/app/models/__init__.py b/app/models/__init__.py index 7de59ffb51e23f4c8667ef2caab0e39524d43b2a..42207d7857f32f79a55f99f90eb17631ac8f0e5c 100644 --- a/app/models/__init__.py +++ b/app/models/__init__.py @@ -7,10 +7,10 @@ from .referral import Referral, ReferralStatus from .procedure import Procedure from .report import Report from .uploaded_file import UploadedFile -from .dietitian import Dietitian, DietitianStatus from .activity_log import ActivityLog from .notification import Notification -from .support import SupportMessage, SupportMessageReadStatus +from .support import SupportMessage +from .patient_dietitian_assignment import PatientDietitianAssignment # Import bất kỳ model bổ sung nào ở đây @@ -23,9 +23,8 @@ __all__ = [ 'Referral', 'ReferralStatus', 'Report', 'UploadedFile', - 'Dietitian', 'DietitianStatus', 'ActivityLog', 'Notification', - 'DietitianProfile', - 'SupportMessage', 'SupportMessageReadStatus' + 'SupportMessage', + 'PatientDietitianAssignment', ] \ No newline at end of file diff --git a/app/models/dietitian.py b/app/models/dietitian.py index f5afef264ea823b6f5f6f9b646f41ba56e36da70..56fab49614f7731ecd2586771c895dd1ae1b2aff 100644 --- a/app/models/dietitian.py +++ b/app/models/dietitian.py @@ -7,7 +7,6 @@ import enum class DietitianStatus(enum.Enum): AVAILABLE = "AVAILABLE" UNAVAILABLE = "UNAVAILABLE" - ON_LEAVE = "ON_LEAVE" class Dietitian(db.Model): """Dietitian model containing dietitian information""" @@ -47,8 +46,8 @@ class Dietitian(db.Model): return 0 def update_status_based_on_patient_count(self): - """Cập nhật trạng thái dựa trên số lượng bệnh nhân.""" - if self.patient_count >= 6: + """Cập nhật trạng thái chỉ dựa trên số lượng bệnh nhân.""" + if self.patient_count >= 10: self.status = DietitianStatus.UNAVAILABLE else: self.status = DietitianStatus.AVAILABLE diff --git a/app/models/encounter.py b/app/models/encounter.py index 287df273bdbcc58d0498a49d079a16207d82b2bf..9e26d8e3d22a6cc7280b068ccf4a65d68d686c8a 100644 --- a/app/models/encounter.py +++ b/app/models/encounter.py @@ -32,6 +32,7 @@ class Encounter(db.Model): assigned_dietitian = relationship('User', foreign_keys=[dietitian_id], backref='assigned_encounters') measurements = relationship('PhysiologicalMeasurement', backref='encounter', lazy='dynamic', cascade='all, delete-orphan') referrals = relationship('Referral', back_populates='encounter', lazy='dynamic', cascade='all, delete-orphan') + procedures = relationship('Procedure', back_populates='encounter', lazy='dynamic', cascade='all, delete-orphan') def __repr__(self): display_id = self.custom_encounter_id if self.custom_encounter_id else self.encounterID diff --git a/app/models/patient_dietitian_assignment.py b/app/models/patient_dietitian_assignment.py new file mode 100644 index 0000000000000000000000000000000000000000..01f005ea3b00c613b984eca1de837b4463be1819 --- /dev/null +++ b/app/models/patient_dietitian_assignment.py @@ -0,0 +1,47 @@ +from app import db +from datetime import datetime +from sqlalchemy.dialects.mysql import INTEGER # Sử dụng INTEGER(unsigned=True) nếu cần + +class PatientDietitianAssignment(db.Model): + __tablename__ = 'patient_dietitian_assignments' + + id = db.Column(INTEGER(unsigned=True), primary_key=True) + patient_id = db.Column(db.String(50), db.ForeignKey('patients.id', ondelete='CASCADE'), nullable=False, index=True) + dietitian_id = db.Column(db.Integer, db.ForeignKey('users.userID', ondelete='CASCADE'), nullable=False, index=True) + assignment_date = db.Column(db.DateTime, nullable=False, default=datetime.utcnow, index=True) + end_date = db.Column(db.DateTime, nullable=True) # Ngày kết thúc assignment (nếu có) + is_active = db.Column(db.Boolean, default=True, nullable=False, index=True) # Trạng thái hiện tại của assignment + notes = db.Column(db.Text, nullable=True) # Ghi chú thêm (tùy chọn) + + # Relationships (optional but helpful) + # patient = db.relationship('Patient', backref=db.backref('dietitian_assignments', lazy='dynamic')) # Có thể gây circular import nếu không cẩn thận + # dietitian = db.relationship('User', backref=db.backref('assigned_patients', lazy='dynamic')) # Tương tự + + # Unique constraint to prevent duplicate active assignments (optional but recommended) + # Bỏ postgresql_where để tương thích với các dialect khác hoặc phiên bản cũ hơn + # Đổi tên constraint để tránh lỗi nếu constraint cũ đã tồn tại + __table_args__ = (db.UniqueConstraint('patient_id', 'dietitian_id', name='uq_patient_dietitian_assignment'),) + + + def __repr__(self): + status = 'Active' if self.is_active else 'Inactive' + return f'<PatientDietitianAssignment Patient:{self.patient_id} - Dietitian:{self.dietitian_id} ({status})>' + + # Phương thức helper để deactive assignment cũ khi tạo assignment mới (cần gọi trong logic route) + @staticmethod + def deactivate_existing_assignments(patient_id, new_assignment_id=None): + assignments_to_deactivate = PatientDietitianAssignment.query.filter_by( + patient_id=patient_id, + is_active=True + ) + if new_assignment_id: + # Không deactivate assignment mới vừa được tạo + assignments_to_deactivate = assignments_to_deactivate.filter(PatientDietitianAssignment.id != new_assignment_id) + + count = assignments_to_deactivate.update({'is_active': False, 'end_date': datetime.utcnow()}) + # db.session.commit() # Commit nên được thực hiện ở route + return count > 0 # Trả về True nếu có assignment nào bị deactive + + @staticmethod + def get_active_assignment(patient_id): + return PatientDietitianAssignment.query.filter_by(patient_id=patient_id, is_active=True).first() \ No newline at end of file diff --git a/app/models/procedure.py b/app/models/procedure.py index 7f5ebdab0abaeaaa878df50ee740f8a446069a28..e9550d1d2bb55e6da267648c9b7daf8f007a66f9 100644 --- a/app/models/procedure.py +++ b/app/models/procedure.py @@ -25,7 +25,7 @@ class Procedure(db.Model): updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) # Relationships - encounter = relationship('Encounter', backref='procedures') + encounter = relationship('Encounter', back_populates='procedures') patient = relationship('Patient', backref='procedures') def __repr__(self): diff --git a/app/models/report.py b/app/models/report.py index 0f7acf6151b24fc339c0beda887673ccfbec6b8c..cb144983814af153fd99fb37addc3fe5d36d7046 100644 --- a/app/models/report.py +++ b/app/models/report.py @@ -1,7 +1,16 @@ from datetime import datetime from app import db from sqlalchemy.orm import relationship +from sqlalchemy import Enum as SQLAlchemyEnum # Import Enum từ sqlalchemy from app.models.procedure import Procedure # Import Procedure +import enum # Import thư viện enum của Python + +# Định nghĩa Enum cho Report Status +class ReportStatus(enum.Enum): + DRAFT = "Draft" + PENDING = "Pending" + COMPLETED = "Completed" + CANCELLED = "Cancelled" class Report(db.Model): """Model for dietitian reports and assessments""" @@ -23,7 +32,7 @@ class Report(db.Model): report_date = db.Column(db.DateTime, nullable=False, default=datetime.utcnow) report_type = db.Column(db.String(50), nullable=False) # e.g., 'initial_assessment', 'follow_up' - status = db.Column(db.String(20), default='Draft') # e.g., 'Draft', 'Pending', 'Completed' + status = db.Column(SQLAlchemyEnum(ReportStatus), default=ReportStatus.DRAFT, nullable=False) completed_date = db.Column(db.DateTime, nullable=True) # Assessment Fields diff --git a/app/models/support.py b/app/models/support.py index 30af6313d2c5c6ff028b77bb4964eb4875f348fc..d9021acef6453f6b9228c9ed24c42adc41a41aa1 100644 --- a/app/models/support.py +++ b/app/models/support.py @@ -1,4 +1,4 @@ -from .. import db +from app import db from datetime import datetime from sqlalchemy.orm import relationship from .user import User @@ -8,25 +8,9 @@ class SupportMessage(db.Model): id = db.Column(db.Integer, primary_key=True) sender_id = db.Column(db.Integer, db.ForeignKey('users.userID'), nullable=False) content = db.Column(db.Text, nullable=False) - timestamp = db.Column(db.DateTime, default=datetime.utcnow) + timestamp = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) - sender = relationship('User', backref='sent_support_messages') - read_statuses = relationship('SupportMessageReadStatus', back_populates='message', cascade='all, delete-orphan') + sender = relationship('User', back_populates='sent_support_messages') def __repr__(self): - return f'<SupportMessage {self.id} from User {self.sender_id}>' - -class SupportMessageReadStatus(db.Model): - __tablename__ = 'support_message_read_status' - id = db.Column(db.Integer, primary_key=True) - user_id = db.Column(db.Integer, db.ForeignKey('users.userID'), nullable=False) - message_id = db.Column(db.Integer, db.ForeignKey('support_messages.id'), nullable=False) - read_at = db.Column(db.DateTime, default=datetime.utcnow) - - user = relationship('User') - message = relationship('SupportMessage') - - __table_args__ = (db.UniqueConstraint('user_id', 'message_id', name='uq_user_message_read'),) - - def __repr__(self): - return f'<SupportMessageReadStatus User {self.user_id} read Message {self.message_id}>' \ No newline at end of file + return f'<SupportMessage {self.id} from User {self.sender_id}>' \ No newline at end of file diff --git a/app/models/user.py b/app/models/user.py index 33ef868273d7b537f24fa96f6edc286292232c8d..ea3c20e5e745b62e4ff20377421e9831d4bf1bce 100644 --- a/app/models/user.py +++ b/app/models/user.py @@ -21,11 +21,13 @@ class User(UserMixin, db.Model): role = Column(Enum('Admin', 'Dietitian', name='user_roles_new'), default='Dietitian', nullable=False) created_at = Column(DateTime(timezone=True), server_default=func.now()) last_login = Column(DateTime(timezone=True)) + last_support_visit = Column(DateTime(timezone=True), nullable=True) # Relationships dietitian = relationship("Dietitian", back_populates="user", uselist=False, cascade="all, delete-orphan") activities = relationship("ActivityLog", back_populates="user", lazy='dynamic') assigned_patients = relationship('Patient', back_populates='assigned_dietitian', foreign_keys='[Patient.assigned_dietitian_user_id]') + sent_support_messages = relationship("SupportMessage", back_populates="sender") def set_password(self, password): self.password_hash = bcrypt.generate_password_hash(password).decode('utf-8') diff --git a/app/routes/auth.py b/app/routes/auth.py index e3a41551c78b5ca91ec5fd56217cd7b72d208162..aceb2ea24fdb213247b1a7f4b0fef151f4b64c66 100644 --- a/app/routes/auth.py +++ b/app/routes/auth.py @@ -5,7 +5,7 @@ from datetime import datetime from app import db, bcrypt from app.models.user import User from app.models.dietitian import Dietitian, DietitianStatus -from app.forms.auth_forms import LoginForm, EditProfileForm, ChangePasswordForm, NotificationSettingsForm +from app.forms.auth_forms import LoginForm, EditProfileForm, ChangePasswordForm, NotificationSettingsForm, ResetPasswordRequestForm from ..utils.validators import is_safe_url, validate_password, validate_email, sanitize_input from sqlalchemy.sql import text from app.utils.decorators import admin_required @@ -86,62 +86,88 @@ def logout(): @auth_bp.route('/profile') @login_required def profile(): - """Hiển thị trang hồ sơ người dùng""" - # Đếm số lượng tải lên bằng SQL trực tiếp chỉ với các cột chính - upload_count = db.session.execute( - text("SELECT COUNT(*) FROM uploadedfiles WHERE userID = :user_id"), - {"user_id": current_user.userID} - ).scalar() - - return render_template( - 'profile.html', - title='Hồ sơ người dùng', - upload_count=upload_count - ) + """Hiển thị trang hồ sơ người dùng, tùy theo vai trò.""" + if current_user.role == 'Admin': + # Logic cho admin (nếu cần thêm dữ liệu riêng) + return render_template('admin/profile.html', title='Admin Profile') + else: + # Logic cho các vai trò khác (ví dụ: Dietitian) + upload_count = db.session.execute( + text("SELECT COUNT(*) FROM uploadedfiles WHERE userID = :user_id"), + {"user_id": current_user.userID} + ).scalar() + + return render_template( + 'profile.html', + title='Hồ sơ người dùng', + upload_count=upload_count + ) -@auth_bp.route('/profile/edit', methods=['GET', 'POST']) +@auth_bp.route('/edit-profile', methods=['GET', 'POST']) @login_required def edit_profile(): - """Xử lý chỉnh sửa hồ sơ người dùng""" - # Truyền current_user vào form để validate email gốc - form = EditProfileForm(obj=current_user) - - if form.validate_on_submit(): - # Kiểm tra email trùng lặp (trừ email của chính người dùng hiện tại) - existing_user = User.query.filter(User.email == form.email.data, User.userID != current_user.userID).first() - if existing_user: - flash('Email này đã được sử dụng bởi người dùng khác.', 'danger') - # Render lại template với lỗi thay vì redirect - return render_template('edit_profile.html', title='Chỉnh sửa Hồ sơ', form=form) + user = current_user + # Xác định template dựa trên vai trò + if user.is_admin: + template = 'admin/edit_profile.html' + elif user.role == 'Dietitian': + template = 'edit_profile.html' # Template cho dietitian + else: + flash('Bạn không có quyền chỉnh sửa hồ sơ này.', 'warning') + return redirect(url_for('main.handle_root')) - # Cập nhật thông tin User - current_user.firstName = form.firstName.data - current_user.lastName = form.lastName.data - current_user.email = form.email.data - current_user.phone = form.phone.data # Cập nhật phone nếu có + # Lấy dietitian profile nếu là dietitian (sử dụng tên relationship đúng: dietitian) + dietitian_profile = user.dietitian if user.role == 'Dietitian' else None - # Nếu là Dietitian và có profile, cập nhật cả profile Dietitian - if current_user.role == 'Dietitian' and current_user.dietitian: - current_user.dietitian.firstName = form.firstName.data - current_user.dietitian.lastName = form.lastName.data - current_user.dietitian.email = form.email.data # Giữ email đồng bộ - current_user.dietitian.phone = form.phone.data - # Cập nhật các trường dietitian khác nếu có trong form - if hasattr(form, 'specialization'): - current_user.dietitian.specialization = form.specialization.data - if hasattr(form, 'notes'): - current_user.dietitian.notes = form.notes.data + # Khởi tạo form với dữ liệu hiện tại + if request.method == 'GET': + # Truyền dietitian_profile vào form nếu là Dietitian + form = EditProfileForm(obj=user, dietitian_profile=dietitian_profile) + # Thêm thuộc tính role_display nếu là Admin + if user.is_admin: + form.role_display = user.role # Để hiển thị trong template admin + else: # POST request + # Tạo instance form để validate dữ liệu gửi lên + form = EditProfileForm(request.form) + # Cần set lại dietitian_profile cho form instance khi POST để xử lý logic dietitian + form.dietitian_profile = dietitian_profile + + if form.validate_on_submit(): + # Cập nhật thông tin User chung + user.firstName = form.firstName.data + user.lastName = form.lastName.data + user.email = form.email.data + user.phone = form.phone.data + + # Cập nhật thông tin Dietitian riêng nếu là Dietitian + if dietitian_profile and user.role == 'Dietitian': + if hasattr(form, 'specialization'): # Kiểm tra field tồn tại trong form + dietitian_profile.specialization = form.specialization.data + if hasattr(form, 'notes'): # Kiểm tra field tồn tại trong form + dietitian_profile.notes = form.notes.data + # Giữ thông tin user và dietitian profile đồng bộ + dietitian_profile.firstName = user.firstName + dietitian_profile.lastName = user.lastName + dietitian_profile.email = user.email + dietitian_profile.phone = user.phone try: db.session.commit() - flash('Hồ sơ của bạn đã được cập nhật thành công!', 'success') - return redirect(url_for('auth.profile')) # Redirect về trang profile sau khi thành công + flash('Thông tin hồ sơ đã được cập nhật thành công.', 'success') + return redirect(url_for('auth.profile')) except Exception as e: db.session.rollback() - flash(f'Lỗi khi cập nhật hồ sơ: {str(e)}', 'danger') + current_app.logger.error(f"Lỗi khi cập nhật hồ sơ user {user.userID}: {e}") + flash('Đã xảy ra lỗi khi cập nhật hồ sơ.', 'danger') - # Nếu là GET request hoặc form validation thất bại, render template - return render_template('edit_profile.html', title='Chỉnh sửa Hồ sơ', form=form) + # Nếu form không hợp lệ hoặc là GET request, hiển thị lại form + # Cần đảm bảo form.role_display được set lại cho GET request của Admin + if request.method == 'GET' and user.is_admin: + form.role_display = user.role + + password_form = ChangePasswordForm() # Luôn cần form đổi mật khẩu + + return render_template(template, title='Chỉnh sửa Hồ sơ', form=form, password_form=password_form) @auth_bp.route('/change-password', methods=['POST']) @login_required @@ -349,9 +375,10 @@ def reset_password_request(): if current_user.is_authenticated: return redirect(url_for('dashboard.index')) - if request.method == 'POST': - email = request.form.get('email') - + form = ResetPasswordRequestForm() + + if form.validate_on_submit(): + email = form.email.data user = User.query.filter_by(email=email).first() if user: # Gửi email đặt lại mật khẩu (sẽ triển khai sau) @@ -362,4 +389,4 @@ def reset_password_request(): return redirect(url_for('auth.login')) - return render_template('reset_password.html') + return render_template('auth/reset_password.html', form=form, title='Yêu cầu Đặt lại Mật khẩu') diff --git a/app/routes/dashboard.py b/app/routes/dashboard.py index d27ac42d6061bbca4849f00d9ce0989c4f763f0b..9d480f648c40241031df3e13441a8e2b9f8d12c4 100644 --- a/app/routes/dashboard.py +++ b/app/routes/dashboard.py @@ -7,12 +7,25 @@ from app.models.referral import Referral, ReferralStatus from app.models.procedure import Procedure from app.models.report import Report from app.models.measurement import PhysiologicalMeasurement +from app.models.user import User +from app.models.dietitian import Dietitian from sqlalchemy import func, case, or_, and_, desc -from datetime import datetime, timedelta +from datetime import datetime, timedelta, timezone import json +from collections import defaultdict dashboard_bp = Blueprint('dashboard', __name__, url_prefix='/dashboard') +def format_status_label(status_input): + """Helper function to format status strings or enum members for display.""" + if hasattr(status_input, 'name'): # Check if it's an Enum member + status_str = status_input.name + elif isinstance(status_input, str): + status_str = status_input + else: + return "Unknown" + return status_str.replace('_', ' ').title() + # Route chính của dashboard ('/dashboard/index') @dashboard_bp.route('/index') @login_required @@ -21,114 +34,163 @@ def index(): if current_user.role == 'Dietitian': return redirect(url_for('dietitian.dashboard')) - # Get statistics for dashboard + # Get statistics for dashboard cards stats = { 'total_patients': Patient.query.count(), - 'new_referrals': Referral.query.filter_by(referral_status=ReferralStatus.DIETITIAN_UNASSIGNED).count(), + 'new_referrals': Referral.query.filter(Referral.referral_status == ReferralStatus.DIETITIAN_UNASSIGNED).count(), 'procedures_today': Procedure.query.filter( - func.date(Procedure.procedureDateTime) == func.date(datetime.now()) + func.date(Procedure.procedureDateTime) == func.current_date() ).count(), 'pending_reports': Report.query.filter(Report.status == 'Pending').count() } - # Get recent patients for dashboard, sắp xếp theo ngày nhập viện mới nhất + # Get recent patients recent_patients = Patient.query.order_by(desc(Patient.admission_date)).limit(5).all() - # Get recent referrals - recent_referrals = (Referral.query - .join(Encounter, Referral.encounter_id == Encounter.encounterID) - .join(Patient, Encounter.patient_id == Patient.id) - .add_columns(Patient.firstName, Patient.lastName, Patient.id) - .order_by(Referral.created_at.desc()) - .limit(5) - .all()) + # Get recent referrals (Consider adding patient name/info) + recent_referrals = Referral.query.order_by(desc(Referral.created_at)).limit(5).all() - # Get BMI distribution - bmi_category = case( + # --- BMI Distribution --- + bmi_category_case = case( (Patient.bmi < 18.5, 'Underweight'), (Patient.bmi.between(18.5, 24.9), 'Normal'), - (Patient.bmi.between(25.0, 29.9), 'Overweight'), - (Patient.bmi >= 30.0, 'Obese'), + (Patient.bmi.between(25, 29.9), 'Overweight'), # Corrected range + (Patient.bmi >= 30, 'Obese'), else_='Unknown' ).label('bmi_category') - bmi_stats = db.session.query( - bmi_category, + bmi_stats_query = db.session.query( + bmi_category_case, func.count().label('count') ).filter(Patient.bmi != None).group_by('bmi_category').all() - - # Get referrals over time for the past 90 days - end_date = datetime.now() - start_date = end_date - timedelta(days=90) - referral_dates = {} - - for i in range(90): - date = (end_date - timedelta(days=i)).strftime('%Y-%m-%d') - referral_dates[date] = 0 - - referrals_by_date = db.session.query( + # Convert to list of lists for JS chart + bmi_stats = [[category, count] for category, count in bmi_stats_query if category != 'Unknown'] + # ------------------------ + + # --- Referrals Timeline (Bắt đầu từ 17/04 năm nay) --- + end_date = datetime.utcnow().date() + current_year = end_date.year + try: + start_date = datetime(current_year, 4, 17).date() + except ValueError: # Xử lý trường hợp ngày không hợp lệ (ví dụ: năm nhuận?) + # Nên log lỗi hoặc xử lý phù hợp, tạm thời dùng ngày đầu năm + start_date = datetime(current_year, 1, 1).date() + print(f"Warning: Could not create date April 17th, {current_year}. Using start of year.") + + # Đảm bảo start_date không ở tương lai + if start_date > end_date: + start_date = end_date + + # Use func.date for database-level date extraction + referrals_by_date_query = db.session.query( func.date(Referral.referralRequestedDateTime).label('date'), func.count().label('count') ).filter( - Referral.referralRequestedDateTime.between(start_date, end_date) - ).group_by('date').all() + func.date(Referral.referralRequestedDateTime).between(start_date, end_date) + ).group_by(func.date(Referral.referralRequestedDateTime)).all() - for date, count in referrals_by_date: - referral_dates[date.strftime('%Y-%m-%d')] = count + # Prepare the timeline data, ensuring all dates in the range are present + delta_days = (end_date - start_date).days + # Tạo dict với đầy đủ các ngày từ start_date đến end_date + referral_timeline_dict = { (start_date + timedelta(days=i)).isoformat(): 0 for i in range(delta_days + 1) } + for date_obj, count in referrals_by_date_query: + if date_obj and date_obj.isoformat() in referral_timeline_dict: + referral_timeline_dict[date_obj.isoformat()] = count referral_timeline = [ + {'date': date_str, 'count': count} + for date_str, count in sorted(referral_timeline_dict.items()) + ] + # ------------------------------------------- + + # --- Dietitian Workload --- + dietitian_workload_query = (db.session.query( + User.firstName, + User.lastName, + func.count(Patient.id).label('patient_count') + ) + .select_from(User) + .outerjoin(Patient, User.userID == Patient.assigned_dietitian_user_id) + .filter(User.role == 'Dietitian') # Ensure we only query Dietitians + .group_by(User.userID, User.firstName, User.lastName) # Group by user details + .order_by(desc('patient_count')) + .all()) + + # Get total count of patients who have an assigned dietitian + assigned_patients_count = db.session.query(func.count(Patient.id)).filter(Patient.assigned_dietitian_user_id != None).scalar() + total_patients_count = stats['total_patients'] + unassigned_patients_count = total_patients_count - (assigned_patients_count or 0) + + dietitian_workload_chart_data = [ { - 'date': date, - 'count': count - } for date, count in sorted(referral_dates.items()) + "name": f"{d.firstName} {d.lastName}" if d.firstName else "Unknown Dietitian", + "count": d.patient_count + } for d in dietitian_workload_query ] - - # Check for nutritional alerts + if unassigned_patients_count > 0: + dietitian_workload_chart_data.append({"name": "Unassigned", "count": unassigned_patients_count}) + # --------------------------- + + # --- Patient Status Distribution --- + patient_status_stats_query = db.session.query( + Patient.status, + func.count().label('count') + ).group_by(Patient.status).all() + patient_status_stats = {format_status_label(status): count for status, count in patient_status_stats_query} + # ----------------------------------- + + # --- Report Status Distribution (Using String) --- + report_status_stats_query = db.session.query( + Report.status, # String column + func.count().label('count') + ).group_by(Report.status).all() + # Use the string status directly as key, apply formatting if needed + report_status_stats = {status.value: count for status, count in report_status_stats_query if status} + # --------------------------------------------------- + + # --- Referral Status Distribution --- + referral_status_stats_query = db.session.query( + Referral.referral_status, + func.count().label('count') + ).group_by(Referral.referral_status).all() + referral_status_stats = {format_status_label(status): count for status, count in referral_status_stats_query} + # ------------------------------------- + + # --- Alerts --- (Keeping existing logic) alerts = [] - - # Alert for patients with critical BMI - critical_bmi_patients = (Patient.query - .filter((Patient.bmi < 16) | (Patient.bmi > 40)) - .all()) - - if critical_bmi_patients: + critical_bmi_patients = Patient.query.filter(or_(Patient.bmi < 16, Patient.bmi > 40)).count() + if critical_bmi_patients > 0: alerts.append({ 'type': 'danger', - 'message': f'{len(critical_bmi_patients)} patients with critical nutritional status detected', - 'link': '/patients?critical=true' + 'message': f'{critical_bmi_patients} patients with critical nutritional status detected', + 'link': url_for('patients.index', bmi_filter='critical') # Example link }) - # Alert for overdue referrals overdue_referrals = Referral.query.filter( Referral.referral_status.in_([ReferralStatus.DIETITIAN_UNASSIGNED, ReferralStatus.WAITING_FOR_REPORT]), - Referral.referralRequestedDateTime < (datetime.now() - timedelta(days=2)) + Referral.referralRequestedDateTime < (datetime.utcnow() - timedelta(days=2)) ).count() - - if overdue_referrals: + if overdue_referrals > 0: alerts.append({ 'type': 'warning', 'message': f'{overdue_referrals} referrals pending for more than 48 hours', - 'link': '/dashboard/referrals?overdue=true' - }) - - # Alert for monthly report - today = datetime.now() - if today.day > 25: # Near end of month - alerts.append({ - 'type': 'info', - 'message': 'Monthly report is ready for export', - 'link': '/reports/generate?type=monthly' + 'link': url_for('referral.index', status='overdue') # Placeholder link }) + # -------------- return render_template( 'dashboard.html', stats=stats, recent_patients=recent_patients, - recent_referrals=recent_referrals, - bmi_stats=bmi_stats, + recent_referrals=recent_referrals, # Pass recent referrals + bmi_stats=bmi_stats, # Pass as list of lists referral_timeline=referral_timeline, + dietitian_workload=dietitian_workload_chart_data, + patient_status_stats=patient_status_stats, # Pass formatted dict + report_status_stats=report_status_stats, # Pass dict with string keys + referral_status_stats=referral_status_stats, # Pass formatted dict alerts=alerts, - PatientStatus=PatientStatus + PatientStatus=PatientStatus # Pass Enum for template logic if needed ) @dashboard_bp.route('/api/stats') diff --git a/app/routes/dietitian.py b/app/routes/dietitian.py index a38bb6a781d44fde3e5c3350b74acc76ddaf2ff6..5038d64fdfd2e686df2f27fdc05a59d01c5d3c66 100644 --- a/app/routes/dietitian.py +++ b/app/routes/dietitian.py @@ -13,6 +13,9 @@ from app.forms.procedure import ProcedureForm # Import ProcedureForm from sqlalchemy import func, case, desc from datetime import datetime, timedelta from flask_wtf import FlaskForm # Đảm bảo import ở đầu file +from app.models import Patient, User, Procedure, Encounter, PatientDietitianAssignment # Giữ lại import cần thiết +from app.forms import ProcedureForm +from app.utils.decorators import permission_required dietitian_bp = Blueprint('dietitian', __name__, url_prefix='/dietitian') @@ -135,98 +138,150 @@ def dashboard(): # --- Procedure Routes for Dietitians --- -@dietitian_bp.route('/procedures') -@dietitian_required -def list_procedures(): +# --- THÊM LẠI ROUTE CHUNG CHO DANH SÁCH PROCEDURES CỦA DIETITIAN --- +@dietitian_bp.route('/procedures', methods=['GET']) +@login_required +@permission_required('DIETITIAN', 'ADMIN') # Admin cũng có thể xem trang này +def list_my_procedures(): # Đổi tên hàm để tránh xung đột """Hiển thị danh sách các procedures, có thể lọc theo bệnh nhân.""" page = request.args.get('page', 1, type=int) per_page = 15 # Số lượng procedure mỗi trang - patient_filter_id = request.args.get('patient_id') # Giữ lại để lọc procedure + patient_filter_id = request.args.get('patient_id') # Lấy patient_id từ query string để lọc # --- Query chính lấy procedures --- + # Bắt đầu với Procedure, join Patient và Encounter query = Procedure.query.join(Patient, Procedure.patient_id == Patient.id)\ - .join(Encounter, Procedure.encounter_id == Encounter.encounterID)\ + .outerjoin(Encounter, Procedure.encounter_id == Encounter.encounterID)\ .options(db.joinedload(Procedure.patient), db.joinedload(Procedure.encounter)) # Nếu người dùng là Dietitian, chỉ hiển thị procedures của bệnh nhân họ quản lý - if not current_user.is_admin: + if current_user.role.upper() == 'DIETITIAN': + # Lọc dựa trên dietitian_id được gán cho BỆNH NHÂN query = query.filter(Patient.assigned_dietitian_user_id == current_user.userID) + # Admin có thể xem tất cả procedures - # Lọc theo bệnh nhân được chọn từ dropdown + # Lọc theo bệnh nhân được chọn từ dropdown (nếu có) if patient_filter_id: query = query.filter(Procedure.patient_id == patient_filter_id) + # Sắp xếp theo ngày giờ procedure giảm dần query = query.order_by(Procedure.procedureDateTime.desc()) + # Thực hiện phân trang pagination = query.paginate(page=page, per_page=per_page, error_out=False) procedures = pagination.items # --- Lấy danh sách bệnh nhân cho dropdown filter --- patients_query = Patient.query - # Nếu là Dietitian, chỉ lấy bệnh nhân được gán - if not current_user.is_admin: + # Nếu là Dietitian, chỉ lấy bệnh nhân được gán cho họ + if current_user.role.upper() == 'DIETITIAN': patients_query = patients_query.filter(Patient.assigned_dietitian_user_id == current_user.userID) patients_for_filter = patients_query.order_by(Patient.lastName, Patient.firstName).all() - # Tạo instance của EmptyForm + # Tạo instance của EmptyForm (cần thiết nếu template dùng modal delete) empty_form = EmptyForm() + # Render template dietitian_procedures.html return render_template('dietitian_procedures.html', procedures=procedures, pagination=pagination, patients_for_filter=patients_for_filter, selected_patient_id=patient_filter_id, empty_form=empty_form) # Truyền empty_form vào context +# --- KẾT THÚC ROUTE CHUNG --- -@dietitian_bp.route('/procedure/new/for_patient/<string:patient_id>', methods=['GET', 'POST']) -@dietitian_required + +# Route mới để hiển thị danh sách Thủ thuật cho một bệnh nhân CỤ THỂ +@dietitian_bp.route('/patient/<string:patient_id>/procedures', methods=['GET']) +@login_required +@permission_required('DIETITIAN', 'ADMIN') # Cho phép cả Admin xem +def list_procedures(patient_id): # Tên hàm này giữ nguyên + patient = Patient.query.get_or_404(patient_id) + # Kiểm tra quyền truy cập của Dietitian + if current_user.role.upper() == 'DIETITIAN': + # Kiểm tra xem dietitian hiện tại có được gán cho bệnh nhân này không + if patient.assigned_dietitian_user_id != current_user.userID: + flash('You are not authorized to view this patient\'s procedures.', 'danger') + return redirect(url_for('dietitian.dashboard')) + # Admin có thể xem tất cả + + procedures = Procedure.query.filter_by(patient_id=patient.id).order_by(Procedure.procedureDateTime.desc()).all() + + # Kiểm tra xem có encounter nào đang ON_GOING không để bật/tắt nút Add + ongoing_encounter = Encounter.query.filter_by( + patient_id=patient.id, + status=EncounterStatus.ON_GOING + ).first() + + # Render template list_patient_procedures.html thay vì list_procedures.html + return render_template('list_patient_procedures.html', + patient=patient, + procedures=procedures, + has_ongoing_encounter=bool(ongoing_encounter)) + +@dietitian_bp.route('/patient/<string:patient_id>/procedure/new', methods=['GET', 'POST']) +@login_required +@permission_required('DIETITIAN', 'ADMIN') # Cho phép cả Admin def new_procedure(patient_id): - """Hiển thị form và xử lý tạo Procedure mới cho bệnh nhân cụ thể.""" patient = Patient.query.get_or_404(patient_id) - - # Lấy encounter_id từ URL nếu có (để chọn sẵn và khóa) + # Kiểm tra quyền truy cập của Dietitian + if current_user.role.upper() == 'DIETITIAN': + # Kiểm tra xem dietitian hiện tại có được gán cho bệnh nhân này không + if patient.assigned_dietitian_user_id != current_user.userID: + flash('You are not authorized to add procedures for this patient.', 'danger') + return redirect(url_for('.list_procedures', patient_id=patient_id)) + # Admin có thể thêm + + # Kiểm tra encounter đang diễn ra TRƯỚC khi tạo form + ongoing_encounter = Encounter.query.filter_by( + patient_id=patient.id, + status=EncounterStatus.ON_GOING + ).first() + + if not ongoing_encounter: + flash('Cannot add a new procedure as there is no ongoing encounter for this patient.', 'warning') + # Kiểm tra xem có phải đang đến từ trang procedures chung không + referrer = request.referrer or '' + if 'procedures' in referrer and 'patient' not in referrer: + # Nếu đến từ trang procedures chung, redirect về đó với filter + return redirect(url_for('.list_my_procedures', patient_id=patient.id)) + else: + # Ngược lại về trang procedures của bệnh nhân cụ thể + return redirect(url_for('.list_procedures', patient_id=patient_id)) + + # Sử dụng preselected_encounter_id để khóa encounter nếu đến từ report preselected_encounter_id = request.args.get('encounter_id', type=int) redirect_to_report_id = request.args.get('redirect_to_report', type=int) - - # Kiểm tra xem có encounter nào đang ON_GOING không - on_going_encounters = Encounter.query.filter( - Encounter.patient_id == patient.id, - Encounter.status == EncounterStatus.ON_GOING - ).order_by(desc(Encounter.start_time)).all() - - # Nếu không có encounter_id được truyền và không có encounter ON_GOING nào - if not preselected_encounter_id and not on_going_encounters: - flash("Encounter is either not started or completed, please check again!", 'warning') - # Chuyển hướng về trang danh sách procedure của bệnh nhân đó - return redirect(url_for('.list_procedures', patient_id=patient.id)) - - # Khởi tạo form, truyền patient_id và encounter_id (nếu có) - # Truyền encounter_id vào để form biết cần khóa và chọn sẵn form = ProcedureForm(patient_id=patient.id, preselected_encounter_id=preselected_encounter_id) - - # Nếu không có preselected_encounter_id, chỉ cho phép chọn từ các encounter ON_GOING + + # Logic xử lý choices của form.encounter_id giữ nguyên như cũ if not preselected_encounter_id: + # Lấy các encounter ON_GOING cho dropdown nếu không có preselected_id + on_going_encounters = Encounter.query.filter( + Encounter.patient_id == patient.id, + Encounter.status == EncounterStatus.ON_GOING + ).order_by(desc(Encounter.start_time)).all() form.encounter_id.choices = [ - (enc.encounterID, f"{enc.custom_encounter_id or enc.encounterID} ({enc.start_time.strftime('%d/%m/%y %H:%M')})") + (enc.encounterID, f"{enc.custom_encounter_id or enc.encounterID} ({enc.start_time.strftime('%d/%m/%y %H:%M')})") for enc in on_going_encounters ] form.encounter_id.choices.insert(0, ('', '-- Select Encounter --')) - # Nếu có preselected_encounter_id, đảm bảo nó nằm trong danh sách choices (dù bị disable) else: - # Lấy tất cả encounters để đảm bảo preselected_id có trong list + # Lấy tất cả encounters nếu có preselected_id để đảm bảo nó có trong list all_encounters = Encounter.query.filter_by(patient_id=patient.id).order_by(desc(Encounter.start_time)).all() form.encounter_id.choices = [ - (enc.encounterID, f"{enc.custom_encounter_id or enc.encounterID} ({enc.start_time.strftime('%d/%m/%y %H:%M')})") + (enc.encounterID, f"{enc.custom_encounter_id or enc.encounterID} ({enc.start_time.strftime('%d/%m/%y %H:%M')})") for enc in all_encounters ] # Không cần insert lựa chọn rỗng vì nó sẽ bị disable và chọn sẵn if form.validate_on_submit(): try: + # Logic tạo Procedure giữ nguyên new_proc = Procedure( patient_id=patient.id, - encounter_id=form.encounter_id.data, # Lấy từ form (sẽ là giá trị đã chọn sẵn nếu bị disable) + encounter_id=form.encounter_id.data, procedureType=form.procedureType.data, procedureName=form.procedureName.data, procedureDateTime=form.procedureDateTime.data, @@ -235,85 +290,127 @@ def new_procedure(patient_id): procedureResults=form.procedureResults.data ) db.session.add(new_proc) - db.session.flush() # Lấy ID của procedure mới + db.session.flush() new_procedure_id = new_proc.id db.session.commit() flash(f'Procedure "{new_proc.procedureType}" created successfully for patient {patient.full_name}.', 'success') - - # Kiểm tra xem có cần redirect về trang report không + + # Redirect về trang REPORT nếu có redirect_to_report_id if redirect_to_report_id: - return redirect(url_for('report.edit_report', report_id=redirect_to_report_id, new_procedure_id=new_procedure_id)) + return redirect(url_for('reports.view_report', report_id=redirect_to_report_id)) + + # Kiểm tra xem có phải đang đến từ trang procedures chung không + referrer = request.referrer or '' + if 'procedures' in referrer and 'patient' not in referrer: + # Nếu đến từ trang procedures chung, redirect về đó với filter + return redirect(url_for('.list_my_procedures', patient_id=patient.id)) else: - # Redirect về danh sách procedure, lọc theo bệnh nhân vừa tạo + # Ngược lại về trang procedures của bệnh nhân cụ thể return redirect(url_for('.list_procedures', patient_id=patient.id)) + except Exception as e: db.session.rollback() - flash(f'Error creating procedure: {str(e)}', 'danger') + flash(f'Error creating procedure: {e}', 'danger') - # Render form nếu là GET request hoặc validation thất bại - return render_template('procedure_form.html', form=form, patient=patient, edit_mode=False, preselected_encounter_id=preselected_encounter_id) + # Render procedure_form.html giữ nguyên + return render_template('procedure_form.html', title='Add New Procedure', form=form, patient=patient, action="Add") @dietitian_bp.route('/procedure/<int:procedure_id>/edit', methods=['GET', 'POST']) -@dietitian_required +@login_required +@permission_required('DIETITIAN', 'ADMIN') def edit_procedure(procedure_id): - """Hiển thị form và xử lý cập nhật Procedure.""" - proc = Procedure.query.options(db.joinedload(Procedure.patient)).get_or_404(procedure_id) - patient = proc.patient - - # Permission check (ví dụ: chỉ dietitian được gán cho patient mới được sửa) - if not current_user.is_admin and patient.assigned_dietitian_user_id != current_user.userID: - flash('You do not have permission to edit this procedure.', 'danger') - return redirect(url_for('.list_procedures', patient_id=patient.id)) + procedure = Procedure.query.get_or_404(procedure_id) + patient = Patient.query.get_or_404(procedure.patient_id) + # Kiểm tra quyền truy cập của Dietitian + if current_user.role.upper() == 'DIETITIAN': + # Kiểm tra xem dietitian hiện tại có được gán cho bệnh nhân này không + if patient.assigned_dietitian_user_id != current_user.userID: + flash('You are not authorized to edit procedures for this patient.', 'danger') + return redirect(url_for('.list_procedures', patient_id=patient.id)) + # Admin có thể sửa + + # Sử dụng preselected_encounter_id để khóa encounter + form = ProcedureForm(obj=procedure, patient_id=patient.id, preselected_encounter_id=procedure.encounter_id) - # Khởi tạo form với dữ liệu hiện có (obj=proc) - # Truyền patient_id để load encounter choices, preselect encounter hiện tại - form = ProcedureForm(obj=proc, patient_id=patient.id, preselected_encounter_id=proc.encounter_id) + # Logic xử lý choices của form.encounter_id giữ nguyên như cũ + all_encounters = Encounter.query.filter_by(patient_id=patient.id).order_by(desc(Encounter.start_time)).all() + form.encounter_id.choices = [ + (enc.encounterID, f"{enc.custom_encounter_id or enc.encounterID} ({enc.start_time.strftime('%d/%m/%y %H:%M')})") + for enc in all_encounters + ] if form.validate_on_submit(): try: - proc.procedureType = form.procedureType.data - proc.procedureName = form.procedureName.data - proc.procedureDateTime = form.procedureDateTime.data - proc.procedureEndDateTime = form.procedureEndDateTime.data - proc.description = form.description.data - proc.procedureResults = form.procedureResults.data - # Không cho sửa encounter_id khi edit? - # proc.encounter_id = form.encounter_id.data - proc.updated_at = datetime.utcnow() # Giả sử có cột updated_at + # Cập nhật procedure + procedure.procedureType = form.procedureType.data + procedure.procedureName = form.procedureName.data + procedure.procedureDateTime = form.procedureDateTime.data + procedure.procedureEndDateTime = form.procedureEndDateTime.data + procedure.description = form.description.data + procedure.procedureResults = form.procedureResults.data + # Không cho sửa encounter_id sau khi tạo db.session.commit() - flash(f'Procedure "{proc.procedureType}" updated successfully.', 'success') - # Redirect về danh sách procedure, lọc theo bệnh nhân + flash(f'Procedure "{procedure.procedureType}" updated successfully.', 'success') + # Redirect về danh sách procedure của bệnh nhân return redirect(url_for('.list_procedures', patient_id=patient.id)) except Exception as e: db.session.rollback() - flash(f'Error updating procedure: {str(e)}', 'danger') - - # Hiển thị form với dữ liệu hiện tại nếu là GET hoặc validation lỗi - return render_template('procedure_form.html', form=form, patient=patient, edit_mode=True, procedure=proc, preselected_encounter_id=proc.encounter_id) + flash(f'Error updating procedure: {e}', 'danger') + + return render_template('procedure_form.html', title='Edit Procedure', form=form, patient=patient, action="Edit") @dietitian_bp.route('/procedure/<int:procedure_id>/delete', methods=['POST']) -@dietitian_required +@login_required +@permission_required('DIETITIAN', 'ADMIN') def delete_procedure(procedure_id): - """Xử lý yêu cầu xóa Procedure.""" - proc = Procedure.query.options(db.joinedload(Procedure.patient)).get_or_404(procedure_id) - patient_id_redirect = proc.patient_id # Lưu lại patient_id để redirect - - # Permission check - if not current_user.is_admin and proc.patient.assigned_dietitian_user_id != current_user.userID: - flash('You do not have permission to delete this procedure.', 'danger') - return redirect(url_for('.list_procedures', patient_id=patient_id_redirect)) + procedure = Procedure.query.get_or_404(procedure_id) + patient_id = procedure.patient_id # Lưu lại patient_id để redirect + patient = Patient.query.get_or_404(patient_id) + # Kiểm tra quyền truy cập của Dietitian + if current_user.role.upper() == 'DIETITIAN': + # Kiểm tra xem dietitian hiện tại có được gán cho bệnh nhân này không + if patient.assigned_dietitian_user_id != current_user.userID: + flash('You are not authorized to delete procedures for this patient.', 'danger') + return redirect(url_for('.list_procedures', patient_id=patient_id)) + # Admin có thể xóa try: - procedure_info = f'{proc.procedureType} (ID: {proc.id})' - db.session.delete(proc) + db.session.delete(procedure) db.session.commit() - flash(f'Procedure {procedure_info} deleted successfully.', 'success') + flash(f'Procedure "{procedure.procedureType}" deleted successfully.', 'success') except Exception as e: db.session.rollback() - flash(f'Error deleting procedure: {str(e)}', 'danger') + flash(f'Error deleting procedure: {e}', 'danger') + + # Redirect về danh sách procedure của bệnh nhân + return redirect(url_for('.list_procedures', patient_id=patient_id)) + +# Route để hiển thị danh sách Thủ thuật cho một bệnh nhân cụ thể +@dietitian_bp.route('/patient/<string:patient_id>/procedures', methods=['GET']) +@login_required +@permission_required('DIETITIAN') +def list_patient_procedures(patient_id): + """Hiển thị danh sách các procedures của một bệnh nhân cụ thể.""" + patient = Patient.query.get_or_404(patient_id) + # Kiểm tra quyền truy cập của dietitian (chỉ bệnh nhân được gán) + if not current_user.is_admin and patient.assigned_dietitian_user_id != current_user.userID: + abort(403) + + # Lấy danh sách procedures của bệnh nhân này + procedures = Procedure.query.filter_by(patient_id=patient_id)\ + .options(db.joinedload(Procedure.encounter))\ + .order_by(Procedure.procedureDateTime.desc()).all() + + # Kiểm tra xem có encounter nào đang ON_GOING để bật/tắt nút Add + has_ongoing_encounter = Encounter.query.filter( + Encounter.patient_id == patient.id, + Encounter.status == EncounterStatus.ON_GOING + ).first() is not None - # Redirect về danh sách procedure của bệnh nhân đó - return redirect(url_for('.list_procedures', patient_id=patient_id_redirect)) + return render_template('list_patient_procedures.html', + patient=patient, + procedures=procedures, + has_ongoing_encounter=has_ongoing_encounter) # (TODO later: Routes for viewing procedures if needed separately) \ No newline at end of file diff --git a/app/routes/dietitians.py b/app/routes/dietitians.py index 1ed3f0814b7338c87814cd01e1e25faca5a55eb6..f986117d27ae6bffd97fb27b9f54506da6479190 100644 --- a/app/routes/dietitians.py +++ b/app/routes/dietitians.py @@ -7,6 +7,8 @@ from app.models.encounter import Encounter from sqlalchemy import desc, or_, func, text from datetime import datetime from app.models.user import User +from flask import current_app +from app.forms.dietitian_forms import DietitianForm dietitians_bp = Blueprint('dietitians', __name__, url_prefix='/dietitians') @@ -96,43 +98,48 @@ def show(id): @dietitians_bp.route('/new', methods=['GET', 'POST']) @login_required def new(): - """Tạo chuyên gia dinh dưỡng mới""" - if request.method == 'POST': - # Xử lý dữ liệu form - firstName = request.form.get('firstName') - lastName = request.form.get('lastName') - status_str = request.form.get('status', '') + """Tạo chuyên gia dinh dưỡng mới sử dụng FlaskForm.""" + if not current_user.is_admin: + flash('Bạn không có quyền thực hiện hành động này.', 'danger') + return redirect(url_for('dietitians.index')) - # Chuyển đổi status string thành enum + form = DietitianForm() + if form.validate_on_submit(): try: - # Chuyển đổi sang chữ hoa để khớp với định nghĩa enum - status_upper = status_str.upper() if status_str else '' - # Xác thực giá trị enum - if status_upper not in [item.name for item in DietitianStatus]: - raise ValueError(f"Giá trị enum không hợp lệ: {status_upper}") - # Lấy enum object, không chuyển sang giá trị enum - status_enum = DietitianStatus[status_upper] - except (ValueError, KeyError) as e: - flash(f'Trạng thái không hợp lệ: {status_str} - Lỗi: {str(e)}', 'error') - return render_template('dietitians/new.html', status_options=DietitianStatus) + # Tạo User trước + new_user = User( + firstName=form.firstName.data, + lastName=form.lastName.data, + email=form.email.data, + role='Dietitian' # Mặc định là Dietitian + ) + new_user.set_password(form.password.data) # Hash mật khẩu từ form + db.session.add(new_user) + db.session.flush() # Lấy userID sau khi add - # Tạo bản ghi mới - dietitian = Dietitian( - firstName=firstName, - lastName=lastName, - status=status_enum # Sử dụng enum - ) - - try: - db.session.add(dietitian) + # Tạo Dietitian profile và liên kết với User + new_dietitian = Dietitian( + user_id=new_user.userID, # Liên kết user ID + firstName=form.firstName.data, # Đồng bộ tên + lastName=form.lastName.data, # Đồng bộ họ + email=form.email.data, # Đồng bộ email + phone=form.phone.data, + specialization=form.specialization.data, + notes=form.notes.data, + status=DietitianStatus.AVAILABLE # Mặc định là AVAILABLE + ) + db.session.add(new_dietitian) db.session.commit() - flash('Thêm chuyên gia dinh dưỡng thành công!', 'success') - return redirect(url_for('dietitians.show', id=dietitian.dietitianID)) + + flash('Tạo tài khoản chuyên gia dinh dưỡng thành công!', 'success') + return redirect(url_for('dietitians.show', id=new_dietitian.dietitianID)) except Exception as e: db.session.rollback() - flash(f'Lỗi: {str(e)}', 'error') - - return render_template('dietitians/new.html', status_options=DietitianStatus) + current_app.logger.error(f"Lỗi khi tạo tài khoản dietitian: {e}") + flash(f'Lỗi khi tạo tài khoản: {str(e)}', 'error') + + # GET request hoặc form validation thất bại + return render_template('dietitians/new.html', form=form) # Truyền form vào template @dietitians_bp.route('/<int:id>/edit', methods=['GET', 'POST']) @login_required @@ -266,7 +273,9 @@ def assign_patient(id, patient_id): return redirect(url_for('dietitians.show', id=id)) @dietitians_bp.route('/<int:id>/unassign_patient/<int:patient_id>', methods=['POST']) +@login_required def unassign_patient(id, patient_id): + """Bỏ phân công bệnh nhân""" try: patient = Patient.query.get_or_404(patient_id) if patient.dietitian_id == id: diff --git a/app/routes/notifications.py b/app/routes/notifications.py index 595d0d556b61c01b47e367f9052b34d208a477d6..4c920afe61c855aab787d67147d4af06eaaf887f 100644 --- a/app/routes/notifications.py +++ b/app/routes/notifications.py @@ -81,8 +81,10 @@ def mark_notification_as_read(notification_id): def mark_all_notifications_as_read(): """API endpoint to mark all unread notifications as read for the current user.""" try: + # Sử dụng .update() để hiệu quả hơn khi cập nhật nhiều bản ghi updated_count = Notification.query.filter_by(user_id=current_user.userID, is_read=False).update({'is_read': True}) db.session.commit() + current_app.logger.info(f"Marked {updated_count} notifications as read for user {current_user.userID}") return jsonify({'success': True, 'message': f'{updated_count} notifications marked as read.'}) except Exception as e: db.session.rollback() diff --git a/app/routes/patients.py b/app/routes/patients.py index 07796b4bb8f59100e901588a4e7bc840f600e71d..c8874410f71606edd15d1d586e3aae69b980b6cb 100644 --- a/app/routes/patients.py +++ b/app/routes/patients.py @@ -7,7 +7,7 @@ from app.models.encounter import Encounter, EncounterStatus from app.models.measurement import PhysiologicalMeasurement from app.models.referral import Referral, ReferralStatus from app.models.procedure import Procedure -from app.models.report import Report +from app.models.report import Report, ReportStatus from app.models.dietitian import Dietitian from sqlalchemy import desc, or_, func from sqlalchemy.orm import joinedload @@ -27,6 +27,10 @@ from sklearn.ensemble import RandomForestClassifier from app.models.user import User from app.models.activity_log import ActivityLog from app.models.notification import Notification +from wtforms import ValidationError +# Import notification helpers +from app.utils.notifications_helper import create_notification, create_notification_for_admins +from app.models.patient_dietitian_assignment import PatientDietitianAssignment patients_bp = Blueprint('patients', __name__, url_prefix='/patients') @@ -194,7 +198,8 @@ def index(): current_page=page, per_page=per_page, pagination=pagination, - EmptyForm=EmptyForm + EmptyForm=EmptyForm, + PatientStatus=PatientStatus # Thêm PatientStatus vào context ) @patients_bp.route('/<string:patient_id>') @@ -237,7 +242,8 @@ def patient_detail(patient_id): ).all() reports = Report.query.filter_by(patient_id=patient_id).options( joinedload(Report.author), # Tải sẵn thông tin người tạo - joinedload(Report.encounter) # Thêm joinedload cho encounter + joinedload(Report.encounter), # Thêm joinedload cho encounter + joinedload(Report.dietitian) # THÊM: Tải sẵn thông tin dietitian được gán cho report ).order_by( desc(Report.report_date) ).all() @@ -249,22 +255,40 @@ def patient_detail(patient_id): desc(Encounter.start_time) ).all() + # Debug để kiểm tra dietitian relationship đã được load đúng chưa + if all_encounters: + if all_encounters[0].dietitian_id: + current_app.logger.info(f"First encounter has dietitian_id={all_encounters[0].dietitian_id}, assigned_dietitian is {all_encounters[0].assigned_dietitian}") + else: + current_app.logger.info(f"First encounter has no dietitian_id") + latest_encounter = all_encounters[0] if all_encounters else None # Chuẩn bị dữ liệu encounters để hiển thị, bao gồm status display mới encounters_data = [] - for enc in all_encounters: + for enc_base in all_encounters: # Đổi tên biến lặp để tránh nhầm lẫn + # Thay vì refresh, query lại encounter để đảm bảo dữ liệu mới nhất + enc = Encounter.query.options(joinedload(Encounter.assigned_dietitian)).get(enc_base.encounterID) + if not enc: + # Bỏ qua nếu encounter bị xóa giữa chừng (trường hợp hiếm) + current_app.logger.warning(f"Encounter ID {enc_base.encounterID} not found during data preparation for patient detail view.") + continue + # Sử dụng hàm helper đã cập nhật để lấy thông tin hiển thị status status_display = get_encounter_status_display(enc.status) # TODO: Xác định các chỉ số bất ổn # Logic này cần định nghĩa ngưỡng cho từng chỉ số unstable_metrics = [] # Placeholder encounters_data.append({ - 'encounter': enc, + 'encounter': enc, # Dùng encounter đã query lại 'status': status_display, 'unstable_metrics': unstable_metrics }) - + + # Debug log lại số lượng encounters với assigned_dietitian + encounters_with_dietitian = sum(1 for data in encounters_data if data['encounter'].assigned_dietitian is not None) + current_app.logger.info(f"Prepared {len(encounters_data)} encounters for display, {encounters_with_dietitian} with assigned dietitian") + # Lấy danh sách dietitian cho modal (nếu cần) dietitians = User.query.filter_by(role='Dietitian').options( joinedload(User.dietitian) # Sửa lại tên relationship từ dietitian_profile thành dietitian @@ -338,10 +362,27 @@ def new_patient(): new_patient.bmi = new_patient.calculate_bmi() db.session.add(new_patient) - db.session.commit() + db.session.flush() # Lấy ID cho log và link + + # Log activity + activity = ActivityLog( + user_id=current_user.userID, + action=f"New patient {new_patient.full_name} (ID: {new_patient.id}) added by {current_user.full_name}.", + details=f"Patient ID: {new_patient.id}, Encounter ID: {new_patient.encounterID}" + ) + db.session.add(activity) - flash('Patient has been added successfully.', 'success') - return redirect(url_for('patients.patient_detail', patient_id=new_patient.patient_id)) + # *** Thêm Notification cho Admins *** + create_notification_for_admins( + message=f"New patient {new_patient.full_name} (ID: {new_patient.id}) added by {current_user.full_name}.", + link=('patients.patient_detail', {'patient_id': new_patient.id}), + exclude_user_id=current_user.userID + ) + # *** Kết thúc Notification *** + + db.session.commit() + flash(f'Patient {new_patient.full_name} created successfully!', 'success') + return redirect(url_for('patients.patient_detail', patient_id=new_patient.id)) return render_template('new_patient.html') @@ -761,7 +802,20 @@ def delete_physical_measurement(patient_id, measurement_id): def encounter_measurements(patient_id, encounter_id): """Hiển thị chi tiết các phép đo cho một encounter cụ thể.""" patient = Patient.query.get_or_404(patient_id) - encounter = Encounter.query.filter_by(encounterID=encounter_id, patient_id=patient_id).first_or_404() + + # Sửa lại query để include joinedload cho assigned_dietitian + encounter = Encounter.query.options( + joinedload(Encounter.assigned_dietitian) + ).filter_by( + encounterID=encounter_id, + patient_id=patient_id + ).first_or_404() + + # Debug dietitian + if encounter.dietitian_id: + current_app.logger.info(f"Encounter {encounter_id} has dietitian_id={encounter.dietitian_id}, assigned_dietitian={encounter.assigned_dietitian}") + else: + current_app.logger.info(f"Encounter {encounter_id} has no dietitian_id") # Lấy tất cả measurements cho encounter này, sắp xếp theo thời gian measurements_query = PhysiologicalMeasurement.query.filter_by( @@ -1027,565 +1081,630 @@ def upload_encounter_measurements_csv(patient_id, encounter_id): @patients_bp.route('/<string:patient_id>/encounters/new', methods=['POST']) @login_required def new_encounter(patient_id): - """Creates a new encounter for the patient, handling interruptions.""" patient = Patient.query.get_or_404(patient_id) - - # Kiểm tra xem có encounter đang ON_GOING không - ongoing_encounter = Encounter.query.filter_by( - patient_id=patient.id, - status=EncounterStatus.ON_GOING - ).first() - - # Kiểm tra xem có encounter đang NOT_STARTED không - latest_encounter = Encounter.query.filter_by( - patient_id=patient.id - ).order_by(Encounter.start_time.desc()).first() - - # Không cho phép tạo encounter mới nếu encounter gần nhất đang NOT_STARTED - if latest_encounter and latest_encounter.status == EncounterStatus.NOT_STARTED: - flash(f"Cannot create a new encounter while the latest encounter ({latest_encounter.custom_encounter_id or latest_encounter.encounterID}) is still not started.", "warning") - return redirect(url_for('patients.patient_detail', patient_id=patient.id, _anchor='encounters')) - - interrupted_report_details = None # Biến để lưu chi tiết report bị interrupt - - if ongoing_encounter: - current_app.logger.warning(f"Attempt to create new encounter for patient {patient_id} while encounter {ongoing_encounter.custom_encounter_id or ongoing_encounter.encounterID} is ON_GOING.") - if current_user.is_admin: - # Admin đang cố interrupt -> Tiến hành interrupt encounter cũ - try: - ongoing_encounter.status = EncounterStatus.FINISHED - ongoing_encounter.end_time = datetime.utcnow() - interrupted_report_details = "Interrupted by admin." - current_app.logger.info(f"Admin {current_user.userID} interrupted encounter {ongoing_encounter.custom_encounter_id or ongoing_encounter.encounterID}. Setting status to FINISHED.") - - # Tìm report tương ứng để cập nhật - report_to_interrupt = Report.query.filter_by(encounter_id=ongoing_encounter.encounterID).first() - if report_to_interrupt: - report_to_interrupt.status = 'Completed' # Đánh dấu report là hoàn thành - report_to_interrupt.completed_date = datetime.utcnow() - # Ghi chú vào assessment_details - report_to_interrupt.assessment_details += "\n\n" + interrupted_report_details if report_to_interrupt.assessment_details else interrupted_report_details - current_app.logger.info(f"Report {report_to_interrupt.id} for interrupted encounter marked as Completed.") - else: - current_app.logger.warning(f"Could not find report associated with interrupted encounter {ongoing_encounter.encounterID} to mark as completed.") - - # --- Thêm: Tìm và cập nhật Referral liên quan --- - referral_to_complete = Referral.query.filter_by(encounter_id=ongoing_encounter.encounterID).first() - if referral_to_complete: - referral_to_complete.referral_status = ReferralStatus.COMPLETED - referral_to_complete.referralCompletedDateTime = datetime.utcnow() - current_app.logger.info(f"Referral {referral_to_complete.id} for interrupted encounter marked as COMPLETED.") - else: - current_app.logger.warning(f"Could not find referral associated with interrupted encounter {ongoing_encounter.encounterID} to mark as completed.") - # ----------------------------------------------- - - # Commit thay đổi trạng thái encounter/report/referral cũ NGAY LẬP TỨC - db.session.commit() - flash(f"Interrupted previous encounter ({ongoing_encounter.custom_encounter_id or ongoing_encounter.encounterID}). Marked as Finished.", "warning") - except Exception as interrupt_err: - db.session.rollback() - current_app.logger.error(f"Error interrupting encounter {ongoing_encounter.encounterID}: {interrupt_err}", exc_info=True) - flash(f"Error interrupting previous encounter: {interrupt_err}", "danger") - return redirect(url_for('patients.patient_detail', patient_id=patient.id, _anchor='encounters')) - else: - # Dietitian đang cố tạo mới -> Ngăn chặn (JS đã xử lý redirect) - # Vẫn nên có check ở backend để an toàn - flash("Please complete the report for the current encounter before creating a new one.", "warning") - # Tìm report ID để redirect (nếu có) - report_id_to_edit = db.session.query(Report.id).filter_by(encounter_id=ongoing_encounter.encounterID).scalar() - if report_id_to_edit: - return redirect(url_for('report.edit_report', report_id=report_id_to_edit)) - else: + form = EmptyForm() + if form.validate_on_submit(): + try: # Khối try chính + # Check if there is an ON_GOING encounter + latest_encounter = Encounter.query.filter_by(patient_id=patient.id).order_by(desc(Encounter.start_time)).first() + if latest_encounter and latest_encounter.status == EncounterStatus.ON_GOING: + flash('Cannot create a new encounter while another is ongoing.', 'warning') + # Redirect immediately if ongoing encounter exists return redirect(url_for('patients.patient_detail', patient_id=patient.id, _anchor='encounters')) - # --- Tạo encounter mới (nếu không có lỗi hoặc sau khi interrupt) --- - try: - # 1. Đếm số encounter hiện có của bệnh nhân này để xác định số thứ tự tiếp theo - existing_encounter_count = Encounter.query.filter_by(patient_id=patient.id).count() - next_sequence_num = existing_encounter_count + 1 - - # 2. Tạo custom_encounter_id - patient_id_part = patient.id[-5:] if patient.id and patient.id.startswith('P-') and len(patient.id) >= 7 else patient.id - custom_id = f"E-{patient_id_part}-{next_sequence_num:02d}" - - while Encounter.query.filter_by(custom_encounter_id=custom_id).first(): - current_app.logger.warning(f"Generated custom encounter ID {custom_id} already exists. Incrementing sequence.") - next_sequence_num += 1 - custom_id = f"E-{patient_id_part}-{next_sequence_num:02d}" - - # 3. Create a new encounter instance - new_encounter_obj = Encounter( - patient_id=patient.id, - start_time=datetime.utcnow(), - status=EncounterStatus.NOT_STARTED, # Bắt đầu là NOT_STARTED - custom_encounter_id=custom_id - ) - db.session.add(new_encounter_obj) - # Commit để lấy ID cho encounter mới - db.session.flush() - new_encounter_id = new_encounter_obj.encounterID - new_custom_id = new_encounter_obj.custom_encounter_id - current_app.logger.info(f"New encounter created: {new_custom_id} (PK: {new_encounter_id}) for patient {patient_id}") + # --- Generate Custom Encounter ID --- + # (Logic for generating ID remains the same) + patient_id_number = patient.id.split('-')[-1] if '-' in patient.id else patient.id + last_custom_encounter = Encounter.query.filter( + Encounter.patient_id == patient.id, + Encounter.custom_encounter_id.like(f'E-{patient_id_number}-%') + ).order_by(desc(Encounter.custom_encounter_id)).first() + + next_seq = 1 + if last_custom_encounter and last_custom_encounter.custom_encounter_id: + try: # Khối try nhỏ cho việc parse sequence + last_seq_str = last_custom_encounter.custom_encounter_id.split('-')[-1] + next_seq = int(last_seq_str) + 1 + except (IndexError, ValueError): + current_app.logger.warning(f"Could not parse sequence from last custom encounter ID '{last_custom_encounter.custom_encounter_id}'. Starting sequence at 1.") + next_seq = 1 + + new_custom_id = f"E-{patient_id_number}-{next_seq:02d}" + # --- End Generate Custom Encounter ID --- - # 4. Cập nhật trạng thái patient dựa trên encounter mới nhất và các referral - update_patient_status_from_encounters(patient) + # Create new Encounter with custom ID + new_encounter = Encounter( + patient_id=patient.id, + start_time=datetime.utcnow(), + custom_encounter_id=new_custom_id + ) + db.session.add(new_encounter) + db.session.flush() # Get encounterID for logs/links + + # Log activity with custom ID + activity = ActivityLog( + user_id=current_user.userID, + action=f"Created new encounter {new_custom_id} (ID: {new_encounter.encounterID}) for patient {patient.full_name}", + details=f"Patient ID: {patient.id}, Encounter ID: {new_encounter.encounterID}, Custom ID: {new_custom_id}" + ) + db.session.add(activity) - # Commit encounter mới và thay đổi trạng thái patient - db.session.commit() - flash(f'New encounter {new_custom_id} created successfully.', 'success') - # Redirect to the new encounter's measurement page - return redirect(url_for('patients.encounter_measurements', patient_id=patient.id, encounter_id=new_encounter_id)) + # Update patient status + update_patient_status_from_encounters(patient) - except Exception as e: - db.session.rollback() - current_app.logger.error(f"Error creating new encounter for patient {patient.id} after check/interrupt: {str(e)}", exc_info=True) - flash(f'Error creating new encounter: {str(e)}', 'error') - return redirect(url_for('patients.patient_detail', patient_id=patient.id, _anchor='encounters')) + # Add Notification for New Encounter + encounter_display_id = new_encounter.custom_encounter_id + admin_message = f"New encounter ({encounter_display_id}) created for patient {patient.full_name} by {current_user.full_name}." + link_kwargs = {'patient_id': patient.id, 'encounter_id': new_encounter.encounterID} + admin_link = ('patients.encounter_measurements', link_kwargs) + create_notification_for_admins(admin_message, admin_link, exclude_user_id=current_user.userID) + + # Notify assigned dietitian + if patient.assigned_dietitian_user_id and patient.assigned_dietitian_user_id != current_user.userID: + dietitian_message = f"New encounter ({encounter_display_id}) created for your patient {patient.full_name}." + create_notification(patient.assigned_dietitian_user_id, dietitian_message, admin_link) + + # Commit everything at the end of the try block + db.session.commit() + flash('New encounter created successfully.', 'success') + # Redirect after successful commit inside try + return redirect(url_for('patients.encounter_measurements', patient_id=patient.id, encounter_id=new_encounter.encounterID)) + + except Exception as e: # Khối except tương ứng, cùng mức thụt lề với try + db.session.rollback() + current_app.logger.error(f"Error creating new encounter for patient {patient.id}: {str(e)}", exc_info=True) + flash(f'Error creating encounter: {str(e)}', 'danger') + # Redirect on error + return redirect(url_for('patients.patient_detail', patient_id=patient.id)) + else: + # Handle CSRF or other form validation errors + flash('Invalid request to create encounter.', 'danger') + return redirect(url_for('patients.patient_detail', patient_id=patient.id)) def update_patient_status_from_encounters(patient): - # ... (docstring và khởi tạo biến như cũ) ... - old_status_val = patient.status.value if patient.status else "None" - old_dietitian_id = patient.assigned_dietitian_user_id # Lưu lại dietitian cũ để log - new_status = None - current_app.logger.info(f"[UpdateStatus] Patient {patient.id}: Starting update. Current status: {old_status_val}, Assigned Dietitian: {old_dietitian_id}") - - # 1. Lấy encounter mới nhất - latest_encounter = Encounter.query.filter_by( - patient_id=patient.id - ).order_by(Encounter.start_time.desc()).first() - - # 2. Kiểm tra referral đang chờ (DIETITIAN_UNASSIGNED) cho encounter mới nhất (nếu có) - pending_referral_for_latest = None - if latest_encounter: - pending_referral_for_latest = Referral.query.filter_by( - encounter_id=latest_encounter.encounterID, - referral_status=ReferralStatus.DIETITIAN_UNASSIGNED - ).first() - - # 3. Logic xác định trạng thái mới + """Update patient status based on encounters and referrals. Also handles status change notifications.""" + latest_encounter = Encounter.query.filter_by(patient_id=patient.id).order_by(desc(Encounter.start_time)).first() + + # Xác định trạng thái mới dựa trên logic ưu tiên + new_status = PatientStatus.NOT_ASSESSED # Mặc định là chưa đánh giá + if latest_encounter: - # a. Encounter FINISHED -> Patient COMPLETED - if latest_encounter.status == EncounterStatus.FINISHED: + # Ưu tiên 1: Encounter chưa bắt đầu -> Patient Not Assessed + if latest_encounter.status == EncounterStatus.NOT_STARTED: + new_status = PatientStatus.NOT_ASSESSED + + # Ưu tiên 2: Encounter đã kết thúc -> Patient Completed + elif latest_encounter.status == EncounterStatus.FINISHED: + # TODO: Có thể cần kiểm tra thêm các yếu tố khác trước khi kết luận Completed new_status = PatientStatus.COMPLETED - current_app.logger.debug(f"Patient {patient.id}: Setting status to COMPLETED (latest encounter {latest_encounter.encounterID} FINISHED).") - # b. Encounter ON_GOING + + # Ưu tiên 3: Encounter đang diễn ra -> Kiểm tra Referral elif latest_encounter.status == EncounterStatus.ON_GOING: - # *SỬA*: Nếu có referral đang chờ gán -> NEEDS_ASSESSMENT - if pending_referral_for_latest: - new_status = PatientStatus.NEEDS_ASSESSMENT - current_app.logger.debug(f"Patient {patient.id}: Setting status to NEEDS_ASSESSMENT (latest encounter {latest_encounter.encounterID} ON_GOING with pending referral {pending_referral_for_latest.id}).") - # Nếu không có referral chờ -> ASSESSMENT_IN_PROGRESS (giả định đã có dietitian gán từ trước đó hoặc luồng khác) + latest_referral = Referral.query.filter_by(encounter_id=latest_encounter.encounterID).order_by(desc(Referral.referralRequestedDateTime)).first() + if latest_referral: + if latest_referral.referral_status == ReferralStatus.DIETITIAN_UNASSIGNED: + new_status = PatientStatus.NEEDS_ASSESSMENT + elif latest_referral.referral_status == ReferralStatus.WAITING_FOR_REPORT: + new_status = PatientStatus.ASSESSMENT_IN_PROGRESS + elif latest_referral.referral_status == ReferralStatus.COMPLETED: + # Encounter đang diễn ra nhưng referral đã xong? -> Vẫn là IN_PROGRESS + new_status = PatientStatus.ASSESSMENT_IN_PROGRESS else: + # Encounter đang diễn ra nhưng không có referral -> ASSESSMENT_IN_PROGRESS new_status = PatientStatus.ASSESSMENT_IN_PROGRESS - current_app.logger.debug(f"Patient {patient.id}: Setting status to ASSESSMENT_IN_PROGRESS (latest encounter {latest_encounter.encounterID} ON_GOING, no pending referral found).") - # c. Encounter NOT_STARTED -> Patient NOT_ASSESSED - elif latest_encounter.status == EncounterStatus.NOT_STARTED: - new_status = PatientStatus.NOT_ASSESSED - current_app.logger.debug(f"Patient {patient.id}: Setting status to NOT_ASSESSED (latest encounter {latest_encounter.encounterID} NOT_STARTED).") - else: # Trạng thái encounter không xác định - new_status = PatientStatus.NOT_ASSESSED # Mặc định an toàn - current_app.logger.warning(f"Patient {patient.id}: Latest encounter {latest_encounter.encounterID} has unexpected status {latest_encounter.status}. Setting patient status to NOT_ASSESSED.") - # 4. Không có encounter nào - else: - # Kiểm tra có referral nào đang chờ gán không (không cần liên kết với encounter cụ thể nữa) - any_pending_referral = Referral.query.filter_by( - patient_id=patient.id, - referral_status=ReferralStatus.DIETITIAN_UNASSIGNED - ).first() - if any_pending_referral: - new_status = PatientStatus.NEEDS_ASSESSMENT - current_app.logger.debug(f"Patient {patient.id}: Setting status to NEEDS_ASSESSMENT (no encounters, but found pending referral {any_pending_referral.id}).") - else: - new_status = PatientStatus.NOT_ASSESSED - current_app.logger.debug(f"Patient {patient.id}: Setting status to NOT_ASSESSED (no encounters, no pending referrals).") - - # 5. Cập nhật trạng thái bệnh nhân và dietitian nếu cần - status_changed = False - dietitian_removed = False - if new_status and (not patient.status or patient.status != new_status): + # So sánh và cập nhật trạng thái nếu có thay đổi + old_status = patient.status + if old_status != new_status: patient.status = new_status - status_changed = True - current_app.logger.info(f"[UpdateStatus] Patient {patient.id} status WILL BE updated to {patient.status.value}") - elif not new_status: - current_app.logger.error(f"[UpdateStatus] Patient {patient.id}: Could not determine new status.") - - # *** Quan trọng: Reset Dietitian nếu trạng thái KHÔNG phải là ASSESSMENT_IN_PROGRESS hoặc COMPLETED *** - # Giữ lại dietitian khi bệnh nhân đang được đánh giá hoặc đã hoàn thành - if new_status not in [PatientStatus.ASSESSMENT_IN_PROGRESS, PatientStatus.COMPLETED] and patient.assigned_dietitian_user_id is not None: - patient.assigned_dietitian_user_id = None - patient.assignment_date = None # Reset cả ngày gán - dietitian_removed = True - current_app.logger.info(f"[UpdateStatus] Patient {patient.id}: Dietitian assignment WILL BE removed because new status is {new_status.value if new_status else 'None'}.") - else: - # Ghi log nếu dietitian được giữ lại - if patient.assigned_dietitian_user_id is not None and (new_status == PatientStatus.ASSESSMENT_IN_PROGRESS or new_status == PatientStatus.COMPLETED): - current_app.logger.info(f"[UpdateStatus] Patient {patient.id}: Dietitian assignment ({patient.assigned_dietitian_user_id}) RETAINED for status {new_status.value}") + db.session.add(patient) + current_app.logger.info(f"Patient {patient.id} status updated from {old_status} to {new_status} by update_patient_status_from_encounters.") - if not status_changed and not dietitian_removed: - current_app.logger.info(f"[UpdateStatus] Patient {patient.id}: No changes to status ({old_status_val}) or dietitian assignment ({old_dietitian_id}).") + # *** Add Notification for Status Change *** + status_change_message = f"Patient {patient.full_name} status changed to {new_status.value}." + status_link = ('patients.patient_detail', {'patient_id': patient.id}) + + # Notify Admins + create_notification_for_admins(status_change_message, status_link) + + # Notify assigned dietitian (if exists) + if patient.assigned_dietitian_user_id: + create_notification(patient.assigned_dietitian_user_id, status_change_message, status_link) + # *** End Notification Status Change *** + else: + current_app.logger.debug(f"Patient {patient.id} status remains {old_status}.") - current_app.logger.info(f"[UpdateStatus] Patient {patient.id}: Finished update logic. Final status decided: {new_status.value if new_status else 'None'}. Dietitian ID decided: {patient.assigned_dietitian_user_id}") - return patient.status # Trả về trạng thái mới (object patient đã được thay đổi trực tiếp) + return new_status # Return the new (or unchanged) status -# Thêm route xóa encounter @patients_bp.route('/<string:patient_id>/encounters/<int:encounter_pk>/delete', methods=['POST']) @login_required def delete_encounter(patient_id, encounter_pk): - """Deletes an encounter and its associated measurements/report/referral, updating patient status and dietitian assignment if necessary.""" - encounter = Encounter.query.get_or_404(encounter_pk) + form = EmptyForm() patient = Patient.query.get_or_404(patient_id) + encounter_to_delete = Encounter.query.filter_by(encounterID=encounter_pk, patient_id=patient.id).first_or_404() - if encounter.patient_id != patient.id: - flash('Invalid encounter for this patient.', 'error') + if not current_user.is_admin: + flash('Permission denied.', 'error') return redirect(url_for('patients.patient_detail', patient_id=patient.id, _anchor='encounters')) - form = EmptyForm() - if not form.validate_on_submit(): - flash('Invalid CSRF token. Please try again.', 'error') - return redirect(url_for('patients.patient_detail', patient_id=patient.id, _anchor='encounters')) + if form.validate_on_submit(): + try: # CORRECT INDENTATION FOR TRY BLOCK + # Store info before deleting + deleted_encounter_id = encounter_to_delete.encounterID + deleted_encounter_display_id = encounter_to_delete.custom_encounter_id or deleted_encounter_id + patient_full_name = patient.full_name + dietitian_id_assigned = encounter_to_delete.dietitian_id + + # Delete related records (Measurements, Referrals, Reports, Procedures for THIS encounter) + # ... (Add deletion logic here if needed) + + db.session.delete(encounter_to_delete) + + # Log activity + activity = ActivityLog( + user_id=current_user.userID, + action=f"Deleted encounter {deleted_encounter_display_id} for patient {patient_full_name}", + details=f"Patient ID: {patient_id}, Encounter ID: {deleted_encounter_id}" + ) + db.session.add(activity) + + # Update patient status AFTER deleting the encounter + update_patient_status_from_encounters(patient) - try: - custom_id_for_flash = encounter.custom_encounter_id if encounter.custom_encounter_id else f"(PK: {encounter.encounterID})" - - # --- Hủy liên kết Dietitian nếu cần --- - # Lấy danh sách referrals của encounter này từ bảng Referral - encounter_referrals = Referral.query.filter_by(encounter_id=encounter.encounterID).all() - - # Kiểm tra xem việc gán dietitian hiện tại có phải là do referral của encounter này không - referral_linked_to_assignment = None - if patient.assigned_dietitian_user_id and encounter_referrals: - # Giả định chỉ có một referral / encounter - # Và việc gán dietitian được lưu trong patient.assigned_dietitian_user_id - # Kiểm tra xem dietitian được gán cho bệnh nhân có phải là dietitian của referral này không - encounter_referral = encounter_referrals[0] # Lấy referral đầu tiên - if encounter_referral.assigned_dietitian_user_id == patient.assigned_dietitian_user_id: - # Nếu dietitian gán cho patient trùng với dietitian của referral sắp bị xóa - referral_linked_to_assignment = encounter_referral # Lưu lại để biết cần hủy gán - - # --- Xóa các đối tượng liên quan --- - # Tìm và xóa Report liên quan - report_to_delete = Report.query.filter_by(encounter_id=encounter.encounterID).first() - if report_to_delete: - db.session.delete(report_to_delete) - current_app.logger.info(f"Deleted report {report_to_delete.id} associated with encounter {encounter_pk}.") + # Add Notification for Encounter Deletion + admin_message = f"Encounter ({deleted_encounter_display_id}) for patient {patient_full_name} deleted by {current_user.full_name}." + admin_link = ('patients.patient_detail', {'patient_id': patient.id, '_anchor': 'encounters'}) + create_notification_for_admins(admin_message, admin_link, exclude_user_id=current_user.userID) - # Tìm và xóa Referral liên quan (nếu có) - # Sử dụng encounter_referrals đã query ở trên - if encounter_referrals: - for ref in encounter_referrals: - db.session.delete(ref) - current_app.logger.info(f"Deleted referral {ref.id} associated with encounter {encounter_pk}.") - - # Xóa encounter (và measurements do cascade) - db.session.delete(encounter) - current_app.logger.info(f"Deleted encounter {encounter_pk}.") - - # --- Cập nhật Patient --- - # Hủy gán dietitian NẾU referral liên quan bị xóa (Thực hiện TRƯỚC khi cập nhật status) - if referral_linked_to_assignment: - patient.assigned_dietitian_user_id = None - patient.assessment_date = None # Reset ngày đánh giá - current_app.logger.info(f"Unassigned dietitian from patient {patient.id} because associated referral {referral_linked_to_assignment.id} was deleted.") - # Flash message này nên hiển thị sau khi commit thành công - # flash('Dietitian assignment removed as the related encounter/referral was deleted.', 'warning') - - # Cập nhật trạng thái của bệnh nhân dựa trên encounter còn lại và referrals - new_status = update_patient_status_from_encounters(patient) - - # Commit tất cả thay đổi vào DB - db.session.commit() - - # Flash messages sau khi commit thành công - flash(f'Encounter {custom_id_for_flash} deleted successfully.', 'success') - if referral_linked_to_assignment: # Flash message hủy gán dietitian ở đây - flash('Dietitian assignment removed as the related encounter/referral was deleted.', 'warning') - if hasattr(patient, 'status') and patient.status == new_status: # Kiểm tra xem status đã được gán chưa - flash(f'Patient status is now {new_status.value}.', 'info') + # Notify the dietitian who WAS assigned to THIS encounter + if dietitian_id_assigned and dietitian_id_assigned != current_user.userID: + dietitian_message = f"Encounter ({deleted_encounter_display_id}) for patient {patient_full_name} (which you were assigned to) was deleted." + create_notification(dietitian_id_assigned, dietitian_message, admin_link) + + # --- THÊM LOGIC HỦY ASSIGNMENT --- + if dietitian_id_assigned: # Nếu encounter bị xóa có gán dietitian + active_assignment = PatientDietitianAssignment.get_active_assignment(patient.id) + # Chỉ hủy assignment nếu dietitian của assignment đang active trùng với dietitian của encounter bị xóa + if active_assignment and active_assignment.dietitian_id == dietitian_id_assigned: + current_app.logger.info(f"Deactivating assignment {active_assignment.id} for patient {patient.id} due to deletion of encounter {deleted_encounter_id}") + active_assignment.is_active = False + active_assignment.end_date = datetime.utcnow() + db.session.add(active_assignment) + # Gửi thêm thông báo về việc hủy assignment + deactivation_message = f"Your assignment to patient {patient.full_name} was deactivated because the related encounter was deleted." + deactivation_link = ('patients.patient_detail', {'patient_id': patient.id}) + create_notification(dietitian_id_assigned, deactivation_message, deactivation_link) + elif active_assignment: + current_app.logger.info(f"Deleted encounter {deleted_encounter_id} had dietitian {dietitian_id_assigned}, but active assignment {active_assignment.id} is for dietitian {active_assignment.dietitian_id}. Assignment not deactivated.") + else: + current_app.logger.info(f"Deleted encounter {deleted_encounter_id} had dietitian {dietitian_id_assigned}, but no active assignment found for patient {patient.id}. Assignment not deactivated.") + # --- KẾT THÚC LOGIC HỦY ASSIGNMENT --- + + # Commit changes (bao gồm cả việc hủy assignment nếu có) + db.session.commit() # Sửa lỗi thụt lề + flash(f'Encounter {deleted_encounter_display_id} deleted successfully.', 'success') # Sửa lỗi thụt lề - except Exception as e: - db.session.rollback() - current_app.logger.error(f"Error deleting encounter {encounter_pk} for patient {patient.id}: {str(e)}", exc_info=True) - flash(f'Error deleting encounter: {str(e)}', 'error') + except Exception as e: # CORRECT INDENTATION FOR EXCEPT BLOCK + db.session.rollback() # Sửa lỗi thụt lề + current_app.logger.error(f"Error deleting encounter {encounter_pk}: {str(e)}", exc_info=True) # Sửa lỗi thụt lề + flash(f'Error deleting encounter: {str(e)}', 'error') # Sửa lỗi thụt lề - return redirect(url_for('patients.patient_detail', patient_id=patient.id, _anchor='encounters')) + # Redirect outside the try...except block but inside the if form.validate_on_submit() + return redirect(url_for('patients.patient_detail', patient_id=patient.id, _anchor='encounters')) # Sửa lỗi thụt lề + else: + # Handle CSRF error + flash('Invalid request to delete encounter.', 'danger') # Sửa lỗi thụt lề + return redirect(url_for('patients.patient_detail', patient_id=patient_id, _anchor='encounters')) # Sửa lỗi thụt lề @patients_bp.route('/<string:patient_id>/encounter/<int:encounter_id>/run_ml', methods=['POST']) @login_required def run_encounter_ml(patient_id, encounter_id): - """Chạy dự đoán ML cho encounter và cập nhật trạng thái theo quy trình mới.""" - patient = Patient.query.get_or_404(patient_id) - encounter = Encounter.query.filter_by(encounterID=encounter_id, patient_id=patient.id).first_or_404() - form = EmptyForm() # Sử dụng form rỗng để validate CSRF - - if not form.validate_on_submit(): - flash('Invalid CSRF token. Please try again.', 'danger') - return redirect(url_for('patients.encounter_measurements', patient_id=patient_id, encounter_id=encounter_id)) - - try: - # *** THÊM: Kiểm tra xem encounter có measurement chưa *** - measurement_count = PhysiologicalMeasurement.query.filter_by(encounter_id=encounter.encounterID).count() - if measurement_count == 0: - current_app.logger.warning(f"ML prediction skipped for encounter {encounter_id}: No measurements found.") - flash("No measurements found for this encounter. Please upload data before running the ML prediction.", "warning") - return redirect(url_for('patients.encounter_measurements', patient_id=patient_id, encounter_id=encounter_id)) - # *** KẾT THÚC KIỂM TRA *** - - # Cập nhật trạng thái encounter thành ON_GOING khi chạy ML - if encounter.status == EncounterStatus.NOT_STARTED: - encounter.status = EncounterStatus.ON_GOING - current_app.logger.info(f"Encounter {encounter_id} status updated to ON_GOING after running ML") - - # Chạy ML prediction và cập nhật encounter - needs_intervention_result = _run_ml_prediction(encounter) - - # Chỉ xử lý nếu kết quả ML không phải None (không có lỗi) - if needs_intervention_result is not None: - # Cập nhật encounter.needs_intervention - encounter.needs_intervention = needs_intervention_result - - if needs_intervention_result: - # Nếu cần can thiệp, tạo referral nếu chưa có - existing_referral = Referral.query.filter_by( - patient_id=patient.id, - encounter_id=encounter.encounterID - ).first() - - if not existing_referral: - new_referral = Referral( - patient_id=patient.id, - encounter_id=encounter.encounterID, - # Sử dụng notes thay vì referral_reason - notes="Algorithm detected need for assessment based on physiological measurements.", - referralRequestedDateTime=datetime.utcnow(), - referral_status=ReferralStatus.DIETITIAN_UNASSIGNED, - is_ml_recommended=True # Đánh dấu là do ML tạo - ) - db.session.add(new_referral) - current_app.logger.info(f"Created new ML-based referral for patient {patient_id}, encounter {encounter_id}") - flash("ML prediction indicates intervention needed. A new referral has been created.", "info") - else: - flash("ML prediction indicates intervention needed. An existing referral is already in place.", "info") - else: - # Nếu không cần can thiệp - flash("ML prediction indicates no intervention needed at this time.", "success") - # *** THÊM LOGIC CẬP NHẬT TRẠNG THÁI ENCOUNTER *** - if encounter.status == EncounterStatus.ON_GOING: - encounter.status = EncounterStatus.FINISHED - encounter.end_time = datetime.utcnow() # Ghi lại thời gian kết thúc - current_app.logger.info(f"Encounter {encounter_id} status updated to FINISHED as ML indicated no intervention needed.") - # *** KẾT THÚC THÊM LOGIC *** + form = EmptyForm() # Add form for CSRF validation + if form.validate_on_submit(): + try: # Start try block + # Fetch patient and encounter FIRST + patient = Patient.query.get_or_404(patient_id) # Sửa lỗi thụt lề + encounter = Encounter.query.filter_by(encounterID=encounter_id, patient_id=patient.id).first_or_404() # Sửa lỗi thụt lề + + # Check if measurements exist (Example check) + if not encounter.measurements: # Sửa lỗi thụt lề + flash('No measurements found for this encounter. Cannot run prediction.', 'warning') # Sửa lỗi thụt lề + return redirect(url_for('patients.encounter_measurements', patient_id=patient_id, encounter_id=encounter_id)) # Sửa lỗi thụt lề + + # Now you can safely access encounter + original_encounter_status = encounter.status # Sửa lỗi thụt lề + encounter.status = EncounterStatus.ON_GOING # Set to ON_GOING when ML runs # Sửa lỗi thụt lề + needs_intervention_result = _run_ml_prediction(encounter) # Sửa lỗi thụt lề - # Quan trọng: Cập nhật trạng thái bệnh nhân SAU KHI xử lý ML và Referral - update_patient_status_from_encounters(patient) + if needs_intervention_result is not None: # Sửa lỗi thụt lề + encounter.needs_intervention = needs_intervention_result # Sửa lỗi thụt lề + referral_created_by_ml = False # Sửa lỗi thụt lề + + if needs_intervention_result: # Sửa lỗi thụt lề + existing_referral = Referral.query.filter_by(patient_id=patient.id, encounter_id=encounter.encounterID).first() # Sửa lỗi thụt lề + if not existing_referral: # Sửa lỗi thụt lề + # Need to define referral details properly here + new_referral = Referral( # Sửa lỗi thụt lề + patient_id=patient.id, # Sửa lỗi thụt lề + encounter_id=encounter.encounterID, # Sửa lỗi thụt lề + notes="ML Prediction Recommended Assessment", # Sửa lỗi thụt lề + referral_status=ReferralStatus.DIETITIAN_UNASSIGNED, # Sửa lỗi thụt lề + referralRequestedDateTime=datetime.utcnow(), # Sửa lỗi thụt lề + is_ml_recommended=True # Sửa lỗi thụt lề + ) # Sửa lỗi thụt lề + db.session.add(new_referral) # Sửa lỗi thụt lề + db.session.flush() # Get referral ID before creating report # Sửa lỗi thụt lề + referral_created_by_ml = True # Sửa lỗi thụt lề + flash("ML prediction indicates intervention needed. A new referral has been created.", "info") # Sửa lỗi thụt lề + + # *** ADD AUTO-CREATE REPORT LOGIC HERE *** + try: # Sửa lỗi thụt lề + # Check if a report for this encounter already exists (e.g., from a previous failed attempt) + existing_report = Report.query.filter_by(encounter_id=encounter.encounterID).first() # Sửa lỗi thụt lề + if not existing_report: # Sửa lỗi thụt lề + new_report = Report( # Sửa lỗi thụt lề + author_id=current_user.userID, # User who triggered ML # Sửa lỗi thụt lề + patient_id=patient.id, # Sửa lỗi thụt lề + encounter_id=encounter.encounterID, # Sửa lỗi thụt lề + referral_id=new_referral.id, # Link to the new referral # Sửa lỗi thụt lề + report_date=datetime.utcnow(), # Sửa lỗi thụt lề + report_type='initial_assessment', # Or a specific type like 'ml_triggered'? # Sửa lỗi thụt lề + status=ReportStatus.PENDING, # Đổi thành Pending # Sửa lỗi thụt lề + # Add minimal default text to required fields to pass potential validation + # Kiểm tra lại model Report xem các trường này có nullable=False không + nutrition_diagnosis="Assessment required based on ML prediction.", # Sửa lỗi thụt lề + assessment_details="Pending dietitian assessment.", # Sửa lỗi thụt lề + intervention_plan="Pending dietitian assessment.", # Sửa lỗi thụt lề + monitoring_plan="Pending dietitian assessment." # Sửa lỗi thụt lề + ) # Sửa lỗi thụt lề + db.session.add(new_report) # Sửa lỗi thụt lề + current_app.logger.info(f"Auto-created draft report for encounter {encounter.encounterID} based on ML prediction.") # Sửa lỗi thụt lề + # Optionally create notification for assigned dietitian/admin about the new DRAFT report? + else: # Sửa lỗi thụt lề + current_app.logger.warning(f"Report for encounter {encounter.encounterID} already exists, skipping auto-creation.") # Sửa lỗi thụt lề + + except Exception as report_error: # Sửa lỗi thụt lề + # Log error but don't necessarily fail the whole ML process + current_app.logger.error(f"Failed to auto-create report for encounter {encounter.encounterID}: {report_error}", exc_info=True) # Sửa lỗi thụt lề + flash("Warning: Could not automatically create a draft report.", "warning") # Sửa lỗi thụt lề + # *** END AUTO-CREATE REPORT LOGIC *** + + # Create Notifications for Referral + admin_users = User.query.filter_by(role='admin').all() # Sửa lỗi thụt lề + admin_ids = [admin.userID for admin in admin_users] # Sửa lỗi thụt lề + # Tạo thông báo cho tất cả admin ngoại trừ người dùng hiện tại nếu họ là admin + referral_message = f"New Referral for Patient {patient.full_name} (ML Recommended)" # Sửa lỗi thụt lề + referral_link = ('referrals.view_referral', {'referral_id': new_referral.id}) # Sửa lỗi thụt lề + # SỬA TÊN THAM SỐ Ở ĐÂY + create_notification_for_admins( # Sửa lỗi thụt lề + message=referral_message, # Sửa lỗi thụt lề + link=referral_link, # Đổi link_tuple thành link # Sửa lỗi thụt lề + exclude_user_id=current_user.userID if current_user.role == 'admin' else None # Sửa lỗi thụt lề + ) # Sửa lỗi thụt lề + # TODO: Consider notifying specific dietitians if needed, although referral is initially unassigned. + + else: # Sửa lỗi thụt lề + flash("ML prediction indicates intervention needed. An existing referral is already in place.", "info") # Sửa lỗi thụt lề + else: # Khi không cần can thiệp # Sửa lỗi thụt lề + flash("ML prediction indicates no intervention needed at this time.", "success") # Sửa lỗi thụt lề + + # Luôn cập nhật encounter thành FINISHED nếu nó chưa finished + if encounter.status != EncounterStatus.FINISHED: # Sửa lỗi thụt lề + encounter.status = EncounterStatus.FINISHED # Sửa lỗi thụt lề + encounter.end_time = datetime.utcnow() # Sửa lỗi thụt lề + current_app.logger.info(f"Encounter {encounter.encounterID} status updated to FINISHED as ML indicated no intervention needed.") # Sửa lỗi thụt lề + db.session.add(encounter) # Add encounter to session if changed # Sửa lỗi thụt lề + + # Add Notification for Admins & Dietitian about Encounter FINISHED + encounter_display_id = encounter.custom_encounter_id or encounter.encounterID # Sửa lỗi thụt lề + finish_message = f"Encounter ({encounter_display_id}) for patient {patient.full_name} marked as FINISHED (ML)." # Sửa lỗi thụt lề + finish_link = ('patients.encounter_measurements', {'patient_id': patient.id, 'encounter_id': encounter.encounterID}) # Sửa lỗi thụt lề + create_notification_for_admins(finish_message, finish_link) # Sửa lỗi thụt lề + if encounter.dietitian_id: # Sửa lỗi thụt lề + create_notification(encounter.dietitian_id, finish_message, finish_link) # Sửa lỗi thụt lề + + # Kiểm tra và cập nhật Referral hiện có thành COMPLETED + existing_referral = Referral.query.filter_by(encounter_id=encounter.encounterID).first() # Sửa lỗi thụt lề + if existing_referral and existing_referral.referral_status != ReferralStatus.COMPLETED: # Sửa lỗi thụt lề + current_app.logger.info(f"Updating existing referral {existing_referral.id} to COMPLETED for encounter {encounter.encounterID} as ML indicated no intervention.") # Sửa lỗi thụt lề + existing_referral.referral_status = ReferralStatus.COMPLETED # Sửa lỗi thụt lề + # Có thể thêm logic cập nhật referralCompletedDateTime nếu cần + db.session.add(existing_referral) # Add referral to session if changed # Sửa lỗi thụt lề + + # Cập nhật trạng thái bệnh nhân (sẽ tự xử lý logic COMPLETED dựa trên encounter FINISHED) + update_patient_status_from_encounters(patient) # Sửa lỗi thụt lề + db.session.commit() # Commit tất cả thay đổi (encounter, referral, report, patient status) # Sửa lỗi thụt lề - db.session.commit() - else: - # Có lỗi xảy ra trong quá trình chạy ML (_run_ml_prediction trả về None) - flash("An error occurred during ML prediction. Please check logs.", "danger") - # Không commit và redirect về trang encounter + else: # needs_intervention_result is None (lỗi ML) # Sửa lỗi thụt lề + flash("An error occurred during ML prediction. Please check logs.", "danger") # Sửa lỗi thụt lề + # Không commit nếu ML lỗi - return redirect(url_for('patients.encounter_measurements', patient_id=patient_id, encounter_id=encounter_id)) + # Luôn redirect sau khi xử lý xong khối try (hoặc nếu ML lỗi) + return redirect(url_for('patients.encounter_measurements', patient_id=patient_id, encounter_id=encounter_id)) # Sửa lỗi thụt lề - except Exception as e: - db.session.rollback() - current_app.logger.error(f"Error running ML for encounter {encounter_id}: {str(e)}", exc_info=True) - flash(f"Error running ML prediction: {str(e)}", "danger") - return redirect(url_for('patients.encounter_measurements', patient_id=patient_id, encounter_id=encounter_id)) + except Exception as e: # Sửa lỗi thụt lề + # ... (khối except) ... + db.session.rollback() # Rollback if any error occurs during the process # Sửa lỗi thụt lề + current_app.logger.error(f"Error running ML for encounter {encounter_id}: {str(e)}", exc_info=True) # Sửa lỗi thụt lề + flash(f"Error running ML prediction: {str(e)}", "danger") # Sửa lỗi thụt lề + return redirect(url_for('patients.encounter_measurements', patient_id=patient_id, encounter_id=encounter_id)) # Sửa lỗi thụt lề + else: + # Handle CSRF error + flash('Invalid request to run ML prediction.', 'danger') # Sửa lỗi thụt lề + return redirect(url_for('patients.encounter_measurements', patient_id=patient_id, encounter_id=encounter_id)) # Sửa lỗi thụt lề @patients_bp.route('/<string:patient_id>/delete', methods=['POST']) @login_required def delete_patient(patient_id): patient = Patient.query.get_or_404(patient_id) - form = EmptyForm() # Dùng để validate CSRF + if not current_user.is_admin: + flash('Permission denied.', 'danger') + return redirect(url_for('patients.index')) + form = EmptyForm() # Use for CSRF validation if form.validate_on_submit(): try: - # Cần xem xét kỹ việc xóa cascade hoặc xóa thủ công các bản ghi liên quan - # Ví dụ: Xóa encounters, measurements, referrals, etc. - # Hiện tại chỉ xóa patient, CẦN ĐIỀU CHỈNH logic xóa liên quan nếu cần - - # Lấy tên bệnh nhân để hiển thị thông báo - patient_name = patient.full_name + patient_name = patient.full_name # Store name before deletion + patient_id_deleted = patient.id - # Xóa các bản ghi liên quan trước (VÍ DỤ - cần kiểm tra model relationships) - # Referral.query.filter_by(patient_id=patient.id).delete() - # Report.query.filter_by(patient_id=patient.id).delete() - # Procedure.query.filter_by(patient_id=patient.id).delete() - # PhysiologicalMeasurement.query.filter_by(patient_id=patient.id).delete() + # Consider cascading deletes or manual deletion of related records + # Example: Delete associated encounters, which might cascade further # Encounter.query.filter_by(patient_id=patient.id).delete() - # # Lưu ý: Thứ tự xóa quan trọng nếu có foreign key constraints + # Be VERY careful with cascading deletes or manual deletion order db.session.delete(patient) + + # Log activity + activity = ActivityLog( + user_id=current_user.userID, + action=f"Deleted patient: {patient_name} (ID: {patient_id_deleted})", + details=f"Patient ID: {patient_id_deleted}" + ) + db.session.add(activity) + + # *** Add Notification for Admins *** + create_notification_for_admins( + message=f"Patient {patient_name} (ID: {patient_id_deleted}) deleted by {current_user.full_name}.", + # No link as the patient is deleted + exclude_user_id=current_user.userID + ) + # *** End Notification *** + db.session.commit() - flash(f'Patient {patient_name} (ID: {patient_id}) has been deleted successfully.', 'success') + flash(f'Patient {patient_name} (ID: {patient_id_deleted}) has been deleted successfully.', 'success') return redirect(url_for('patients.index')) except Exception as e: db.session.rollback() current_app.logger.error(f"Error deleting patient {patient_id}: {str(e)}", exc_info=True) flash(f'Error deleting patient: {str(e)}', 'danger') - # Redirect về trang chi tiết nếu xóa lỗi return redirect(url_for('patients.patient_detail', patient_id=patient_id)) else: - # Lỗi CSRF hoặc lỗi form khác + # CSRF error or other form error flash('Invalid request. Could not delete patient.', 'danger') return redirect(url_for('patients.index')) @patients_bp.route('/<string:patient_id>/assign_dietitian', methods=['POST']) @login_required def assign_dietitian(patient_id): - form = EmptyForm() + form = EmptyForm() # For CSRF validation if form.validate_on_submit(): - patient = Patient.query.filter_by(id=patient_id).first_or_404() + patient = Patient.query.options(joinedload(Patient.referrals)).filter_by(id=patient_id).first_or_404() + + # --- Permission & Status Checks --- + if not current_user.is_admin: + flash('Permission denied.', 'danger') + return redirect(url_for('patients.patient_detail', patient_id=patient_id)) - # Kiểm tra trạng thái bệnh nhân có phù hợp để gán không if patient.status != PatientStatus.NEEDS_ASSESSMENT: - flash(f'Bệnh nhân không ở trạng thái "{PatientStatus.NEEDS_ASSESSMENT.value}" để gán chuyên gia.', 'warning') + flash(f'Patient is not currently in {PatientStatus.NEEDS_ASSESSMENT.value} status.', 'warning') return redirect(url_for('patients.patient_detail', patient_id=patient_id)) + # --- End Checks --- - assignment_type = request.form.get("assignment_type", "manual") - dietitian = None + assignment_type = request.form.get('assignment_type') + notes = request.form.get('notes', '') + selected_dietitian_id = None + assigned_dietitian = None - if assignment_type == "auto": - # Tự động gán cho dietitian có ít bệnh nhân nhất - # Query Users with Dietitian role and count their assigned patients - dietitians_with_counts = db.session.query( - User, - func.count(Patient.assigned_dietitian_user_id).label('patient_count') + try: + if assignment_type == 'auto': + # --- Auto Assign Logic --- + current_app.logger.info(f"Attempting auto-assignment for patient {patient.id}") + # Find dietitian with the fewest assigned patients + # Query users with role Dietitian, join Patient on assigned_dietitian_user_id + # Count patients per dietitian, order by count ascending + dietitian_counts = db.session.query( + User.userID, + func.count(Patient.id).label('patient_count') ).outerjoin(Patient, User.userID == Patient.assigned_dietitian_user_id)\ .filter(User.role == 'Dietitian')\ .group_by(User.userID)\ - .order_by(func.count(Patient.assigned_dietitian_user_id))\ + .order_by(func.count(Patient.id).asc())\ .all() - if not dietitians_with_counts: - flash("Không tìm thấy chuyên gia dinh dưỡng nào trong hệ thống.", "error") - return redirect(url_for("patients.patient_detail", patient_id=patient_id)) - - # Chọn dietitian đầu tiên (có ít bệnh nhân nhất) - dietitian = dietitians_with_counts[0][0] # Lấy đối tượng User - else: - # Gán thủ công - dietitian_id = request.form.get("dietitian_id") - if not dietitian_id: - flash("Vui lòng chọn một chuyên gia dinh dưỡng.", "error") - return redirect(url_for("patients.patient_detail", patient_id=patient_id)) - - dietitian = User.query.get(dietitian_id) - if not dietitian or dietitian.role != 'Dietitian': # Kiểm tra cả role - flash("Chuyên gia dinh dưỡng không hợp lệ.", "error") - return redirect(url_for("patients.patient_detail", patient_id=patient_id)) - - notes = request.form.get("notes", "") - - if patient.assigned_dietitian_user_id != dietitian.userID: - try: - # *** SỬA LOGIC TÌM ENCOUNTER/REFERRAL *** - # 1. Tìm referral mới nhất đang chờ gán cho bệnh nhân này - referral_to_update = Referral.query.filter_by( - patient_id=patient.id, - referral_status=ReferralStatus.DIETITIAN_UNASSIGNED - ).order_by(desc(Referral.referralRequestedDateTime)).first() - - if not referral_to_update: - flash(f'Không tìm thấy yêu cầu đánh giá (referral) nào đang chờ được gán cho bệnh nhân này.', 'error') + if not dietitian_counts: + flash('No dietitians available for auto-assignment.', 'error') return redirect(url_for('patients.patient_detail', patient_id=patient_id)) - # 2. Lấy encounter liên kết với referral đó - if not referral_to_update.encounter_id: - flash(f'Referral đang chờ (ID: {referral_to_update.id}) không liên kết với lượt khám nào.', 'error') - return redirect(url_for('patients.patient_detail', patient_id=patient_id)) - - encounter_to_update = Encounter.query.get(referral_to_update.encounter_id) - - if not encounter_to_update: - flash(f'Không tìm thấy lượt khám (encounter) liên kết với referral ID {referral_to_update.id}.', 'error') - return redirect(url_for('patients.patient_detail', patient_id=patient_id)) + # Select the dietitian with the lowest count + selected_dietitian_id = dietitian_counts[0][0] + assigned_dietitian = User.query.get(selected_dietitian_id) + if not assigned_dietitian: + # Should not happen if query is correct, but check anyway + raise ValueError(f"Could not find dietitian user with ID {selected_dietitian_id} for auto-assignment.") + current_app.logger.info(f"Auto-assigning patient {patient.id} to dietitian {assigned_dietitian.full_name} (ID: {selected_dietitian_id}) with {dietitian_counts[0][1]} patients.") + # --- End Auto Assign Logic --- + + elif assignment_type == 'manual': + # --- Manual Assign Logic --- + selected_dietitian_id = request.form.get('dietitian_id') + if not selected_dietitian_id: + flash('Please select a dietitian for manual assignment.', 'warning') + # Need to redirect back, ideally reopening the modal - harder without JS state saving + # Simplest redirect for now: + return redirect(url_for('patients.patient_detail', patient_id=patient_id, action='assign')) # Try to reopen modal + + assigned_dietitian = User.query.filter_by(userID=selected_dietitian_id, role='Dietitian').first() + if not assigned_dietitian: + flash('Invalid dietitian selected.', 'error') + return redirect(url_for('patients.patient_detail', patient_id=patient_id, action='assign')) + current_app.logger.info(f"Manually assigning patient {patient.id} to dietitian {assigned_dietitian.full_name} (ID: {selected_dietitian_id}). Notes: {notes}") + # --- End Manual Assign Logic --- + + else: + flash('Invalid assignment type specified.', 'error') + return redirect(url_for('patients.patient_detail', patient_id=patient_id)) # Sửa lỗi thụt lề - # Optional: Kiểm tra trạng thái encounter có hợp lệ không (ví dụ: không phải FINISHED) - if encounter_to_update.status == EncounterStatus.FINISHED: - flash(f'Lượt khám (ID: {encounter_to_update.custom_encounter_id or encounter_to_update.encounterID}) liên kết với referral đã hoàn thành.', 'warning') - # Có thể vẫn cho phép gán nhưng logic sau đó cần xem xét? - # Tạm thời cho phép tiếp tục, nhưng log lại - current_app.logger.warning(f"Assigning dietitian to patient {patient_id} although associated encounter {encounter_to_update.encounterID} is FINISHED.") - # *** KẾT THÚC SỬA LOGIC TÌM KIẾM *** - - # --- Bắt đầu cập nhật --- - old_dietitian_id = patient.assigned_dietitian_user_id - - # 1. Cập nhật Patient - patient.assigned_dietitian_user_id = dietitian.userID + # --- Update Patient and Related Objects --- + if assigned_dietitian: + patient.assigned_dietitian_user_id = assigned_dietitian.userID patient.assignment_date = datetime.utcnow() - patient.status = PatientStatus.ASSESSMENT_IN_PROGRESS - - # 2. Cập nhật Encounter - encounter_to_update.status = EncounterStatus.ON_GOING - # Gán dietitian cho encounter luôn? (Có thể cần thiết nếu report/logic khác dựa vào đây) - encounter_to_update.dietitian_id = dietitian.userID - - # 3. Cập nhật Referral - referral_to_update.referral_status = ReferralStatus.WAITING_FOR_REPORT - referral_to_update.assigned_dietitian_user_id = dietitian.userID # Gán dietitian cho referral + patient.status = PatientStatus.ASSESSMENT_IN_PROGRESS # Update patient status + db.session.add(patient) + + # Find the latest relevant referral needing assignment and update it + latest_needing_referral = Referral.query.filter( + Referral.patient_id == patient.id, + Referral.referral_status == ReferralStatus.DIETITIAN_UNASSIGNED + ).order_by(desc(Referral.referralRequestedDateTime)).first() - # 4. Tạo Report mới - new_report = Report( + if latest_needing_referral: + latest_needing_referral.assigned_dietitian_user_id = assigned_dietitian.userID + latest_needing_referral.referral_status = ReferralStatus.WAITING_FOR_REPORT # Update referral status + # Add assignment notes to referral notes? + if notes: + latest_needing_referral.notes = f"(Assignment Note: {notes})\n{latest_needing_referral.notes or ''}" + db.session.add(latest_needing_referral) + current_app.logger.info(f"Updated referral {latest_needing_referral.id} status to WAITING_FOR_REPORT and assigned dietitian.") + else: + current_app.logger.warning(f"Could not find a referral needing assignment for patient {patient.id} when assigning dietitian.") + + # --- ADD: Update latest ONGOING encounter with the assigned dietitian --- + latest_ongoing_encounter = Encounter.query.filter_by( patient_id=patient.id, - encounter_id=encounter_to_update.encounterID, - dietitian_id=dietitian.userID, # Lưu ID của dietitian tạo report - author_id=current_user.userID, # Người tạo report là người đang phân công - report_date=datetime.utcnow(), - report_type='initial_assessment', # Thêm loại báo cáo - status='Pending' # Trạng thái ban đầu của report - # Thêm các trường mặc định khác nếu cần - ) - db.session.add(new_report) - current_app.logger.info(f"Created new Report (ID: will be assigned on commit) for encounter {encounter_to_update.encounterID}") - - # 5. Log hoạt động - activity_details = f"Patient ID: {patient.id}, Encounter ID: {encounter_to_update.encounterID}" - if notes: - activity_details += f"\nAssignment Notes: {notes}" - action_message = f"Assigned dietitian {dietitian.full_name} to patient {patient.full_name}" - if assignment_type == "auto": - action_message = f"AUTO-Assigned dietitian {dietitian.full_name} to patient {patient.full_name}" + status=EncounterStatus.ON_GOING + ).order_by(desc(Encounter.start_time)).first() + if latest_ongoing_encounter: + latest_ongoing_encounter.dietitian_id = assigned_dietitian.userID + db.session.add(latest_ongoing_encounter) + # Đảm bảo refresh lại object để SQLAlchemy cập nhật relationship + db.session.flush() + # Nếu chạy mà vẫn không hiện tên dietitian, thử thêm dòng này: + db.session.refresh(latest_ongoing_encounter) + current_app.logger.info(f"Updated latest ongoing encounter {latest_ongoing_encounter.encounterID} with assigned dietitian {assigned_dietitian.userID}. Relationship loaded: {latest_ongoing_encounter.assigned_dietitian is not None}") + else: + current_app.logger.warning(f"Could not find an ongoing encounter for patient {patient.id} to assign dietitian to.") + # --- END ADD --- + + # --- ADD: Update dietitian_id for related PENDING reports --- + if latest_ongoing_encounter: # Chỉ cập nhật report nếu có encounter liên quan + associated_pending_reports = Report.query.filter_by( + encounter_id=latest_ongoing_encounter.encounterID, + status=ReportStatus.PENDING + ).all() + + updated_report_count = 0 + for report_to_update in associated_pending_reports: + if report_to_update.dietitian_id is None: # Chỉ cập nhật nếu chưa có dietitian + report_to_update.dietitian_id = assigned_dietitian.userID + db.session.add(report_to_update) + updated_report_count += 1 + + if updated_report_count > 0: + current_app.logger.info(f"Updated dietitian_id for {updated_report_count} pending reports associated with encounter {latest_ongoing_encounter.encounterID}.") + # --- END ADD --- + + # --- Logging and Notifications --- + # Log Activity + log_message = f"Assigned dietitian {assigned_dietitian.full_name} to patient {patient.full_name} ({assignment_type} assignment)." activity = ActivityLog( user_id=current_user.userID, - action=action_message, - details=activity_details + action=log_message, + details=f"Patient ID: {patient.id}, Dietitian ID: {assigned_dietitian.userID}, Notes: {notes}" ) db.session.add(activity) - # 6. Thông báo cho dietitian mới - notification = Notification( - user_id=dietitian.userID, - message=f"Bạn đã được gán cho bệnh nhân {patient.full_name} (ID: {patient.id}) cho lượt khám {encounter_to_update.custom_encounter_id or encounter_to_update.encounterID}. Vui lòng hoàn thành báo cáo đánh giá.", - is_read=False, - link=url_for("patients.patient_detail", patient_id=patient.id) # Link đến patient detail hoặc trang report? + # Create Notification for the assigned Dietitian + create_notification( + recipient_user_id=assigned_dietitian.userID, + message=f"You have been assigned to patient {patient.full_name}.", + link=('patients.patient_detail', {'patient_id': patient.id}) + ) + # Optionally notify other admins (excluding current user) + create_notification_for_admins( + message=f"Dietitian {assigned_dietitian.full_name} assigned to patient {patient.full_name} by {current_user.full_name}.", + link=('patients.patient_detail', {'patient_id': patient.id}), + exclude_user_id=current_user.userID ) - db.session.add(notification) + # --- End Logging and Notifications --- - # 7. Commit tất cả thay đổi db.session.commit() + flash(f'Successfully assigned dietitian {assigned_dietitian.full_name} to {patient.full_name}.', 'success') + else: + # This case should ideally not be reached if logic above is correct + flash('Could not determine dietitian to assign.', 'error') + + except Exception as e: # Sửa lỗi thụt lề + db.session.rollback() # Sửa lỗi thụt lề + current_app.logger.error(f"Error assigning dietitian for patient {patient.id}: {str(e)}", exc_info=True) # Sửa lỗi thụt lề + flash(f'An error occurred during assignment: {str(e)}', 'danger') # Sửa lỗi thụt lề + + # --- RETURN STATEMENT --- + # Always redirect back to the patient detail page after processing + return redirect(url_for('patients.patient_detail', patient_id=patient_id)) + + # Handle CSRF error or non-POST request (though route only accepts POST) + flash('Invalid request.', 'danger') + # Redirect back to patient detail or maybe index? + return redirect(url_for('patients.patient_detail', patient_id=patient_id if patient_id else 'index')) # Need a fallback if patient_id isn't available + +# --- NEW ROUTE FOR BULK AUTO ASSIGN --- +@patients_bp.route('/bulk_auto_assign', methods=['POST']) +@login_required +def bulk_auto_assign(): + """Handles the bulk auto-assignment of dietitians to selected patients.""" + if not current_user.is_admin: + flash('You do not have permission to perform this action.', 'error') + return redirect(url_for('patients.index')) - flash_message = f"Đã gán bệnh nhân cho chuyên gia {dietitian.full_name}. Trạng thái cập nhật thành '{PatientStatus.ASSESSMENT_IN_PROGRESS.value}'. Báo cáo mới đã được tạo." - if assignment_type == "auto": - flash_message = f"Đã TỰ ĐỘNG gán bệnh nhân cho chuyên gia {dietitian.full_name}. Trạng thái cập nhật thành '{PatientStatus.ASSESSMENT_IN_PROGRESS.value}'. Báo cáo mới đã được tạo." - flash(flash_message, "success") + # Get the list of patient IDs from the form + selected_patient_ids = request.form.getlist('selected_patients') - except Exception as e: - db.session.rollback() - current_app.logger.error(f"Lỗi khi gán dietitian và cập nhật trạng thái: {str(e)}", exc_info=True) - flash(f"Đã xảy ra lỗi khi gán chuyên gia dinh dưỡng: {str(e)}", "error") - else: - flash(f"Bệnh nhân đã được gán cho chuyên gia {dietitian.full_name} rồi.", "info") + if not selected_patient_ids: + flash('No patients selected for assignment.', 'warning') + return redirect(url_for('patients.index')) + + current_app.logger.info(f"Bulk auto-assign requested by admin {current_user.userID} for patients: {selected_patient_ids}") + + # --- PLACEHOLDER LOGIC --- + # TODO: Implement the actual logic for finding the least busy dietitian + # and assigning them to the selected patients. + # This might involve: + # 1. Querying dietitians and their current patient counts. + # 2. Finding the dietitian with the minimum count. + # 3. Iterating through selected_patient_ids: + # - Get the Patient object. + # - Check if the patient needs assignment (e.g., status is NEEDS_ASSESSMENT). + # - Update patient.assigned_dietitian_user_id. + # - Update patient.status. + # - Create notifications/activity logs. + # - Commit changes. + # 4. Handle potential errors (e.g., no dietitians available). + + assigned_count = 0 + errors = [] + + # Example placeholder: Just log and flash success for now + assigned_count = len(selected_patient_ids) # Simulate assignment + + # --- END PLACEHOLDER --- - return redirect(url_for("patients.patient_detail", patient_id=patient_id)) + if assigned_count > 0: + flash(f'Successfully initiated auto-assignment for {assigned_count} patient(s).', 'success') + # You might want to create a single notification for the admin summarizing the action + # create_notification(current_user.userID, f"Bulk auto-assigned {assigned_count} patients.") + if errors: + flash(f'Could not assign all selected patients. Errors: {"; ".join(errors)}', 'danger') - # Nếu form không validate (ví dụ CSRF lỗi) - Đảm bảo thụt lề đúng ở đây - flash('Yêu cầu không hợp lệ.', 'danger') - patient_id_from_url = request.view_args.get('patient_id') # Lấy patient_id từ URL - if patient_id_from_url: - return redirect(url_for("patients.patient_detail", patient_id=patient_id_from_url)) - else: - return redirect(url_for("main.handle_root")) # Fallback về trang chủ nếu không lấy được patient_id + return redirect(url_for('patients.index')) +# --- END NEW ROUTE --- diff --git a/app/routes/report.py b/app/routes/report.py index 69d0fc6ba92cbe6874520bc8e287453a156b6fd7..a621d021a003d2a82d45a28d1e5e1509a60d69e5 100644 --- a/app/routes/report.py +++ b/app/routes/report.py @@ -305,18 +305,31 @@ def edit_report(report_id): # 1. Admin can always edit if current_user.is_admin: can_edit = True - # 2. Author can always edit - elif report.author_id == current_user.userID: - can_edit = True - # 3. Assigned dietitian for the PATIENT can edit Draft or Pending reports - elif patient and patient.assigned_dietitian_user_id == current_user.userID and report.status in ['Draft', 'Pending']: - can_edit = True + # 2. Dietitian can edit if it's assigned to them AND status is Draft/Pending + # Check if dietitian is assigned directly to the report FIRST + # Also check status using .value for safety + elif current_user.role.upper() == 'DIETITIAN' and report.dietitian_id == current_user.userID and (report.status.value if report.status else None) in ['Draft', 'Pending']: + can_edit = True + # 3. NEW: Điều kiện thứ ba - Kiểm tra xem dietitian có phải là người được gán cho bệnh nhân không + elif current_user.role.upper() == 'DIETITIAN' and patient and patient.assigned_dietitian_user_id == current_user.userID and (report.status.value if report.status else None) in ['Draft', 'Pending']: + can_edit = True + # 4. Fallback: Check if the user is the ORIGINAL AUTHOR and status is Draft/Pending (e.g., if dietitian hasn't been formally assigned yet) + # This might be less common if assignment happens early. + # Also check status using .value for safety + elif report.author_id == current_user.userID and (report.status.value if report.status else None) in ['Draft', 'Pending']: + can_edit = True # Original author can edit drafts/pending if not can_edit: flash('You do not have permission to edit this report.', 'error') return redirect(url_for('report.view_report', report_id=report.id)) # --- End Permission Check --- + # Check if report is editable (Draft or Pending) - redundant now with above check, but keep for clarity maybe? + # Also check status using .value for safety + if (report.status.value if report.status else None) == 'Completed': + flash('Cannot edit a completed report.', 'warning') + return redirect(url_for('report.view_report', report_id=report.id)) + # Truyền patient_id vào form để load procedure choices # Khởi tạo form với obj=report để điền dữ liệu hiện có form = ReportForm(patient_id=report.patient_id, obj=report) @@ -363,54 +376,133 @@ def edit_report(report_id): if action == 'save_draft': report.status = 'Draft' print(f"--- Action: Save Draft detected. OVERRIDING status to Draft. ---") - elif action == 'complete': # Xử lý action complete ở đây - # Lưu trước báo cáo - report.updated_at = datetime.utcnow() + report.updated_at = datetime.utcnow() # Update timestamp for draft save too + # Commit draft save separately before redirecting try: db.session.commit() - print(f"--- Action: Complete detected. Saved report and redirecting to complete_report route... ---") - # Chuyển hướng đến complete_report route với báo cáo đã lưu - return redirect(url_for('report.complete_report', report_id=report.id)) + flash('Report draft saved successfully.', 'success') + # Redirect to view report after saving draft + return redirect(url_for('report.view_report', report_id=report.id)) except Exception as e: db.session.rollback() - current_app.logger.error(f"Database error updating report before complete {report_id}: {e}", exc_info=True) - flash(f'Lỗi khi lưu báo cáo trước khi hoàn thành: {e}', 'error') - # Reload choices và render lại form khi gặp lỗi DB - form.patient_id.choices = [(p.id, f"{p.full_name} ({p.id})") for p in Patient.query.order_by(Patient.lastName, Patient.firstName).all()] - form.patient_id.choices.insert(0, ('', '-- Select Patient --')) + current_app.logger.error(f"Database error saving report draft {report_id}: {e}", exc_info=True) + flash(f'Database error occurred while saving draft: {e}', 'error') + # Find first error for scrolling on DB error if form.errors: first_error_field = next(iter(form.errors)) first_error_field_id = form[first_error_field].id if first_error_field in form else None - return render_template('report_form.html', form=form, report=report, edit_mode=True, attachments=[], first_error_field_id=first_error_field_id) - else: - print(f"--- Action: {action}. Using status from populate_obj: {report.status} ---") + # Reload choices before rendering again after DB error + form.patient_id.choices = [(p.id, f"{p.full_name} ({p.id})") for p in Patient.query.order_by(Patient.lastName, Patient.firstName).all()] + form.patient_id.choices.insert(0, ('', '-- Select Patient --')) + # Load procedures again + patient_procedures = Procedure.query.filter_by(patient_id=report.patient_id).order_by(Procedure.procedureDateTime.desc()).all() + form.related_procedure_id.choices = [('', '-- Select Procedure (Optional) --')] + \ + [(proc.id, f"#{proc.id} - {proc.procedureType} ({proc.procedureDateTime.strftime('%d/%m/%y %H:%M') if proc.procedureDateTime else 'N/A'})") + for proc in patient_procedures] + # Add EncounterStatus here + return render_template('report_form.html', form=form, report=report, edit_mode=True, attachments=[], first_error_field_id=first_error_field_id, EncounterStatus=EncounterStatus) + + elif action == 'complete': # Handle completion directly here + print(f"--- Action: Complete detected. Processing completion logic... ---") + # Logic moved from complete_report route + try: + # Permission check is already done before form validation + # Status check (Draft/Pending) is implicitly handled by permission check logic + + # Find related objects (patient already loaded) + patient = report.patient + encounter = Encounter.query.get(report.encounter_id) if report.encounter_id else None + referral = Referral.query.filter_by(encounter_id=report.encounter_id).first() if report.encounter_id else None + + if not patient: + flash('Cannot find related Patient.', 'error') + return redirect(url_for('report.index')) + + # --- Update statuses --- + # 1. Report: Set status directly to Completed + report.status = 'Completed' + # report.completed_date = datetime.utcnow() # Uncomment if column exists + report.updated_at = datetime.utcnow() + + # *** ADD CHECK FOR EMPTY encounter_id *** + # Ensure encounter_id is None if it's an empty string after populate_obj + if report.encounter_id == '': + current_app.logger.warning(f"Report {report.id} had empty string for encounter_id after populate_obj. Setting to None before commit.") + report.encounter_id = None + # *** END CHECK *** + + # 2. Patient + patient.status = PatientStatus.COMPLETED + + # 3. Encounter + # Check if encounter_id exists before trying to get encounter + if report.encounter_id: + encounter = Encounter.query.get(report.encounter_id) + if encounter: + encounter.status = EncounterStatus.FINISHED + encounter.end_time = datetime.utcnow() + else: + current_app.logger.warning(f"Could not find encounter with ID {report.encounter_id} when completing report {report.id}") + else: + current_app.logger.info(f"Report {report.id} does not have an associated encounter_id.") + + # 4. Referral + # Check if encounter_id exists before trying to get referral + if report.encounter_id: + referral = Referral.query.filter_by(encounter_id=report.encounter_id).first() + if referral: + referral.referral_status = ReferralStatus.COMPLETED + # referral.referralCompletedDateTime = datetime.utcnow() # Uncomment if column exists + elif report.encounter_id: # Check encounter_id again for clarity in log + current_app.logger.warning(f"No matching Referral found for encounter {report.encounter_id} when completing report {report.id}") + else: + current_app.logger.info(f"Report {report.id} does not have an associated encounter_id to find referral.") + + # 5. Commit changes + db.session.commit() + flash('Report completed successfully. Associated statuses updated.', 'success') + current_app.logger.info(f"Report {report.id} completed by User {current_user.userID} within edit_report route.") - report.updated_at = datetime.utcnow() + # Redirect to patient detail page after completion + return redirect(url_for('patients.patient_detail', patient_id=report.patient_id, _anchor='reports')) - try: - # Debugging: Log before commit - print(f"--- Committing report update. ID: {report.id}, Final Status: {report.status}, Summary: {report.intervention_summary[:50] if report.intervention_summary else 'None'}... ---") - db.session.commit() - # TODO: Xử lý file attachments mới và xóa file cũ nếu cần - flash('Report updated successfully.', 'success') - # Redirect to view report after successful save/update - return redirect(url_for('report.view_report', report_id=report.id)) - except Exception as e: - db.session.rollback() - current_app.logger.error(f"Database error updating report {report_id}: {e}", exc_info=True) - flash(f'Database error occurred while updating report: {e}', 'error') - # Find first error for scrolling on DB error - if form.errors: # Check form errors again? Might not be relevant here. - first_error_field = next(iter(form.errors)) - first_error_field_id = form[first_error_field].id if first_error_field in form else None - # Reload choices before rendering again after DB error - form.patient_id.choices = [(p.id, f"{p.full_name} ({p.id})") for p in Patient.query.order_by(Patient.lastName, Patient.firstName).all()] - form.patient_id.choices.insert(0, ('', '-- Select Patient --')) - patient_procedures = Procedure.query.filter_by(patient_id=report.patient_id).order_by(Procedure.procedureDateTime.desc()).all() - form.related_procedure_id.choices = [('', '-- Select Procedure (Optional) --')] + \ - [(proc.id, f"#{proc.id} - {proc.procedureType} ({proc.procedureDateTime.strftime('%d/%m/%y %H:%M') if proc.procedureDateTime else 'N/A'})") - for proc in patient_procedures] - return render_template('report_form.html', form=form, report=report, edit_mode=True, attachments=[], first_error_field_id=first_error_field_id) # Pass attachments + except Exception as e: + db.session.rollback() + current_app.logger.error(f"Error completing report {report_id} within edit_report: {str(e)}", exc_info=True) + flash(f'An error occurred while completing the report: {str(e)}', 'danger') + # Render edit form again on error + form.patient_id.choices = [(p.id, f"{p.full_name} ({p.id})") for p in Patient.query.order_by(Patient.lastName, Patient.firstName).all()] + form.patient_id.choices.insert(0, ('', '-- Select Patient --')) + patient_procedures = Procedure.query.filter_by(patient_id=report.patient_id).order_by(Procedure.procedureDateTime.desc()).all() + form.related_procedure_id.choices = [('', '-- Select Procedure (Optional) --')] + \ + [(proc.id, f"#{proc.id} - {proc.procedureType} ({proc.procedureDateTime.strftime('%d/%m/%y %H:%M') if proc.procedureDateTime else 'N/A'})") + for proc in patient_procedures] + # Add EncounterStatus here + return render_template('report_form.html', form=form, report=report, edit_mode=True, attachments=[], first_error_field_id=None, EncounterStatus=EncounterStatus) + + else: # Handle default save/update if no specific action or action='save' + # This part might need review - currently only 'save_draft' and 'complete' explicitly commit. + # Should there be a generic 'save' action that commits updates without changing status from Draft/Pending? + print(f"--- Action: {action}. No explicit commit logic for this action in edit_report. Using status from populate_obj: {report.status} ---") + # Perhaps add a commit here as well? + report.updated_at = datetime.utcnow() + try: + db.session.commit() + flash('Report updated successfully.', 'success') + return redirect(url_for('report.view_report', report_id=report.id)) + except Exception as e: + db.session.rollback() + current_app.logger.error(f"Database error updating report {report_id} (default action): {e}", exc_info=True) + flash(f'Database error occurred while updating report: {e}', 'error') + # Reload choices and render form + form.patient_id.choices = [(p.id, f"{p.full_name} ({p.id})") for p in Patient.query.order_by(Patient.lastName, Patient.firstName).all()] + form.patient_id.choices.insert(0, ('', '-- Select Patient --')) + patient_procedures = Procedure.query.filter_by(patient_id=report.patient_id).order_by(Procedure.procedureDateTime.desc()).all() + form.related_procedure_id.choices = [('', '-- Select Procedure (Optional) --')] + \ + [(proc.id, f"#{proc.id} - {proc.procedureType} ({proc.procedureDateTime.strftime('%d/%m/%y %H:%M') if proc.procedureDateTime else 'N/A'})") + for proc in patient_procedures] + # Add EncounterStatus here + return render_template('report_form.html', form=form, report=report, edit_mode=True, attachments=[], first_error_field_id=None, EncounterStatus=EncounterStatus) elif request.method == 'POST': # Validation failed on POST current_app.logger.warning(f"Report form validation failed. Errors: {form.errors}") @@ -420,11 +512,20 @@ def edit_report(report_id): first_error_field_id = form[first_error_field].id if first_error_field in form else None # Get ID from form field object print(f"--- First error field ID for scroll: {first_error_field_id} ---") - # ... (existing code to reload choices) ... + # Reload choices and render form + form.patient_id.choices = [(p.id, f"{p.full_name} ({p.id})") for p in Patient.query.order_by(Patient.lastName, Patient.firstName).all()] + form.patient_id.choices.insert(0, ('', '-- Select Patient --')) + patient_procedures = Procedure.query.filter_by(patient_id=report.patient_id).order_by(Procedure.procedureDateTime.desc()).all() + form.related_procedure_id.choices = [('', '-- Select Procedure (Optional) --')] + \ + [(proc.id, f"#{proc.id} - {proc.procedureType} ({proc.procedureDateTime.strftime('%d/%m/%y %H:%M') if proc.procedureDateTime else 'N/A'})") + for proc in patient_procedures] + # Add EncounterStatus here too + return render_template('report_form.html', form=form, report=report, edit_mode=True, attachments=[], first_error_field_id=first_error_field_id, EncounterStatus=EncounterStatus) # Render form chỉnh sửa (GET request or POST validation failed) attachments = [] # TODO: Lấy danh sách attachments nếu có # Pass the ID of the first error field to the template + # Add EncounterStatus here return render_template( 'report_form.html', form=form, @@ -838,71 +939,6 @@ def create_stats_report(report_type): mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' ) -# Route mới để hoàn thành Report và cập nhật trạng thái liên quan -@report_bp.route('/<int:report_id>/complete', methods=['POST', 'GET']) -@login_required -def complete_report(report_id): - """Hoàn thành report, cập nhật trạng thái Patient, Encounter, Referral.""" - report = Report.query.get_or_404(report_id) - - # Kiểm tra quyền: Chỉ dietitian được gán cho report hoặc admin - if not current_user.is_admin and ( - current_user.userID != report.author_id and - (report.patient and current_user.userID != report.patient.assigned_dietitian_user_id)): - flash('Bạn không có quyền hoàn thành báo cáo này.', 'error') - return redirect(url_for('report.view_report', report_id=report_id)) - - # Kiểm tra trạng thái report có phải là 'Pending' hoặc trạng thái phù hợp không? - if report.status not in ['Draft', 'Pending']: # Cho phép hoàn thành từ Draft hoặc Pending - flash(f'Báo cáo này không ở trạng thái "Draft" hoặc "Pending" để hoàn thành (hiện tại: {report.status}).', 'warning') - return redirect(url_for('report.view_report', report_id=report_id)) - - try: - # Tìm các đối tượng liên quan - patient = Patient.query.get(report.patient_id) - encounter = Encounter.query.get(report.encounter_id) if report.encounter_id else None - # Tìm referral tương ứng (dựa trên encounter_id) - referral = Referral.query.filter_by(encounter_id=report.encounter_id).first() if report.encounter_id else None - - if not patient: - flash('Không tìm thấy Bệnh nhân liên quan.', 'error') - return redirect(url_for('report.view_report', report_id=report_id)) - - # --- Bắt đầu cập nhật trạng thái --- - # 1. Cập nhật Report - report.status = 'Completed' # Hoặc 'Finalized' tùy theo Enum của bạn - report.completed_date = datetime.utcnow() # Thêm cột completed_date vào Report model - - # 2. Cập nhật Patient - patient.status = PatientStatus.COMPLETED - - # 3. Cập nhật Encounter (nếu có) - if encounter: - encounter.status = EncounterStatus.FINISHED - encounter.end_time = datetime.utcnow() # Đặt thời gian kết thúc encounter - - # 4. Cập nhật Referral (nếu tìm thấy) - if referral: - referral.referral_status = ReferralStatus.COMPLETED - referral.referralCompletedDateTime = datetime.utcnow() - else: - if report.encounter_id: - current_app.logger.warning(f"Không tìm thấy Referral tương ứng cho encounter {report.encounter_id} khi hoàn thành report {report.id}") - - # 5. Commit thay đổi - db.session.commit() - flash('Báo cáo đã được hoàn thành thành công. Trạng thái Bệnh nhân, Lượt khám và Yêu cầu đánh giá đã được cập nhật.', 'success') - current_app.logger.info(f"Report {report.id} completed by User {current_user.userID}. Associated Patient/Encounter/Referral statuses updated.") - - # Redirect về trang chi tiết bệnh nhân - return redirect(url_for('patients.patient_detail', patient_id=report.patient_id, _anchor='reports')) - - except Exception as e: - db.session.rollback() - current_app.logger.error(f"Lỗi khi hoàn thành report {report_id}: {str(e)}", exc_info=True) - flash(f'Đã xảy ra lỗi khi hoàn thành báo cáo: {str(e)}', 'danger') - return redirect(url_for('report.view_report', report_id=report_id)) - @report_bp.route('/<int:report_id>/delete', methods=['POST']) @login_required def delete_report(report_id): diff --git a/app/routes/support.py b/app/routes/support.py index 31f8d9174aaeb6445e15d8d2879184dd55b86561..583622b2b8cade6325cc5d220ab11151f4df3e1c 100644 --- a/app/routes/support.py +++ b/app/routes/support.py @@ -1,10 +1,10 @@ from flask import Blueprint, render_template, request, flash, redirect, url_for, jsonify, current_app from flask_login import login_required, current_user from .. import db -from ..models import SupportMessage, SupportMessageReadStatus, User -from ..forms import SupportMessageForm # Sẽ tạo form này sau -from sqlalchemy import desc, or_, func # Import func -from datetime import datetime +from ..models import SupportMessage, User +from ..forms import SupportMessageForm +from sqlalchemy import desc +from datetime import datetime, timezone support_bp = Blueprint('support', __name__, url_prefix='/support') @@ -16,8 +16,6 @@ def index(): flash('Bạn không có quyền truy cập trang này.', 'danger') return redirect(url_for('main.handle_root')) - # Sẽ tạo SupportMessageForm trong app/forms/__init__.py hoặc file riêng - # Tạm thời giả định nó đã tồn tại và có trường 'content' form = SupportMessageForm() if form.validate_on_submit(): @@ -25,7 +23,7 @@ def index(): new_message = SupportMessage( sender_id=current_user.userID, content=form.content.data, - timestamp=datetime.utcnow() # Đảm bảo timestamp được set + timestamp=datetime.utcnow() ) db.session.add(new_message) db.session.commit() @@ -41,53 +39,38 @@ def index(): db.joinedload(SupportMessage.sender) # Tải sẵn thông tin người gửi ).order_by(desc(SupportMessage.timestamp)).all() - # Đánh dấu các tin nhắn là đã đọc cho user hiện tại - unread_message_ids = db.session.query(SupportMessage.id).\ - outerjoin(SupportMessageReadStatus, - (SupportMessage.id == SupportMessageReadStatus.message_id) & - (SupportMessageReadStatus.user_id == current_user.userID)).\ - filter(SupportMessageReadStatus.id == None).\ - filter(SupportMessage.sender_id != current_user.userID).\ - all() - - unread_message_ids = [m[0] for m in unread_message_ids] - - if unread_message_ids: - now = datetime.utcnow() - new_read_statuses = [] - for msg_id in unread_message_ids: - read_status = SupportMessageReadStatus( - user_id=current_user.userID, - message_id=msg_id, - read_at=now - ) - new_read_statuses.append(read_status) - - if new_read_statuses: - try: - db.session.add_all(new_read_statuses) - db.session.commit() - current_app.logger.info(f"User {current_user.userID} marked {len(new_read_statuses)} support messages as read.") - except Exception as e: - db.session.rollback() - current_app.logger.error(f"Lỗi khi đánh dấu tin nhắn support đã đọc cho user {current_user.userID}: {e}") + # Cập nhật thời gian truy cập trang support lần cuối + try: + current_user.last_support_visit = datetime.now(timezone.utc) + db.session.commit() + except Exception as e: + db.session.rollback() + current_app.logger.error(f"Lỗi khi cập nhật last_support_visit cho user {current_user.userID}: {e}") + # Không cần flash lỗi cho người dùng về việc này return render_template('support.html', messages=messages, form=form) - @support_bp.route('/unread_count') @login_required def unread_count(): - """API endpoint trả về số lượng tin nhắn support chưa đọc.""" + """API endpoint trả về số lượng tin nhắn support mới (chưa xem).""" if current_user.role not in ['Admin', 'Dietitian']: return jsonify(count=0) - count = db.session.query(func.count(SupportMessage.id)).\ - outerjoin(SupportMessageReadStatus, - (SupportMessage.id == SupportMessageReadStatus.message_id) & - (SupportMessageReadStatus.user_id == current_user.userID)).\ - filter(SupportMessageReadStatus.id == None).\ - filter(SupportMessage.sender_id != current_user.userID).\ - scalar() - - return jsonify(count=count or 0) \ No newline at end of file + last_visit = current_user.last_support_visit + + query = SupportMessage.query.filter( + SupportMessage.sender_id != current_user.userID # Chỉ đếm tin nhắn từ người khác + ) + + if last_visit: + # Đảm bảo last_visit là timezone-aware (UTC) nếu timestamp là timezone-aware + # Hoặc chuyển cả hai về naive nếu cần + # Giả sử cả hai đều là UTC timezone-aware hoặc naive + query = query.filter(SupportMessage.timestamp > last_visit) + + count = query.count() + + return jsonify(count=count or 0) + +# Removed: /unread_count endpoint \ No newline at end of file diff --git a/app/routes/upload.py b/app/routes/upload.py index cef5008a43e199d83ec9ff61c1ad823a64c7af30..083951b9e4032451964386f83d6a946a812f7279 100644 --- a/app/routes/upload.py +++ b/app/routes/upload.py @@ -4,7 +4,7 @@ import io import json import uuid from datetime import datetime -from flask import Blueprint, render_template, request, redirect, url_for, flash, current_app, send_from_directory, session +from flask import Blueprint, render_template, request, redirect, url_for, flash, current_app, send_from_directory, session, Response from flask_login import login_required, current_user from werkzeug.utils import secure_filename from werkzeug.datastructures import FileStorage @@ -101,59 +101,53 @@ def index(): db.session.add(upload_record) db.session.commit() - # Xử lý file CSV + # Xử lý file CSV (Hàm này cần được sửa để trả về cấu trúc mới) result = process_new_patients_csv( uploaded_file_id=upload_record.id ) - # Cập nhật trạng thái - upload_record.status = 'completed' if result.get('success') else 'failed' - if result.get('success'): - upload_record.processed_records = result.get('processed_records', 0) - upload_record.error_records = result.get('error_records', 0) - upload_record.total_records = result.get('total_records', 0) - if result.get('errors'): - upload_record.error_details = json.dumps(result['errors']) - else: - upload_record.error_details = json.dumps([{'row': 0, 'error': result.get('error', 'Unknown error')}]) - - db.session.commit() - + # Cập nhật trạng thái dựa trên kết quả mới + upload_record.processed_records = result.get('processed_records', 0) + upload_record.error_records = result.get('error_records', 0) + upload_record.total_records = result.get('total_records', 0) + upload_record.error_details = json.dumps(result.get('error_details', {})) # Lưu chi tiết lỗi (nếu có) + + duplicate_errors = result.get('duplicate_errors', []) + format_errors_exist = result.get('format_errors', False) + num_duplicates = len(duplicate_errors) + if result.get('success'): - processed_count = result.get('processed_records', 0) - error_count = result.get('error_records', 0) - if error_count > 0: - # Hiển thị thông báo lỗi dễ hiểu cho người dùng - error_details = result.get('error_details', '{}') - try: - error_json = json.loads(error_details) - if 'summary' in error_json: - # Đây là lỗi đã được tóm tắt - flash(f"{error_json.get('summary')} {error_json.get('message', '')}", 'warning') - else: - flash(f'Đã tải lên và xử lý {processed_count} bản ghi, nhưng có {error_count} lỗi. Xem chi tiết trong lịch sử tải lên.', 'warning') - except: - flash(f'Đã tải lên và xử lý {processed_count} bản ghi, nhưng có {error_count} lỗi. Xem chi tiết trong lịch sử tải lên.', 'warning') + # Thành công hoàn toàn hoặc có lỗi trùng lặp + if num_duplicates > 0: upload_record.status = 'completed_with_errors' + if num_duplicates > 3: + flash(f'Đã xử lý {upload_record.processed_records} bản ghi. Phát hiện trùng lặp {num_duplicates} bệnh nhân.', 'warning') + else: + colliding_names = ", ".join(duplicate_errors) + flash(f'Đã xử lý {upload_record.processed_records} bản ghi. Bệnh nhân trùng lặp: {colliding_names}.', 'warning') else: - flash(f'Đã tải lên và xử lý thành công {processed_count} bản ghi từ {filename}.', 'success') upload_record.status = 'completed' + flash(f'Đã tải lên và xử lý thành công {upload_record.processed_records} bản ghi từ {filename}.', 'success') else: - error_msg = result.get('error', 'Unknown processing error') - # Đơn giản hóa thông báo lỗi cho người dùng cuối - if 'Data too long' in error_msg: - user_error_message = "Dữ liệu lỗi quá lớn để lưu chi tiết. Vui lòng kiểm tra tóm tắt lỗi trong lịch sử tải lên." - # Ghi log lỗi đầy đủ cho dev - current_app.logger.error(f"Data too long error during CSV processing for file ID {upload_record.id}: {error_msg}") - elif 'already exists' in error_msg: # Có thể bắt thêm các lỗi cụ thể khác - user_error_message = "Phát hiện dữ liệu bệnh nhân trùng lặp. Xem chi tiết trong lịch sử tải lên." - else: - user_error_message = f"Xử lý file thất bại. Vui lòng kiểm tra định dạng file hoặc xem chi tiết lỗi trong lịch sử tải lên." - # Ghi log lỗi không xác định - current_app.logger.error(f"Unknown error during CSV processing for file ID {upload_record.id}: {error_msg}") - - flash(user_error_message, 'error') + # Có lỗi format hoặc lỗi nghiêm trọng khác upload_record.status = 'failed' + # Ưu tiên hiển thị lỗi format nếu có + if format_errors_exist: + flash('Lỗi định dạng file CSV không đúng. Vui lòng kiểm tra lại cấu trúc cột hoặc tải xuống file mẫu.', 'error') + else: + # Xử lý lỗi trùng lặp như một trường hợp đặc biệt nếu nó không được coi là 'success' + if num_duplicates > 0: + upload_record.status = 'failed' # Vẫn là failed vì process_new_patients_csv trả về success=False + if num_duplicates > 3: + flash(f'Xử lý thất bại. Phát hiện trùng lặp {num_duplicates} bệnh nhân. Vui lòng kiểm tra file.', 'error') + else: + colliding_names = ", ".join(duplicate_errors) + flash(f'Xử lý thất bại. Bệnh nhân đã tồn tại: {colliding_names}.', 'error') + else: + # Lỗi không xác định khác + error_msg = result.get('error', 'Không có thông tin chi tiết') + flash(f"Xử lý file thất bại. Lỗi không xác định. ({error_msg})", 'error') + current_app.logger.error(f"Unknown CSV processing error for file ID {upload_record.id}: {error_msg}") db.session.commit() @@ -293,3 +287,36 @@ def delete(file_id): flash(f'Lỗi khi xóa file: {str(e)}', 'error') return redirect(url_for('upload.history')) + +# Route mới để tải template CSV +@upload_bp.route('/download_template/<template_type>') +@login_required +def download_template(template_type): + output = io.StringIO() + writer = csv.writer(output) + filename = "template.csv" + + if template_type == 'new_patients': + # Header dựa trên hàm generate_new_patient_upload_rows trong generate_patients.py + header = [ + 'firstName', 'lastName', 'age', 'gender', 'height', 'weight', 'blood_type' + ] + writer.writerow(header) + # Có thể thêm một dòng ví dụ (tùy chọn) + # writer.writerow(['John', 'Doe', 50, 'male', 175.0, 75.5, 'O+']) + filename = "new_patients_template.csv" + # Thêm các loại template khác ở đây nếu cần (ví dụ: encounter_measurements) + # elif template_type == 'encounter_measurements': + # header = [...] + # writer.writerow(header) + # filename = "encounter_measurements_template.csv" + else: + flash('Loại template không hợp lệ', 'error') + return redirect(url_for('upload.index')) + + output.seek(0) + return Response( + output, + mimetype="text/csv", + headers={"Content-Disposition": f"attachment;filename={filename}"} + ) diff --git a/app/templates/_formhelpers.html b/app/templates/_formhelpers.html new file mode 100644 index 0000000000000000000000000000000000000000..cbaddd4add11d0220952c58b5a911d8410bd3e7f --- /dev/null +++ b/app/templates/_formhelpers.html @@ -0,0 +1,26 @@ +{% macro render_field(field, label_text=None, placeholder=None, class_=None) %} + <div class="form-group"> + {{ field.label(label_text if label_text else field.label.text, class="block text-sm font-medium text-gray-700") }} + <div class="mt-1"> + {% set field_class = class_ if class_ else "shadow-sm focus:ring-primary-500 focus:border-primary-500 block w-full sm:text-sm border-gray-300 rounded-md " + (" border-red-500" if field.errors else "") %} + {% if placeholder %} + {{ field(class=field_class, placeholder=placeholder) | safe }} + {% else %} + {{ field(class=field_class) | safe }} + {% endif %} + {% if field.errors %} + <p class="mt-2 text-sm text-red-600">{{ field.errors[0] }}</p> + {% endif %} + </div> + </div> +{% endmacro %} + +{% macro render_field_readonly(field, label_text=None, value=None) %} + <div class="form-group"> + {{ field.label(label_text if label_text else field.label.text, class="block text-sm font-medium text-gray-700") }} + <div class="mt-1"> + <input type="text" id="{{ field.id }}" name="{{ field.name }}" value="{{ value if value is not none else field.data }}" readonly + class="bg-gray-50 shadow-sm focus:ring-primary-500 focus:border-primary-500 block w-full sm:text-sm border-gray-300 rounded-md"> + </div> + </div> +{% endmacro %} \ No newline at end of file diff --git a/app/templates/_macros.html b/app/templates/_macros.html index 33fb5493e74c4e4af19b56f5a3387423b7605b47..2102e42caf877b9227061f2b2f59d77059ef82d8 100644 --- a/app/templates/_macros.html +++ b/app/templates/_macros.html @@ -28,24 +28,28 @@ </nav> {% endmacro %} -{% macro status_badge(status) %} - {% if status == 'available' %} - <span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-green-100 text-green-800"> - Sẵn sàng - </span> - {% elif status == 'unavailable' %} - <span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-red-100 text-red-800"> - Không khả dụng - </span> - {% elif status == 'on_leave' %} - <span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-yellow-100 text-yellow-800"> - Đang nghỉ - </span> - {% else %} - <span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-gray-100 text-gray-800"> - {{ status|title }} +{% macro status_badge(status_value) %} + {% set status_str = status_value | string | upper %} + {% set badge_class = 'bg-gray-100 text-gray-800' %} {# Default #} + {% set status_text = status_str.replace('_', ' ').title() %} + + {% if status_str == 'AVAILABLE' or status_str == 'ACTIVE' or status_str == 'COMPLETED' or status_str == 'SUCCESS' %} + {% set badge_class = 'bg-green-100 text-green-800' %} + {% elif status_str == 'UNAVAILABLE' or status_str == 'INACTIVE' or status_str == 'FAILED' or status_str == 'ERROR' %} + {% set badge_class = 'bg-red-100 text-red-800' %} + {% elif status_str == 'PENDING' or status_str == 'NEEDS_ASSESSMENT' or status_str == 'IN_PROGRESS' %} + {% set badge_class = 'bg-yellow-100 text-yellow-800' %} + {% elif status_str == 'ON_LEAVE' %} + {% set badge_class = 'bg-blue-100 text-blue-800' %} + {% set status_text = 'On Leave' %} + {% elif status_str == 'DRAFT' %} + {% set badge_class = 'bg-purple-100 text-purple-800' %} + {% elif status_str == 'ASSESSMENT_IN_PROGRESS' %} + {% set badge_class = 'bg-indigo-100 text-indigo-800' %} + {% endif %} + <span class="px-2.5 py-0.5 inline-flex text-xs leading-5 font-semibold rounded-full {{ badge_class }}"> + {{ status_text }} </span> - {% endif %} {% endmacro %} {% macro patient_status_badge(status) %} diff --git a/app/templates/admin/edit_profile.html b/app/templates/admin/edit_profile.html new file mode 100644 index 0000000000000000000000000000000000000000..cbe1e54d4fb45ca0815a2a0142b5732e4f739df6 --- /dev/null +++ b/app/templates/admin/edit_profile.html @@ -0,0 +1,87 @@ +{% extends "base.html" %} +{% from "_formhelpers.html" import render_field, render_field_readonly %} + +{% block title %}Chỉnh sửa hồ sơ Admin{% endblock %} + +{% block header %}Chỉnh sửa thông tin Admin{% endblock %} + +{% block content %} +<div class="container mx-auto px-4 py-6"> + <!-- Breadcrumb --> + <nav class="mb-6" aria-label="Breadcrumb"> + <ol class="inline-flex items-center space-x-1 md:space-x-3"> + <li class="inline-flex items-center"> + <a href="{{ url_for('main.handle_root') }}" class="inline-flex items-center text-sm font-medium text-gray-700 hover:text-blue-600 transition duration-200"> + <svg class="w-4 h-4 mr-2" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z"></path></svg> + Trang chủ + </a> + </li> + <li> + <div class="flex items-center"> + <svg class="w-6 h-6 text-gray-400" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clip-rule="evenodd"></path></svg> + <a href="{{ url_for('auth.profile') }}" class="ml-1 text-sm font-medium text-gray-700 hover:text-blue-600 md:ml-2 transition duration-200">Hồ sơ</a> + </div> + </li> + <li aria-current="page"> + <div class="flex items-center"> + <svg class="w-6 h-6 text-gray-400" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clip-rule="evenodd"></path></svg> + <span class="ml-1 text-sm font-medium text-gray-500 md:ml-2">Chỉnh sửa hồ sơ</span> + </div> + </li> + </ol> + </nav> + + <div class="bg-white shadow overflow-hidden sm:rounded-lg"> + <div class="px-4 py-5 sm:px-6 border-b border-gray-200"> + <h3 class="text-lg leading-6 font-medium text-gray-900">Chỉnh sửa thông tin cá nhân</h3> + <p class="mt-1 text-sm text-gray-500">Cập nhật hồ sơ quản trị viên của bạn.</p> + </div> + + <div class="px-4 py-5 sm:p-6"> + <form action="{{ url_for('auth.edit_profile') }}" method="POST" class="space-y-6"> + {{ form.csrf_token }} + <input type="hidden" name="form_type" value="profile"> + + <div class="grid grid-cols-1 gap-y-6 gap-x-4 sm:grid-cols-6"> + <div class="sm:col-span-3"> + {{ render_field(form.firstName, label_text="Tên") }} + </div> + + <div class="sm:col-span-3"> + {{ render_field(form.lastName, label_text="Họ") }} + </div> + + <div class="sm:col-span-4"> + {{ render_field(form.email, label_text="Email") }} + </div> + + <div class="sm:col-span-4"> + {{ render_field(form.phone, label_text="Số điện thoại") }} + </div> + + <div class="sm:col-span-4"> + <div class="form-group"> + <label class="block text-sm font-medium text-gray-700">Vai trò</label> + <div class="mt-1"> + <input type="text" readonly value="{{ current_user.role }}" + class="bg-gray-50 shadow-sm focus:ring-primary-500 focus:border-primary-500 block w-full sm:text-sm border-gray-300 rounded-md"> + </div> + </div> + </div> + </div> + + <div class="pt-5 border-t border-gray-200 mt-6"> + <div class="flex justify-end"> + <a href="{{ url_for('auth.profile') }}" class="bg-white py-2 px-4 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500"> + Hủy + </a> + <button type="submit" class="ml-3 inline-flex justify-center py-2 px-4 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-primary-600 hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500"> + Lưu thay đổi + </button> + </div> + </div> + </form> + </div> + </div> +</div> +{% endblock %} \ No newline at end of file diff --git a/app/templates/admin/profile.html b/app/templates/admin/profile.html new file mode 100644 index 0000000000000000000000000000000000000000..8eb4d45b6735584cdb2f87acbd915072c8379ca4 --- /dev/null +++ b/app/templates/admin/profile.html @@ -0,0 +1,99 @@ +{% extends "base.html" %} + +{% block title %}Admin Profile - {{ super() }}{% endblock %} + +{% block header %}Admin Profile{% endblock %} + +{% block content %} +<div class="container mx-auto px-4 py-6"> + <!-- Breadcrumb --> + <nav class="mb-6" aria-label="Breadcrumb"> + <ol class="inline-flex items-center space-x-1 md:space-x-3"> + <li class="inline-flex items-center"> + <a href="{{ url_for('main.handle_root') }}" class="inline-flex items-center text-sm font-medium text-gray-700 hover:text-primary-600 transition duration-200"> + <svg class="w-4 h-4 mr-2" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z"></path></svg> + Dashboard + </a> + </li> + <li aria-current="page"> + <div class="flex items-center"> + <svg class="w-6 h-6 text-gray-400" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clip-rule="evenodd"></path></svg> + <span class="ml-1 text-sm font-medium text-gray-500 md:ml-2">My Profile</span> + </div> + </li> + </ol> + </nav> + + <div class="bg-white shadow-lg overflow-hidden sm:rounded-lg"> + <div class="px-4 py-5 sm:px-6 border-b border-gray-200"> + <div class="flex items-center justify-between"> + <div> + <h3 class="text-lg leading-6 font-medium text-gray-900">{{ current_user.full_name }}</h3> + <p class="mt-1 max-w-2xl text-sm text-gray-500">Administrator Profile</p> + </div> + <div class="flex-shrink-0"> + <a href="{{ url_for('auth.edit_profile') }}" class="inline-flex items-center px-4 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 transition duration-200"> + <svg class="-ml-1 mr-2 h-5 w-5 text-gray-400" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor"> + <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z" /> + </svg> + Edit Profile + </a> + </div> + </div> + </div> + <div class="border-t border-gray-200 px-4 py-5 sm:p-0"> + <dl class="sm:divide-y sm:divide-gray-200"> + <div class="py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6"> + <dt class="text-sm font-medium text-gray-500">Full Name</dt> + <dd class="mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2">{{ current_user.full_name }}</dd> + </div> + <div class="py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6"> + <dt class="text-sm font-medium text-gray-500">Email Address</dt> + <dd class="mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2">{{ current_user.email }}</dd> + </div> + <div class="py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6"> + <dt class="text-sm font-medium text-gray-500">Phone Number</dt> + <dd class="mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2">{{ current_user.phone or 'Not provided' }}</dd> + </div> + <div class="py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6"> + <dt class="text-sm font-medium text-gray-500">Role</dt> + <dd class="mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2"> + <span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-red-100 text-red-800"> + {{ current_user.role }} + </span> + </dd> + </div> + <div class="py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6"> + <dt class="text-sm font-medium text-gray-500">Account Created</dt> + <dd class="mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2">{{ current_user.created_at.strftime('%d %b %Y, %H:%M') if current_user.created_at else 'N/A' }}</dd> + </div> + <div class="py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6"> + <dt class="text-sm font-medium text-gray-500">Last Login</dt> + <dd class="mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2">{{ current_user.last_login_at.strftime('%d %b %Y, %H:%M') if current_user.last_login_at else 'Never' }}</dd> + </div> + <!-- Admin specific sections --> + <div class="py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6"> + <dt class="text-sm font-medium text-gray-500">Admin Actions</dt> + <dd class="mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2"> + <div class="space-y-2"> + <a href="{{ url_for('auth.admin_users') }}" class="text-indigo-600 hover:text-indigo-900 text-sm font-medium flex items-center"> + <svg class="w-4 h-4 mr-1" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z" /></svg> + User Management + </a> + {# Placeholder links - update when these routes exist #} + <a href="#" class="text-gray-400 text-sm font-medium flex items-center cursor-not-allowed" title="System Logs (Not Implemented)"> + <svg class="w-4 h-4 mr-1" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" /></svg> + System Logs + </a> + <a href="#" class="text-gray-400 text-sm font-medium flex items-center cursor-not-allowed" title="System Configuration (Not Implemented)"> + <svg class="w-4 h-4 mr-1" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" /><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" /></svg> + System Configuration + </a> + </div> + </dd> + </div> + </dl> + </div> + </div> +</div> +{% endblock %} \ No newline at end of file diff --git a/app/templates/auth/login.html b/app/templates/auth/login.html new file mode 100644 index 0000000000000000000000000000000000000000..5e9bb8dc1cf245a6f1846665c315e8f016f5c9bd --- /dev/null +++ b/app/templates/auth/login.html @@ -0,0 +1,39 @@ +{% extends "base_auth.html" %} +{% from "_formhelpers.html" import render_field %} + +{% block title %}Đăng nhập{% endblock %} + +{% block card_title %}Đăng nhập vào tài khoản của bạn{% endblock %} + +{% block card_content %} +<form method="POST" action="{{ url_for('auth.login') }}"> + {{ form.csrf_token }} + + <div class="mb-4"> + {{ render_field(form.email, placeholder='Địa chỉ email của bạn', class_='w-full px-4 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500') }} + </div> + + <div class="mb-6"> + {{ render_field(form.password, placeholder='Mật khẩu của bạn', class_='w-full px-4 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500') }} + {# Remove Forgot Password Link #} + {# + <div class="text-right mt-1"> + <a href="{{ url_for('auth.reset_password_request') }}" class="text-sm text-blue-600 hover:underline">Quên mật khẩu?</a> + </div> + #} + </div> + + <div class="mb-4"> + <button type="submit" class="w-full bg-blue-600 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded-lg transition duration-200 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-opacity-50"> + Đăng nhập + </button> + </div> + + {# Remove Registration Link if needed + <div class="text-center text-sm text-gray-600"> + Chưa có tài khoản? + <a href="{{ url_for('auth.register') }}" class="font-medium text-blue-600 hover:underline">Đăng ký</a> + </div> + #} +</form> +{% endblock %} \ No newline at end of file diff --git a/app/templates/base.html b/app/templates/base.html index 236e46a6d930432ec10cd1bb08678afe7579c50d..f681991a2767c88b23345ee309144c8b4dc1fe62 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -99,7 +99,6 @@ transition: width 0.3s ease; z-index: 40; box-shadow: 4px 0 15px rgba(0, 0, 0, 0.1); - overflow-x: hidden; } .sidebar.collapsed { @@ -120,10 +119,10 @@ position: absolute; top: 20px; right: -15px; - background: #2563eb; + background-color: #1e40af; color: white; - width: 30px; - height: 30px; + width: 32px; + height: 32px; border-radius: 50%; display: flex; align-items: center; @@ -131,7 +130,16 @@ box-shadow: 0 2px 10px rgba(0, 0, 0, 0.15); cursor: pointer; z-index: 50; - border: 2px solid #e5e7eb; + border: 2px solid white; + transition: background-color 0.2s ease; + } + + .sidebar-toggle:hover { + background-color: #1e3a8a; + } + + .sidebar-toggle i { + pointer-events: none; } /* Navigation items */ @@ -140,26 +148,34 @@ margin: 4px 0; overflow: hidden; white-space: nowrap; - display: flex; - align-items: center; - padding: 0.8rem 1rem; - height: 50px; } - .nav-item i { - margin-right: 0.75rem; - font-size: 1.2rem; - width: 24px; - text-align: center; + .nav-item a { + padding: 0.8rem 1rem; + height: 50px; + display: flex; + align-items: center; + width: 100%; + border-radius: 8px; + transition: background-color 0.2s ease; + color: white; + text-decoration: none; } - .nav-item:hover { + .nav-item a:hover { background-color: var(--primary-dark); } - .nav-item.active { - background-color: var(--primary-dark); - box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1); + .nav-item.active a { + background-color: var(--primary-dark); + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1); + } + + .nav-item a i { + font-size: 1.2rem; + width: 24px; + text-align: center; + margin-right: 0.75rem; } /* Cards and components */ @@ -241,12 +257,12 @@ } /* Style khi sidebar thu gọn */ - .sidebar.collapsed .nav-item { + .sidebar.collapsed .nav-item a { justify-content: center; padding: 0.8rem 0; } - - .sidebar.collapsed .nav-item i { + + .sidebar.collapsed .nav-item a i { margin-right: 0; font-size: 1.6rem; } @@ -278,68 +294,63 @@ <nav> <ul> - <li> - <a href="{{ url_for('dashboard.index') }}" class="nav-item flex items-center py-3 px-4 {% if request.endpoint == 'dashboard.index' %}active{% endif %}"> - <i class="fas fa-tachometer-alt mr-3 text-lg"></i> <span class="nav-text">Dashboard</span> + {# Dashboard #} + <li class="nav-item {% if request.endpoint == 'dashboard.index' %}active{% endif %}"> + <a href="{{ url_for('dashboard.index') }}"> + <i class="fas fa-tachometer-alt"></i> <span class="nav-text">Dashboard</span> </a> </li> - <li> - <a href="{{ url_for('patients.index') }}" class="nav-item flex items-center py-3 px-4 {% if request.endpoint and request.endpoint.startswith('patients.') and request.endpoint != 'patients.physical_measurements' %}active{% endif %}"> - <i class="fas fa-user-injured mr-3 text-lg"></i> <span class="nav-text">Patient</span> + {# Patient #} + <li class="nav-item {% if request.endpoint and request.endpoint.startswith('patients.') and request.endpoint != 'patients.physical_measurements' %}active{% endif %}"> + <a href="{{ url_for('patients.index') }}"> + <i class="fas fa-user-injured"></i> <span class="nav-text">Patient</span> </a> </li> - {# Procedures (Dietitian Only) - Move before Reports #} + {# Procedures (Dietitian Only) #} {% if current_user.role == 'Dietitian' %} - <li class="nav-item {% if request.endpoint.startswith('dietitian.list_procedures') %}active{% endif %}"> - <a href="{{ url_for('dietitian.list_procedures') }}" class="text-white hover:text-primary-light flex items-center w-full"> + <li class="nav-item {% if request.endpoint.startswith('dietitian.') and request.endpoint != 'dietitian.dashboard' %}active{% endif %}"> + <a href="{{ url_for('dietitian.list_my_procedures') }}"> <i class="fas fa-procedures"></i> <span class="nav-text">Procedures</span> </a> </li> {% endif %} + {# Reports #} <li class="nav-item {% if request.endpoint and request.endpoint.startswith('report.') %}active{% endif %}"> - <a href="{{ url_for('report.index') }}" class="text-white hover:text-primary-light flex items-center w-full"> + <a href="{{ url_for('report.index') }}"> <i class="fas fa-chart-bar"></i> <span class="nav-text">Reports</span> </a> </li> + {# Dietitians #} <li class="nav-item {% if request.endpoint and request.endpoint.startswith('dietitians.') %}active{% endif %}"> - <a href="{{ url_for('dietitians.index') }}" class="text-white hover:text-primary-light flex items-center w-full"> + <a href="{{ url_for('dietitians.index') }}"> <i class="fas fa-user-md"></i> <span class="nav-text">Dietitians</span> </a> </li> - + {# Upload CSV (Not for Dietitian) #} {% if current_user.role != 'Dietitian' %} <li class="nav-item {% if request.endpoint and request.endpoint.startswith('upload.') %}active{% endif %}"> - <a href="{{ url_for('upload.index') }}" class="text-white hover:text-primary-light flex items-center w-full"> + <a href="{{ url_for('upload.index') }}"> <i class="fas fa-upload"></i> <span class="nav-text">Upload CSV</span> </a> </li> {% endif %} - {% if current_user.role == 'Admin' %} - {# ... Admin items (Admin Panel, Upload Data đã bị xóa) ... #} - {% endif %} - {# Mục mới: Support (Cho cả Admin và Dietitian) #} + {# Support #} {% if current_user.role in ['Admin', 'Dietitian'] %} - <li class="nav-item {% if request.endpoint == 'support.index' %}active{% endif %}"> - <a href="{{ url_for('support.index') }}" class="relative flex items-center w-full"> + <li class="nav-item relative {% if request.endpoint == 'support.index' %}active{% endif %}"> + <a href="{{ url_for('support.index') }}"> <i class="fas fa-headset"></i> <span class="nav-text">Support</span> - <span id="support-link-badge" class="absolute top-2 right-2 notification-badge hidden">0</span> + {# Điều chỉnh vị trí để badge không che lấp icon #} + <span id="support-link-badge" + class="absolute notification-badge hidden text-xs font-bold leading-none text-center whitespace-nowrap align-baseline rounded-full" + style="top: 0rem !important; right: 0rem !important; padding: 0.2em 0.4em; min-width: 18px; background-color: #ef4444;">0</span> </a> </li> {% endif %} - {# Mục Help giữ nguyên -> Comment out vì không có route #} - {# - <li class="nav-item {% if request.endpoint == 'main.help' %}active{% endif %}"> - <a href="{{ url_for('main.help') }}" class="flex items-center w-full"> - <i class="fas fa-question-circle"></i> - <span class="nav-text">Help</span> - </a> - </li> - #} </ul> </nav> </div> @@ -365,8 +376,8 @@ <div id="notificationMenu" class="dropdown-menu absolute right-0 mt-2 w-72 bg-white rounded-lg shadow-lg z-50 py-2"> <div class="flex items-center justify-between px-4 py-2 border-b border-gray-100"> - <h5 class="font-semibold text-gray-700">Thông báo</h5> - <span class="px-2 py-1 bg-blue-100 text-blue-700 rounded-lg text-xs">3 mới</span> + <h5 class="font-semibold text-gray-700">Notifications</h5> + <span class="px-2 py-1 bg-blue-100 text-blue-700 rounded-lg text-xs">3 new</span> </div> <div class="max-h-64 overflow-y-auto"> @@ -426,13 +437,13 @@ <div id="userDropdownMenu" class="dropdown-menu absolute right-0 mt-2 w-48 rounded-md shadow-lg bg-white ring-1 ring-black ring-opacity-5 z-50"> <div class="py-1" role="menu" aria-orientation="vertical" aria-labelledby="dropdownMenuButton"> <a href="{{ url_for('auth.profile') }}" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100" role="menuitem"> - <i class="fas fa-user mr-2"></i> Xem Hồ sơ + <i class="fas fa-user mr-2"></i> Profile </a> <a href="{{ url_for('auth.edit_profile') }}" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100" role="menuitem"> - <i class="fas fa-user-edit mr-2"></i> Chỉnh sửa Hồ sơ + <i class="fas fa-user-edit mr-2"></i> Edit Profile </a> <a href="{{ url_for('auth.logout') }}" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100" role="menuitem"> - <i class="fas fa-sign-out-alt mr-2"></i> Đăng xuất + <i class="fas fa-sign-out-alt mr-2"></i> Logout </a> </div> </div> @@ -489,7 +500,7 @@ <!-- Footer --> <footer class="bg-white py-6 mt-12"> <div class="container mx-auto px-4"> - <p class="text-center text-gray-500 text-sm">© {{ current_year|default(2023) }} CCU HTM - Hệ thống dinh dưỡng đơn vị chăm sóc tích cực</p> + <p class="text-center text-gray-500 text-sm">© {{ current_year|default(2023) }} CCU HTM - Intensive Care Unit Nutrition Management System</p> </div> </footer> @@ -602,14 +613,30 @@ dropdownButtons.forEach(button => { button.addEventListener('click', function(event) { const dropdownMenu = this.nextElementSibling; + const isNotificationButton = this.id === 'notificationButton'; + // Close other open dropdowns document.querySelectorAll('.dropdown-menu.visible').forEach(menu => { if (menu !== dropdownMenu) { menu.classList.remove('visible'); } }); + // Toggle current dropdown + const becomingVisible = !dropdownMenu.classList.contains('visible'); dropdownMenu.classList.toggle('visible'); + + // If it's the notification button AND it's becoming visible, mark all as read + if (isNotificationButton && becomingVisible) { + console.log("[Notifications] Dropdown opened, marking all as read..."); + markAllNotificationsReadAPI(); // Gọi hàm API mới + // Cập nhật badge ngay lập tức để phản hồi nhanh + if (notificationBadge) { + notificationBadge.textContent = '0'; + notificationBadge.style.display = 'none'; + } + } + event.stopPropagation(); }); }); @@ -624,13 +651,36 @@ }); }); - // --- Notification Logic --- (giữ nguyên phần này) + // --- Notification Logic --- const notificationButton = document.getElementById('notificationButton'); const notificationMenu = document.getElementById('notificationMenu'); const notificationBadge = notificationButton ? notificationButton.querySelector('.notification-badge') : null; const notificationList = notificationMenu ? notificationMenu.querySelector('.max-h-64') : null; // Div chứa danh sách thông báo const notificationCountSpan = notificationMenu ? notificationMenu.querySelector('.flex span') : null; // Span hiển thị số lượng + // Hàm gọi API mark-all-read + function markAllNotificationsReadAPI() { + fetch('{{ url_for("notifications.mark_all_notifications_as_read") }}', { // Sử dụng url_for + method: 'POST', + headers: { + 'X-CSRFToken': document.querySelector('meta[name="csrf-token"]').getAttribute('content') + }, + }) + .then(response => response.json()) + .then(data => { + if (data.success) { + console.log('[Notifications] Marked all as read successfully:', data.message); + // Có thể fetch lại thông báo để cập nhật trạng thái is_read nếu cần + // fetchNotifications(); + } else { + console.error('[Notifications] Failed to mark all as read:', data.message); + } + }) + .catch(error => { + console.error('[Notifications] Error calling mark all read API:', error); + }); + } + function fetchNotifications() { if (!notificationButton || !notificationMenu || !notificationBadge || !notificationList || !notificationCountSpan) return; @@ -641,10 +691,10 @@ if (data.count > 0) { notificationBadge.textContent = data.count; notificationBadge.style.display = 'flex'; // Hiển thị badge nếu có thông báo - notificationCountSpan.textContent = `${data.count} mới`; // Cập nhật số lượng trong dropdown + notificationCountSpan.textContent = `${data.count} new`; // Cập nhật số lượng trong dropdown } else { notificationBadge.style.display = 'none'; // Ẩn badge nếu không có - notificationCountSpan.textContent = 'Không có thông báo mới'; + notificationCountSpan.textContent = 'No new notifications'; } // Xóa danh sách cũ và hiển thị danh sách mới @@ -680,7 +730,7 @@ }); } else { - notificationList.innerHTML = '<p class="text-sm text-gray-500 px-4 py-3">Không có thông báo nào.</p>'; + notificationList.innerHTML = '<p class="text-sm text-gray-500 px-4 py-3">No notifications.</p>'; } }) .catch(error => { @@ -796,10 +846,20 @@ <!-- Support Message Unread Count Script --> <script> document.addEventListener('DOMContentLoaded', function() { - // --- Support Message Unread Count --- const supportLinkBadge = document.getElementById('support-link-badge'); + const supportNavLink = document.querySelector('a[href="{{ url_for("support.index") }}"]'); // Find the support nav link + function fetchSupportUnreadCount() { - fetch('{{ url_for("support.unread_count") }}') // API endpoint for unread count + // Only fetch if the badge element exists + if (!supportLinkBadge) return; + + // Do not fetch/show count if currently on the support page + if (window.location.pathname === '{{ url_for("support.index") }}') { + supportLinkBadge.classList.add('hidden'); + return; + } + + fetch('{{ url_for("support.unread_count") }}') .then(response => { if (!response.ok) { throw new Error(`HTTP error! status: ${response.status}`); @@ -807,25 +867,34 @@ return response.json(); }) .then(data => { - if (supportLinkBadge) { - const count = data.count || 0; - supportLinkBadge.textContent = count; - if (count > 0) { - supportLinkBadge.classList.remove('hidden'); - } else { - supportLinkBadge.classList.add('hidden'); - } + const count = data.count || 0; + supportLinkBadge.textContent = count; + if (count > 0) { + supportLinkBadge.classList.remove('hidden'); + } else { + supportLinkBadge.classList.add('hidden'); } }) .catch(error => console.error('Error fetching support unread count:', error)); } + // Hide badge immediately if on support page on initial load + if (window.location.pathname === '{{ url_for("support.index") }}' && supportLinkBadge) { + supportLinkBadge.classList.add('hidden'); + } + // Fetch support count initially and periodically if the badge exists if (supportLinkBadge) { - fetchSupportUnreadCount(); - setInterval(fetchSupportUnreadCount, 45000); // Check every 45 seconds + fetchSupportUnreadCount(); // Fetch on load (if not on support page) + setInterval(fetchSupportUnreadCount, 30000); // Check every 30 seconds + } + + // Add listener to hide badge when navigating TO support page (for SPA-like behavior if any) + if (supportNavLink && supportLinkBadge) { + supportNavLink.addEventListener('click', function() { + supportLinkBadge.classList.add('hidden'); + }); } - // --- End Support Message Unread Count --- }); </script> </body> diff --git a/app/templates/base_auth.html b/app/templates/base_auth.html new file mode 100644 index 0000000000000000000000000000000000000000..9bbc0f9db79860ae11e0fe31d2f181c80742a9d9 --- /dev/null +++ b/app/templates/base_auth.html @@ -0,0 +1,63 @@ +<!DOCTYPE html> +<html lang="vi"> +<head> + <meta charset="UTF-8"> + <meta name="viewport" content="width=device-width, initial-scale=1.0"> + <title>{% block title %}CCU HTM{% endblock %}</title> + <link href="https://cdn.jsdelivr.net/npm/tailwindcss@2.2.19/dist/tailwind.min.css" rel="stylesheet"> + <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css"> + <link rel="stylesheet" href="{{ url_for('static', filename='css/styles.css') }}"> + {% block head_extra %}{% endblock %} +</head> +<body class="bg-gradient-to-br from-blue-50 via-white to-indigo-100 flex items-center justify-center min-h-screen"> + <div class="w-full max-w-md p-8 space-y-6 bg-white shadow-xl rounded-lg border border-gray-200"> + <div class="text-center"> + <a href="{{ url_for('main.handle_root') }}" class="inline-block mb-4"> + {# You can add a logo here if you have one #} + <span class="text-2xl font-bold text-indigo-600">CCU HTM</span> + </a> + <h2 class="text-2xl font-bold text-gray-800">{% block card_title %}Authentication{% endblock %}</h2> + </div> + + <!-- Flash messages --> + {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} + <div class="space-y-2"> + {% for category, message in messages %} + <div class="p-3 rounded-md flex items-start + {% if category == 'error' or category == 'danger' %}bg-red-50 border border-red-200 + {% elif category == 'warning' %}bg-yellow-50 border border-yellow-200 + {% elif category == 'success' %}bg-green-50 border border-green-200 + {% else %}bg-blue-50 border border-blue-200{% endif %}"> + <div class="flex-shrink-0"> + {% if category == 'error' or category == 'danger' %} + <i class="fas fa-times-circle text-red-500"></i> + {% elif category == 'warning' %} + <i class="fas fa-exclamation-triangle text-yellow-500"></i> + {% elif category == 'success' %} + <i class="fas fa-check-circle text-green-500"></i> + {% else %} + <i class="fas fa-info-circle text-blue-500"></i> + {% endif %} + </div> + <div class="ml-3"> + <p class="text-sm font-medium + {% if category == 'error' or category == 'danger' %}text-red-800 + {% elif category == 'warning' %}text-yellow-800 + {% elif category == 'success' %}text-green-800 + {% else %}text-blue-800{% endif %}"> + {{ message }} + </p> + </div> + </div> + {% endfor %} + </div> + {% endif %} + {% endwith %} + + {% block card_content %} + {% endblock %} + </div> + {% block scripts %}{% endblock %} +</body> +</html> \ No newline at end of file diff --git a/app/templates/dashboard.html b/app/templates/dashboard.html index bb387345e04a1ac88f6c59f8d6994fa365653f7e..810df42f37cc578df517b4914547d6cb469ff7fb 100644 --- a/app/templates/dashboard.html +++ b/app/templates/dashboard.html @@ -189,9 +189,22 @@ </div> {% endif %} - <div class="grid grid-cols-1 lg:grid-cols-2 gap-8"> - <!-- Recent Patients --> - <div class="bg-white shadow-lg overflow-hidden sm:rounded-lg animate-fade-in" style="animation-delay: 0.6s"> + <div class="grid grid-cols-1 lg:grid-cols-3 gap-8"> + <!-- Patient Assignment Overview & Recent Patients (HÀNG 1) --> + <div class="bg-white shadow-lg overflow-hidden sm:rounded-lg lg:col-span-2 animate-fade-in" style="animation-delay: 0.6s"> + <div class="px-4 py-5 sm:px-6 bg-gradient-to-r from-indigo-50 to-white"> + <h2 class="text-lg font-medium text-gray-900">Patient Assignment Overview</h2> + </div> + <div class="border-t border-gray-200 p-4 flex flex-col"> + <div class="flex-grow mb-4" style="height: 380px;"> + <canvas id="dietitianWorkloadChart"></canvas> + </div> + <div id="workloadLegend" class="text-xs text-center space-y-1 mt-auto"> + {# Legend will be generated by JS #} + </div> + </div> + </div> + <div class="bg-white shadow-lg overflow-hidden sm:rounded-lg lg:col-span-1 animate-fade-in" style="animation-delay: 0.65s"> <div class="px-4 py-5 sm:px-6 flex justify-between items-center bg-gradient-to-r from-blue-50 to-white"> <h2 class="text-lg font-medium text-gray-900">Recent Patients</h2> <a href="{{ url_for('patients.index') }}" class="text-sm text-blue-600 hover:text-blue-500 flex items-center"> @@ -243,22 +256,68 @@ </div> </div> - <!-- BMI Distribution --> - <div class="bg-white shadow-lg overflow-hidden sm:rounded-lg animate-fade-in" style="animation-delay: 0.7s"> + <!-- HÀNG 2: 3 BIỂU ĐỒ STATUS (Full Width) --> + <div class="lg:col-span-3 grid grid-cols-1 md:grid-cols-3 gap-8 mb-8"> + <!-- Patient Status Distribution --> + <div class="bg-white shadow-lg overflow-hidden sm:rounded-lg animate-fade-in" style="animation-delay: 0.75s"> + <div class="px-4 py-5 sm:px-6 bg-gradient-to-r from-yellow-50 to-white"> + <h2 class="text-lg font-medium text-gray-900">Patient Status</h2> + </div> + <div class="border-t border-gray-200 p-4"> + <div class="h-64" id="patientStatusChartContainer"> + <canvas id="patientStatusChart"></canvas> + </div> + </div> + </div> + + <!-- Report Status Distribution --> + <div class="bg-white shadow-lg overflow-hidden sm:rounded-lg animate-fade-in" style="animation-delay: 0.8s"> + <div class="px-4 py-5 sm:px-6 bg-gradient-to-r from-purple-50 to-white"> + <h2 class="text-lg font-medium text-gray-900">Report Status</h2> + </div> + <div class="border-t border-gray-200 p-4"> + <div class="h-64" id="reportStatusChartContainer"> + <canvas id="reportStatusChart"></canvas> + </div> + </div> + </div> + + <!-- Referral Status Distribution --> + <div class="bg-white shadow-lg overflow-hidden sm:rounded-lg animate-fade-in" style="animation-delay: 0.85s"> + <div class="px-4 py-5 sm:px-6 bg-gradient-to-r from-pink-50 to-white"> + <h2 class="text-lg font-medium text-gray-900">Referral Status</h2> + </div> + <div class="border-t border-gray-200 p-4"> + <div class="h-64" id="referralStatusChartContainer"> + <canvas id="referralStatusChart"></canvas> + </div> + </div> + </div> + </div> + + <!-- HÀNG 3: BMI Distribution (Full Width, Centered) --> + <div class="lg:col-span-3 bg-white shadow-lg overflow-hidden sm:rounded-lg mb-8 animate-fade-in" style="animation-delay: 0.7s"> <div class="px-4 py-5 sm:px-6 bg-gradient-to-r from-green-50 to-white"> <h2 class="text-lg font-medium text-gray-900">BMI Distribution</h2> </div> - <div class="border-t border-gray-200 p-4"> - <div class="h-64"> + <div class="border-t border-gray-200 p-4 flex justify-center"> {# Giữ flex justify-center #} + <div class="h-64 w-full max-w-xs" id="bmiChartContainer"> {# Giảm max-width thành max-w-xs #} <canvas id="bmiChart"></canvas> </div> </div> </div> - <!-- Referrals Timeline --> - <div class="bg-white shadow-lg overflow-hidden sm:rounded-lg lg:col-span-2 animate-fade-in" style="animation-delay: 0.8s"> - <div class="px-4 py-5 sm:px-6 bg-gradient-to-r from-blue-50 to-white"> - <h2 class="text-lg font-medium text-gray-900">Referrals Timeline (Last 90 Days)</h2> + <!-- HÀNG 4: Referrals Timeline (Full Width) --> + <div class="lg:col-span-3 bg-white shadow-lg overflow-hidden sm:rounded-lg animate-fade-in" style="animation-delay: 0.9s"> + <div class="px-4 py-5 sm:px-6 bg-gradient-to-r from-blue-50 to-white flex justify-between items-center"> + <h2 class="text-lg font-medium text-gray-900">Referrals Timeline</h2> + <!-- Nút chọn Range --> + <div class="flex space-x-1" id="timeline-range-selector"> + <button data-range="day" class="px-2 py-1 text-xs font-medium rounded-md text-gray-600 bg-gray-100 hover:bg-gray-200 transition">Today</button> + <button data-range="week" class="px-2 py-1 text-xs font-medium rounded-md text-gray-600 bg-gray-100 hover:bg-gray-200 transition">Last Week</button> + <button data-range="month" class="px-2 py-1 text-xs font-medium rounded-md text-gray-600 bg-gray-100 hover:bg-gray-200 transition">Last Month</button> + <button data-range="all" class="px-2 py-1 text-xs font-medium rounded-md text-white bg-blue-600 transition">All Time</button> {# Active state #} + </div> </div> <div class="border-t border-gray-200 p-4"> <div class="h-64"> @@ -272,84 +331,172 @@ {% block scripts %} <script> - document.addEventListener('DOMContentLoaded', function() { - // BMI Chart with animation - var bmiCtx = document.getElementById('bmiChart').getContext('2d'); - var bmiData = { - labels: [ - {% for category, count in bmi_stats %} - '{{ category }}', - {% endfor %} - ], - datasets: [{ - label: 'Number of Patients', - data: [ - {% for category, count in bmi_stats %} - {{ count }}, - {% endfor %} - ], - backgroundColor: [ - 'rgba(255, 99, 132, 0.7)', - 'rgba(54, 162, 235, 0.7)', - 'rgba(255, 206, 86, 0.7)', - 'rgba(75, 192, 192, 0.7)', - 'rgba(153, 102, 255, 0.7)' - ], - borderColor: [ - 'rgba(255, 99, 132, 1)', - 'rgba(54, 162, 235, 1)', - 'rgba(255, 206, 86, 1)', - 'rgba(75, 192, 192, 1)', - 'rgba(153, 102, 255, 1)' - ], - borderWidth: 1 - }] - }; - var bmiChart = new Chart(bmiCtx, { - type: 'doughnut', - data: bmiData, - options: { - responsive: true, - maintainAspectRatio: false, - plugins: { - legend: { - position: 'right' - }, - tooltip: { - backgroundColor: 'rgba(0, 0, 0, 0.7)', - padding: 10, - titleColor: '#fff', - titleFont: { - size: 14 - }, - bodyColor: '#fff', - bodyFont: { - size: 13 - } - } + // Helper function to generate colors + function generateColors(numColors) { + const colors = [ + 'rgba(54, 162, 235, 0.7)', // Blue + 'rgba(75, 192, 192, 0.7)', // Green + 'rgba(255, 206, 86, 0.7)', // Yellow + 'rgba(153, 102, 255, 0.7)', // Purple + 'rgba(255, 99, 132, 0.7)', // Red + 'rgba(255, 159, 64, 0.7)', // Orange + 'rgba(199, 199, 199, 0.7)', // Grey + 'rgba(83, 109, 254, 0.7)', // Indigo + 'rgba(236, 64, 122, 0.7)' // Pink + ]; + const result = []; + for (let i = 0; i < numColors; i++) { + result.push(colors[i % colors.length]); + } + return result; + } + + // Function to render a chart or display an error/no data message + function renderChart(ctx, chartId, chartType, data, options = {}) { + const container = document.getElementById(chartId + 'Container'); + if (!ctx) { + console.error(`Chart context not found for ${chartId}`); + if (container) container.innerHTML = `<p class="text-center text-sm text-red-500 py-4">Error: Canvas element not found.</p>`; + return null; + } + try { + if (!data || (Array.isArray(data) && data.length === 0) || (typeof data === 'object' && Object.keys(data).length === 0)) { + console.log(`${chartId} data is empty.`); + if (container) container.innerHTML = `<p class="text-center text-sm text-gray-500 py-4">No data available.</p>`; + return null; + } + + // Basic default options (can be customized per chart) + const defaultOptions = { + responsive: true, + maintainAspectRatio: false, + plugins: { + legend: { position: 'top', labels: { font: { size: 12 } } }, + tooltip: { bodyFont: { size: 12 } } }, - animation: { - animateScale: true, - animateRotate: true + }; + + // Specific options based on type + if (chartType === 'bar') { + defaultOptions.indexAxis = 'y'; // Horizontal bar + defaultOptions.scales = { + x: { beginAtZero: true, ticks: { precision: 0 } }, + y: { ticks: { font: { size: 10 } } } + }; + defaultOptions.plugins.legend.display = false; // Often hide legend for simple bar + } else if (chartType === 'pie' || chartType === 'doughnut') { + defaultOptions.plugins.tooltip.callbacks = { + label: function(context) { + let label = context.label || ''; + let value = context.parsed; + let total = context.dataset.data.reduce((acc, val) => acc + val, 0); + let percentage = total > 0 ? ((value / total) * 100).toFixed(1) + '%' : '0.0%'; + return `${label}: ${value} (${percentage})`; + } } + } else if (chartType === 'line') { + defaultOptions.scales = { + x: { display: true, title: { display: true, text: 'Date', font: { size: 14, weight: 'bold' } }, ticks: { maxTicksLimit: 10, maxRotation: 45, minRotation: 45 }, grid: { display: false } }, + y: { display: true, title: { display: true, text: 'Number of Referrals', font: { size: 14, weight: 'bold' } }, beginAtZero: true, ticks: { precision: 0, stepSize: 1 }, grid: { color: 'rgba(0, 0, 0, 0.05)' } } // Ensure integer steps + }; + defaultOptions.animation = { duration: 1500, easing: 'easeOutQuart' }; } - }); + + const chartOptions = { ...defaultOptions, ...options }; // Merge specific options + + return new Chart(ctx, { + type: chartType, + data: data, + options: chartOptions + }); + + } catch (error) { + console.error(`Error loading ${chartId}:`, error); + if (container) container.innerHTML = `<p class="text-center text-sm text-red-500 py-4">Error loading chart.</p>`; + return null; + } + } + + // Store chart instances + let referralChartInstance = null; + let fullTimelineData = []; // Store the full data from backend + + // Helper: Convert YYYY-MM-DD string to Date object at UTC midnight + function parseISODate(isoDateString) { + const parts = isoDateString.split('-').map(Number); + return new Date(Date.UTC(parts[0], parts[1] - 1, parts[2])); + } + + // Helper: Format Date object to YYYY-MM-DD string + function formatISODate(date) { + return date.toISOString().split('T')[0]; + } + + // Function to update the timeline chart based on selected range + function updateTimelineChart(range) { + const today = new Date(); + today.setUTCHours(0, 0, 0, 0); // Use UTC midnight + + let rangeStartDate = null; + const rangeEndDate = new Date(today); // Use a copy + + // Determine the start date for the desired range + switch (range) { + case 'day': + rangeStartDate = new Date(today); + break; + case 'week': + rangeStartDate = new Date(today); + rangeStartDate.setUTCDate(today.getUTCDate() - 6); // 7 days including today + break; + case 'month': + rangeStartDate = new Date(today); + rangeStartDate.setUTCMonth(today.getUTCMonth() - 1); + // Ensure day is valid if previous month was shorter + if (rangeStartDate.getUTCMonth() === today.getUTCMonth()) { + rangeStartDate.setUTCDate(0); // Last day of previous month + } + break; + case 'all': + default: + // Find the earliest date in the full data if it exists + if (fullTimelineData.length > 0) { + // Sort dates to find the earliest reliably (dates are strings) + const sortedDates = [...fullTimelineData].sort((a, b) => a.date.localeCompare(b.date)); + let earliestDate = parseISODate(sortedDates[0].date); + // Set rangeStartDate to one day BEFORE the earliest date + rangeStartDate = new Date(earliestDate); + rangeStartDate.setUTCDate(earliestDate.getUTCDate() - 1); + } else { + rangeStartDate = new Date(today); // Default to today if no data + } + break; + } + + // Create a map of counts from the full data for quick lookup + const countsMap = new Map(fullTimelineData.map(item => [item.date, item.count])); + + // Generate all dates within the calculated range + const filteredData = []; + let currentDate = new Date(rangeStartDate); + while (currentDate <= rangeEndDate) { + const currentDateISO = formatISODate(currentDate); + filteredData.push({ + date: currentDateISO, + count: countsMap.get(currentDateISO) || 0 // Get count or default to 0 + }); + currentDate.setUTCDate(currentDate.getUTCDate() + 1); // Move to the next day + } + + // Prepare labels and counts for the chart + const timelineLabels = filteredData.map(item => item.date); + const timelineCounts = filteredData.map(item => item.count); - // Referrals Timeline Chart with animation - var referralCtx = document.getElementById('referralChart').getContext('2d'); - var referralData = { - labels: [ - {% for item in referral_timeline[-30:] %} - '{{ item.date }}', - {% endfor %} - ], + const chartData = { + labels: timelineLabels, datasets: [{ label: 'Referrals', - data: [ - {% for item in referral_timeline[-30:] %} - {{ item.count }}, - {% endfor %} - ], + data: timelineCounts, backgroundColor: 'rgba(54, 162, 235, 0.2)', borderColor: 'rgba(54, 162, 235, 1)', borderWidth: 2, @@ -357,79 +504,250 @@ tension: 0.4, pointBackgroundColor: 'rgba(54, 162, 235, 1)', pointBorderColor: '#fff', - pointBorderWidth: 2, - pointRadius: 4, - pointHoverRadius: 6, - pointHoverBackgroundColor: 'rgba(54, 162, 235, 1)', - pointHoverBorderColor: '#fff' + pointHoverRadius: 6 }] }; - var referralChart = new Chart(referralCtx, { - type: 'line', - data: referralData, - options: { - responsive: true, - maintainAspectRatio: false, + + // Update or create chart + const ctx = document.getElementById('referralChart')?.getContext('2d'); + if (!ctx) { + console.error("Referral chart context not found"); + return; + } + + if (referralChartInstance) { + referralChartInstance.data = chartData; + // Adjust x-axis ticks based on range + if (range === 'day' || filteredData.length <= 7) { + referralChartInstance.options.scales.x.ticks.maxTicksLimit = undefined; // Show all ticks for short ranges + } else if (range === 'week' || filteredData.length <= 10) { + referralChartInstance.options.scales.x.ticks.maxTicksLimit = 7; + } else { + referralChartInstance.options.scales.x.ticks.maxTicksLimit = 10; // Default for longer ranges + } + referralChartInstance.update(); + } else { + // Initial chart creation options (including integer Y-axis) + const initialOptions = { scales: { - x: { - display: true, - title: { - display: true, - text: 'Date', - font: { - size: 14, - weight: 'bold' + x: { display: true, title: { display: true, text: 'Date', font: { size: 14, weight: 'bold' } }, ticks: { maxTicksLimit: 10, maxRotation: 45, minRotation: 45 }, grid: { display: false } }, + y: { display: true, title: { display: true, text: 'Number of Referrals', font: { size: 14, weight: 'bold' } }, beginAtZero: true, ticks: { precision: 0, stepSize: 1 }, grid: { color: 'rgba(0, 0, 0, 0.05)' } } + }, + animation: { duration: 1500, easing: 'easeOutQuart' } + }; + referralChartInstance = renderChart(ctx, 'referralChart', 'line', chartData, initialOptions); + } + + // Update button styles + const buttons = document.querySelectorAll('#timeline-range-selector button'); + buttons.forEach(button => { + if (button.getAttribute('data-range') === range) { + button.classList.remove('text-gray-600', 'bg-gray-100', 'hover:bg-gray-200'); + button.classList.add('text-white', 'bg-blue-600'); + } else { + button.classList.remove('text-white', 'bg-blue-600'); + button.classList.add('text-gray-600', 'bg-gray-100', 'hover:bg-gray-200'); + } + }); + } + + // Prepare data and render charts on DOMContentLoaded + document.addEventListener('DOMContentLoaded', function() { + // --- Get Contexts --- + const ctxPatientStatus = document.getElementById('patientStatusChart')?.getContext('2d'); + const ctxBmi = document.getElementById('bmiChart')?.getContext('2d'); + const ctxReferralStatus = document.getElementById('referralStatusChart')?.getContext('2d'); + const ctxReportStatus = document.getElementById('reportStatusChart')?.getContext('2d'); + const ctxWorkload = document.getElementById('dietitianWorkloadChart')?.getContext('2d'); + // Context for timeline is retrieved inside updateTimelineChart + + // --- Data from Backend --- + const patientStatusDataRaw = JSON.parse('{{ patient_status_stats | tojson | safe }}') || {}; + const bmiDataRaw = JSON.parse('{{ bmi_stats | tojson | safe }}') || []; + const referralStatusDataRaw = JSON.parse('{{ referral_status_stats | tojson | safe }}') || {}; + const reportStatusDataRaw = JSON.parse('{{ report_status_stats | tojson | safe }}') || {}; + const workloadRawData = JSON.parse('{{ dietitian_workload | tojson | safe }}') || []; + fullTimelineData = JSON.parse('{{ referral_timeline | tojson | safe }}') || []; // Store full data + + // --- Render Initial Charts (excluding timeline) --- + // 1. Patient Status Chart (Bar) + if (ctxPatientStatus) { + try { + if (Object.keys(patientStatusDataRaw).length === 0) { + ctxPatientStatus.canvas.parentElement.innerHTML = '<p class="text-center text-sm text-gray-500 py-4">No patient status data available.</p>'; + } else { + const patientStatusLabels = Object.keys(patientStatusDataRaw); + const patientStatusValues = Object.values(patientStatusDataRaw); + renderChart(ctxPatientStatus, 'patientStatusChart', 'bar', { + labels: patientStatusLabels, + datasets: [{ + label: 'Patient Count', + data: patientStatusValues, + backgroundColor: generateColors(patientStatusLabels.length) + }] + }); + } + } catch (error) { + console.error("Error loading Patient Status chart:", error); + ctxPatientStatus.canvas.parentElement.innerHTML = '<p class="text-center text-sm text-red-500 py-4">Error loading chart.</p>'; + } + } + + // 2. BMI Chart (Pie) + const bmiEntries = Array.isArray(bmiDataRaw) ? bmiDataRaw.filter(item => Array.isArray(item) && item.length === 2 && item[0] != null) : []; + if (ctxBmi) { + try { + if (bmiEntries.length === 0) { + ctxBmi.canvas.parentElement.innerHTML = '<p class="text-center text-sm text-gray-500 py-4">No BMI data available.</p>'; + } else { + const bmiLabels = bmiEntries.map(entry => entry[0]); + const bmiValues = bmiEntries.map(entry => entry[1]); + renderChart(ctxBmi, 'bmiChart', 'pie', { + labels: bmiLabels, + datasets: [{ + label: 'Patients', + data: bmiValues, + backgroundColor: generateColors(bmiLabels.length) + }] + }); + } + } catch (error) { + console.error("Error loading BMI chart:", error); + ctxBmi.canvas.parentElement.innerHTML = '<p class="text-center text-sm text-red-500 py-4">Error loading chart.</p>'; + } + } + + // 3. Referral Status Chart (Bar) + const referralStatusLabels = Object.keys(referralStatusDataRaw); + const referralStatusValues = Object.values(referralStatusDataRaw); + if (ctxReferralStatus) { + try { + if (referralStatusLabels.length === 0) { + ctxReferralStatus.canvas.parentElement.innerHTML = '<p class="text-center text-sm text-gray-500 py-4">No referral status data available.</p>'; + } else { + const referralStatusChart = renderChart(ctxReferralStatus, 'referralStatusChart', 'bar', { + labels: referralStatusLabels, + datasets: [{ + label: 'Referral Count', + data: referralStatusValues, + backgroundColor: generateColors(referralStatusLabels.length) + }] + }); + // Add click handler + if (referralStatusChart) { + ctxReferralStatus.canvas.onclick = function(evt) { + const points = referralStatusChart.getElementsAtEventForMode(evt, 'nearest', { intersect: true }, true); + if (points.length) { + const firstPoint = points[0]; + const originalStatus = referralStatusLabels[firstPoint.index]; + const encodedStatus = encodeURIComponent(originalStatus || ''); + window.location.href = `{{ url_for('patients.index') }}?referral_status=${encodedStatus}`; } - }, - ticks: { - maxTicksLimit: 10, - maxRotation: 45, - minRotation: 45 - }, - grid: { - display: false - } - }, - y: { - display: true, - title: { - display: true, - text: 'Number of Referrals', - font: { - size: 14, - weight: 'bold' + }; + ctxReferralStatus.canvas.style.cursor = 'pointer'; + } + } + } catch (error) { + console.error("Error loading Referral Status chart:", error); + ctxReferralStatus.canvas.parentElement.innerHTML = '<p class="text-center text-sm text-red-500 py-4">Error loading chart.</p>'; + } + } + + // 4. Report Status Chart (Doughnut) + const reportStatusLabels = Object.keys(reportStatusDataRaw); + const reportStatusValues = Object.values(reportStatusDataRaw); + if (ctxReportStatus) { + try { + if (reportStatusLabels.length === 0) { + ctxReportStatus.canvas.parentElement.innerHTML = '<p class="text-center text-sm text-gray-500 py-4">No report status data available.</p>'; + } else { + const reportStatusChart = renderChart(ctxReportStatus, 'reportStatusChart', 'doughnut', { + labels: reportStatusLabels, + datasets: [{ + label: 'Reports', + data: reportStatusValues, + backgroundColor: generateColors(reportStatusLabels.length) + }] + }); + // Add click handler + if (reportStatusChart) { + ctxReportStatus.canvas.onclick = function(evt) { + const points = reportStatusChart.getElementsAtEventForMode(evt, 'nearest', { intersect: true }, true); + if (points.length) { + const firstPoint = points[0]; + const originalStatus = reportStatusLabels[firstPoint.index]; + const encodedStatus = encodeURIComponent(originalStatus || ''); + window.location.href = `{{ url_for('report.index') }}?status=${encodedStatus}`; } - }, - beginAtZero: true, - grid: { - color: 'rgba(0, 0, 0, 0.05)' - } + }; + ctxReportStatus.canvas.style.cursor = 'pointer'; } - }, - plugins: { - tooltip: { - backgroundColor: 'rgba(0, 0, 0, 0.7)', - padding: 10, - titleColor: '#fff', - titleFont: { - size: 14 - }, - bodyColor: '#fff', - bodyFont: { - size: 13 + } + } catch (error) { + console.error("Error loading Report Status chart:", error); + ctxReportStatus.canvas.parentElement.innerHTML = '<p class="text-center text-sm text-red-500 py-4">Error loading chart.</p>'; + } + } + + // 5. Dietitian Workload Chart (Pie with Custom Legend) + const validWorkloadData = Array.isArray(workloadRawData) ? workloadRawData.filter(item => item && item.name != null && item.count != null) : []; + if(ctxWorkload) { + try { + if (validWorkloadData.length === 0) { + ctxWorkload.canvas.parentElement.innerHTML = '<p class="text-center text-sm text-gray-500 py-4">No workload data available.</p>'; + } else { + const workloadLabels = validWorkloadData.map(item => item.name === null || item.name === 'null' ? 'Unassigned' : item.name); + const workloadCounts = validWorkloadData.map(item => item.count); + const workloadColors = generateColors(workloadLabels.length); + const workloadChart = renderChart(ctxWorkload, 'dietitianWorkloadChart', 'pie', { + labels: workloadLabels, + datasets: [{ + label: 'Assigned Patients', + data: workloadCounts, + backgroundColor: workloadColors, + borderColor: workloadColors.map(color => color.replace('0.7', '1')), + borderWidth: 1 + }] + }, { plugins: { legend: { display: false } } }); // Disable default legend + // Generate custom legend + if (workloadChart) { + const legendContainer = document.getElementById('workloadLegend'); + if (legendContainer) { + legendContainer.innerHTML = ''; // Clear previous + validWorkloadData.forEach((item, index) => { + const legendItem = document.createElement('div'); + legendItem.classList.add('inline-flex', 'items-center', 'mr-3', 'mb-1'); + legendItem.innerHTML = ` + <span class="w-3 h-3 mr-1.5 rounded-full flex-shrink-0" style="background-color: ${workloadColors[index].replace('0.7','1')}"></span> + <span class="truncate">${workloadLabels[index]}: ${item.count}</span> + `; + legendContainer.appendChild(legendItem); + }); } - }, - legend: { - display: true, - position: 'top' } - }, - animation: { - duration: 2000, - easing: 'easeOutQuart' + } + } catch(error) { + console.error("Error loading Workload chart:", error); + ctxWorkload.canvas.parentElement.innerHTML = '<p class="text-center text-sm text-red-500 py-4">Error loading chart.</p>'; + } + } + + // --- 6. Initialize Referrals Timeline Chart (with 'all' data) --- + updateTimelineChart('all'); // Initial render with full data + + // --- Add Event Listeners for Timeline Range Buttons --- + const rangeSelector = document.getElementById('timeline-range-selector'); + if (rangeSelector) { + rangeSelector.addEventListener('click', function(event) { + if (event.target.tagName === 'BUTTON') { + const range = event.target.getAttribute('data-range'); + if (range) { + updateTimelineChart(range); } } }); + } + }); </script> {% endblock %} diff --git a/app/templates/dietitian_procedures.html b/app/templates/dietitian_procedures.html index 3af96d7f14bfb8049c94a55966fb24d2c91afc7f..cacbc7fcce601dd78aed4d61266db11cf6405523 100644 --- a/app/templates/dietitian_procedures.html +++ b/app/templates/dietitian_procedures.html @@ -9,12 +9,17 @@ <div class="mb-6 flex justify-between items-center"> <h1 class="text-3xl font-bold text-gray-800">Manage Procedures</h1> - {# Có thể thêm nút Add Procedure ở đây nếu muốn #} - {# <a href="#" class="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded">Add New Procedure</a> #} + {# Hiển thị nút Add Procedure chỉ khi đã chọn bệnh nhân cụ thể #} + {% if selected_patient_id %} + <a href="{{ url_for('dietitian.new_procedure', patient_id=selected_patient_id) }}" + class="bg-blue-600 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded inline-flex items-center"> + <i class="fas fa-plus mr-2"></i> Add Procedure + </a> + {% endif %} </div> <!-- Filter Form --> - <form method="GET" action="{{ url_for('.list_procedures') }}" class="mb-6 bg-white shadow-md rounded px-8 pt-6 pb-8"> + <form method="GET" action="{{ url_for('.list_my_procedures') }}" class="mb-6 bg-white shadow-md rounded px-8 pt-6 pb-8"> <div class="flex flex-wrap gap-4 items-end"> <div class="flex-grow"> <label for="patient_id" class="block text-gray-700 text-sm font-bold mb-2">Filter by Patient:</label> @@ -27,7 +32,7 @@ </div> <div class="flex items-end space-x-2"> <button type="submit" class="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded focus:outline-none focus:shadow-outline">Filter</button> - <a href="{{ url_for('.list_procedures') }}" class="bg-gray-200 hover:bg-gray-300 text-gray-800 font-bold py-2 px-4 rounded focus:outline-none focus:shadow-outline">Reset</a> + <a href="{{ url_for('.list_my_procedures') }}" class="bg-gray-200 hover:bg-gray-300 text-gray-800 font-bold py-2 px-4 rounded focus:outline-none focus:shadow-outline">Reset</a> </div> </div> </form> @@ -92,7 +97,7 @@ <!-- Pagination --> <div class="mt-6"> - {{ render_pagination(pagination, '.list_procedures', filter_args={'patient_id': selected_patient_id}) }} + {{ render_pagination(pagination, '.list_my_procedures', filter_args={'patient_id': selected_patient_id}) }} </div> </div> diff --git a/app/templates/dietitians/new.html b/app/templates/dietitians/new.html index dea99df4f03e6655b09390a17a75e52052115d98..9613147ad0983349d59383d4cb779ed39d81596d 100644 --- a/app/templates/dietitians/new.html +++ b/app/templates/dietitians/new.html @@ -1,4 +1,5 @@ {% extends "base.html" %} +{% from "_formhelpers.html" import render_field %} {% block title %}Thêm chuyên gia dinh dưỡng mới - Admin{% endblock %} @@ -31,73 +32,32 @@ <h1 class="text-2xl font-semibold text-gray-800 mb-6">Tạo tài khoản chuyên gia dinh dưỡng mới</h1> - {# Lưu ý: Route dietitians.new hiện tại có thể chưa xử lý hết các field này, cần cập nhật route #} <form method="POST" action="{{ url_for('dietitians.new') }}" class="bg-white shadow-md rounded-lg px-8 pt-6 pb-8 mb-4"> - {# Assume CSRF token is handled #} + {{ form.csrf_token }} <h3 class="text-lg font-medium text-gray-900 mb-4 border-b pb-2">Thông tin tài khoản (User)</h3> <div class="grid grid-cols-1 md:grid-cols-2 gap-6 mb-6"> <!-- First Name --> - <div> - <label for="firstName" class="block text-sm font-medium text-gray-700 mb-1">Họ <span class="text-red-500">*</span></label> - <input type="text" id="firstName" name="firstName" required - class="shadow-sm focus:ring-blue-500 focus:border-blue-500 block w-full sm:text-sm border-gray-300 rounded-md"> - </div> - + {{ render_field(form.firstName, label_text="Họ") }} <!-- Last Name --> - <div> - <label for="lastName" class="block text-sm font-medium text-gray-700 mb-1">Tên <span class="text-red-500">*</span></label> - <input type="text" id="lastName" name="lastName" required - class="shadow-sm focus:ring-blue-500 focus:border-blue-500 block w-full sm:text-sm border-gray-300 rounded-md"> - </div> - + {{ render_field(form.lastName, label_text="Tên") }} <!-- Email --> - <div> - <label for="email" class="block text-sm font-medium text-gray-700 mb-1">Email <span class="text-red-500">*</span></label> - <input type="email" id="email" name="email" required - class="shadow-sm focus:ring-blue-500 focus:border-blue-500 block w-full sm:text-sm border-gray-300 rounded-md"> - </div> - + {{ render_field(form.email, label_text="Email") }} <!-- Password --> - <div> - <label for="password" class="block text-sm font-medium text-gray-700 mb-1">Mật khẩu <span class="text-red-500">*</span></label> - <input type="password" id="password" name="password" required - class="shadow-sm focus:ring-blue-500 focus:border-blue-500 block w-full sm:text-sm border-gray-300 rounded-md"> - <p class="text-xs text-gray-500 mt-1">Mật khẩu tạm thời cho người dùng mới.</p> - </div> + {{ render_field(form.password, label_text="Mật khẩu") }} + <div class="md:col-span-2"><p class="text-xs text-gray-500 -mt-4">Mật khẩu tạm thời cho người dùng mới.</p></div> </div> <h3 class="text-lg font-medium text-gray-900 mb-4 border-b pb-2">Thông tin hồ sơ (Dietitian Profile)</h3> <div class="grid grid-cols-1 md:grid-cols-2 gap-6 mb-6"> <!-- Phone --> - <div> - <label for="phone" class="block text-sm font-medium text-gray-700 mb-1">Số điện thoại</label> - <input type="tel" id="phone" name="phone" - class="shadow-sm focus:ring-blue-500 focus:border-blue-500 block w-full sm:text-sm border-gray-300 rounded-md"> - </div> - + {{ render_field(form.phone, label_text="Số điện thoại") }} <!-- Specialization --> - <div> - <label for="specialization" class="block text-sm font-medium text-gray-700 mb-1">Chuyên môn</label> - <input type="text" id="specialization" name="specialization" - class="shadow-sm focus:ring-blue-500 focus:border-blue-500 block w-full sm:text-sm border-gray-300 rounded-md"> - </div> - - <!-- Status --> - <div> - <label for="status" class="block text-sm font-medium text-gray-700 mb-1">Trạng thái <span class="text-red-500">*</span></label> - <select id="status" name="status" required class="shadow-sm focus:ring-blue-500 focus:border-blue-500 block w-full sm:text-sm border-gray-300 rounded-md"> - {% for status_item in status_options %} - <option value="{{ status_item.name }}" {% if status_item.name == 'AVAILABLE' %}selected{% endif %}>{{ status_item.value.replace('_', ' ').title() }}</option> - {% endfor %} - </select> - </div> - + {{ render_field(form.specialization, label_text="Chuyên môn") }} <!-- Notes --> <div class="md:col-span-2"> - <label for="notes" class="block text-sm font-medium text-gray-700 mb-1">Ghi chú</label> - <textarea id="notes" name="notes" rows="3" - class="shadow-sm focus:ring-blue-500 focus:border-blue-500 block w-full sm:text-sm border border-gray-300 rounded-md"></textarea> + <label for="notes" class="block text-sm font-medium text-gray-700 mb-1">Ghi chú</label> + {{ form.notes(rows="3", class="shadow-sm focus:ring-blue-500 focus:border-blue-500 block w-full sm:text-sm border border-gray-300 rounded-md") }} </div> </div> diff --git a/app/templates/edit_profile.html b/app/templates/edit_profile.html index c2ed5812fbe94d16f24db161bbc145fab4792762..9ae7bacbac1f6ff3e4eefce3037fbcdcf324dd08 100644 --- a/app/templates/edit_profile.html +++ b/app/templates/edit_profile.html @@ -1,181 +1,166 @@ {% extends "base.html" %} +{% from "_formhelpers.html" import render_field, render_field_readonly %} -{% block title %}Chỉnh sửa hồ sơ - CCU HTM{% endblock %} +{% block title %}Chỉnh sửa hồ sơ - {{ super() }}{% endblock %} {% block header %}Chỉnh sửa thông tin cá nhân{% endblock %} {% block content %} <div class="container mx-auto px-4 py-6"> <!-- Breadcrumb --> - <div class="mb-6"> - <nav class="flex" aria-label="Breadcrumb"> - <ol class="inline-flex items-center space-x-1 md:space-x-3"> - <li class="inline-flex items-center"> - <a href="{{ url_for('handle_root') }}" class="inline-flex items-center text-sm font-medium text-gray-700 hover:text-blue-600 transition duration-200"> - <svg class="w-4 h-4 mr-2" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z"></path></svg> - Trang chủ - </a> - </li> - <li aria-current="page"> - <div class="flex items-center"> - <svg class="w-6 h-6 text-gray-400" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clip-rule="evenodd"></path></svg> - <span class="ml-1 text-sm font-medium text-gray-500 md:ml-2">Chỉnh sửa hồ sơ</span> + <nav class="mb-6" aria-label="Breadcrumb"> + <ol class="inline-flex items-center space-x-1 md:space-x-3"> + <li class="inline-flex items-center"> + <a href="{{ url_for('main.handle_root') }}" class="inline-flex items-center text-sm font-medium text-gray-700 hover:text-blue-600 transition duration-200"> + <svg class="w-4 h-4 mr-2" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z"></path></svg> + Trang chủ + </a> + </li> + <li> + <div class="flex items-center"> + <svg class="w-6 h-6 text-gray-400" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clip-rule="evenodd"></path></svg> + <a href="{{ url_for('auth.profile') }}" class="ml-1 text-sm font-medium text-gray-700 hover:text-blue-600 md:ml-2 transition duration-200">Hồ sơ của tôi</a> + </div> + </li> + <li aria-current="page"> + <div class="flex items-center"> + <svg class="w-6 h-6 text-gray-400" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clip-rule="evenodd"></path></svg> + <span class="ml-1 text-sm font-medium text-gray-500 md:ml-2">Chỉnh sửa hồ sơ</span> + </div> + </li> + </ol> + </nav> + + <div class="max-w-4xl mx-auto bg-white shadow-lg overflow-hidden sm:rounded-lg"> + <!-- Tab navigation --> + <div class="border-b border-gray-200"> + <nav class="-mb-px flex space-x-8 px-6" aria-label="Tabs"> + <button id="profile-tab" type="button" class="border-indigo-500 text-indigo-600 whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm" aria-current="page"> + Thông tin cá nhân + </button> + <button id="password-tab" type="button" class="border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm"> + Thay đổi mật khẩu + </button> + </nav> + </div> + + <!-- Profile content --> + <div id="profile-content" class="px-4 py-5 sm:p-6"> + <form action="{{ url_for('auth.edit_profile') }}" method="POST" class="space-y-6"> + {{ form.csrf_token if form is defined }} + <input type="hidden" name="form_type" value="profile"> + + <div class="grid grid-cols-1 gap-y-6 gap-x-4 sm:grid-cols-6"> + <div class="sm:col-span-3"> + {{ render_field(form.firstName, label_text="Tên") }} </div> - </li> - </ol> - </nav> - </div> - <div class="max-w-4xl mx-auto"> - <div class="bg-white shadow overflow-hidden sm:rounded-lg"> - <div class="px-4 py-5 sm:px-6 border-b border-gray-200"> - <h3 class="text-lg leading-6 font-medium text-gray-900">Chỉnh sửa thông tin cá nhân</h3> - <p class="mt-1 max-w-2xl text-sm text-gray-500">Cập nhật thông tin chi tiết của bạn.</p> - </div> - - <div class="px-4 py-5 sm:p-6"> - <!-- Tab navigation --> - <nav class="flex border-b border-gray-200"> - <button id="profile-tab" type="button" class="py-4 px-6 text-center border-b-2 border-primary-500 font-medium text-sm text-primary-600 bg-white" aria-current="page"> - Thông tin cá nhân - </button> - <button id="password-tab" type="button" class="py-4 px-6 text-center border-b-2 border-transparent font-medium text-sm text-gray-500 hover:text-gray-700 hover:border-gray-300 bg-white"> - Thay đổi mật khẩu - </button> - </nav> - - <!-- Profile content --> - <div id="profile-content" class="py-6"> - <form action="{{ url_for('auth.edit_profile') }}" method="POST" class="space-y-8 divide-y divide-gray-200"> - {{ form.csrf_token if form is defined }} - <input type="hidden" name="form_type" value="profile"> - <div class="space-y-8 divide-y divide-gray-200"> - <div class="grid grid-cols-6 gap-6"> - <div class="col-span-6 sm:col-span-3"> - <label for="firstName" class="block text-sm font-medium text-gray-700">Tên</label> - {{ form.firstName(id="firstName", class="mt-1 focus:ring-primary-500 focus:border-primary-500 block w-full shadow-sm sm:text-sm border border-gray-300 rounded-md", required=True) }} - {% if form.firstName.errors %} - <p class="mt-2 text-sm text-red-600">{{ form.firstName.errors[0] }}</p> - {% endif %} - </div> - - <div class="col-span-6 sm:col-span-3"> - <label for="lastName" class="block text-sm font-medium text-gray-700">Họ</label> - {{ form.lastName(id="lastName", class="mt-1 focus:ring-primary-500 focus:border-primary-500 block w-full shadow-sm sm:text-sm border border-gray-300 rounded-md", required=True) }} - {% if form.lastName.errors %} - <p class="mt-2 text-sm text-red-600">{{ form.lastName.errors[0] }}</p> - {% endif %} - </div> - - <div class="col-span-6 sm:col-span-3"> - <label for="email" class="block text-sm font-medium text-gray-700">Email</label> - {{ form.email(id="email", class="mt-1 focus:ring-primary-500 focus:border-primary-500 block w-full shadow-sm sm:text-sm border border-gray-300 rounded-md", required=True) }} - {% if form.email.errors %} - <p class="mt-2 text-sm text-red-600">{{ form.email.errors[0] }}</p> - {% endif %} - </div> - - <div class="col-span-6 sm:col-span-3"> - <label for="phone" class="block text-sm font-medium text-gray-700">Số điện thoại</label> - {{ form.phone(id="phone", class="mt-1 focus:ring-primary-500 focus:border-primary-500 block w-full shadow-sm sm:text-sm border border-gray-300 rounded-md") }} - {% if form.phone.errors %} - <p class="mt-2 text-sm text-red-600">{{ form.phone.errors[0] }}</p> - {% endif %} - </div> - - {% if current_user.role == 'Dietitian' or (form.specialization is defined) %} - <div class="col-span-6"> - <label for="specialization" class="block text-sm font-medium text-gray-700">Chuyên môn</label> - {{ form.specialization(id="specialization", class="mt-1 focus:ring-primary-500 focus:border-primary-500 block w-full shadow-sm sm:text-sm border border-gray-300 rounded-md") }} - {% if form.specialization.errors %} - <p class="mt-2 text-sm text-red-600">{{ form.specialization.errors[0] }}</p> - {% endif %} - </div> - - <div class="col-span-6"> - <label for="status" class="block text-sm font-medium text-gray-700">Trạng thái</label> - <div class="mt-1 block w-full py-2 px-3 border border-gray-300 bg-gray-100 rounded-md shadow-sm text-sm text-gray-700"> - {{ current_user.dietitian_profile.status.value if current_user.dietitian_profile else 'N/A' }} - <span class="text-xs text-gray-500 ml-2">(Tự động cập nhật)</span> - </div> - </div> - - <div class="col-span-6"> - <label for="notes" class="block text-sm font-medium text-gray-700">Ghi chú</label> - {{ form.notes(id="notes", rows="3", class="mt-1 focus:ring-primary-500 focus:border-primary-500 block w-full shadow-sm sm:text-sm border-gray-300 rounded-md") }} - {% if form.notes.errors %} - <p class="mt-2 text-sm text-red-600">{{ form.notes.errors[0] }}</p> - {% endif %} - </div> - {% endif %} - </div> - </div> - - <div class="pt-5"> - <div class="flex justify-end"> - <button type="button" class="bg-white py-2 px-4 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500" onclick="window.location.href='{{ url_for('auth.profile') }}'"> - Hủy - </button> - <button type="submit" class="ml-3 inline-flex justify-center py-2 px-4 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-primary-600 hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500"> - Lưu thay đổi - </button> - </div> + <div class="sm:col-span-3"> + {{ render_field(form.lastName, label_text="Họ") }} + </div> + + <div class="sm:col-span-4"> + {{ render_field(form.email, label_text="Email") }} + </div> + + <div class="sm:col-span-4"> + {{ render_field(form.phone, label_text="Số điện thoại") }} + </div> + + {% if current_user.role == 'Dietitian' and current_user.dietitian %} + <div class="sm:col-span-6 border-t border-gray-200 pt-6 mt-6"> + <h3 class="text-base font-semibold leading-6 text-gray-900">Thông tin Chuyên gia Dinh dưỡng</h3> + </div> + + <div class="sm:col-span-4"> + <label class="block text-sm font-medium text-gray-700">Dietitian ID</label> + <input type="text" value="{{ current_user.dietitian.formattedID }}" readonly + class="mt-1 block w-full bg-gray-50 shadow-sm focus:ring-primary-500 focus:border-primary-500 sm:text-sm border-gray-300 rounded-md"> + </div> + + <div class="sm:col-span-4"> + <label class="block text-sm font-medium text-gray-700">Trạng thái</label> + <input type="text" value="{{ current_user.dietitian.status.value.replace('_', ' ').title() }}" readonly + class="mt-1 block w-full bg-gray-50 shadow-sm focus:ring-primary-500 focus:border-primary-500 sm:text-sm border-gray-300 rounded-md"> + <p class="mt-1 text-xs text-gray-500">Trạng thái được cập nhật tự động dựa trên số lượng bệnh nhân.</p> + </div> + + <div class="sm:col-span-6"> + {{ render_field(form.specialization, label_text="Chuyên môn") }} + </div> + + <div class="sm:col-span-6"> + <label for="notes" class="block text-sm font-medium text-gray-700">Ghi chú</label> + <div class="mt-1"> + {{ form.notes(id="notes", rows="4", class="shadow-sm focus:ring-indigo-500 focus:border-indigo-500 block w-full sm:text-sm border border-gray-300 rounded-md") }} </div> - </form> + {% if form.notes.errors %}<p class="mt-2 text-sm text-red-600">{{ form.notes.errors[0] }}</p>{% endif %} + </div> + {% endif %} + </div> + + <div class="pt-5 border-t border-gray-200 mt-6"> + <div class="flex justify-end"> + <a href="{{ url_for('auth.profile') }}" class="bg-white py-2 px-4 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"> + Hủy + </a> + <button type="submit" class="ml-3 inline-flex justify-center py-2 px-4 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"> + Lưu thay đổi + </button> + </div> + </div> + </form> + </div> + + <!-- Password content (Initially hidden) --> + <div id="password-content" class="px-4 py-5 sm:p-6 hidden"> + <form action="{{ url_for('auth.change_password') }}" method="POST" class="space-y-6"> + <input type="hidden" name="csrf_token" value="{{ csrf_token() }}"> + <div> + <h3 class="text-base font-semibold leading-6 text-gray-900">Thay đổi mật khẩu</h3> + <p class="mt-1 text-sm text-gray-500">Cập nhật mật khẩu của bạn để bảo mật tài khoản.</p> </div> - <!-- Password content (Initially hidden) --> - <div id="password-content" class="py-6 hidden"> - <form action="{{ url_for('auth.change_password') }}" method="POST" class="space-y-8 divide-y divide-gray-200"> - <input type="hidden" name="csrf_token" value="{{ csrf_token() }}"> - <div class="space-y-8 divide-y divide-gray-200"> - <div> - <div> - <h3 class="text-lg leading-6 font-medium text-gray-900">Thay đổi mật khẩu</h3> - <p class="mt-1 text-sm text-gray-500">Cập nhật mật khẩu của bạn để bảo mật tài khoản.</p> - </div> - - <div class="mt-6 grid grid-cols-1 gap-y-6 gap-x-4 sm:grid-cols-6"> - <div class="sm:col-span-4"> - <label for="current_password" class="block text-sm font-medium text-gray-700">Mật khẩu hiện tại</label> - <div class="mt-1"> - <input type="password" name="current_password" id="current_password" - class="shadow-sm focus:ring-primary-500 focus:border-primary-500 block w-full sm:text-sm border border-gray-300 rounded-md" required> - </div> - </div> - - <div class="sm:col-span-4"> - <label for="new_password" class="block text-sm font-medium text-gray-700">Mật khẩu mới</label> - <div class="mt-1"> - <input type="password" name="new_password" id="new_password" - class="shadow-sm focus:ring-primary-500 focus:border-primary-500 block w-full sm:text-sm border border-gray-300 rounded-md" required> - </div> - </div> - - <div class="sm:col-span-4"> - <label for="confirm_password" class="block text-sm font-medium text-gray-700">Xác nhận mật khẩu mới</label> - <div class="mt-1"> - <input type="password" name="confirm_password" id="confirm_password" - class="shadow-sm focus:ring-primary-500 focus:border-primary-500 block w-full sm:text-sm border border-gray-300 rounded-md" required> - </div> - </div> - </div> - </div> + <div class="grid grid-cols-1 gap-y-6 sm:grid-cols-6"> + <div class="sm:col-span-4"> + <label for="current_password" class="block text-sm font-medium text-gray-700">Mật khẩu hiện tại</label> + <div class="mt-1"> + <input type="password" name="current_password" id="current_password" + class="shadow-sm focus:ring-indigo-500 focus:border-indigo-500 block w-full sm:text-sm border border-gray-300 rounded-md" required> + {% if password_form and password_form.current_password.errors %}<p class="mt-2 text-sm text-red-600">{{ password_form.current_password.errors[0] }}</p>{% endif %} </div> - - <div class="pt-5"> - <div class="flex justify-end"> - <button type="button" class="bg-white py-2 px-4 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500" onclick="window.location.href='{{ url_for('auth.profile') }}'"> - Hủy - </button> - <button type="submit" class="ml-3 inline-flex justify-center py-2 px-4 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-primary-600 hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500"> - Lưu thay đổi - </button> - </div> + </div> + + <div class="sm:col-span-4"> + <label for="new_password" class="block text-sm font-medium text-gray-700">Mật khẩu mới</label> + <div class="mt-1"> + <input type="password" name="new_password" id="new_password" + class="shadow-sm focus:ring-indigo-500 focus:border-indigo-500 block w-full sm:text-sm border border-gray-300 rounded-md" required> + {% if password_form and password_form.new_password.errors %}<p class="mt-2 text-sm text-red-600">{{ password_form.new_password.errors[0] }}</p>{% endif %} + </div> + </div> + + <div class="sm:col-span-4"> + <label for="confirm_password" class="block text-sm font-medium text-gray-700">Xác nhận mật khẩu mới</label> + <div class="mt-1"> + <input type="password" name="confirm_password" id="confirm_password" + class="shadow-sm focus:ring-indigo-500 focus:border-indigo-500 block w-full sm:text-sm border border-gray-300 rounded-md" required> + {% if password_form and password_form.confirm_password.errors %}<p class="mt-2 text-sm text-red-600">{{ password_form.confirm_password.errors[0] }}</p>{% endif %} </div> - </form> + </div> + </div> + + <div class="pt-5 border-t border-gray-200 mt-6"> + <div class="flex justify-end"> + <button type="button" class="bg-white py-2 px-4 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" onclick="showProfileTab()"> + Hủy + </button> + <button type="submit" class="ml-3 inline-flex justify-center py-2 px-4 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"> + Cập nhật mật khẩu + </button> + </div> </div> - </div> + </form> </div> </div> </div> @@ -188,39 +173,49 @@ const profileContent = document.getElementById('profile-content'); const passwordContent = document.getElementById('password-content'); - // Kiểm tra hash URL để hiển thị tab tương ứng + // Function to switch tabs + function switchTab(activeTab, inactiveTab, activeContent, inactiveContent, hash) { + activeTab.classList.remove('border-transparent', 'text-gray-500', 'hover:text-gray-700', 'hover:border-gray-300'); + activeTab.classList.add('border-indigo-500', 'text-indigo-600'); + activeTab.setAttribute('aria-current', 'page'); + + inactiveTab.classList.remove('border-indigo-500', 'text-indigo-600'); + inactiveTab.classList.add('border-transparent', 'text-gray-500', 'hover:text-gray-700', 'hover:border-gray-300'); + inactiveTab.removeAttribute('aria-current'); + + activeContent.classList.remove('hidden'); + inactiveContent.classList.add('hidden'); + + // Update URL hash without causing page jump if possible + if (history.pushState) { + history.pushState(null, null, hash ? '#' + hash : window.location.pathname + window.location.search); + } else { + window.location.hash = hash; + } + } + + const showProfileTab = () => switchTab(profileTab, passwordTab, profileContent, passwordContent, ''); + const showPasswordTab = () => switchTab(passwordTab, profileTab, passwordContent, profileContent, 'change-password'); + + // Check initial hash if (window.location.hash === '#change-password') { showPasswordTab(); + } else { + showProfileTab(); // Default to profile tab } + // Add event listeners profileTab.addEventListener('click', showProfileTab); passwordTab.addEventListener('click', showPasswordTab); - function showProfileTab() { - profileTab.classList.add('border-primary-500', 'text-primary-600'); - profileTab.classList.remove('border-transparent', 'text-gray-500'); - passwordTab.classList.add('border-transparent', 'text-gray-500'); - passwordTab.classList.remove('border-primary-500', 'text-primary-600'); - - profileContent.classList.remove('hidden'); - passwordContent.classList.add('hidden'); - - // Cập nhật URL hash - window.location.hash = ''; - } - - function showPasswordTab() { - passwordTab.classList.add('border-primary-500', 'text-primary-600'); - passwordTab.classList.remove('border-transparent', 'text-gray-500'); - profileTab.classList.add('border-transparent', 'text-gray-500'); - profileTab.classList.remove('border-primary-500', 'text-primary-600'); - - passwordContent.classList.remove('hidden'); - profileContent.classList.add('hidden'); - - // Cập nhật URL hash - window.location.hash = 'change-password'; - } + // Handle back/forward navigation + window.addEventListener('hashchange', function() { + if (window.location.hash === '#change-password') { + showPasswordTab(); + } else { + showProfileTab(); + } + }); }); </script> {% endblock %} \ No newline at end of file diff --git a/app/templates/list_patient_procedures.html b/app/templates/list_patient_procedures.html new file mode 100644 index 0000000000000000000000000000000000000000..6f1261ea5fe0c4de6b751319f6310eec3cd5bf3d --- /dev/null +++ b/app/templates/list_patient_procedures.html @@ -0,0 +1,106 @@ +{% extends 'base.html' %} + +{% block title %}Procedures for {{ patient.full_name }} - Dietitian Area{% endblock %} + +{% block content %} +<div class="container mx-auto px-4 py-6 animate-slide-in"> + + <div class="bg-white shadow-md rounded-lg p-6 mb-8"> + <div class="flex flex-col sm:flex-row justify-between items-start sm:items-center mb-6 pb-4 border-b border-gray-200"> + <div> + <h1 class="text-2xl font-bold text-gray-800"> + Procedures for: {{ patient.full_name }} ({{ patient.id }}) + </h1> + <p class="text-sm text-gray-500 mt-1"> + <a href="{{ url_for('patients.patient_detail', patient_id=patient.id) }}" class="text-primary-600 hover:underline">Back to Patient Detail</a> + </p> + </div> + {# Nút Add Procedure #} + <a href="{{ url_for('.new_procedure', patient_id=patient.id) }}" + class="mt-3 sm:mt-0 inline-flex items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 transition-colors duration-150 + {% if has_ongoing_encounter %}bg-blue-600 hover:bg-blue-700{% else %}bg-gray-400 cursor-not-allowed{% endif %}" + {% if not has_ongoing_encounter %}aria-disabled="true" title="No active encounter to add procedure to." tabindex="-1" {% endif %}> + <i class="fas fa-plus mr-2"></i> Add New Procedure + </a> + </div> + + {# Procedure Table #} + {% if procedures and procedures|length > 0 %} + <div class="overflow-x-auto"> + <table class="min-w-full divide-y divide-gray-200"> + <thead class="bg-gray-50"> + <tr> + <th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Type</th> + <th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Name</th> + <th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Performed Time</th> + <th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">End Time</th> + <th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Description</th> + <th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Results</th> + <th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Encounter</th> + <th scope="col" class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th> + </tr> + </thead> + <tbody class="bg-white divide-y divide-gray-200"> + {% for proc in procedures %} + <tr class="hover:bg-gray-50"> + <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">{{ proc.procedureType }}</td> + <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{{ proc.procedureName or 'N/A' }}</td> + <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{{ proc.procedureDateTime.strftime('%d/%m/%Y %H:%M') if proc.procedureDateTime else 'N/A' }}</td> + <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{{ proc.procedureEndDateTime.strftime('%d/%m/%Y %H:%M') if proc.procedureEndDateTime else 'N/A' }}</td> + <td class="px-6 py-4 whitespace-normal text-sm text-gray-500 max-w-xs truncate" title="{{ proc.description or 'N/A' }}">{{ proc.description or 'N/A' }}</td> + <td class="px-6 py-4 whitespace-normal text-sm text-gray-500 max-w-xs truncate" title="{{ proc.procedureResults or 'N/A' }}">{{ proc.procedureResults or 'N/A' }}</td> + <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500"> + {% if proc.encounter %} + <a href="{{ url_for('patients.encounter_measurements', patient_id=proc.patient_id, encounter_id=proc.encounter.encounterID) }}" class="text-blue-600 hover:underline"> + {{ proc.encounter.custom_encounter_id or proc.encounter.encounterID }} + </a> + {% else %} + N/A + {% endif %} + </td> + <td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium space-x-2"> + {# Nút Edit #} + <a href="{{ url_for('.edit_procedure', procedure_id=proc.id) }}" + class="text-indigo-600 hover:text-indigo-900 text-lg p-1 rounded-md hover:bg-indigo-100 transition duration-150 ease-in-out" + title="Edit Procedure"> + <i class="fas fa-edit"></i> + </a> + {# Nút Delete (dùng JS confirm hoặc modal nếu có) #} + <form action="{{ url_for('.delete_procedure', procedure_id=proc.id) }}" method="POST" class="inline needs-confirmation" data-confirmation-message="Are you sure you want to delete procedure #{{ proc.id }}?"> + <input type="hidden" name="_method" value="DELETE"> {# Nếu dùng method override #} + {{ csrf_token() if csrf_token else '' }} {# Add CSRF token #} + <button type="submit" + class="text-red-600 hover:text-red-900 text-lg p-1 rounded-md hover:bg-red-100 transition duration-150 ease-in-out" + title="Delete Procedure"> + <i class="fas fa-trash-alt"></i> + </button> + </form> + </td> + </tr> + {% endfor %} + </tbody> + </table> + </div> + {% else %} + <p class="text-gray-500 italic text-center py-4">No procedures recorded for this patient yet.</p> + {% endif %} + </div> + +</div> + +{# Optional: Add JS for confirmation modal if needed #} +<script> +document.addEventListener('DOMContentLoaded', function() { + // Basic confirmation for delete buttons + document.querySelectorAll('form.needs-confirmation').forEach(form => { + form.addEventListener('submit', function(event) { + const message = this.getAttribute('data-confirmation-message') || 'Are you sure?'; + if (!confirm(message)) { + event.preventDefault(); // Stop submission if user cancels + } + }); + }); +}); +</script> + +{% endblock %} \ No newline at end of file diff --git a/app/templates/login.html b/app/templates/login.html index 0d1aef110b7ae05601cba7979ba7b7ca18830e01..ce62c09868201387ece0bde02cdea4ac01b632cb 100644 --- a/app/templates/login.html +++ b/app/templates/login.html @@ -150,10 +150,6 @@ Ghi nhớ đăng nhập </label> </div> - - <a href="{{ url_for('auth.reset_password_request') }}" class="text-sm md:text-base text-blue-600 hover:text-blue-800 transition ml-4"> - Quên mật khẩu? - </a> </div> <button type="submit" class="btn-login w-full border border-transparent rounded-md shadow-sm font-medium text-white focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"> diff --git a/app/templates/patient_detail.html b/app/templates/patient_detail.html index d81f1a5e23df0bcd77f7930d86dff88fc1e53b6c..efe24ced7e78d210da42e51782c4e825b6867427 100644 --- a/app/templates/patient_detail.html +++ b/app/templates/patient_detail.html @@ -97,7 +97,7 @@ {% set p_status = patient.status %} {% set p_color_map = { PatientStatus.NOT_ASSESSED: 'gray', - PatientStatus.NEEDS_ASSESSMENT: 'amber', + PatientStatus.NEEDS_ASSESSMENT: 'red', PatientStatus.ASSESSMENT_IN_PROGRESS: 'blue', PatientStatus.COMPLETED: 'green' } %} @@ -239,11 +239,24 @@ {# Định dạng lại BMI #} <div class="text-3xl font-bold text-gray-900">{{ "%.1f"|format(patient.bmi) if patient.bmi else '--' }}</div> <div class="mt-1 w-full bg-gray-200 rounded-full h-2"> - {% set bmi_percentage = patient.get_bmi_percentage() %} + {# Tính toán lại phần trăm dựa trên thang đo 0-45 cho hiển thị #} + {% set visual_bmi_max = 45.0 %} + {% set bmi_value = patient.bmi if patient.bmi is not none else 0 %} + {% set bmi_percentage = ((bmi_value / visual_bmi_max) * 100) | round(1) %} + {% set bmi_percentage = 100 if bmi_percentage > 100 else bmi_percentage %} {# Lấy tên màu (ví dụ: 'blue', 'green') từ hàm helper #} {% set bmi_color_class_name = patient.get_bmi_color_class() or 'gray' %} - {# Sử dụng class động cho màu nền và style inline cho width, thêm dấu ; #} - <div class="h-2 rounded-full bg-{{ bmi_color_class_name }}-500" style="width: {{ bmi_percentage }}%;"></div> + {# Sử dụng class động cho màu nền và style inline cho width - SỬA LỖI LINTER (Attempt 3) #} + {# Tạo biến để lưu class màu #} + {% set bmi_bg_class = 'bg-gray-500' %} {# Default #} + {% if bmi_color_class_name == 'blue' %}{% set bmi_bg_class = 'bg-blue-500' %} + {% elif bmi_color_class_name == 'green' %}{% set bmi_bg_class = 'bg-green-500' %} + {% elif bmi_color_class_name == 'yellow' %}{% set bmi_bg_class = 'bg-yellow-500' %} + {% elif bmi_color_class_name == 'orange' %}{% set bmi_bg_class = 'bg-orange-500' %} + {% elif bmi_color_class_name == 'red' %}{% set bmi_bg_class = 'bg-red-500' %} + {% endif %} + {# In class vào thẻ div #} + <div class="h-2 rounded-full {{ bmi_bg_class }}" style="width: {{ bmi_percentage }}%;"></div> </div> <div class="mt-2 flex justify-between w-full text-xs text-gray-500"> <span>0</span> @@ -380,7 +393,7 @@ <th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Chuyên gia DD</th> <th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Chỉ số bất ổn (Max 3)</th> <th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Trạng thái</th> - <th scope="col" class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Thao tác</th> + <th scope="col" class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">THAO TÁC</th> </tr> </thead> <tbody class="bg-white divide-y divide-gray-200"> @@ -460,7 +473,7 @@ <th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Lý do</th> <th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Trạng thái</th> <th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Chuyên gia DD</th> - <th scope="col" class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Thao tác</th> + <th scope="col" class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">THAO TÁC</th> </tr> </thead> <tbody class="bg-white divide-y divide-gray-200"> @@ -537,20 +550,33 @@ <div id="procedures" class="tab-pane"> {# Apply Tailwind classes to the procedure content #} <div class="bg-white shadow rounded-lg overflow-hidden"> - <div class="px-4 py-5 sm:px-6 flex justify-between items-center border-b border-gray-200"> - <h3 class="text-lg leading-6 font-medium text-gray-900"> + {# Conditional Header for Procedures Tab #} + <div class="px-4 py-5 sm:px-6 flex flex-col sm:flex-row justify-between items-start sm:items-center border-b border-gray-200"> + <h3 class="text-lg leading-6 font-medium text-gray-900 mb-2 sm:mb-0"> Danh sách Thủ thuật </h3> - {# Check for ongoing encounter to enable button #} - {% set has_ongoing_encounter = latest_encounter and latest_encounter.status == EncounterStatus.ON_GOING %} - <a href="{{ url_for('dietitian.new_procedure', patient_id=patient.id) }}" - class="inline-flex items-center px-3 py-1 border border-transparent rounded text-sm font-medium text-white focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 transition-colors duration-150 - {% if has_ongoing_encounter %}bg-blue-600 hover:bg-blue-700{% else %}bg-gray-400 cursor-not-allowed{% endif %}" - {% if not has_ongoing_encounter %}aria-disabled="true" title="No active encounter to add procedure to."{% endif %}> - <i class="fas fa-plus mr-1"></i> Thêm Thủ thuật - </a> + {# Check if procedures list exists and is not empty #} + {% if procedures and procedures|length > 0 %} + {# Display Add button ONLY if procedures exist #} + {% set has_ongoing_encounter = latest_encounter and latest_encounter.status == EncounterStatus.ON_GOING %} + <a href="{{ url_for('dietitian.new_procedure', patient_id=patient.id) }}" + class="inline-flex items-center px-3 py-1 border border-transparent rounded text-sm font-medium text-white focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 transition-colors duration-150 + {% if has_ongoing_encounter %}bg-blue-600 hover:bg-blue-700{% else %}bg-gray-400 cursor-not-allowed{% endif %}" + {% if not has_ongoing_encounter %}aria-disabled="true" title="No active encounter to add procedure to." tabindex="-1"{% endif %}> + <i class="fas fa-plus mr-1"></i> Thêm Thủ thuật + </a> + {% else %} + {# Display text link if no procedures exist #} + <p class="text-sm text-gray-600"> + No procedures yet! + <a href="{{ url_for('dietitian.list_my_procedures') }}" class="text-blue-600 hover:underline">Click to go to the procedures list.</a> + </p> + {% endif %} </div> + {# End Conditional Header #} + <div class="px-4 py-5 sm:p-6"> + {# Keep the table rendering logic, it handles the empty case internally #} {% if procedures and procedures|length > 0 %} <div class="overflow-x-auto"> <table class="min-w-full divide-y divide-gray-200"> @@ -606,7 +632,8 @@ </table> </div> {% else %} - <p class="text-gray-500 italic">No procedures recorded for this encounter.</p> + {# Message already handled in the header part now #} + <p class="text-gray-500 italic text-center py-4">No procedures recorded for this patient.</p> {% endif %} </div> </div> @@ -614,130 +641,87 @@ {# ... other sections ... #} - </div> {# End grid #} - </div> {# End container #} - - <!-- Delete Procedure Confirmation Modal --> - <div id="deleteProcedureConfirmModal" - class="fixed inset-0 z-50 hidden flex items-center justify-center transition-opacity duration-300 ease-out" - aria-labelledby="deleteProcedureConfirmModalLabel" - aria-modal="true" - role="dialog"> - <!-- Backdrop --> - <div class="fixed inset-0 bg-gray-900 bg-opacity-60 backdrop-blur-sm modal-backdrop transition-opacity duration-300 ease-out" aria-hidden="true"></div> - - <!-- Modal Content --> - <div class="relative bg-white rounded-lg shadow-xl w-full max-w-md transform transition-all duration-300 ease-out scale-95 opacity-0 modal-content"> - <div class="p-6"> - <div class="flex items-start"> - <div class="mx-auto flex-shrink-0 flex items-center justify-center h-12 w-12 rounded-full bg-red-100 sm:mx-0 sm:h-10 sm:w-10"> - <!-- Heroicon name: outline/exclamation-triangle --> - <svg class="h-6 w-6 text-red-600" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"> - <path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z" /> - </svg> - </div> - <div class="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left flex-grow"> - <h3 class="text-lg font-semibold leading-6 text-gray-900" id="deleteProcedureConfirmModalLabel"> - Xác nhận Xóa Thủ thuật - </h3> - <div class="mt-2"> - <p class="text-sm text-gray-600"> - Bạn có chắc chắn muốn xóa thủ thuật <strong id="procedure-info-placeholder" class="font-medium">[Procedure Info]</strong>? -Hành động này không thể hoàn tác. - </p> - </div> - </div> - <button type="button" class="text-gray-400 bg-transparent hover:bg-gray-200 hover:text-gray-900 rounded-lg text-sm p-1.5 ml-auto inline-flex items-center modal-close-btn"> - <svg class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd"></path></svg> - <span class="sr-only">Close modal</span> - </button> - </div> - </div> - <div class="bg-gray-50 px-6 py-4 sm:flex sm:flex-row-reverse rounded-b-lg"> - {# This form's action will be set by JavaScript #} - <form id="delete-procedure-form" action="#" method="POST" class="inline-block"> - {{ empty_form.csrf_token }} {# Use csrf_token from the passed form object #} - <button id="confirm-delete-procedure-btn" type="submit" class="inline-flex w-full justify-center rounded-md bg-red-600 px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-red-500 sm:ml-3 sm:w-auto"> - Xóa - </button> - </form> - <button type="button" class="mt-3 inline-flex w-full justify-center rounded-md bg-white px-4 py-2 text-sm font-semibold text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 hover:bg-gray-50 sm:mt-0 sm:w-auto modal-close-btn"> - Hủy bỏ - </button> - </div> - </div> - </div> -{% endif %} {# Đóng khối if not current_user.is_admin cho tab Thủ thuật #} + </div> {# End Procedures Tab Pane #} + {% endif %} - <!-- Tab Báo cáo --> + <!-- Tab Báo cáo --> {% if not current_user.is_admin %} <div id="reports" class="tab-pane"> - <div class="bg-white shadow-md rounded-lg overflow-hidden"> - <div class="px-4 py-5 sm:px-6 border-b border-gray-200 flex justify-between items-center"> - <h3 class="text-lg leading-6 font-medium text-gray-900"> - Danh sách Báo cáo Dinh dưỡng - </h3> - </div> - <div class="px-4 py-5 sm:p-6"> + <div class="bg-white shadow-md rounded-lg overflow-hidden"> + <div class="px-4 py-5 sm:px-6 border-b border-gray-200 flex justify-between items-center"> + <h3 class="text-lg leading-6 font-medium text-gray-900"> + Danh sách Báo cáo Dinh dưỡng + </h3> + </div> + <div class="px-4 py-5 sm:p-6"> {% if reports %} - <table class="min-w-full divide-y divide-gray-200"> - <thead class="bg-gray-50"> - <tr> + <table class="min-w-full divide-y divide-gray-200"> + <thead class="bg-gray-50"> + <tr> <th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">ENCOUNTER ID</th> <th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">CREATED DATE</th> <th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">IN-CHARGE DIETITIAN</th> <th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">STATUS</th> {# Change MORE back to THAO TAC and align right #} <th scope="col" class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">THAO TÁC</th> - </tr> - </thead> - <tbody class="bg-white divide-y divide-gray-200"> + </tr> + </thead> + <tbody class="bg-white divide-y divide-gray-200"> {% for report in reports|sort(attribute='report_date', reverse=True) %} {# Sắp xếp báo cáo theo ngày, mới nhất đầu tiên #} - <tr class="hover:bg-gray-50 transition-colors duration-150"> - <td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900"> + <tr class="hover:bg-gray-50 transition-colors duration-150"> + <td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900"> {# Hiển thị encounter ID, link tới report view #} <a href="{{ url_for('report.view_report', report_id=report.id) }}" class="text-primary-600 hover:text-primary-900 hover:underline"> #{{ report.encounter.custom_encounter_id if report.encounter and report.encounter.custom_encounter_id else report.encounter_id }} </a> - </td> - <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{{ report.report_date.strftime('%d/%m/%Y %H:%M') if report.report_date else 'N/A' }}</td> - {# Hiển thị tên người tạo #} + </td> + <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{{ report.report_date.strftime('%d/%m/%Y %H:%M') if report.report_date else 'N/A' }}</td> + {# SỬA: Hiển thị dietitian được gán cho report, fallback về author #} <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500"> {# Ưu tiên hiển thị dietitian được gán cho report #} {{ report.dietitian.full_name if report.dietitian else (report.author.full_name if report.author else 'N/A') }} </td> - <td class="px-6 py-4 whitespace-nowrap"> - {% set status_color = 'green' if report.status == 'Completed' else 'yellow' if report.status == 'Pending' else 'gray' if report.status == 'Draft' else 'gray' %} - <span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-{{ status_color }}-100 text-{{ status_color }}-800"> - {{ report.status }} - </span> - </td> + <td class="px-6 py-4 whitespace-nowrap"> + {# Directly use status value and map colors #} + {% set status_value = report.status.value %} + {% set status_color_map = { + 'Draft': 'gray', + 'Pending': 'yellow', + 'Completed': 'green' + } %} + {% set status_color = status_color_map.get(status_value, 'gray') %} + <span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-{{ status_color }}-100 text-{{ status_color }}-800"> + {{ status_value }} + </span> + </td> <td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium"> {# Bọc các actions vào div để căn chỉnh #} <div class="flex items-center justify-end space-x-2"> {# Nút Xem #} <a href="{{ url_for('report.view_report', report_id=report.id) }}" class="text-blue-600 hover:text-blue-900" title="Xem chi tiết"><i class="fas fa-eye"></i></a> - {# Nút Sửa (chỉ khi Pending/Draft và có quyền) #} - {% if report.status in ['Pending', 'Draft'] and (current_user.is_admin or report.dietitian_id == current_user.userID) %} + {# SỬA: Nút Sửa (chỉ khi Pending/Draft và có quyền) #} + {# Điều kiện: report status là Draft hoặc Pending VÀ (user là admin HOẶC user là author HOẶC user là dietitian được gán) #} + {% set report_status = report.status.value if report.status else None %} + {% if report_status in ['Draft', 'Pending'] and (current_user.is_admin or (report.author_id == current_user.userID) or (report.dietitian_id == current_user.userID)) %} <a href="{{ url_for('report.edit_report', report_id=report.id) }}" class="text-indigo-600 hover:text-indigo-900" title="Chỉnh sửa"><i class="fas fa-edit"></i></a> - {% endif %} + {% endif %} </div> - </td> - </tr> - {% endfor %} - </tbody> - </table> - {% else %} - <p class="text-center text-gray-500 py-6">Chưa có báo cáo nào cho bệnh nhân này.</p> - {% endif %} + </td> + </tr> + {% endfor %} + </tbody> + </table> + {% else %} + <p class="text-center text-gray-500 py-6">Chưa có báo cáo nào cho bệnh nhân này.</p> + {% endif %} + </div> </div> </div> - </div> {% endif %} - <!-- Các tab khác sẽ được thêm vào đây --> -</div> + <!-- Các tab khác sẽ được thêm vào đây --> + </div> </div> {# Include confirmation modal macro #} @@ -1227,10 +1211,10 @@ Hành động này không thể hoàn tác. // If target tab/pane doesn't exist, default to overview if (!tabToActivate || !paneToActivate) { console.log(`[activateTab] Target ${targetId} not found, defaulting to ${defaultTabId}`); - tabToActivate = document.querySelector(`.tab-link[data-target="${defaultTabId}"]`); - paneToActivate = document.getElementById(defaultTabId); - targetId = defaultTabId; // Update targetId for hash update - } + tabToActivate = document.querySelector(`.tab-link[data-target="${defaultTabId}"]`); + paneToActivate = document.getElementById(defaultTabId); + targetId = defaultTabId; // Update targetId for hash update + } if (tabToActivate && paneToActivate) { console.log(`[activateTab] Activating tab ${targetId}`); @@ -1244,7 +1228,7 @@ Hành động này không thể hoàn tác. // Add animation class with a slight delay setTimeout(() => { if (paneToActivate.classList.contains('active')) { - paneToActivate.classList.add('animate-fade-in-fast'); + paneToActivate.classList.add('animate-fade-in-fast'); console.log(`[activateTab] Applied animation to ${targetId}`); } }, 10); // Small delay @@ -1254,10 +1238,10 @@ Hành động này không thể hoàn tác. // Update URL hash without scrolling if (history.replaceState) { - history.replaceState(null, null, `#${targetId}`); - } else { - window.location.hash = `#${targetId}`; - } + history.replaceState(null, null, `#${targetId}`); + } else { + window.location.hash = `#${targetId}`; + } } tabs.forEach(tab => { @@ -1351,212 +1335,178 @@ Hành động này không thể hoàn tác. const backToChoiceBtn = document.getElementById('backToChoiceBtn'); let assignDietitianModalInstance = null; + // INITIALIZE the modal instance FIRST if (assignModalEl && typeof bootstrap !== 'undefined') { assignDietitianModalInstance = new bootstrap.Modal(assignModalEl); + console.log("[Assign Modal] Instance created successfully."); } else { - console.error("Assign Dietitian Modal element or Bootstrap JS not found."); + console.error("[Assign Modal] Modal element or Bootstrap JS not found. Modal cannot be shown."); } // Function to switch views const showChoiceView = () => { - if (choiceView) choiceView.style.display = 'grid'; // Hoặc 'block' tùy layout + // ... (showChoiceView implementation remains the same) + if (choiceView) choiceView.style.display = 'grid'; if (manualView) manualView.style.display = 'none'; if (backToChoiceBtn) backToChoiceBtn.classList.add('hidden'); - if (assignSubmitBtn) assignSubmitBtn.classList.add('hidden'); // Ẩn nút submit chính khi ở view lựa chọn + if (assignSubmitBtn) assignSubmitBtn.classList.add('hidden'); }; const showManualView = () => { + // ... (showManualView implementation remains the same) if (choiceView) choiceView.style.display = 'none'; if (manualView) manualView.style.display = 'block'; if (backToChoiceBtn) backToChoiceBtn.classList.remove('hidden'); - if (assignSubmitBtn) assignSubmitBtn.classList.remove('hidden'); // Hiện nút submit khi ở view manual + if (assignSubmitBtn) assignSubmitBtn.classList.remove('hidden'); if (assignmentTypeInput) assignmentTypeInput.value = 'manual'; }; - // Event listener for the main Assign Dietitian button + // Event listener for the main Assign Dietitian button (on detail page) if (assignDietitianBtn && assignDietitianModalInstance) { - assignDietitianBtn.addEventListener('click', () => { - console.log("[Assign Modal] Assign Dietitian button clicked."); // DEBUG - showChoiceView(); // Reset về view lựa chọn khi mở modal - assignDietitianModalInstance.show(); - }); + assignDietitianBtn.addEventListener('click', () => { + console.log("[Assign Modal] Assign Dietitian button clicked on detail page."); + showChoiceView(); // Reset to choice view + + // Update title and action based on current patient when clicking the button + const assignPatientNameEl = document.querySelector('#assignDietitianModal #modal-title'); + const pagePatientName = document.querySelector('h2.text-2xl.font-bold')?.textContent.trim(); + if (assignPatientNameEl) { + if(pagePatientName) { + assignPatientNameEl.textContent = `Assign Dietitian for ${pagePatientName}`; + } else { + assignPatientNameEl.textContent = `Assign Dietitian`; // Fallback + } + } + const patientIdMatch = window.location.pathname.match(/\/patients\/([^\/]+)/); + const currentPatientId = patientIdMatch ? patientIdMatch[1] : null; + if (assignForm && currentPatientId) { + assignForm.action = `/patients/${currentPatientId}/assign_dietitian`; + console.log("[Assign Modal] Updated form action on button click to: ", assignForm.action); + } else { + console.warn("[Assign Modal] Could not find assign form or patient ID to update action on button click."); + } + + assignDietitianModalInstance.show(); + }); } else if (!assignDietitianBtn) { - console.error("[Assign Modal] Assign Dietitian button (#assignDietitianBtn) not found."); // DEBUG + console.log("[Assign Modal] Assign Dietitian button (#assignDietitianBtn) not found on this page."); } - // Event listeners for buttons inside the modal + // Event listeners for buttons inside the modal (auto, manual, back, close) + // ... (listeners for chooseAutoBtn, chooseManualBtn, backToChoiceBtn, closeModalBtn remain the same) ... if (chooseAutoBtn && assignmentTypeInput && assignForm) { chooseAutoBtn.addEventListener('click', () => { - console.log("[Assign Modal] Auto Assign chosen."); // DEBUG + console.log("[Assign Modal] Auto Assign chosen."); assignmentTypeInput.value = 'auto'; - assignForm.submit(); // Submit form ngay lập tức + assignForm.submit(); }); } if (chooseManualBtn) { chooseManualBtn.addEventListener('click', () => { - console.log("[Assign Modal] Manual Assign chosen."); // DEBUG + console.log("[Assign Modal] Manual Assign chosen."); showManualView(); }); } if (backToChoiceBtn) { backToChoiceBtn.addEventListener('click', () => { - console.log("[Assign Modal] Back button clicked."); // DEBUG + console.log("[Assign Modal] Back button clicked."); showChoiceView(); }); } if (closeModalBtn && assignDietitianModalInstance) { - closeModalBtn.addEventListener('click', () => { - console.log("[Assign Modal] Cancel button clicked. Manually hiding modal elements."); // DEBUG - // *** BỎ HOÀN TOÀN LỆNH GỌI hide() *** - // try { - // if (assignDietitianModalInstance) assignDietitianModalInstance.hide(); - // } catch (e) { - // console.error("[Assign Modal] Error during Bootstrap modalInstance.hide():", e); - // } - - // *** CHỈ DÙNG LOGIC ẨN THỦ CÔNG *** - if (assignModalEl) { - assignModalEl.style.display = 'none'; + closeModalBtn.addEventListener('click', () => { + console.log("[Assign Modal] Cancel button clicked. Instance:", assignDietitianModalInstance); // Log the instance + try { + assignDietitianModalInstance.hide(); + console.log("[Assign Modal] hide() method called successfully."); + } catch (error) { + console.error("[Assign Modal] Error calling hide():", error); + } + // *** THÊM: Buộc ẩn modal thủ công *** + if (assignModalEl) { + console.log("[Assign Modal] Manually forcing display: none and removing classes."); + assignModalEl.style.display = 'none'; assignModalEl.setAttribute('aria-hidden', 'true'); - assignModalEl.classList.remove('show', 'block', 'flex'); - - const backdrop = document.querySelector('.modal-backdrop.fade.show'); - if (backdrop) backdrop.remove(); - - // Xóa class và reset style cho body - document.body.classList.remove('modal-open'); - document.body.style.overflow = ''; - document.body.style.paddingRight = ''; - console.log("[Assign Modal] Manual hide complete."); // DEBUG - - // *** THÊM: Dispose và Re-initialize modal instance *** - if (assignDietitianModalInstance) { - try { - assignDietitianModalInstance.dispose(); - console.log("[Assign Modal] Modal instance disposed."); - // Re-create the instance - if (assignModalEl && typeof bootstrap !== 'undefined') { - assignDietitianModalInstance = new bootstrap.Modal(assignModalEl); - console.log("[Assign Modal] Modal instance re-initialized."); - } else { - console.error("[Assign Modal] Cannot re-initialize modal: Element or Bootstrap JS missing."); - } - } catch (e) { - console.error("[Assign Modal] Error during modal dispose/re-initialize:", e); - } + assignModalEl.classList.remove('show'); + // Xóa backdrop nếu còn tồn tại + const backdrop = document.querySelector('.modal-backdrop.fade.show'); + if (backdrop) { + backdrop.remove(); + console.log("[Assign Modal] Manually removed backdrop."); } - // *** KẾT THÚC THÊM *** - } else { - console.error("[Assign Modal] Cannot manually hide modal, element not found."); + // Reset body style nếu Bootstrap thêm + document.body.style.overflow = ''; + document.body.style.paddingRight = ''; } - }); + // *** KẾT THÚC THÊM *** + }); + console.log("[Assign Modal] Event listener added to closeModalBtn."); // Confirm listener attachment + } else { + if (!closeModalBtn) console.error("[Assign Modal] closeModalBtn not found."); + if (!assignDietitianModalInstance) console.error("[Assign Modal] assignDietitianModalInstance is null, cannot add listener to close button."); } + + // Event listener for the final submit button (validation) + // ... (listener for assignSubmitBtn remains the same) ... if (assignSubmitBtn && assignForm) { - // Nút này chỉ hoạt động khi ở Manual View và đã chọn dietitian - // Việc submit form đã được xử lý bởi thẻ <form> và type="submit" - // Có thể thêm validation ở đây nếu cần assignSubmitBtn.addEventListener('click', (e) => { const selectedDietitian = document.getElementById('dietitian_id'); if (assignmentTypeInput && assignmentTypeInput.value === 'manual' && selectedDietitian && !selectedDietitian.value) { - e.preventDefault(); // Ngăn submit nếu chưa chọn dietitian + e.preventDefault(); alert("Vui lòng chọn một chuyên gia dinh dưỡng."); - console.log("[Assign Modal] Submit prevented: No dietitian selected."); // DEBUG + console.log("[Assign Modal] Submit prevented: No dietitian selected."); } else { - console.log("[Assign Modal] Submit button clicked (Manual assignment)."); // DEBUG + console.log("[Assign Modal] Submit button clicked (Manual assignment)."); } }); } // *** END: Logic for Assign Dietitian Modal *** - - // Add event listeners for delete buttons - const deleteButtons = document.querySelectorAll('.delete-procedure-btn'); - deleteButtons.forEach(button => { - button.addEventListener('click', () => { - const procId = button.getAttribute('data-proc-id'); - const procInfo = button.getAttribute('data-proc-info'); - const modalBody = document.querySelector('#deleteProcedureConfirmModal .modal-body #procedure-info-placeholder'); - modalBody.textContent = `Thủ thuật: ${procInfo}`; - const confirmBtn = document.getElementById('confirm-delete-procedure-btn'); - confirmBtn.addEventListener('click', () => { - // Add your delete logic here - console.log(`Thủ thuật ${procInfo} đã được xóa.`); - button.closest('tr').remove(); - document.getElementById('deleteProcedureConfirmModal').modal('hide'); - }); - }); - }); - - // *** START: Logic for Delete Procedure Modal (Revised for Tailwind) *** - const deleteProcedureModalEl = document.getElementById('deleteProcedureConfirmModal'); - const modalBackdrop = deleteProcedureModalEl.querySelector('.modal-backdrop'); - const modalContent = deleteProcedureModalEl.querySelector('.modal-content'); - const deleteProcedureButtons = document.querySelectorAll('.delete-procedure-btn'); - const procedureInfoPlaceholder = document.getElementById('procedure-info-placeholder'); - const confirmDeleteProcedureBtn = document.getElementById('confirm-delete-procedure-btn'); - const deleteProcedureForm = document.getElementById('delete-procedure-form'); - const closeButtons = deleteProcedureModalEl.querySelectorAll('.modal-close-btn'); - let currentProcedureIdToDelete = null; - - function openModal() { - deleteProcedureModalEl.classList.remove('hidden'); - // Trigger transitions - setTimeout(() => { - modalBackdrop.classList.add('opacity-100'); - modalContent.classList.add('opacity-100', 'scale-100'); - modalContent.classList.remove('scale-95'); // Start slightly scaled down - }, 10); // Small delay for transitions - } - - function closeModal() { - modalBackdrop.classList.remove('opacity-100'); - modalContent.classList.remove('opacity-100', 'scale-100'); - modalContent.classList.add('scale-95'); - // Wait for transitions to finish before hiding - setTimeout(() => { - deleteProcedureModalEl.classList.add('hidden'); - }, 300); // Match transition duration - currentProcedureIdToDelete = null; // Reset ID when closing - } - - deleteProcedureButtons.forEach(button => { - button.addEventListener('click', function() { - currentProcedureIdToDelete = this.getAttribute('data-proc-id'); - const procedureInfo = this.getAttribute('data-proc-info'); - - if (procedureInfoPlaceholder) { - procedureInfoPlaceholder.textContent = procedureInfo; // Update modal body - } - - // Update form action - if (deleteProcedureForm && currentProcedureIdToDelete) { - const deleteUrl = `{{ url_for('dietitian.delete_procedure', procedure_id=0) }}`.replace('/0', '/' + currentProcedureIdToDelete); - deleteProcedureForm.action = deleteUrl; - console.log("Delete form action set to:", deleteUrl); + + // *** START: Auto-open Assign Modal Logic (Now placed AFTER instance creation) *** + if ((window.location.hash === '#assign-dietitian' || new URLSearchParams(window.location.search).get('action') === 'assign') && assignDietitianModalInstance) { + console.log("[Auto Open Modal] Assign action detected AND modal instance exists."); + // Cuộn trang lên đầu + window.scrollTo(0, 0); + showChoiceView(); // Reset to choice view + + // Update title and action based on current patient + const assignPatientNameEl = document.querySelector('#assignDietitianModal #modal-title'); + const pagePatientName = document.querySelector('h2.text-2xl.font-bold')?.textContent.trim(); + if (assignPatientNameEl) { + if(pagePatientName) { + assignPatientNameEl.textContent = `Assign Dietitian for ${pagePatientName}`; } else { - console.error("Could not set delete form action."); + assignPatientNameEl.textContent = `Assign Dietitian`; // Fallback } - - openModal(); - }); - }); + } + const patientIdMatch = window.location.pathname.match(/\/patients\/([^\/]+)/); + const currentPatientId = patientIdMatch ? patientIdMatch[1] : null; + if (assignForm && currentPatientId) { + assignForm.action = `/patients/${currentPatientId}/assign_dietitian`; + console.log("[Auto Open Modal] Updated form action to: ", assignForm.action); + } else { + console.warn("[Auto Open Modal] Could not find assign form or patient ID to update action."); + } + + // Show the modal + assignDietitianModalInstance.show(); + console.log("[Auto Open Modal] Modal show() called."); - // Add click listener for the final confirm button - if (confirmDeleteProcedureBtn && deleteProcedureForm) { - // No event listener needed here, the form submission handles it. - // The button type="submit" inside the form works. - } - - // Add listeners to close buttons - closeButtons.forEach(button => { - button.addEventListener('click', closeModal); - }); + // Remove the query parameter and hash after opening + if (history.replaceState) { + const url = window.location.pathname; + history.replaceState(null, null, url); + console.log("[Auto Open Modal] Removed query parameters/hash from URL."); + } - // Close modal on backdrop click - modalBackdrop.addEventListener('click', closeModal); + } else if ((window.location.hash === '#assign-dietitian' || new URLSearchParams(window.location.search).get('action') === 'assign') && !assignDietitianModalInstance) { + console.error("[Auto Open Modal] Assign action detected, but modal instance is NOT available."); + } + // *** END: Auto-open Assign Modal Logic *** - // *** END: Logic for Delete Procedure Modal *** + // Add event listeners for delete procedure buttons (if on this page) + // ... (existing delete procedure logic) ... }); </script> {% endblock %} - \ No newline at end of file diff --git a/app/templates/patients.html b/app/templates/patients.html index 14cfff0b03b0638caf0e80eb2034e25c8786d5da..71d5eede16cca9eb9a1a50c8231d10ae33285814 100644 --- a/app/templates/patients.html +++ b/app/templates/patients.html @@ -34,6 +34,13 @@ <option value="COMPLETED">Completed</option> </select> + {# Nút Bulk Auto Assign (Admin only) #} + {% if current_user.is_admin %} + <button type="button" id="bulkAssignBtn" class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-purple-600 hover:bg-purple-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-purple-500"> + <i class="fas fa-users-cog mr-2"></i> Bulk Auto Assign + </button> + {% endif %} + <button type="button" id="addPatientBtn" class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"> <i class="fas fa-plus mr-2"></i> Add Patient </button> @@ -63,7 +70,7 @@ Age/Gender </th> <th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"> - Status + Status & Actions </th> <th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"> Admission Date @@ -95,19 +102,33 @@ <div class="text-sm text-gray-900">{{ patient.age }} years</div> <div class="text-sm text-gray-500">{{ patient.gender }}</div> </td> - <td class="px-6 py-4 whitespace-nowrap"> - <span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full - {% set p_status = patient.status %} - {% set p_color_map = { - 'NOT_ASSESSED': 'gray', - 'NEEDS_ASSESSMENT': 'amber', - 'ASSESSMENT_IN_PROGRESS': 'blue', - 'COMPLETED': 'green' - } %} - {% set p_color = p_color_map.get(p_status.value if p_status else 'UNKNOWN', 'gray') %} - bg-{{ p_color }}-100 text-{{ p_color }}-800" data-status="{{ patient.status.value if patient.status else 'UNKNOWN' }}"> - {{ patient.status.value.replace('_', ' ').title() if patient.status else 'Unknown' }} + <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500" data-status="{{ patient.status.value if patient.status else 'UNKNOWN' }}"> + {% set p_status = patient.status %} + {% set p_color_map = { + PatientStatus.NOT_ASSESSED: 'gray', + PatientStatus.NEEDS_ASSESSMENT: 'red', + PatientStatus.ASSESSMENT_IN_PROGRESS: 'blue', + PatientStatus.COMPLETED: 'green' + } %} + {% set p_color = p_color_map.get(p_status, 'gray') %} + <span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-{{ p_color }}-100 text-{{ p_color }}-800 mr-2"> + <span class="w-2 h-2 mr-1.5 rounded-full bg-{{ p_color }}-500"></span> + {{ p_status.value.replace('_', ' ').title() if p_status else 'Unknown' }} </span> + + {# Restore Assign button for Needs Assessment #} + {% if p_status == PatientStatus.NEEDS_ASSESSMENT %} + <a href="{{ url_for('patients.patient_detail', patient_id=patient.patient_id) }}?action=assign" + class="inline-flex items-center px-2 py-1 border border-transparent text-xs font-medium rounded shadow-sm text-white bg-red-600 hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500" + title="Assign Dietitian"> + <i class="fas fa-user-plus mr-1"></i> Assign + </a> + {# Only show dietitian name to Admins #} + {% elif p_status == PatientStatus.ASSESSMENT_IN_PROGRESS and patient.assigned_dietitian %} + {% if current_user.is_admin %} + <span class="text-xs text-gray-600 italic">by {{ patient.assigned_dietitian.full_name }}</span> + {% endif %} + {% endif %} </td> <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{{ patient.admission_date.strftime('%Y-%m-%d') if patient.admission_date else 'N/A' }}</td> <td class="px-6 py-4 whitespace-nowrap text-sm font-medium"> @@ -214,6 +235,44 @@ </div> </div> </div> + +<!-- Bulk Assign Confirmation Modal --> +<div id="bulk-assign-modal" class="hidden fixed z-10 inset-0 overflow-y-auto" aria-labelledby="bulk-assign-title" role="dialog" aria-modal="true"> + <div class="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0"> + <div class="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity" aria-hidden="true"></div> + <span class="hidden sm:inline-block sm:align-middle sm:h-screen" aria-hidden="true">​</span> + <div class="inline-block align-bottom bg-white rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full"> + <form id="bulk-assign-form" action="{{ url_for('patients.bulk_auto_assign') }}" method="POST"> + {{ EmptyForm().csrf_token }} + <div class="bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"> + <div class="sm:flex sm:items-start"> + <div class="mx-auto flex-shrink-0 flex items-center justify-center h-12 w-12 rounded-full bg-purple-100 sm:mx-0 sm:h-10 sm:w-10"> + <i class="fas fa-question-circle text-purple-600"></i> + </div> + <div class="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"> + <h3 class="text-lg leading-6 font-medium text-gray-900" id="bulk-assign-title"> + Confirm Bulk Auto Assignment + </h3> + <div class="mt-2"> + <p class="text-sm text-gray-500"> + Are you sure that you want to AUTO-ASSIGN every patient with 'Needs Assessment' status? + </p> + </div> + </div> + </div> + </div> + <div class="bg-gray-50 px-4 py-3 sm:px-6 sm:flex sm:flex-row-reverse"> + <button type="submit" id="confirm-bulk-assign" class="w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-purple-600 text-base font-medium text-white hover:bg-purple-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-purple-500 sm:ml-3 sm:w-auto sm:text-sm"> + Confirm Auto Assign + </button> + <button type="button" id="cancel-bulk-assign" class="mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 sm:mt-0 sm:ml-3 sm:w-auto sm:text-sm"> + Cancel + </button> + </div> + </form> + </div> + </div> +</div> {% endblock %} {% block scripts %} @@ -333,8 +392,106 @@ console.warn('Delete modal elements not found. Delete functionality might be incomplete.'); } - // Initial filter on load (optional) - // filterAndSearchPatients(); + // --- Bulk Assign Modal Logic --- + const bulkAssignBtn = document.getElementById('bulkAssignBtn'); + const bulkAssignModal = document.getElementById('bulk-assign-modal'); + const confirmBulkAssignBtn = document.getElementById('confirm-bulk-assign'); + const cancelBulkAssignBtn = document.getElementById('cancel-bulk-assign'); + + if (bulkAssignBtn && bulkAssignModal && confirmBulkAssignBtn && cancelBulkAssignBtn) { + bulkAssignBtn.addEventListener('click', function() { + bulkAssignModal.classList.remove('hidden'); + bulkAssignModal.setAttribute('aria-hidden', 'false'); + }); + + cancelBulkAssignBtn.addEventListener('click', function() { + bulkAssignModal.classList.add('hidden'); + bulkAssignModal.setAttribute('aria-hidden', 'true'); + }); + + // Đóng modal khi click bên ngoài + bulkAssignModal.addEventListener('click', function(event) { + if (event.target === bulkAssignModal) { + cancelBulkAssignBtn.click(); + } + }); + + confirmBulkAssignBtn.addEventListener('click', function(event) { + event.preventDefault(); // Ngăn submit ngay lập tức để hiển thị loading + console.log("Bulk assign confirmed. Submitting form..."); + // Hiển thị loading spinner hoặc vô hiệu hóa nút + confirmBulkAssignBtn.disabled = true; + confirmBulkAssignBtn.innerHTML = '<i class="fas fa-spinner fa-spin mr-2"></i> Processing...'; + + // Tìm form thật trong modal + const form = document.getElementById('bulk-assign-form'); + + if (form) { + // Submit form thật + form.submit(); + } else { + console.error("Bulk Assign Form (#bulk-assign-form) not found!"); + alert("Error: Could not submit the assignment request."); + // Khôi phục trạng thái nút nếu form không tìm thấy + confirmBulkAssignBtn.disabled = false; + confirmBulkAssignBtn.innerHTML = 'Confirm Auto Assign'; + } + + // Không cần ẩn modal ở đây, trang sẽ reload sau khi submit + }); + } else { + if (!bulkAssignBtn) console.log("Bulk Assign button not found (might be non-admin user)."); + // Các kiểm tra khác nếu cần + } + + // --- Assign Dietitian Modal Trigger (from patient list) --- + // --- BỎ PHẦN NÀY ĐI VÌ NÚT ASSIGN GIỜ LÀ LINK NAVIGATE --- + /* + const assignModalTriggers = document.querySelectorAll('.open-assign-modal'); + const assignDietitianModalEl = document.getElementById('assignDietitianModal'); // ID của modal gán (cần tạo ở base/macro) + let assignDietitianModalInstance = null; + const assignDietitianForm = document.getElementById('assignDietitianForm'); // ID của form trong modal + const assignPatientNameEl = document.querySelector('#assignDietitianModal #modal-title'); // Element để hiển thị tên BN + + if (assignDietitianModalEl && typeof bootstrap !== 'undefined') { + assignDietitianModalInstance = new bootstrap.Modal(assignDietitianModalEl); + } else { + console.warn("Assign Dietitian modal element or Bootstrap JS not found. Assign buttons on list page will not work."); + } + + if (assignModalTriggers.length > 0 && assignDietitianModalInstance && assignDietitianForm && assignPatientNameEl) { + assignModalTriggers.forEach(button => { + button.addEventListener('click', function() { + const patientId = this.dataset.patientId; + const patientName = this.dataset.patientName; + + // Cập nhật action của form trong modal + assignDietitianForm.action = `{{ url_for('patients.assign_dietitian', patient_id=0) }}`.replace('/0', '/' + patientId); + + // Cập nhật tiêu đề modal (tùy chọn) + assignPatientNameEl.textContent = `Assign Dietitian for ${patientName}`; + + // Reset về view lựa chọn ban đầu khi mở modal + const choiceView = document.getElementById('assignmentChoiceView'); + const manualView = document.getElementById('manualAssignmentView'); + const backBtn = document.getElementById('backToChoiceBtn'); + const submitBtn = document.getElementById('assignSubmitBtn'); + if (choiceView) choiceView.style.display = 'grid'; + if (manualView) manualView.style.display = 'none'; + if (backBtn) backBtn.classList.add('hidden'); + if (submitBtn) submitBtn.classList.add('hidden'); // Ẩn nút submit ban đầu + + // Mở modal + assignDietitianModalInstance.show(); + }); + }); + } else { + if (assignModalTriggers.length > 0) { + console.warn("Assign Dietitian modal/form/title elements missing. Assign buttons might not work correctly."); + } + } + */ + }); </script> {% endblock %} diff --git a/app/templates/profile.html b/app/templates/profile.html index 2a71f6ee28bac5bbc21e7d339e6168edc9c0a5f2..56bbb6ee58ddf1999f48a121706d872c528612b6 100644 --- a/app/templates/profile.html +++ b/app/templates/profile.html @@ -1,10 +1,20 @@ {% extends "base.html" %} +{% from "_macros.html" import status_badge %} {% block title %}Hồ sơ người dùng{% endblock %} {% block content %} <div class="container mx-auto mt-10 px-4"> - <h1 class="text-3xl font-semibold text-gray-800 mb-6">Hồ sơ người dùng</h1> + <div class="flex justify-between items-center mb-6"> + <h1 class="text-3xl font-semibold text-gray-800">Hồ sơ người dùng</h1> + {# Display Dietitian Status (Read-only) #} + {% if current_user.role == 'Dietitian' and current_user.dietitian %} + <div class="flex items-center space-x-2"> + <span class="text-sm font-medium text-gray-600">Trạng thái:</span> + {{ status_badge(current_user.dietitian.status.value) }} + </div> + {% endif %} + </div> <div class="bg-white shadow-md rounded-lg overflow-hidden"> <div class="px-6 py-4"> @@ -28,14 +38,36 @@ <dd class="mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2">{{ current_user.email }}</dd> </div> <div class="bg-gray-50 px-4 py-3 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6"> + <dt class="text-sm font-medium text-gray-500">Số điện thoại</dt> + <dd class="mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2">{{ current_user.phone or 'Chưa cập nhật' }}</dd> + </div> + <div class="bg-white px-4 py-3 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6"> <dt class="text-sm font-medium text-gray-500">Vai trò</dt> <dd class="mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2">{{ current_user.role }}</dd> </div> - <div class="bg-white px-4 py-3 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6"> + {# Dietitian Specific Info #} + {% if current_user.role == 'Dietitian' and current_user.dietitian %} + <div class="bg-gray-50 px-4 py-3 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6"> + <dt class="text-sm font-medium text-gray-500">Dietitian ID</dt> + <dd class="mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2">{{ current_user.dietitian.formattedID }}</dd> + </div> + <div class="bg-white px-4 py-3 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6"> + <dt class="text-sm font-medium text-gray-500">Chuyên môn</dt> + <dd class="mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2">{{ current_user.dietitian.specialization or 'Chưa cập nhật' }}</dd> + </div> + <div class="bg-gray-50 px-4 py-3 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6"> + <dt class="text-sm font-medium text-gray-500">Số bệnh nhân đang theo dõi</dt> + <dd class="mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2">{{ current_user.dietitian.patient_count }}</dd> + </div> + <div class="bg-white px-4 py-3 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6"> + <dt class="text-sm font-medium text-gray-500">Ghi chú</dt> + <dd class="mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2 whitespace-pre-wrap">{{ current_user.dietitian.notes or 'Không có ghi chú' }}</dd> + </div> + {% endif %} + <div class="bg-gray-50 px-4 py-3 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6"> <dt class="text-sm font-medium text-gray-500">Ngày tham gia</dt> <dd class="mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2">{{ current_user.created_at.strftime('%d/%m/%Y') if current_user.created_at else 'N/A' }}</dd> </div> - <!-- Add more fields as needed --> </dl> </div> </div> diff --git a/app/templates/report.html b/app/templates/report.html index f9cfca4c225d711e62b48c2cd5178d7a19fbdaf4..e7e27a547c47fe7dcccfa629bf82800554a82234 100644 --- a/app/templates/report.html +++ b/app/templates/report.html @@ -156,9 +156,10 @@ 'Pending': 'yellow', 'Completed': 'green' } %} - {% set status_color = status_color_map.get(report.status, 'gray') %} + {# Directly use report.status.value, assuming it exists #} + {% set status_color = status_color_map.get(report.status.value, 'gray') %} <span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-{{ status_color }}-100 text-{{ status_color }}-800"> - {{ report.status }} + {{ report.status.value }} </span> </td> <td class="px-6 py-4 whitespace-nowrap text-sm font-medium"> @@ -168,13 +169,15 @@ </a> {# Nút Edit chỉ hiện khi Draft/Pending và có quyền (Admin hoặc Author/Assigned Dietitian) #} {# Logic kiểm tra quyền đã phức tạp hơn, dựa vào route xử lý #} - {% if report.status in ['Draft', 'Pending'] %} + {# Compare status value directly #} + {% if report.status.value in ['Draft', 'Pending'] %} <a href="{{ url_for('report.edit_report', report_id=report.id) }}" class="text-indigo-600 hover:text-indigo-900 transform hover:scale-110 transition-transform duration-200" title="Edit Report"> <i class="fas fa-edit"></i> </a> {% endif %} {# Nút Download chỉ khi Completed #} - {% if report.status == 'Completed' %} + {# Compare status value directly #} + {% if report.status.value == 'Completed' %} <a href="{{ url_for('report.download_report', report_id=report.id) }}" class="text-green-600 hover:text-green-900 transform hover:scale-110 transition-transform duration-200" title="Download PDF"> <i class="fas fa-file-pdf"></i> </a> @@ -277,7 +280,7 @@ <div class="flex items-center"> <div class="flex-shrink-0 flex items-center justify-center h-12 w-12 bg-gray-100 rounded-md"> <i class="fas fa-pencil-alt text-gray-600"></i> - </div> + </div> <div class="ml-5 w-0 flex-1"> <dl> <dt class="text-sm font-medium text-gray-500 truncate">Draft Reports</dt> diff --git a/app/templates/report_form.html b/app/templates/report_form.html index adbf97be2e5b0ea593d6d857f48b512511c46989..93f1406df28f06c796a397d0a05a08ac4fdc12e0 100644 --- a/app/templates/report_form.html +++ b/app/templates/report_form.html @@ -344,7 +344,9 @@ </button> {# Nút Hoàn thành - Chỉ hiện khi status là Pending và user có quyền #} - {% if report and report.status in ['Draft', 'Pending'] and (current_user.is_admin or current_user.userID == report.author_id or (report.patient and current_user.userID == report.patient.assigned_dietitian_user_id)) %} + {# DEBUG: Print status value #} + <!-- DEBUG: Report Status Value: {{ report.status.value if report.status else 'None' }} --> + {% if report and (report.status.value if report.status else None) in ['Draft', 'Pending'] and (current_user.is_admin or current_user.userID == report.author_id or (report.patient and current_user.userID == report.patient.assigned_dietitian_user_id)) %} <button type="submit" name="action" value="complete" class="bg-green-600 hover:bg-green-700 text-white font-medium py-2 px-5 rounded-md transition duration-300 shadow hover:shadow-md"> <i class="fas fa-check-circle mr-2"></i> Complete Report </button> diff --git a/app/templates/upload.html b/app/templates/upload.html index 0a18294f9e9684c6dc58b7a56e48af76f596daa7..c6b4c46c89096d20ef9ab45459b46e557ab1726e 100644 --- a/app/templates/upload.html +++ b/app/templates/upload.html @@ -163,8 +163,8 @@ </div> <div class="mt-6"> - <a href="{{ url_for('static', filename='templates/initial_patient_data_sample.csv') }}" class="text-primary-600 hover:text-primary-500 text-sm font-medium flex items-center"> - <i class="fas fa-download mr-1"></i> Tải xuống mẫu CSV (Bệnh nhân ban đầu) + <a href="{{ url_for('upload.download_template', template_type='new_patients') }}" class="text-primary-600 hover:text-primary-500 text-sm font-medium flex items-center"> + <i class="fas fa-download mr-1"></i> Tải xuống mẫu CSV (Bệnh nhân mới) </a> </div> </div> diff --git a/app/utils/csv_handler.py b/app/utils/csv_handler.py index aebabbc4b4d31887e4b6edb403c3c988f062bcff..54e1117c76060bd2663231fa3b0dab4c548b6575 100644 --- a/app/utils/csv_handler.py +++ b/app/utils/csv_handler.py @@ -5,7 +5,7 @@ import json from flask import current_app from datetime import datetime from app import db -from app.models.patient import Patient +from app.models.patient import Patient, PatientStatus from app.models.encounter import Encounter from app.models.measurement import PhysiologicalMeasurement from app.models.referral import Referral @@ -81,115 +81,236 @@ def process_new_patients_csv(uploaded_file_id): """ Process a CSV file containing only new patient information. Automatically generates a new patientID for each row. + Checks for duplicates based on firstName, lastName, gender, blood_type. Expected headers: firstName, lastName, age, gender, height, weight, blood_type + Returns a dictionary with processing results including duplicate checks. """ uploaded_file = UploadedFile.query.get(uploaded_file_id) if not uploaded_file: - return {'success': False, 'error': f'Uploaded file record not found (ID: {uploaded_file_id})'} + return { + 'success': False, + 'error': f'Uploaded file record not found (ID: {uploaded_file_id})', + 'processed_records': 0, 'error_records': 0, 'total_records': 0, + 'duplicate_errors': [], 'format_errors': False + } total_records = 0 processed_records = 0 error_records = 0 - errors = [] + errors = [] # For detailed logging + duplicate_names = [] # Store names of duplicate patients + format_error_found = False # Flag for format errors patient_ids_created = [] + # Sử dụng header này để kiểm tra và lấy dữ liệu expected_headers = ['firstName', 'lastName', 'age', 'gender', 'height', 'weight', 'blood_type'] + # Các trường dùng để kiểm tra trùng lặp + duplicate_check_fields = ['firstName', 'lastName', 'gender', 'blood_type'] try: with open(uploaded_file.filePath, 'r', encoding=uploaded_file.file_encoding, newline='') as csvfile: reader = csv.DictReader(csvfile, delimiter=uploaded_file.delimiter) + actual_headers = reader.fieldnames - # Basic header check - if not reader.fieldnames or any(h not in reader.fieldnames for h in expected_headers): - missing = [h for h in expected_headers if h not in (reader.fieldnames or [])] - raise ValueError(f"CSV header mismatch. Missing or incorrect columns: {', '.join(missing)}. Required: {', '.join(expected_headers)}") + # --- Header Check --- + if not actual_headers: + format_error_found = True + raise ValueError("CSV file is empty or header row is missing.") + + missing_headers = [h for h in expected_headers if h not in actual_headers] + if missing_headers: + format_error_found = True + raise ValueError(f"CSV header mismatch. Missing columns: {', '.join(missing_headers)}.") + # --- End Header Check --- for i, row in enumerate(reader): total_records += 1 row_num = i + 2 # Row number in the original file + + # Lấy dữ liệu cần thiết cho kiểm tra trùng lặp và tạo bệnh nhân + first_name = row.get('firstName', '').strip() + last_name = row.get('lastName', '').strip() + gender = row.get('gender', '').strip().lower() or None + blood_type = row.get('blood_type', '').strip() or None + age_str = row.get('age') + height_str = row.get('height') + weight_str = row.get('weight') + + # --- Validate required fields for duplicate check and basic info --- + if not first_name or not last_name or not gender: + error_records += 1 + errors.append({'row': row_num, 'error': 'Missing required field(s): firstName, lastName, or gender.', 'data': dict(row)}) + format_error_found = True # Thiếu trường cơ bản coi là lỗi format + continue # Bỏ qua dòng này + # --- End Validation --- try: - # Generate new Patient ID + # --- Duplicate Check --- + existing_patient = Patient.query.filter_by( + firstName=first_name, + lastName=last_name, + gender=gender, + # Kiểm tra cả blood_type nếu nó có giá trị + blood_type=blood_type if blood_type else None + ).first() + + if existing_patient: + error_records += 1 + full_name = f"{first_name} {last_name}" + if full_name not in duplicate_names: # Chỉ thêm tên 1 lần + duplicate_names.append(full_name) + errors.append({'row': row_num, 'error': f'Duplicate patient found: {full_name} (Gender: {gender}, Blood: {blood_type})', 'data': dict(row)}) + continue # Bỏ qua, không tạo bệnh nhân trùng + # --- End Duplicate Check --- + + # Generate new Patient ID if not duplicate new_patient_id = _get_next_patient_id() - # Check if somehow this ID already exists (extremely unlikely but safe) while Patient.query.get(new_patient_id): current_app.logger.warning(f"Generated patient ID {new_patient_id} already exists. Regenerating.") - new_patient_id = _get_next_patient_id() # Regenerate + new_patient_id = _get_next_patient_id() - height = _parse_float(row.get('height')) - weight = _parse_float(row.get('weight')) + # Parse remaining data (potential format errors here) + age = _parse_int(age_str) + height = _parse_float(height_str) + weight = _parse_float(weight_str) + + # Kiểm tra lỗi parse cơ bản + parse_errors = [] + if age is None and age_str: parse_errors.append(f"Invalid age value: '{age_str}'") + if height is None and height_str: parse_errors.append(f"Invalid height value: '{height_str}'") + if weight is None and weight_str: parse_errors.append(f"Invalid weight value: '{weight_str}'") + + if parse_errors: + format_error_found = True + raise ValueError("; ".join(parse_errors)) # Gộp lỗi parse + # Create Patient object new_patient = Patient( - id=new_patient_id, # Use generated ID - firstName=row.get('firstName'), - lastName=row.get('lastName'), - age=_parse_int(row.get('age')), - gender=row.get('gender', '').lower() or None, + id=new_patient_id, + firstName=first_name, + lastName=last_name, + age=age, + gender=gender, height=height, weight=weight, - blood_type=row.get('blood_type'), - admission_date=datetime.utcnow(), # Set admission date on creation - status='Active' # Default status + blood_type=blood_type, + admission_date=datetime.utcnow(), + status=PatientStatus.NOT_ASSESSED # Sử dụng giá trị Enum mặc định ) - new_patient.calculate_bmi() # Calculate BMI if possible + new_patient.calculate_bmi() db.session.add(new_patient) - db.session.commit() # Commit each patient + db.session.commit() # Commit từng bệnh nhân để lấy ID tiếp theo chính xác processed_records += 1 patient_ids_created.append(new_patient_id) except IntegrityError as ie: db.session.rollback() error_records += 1 - errors.append({'row': row_num, 'error': f'Database integrity error (check logs): {str(ie)}', 'data': dict(row)}) + # Lỗi này thường nghiêm trọng, có thể là lỗi database + errors.append({'row': row_num, 'error': f'Database integrity error: {str(ie)}', 'data': dict(row)}) current_app.logger.error(f"IntegrityError on row {row_num} for file {uploaded_file_id}: {ie}", exc_info=True) + # Không chắc đây là lỗi format, có thể là lỗi DB + except ValueError as ve: # Bắt lỗi từ parse hoặc kiểm tra thiếu trường + db.session.rollback() + error_records += 1 + errors.append({'row': row_num, 'error': f'Data format error: {str(ve)}', 'data': dict(row)}) + format_error_found = True # Đánh dấu có lỗi format except Exception as e: db.session.rollback() error_records += 1 - error_message = f'Error processing row: {str(e)}' + error_message = f'Unexpected error processing row: {str(e)}' errors.append({'row': row_num, 'error': error_message, 'data': dict(row)}) current_app.logger.error(f"CSV Processing Error (Row {row_num}, File {uploaded_file_id}): {error_message}", exc_info=True) + # Lỗi không mong đợi cũng có thể coi là format nếu không rõ + # format_error_found = True - except Exception as e: - db.session.rollback() # Rollback any potential partial commits if file reading fails + except ValueError as file_ve: # Bắt lỗi từ kiểm tra header hoặc đọc file + db.session.rollback() uploaded_file.status = 'failed' - error_message = f'Failed to read or process CSV file: {str(e)}' - uploaded_file.error_details = json.dumps([{'row': 0, 'error': error_message}]) # Store file-level error + error_message = f'Failed to process CSV file: {str(file_ve)}' + uploaded_file.error_details = json.dumps([{'row': 0, 'error': error_message}]) current_app.logger.error(f"CSV File Processing Failed (File ID: {uploaded_file_id}): {error_message}", exc_info=True) db.session.commit() - return {'success': False, 'error': error_message} + return { + 'success': False, 'error': error_message, + 'processed_records': 0, 'error_records': total_records, 'total_records': total_records, + 'duplicate_errors': [], 'format_errors': True # Lỗi header/đọc file là lỗi format + } + except Exception as file_e: + db.session.rollback() + uploaded_file.status = 'failed' + error_message = f'Failed to read or process CSV file: {str(file_e)}' + uploaded_file.error_details = json.dumps([{'row': 0, 'error': error_message}]) + current_app.logger.error(f"CSV File Processing Failed (File ID: {uploaded_file_id}): {error_message}", exc_info=True) + db.session.commit() + return { + 'success': False, 'error': error_message, + 'processed_records': 0, 'error_records': total_records, 'total_records': total_records, + 'duplicate_errors': [], 'format_errors': True # Lỗi đọc file cũng coi là format + } - # Update uploaded file record + # --- Final Update and Return --- uploaded_file.total_records = total_records uploaded_file.processed_records = processed_records uploaded_file.error_records = error_records - if errors: - # Limit the size of error details stored in DB - MAX_ERROR_DETAIL_LENGTH = 16000000 # Slightly less than 16MB to be safe - error_details_json = json.dumps(errors) - if len(error_details_json) > MAX_ERROR_DETAIL_LENGTH: - # Provide a summary instead of truncated data - summary_error = { - "summary": f"Too many errors ({len(errors)}) to store details.", - "message": "Check application logs for full error details.", - "first_few_errors": errors[:5] # Store first few errors as sample - } - uploaded_file.error_details = json.dumps(summary_error) - current_app.logger.warning(f"Error details for file {uploaded_file_id} exceeded length limit. Storing summary.") - else: - uploaded_file.error_details = error_details_json - - uploaded_file.status = 'completed_with_errors' if error_records > 0 else 'completed' - uploaded_file.process_end = datetime.utcnow() # Mark end time + # ... (Logic giới hạn kích thước error_details giữ nguyên) ... + MAX_ERROR_DETAIL_LENGTH = 16000000 + error_details_json = json.dumps(errors) + if len(error_details_json) > MAX_ERROR_DETAIL_LENGTH: + summary_error = { + "summary": f"Too many errors ({len(errors)}) to store details.", + "message": "Check application logs for full error details.", + "first_few_errors": errors[:5] + } + uploaded_file.error_details = json.dumps(summary_error) + current_app.logger.warning(f"Error details for file {uploaded_file_id} exceeded length limit. Storing summary.") + else: + uploaded_file.error_details = error_details_json + + # Xác định trạng thái cuối cùng và success flag + final_success_flag = False + if error_records == 0: # Không có lỗi gì cả + uploaded_file.status = 'completed' + final_success_flag = True + elif processed_records > 0 or len(duplicate_names) > 0: # Có xử lý thành công hoặc chỉ có lỗi trùng lặp + if format_error_found: # Nếu có cả lỗi format thì vẫn là failed + uploaded_file.status = 'failed' + final_success_flag = False + else: # Chỉ có lỗi trùng lặp hoặc lỗi khác không phải format + uploaded_file.status = 'completed_with_errors' + final_success_flag = True # Coi là success vì file đọc được, chỉ là data trùng + else: # Không xử lý được gì và có lỗi (thường là format hoặc lỗi nghiêm trọng) + uploaded_file.status = 'failed' + final_success_flag = False + + uploaded_file.process_end = datetime.utcnow() try: db.session.commit() except Exception as commit_error: current_app.logger.error(f"Failed to commit final status for uploaded file {uploaded_file_id}: {commit_error}", exc_info=True) - # Even if final commit fails, processing might have partially succeeded. - return {'success': error_records == 0, 'processed_records': processed_records, 'error_records': error_records, 'total_records': total_records, 'errors': errors} - - return {'success': error_records == 0, 'processed_records': processed_records, 'error_records': error_records, 'total_records': total_records, 'errors': errors, 'patient_ids_created': patient_ids_created} + # Return kết quả đã tính toán được ngay cả khi commit cuối lỗi + return { + 'success': final_success_flag, + 'processed_records': processed_records, + 'error_records': error_records, + 'total_records': total_records, + 'duplicate_errors': duplicate_names, + 'format_errors': format_error_found, + 'error_details': errors # Trả về lỗi chi tiết nếu cần + } + + return { + 'success': final_success_flag, + 'processed_records': processed_records, + 'error_records': error_records, + 'total_records': total_records, + 'duplicate_errors': duplicate_names, + 'format_errors': format_error_found, + 'patient_ids_created': patient_ids_created, + 'error_details': errors # Trả về lỗi chi tiết nếu cần + } # --- Renamed old process_csv function --- @@ -480,8 +601,6 @@ def process_encounter_measurements_csv(file_stream, patient_id_param, encounter_ # Điều này không nên xảy ra nếu route hoạt động đúng, nhưng kiểm tra cho chắc raise ValueError(f"Không tìm thấy encounter hợp lệ với ID {encounter_id_param} cho bệnh nhân {patient_id_param}.") target_custom_encounter_id = current_encounter.custom_encounter_id - if not target_custom_encounter_id: - raise ValueError(f"Encounter ID {encounter_id_param} không có custom_encounter_id được đặt.") for row in reader: row_count += 1 @@ -500,9 +619,10 @@ def process_encounter_measurements_csv(file_stream, patient_id_param, encounter_ if not csv_patient_id: errors.append(f"Dòng {row_count + 1}: Thiếu giá trị PatientID.") continue # Skip this row - if not csv_encounter_id_str: - errors.append(f"Dòng {row_count + 1}: Thiếu giá trị EncounterID.") - continue # Skip this row + # Bỏ kiểm tra thiếu EncounterID ở đây vì logic so sánh sẽ xử lý nó + # if not csv_encounter_id_str: + # errors.append(f"Dòng {row_count + 1}: Thiếu giá trị EncounterID.") + # continue # Skip this row # Check if PatientID matches the parameter if csv_patient_id != str(patient_id_param): @@ -512,11 +632,19 @@ def process_encounter_measurements_csv(file_stream, patient_id_param, encounter_ continue # Skip this row # Check if EncounterID from CSV matches the custom_id of the current encounter - if csv_encounter_id_str != target_custom_encounter_id: - skipped_mismatch_count += 1 - # Log nếu cần - # current_app.logger.info(f"CSV Dòng {row_count + 1}: Bỏ qua do EncounterID ({csv_encounter_id_str}) không khớp với encounter hiện tại ({target_custom_encounter_id}).") - continue # Skip this row + # Chỉ thực hiện so sánh nếu target_custom_encounter_id tồn tại + if target_custom_encounter_id: + if csv_encounter_id_str != target_custom_encounter_id: + skipped_mismatch_count += 1 + # Log nếu cần + # current_app.logger.info(f"CSV Dòng {row_count + 1}: Bỏ qua do EncounterID ({csv_encounter_id_str}) không khớp với encounter hiện tại ({target_custom_encounter_id}).") + continue # Skip this row + else: + # Nếu encounter đích không có custom_id, bỏ qua dòng này vì không thể so sánh + skipped_mismatch_count += 1 + # Log nếu cần + # current_app.logger.warning(f"CSV Dòng {row_count + 1}: Bỏ qua do Encounter đích ({encounter_id_param}) không có custom_encounter_id để so sánh.") + continue # Skip this row # --- Data Parsing and Type Conversion --- # Gán patient_id và encounter_id từ parameter URL (vì dòng này đã được xác định là của encounter này) diff --git a/app/utils/decorators.py b/app/utils/decorators.py index 9c128964e575ea1eef6c28289678d17d15e73d67..c5788e8b024bf2d74d53effae9fcbf6b1a60493e 100644 --- a/app/utils/decorators.py +++ b/app/utils/decorators.py @@ -1,5 +1,5 @@ from functools import wraps -from flask import flash, redirect, url_for, request +from flask import flash, redirect, url_for, request, abort from flask_login import current_user def admin_required(f): @@ -13,4 +13,28 @@ def admin_required(f): flash('Bạn không có quyền truy cập trang này.', 'danger') return redirect(url_for('dashboard.index')) # Hoặc trang chính khác return f(*args, **kwargs) - return decorated_function \ No newline at end of file + return decorated_function + +def permission_required(*roles): + """ + Decorator để giới hạn quyền truy cập cho các vai trò cụ thể. + Kiểm tra xem vai trò của current_user có nằm trong danh sách roles được phép không. + """ + def decorator(f): + @wraps(f) + def decorated_function(*args, **kwargs): + if not current_user.is_authenticated: + flash('Bạn cần đăng nhập để truy cập trang này.', 'warning') + return redirect(url_for('auth.login', next=request.url)) + + # Chuyển đổi các role thành chữ hoa để so sánh không phân biệt chữ hoa/thường + allowed_roles = [role.upper() for role in roles] + user_role = current_user.role.upper() if hasattr(current_user, 'role') and current_user.role else None + + if user_role not in allowed_roles: + # flash(f'Bạn không có quyền truy cập trang này. Yêu cầu quyền: {", ".join(roles)}', 'danger') + # return redirect(url_for('dashboard.index')) # Hoặc trang phù hợp + abort(403) # Trả về lỗi 403 Forbidden thay vì flash và redirect + return f(*args, **kwargs) + return decorated_function + return decorator \ No newline at end of file diff --git a/app/utils/notifications_helper.py b/app/utils/notifications_helper.py new file mode 100644 index 0000000000000000000000000000000000000000..92c28eb179544436c6845e6e4e94bf153f73c77a --- /dev/null +++ b/app/utils/notifications_helper.py @@ -0,0 +1,63 @@ +from app import db +from app.models.notification import Notification +from app.models.user import User +from flask import url_for, current_app + +def create_notification(recipient_user_id, message, link=None): + """ + Tạo và lưu một thông báo mới cho một người dùng cụ thể. + + Args: + recipient_user_id: ID của người dùng sẽ nhận thông báo. + message: Nội dung thông báo. + link: URL tùy chọn để người dùng nhấp vào (sẽ được tạo bằng url_for nếu là tuple). + """ + try: + # Kiểm tra xem recipient_user_id có hợp lệ không + recipient = User.query.get(recipient_user_id) + if not recipient: + current_app.logger.warning(f"Attempted to create notification for non-existent user ID: {recipient_user_id}") + return + + # Xử lý link nếu nó là tuple (endpoint, **kwargs) + final_link = link + if isinstance(link, tuple) and len(link) > 0: + endpoint = link[0] + kwargs = link[1] if len(link) > 1 else {} + try: + # Luôn tạo link _external=True để đảm bảo hoạt động đúng trong mọi ngữ cảnh (ví dụ: email) + final_link = url_for(endpoint, **kwargs, _external=True) + except Exception as e: + current_app.logger.error(f"Failed to build URL for notification: endpoint={endpoint}, kwargs={kwargs}, error={e}", exc_info=True) + final_link = None # Không đặt link nếu không tạo được + + notification = Notification( + user_id=recipient_user_id, + message=message, + link=final_link + ) + db.session.add(notification) + # Không commit ở đây, để commit diễn ra trong route gốc + # Nhưng flush để có thể lấy ID nếu cần ngay lập tức (hiếm khi cần cho helper này) + # db.session.flush() + current_app.logger.info(f"Prepared notification for user {recipient_user_id}: '{message[:50]}...' Link: {final_link}") + + except Exception as e: + current_app.logger.error(f"Error creating notification for user {recipient_user_id}: {e}", exc_info=True) + # Không rollback ở đây vì có thể ảnh hưởng đến transaction gốc + +def create_notification_for_admins(message, link=None, exclude_user_id=None): + """ + Tạo thông báo cho tất cả Admin. + + Args: + message: Nội dung thông báo. + link: URL tùy chọn hoặc tuple (endpoint, **kwargs). + exclude_user_id: ID của người dùng (thường là người thực hiện hành động) để loại trừ. + """ + admins = User.query.filter_by(role='Admin').all() + for admin in admins: + if admin.userID != exclude_user_id: + create_notification(admin.userID, message, link) + +# Có thể thêm các hàm helper khác nếu cần, ví dụ: create_notification_for_role(...) \ No newline at end of file diff --git a/migrations/versions/0a3ef5c08275_add_encounterstatus_enum_and_update_.py b/migrations/versions/0a3ef5c08275_add_encounterstatus_enum_and_update_.py deleted file mode 100644 index 02c0469e690b5e7da375e5d0f4b87d7f4be61a5c..0000000000000000000000000000000000000000 --- a/migrations/versions/0a3ef5c08275_add_encounterstatus_enum_and_update_.py +++ /dev/null @@ -1,52 +0,0 @@ -"""Add EncounterStatus enum and update status column - -Revision ID: 0a3ef5c08275 -Revises: 6d1cbb67e2e2 -Create Date: 2025-04-19 11:22:10.500929 - -""" -from alembic import op -import sqlalchemy as sa -from sqlalchemy.dialects import mysql - -# revision identifiers, used by Alembic. -revision = '0a3ef5c08275' -down_revision = '6d1cbb67e2e2' -branch_labels = None -depends_on = None - - -def upgrade(): - # ### commands auto generated by Alembic - please adjust! ### - with op.batch_alter_table('encounters', schema=None) as batch_op: - batch_op.alter_column('status', - existing_type=mysql.VARCHAR(collation='utf8mb4_unicode_ci', length=50), - type_=sa.Enum('ACTIVE', 'NEEDS_ASSESSMENT', 'ASSESSMENT_IN_PROGRESS', 'AWAITING_REVIEW', 'COMPLETED', 'CANCELLED', name='encounterstatus'), - existing_nullable=False) - batch_op.create_index(batch_op.f('ix_encounters_status'), ['status'], unique=False) - - with op.batch_alter_table('uploadedfiles', schema=None) as batch_op: - batch_op.alter_column('error_details', - existing_type=mysql.LONGTEXT(collation='utf8mb4_unicode_ci'), - type_=sa.Text(length=16777215), - existing_nullable=True) - - # ### end Alembic commands ### - - -def downgrade(): - # ### commands auto generated by Alembic - please adjust! ### - with op.batch_alter_table('uploadedfiles', schema=None) as batch_op: - batch_op.alter_column('error_details', - existing_type=sa.Text(length=16777215), - type_=mysql.LONGTEXT(collation='utf8mb4_unicode_ci'), - existing_nullable=True) - - with op.batch_alter_table('encounters', schema=None) as batch_op: - batch_op.drop_index(batch_op.f('ix_encounters_status')) - batch_op.alter_column('status', - existing_type=sa.Enum('ACTIVE', 'NEEDS_ASSESSMENT', 'ASSESSMENT_IN_PROGRESS', 'AWAITING_REVIEW', 'COMPLETED', 'CANCELLED', name='encounterstatus'), - type_=mysql.VARCHAR(collation='utf8mb4_unicode_ci', length=50), - existing_nullable=False) - - # ### end Alembic commands ### diff --git a/migrations/versions/11d6fcd3a14d_refactor_encounter_and_measurement_.py b/migrations/versions/11d6fcd3a14d_refactor_encounter_and_measurement_.py deleted file mode 100644 index eca7ecbccbb641849874cc23b639bceeb161a0bb..0000000000000000000000000000000000000000 --- a/migrations/versions/11d6fcd3a14d_refactor_encounter_and_measurement_.py +++ /dev/null @@ -1,229 +0,0 @@ -"""Refactor Encounter and Measurement models with relationships - -Revision ID: 11d6fcd3a14d -Revises: -Create Date: 2025-04-17 13:48:23.589559 - -""" -from alembic import op -import sqlalchemy as sa - - -# revision identifiers, used by Alembic. -revision = '11d6fcd3a14d' -down_revision = None -branch_labels = None -depends_on = None - - -def upgrade(): - # ### commands auto generated by Alembic - please adjust! ### - op.create_table('users', - sa.Column('userID', sa.Integer(), nullable=False), - sa.Column('email', sa.String(length=100), nullable=False), - sa.Column('password_hash', sa.String(length=128), nullable=True), - sa.Column('firstName', sa.String(length=50), nullable=False), - sa.Column('lastName', sa.String(length=50), nullable=False), - sa.Column('role', sa.Enum('Admin', 'Dietitian', name='user_roles_new'), nullable=False), - sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True), - sa.Column('last_login', sa.DateTime(timezone=True), nullable=True), - sa.PrimaryKeyConstraint('userID'), - sa.UniqueConstraint('email') - ) - op.create_table('activity_logs', - sa.Column('id', sa.Integer(), nullable=False), - sa.Column('user_id', sa.Integer(), nullable=False), - sa.Column('action', sa.String(length=255), nullable=False), - sa.Column('details', sa.Text(), nullable=True), - sa.Column('timestamp', sa.DateTime(), nullable=True), - sa.ForeignKeyConstraint(['user_id'], ['users.userID'], ), - sa.PrimaryKeyConstraint('id') - ) - op.create_table('dietitians', - sa.Column('dietitianID', sa.Integer(), nullable=False), - sa.Column('user_id', sa.Integer(), nullable=True), - sa.Column('firstName', sa.String(length=50), nullable=False), - sa.Column('lastName', sa.String(length=50), nullable=False), - sa.Column('status', sa.Enum('AVAILABLE', 'UNAVAILABLE', 'ON_LEAVE', name='dietitianstatus'), nullable=False), - sa.Column('email', sa.String(length=100), nullable=True), - sa.Column('phone', sa.String(length=20), nullable=True), - sa.Column('specialization', sa.String(length=100), nullable=True), - sa.Column('notes', sa.Text(), nullable=True), - sa.Column('created_at', sa.DateTime(), nullable=True), - sa.Column('updated_at', sa.DateTime(), nullable=True), - sa.ForeignKeyConstraint(['user_id'], ['users.userID'], ), - sa.PrimaryKeyConstraint('dietitianID'), - sa.UniqueConstraint('email'), - sa.UniqueConstraint('user_id') - ) - op.create_table('uploadedfiles', - sa.Column('fileID', sa.Integer(), nullable=False), - sa.Column('fileName', sa.String(length=255), nullable=False), - sa.Column('filePath', sa.String(length=255), nullable=False), - sa.Column('userID', sa.Integer(), nullable=False), - sa.Column('original_filename', sa.String(length=256), nullable=False), - sa.Column('file_size', sa.Integer(), nullable=True), - sa.Column('file_type', sa.String(length=64), nullable=True), - sa.Column('delimiter', sa.String(length=10), nullable=True), - sa.Column('file_encoding', sa.String(length=20), nullable=True), - sa.Column('status', sa.String(length=50), nullable=True), - sa.Column('process_start', sa.DateTime(), nullable=True), - sa.Column('process_end', sa.DateTime(), nullable=True), - sa.Column('total_records', sa.Integer(), nullable=True), - sa.Column('processed_records', sa.Integer(), nullable=True), - sa.Column('error_records', sa.Integer(), nullable=True), - sa.Column('error_details', sa.Text(length=16777215), nullable=True), - sa.Column('process_referrals', sa.Boolean(), nullable=True), - sa.Column('description', sa.Text(), nullable=True), - sa.Column('notes', sa.Text(), nullable=True), - sa.Column('created_at', sa.DateTime(), nullable=True), - sa.Column('updated_at', sa.DateTime(), nullable=True), - sa.Column('upload_date', sa.DateTime(), nullable=True), - sa.ForeignKeyConstraint(['userID'], ['users.userID'], ), - sa.PrimaryKeyConstraint('fileID') - ) - op.create_table('patients', - sa.Column('patientID', sa.String(length=20), nullable=False), - sa.Column('firstName', sa.String(length=50), nullable=False), - sa.Column('lastName', sa.String(length=50), nullable=False), - sa.Column('age', sa.Integer(), nullable=False), - sa.Column('gender', sa.Enum('male', 'female', 'other', name='gender_types'), nullable=False), - sa.Column('bmi', sa.Float(), nullable=True), - sa.Column('status', sa.String(length=20), nullable=True), - sa.Column('height', sa.Float(), nullable=True), - sa.Column('weight', sa.Float(), nullable=True), - sa.Column('blood_type', sa.String(length=10), nullable=True), - sa.Column('admission_date', sa.DateTime(), nullable=True), - sa.Column('created_at', sa.DateTime(), nullable=True), - sa.Column('updated_at', sa.DateTime(), nullable=True), - sa.Column('dietitian_id', sa.Integer(), nullable=True), - sa.ForeignKeyConstraint(['dietitian_id'], ['dietitians.dietitianID'], ), - sa.PrimaryKeyConstraint('patientID') - ) - op.create_table('encounters', - sa.Column('encounterID', sa.Integer(), nullable=False), - sa.Column('patient_id', sa.String(length=20), nullable=False), - sa.Column('notes', sa.Text(), nullable=True), - sa.Column('start_time', sa.DateTime(), nullable=False), - sa.Column('dietitian_id', sa.Integer(), nullable=True), - sa.Column('status', sa.String(length=50), nullable=False), - sa.Column('created_at', sa.DateTime(), nullable=True), - sa.Column('updated_at', sa.DateTime(), nullable=True), - sa.ForeignKeyConstraint(['dietitian_id'], ['users.userID'], ), - sa.ForeignKeyConstraint(['patient_id'], ['patients.patientID'], ), - sa.PrimaryKeyConstraint('encounterID') - ) - with op.batch_alter_table('encounters', schema=None) as batch_op: - batch_op.create_index(batch_op.f('ix_encounters_start_time'), ['start_time'], unique=False) - - op.create_table('reports', - sa.Column('reportID', sa.Integer(), nullable=False), - sa.Column('userID', sa.Integer(), nullable=False), - sa.Column('patientID', sa.String(length=20), nullable=False), - sa.Column('reportDateTime', sa.DateTime(), nullable=False), - sa.Column('reportTitle', sa.String(length=100), nullable=False), - sa.Column('reportContent', sa.Text(), nullable=True), - sa.Column('status', sa.String(length=20), nullable=True), - sa.Column('created_at', sa.DateTime(), nullable=True), - sa.Column('updated_at', sa.DateTime(), nullable=True), - sa.ForeignKeyConstraint(['patientID'], ['patients.patientID'], ), - sa.ForeignKeyConstraint(['userID'], ['users.userID'], ), - sa.PrimaryKeyConstraint('reportID') - ) - op.create_table('physiologicalmeasurements', - sa.Column('measurementID', sa.Integer(), nullable=False), - sa.Column('encounterID', sa.Integer(), nullable=False), - sa.Column('patientID', sa.String(length=20), nullable=False), - sa.Column('measurementDateTime', sa.DateTime(), nullable=False), - sa.Column('end_tidal_co2', sa.Float(), nullable=True), - sa.Column('feed_vol', sa.Float(), nullable=True), - sa.Column('feed_vol_adm', sa.Float(), nullable=True), - sa.Column('fio2', sa.Float(), nullable=True), - sa.Column('fio2_ratio', sa.Float(), nullable=True), - sa.Column('insp_time', sa.Float(), nullable=True), - sa.Column('oxygen_flow_rate', sa.Float(), nullable=True), - sa.Column('peep', sa.Float(), nullable=True), - sa.Column('pip', sa.Float(), nullable=True), - sa.Column('resp_rate', sa.Float(), nullable=True), - sa.Column('sip', sa.Float(), nullable=True), - sa.Column('tidal_vol', sa.Float(), nullable=True), - sa.Column('tidal_vol_actual', sa.Float(), nullable=True), - sa.Column('tidal_vol_kg', sa.Float(), nullable=True), - sa.Column('tidal_vol_spon', sa.Float(), nullable=True), - sa.Column('temperature', sa.Float(), nullable=True), - sa.Column('heart_rate', sa.Integer(), nullable=True), - sa.Column('respiratory_rate', sa.Integer(), nullable=True), - sa.Column('blood_pressure_systolic', sa.Integer(), nullable=True), - sa.Column('blood_pressure_diastolic', sa.Integer(), nullable=True), - sa.Column('oxygen_saturation', sa.Float(), nullable=True), - sa.Column('bmi', sa.Float(), nullable=True), - sa.Column('tidal_volume', sa.Float(), nullable=True), - sa.Column('notes', sa.Text(), nullable=True), - sa.Column('referral_score', sa.Float(), nullable=True, comment='Score from ML algorithm indicating referral recommendation (0-1)'), - sa.Column('created_at', sa.DateTime(), nullable=True), - sa.Column('updated_at', sa.DateTime(), nullable=True), - sa.ForeignKeyConstraint(['encounterID'], ['encounters.encounterID'], ), - sa.ForeignKeyConstraint(['patientID'], ['patients.patientID'], ), - sa.PrimaryKeyConstraint('measurementID') - ) - with op.batch_alter_table('physiologicalmeasurements', schema=None) as batch_op: - batch_op.create_index(batch_op.f('ix_physiologicalmeasurements_encounterID'), ['encounterID'], unique=False) - batch_op.create_index(batch_op.f('ix_physiologicalmeasurements_measurementDateTime'), ['measurementDateTime'], unique=False) - - op.create_table('procedures', - sa.Column('procedureID', sa.Integer(), nullable=False), - sa.Column('encounterID', sa.Integer(), nullable=False), - sa.Column('patientID', sa.String(length=20), nullable=False), - sa.Column('procedureType', sa.String(length=100), nullable=False), - sa.Column('procedureName', sa.String(length=255), nullable=True), - sa.Column('procedureDateTime', sa.DateTime(), nullable=False), - sa.Column('procedureEndDateTime', sa.DateTime(), nullable=True), - sa.Column('procedureResults', sa.Text(), nullable=True), - sa.Column('description', sa.Text(), nullable=True), - sa.Column('created_at', sa.DateTime(), nullable=True), - sa.Column('updated_at', sa.DateTime(), nullable=True), - sa.ForeignKeyConstraint(['encounterID'], ['encounters.encounterID'], ), - sa.ForeignKeyConstraint(['patientID'], ['patients.patientID'], ), - sa.PrimaryKeyConstraint('procedureID') - ) - op.create_table('referrals', - sa.Column('referralID', sa.Integer(), nullable=False), - sa.Column('encounterID', sa.Integer(), nullable=False), - sa.Column('patientID', sa.String(length=20), nullable=False), - sa.Column('is_ml_recommended', sa.Boolean(), nullable=True), - sa.Column('is_staff_referred', sa.Boolean(), nullable=True), - sa.Column('referral_status', sa.Enum('Not Needed', 'ML Recommended', 'Pending Review', 'Staff Referred', 'Completed', 'Rejected'), nullable=True), - sa.Column('referralRequestedDateTime', sa.DateTime(), nullable=True), - sa.Column('referralCompletedDateTime', sa.DateTime(), nullable=True), - sa.Column('dietitianID', sa.Integer(), nullable=True), - sa.Column('notes', sa.Text(), nullable=True), - sa.Column('createdAt', sa.DateTime(), nullable=True), - sa.Column('updatedAt', sa.DateTime(), nullable=True), - sa.ForeignKeyConstraint(['dietitianID'], ['dietitians.dietitianID'], ), - sa.ForeignKeyConstraint(['encounterID'], ['encounters.encounterID'], ), - sa.ForeignKeyConstraint(['patientID'], ['patients.patientID'], ), - sa.PrimaryKeyConstraint('referralID') - ) - # ### end Alembic commands ### - - -def downgrade(): - # ### commands auto generated by Alembic - please adjust! ### - op.drop_table('referrals') - op.drop_table('procedures') - with op.batch_alter_table('physiologicalmeasurements', schema=None) as batch_op: - batch_op.drop_index(batch_op.f('ix_physiologicalmeasurements_measurementDateTime')) - batch_op.drop_index(batch_op.f('ix_physiologicalmeasurements_encounterID')) - - op.drop_table('physiologicalmeasurements') - op.drop_table('reports') - with op.batch_alter_table('encounters', schema=None) as batch_op: - batch_op.drop_index(batch_op.f('ix_encounters_start_time')) - - op.drop_table('encounters') - op.drop_table('patients') - op.drop_table('uploadedfiles') - op.drop_table('dietitians') - op.drop_table('activity_logs') - op.drop_table('users') - # ### end Alembic commands ### diff --git a/migrations/versions/1af2bf32a740_add_noti.py b/migrations/versions/1af2bf32a740_add_noti.py deleted file mode 100644 index 3c19b2ec7b1527eafe6c7ddb5b739aaa811aef2d..0000000000000000000000000000000000000000 --- a/migrations/versions/1af2bf32a740_add_noti.py +++ /dev/null @@ -1,57 +0,0 @@ -"""add noti - -Revision ID: 1af2bf32a740 -Revises: 8ea1605c5393 -Create Date: 2025-04-17 23:29:42.398550 - -""" -from alembic import op -import sqlalchemy as sa -from sqlalchemy.dialects import mysql - -# revision identifiers, used by Alembic. -revision = '1af2bf32a740' -down_revision = '8ea1605c5393' -branch_labels = None -depends_on = None - - -def upgrade(): - # ### commands auto generated by Alembic - please adjust! ### - op.create_table('notifications', - sa.Column('id', mysql.INTEGER(unsigned=True), nullable=False), - sa.Column('user_id', sa.Integer(), nullable=False), - sa.Column('message', sa.Text(), nullable=False), - sa.Column('timestamp', sa.DateTime(), nullable=True), - sa.Column('is_read', sa.Boolean(), nullable=False), - sa.Column('link', sa.String(length=255), nullable=True), - sa.ForeignKeyConstraint(['user_id'], ['users.userID'], ), - sa.PrimaryKeyConstraint('id') - ) - with op.batch_alter_table('notifications', schema=None) as batch_op: - batch_op.create_index(batch_op.f('ix_notifications_timestamp'), ['timestamp'], unique=False) - batch_op.create_index(batch_op.f('ix_notifications_user_id'), ['user_id'], unique=False) - - with op.batch_alter_table('uploadedfiles', schema=None) as batch_op: - batch_op.alter_column('error_details', - existing_type=mysql.LONGTEXT(collation='utf8mb4_unicode_ci'), - type_=sa.Text(length=16777215), - existing_nullable=True) - - # ### end Alembic commands ### - - -def downgrade(): - # ### commands auto generated by Alembic - please adjust! ### - with op.batch_alter_table('uploadedfiles', schema=None) as batch_op: - batch_op.alter_column('error_details', - existing_type=sa.Text(length=16777215), - type_=mysql.LONGTEXT(collation='utf8mb4_unicode_ci'), - existing_nullable=True) - - with op.batch_alter_table('notifications', schema=None) as batch_op: - batch_op.drop_index(batch_op.f('ix_notifications_user_id')) - batch_op.drop_index(batch_op.f('ix_notifications_timestamp')) - - op.drop_table('notifications') - # ### end Alembic commands ### diff --git a/migrations/versions/375b7f062a5f_add_ass_date_to_patient.py b/migrations/versions/375b7f062a5f_add_ass_date_to_patient.py deleted file mode 100644 index d9414503dedacbf345ffd1289c284b3b4428750d..0000000000000000000000000000000000000000 --- a/migrations/versions/375b7f062a5f_add_ass_date_to_patient.py +++ /dev/null @@ -1,44 +0,0 @@ -"""add ass date to patient - -Revision ID: 375b7f062a5f -Revises: 0a3ef5c08275 -Create Date: 2025-04-19 11:58:03.391271 - -""" -from alembic import op -import sqlalchemy as sa -from sqlalchemy.dialects import mysql - -# revision identifiers, used by Alembic. -revision = '375b7f062a5f' -down_revision = '0a3ef5c08275' -branch_labels = None -depends_on = None - - -def upgrade(): - # ### commands auto generated by Alembic - please adjust! ### - with op.batch_alter_table('patients', schema=None) as batch_op: - batch_op.add_column(sa.Column('assignment_date', sa.DateTime(), nullable=True)) - - with op.batch_alter_table('uploadedfiles', schema=None) as batch_op: - batch_op.alter_column('error_details', - existing_type=mysql.LONGTEXT(collation='utf8mb4_unicode_ci'), - type_=sa.Text(length=16777215), - existing_nullable=True) - - # ### end Alembic commands ### - - -def downgrade(): - # ### commands auto generated by Alembic - please adjust! ### - with op.batch_alter_table('uploadedfiles', schema=None) as batch_op: - batch_op.alter_column('error_details', - existing_type=sa.Text(length=16777215), - type_=mysql.LONGTEXT(collation='utf8mb4_unicode_ci'), - existing_nullable=True) - - with op.batch_alter_table('patients', schema=None) as batch_op: - batch_op.drop_column('assignment_date') - - # ### end Alembic commands ### diff --git a/migrations/versions/617d526bbcee_add.py b/migrations/versions/617d526bbcee_add.py deleted file mode 100644 index c34079c2c3a94f8aeb5a769c964f904469ce445a..0000000000000000000000000000000000000000 --- a/migrations/versions/617d526bbcee_add.py +++ /dev/null @@ -1,48 +0,0 @@ -"""add - -Revision ID: 617d526bbcee -Revises: 9d82acfed8fa -Create Date: 2025-04-18 22:06:25.939688 - -""" -from alembic import op -import sqlalchemy as sa -from sqlalchemy.dialects import mysql - -# revision identifiers, used by Alembic. -revision = '617d526bbcee' -down_revision = '9d82acfed8fa' -branch_labels = None -depends_on = None - - -def upgrade(): - # ### commands auto generated by Alembic - please adjust! ### - with op.batch_alter_table('encounters', schema=None) as batch_op: - batch_op.add_column(sa.Column('end_time', sa.DateTime(), nullable=True)) - batch_op.add_column(sa.Column('encounter_type', sa.String(length=50), nullable=True)) - batch_op.add_column(sa.Column('needs_intervention', sa.Boolean(), nullable=False)) - - with op.batch_alter_table('uploadedfiles', schema=None) as batch_op: - batch_op.alter_column('error_details', - existing_type=mysql.LONGTEXT(collation='utf8mb4_unicode_ci'), - type_=sa.Text(length=16777215), - existing_nullable=True) - - # ### end Alembic commands ### - - -def downgrade(): - # ### commands auto generated by Alembic - please adjust! ### - with op.batch_alter_table('uploadedfiles', schema=None) as batch_op: - batch_op.alter_column('error_details', - existing_type=sa.Text(length=16777215), - type_=mysql.LONGTEXT(collation='utf8mb4_unicode_ci'), - existing_nullable=True) - - with op.batch_alter_table('encounters', schema=None) as batch_op: - batch_op.drop_column('needs_intervention') - batch_op.drop_column('encounter_type') - batch_op.drop_column('end_time') - - # ### end Alembic commands ### diff --git a/migrations/versions/6d1cbb67e2e2_assign.py b/migrations/versions/6d1cbb67e2e2_assign.py deleted file mode 100644 index 3a5c81d9de2dfd3cefa8ff43565afe37f3ad2166..0000000000000000000000000000000000000000 --- a/migrations/versions/6d1cbb67e2e2_assign.py +++ /dev/null @@ -1,50 +0,0 @@ -"""assign - -Revision ID: 6d1cbb67e2e2 -Revises: bfff5e42bd2f -Create Date: 2025-04-19 00:03:59.435077 - -""" -from alembic import op -import sqlalchemy as sa -from sqlalchemy.dialects import mysql - -# revision identifiers, used by Alembic. -revision = '6d1cbb67e2e2' -down_revision = 'bfff5e42bd2f' -branch_labels = None -depends_on = None - - -def upgrade(): - # ### commands auto generated by Alembic - please adjust! ### - with op.batch_alter_table('patients', schema=None) as batch_op: - batch_op.add_column(sa.Column('assigned_dietitian_user_id', sa.Integer(), nullable=True)) - batch_op.drop_constraint('patients_ibfk_1', type_='foreignkey') - batch_op.create_foreign_key(None, 'users', ['assigned_dietitian_user_id'], ['userID']) - batch_op.drop_column('dietitian_id') - - with op.batch_alter_table('uploadedfiles', schema=None) as batch_op: - batch_op.alter_column('error_details', - existing_type=mysql.LONGTEXT(collation='utf8mb4_unicode_ci'), - type_=sa.Text(length=16777215), - existing_nullable=True) - - # ### end Alembic commands ### - - -def downgrade(): - # ### commands auto generated by Alembic - please adjust! ### - with op.batch_alter_table('uploadedfiles', schema=None) as batch_op: - batch_op.alter_column('error_details', - existing_type=sa.Text(length=16777215), - type_=mysql.LONGTEXT(collation='utf8mb4_unicode_ci'), - existing_nullable=True) - - with op.batch_alter_table('patients', schema=None) as batch_op: - batch_op.add_column(sa.Column('dietitian_id', mysql.INTEGER(), autoincrement=False, nullable=True)) - batch_op.drop_constraint(None, type_='foreignkey') - batch_op.create_foreign_key('patients_ibfk_1', 'dietitians', ['dietitian_id'], ['dietitianID']) - batch_op.drop_column('assigned_dietitian_user_id') - - # ### end Alembic commands ### diff --git a/migrations/versions/734f404fad77_add_encounter_id_and_dietitian_id_to_.py b/migrations/versions/734f404fad77_add_encounter_id_and_dietitian_id_to_.py deleted file mode 100644 index a725f86a49510a496c1234e9d10ab298ac504d9c..0000000000000000000000000000000000000000 --- a/migrations/versions/734f404fad77_add_encounter_id_and_dietitian_id_to_.py +++ /dev/null @@ -1,52 +0,0 @@ -"""Add encounter_id and dietitian_id to Report mode - -Revision ID: 734f404fad77 -Revises: cf323f4343be -Create Date: 2025-04-19 15:30:30.321092 - -""" -from alembic import op -import sqlalchemy as sa -from sqlalchemy.dialects import mysql - -# revision identifiers, used by Alembic. -revision = '734f404fad77' -down_revision = 'cf323f4343be' -branch_labels = None -depends_on = None - - -def upgrade(): - # ### commands auto generated by Alembic - please adjust! ### - with op.batch_alter_table('reports', schema=None) as batch_op: - batch_op.add_column(sa.Column('encounterID', sa.Integer(), nullable=True)) - batch_op.add_column(sa.Column('dietitianID', sa.Integer(), nullable=True)) - batch_op.add_column(sa.Column('completed_date', sa.DateTime(), nullable=True)) - batch_op.create_foreign_key(None, 'encounters', ['encounterID'], ['encounterID']) - batch_op.create_foreign_key(None, 'users', ['dietitianID'], ['userID']) - - with op.batch_alter_table('uploadedfiles', schema=None) as batch_op: - batch_op.alter_column('error_details', - existing_type=mysql.LONGTEXT(collation='utf8mb4_unicode_ci'), - type_=sa.Text(length=16777215), - existing_nullable=True) - - # ### end Alembic commands ### - - -def downgrade(): - # ### commands auto generated by Alembic - please adjust! ### - with op.batch_alter_table('uploadedfiles', schema=None) as batch_op: - batch_op.alter_column('error_details', - existing_type=sa.Text(length=16777215), - type_=mysql.LONGTEXT(collation='utf8mb4_unicode_ci'), - existing_nullable=True) - - with op.batch_alter_table('reports', schema=None) as batch_op: - batch_op.drop_constraint(None, type_='foreignkey') - batch_op.drop_constraint(None, type_='foreignkey') - batch_op.drop_column('completed_date') - batch_op.drop_column('dietitianID') - batch_op.drop_column('encounterID') - - # ### end Alembic commands ### diff --git a/migrations/versions/8116b7d4aede_initital.py b/migrations/versions/8116b7d4aede_initital.py new file mode 100644 index 0000000000000000000000000000000000000000..f61f1161e3acc787855f8a8c75743c2745a0ae97 --- /dev/null +++ b/migrations/versions/8116b7d4aede_initital.py @@ -0,0 +1,103 @@ +"""initital + +Revision ID: 8116b7d4aede +Revises: +Create Date: 2025-04-21 13:04:43.442726 + +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import mysql + +# revision identifiers, used by Alembic. +revision = '8116b7d4aede' +down_revision = None +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('support_message_read_status', schema=None) as batch_op: + # Drop constraints FIRST + try: # Use try-except in case constraints don't exist or have different names + batch_op.drop_constraint('support_message_read_status_ibfk_1', type_='foreignkey') + except Exception as e: + print(f"Info: Could not drop FK support_message_read_status_ibfk_1 (might not exist): {e}") + try: + batch_op.drop_constraint('support_message_read_status_ibfk_2', type_='foreignkey') + except Exception as e: + print(f"Info: Could not drop FK support_message_read_status_ibfk_2 (might not exist): {e}") + + # Now drop the index + try: + batch_op.drop_index('uq_user_message_read') + except Exception as e: + print(f"Info: Could not drop Index uq_user_message_read (might not exist): {e}") + + # Drop the table after constraints and indexes are gone + try: + op.drop_table('support_message_read_status') + except Exception as e: + print(f"Info: Could not drop table support_message_read_status (might not exist): {e}") + with op.batch_alter_table('reports', schema=None) as batch_op: + batch_op.alter_column('status', + existing_type=mysql.VARCHAR(collation='utf8mb4_unicode_ci', length=20), + type_=sa.Enum('DRAFT', 'PENDING', 'COMPLETED', 'CANCELLED', name='reportstatus'), + nullable=False) + + with op.batch_alter_table('support_messages', schema=None) as batch_op: + batch_op.alter_column('timestamp', + existing_type=mysql.DATETIME(), + nullable=False) + + with op.batch_alter_table('uploadedfiles', schema=None) as batch_op: + batch_op.alter_column('error_details', + existing_type=mysql.LONGTEXT(collation='utf8mb4_unicode_ci'), + type_=sa.Text(length=16777215), + existing_nullable=True) + + with op.batch_alter_table('users', schema=None) as batch_op: + batch_op.add_column(sa.Column('last_support_visit', sa.DateTime(timezone=True), nullable=True)) + + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('users', schema=None) as batch_op: + batch_op.drop_column('last_support_visit') + + with op.batch_alter_table('uploadedfiles', schema=None) as batch_op: + batch_op.alter_column('error_details', + existing_type=sa.Text(length=16777215), + type_=mysql.LONGTEXT(collation='utf8mb4_unicode_ci'), + existing_nullable=True) + + with op.batch_alter_table('support_messages', schema=None) as batch_op: + batch_op.alter_column('timestamp', + existing_type=mysql.DATETIME(), + nullable=True) + + with op.batch_alter_table('reports', schema=None) as batch_op: + batch_op.alter_column('status', + existing_type=sa.Enum('DRAFT', 'PENDING', 'COMPLETED', 'CANCELLED', name='reportstatus'), + type_=mysql.VARCHAR(collation='utf8mb4_unicode_ci', length=20), + nullable=True) + + op.create_table('support_message_read_status', + sa.Column('id', mysql.INTEGER(), autoincrement=True, nullable=False), + sa.Column('user_id', mysql.INTEGER(), autoincrement=False, nullable=False), + sa.Column('message_id', mysql.INTEGER(), autoincrement=False, nullable=False), + sa.Column('read_at', mysql.DATETIME(), nullable=True), + sa.ForeignKeyConstraint(['message_id'], ['support_messages.id'], name='support_message_read_status_ibfk_1'), + sa.ForeignKeyConstraint(['user_id'], ['users.userID'], name='support_message_read_status_ibfk_2'), + sa.PrimaryKeyConstraint('id'), + mysql_collate='utf8mb4_unicode_ci', + mysql_default_charset='utf8mb4', + mysql_engine='InnoDB' + ) + with op.batch_alter_table('support_message_read_status', schema=None) as batch_op: + batch_op.create_index('uq_user_message_read', ['user_id', 'message_id'], unique=True) + + # ### end Alembic commands ### diff --git a/migrations/versions/8ea1605c5393_add_encounter_id.py b/migrations/versions/8ea1605c5393_add_encounter_id.py deleted file mode 100644 index 119aacb0c51654f7703624b6ad6f01eddbd0eec2..0000000000000000000000000000000000000000 --- a/migrations/versions/8ea1605c5393_add_encounter_id.py +++ /dev/null @@ -1,46 +0,0 @@ -"""add encounter_id - -Revision ID: 8ea1605c5393 -Revises: 11d6fcd3a14d -Create Date: 2025-04-17 22:35:02.844489 - -""" -from alembic import op -import sqlalchemy as sa -from sqlalchemy.dialects import mysql - -# revision identifiers, used by Alembic. -revision = '8ea1605c5393' -down_revision = '11d6fcd3a14d' -branch_labels = None -depends_on = None - - -def upgrade(): - # ### commands auto generated by Alembic - please adjust! ### - with op.batch_alter_table('encounters', schema=None) as batch_op: - batch_op.add_column(sa.Column('custom_encounter_id', sa.String(length=50), nullable=True)) - batch_op.create_index(batch_op.f('ix_encounters_custom_encounter_id'), ['custom_encounter_id'], unique=True) - - with op.batch_alter_table('uploadedfiles', schema=None) as batch_op: - batch_op.alter_column('error_details', - existing_type=mysql.LONGTEXT(collation='utf8mb4_unicode_ci'), - type_=sa.Text(length=16777215), - existing_nullable=True) - - # ### end Alembic commands ### - - -def downgrade(): - # ### commands auto generated by Alembic - please adjust! ### - with op.batch_alter_table('uploadedfiles', schema=None) as batch_op: - batch_op.alter_column('error_details', - existing_type=sa.Text(length=16777215), - type_=mysql.LONGTEXT(collation='utf8mb4_unicode_ci'), - existing_nullable=True) - - with op.batch_alter_table('encounters', schema=None) as batch_op: - batch_op.drop_index(batch_op.f('ix_encounters_custom_encounter_id')) - batch_op.drop_column('custom_encounter_id') - - # ### end Alembic commands ### diff --git a/migrations/versions/9d82acfed8fa_update_report.py b/migrations/versions/9d82acfed8fa_update_report.py deleted file mode 100644 index ccf807f304f6facb9e3a51eff12ad7a00865b268..0000000000000000000000000000000000000000 --- a/migrations/versions/9d82acfed8fa_update_report.py +++ /dev/null @@ -1,96 +0,0 @@ -"""update report - -Revision ID: 9d82acfed8fa -Revises: 1af2bf32a740 -Create Date: 2025-04-18 11:06:04.115320 - -""" -from alembic import op -import sqlalchemy as sa -from sqlalchemy.dialects import mysql - -# revision identifiers, used by Alembic. -revision = '9d82acfed8fa' -down_revision = '1af2bf32a740' -branch_labels = None -depends_on = None - - -def upgrade(): - # ### commands auto generated by Alembic - please adjust! ### - with op.batch_alter_table('reports', schema=None) as batch_op: - batch_op.add_column(sa.Column('authorID', sa.Integer(), nullable=False)) - batch_op.add_column(sa.Column('referralID', sa.Integer(), nullable=True)) - batch_op.add_column(sa.Column('report_date', sa.DateTime(), nullable=False)) - batch_op.add_column(sa.Column('report_type', sa.String(length=50), nullable=False)) - batch_op.add_column(sa.Column('nutritional_status', sa.String(length=100), nullable=True)) - batch_op.add_column(sa.Column('nutrition_diagnosis', sa.Text(), nullable=True)) - batch_op.add_column(sa.Column('assessment_details', sa.Text(), nullable=True)) - batch_op.add_column(sa.Column('nutritional_risk_score', sa.Float(), nullable=True)) - batch_op.add_column(sa.Column('malnutrition_screening_result', sa.String(length=50), nullable=True)) - batch_op.add_column(sa.Column('intervention_plan', sa.Text(), nullable=True)) - batch_op.add_column(sa.Column('intervention_summary', sa.Text(), nullable=True)) - batch_op.add_column(sa.Column('dietary_recommendations', sa.Text(), nullable=True)) - batch_op.add_column(sa.Column('supplement_recommendations', sa.Text(), nullable=True)) - batch_op.add_column(sa.Column('enteral_feeding_recommendations', sa.Text(), nullable=True)) - batch_op.add_column(sa.Column('parenteral_nutrition_recommendations', sa.Text(), nullable=True)) - batch_op.add_column(sa.Column('monitoring_plan', sa.Text(), nullable=True)) - batch_op.add_column(sa.Column('follow_up_needed', sa.Boolean(), nullable=True)) - batch_op.add_column(sa.Column('follow_up_date', sa.Date(), nullable=True)) - batch_op.add_column(sa.Column('notes', sa.Text(), nullable=True)) - batch_op.add_column(sa.Column('finalized_at', sa.DateTime(), nullable=True)) - batch_op.drop_constraint('reports_ibfk_1', type_='foreignkey') - batch_op.create_foreign_key(None, 'referrals', ['referralID'], ['referralID']) - batch_op.create_foreign_key(None, 'users', ['authorID'], ['userID']) - batch_op.drop_column('reportTitle') - batch_op.drop_column('reportContent') - batch_op.drop_column('reportDateTime') - batch_op.drop_column('userID') - - with op.batch_alter_table('uploadedfiles', schema=None) as batch_op: - batch_op.alter_column('error_details', - existing_type=mysql.LONGTEXT(collation='utf8mb4_unicode_ci'), - type_=sa.Text(length=16777215), - existing_nullable=True) - - # ### end Alembic commands ### - - -def downgrade(): - # ### commands auto generated by Alembic - please adjust! ### - with op.batch_alter_table('uploadedfiles', schema=None) as batch_op: - batch_op.alter_column('error_details', - existing_type=sa.Text(length=16777215), - type_=mysql.LONGTEXT(collation='utf8mb4_unicode_ci'), - existing_nullable=True) - - with op.batch_alter_table('reports', schema=None) as batch_op: - batch_op.add_column(sa.Column('userID', mysql.INTEGER(), autoincrement=False, nullable=False)) - batch_op.add_column(sa.Column('reportDateTime', mysql.DATETIME(), nullable=False)) - batch_op.add_column(sa.Column('reportContent', mysql.TEXT(collation='utf8mb4_unicode_ci'), nullable=True)) - batch_op.add_column(sa.Column('reportTitle', mysql.VARCHAR(collation='utf8mb4_unicode_ci', length=100), nullable=False)) - batch_op.drop_constraint(None, type_='foreignkey') - batch_op.drop_constraint(None, type_='foreignkey') - batch_op.create_foreign_key('reports_ibfk_1', 'users', ['userID'], ['userID']) - batch_op.drop_column('finalized_at') - batch_op.drop_column('notes') - batch_op.drop_column('follow_up_date') - batch_op.drop_column('follow_up_needed') - batch_op.drop_column('monitoring_plan') - batch_op.drop_column('parenteral_nutrition_recommendations') - batch_op.drop_column('enteral_feeding_recommendations') - batch_op.drop_column('supplement_recommendations') - batch_op.drop_column('dietary_recommendations') - batch_op.drop_column('intervention_summary') - batch_op.drop_column('intervention_plan') - batch_op.drop_column('malnutrition_screening_result') - batch_op.drop_column('nutritional_risk_score') - batch_op.drop_column('assessment_details') - batch_op.drop_column('nutrition_diagnosis') - batch_op.drop_column('nutritional_status') - batch_op.drop_column('report_type') - batch_op.drop_column('report_date') - batch_op.drop_column('referralID') - batch_op.drop_column('authorID') - - # ### end Alembic commands ### diff --git a/migrations/versions/af3981c4f7e5_add_chat.py b/migrations/versions/af3981c4f7e5_add_chat.py deleted file mode 100644 index 690999d6537b1d2ff1b457b2189d7aac83de0889..0000000000000000000000000000000000000000 --- a/migrations/versions/af3981c4f7e5_add_chat.py +++ /dev/null @@ -1,58 +0,0 @@ -"""add chat - -Revision ID: af3981c4f7e5 -Revises: eaf7e2cfc895 -Create Date: 2025-04-20 19:25:34.946616 - -""" -from alembic import op -import sqlalchemy as sa -from sqlalchemy.dialects import mysql - -# revision identifiers, used by Alembic. -revision = 'af3981c4f7e5' -down_revision = 'eaf7e2cfc895' -branch_labels = None -depends_on = None - - -def upgrade(): - # ### commands auto generated by Alembic - please adjust! ### - op.create_table('support_messages', - sa.Column('id', sa.Integer(), nullable=False), - sa.Column('sender_id', sa.Integer(), nullable=False), - sa.Column('content', sa.Text(), nullable=False), - sa.Column('timestamp', sa.DateTime(), nullable=True), - sa.ForeignKeyConstraint(['sender_id'], ['users.userID'], ), - sa.PrimaryKeyConstraint('id') - ) - op.create_table('support_message_read_status', - sa.Column('id', sa.Integer(), nullable=False), - sa.Column('user_id', sa.Integer(), nullable=False), - sa.Column('message_id', sa.Integer(), nullable=False), - sa.Column('read_at', sa.DateTime(), nullable=True), - sa.ForeignKeyConstraint(['message_id'], ['support_messages.id'], ), - sa.ForeignKeyConstraint(['user_id'], ['users.userID'], ), - sa.PrimaryKeyConstraint('id'), - sa.UniqueConstraint('user_id', 'message_id', name='uq_user_message_read') - ) - with op.batch_alter_table('uploadedfiles', schema=None) as batch_op: - batch_op.alter_column('error_details', - existing_type=mysql.LONGTEXT(collation='utf8mb4_unicode_ci'), - type_=sa.Text(length=16777215), - existing_nullable=True) - - # ### end Alembic commands ### - - -def downgrade(): - # ### commands auto generated by Alembic - please adjust! ### - with op.batch_alter_table('uploadedfiles', schema=None) as batch_op: - batch_op.alter_column('error_details', - existing_type=sa.Text(length=16777215), - type_=mysql.LONGTEXT(collation='utf8mb4_unicode_ci'), - existing_nullable=True) - - op.drop_table('support_message_read_status') - op.drop_table('support_messages') - # ### end Alembic commands ### diff --git a/migrations/versions/bfff5e42bd2f_allow_null_for_needs_intervention.py b/migrations/versions/bfff5e42bd2f_allow_null_for_needs_intervention.py deleted file mode 100644 index bb21b71136921d364830f1964bf5f3f23a0d9be6..0000000000000000000000000000000000000000 --- a/migrations/versions/bfff5e42bd2f_allow_null_for_needs_intervention.py +++ /dev/null @@ -1,48 +0,0 @@ -"""Allow null for needs_intervention - -Revision ID: bfff5e42bd2f -Revises: e631bdcdce5f -Create Date: 2025-04-18 23:01:08.603279 - -""" -from alembic import op -import sqlalchemy as sa -from sqlalchemy.dialects import mysql - -# revision identifiers, used by Alembic. -revision = 'bfff5e42bd2f' -down_revision = 'e631bdcdce5f' -branch_labels = None -depends_on = None - - -def upgrade(): - # ### commands auto generated by Alembic - please adjust! ### - with op.batch_alter_table('encounters', schema=None) as batch_op: - batch_op.alter_column('needs_intervention', - existing_type=mysql.TINYINT(display_width=1), - nullable=True) - - with op.batch_alter_table('uploadedfiles', schema=None) as batch_op: - batch_op.alter_column('error_details', - existing_type=mysql.LONGTEXT(collation='utf8mb4_unicode_ci'), - type_=sa.Text(length=16777215), - existing_nullable=True) - - # ### end Alembic commands ### - - -def downgrade(): - # ### commands auto generated by Alembic - please adjust! ### - with op.batch_alter_table('uploadedfiles', schema=None) as batch_op: - batch_op.alter_column('error_details', - existing_type=sa.Text(length=16777215), - type_=mysql.LONGTEXT(collation='utf8mb4_unicode_ci'), - existing_nullable=True) - - with op.batch_alter_table('encounters', schema=None) as batch_op: - batch_op.alter_column('needs_intervention', - existing_type=mysql.TINYINT(display_width=1), - nullable=False) - - # ### end Alembic commands ### diff --git "a/migrations/versions/cf323f4343be_thay_\304\221\341\273\225i_tr\341\272\241ng_th\303\241i.py" "b/migrations/versions/cf323f4343be_thay_\304\221\341\273\225i_tr\341\272\241ng_th\303\241i.py" deleted file mode 100644 index ce366055b4bf4c6a54921e69e2b570136bfaf0f8..0000000000000000000000000000000000000000 --- "a/migrations/versions/cf323f4343be_thay_\304\221\341\273\225i_tr\341\272\241ng_th\303\241i.py" +++ /dev/null @@ -1,38 +0,0 @@ -"""thay đổi trạng thái - -Revision ID: cf323f4343be -Revises: fd2ac04819c9 -Create Date: 2025-04-19 14:39:22.579139 - -""" -from alembic import op -import sqlalchemy as sa -from sqlalchemy.dialects import mysql - -# revision identifiers, used by Alembic. -revision = 'cf323f4343be' -down_revision = 'fd2ac04819c9' -branch_labels = None -depends_on = None - - -def upgrade(): - # ### commands auto generated by Alembic - please adjust! ### - with op.batch_alter_table('uploadedfiles', schema=None) as batch_op: - batch_op.alter_column('error_details', - existing_type=mysql.LONGTEXT(collation='utf8mb4_unicode_ci'), - type_=sa.Text(length=16777215), - existing_nullable=True) - - # ### end Alembic commands ### - - -def downgrade(): - # ### commands auto generated by Alembic - please adjust! ### - with op.batch_alter_table('uploadedfiles', schema=None) as batch_op: - batch_op.alter_column('error_details', - existing_type=sa.Text(length=16777215), - type_=mysql.LONGTEXT(collation='utf8mb4_unicode_ci'), - existing_nullable=True) - - # ### end Alembic commands ### diff --git a/migrations/versions/e631bdcdce5f_ml.py b/migrations/versions/e631bdcdce5f_ml.py deleted file mode 100644 index 42785d6a089d27848dfaab7d56f06e447df20b97..0000000000000000000000000000000000000000 --- a/migrations/versions/e631bdcdce5f_ml.py +++ /dev/null @@ -1,44 +0,0 @@ -"""ml - -Revision ID: e631bdcdce5f -Revises: 617d526bbcee -Create Date: 2025-04-18 22:18:57.110317 - -""" -from alembic import op -import sqlalchemy as sa -from sqlalchemy.dialects import mysql - -# revision identifiers, used by Alembic. -revision = 'e631bdcdce5f' -down_revision = '617d526bbcee' -branch_labels = None -depends_on = None - - -def upgrade(): - # ### commands auto generated by Alembic - please adjust! ### - with op.batch_alter_table('encounters', schema=None) as batch_op: - batch_op.add_column(sa.Column('diagnosis', sa.String(length=255), nullable=True)) - - with op.batch_alter_table('uploadedfiles', schema=None) as batch_op: - batch_op.alter_column('error_details', - existing_type=mysql.LONGTEXT(collation='utf8mb4_unicode_ci'), - type_=sa.Text(length=16777215), - existing_nullable=True) - - # ### end Alembic commands ### - - -def downgrade(): - # ### commands auto generated by Alembic - please adjust! ### - with op.batch_alter_table('uploadedfiles', schema=None) as batch_op: - batch_op.alter_column('error_details', - existing_type=sa.Text(length=16777215), - type_=mysql.LONGTEXT(collation='utf8mb4_unicode_ci'), - existing_nullable=True) - - with op.batch_alter_table('encounters', schema=None) as batch_op: - batch_op.drop_column('diagnosis') - - # ### end Alembic commands ### diff --git a/migrations/versions/eaf7e2cfc895_add_procedure.py b/migrations/versions/eaf7e2cfc895_add_procedure.py deleted file mode 100644 index e63dcdd27ee7cd05eeb21b3d200e8d1fd077518d..0000000000000000000000000000000000000000 --- a/migrations/versions/eaf7e2cfc895_add_procedure.py +++ /dev/null @@ -1,46 +0,0 @@ -"""add procedure - -Revision ID: eaf7e2cfc895 -Revises: 734f404fad77 -Create Date: 2025-04-19 17:41:56.281693 - -""" -from alembic import op -import sqlalchemy as sa -from sqlalchemy.dialects import mysql - -# revision identifiers, used by Alembic. -revision = 'eaf7e2cfc895' -down_revision = '734f404fad77' -branch_labels = None -depends_on = None - - -def upgrade(): - # ### commands auto generated by Alembic - please adjust! ### - with op.batch_alter_table('reports', schema=None) as batch_op: - batch_op.add_column(sa.Column('related_procedure_id', sa.Integer(), nullable=True)) - batch_op.create_foreign_key(None, 'procedures', ['related_procedure_id'], ['procedureID']) - - with op.batch_alter_table('uploadedfiles', schema=None) as batch_op: - batch_op.alter_column('error_details', - existing_type=mysql.LONGTEXT(collation='utf8mb4_unicode_ci'), - type_=sa.Text(length=16777215), - existing_nullable=True) - - # ### end Alembic commands ### - - -def downgrade(): - # ### commands auto generated by Alembic - please adjust! ### - with op.batch_alter_table('uploadedfiles', schema=None) as batch_op: - batch_op.alter_column('error_details', - existing_type=sa.Text(length=16777215), - type_=mysql.LONGTEXT(collation='utf8mb4_unicode_ci'), - existing_nullable=True) - - with op.batch_alter_table('reports', schema=None) as batch_op: - batch_op.drop_constraint(None, type_='foreignkey') - batch_op.drop_column('related_procedure_id') - - # ### end Alembic commands ### diff --git a/migrations/versions/fd2ac04819c9_update_status_enums_and_referral_.py b/migrations/versions/fd2ac04819c9_update_status_enums_and_referral_.py deleted file mode 100644 index 3da25157938a3344a96bb8b1c47b0803d68da0bf..0000000000000000000000000000000000000000 --- a/migrations/versions/fd2ac04819c9_update_status_enums_and_referral_.py +++ /dev/null @@ -1,136 +0,0 @@ -"""Update status enums and referral dietitian relationship - -Revision ID: fd2ac04819c9 -Revises: 375b7f062a5f -Create Date: 2025-04-19 13:54:15.128405 - -""" -from alembic import op -import sqlalchemy as sa -from sqlalchemy.dialects import mysql - -# revision identifiers, used by Alembic. -revision = 'fd2ac04819c9' -down_revision = '375b7f062a5f' -branch_labels = None -depends_on = None - -# Định nghĩa Enum để sử dụng trong migration -patient_status_enum = sa.Enum('NOT_ASSESSED', 'NEEDS_ASSESSMENT', 'ASSESSMENT_IN_PROGRESS', 'COMPLETED', name='patientstatus') -referral_status_enum = sa.Enum('DIETITIAN_UNASSIGNED', 'WAITING_FOR_REPORT', 'COMPLETED', name='referralstatus') -old_referral_status_enum_for_downgrade = sa.Enum('Not Needed', 'ML Recommended', 'Pending Review', 'Staff Referred', 'Completed', 'Rejected', name='referrals_referral_status') - -def upgrade(): - # ### commands auto generated by Alembic - please adjust! ### - with op.batch_alter_table('patients', schema=None) as batch_op: - # === BEGIN DATA MIGRATION FOR patients.status === - op.execute("UPDATE patients SET status = 'NEEDS_ASSESSMENT' WHERE status = 'Needs Assessment'") - op.execute("UPDATE patients SET status = 'COMPLETED' WHERE status = 'Completed'") - op.execute("UPDATE patients SET status = 'NOT_ASSESSED' WHERE status = 'active'") - # === END DATA MIGRATION === - - batch_op.alter_column('status', - existing_type=mysql.VARCHAR(collation='utf8mb4_unicode_ci', length=20), - type_=patient_status_enum, # Sử dụng Enum đã định nghĩa - existing_nullable=True, - nullable=False, - postgresql_using='status::patientstatus' - ) - batch_op.create_index(batch_op.f('ix_patients_status'), ['status'], unique=False) - - with op.batch_alter_table('referrals', schema=None) as batch_op: - # === BEGIN DATA MIGRATION FOR referrals.referral_status === - op.execute("UPDATE referrals SET referral_status = 'DIETITIAN_UNASSIGNED' WHERE referral_status = 'ML Recommended'") - op.execute("UPDATE referrals SET referral_status = 'DIETITIAN_UNASSIGNED' WHERE referral_status = 'Staff Referred'") - op.execute("UPDATE referrals SET referral_status = 'DIETITIAN_UNASSIGNED' WHERE referral_status = 'Pending Review'") - op.execute("UPDATE referrals SET referral_status = 'COMPLETED' WHERE referral_status = 'Completed'") - op.execute("UPDATE referrals SET referral_status = NULL WHERE referral_status = 'Not Needed'") - op.execute("UPDATE referrals SET referral_status = NULL WHERE referral_status = 'Rejected'") - # === END DATA MIGRATION === - - # Thay đổi kiểu cột referral_status - batch_op.alter_column('referral_status', - existing_type=mysql.ENUM('Not Needed', 'ML Recommended', 'Pending Review', 'Staff Referred', 'Completed', 'Rejected', collation='utf8mb4_unicode_ci'), - type_=referral_status_enum, # Sử dụng Enum mới - existing_nullable=True, # Giữ nullable cũ (nếu có) - nullable=True, # Cho phép NULL theo model mới - postgresql_using='referral_status::referralstatus' - ) - - # Thay đổi liên kết dietitian - batch_op.add_column(sa.Column('assigned_dietitian_user_id', sa.Integer(), nullable=True)) - batch_op.create_foreign_key('fk_referrals_assigned_dietitian_user', 'users', ['assigned_dietitian_user_id'], ['userID']) - # Kiểm tra xem khóa ngoại cũ có tồn tại không trước khi xóa - # Tên khóa ngoại có thể khác nhau, cần kiểm tra DB hoặc migration trước đó - # Giả sử tên là 'referrals_ibfk_3' như trong file gốc - try: - batch_op.drop_constraint('referrals_ibfk_3', type_='foreignkey') - except Exception as e: - print(f"Could not drop foreign key constraint 'referrals_ibfk_3': {e}") - # Có thể bỏ qua lỗi nếu khóa không tồn tại - batch_op.drop_column('dietitianID') - - # Tạo index mới cho referral_status - batch_op.create_index(batch_op.f('ix_referrals_referral_status'), ['referral_status'], unique=False) - - with op.batch_alter_table('uploadedfiles', schema=None) as batch_op: - batch_op.alter_column('error_details', - existing_type=mysql.LONGTEXT(collation='utf8mb4_unicode_ci'), - type_=sa.Text(length=16777215), - existing_nullable=True) - - # ### end Alembic commands ### - - -def downgrade(): - # ### commands auto generated by Alembic - please adjust! ### - with op.batch_alter_table('uploadedfiles', schema=None) as batch_op: - batch_op.alter_column('error_details', - existing_type=sa.Text(length=16777215), - type_=mysql.LONGTEXT(collation='utf8mb4_unicode_ci'), - existing_nullable=True) - - with op.batch_alter_table('referrals', schema=None) as batch_op: - batch_op.drop_index(batch_op.f('ix_referrals_referral_status')) - - # Thêm lại cột dietitianID và khóa ngoại cũ - batch_op.add_column(sa.Column('dietitianID', mysql.INTEGER(), autoincrement=False, nullable=True)) - batch_op.create_foreign_key('referrals_ibfk_3', 'dietitians', ['dietitianID'], ['dietitianID']) - # Xóa cột và khóa ngoại mới - batch_op.drop_constraint('fk_referrals_assigned_dietitian_user', type_='foreignkey') - batch_op.drop_column('assigned_dietitian_user_id') - - # === BEGIN DATA MIGRATION FOR referrals.referral_status (DOWNGRADE) === - op.execute("UPDATE referrals SET referral_status = 'ML Recommended' WHERE referral_status = 'DIETITIAN_UNASSIGNED' AND is_ml_recommended = TRUE") # Ưu tiên ML nếu is_ml_recommended là True - op.execute("UPDATE referrals SET referral_status = 'Staff Referred' WHERE referral_status = 'DIETITIAN_UNASSIGNED' AND is_ml_recommended = FALSE") # Giả định còn lại là Staff Referred - op.execute("UPDATE referrals SET referral_status = 'Pending Review' WHERE referral_status = 'WAITING_FOR_REPORT'") # Map tương đối - op.execute("UPDATE referrals SET referral_status = 'Completed' WHERE referral_status = 'COMPLETED'") - op.execute("UPDATE referrals SET referral_status = 'Not Needed' WHERE referral_status IS NULL") # Map NULL về Not Needed - # === END DATA MIGRATION === - - # Thay đổi kiểu cột referral_status về cũ - batch_op.alter_column('referral_status', - existing_type=referral_status_enum, - type_=old_referral_status_enum_for_downgrade, # Sử dụng Enum cũ - existing_nullable=True, - nullable=True, # Giữ nullable như cũ? - server_default='Not Needed', # Đặt lại default cũ - postgresql_using='referral_status::referrals_referral_status' - ) - - with op.batch_alter_table('patients', schema=None) as batch_op: - batch_op.drop_index(batch_op.f('ix_patients_status')) - - # === BEGIN DATA MIGRATION FOR patients.status (DOWNGRADE) === - op.execute("UPDATE patients SET status = 'Needs Assessment' WHERE status = 'NEEDS_ASSESSMENT'") - op.execute("UPDATE patients SET status = 'Completed' WHERE status = 'COMPLETED'") - op.execute("UPDATE patients SET status = 'active' WHERE status = 'NOT_ASSESSED'") - # === END DATA MIGRATION === - - batch_op.alter_column('status', - existing_type=patient_status_enum, - type_=mysql.VARCHAR(collation='utf8mb4_unicode_ci', length=20), - existing_nullable=False, - nullable=True) - - # ### end Alembic commands ### diff --git a/migrations/versions/fe0cbf650625_add.py b/migrations/versions/fe0cbf650625_add.py new file mode 100644 index 0000000000000000000000000000000000000000..a517bcc835710accca34ac7e11909f3d237afef9 --- /dev/null +++ b/migrations/versions/fe0cbf650625_add.py @@ -0,0 +1,64 @@ +"""add + +Revision ID: fe0cbf650625 +Revises: 8116b7d4aede +Create Date: 2025-04-21 20:14:07.900955 + +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import mysql + +# revision identifiers, used by Alembic. +revision = 'fe0cbf650625' +down_revision = '8116b7d4aede' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('patient_dietitian_assignments', + sa.Column('id', mysql.INTEGER(unsigned=True), nullable=False), + sa.Column('patient_id', sa.String(length=50), nullable=False), + sa.Column('dietitian_id', sa.Integer(), nullable=False), + sa.Column('assignment_date', sa.DateTime(), nullable=False), + sa.Column('end_date', sa.DateTime(), nullable=True), + sa.Column('is_active', sa.Boolean(), nullable=False), + sa.Column('notes', sa.Text(), nullable=True), + sa.ForeignKeyConstraint(['dietitian_id'], ['users.userID'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['patient_id'], ['patients.patientID'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('patient_id', 'dietitian_id', name='uq_patient_dietitian_assignment') + ) + with op.batch_alter_table('patient_dietitian_assignments', schema=None) as batch_op: + batch_op.create_index(batch_op.f('ix_patient_dietitian_assignments_assignment_date'), ['assignment_date'], unique=False) + batch_op.create_index(batch_op.f('ix_patient_dietitian_assignments_dietitian_id'), ['dietitian_id'], unique=False) + batch_op.create_index(batch_op.f('ix_patient_dietitian_assignments_is_active'), ['is_active'], unique=False) + batch_op.create_index(batch_op.f('ix_patient_dietitian_assignments_patient_id'), ['patient_id'], unique=False) + + with op.batch_alter_table('uploadedfiles', schema=None) as batch_op: + batch_op.alter_column('error_details', + existing_type=mysql.LONGTEXT(collation='utf8mb4_unicode_ci'), + type_=sa.Text(length=16777215), + existing_nullable=True) + + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('uploadedfiles', schema=None) as batch_op: + batch_op.alter_column('error_details', + existing_type=sa.Text(length=16777215), + type_=mysql.LONGTEXT(collation='utf8mb4_unicode_ci'), + existing_nullable=True) + + with op.batch_alter_table('patient_dietitian_assignments', schema=None) as batch_op: + batch_op.drop_index(batch_op.f('ix_patient_dietitian_assignments_patient_id')) + batch_op.drop_index(batch_op.f('ix_patient_dietitian_assignments_is_active')) + batch_op.drop_index(batch_op.f('ix_patient_dietitian_assignments_dietitian_id')) + batch_op.drop_index(batch_op.f('ix_patient_dietitian_assignments_assignment_date')) + + op.drop_table('patient_dietitian_assignments') + # ### end Alembic commands ###