Initial add
This commit is contained in:
7
.dockerignore
Normal file
7
.dockerignore
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
*.pyo
|
||||||
|
*.pyd
|
||||||
|
venv/
|
||||||
|
.env
|
||||||
|
.git/
|
||||||
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
# Created by venv; see https://docs.python.org/3/library/venv.html
|
||||||
|
*~
|
||||||
6
Chart.yaml
Normal file
6
Chart.yaml
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
apiVersion: v2
|
||||||
|
name: timeclock
|
||||||
|
description: Flask app with SQLite persistence
|
||||||
|
type: application
|
||||||
|
version: 0.1.0
|
||||||
|
appVersion: "1.0"
|
||||||
22
Dockerfile
Normal file
22
Dockerfile
Normal file
@@ -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"]
|
||||||
298
app.py
Normal file
298
app.py
Normal file
@@ -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
|
||||||
|
)
|
||||||
34
chart/templates/deployment.yaml
Normal file
34
chart/templates/deployment.yaml
Normal file
@@ -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
|
||||||
4
chart/templates/namespace.yaml
Normal file
4
chart/templates/namespace.yaml
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
apiVersion: v1
|
||||||
|
kind: Namespace
|
||||||
|
metadata:
|
||||||
|
name: {{ .Values.namespace }}
|
||||||
14
chart/templates/pvc.yaml
Normal file
14
chart/templates/pvc.yaml
Normal file
@@ -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 }}
|
||||||
15
chart/templates/service.yaml
Normal file
15
chart/templates/service.yaml
Normal file
@@ -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
|
||||||
21
chart/values.yaml
Normal file
21
chart/values.yaml
Normal file
@@ -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
|
||||||
2
requirements.txt
Normal file
2
requirements.txt
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
flask
|
||||||
|
gunicorn
|
||||||
101
templates/index.html
Normal file
101
templates/index.html
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>Time Clock</title>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<h1>Simple Time Clock</h1>
|
||||||
|
|
||||||
|
<form method="POST">
|
||||||
|
|
||||||
|
<table border="1" cellpadding="5">
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
<td>User</td>
|
||||||
|
<td>
|
||||||
|
<select name="user_id">
|
||||||
|
|
||||||
|
{% for user in users %}
|
||||||
|
<option value="{{ user.id }}">
|
||||||
|
{{ user.name }}
|
||||||
|
</option>
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
</select>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
<td>Clock Actions</td>
|
||||||
|
|
||||||
|
<td>
|
||||||
|
<button type="submit" name="action" value="clock_in">
|
||||||
|
Clock In
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button type="submit" name="action" value="clock_out">
|
||||||
|
Clock Out
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
<td>Begin Date</td>
|
||||||
|
|
||||||
|
<td>
|
||||||
|
<input
|
||||||
|
type="datetime-local"
|
||||||
|
name="begin_date"
|
||||||
|
value=""
|
||||||
|
>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
<td>End Date</td>
|
||||||
|
|
||||||
|
<td>
|
||||||
|
<input
|
||||||
|
type="datetime-local"
|
||||||
|
name="end_date"
|
||||||
|
value=""
|
||||||
|
>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
<td>Report</td>
|
||||||
|
|
||||||
|
<td>
|
||||||
|
<button type="submit" name="action" value="report">
|
||||||
|
Generate Report
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
</table>
|
||||||
|
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<br>
|
||||||
|
|
||||||
|
{% if report_hours is not none %}
|
||||||
|
|
||||||
|
<table border="1" cellpadding="5">
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
<th>Total Hours Worked</th>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
<td>{{ report_hours }}</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
</table>
|
||||||
|
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Reference in New Issue
Block a user