From ad3dee4b598c8801d2844f94e070f1c509f276f4 Mon Sep 17 00:00:00 2001 From: muoimeo <bvnminh6a01@gmail.com> Date: Tue, 22 Apr 2025 19:24:48 +0700 Subject: [PATCH] 99% maybe last one --- app/routes/patients.py | 130 +++++++++++++++--- app/templates/encounter_measurements.html | 6 +- app/templates/new_patient.html | 1 + app/templates/patient_detail.html | 11 +- app/templates/patients.html | 6 +- app/templates/report_form.html | 2 +- migrations/versions/8116b7d4aede_initital.py | 103 -------------- .../versions/fb934f468320_add_ml_pred.py | 50 ------- migrations/versions/fe0cbf650625_add.py | 64 --------- requirements.txt | Bin 755 -> 12936 bytes 10 files changed, 126 insertions(+), 247 deletions(-) delete mode 100644 migrations/versions/8116b7d4aede_initital.py delete mode 100644 migrations/versions/fb934f468320_add_ml_pred.py delete mode 100644 migrations/versions/fe0cbf650625_add.py diff --git a/app/routes/patients.py b/app/routes/patients.py index bd4904b..98deb4d 100644 --- a/app/routes/patients.py +++ b/app/routes/patients.py @@ -1356,7 +1356,8 @@ def new_encounter(patient_id): create_notification_for_admins( f"New encounter ({custom_id}) for patient {patient.full_name} by {current_user.full_name}.", admin_link, - exclude_user_id=current_user.userID) + exclude_user_id=current_user.userID + ) if (patient.assigned_dietitian_user_id and patient.assigned_dietitian_user_id != current_user.userID): @@ -1480,18 +1481,83 @@ def delete_encounter(patient_id, encounter_pk): create_notification(dietitian_id_assigned, dietitian_message, admin_link) # --- THÊM LOGIC HỦY ASSIGNMENT --- + from app.models.patient_dietitian_assignment import PatientDietitianAssignment # DI CHUYỂN IMPORT LÊN ĐÂY + unassignment_successful = False # Flag để kiểm tra if dietitian_id_assigned: # Nếu encounter bị xóa có gán dietitian active_assignment = PatientDietitianAssignment.get_active_assignment(patient.id) # Chỉ hủy assignment nếu dietitian của assignment đang active trùng với dietitian của encounter bị xóa if active_assignment and active_assignment.dietitian_id == dietitian_id_assigned: - current_app.logger.info(f"Deactivating assignment {active_assignment.id} for patient {patient.id} due to deletion of encounter {deleted_encounter_id}") - active_assignment.is_active = False - active_assignment.end_date = datetime.utcnow() - db.session.add(active_assignment) - # Gửi thêm thông báo về việc hủy assignment - deactivation_message = f"Your assignment to patient {patient.full_name} was deactivated because the related encounter was deleted." - deactivation_link = ('patients.patient_detail', {'patient_id': patient.id}) - create_notification(dietitian_id_assigned, deactivation_message, deactivation_link) + current_app.logger.info(f"Examining assignment {active_assignment.id} for patient {patient.id} due to deletion of encounter {deleted_encounter_id}") + + # Kiểm tra xem có encounters nào khác của patient này đang gán cho CÙNG dietitian không + other_same_dietitian_encounters = Encounter.query.filter( + Encounter.patient_id == patient.id, + Encounter.encounterID != deleted_encounter_id, + Encounter.dietitian_id == dietitian_id_assigned + ).all() + + # Nếu không còn encounter nào khác có cùng dietitian, hủy gán + if not other_same_dietitian_encounters: + current_app.logger.info(f"No other encounters with dietitian {dietitian_id_assigned} found. Attempting to deactivate assignment.") + try: + # --- BẮT ĐẦU KHỐI TRY RIÊNG CHO UNASSIGNMENT --- + # 1. Hủy assignment hiện tại + active_assignment.is_active = False + active_assignment.end_date = datetime.utcnow() + db.session.add(active_assignment) + + # 2. Cập nhật trường assigned_dietitian_user_id của patient về null + patient.assigned_dietitian_user_id = None + db.session.add(patient) + + # 3. (Số thứ tự cũ là 4) Đảm bảo không còn encounter nào liên kết với dietitian đã bị hủy + encounter_updates = Encounter.query.filter( + Encounter.patient_id == patient.id, + Encounter.dietitian_id == dietitian_id_assigned + ).update({"dietitian_id": None}, synchronize_session=False) + if encounter_updates > 0: + current_app.logger.info(f"Updated {encounter_updates} other encounters to remove dietitian link") + + # 4. (Số thứ tự cũ là 5) Cập nhật các assignment khác có thể vẫn đang active (phòng hờ) + other_active_assignments = PatientDietitianAssignment.query.filter( + PatientDietitianAssignment.patient_id == patient.id, + PatientDietitianAssignment.dietitian_id == dietitian_id_assigned, + PatientDietitianAssignment.is_active == True, + PatientDietitianAssignment.id != active_assignment.id + ).all() + for other_assignment in other_active_assignments: + other_assignment.is_active = False + other_assignment.end_date = datetime.utcnow() + db.session.add(other_assignment) + current_app.logger.info(f"Deactivated additional assignment {other_assignment.id}") + + # 5. (Số thứ tự cũ là 6) Kiểm tra và cập nhật bảng Dietitian + from app.models.dietitian import Dietitian + dietitian = Dietitian.query.filter_by(user_id=dietitian_id_assigned).first() + if dietitian: + dietitian.update_status_based_on_patient_count() # Cần chạy sau khi patient.assigned_dietitian_user_id đã được commit + db.session.add(dietitian) + current_app.logger.info(f"Dietitian {dietitian.formattedID} status marked for update.") + + # *** COMMIT RIÊNG CHO UNASSIGNMENT *** + db.session.commit() + unassignment_successful = True # Đánh dấu thành công + current_app.logger.info(f"Successfully committed unassignment changes for patient {patient.id} and dietitian {dietitian_id_assigned}.") + + # Gửi thông báo *sau khi* commit thành công + deactivation_message = f"Your assignment to patient {patient.full_name} was deactivated because the related encounter was deleted." + deactivation_link = ('patients.patient_detail', {'patient_id': patient.id}) + create_notification(dietitian_id_assigned, deactivation_message, deactivation_link) + + except Exception as unassign_error: + db.session.rollback() # Rollback CHỈ các thay đổi unassignment + current_app.logger.error(f"Error during unassignment process for patient {patient.id}: {unassign_error}", exc_info=True) + flash("An error occurred while unassigning the dietitian. Encounter deleted, but assignment might persist.", "warning") + # --- KẾT THÚC KHỐI TRY RIÊNG CHO UNASSIGNMENT --- + + else: + # Vẫn giữ assignment vì còn encounter khác với cùng dietitian + current_app.logger.info(f"Patient {patient.id} still has {len(other_same_dietitian_encounters)} other encounters with dietitian {dietitian_id_assigned}. Assignment maintained.") elif active_assignment: current_app.logger.info(f"Deleted encounter {deleted_encounter_id} had dietitian {dietitian_id_assigned}, but active assignment {active_assignment.id} is for dietitian {active_assignment.dietitian_id}. Assignment not deactivated.") else: @@ -1544,7 +1610,10 @@ def run_encounter_ml(patient_id, encounter_id): # Xử lý lỗi từ hàm dự đoán if prediction_error: # Sửa lỗi thụt lề - flash(f"ML Prediction Warning/Error: {prediction_error}", "warning") # Sửa lỗi thụt lề + # Format encounter ID as "E-XXXXX-XX" rather than just the numeric ID + encounter_display_id = encounter.custom_encounter_id or f"E-{encounter.encounterID:05d}-01" + error_message = prediction_error.replace(f"encounter {encounter.encounterID}", f"encounter {encounter_display_id}") + flash(f"ML Prediction Warning/Error: {error_message}", "warning") # Sửa lỗi thụt lề # Vẫn tiếp tục để lưu kết quả (có thể là None) if needs_intervention_result is not None: # Chỉ lưu kết quả nếu ML chạy không lỗi cơ bản # Sửa lỗi thụt lề @@ -1654,8 +1723,9 @@ def run_encounter_ml(patient_id, encounter_id): db.session.commit() # Commit tất cả thay đổi (encounter, referral, report, patient status) # Sửa lỗi thụt lề else: # needs_intervention_result is None (lỗi ML) # Sửa lỗi thụt lề - flash("An error occurred during ML prediction. Please check logs.", "danger") # Sửa lỗi thụt lề + # Bỏ thông báo lỗi ở đây vì đã xử lý ở trên với mã lỗi cụ thể # Không commit nếu ML lỗi + pass # Thêm pass để đảm bảo cú pháp đúng khi xóa nội dung của khối # Luôn redirect sau khi xử lý xong khối try (hoặc nếu ML lỗi) return redirect(url_for('patients.encounter_measurements', patient_id=patient_id, encounter_id=encounter_id)) # Sửa lỗi thụt lề @@ -1795,12 +1865,32 @@ def assign_dietitian(patient_id): # --- Update Patient and Related Objects --- if assigned_dietitian: + # --- ADD: Deactivate existing assignments FIRST --- + from app.models.patient_dietitian_assignment import PatientDietitianAssignment + deactivated_count = PatientDietitianAssignment.deactivate_existing_assignments(patient.id) + if deactivated_count: + current_app.logger.info(f"Deactivated {deactivated_count} previous assignment(s) for patient {patient.id}.") + # --- END ADD --- + + # --- ADD: Create NEW active assignment record --- + new_assignment = PatientDietitianAssignment( + patient_id=patient.id, + dietitian_id=assigned_dietitian.userID, + assignment_date=datetime.utcnow(), + is_active=True, + notes=notes # Thêm ghi chú từ form vào assignment + ) + db.session.add(new_assignment) + current_app.logger.info(f"Created new active assignment record for patient {patient.id} and dietitian {assigned_dietitian.userID}.") + # --- END ADD --- + + # Cập nhật thông tin Patient (giữ nguyên) patient.assigned_dietitian_user_id = assigned_dietitian.userID patient.assignment_date = datetime.utcnow() patient.status = PatientStatus.ASSESSMENT_IN_PROGRESS # Update patient status db.session.add(patient) - # Find the latest relevant referral needing assignment and update it + # Cập nhật Referral (giữ nguyên) latest_needing_referral = Referral.query.filter( Referral.patient_id == patient.id, Referral.referral_status == ReferralStatus.DIETITIAN_UNASSIGNED @@ -1817,7 +1907,7 @@ def assign_dietitian(patient_id): else: current_app.logger.warning(f"Could not find a referral needing assignment for patient {patient.id} when assigning dietitian.") - # --- ADD: Update latest ONGOING encounter with the assigned dietitian --- + # Cập nhật Encounter (giữ nguyên) latest_ongoing_encounter = Encounter.query.filter_by( patient_id=patient.id, status=EncounterStatus.ON_GOING @@ -1826,17 +1916,14 @@ def assign_dietitian(patient_id): if latest_ongoing_encounter: latest_ongoing_encounter.dietitian_id = assigned_dietitian.userID db.session.add(latest_ongoing_encounter) - # Đảm bảo refresh lại object để SQLAlchemy cập nhật relationship db.session.flush() - # Nếu chạy mà vẫn không hiện tên dietitian, thử thêm dòng này: db.session.refresh(latest_ongoing_encounter) current_app.logger.info(f"Updated latest ongoing encounter {latest_ongoing_encounter.encounterID} with assigned dietitian {assigned_dietitian.userID}. Relationship loaded: {latest_ongoing_encounter.assigned_dietitian is not None}") else: current_app.logger.warning(f"Could not find an ongoing encounter for patient {patient.id} to assign dietitian to.") - # --- END ADD --- - # --- ADD: Update dietitian_id for related PENDING reports --- - if latest_ongoing_encounter: # Chỉ cập nhật report nếu có encounter liên quan + # Cập nhật Report (giữ nguyên) + if latest_ongoing_encounter: associated_pending_reports = Report.query.filter_by( encounter_id=latest_ongoing_encounter.encounterID, status=ReportStatus.PENDING @@ -1844,16 +1931,16 @@ def assign_dietitian(patient_id): updated_report_count = 0 for report_to_update in associated_pending_reports: - if report_to_update.dietitian_id is None: # Chỉ cập nhật nếu chưa có dietitian + if report_to_update.dietitian_id is None: report_to_update.dietitian_id = assigned_dietitian.userID db.session.add(report_to_update) updated_report_count += 1 if updated_report_count > 0: current_app.logger.info(f"Updated dietitian_id for {updated_report_count} pending reports associated with encounter {latest_ongoing_encounter.encounterID}.") - # --- END ADD --- - # --- Logging and Notifications --- + # Logging và Notifications (giữ nguyên) + # ... (code logging và notification giữ nguyên) # Log Activity log_message = f"Assigned dietitian {assigned_dietitian.full_name} to patient {patient.full_name} ({assignment_type} assignment)." activity = ActivityLog( @@ -2293,3 +2380,4 @@ def download_ml_results(patient_id, encounter_id): return response # --- KẾT THÚC ROUTE TẢI KẾT QUẢ ML --- + \ No newline at end of file diff --git a/app/templates/encounter_measurements.html b/app/templates/encounter_measurements.html index c3ea3ec..e9007b9 100644 --- a/app/templates/encounter_measurements.html +++ b/app/templates/encounter_measurements.html @@ -5,7 +5,7 @@ {% block title %}Encounter #{{ display_encounter_id }} Measurements - {{ patient.full_name }}{% endblock %} -{% block header %}Encounter #{{ display_encounter_id }} Measurements - {{ patient.full_name }}{% endblock %} +{% block header %}Encounter {{ display_encounter_id }} Details{% endblock %} {% block content %} <div class="animate-slide-in container mx-auto px-4 py-8"> @@ -53,7 +53,7 @@ <div class="grid grid-cols-1 md:grid-cols-3 gap-4 text-sm"> <div><strong>Start Time:</strong> {{ encounter.start_time.strftime('%d/%m/%Y %H:%M') if encounter.start_time else 'N/A' }}</div> {# Sửa cách hiển thị status #} - <div><strong>Status:</strong> <span class="px-2 py-0.5 text-xs font-semibold rounded-full bg-{{ status.color if status and status.color else 'gray' }}-100 text-{{ status.color if status and status.color else 'gray' }}-800">{{ status.text if status and status.text else 'Unknown' }}</span></div> + <div><strong>Status:</strong> <span class="px-2 py-0.5 text-xs font-semibold rounded-full bg-{{ encounter_status.color if encounter_status and encounter_status.color else 'gray' }}-100 text-{{ encounter_status.color if encounter_status and encounter_status.color else 'gray' }}-800">{{ encounter_status.text if encounter_status and encounter_status.text else 'Unknown' }}</span></div> {# Chỉ hiển thị Dietitian ở đây #} <div><strong>Dietitian:</strong> {{ encounter.assigned_dietitian.full_name if encounter.assigned_dietitian else 'None' }}</div> @@ -108,7 +108,7 @@ <a href="{{ url_for('patients.patient_detail', patient_id=patient.id) }}#encounters" class="inline-flex items-center px-3 py-1.5 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-all duration-200"> <i class="fas fa-arrow-left mr-1.5"></i> - Quay lại danh sách lượt khám + Return to Encounter List </a> <div class="flex items-center space-x-3"> {# Wrap remaining buttons #} diff --git a/app/templates/new_patient.html b/app/templates/new_patient.html index 69e49ab..90bab75 100644 --- a/app/templates/new_patient.html +++ b/app/templates/new_patient.html @@ -1,6 +1,7 @@ {% extends "base.html" %} {% block title %}Add New Patient{% endblock %} +{% block header %}Add Patient{% endblock %} {% block content %} <div class="container mx-auto px-4 py-6"> diff --git a/app/templates/patient_detail.html b/app/templates/patient_detail.html index cac6f96..ff7c39c 100644 --- a/app/templates/patient_detail.html +++ b/app/templates/patient_detail.html @@ -208,7 +208,7 @@ <div class="flex justify-between"> <dt class="text-sm font-medium text-gray-500">In-charge dietitian</dt> {# Cập nhật để dùng assigned_dietitian từ Patient #} - <dd class="mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2">{{ patient.assigned_dietitian.full_name if patient.assigned_dietitian else 'Chưa gán' }}</dd> + <dd class="mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2">{{ patient.assigned_dietitian.full_name if patient.assigned_dietitian else 'Not yet assigned' }}</dd> </div> </dl> </div> @@ -393,7 +393,7 @@ <th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">ID</th> <th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Time</th> <th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Dietitian</th> - <th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Critical Indicators (Max 3)</th> + <th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Critical Indicators</th> <th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Status</th> <th scope="col" class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">ACTIONS</th> </tr> @@ -1535,6 +1535,13 @@ // Add event listeners for delete procedure buttons (if on this page) // ... (existing delete procedure logic) ... + // Scroll to top after initial tab activation to prevent jumping due to hash + // Use setTimeout to ensure scrolling happens after potential browser hash scroll + setTimeout(() => { + window.scrollTo(0, 0); + console.log("[DOMContentLoaded] Scrolled window to top (after timeout)."); + }, 100); // Delay of 100ms + }); </script> diff --git a/app/templates/patients.html b/app/templates/patients.html index e2c91c7..f613aaf 100644 --- a/app/templates/patients.html +++ b/app/templates/patients.html @@ -133,17 +133,17 @@ <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{{ patient.admission_date.strftime('%Y-%m-%d') if patient.admission_date else 'N/A' }}</td> <td class="px-6 py-4 whitespace-nowrap text-sm font-medium"> <a href="{{ url_for('patients.patient_detail', patient_id=patient.patient_id) }}" class="text-blue-600 hover:text-blue-900 mr-3" title="View Details"> - <i class="fas fa-eye text-2xl"></i> + <i class="fas fa-eye text-xl"></i> </a> {% if patient.status and patient.status.value != 'COMPLETED' %} <a href="{{ url_for('patients.edit_patient', patient_id=patient.patient_id) }}" class="text-indigo-600 hover:text-indigo-900 mr-3" title="Edit Patient"> - <i class="fas fa-edit text-2xl"></i> + <i class="fas fa-edit text-xl"></i> </a> {% endif %} {# Add admin check for delete button #} {% if current_user.is_admin %} <button type="button" data-patient-id="{{ patient.patient_id }}" data-patient-name="{{ patient.full_name }}" class="text-red-600 hover:text-red-900 delete-patient" title="Delete Patient"> - <i class="fas fa-trash text-2xl"></i> + <i class="fas fa-trash text-xl"></i> </button> {% endif %} </td> diff --git a/app/templates/report_form.html b/app/templates/report_form.html index 8fad603..0e1de6e 100644 --- a/app/templates/report_form.html +++ b/app/templates/report_form.html @@ -402,7 +402,7 @@ e.preventDefault(); // Ngăn form submit ngay lập tức // Lấy thông báo xác nhận từ thuộc tính data - const confirmMessage = this.getAttribute('data-confirmation-message') || 'Bạn có chắc chắn muốn thực hiện?'; + const confirmMessage = this.getAttribute('data-confirmation-message') || 'Are you certain the report has been fully completed?'; // Hiển thị hộp thoại xác nhận if (confirm(confirmMessage)) { diff --git a/migrations/versions/8116b7d4aede_initital.py b/migrations/versions/8116b7d4aede_initital.py deleted file mode 100644 index f61f116..0000000 --- a/migrations/versions/8116b7d4aede_initital.py +++ /dev/null @@ -1,103 +0,0 @@ -"""initital - -Revision ID: 8116b7d4aede -Revises: -Create Date: 2025-04-21 13:04:43.442726 - -""" -from alembic import op -import sqlalchemy as sa -from sqlalchemy.dialects import mysql - -# revision identifiers, used by Alembic. -revision = '8116b7d4aede' -down_revision = None -branch_labels = None -depends_on = None - - -def upgrade(): - # ### commands auto generated by Alembic - please adjust! ### - with op.batch_alter_table('support_message_read_status', schema=None) as batch_op: - # Drop constraints FIRST - try: # Use try-except in case constraints don't exist or have different names - batch_op.drop_constraint('support_message_read_status_ibfk_1', type_='foreignkey') - except Exception as e: - print(f"Info: Could not drop FK support_message_read_status_ibfk_1 (might not exist): {e}") - try: - batch_op.drop_constraint('support_message_read_status_ibfk_2', type_='foreignkey') - except Exception as e: - print(f"Info: Could not drop FK support_message_read_status_ibfk_2 (might not exist): {e}") - - # Now drop the index - try: - batch_op.drop_index('uq_user_message_read') - except Exception as e: - print(f"Info: Could not drop Index uq_user_message_read (might not exist): {e}") - - # Drop the table after constraints and indexes are gone - try: - op.drop_table('support_message_read_status') - except Exception as e: - print(f"Info: Could not drop table support_message_read_status (might not exist): {e}") - with op.batch_alter_table('reports', schema=None) as batch_op: - batch_op.alter_column('status', - existing_type=mysql.VARCHAR(collation='utf8mb4_unicode_ci', length=20), - type_=sa.Enum('DRAFT', 'PENDING', 'COMPLETED', 'CANCELLED', name='reportstatus'), - nullable=False) - - with op.batch_alter_table('support_messages', schema=None) as batch_op: - batch_op.alter_column('timestamp', - existing_type=mysql.DATETIME(), - nullable=False) - - with op.batch_alter_table('uploadedfiles', schema=None) as batch_op: - batch_op.alter_column('error_details', - existing_type=mysql.LONGTEXT(collation='utf8mb4_unicode_ci'), - type_=sa.Text(length=16777215), - existing_nullable=True) - - with op.batch_alter_table('users', schema=None) as batch_op: - batch_op.add_column(sa.Column('last_support_visit', sa.DateTime(timezone=True), nullable=True)) - - # ### end Alembic commands ### - - -def downgrade(): - # ### commands auto generated by Alembic - please adjust! ### - with op.batch_alter_table('users', schema=None) as batch_op: - batch_op.drop_column('last_support_visit') - - with op.batch_alter_table('uploadedfiles', schema=None) as batch_op: - batch_op.alter_column('error_details', - existing_type=sa.Text(length=16777215), - type_=mysql.LONGTEXT(collation='utf8mb4_unicode_ci'), - existing_nullable=True) - - with op.batch_alter_table('support_messages', schema=None) as batch_op: - batch_op.alter_column('timestamp', - existing_type=mysql.DATETIME(), - nullable=True) - - with op.batch_alter_table('reports', schema=None) as batch_op: - batch_op.alter_column('status', - existing_type=sa.Enum('DRAFT', 'PENDING', 'COMPLETED', 'CANCELLED', name='reportstatus'), - type_=mysql.VARCHAR(collation='utf8mb4_unicode_ci', length=20), - nullable=True) - - op.create_table('support_message_read_status', - sa.Column('id', mysql.INTEGER(), autoincrement=True, nullable=False), - sa.Column('user_id', mysql.INTEGER(), autoincrement=False, nullable=False), - sa.Column('message_id', mysql.INTEGER(), autoincrement=False, nullable=False), - sa.Column('read_at', mysql.DATETIME(), nullable=True), - sa.ForeignKeyConstraint(['message_id'], ['support_messages.id'], name='support_message_read_status_ibfk_1'), - sa.ForeignKeyConstraint(['user_id'], ['users.userID'], name='support_message_read_status_ibfk_2'), - sa.PrimaryKeyConstraint('id'), - mysql_collate='utf8mb4_unicode_ci', - mysql_default_charset='utf8mb4', - mysql_engine='InnoDB' - ) - with op.batch_alter_table('support_message_read_status', schema=None) as batch_op: - batch_op.create_index('uq_user_message_read', ['user_id', 'message_id'], unique=True) - - # ### end Alembic commands ### diff --git a/migrations/versions/fb934f468320_add_ml_pred.py b/migrations/versions/fb934f468320_add_ml_pred.py deleted file mode 100644 index 7404e7c..0000000 --- a/migrations/versions/fb934f468320_add_ml_pred.py +++ /dev/null @@ -1,50 +0,0 @@ -"""add ml pred - -Revision ID: fb934f468320 -Revises: fe0cbf650625 -Create Date: 2025-04-22 01:02:07.781555 - -""" -from alembic import op -import sqlalchemy as sa -from sqlalchemy.dialects import mysql - -# revision identifiers, used by Alembic. -revision = 'fb934f468320' -down_revision = 'fe0cbf650625' -branch_labels = None -depends_on = None - - -def upgrade(): - # ### commands auto generated by Alembic - please adjust! ### - with op.batch_alter_table('encounters', schema=None) as batch_op: - batch_op.add_column(sa.Column('ml_needs_intervention', sa.Boolean(), nullable=True, comment='Result of ML prediction (True=Needs Intervention)')) - batch_op.add_column(sa.Column('ml_prediction_time', sa.DateTime(), nullable=True, comment='Timestamp of the last ML prediction run')) - batch_op.add_column(sa.Column('ml_top_features_json', sa.JSON(), nullable=True, comment='JSON array of top influential features from SHAP')) - batch_op.add_column(sa.Column('ml_limit_breaches_json', sa.JSON(), nullable=True, comment='JSON array of features breaching physiological limits')) - - with op.batch_alter_table('uploadedfiles', schema=None) as batch_op: - batch_op.alter_column('error_details', - existing_type=mysql.LONGTEXT(collation='utf8mb4_unicode_ci'), - type_=sa.Text(length=16777215), - existing_nullable=True) - - # ### end Alembic commands ### - - -def downgrade(): - # ### commands auto generated by Alembic - please adjust! ### - with op.batch_alter_table('uploadedfiles', schema=None) as batch_op: - batch_op.alter_column('error_details', - existing_type=sa.Text(length=16777215), - type_=mysql.LONGTEXT(collation='utf8mb4_unicode_ci'), - existing_nullable=True) - - with op.batch_alter_table('encounters', schema=None) as batch_op: - batch_op.drop_column('ml_limit_breaches_json') - batch_op.drop_column('ml_top_features_json') - batch_op.drop_column('ml_prediction_time') - batch_op.drop_column('ml_needs_intervention') - - # ### end Alembic commands ### diff --git a/migrations/versions/fe0cbf650625_add.py b/migrations/versions/fe0cbf650625_add.py deleted file mode 100644 index a517bcc..0000000 --- a/migrations/versions/fe0cbf650625_add.py +++ /dev/null @@ -1,64 +0,0 @@ -"""add - -Revision ID: fe0cbf650625 -Revises: 8116b7d4aede -Create Date: 2025-04-21 20:14:07.900955 - -""" -from alembic import op -import sqlalchemy as sa -from sqlalchemy.dialects import mysql - -# revision identifiers, used by Alembic. -revision = 'fe0cbf650625' -down_revision = '8116b7d4aede' -branch_labels = None -depends_on = None - - -def upgrade(): - # ### commands auto generated by Alembic - please adjust! ### - op.create_table('patient_dietitian_assignments', - sa.Column('id', mysql.INTEGER(unsigned=True), nullable=False), - sa.Column('patient_id', sa.String(length=50), nullable=False), - sa.Column('dietitian_id', sa.Integer(), nullable=False), - sa.Column('assignment_date', sa.DateTime(), nullable=False), - sa.Column('end_date', sa.DateTime(), nullable=True), - sa.Column('is_active', sa.Boolean(), nullable=False), - sa.Column('notes', sa.Text(), nullable=True), - sa.ForeignKeyConstraint(['dietitian_id'], ['users.userID'], ondelete='CASCADE'), - sa.ForeignKeyConstraint(['patient_id'], ['patients.patientID'], ondelete='CASCADE'), - sa.PrimaryKeyConstraint('id'), - sa.UniqueConstraint('patient_id', 'dietitian_id', name='uq_patient_dietitian_assignment') - ) - with op.batch_alter_table('patient_dietitian_assignments', schema=None) as batch_op: - batch_op.create_index(batch_op.f('ix_patient_dietitian_assignments_assignment_date'), ['assignment_date'], unique=False) - batch_op.create_index(batch_op.f('ix_patient_dietitian_assignments_dietitian_id'), ['dietitian_id'], unique=False) - batch_op.create_index(batch_op.f('ix_patient_dietitian_assignments_is_active'), ['is_active'], unique=False) - batch_op.create_index(batch_op.f('ix_patient_dietitian_assignments_patient_id'), ['patient_id'], unique=False) - - with op.batch_alter_table('uploadedfiles', schema=None) as batch_op: - batch_op.alter_column('error_details', - existing_type=mysql.LONGTEXT(collation='utf8mb4_unicode_ci'), - type_=sa.Text(length=16777215), - existing_nullable=True) - - # ### end Alembic commands ### - - -def downgrade(): - # ### commands auto generated by Alembic - please adjust! ### - with op.batch_alter_table('uploadedfiles', schema=None) as batch_op: - batch_op.alter_column('error_details', - existing_type=sa.Text(length=16777215), - type_=mysql.LONGTEXT(collation='utf8mb4_unicode_ci'), - existing_nullable=True) - - with op.batch_alter_table('patient_dietitian_assignments', schema=None) as batch_op: - batch_op.drop_index(batch_op.f('ix_patient_dietitian_assignments_patient_id')) - batch_op.drop_index(batch_op.f('ix_patient_dietitian_assignments_is_active')) - batch_op.drop_index(batch_op.f('ix_patient_dietitian_assignments_dietitian_id')) - batch_op.drop_index(batch_op.f('ix_patient_dietitian_assignments_assignment_date')) - - op.drop_table('patient_dietitian_assignments') - # ### end Alembic commands ### diff --git a/requirements.txt b/requirements.txt index 9fe069ec95febee3cd67ed36de2e4e5dd2468719..43852f9a197652224e64a4d6b7d93be7d0f44d64 100644 GIT binary patch literal 12936 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 literal 755 zcmY#ZaL&&yNG!=r%FM|usZ>bItl%n6Eh#N1$<NOzwlOo)Gte{O;v!mEcxsYDT2W$d zYI%N9HkVsYVsW;ut&yIQo-u@@8yx84n3J55np<gWYpiDg7x&FfFG?&)wY4?TGte`E zD)z}w&&;#6HPAEDGlB{^B^Ol|l-SxD>KW)6LdC*E+&}_`dWKx#A#V9axy2B(4Y^Wt z6Ekyk%Mx=kQxZ$^i$G3+`o|@)Brz$mIF&26vbZoOIVUqUuLL9wG6dO;MquY8=A`B( zWhR4dG0-yt1qjFhg@U5|<kaHg%)E4k#Jm)RvdrSr#GK5k#FEVXJg$Poyp+UZkRDS# zW3If?+=5CF$H+v_h$}a-q#!51BquWo<WzG#L$3US)VzYqiX0H%P|t`fATuW?zueZ= zQqNS+fU79AAit<2Cou^UK;S3|Edj+@W@<54L1jrsex7bheo1Ox8ORQhC&N>Vva3={ z(?Pxhg@1Z!US@KBQJ$@>k%69}o&i@<PHJLuhOI3mjteSFsz6dk#(KtFMX80Qsl_G5 zV7(T4MqC9cY1x_3U^n2Z$SJNUFUl-QEdsgLK+lpZDK)XQBr~lvr#Qc~zy#z-LnBDS zaY-#p&B-swP0cG&P*Vs=EiTE-O9#0)wYbFA)?Ck6&k)MfO@kyCLp_kz<ovSKqQvx6 zkQyUBBd(;J#AHxfG6uUnEhjNM)dJ@B%;Nl_5?fnSJ+P&@m7st&(6h8ORN!(;%uX%h RD#^@EO-#?{D$YnO008sV=J@~s -- GitLab