Qwen broke several things with the last features. Fix those. Handle user timezones properly. Use a textarea for the comments instead of a text input.
This commit is contained in:
239
app.py
239
app.py
@@ -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
|
||||||
@@ -51,20 +66,34 @@ 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, comments=None):
|
def pay_hours(user_id, begin_date, end_date):
|
||||||
|
conn = get_db_connection()
|
||||||
|
conn.set_trace_callback(logging.error)
|
||||||
|
cursor = conn.cursor()
|
||||||
|
begin_dt = parse_iso_datetime(begin_date)
|
||||||
|
end_dt = parse_iso_datetime(end_date)
|
||||||
|
cursor.execute("""
|
||||||
|
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()
|
conn = get_db_connection()
|
||||||
cursor = conn.cursor()
|
cursor = conn.cursor()
|
||||||
if client_timestamp:
|
if client_timestamp:
|
||||||
timestamp = parse_iso_datetime(client_timestamp)
|
timestamp = make_timezone_aware(parse_iso_datetime(client_timestamp), client_timezone)
|
||||||
else:
|
else:
|
||||||
timestamp = utc_now()
|
timestamp = est_now()
|
||||||
if comments:
|
if comments:
|
||||||
cursor.execute("""
|
cursor.execute("""
|
||||||
INSERT INTO entries (user_id, entrytype, ts, paid, comments)
|
INSERT INTO entries (user_id, entrytype, ts, paid, comments)
|
||||||
@@ -78,33 +107,41 @@ def create_entry(user_id, entry_type, client_timestamp=None, comments=None):
|
|||||||
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, client_timezone):
|
||||||
|
logging.error(f"user_id={user_id}, begin_date={begin_date}, end_date={end_date}")
|
||||||
conn = get_db_connection()
|
conn = get_db_connection()
|
||||||
cursor = conn.cursor()
|
cursor = conn.cursor()
|
||||||
begin_dt = parse_iso_datetime(begin_date)
|
begin_dt = make_timezone_aware(parse_iso_datetime(begin_date), client_timezone)
|
||||||
end_dt = parse_iso_datetime(end_date)
|
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("""
|
cursor.execute("""
|
||||||
SELECT entrytype, ts, paid, comments
|
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 = []
|
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":
|
||||||
|
clock_in_time = timestamp
|
||||||
|
elif row["entrytype"] == "out" and clock_in_time is not None:
|
||||||
|
delta = timestamp - clock_in_time
|
||||||
if row["paid"]:
|
if row["paid"]:
|
||||||
paid_seconds += delta.total_seconds()
|
paid_seconds += delta.total_seconds()
|
||||||
else:
|
|
||||||
total_seconds += delta.total_seconds()
|
total_seconds += delta.total_seconds()
|
||||||
if row["comments"]:
|
if row["comments"]:
|
||||||
|
if row["paid"]:
|
||||||
|
actions.append("(PAID) {}".format(row["comments"]))
|
||||||
|
else:
|
||||||
actions.append(row["comments"])
|
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
|
||||||
@@ -112,12 +149,83 @@ def generate_report(user_id, begin_date, end_date):
|
|||||||
|
|
||||||
@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
|
||||||
@@ -145,65 +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")
|
|
||||||
comments = request.form.get("comments")
|
|
||||||
if action == "clock_in":
|
|
||||||
create_entry(
|
|
||||||
selected_user_id,
|
|
||||||
"in",
|
|
||||||
client_timestamp,
|
|
||||||
comments
|
|
||||||
)
|
|
||||||
elif action == "clock_out":
|
|
||||||
create_entry(
|
|
||||||
selected_user_id,
|
|
||||||
"out",
|
|
||||||
client_timestamp,
|
|
||||||
comments
|
|
||||||
)
|
|
||||||
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, paid_hours, actions = 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, actions = 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,
|
|
||||||
"actions": actions
|
|
||||||
})
|
|
||||||
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"),
|
||||||
@@ -211,50 +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, comments
|
|
||||||
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)
|
|
||||||
comments = data.get("comments", "")
|
|
||||||
if entry_type not in ("in", "out", "pay"):
|
|
||||||
return jsonify({
|
|
||||||
"error": "Invalid entrytype"
|
|
||||||
}), 400
|
|
||||||
create_entry(
|
|
||||||
user_id,
|
|
||||||
entry_type,
|
|
||||||
client_timestamp,
|
|
||||||
paid,
|
|
||||||
comments
|
|
||||||
)
|
|
||||||
return jsonify({
|
|
||||||
"status": "success"
|
|
||||||
}), 201
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
initialize_database()
|
initialize_database()
|
||||||
app.run(
|
app.run(
|
||||||
|
|||||||
@@ -68,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"
|
||||||
@@ -139,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>
|
||||||
|
|
||||||
@@ -161,12 +166,11 @@
|
|||||||
<td>Comments</td>
|
<td>Comments</td>
|
||||||
|
|
||||||
<td>
|
<td>
|
||||||
<input
|
<textarea
|
||||||
type="textarea"
|
|
||||||
name="comments"
|
name="comments"
|
||||||
id="comments"
|
id="comments"
|
||||||
value=""
|
rows="5" cols="72"
|
||||||
>
|
></textarea>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
||||||
@@ -190,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>
|
||||||
|
|
||||||
@@ -199,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>
|
||||||
@@ -212,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 (
|
||||||
@@ -231,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) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user