From 86e6adfe2e6bf80836d321be664709689a1b5c37 Mon Sep 17 00:00:00 2001 From: Andrew Kesterson Date: Tue, 19 May 2026 18:14:39 -0400 Subject: [PATCH] Initial add --- .dockerignore | 7 + .gitignore | 2 + Chart.yaml | 6 + Dockerfile | 22 +++ app.py | 298 ++++++++++++++++++++++++++++++++ chart/templates/deployment.yaml | 34 ++++ chart/templates/namespace.yaml | 4 + chart/templates/pvc.yaml | 14 ++ chart/templates/service.yaml | 15 ++ chart/values.yaml | 21 +++ requirements.txt | 2 + templates/index.html | 101 +++++++++++ 12 files changed, 526 insertions(+) create mode 100644 .dockerignore create mode 100644 .gitignore create mode 100644 Chart.yaml create mode 100644 Dockerfile create mode 100644 app.py create mode 100644 chart/templates/deployment.yaml create mode 100644 chart/templates/namespace.yaml create mode 100644 chart/templates/pvc.yaml create mode 100644 chart/templates/service.yaml create mode 100644 chart/values.yaml create mode 100644 requirements.txt create mode 100644 templates/index.html diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..0a9ea6e --- /dev/null +++ b/.dockerignore @@ -0,0 +1,7 @@ +__pycache__/ +*.pyc +*.pyo +*.pyd +venv/ +.env +.git/ \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7778be9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +# Created by venv; see https://docs.python.org/3/library/venv.html +*~ diff --git a/Chart.yaml b/Chart.yaml new file mode 100644 index 0000000..ffbeb8b --- /dev/null +++ b/Chart.yaml @@ -0,0 +1,6 @@ +apiVersion: v2 +name: timeclock +description: Flask app with SQLite persistence +type: application +version: 0.1.0 +appVersion: "1.0" diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..649f204 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,22 @@ +FROM python:3.12-slim + +# Prevent Python from buffering stdout/stderr +ENV PYTHONUNBUFFERED=1 + +WORKDIR /app + +# Install dependencies +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +# Copy application code +COPY . . + +# Create database directory if needed +RUN mkdir -p database + +# Expose Flask/Gunicorn port +EXPOSE 5000 + +# Start app +CMD ["gunicorn", "-b", "0.0.0.0:5000", "app:app"] \ No newline at end of file diff --git a/app.py b/app.py new file mode 100644 index 0000000..86df8d1 --- /dev/null +++ b/app.py @@ -0,0 +1,298 @@ +#!/usr/bin/env python3 + +""" +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 + +# ----------------------------------------------------------------------------- +# Flask Configuration +# ----------------------------------------------------------------------------- + +app = Flask(__name__) + +DATABASE = "database/timeclock.db" + +# ----------------------------------------------------------------------------- +# Database Helpers +# ----------------------------------------------------------------------------- + +def get_db_connection(): + """ + Create and return a sqlite3 database connection. + """ + conn = sqlite3.connect(DATABASE) + conn.row_factory = sqlite3.Row + return conn + + +def initialize_database(): + """ + Create database tables if they do not already exist. + """ + + conn = get_db_connection() + cursor = conn.cursor() + + # Users table + cursor.execute(""" + CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL + ) + """) + + # 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, + 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() + +# ----------------------------------------------------------------------------- +# Utility Functions +# ----------------------------------------------------------------------------- + +def create_entry(user_id, entry_type): + """ + 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") + + cursor.execute(""" + INSERT INTO entries (user_id, entrytype, ts) + VALUES (?, ?, ?) + """, (user_id, entry_type, timestamp)) + + 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) + + 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") + )) + + 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": + clock_in_time = timestamp + + elif entry_type == "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 + + return round(total_hours, 2) + +# ----------------------------------------------------------------------------- +# Web UI Routes +# ----------------------------------------------------------------------------- + +@app.route("/", methods=["GET", "POST"]) +def index(): + """ + Main application page. + """ + + conn = get_db_connection() + + users = conn.execute(""" + SELECT id, name + FROM users + ORDER BY name + """).fetchall() + + conn.close() + + report_hours = None + + if request.method == "POST": + + user_id = request.form.get("user_id") + action = request.form.get("action") + + if action == "clock_in": + create_entry(user_id, "in") + + elif action == "clock_out": + create_entry(user_id, "out") + + elif action == "report": + + begin_date = request.form.get("begin_date") + end_date = request.form.get("end_date") + + report_hours = generate_report( + user_id, + begin_date, + end_date + ) + + return render_template( + "index.html", + users=users, + report_hours=report_hours + ) + +# ----------------------------------------------------------------------------- +# CRUD API +# ----------------------------------------------------------------------------- + +@app.route("/api/users", methods=["GET"]) +def api_get_users(): + """ + Return all 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(): + """ + Return all entries. + """ + + conn = get_db_connection() + + entries = conn.execute(""" + SELECT id, user_id, entrytype, ts + 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(): + """ + Create a clock entry. + """ + + data = request.get_json() + + user_id = data.get("user_id") + entry_type = data.get("entrytype") + + if entry_type not in ("in", "out"): + return jsonify({"error": "Invalid entrytype"}), 400 + + create_entry(user_id, entry_type) + + return jsonify({"status": "success"}), 201 + +# ----------------------------------------------------------------------------- +# Application Entry Point +# ----------------------------------------------------------------------------- + +if __name__ == "__main__": + + initialize_database() + + app.run( + host="0.0.0.0", + port=5000, + debug=False + ) diff --git a/chart/templates/deployment.yaml b/chart/templates/deployment.yaml new file mode 100644 index 0000000..e94511b --- /dev/null +++ b/chart/templates/deployment.yaml @@ -0,0 +1,34 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "timeclock.fullname" . }} + namespace: {{ .Values.namespace }} +spec: + replicas: {{ .Values.replicaCount }} + + selector: + matchLabels: + app: {{ include "timeclock.name" . }} + + template: + metadata: + labels: + app: {{ include "timeclock.name" . }} + + spec: + containers: + - name: flask-app + image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}" + imagePullPolicy: {{ .Values.image.pullPolicy }} + + ports: + - containerPort: 5000 + + volumeMounts: + - name: sqlite-data + mountPath: {{ .Values.app.dbPath }} + + volumes: + - name: sqlite-data + persistentVolumeClaim: + claimName: {{ include "timeclock.fullname" . }}-pvc diff --git a/chart/templates/namespace.yaml b/chart/templates/namespace.yaml new file mode 100644 index 0000000..77db5f9 --- /dev/null +++ b/chart/templates/namespace.yaml @@ -0,0 +1,4 @@ +apiVersion: v1 +kind: Namespace +metadata: + name: {{ .Values.namespace }} diff --git a/chart/templates/pvc.yaml b/chart/templates/pvc.yaml new file mode 100644 index 0000000..dca293c --- /dev/null +++ b/chart/templates/pvc.yaml @@ -0,0 +1,14 @@ +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: {{ include "timeclock.fullname" . }}-pvc + namespace: {{ .Values.namespace }} +spec: + accessModes: + - {{ .Values.persistence.accessMode }} + resources: + requests: + storage: {{ .Values.persistence.size }} + {{- if .Values.persistence.storageClass }} + storageClassName: {{ .Values.persistence.storageClass }} + {{- end }} diff --git a/chart/templates/service.yaml b/chart/templates/service.yaml new file mode 100644 index 0000000..d8b30c8 --- /dev/null +++ b/chart/templates/service.yaml @@ -0,0 +1,15 @@ +apiVersion: v1 +kind: Service +metadata: + name: {{ include "timeclock.fullname" . }} + namespace: {{ .Values.namespace }} +spec: + type: {{ .Values.service.type }} + + selector: + app: {{ include "timeclock.name" . }} + + ports: + - protocol: TCP + port: {{ .Values.service.port }} + targetPort: 5000 diff --git a/chart/values.yaml b/chart/values.yaml new file mode 100644 index 0000000..cac0b04 --- /dev/null +++ b/chart/values.yaml @@ -0,0 +1,21 @@ +namespace: flask-app + +replicaCount: 1 + +image: + repository: registry.home.aklabs.net/timeclock + tag: latest + pullPolicy: IfNotPresent + +service: + type: ClusterIP + port: 5000 + +persistence: + enabled: true + accessMode: ReadWriteOnce + size: 1Gi + storageClass: "" + +app: + dbPath: /app/database diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..e4a286c --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +flask +gunicorn diff --git a/templates/index.html b/templates/index.html new file mode 100644 index 0000000..d0f74a4 --- /dev/null +++ b/templates/index.html @@ -0,0 +1,101 @@ + + + + Time Clock + + + + +

Simple Time Clock

+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
User + +
Clock Actions + + + +
Begin Date + +
End Date + +
Report + +
+ +
+ +
+ +{% if report_hours is not none %} + + + + + + + + + + + +
Total Hours Worked
{{ report_hours }}
+ +{% endif %} + + +