Files
Fatima/app.py
T

277 lines
10 KiB
Python
Raw Normal View History

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__":
2026-07-16 07:13:26 +02:00
app.run(host="0.0.0.0", port=int(os.environ.get("PORT", "9009")))