Skip to content

Commit 5d2ac7a

Browse files
authored
Merge pull request #27 from PPeitsch/feature/crud-absence-codes
feat: Implement CRUD management for absence codes
2 parents 7de483e + 9b055e5 commit 5d2ac7a

13 files changed

Lines changed: 577 additions & 86 deletions

CHANGELOG.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1414
- Your new fix here.
1515

1616

17+
## [1.2.0] - 2025-08-07
18+
19+
### Added
20+
- **CRUD Management for Absence Codes:**
21+
- Implemented a new "Settings" page at `/settings/absences` for full CRUD (Create, Read, Update, Delete) management of absence codes.
22+
- Created a set of RESTful API endpoints under `/settings/api/absence-codes` to support the new management interface.
23+
- Added a comprehensive test suite (`tests/test_settings.py`) with 100% code coverage for the new backend routes and logic.
24+
25+
### Changed
26+
- The "Manual Entry" dropdown is now dynamically populated with the user-managed absence codes from the database, replacing the previous hardcoded list.
27+
- The "Calendar Log" view now fetches absence codes from the new centralized API endpoint, ensuring consistency across the application.
28+
29+
### Fixed
30+
- Prevented the deletion of absence codes that are currently in use in any `ScheduleEntry`, ensuring data integrity.
31+
32+
1733
## [1.1.1] - 2025-08-07
1834

1935
### Changed

app/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
from app.routes.main import main
66
from app.routes.manual_entry import manual_entry
77
from app.routes.monthly_log import monthly_log_bp
8+
from app.routes.settings import settings_bp
89
from app.routes.time_log import time_log
910
from app.routes.time_summary import time_summary
1011

@@ -21,5 +22,6 @@ def create_app(config_object):
2122
app.register_blueprint(time_summary)
2223
app.register_blueprint(time_log)
2324
app.register_blueprint(monthly_log_bp)
25+
app.register_blueprint(settings_bp)
2426

2527
return app

app/routes/manual_entry.py

Lines changed: 3 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,8 @@
1414
@manual_entry.route("/entry", methods=["GET"])
1515
def show_entry_form():
1616
employees = Employee.query.all()
17-
absence_codes = AbsenceCode.query.all()
17+
# Fetch codes dynamically from the database
18+
absence_codes = AbsenceCode.query.order_by(AbsenceCode.code).all()
1819
return render_template(
1920
"manual_entry.html", employees=employees, absence_codes=absence_codes
2021
)
@@ -33,47 +34,34 @@ def save_entry():
3334
if not validate_date(date_str):
3435
return jsonify({"error": "Invalid date format"}), 400
3536

36-
# Skip validation if it's an absence day
3737
absence_code = data.get("absence_code")
3838

39-
# For work days, validate entries
4039
if absence_code is None:
4140
entries = data.get("entries")
4241
if entries is None:
4342
return jsonify({"error": "Entries are required for work day"}), 400
44-
45-
# Check if entries is empty
4643
if not entries:
4744
return jsonify({"error": "No time entries provided for work day"}), 400
48-
49-
# Validate the entries
5045
is_valid, error = validate_entries(entries)
5146
if not is_valid:
5247
return jsonify({"error": error}), 400
5348

54-
# Convert date string to date object
5549
entry_date = datetime.strptime(date_str, "%Y-%m-%d").date()
56-
57-
# Get employee_id safely
5850
employee_id = data.get("employee_id")
5951
if employee_id is None:
6052
return jsonify({"error": "Employee ID is required"}), 400
6153

62-
# Check if an entry already exists for this date and employee
6354
existing_entry = ScheduleEntry.query.filter_by(
6455
employee_id=employee_id, date=entry_date
6556
).first()
6657

67-
# Get entries safely
6858
entries = data.get("entries", [])
6959

