GPT : Timezone and reporting improvements
Add timezone-aware timestamp support and reporting improvements Store timestamps in ISO-8601 format with timezone offsets Accept client-local timestamps from browser and API Normalize legacy naive timestamps during parsing Add migration script for converting old SQLite timestamps Fix mixed naive/aware datetime comparison errors Render timestamps in browser-local timezone Auto-generate default 7-day reports on page load Expand default dashboard report to include all users Preserve manual single-user report generation Add curl-based validation/test script for API and report flows
This commit is contained in:
248
app.py
248
app.py
@@ -2,27 +2,20 @@
|
||||
|
||||
"""
|
||||
Minimal Time Clock Application
|
||||
|
||||
Requirements:
|
||||
- Python 3
|
||||
- Flask
|
||||
- sqlite3
|
||||
- Linux compatible
|
||||
- No JavaScript
|
||||
- Simple HTML UI
|
||||
|
||||
Features:
|
||||
- User selection
|
||||
- Clock in
|
||||
- Clock out
|
||||
- Report generation
|
||||
- CRUD API
|
||||
- Automatic database initialization
|
||||
"""
|
||||
|
||||
import sqlite3
|
||||
from datetime import datetime, timedelta
|
||||
from flask import Flask, request, redirect, url_for, render_template, jsonify
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
LEGACY_TIMEZONE = timezone.utc
|
||||
|
||||
from flask import (
|
||||
Flask,
|
||||
request,
|
||||
render_template,
|
||||
jsonify
|
||||
)
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Flask Configuration
|
||||
@@ -40,8 +33,10 @@ def get_db_connection():
|
||||
"""
|
||||
Create and return a sqlite3 database connection.
|
||||
"""
|
||||
|
||||
conn = sqlite3.connect(DATABASE)
|
||||
conn.row_factory = sqlite3.Row
|
||||
|
||||
return conn
|
||||
|
||||
|
||||
@@ -51,9 +46,9 @@ def initialize_database():
|
||||
"""
|
||||
|
||||
conn = get_db_connection()
|
||||
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Users table
|
||||
cursor.execute("""
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
@@ -61,113 +56,146 @@ def initialize_database():
|
||||
)
|
||||
""")
|
||||
|
||||
# Entries table
|
||||
#
|
||||
# user_id:
|
||||
# References users.id
|
||||
#
|
||||
# entrytype:
|
||||
# Must be either 'in' or 'out'
|
||||
#
|
||||
# ts:
|
||||
# Timestamp stored as ISO datetime string
|
||||
#
|
||||
cursor.execute("""
|
||||
CREATE TABLE IF NOT EXISTS entries (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
entrytype TEXT NOT NULL CHECK(entrytype IN ('in', 'out')),
|
||||
ts DATETIME NOT NULL,
|
||||
ts TEXT NOT NULL,
|
||||
FOREIGN KEY(user_id) REFERENCES users(id)
|
||||
)
|
||||
""")
|
||||
|
||||
conn.commit()
|
||||
|
||||
# Add sample users if table is empty
|
||||
cursor.execute("SELECT COUNT(*) AS count FROM users")
|
||||
|
||||
result = cursor.fetchone()
|
||||
|
||||
if result["count"] == 0:
|
||||
cursor.execute("INSERT INTO users (name) VALUES ('Alice')")
|
||||
cursor.execute("INSERT INTO users (name) VALUES ('Bob')")
|
||||
cursor.execute("INSERT INTO users (name) VALUES ('Charlie')")
|
||||
|
||||
conn.commit()
|
||||
|
||||
conn.close()
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Time Helpers
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
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")
|
||||
|
||||
dt = datetime.fromisoformat(normalized)
|
||||
|
||||
#
|
||||
# If timestamp is naive, assume legacy timezone.
|
||||
#
|
||||
|
||||
if dt.tzinfo is None:
|
||||
dt = dt.replace(tzinfo=LEGACY_TIMEZONE)
|
||||
|
||||
return dt
|
||||
|
||||
def utc_now():
|
||||
"""
|
||||
Return current UTC timestamp.
|
||||
"""
|
||||
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Utility Functions
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
def create_entry(user_id, entry_type):
|
||||
def create_entry(user_id, entry_type, client_timestamp=None):
|
||||
"""
|
||||
Create a clock in/out entry for the specified user.
|
||||
"""
|
||||
|
||||
conn = get_db_connection()
|
||||
|
||||
cursor = conn.cursor()
|
||||
|
||||
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
if client_timestamp:
|
||||
timestamp = parse_iso_datetime(client_timestamp)
|
||||
else:
|
||||
timestamp = utc_now()
|
||||
|
||||
cursor.execute("""
|
||||
INSERT INTO entries (user_id, entrytype, ts)
|
||||
VALUES (?, ?, ?)
|
||||
""", (user_id, entry_type, timestamp))
|
||||
""", (
|
||||
user_id,
|
||||
entry_type,
|
||||
timestamp.isoformat()
|
||||
))
|
||||
|
||||
conn.commit()
|
||||
|
||||
conn.close()
|
||||
|
||||
|
||||
def generate_report(user_id, begin_date, end_date):
|
||||
"""
|
||||
Generate total worked hours for a user between two dates.
|
||||
|
||||
Assumptions:
|
||||
- Entries alternate correctly between 'in' and 'out'
|
||||
- Missing pairs are ignored
|
||||
"""
|
||||
|
||||
conn = get_db_connection()
|
||||
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Inclusive date range
|
||||
begin_dt = datetime.strptime(begin_date, "%Y-%m-%dT%H:%M")
|
||||
end_dt = datetime.strptime(end_date, "%Y-%m-%dT%H:%M")
|
||||
|
||||
# Include entire ending day
|
||||
end_dt = end_dt + timedelta(days=1)
|
||||
begin_dt = parse_iso_datetime(begin_date)
|
||||
end_dt = parse_iso_datetime(end_date)
|
||||
|
||||
cursor.execute("""
|
||||
SELECT entrytype, ts
|
||||
FROM entries
|
||||
WHERE user_id = ?
|
||||
AND ts >= ?
|
||||
AND ts < ?
|
||||
ORDER BY ts ASC
|
||||
""", (
|
||||
user_id,
|
||||
begin_dt.strftime("%Y-%m-%d %H:%M"),
|
||||
end_dt.strftime("%Y-%m-%d %H:%M")
|
||||
))
|
||||
""", (user_id,))
|
||||
|
||||
rows = cursor.fetchall()
|
||||
|
||||
conn.close()
|
||||
|
||||
total_seconds = 0
|
||||
|
||||
clock_in_time = None
|
||||
|
||||
for row in rows:
|
||||
entry_type = row["entrytype"]
|
||||
timestamp = datetime.strptime(row["ts"], "%Y-%m-%d %H:%M:%S")
|
||||
|
||||
if entry_type == "in":
|
||||
timestamp = parse_iso_datetime(row["ts"])
|
||||
|
||||
if timestamp < begin_dt or timestamp > end_dt:
|
||||
continue
|
||||
|
||||
if row["entrytype"] == "in":
|
||||
clock_in_time = timestamp
|
||||
|
||||
elif entry_type == "out" and clock_in_time is not None:
|
||||
elif row["entrytype"] == "out" and clock_in_time is not None:
|
||||
|
||||
delta = timestamp - clock_in_time
|
||||
|
||||
total_seconds += delta.total_seconds()
|
||||
|
||||
clock_in_time = None
|
||||
|
||||
total_hours = total_seconds / 3600.0
|
||||
@@ -192,10 +220,8 @@ def index():
|
||||
ORDER BY name
|
||||
""").fetchall()
|
||||
|
||||
|
||||
# NEW: users currently clocked in
|
||||
clocked_in_users = conn.execute("""
|
||||
SELECT u.id, u.name
|
||||
SELECT u.id, u.name, last_entry.ts
|
||||
FROM users u
|
||||
JOIN (
|
||||
SELECT e.user_id, e.entrytype, e.ts
|
||||
@@ -206,7 +232,7 @@ def index():
|
||||
GROUP BY user_id
|
||||
) latest
|
||||
ON e.user_id = latest.user_id
|
||||
AND e.ts = latest.max_ts
|
||||
AND e.ts = latest.max_ts
|
||||
) last_entry
|
||||
ON u.id = last_entry.user_id
|
||||
WHERE last_entry.entrytype = 'in'
|
||||
@@ -215,35 +241,100 @@ def index():
|
||||
|
||||
conn.close()
|
||||
|
||||
clocked_in_list = []
|
||||
|
||||
for user in clocked_in_users:
|
||||
|
||||
ts = parse_iso_datetime(user["ts"])
|
||||
|
||||
clocked_in_list.append({
|
||||
"id": user["id"],
|
||||
"name": user["name"],
|
||||
"since": ts.isoformat()
|
||||
})
|
||||
|
||||
report_hours = None
|
||||
|
||||
all_user_reports = []
|
||||
|
||||
selected_user_id = None
|
||||
|
||||
now = utc_now()
|
||||
|
||||
default_begin = now - timedelta(days=7)
|
||||
|
||||
default_end = now
|
||||
|
||||
if request.method == "POST":
|
||||
|
||||
user_id = request.form.get("user_id")
|
||||
selected_user_id = request.form.get("user_id")
|
||||
|
||||
action = request.form.get("action")
|
||||
|
||||
client_timestamp = request.form.get("client_timestamp")
|
||||
|
||||
if action == "clock_in":
|
||||
create_entry(user_id, "in")
|
||||
|
||||
create_entry(
|
||||
selected_user_id,
|
||||
"in",
|
||||
client_timestamp
|
||||
)
|
||||
|
||||
elif action == "clock_out":
|
||||
create_entry(user_id, "out")
|
||||
|
||||
create_entry(
|
||||
selected_user_id,
|
||||
"out",
|
||||
client_timestamp
|
||||
)
|
||||
|
||||
elif action == "report":
|
||||
|
||||
begin_date = request.form.get("begin_date")
|
||||
|
||||
end_date = request.form.get("end_date")
|
||||
|
||||
report_hours = generate_report(
|
||||
user_id,
|
||||
selected_user_id,
|
||||
begin_date,
|
||||
end_date
|
||||
)
|
||||
|
||||
default_begin = parse_iso_datetime(begin_date)
|
||||
|
||||
default_end = parse_iso_datetime(end_date)
|
||||
|
||||
else:
|
||||
|
||||
#
|
||||
# Automatically generate last 7 day report
|
||||
# for ALL users
|
||||
#
|
||||
|
||||
for user in users:
|
||||
|
||||
hours = generate_report(
|
||||
user["id"],
|
||||
default_begin.isoformat(),
|
||||
default_end.isoformat()
|
||||
)
|
||||
|
||||
all_user_reports.append({
|
||||
"id": user["id"],
|
||||
"name": user["name"],
|
||||
"hours": hours
|
||||
})
|
||||
|
||||
return render_template(
|
||||
"index.html",
|
||||
users=users,
|
||||
report_hours=report_hours,
|
||||
clocked_in_users=clocked_in_users
|
||||
all_user_reports=all_user_reports,
|
||||
clocked_in_users=clocked_in_list,
|
||||
default_begin=default_begin.isoformat(timespec="minutes"),
|
||||
default_end=default_end.isoformat(timespec="minutes"),
|
||||
selected_user_id=selected_user_id
|
||||
)
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@@ -252,9 +343,6 @@ def index():
|
||||
|
||||
@app.route("/api/users", methods=["GET"])
|
||||
def api_get_users():
|
||||
"""
|
||||
Return all users.
|
||||
"""
|
||||
|
||||
conn = get_db_connection()
|
||||
|
||||
@@ -270,9 +358,6 @@ def api_get_users():
|
||||
|
||||
@app.route("/api/entries", methods=["GET"])
|
||||
def api_get_entries():
|
||||
"""
|
||||
Return all entries.
|
||||
"""
|
||||
|
||||
conn = get_db_connection()
|
||||
|
||||
@@ -289,21 +374,30 @@ def api_get_entries():
|
||||
|
||||
@app.route("/api/entries", methods=["POST"])
|
||||
def api_create_entry():
|
||||
"""
|
||||
Create a clock entry.
|
||||
"""
|
||||
|
||||
data = request.get_json()
|
||||
|
||||
user_id = data.get("user_id")
|
||||
|
||||
entry_type = data.get("entrytype")
|
||||
|
||||
client_timestamp = data.get("timestamp")
|
||||
|
||||
if entry_type not in ("in", "out"):
|
||||
return jsonify({"error": "Invalid entrytype"}), 400
|
||||
|
||||
create_entry(user_id, entry_type)
|
||||
return jsonify({
|
||||
"error": "Invalid entrytype"
|
||||
}), 400
|
||||
|
||||
return jsonify({"status": "success"}), 201
|
||||
create_entry(
|
||||
user_id,
|
||||
entry_type,
|
||||
client_timestamp
|
||||
)
|
||||
|
||||
return jsonify({
|
||||
"status": "success"
|
||||
}), 201
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Application Entry Point
|
||||
|
||||
Reference in New Issue
Block a user