From 350b3f649cfd6e97cd121b81522dcb087dea6910 Mon Sep 17 00:00:00 2001 From: muoimeo <bvnminh6a01@gmail.com> Date: Mon, 14 Apr 2025 00:21:21 +0700 Subject: [PATCH] =?UTF-8?q?th=E1=BA=BF=20gi=E1=BB=9Bi=20vcl?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 5 +- app/forms/auth.py | 19 + app/forms/auth_forms.py | 81 +-- app/models/__init__.py | 4 +- app/models/activity_log.py | 20 + app/models/dietitian.py | 15 +- app/models/patient.py | 2 +- app/models/referral.py | 15 +- app/models/user.py | 71 +-- app/routes/auth.py | 189 +++---- app/routes/dietitians.py | 284 ++++------ app/templates/auth_base.html | 5 +- app/templates/base.html | 135 ++++- app/templates/dietitians/edit.html | 104 ---- app/templates/dietitians/index.html | 67 +-- app/templates/dietitians/link_profile.html | 121 ---- app/templates/dietitians/show.html | 51 +- app/templates/edit_profile.html | 333 ++++++----- app/templates/login.html | 107 ++-- app/templates/profile.html | 156 ++---- app/templates/register.html | 181 ------ app/utils/decorators.py | 16 + fix_template_urls.py | 46 -- generate_patients.py | 129 +++++ migrations/README | 1 + migrations/alembic.ini | 50 ++ migrations/env.py | 113 ++++ migrations/script.py.mako | 24 + ..._update_database_schema_to_match_models.py | 60 ++ requirements.txt | 3 +- run.py | 517 ++++++++---------- run_ccu.bat | 43 +- 32 files changed, 1426 insertions(+), 1541 deletions(-) create mode 100644 app/forms/auth.py create mode 100644 app/models/activity_log.py delete mode 100644 app/templates/dietitians/edit.html delete mode 100644 app/templates/dietitians/link_profile.html delete mode 100644 app/templates/register.html create mode 100644 app/utils/decorators.py delete mode 100644 fix_template_urls.py create mode 100644 generate_patients.py create mode 100644 migrations/README create mode 100644 migrations/alembic.ini create mode 100644 migrations/env.py create mode 100644 migrations/script.py.mako create mode 100644 migrations/versions/cbc9db9dd958_update_database_schema_to_match_models.py diff --git a/.gitignore b/.gitignore index 0fb850d..b1766a0 100644 --- a/.gitignore +++ b/.gitignore @@ -146,4 +146,7 @@ logs/ # Thư mục và tệp khác .DS_Store -Thumbs.db \ No newline at end of file +Thumbs.db + +#json file +*.json \ No newline at end of file diff --git a/app/forms/auth.py b/app/forms/auth.py new file mode 100644 index 0000000..49e3501 --- /dev/null +++ b/app/forms/auth.py @@ -0,0 +1,19 @@ +from flask_wtf import FlaskForm +from wtforms import StringField, PasswordField, SubmitField, BooleanField, TextAreaField +from wtforms.validators import DataRequired, Email, EqualTo, Length, Optional, ValidationError + +class EditProfileForm(FlaskForm): + """Form chỉnh sửa hồ sơ người dùng""" + firstName = StringField('Tên', validators=[DataRequired(message="Tên không được để trống")]) + lastName = StringField('Họ', validators=[DataRequired(message="Họ không được để trống")]) + email = StringField('Email', validators=[DataRequired(message="Email không được để trống"), Email(message="Email không hợp lệ")]) + phone = StringField('Số điện thoại', validators=[Optional()]) + + # Các trường cho dietitian + specialization = StringField('Chuyên môn', validators=[Optional()]) + notes = TextAreaField('Ghi chú', validators=[Optional(), Length(max=500, message="Ghi chú không được vượt quá 500 ký tự")]) + + password = PasswordField('Mật khẩu mới', validators=[Optional(), Length(min=6, message="Mật khẩu phải có ít nhất 6 ký tự")]) + confirm_password = PasswordField('Xác nhận mật khẩu', validators=[Optional(), EqualTo('password', message="Xác nhận mật khẩu không khớp")]) + + submit = SubmitField('Cập nhật') \ No newline at end of file diff --git a/app/forms/auth_forms.py b/app/forms/auth_forms.py index 9a01367..b28b2cc 100644 --- a/app/forms/auth_forms.py +++ b/app/forms/auth_forms.py @@ -1,6 +1,6 @@ from flask_wtf import FlaskForm -from wtforms import StringField, PasswordField, SubmitField, BooleanField, TextAreaField -from wtforms.validators import DataRequired, Email, Length, EqualTo, ValidationError +from wtforms import StringField, PasswordField, SubmitField, BooleanField, TextAreaField, SelectField +from wtforms.validators import DataRequired, Email, Length, EqualTo, ValidationError, Optional from app.models.user import User class LoginForm(FlaskForm): @@ -9,80 +9,33 @@ class LoginForm(FlaskForm): DataRequired(message="Email không được để trống"), Email(message="Vui lòng nhập địa chỉ email hợp lệ") ]) + dietitianID = StringField('Dietitian ID', validators=[Optional()]) password = PasswordField('Mật khẩu', validators=[ DataRequired(message="Mật khẩu không được để trống") ]) remember = BooleanField('Ghi nhớ đăng nhập') submit = SubmitField('Đăng nhập') -class RegisterForm(FlaskForm): - """Form đăng ký người dùng mới""" - firstName = StringField('Họ', validators=[ - DataRequired(message="Họ không được để trống"), - Length(min=1, max=50, message="Họ phải có từ 1 đến 50 ký tự") - ]) - lastName = StringField('Tên', validators=[ - DataRequired(message="Tên không được để trống"), - Length(min=1, max=50, message="Tên phải có từ 1 đến 50 ký tự") - ]) - username = StringField('Tên người dùng', validators=[ - DataRequired(message="Tên người dùng không được để trống"), - Length(min=3, max=25, message="Tên người dùng phải có từ 3 đến 25 ký tự") - ]) - 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ệ") - ]) - password = PasswordField('Mật khẩu', validators=[ - DataRequired(message="Mật khẩu không được để trống"), - Length(min=6, message="Mật khẩu phải có ít nhất 6 ký tự") - ]) - confirm_password = PasswordField('Xác nhận mật khẩu', validators=[ - DataRequired(message="Vui lòng xác nhận mật khẩu"), - EqualTo('password', message="Mật khẩu không khớp") - ]) - submit = SubmitField('Đăng ký') - - def validate_username(self, username): - """Kiểm tra xem tên người dùng đã tồn tại chưa""" - user = User.query.filter_by(username=username.data).first() - if user: - raise ValidationError('Tên người dùng này đã được sử dụng. Vui lòng chọn tên khác.') - - def validate_email(self, email): - """Kiểm tra xem email đã tồn tại chưa""" - user = User.query.filter_by(email=email.data).first() - if user: - raise ValidationError('Email này đã được sử dụng. Vui lòng sử dụng email khác.') - class EditProfileForm(FlaskForm): - """Form chỉnh sửa thông tin hồ sơ người dùng""" - username = StringField('Tên người dùng', validators=[ - DataRequired(message="Tên người dùng không được để trống"), - Length(min=3, max=25, message="Tên người dùng phải có từ 3 đến 25 ký tự") - ]) - 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ệ") - ]) - bio = TextAreaField('Giới thiệu', validators=[ - Length(max=200, message="Giới thiệu không được quá 200 ký tự") - ]) - submit = SubmitField('Cập nhật thông tin') + """Form for users to edit their profile""" + firstName = StringField('First Name', validators=[DataRequired(), Length(min=2, max=50)]) + lastName = StringField('Last Name', validators=[DataRequired(), Length(min=2, max=50)]) + email = StringField('Email', validators=[DataRequired(), Email(), Length(max=100)]) + phone = StringField('Số điện thoại', validators=[Optional()]) + + # Các trường cho dietitian + specialization = StringField('Chuyên môn', validators=[Optional()]) + notes = TextAreaField('Ghi chú', validators=[Optional(), Length(max=500, message="Ghi chú không được vượt quá 500 ký tự")]) + + password = PasswordField('Mật khẩu mới', validators=[Optional(), Length(min=6, message="Mật khẩu phải có ít nhất 6 ký tự")]) + confirm_password = PasswordField('Xác nhận mật khẩu', validators=[Optional(), EqualTo('password', message="Xác nhận mật khẩu không khớp")]) + + submit = SubmitField('Cập nhật') def __init__(self, *args, **kwargs): super(EditProfileForm, self).__init__(*args, **kwargs) - self.original_username = kwargs.get('obj', None) self.original_email = kwargs.get('obj', None) - def validate_username(self, username): - """Kiểm tra xem tên người dùng đã tồn tại chưa""" - if self.original_username and self.original_username.username == username.data: - return - user = User.query.filter_by(username=username.data).first() - if user: - raise ValidationError('Tên người dùng này đã được sử dụng. Vui lòng chọn tên khác.') - def validate_email(self, email): """Kiểm tra xem email đã tồn tại chưa""" if self.original_email and self.original_email.email == email.data: diff --git a/app/models/__init__.py b/app/models/__init__.py index 47a04f8..48524af 100644 --- a/app/models/__init__.py +++ b/app/models/__init__.py @@ -7,6 +7,7 @@ from .procedure import Procedure from .report import Report from .uploaded_file import UploadedFile from .dietitian import Dietitian, DietitianStatus +from .activity_log import ActivityLog # Import bất kỳ model bổ sung nào ở đây @@ -20,5 +21,6 @@ __all__ = [ 'Report', 'UploadedFile', 'Dietitian', - 'DietitianStatus' + 'DietitianStatus', + 'ActivityLog' ] \ No newline at end of file diff --git a/app/models/activity_log.py b/app/models/activity_log.py new file mode 100644 index 0000000..d9a36bc --- /dev/null +++ b/app/models/activity_log.py @@ -0,0 +1,20 @@ +from datetime import datetime +from .. import db +from sqlalchemy import Column, Integer, String, Text, DateTime, ForeignKey +from sqlalchemy.orm import relationship + +class ActivityLog(db.Model): + """Model để lưu trữ lịch sử hoạt động của người dùng.""" + __tablename__ = 'activity_logs' + + id = Column(Integer, primary_key=True) + user_id = Column(Integer, ForeignKey('users.userID'), nullable=False) + action = Column(String(255), nullable=False) # Mô tả hành động (e.g., 'Logged In', 'Created Patient') + details = Column(Text) # Chi tiết thêm về hành động (e.g., 'Patient ID: P-00001') + timestamp = Column(DateTime, default=datetime.utcnow) + + # Relationship back to User + user = relationship("User", back_populates="activities") + + def __repr__(self): + return f'<ActivityLog {self.id} by User {self.user_id} at {self.timestamp}>' \ No newline at end of file diff --git a/app/models/dietitian.py b/app/models/dietitian.py index 767cbd5..88c2555 100644 --- a/app/models/dietitian.py +++ b/app/models/dietitian.py @@ -26,7 +26,7 @@ class Dietitian(db.Model): created_at = Column(DateTime, default=datetime.utcnow) updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) - user = relationship("User", back_populates="dietitian_profile") + user = relationship("User", back_populates="dietitian") @property def fullName(self): @@ -38,5 +38,18 @@ class Dietitian(db.Model): """Return the dietitian ID in the format 'DT-XXXXX'.""" return f"DT-{self.dietitianID:05d}" + @property + def patient_count(self): + """Trả về số lượng bệnh nhân mà dietitian đang chăm sóc.""" + from app.models.patient import Patient + return Patient.query.filter_by(dietitian_id=self.dietitianID).count() + + 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: + self.status = DietitianStatus.UNAVAILABLE + else: + self.status = DietitianStatus.AVAILABLE + def __repr__(self): return f"<Dietitian {self.fullName}>" \ No newline at end of file diff --git a/app/models/patient.py b/app/models/patient.py index 706a1af..d47231f 100644 --- a/app/models/patient.py +++ b/app/models/patient.py @@ -24,7 +24,7 @@ class Patient(db.Model): status = Column(String(20), default='active', nullable=True) height = Column(Float, nullable=True) # Height in cm weight = Column(Float, nullable=True) # Weight in kg - blood_type = Column(String(5), nullable=True) + blood_type = Column(String(10), nullable=True) admission_date = Column(DateTime, nullable=True) # Timestamps diff --git a/app/models/referral.py b/app/models/referral.py index f576e91..d828018 100644 --- a/app/models/referral.py +++ b/app/models/referral.py @@ -49,4 +49,17 @@ class Referral(db.Model): else: delta = datetime.utcnow() - self.referralRequestedDateTime return delta.days - return None \ No newline at end of file + return None + + # Add a property to generate a descriptive name + @property + def referral_name(self): + """Generates a descriptive name for the referral.""" + if self.patient: # Check if patient relationship is loaded + patient_name = self.patient.full_name + else: + # Fallback if patient object not loaded, using patient_id which is always available + patient_name = f"Patient ID {self.patient_id}" + + date_str = self.referralRequestedDateTime.strftime('%Y-%m-%d') if self.referralRequestedDateTime else "Unknown Date" + return f"Referral for {patient_name} ({date_str})" \ No newline at end of file diff --git a/app/models/user.py b/app/models/user.py index 3a00cd2..5d52a70 100644 --- a/app/models/user.py +++ b/app/models/user.py @@ -3,36 +3,45 @@ from flask_login import UserMixin from .. import db, login_manager from sqlalchemy.sql import func from flask_bcrypt import Bcrypt -from sqlalchemy import Column, Integer, String, Boolean, DateTime, Enum +from sqlalchemy import Column, Integer, String, Boolean, DateTime, Enum, ForeignKey from sqlalchemy.orm import relationship bcrypt = Bcrypt() -class User(db.Model, UserMixin): - """User model for dietitians and administrators""" +class User(UserMixin, db.Model): + """User model""" __tablename__ = 'users' - + __table_args__ = {'extend_existing': True} + userID = Column('userID', Integer, primary_key=True) - username = Column(String(50), unique=True, nullable=False) email = Column(String(100), unique=True, nullable=False) - password_hash = Column(String(255), nullable=False) - role = Column(Enum('Admin', 'Dietitian'), default='Dietitian') - - # Relationship với Dietitian model - dietitian_profile = relationship("Dietitian", back_populates="user", uselist=False, cascade="all, delete-orphan") + password_hash = Column(String(128)) + firstName = Column(String(50), nullable=False) + lastName = Column(String(50), nullable=False) + role = Column(Enum('Admin', 'Dietitian', name='user_roles_new'), default='Dietitian', nullable=False) + is_active = Column(Boolean, default=True) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + last_login = Column(DateTime(timezone=True)) + # Relationships + dietitian = relationship("Dietitian", back_populates="user", uselist=False, cascade="all, delete-orphan") + activities = relationship("ActivityLog", back_populates="user", lazy='dynamic') + + def set_password(self, password): + self.password_hash = bcrypt.generate_password_hash(password).decode('utf-8') + + def check_password(self, password): + return bcrypt.check_password_hash(self.password_hash, password) + @property - def id(self): - """Trả về userID để đảm bảo tương thích khi code truy cập user.id""" - return self.userID - + def full_name(self): + """Return the user's full name.""" + return f"{self.firstName} {self.lastName}" + @property def is_admin(self): return self.role == 'Admin' - - def get_id(self): - return str(self.userID) - + @property def is_active(self): return True @@ -45,25 +54,19 @@ class User(db.Model, UserMixin): def is_anonymous(self): return False + def get_id(self): + """Return the primary key of the user for Flask-Login.""" + return str(self.userID) + def __repr__(self): - return f"User('{self.username}', '{self.email}')" - - @property - def full_name(self): - if self.dietitian_profile: - return f"{self.dietitian_profile.firstName} {self.dietitian_profile.lastName}" - return self.username + return f"User('{self.email}')" @property - def password(self): - raise AttributeError('password is not a readable attribute') - - @password.setter - def password(self, password): - self.password_hash = bcrypt.generate_password_hash(password).decode('utf-8') - - def verify_password(self, password): - return bcrypt.check_password_hash(self.password_hash, password) + def formattedID(self): + """Return the user ID in the format 'U-XXXXX' or 'AD-XXXXX'.""" + if self.role == 'Admin': + return f"AD-{self.userID:05d}" + return f"U-{self.userID:05d}" @login_manager.user_loader def load_user(user_id): diff --git a/app/routes/auth.py b/app/routes/auth.py index 799062c..6a1311c 100644 --- a/app/routes/auth.py +++ b/app/routes/auth.py @@ -4,9 +4,11 @@ from werkzeug.security import generate_password_hash, check_password_hash from datetime import datetime from app import db, bcrypt from app.models.user import User -from app.forms.auth_forms import LoginForm, RegisterForm, EditProfileForm, ChangePasswordForm, NotificationSettingsForm +from app.models.dietitian import Dietitian, DietitianStatus +from app.forms.auth_forms import LoginForm, EditProfileForm, ChangePasswordForm, NotificationSettingsForm 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 auth_bp = Blueprint('auth', __name__, url_prefix='/auth') @@ -21,24 +23,43 @@ def login(): if form.validate_on_submit(): user = User.query.filter_by(email=form.email.data).first() - # Sử dụng phương thức verify_password của model User thay vì truy cập trực tiếp - if user and user.verify_password(form.password.data): - # Đăng nhập người dùng - login_user(user, remember=form.remember.data) - next_page = request.args.get('next') - - flash('Đăng nhập thành công!', 'success') + if not user or not user.check_password(form.password.data): + flash('Email hoặc mật khẩu không chính xác.', 'error') + return render_template('login.html', form=form, title='Đăng nhập') + + # Kiểm tra role và dietitianID nếu là Dietitian + if user.role == 'Dietitian': + # Lấy Dietitian ID từ form và chuẩn hóa (viết hoa) + form_dietitian_id_raw = form.dietitianID.data + if not form_dietitian_id_raw: + flash('Vui lòng nhập Dietitian ID.', 'error') + return render_template('login.html', form=form, title='Đăng nhập') - # Chuyển hướng đến trang được yêu cầu hoặc trang chính - if next_page: - # Kiểm tra an toàn cho URL chuyển hướng (is_safe_url cần được định nghĩa hoặc import) - # if is_safe_url(next_page): - # return redirect(next_page) - # Tạm thời cho phép mọi next_page nếu không có is_safe_url - return redirect(next_page) - return redirect(url_for('dashboard.index')) - else: - flash('Email hoặc mật khẩu không chính xác. Vui lòng thử lại.', 'error') + form_dietitian_id = form_dietitian_id_raw.strip().upper() + + # Kiểm tra xem user có dietitian profile không + if not user.dietitian: + flash('Tài khoản Dietitian này chưa có hồ sơ được liên kết.', 'error') + return render_template('login.html', form=form, title='Đăng nhập') + + # Kiểm tra khớp Dietitian ID (đã chuẩn hóa) + if user.dietitian.formattedID.upper() != form_dietitian_id: + flash('Dietitian ID không khớp với tài khoản này.', 'error') + return render_template('login.html', form=form, title='Đăng nhập') + + # Nếu là Admin hoặc Dietitian đã xác thực ID thành công + login_user(user, remember=form.remember.data) + next_page = request.args.get('next') + flash('Đăng nhập thành công!', 'success') + + # Chuyển hướng đến trang được yêu cầu hoặc trang chính + if next_page: + # Kiểm tra an toàn cho URL chuyển hướng (is_safe_url cần được định nghĩa hoặc import) + # if is_safe_url(next_page): + # return redirect(next_page) + # Tạm thời cho phép mọi next_page nếu không có is_safe_url + return redirect(next_page) + return redirect(url_for('dashboard.index')) return render_template('login.html', form=form, title='Đăng nhập') @@ -50,46 +71,6 @@ def logout(): flash('Bạn đã đăng xuất thành công.', 'info') return redirect(url_for('auth.login')) -@auth_bp.route('/register', methods=['GET', 'POST']) -def register(): - """Xử lý đăng ký người dùng mới""" - if current_user.is_authenticated: - return redirect(url_for('dashboard.index')) - - form = RegisterForm() - if form.validate_on_submit(): - # Kiểm tra xem email đã được sử dụng chưa - existing_user = User.query.filter_by(email=form.email.data).first() - if existing_user: - flash('Email này đã được sử dụng. Vui lòng chọn email khác.', 'error') - return render_template('register.html', form=form) - - # Kiểm tra xem tên người dùng đã được sử dụng chưa - existing_username = User.query.filter_by(username=form.username.data).first() - if existing_username: - flash('Tên người dùng này đã được sử dụng. Vui lòng chọn tên khác.', 'error') - return render_template('register.html', form=form) - - # Tạo người dùng mới - user = User( - username=form.username.data, - email=form.email.data, - password=form.password.data, # Sử dụng setter của User model thay vì gán hash trực tiếp - role='Dietitian' # Mặc định là người dùng thông thường - ) - - # Lưu vào cơ sở dữ liệu - try: - db.session.add(user) - db.session.commit() - flash('Tài khoản của bạn đã được tạo! Bạn có thể đăng nhập ngay bây giờ.', 'success') - return redirect(url_for('auth.login')) - except Exception as e: - db.session.rollback() - flash(f'Đã xảy ra lỗi khi tạo tài khoản: {str(e)}', 'danger') - - return render_template('register.html', form=form, title='Đăng ký') - @auth_bp.route('/profile') @login_required def profile(): @@ -97,7 +78,7 @@ def profile(): # Đế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.id} + {"user_id": current_user.userID} ).scalar() return render_template( @@ -106,38 +87,49 @@ def profile(): upload_count=upload_count ) -@auth_bp.route('/edit-profile', methods=['GET', 'POST']) +@auth_bp.route('/profile/edit', methods=['GET', 'POST']) @login_required def edit_profile(): - """Xử lý chỉnh sửa thông tin hồ sơ người dùng""" + """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) - password_form = ChangePasswordForm() - notification_form = NotificationSettingsForm(obj=current_user) - if request.method == 'POST' and form.validate_on_submit(): - # Kiểm tra xem email mới đã tồn tại chưa (nếu email thay đổi) - if form.email.data != current_user.email: - existing_user = User.query.filter_by(email=form.email.data).first() - if existing_user: - flash('Email này đã được sử dụng bởi tài khoản khác.', 'danger') - return redirect(url_for('auth.edit_profile')) - - # Cập nhật thông tin người dùng - current_user.username = form.username.data + 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) + + # 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 - - db.session.commit() - - flash('Thông tin hồ sơ đã được cập nhật thành công.', 'success') - return redirect(url_for('auth.profile')) - - return render_template( - 'edit_profile.html', - title='Chỉnh sửa hồ sơ', - form=form, - password_form=password_form, - notification_form=notification_form - ) + current_user.phone = form.phone.data # Cập nhật phone nếu có + + # 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 + + 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 + except Exception as e: + db.session.rollback() + flash(f'Lỗi khi cập nhật hồ sơ: {str(e)}', '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) @auth_bp.route('/change-password', methods=['POST']) @login_required @@ -150,16 +142,16 @@ def change_password(): # notification_form = NotificationSettingsForm(obj=current_user) if password_form.validate_on_submit(): - # Sử dụng phương thức verify_password của model User - if not current_user.verify_password(password_form.current_password.data): + # Sử dụng phương thức check_password của model User + if not current_user.check_password(password_form.current_password.data): flash('Mật khẩu hiện tại không đúng.', 'danger') # Chuyển hướng về trang edit profile, có thể cần trả về cả các form khác # return redirect(url_for('auth.edit_profile', _anchor='password')) # Tạm thời redirect về profile: return redirect(url_for('auth.profile')) - # Sử dụng setter của model User để cập nhật và hash mật khẩu mới - current_user.password = password_form.new_password.data + # Sử dụng phương thức set_password để cập nhật mật khẩu mới + current_user.set_password(password_form.new_password.data) try: db.session.commit() @@ -267,16 +259,17 @@ def admin_edit_user(user_id): existing_user = User.query.filter_by(email=form.email.data).first() if existing_user: flash('Email này đã được sử dụng bởi tài khoản khác.', 'danger') - return redirect(url_for('auth.admin_edit_user', user_id=user.id)) + return redirect(url_for('auth.admin_edit_user', user_id=user.userID)) # Cập nhật thông tin người dùng - user.username = form.username.data + user.firstName = form.firstName.data + user.lastName = form.lastName.data user.email = form.email.data if hasattr(form, 'bio'): user.bio = form.bio.data db.session.commit() - flash(f'Thông tin của người dùng {user.username} đã được cập nhật thành công.', 'success') + flash(f'Thông tin của người dùng {user.firstName} {user.lastName} đã được cập nhật thành công.', 'success') return redirect(url_for('auth.admin_users')) return render_template( @@ -297,12 +290,12 @@ def admin_delete_user(user_id): user = User.query.get_or_404(user_id) # Ngăn chặn xóa chính mình - if user.id == current_user.id: + if user.userID == current_user.userID: flash('Bạn không thể xóa tài khoản của chính mình.', 'danger') return redirect(url_for('auth.admin_users')) # Lưu tên người dùng để hiển thị trong thông báo - username = user.username + username = f"{user.firstName} {user.lastName}" # Xóa người dùng db.session.delete(user) @@ -322,17 +315,17 @@ def admin_toggle_role(user_id): user = User.query.get_or_404(user_id) # Ngăn chặn hạ cấp chính mình - if user.id == current_user.id: + if user.userID == current_user.userID: flash('Bạn không thể thay đổi vai trò của chính mình.', 'danger') return redirect(url_for('auth.admin_users')) # Chuyển đổi vai trò if user.role == 'Admin': user.role = 'Dietitian' - message = f'{user.username} đã được hạ cấp xuống Dietitian.' + message = f'{user.firstName} {user.lastName} đã được hạ cấp xuống Dietitian.' else: user.role = 'Admin' - message = f'{user.username} đã được nâng cấp lên Admin.' + message = f'{user.firstName} {user.lastName} đã được nâng cấp lên Admin.' db.session.commit() flash(message, 'success') diff --git a/app/routes/dietitians.py b/app/routes/dietitians.py index 000b373..53a1a34 100644 --- a/app/routes/dietitians.py +++ b/app/routes/dietitians.py @@ -5,79 +5,79 @@ from app.models.dietitian import Dietitian, DietitianStatus from app.models.patient import Patient, Encounter from sqlalchemy import desc, or_, func, text from datetime import datetime +from app.models.user import User dietitians_bp = Blueprint('dietitians', __name__, url_prefix='/dietitians') @dietitians_bp.route('/') @login_required def index(): - """Hiển thị danh sách chuyên gia dinh dưỡng, ưu tiên user hiện tại""" - - search_query = request.args.get('search', '') - status_filter = request.args.get('status', '') + """Danh sách chuyên gia dinh dưỡng""" page = request.args.get('page', 1, type=int) - per_page = 10 + search = request.args.get('search', '') + status_filter = request.args.get('status', '') + sort_by = request.args.get('sort_by', 'dietitianID') + sort_dir = request.args.get('sort_dir', 'asc') - try: - # Lấy dietitian profile của user hiện tại (nếu có) - current_dietitian = None - if hasattr(current_user, 'dietitian_profile'): - current_dietitian = current_user.dietitian_profile - - # Nếu user có role là dietitian nhưng không có dietitian profile - if current_dietitian is None and current_user.role == 'Dietitian': - flash('Tài khoản của bạn có vai trò là chuyên gia dinh dưỡng nhưng chưa được liên kết với hồ sơ chuyên gia.', 'warning') - - # Xây dựng truy vấn cơ bản - base_query = Dietitian.query - - # Áp dụng bộ lọc tìm kiếm - if search_query: - search_term = f'%{search_query}%' - base_query = base_query.filter( - or_( - Dietitian.firstName.like(search_term), - Dietitian.lastName.like(search_term), - Dietitian.email.like(search_term), - # Cần chuyển đổi ID sang chuỗi để tìm kiếm LIKE - func.cast(Dietitian.dietitianID, db.String).like(search_term) - ) - ) - - # Áp dụng bộ lọc trạng thái - if status_filter and status_filter != 'all': + query = Dietitian.query + + query = query.outerjoin(User, User.userID == Dietitian.user_id).filter( + or_(Dietitian.user_id == None, User.role != 'Admin') + ) + + if search: + search_term = f'%{search}%' + search_filter = db.or_( + Dietitian.firstName.ilike(search_term), + Dietitian.lastName.ilike(search_term), + Dietitian.email.ilike(search_term), + Dietitian.specialization.ilike(search_term) + ) + if search.upper().startswith('DT-') and search[3:].isdigit(): try: - status_enum = DietitianStatus[status_filter.upper()] - base_query = base_query.filter(Dietitian.status == status_enum) - except KeyError: - flash(f'Trạng thái lọc không hợp lệ: {status_filter}', 'warning') - - # Loại trừ dietitian hiện tại khỏi truy vấn chính (nếu có) - if current_dietitian: - query_others = base_query.filter(Dietitian.dietitianID != current_dietitian.dietitianID) + search_id = int(search[3:]) + search_filter = db.or_(search_filter, Dietitian.dietitianID == search_id) + except ValueError: + pass + query = query.filter(search_filter) + + if status_filter and status_filter != 'all': + try: + status_enum = DietitianStatus[status_filter.upper()] + query = query.filter(Dietitian.status == status_enum) + except KeyError: + flash(f'Trạng thái lọc không hợp lệ: {status_filter}', 'warning') + pass + + if sort_dir == 'desc': + query = query.order_by(desc(getattr(Dietitian, sort_by))) + else: + query = query.order_by(getattr(Dietitian, sort_by)) + + dietitians_pagination = query.paginate(page=page, per_page=10) + + if current_user.is_authenticated: + if hasattr(current_user, 'dietitian') and current_user.dietitian: + current_dietitian = current_user.dietitian + other_dietitians = [d for d in dietitians_pagination.items if d.dietitianID != current_dietitian.dietitianID] else: - query_others = base_query - - # Sắp xếp theo lastName, firstName - query_others = query_others.order_by(Dietitian.lastName, Dietitian.firstName) - - # Thực hiện phân trang cho các dietitian khác - pagination = query_others.paginate(page=page, per_page=per_page, error_out=False) - other_dietitians = pagination.items - - except Exception as e: - flash(f'Lỗi khi truy vấn dữ liệu chuyên gia dinh dưỡng: {str(e)}', 'error') + current_dietitian = None + other_dietitians = dietitians_pagination.items + else: current_dietitian = None - other_dietitians = [] - pagination = None # Đảm bảo pagination có giá trị - - return render_template('dietitians/index.html', - current_dietitian=current_dietitian, - other_dietitians=other_dietitians, - pagination=pagination, # Truyền đối tượng pagination - search_query=search_query, - status_filter=status_filter, - status_options=DietitianStatus) + other_dietitians = dietitians_pagination.items + + return render_template( + 'dietitians/index.html', + dietitians=dietitians_pagination, + search_query=search, + status_filter=status_filter, + sort_by=sort_by, + sort_dir=sort_dir, + status_options=DietitianStatus, + current_dietitian=current_dietitian, + other_dietitians=other_dietitians + ) @dietitians_bp.route('/<int:id>') @login_required @@ -131,45 +131,62 @@ def new(): @login_required def edit(id): """Chỉnh sửa chuyên gia dinh dưỡng""" - # Sử dụng .first_or_404() để xử lý trường hợp không tìm thấy dietitian dietitian = Dietitian.query.filter_by(dietitianID=id).first_or_404() # Kiểm tra quyền: chỉ admin hoặc chính dietitian đó mới được sửa - if not current_user.is_admin and (not dietitian.user or dietitian.user_id != current_user.id): + if not current_user.is_admin and (not dietitian.user or dietitian.user_id != current_user.userID): flash('Bạn không có quyền chỉnh sửa thông tin này.', 'danger') return redirect(url_for('dietitians.index')) - + + # Nếu user đang sửa profile của chính mình, chuyển hướng đến edit_profile + if dietitian.user and dietitian.user_id == current_user.userID: + return redirect(url_for('auth.edit_profile')) + + # Nếu là admin đang sửa thông tin dietitian khác if request.method == 'POST': - # Cập nhật dữ liệu - dietitian.firstName = request.form.get('firstName', dietitian.firstName) - dietitian.lastName = request.form.get('lastName', dietitian.lastName) - dietitian.email = request.form.get('email', dietitian.email) - dietitian.phone = request.form.get('phone', dietitian.phone) - dietitian.specialization = request.form.get('specialization', dietitian.specialization) - dietitian.notes = request.form.get('notes', dietitian.notes) - status_str = request.form.get('status', dietitian.status.name) # Lấy name thay vì value - - # Chuyển đổi status string thành enum + # Lấy dữ liệu form + firstName = request.form.get('firstName', '').strip() + lastName = request.form.get('lastName', '').strip() + email = request.form.get('email', '').strip() + phone = request.form.get('phone', '').strip() + specialization = request.form.get('specialization', '').strip() + notes = request.form.get('notes', '').strip() + + # Kiểm tra dữ liệu + if not firstName or not lastName or not email: + flash('Họ, tên và email là bắt buộc.', 'danger') + return render_template('dietitians/edit_admin.html', dietitian=dietitian, status_options=DietitianStatus) + try: - status_upper = status_str.upper() - if status_upper not in [item.name for item in DietitianStatus]: - raise ValueError(f"Giá trị enum không hợp lệ: {status_upper}") - status_enum = DietitianStatus[status_upper] - dietitian.status = status_enum - except (ValueError, KeyError) as e: - flash(f'Trạng thái không hợp lệ: {status_str} - Lỗi: {str(e)}', 'error') - # Không return ngay, để user có thể sửa lại form - else: # Chỉ commit nếu không có lỗi trạng thái - try: - db.session.commit() - flash('Cập nhật chuyên gia dinh dưỡng thành công!', 'success') - return redirect(url_for('dietitians.show', id=dietitian.dietitianID)) - except Exception as e: - db.session.rollback() - flash(f'Lỗi khi cập nhật: {str(e)}', 'error') - - # Truyền cả dietitian và status_options vào template - return render_template('dietitians/edit.html', dietitian=dietitian, status_options=DietitianStatus) + # Cập nhật thông tin dietitian + dietitian.firstName = firstName + dietitian.lastName = lastName + dietitian.email = email + dietitian.phone = phone + dietitian.specialization = specialization + dietitian.notes = notes + # Trạng thái không cho sửa trực tiếp ở đây + + # Đồng bộ ngược lại User nếu có thay đổi email, phone (tùy chọn) + if dietitian.user: + dietitian.user.firstName = dietitian.firstName + dietitian.user.lastName = dietitian.lastName + dietitian.user.email = dietitian.email + dietitian.user.phone = dietitian.phone + + db.session.commit() + flash('Cập nhật thông tin thành công!', 'success') + return redirect(url_for('dietitians.show', id=dietitian.dietitianID)) + except Exception as e: + db.session.rollback() + flash(f'Có lỗi xảy ra: {str(e)}', 'danger') + # Render lại form với dietitian object + return render_template('dietitians/edit_admin.html', dietitian=dietitian, status_options=DietitianStatus) + + # GET request: hiển thị form chỉnh sửa (dùng template riêng cho admin sửa) + # Lưu ý: Cần tạo template edit_admin.html hoặc điều chỉnh edit.html cũ + # Sử dụng một template riêng như edit_admin.html sẽ rõ ràng hơn + return render_template('dietitians/edit_admin.html', dietitian=dietitian, status_options=DietitianStatus) @dietitians_bp.route('/<int:id>/delete', methods=['POST']) @login_required @@ -254,81 +271,4 @@ def unassign_patient(id, patient_id): except Exception as e: db.session.rollback() flash(f'Đã xảy ra lỗi: {str(e)}', 'error') - return redirect(url_for('dietitians.show', id=id)) - -@dietitians_bp.route('/link_profile', methods=['GET', 'POST']) -@login_required -def link_profile(): - """Liên kết tài khoản người dùng với hồ sơ dietitian""" - # Chỉ cho phép user có vai trò dietitian sử dụng tính năng này - if current_user.role != 'Dietitian': - flash('Chỉ người dùng có vai trò chuyên gia dinh dưỡng mới có thể liên kết hồ sơ.', 'error') - return redirect(url_for('dietitians.index')) - - # Kiểm tra nếu user đã có dietitian_profile - if current_user.dietitian_profile: - flash('Tài khoản của bạn đã được liên kết với hồ sơ chuyên gia dinh dưỡng.', 'info') - return redirect(url_for('dietitians.show', id=current_user.dietitian_profile.dietitianID)) - - if request.method == 'POST': - action = request.form.get('action') - - if action == 'create_new': - # Tạo mới hồ sơ dietitian - firstName = request.form.get('firstName') - lastName = request.form.get('lastName') - email = request.form.get('email', current_user.email) - phone = request.form.get('phone') - specialization = request.form.get('specialization') - - # Tạo đối tượng dietitian mới - dietitian = Dietitian( - firstName=firstName, - lastName=lastName, - email=email, - phone=phone, - specialization=specialization, - status=DietitianStatus.AVAILABLE, - user_id=current_user.userID # Liên kết với user hiện tại - ) - - try: - db.session.add(dietitian) - db.session.commit() - flash('Đã tạo và liên kết hồ sơ chuyên gia dinh dưỡng thành công!', 'success') - return redirect(url_for('dietitians.show', id=dietitian.dietitianID)) - except Exception as e: - db.session.rollback() - flash(f'Lỗi khi tạo hồ sơ: {str(e)}', 'error') - - elif action == 'link_existing': - # Liên kết với hồ sơ hiện có - dietitian_id = request.form.get('dietitian_id') - if not dietitian_id: - flash('Vui lòng chọn một hồ sơ để liên kết.', 'error') - return redirect(url_for('dietitians.link_profile')) - - try: - dietitian = Dietitian.query.get(int(dietitian_id)) - if not dietitian: - flash('Không tìm thấy hồ sơ chuyên gia dinh dưỡng.', 'error') - return redirect(url_for('dietitians.link_profile')) - - # Kiểm tra xem hồ sơ đã được liên kết với user khác chưa - if dietitian.user_id and dietitian.user_id != current_user.userID: - flash('Hồ sơ này đã được liên kết với một tài khoản khác.', 'error') - return redirect(url_for('dietitians.link_profile')) - - # Liên kết hồ sơ với user hiện tại - dietitian.user_id = current_user.userID - db.session.commit() - flash('Đã liên kết hồ sơ chuyên gia dinh dưỡng thành công!', 'success') - return redirect(url_for('dietitians.show', id=dietitian.dietitianID)) - except Exception as e: - db.session.rollback() - flash(f'Lỗi khi liên kết hồ sơ: {str(e)}', 'error') - - # Lấy danh sách dietitian chưa được liên kết với user nào - unlinked_dietitians = Dietitian.query.filter(Dietitian.user_id.is_(None)).all() - - return render_template('dietitians/link_profile.html', unlinked_dietitians=unlinked_dietitians) \ No newline at end of file + return redirect(url_for('dietitians.show', id=id)) \ No newline at end of file diff --git a/app/templates/auth_base.html b/app/templates/auth_base.html index 8ee31fe..d61f312 100644 --- a/app/templates/auth_base.html +++ b/app/templates/auth_base.html @@ -33,10 +33,11 @@ } .auth-container { - max-width: 600px; + max-width: 650px; + min-width: 550px; width: 100%; margin: 2rem auto; - padding: 2.5rem; + padding: 2.5rem 3.5rem; background-color: white; border-radius: 1rem; box-shadow: 0 10px 25px rgba(0, 0, 0, 0.1); diff --git a/app/templates/base.html b/app/templates/base.html index a27125f..f27b1f0 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -117,9 +117,10 @@ .sidebar-toggle { position: absolute; - bottom: 20px; + top: 20px; right: -15px; - background: white; + background: #2563eb; + color: white; width: 30px; height: 30px; border-radius: 50%; @@ -197,19 +198,32 @@ justify-content: center; } + /* Dropdown menus */ .dropdown-menu { opacity: 0; visibility: hidden; transform: translateY(10px); - transition: all 0.3s ease; + /* Delay hiding visibility until opacity transition is done */ + transition: opacity 0.3s ease, transform 0.3s ease, visibility 0s linear 0.3s; + pointer-events: none; /* Prevent interaction when hidden */ } - .dropdown:hover .dropdown-menu { + /* Visible state for dropdown menus */ + .dropdown-menu.visible { opacity: 1; visibility: visible; transform: translateY(0); + /* Show visibility immediately when becoming visible */ + transition: opacity 0.3s ease, transform 0.3s ease, visibility 0s linear 0s; + pointer-events: auto; /* Allow interaction when visible */ } + /* Remove hover effect for dropdowns */ + /* .dropdown.profile-dropdown:hover .dropdown-menu { ... } */ + + /* Remove :not(.hidden) rule as we use .visible now */ + /* .dropdown-menu:not(.hidden) { ... } */ + /* Flash messages */ .alert-message { transition: all 0.5s ease; @@ -237,7 +251,7 @@ <div class="flex items-center justify-between mb-10"> <h2 class="text-xl font-bold animate-pulse-slow">CCU HTM</h2> <div class="sidebar-toggle" id="sidebarToggle"> - <i class="fas fa-chevron-left"></i> + <i class="fas fa-chevron-left text-blue-600"></i> </div> </div> @@ -278,7 +292,6 @@ </ul> </nav> </div> - {% endif %} <!-- Content with Header --> <div id="content-wrapper" class="content-wrapper flex-1 overflow-auto {% if not current_user.is_authenticated %}w-full ml-0{% endif %}"> @@ -294,14 +307,15 @@ <div class="flex items-center space-x-4"> <!-- Notifications --> <div class="relative dropdown"> - <button class="p-2 text-gray-600 hover:text-blue-600 focus:outline-none"> + <button id="notificationButton" class="p-2 text-gray-600 hover:text-blue-600 focus:outline-none"> <i class="fas fa-bell text-lg"></i> <span class="notification-badge">3</span> </button> - <div class="dropdown-menu absolute right-0 mt-2 w-72 bg-white rounded-lg shadow-lg z-50 py-2"> - <div class="px-4 py-2 border-b border-gray-200"> - <h3 class="font-bold text-gray-800">Notifications</h3> + <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> </div> <div class="max-h-64 overflow-y-auto"> @@ -311,7 +325,7 @@ <i class="fas fa-info-circle text-blue-600"></i> </div> <div class="ml-3"> - <p class="text-sm font-medium text-gray-900">Welcome {{ current_user.username }}</p> + <p class="text-sm font-medium text-gray-900">Welcome {{ current_user.full_name }}</p> <p class="text-xs text-gray-500">Just now</p> </div> </div> @@ -346,26 +360,30 @@ </div> </div> - <!-- User Profile --> - <div class="relative dropdown"> - <button class="flex items-center text-gray-700 hover:text-blue-600 focus:outline-none"> - <span class="mr-2 hidden md:block">{{ current_user.username }}</span> - <div class="h-8 w-8 rounded-full bg-blue-500 flex items-center justify-center text-white"> - <i class="fas fa-user"></i> + <!-- User profile dropdown --> + <div class="relative inline-block text-left dropdown profile-dropdown"> + <button class="flex items-center justify-center text-sm font-medium text-gray-700 hover:text-gray-900" type="button" id="dropdownMenuButton"> + <span class="mr-2">{{ current_user.firstName }} {{ current_user.lastName }}</span> + <div class="w-8 h-8 rounded-full overflow-hidden bg-blue-500 flex items-center justify-center"> + {% if current_user.profile_image %} + <img src="{{ current_user.profile_image }}" alt="Avatar" class="w-full h-full object-cover"> + {% else %} + <span class="text-white text-sm font-bold">{{ current_user.firstName[0] }}{{ current_user.lastName[0] }}</span> + {% endif %} </div> </button> - - <div class="dropdown-menu absolute right-0 mt-2 w-48 bg-white rounded-lg shadow-lg z-50 py-2"> - <a href="{{ url_for('auth.profile') }}" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100"> - <i class="fas fa-user-circle mr-2"></i> My Profile - </a> - <a href="{{ url_for('auth.edit_profile') }}" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100"> - <i class="fas fa-cog mr-2"></i> Settings - </a> - <div class="border-t border-gray-200 my-1"></div> - <a href="{{ url_for('auth.logout') }}" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100"> - <i class="fas fa-sign-out-alt mr-2"></i> Logout - </a> + <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ơ + </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ơ + </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 + </a> + </div> </div> </div> </div> @@ -414,6 +432,7 @@ </div> </div> </div> + {% endif %} </div> <!-- Footer --> @@ -462,6 +481,64 @@ } }); } + + // User dropdown menu functionality + const dropdownButton = document.getElementById('dropdownMenuButton'); + const dropdownMenu = document.getElementById('userDropdownMenu'); // Use specific ID + + // Notification dropdown menu functionality + const notificationButton = document.getElementById('notificationButton'); + const notificationMenu = document.getElementById('notificationMenu'); + + // Function to close all dropdowns + function closeAllDropdowns() { + if (dropdownMenu) dropdownMenu.classList.remove('visible'); + if (notificationMenu) notificationMenu.classList.remove('visible'); + } + + // Toggle user dropdown + if (dropdownButton && dropdownMenu) { + dropdownButton.addEventListener('click', function(event) { + event.stopPropagation(); + const isVisible = dropdownMenu.classList.contains('visible'); + closeAllDropdowns(); // Close others first + if (!isVisible) { + dropdownMenu.classList.add('visible'); + } + }); + } + + // Toggle notification dropdown + if (notificationButton && notificationMenu) { + notificationButton.addEventListener('click', function(event) { + event.stopPropagation(); + const isVisible = notificationMenu.classList.contains('visible'); + closeAllDropdowns(); // Close others first + if (!isVisible) { + notificationMenu.classList.add('visible'); + } + }); + } + + // Close dropdowns when clicking outside + document.addEventListener('click', function(event) { + // Check if the click is outside both dropdowns and their buttons + const isOutsideUser = dropdownButton && !dropdownButton.contains(event.target) && dropdownMenu && !dropdownMenu.contains(event.target); + const isOutsideNotification = notificationButton && !notificationButton.contains(event.target) && notificationMenu && !notificationMenu.contains(event.target); + + // Only close if the click is outside the currently open dropdown + if ((dropdownMenu && dropdownMenu.classList.contains('visible') && isOutsideUser) || + (notificationMenu && notificationMenu.classList.contains('visible') && isOutsideNotification)) { + // Check again to be sure, might not be needed but safer + if (isOutsideUser && isOutsideNotification) { + closeAllDropdowns(); + } else if (dropdownMenu.classList.contains('visible') && isOutsideUser) { + closeAllDropdowns(); // Close all if clicking outside user dropdown + } else if (notificationMenu.classList.contains('visible') && isOutsideNotification) { + closeAllDropdowns(); // Close all if clicking outside notification dropdown + } + } + }); }); </script> diff --git a/app/templates/dietitians/edit.html b/app/templates/dietitians/edit.html deleted file mode 100644 index 3d248a1..0000000 --- a/app/templates/dietitians/edit.html +++ /dev/null @@ -1,104 +0,0 @@ -{% extends "base.html" %} - -{% block title %}Chỉnh sửa chuyên gia dinh dưỡng - CCU HTM{% endblock %} - -{% block header %}Chỉnh sửa thông tin chuyên gia dinh dưỡng{% 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> - <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('dietitians.index') }}" class="ml-1 text-sm font-medium text-gray-700 hover:text-blue-600 md:ml-2 transition duration-200">Chuyên gia dinh dưỡng</a> - </div> - </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('dietitians.show', id=dietitian.dietitianID) }}" class="ml-1 text-sm font-medium text-gray-700 hover:text-blue-600 md:ml-2 transition duration-200">{{ dietitian.fullName }}</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</span> - </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 chuyên gia dinh dưỡng</h3> - <p class="mt-1 max-w-2xl text-sm text-gray-500">Cập nhật thông tin chi tiết cho chuyên gia dinh dưỡng.</p> - </div> - - <form method="POST" action="{{ url_for('dietitians.update', id=dietitian.dietitianID) }}" class="px-4 py-5 sm:p-6"> - <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> - <input type="text" name="firstName" id="firstName" value="{{ dietitian.firstName }}" class="mt-1 focus:ring-primary-500 focus:border-primary-500 block w-full shadow-sm sm:text-sm border-gray-300 rounded-md" required> - </div> - - <div class="col-span-6 sm:col-span-3"> - <label for="lastName" class="block text-sm font-medium text-gray-700">Họ</label> - <input type="text" name="lastName" id="lastName" value="{{ dietitian.lastName }}" class="mt-1 focus:ring-primary-500 focus:border-primary-500 block w-full shadow-sm sm:text-sm border-gray-300 rounded-md" required> - </div> - - <div class="col-span-6 sm:col-span-3"> - <label for="email" class="block text-sm font-medium text-gray-700">Email</label> - <input type="email" name="email" id="email" value="{{ dietitian.email }}" class="mt-1 focus:ring-primary-500 focus:border-primary-500 block w-full shadow-sm sm:text-sm border-gray-300 rounded-md" required> - </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> - <input type="text" name="phone" id="phone" value="{{ dietitian.phone }}" class="mt-1 focus:ring-primary-500 focus:border-primary-500 block w-full shadow-sm sm:text-sm border-gray-300 rounded-md"> - </div> - - <div class="col-span-6"> - <label for="specialization" class="block text-sm font-medium text-gray-700">Chuyên môn</label> - <input type="text" name="specialization" id="specialization" value="{{ dietitian.specialization }}" class="mt-1 focus:ring-primary-500 focus:border-primary-500 block w-full shadow-sm sm:text-sm border-gray-300 rounded-md"> - </div> - - <div class="col-span-6"> - <label for="notes" class="block text-sm font-medium text-gray-700">Ghi chú</label> - <textarea name="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">{{ dietitian.notes }}</textarea> - </div> - - <div class="col-span-6"> - <label for="status" class="block text-sm font-medium text-gray-700">Trạng thái</label> - <select id="status" name="status" class="mt-1 block w-full py-2 px-3 border border-gray-300 bg-white rounded-md shadow-sm focus:outline-none focus:ring-primary-500 focus:border-primary-500 sm:text-sm"> - <option value="available" {% if dietitian.status.value == 'available' %}selected{% endif %}>Khả dụng</option> - <option value="unavailable" {% if dietitian.status.value == 'unavailable' %}selected{% endif %}>Không khả dụng</option> - <option value="on_leave" {% if dietitian.status.value == 'on_leave' %}selected{% endif %}>Nghỉ phép</option> - </select> - </div> - </div> - - <div class="pt-5 mt-4 border-t border-gray-200"> - <div class="flex justify-end"> - <a href="{{ url_for('dietitians.show', id=dietitian.dietitianID) }}" 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 transition duration-200"> - 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 transition duration-200"> - Cập nhật - </button> - </div> - </div> - </form> - </div> - </div> -</div> -{% endblock %} \ No newline at end of file diff --git a/app/templates/dietitians/index.html b/app/templates/dietitians/index.html index 70b3b35..3857a07 100644 --- a/app/templates/dietitians/index.html +++ b/app/templates/dietitians/index.html @@ -55,7 +55,7 @@ </select> </div> <div class="flex space-x-2"> - <button type="submit" class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-primary-600 hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 transition duration-200 w-full justify-center"> + <button type="submit" 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 transition duration-200 w-full justify-center"> Lọc </button> <a href="{{ url_for('dietitians.index') }}" class="inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md shadow-sm text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 transition duration-200 w-full justify-center"> @@ -75,41 +75,39 @@ <div class="border-t border-gray-200 px-4 py-5 sm:p-0"> <div class="overflow-x-auto"> <table class="min-w-full divide-y divide-gray-200"> - <thead class="bg-gray-50"> + <thead class="bg-gray-100"> <tr> - <th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Chuyên gia</th> - <th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Liên hệ</th> - <th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Chuyên môn</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-left text-xs font-semibold text-gray-600 uppercase tracking-wider">Chuyên gia</th> + <th scope="col" class="px-6 py-3 text-left text-xs font-semibold text-gray-600 uppercase tracking-wider">Liên hệ</th> + <th scope="col" class="px-6 py-3 text-left text-xs font-semibold text-gray-600 uppercase tracking-wider">Chuyên môn</th> + <th scope="col" class="px-6 py-3 text-left text-xs font-semibold text-gray-600 uppercase tracking-wider">Trạng thái</th> + <th scope="col" class="px-6 py-3 text-right text-xs font-semibold text-gray-600 uppercase tracking-wider">Thao tác</th> </tr> </thead> <tbody class="bg-white divide-y divide-gray-200"> - <tr> + <tr class="bg-blue-50 hover:bg-blue-100 transition duration-150"> <td class="px-6 py-4 whitespace-nowrap"> <div class="flex items-center"> - <!-- Avatar placeholder --> <div class="flex-shrink-0 h-10 w-10"> - <span class="inline-flex items-center justify-center h-10 w-10 rounded-full bg-blue-100"> - <span class="font-medium text-blue-700">{{ current_dietitian.firstName[0] }}{{ current_dietitian.lastName[0] }}</span> + <span class="inline-flex items-center justify-center h-10 w-10 rounded-full bg-blue-200 text-blue-800 font-bold"> + {{ current_user.firstName[0] }}{{ current_user.lastName[0] }} </span> </div> <div class="ml-4"> - <div class="text-sm font-medium text-gray-900">{{ current_dietitian.fullName }}</div> - <div class="text-sm text-gray-500">{{ current_dietitian.formattedID }}</div> + <div class="text-sm font-semibold text-gray-900">{{ current_user.firstName }} {{ current_user.lastName }}</div> + <div class="text-sm text-blue-700">{{ current_dietitian.formattedID }}</div> </div> </div> </td> <td class="px-6 py-4 whitespace-nowrap"> - <div class="text-sm text-gray-900">{{ current_dietitian.email or 'N/A' }}</div> - <div class="text-sm text-gray-500">{{ current_dietitian.phone or 'N/A' }}</div> + <div class="text-sm font-medium text-gray-800">{{ current_user.email or 'N/A' }}</div> + <div class="text-sm text-gray-600">{{ current_user.phone or 'N/A' }}</div> </td> - <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{{ current_dietitian.specialization or 'N/A' }}</td> + <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-700">{{ current_dietitian.specialization or 'N/A' }}</td> <td class="px-6 py-4 whitespace-nowrap">{{ status_badge(current_dietitian.status.value) }}</td> <td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium"> - <a href="{{ url_for('dietitians.show', id=current_dietitian.dietitianID) }}" class="text-primary-600 hover:text-primary-900 mr-3 transition duration-200">Chi tiết</a> - <a href="{{ url_for('dietitians.edit', id=current_dietitian.dietitianID) }}" class="text-indigo-600 hover:text-indigo-900 mr-3 transition duration-200">Sửa</a> - <!-- Chỉ Admin mới thấy nút xóa --> + <a href="{{ url_for('dietitians.show', id=current_dietitian.dietitianID) }}" class="text-blue-600 hover:text-blue-800 mr-3 transition duration-200">Chi tiết</a> + <a href="{{ url_for('auth.edit_profile') }}" class="text-indigo-600 hover:text-indigo-900 mr-3 transition duration-200">Sửa</a> {% if current_user.is_admin %} <form action="{{ url_for('dietitians.delete', id=current_dietitian.dietitianID) }}" method="POST" class="inline-block" onsubmit="return confirm('Bạn có chắc chắn muốn xóa chuyên gia dinh dưỡng này?');"> <button type="submit" class="text-red-600 hover:text-red-900 transition duration-200">Xóa</button> @@ -122,31 +120,6 @@ </div> </div> </div> - {% elif current_user.role == 'Dietitian' %} - <div class="mb-8 bg-gradient-to-r from-yellow-50 to-amber-50 shadow sm:rounded-lg border border-amber-200"> - <div class="px-4 py-5 sm:px-6"> - <h3 class="text-lg leading-6 font-medium text-gray-900">Chưa có hồ sơ chuyên gia dinh dưỡng</h3> - </div> - <div class="border-t border-gray-200 px-4 py-5"> - <p class="text-gray-700 mb-4">Tài khoản của bạn có vai trò là chuyên gia dinh dưỡng nhưng chưa được liên kết với hồ sơ chuyên gia.</p> - <div class="flex space-x-4"> - <a href="{{ url_for('dietitians.link_profile') }}" class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-primary-600 hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 transition duration-200"> - <svg class="-ml-1 mr-2 h-5 w-5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true"> - <path fill-rule="evenodd" d="M10 3a1 1 0 011 1v5h5a1 1 0 110 2h-5v5a1 1 0 11-2 0v-5H4a1 1 0 110-2h5V4a1 1 0 011-1z" clip-rule="evenodd" /> - </svg> - Tạo hoặc liên kết hồ sơ - </a> - {% if current_user.is_admin %} - <a href="{{ url_for('dietitians.new') }}" class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-indigo-600 hover:bg-indigo-700 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" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true"> - <path fill-rule="evenodd" d="M10 3a1 1 0 011 1v5h5a1 1 0 110 2h-5v5a1 1 0 11-2 0v-5H4a1 1 0 110-2h5V4a1 1 0 011-1z" clip-rule="evenodd" /> - </svg> - Tạo hồ sơ mới (Admin) - </a> - {% endif %} - </div> - </div> - </div> {% endif %} <!-- Bảng Các Dietitian Khác --> @@ -179,7 +152,7 @@ </span> </div> <div class="ml-4"> - <div class="text-sm font-medium text-gray-900">{{ dietitian.fullName }}</div> + <div class="text-sm font-medium text-gray-900">{{ dietitian.firstName }} {{ dietitian.lastName }}</div> <div class="text-sm text-gray-500">{{ dietitian.formattedID }}</div> </div> </div> @@ -213,9 +186,9 @@ </div> <!-- Phân trang --> - {% if pagination and pagination.pages > 1 %} + {% if dietitians and dietitians.pages > 1 %} <div class="bg-white px-4 py-3 flex items-center justify-between border-t border-gray-200 sm:px-6"> - {{ render_pagination(pagination, 'dietitians.index', search=search_query, status=status_filter) }} + {{ render_pagination(dietitians, 'dietitians.index', search=search_query, status=status_filter) }} </div> {% endif %} </div> diff --git a/app/templates/dietitians/link_profile.html b/app/templates/dietitians/link_profile.html deleted file mode 100644 index 3edfae8..0000000 --- a/app/templates/dietitians/link_profile.html +++ /dev/null @@ -1,121 +0,0 @@ -{% extends "base.html" %} - -{% block title %}Liên kết hồ sơ Chuyên gia dinh dưỡng - CCU HTM{% endblock %} - -{% block header %}Liên kết hồ sơ Chuyên gia dinh dưỡng{% 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('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> - 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('dietitians.index') }}" class="ml-1 text-sm font-medium text-gray-700 hover:text-primary-600 md:ml-2 transition duration-200">Chuyên gia dinh dưỡng</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">Liên kết hồ sơ</span> - </div> - </li> - </ol> - </nav> - - <div class="flex justify-between items-center mb-6"> - <h1 class="text-2xl font-semibold text-gray-900">Liên kết hồ sơ Chuyên gia dinh dưỡng</h1> - </div> - - <div class="bg-white shadow rounded-lg overflow-hidden mb-6"> - <div class="px-6 py-5 border-b border-gray-200"> - <h2 class="text-lg font-medium text-gray-900">Tạo hồ sơ mới</h2> - <p class="mt-1 text-sm text-gray-600">Tạo và liên kết hồ sơ chuyên gia dinh dưỡng mới với tài khoản của bạn.</p> - </div> - <div class="px-6 py-4"> - <form method="POST" action="{{ url_for('dietitians.link_profile') }}"> - <input type="hidden" name="action" value="create_new"> - - <div class="grid grid-cols-1 md:grid-cols-2 gap-6 mb-4"> - <div> - <label for="firstName" class="block text-sm font-medium text-gray-700">Tên</label> - <input type="text" name="firstName" id="firstName" required class="mt-1 focus:ring-primary-500 focus:border-primary-500 block w-full shadow-sm sm:text-sm border-gray-300 rounded-md"> - </div> - <div> - <label for="lastName" class="block text-sm font-medium text-gray-700">Họ</label> - <input type="text" name="lastName" id="lastName" required class="mt-1 focus:ring-primary-500 focus:border-primary-500 block w-full shadow-sm sm:text-sm border-gray-300 rounded-md"> - </div> - </div> - - <div class="grid grid-cols-1 md:grid-cols-2 gap-6 mb-4"> - <div> - <label for="email" class="block text-sm font-medium text-gray-700">Email</label> - <input type="email" name="email" id="email" value="{{ current_user.email }}" class="mt-1 focus:ring-primary-500 focus:border-primary-500 block w-full shadow-sm sm:text-sm border-gray-300 rounded-md"> - </div> - <div> - <label for="phone" class="block text-sm font-medium text-gray-700">Số điện thoại</label> - <input type="text" name="phone" id="phone" class="mt-1 focus:ring-primary-500 focus:border-primary-500 block w-full shadow-sm sm:text-sm border-gray-300 rounded-md"> - </div> - </div> - - <div class="mb-4"> - <label for="specialization" class="block text-sm font-medium text-gray-700">Chuyên môn</label> - <input type="text" name="specialization" id="specialization" class="mt-1 focus:ring-primary-500 focus:border-primary-500 block w-full shadow-sm sm:text-sm border-gray-300 rounded-md"> - </div> - - <div class="flex justify-end"> - <button type="submit" class="inline-flex items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-primary-600 hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500"> - Tạo hồ sơ - </button> - </div> - </form> - </div> - </div> - - {% if unlinked_dietitians %} - <div class="bg-white shadow rounded-lg overflow-hidden"> - <div class="px-6 py-5 border-b border-gray-200"> - <h2 class="text-lg font-medium text-gray-900">Liên kết với hồ sơ có sẵn</h2> - <p class="mt-1 text-sm text-gray-600">Liên kết tài khoản của bạn với một hồ sơ chuyên gia dinh dưỡng đã có sẵn trong hệ thống.</p> - </div> - <div class="px-6 py-4"> - <form method="POST" action="{{ url_for('dietitians.link_profile') }}"> - <input type="hidden" name="action" value="link_existing"> - - <div class="mb-4"> - <label for="dietitian_id" class="block text-sm font-medium text-gray-700">Chọn hồ sơ</label> - <select name="dietitian_id" id="dietitian_id" required class="mt-1 block w-full py-2 px-3 border border-gray-300 bg-white rounded-md shadow-sm focus:outline-none focus:ring-primary-500 focus:border-primary-500 sm:text-sm"> - <option value="">-- Chọn hồ sơ --</option> - {% for d in unlinked_dietitians %} - <option value="{{ d.dietitianID }}">{{ d.fullName }} ({{ d.formattedID }})</option> - {% endfor %} - </select> - </div> - - <div class="flex justify-end"> - <button type="submit" class="inline-flex items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"> - Liên kết hồ sơ - </button> - </div> - </form> - </div> - </div> - {% else %} - <div class="bg-white shadow rounded-lg overflow-hidden"> - <div class="px-6 py-5 border-b border-gray-200"> - <h2 class="text-lg font-medium text-gray-900">Liên kết với hồ sơ có sẵn</h2> - </div> - <div class="px-6 py-4"> - <p class="text-gray-600">Hiện không có hồ sơ chuyên gia dinh dưỡng nào chưa được liên kết trong hệ thống.</p> - </div> - </div> - {% endif %} -</div> -{% endblock %} \ No newline at end of file diff --git a/app/templates/dietitians/show.html b/app/templates/dietitians/show.html index 2a6af58..24d80cd 100644 --- a/app/templates/dietitians/show.html +++ b/app/templates/dietitians/show.html @@ -1,6 +1,7 @@ {% extends "base.html" %} +{% from "_macros.html" import status_badge %} -{% block title %}{{ dietitian.firstName }} {{ dietitian.lastName }} - CCU HTM{% endblock %} +{% block title %}{{ dietitian.user.firstName if dietitian.user else dietitian.firstName }} {{ dietitian.user.lastName if dietitian.user else dietitian.lastName }} - CCU HTM{% endblock %} {% block header %}Thông tin chi tiết chuyên gia dinh dưỡng{% endblock %} @@ -25,7 +26,7 @@ <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">{{ dietitian.firstName }} {{ dietitian.lastName }}</span> + <span class="ml-1 text-sm font-medium text-gray-500 md:ml-2">{{ dietitian.user.firstName if dietitian.user else dietitian.firstName }} {{ dietitian.user.lastName if dietitian.user else dietitian.lastName }}</span> </div> </li> </ol> @@ -40,55 +41,61 @@ <p class="mt-1 max-w-2xl text-sm text-gray-500">Chi tiết thông tin cá nhân và liên hệ.</p> </div> <div class="flex space-x-3"> + {% if current_user.is_admin or (dietitian.user and dietitian.user.userID == current_user.userID) %} <a href="{{ url_for('dietitians.edit', id=dietitian.dietitianID) }}" class="inline-flex items-center px-3 py-2 border border-gray-300 shadow-sm text-sm leading-4 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-primary-500 transition duration-200"> <svg class="mr-2 -ml-1 h-4 w-4" 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> Chỉnh sửa </a> - <a href="{{ url_for('dietitians.delete', id=dietitian.dietitianID) }}" class="inline-flex items-center px-3 py-2 border border-transparent text-sm leading-4 font-medium rounded-md text-white bg-red-600 hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500 transition duration-200" onclick="return confirm('Bạn có chắc chắn muốn xóa chuyên gia dinh dưỡng này?');"> - <svg class="mr-2 -ml-1 h-4 w-4" 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="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" /> - </svg> - Xóa - </a> + {% endif %} + {% if current_user.is_admin %} + <form action="{{ url_for('dietitians.delete', id=dietitian.dietitianID) }}" method="POST" onsubmit="return confirm('Bạn có chắc chắn muốn xóa chuyên gia dinh dưỡng này?');" class="inline-block"> + <button type="submit" class="inline-flex items-center px-3 py-2 border border-transparent text-sm leading-4 font-medium rounded-md text-white bg-red-600 hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500 transition duration-200"> + <svg class="mr-2 -ml-1 h-4 w-4" 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="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" /> + </svg> + Xóa + </button> + </form> + {% endif %} </div> </div> <div class="border-t border-gray-200"> <dl> <div class="bg-gray-50 px-4 py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6"> <dt class="text-sm font-medium text-gray-500">Họ và tên</dt> - <dd class="mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2">{{ dietitian.firstName }} {{ dietitian.lastName }}</dd> + <dd class="mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2">{{ dietitian.user.firstName if dietitian.user else dietitian.firstName }} {{ dietitian.user.lastName if dietitian.user else dietitian.lastName }}</dd> </div> <div class="bg-white px-4 py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6"> <dt class="text-sm font-medium text-gray-500">Email</dt> - <dd class="mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2">{{ dietitian.email }}</dd> + <dd class="mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2">{{ dietitian.user.email if dietitian.user else dietitian.email }}</dd> </div> <div class="bg-gray-50 px-4 py-5 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">{{ dietitian.phone }}</dd> + <dd class="mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2">{{ dietitian.user.phone if dietitian.user and dietitian.user.phone else dietitian.phone or 'None' }}</dd> </div> <div class="bg-white px-4 py-5 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">{{ dietitian.specialization }}</dd> + <dd class="mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2">{{ dietitian.specialization or 'None' }}</dd> </div> <div class="bg-gray-50 px-4 py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6"> <dt class="text-sm font-medium text-gray-500">Trạng thái</dt> <dd class="mt-1 text-sm sm:mt-0 sm:col-span-2"> - {% if dietitian.status == 'available' %} - <span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-green-100 text-green-800"> - Khả dụng - </span> - {% else %} - <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> - {% endif %} + {{ status_badge(dietitian.status.value) }} </dd> </div> <div class="bg-white px-4 py-5 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">{{ dietitian.notes }}</dd> + <dd class="mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2">{{ dietitian.notes or 'None' }}</dd> + </div> + <div class="bg-gray-50 px-4 py-5 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">{{ dietitian.formattedID }}</dd> + </div> + <div class="bg-white px-4 py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6"> + <dt class="text-sm font-medium text-gray-500">User ID liên kết</dt> + <dd class="mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2">{{ dietitian.user.formattedID if dietitian.user else 'Chưa liên kết' }}</dd> </div> </dl> </div> diff --git a/app/templates/edit_profile.html b/app/templates/edit_profile.html index d4f816d..c2ed581 100644 --- a/app/templates/edit_profile.html +++ b/app/templates/edit_profile.html @@ -1,171 +1,226 @@ {% extends "base.html" %} -{% block title %}Chỉnh sửa hồ sơ{% endblock %} +{% block title %}Chỉnh sửa hồ sơ - CCU HTM{% endblock %} + +{% block header %}Chỉnh sửa thông tin cá nhân{% endblock %} {% block content %} -<div class="container mt-4"> - <div class="row"> - <div class="col-md-12 mb-4"> - <h1 class="mb-3">Chỉnh sửa hồ sơ</h1> - <div class="card shadow"> - <div class="card-body"> - <ul class="nav nav-tabs" id="profileTabs" role="tablist"> - <li class="nav-item" role="presentation"> - <a class="nav-link active" id="profile-tab" data-toggle="tab" href="#profile" role="tab"> - Thông tin cá nhân - </a> - </li> - <li class="nav-item" role="presentation"> - <a class="nav-link" id="password-tab" data-toggle="tab" href="#password" role="tab"> - Thay đổi mật khẩu - </a> - </li> - <li class="nav-item" role="presentation"> - <a class="nav-link" id="notifications-tab" data-toggle="tab" href="#notifications" role="tab"> - Cài đặt thông báo - </a> - </li> - </ul> - - <div class="tab-content p-3" id="profileTabsContent"> - <!-- Tab thông tin cá nhân --> - <div class="tab-pane fade show active" id="profile" role="tabpanel"> - <form method="POST" action="{{ url_for('auth.edit_profile') }}"> - {{ form.csrf_token }} - - <div class="form-group"> - {{ form.username.label }} - {{ form.username(class="form-control") }} - {% if form.username.errors %} - <div class="invalid-feedback d-block"> - {% for error in form.username.errors %} - {{ error }} - {% endfor %} - </div> +<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> + </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="form-group"> - {{ form.email.label }} - {{ form.email(class="form-control") }} - {% if form.email.errors %} - <div class="invalid-feedback d-block"> - {% for error in form.email.errors %} - {{ error }} - {% endfor %} - </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> - - {% if form.bio %} - <div class="form-group"> - {{ form.bio.label }} - {{ form.bio(class="form-control", rows=3) }} - {% if form.bio.errors %} - <div class="invalid-feedback d-block"> - {% for error in form.bio.errors %} - {{ error }} - {% endfor %} - </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> - {% endif %} - - <div class="form-group"> - <button type="submit" class="btn btn-primary">Cập nhật thông tin</button> - <a href="{{ url_for('auth.profile') }}" class="btn btn-secondary">Hủy</a> - </div> - </form> - </div> - - <!-- Tab thay đổi mật khẩu --> - <div class="tab-pane fade" id="password" role="tabpanel"> - <form method="POST" action="{{ url_for('auth.change_password') }}"> - {{ password_form.csrf_token }} - - <div class="form-group"> - {{ password_form.current_password.label }} - {{ password_form.current_password(class="form-control") }} - {% if password_form.current_password.errors %} - <div class="invalid-feedback d-block"> - {% for error in password_form.current_password.errors %} - {{ error }} - {% endfor %} - </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> - - <div class="form-group"> - {{ password_form.new_password.label }} - {{ password_form.new_password(class="form-control") }} - {% if password_form.new_password.errors %} - <div class="invalid-feedback d-block"> - {% for error in password_form.new_password.errors %} - {{ error }} - {% endfor %} - </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="form-group"> - {{ password_form.confirm_password.label }} - {{ password_form.confirm_password(class="form-control") }} - {% if password_form.confirm_password.errors %} - <div class="invalid-feedback d-block"> - {% for error in password_form.confirm_password.errors %} - {{ error }} - {% endfor %} - </div> - {% endif %} + + <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="form-group"> - <button type="submit" class="btn btn-primary">Cập nhật mật khẩu</button> + + <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> - </form> + {% endif %} + </div> </div> - <!-- Tab cài đặt thông báo --> - <div class="tab-pane fade" id="notifications" role="tabpanel"> - <form method="POST" action="{{ url_for('auth.update_notifications') }}"> - {{ notification_form.csrf_token }} - - <div class="form-group form-check"> - {{ notification_form.email_notifications(class="form-check-input") }} - {{ notification_form.email_notifications.label(class="form-check-label") }} - </div> - - <div class="form-group form-check"> - {{ notification_form.system_notifications(class="form-check-input") }} - {{ notification_form.system_notifications.label(class="form-check-label") }} + <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> + </form> + </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="form-group"> - <button type="submit" class="btn btn-primary">Cập nhật cài đặt thông báo</button> + + <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> - </form> + </div> </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> + </form> </div> </div> </div> </div> </div> -{% endblock %} -{% block extra_js %} +<!-- JavaScript để xử lý tab --> <script> - $(document).ready(function() { - // Kích hoạt tab dựa trên hash URL - var hash = window.location.hash; - if (hash) { - $('#profileTabs a[href="' + hash + '"]').tab('show'); + document.addEventListener('DOMContentLoaded', function() { + const profileTab = document.getElementById('profile-tab'); + const passwordTab = document.getElementById('password-tab'); + const profileContent = document.getElementById('profile-content'); + const passwordContent = document.getElementById('password-content'); + + // Kiểm tra hash URL để hiển thị tab tương ứng + if (window.location.hash === '#change-password') { + showPasswordTab(); + } + + 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'; } - - // Cập nhật hash khi thay đổi tab - $('a[data-toggle="tab"]').on('shown.bs.tab', function (e) { - window.location.hash = e.target.hash; - }); }); </script> {% endblock %} \ No newline at end of file diff --git a/app/templates/login.html b/app/templates/login.html index b8414b1..0d1aef1 100644 --- a/app/templates/login.html +++ b/app/templates/login.html @@ -7,11 +7,15 @@ .login-form { animation: fadeIn 0.6s ease-in-out 0.3s forwards; opacity: 0; + width: 100%; + margin: 0 auto; } .form-input { font-size: 1.05rem; - padding: 0.75rem 1rem; + padding: 0.85rem 1.2rem; + width: 100%; + border-radius: 0.5rem; } .form-input:focus { @@ -23,7 +27,8 @@ transition: all 0.3s ease; background: linear-gradient(to right, #3b82f6, #2563eb); font-size: 1.1rem; - padding: 0.75rem 1.5rem; + padding: 0.85rem 1.5rem; + border-radius: 0.5rem; } .btn-login:hover { @@ -33,42 +38,73 @@ } .form-label { - font-size: 1rem; + font-size: 1.05rem; margin-bottom: 0.5rem; + font-weight: 500; } .alert-message { + margin-bottom: 1rem; + } + + .mb-5 { margin-bottom: 1.5rem; } + + .error-container { + margin-bottom: 1rem; + } + + .error-container.hidden { + display: none; + margin: 0; + padding: 0; + height: 0; + } + + @media (max-width: 500px) { + .flex.items-center.justify-between { + flex-direction: column; + align-items: flex-start; + } + + .flex.items-center.justify-between a { + margin-top: 0.5rem; + margin-left: 0; + } + } </style> {% endblock %} {% block content %} <div class="bg-white rounded-lg shadow-lg p-8 login-form"> - <h2 class="text-3xl font-bold text-center text-gray-800 mb-8">Đăng nhập</h2> + <h2 class="text-3xl font-bold text-center text-gray-800 mb-6">Đăng nhập</h2> - {% with messages = get_flashed_messages(with_categories=true) %} - {% if messages %} - {% for category, message in messages %} - <div class="p-4 mb-4 rounded-md {% if category == 'error' %}bg-red-100 text-red-700{% else %}bg-green-100 text-green-700{% endif %} animate-fadeIn alert-message"> - <div class="flex"> - <div class="flex-shrink-0"> - {% if category == 'error' %} - <i class="fas fa-exclamation-circle text-red-500"></i> - {% else %} - <i class="fas fa-check-circle text-green-500"></i> - {% endif %} - </div> - <div class="ml-3"> - <p class="text-sm font-medium">{{ message }}</p> + <!-- Error message container - Only takes space when messages exist --> + <div class="error-container {% if not get_flashed_messages() %}hidden{% endif %}"> + {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} + {% for category, message in messages %} + <div class="p-4 mb-4 rounded-md {% if category == 'error' %}bg-red-100 text-red-700{% else %}bg-green-100 text-green-700{% endif %} animate-fadeIn alert-message"> + <div class="flex"> + <div class="flex-shrink-0"> + {% if category == 'error' %} + <i class="fas fa-exclamation-circle text-red-500"></i> + {% else %} + <i class="fas fa-check-circle text-green-500"></i> + {% endif %} + </div> + <div class="ml-3"> + <p class="text-sm font-medium">{{ message }}</p> + </div> </div> </div> - </div> - {% endfor %} - {% endif %} - {% endwith %} + {% endfor %} + {% endif %} + {% endwith %} + </div> - <form method="POST" action="{{ url_for('auth.login') }}"> + <form method="POST" action="{{ url_for('auth.login') }}" class="mt-4 px-1"> {{ form.hidden_tag() }} <div class="mb-5"> @@ -83,6 +119,18 @@ {% endif %} </div> + <div class="mb-5"> + <label for="dietitianID" class="block form-label font-medium text-gray-700">Dietitian ID (nếu có)</label> + {{ form.dietitianID(class="form-input w-full border border-gray-300 rounded-md focus:outline-none", placeholder="Ví dụ: DT-00001") }} + {% if form.dietitianID.errors %} + <div class="text-red-600 text-sm mt-1"> + {% for error in form.dietitianID.errors %} + {{ error }} + {% endfor %} + </div> + {% endif %} + </div> + <div class="mb-5"> <label for="password" class="block form-label font-medium text-gray-700">Mật khẩu</label> {{ form.password(class="form-input w-full border border-gray-300 rounded-md focus:outline-none", placeholder="Nhập mật khẩu của bạn") }} @@ -98,12 +146,12 @@ <div class="flex items-center justify-between mb-8"> <div class="flex items-center"> {{ form.remember(class="h-5 w-5 text-blue-600 focus:ring-blue-500 border-gray-300 rounded") }} - <label for="remember" class="ml-2 block text-base text-gray-700"> + <label for="remember" class="ml-2 block text-sm md:text-base text-gray-700"> Ghi nhớ đăng nhập </label> </div> - <a href="#" class="text-base text-blue-600 hover:text-blue-800 transition"> + <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> @@ -112,15 +160,6 @@ Đăng nhập </button> </form> - - <div class="mt-8 text-center"> - <p class="text-base text-gray-600"> - Chưa có tài khoản? - <a href="{{ url_for('auth.register') }}" class="text-blue-600 hover:text-blue-800 font-medium transition"> - Đăng ký ngay - </a> - </p> - </div> </div> {% endblock %} diff --git a/app/templates/profile.html b/app/templates/profile.html index faca06f..2a71f6e 100644 --- a/app/templates/profile.html +++ b/app/templates/profile.html @@ -1,133 +1,49 @@ {% extends "base.html" %} -{% block title %}Profile - CCU HTM{% endblock %} +{% block title %}Hồ sơ người dùng{% endblock %} {% block content %} -<div class="container mx-auto px-4 py-8"> - <div class="max-w-4xl mx-auto"> - <!-- Profile header --> - <div class="bg-white rounded-lg shadow-md overflow-hidden mb-6 transform hover:scale-[1.01] transition-all duration-300"> - <div class="bg-primary-600 h-32 relative"> - <div class="absolute bottom-0 left-0 transform translate-y-1/2 ml-8"> - <div class="rounded-full bg-white p-1 shadow-lg"> - <div class="bg-gray-200 rounded-full h-24 w-24 flex items-center justify-center text-3xl font-bold text-primary-600"> - {{ current_user.username[0].upper() }} - </div> - </div> - </div> - <div class="absolute top-4 right-4"> - <a href="{{ url_for('auth.edit_profile') }}" class="bg-white text-primary-600 px-4 py-2 rounded-md shadow hover:bg-gray-50 transition-colors flex items-center gap-2"> - <i class="fas fa-pencil-alt"></i> Edit Profile - </a> - </div> - </div> - <div class="pt-16 pb-8 px-8"> - <h1 class="text-2xl font-bold text-gray-800">{{ current_user.username }}</h1> - <p class="text-gray-600">{{ current_user.email }}</p> - <div class="mt-4 grid grid-cols-1 md:grid-cols-3 gap-4"> - <div class="bg-gray-50 p-4 rounded-lg"> - <div class="text-sm text-gray-500">Role</div> - <div class="font-medium">{{ current_user.role or 'User' }}</div> - </div> - <div class="bg-gray-50 p-4 rounded-lg"> - <div class="text-sm text-gray-500">Member Since</div> - <div class="font-medium">{{ current_user.created_at.strftime('%B %d, %Y') if current_user.created_at else 'N/A' }}</div> - </div> - <div class="bg-gray-50 p-4 rounded-lg"> - <div class="text-sm text-gray-500">Last Login</div> - <div class="font-medium">{{ current_user.last_login.strftime('%B %d, %Y %H:%M') if current_user.last_login else 'N/A' }}</div> - </div> - </div> - </div> - </div> - - <!-- Activity section --> - <div class="bg-white rounded-lg shadow-md overflow-hidden mb-6 transform hover:scale-[1.01] transition-all duration-300"> - <div class="px-8 py-6"> - <h2 class="text-xl font-bold text-gray-800 mb-4">Recent Activity</h2> - - {% if activities %} - <div class="space-y-4"> - {% for activity in activities %} - <div class="flex items-start p-3 hover:bg-gray-50 rounded-lg transition-colors"> - <div class="flex-shrink-0 bg-primary-100 text-primary-600 p-2 rounded-full mr-4"> - <i class="fas fa-{{ activity.icon if activity.icon else 'check' }}"></i> - </div> - <div> - <p class="font-medium text-gray-800">{{ activity.description }}</p> - <p class="text-sm text-gray-500">{{ activity.timestamp.strftime('%B %d, %Y %H:%M') }}</p> - </div> - </div> - {% endfor %} - </div> - {% else %} - <div class="text-gray-500 text-center py-4"> - <p>No recent activity to display</p> - </div> - {% endif %} - </div> - </div> +<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> - <!-- Stats section --> - <div class="grid grid-cols-1 md:grid-cols-2 gap-6 mb-6"> - <div class="bg-white rounded-lg shadow-md overflow-hidden transform hover:scale-[1.01] transition-all duration-300"> - <div class="px-8 py-6"> - <h2 class="text-xl font-bold text-gray-800 mb-4">Uploads</h2> - <div class="flex items-center justify-center h-32"> - <div class="text-center"> - <div class="text-4xl font-bold text-primary-600">{{ upload_count if upload_count is defined else 0 }}</div> - <div class="text-gray-500">Files Uploaded</div> - </div> - </div> - </div> - </div> - - <div class="bg-white rounded-lg shadow-md overflow-hidden transform hover:scale-[1.01] transition-all duration-300"> - <div class="px-8 py-6"> - <h2 class="text-xl font-bold text-gray-800 mb-4">Patients</h2> - <div class="flex items-center justify-center h-32"> - <div class="text-center"> - <div class="text-4xl font-bold text-primary-600">{{ patient_count if patient_count is defined else 0 }}</div> - <div class="text-gray-500">Patients Added</div> - </div> - </div> + <div class="bg-white shadow-md rounded-lg overflow-hidden"> + <div class="px-6 py-4"> + <div class="flex items-center mb-4"> + <!-- Placeholder for user avatar --> + <img class="h-16 w-16 rounded-full object-cover mr-4" src="https://via.placeholder.com/64" alt="User Avatar"> + <div> + <h2 class="text-xl font-semibold text-gray-900">{{ current_user.full_name }}</h2> + <p class="text-gray-600">{{ current_user.role }}</p> </div> </div> - </div> - <!-- Security section --> - <div class="bg-white rounded-lg shadow-md overflow-hidden mb-6 transform hover:scale-[1.01] transition-all duration-300"> - <div class="px-8 py-6"> - <h2 class="text-xl font-bold text-gray-800 mb-4">Security</h2> - <div class="space-y-4"> - <div class="flex items-center justify-between p-3 hover:bg-gray-50 rounded-lg transition-colors"> - <div class="flex items-center"> - <div class="bg-green-100 text-green-600 p-2 rounded-full mr-4"> - <i class="fas fa-lock"></i> - </div> - <div> - <p class="font-medium text-gray-800">Password</p> - <p class="text-sm text-gray-500">Last changed: {{ password_changed if password_changed is defined else 'N/A' }}</p> - </div> - </div> - <a href="{{ url_for('auth.edit_profile') }}#password" class="text-primary-600 hover:text-primary-700">Change</a> - </div> - - <div class="flex items-center justify-between p-3 hover:bg-gray-50 rounded-lg transition-colors"> - <div class="flex items-center"> - <div class="bg-blue-100 text-blue-600 p-2 rounded-full mr-4"> - <i class="fas fa-envelope"></i> - </div> - <div> - <p class="font-medium text-gray-800">Email Address</p> - <p class="text-sm text-gray-500">{{ current_user.email }}</p> - </div> - </div> - <a href="{{ url_for('auth.edit_profile') }}#email" class="text-primary-600 hover:text-primary-700">Update</a> - </div> - </div> + <div class="border-t border-gray-200 pt-4"> + <dl> + <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">Họ và tên</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="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">Email</dt> + <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">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"> + <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> + <div class="px-6 py-3 bg-gray-50 text-right"> + <a href="{{ url_for('auth.edit_profile') }}" class="inline-flex justify-center py-2 px-4 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"> + Chỉnh sửa hồ sơ + </a> + </div> </div> </div> {% endblock %} \ No newline at end of file diff --git a/app/templates/register.html b/app/templates/register.html deleted file mode 100644 index f756e75..0000000 --- a/app/templates/register.html +++ /dev/null @@ -1,181 +0,0 @@ -{% extends "auth_base.html" %} - -{% block title %}Đăng ký - CCU HTM{% endblock %} - -{% block extra_css %} -<style> - .register-form { - animation: fadeIn 0.6s ease-in-out 0.3s forwards; - opacity: 0; - } - - .form-input { - font-size: 1.05rem; - padding: 0.75rem 1rem; - } - - .form-input:focus { - border-color: #3b82f6; - box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.2); - } - - .btn-register { - transition: all 0.3s ease; - background: linear-gradient(to right, #3b82f6, #2563eb); - font-size: 1.1rem; - padding: 0.75rem 1.5rem; - } - - .btn-register:hover { - transform: translateY(-2px); - box-shadow: 0 4px 12px rgba(37, 99, 235, 0.3); - background: linear-gradient(to right, #2563eb, #1d4ed8); - } - - .form-label { - font-size: 1rem; - margin-bottom: 0.5rem; - } - - .alert-message { - margin-bottom: 1.5rem; - } - - .name-fields { - display: grid; - grid-template-columns: 1fr 1fr; - gap: 1rem; - } -</style> -{% endblock %} - -{% block content %} -<div class="bg-white rounded-lg shadow-lg p-8 register-form"> - <h2 class="text-3xl font-bold text-center text-gray-800 mb-8">Tạo tài khoản mới</h2> - - {% with messages = get_flashed_messages(with_categories=true) %} - {% if messages %} - {% for category, message in messages %} - <div class="p-4 mb-4 rounded-md {% if category == 'error' %}bg-red-100 text-red-700{% else %}bg-green-100 text-green-700{% endif %} animate-fadeIn alert-message"> - <div class="flex"> - <div class="flex-shrink-0"> - {% if category == 'error' %} - <i class="fas fa-exclamation-circle text-red-500"></i> - {% else %} - <i class="fas fa-check-circle text-green-500"></i> - {% endif %} - </div> - <div class="ml-3"> - <p class="text-sm font-medium">{{ message }}</p> - </div> - </div> - </div> - {% endfor %} - {% endif %} - {% endwith %} - - <form method="POST" action="{{ url_for('auth.register') }}"> - {{ form.hidden_tag() }} - - <div class="name-fields mb-5"> - <div> - <label for="firstName" class="block form-label font-medium text-gray-700">Họ</label> - {{ form.firstName(class="form-input w-full border border-gray-300 rounded-md focus:outline-none", placeholder="Nhập họ của bạn") }} - {% if form.firstName.errors %} - <div class="text-red-600 text-sm mt-1"> - {% for error in form.firstName.errors %} - {{ error }} - {% endfor %} - </div> - {% endif %} - </div> - - <div> - <label for="lastName" class="block form-label font-medium text-gray-700">Tên</label> - {{ form.lastName(class="form-input w-full border border-gray-300 rounded-md focus:outline-none", placeholder="Nhập tên của bạn") }} - {% if form.lastName.errors %} - <div class="text-red-600 text-sm mt-1"> - {% for error in form.lastName.errors %} - {{ error }} - {% endfor %} - </div> - {% endif %} - </div> - </div> - - <div class="mb-5"> - <label for="username" class="block form-label font-medium text-gray-700">Tên người dùng</label> - {{ form.username(class="form-input w-full border border-gray-300 rounded-md focus:outline-none", placeholder="Nhập tên người dùng của bạn") }} - {% if form.username.errors %} - <div class="text-red-600 text-sm mt-1"> - {% for error in form.username.errors %} - {{ error }} - {% endfor %} - </div> - {% endif %} - </div> - - <div class="mb-5"> - <label for="email" class="block form-label font-medium text-gray-700">Email</label> - {{ form.email(class="form-input w-full border border-gray-300 rounded-md focus:outline-none", placeholder="Nhập email của bạn") }} - {% if form.email.errors %} - <div class="text-red-600 text-sm mt-1"> - {% for error in form.email.errors %} - {{ error }} - {% endfor %} - </div> - {% endif %} - </div> - - <div class="mb-5"> - <label for="password" class="block form-label font-medium text-gray-700">Mật khẩu</label> - {{ form.password(class="form-input w-full border border-gray-300 rounded-md focus:outline-none", placeholder="Nhập mật khẩu của bạn") }} - {% if form.password.errors %} - <div class="text-red-600 text-sm mt-1"> - {% for error in form.password.errors %} - {{ error }} - {% endfor %} - </div> - {% endif %} - </div> - - <div class="mb-6"> - <label for="confirm_password" class="block form-label font-medium text-gray-700">Xác nhận mật khẩu</label> - {{ form.confirm_password(class="form-input w-full border border-gray-300 rounded-md focus:outline-none", placeholder="Nhập lại mật khẩu của bạn") }} - {% if form.confirm_password.errors %} - <div class="text-red-600 text-sm mt-1"> - {% for error in form.confirm_password.errors %} - {{ error }} - {% endfor %} - </div> - {% endif %} - </div> - - <div class="text-base text-gray-600 mb-8"> - Bằng cách đăng ký, bạn đồng ý với các <a href="#" class="text-blue-600 hover:text-blue-800 transition">Điều khoản sử dụng</a> và <a href="#" class="text-blue-600 hover:text-blue-800 transition">Chính sách bảo mật</a> của chúng tôi. - </div> - - <button type="submit" class="btn-register 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"> - Đăng ký - </button> - </form> - - <div class="mt-8 text-center"> - <p class="text-base text-gray-600"> - Đã có tài khoản? - <a href="{{ url_for('auth.login') }}" class="text-blue-600 hover:text-blue-800 font-medium transition"> - Đăng nhập ngay - </a> - </p> - </div> -</div> -{% endblock %} - -{% block extra_js %} -<script> - document.addEventListener('DOMContentLoaded', function() { - // Tập trung vào trường họ khi trang tải xong - document.getElementById('firstName').focus(); - }); -</script> -{% endblock %} \ No newline at end of file diff --git a/app/utils/decorators.py b/app/utils/decorators.py new file mode 100644 index 0000000..9c12896 --- /dev/null +++ b/app/utils/decorators.py @@ -0,0 +1,16 @@ +from functools import wraps +from flask import flash, redirect, url_for, request +from flask_login import current_user + +def admin_required(f): + """Decorator để giới hạn quyền truy cập chỉ cho Admin.""" + @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)) + if not current_user.is_admin: + 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 diff --git a/fix_template_urls.py b/fix_template_urls.py deleted file mode 100644 index c37361f..0000000 --- a/fix_template_urls.py +++ /dev/null @@ -1,46 +0,0 @@ -#!/usr/bin/env python -# -*- coding: utf-8 -*- - -import os -import re - -def fix_templates(): - """Sửa tất cả template đang sử dụng url_for('index') thành url_for('handle_root')""" - templates_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'app', 'templates') - pattern = re.compile(r"url_for\('index'\)") - replacement = "url_for('handle_root')" - - # Đếm số file đã được sửa - fixed_count = 0 - - # Đi qua tất cả các file trong thư mục templates và các thư mục con - for root, dirs, files in os.walk(templates_dir): - for file in files: - if file.endswith('.html'): - filepath = os.path.join(root, file) - - # Đọc nội dung file - with open(filepath, 'r', encoding='utf-8') as f: - content = f.read() - - # Kiểm tra xem có cần sửa không - if pattern.search(content): - # Sửa nội dung - modified_content = pattern.sub(replacement, content) - - # Ghi lại vào file - with open(filepath, 'w', encoding='utf-8') as f: - f.write(modified_content) - - # Tăng số file đã sửa - fixed_count += 1 - - # In thông báo - rel_path = os.path.relpath(filepath, os.path.dirname(os.path.abspath(__file__))) - print(f"Đã sửa file: {rel_path}") - - print(f"\nHoàn tất! Đã sửa {fixed_count} file template.") - -if __name__ == "__main__": - print("Bắt đầu sửa các template...") - fix_templates() \ No newline at end of file diff --git a/generate_patients.py b/generate_patients.py new file mode 100644 index 0000000..93b30e2 --- /dev/null +++ b/generate_patients.py @@ -0,0 +1,129 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +import json +import random +# Bạn cần cài đặt thư viện Faker: pip install Faker +from faker import Faker +import os + +# Lấy đường dẫn thư mục của script hiện tại +script_dir = os.path.dirname(os.path.abspath(__file__)) +output_file = os.path.join(script_dir, 'patients_data.json') + +fake = Faker('en_US') # Có thể dùng 'vi_VN' nếu muốn tên Việt + +def generate_measurement(age): + """Tạo dữ liệu đo lường ngẫu nhiên nhưng hợp lý theo độ tuổi""" + measurements = {} + + # Nhiệt độ (Temperature): 36.0 - 37.5, có thể cao hơn nếu già/bệnh + base_temp = random.uniform(36.0, 37.2) + if age > 70 or random.random() < 0.1: # 10% chance of higher temp + base_temp += random.uniform(0.3, 1.0) + measurements['temperature'] = round(base_temp, 1) + + # Nhịp tim (Heart Rate): Trẻ khỏe: 60-80, Già/Bệnh: 70-100+ + base_hr = random.randint(65, 85) + if age < 30: + base_hr = random.randint(60, 80) + elif age > 60: + base_hr += random.randint(5, 20) + if random.random() < 0.15: # 15% chance of abnormal HR + base_hr += random.randint(15, 30) * random.choice([-1, 1]) + measurements['heart_rate'] = max(40, min(140, base_hr)) # Giới hạn hợp lý + + # Huyết áp (Blood Pressure): Trẻ khỏe: 110-120 / 70-80. Già/Bệnh: Cao hơn + systolic = random.randint(110, 125) + diastolic = random.randint(70, 85) + if age > 50: + systolic += random.randint(10, 25) + diastolic += random.randint(5, 15) + if random.random() < 0.2: # 20% chance of higher BP + systolic += random.randint(10, 20) + diastolic += random.randint(5, 10) + measurements['blood_pressure_systolic'] = max(90, min(180, systolic)) + measurements['blood_pressure_diastolic'] = max(50, min(110, diastolic)) + + # SpO2: 95-100, có thể thấp hơn nếu bệnh + spo2 = random.uniform(96.0, 99.5) + if random.random() < 0.1: # 10% chance of lower SpO2 + spo2 -= random.uniform(2.0, 5.0) + measurements['oxygen_saturation'] = round(max(88.0, min(100.0, spo2)), 1) + + # Nhịp thở (Respiratory Rate): 12-20 (đổi tên biến thành resp_rate cho khớp model) + measurements['resp_rate'] = random.randint(12, 20) + if random.random() < 0.05: # Chance of slightly higher + measurements['resp_rate'] += random.randint(1,4) + + # FiO2: Thường là 21% (không khí), có thể cao hơn nếu thở oxy + measurements['fio2'] = 21.0 + if random.random() < 0.15: # 15% thở oxy + measurements['fio2'] = round(random.uniform(24.0, 60.0), 1) + + # Tidal Volume: Khoảng 6-8 ml/kg trọng lượng cơ thể lý tưởng + # Tạm thời để giá trị ngẫu nhiên đơn giản (đổi tên biến) + measurements['tidal_vol'] = random.randint(350, 550) + + # Các chỉ số khác (tùy chọn, để None hoặc giá trị ngẫu nhiên cơ bản) + measurements['end_tidal_co2'] = round(random.uniform(35, 45), 1) if random.random() < 0.5 else None + measurements['feed_vol'] = random.randint(100, 500) if random.random() < 0.3 else None + measurements['peep'] = random.randint(4, 8) if random.random() < 0.4 else None + measurements['pip'] = random.randint(25, 35) if random.random() < 0.4 else None + + return measurements + +def generate_patients(num_patients=50): + patients = [] + blood_types = ['A+', 'A-', 'B+', 'B-', 'AB+', 'AB-', 'O+', 'O-'] + genders = ['male', 'female', 'other'] + + for i in range(num_patients): + patient_id_val = f"P-{i+1:05d}" # Sử dụng patient_id_val để tránh trùng tên biến + age = random.randint(18, 90) + gender = random.choices(genders, weights=[48, 48, 4], k=1)[0] + + if gender == 'male': + firstName = fake.first_name_male() + lastName = fake.last_name() + height = round(random.uniform(160, 190), 1) + weight = round(random.gauss(mu=height * 0.45, sigma=10), 1) + elif gender == 'female': + firstName = fake.first_name_female() + lastName = fake.last_name() + height = round(random.uniform(150, 175), 1) + weight = round(random.gauss(mu=height * 0.4, sigma=8), 1) + else: + firstName = fake.first_name_nonbinary() + lastName = fake.last_name() + height = round(random.uniform(155, 180), 1) + weight = round(random.gauss(mu=height * 0.42, sigma=9), 1) + + weight = max(40, min(150, weight)) + + patient = { + "patientID": patient_id_val, # Sử dụng patient_id_val + "firstName": firstName, + "lastName": lastName, + "age": age, + "gender": gender, + "height": height, + "weight": weight, + "blood_type": random.choice(blood_types), + "initial_measurement": generate_measurement(age) + } + patients.append(patient) + + return patients + +if __name__ == "__main__": + num = 50 + print(f"Generating {num} patient records...") + patient_data = generate_patients(num) + + try: + with open(output_file, 'w', encoding='utf-8') as f: + json.dump(patient_data, f, indent=4, ensure_ascii=False) + print(f"Successfully generated {num} patient records to {output_file}") + except IOError as e: + print(f"Error writing to file {output_file}: {e}") diff --git a/migrations/README b/migrations/README new file mode 100644 index 0000000..0e04844 --- /dev/null +++ b/migrations/README @@ -0,0 +1 @@ +Single-database configuration for Flask. diff --git a/migrations/alembic.ini b/migrations/alembic.ini new file mode 100644 index 0000000..ec9d45c --- /dev/null +++ b/migrations/alembic.ini @@ -0,0 +1,50 @@ +# A generic, single database configuration. + +[alembic] +# template used to generate migration files +# file_template = %%(rev)s_%%(slug)s + +# set to 'true' to run the environment during +# the 'revision' command, regardless of autogenerate +# revision_environment = false + + +# Logging configuration +[loggers] +keys = root,sqlalchemy,alembic,flask_migrate + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[logger_flask_migrate] +level = INFO +handlers = +qualname = flask_migrate + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/migrations/env.py b/migrations/env.py new file mode 100644 index 0000000..4c97092 --- /dev/null +++ b/migrations/env.py @@ -0,0 +1,113 @@ +import logging +from logging.config import fileConfig + +from flask import current_app + +from alembic import context + +# this is the Alembic Config object, which provides +# access to the values within the .ini file in use. +config = context.config + +# Interpret the config file for Python logging. +# This line sets up loggers basically. +fileConfig(config.config_file_name) +logger = logging.getLogger('alembic.env') + + +def get_engine(): + try: + # this works with Flask-SQLAlchemy<3 and Alchemical + return current_app.extensions['migrate'].db.get_engine() + except (TypeError, AttributeError): + # this works with Flask-SQLAlchemy>=3 + return current_app.extensions['migrate'].db.engine + + +def get_engine_url(): + try: + return get_engine().url.render_as_string(hide_password=False).replace( + '%', '%%') + except AttributeError: + return str(get_engine().url).replace('%', '%%') + + +# add your model's MetaData object here +# for 'autogenerate' support +# from myapp import mymodel +# target_metadata = mymodel.Base.metadata +config.set_main_option('sqlalchemy.url', get_engine_url()) +target_db = current_app.extensions['migrate'].db + +# other values from the config, defined by the needs of env.py, +# can be acquired: +# my_important_option = config.get_main_option("my_important_option") +# ... etc. + + +def get_metadata(): + if hasattr(target_db, 'metadatas'): + return target_db.metadatas[None] + return target_db.metadata + + +def run_migrations_offline(): + """Run migrations in 'offline' mode. + + This configures the context with just a URL + and not an Engine, though an Engine is acceptable + here as well. By skipping the Engine creation + we don't even need a DBAPI to be available. + + Calls to context.execute() here emit the given string to the + script output. + + """ + url = config.get_main_option("sqlalchemy.url") + context.configure( + url=url, target_metadata=get_metadata(), literal_binds=True + ) + + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online(): + """Run migrations in 'online' mode. + + In this scenario we need to create an Engine + and associate a connection with the context. + + """ + + # this callback is used to prevent an auto-migration from being generated + # when there are no changes to the schema + # reference: http://alembic.zzzcomputing.com/en/latest/cookbook.html + def process_revision_directives(context, revision, directives): + if getattr(config.cmd_opts, 'autogenerate', False): + script = directives[0] + if script.upgrade_ops.is_empty(): + directives[:] = [] + logger.info('No changes in schema detected.') + + conf_args = current_app.extensions['migrate'].configure_args + if conf_args.get("process_revision_directives") is None: + conf_args["process_revision_directives"] = process_revision_directives + + connectable = get_engine() + + with connectable.connect() as connection: + context.configure( + connection=connection, + target_metadata=get_metadata(), + **conf_args + ) + + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/migrations/script.py.mako b/migrations/script.py.mako new file mode 100644 index 0000000..2c01563 --- /dev/null +++ b/migrations/script.py.mako @@ -0,0 +1,24 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision = ${repr(up_revision)} +down_revision = ${repr(down_revision)} +branch_labels = ${repr(branch_labels)} +depends_on = ${repr(depends_on)} + + +def upgrade(): + ${upgrades if upgrades else "pass"} + + +def downgrade(): + ${downgrades if downgrades else "pass"} diff --git a/migrations/versions/cbc9db9dd958_update_database_schema_to_match_models.py b/migrations/versions/cbc9db9dd958_update_database_schema_to_match_models.py new file mode 100644 index 0000000..929010b --- /dev/null +++ b/migrations/versions/cbc9db9dd958_update_database_schema_to_match_models.py @@ -0,0 +1,60 @@ +"""Update database schema to match models + +Revision ID: cbc9db9dd958 +Revises: +Create Date: 2025-04-13 19:06:46.600555 + +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import mysql + +# revision identifiers, used by Alembic. +revision = 'cbc9db9dd958' +down_revision = None +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + 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') + ) + with op.batch_alter_table('users', schema=None) as batch_op: + batch_op.add_column(sa.Column('firstName', sa.String(length=50), nullable=False)) + batch_op.add_column(sa.Column('lastName', sa.String(length=50), nullable=False)) + batch_op.add_column(sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True)) + batch_op.add_column(sa.Column('last_login', sa.DateTime(timezone=True), nullable=True)) + batch_op.alter_column('password_hash', + existing_type=mysql.VARCHAR(length=255), + type_=sa.String(length=128), + nullable=True) + batch_op.drop_index('username') + batch_op.drop_column('username') + + # ### 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.add_column(sa.Column('username', mysql.VARCHAR(length=50), nullable=False)) + batch_op.create_index('username', ['username'], unique=True) + batch_op.alter_column('password_hash', + existing_type=sa.String(length=128), + type_=mysql.VARCHAR(length=255), + nullable=False) + batch_op.drop_column('last_login') + batch_op.drop_column('created_at') + batch_op.drop_column('lastName') + batch_op.drop_column('firstName') + + op.drop_table('activity_logs') + # ### end Alembic commands ### diff --git a/requirements.txt b/requirements.txt index da70a9d..7ae5fa4 100644 --- a/requirements.txt +++ b/requirements.txt @@ -42,4 +42,5 @@ coverage==7.2.2 black==23.1.0 flake8==6.0.0 isort==5.12.0 -mypy==0.991 \ No newline at end of file +mypy==0.991 +Faker \ No newline at end of file diff --git a/run.py b/run.py index 7a3d58d..b3246be 100644 --- a/run.py +++ b/run.py @@ -10,34 +10,13 @@ from flask_bcrypt import Bcrypt from flask import Flask from datetime import datetime from flask_migrate import Migrate, init, migrate, upgrade +import json # Import json -# Định nghĩa schema updates cho các bảng -schema_updates = { - 'dietitians': [ - "ALTER TABLE dietitians ADD COLUMN user_id INT NULL UNIQUE", - "ALTER TABLE dietitians ADD CONSTRAINT fk_dietitian_user FOREIGN KEY (user_id) REFERENCES users(userID) ON DELETE SET NULL", - "ALTER TABLE dietitians ADD COLUMN status ENUM('available', 'unavailable', 'on_leave') DEFAULT 'available'", - "ALTER TABLE dietitians ADD COLUMN email VARCHAR(100) UNIQUE", - "ALTER TABLE dietitians ADD COLUMN phone VARCHAR(20)", - "ALTER TABLE dietitians ADD COLUMN specialization VARCHAR(100)", - "ALTER TABLE dietitians ADD COLUMN notes TEXT", - "ALTER TABLE dietitians ADD COLUMN created_at DATETIME DEFAULT CURRENT_TIMESTAMP", - "ALTER TABLE dietitians ADD COLUMN updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP" - ], - 'patients': [ - "ALTER TABLE patients ADD COLUMN dietitianID INT NULL", - "ALTER TABLE patients ADD CONSTRAINT fk_patient_dietitian FOREIGN KEY (dietitianID) REFERENCES dietitians(dietitianID)" - ], - 'encounters': [ - "ALTER TABLE encounters ADD COLUMN dietitianID INT NULL", - "ALTER TABLE encounters ADD CONSTRAINT fk_encounter_dietitian FOREIGN KEY (dietitianID) REFERENCES dietitians(dietitianID)" - ], - 'procedures': [ - "ALTER TABLE procedures ADD COLUMN procedureName VARCHAR(100)", - "ALTER TABLE procedures ADD COLUMN procedureEndDateTime DATETIME", - "ALTER TABLE procedures ADD COLUMN procedureResults TEXT" - ] -} +# Import models and status needed for reset +from app.models.user import User +from app.models.dietitian import Dietitian, DietitianStatus +from app.models.patient import Patient, Encounter # Import Encounter +from app.models.measurement import PhysiologicalMeasurement # Import Measurement def clear_cache(): print("\n[Cache] Cleaning __pycache__ and .pyc files...") @@ -52,6 +31,7 @@ def clear_cache(): print(f"[Error] {e}") def create_mysql_database(app): + """Create MySQL database if it doesn't exist""" try: uri = app.config['SQLALCHEMY_DATABASE_URI'] if not uri.startswith('mysql'): @@ -64,9 +44,13 @@ def create_mysql_database(app): connection = mysql.connector.connect(host=host, user=user, password=pwd) cursor = connection.cursor() + + # Create database with UTF-8 support cursor.execute(f"CREATE DATABASE IF NOT EXISTS {db} CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci") - cursor.close(); connection.close() - print(f"[DB] Database '{db}' ready.") + cursor.close() + connection.close() + + print(f"[DB] Database '{db}' created/verified.") return True except Exception as e: print(f"[DB Error] {e}") @@ -92,8 +76,8 @@ def init_tables_and_admin(app, db): if not User.query.filter_by(email='admin@ccuhtm.com').first(): admin = User( username='admin', - email='admin@ccuhtm.com', - password=bcrypt.generate_password_hash('admin').decode('utf-8') + email='admin@gmail.com', + password=bcrypt.generate_password_hash('111111').decode('utf-8') ) db.session.add(admin) db.session.commit() @@ -134,319 +118,256 @@ def run_migrations(app, db): print(f"Lỗi khi thực hiện migration: {str(e)}") return False -def check_database_structure(app): +def perform_database_reset(app, db, bcrypt): """ - Kiểm tra cấu trúc cơ sở dữ liệu hiện tại + Reset the database by deleting all data and recreating default accounts. + This is a destructive operation. """ try: - # Đọc thông tin kết nối từ cấu hình - db_uri = app.config['SQLALCHEMY_DATABASE_URI'] - - if not db_uri.startswith('mysql'): - print("Không phải cấu hình MySQL, không thể kiểm tra cấu trúc.") + # First ensure database exists + if not create_mysql_database(app): + print("[Error] Failed to create/verify database.") return False + + # Get database connection information + uri = app.config['SQLALCHEMY_DATABASE_URI'] + if not uri.startswith('mysql'): + print("[Error] Only MySQL databases are supported for reset.") + return False + + # Parse connection string + creds, path = uri.replace('mysql://', '').split('@') + user_db, pwd = creds.split(':') # Renamed user to user_db to avoid conflict + host, database = path.split('/') - # Phân tích chuỗi kết nối - db_parts = db_uri.replace('mysql://', '').split('@') - auth_parts = db_parts[0].split(':') - host_parts = db_parts[1].split('/') - - username = auth_parts[0] - password = auth_parts[1] if len(auth_parts) > 1 else '' - host = host_parts[0] - database = host_parts[1] - - print(f"Đang kết nối đến MySQL với người dùng '{username}' trên máy chủ '{host}'") - - # Kết nối đến MySQL + # Connect directly to database + print(f"[DB] Connecting to MySQL database '{database}'...") connection = mysql.connector.connect( host=host, - user=username, - password=password, + user=user_db, # Use renamed variable + password=pwd, database=database ) - cursor = connection.cursor() - # Lấy danh sách tất cả các bảng - cursor.execute("SHOW TABLES") - tables = cursor.fetchall() - - print("\n=== DANH SÁCH BẢNG TRONG CƠ SỞ DỮ LIỆU ===") - for (table_name,) in tables: - print(f"\n--- Bảng: {table_name} ---") + try: + # Disable foreign key checks + print("[DB] Disabling foreign key checks...") + cursor.execute("SET FOREIGN_KEY_CHECKS = 0") - # Lấy cấu trúc của bảng - cursor.execute(f"DESCRIBE {table_name}") - columns = cursor.fetchall() + # Get all tables + print("[DB] Getting list of all tables...") + cursor.execute("SHOW TABLES") + tables = cursor.fetchall() - # Hiển thị thông tin cột - print("Tên cột\t\tLoại dữ liệu\tCó thể NULL\tKhóa\tMặc định\tThông tin khác") - print("-" * 100) - for column in columns: - field = column[0] - type = column[1] - null = column[2] - key = column[3] - default = column[4] - extra = column[5] - print(f"{field: <16} {type: <16} {null: <16} {key: <8} {default if default else 'NULL': <16} {extra}") - - # Đóng kết nối - cursor.close() - connection.close() - - return True - - except Exception as e: - print(f"Lỗi khi kiểm tra cấu trúc cơ sở dữ liệu: {str(e)}") - return False - -def update_database_schema(app, db): - """ - Cập nhật cấu trúc cơ sở dữ liệu trực tiếp từ models - Cách này nhanh hơn so với migration nhưng chỉ nên dùng trong môi trường phát triển - """ - try: - # Định nghĩa schema updates cho các bảng - schema_updates = { - 'dietitians': [ - "ALTER TABLE dietitians ADD COLUMN user_id INT NULL UNIQUE", - "ALTER TABLE dietitians ADD CONSTRAINT fk_dietitian_user FOREIGN KEY (user_id) REFERENCES users(userID) ON DELETE SET NULL", - "ALTER TABLE dietitians ADD COLUMN status ENUM('available', 'unavailable', 'on_leave') DEFAULT 'available'", - "ALTER TABLE dietitians ADD COLUMN email VARCHAR(100) UNIQUE", - "ALTER TABLE dietitians ADD COLUMN phone VARCHAR(20)", - "ALTER TABLE dietitians ADD COLUMN specialization VARCHAR(100)", - "ALTER TABLE dietitians ADD COLUMN notes TEXT", - "ALTER TABLE dietitians ADD COLUMN created_at DATETIME DEFAULT CURRENT_TIMESTAMP", - "ALTER TABLE dietitians ADD COLUMN updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP" - ], - 'patients': [ - "ALTER TABLE patients ADD COLUMN dietitianID INT NULL", - "ALTER TABLE patients ADD CONSTRAINT fk_patient_dietitian FOREIGN KEY (dietitianID) REFERENCES dietitians(dietitianID)" - ], - 'encounters': [ - "ALTER TABLE encounters ADD COLUMN dietitianID INT NULL", - "ALTER TABLE encounters ADD CONSTRAINT fk_encounter_dietitian FOREIGN KEY (dietitianID) REFERENCES dietitians(dietitianID)" - ], - 'procedures': [ - "ALTER TABLE procedures ADD COLUMN procedureName VARCHAR(100)", - "ALTER TABLE procedures ADD COLUMN procedureEndDateTime DATETIME", - "ALTER TABLE procedures ADD COLUMN procedureResults TEXT" - ] - } - - # Đọc thông tin kết nối từ cấu hình - db_uri = app.config['SQLALCHEMY_DATABASE_URI'] - - if not db_uri.startswith('mysql'): - print("Không phải cấu hình MySQL, không thể cập nhật cấu trúc.") + # Drop all tables + print("[DB] Dropping all tables...") + for (table_name,) in tables: + print(f" - Dropping table: {table_name}") + cursor.execute(f"DROP TABLE IF EXISTS `{table_name}`") + + # Re-enable foreign key checks + print("[DB] Re-enabling foreign key checks...") + cursor.execute("SET FOREIGN_KEY_CHECKS = 1") + + # Commit changes + connection.commit() + + except mysql.connector.Error as e: + print(f"[MySQL Error] {e}") return False + finally: + # Ensure connection is closed + if connection.is_connected(): + cursor.close() + connection.close() - # Phân tích chuỗi kết nối - db_parts = db_uri.replace('mysql://', '').split('@') - auth_parts = db_parts[0].split(':') - host_parts = db_parts[1].split('/') - - username = auth_parts[0] - password = auth_parts[1] if len(auth_parts) > 1 else '' - host = host_parts[0] - database = host_parts[1] - - print(f"Đang kết nối đến MySQL với người dùng '{username}' trên máy chủ '{host}'") - - # Kết nối đến MySQL - connection = mysql.connector.connect( - host=host, - user=username, - password=password, - database=database - ) - - cursor = connection.cursor() - + # Now use Flask-SQLAlchemy to create tables and users with app.app_context(): - # Kiểm tra và xóa cột 'userID' không mong muốn khỏi bảng 'dietitians' - print("\nKiểm tra và xóa cột 'userID' không mong muốn khỏi bảng 'dietitians'...") try: - cursor.execute("SHOW COLUMNS FROM dietitians LIKE 'userID'") - if cursor.fetchone(): - print(" - Tìm thấy cột 'userID'. Đang xóa...") - cursor.execute("ALTER TABLE dietitians DROP COLUMN userID") - print(" - Đã xóa cột 'userID'.") - else: - print(" - Cột 'userID' không tồn tại, không cần xóa.") - except mysql.connector.Error as err: - print(f" - Lỗi khi kiểm tra/xóa cột 'userID': {err.msg}") - - # Thực hiện các câu lệnh cập nhật - for table, statements in schema_updates.items(): - print(f"\nĐang cập nhật bảng {table}...") + # Create all tables + print("[DB] Creating all tables from models...") + db.create_all() + db.session.commit() - # Kiểm tra xem bảng có tồn tại không - cursor.execute(f"SHOW TABLES LIKE '{table}'") - table_exists = cursor.fetchone() + print("[DB] Creating default admin account...") + # Create default admin account + admin_user = User( + firstName="Admin", + lastName="User", + email="admin@ccuhtm.com", + role="Admin" + ) + admin_user.set_password("admin") # Use set_password method + db.session.add(admin_user) - if not table_exists: - print(f" - Bảng {table} không tồn tại, bỏ qua") - continue + print("[DB] Creating default dietitian accounts...") + default_password = "111111" + hashed_password = bcrypt.generate_password_hash(default_password).decode('utf-8') + created_dietitian_users = [] # List to store created users + + for i in range(1, 6): + # Create User with Dietitian role + dietitian_user = User( + firstName=f"Dietitian{i}", + lastName="Test", + email=f"dietitian{i}@gmail.com", + password_hash=hashed_password, + role="Dietitian" + ) + db.session.add(dietitian_user) + db.session.flush() # Get the userID for the new user + created_dietitian_users.append(dietitian_user) # Add user to list + + # Create Dietitian profile and link it to the user + dietitian_profile = Dietitian( + firstName=dietitian_user.firstName, + lastName=dietitian_user.lastName, + email=dietitian_user.email, + status=DietitianStatus.AVAILABLE, + user_id=dietitian_user.userID + ) + db.session.add(dietitian_profile) - # Lấy danh sách các cột hiện tại của bảng - cursor.execute(f"SHOW COLUMNS FROM {table}") - existing_columns = [column[0] for column in cursor.fetchall()] + # Commit all changes + db.session.commit() + print("\n[DB] Database reset completed successfully.") + print("[Admin] Default admin account created:") + print(f" - Email: {admin_user.email}") + print(" - Password: admin") + print("[Dietitians] 5 default dietitian accounts created:") + print(f" - Password (for all): {default_password}") - # Thực hiện các câu lệnh ALTER TABLE - for statement in statements: + # Truy vấn và in thông tin dietitian sau khi commit + print(" - Created Dietitian Details:") + final_dietitians = Dietitian.query.join(User).filter(User.email.in_([u.email for u in created_dietitian_users])).order_by(Dietitian.dietitianID).all() + for dt in final_dietitians: + print(f" - {dt.user.email} / {dt.formattedID}") + + print("\nImportant: Please change passwords after logging in!") + + # --- Add Patients from JSON --- + print("[DB] Adding patients from JSON file...") + json_file_path = os.path.join(os.path.dirname(__file__), 'patients_data.json') + if not os.path.exists(json_file_path): + print(f"[Warning] patients_data.json not found at {json_file_path}. Skipping patient data loading.") + else: try: - column_name = None - constraint_name = None - is_add_column = "ADD COLUMN" in statement - is_add_constraint = "ADD CONSTRAINT" in statement + with open(json_file_path, 'r', encoding='utf-8') as f: + patients_json_data = json.load(f) + + patients_added = 0 + measurements_added = 0 + for patient_data in patients_json_data: + try: + # Create Patient + new_patient = Patient( + id=patient_data.get('patientID'), + firstName=patient_data.get('firstName'), + lastName=patient_data.get('lastName'), + age=patient_data.get('age'), + gender=patient_data.get('gender'), + height=patient_data.get('height'), + weight=patient_data.get('weight'), + blood_type=patient_data.get('blood_type'), + admission_date=datetime.utcnow() + ) + new_patient.calculate_bmi() + db.session.add(new_patient) + # Không cần flush vì khóa chính (id) đã có giá trị - if is_add_column: - # Phân tích cú pháp để lấy tên cột - parts = statement.split("ADD COLUMN")[1].strip().split(" ") - if len(parts) > 0: - column_name = parts[0].strip('`') # Remove potential backticks + # Create an initial Encounter for the patient + initial_encounter = Encounter( + patientID=new_patient.id, # Link using patient's string ID + admissionDateTime=new_patient.admission_date # Use patient's admission time + # Các trường khác sẽ là NULL hoặc default + ) + db.session.add(initial_encounter) + db.session.flush() # Flush để lấy encounter.id (Integer) - # Kiểm tra xem cột đã tồn tại chưa - if column_name and column_name in existing_columns: - print(f" - Bỏ qua: Cột {column_name} đã tồn tại trong bảng {table}") - continue - elif is_add_constraint: - # Phân tích cú pháp để lấy tên constraint - parts = statement.split("ADD CONSTRAINT")[1].strip().split(" ") - if len(parts) > 0: - constraint_name = parts[0].strip('`') + # Create Initial Measurement, linking to the new encounter + initial_measurement_data = patient_data.get('initial_measurement', {}) + if initial_measurement_data: + new_measurement = PhysiologicalMeasurement( + patient_id=new_patient.id, + encounter_id=initial_encounter.id, # Sử dụng ID của encounter vừa tạo + measurementDateTime=datetime.utcnow(), + temperature=initial_measurement_data.get('temperature'), + heart_rate=initial_measurement_data.get('heart_rate'), + blood_pressure_systolic=initial_measurement_data.get('blood_pressure_systolic'), + blood_pressure_diastolic=initial_measurement_data.get('blood_pressure_diastolic'), + oxygen_saturation=initial_measurement_data.get('oxygen_saturation'), + resp_rate=initial_measurement_data.get('resp_rate'), + fio2=initial_measurement_data.get('fio2'), + tidal_vol=initial_measurement_data.get('tidal_vol'), + end_tidal_co2=initial_measurement_data.get('end_tidal_co2'), + feed_vol=initial_measurement_data.get('feed_vol'), + peep=initial_measurement_data.get('peep'), + pip=initial_measurement_data.get('pip') + ) + db.session.add(new_measurement) + measurements_added += 1 + + patients_added += 1 + except Exception as patient_err: + print(f"[Error] Could not add patient {patient_data.get('patientID')}: {patient_err}") + db.session.rollback() + + db.session.commit() + print(f"[DB] Successfully added {patients_added} patients and {measurements_added} initial measurements.") - # Kiểm tra xem constraint đã tồn tại chưa - if constraint_name: - cursor.execute(f"SHOW CREATE TABLE {table}") - create_table_sql = cursor.fetchone()[1] - if constraint_name in create_table_sql: - print(f" - Bỏ qua: Constraint {constraint_name} đã tồn tại trong bảng {table}") - continue + except FileNotFoundError: + print(f"[Error] patients_data.json not found at {json_file_path}.") + except json.JSONDecodeError: + print(f"[Error] Could not decode JSON from {json_file_path}.") + except Exception as json_err: + print(f"[Error] Failed loading patient data from JSON: {json_err}") + db.session.rollback() - # Thêm log đặc biệt cho việc thêm cột status vào dietitians - if table == 'dietitians' and column_name == 'status': - print(f" -> Đang chuẩn bị thực thi: {statement}") + # Kết thúc phần thêm bệnh nhân - cursor.execute(statement) - print(f" - Thành công: {statement}") - except mysql.connector.Error as err: - # Lỗi cụ thể từ MySQL Connector - print(f" - Lỗi MySQL khi thực thi: {statement}") - print(f" Error Code: {err.errno}") - print(f" SQLSTATE: {err.sqlstate}") - print(f" Message: {err.msg}") - except Exception as e: - # Lỗi chung khác - print(f" - Lỗi chung khi thực thi: {statement}") - print(f" {str(e)}") - - # Commit các thay đổi - print(f"Committing changes for database {database}...") - connection.commit() - - # Đóng kết nối - cursor.close() - connection.close() - - print("\nHoàn tất cập nhật cấu trúc cơ sở dữ liệu!") - return True - - except Exception as e: - print(f"Lỗi khi cập nhật cấu trúc cơ sở dữ liệu: {str(e)}") - return False - -def create_admin_user_direct(): - """Tạo tài khoản admin trực tiếp""" - try: - from app import create_app, db - from app.models.user import User - from flask_bcrypt import Bcrypt - - app = create_app() - bcrypt = Bcrypt(app) - - with app.app_context(): - admin_email = 'admin@ccuhtm.com' - if not User.query.filter_by(email=admin_email).first(): - admin = User( - username='admin', - email=admin_email, - password=bcrypt.generate_password_hash('admin').decode('utf-8'), - role='Admin' - ) - db.session.add(admin) - db.session.commit() - print("[Admin] Created default admin user with username: admin, password: admin") - else: - print("[Admin] Admin user already exists") - - return True + return True + except Exception as e: + db.session.rollback() + print(f"[Error] Failed during table creation or data insertion: {e}") + return False + except Exception as e: - print(f"[Error] Failed to create admin user: {e}") + print(f"[Error] Failed to reset database: {e}") return False def main(): - # Phân tích tham số dòng lệnh + # Parse command line arguments parser = argparse.ArgumentParser(description='CCU HTM Management System') - parser.add_argument('--init', action='store_true', help='Khởi tạo cơ sở dữ liệu') - parser.add_argument('--clean', action='store_true', help='Xóa cache trước khi chạy') - parser.add_argument('--admin', action='store_true', help='Tạo tài khoản admin') - parser.add_argument('--port', type=int, default=5000, help='Port để chạy ứng dụng') - parser.add_argument('--migrate', action='store_true', help='Thực hiện migration cơ sở dữ liệu') - parser.add_argument('--check-db', action='store_true', help='Kiểm tra cấu trúc cơ sở dữ liệu') - parser.add_argument('--update-db', action='store_true', help='Cập nhật cấu trúc cơ sở dữ liệu trực tiếp từ models') + parser.add_argument('--reset', action='store_true', help='Reset database (deletes all data and recreates admin account)') + parser.add_argument('--clean', action='store_true', help='Clean cache before running') + parser.add_argument('--port', type=int, default=5000, help='Port to run the application') + parser.add_argument('--migrate', action='store_true', help='Run database migrations') args = parser.parse_args() - # Import modules from app import create_app, db + bcrypt = Bcrypt(create_app()) - # Tạo ứng dụng app = create_app() - # Xóa cache nếu yêu cầu if args.clean: clear_cache() - # Khởi tạo cơ sở dữ liệu nếu yêu cầu - if args.init: - print("Bắt đầu khởi tạo cơ sở dữ liệu...") - if create_mysql_database(app): - if init_tables_and_admin(app, db): - print("Khởi tạo cơ sở dữ liệu hoàn tất!") - else: - print("Lỗi khi khởi tạo bảng và tài khoản admin.") + if args.reset: + print("[DB] Starting database reset requested via --reset flag...") + if perform_database_reset(app, db, bcrypt): + print("[DB] Database reset via --reset flag completed!") else: - print("Lỗi khi tạo cơ sở dữ liệu MySQL.") + print("[Error] Failed to reset database via --reset flag.") + return # Exit after reset attempt - # Thực hiện migration nếu yêu cầu if args.migrate: - print("Bắt đầu thực hiện migration...") + print("[DB] Starting database migration...") run_migrations(app, db) + return # Exit after migration attempt - # Kiểm tra cấu trúc cơ sở dữ liệu nếu yêu cầu - if args.check_db: - print("Đang kiểm tra cấu trúc cơ sở dữ liệu...") - check_database_structure(app) - - # Cập nhật cấu trúc cơ sở dữ liệu trực tiếp nếu yêu cầu - if args.update_db: - print("Bắt đầu cập nhật cấu trúc cơ sở dữ liệu...") - update_database_schema(app, db) - - # Tạo tài khoản admin nếu yêu cầu - if args.admin: - print("Bắt đầu tạo tài khoản admin...") - create_admin_user_direct() - - # Chạy ứng dụng nếu không có tham số nào khác hoặc chỉ có flag --clean - is_only_clean = args.clean and not any([args.init, args.admin, args.migrate, args.check_db, args.update_db]) - if not any([args.init, args.admin, args.migrate, args.check_db, args.update_db]) or is_only_clean: - print(f"Khởi chạy CCU HTM trên port {args.port}...") - app.run(host='0.0.0.0', port=args.port, debug=True) + print(f"\n[App] Starting CCU HTM on port {args.port}...") + app.run(host='0.0.0.0', port=args.port, debug=True) if __name__ == '__main__': main() diff --git a/run_ccu.bat b/run_ccu.bat index af81c1a..ad98dfc 100644 --- a/run_ccu.bat +++ b/run_ccu.bat @@ -5,45 +5,40 @@ echo ------------------------- :menu cls echo Select a function: -echo 1. Initialize the database (includes migration) -echo 2. Run the application -echo 3. Create admin account -echo 4. Update database schema -echo 5. Exit +echo 1. Run the application +echo 2. Apply database migrations +echo 3. Reset database (warning: this will delete all data) +echo 4. Exit echo. -set /p choice=Your choice (1-5): +set /p choice=Your choice (1-4): if "%choice%"=="1" ( - echo Initializing the database and performing migration... - python run.py --init --migrate - echo. - echo Press any key to return to the menu... - pause >nul - goto menu -) -if "%choice%"=="2" ( echo Starting the application... python run.py --clean goto end ) -if "%choice%"=="3" ( - echo Creating admin account... - python run.py --admin +if "%choice%"=="2" ( + echo Applying database migrations... + python run.py --migrate echo. echo Press any key to return to the menu... pause >nul goto menu ) -if "%choice%"=="4" ( - echo Updating database schema... - python run.py --check-db --update-db - echo. - echo Press any key to return to the menu... - pause >nul +if "%choice%"=="3" ( + echo WARNING: This will delete all existing data in the database. + set /p confirm="Are you sure you want to proceed? (Y/N) " + if /i "%confirm%"=="y" ( + echo Resetting database... + python run.py --reset + echo. + echo Press any key to return to the menu... + pause >nul + ) goto menu ) -if "%choice%"=="5" ( +if "%choice%"=="4" ( goto end ) else ( echo Invalid choice! -- GitLab