First fully working model with data from 2025 and 2026
This commit is contained in:
@@ -0,0 +1,276 @@
|
||||
import os
|
||||
import secrets
|
||||
import sqlite3
|
||||
from datetime import date
|
||||
from decimal import Decimal, InvalidOperation, ROUND_HALF_UP
|
||||
from functools import wraps
|
||||
from pathlib import Path
|
||||
|
||||
from flask import Flask, jsonify, redirect, render_template, request, session, url_for
|
||||
import yaml
|
||||
|
||||
|
||||
def load_yaml_config(path):
|
||||
config_path = Path(path)
|
||||
if not config_path.is_file():
|
||||
raise RuntimeError(
|
||||
f"Configuration file not found: {config_path}. "
|
||||
"Copy config.example.yaml to config.yaml and set its values."
|
||||
)
|
||||
|
||||
with config_path.open(encoding="utf-8") as config_file:
|
||||
values = yaml.safe_load(config_file) or {}
|
||||
|
||||
auth = values.get("auth", {})
|
||||
flask_config = values.get("flask", {})
|
||||
amount_config = values.get("amounts", {})
|
||||
required = {
|
||||
"SECRET_KEY": flask_config.get("secret_key"),
|
||||
"APP_USERNAME": auth.get("username"),
|
||||
"APP_PASSWORD": auth.get("password"),
|
||||
}
|
||||
missing = [name for name, value in required.items() if not isinstance(value, str) or not value]
|
||||
if missing:
|
||||
raise RuntimeError(f"Missing or empty values in {config_path}: {', '.join(missing)}")
|
||||
presets = amount_config.get("presets", ["55", "27.50"])
|
||||
if not isinstance(presets, list) or len(presets) != 2:
|
||||
raise RuntimeError(f"{config_path}: amounts.presets must contain exactly two amounts")
|
||||
try:
|
||||
normalized_presets = []
|
||||
for preset in presets:
|
||||
amount = Decimal(str(preset))
|
||||
if not amount.is_finite() or amount <= 0:
|
||||
raise InvalidOperation
|
||||
normalized_presets.append(f"{amount:.2f}".rstrip("0").rstrip("."))
|
||||
except (InvalidOperation, ValueError):
|
||||
raise RuntimeError(f"{config_path}: amount presets must be positive numbers") from None
|
||||
required["PRESET_AMOUNTS"] = normalized_presets
|
||||
return required
|
||||
|
||||
|
||||
def create_app(test_config=None):
|
||||
app = Flask(__name__)
|
||||
config_path = os.environ.get("APP_CONFIG", Path(app.root_path) / "config.yaml")
|
||||
app.config.from_mapping(load_yaml_config(config_path))
|
||||
app.config.setdefault("DATABASE", str(Path(app.instance_path) / "calendar.sqlite"))
|
||||
|
||||
# Environment variables remain useful when deploying without a local file.
|
||||
for name in ("SECRET_KEY", "APP_USERNAME", "APP_PASSWORD"):
|
||||
if os.environ.get(name):
|
||||
app.config[name] = os.environ[name]
|
||||
if test_config:
|
||||
app.config.update(test_config)
|
||||
|
||||
Path(app.config["DATABASE"]).parent.mkdir(parents=True, exist_ok=True)
|
||||
with sqlite3.connect(app.config["DATABASE"]) as database:
|
||||
database.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS date_choices (
|
||||
choice_date TEXT PRIMARY KEY,
|
||||
amount_cents INTEGER NOT NULL CHECK (amount_cents >= 0),
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
"""
|
||||
)
|
||||
database.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS date_statuses (
|
||||
status_date TEXT PRIMARY KEY,
|
||||
day_status TEXT NOT NULL DEFAULT 'worked'
|
||||
CHECK (day_status IN ('worked', 'vacation', 'sick')),
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
def get_database():
|
||||
connection = sqlite3.connect(app.config["DATABASE"])
|
||||
connection.row_factory = sqlite3.Row
|
||||
return connection
|
||||
|
||||
def login_required(view):
|
||||
@wraps(view)
|
||||
def wrapped_view(*args, **kwargs):
|
||||
if not session.get("authorized"):
|
||||
return redirect(url_for("login"))
|
||||
return view(*args, **kwargs)
|
||||
|
||||
return wrapped_view
|
||||
|
||||
@app.route("/login", methods=("GET", "POST"))
|
||||
def login():
|
||||
if session.get("authorized"):
|
||||
return redirect(url_for("calendar"))
|
||||
|
||||
error = None
|
||||
if request.method == "POST":
|
||||
username = request.form.get("username", "")
|
||||
password = request.form.get("password", "")
|
||||
valid_user = secrets.compare_digest(username, app.config["APP_USERNAME"])
|
||||
valid_password = secrets.compare_digest(password, app.config["APP_PASSWORD"])
|
||||
if valid_user and valid_password:
|
||||
session.clear()
|
||||
session["authorized"] = True
|
||||
return redirect(url_for("calendar"))
|
||||
error = "That username or password is not correct."
|
||||
|
||||
return render_template("login.html", error=error)
|
||||
|
||||
@app.route("/")
|
||||
@login_required
|
||||
def calendar():
|
||||
with get_database() as database:
|
||||
choices = {
|
||||
row["choice_date"]: row["amount_cents"]
|
||||
for row in database.execute("SELECT choice_date, amount_cents FROM date_choices")
|
||||
}
|
||||
statuses = {
|
||||
row["status_date"]: row["day_status"]
|
||||
for row in database.execute("SELECT status_date, day_status FROM date_statuses")
|
||||
}
|
||||
return render_template(
|
||||
"calendar.html",
|
||||
today=date.today().isoformat(),
|
||||
choices=choices,
|
||||
statuses=statuses,
|
||||
preset_amounts=app.config["PRESET_AMOUNTS"],
|
||||
)
|
||||
|
||||
@app.post("/api/choices")
|
||||
@login_required
|
||||
def save_choice():
|
||||
data = request.get_json(silent=True) or {}
|
||||
choice_date = data.get("date", "")
|
||||
try:
|
||||
date.fromisoformat(choice_date)
|
||||
except (TypeError, ValueError):
|
||||
return jsonify(error="Enter a valid date."), 400
|
||||
|
||||
try:
|
||||
amount = Decimal(str(data.get("amount", "")).replace(",", "."))
|
||||
if not amount.is_finite() or amount < 0:
|
||||
raise InvalidOperation
|
||||
amount_cents = int((amount * 100).quantize(Decimal("1"), rounding=ROUND_HALF_UP))
|
||||
except (InvalidOperation, ValueError):
|
||||
return jsonify(error="Enter a valid amount of zero or more."), 400
|
||||
|
||||
with get_database() as database:
|
||||
database.execute(
|
||||
"""
|
||||
INSERT INTO date_choices (choice_date, amount_cents)
|
||||
VALUES (?, ?)
|
||||
ON CONFLICT(choice_date) DO UPDATE SET
|
||||
amount_cents = excluded.amount_cents,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
""",
|
||||
(choice_date, amount_cents),
|
||||
)
|
||||
return jsonify(date=choice_date, amount_cents=amount_cents)
|
||||
|
||||
@app.post("/api/statuses")
|
||||
@login_required
|
||||
def save_status():
|
||||
data = request.get_json(silent=True) or {}
|
||||
status_date = data.get("date", "")
|
||||
day_status = data.get("status", "")
|
||||
try:
|
||||
date.fromisoformat(status_date)
|
||||
except (TypeError, ValueError):
|
||||
return jsonify(error="Enter a valid date."), 400
|
||||
if day_status not in {"worked", "vacation", "sick"}:
|
||||
return jsonify(error="Choose worked, vacation, or sick."), 400
|
||||
|
||||
with get_database() as database:
|
||||
database.execute(
|
||||
"""
|
||||
INSERT INTO date_statuses (status_date, day_status)
|
||||
VALUES (?, ?)
|
||||
ON CONFLICT(status_date) DO UPDATE SET
|
||||
day_status = excluded.day_status,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
""",
|
||||
(status_date, day_status),
|
||||
)
|
||||
return jsonify(date=status_date, status=day_status)
|
||||
|
||||
@app.post("/api/delete-date")
|
||||
@login_required
|
||||
def delete_date_data():
|
||||
data = request.get_json(silent=True) or {}
|
||||
selected_date = data.get("date", "")
|
||||
try:
|
||||
date.fromisoformat(selected_date)
|
||||
except (TypeError, ValueError):
|
||||
return jsonify(error="Enter a valid date."), 400
|
||||
|
||||
with get_database() as database:
|
||||
database.execute("DELETE FROM date_choices WHERE choice_date = ?", (selected_date,))
|
||||
database.execute("DELETE FROM date_statuses WHERE status_date = ?", (selected_date,))
|
||||
return jsonify(date=selected_date, deleted=True)
|
||||
|
||||
@app.get("/report")
|
||||
@login_required
|
||||
def report():
|
||||
current_year = request.args.get("year", default=date.today().year, type=int)
|
||||
if current_year < 1900 or current_year > 9998:
|
||||
return redirect(url_for("report"))
|
||||
start_date = f"{current_year}-01-01"
|
||||
end_date = f"{current_year + 1}-01-01"
|
||||
with get_database() as database:
|
||||
rows = database.execute(
|
||||
"""
|
||||
WITH recorded_dates AS (
|
||||
SELECT choice_date AS selected_date FROM date_choices
|
||||
WHERE choice_date >= ? AND choice_date < ?
|
||||
UNION
|
||||
SELECT status_date AS selected_date FROM date_statuses
|
||||
WHERE status_date >= ? AND status_date < ?
|
||||
)
|
||||
SELECT
|
||||
COALESCE(date_statuses.day_status, 'worked') AS category,
|
||||
COUNT(*) AS day_count,
|
||||
COALESCE(SUM(date_choices.amount_cents), 0) AS amount_cents
|
||||
FROM recorded_dates
|
||||
LEFT JOIN date_choices
|
||||
ON date_choices.choice_date = recorded_dates.selected_date
|
||||
LEFT JOIN date_statuses
|
||||
ON date_statuses.status_date = recorded_dates.selected_date
|
||||
GROUP BY category
|
||||
""",
|
||||
(start_date, end_date, start_date, end_date),
|
||||
).fetchall()
|
||||
|
||||
values = {row["category"]: row for row in rows}
|
||||
categories = [
|
||||
{
|
||||
"key": key,
|
||||
"label": label,
|
||||
"days": values[key]["day_count"] if key in values else 0,
|
||||
"amount_cents": values[key]["amount_cents"] if key in values else 0,
|
||||
}
|
||||
for key, label in (
|
||||
("worked", "Worked"),
|
||||
("vacation", "Vacation"),
|
||||
("sick", "Sick"),
|
||||
)
|
||||
]
|
||||
return render_template(
|
||||
"report.html",
|
||||
year=current_year,
|
||||
categories=categories,
|
||||
total_days=sum(item["days"] for item in categories),
|
||||
total_cents=sum(item["amount_cents"] for item in categories),
|
||||
)
|
||||
|
||||
@app.post("/logout")
|
||||
def logout():
|
||||
session.clear()
|
||||
return redirect(url_for("login"))
|
||||
|
||||
return app
|
||||
|
||||
|
||||
app = create_app()
|
||||
|
||||
if __name__ == "__main__":
|
||||
app.run(host="0.0.0.0", port=int(os.environ.get("PORT", "9009")), debug=True)
|
||||
Reference in New Issue
Block a user