From 38e37bb33efd79358710647a342dab16c6d23784 Mon Sep 17 00:00:00 2001 From: PabloPeitsch Date: Thu, 7 Aug 2025 20:07:17 -0300 Subject: [PATCH 01/13] feat(api): Create CRUD endpoints for absence code management --- app/__init__.py | 2 + app/routes/monthly_log.py | 10 ---- app/routes/settings.py | 103 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 105 insertions(+), 10 deletions(-) create mode 100644 app/routes/settings.py diff --git a/app/__init__.py b/app/__init__.py index a0b9a51..1dc783f 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -5,6 +5,7 @@ from app.routes.main import main from app.routes.manual_entry import manual_entry from app.routes.monthly_log import monthly_log_bp +from app.routes.settings import settings_bp from app.routes.time_log import time_log from app.routes.time_summary import time_summary @@ -21,5 +22,6 @@ def create_app(config_object): app.register_blueprint(time_summary) app.register_blueprint(time_log) app.register_blueprint(monthly_log_bp) + app.register_blueprint(settings_bp) return app diff --git a/app/routes/monthly_log.py b/app/routes/monthly_log.py index b79c296..7ebb094 100644 --- a/app/routes/monthly_log.py +++ b/app/routes/monthly_log.py @@ -15,16 +15,6 @@ def view_monthly_log(): return render_template("monthly_log.html") -@monthly_log_bp.route("/api/absence-codes", methods=["GET"]) -def get_absence_codes(): - """Returns a list of available absence codes.""" - try: - codes = AbsenceCode.query.all() - return jsonify([code.code for code in codes]) - except Exception as e: - return jsonify({"error": str(e)}), 500 - - @monthly_log_bp.route("/api//", methods=["GET"]) def get_monthly_log_data(year, month): """ diff --git a/app/routes/settings.py b/app/routes/settings.py new file mode 100644 index 0000000..2b6ff8c --- /dev/null +++ b/app/routes/settings.py @@ -0,0 +1,103 @@ +from flask import Blueprint, jsonify, render_template, request + +from app.db.database import db +from app.models.models import AbsenceCode + +settings_bp = Blueprint("settings", __name__, url_prefix="/settings") + + +@settings_bp.route("/absences", methods=["GET"]) +def manage_absences_page(): + """Renders the absence code management page.""" + return render_template("settings_absences.html") + + +# --- API Endpoints --- + + +@settings_bp.route("/api/absence-codes", methods=["GET"]) +def get_absence_codes(): + """Returns a list of all available absence codes.""" + try: + codes = AbsenceCode.query.order_by(AbsenceCode.code).all() + return jsonify([{"id": code.id, "code": code.code} for code in codes]) + except Exception as e: + return jsonify({"error": str(e)}), 500 + + +@settings_bp.route("/api/absence-codes", methods=["POST"]) +def create_absence_code(): + """Creates a new absence code.""" + data = request.json + if not data or not data.get("code"): + return jsonify({"error": "Code is required"}), 400 + + new_code_str = data["code"].strip() + if not new_code_str: + return jsonify({"error": "Code cannot be empty"}), 400 + + existing_code = AbsenceCode.query.filter_by(code=new_code_str).first() + if existing_code: + return jsonify({"error": "Code already exists"}), 409 # Conflict + + try: + new_code = AbsenceCode(code=new_code_str) + db.session.add(new_code) + db.session.commit() + return jsonify({"id": new_code.id, "code": new_code.code}), 201 + except Exception as e: + db.session.rollback() + return jsonify({"error": str(e)}), 500 + + +@settings_bp.route("/api/absence-codes/", methods=["PUT"]) +def update_absence_code(code_id): + """Updates an existing absence code.""" + data = request.json + if not data or not data.get("code"): + return jsonify({"error": "Code is required"}), 400 + + new_code_str = data["code"].strip() + if not new_code_str: + return jsonify({"error": "Code cannot be empty"}), 400 + + code_to_update = AbsenceCode.query.get(code_id) + if not code_to_update: + return jsonify({"error": "Code not found"}), 404 + + # Check if the new name conflicts with another existing code + existing_code = AbsenceCode.query.filter( + AbsenceCode.id != code_id, AbsenceCode.code == new_code_str + ).first() + if existing_code: + return jsonify({"error": "Another code with this name already exists"}), 409 + + try: + code_to_update.code = new_code_str + db.session.commit() + return jsonify({"id": code_to_update.id, "code": code_to_update.code}) + except Exception as e: + db.session.rollback() + return jsonify({"error": str(e)}), 500 + + +@settings_bp.route("/api/absence-codes/", methods=["DELETE"]) +def delete_absence_code(code_id): + """Deletes an absence code.""" + code_to_delete = AbsenceCode.query.get(code_id) + if not code_to_delete: + return jsonify({"error": "Code not found"}), 404 + + try: + db.session.delete(code_to_delete) + db.session.commit() + return jsonify({"status": "success"}), 200 + except Exception as e: + db.session.rollback() + # Handle cases where the code is in use (foreign key constraint) + if "violates foreign key constraint" in str(e).lower(): + return ( + jsonify({"error": "Cannot delete code, it is currently in use."}), + 409, + ) + return jsonify({"error": str(e)}), 500 From 494e392487ea3db9df936037661d4c122bbe33b7 Mon Sep 17 00:00:00 2001 From: PabloPeitsch Date: Thu, 7 Aug 2025 20:10:02 -0300 Subject: [PATCH 02/13] feat(ui): Create static page structure for absence code management --- app/templates/base.html | 5 ++ app/templates/settings_absences.html | 83 ++++++++++++++++++++++++++++ 2 files changed, 88 insertions(+) create mode 100644 app/templates/settings_absences.html diff --git a/app/templates/base.html b/app/templates/base.html index 8a7892d..f562f9f 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -45,6 +45,11 @@ Time Logs + diff --git a/app/templates/settings_absences.html b/app/templates/settings_absences.html new file mode 100644 index 0000000..5e04ce0 --- /dev/null +++ b/app/templates/settings_absences.html @@ -0,0 +1,83 @@ +{% extends "base.html" %} + +{% block title %}Absence Code Management{% endblock %} + +{% block content %} +
+ +
+
+
+
Add New Absence Code
+
+
+
+
+ + +
+ +
+
+
+
+ + +
+
+
+
Existing Absence Codes
+
+
+
+ + + + + + + + + + +
Code NameActions
+
+ +
+
+
+
+ + + +{% endblock %} + +{% block scripts %} + +{% endblock %} From 4fa18f7f48d8cdc81ac48ba340d4f32d7badfa03 Mon Sep 17 00:00:00 2001 From: PabloPeitsch Date: Thu, 7 Aug 2025 20:14:07 -0300 Subject: [PATCH 03/13] feat(js): Implement interactive CRUD functionality for absence codes --- app/static/js/settings_absences.js | 148 +++++++++++++++++++++++++++++ 1 file changed, 148 insertions(+) create mode 100644 app/static/js/settings_absences.js diff --git a/app/static/js/settings_absences.js b/app/static/js/settings_absences.js new file mode 100644 index 0000000..76259d5 --- /dev/null +++ b/app/static/js/settings_absences.js @@ -0,0 +1,148 @@ +document.addEventListener('DOMContentLoaded', function () { + const addCodeForm = document.getElementById('addCodeForm'); + const newCodeInput = document.getElementById('newCodeInput'); + const codesTableBody = document.getElementById('codesTableBody'); + const loadingIndicator = document.getElementById('loadingIndicator'); + + // Modal elements + const editCodeModalEl = document.getElementById('editCodeModal'); + const editCodeModal = new bootstrap.Modal(editCodeModalEl); + const editCodeIdInput = document.getElementById('editCodeId'); + const editCodeInput = document.getElementById('editCodeInput'); + const saveEditBtn = document.getElementById('saveEditBtn'); + + const API_URL = '/settings/api/absence-codes'; + + // --- Core Functions --- + + async function fetchCodes() { + loadingIndicator.style.display = 'block'; + codesTableBody.innerHTML = ''; + try { + const response = await fetch(API_URL); + if (!response.ok) throw new Error('Failed to fetch codes'); + const codes = await response.json(); + renderCodes(codes); + } catch (error) { + console.error(error); + codesTableBody.innerHTML = `Error loading codes.`; + } finally { + loadingIndicator.style.display = 'none'; + } + } + + function renderCodes(codes) { + codesTableBody.innerHTML = ''; + if (codes.length === 0) { + codesTableBody.innerHTML = `No absence codes found.`; + return; + } + codes.forEach(code => { + const row = document.createElement('tr'); + row.dataset.id = code.id; + row.innerHTML = ` + ${escapeHTML(code.code)} + + + + + `; + codesTableBody.appendChild(row); + }); + } + + // --- Event Handlers --- + + addCodeForm.addEventListener('submit', async function (e) { + e.preventDefault(); + const code = newCodeInput.value.trim(); + if (!code) return; + + try { + const response = await fetch(API_URL, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ code: code }) + }); + const result = await response.json(); + if (!response.ok) throw new Error(result.error || 'Failed to add code'); + + newCodeInput.value = ''; + await fetchCodes(); // Refresh the list + } catch (error) { + alert(`Error: ${error.message}`); + } + }); + + codesTableBody.addEventListener('click', function (e) { + const target = e.target.closest('button'); + if (!target) return; + + const id = target.dataset.id; + if (target.classList.contains('edit-btn')) { + const code = target.dataset.code; + editCodeIdInput.value = id; + editCodeInput.value = code; + editCodeModal.show(); + } else if (target.classList.contains('delete-btn')) { + if (confirm('Are you sure you want to delete this code? This action cannot be undone.')) { + deleteCode(id); + } + } + }); + + saveEditBtn.addEventListener('click', async function () { + const id = editCodeIdInput.value; + const code = editCodeInput.value.trim(); + if (!id || !code) return; + + try { + const response = await fetch(`${API_URL}/${id}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ code: code }) + }); + const result = await response.json(); + if (!response.ok) throw new Error(result.error || 'Failed to update code'); + + editCodeModal.hide(); + await fetchCodes(); // Refresh the list + } catch (error) { + alert(`Error: ${error.message}`); + } + }); + + async function deleteCode(id) { + try { + const response = await fetch(`${API_URL}/${id}`, { + method: 'DELETE' + }); + const result = await response.json(); + if (!response.ok) throw new Error(result.error || 'Failed to delete code'); + + await fetchCodes(); // Refresh the list + } catch (error) { + alert(`Error: ${error.message}`); + } + } + + // --- Utility --- + function escapeHTML(str) { + return str.replace(/[&<>"']/g, function(match) { + return { + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''' + }[match]; + }); + } + + // --- Initial Load --- + fetchCodes(); +}); From afb291768ff660ce171aaf8581570a062797d57e Mon Sep 17 00:00:00 2001 From: PabloPeitsch Date: Thu, 7 Aug 2025 20:38:42 -0300 Subject: [PATCH 04/13] refactor(js): Update calendar log to use centralized absence code API --- app/static/js/monthly_log.js | 21 +++++++++++---------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/app/static/js/monthly_log.js b/app/static/js/monthly_log.js index 851d573..d1003c7 100644 --- a/app/static/js/monthly_log.js +++ b/app/static/js/monthly_log.js @@ -18,33 +18,34 @@ document.addEventListener('DOMContentLoaded', function() { let currentDate = new Date(); let selectedDates = []; let isMouseDown = false; - let absenceCodes = []; async function fetchAbsenceCodes() { try { - const response = await fetch('/monthly-log/api/absence-codes'); + // Point to the new, centralized API endpoint + const response = await fetch('/settings/api/absence-codes'); if (!response.ok) throw new Error('Failed to fetch absence codes'); - absenceCodes = await response.json(); + // The new API returns a list of objects, so we return that directly + return await response.json(); } catch (error) { console.error(error); + return []; } } - function populateDayTypeSelect() { + function populateDayTypeSelect(absenceCodes) { dayTypeSelect.innerHTML = ''; // Add special "Default" option first dayTypeSelect.add(new Option("(Revert to Default)", "DEFAULT")); dayTypeSelect.add(new Option("Work Day", "Work Day")); if (absenceCodes && absenceCodes.length > 0) { - absenceCodes.forEach(code => { - dayTypeSelect.add(new Option(code.replace(/_/g, ' '), code)); + // Adjust to handle the new object format {id, code} + absenceCodes.forEach(item => { + dayTypeSelect.add(new Option(item.code.replace(/_/g, ' '), item.code)); }); } } - // ... (el resto de las funciones de JS no cambian) - function populateSelectors() { const currentYear = new Date().getFullYear(); const startYear = currentYear - 5; @@ -192,8 +193,8 @@ document.addEventListener('DOMContentLoaded', function() { async function init() { populateSelectors(); updateSelectors(); - await fetchAbsenceCodes(); - populateDayTypeSelect(); + const absenceCodes = await fetchAbsenceCodes(); + populateDayTypeSelect(absenceCodes); await renderCalendar(); } From 1cd45ee4c86427b760aa40f1c9f03f2fed33b311 Mon Sep 17 00:00:00 2001 From: PabloPeitsch Date: Thu, 7 Aug 2025 20:45:47 -0300 Subject: [PATCH 05/13] fix(manual_entry): Dynamically populate absence codes in dropdown --- app/routes/manual_entry.py | 19 +++---------------- app/templates/manual_entry.html | 8 ++++---- 2 files changed, 7 insertions(+), 20 deletions(-) diff --git a/app/routes/manual_entry.py b/app/routes/manual_entry.py index f22a8f6..8a1a816 100644 --- a/app/routes/manual_entry.py +++ b/app/routes/manual_entry.py @@ -14,7 +14,8 @@ @manual_entry.route("/entry", methods=["GET"]) def show_entry_form(): employees = Employee.query.all() - absence_codes = AbsenceCode.query.all() + # Fetch codes dynamically from the database + absence_codes = AbsenceCode.query.order_by(AbsenceCode.code).all() return render_template( "manual_entry.html", employees=employees, absence_codes=absence_codes ) @@ -33,47 +34,34 @@ def save_entry(): if not validate_date(date_str): return jsonify({"error": "Invalid date format"}), 400 - # Skip validation if it's an absence day absence_code = data.get("absence_code") - # For work days, validate entries if absence_code is None: entries = data.get("entries") if entries is None: return jsonify({"error": "Entries are required for work day"}), 400 - - # Check if entries is empty if not entries: return jsonify({"error": "No time entries provided for work day"}), 400 - - # Validate the entries is_valid, error = validate_entries(entries) if not is_valid: return jsonify({"error": error}), 400 - # Convert date string to date object entry_date = datetime.strptime(date_str, "%Y-%m-%d").date() - - # Get employee_id safely employee_id = data.get("employee_id") if employee_id is None: return jsonify({"error": "Employee ID is required"}), 400 - # Check if an entry already exists for this date and employee existing_entry = ScheduleEntry.query.filter_by( employee_id=employee_id, date=entry_date ).first() - # Get entries safely entries = data.get("entries", []) if existing_entry: - # Update existing entry existing_entry.entries = [] if absence_code else entries existing_entry.absence_code = absence_code db.session.commit() else: - # Create new entry schedule_entry = ScheduleEntry( employee_id=employee_id, date=entry_date, @@ -83,7 +71,6 @@ def save_entry(): db.session.add(schedule_entry) db.session.commit() - # Calculate hours if not an absence if absence_code is None: hours = calculate_daily_hours(entries) return jsonify({"status": "success", "hours": hours}) @@ -98,7 +85,7 @@ def get_entry(date): entry = ScheduleEntry.query.filter_by( date=datetime.strptime(date, "%Y-%m-%d").date(), - employee_id=1, # Default employee ID until login is implemented + employee_id=1, ).first() if entry: diff --git a/app/templates/manual_entry.html b/app/templates/manual_entry.html index 47b929b..7a4a19d 100644 --- a/app/templates/manual_entry.html +++ b/app/templates/manual_entry.html @@ -20,13 +20,13 @@
Record Time Entry
-
From 470f1ff4a983b6289f1b7b06c3070712c95b2d61 Mon Sep 17 00:00:00 2001 From: PabloPeitsch Date: Thu, 7 Aug 2025 20:53:46 -0300 Subject: [PATCH 06/13] fix(tests): Update API endpoints and resolve legacy warnings --- tests/test_models.py | 23 ++--------------------- tests/test_monthly_log.py | 30 +++++------------------------- 2 files changed, 7 insertions(+), 46 deletions(-) diff --git a/tests/test_models.py b/tests/test_models.py index b1ff95a..f71cf13 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -8,7 +8,6 @@ class TestModels(unittest.TestCase): def setUp(self): - # Configure a test app and database from app import create_app from app.config.config import Config @@ -27,24 +26,19 @@ def tearDown(self): def test_employee_model(self): with self.app.app_context(): - # Create an employee employee = Employee(name="Test Employee") db.session.add(employee) db.session.commit() - - # Query the employee saved_employee = Employee.query.filter_by(name="Test Employee").first() self.assertIsNotNone(saved_employee) self.assertEqual(saved_employee.name, "Test Employee") def test_schedule_entry_model(self): with self.app.app_context(): - # Create an employee first employee = Employee(name="Test Employee") db.session.add(employee) db.session.commit() - # Create a schedule entry entry_date = date(2025, 3, 16) entry_data = [{"entry": "09:00", "exit": "17:00"}] schedule_entry = ScheduleEntry( @@ -56,22 +50,18 @@ def test_schedule_entry_model(self): db.session.add(schedule_entry) db.session.commit() - # Query the schedule entry saved_entry = ScheduleEntry.query.filter_by(date=entry_date).first() self.assertIsNotNone(saved_entry) self.assertEqual(saved_entry.employee_id, employee.id) self.assertEqual(saved_entry.date, entry_date) self.assertEqual(saved_entry.entries, entry_data) self.assertIsNone(saved_entry.absence_code) - - # Test relationship self.assertEqual(saved_entry.employee, employee) - # Refetch the employee to ensure relationships are loaded - refreshed_employee = Employee.query.get(employee.id) + # Use the modern db.session.get() to avoid legacy warnings + refreshed_employee = db.session.get(Employee, employee.id) self.assertIsNotNone(refreshed_employee) - # Check that the employee has the schedule entry self.assertTrue(hasattr(refreshed_employee, "schedule_entries")) entries_list = list(refreshed_employee.schedule_entries) self.assertEqual(len(entries_list), 1) @@ -79,31 +69,22 @@ def test_schedule_entry_model(self): def test_holiday_model(self): with self.app.app_context(): - # Create a holiday holiday_date = date(2025, 1, 1) holiday = Holiday( date=holiday_date, description="New Year's Day", type="National" ) db.session.add(holiday) db.session.commit() - - # Query the holiday saved_holiday = Holiday.query.filter_by(date=holiday_date).first() self.assertIsNotNone(saved_holiday) - self.assertEqual(saved_holiday.description, "New Year's Day") - self.assertEqual(saved_holiday.type, "National") def test_absence_code_model(self): with self.app.app_context(): - # Create an absence code absence_code = AbsenceCode(code="SICK", description="Sick Leave") db.session.add(absence_code) db.session.commit() - - # Query the absence code saved_code = AbsenceCode.query.filter_by(code="SICK").first() self.assertIsNotNone(saved_code) - self.assertEqual(saved_code.description, "Sick Leave") if __name__ == "__main__": diff --git a/tests/test_monthly_log.py b/tests/test_monthly_log.py index 286915c..95f8c50 100644 --- a/tests/test_monthly_log.py +++ b/tests/test_monthly_log.py @@ -16,29 +16,11 @@ def test_view_monthly_log_page(self, client): assert response.status_code == 200 assert b"Monthly Log Management" in response.data - def test_get_absence_codes_api(self, app): - """Test that the API returns a list of absence codes.""" - with app.app_context(): - db.session.add(AbsenceCode(code="LAR")) - db.session.add(AbsenceCode(code="MEDICAL")) - db.session.commit() - + def test_get_absence_codes_api_is_moved(self, app): + """Test that the old absence codes API URL is no longer available.""" client = app.test_client() response = client.get("/monthly-log/api/absence-codes") - assert response.status_code == 200 - data = json.loads(response.data) - assert "LAR" in data - assert "MEDICAL" in data - - def test_get_absence_codes_api_exception(self, client, mocker): - """Test exception handling for the absence codes API.""" - mocker.patch("app.routes.monthly_log.AbsenceCode.query").all.side_effect = ( - Exception("DB Error") - ) - response = client.get("/monthly-log/api/absence-codes") - assert response.status_code == 500 - data = json.loads(response.data) - assert data["error"] == "DB Error" + assert response.status_code == 404 def test_get_monthly_log_data_api(self, app, default_employee_id): """Test that the API returns correct day types for a month.""" @@ -81,7 +63,6 @@ def test_update_day_types_api_creates_and_reverts(self, app, default_employee_id client = app.test_client() target_date = date(2025, 9, 1) - # Create a new absence entry payload_create = {"dates": [target_date.isoformat()], "day_type": "LAR"} client.post("/monthly-log/api/update-days", json=payload_create) with app.app_context(): @@ -90,14 +71,13 @@ def test_update_day_types_api_creates_and_reverts(self, app, default_employee_id ).first() assert entry is not None and entry.absence_code == "LAR" - # Revert the absence back to a Work Day - payload_revert = {"dates": [target_date.isoformat()], "day_type": "Work Day"} + payload_revert = {"dates": [target_date.isoformat()], "day_type": "DEFAULT"} client.post("/monthly-log/api/update-days", json=payload_revert) with app.app_context(): entry = ScheduleEntry.query.filter_by( date=target_date, employee_id=default_employee_id ).first() - assert entry is not None and entry.absence_code is None + assert entry is None def test_update_day_types_modifies_existing(self, app, default_employee_id): """Test that updating an existing entry to an absence clears its time entries.""" From 0e7a3a4410b47a4354595b9afe8e70489ae08eea Mon Sep 17 00:00:00 2001 From: PabloPeitsch Date: Thu, 7 Aug 2025 21:06:13 -0300 Subject: [PATCH 07/13] fix(tests): Rework settings API tests for correctness and reliability --- tests/test_settings.py | 114 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 114 insertions(+) create mode 100644 tests/test_settings.py diff --git a/tests/test_settings.py b/tests/test_settings.py new file mode 100644 index 0000000..7c1e904 --- /dev/null +++ b/tests/test_settings.py @@ -0,0 +1,114 @@ +import json +from datetime import date + +from app.db.database import db +from app.models.models import AbsenceCode, ScheduleEntry + + +class TestSettingsRoutes: + """Tests for the settings and absence code management feature.""" + + def test_manage_absences_page(self, client): + """Test that the settings page for absences loads correctly.""" + response = client.get("/settings/absences") + assert response.status_code == 200 + assert b"Add New Absence Code" in response.data + + def test_get_absence_codes_api(self, app): + """Test GET all absence codes are returned and ordered correctly.""" + with app.app_context(): + # Create codes out of order to test sorting + db.session.add(AbsenceCode(code="Z-CODE")) + db.session.add(AbsenceCode(code="A-CODE")) + db.session.commit() + + client = app.test_client() + response = client.get("/settings/api/absence-codes") + assert response.status_code == 200 + data = json.loads(response.data) + + # We expect at least the two codes we created + assert len(data) >= 2 + # Verify they are sorted alphabetically by code + assert data[0]["code"] == "A-CODE" + assert data[-1]["code"] == "Z-CODE" + + def test_create_absence_code_api(self, client): + """Test POST to create a new absence code.""" + payload = {"code": "NEW-TEST-CODE"} + response = client.post("/settings/api/absence-codes", json=payload) + + assert response.status_code == 201 + data = json.loads(response.data) + assert data["code"] == "NEW-TEST-CODE" + assert "id" in data + + def test_create_absence_code_api_conflict(self, app): + """Test that creating a code that already exists returns a conflict.""" + client = app.test_client() + # Pre-condition: Create the code first + with app.app_context(): + db.session.add(AbsenceCode(code="EXISTING-CODE")) + db.session.commit() + + payload = {"code": "EXISTING-CODE"} + response = client.post("/settings/api/absence-codes", json=payload) + assert response.status_code == 409 + + def test_update_absence_code_api(self, app): + """Test PUT to update an absence code.""" + client = app.test_client() + # Pre-condition: Create the code to be updated + with app.app_context(): + code = AbsenceCode(code="OLD-NAME") + db.session.add(code) + db.session.commit() + code_id = code.id + + payload = {"code": "NEW-NAME"} + response = client.put(f"/settings/api/absence-codes/{code_id}", json=payload) + assert response.status_code == 200 + data = json.loads(response.data) + assert data["code"] == "NEW-NAME" + + def test_delete_absence_code_api(self, app): + """Test DELETE to remove an absence code.""" + client = app.test_client() + # Pre-condition: Create the code to be deleted + with app.app_context(): + code_to_delete = AbsenceCode(code="TO-DELETE") + db.session.add(code_to_delete) + db.session.commit() + code_id = code_to_delete.id + + response = client.delete(f"/settings/api/absence-codes/{code_id}") + assert response.status_code == 200 + + # Verify it's gone + with app.app_context(): + code = db.session.get(AbsenceCode, code_id) + assert code is None + + def test_delete_absence_code_in_use(self, app, default_employee_id): + """Test that a code in use cannot be deleted.""" + client = app.test_client() + # Pre-conditions: Create the code and a schedule entry that uses it + with app.app_context(): + code_in_use = AbsenceCode(code="IN-USE-CODE") + db.session.add(code_in_use) + db.session.commit() + code_id = code_in_use.id + + # Use a proper date object, not a string + schedule_entry = ScheduleEntry( + employee_id=default_employee_id, + date=date(2025, 10, 10), + absence_code="IN-USE-CODE", + ) + db.session.add(schedule_entry) + db.session.commit() + + response = client.delete(f"/settings/api/absence-codes/{code_id}") + assert response.status_code == 409 + data = json.loads(response.data) + assert "Cannot delete code, it is currently in use" in data["error"] From 15c533b46c8717edc9afc4afe7d6fe6441596b83 Mon Sep 17 00:00:00 2001 From: PabloPeitsch Date: Thu, 7 Aug 2025 21:19:56 -0300 Subject: [PATCH 08/13] fix(tests): Correct missing 'entries' field in settings test --- tests/test_settings.py | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/tests/test_settings.py b/tests/test_settings.py index 7c1e904..312fe43 100644 --- a/tests/test_settings.py +++ b/tests/test_settings.py @@ -17,7 +17,6 @@ def test_manage_absences_page(self, client): def test_get_absence_codes_api(self, app): """Test GET all absence codes are returned and ordered correctly.""" with app.app_context(): - # Create codes out of order to test sorting db.session.add(AbsenceCode(code="Z-CODE")) db.session.add(AbsenceCode(code="A-CODE")) db.session.commit() @@ -27,9 +26,7 @@ def test_get_absence_codes_api(self, app): assert response.status_code == 200 data = json.loads(response.data) - # We expect at least the two codes we created assert len(data) >= 2 - # Verify they are sorted alphabetically by code assert data[0]["code"] == "A-CODE" assert data[-1]["code"] == "Z-CODE" @@ -46,7 +43,6 @@ def test_create_absence_code_api(self, client): def test_create_absence_code_api_conflict(self, app): """Test that creating a code that already exists returns a conflict.""" client = app.test_client() - # Pre-condition: Create the code first with app.app_context(): db.session.add(AbsenceCode(code="EXISTING-CODE")) db.session.commit() @@ -58,7 +54,6 @@ def test_create_absence_code_api_conflict(self, app): def test_update_absence_code_api(self, app): """Test PUT to update an absence code.""" client = app.test_client() - # Pre-condition: Create the code to be updated with app.app_context(): code = AbsenceCode(code="OLD-NAME") db.session.add(code) @@ -74,7 +69,6 @@ def test_update_absence_code_api(self, app): def test_delete_absence_code_api(self, app): """Test DELETE to remove an absence code.""" client = app.test_client() - # Pre-condition: Create the code to be deleted with app.app_context(): code_to_delete = AbsenceCode(code="TO-DELETE") db.session.add(code_to_delete) @@ -84,7 +78,6 @@ def test_delete_absence_code_api(self, app): response = client.delete(f"/settings/api/absence-codes/{code_id}") assert response.status_code == 200 - # Verify it's gone with app.app_context(): code = db.session.get(AbsenceCode, code_id) assert code is None @@ -92,18 +85,17 @@ def test_delete_absence_code_api(self, app): def test_delete_absence_code_in_use(self, app, default_employee_id): """Test that a code in use cannot be deleted.""" client = app.test_client() - # Pre-conditions: Create the code and a schedule entry that uses it with app.app_context(): code_in_use = AbsenceCode(code="IN-USE-CODE") db.session.add(code_in_use) db.session.commit() code_id = code_in_use.id - # Use a proper date object, not a string schedule_entry = ScheduleEntry( employee_id=default_employee_id, date=date(2025, 10, 10), absence_code="IN-USE-CODE", + entries=[] # Add missing non-nullable field ) db.session.add(schedule_entry) db.session.commit() From 0697b9e1c57d29fc00ea7e73a8f6543c7dbb1ddb Mon Sep 17 00:00:00 2001 From: PabloPeitsch Date: Thu, 7 Aug 2025 21:21:09 -0300 Subject: [PATCH 09/13] test(api): Add tests for validation and error paths in settings API --- tests/test_settings.py | 83 +++++++++++++++++++++++++++++++++++++++++- 1 file changed, 82 insertions(+), 1 deletion(-) diff --git a/tests/test_settings.py b/tests/test_settings.py index 312fe43..4b5c9e3 100644 --- a/tests/test_settings.py +++ b/tests/test_settings.py @@ -1,6 +1,8 @@ import json from datetime import date +import pytest + from app.db.database import db from app.models.models import AbsenceCode, ScheduleEntry @@ -40,6 +42,14 @@ def test_create_absence_code_api(self, client): assert data["code"] == "NEW-TEST-CODE" assert "id" in data + @pytest.mark.parametrize( + "payload", [({"code": ""}), ({"code": " "}), ({"wrong_key": "value"}), ({})] + ) + def test_create_absence_code_invalid_payload(self, client, payload): + """Test creating a code with invalid payloads.""" + response = client.post("/settings/api/absence-codes", json=payload) + assert response.status_code == 400 + def test_create_absence_code_api_conflict(self, app): """Test that creating a code that already exists returns a conflict.""" client = app.test_client() @@ -66,6 +76,26 @@ def test_update_absence_code_api(self, app): data = json.loads(response.data) assert data["code"] == "NEW-NAME" + def test_update_absence_code_not_found(self, client): + """Test updating a code that does not exist.""" + response = client.put("/settings/api/absence-codes/999", json={"code": "ANY"}) + assert response.status_code == 404 + + def test_update_absence_code_conflict(self, app): + """Test updating a code to a name that already exists.""" + client = app.test_client() + with app.app_context(): + db.session.add(AbsenceCode(code="CODE-A")) + code_b = AbsenceCode(code="CODE-B") + db.session.add(code_b) + db.session.commit() + code_b_id = code_b.id + + response = client.put( + f"/settings/api/absence-codes/{code_b_id}", json={"code": "CODE-A"} + ) + assert response.status_code == 409 + def test_delete_absence_code_api(self, app): """Test DELETE to remove an absence code.""" client = app.test_client() @@ -82,6 +112,11 @@ def test_delete_absence_code_api(self, app): code = db.session.get(AbsenceCode, code_id) assert code is None + def test_delete_absence_code_not_found(self, client): + """Test deleting a code that does not exist.""" + response = client.delete("/settings/api/absence-codes/999") + assert response.status_code == 404 + def test_delete_absence_code_in_use(self, app, default_employee_id): """Test that a code in use cannot be deleted.""" client = app.test_client() @@ -95,7 +130,7 @@ def test_delete_absence_code_in_use(self, app, default_employee_id): employee_id=default_employee_id, date=date(2025, 10, 10), absence_code="IN-USE-CODE", - entries=[] # Add missing non-nullable field + entries=[], ) db.session.add(schedule_entry) db.session.commit() @@ -104,3 +139,49 @@ def test_delete_absence_code_in_use(self, app, default_employee_id): assert response.status_code == 409 data = json.loads(response.data) assert "Cannot delete code, it is currently in use" in data["error"] + + def test_get_codes_db_error(self, client, mocker): + """Test generic exception for GET endpoint.""" + mocker.patch("app.routes.settings.AbsenceCode.query").order_by.side_effect = ( + Exception("DB Error") + ) + response = client.get("/settings/api/absence-codes") + assert response.status_code == 500 + + def test_create_code_db_error(self, client, mocker): + """Test generic exception for POST endpoint.""" + mocker.patch("app.routes.settings.db.session.commit").side_effect = Exception( + "DB Error" + ) + response = client.post("/settings/api/absence-codes", json={"code": "ANY"}) + assert response.status_code == 500 + + def test_update_code_db_error(self, app, mocker): + """Test generic exception for PUT endpoint.""" + client = app.test_client() + with app.app_context(): + code = AbsenceCode(code="ANY") + db.session.add(code) + db.session.commit() + code_id = code.id + mocker.patch("app.routes.settings.db.session.commit").side_effect = Exception( + "DB Error" + ) + response = client.put( + f"/settings/api/absence-codes/{code_id}", json={"code": "NEW"} + ) + assert response.status_code == 500 + + def test_delete_code_db_error(self, app, mocker): + """Test generic exception for DELETE endpoint.""" + client = app.test_client() + with app.app_context(): + code = AbsenceCode(code="ANY") + db.session.add(code) + db.session.commit() + code_id = code.id + mocker.patch("app.routes.settings.db.session.commit").side_effect = Exception( + "DB Error" + ) + response = client.delete(f"/settings/api/absence-codes/{code_id}") + assert response.status_code == 500 From f246fc3eab22847586e73652741ea3df12e4ba5d Mon Sep 17 00:00:00 2001 From: PabloPeitsch Date: Thu, 7 Aug 2025 21:26:36 -0300 Subject: [PATCH 10/13] fix(api): Prevent deletion of absence codes that are in use --- app/routes/settings.py | 20 +++++++++----------- 1 file changed, 9 insertions(+), 11 deletions(-) diff --git a/app/routes/settings.py b/app/routes/settings.py index 2b6ff8c..4764be8 100644 --- a/app/routes/settings.py +++ b/app/routes/settings.py @@ -1,7 +1,7 @@ from flask import Blueprint, jsonify, render_template, request from app.db.database import db -from app.models.models import AbsenceCode +from app.models.models import AbsenceCode, ScheduleEntry settings_bp = Blueprint("settings", __name__, url_prefix="/settings") @@ -38,7 +38,7 @@ def create_absence_code(): existing_code = AbsenceCode.query.filter_by(code=new_code_str).first() if existing_code: - return jsonify({"error": "Code already exists"}), 409 # Conflict + return jsonify({"error": "Code already exists"}), 409 try: new_code = AbsenceCode(code=new_code_str) @@ -61,11 +61,10 @@ def update_absence_code(code_id): if not new_code_str: return jsonify({"error": "Code cannot be empty"}), 400 - code_to_update = AbsenceCode.query.get(code_id) + code_to_update = db.session.get(AbsenceCode, code_id) if not code_to_update: return jsonify({"error": "Code not found"}), 404 - # Check if the new name conflicts with another existing code existing_code = AbsenceCode.query.filter( AbsenceCode.id != code_id, AbsenceCode.code == new_code_str ).first() @@ -84,20 +83,19 @@ def update_absence_code(code_id): @settings_bp.route("/api/absence-codes/", methods=["DELETE"]) def delete_absence_code(code_id): """Deletes an absence code.""" - code_to_delete = AbsenceCode.query.get(code_id) + code_to_delete = db.session.get(AbsenceCode, code_id) if not code_to_delete: return jsonify({"error": "Code not found"}), 404 + # Manually check if the code is in use before attempting to delete. + is_in_use = ScheduleEntry.query.filter_by(absence_code=code_to_delete.code).first() + if is_in_use: + return jsonify({"error": "Cannot delete code, it is currently in use."}), 409 + try: db.session.delete(code_to_delete) db.session.commit() return jsonify({"status": "success"}), 200 except Exception as e: db.session.rollback() - # Handle cases where the code is in use (foreign key constraint) - if "violates foreign key constraint" in str(e).lower(): - return ( - jsonify({"error": "Cannot delete code, it is currently in use."}), - 409, - ) return jsonify({"error": str(e)}), 500 From 80eee9ac386c797dbf0f936750e2066392174683 Mon Sep 17 00:00:00 2001 From: PabloPeitsch Date: Thu, 7 Aug 2025 21:55:35 -0300 Subject: [PATCH 11/13] test(api): Add tests for creation payload validation --- tests/test_settings.py | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/tests/test_settings.py b/tests/test_settings.py index 4b5c9e3..d811ff0 100644 --- a/tests/test_settings.py +++ b/tests/test_settings.py @@ -42,12 +42,14 @@ def test_create_absence_code_api(self, client): assert data["code"] == "NEW-TEST-CODE" assert "id" in data - @pytest.mark.parametrize( - "payload", [({"code": ""}), ({"code": " "}), ({"wrong_key": "value"}), ({})] - ) - def test_create_absence_code_invalid_payload(self, client, payload): - """Test creating a code with invalid payloads.""" - response = client.post("/settings/api/absence-codes", json=payload) + def test_create_absence_code_empty_string(self, client): + """Test creating a code with an empty string.""" + response = client.post("/settings/api/absence-codes", json={"code": " "}) + assert response.status_code == 400 + + def test_create_absence_code_missing_key(self, client): + """Test creating a code with a missing 'code' key.""" + response = client.post("/settings/api/absence-codes", json={"name": "test"}) assert response.status_code == 400 def test_create_absence_code_api_conflict(self, app): From c6edfb0b0e075a89929f4b5107cb1d762640f416 Mon Sep 17 00:00:00 2001 From: PabloPeitsch Date: Thu, 7 Aug 2025 22:02:25 -0300 Subject: [PATCH 12/13] test(api): Add tests for update payload validation in settings API --- tests/test_settings.py | 24 ++++++++++++++++-------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/tests/test_settings.py b/tests/test_settings.py index d811ff0..e55b3ca 100644 --- a/tests/test_settings.py +++ b/tests/test_settings.py @@ -42,14 +42,12 @@ def test_create_absence_code_api(self, client): assert data["code"] == "NEW-TEST-CODE" assert "id" in data - def test_create_absence_code_empty_string(self, client): - """Test creating a code with an empty string.""" - response = client.post("/settings/api/absence-codes", json={"code": " "}) - assert response.status_code == 400 - - def test_create_absence_code_missing_key(self, client): - """Test creating a code with a missing 'code' key.""" - response = client.post("/settings/api/absence-codes", json={"name": "test"}) + @pytest.mark.parametrize( + "payload", [({"code": " "}), ({"code": ""}), ({"wrong_key": "v"}), ({})] + ) + def test_create_absence_code_invalid_payloads(self, client, payload): + """Test creating a code with various invalid payloads.""" + response = client.post("/settings/api/absence-codes", json=payload) assert response.status_code == 400 def test_create_absence_code_api_conflict(self, app): @@ -78,6 +76,16 @@ def test_update_absence_code_api(self, app): data = json.loads(response.data) assert data["code"] == "NEW-NAME" + @pytest.mark.parametrize( + "payload", [({"code": " "}), ({"code": ""}), ({"wrong_key": "v"}), ({})] + ) + def test_update_absence_code_invalid_payloads(self, client, payload): + """Test updating a code with various invalid payloads.""" + # We only need an ID that exists for the endpoint to proceed to validation. + # The actual ID doesn't matter since the request will fail before DB access. + response = client.put("/settings/api/absence-codes/1", json=payload) + assert response.status_code == 400 + def test_update_absence_code_not_found(self, client): """Test updating a code that does not exist.""" response = client.put("/settings/api/absence-codes/999", json={"code": "ANY"}) From 9b055e559fab0dde53de81addeb24f586d092744 Mon Sep 17 00:00:00 2001 From: PabloPeitsch Date: Thu, 7 Aug 2025 22:11:54 -0300 Subject: [PATCH 13/13] docs: Update CHANGELOG for version 1.2.0 --- CHANGELOG.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index fc1bba6..7b1a3dd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Your new fix here. +## [1.2.0] - 2025-08-07 + +### Added +- **CRUD Management for Absence Codes:** + - Implemented a new "Settings" page at `/settings/absences` for full CRUD (Create, Read, Update, Delete) management of absence codes. + - Created a set of RESTful API endpoints under `/settings/api/absence-codes` to support the new management interface. + - Added a comprehensive test suite (`tests/test_settings.py`) with 100% code coverage for the new backend routes and logic. + +### Changed +- The "Manual Entry" dropdown is now dynamically populated with the user-managed absence codes from the database, replacing the previous hardcoded list. +- The "Calendar Log" view now fetches absence codes from the new centralized API endpoint, ensuring consistency across the application. + +### Fixed +- Prevented the deletion of absence codes that are currently in use in any `ScheduleEntry`, ensuring data integrity. + + ## [1.1.1] - 2025-08-07 ### Changed