Compare commits

...

4 Commits

2 changed files with 199 additions and 128 deletions

270
app.py
View File

@@ -1,18 +1,33 @@
import sqlite3 import sqlite3
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from zoneinfo import ZoneInfo
from flask import ( from flask import (
Flask, Flask,
request, request,
render_template, render_template,
jsonify jsonify
) )
import logging
LEGACY_TIMEZONE = timezone.utc LEGACY_TIMEZONE = ZoneInfo("America/New_York")
app = Flask(__name__) app = Flask(__name__)
DATABASE = "database/timeclock.db" DATABASE = "database/timeclock.db"
def is_timezone_aware(dt):
return dt.tzinfo is not None and dt.tzinfo.utcoffset(dt) is not None
def make_timezone_aware(dt, tzstr):
if tzstr:
tzval = datetime.strptime(tzstr.replace(":", ""), "%z").tzinfo
else:
tzval = LEGACY_TIMEZONE
if not is_timezone_aware(dt):
return dt.replace(tzinfo=tzval)
else:
return dt
def get_db_connection(): def get_db_connection():
conn = sqlite3.connect(DATABASE) conn = sqlite3.connect(DATABASE)
conn.row_factory = sqlite3.Row conn.row_factory = sqlite3.Row
@@ -34,6 +49,7 @@ def initialize_database():
entrytype TEXT NOT NULL CHECK(entrytype IN ('in', 'out')), entrytype TEXT NOT NULL CHECK(entrytype IN ('in', 'out')),
ts TEXT NOT NULL, ts TEXT NOT NULL,
paid BOOLEAN NOT NULL DEFAULT FALSE, paid BOOLEAN NOT NULL DEFAULT FALSE,
comments TEXT,
FOREIGN KEY(user_id) REFERENCES users(id) FOREIGN KEY(user_id) REFERENCES users(id)
) )
""") """)
@@ -50,64 +66,166 @@ def initialize_database():
def parse_iso_datetime(value): def parse_iso_datetime(value):
normalized = value.replace(" ", "T") normalized = value.replace(" ", "T")
dt = datetime.fromisoformat(normalized) dt = datetime.fromisoformat(normalized)
if dt.tzinfo is None:
dt = dt.replace(tzinfo=LEGACY_TIMEZONE)
return dt return dt
def utc_now(): def est_now():
return datetime.now(timezone.utc) return datetime.now(ZoneInfo("America/New_York"))
def create_entry(user_id, entry_type, client_timestamp=None): def pay_hours(user_id, begin_date, end_date):
conn = get_db_connection()
cursor = conn.cursor()
if client_timestamp:
timestamp = parse_iso_datetime(client_timestamp)
else:
timestamp = utc_now()
cursor.execute("""
INSERT INTO entries (user_id, entrytype, ts, paid)
VALUES (?, ?, ?, ?)
""", (user_id, entry_type, timestamp.isoformat(), False))
conn.commit()
conn.close()
def generate_report(user_id, begin_date, end_date):
conn = get_db_connection() conn = get_db_connection()
conn.set_trace_callback(logging.error)
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, paid UPDATE entries
SET paid = TRUE
WHERE user_id = ?
AND datetime(ts) BETWEEN datetime(?) AND datetime(?)
""", (user_id, begin_dt.isoformat(), end_dt.isoformat()))
rows = cursor.fetchall()
conn.commit()
conn.close()
def create_entry(user_id, entry_type, client_timestamp=None, comments=None, client_timezone=None):
conn = get_db_connection()
cursor = conn.cursor()
if client_timestamp:
timestamp = make_timezone_aware(parse_iso_datetime(client_timestamp), client_timezone)
else:
timestamp = est_now()
if comments:
cursor.execute("""
INSERT INTO entries (user_id, entrytype, ts, paid, comments)
VALUES (?, ?, ?, ?, ?)
""", (user_id, entry_type, timestamp.isoformat(), False, comments))
else:
cursor.execute("""
INSERT INTO entries (user_id, entrytype, ts, paid)
VALUES (?, ?, ?, ?)
""", (user_id, entry_type, timestamp.isoformat(), False))
conn.commit()
conn.close()
def generate_report(user_id, begin_date, end_date, client_timezone):
logging.error(f"user_id={user_id}, begin_date={begin_date}, end_date={end_date}")
conn = get_db_connection()
cursor = conn.cursor()
begin_dt = make_timezone_aware(parse_iso_datetime(begin_date), client_timezone)
end_dt = make_timezone_aware(parse_iso_datetime(end_date), client_timezone)
logging.error(f"begin_dt={begin_dt}, end_dt={end_dt}")
cursor.execute("""
SELECT entrytype, ts, paid, comments
FROM entries FROM entries
WHERE user_id = ? WHERE user_id = ?
AND ts BETWEEN ? AND ? AND datetime(ts) BETWEEN datetime(?) AND datetime(?)
ORDER BY ts ASC ORDER BY ts ASC
""", (user_id, begin_dt, end_dt)) """, (user_id, begin_dt.isoformat(), end_dt.isoformat()))
rows = cursor.fetchall() rows = cursor.fetchall()
conn.close() conn.close()
total_seconds = 0 total_seconds = 0
clock_in_time = None
paid_seconds = 0 paid_seconds = 0
actions = []
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
delta = end_dt - timestamp if row["entrytype"] == "in":
if row["paid"]: clock_in_time = timestamp
paid_seconds += delta.total_seconds() elif row["entrytype"] == "out" and clock_in_time is not None:
else: delta = timestamp - clock_in_time
if row["paid"]:
paid_seconds += delta.total_seconds()
total_seconds += delta.total_seconds() total_seconds += delta.total_seconds()
if row["comments"]:
if row["paid"]:
actions.append("(PAID) {}".format(row["comments"]))
else:
actions.append(row["comments"])
total_hours = total_seconds / 3600.0 total_hours = total_seconds / 3600.0
paid_hours = paid_seconds / 3600.0 paid_hours = paid_seconds / 3600.0
return round(total_hours, 2), round(paid_hours, 2) return round(total_hours, 2), round(paid_hours, 2), actions
@app.route("/", methods=["GET", "POST"]) @app.route("/", methods=["GET", "POST"])
def index(): def index():
report_hours = None
user_report = {}
all_user_reports = []
selected_user_id = None
now = est_now()
default_begin = now - timedelta(days=7)
default_end = now
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()
conn.close()
if request.method == "POST":
selected_user_id = request.form.get("user_id")
action = request.form.get("action")
client_timestamp = request.form.get("client_timestamp")
client_timezone = request.form.get("client_timezone")
comments = request.form.get("comments")
if action == "clock_in":
create_entry(
selected_user_id,
"in",
client_timestamp,
comments,
client_timezone
)
elif action == "clock_out":
create_entry(
selected_user_id,
"out",
client_timestamp,
comments,
client_timezone
)
elif action == "pay":
begin_date = request.form.get("begin_date")
end_date = request.form.get("end_date")
pay_hours(
selected_user_id,
begin_date,
end_date,
client_timezone
)
elif action == "report":
begin_date = request.form.get("begin_date")
end_date = request.form.get("end_date")
client_timezone = request.form.get("client_timezone")
report_hours, paid_hours, actions = generate_report(
selected_user_id,
begin_date,
end_date,
client_timezone
)
user_report = {
"total_hours": report_hours,
"paid_hours": paid_hours,
"actions": actions
}
default_begin = parse_iso_datetime(begin_date)
default_end = parse_iso_datetime(end_date)
for user in users:
total_hours, paid_hours, actions = generate_report(
user["id"],
default_begin.isoformat(),
default_end.isoformat(),
request.form.get("client_timezone")
)
all_user_reports.append({
"id": user["id"],
"name": user["name"],
"total_hours": total_hours,
"paid_hours": paid_hours,
"actions": actions
})
conn = get_db_connection()
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
@@ -135,61 +253,10 @@ def index():
"name": user["name"], "name": user["name"],
"since": ts.isoformat() "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":
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(
selected_user_id,
"in",
client_timestamp
)
elif action == "clock_out":
create_entry(
selected_user_id,
"out",
client_timestamp
)
elif action == "pay":
# Update the paid column for all entries for the selected user to True
create_entry(
selected_user_id,
"pay"
)
elif action == "report":
begin_date = request.form.get("begin_date")
end_date = request.form.get("end_date")
report_hours = generate_report(
selected_user_id,
begin_date,
end_date
)
default_begin = parse_iso_datetime(begin_date)
default_end = parse_iso_datetime(end_date)
else:
for user in users:
total_hours, paid_hours = generate_report(
user["id"],
default_begin.isoformat(),
default_end.isoformat()
)
all_user_reports.append({
"id": user["id"],
"name": user["name"],
"total_hours": total_hours,
"paid_hours": paid_hours
})
return render_template( return render_template(
"index.html", "index.html",
users=users, users=users,
report_hours=report_hours, user_report=user_report,
all_user_reports=all_user_reports, all_user_reports=all_user_reports,
clocked_in_users=clocked_in_list, clocked_in_users=clocked_in_list,
default_begin=default_begin.isoformat(timespec="minutes"), default_begin=default_begin.isoformat(timespec="minutes"),
@@ -197,48 +264,6 @@ def index():
selected_user_id=selected_user_id selected_user_id=selected_user_id
) )
@app.route("/api/users", methods=["GET"])
def api_get_users():
conn = get_db_connection()
users = conn.execute("""
SELECT id, name
FROM users
""").fetchall()
conn.close()
return jsonify([dict(user) for user in users])
@app.route("/api/entries", methods=["GET"])
def api_get_entries():
conn = get_db_connection()
entries = conn.execute("""
SELECT id, user_id, entrytype, ts, paid
FROM entries
ORDER BY ts DESC
""").fetchall()
conn.close()
return jsonify([dict(entry) for entry in entries])
@app.route("/api/entries", methods=["POST"])
def api_create_entry():
data = request.get_json()
user_id = data.get("user_id")
entry_type = data.get("entrytype")
client_timestamp = data.get("timestamp")
paid = data.get("paid", False)
if entry_type not in ("in", "out", "pay"):
return jsonify({
"error": "Invalid entrytype"
}), 400
create_entry(
user_id,
entry_type,
client_timestamp,
paid
)
return jsonify({
"status": "success"
}), 201
if __name__ == "__main__": if __name__ == "__main__":
initialize_database() initialize_database()
app.run( app.run(
@@ -246,4 +271,3 @@ if __name__ == "__main__":
port=5000, port=5000,
debug=False debug=False
) )