7060
if existing_entry:
71-
# Update existing entry
7261
existing_entry.entries = [] if absence_code else entries
7362
existing_entry.absence_code = absence_code
7463
db.session.commit()
7564
else:
76-
# Create new entry
7765
schedule_entry = ScheduleEntry(
7866
employee_id=employee_id,
7967
date=entry_date,
@@ -83,7 +71,6 @@ def save_entry():
8371
db.session.add(schedule_entry)
8472
db.session.commit()
8573

86-
# Calculate hours if not an absence
8774
if absence_code is None:
8875
hours = calculate_daily_hours(entries)
8976
return jsonify({"status": "success", "hours": hours})
@@ -98,7 +85,7 @@ def get_entry(date):
9885

9986
entry = ScheduleEntry.query.filter_by(
10087
date=datetime.strptime(date, "%Y-%m-%d").date(),
101-
employee_id=1, # Default employee ID until login is implemented
88+
employee_id=1,
10289
).first()
10390

10491
if entry:

app/routes/monthly_log.py

Lines changed: 0 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -15,16 +15,6 @@ def view_monthly_log():
1515
return render_template("monthly_log.html")
1616

1717

18-
@monthly_log_bp.route("/api/absence-codes", methods=["GET"])
19-
def get_absence_codes():
20-
"""Returns a list of available absence codes."""
21-
try:
22-
codes = AbsenceCode.query.all()
23-
return jsonify([code.code for code in codes])
24-
except Exception as e:
25-
return jsonify({"error": str(e)}), 500
26-
27-
2818
@monthly_log_bp.route("/api/<int:year>/<int:month>", methods=["GET"])
2919
def get_monthly_log_data(year, month):
3020
"""

app/routes/settings.py

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
from flask import Blueprint, jsonify, render_template, request
2+
3+
from app.db.database import db
4+
from app.models.models import AbsenceCode, ScheduleEntry
5+
6+
settings_bp = Blueprint("settings", __name__, url_prefix="/settings")
7+
8+
9+
@settings_bp.route("/absences", methods=["GET"])
10+
def manage_absences_page():
11+
"""Renders the absence code management page."""
12+
return render_template("settings_absences.html")
13+
14+
15+
# --- API Endpoints ---
16+
17+
18+
@settings_bp.route("/api/absence-codes", methods=["GET"])
19+
def get_absence_codes():
20+
"""Returns a list of all available absence codes."""
21+
try:
22+
codes = AbsenceCode.query.order_by(AbsenceCode.code).all()
23+
return jsonify([{"id": code.id, "code": code.code} for code in codes])
24+
except Exception as e:
25+
return jsonify({"error": str(e)}), 500
26+
27+
28+
@settings_bp.route("/api/absence-codes", methods=["POST"])
29+
def create_absence_code():
30+
"""Creates a new absence code."""
31+
data = request.json
32+
if not data or not data.get("code"):
33+
return jsonify({"error": "Code is required"}), 400
34+
35+
new_code_str = data["code"].strip()
36+
if not new_code_str:
37+
return jsonify({"error": "Code cannot be empty"}), 400
38+
39+
existing_code = AbsenceCode.query.filter_by(code=new_code_str).first()
40+
if existing_code:
41+
return jsonify({"error": "Code already exists"}), 409
42+
43+
try:
44+
new_code = AbsenceCode(code=new_code_str)
45+
db.session.add(new_code)
46+
db.session.commit()
47+
return jsonify({"id": new_code.id, "code": new_code.code}), 201
48+
except Exception as e:
49+
db.session.rollback()
50+
return jsonify({"error": str(e)}), 500
51+
52+
53+
@settings_bp.route("/api/absence-codes/<int:code_id>", methods=["PUT"])
54+
def update_absence_code(code_id):
55+
"""Updates an existing absence code."""
56+
data = request.json
57+
if not data or not data.get("code"):
58+
return jsonify({"error": "Code is required"}), 400
59+
60+
new_code_str = data["code"].strip()
61+
if not new_code_str:
62+
return jsonify({"error": "Code cannot be empty"}), 400
63+
64+
code_to_update = db.session.get(AbsenceCode, code_id)
65+
if not code_to_update:
66+
return jsonify({"error": "Code not found"}), 404
67+
68+
existing_code = AbsenceCode.query.filter(
69+
AbsenceCode.id != code_id, AbsenceCode.code == new_code_str
70+
).first()
71+
if existing_code:
72+
return jsonify({"error": "Another code with this name already exists"}), 409
73+
74+
try:
75+
code_to_update.code = new_code_str
76+
db.session.commit()
77+
return jsonify({"id": code_to_update.id, "code": code_to_update.code})
78+
except Exception as e:
79+
db.session.rollback()
80+
return jsonify({"error": str(e)}), 500
81+
82+
83+
@settings_bp.route("/api/absence-codes/<int:code_id>", methods=["DELETE"])
84+
def delete_absence_code(code_id):
85+
"""Deletes an absence code."""
86+
code_to_delete = db.session.get(AbsenceCode, code_id)
87+
if not code_to_delete:
88+
return jsonify({"error": "Code not found"}), 404
89+
90+
# Manually check if the code is in use before attempting to delete.
91+
is_in_use = ScheduleEntry.query.filter_by(absence_code=code_to_delete.code).first()
92+
if is_in_use:
93+
return jsonify({"error": "Cannot delete code, it is currently in use."}), 409
94+
95+
try:
96+
db.session.delete(code_to_delete)
97+
db.session.commit()
98+
return jsonify({"status": "success"}), 200
99+
except Exception as e:
100+
db.session.rollback()
101+
return jsonify({"error": str(e)}), 500

app/static/js/monthly_log.js

Lines changed: 11 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -18,33 +18,34 @@ document.addEventListener('DOMContentLoaded', function() {
1818
let currentDate = new Date();
1919
let selectedDates = [];
2020
let isMouseDown = false;
21-
let absenceCodes = [];
2221

2322
async function fetchAbsenceCodes() {
2423
try {
25-
const response = await fetch('/monthly-log/api/absence-codes');
24+
// Point to the new, centralized API endpoint
25+
const response = await fetch('/settings/api/absence-codes');
2626
if (!response.ok) throw new Error('Failed to fetch absence codes');
27-
absenceCodes = await response.json();
27+
// The new API returns a list of objects, so we return that directly
28+
return await response.json();
2829
} catch (error) {
2930
console.error(error);
31+
return [];
3032
}
3133
}
3234

33-
function populateDayTypeSelect() {
35+
function populateDayTypeSelect(absenceCodes) {
3436
dayTypeSelect.innerHTML = '';
3537
// Add special "Default" option first
3638
dayTypeSelect.add(new Option("(Revert to Default)", "DEFAULT"));
3739
dayTypeSelect.add(new Option("Work Day", "Work Day"));
3840

3941
if (absenceCodes && absenceCodes.length > 0) {
40-
absenceCodes.forEach(code => {
41-
dayTypeSelect.add(new Option(code.replace(/_/g, ' '), code));
42+
// Adjust to handle the new object format {id, code}
43+
absenceCodes.forEach(item => {
44+
dayTypeSelect.add(new Option(item.code.replace(/_/g, ' '), item.code));
4245
});
4346
}
4447
}
4548

46-
// ... (el resto de las funciones de JS no cambian)
47-
4849
function populateSelectors() {
4950
const currentYear = new Date().getFullYear();
5051
const startYear = currentYear - 5;
@@ -192,8 +193,8 @@ document.addEventListener('DOMContentLoaded', function() {
192193
async function init() {
193194
populateSelectors();
194195
updateSelectors();
195-
await fetchAbsenceCodes();
196-
populateDayTypeSelect();
196+
const absenceCodes = await fetchAbsenceCodes();
197+
populateDayTypeSelect(absenceCodes);
197198
await renderCalendar();
198199
}
199200

0 commit comments

Comments
 (0)