Clean things up a bit
This commit is contained in:
182
app.py
182
app.py
@@ -1,15 +1,5 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
|
|
||||||
"""
|
|
||||||
Minimal Time Clock Application
|
|
||||||
"""
|
|
||||||
|
|
||||||
import sqlite3
|
import sqlite3
|
||||||
|
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
LEGACY_TIMEZONE = timezone.utc
|
|
||||||
|
|
||||||
from flask import (
|
from flask import (
|
||||||
Flask,
|
Flask,
|
||||||
request,
|
request,
|
||||||
@@ -17,45 +7,26 @@ from flask import (
|
|||||||
jsonify
|
jsonify
|
||||||
)
|
)
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
LEGACY_TIMEZONE = timezone.utc
|
||||||
# Flask Configuration
|
|
||||||
# -----------------------------------------------------------------------------
|
|
||||||
|
|
||||||
app = Flask(__name__)
|
app = Flask(__name__)
|
||||||
|
|
||||||
DATABASE = "database/timeclock.db"
|
DATABASE = "database/timeclock.db"
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
|
||||||
# Database Helpers
|
|
||||||
# -----------------------------------------------------------------------------
|
|
||||||
|
|
||||||
def get_db_connection():
|
def get_db_connection():
|
||||||
"""
|
|
||||||
Create and return a sqlite3 database connection.
|
|
||||||
"""
|
|
||||||
|
|
||||||
conn = sqlite3.connect(DATABASE)
|
conn = sqlite3.connect(DATABASE)
|
||||||
conn.row_factory = sqlite3.Row
|
conn.row_factory = sqlite3.Row
|
||||||
|
|
||||||
return conn
|
return conn
|
||||||
|
|
||||||
|
|
||||||
def initialize_database():
|
def initialize_database():
|
||||||
"""
|
|
||||||
Create database tables if they do not already exist.
|
|
||||||
"""
|
|
||||||
|
|
||||||
conn = get_db_connection()
|
conn = get_db_connection()
|
||||||
|
|
||||||
cursor = conn.cursor()
|
cursor = conn.cursor()
|
||||||
|
|
||||||
cursor.execute("""
|
cursor.execute("""
|
||||||
CREATE TABLE IF NOT EXISTS users (
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
name TEXT NOT NULL
|
name TEXT NOT NULL
|
||||||
)
|
)
|
||||||
""")
|
""")
|
||||||
|
|
||||||
cursor.execute("""
|
cursor.execute("""
|
||||||
CREATE TABLE IF NOT EXISTS entries (
|
CREATE TABLE IF NOT EXISTS entries (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
@@ -65,161 +36,76 @@ def initialize_database():
|
|||||||
FOREIGN KEY(user_id) REFERENCES users(id)
|
FOREIGN KEY(user_id) REFERENCES users(id)
|
||||||
)
|
)
|
||||||
""")
|
""")
|
||||||
|
|
||||||
conn.commit()
|
conn.commit()
|
||||||
|
|
||||||
cursor.execute("SELECT COUNT(*) AS count FROM users")
|
cursor.execute("SELECT COUNT(*) AS count FROM users")
|
||||||
|
|
||||||
result = cursor.fetchone()
|
result = cursor.fetchone()
|
||||||
|
|
||||||
if result["count"] == 0:
|
if result["count"] == 0:
|
||||||
cursor.execute("INSERT INTO users (name) VALUES ('Alice')")
|
cursor.execute("INSERT INTO users (name) VALUES ('Alice')")
|
||||||
cursor.execute("INSERT INTO users (name) VALUES ('Bob')")
|
cursor.execute("INSERT INTO users (name) VALUES ('Bob')")
|
||||||
cursor.execute("INSERT INTO users (name) VALUES ('Charlie')")
|
cursor.execute("INSERT INTO users (name) VALUES ('Charlie')")
|
||||||
|
|
||||||
conn.commit()
|
conn.commit()
|
||||||
|
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
|
||||||
# Time Helpers
|
|
||||||
# -----------------------------------------------------------------------------
|
|
||||||
|
|
||||||
def parse_iso_datetime(value):
|
def parse_iso_datetime(value):
|
||||||
"""
|
|
||||||
Parse timestamps into timezone-aware datetime objects.
|
|
||||||
|
|
||||||
Handles both:
|
|
||||||
2026-05-29 08:00:00
|
|
||||||
and:
|
|
||||||
2026-05-29T08:00:00-04:00
|
|
||||||
"""
|
|
||||||
|
|
||||||
#
|
|
||||||
# Support legacy sqlite format:
|
|
||||||
# 2026-05-29 08:00:00
|
|
||||||
#
|
|
||||||
|
|
||||||
normalized = value.replace(" ", "T")
|
normalized = value.replace(" ", "T")
|
||||||
|
|
||||||
dt = datetime.fromisoformat(normalized)
|
dt = datetime.fromisoformat(normalized)
|
||||||
|
|
||||||
#
|
|
||||||
# If timestamp is naive, assume legacy timezone.
|
|
||||||
#
|
|
||||||
|
|
||||||
if dt.tzinfo is None:
|
if dt.tzinfo is None:
|
||||||
dt = dt.replace(tzinfo=LEGACY_TIMEZONE)
|
dt = dt.replace(tzinfo=LEGACY_TIMEZONE)
|
||||||
|
|
||||||
return dt
|
return dt
|
||||||
|
|
||||||
def utc_now():
|
def utc_now():
|
||||||
"""
|
|
||||||
Return current UTC timestamp.
|
|
||||||
"""
|
|
||||||
|
|
||||||
return datetime.now(timezone.utc)
|
return datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
|
||||||
# Utility Functions
|
|
||||||
# -----------------------------------------------------------------------------
|
|
||||||
|
|
||||||
def create_entry(user_id, entry_type, client_timestamp=None):
|
def create_entry(user_id, entry_type, client_timestamp=None):
|
||||||
"""
|
|
||||||
Create a clock in/out entry for the specified user.
|
|
||||||
"""
|
|
||||||
|
|
||||||
conn = get_db_connection()
|
conn = get_db_connection()
|
||||||
|
|
||||||
cursor = conn.cursor()
|
cursor = conn.cursor()
|
||||||
|
|
||||||
if client_timestamp:
|
if client_timestamp:
|
||||||
timestamp = parse_iso_datetime(client_timestamp)
|
timestamp = parse_iso_datetime(client_timestamp)
|
||||||
else:
|
else:
|
||||||
timestamp = utc_now()
|
timestamp = utc_now()
|
||||||
|
|
||||||
cursor.execute("""
|
cursor.execute("""
|
||||||
INSERT INTO entries (user_id, entrytype, ts)
|
INSERT INTO entries (user_id, entrytype, ts)
|
||||||
VALUES (?, ?, ?)
|
VALUES (?, ?, ?)
|
||||||
""", (
|
""", (user_id, entry_type, timestamp.isoformat()))
|
||||||
user_id,
|
|
||||||
entry_type,
|
|
||||||
timestamp.isoformat()
|
|
||||||
))
|
|
||||||
|
|
||||||
conn.commit()
|
conn.commit()
|
||||||
|
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
def generate_report(user_id, begin_date, end_date):
|
def generate_report(user_id, begin_date, end_date):
|
||||||
"""
|
|
||||||
Generate total worked hours for a user between two dates.
|
|
||||||
"""
|
|
||||||
|
|
||||||
conn = get_db_connection()
|
conn = get_db_connection()
|
||||||
|
|
||||||
cursor = conn.cursor()
|
cursor = conn.cursor()
|
||||||
|
|
||||||
begin_dt = parse_iso_datetime(begin_date)
|
begin_dt = parse_iso_datetime(begin_date)
|
||||||
end_dt = parse_iso_datetime(end_date)
|
end_dt = parse_iso_datetime(end_date)
|
||||||
|
|
||||||
cursor.execute("""
|
cursor.execute("""
|
||||||
SELECT entrytype, ts
|
SELECT entrytype, ts
|
||||||
FROM entries
|
FROM entries
|
||||||
WHERE user_id = ?
|
WHERE user_id = ?
|
||||||
ORDER BY ts ASC
|
ORDER BY ts ASC
|
||||||
""", (user_id,))
|
""", (user_id,))
|
||||||
|
|
||||||
rows = cursor.fetchall()
|
rows = cursor.fetchall()
|
||||||
|
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
total_seconds = 0
|
total_seconds = 0
|
||||||
|
|
||||||
clock_in_time = None
|
clock_in_time = None
|
||||||
|
|
||||||
for row in rows:
|
for row in rows:
|
||||||
|
|
||||||
timestamp = parse_iso_datetime(row["ts"])
|
timestamp = parse_iso_datetime(row["ts"])
|
||||||
|
|
||||||
if timestamp < begin_dt or timestamp > end_dt:
|
if timestamp < begin_dt or timestamp > end_dt:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if row["entrytype"] == "in":
|
if row["entrytype"] == "in":
|
||||||
clock_in_time = timestamp
|
clock_in_time = timestamp
|
||||||
|
|
||||||
elif row["entrytype"] == "out" and clock_in_time is not None:
|
elif row["entrytype"] == "out" and clock_in_time is not None:
|
||||||
|
|
||||||
delta = timestamp - clock_in_time
|
delta = timestamp - clock_in_time
|
||||||
|
|
||||||
total_seconds += delta.total_seconds()
|
total_seconds += delta.total_seconds()
|
||||||
|
|
||||||
clock_in_time = None
|
clock_in_time = None
|
||||||
|
|
||||||
total_hours = total_seconds / 3600.0
|
total_hours = total_seconds / 3600.0
|
||||||
|
|
||||||
return round(total_hours, 2)
|
return round(total_hours, 2)
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
|
||||||
# Web UI Routes
|
|
||||||
# -----------------------------------------------------------------------------
|
|
||||||
|
|
||||||
@app.route("/", methods=["GET", "POST"])
|
@app.route("/", methods=["GET", "POST"])
|
||||||
def index():
|
def index():
|
||||||
"""
|
|
||||||
Main application page.
|
|
||||||
"""
|
|
||||||
|
|
||||||
conn = get_db_connection()
|
conn = get_db_connection()
|
||||||
|
|
||||||
users = conn.execute("""
|
users = conn.execute("""
|
||||||
SELECT id, name
|
SELECT id, name
|
||||||
FROM users
|
FROM users
|
||||||
ORDER BY name
|
ORDER BY name
|
||||||
""").fetchall()
|
""").fetchall()
|
||||||
|
|
||||||
clocked_in_users = conn.execute("""
|
clocked_in_users = conn.execute("""
|
||||||
SELECT u.id, u.name, last_entry.ts
|
SELECT u.id, u.name, last_entry.ts
|
||||||
FROM users u
|
FROM users u
|
||||||
@@ -238,94 +124,59 @@ def index():
|
|||||||
WHERE last_entry.entrytype = 'in'
|
WHERE last_entry.entrytype = 'in'
|
||||||
ORDER BY u.name
|
ORDER BY u.name
|
||||||
""").fetchall()
|
""").fetchall()
|
||||||
|
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
clocked_in_list = []
|
clocked_in_list = []
|
||||||
|
|
||||||
for user in clocked_in_users:
|
for user in clocked_in_users:
|
||||||
|
|
||||||
ts = parse_iso_datetime(user["ts"])
|
ts = parse_iso_datetime(user["ts"])
|
||||||
|
|
||||||
clocked_in_list.append({
|
clocked_in_list.append({
|
||||||
"id": user["id"],
|
"id": user["id"],
|
||||||
"name": user["name"],
|
"name": user["name"],
|
||||||
"since": ts.isoformat()
|
"since": ts.isoformat()
|
||||||
})
|
})
|
||||||
|
|
||||||
report_hours = None
|
report_hours = None
|
||||||
|
|
||||||
all_user_reports = []
|
all_user_reports = []
|
||||||
|
|
||||||
selected_user_id = None
|
selected_user_id = None
|
||||||
|
|
||||||
now = utc_now()
|
now = utc_now()
|
||||||
|
|
||||||
default_begin = now - timedelta(days=7)
|
default_begin = now - timedelta(days=7)
|
||||||
|
|
||||||
default_end = now
|
default_end = now
|
||||||
|
|
||||||
if request.method == "POST":
|
if request.method == "POST":
|
||||||
|
|
||||||
selected_user_id = request.form.get("user_id")
|
selected_user_id = request.form.get("user_id")
|
||||||
|
|
||||||
action = request.form.get("action")
|
action = request.form.get("action")
|
||||||
|
|
||||||
client_timestamp = request.form.get("client_timestamp")
|
client_timestamp = request.form.get("client_timestamp")
|
||||||
|
|
||||||
if action == "clock_in":
|
if action == "clock_in":
|
||||||
|
|
||||||
create_entry(
|
create_entry(
|
||||||
selected_user_id,
|
selected_user_id,
|
||||||
"in",
|
"in",
|
||||||
client_timestamp
|
client_timestamp
|
||||||
)
|
)
|
||||||
|
|
||||||
elif action == "clock_out":
|
elif action == "clock_out":
|
||||||
|
|
||||||
create_entry(
|
create_entry(
|
||||||
selected_user_id,
|
selected_user_id,
|
||||||
"out",
|
"out",
|
||||||
client_timestamp
|
client_timestamp
|
||||||
)
|
)
|
||||||
|
|
||||||
elif action == "report":
|
elif action == "report":
|
||||||
|
|
||||||
begin_date = request.form.get("begin_date")
|
begin_date = request.form.get("begin_date")
|
||||||
|
|
||||||
end_date = request.form.get("end_date")
|
end_date = request.form.get("end_date")
|
||||||
|
|
||||||
report_hours = generate_report(
|
report_hours = generate_report(
|
||||||
selected_user_id,
|
selected_user_id,
|
||||||
begin_date,
|
begin_date,
|
||||||
end_date
|
end_date
|
||||||
)
|
)
|
||||||
|
|
||||||
default_begin = parse_iso_datetime(begin_date)
|
default_begin = parse_iso_datetime(begin_date)
|
||||||
|
|
||||||
default_end = parse_iso_datetime(end_date)
|
default_end = parse_iso_datetime(end_date)
|
||||||
|
|
||||||
else:
|
else:
|
||||||
|
|
||||||
#
|
|
||||||
# Automatically generate last 7 day report
|
|
||||||
# for ALL users
|
|
||||||
#
|
|
||||||
|
|
||||||
for user in users:
|
for user in users:
|
||||||
|
|
||||||
hours = generate_report(
|
hours = generate_report(
|
||||||
user["id"],
|
user["id"],
|
||||||
default_begin.isoformat(),
|
default_begin.isoformat(),
|
||||||
default_end.isoformat()
|
default_end.isoformat()
|
||||||
)
|
)
|
||||||
|
|
||||||
all_user_reports.append({
|
all_user_reports.append({
|
||||||
"id": user["id"],
|
"id": user["id"],
|
||||||
"name": user["name"],
|
"name": user["name"],
|
||||||
"hours": hours
|
"hours": hours
|
||||||
})
|
})
|
||||||
|
|
||||||
return render_template(
|
return render_template(
|
||||||
"index.html",
|
"index.html",
|
||||||
users=users,
|
users=users,
|
||||||
@@ -337,78 +188,51 @@ def index():
|
|||||||
selected_user_id=selected_user_id
|
selected_user_id=selected_user_id
|
||||||
)
|
)
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
|
||||||
# CRUD API
|
|
||||||
# -----------------------------------------------------------------------------
|
|
||||||
|
|
||||||
@app.route("/api/users", methods=["GET"])
|
@app.route("/api/users", methods=["GET"])
|
||||||
def api_get_users():
|
def api_get_users():
|
||||||
|
|
||||||
conn = get_db_connection()
|
conn = get_db_connection()
|
||||||
|
|
||||||
users = conn.execute("""
|
users = conn.execute("""
|
||||||
SELECT id, name
|
SELECT id, name
|
||||||
FROM users
|
FROM users
|
||||||
""").fetchall()
|
""").fetchall()
|
||||||
|
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
return jsonify([dict(user) for user in users])
|
return jsonify([dict(user) for user in users])
|
||||||
|
|
||||||
|
|
||||||
@app.route("/api/entries", methods=["GET"])
|
@app.route("/api/entries", methods=["GET"])
|
||||||
def api_get_entries():
|
def api_get_entries():
|
||||||
|
|
||||||
conn = get_db_connection()
|
conn = get_db_connection()
|
||||||
|
|
||||||
entries = conn.execute("""
|
entries = conn.execute("""
|
||||||
SELECT id, user_id, entrytype, ts
|
SELECT id, user_id, entrytype, ts
|
||||||
FROM entries
|
FROM entries
|
||||||
ORDER BY ts DESC
|
ORDER BY ts DESC
|
||||||
""").fetchall()
|
""").fetchall()
|
||||||
|
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
return jsonify([dict(entry) for entry in entries])
|
return jsonify([dict(entry) for entry in entries])
|
||||||
|
|
||||||
|
|
||||||
@app.route("/api/entries", methods=["POST"])
|
@app.route("/api/entries", methods=["POST"])
|
||||||
def api_create_entry():
|
def api_create_entry():
|
||||||
|
|
||||||
data = request.get_json()
|
data = request.get_json()
|
||||||
|
|
||||||
user_id = data.get("user_id")
|
user_id = data.get("user_id")
|
||||||
|
|
||||||
entry_type = data.get("entrytype")
|
entry_type = data.get("entrytype")
|
||||||
|
|
||||||
client_timestamp = data.get("timestamp")
|
client_timestamp = data.get("timestamp")
|
||||||
|
|
||||||
if entry_type not in ("in", "out"):
|
if entry_type not in ("in", "out"):
|
||||||
|
|
||||||
return jsonify({
|
return jsonify({
|
||||||
"error": "Invalid entrytype"
|
"error": "Invalid entrytype"
|
||||||
}), 400
|
}), 400
|
||||||
|
|
||||||
create_entry(
|
create_entry(
|
||||||
user_id,
|
user_id,
|
||||||
entry_type,
|
entry_type,
|
||||||
client_timestamp
|
client_timestamp
|
||||||
)
|
)
|
||||||
|
|
||||||
return jsonify({
|
return jsonify({
|
||||||
"status": "success"
|
"status": "success"
|
||||||
}), 201
|
}), 201
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
|
||||||
# Application Entry Point
|
|
||||||
# -----------------------------------------------------------------------------
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|
||||||
initialize_database()
|
initialize_database()
|
||||||
|
|
||||||
app.run(
|
app.run(
|
||||||
host="0.0.0.0",
|
host="0.0.0.0",
|
||||||
port=5000,
|
port=5000,
|
||||||
debug=False
|
debug=False
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -82,7 +82,6 @@
|
|||||||
{{ user.name }}
|
{{ user.name }}
|
||||||
</option>
|
</option>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|
||||||
</select>
|
</select>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -180,9 +179,7 @@
|
|||||||
<script>
|
<script>
|
||||||
|
|
||||||
function localDatetimeValue(date) {
|
function localDatetimeValue(date) {
|
||||||
|
|
||||||
const pad = (v) => String(v).padStart(2, "0");
|
const pad = (v) => String(v).padStart(2, "0");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
date.getFullYear() + "-" +
|
date.getFullYear() + "-" +
|
||||||
pad(date.getMonth() + 1) + "-" +
|
pad(date.getMonth() + 1) + "-" +
|
||||||
@@ -193,43 +190,21 @@ function localDatetimeValue(date) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function setCurrentTimestamp() {
|
function setCurrentTimestamp() {
|
||||||
|
document.getElementById("client_timestamp").value = new Date().toISOString();
|
||||||
document.getElementById("client_timestamp").value =
|
|
||||||
new Date().toISOString();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
window.addEventListener("load", () => {
|
window.addEventListener("load", () => {
|
||||||
|
|
||||||
//
|
|
||||||
// Populate default report range
|
|
||||||
//
|
|
||||||
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
|
|
||||||
const weekAgo = new Date();
|
const weekAgo = new Date();
|
||||||
|
|
||||||
weekAgo.setDate(now.getDate() - 7);
|
weekAgo.setDate(now.getDate() - 7);
|
||||||
|
document.getElementById("begin_date").value = localDatetimeValue(weekAgo);
|
||||||
document.getElementById("begin_date").value =
|
document.getElementById("end_date").value = localDatetimeValue(now);
|
||||||
localDatetimeValue(weekAgo);
|
|
||||||
|
|
||||||
document.getElementById("end_date").value =
|
|
||||||
localDatetimeValue(now);
|
|
||||||
|
|
||||||
//
|
|
||||||
// Convert displayed timestamps to local timezone
|
|
||||||
//
|
|
||||||
|
|
||||||
document.querySelectorAll(".tztime").forEach((el) => {
|
document.querySelectorAll(".tztime").forEach((el) => {
|
||||||
|
|
||||||
const ts = el.dataset.ts;
|
const ts = el.dataset.ts;
|
||||||
|
|
||||||
const d = new Date(ts);
|
const d = new Date(ts);
|
||||||
|
|
||||||
el.textContent = d.toLocaleString();
|
el.textContent = d.toLocaleString();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
Reference in New Issue
Block a user