diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000000000000000000000000000000000000..e5d39a7e22ac3d0af7424a4896bb3c5f0b23fe77 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,7 @@ +.git +__pycache__/ +*.pyc +.vscode/ +migrations/ +*.db +*.log diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000000000000000000000000000000000000..3cbc66ef54811ffebdfecb9907ae2328da1146c0 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,46 @@ +# Dockerfile + +# Use an official Python runtime as a parent image +# Using slim-buster for a smaller image size +FROM python:3.11-slim-buster + +# Set the working directory in the container +WORKDIR /app + +# Prevent python from writing pyc files to disc (optional) +ENV PYTHONDONTWRITEBYTECODE 1 +# Ensure python output is sent straight to terminal (useful for logs) +ENV PYTHONUNBUFFERED 1 + +# Install system dependencies first (cached if they don't change often) +RUN apt-get update && apt-get install -y --no-install-recommends \ + build-essential \ + cmake \ + pkg-config \ + libcairo2-dev \ + && rm -rf /var/lib/apt/lists/* + +# Copy only the requirements file first to leverage Docker cache +COPY requirements.txt . + +# Install Python dependencies +# This layer is cached and only re-run if requirements.txt changes +RUN pip install --no-cache-dir -r requirements.txt + +# Now copy the rest of the application code +# This needs to happen *after* pip install to leverage caching +COPY . . + +# Make port 5000 available to the world outside this container +# This is the port Gunicorn will listen on INSIDE the container +EXPOSE 5000 + +# Define environment variables (can be overridden at runtime) +# Example: Setting Flask environment to production +ENV FLASK_ENV production +# You SHOULD set SECRET_KEY, DATABASE_URL etc. via docker run -e or docker-compose environment section + +# Run wsgi.py when the container launches using Gunicorn +# Use 0.0.0.0 to allow connections from outside the container +# Adjust the number of workers (-w) based on your EC2 instance resources +CMD ["gunicorn", "--bind", "0.0.0.0:5000", "-w", "4", "wsgi:app"] \ No newline at end of file diff --git a/README.md b/README.md index 21b6e9bfa7bb2e0458d26d2a5810accc92518abb..7eb25f554531463caebde4db0a524bdf3650f761 100644 --- a/README.md +++ b/README.md @@ -162,7 +162,3 @@ Dự án được phát triển bởi nhóm sinh viên năm 2 tại Đại học 3. Commit thay đổi của bạn (`git commit -m 'Add some amazing feature'`) 4. Push lên nhánh (`git push origin feature/amazing-feature`) 5. Tạo Pull Request - -## Giấy phép - -Phần mềm này được phân phối dưới giấy phép MIT. Xem tệp LICENSE để biết thêm thông tin. diff --git a/app/__init__.py b/app/__init__.py index b830505c688020d34ac68375ba07010c0c67a80a..9a46639c6b837ff32b797781e1fa86f3b46b58e8 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -69,13 +69,18 @@ def create_app(config_class=None): # Có thể bỏ config_class hoặc dùng ch # silent=True để không báo lỗi nếu file không tồn tại app.config.from_pyfile('config.py', silent=True) - # --- Quan trọng: Đảm bảo các cấu hình cần thiết được thiết lập --- - # Nếu không có trong file config.py, cần có giá trị mặc định ở đây - app.config.setdefault('SECRET_KEY', 'a_default_secret_key_change_me_123') # Changed default key - app.config.setdefault('SQLALCHEMY_DATABASE_URI', 'mysql://root:MinhDZ3009@localhost/ccu') - app.config.setdefault('SQLALCHEMY_TRACK_MODIFICATIONS', False) - app.config.setdefault('UPLOAD_FOLDER', os.path.join(app.instance_path, 'uploads')) - app.config.setdefault('WTF_CSRF_ENABLED', True) + # --- Quan trọng: Thiết lập cấu hình từ biến môi trường hoặc giá trị mặc định --- + app.config['SECRET_KEY'] = os.environ.get('SECRET_KEY', 'replace_with_strong_key_in_production') + + # Ưu tiên đọc từ biến môi trường DATABASE_URL, nếu không có thì dùng giá trị mặc định + # Đảm bảo giá trị mặc định cũng dùng đúng driver 'mysql+mysqlconnector' + default_db_uri = 'mysql+mysqlconnector://ccuuser:MinhDZ3009@localhost/ccu' # Thay your_local_password nếu cần + app.config['SQLALCHEMY_DATABASE_URI'] = os.environ.get('DATABASE_URL', default_db_uri) + + app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False + app.config['UPLOAD_FOLDER'] = os.environ.get('UPLOAD_FOLDER', os.path.join(app.instance_path, 'uploads')) + app.config['MAX_CONTENT_LENGTH'] = int(os.environ.get('MAX_CONTENT_LENGTH', 16 * 1024 * 1024)) # Default 16MB + app.config['WTF_CSRF_ENABLED'] = True # Nên luôn bật CSRF # Tạo thư mục UPLOAD_FOLDER nếu chưa có upload_folder = app.config['UPLOAD_FOLDER'] @@ -89,6 +94,9 @@ def create_app(config_class=None): # Có thể bỏ config_class hoặc dùng ch print(f"Error creating upload folder {upload_folder}: {e}") # Log lỗi nếu không tạo được # ----------------------------------------------------------------- + # Print the final DB URI being used before initializing db + print(f"DEBUG: SQLALCHEMY_DATABASE_URI before db.init_app: {app.config.get('SQLALCHEMY_DATABASE_URI')}") + # Liên kết các extensions với app db.init_app(app) login_manager.init_app(app) diff --git a/app/forms/dietitian_forms.py b/app/forms/dietitian_forms.py index c2fd31e77e232b2457afe06c27c6f2ad62da2204..685419b6e912c2c9627952a844d849b4488f965d 100644 --- a/app/forms/dietitian_forms.py +++ b/app/forms/dietitian_forms.py @@ -1,18 +1,34 @@ from flask_wtf import FlaskForm -from wtforms import StringField, PasswordField, TextAreaField, SubmitField, ValidationError -from wtforms.validators import DataRequired, Email, Length, EqualTo +from wtforms import StringField, PasswordField, TextAreaField, SubmitField, ValidationError, SelectField +from wtforms.validators import DataRequired, Email, Length, EqualTo, Optional from app.models import User +from app.models.dietitian import DietitianStatus class DietitianForm(FlaskForm): - firstName = StringField('Họ', validators=[DataRequired(), Length(max=50)]) - lastName = StringField('Tên', validators=[DataRequired(), Length(max=50)]) + firstName = StringField('First Name', validators=[DataRequired(), Length(max=50)]) + lastName = StringField('Last Name', validators=[DataRequired(), Length(max=50)]) email = StringField('Email', validators=[DataRequired(), Email(), Length(max=100)]) - password = PasswordField('Mật khẩu', validators=[DataRequired(), Length(min=6)]) - phone = StringField('Số điện thoại', validators=[Length(max=20)]) - specialization = StringField('Chuyên môn', validators=[Length(max=100)]) - notes = TextAreaField('Ghi chú') - submit = SubmitField('Tạo tài khoản') + password = PasswordField('Password', validators=[DataRequired(), Length(min=6)]) + phone = StringField('Phone Number', validators=[Length(max=20)]) + specialization = StringField('Specialization', validators=[Length(max=100)]) + notes = TextAreaField('Notes') + submit = SubmitField('Create Account') def validate_email(self, field): if User.query.filter_by(email=field.data).first(): - raise ValidationError('Email này đã được sử dụng.') \ No newline at end of file + raise ValidationError('This email is already in use.') + +class EditDietitianForm(FlaskForm): + firstName = StringField('First Name', validators=[DataRequired(), Length(max=50)]) + lastName = StringField('Last Name', validators=[DataRequired(), Length(max=50)]) + email = StringField('Email', validators=[DataRequired(), Email(), Length(max=100)]) + phone = StringField('Phone Number', validators=[Length(max=20)]) + specialization = StringField('Specialization', validators=[Length(max=100)]) + notes = TextAreaField('Notes') + submit = SubmitField('Update Information') + + def validate_email(self, field): + # Kiểm tra email đã tồn tại nhưng bỏ qua email hiện tại của dietitian + user = User.query.filter_by(email=field.data).first() + if user and user.email != self.email.default: + raise ValidationError('This email is already in use by another user.') \ No newline at end of file diff --git a/app/routes/dietitians.py b/app/routes/dietitians.py index f986117d27ae6bffd97fb27b9f54506da6479190..12cbd2615a0d545469679c7f83f97fa2cc5c4830 100644 --- a/app/routes/dietitians.py +++ b/app/routes/dietitians.py @@ -2,13 +2,14 @@ from flask import Blueprint, render_template, request, redirect, url_for, flash, from flask_login import login_required, current_user from app import db from app.models.dietitian import Dietitian, DietitianStatus -from app.models.patient import Patient +from app.models.patient import Patient, PatientStatus from app.models.encounter import Encounter from sqlalchemy import desc, or_, func, text from datetime import datetime from app.models.user import User from flask import current_app -from app.forms.dietitian_forms import DietitianForm +from app.forms.dietitian_forms import DietitianForm, EditDietitianForm +from app.utils.decorators import permission_required dietitians_bp = Blueprint('dietitians', __name__, url_prefix='/dietitians') @@ -113,7 +114,12 @@ def new(): email=form.email.data, role='Dietitian' # Mặc định là Dietitian ) - new_user.set_password(form.password.data) # Hash mật khẩu từ form + if hasattr(form, 'password') and form.password.data: + new_user.set_password(form.password.data) + else: + flash('Mật khẩu là bắt buộc khi tạo mới.', 'danger') + return render_template('dietitians/new.html', form=form) + db.session.add(new_user) db.session.flush() # Lấy userID sau khi add @@ -123,11 +129,14 @@ def new(): firstName=form.firstName.data, # Đồng bộ tên lastName=form.lastName.data, # Đồng bộ họ email=form.email.data, # Đồng bộ email - phone=form.phone.data, - specialization=form.specialization.data, - notes=form.notes.data, + phone=form.phone.data if hasattr(form, 'phone') else None, + specialization=form.specialization.data if hasattr(form, 'specialization') else None, + notes=form.notes.data if hasattr(form, 'notes') else None, status=DietitianStatus.AVAILABLE # Mặc định là AVAILABLE ) + if hasattr(form, 'phone') and form.phone.data: + new_user.phone = form.phone.data + db.session.add(new_dietitian) db.session.commit() @@ -136,85 +145,99 @@ def new(): except Exception as e: db.session.rollback() current_app.logger.error(f"Lỗi khi tạo tài khoản dietitian: {e}") - flash(f'Lỗi khi tạo tài khoản: {str(e)}', 'error') + flash(f'Lỗi khi tạo tài khoản: {str(e)}', 'danger') # GET request hoặc form validation thất bại - return render_template('dietitians/new.html', form=form) # Truyền form vào template + return render_template('dietitians/new.html', form=form) @dietitians_bp.route('/<int:id>/edit', methods=['GET', 'POST']) @login_required def edit(id): - """Chỉnh sửa chuyên gia dinh dưỡng""" + """Edit a dietitian (Admin view)""" 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.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': - # 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) - + user = dietitian.user # Get the associated user + + # --- Permissions --- + # Only Admin can access this route directly + if not current_user.is_admin: + # If a dietitian is trying to edit their own profile, redirect them + if user and user.userID == current_user.userID: + return redirect(url_for('auth.edit_profile')) + else: + flash('You do not have permission to access this page.', 'danger') + return redirect(url_for('dietitians.index')) + # ----------------- + + # Use EditDietitianForm (without password or status field) + form = EditDietitianForm(obj=dietitian) + + if form.validate_on_submit(): try: - # 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 + # Update dietitian information from form + dietitian.firstName = form.firstName.data + dietitian.lastName = form.lastName.data + dietitian.email = form.email.data + dietitian.phone = form.phone.data + dietitian.specialization = form.specialization.data + dietitian.notes = form.notes.data - # Đồ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 + # No more status update - status is managed elsewhere + # Synchronize changes to linked User (if exists) + if user: + user.firstName = form.firstName.data + user.lastName = form.lastName.data + user.email = form.email.data # Sync User email + user.phone = form.phone.data + db.session.commit() - flash('Cập nhật thông tin thành công!', 'success') + flash('Information updated successfully!', '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) + current_app.logger.error(f"Error updating dietitian {id}: {e}") + flash(f'An error occurred during update: {str(e)}', 'danger') - # 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) + # Render template, pass form to context + return render_template('dietitians/edit_admin.html', + dietitian=dietitian, + form=form) @dietitians_bp.route('/<int:id>/delete', methods=['POST']) @login_required def delete(id): - """Xóa chuyên gia dinh dưỡng""" + """Xóa chuyên gia dinh dưỡng và User liên kết""" + # --- Quyền hạn: Chỉ Admin --- + if not current_user.is_admin: + flash('Bạn không có quyền thực hiện hành động này.', 'danger') + return redirect(url_for('dietitians.index')) + # -------------------------- + dietitian = Dietitian.query.get_or_404(id) + user_to_delete = dietitian.user # Lấy user liên kết + # Kiểm tra xem dietitian có đang quản lý bệnh nhân không + assigned_patient_count = Patient.query.filter_by(assigned_dietitian_user_id=dietitian.user_id).count() + if assigned_patient_count > 0: + flash(f'Không thể xóa chuyên gia dinh dưỡng {dietitian.full_name} vì họ đang quản lý {assigned_patient_count} bệnh nhân. Vui lòng gán lại bệnh nhân trước.', 'warning') + return redirect(url_for('dietitians.show', id=id)) + try: + # Xóa Dietitian profile trước db.session.delete(dietitian) + db.session.flush() # Đảm bảo dietitian bị xóa trước khi xóa user (nếu có ràng buộc) + + # Xóa User liên kết (nếu có) + if user_to_delete: + db.session.delete(user_to_delete) + db.session.commit() - flash('Xóa chuyên gia dinh dưỡng thành công!', 'success') + flash(f'Đã xóa chuyên gia dinh dưỡng {dietitian.firstName} {dietitian.lastName} và tài khoản liên kết thành công!', 'success') except Exception as e: db.session.rollback() - flash(f'Lỗi: {str(e)}', 'error') + current_app.logger.error(f"Error deleting dietitian {id}: {e}") + flash(f'Lỗi khi xóa chuyên gia dinh dưỡng: {str(e)}', 'error') return redirect(url_for('dietitians.index')) @@ -224,67 +247,104 @@ def assign_dietitian(patient_id): """Phân công chuyên gia dinh dưỡng cho bệnh nhân""" patient = Patient.query.filter_by(patient_id=patient_id).first_or_404() - # Tìm encounter hiện tại của bệnh nhân - encounter = Encounter.query.filter_by( - patient_id=patient.id, - dischargeDateTime=None - ).order_by(Encounter.admissionDateTime.desc()).first() - - if not encounter: - flash('Không tìm thấy thông tin nhập viện của bệnh nhân này.', 'error') - return redirect(url_for('patients.patient_detail', patient_id=patient.patient_id)) - - dietitian_id = request.form.get('dietitian_id') - - if dietitian_id: - # Nếu có chỉ định cụ thể dietitian - dietitian = Dietitian.query.get_or_404(int(dietitian_id)) + # Nên dùng ID dạng số của patient (thường là primary key) thay vì patient_id dạng string + # patient = Patient.query.get_or_404(patient_id_int) # Nếu có primary key là số + + # Lấy dietitian_id từ form + dietitian_user_id = request.form.get('dietitian_user_id', type=int) + + if dietitian_user_id: + # Tìm User của dietitian được chọn + dietitian_user = User.query.filter_by(userID=dietitian_user_id, role='Dietitian').first_or_404() else: - # Tự động phân công dietitian có ít bệnh nhân nhất - dietitian = Dietitian.get_least_busy_dietitian() + # Tự động phân công dietitian có ít bệnh nhân nhất (logic cần xem lại) + # Logic get_least_busy_dietitian() cần trả về User object + # dietitian_user = Dietitian.get_least_busy_dietitian_user() # Ví dụ + # if not dietitian_user: + # flash('Không tìm thấy chuyên gia dinh dưỡng khả dụng.', 'error') + # return redirect(url_for('patients.patient_detail', patient_id=patient.patient_id)) # Nên dùng patient_id dạng string + flash('Chức năng tự động phân công chưa được triển khai đầy đủ.', 'warning') + return redirect(url_for('patients.patient_detail', patient_id=patient.patient_id)) # Dùng patient_id dạng string + + # Cập nhật trường assigned_dietitian_user_id của Patient + patient.assigned_dietitian_user_id = dietitian_user.userID + patient.assignment_date = datetime.utcnow() # Ghi nhận thời gian gán + # Cập nhật trạng thái bệnh nhân nếu cần (ví dụ: từ NEW -> NEEDS_ASSESSMENT) + if patient.status == PatientStatus.NEW: + patient.status = PatientStatus.NEEDS_ASSESSMENT - if not dietitian: - flash('Không tìm thấy chuyên gia dinh dưỡng khả dụng.', 'error') - return redirect(url_for('patients.patient_detail', patient_id=patient.patient_id)) - - # Cập nhật encounter với dietitian mới - encounter.dietitian_id = dietitian.id db.session.commit() - flash(f'Đã phân công bệnh nhân {patient.full_name} cho chuyên gia dinh dưỡng {dietitian.full_name}.', 'success') - return redirect(url_for('patients.patient_detail', patient_id=patient.patient_id)) + flash(f'Đã phân công bệnh nhân {patient.full_name} cho chuyên gia dinh dưỡng {dietitian_user.full_name}.', 'success') + return redirect(url_for('patients.patient_detail', patient_id=patient.patient_id)) # Dùng patient_id dạng string @dietitians_bp.route('/<int:id>/patients') -def patients(id): +@login_required # Chỉ cần login, không cần quyền cụ thể để xem profile +def patients_list(id): # Đổi tên hàm để tránh trùng lặp + """Hiển thị danh sách bệnh nhân của một chuyên gia dinh dưỡng cụ thể (theo dietitianID)""" dietitian = Dietitian.query.get_or_404(id) - patients = Patient.query.filter(Patient.dietitian_id == id).all() - return render_template('dietitians/patients.html', dietitian=dietitian, patients=patients) + + # Lấy danh sách bệnh nhân được gán qua user_id + if dietitian.user_id: + assigned_patients = Patient.query.filter_by(assigned_dietitian_user_id=dietitian.user_id)\ + .order_by(Patient.lastName, Patient.firstName).all() + else: + assigned_patients = [] # Nếu dietitian chưa có user liên kết thì không có BN nào + + # Render template khác hoặc phần riêng trong show.html + # Ví dụ: return render_template('dietitians/_assigned_patients.html', patients=assigned_patients) + # Hoặc nếu dùng trong show.html thì không cần route riêng này + flash('Route này có thể không cần thiết nếu danh sách bệnh nhân đã hiển thị trong trang show.', 'info') + return redirect(url_for('.show', id=id)) + +@dietitians_bp.route('/<int:dietitian_id>/assign_patient/<string:patient_id>', methods=['POST']) +@login_required +@permission_required('ADMIN') # Chỉ Admin mới được gán BN tuỳ ý +def assign_patient_manual(dietitian_id, patient_id): + """Gán một bệnh nhân cụ thể cho một chuyên gia dinh dưỡng (Admin)""" + patient = Patient.query.filter_by(patient_id=patient_id).first_or_404() + dietitian = Dietitian.query.get_or_404(dietitian_id) + + if not dietitian.user_id: + flash('Chuyên gia dinh dưỡng này chưa có tài khoản người dùng liên kết.', 'error') + return redirect(url_for('dietitians.show', id=dietitian_id)) -@dietitians_bp.route('/<int:id>/assign_patient/<int:patient_id>', methods=['POST']) -def assign_patient(id, patient_id): try: - patient = Patient.query.get_or_404(patient_id) - patient.dietitian_id = id + patient.assigned_dietitian_user_id = dietitian.user_id + patient.assignment_date = datetime.utcnow() + if patient.status == PatientStatus.NEW: + patient.status = PatientStatus.NEEDS_ASSESSMENT db.session.commit() - flash(f'Bệnh nhân {patient.fullName} đã được gán cho chuyên gia dinh dưỡng này!', 'success') + flash(f'Bệnh nhân {patient.full_name} đã được gán cho {dietitian.full_name}!', 'success') 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)) + return redirect(url_for('dietitians.show', id=dietitian_id)) -@dietitians_bp.route('/<int:id>/unassign_patient/<int:patient_id>', methods=['POST']) +@dietitians_bp.route('/<int:dietitian_id>/unassign_patient/<string:patient_id>', methods=['POST']) @login_required -def unassign_patient(id, patient_id): - """Bỏ phân công bệnh nhân""" +@permission_required('ADMIN') # Chỉ Admin mới được bỏ gán +def unassign_patient_manual(dietitian_id, patient_id): + """Bỏ phân công bệnh nhân khỏi chuyên gia dinh dưỡng (Admin)""" + patient = Patient.query.filter_by(patient_id=patient_id).first_or_404() + dietitian = Dietitian.query.get_or_404(dietitian_id) + + if not dietitian.user_id: + flash('Chuyên gia dinh dưỡng này không có tài khoản người dùng liên kết.', 'warning') + return redirect(url_for('dietitians.show', id=dietitian_id)) + try: - patient = Patient.query.get_or_404(patient_id) - if patient.dietitian_id == id: - patient.dietitian_id = None + if patient.assigned_dietitian_user_id == dietitian.user_id: + patient.assigned_dietitian_user_id = None + patient.assignment_date = None # Xóa ngày gán + # Cân nhắc chuyển trạng thái bệnh nhân về NEW hoặc giữ nguyên + # if patient.status == PatientStatus.NEEDS_ASSESSMENT: + # patient.status = PatientStatus.NEW db.session.commit() - flash(f'Bệnh nhân {patient.fullName} đã được gỡ bỏ khỏi chuyên gia dinh dưỡng này!', 'success') + flash(f'Bệnh nhân {patient.full_name} đã được gỡ bỏ khỏi {dietitian.full_name}!', 'success') else: - flash('Bệnh nhân không được gán cho chuyên gia dinh dưỡng này!', 'error') + flash('Bệnh nhân này không được gán cho chuyên gia dinh dưỡng này!', 'error') 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)) \ No newline at end of file + return redirect(url_for('dietitians.show', id=dietitian_id)) \ No newline at end of file diff --git a/app/templates/_macros.html b/app/templates/_macros.html index 2102e42caf877b9227061f2b2f59d77059ef82d8..f60ab57ec52d723db58405444501a17476900a93 100644 --- a/app/templates/_macros.html +++ b/app/templates/_macros.html @@ -29,27 +29,27 @@ {% endmacro %} {% macro status_badge(status_value) %} - {% set status_str = status_value | string | upper %} - {% set badge_class = 'bg-gray-100 text-gray-800' %} {# Default #} - {% set status_text = status_str.replace('_', ' ').title() %} - - {% if status_str == 'AVAILABLE' or status_str == 'ACTIVE' or status_str == 'COMPLETED' or status_str == 'SUCCESS' %} - {% set badge_class = 'bg-green-100 text-green-800' %} - {% elif status_str == 'UNAVAILABLE' or status_str == 'INACTIVE' or status_str == 'FAILED' or status_str == 'ERROR' %} - {% set badge_class = 'bg-red-100 text-red-800' %} - {% elif status_str == 'PENDING' or status_str == 'NEEDS_ASSESSMENT' or status_str == 'IN_PROGRESS' %} - {% set badge_class = 'bg-yellow-100 text-yellow-800' %} - {% elif status_str == 'ON_LEAVE' %} - {% set badge_class = 'bg-blue-100 text-blue-800' %} - {% set status_text = 'On Leave' %} - {% elif status_str == 'DRAFT' %} - {% set badge_class = 'bg-purple-100 text-purple-800' %} - {% elif status_str == 'ASSESSMENT_IN_PROGRESS' %} - {% set badge_class = 'bg-indigo-100 text-indigo-800' %} + {% if status_value == "AVAILABLE" %} + <span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-green-100 text-green-800"> + {{ status_value.replace("_", " ").title() }} + </span> + {% elif status_value == "BUSY" %} + <span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-yellow-100 text-yellow-800"> + {{ status_value.replace("_", " ").title() }} + </span> + {% elif status_value == "ON_LEAVE" %} + <span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-orange-100 text-orange-800"> + {{ status_value.replace("_", " ").title() }} + </span> + {% elif status_value == "UNAVAILABLE" %} + <span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-red-100 text-red-800"> + {{ status_value.replace("_", " ").title() }} + </span> + {% else %} + <span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-gray-100 text-gray-800"> + {{ status_value.replace("_", " ").title() }} + </span> {% endif %} - <span class="px-2.5 py-0.5 inline-flex text-xs leading-5 font-semibold rounded-full {{ badge_class }}"> - {{ status_text }} - </span> {% endmacro %} {% macro patient_status_badge(status) %} @@ -138,4 +138,44 @@ </div> {% endif %} {% endwith %} +{% endmacro %} + +{% macro render_form_field(field) %} + {% if field.type != 'CSRFTokenField' and field.type != 'SubmitField' %} + <div class="form-group mb-4"> + {% if field.type == 'BooleanField' %} + <div class="flex items-center"> + {{ field(class="h-4 w-4 text-primary-600 focus:ring-primary-500 border-gray-300 rounded") }} + {{ field.label(class="ml-2 block text-sm text-gray-900") }} + </div> + {% else %} + {{ field.label(class="block text-sm font-medium text-gray-700 mb-1") }} + {% if field.type == 'TextAreaField' %} + {{ field(class="shadow-sm focus:ring-primary-500 focus:border-primary-500 block w-full sm:text-sm border-gray-300 rounded-md", rows=4) }} + {% elif field.type == 'SelectField' %} + {{ field(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") }} + {% else %} + {{ field(class="shadow-sm focus:ring-primary-500 focus:border-primary-500 block w-full sm:text-sm border-gray-300 rounded-md") }} + {% endif %} + {% endif %} + + {% if field.errors %} + <div class="mt-1"> + {% for error in field.errors %} + <p class="text-sm text-red-600">{{ error }}</p> + {% endfor %} + </div> + {% endif %} + + {% if field.description %} + <p class="mt-1 text-sm text-gray-500">{{ field.description }}</p> + {% endif %} + </div> + {% elif field.type == 'CSRFTokenField' %} + {{ field }} + {% elif field.type == 'SubmitField' %} + <button type="submit" class="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"> + {{ field.label.text }} + </button> + {% endif %} {% endmacro %} \ No newline at end of file diff --git a/app/templates/admin/edit_profile.html b/app/templates/admin/edit_profile.html index cbe1e54d4fb45ca0815a2a0142b5732e4f739df6..754aa32e226b26d441e6979317af4affbb023a32 100644 --- a/app/templates/admin/edit_profile.html +++ b/app/templates/admin/edit_profile.html @@ -44,11 +44,11 @@ <div class="grid grid-cols-1 gap-y-6 gap-x-4 sm:grid-cols-6"> <div class="sm:col-span-3"> - {{ render_field(form.firstName, label_text="Tên") }} + {{ render_field(form.firstName, label_text="First Name") }} </div> <div class="sm:col-span-3"> - {{ render_field(form.lastName, label_text="Họ") }} + {{ render_field(form.lastName, label_text="Last Name") }} </div> <div class="sm:col-span-4"> diff --git a/app/templates/base.html b/app/templates/base.html index ca9f8fc4b067caa41f147ca6ee334c5f56ad7e56..419abcf096ece45c59141755fcb4f7ad52a7582e 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -5,6 +5,7 @@ <meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="csrf-token" content="{{ csrf_token }}"> <title>{% block title %}CCU HTM{% endblock %}</title> + <link rel="icon" type="image/png" href="{{ url_for('static', filename='img/logo.png') }}"> <link href="https://cdn.jsdelivr.net/npm/tailwindcss@2.2.19/dist/tailwind.min.css" rel="stylesheet"> <link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet"> <script src="https://cdn.jsdelivr.net/npm/chart.js@3.7.1/dist/chart.min.js"></script> diff --git a/app/templates/dietitians/create.html b/app/templates/dietitians/create.html deleted file mode 100644 index 45671da57daf9a2c26b6ef651bc89696077d2db9..0000000000000000000000000000000000000000 --- a/app/templates/dietitians/create.html +++ /dev/null @@ -1,97 +0,0 @@ -{% extends "base.html" %} - -{% block title %}Thêm chuyên gia dinh dưỡng - CCU HTM{% endblock %} - -{% block header %}Thêm chuyên gia dinh dưỡng mới{% 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 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">Thêm mới</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">Thông tin chuyên gia dinh dưỡng</h3> - <p class="mt-1 max-w-2xl text-sm text-gray-500">Nhập thông tin chi tiết cho chuyên gia dinh dưỡng mới.</p> - </div> - - <form method="POST" action="{{ url_for('dietitians.store') }}" 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" 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" 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" 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" 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" 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"></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" selected>Khả dụng</option> - <option value="unavailable">Không khả dụng</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.index') }}" 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"> - Thêm mới - </button> - </div> - </div> - </form> - </div> - </div> -</div> -{% endblock %} \ No newline at end of file diff --git a/app/templates/dietitians/edit_admin.html b/app/templates/dietitians/edit_admin.html index c57cd1a9b607040d0845b58c6862c35e59cefaa1..450e4d824dab51eab9667a0071e7f63e437b18f7 100644 --- a/app/templates/dietitians/edit_admin.html +++ b/app/templates/dietitians/edit_admin.html @@ -1,6 +1,8 @@ {% extends "base.html" %} +{% from "_macros.html" import render_form_field, status_badge %} -{% block title %}Chỉnh sửa chuyên gia dinh dưỡng - Admin{% endblock %} +{% block title %}Edit Dietitian - Admin{% endblock %} +{% block header %}Edit Dietitian Profile{% endblock %} {% block content %} <div class="container mx-auto px-4 py-8 animate-fade-in"> @@ -16,84 +18,62 @@ <li> <div class="flex items-center"> <i class="fas fa-chevron-right text-gray-400 mx-2"></i> - <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> + <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">Dietitians</a> </div> </li> <li aria-current="page"> <div class="flex items-center"> <i class="fas fa-chevron-right text-gray-400 mx-2"></i> - <span class="ml-1 text-sm font-medium text-gray-500 md:ml-2">Chỉnh sửa: {{ dietitian.full_name }}</span> + <span class="ml-1 text-sm font-medium text-gray-500 md:ml-2">Editing: {{ dietitian.firstName }} {{ dietitian.lastName }}</span> </div> </li> </ol> </nav> </div> - <h1 class="text-2xl font-semibold text-gray-800 mb-6">Chỉnh sửa thông tin: {{ dietitian.full_name }} ({{ dietitian.formattedID }})</h1> + <h1 class="text-2xl font-bold leading-7 text-gray-900 sm:text-2xl sm:truncate">Editing {{ dietitian.firstName }} {{ dietitian.lastName }} ({{ dietitian.formattedID }})'s Information</h1> <form method="POST" action="{{ url_for('dietitians.edit', id=dietitian.dietitianID) }}" class="bg-white shadow-md rounded-lg px-8 pt-6 pb-8 mb-4"> - {# Assume CSRF token is handled by Flask-WTF or you add it manually #} - {# <input type="hidden" name="csrf_token" value="{{ csrf_token() }}"> #} + {{ form.csrf_token }} <div class="grid grid-cols-1 md:grid-cols-2 gap-6 mb-6"> <!-- First Name --> <div> - <label for="firstName" class="block text-sm font-medium text-gray-700 mb-1">Họ <span class="text-red-500">*</span></label> - <input type="text" id="firstName" name="firstName" value="{{ dietitian.firstName or '' }}" required - class="shadow-sm focus:ring-blue-500 focus:border-blue-500 block w-full sm:text-sm border-gray-300 rounded-md"> + {{ render_form_field(form.firstName) }} </div> <!-- Last Name --> <div> - <label for="lastName" class="block text-sm font-medium text-gray-700 mb-1">Tên <span class="text-red-500">*</span></label> - <input type="text" id="lastName" name="lastName" value="{{ dietitian.lastName or '' }}" required - class="shadow-sm focus:ring-blue-500 focus:border-blue-500 block w-full sm:text-sm border-gray-300 rounded-md"> + {{ render_form_field(form.lastName) }} </div> <!-- Email --> <div> - <label for="email" class="block text-sm font-medium text-gray-700 mb-1">Email <span class="text-red-500">*</span></label> - <input type="email" id="email" name="email" value="{{ dietitian.email or '' }}" required - class="shadow-sm focus:ring-blue-500 focus:border-blue-500 block w-full sm:text-sm border-gray-300 rounded-md"> + {{ render_form_field(form.email) }} </div> <!-- Phone --> <div> - <label for="phone" class="block text-sm font-medium text-gray-700 mb-1">Số điện thoại</label> - <input type="tel" id="phone" name="phone" value="{{ dietitian.phone or '' }}" - class="shadow-sm focus:ring-blue-500 focus:border-blue-500 block w-full sm:text-sm border-gray-300 rounded-md"> + {{ render_form_field(form.phone) }} </div> <!-- Specialization --> <div class="md:col-span-2"> - <label for="specialization" class="block text-sm font-medium text-gray-700 mb-1">Chuyên môn</label> - <input type="text" id="specialization" name="specialization" value="{{ dietitian.specialization or '' }}" - class="shadow-sm focus:ring-blue-500 focus:border-blue-500 block w-full sm:text-sm border-gray-300 rounded-md"> - </div> - - <!-- Status (Read-only for Admin in this form - maybe changed via separate action) --> - <div> - <label class="block text-sm font-medium text-gray-700 mb-1">Trạng thái</label> - <span class="inline-flex items-center px-3 py-1.5 rounded-full text-sm font-medium bg-{{ dietitian.get_status_badge_class() }}-100 text-{{ dietitian.get_status_badge_class() }}-800"> - {{ dietitian.status.value if dietitian.status else 'Không xác định' }} - </span> - <p class="text-xs text-gray-500 mt-1">Trạng thái hiện không thể thay đổi trực tiếp tại đây.</p> + {{ render_form_field(form.specialization) }} </div> <!-- Notes --> <div class="md:col-span-2"> - <label for="notes" class="block text-sm font-medium text-gray-700 mb-1">Ghi chú</label> - <textarea id="notes" name="notes" rows="4" - class="shadow-sm focus:ring-blue-500 focus:border-blue-500 block w-full sm:text-sm border border-gray-300 rounded-md">{{ dietitian.notes or '' }}</textarea> + {{ render_form_field(form.notes) }} </div> </div> <div class="flex items-center justify-end space-x-4"> <a href="{{ url_for('dietitians.index') }}" class="bg-gray-200 hover:bg-gray-300 text-gray-800 font-medium py-2 px-4 rounded-md transition duration-300"> - Hủy bỏ + Cancel </a> <button type="submit" class="bg-blue-600 hover:bg-blue-700 text-white font-medium py-2 px-4 rounded-md transition duration-300"> - <i class="fas fa-save mr-2"></i> Lưu thay đổi + <i class="fas fa-save mr-2"></i> Save Changes </button> </div> </form> diff --git a/app/templates/dietitians/index.html b/app/templates/dietitians/index.html index ecb4fbf208d223deb9f2d3cbfd6e39bdfd5c6ab4..829a57734498018391cc3e8dc26f9a6f7c6db148 100644 --- a/app/templates/dietitians/index.html +++ b/app/templates/dietitians/index.html @@ -111,7 +111,8 @@ <a href="{{ url_for('dietitians.show', id=current_dietitian.dietitianID) }}" class="text-blue-600 hover:text-blue-800 mr-3 transition duration-200">Detail</a> <a href="{{ url_for('auth.edit_profile') }}" class="text-indigo-600 hover:text-indigo-900 mr-3 transition duration-200">Edit</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?');"> + <form action="{{ url_for('dietitians.delete', id=current_dietitian.dietitianID) }}" method="POST" class="inline-block" onsubmit="return confirm('Are you sure that you want to delete this Dietitian account?');"> + <input type="hidden" name="csrf_token" value="{{ csrf_token() }}"> <button type="submit" class="text-red-600 hover:text-red-900 transition duration-200">Xóa</button> </form> {% endif %} @@ -170,7 +171,8 @@ <a href="{{ url_for('dietitians.show', id=dietitian.dietitianID) }}" class="text-primary-600 hover:text-primary-900 mr-3 transition duration-200">Detail</a> {% if current_user.is_admin %} <a href="{{ url_for('dietitians.edit', id=dietitian.dietitianID) }}" class="text-indigo-600 hover:text-indigo-900 mr-3 transition duration-200">Edit</a> - <form action="{{ url_for('dietitians.delete', id=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?');"> + <form action="{{ url_for('dietitians.delete', id=dietitian.dietitianID) }}" method="POST" class="inline-block" onsubmit="return confirm('Are you sure that you want to delete this Dietitian account?');"> + <input type="hidden" name="csrf_token" value="{{ csrf_token() }}"> <button type="submit" class="text-red-600 hover:text-red-900 transition duration-200">Delete</button> </form> {% endif %} diff --git a/app/templates/dietitians/new.html b/app/templates/dietitians/new.html index 9613147ad0983349d59383d4cb779ed39d81596d..7bf31ffbb761cce18c1bdb5cbf97c76e0f6b2f75 100644 --- a/app/templates/dietitians/new.html +++ b/app/templates/dietitians/new.html @@ -1,7 +1,7 @@ {% extends "base.html" %} {% from "_formhelpers.html" import render_field %} -{% block title %}Thêm chuyên gia dinh dưỡng mới - Admin{% endblock %} +{% block title %}Add New Dietitian - Admin{% endblock %} {% block content %} <div class="container mx-auto px-4 py-8 animate-fade-in"> @@ -17,56 +17,56 @@ <li> <div class="flex items-center"> <i class="fas fa-chevron-right text-gray-400 mx-2"></i> - <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> + <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">Dietitians</a> </div> </li> <li aria-current="page"> <div class="flex items-center"> <i class="fas fa-chevron-right text-gray-400 mx-2"></i> - <span class="ml-1 text-sm font-medium text-gray-500 md:ml-2">Thêm mới</span> + <span class="ml-1 text-sm font-medium text-gray-500 md:ml-2">Add New</span> </div> </li> </ol> </nav> </div> - <h1 class="text-2xl font-semibold text-gray-800 mb-6">Tạo tài khoản chuyên gia dinh dưỡng mới</h1> + <h1 class="text-2xl font-semibold text-gray-800 mb-6">Create New Dietitian Account</h1> <form method="POST" action="{{ url_for('dietitians.new') }}" class="bg-white shadow-md rounded-lg px-8 pt-6 pb-8 mb-4"> {{ form.csrf_token }} - <h3 class="text-lg font-medium text-gray-900 mb-4 border-b pb-2">Thông tin tài khoản (User)</h3> + <h3 class="text-lg font-medium text-gray-900 mb-4 border-b pb-2">Account Information (User)</h3> <div class="grid grid-cols-1 md:grid-cols-2 gap-6 mb-6"> <!-- First Name --> - {{ render_field(form.firstName, label_text="Họ") }} + {{ render_field(form.firstName) }} <!-- Last Name --> - {{ render_field(form.lastName, label_text="Tên") }} + {{ render_field(form.lastName) }} <!-- Email --> - {{ render_field(form.email, label_text="Email") }} + {{ render_field(form.email) }} <!-- Password --> - {{ render_field(form.password, label_text="Mật khẩu") }} - <div class="md:col-span-2"><p class="text-xs text-gray-500 -mt-4">Mật khẩu tạm thời cho người dùng mới.</p></div> + {{ render_field(form.password) }} + <div class="md:col-span-2"><p class="text-xs text-gray-500 -mt-4">Temporary password for the new user.</p></div> </div> - <h3 class="text-lg font-medium text-gray-900 mb-4 border-b pb-2">Thông tin hồ sơ (Dietitian Profile)</h3> + <h3 class="text-lg font-medium text-gray-900 mb-4 border-b pb-2">Profile Information (Dietitian Profile)</h3> <div class="grid grid-cols-1 md:grid-cols-2 gap-6 mb-6"> <!-- Phone --> - {{ render_field(form.phone, label_text="Số điện thoại") }} + {{ render_field(form.phone) }} <!-- Specialization --> - {{ render_field(form.specialization, label_text="Chuyên môn") }} + {{ render_field(form.specialization) }} <!-- Notes --> <div class="md:col-span-2"> - <label for="notes" class="block text-sm font-medium text-gray-700 mb-1">Ghi chú</label> + <label for="notes" class="block text-sm font-medium text-gray-700 mb-1">Notes</label> {{ form.notes(rows="3", class="shadow-sm focus:ring-blue-500 focus:border-blue-500 block w-full sm:text-sm border border-gray-300 rounded-md") }} </div> </div> <div class="flex items-center justify-end space-x-4"> <a href="{{ url_for('dietitians.index') }}" class="bg-gray-200 hover:bg-gray-300 text-gray-800 font-medium py-2 px-4 rounded-md transition duration-300"> - Hủy bỏ + Cancel </a> <button type="submit" class="bg-blue-600 hover:bg-blue-700 text-white font-medium py-2 px-4 rounded-md transition duration-300"> - <i class="fas fa-user-plus mr-2"></i> Tạo tài khoản + <i class="fas fa-user-plus mr-2"></i> Create Account </button> </div> </form> diff --git a/app/templates/dietitians/show.html b/app/templates/dietitians/show.html index 01cf33bb65c3d233db70409c1dcb7e9dcf6c3cbf..1b22033c30026415a4bed438cdd9746ff2210c6e 100644 --- a/app/templates/dietitians/show.html +++ b/app/templates/dietitians/show.html @@ -51,6 +51,7 @@ {% endif %} {% if current_user.is_admin %} <form action="{{ url_for('dietitians.delete', id=dietitian.dietitianID) }}" method="POST" onsubmit="return confirm('Are you sure you want to delete this dietitian?');" class="inline-block"> + <input type="hidden" name="csrf_token" value="{{ csrf_token() }}"> <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" /> diff --git a/ccu_htm_db_schema.png b/ccu_htm_db_schema.png new file mode 100644 index 0000000000000000000000000000000000000000..81067858690cbc90f6a1bab913aa0545cd050e3c Binary files /dev/null and b/ccu_htm_db_schema.png differ diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000000000000000000000000000000000000..359ebb09785cac28c3d346e726763da48dec324c --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,80 @@ +version: '3.8' + +services: + app: + build: . + container_name: ccu_htm_app + restart: always + depends_on: + db: + condition: service_healthy # Wait for DB healthcheck + ports: + - "${PORT:-5000}:5000" # Use PORT from .env or default to 5000 + volumes: + - ./uploads:/app/uploads # Mount uploads folder + - ./instance:/app/instance # Mount instance folder (if needed for config/db) + # Migrations folder is part of the image, not usually mounted + env_file: + - .env + environment: + # Override DATABASE_URL to use the service name 'db' and correct driver + - DATABASE_URL=mysql+mysqlconnector://${MYSQL_USER}:${MYSQL_PASSWORD}@db:3306/${MYSQL_DATABASE} + - FLASK_APP=${FLASK_APP} + - FLASK_ENV=${FLASK_ENV} + - SECRET_KEY=${SECRET_KEY} + - PORT=5000 # Internal port Gunicorn listens on + networks: + - app-network + # Removed healthcheck for app as it might depend on readiness + + db: + image: mysql:8.0 + container_name: ccu_htm_db + restart: always + # Removed port mapping for DB - not needed if only app connects + volumes: + - mysql_data:/var/lib/mysql + # Uncomment if you have init scripts + # - ./sql:/docker-entrypoint-initdb.d + env_file: + - .env + environment: + # Pass variables directly from .env + - MYSQL_DATABASE=${MYSQL_DATABASE} + - MYSQL_USER=${MYSQL_USER} + - MYSQL_PASSWORD=${MYSQL_PASSWORD} + - MYSQL_ROOT_PASSWORD=${MYSQL_ROOT_PASSWORD} + networks: + - app-network + command: --default-authentication-plugin=mysql_native_password --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci + healthcheck: + test: ["CMD", "mysqladmin" ,"ping", "-h", "localhost", "-u$${MYSQL_USER}", "-p$${MYSQL_PASSWORD}"] # Use user/pass for ping + interval: 10s + timeout: 5s + retries: 10 # Increased retries + start_period: 30s + + nginx: + image: nginx:alpine # Use alpine for smaller size + container_name: ccu_htm_nginx + restart: always + ports: + - "80:80" + - "443:443" + volumes: + - ./nginx/conf.d:/etc/nginx/conf.d:ro # Mount config read-only + - ./nginx/ssl:/etc/nginx/ssl:ro # Mount ssl certs read-only + # Static files should ideally be served by Nginx if collected + # - ./staticfiles:/app/static # Example if static files are collected here + depends_on: + - app + networks: + - app-network + +networks: + app-network: + driver: bridge + +volumes: + mysql_data: + # driver: local # Default is local \ No newline at end of file diff --git a/generate_architecture_diagram.py b/generate_architecture_diagram.py new file mode 100644 index 0000000000000000000000000000000000000000..4380a643a00e70464ecfc7552cf9aada5d0a1efc --- /dev/null +++ b/generate_architecture_diagram.py @@ -0,0 +1,78 @@ +from diagrams import Diagram, Cluster, Edge +from diagrams.programming.framework import Flask +from diagrams.programming.language import Python +from diagrams.onprem.database import MySQL # Sử dụng MySQL vì cấu hình của bạn là MySQL +from diagrams.onprem.network import Nginx # Hoặc một web server khác nếu bạn dùng +from diagrams.onprem.client import User, Client # User và Browser +from diagrams.onprem.compute import Server # Server tổng quát +from diagrams.generic.storage import Storage # Cho static/templates +from diagrams.generic.device import Mobile # Ví dụ client khác + +# Đặt tên file output không có phần mở rộng +diagram_filename = "ccu_htm_architecture" + +# Hướng của biểu đồ: TB (Top to Bottom), LR (Left to Right) +diagram_direction = "TB" + +with Diagram(f"{diagram_filename}", show=False, direction=diagram_direction, filename=diagram_filename) as diag: + # --- Clients --- + user = User("End User") + browser = Client("Web Browser") + # mobile_app = Mobile("Mobile App (Future?)") # Ví dụ nếu có app di động + + # --- Infrastructure / Deployment --- + # Giả sử dùng Nginx làm reverse proxy và Gunicorn làm WSGI server + # Nếu chỉ chạy Flask dev server, bạn có thể bỏ qua Nginx/Gunicorn + # nginx = Nginx("Nginx\n(Reverse Proxy)") + # gunicorn = Server("Gunicorn\n(WSGI Server)") + + # --- Backend Application --- + with Cluster("Flask Application (Python Backend)"): + flask_app = Flask("Flask App Core") + # Các thành phần chính trong app/ + with Cluster("Application Logic"): + routes = Python("Routes\n(Blueprints)") + forms = Python("Forms\n(Flask-WTF)") + models = Python("Models\n(SQLAlchemy)") + utils = Python("Utilities / Decorators") + + # Template và Static files + with Cluster("Presentation"): + templates = Storage("Jinja2 Templates") + static_files = Storage("Static Files\n(CSS, JS, Images)") + macros = Storage("Macros") + + # Liên kết nội bộ trong Flask App + flask_app >> routes + routes >> models + routes >> forms + routes >> templates + routes >> utils + templates >> macros # Templates import macros + templates >> static_files # Templates link tới static + + # --- Database --- + database = MySQL("MySQL Database\n(ccu)") + + # --- Configuration --- + config = Storage("Configuration\n(instance/config.py, .env)") + + # --- Connections --- + user >> browser + + # Nếu dùng Nginx/Gunicorn: + # browser >> nginx >> gunicorn >> flask_app + + # Nếu chỉ dùng Flask dev server: + browser >> Edge(label="HTTP Requests") >> flask_app + + # Backend connections + flask_app >> Edge(label="Reads config") >> config + models >> Edge(label="CRUD Operations") >> database + + # Frontend response + flask_app >> Edge(label="HTML Response") >> browser + +diag # Hiển thị biểu đồ (nếu show=True) hoặc chỉ tạo file + +print(f"Diagram generated: {diagram_filename}.png") diff --git a/generate_db_schema_diagram.py b/generate_db_schema_diagram.py new file mode 100644 index 0000000000000000000000000000000000000000..9d5dcc023c29697fb4d940712e080d4f76c8e410 --- /dev/null +++ b/generate_db_schema_diagram.py @@ -0,0 +1,86 @@ +from diagrams import Diagram, Cluster, Edge, Node +from diagrams.onprem.database import MySQL # Chỉ cần MySQL icon + +# Đặt tên file output không có phần mở rộng +diagram_filename = "ccu_htm_db_schema" + +# Hướng của biểu đồ: TB (Top to Bottom), LR (Left to Right) +diagram_direction = "LR" + +with Diagram(f"{diagram_filename}", show=False, direction=diagram_direction, filename=diagram_filename) as diag: + # Định nghĩa database cluster + with Cluster("CCU HTM Database Schema (MySQL: ccu)") as db_cluster: + # Định nghĩa các bảng chính + user_table = Node("User\n(users)\nPK: userID") + dietitian_table = Node("Dietitian\n(dietitians)\nPK: dietitianID") + patient_table = Node("Patient\n(patients)\nPK: patientID") + encounter_table = Node("Encounter\n(encounters)\nPK: encounterID") + measurement_table = Node("PhysiologicalMeasurement\n(physiologicalmeasurements)\nPK: measurementID") + procedure_table = Node("Procedure\n(procedures)\nPK: procedureID") + referral_table = Node("Referral\n(referrals)\nPK: referralID") + report_table = Node("Report\n(reports)\nPK: reportID") + support_msg_table = Node("SupportMessage\n(support_messages)\nPK: messageID") + activity_log_table = Node("ActivityLog\n(activity_logs)\nPK: logID") + + # Định nghĩa các mối quan hệ từ models + + # 1. User <-> Dietitian (One-to-One) + user_table << Edge(label="1..1 (user_id)") >> dietitian_table + + # 2. User (Dietitian) <-> Patient (One-to-Many) + user_table << Edge(label="1..N (assigned_dietitian_user_id)") << patient_table + + # 3. Patient <-> Encounter (One-to-Many) + patient_table >> Edge(label="1..N (patient_id)") >> encounter_table + + # 4. Patient <-> PhysiologicalMeasurement (One-to-Many) + patient_table >> Edge(label="1..N (patient_id)") >> measurement_table + + # 5. Patient <-> Procedure (One-to-Many) + patient_table >> Edge(label="1..N (patient_id)") >> procedure_table + + # 6. Patient <-> Referral (One-to-Many) + patient_table >> Edge(label="1..N (patient_id)") >> referral_table + + # 7. Patient <-> Report (One-to-Many) + patient_table >> Edge(label="1..N (patient_id)") >> report_table + + # 8. Encounter <-> PhysiologicalMeasurement (One-to-Many) + encounter_table >> Edge(label="1..N (encounter_id)") >> measurement_table + + # 9. Encounter <-> Procedure (One-to-Many) + encounter_table >> Edge(label="1..N (encounter_id)") >> procedure_table + + # 10. Encounter <-> Referral (One-to-Many) + encounter_table >> Edge(label="1..N (encounter_id)") >> referral_table + + # 11. Encounter <-> Report (One-to-Many) + encounter_table >> Edge(label="1..N (encounter_id)") >> report_table + + # 12. User <-> Report (One-to-Many) - author relationship + user_table >> Edge(label="1..N (author_id)") >> report_table + + # 13. User <-> Report (One-to-Many) - dietitian assignment + user_table >> Edge(label="1..N (dietitian_id)", style="dashed") >> report_table + + # 14. User <-> Referral (One-to-Many) - dietitian assignment + user_table >> Edge(label="1..N (assigned_dietitian_user_id)", style="dashed") >> referral_table + + # 15. User <-> Encounter (One-to-Many) - dietitian assignment + user_table >> Edge(label="1..N (dietitian_id)", style="dashed") >> encounter_table + + # 16. User <-> SupportMessage (One-to-Many) + user_table >> Edge(label="1..N (sender)") >> support_msg_table + + # 17. User <-> ActivityLog (One-to-Many) + user_table >> Edge(label="1..N (user_id)") >> activity_log_table + + # 18. Procedure <-> Report (One-to-Many) + procedure_table >> Edge(label="1..N (related_procedure_id)") >> report_table + + # 19. Referral <-> Report (One-to-One) + referral_table >> Edge(label="1..1 (referral_id)") >> report_table + +diag + +print(f"Comprehensive database schema diagram generated: {diagram_filename}.png") \ No newline at end of file diff --git a/nginx/conf.d/default.conf b/nginx/conf.d/default.conf new file mode 100644 index 0000000000000000000000000000000000000000..090adba5e786b9edd706bccbd4e8ae432ed8ca0f --- /dev/null +++ b/nginx/conf.d/default.conf @@ -0,0 +1,46 @@ +server { + listen 80; + # Replace with your actual domain name + server_name demoblog.ccuhuantrungminh.site www.demoblog.ccuhuantrungminh.site; + + # Optional: Redirect www to non-www (or vice-versa) + # if ($host = www.demoblog.ccuhuantrungminh.site) { + # return 301 https://demoblog.ccuhuantrungminh.site$request_uri; + # } + + # Serve static files directly if they exist (adjust path if needed) + # location /static/ { + # alias /app/static/; + # expires 1d; # Add caching headers for static files + # } + + # Pass all other requests to the Flask/Gunicorn app service + location / { + proxy_pass http://app:5000; # Match the internal port Gunicorn listens on + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + # Optional: Increase timeouts for long-running requests + # proxy_connect_timeout 600; + # proxy_send_timeout 600; + # proxy_read_timeout 600; + # send_timeout 600; + } + + # Optional: Add location block for handling uploads if needed directly via Nginx + # location /uploads/ { + # alias /app/uploads/; + # } + + # Add HTTPS configuration later using certbot/Let's Encrypt + # For now, only HTTP is configured +} + +# Optional: Add another server block for www redirection if not handled above +# server { +# listen 80; +# server_name www.demoblog.ccuhuantrungminh.site; +# return 301 https://demoblog.ccuhuantrungminh.site$request_uri; +# } \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 43852f9a197652224e64a4d6b7d93be7d0f44d64..33bd4990110db41df8e3738b6219b14e0dafc7f3 100644 Binary files a/requirements.txt and b/requirements.txt differ diff --git a/run.py b/run.py index 4d4dcaaf801b07d855119bc4c342916e5b85bc45..947c5f4d713198f6cda4a0fb05474bf47290d3a9 100644 --- a/run.py +++ b/run.py @@ -11,6 +11,7 @@ from flask import Flask from datetime import datetime from flask_migrate import Migrate, init, migrate, upgrade, stamp import json # Import json +from urllib.parse import urlparse # Import urlparse # Import models and status needed for reset from app.models.user import User @@ -34,27 +35,50 @@ def clear_cache(): 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'): + uri_str = app.config['SQLALCHEMY_DATABASE_URI'] + + # Parse the URI using urllib.parse + parsed_uri = urlparse(uri_str) + + # Ensure it's a MySQL URI (can handle mysql+mysqlconnector, etc.) + if not parsed_uri.scheme.startswith('mysql'): print("[Skip] Not a MySQL URI.") return True - creds, path = uri.replace('mysql://', '').split('@') - user, pwd = creds.split(':') - host, db = path.split('/') + # Extract connection details safely + db_user = parsed_uri.username + db_password = parsed_uri.password + db_host = parsed_uri.hostname + db_port = parsed_uri.port or 3306 # Default MySQL port if not specified + db_name = parsed_uri.path.lstrip('/') # Remove leading slash from path - connection = mysql.connector.connect(host=host, user=user, password=pwd) + if not all([db_user, db_password, db_host, db_name]): + print("[DB Error] Could not parse all required components from the URI.") + print(f" User: {db_user}, Pass: {'******' if db_password else None}, Host: {db_host}, DB: {db_name}") + return False + + print(f"[DB] Attempting to connect to host: {db_host}:{db_port} as user: {db_user}") + connection = mysql.connector.connect( + host=db_host, + port=db_port, + user=db_user, + password=db_password + ) 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") + print(f"[DB] Creating/verifying database '{db_name}'...") + cursor.execute(f"CREATE DATABASE IF NOT EXISTS `{db_name}` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci") cursor.close() connection.close() - print(f"[DB] Database '{db}' created/verified.") + print(f"[DB] Database '{db_name}' created/verified.") return True + except mysql.connector.Error as err: + print(f"[DB Error] MySQL connection/creation error: {err}") + return False except Exception as e: - print(f"[DB Error] {e}") + print(f"[DB Error] Unexpected error during DB creation: {e}") return False def init_tables_and_admin(app, db): @@ -103,20 +127,29 @@ def perform_database_reset(app, db, bcrypt): return False # Get database connection information - uri = app.config['SQLALCHEMY_DATABASE_URI'] - if not uri.startswith('mysql'): + uri_str = app.config['SQLALCHEMY_DATABASE_URI'] + parsed_uri = urlparse(uri_str) + + if not parsed_uri.scheme.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('/') + # Extract connection details safely + user_db = parsed_uri.username + pwd = parsed_uri.password + host = parsed_uri.hostname + port = parsed_uri.port or 3306 + database = parsed_uri.path.lstrip('/') + + if not all([user_db, pwd, host, database]): + print("[DB Error] Could not parse all required components from the URI for reset.") + return False # Connect directly to database - print(f"[DB] Connecting to MySQL database '{database}'...") + print(f"[DB] Connecting to MySQL database '{database}' on {host}:{port} for reset...") connection = mysql.connector.connect( host=host, + port=port, user=user_db, # Use renamed variable password=pwd, database=database diff --git a/wsgi.py b/wsgi.py new file mode 100644 index 0000000000000000000000000000000000000000..a0cc3777ea261492aee49e5223edf6b539bed659 --- /dev/null +++ b/wsgi.py @@ -0,0 +1,19 @@ +# wsgi.py +import os +from dotenv import load_dotenv + +# Load environment variables from .env file if it exists +# This is useful for local development but might not be needed in production +# if environment variables are set directly. +dotenv_path = os.path.join(os.path.dirname(__file__), '.env') +if os.path.exists(dotenv_path): + load_dotenv(dotenv_path) + +from app import create_app + +# Create the Flask app instance +# The create_app function should handle reading configuration +# (e.g., from environment variables) +app = create_app() + +# Gunicorn (or other WSGI servers) will look for this 'app' variable \ No newline at end of file