View File

@@ -39,6 +39,7 @@
<th>User</th> <th>User</th>
<th>Total Hours Worked</th> <th>Total Hours Worked</th>
<th>Paid Hours</th> <th>Paid Hours</th>
<th>Actions</th>
</tr> </tr>
{% for report in all_user_reports %} {% for report in all_user_reports %}
@@ -46,6 +47,13 @@
<td>{{ report.name }}</td> <td>{{ report.name }}</td>
<td>{{ report.total_hours }}</td> <td>{{ report.total_hours }}</td>
<td>{{ report.paid_hours }}</td> <td>{{ report.paid_hours }}</td>
<td>
<ul>
{% for action in report.actions %}
<li>{{ action }}</li>
{% endfor %}
</ul>
</td>
</tr> </tr>
{% endfor %} {% endfor %}
@@ -60,6 +68,12 @@
<form method="POST" id="timeclock-form"> <form method="POST" id="timeclock-form">
<input
type="hidden"
id="client_timezone"
name="client_timezone"
value=""/>
<input <input
type="hidden" type="hidden"
name="client_timestamp" name="client_timestamp"
@@ -131,8 +145,7 @@
type="datetime-local" type="datetime-local"
name="begin_date" name="begin_date"
id="begin_date" id="begin_date"
value="" value="">
>
</td> </td>
</tr> </tr>
@@ -149,6 +162,18 @@
</td> </td>
</tr> </tr>
<tr>
<td>Comments</td>
<td>
<textarea
name="comments"
id="comments"
rows="5" cols="72"
></textarea>
</td>
</tr>
<tr> <tr>
<td>Report</td> <td>Report</td>
@@ -169,7 +194,7 @@
<br> <br>
{% if report_hours is not none %} {% if user_report is not none %}
<h2>Custom Report</h2> <h2>Custom Report</h2>
@@ -178,11 +203,19 @@
<tr> <tr>
<th>Total Hours Worked</th> <th>Total Hours Worked</th>
<th>Paid Hours</th> <th>Paid Hours</th>
<th>Actions</th>
</tr> </tr>
<tr> <tr>
<td>{{ report_hours.total_hours }}</td> <td>{{ user_report.total_hours }}</td>
<td>{{ report_hours.paid_hours }}</td> <td>{{ user_report.paid_hours }}</td>
<td>
<ul>
{% for action in user_report.actions %}
<li>{{ action }}</li>
{% endfor %}
</ul>
</td>
</tr> </tr>
</table> </table>
@@ -191,6 +224,19 @@
<script> <script>
function getFormattedOffset() {
const offset = new Date().getTimezoneOffset();
const absMinutes = Math.abs(offset);
// Invert the sign to match standard UTC notation
const sign = offset <= 0 ? "+" : "-";
const hours = String(Math.floor(absMinutes / 60)).padStart(2, "0");
const minutes = String(absMinutes % 60).padStart(2, "0");
return `${sign}${hours}:${minutes}`;
}
function localDatetimeValue(date) { function localDatetimeValue(date) {
const pad = (v) => String(v).padStart(2, "0"); const pad = (v) => String(v).padStart(2, "0");
return ( return (
@@ -210,6 +256,7 @@ window.addEventListener("load", () => {
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("client_timezone").value = getFormattedOffset();
document.getElementById("begin_date").value = localDatetimeValue(weekAgo); document.getElementById("begin_date").value = localDatetimeValue(weekAgo);
document.getElementById("end_date").value = localDatetimeValue(now); document.getElementById("end_date").value = localDatetimeValue(now);
document.querySelectorAll(".tztime").forEach((el) => { document.querySelectorAll(".tztime").forEach((el) => {