Init repo

This commit is contained in:
jwradhe 2025-12-16 20:02:55 +01:00
parent e94c07b9d6
commit 93c6b44e93
18 changed files with 570 additions and 0 deletions

26
.github/workflows/tests.yml vendored Normal file
View File

@ -0,0 +1,26 @@
name: tests
on:
push:
pull_request:
jobs:
pytest:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Install dependencies
run: |
python -m pip install -U pip
pip install -r requirements.txt
pip install -r requirements-dev.txt
pip install pytest
- name: Run tests
run: |
pytest -q

0
__init__.py Normal file
View File

Binary file not shown.

Binary file not shown.

Binary file not shown.

163
app.py Normal file
View File

@ -0,0 +1,163 @@
import os
from datetime import datetime
from flask import Flask, render_template, redirect, url_for, request, flash
from db import get_db, init_db
# =====================================================
# Constants
# =====================================================
STATUS_VALUES = ("not-started", "in-progress", "done")
# =====================================================
# Flask App Factory
# =====================================================
def create_app(test_config: dict | None = None) -> Flask:
"""
Skapar och konfigurerar Flask-applikationen.
Används både för runtime och tester.
"""
app = Flask(__name__)
app.secret_key = os.environ.get("FLASK_SECRET_KEY", "dev-secret")
# -------------------------------------------------
# App Configuration
# -------------------------------------------------
default_db = os.path.join(os.path.dirname(__file__), "todo.db")
app.config.update(
DB_PATH=default_db,
TESTING=False,
)
if test_config:
app.config.update(test_config)
# -------------------------------------------------
# Initiera databasen
# -------------------------------------------------
init_db(app)
# =================================================
# Routes
# =================================================
@app.route("/")
def index():
"""
Root redirect till dashboard
"""
return redirect(url_for("dashboard"))
@app.route("/dashboard", methods=["GET"])
def dashboard():
"""
Visar alla todos
"""
with get_db(app) as conn:
rows = conn.execute(
"""
SELECT * FROM todo
ORDER BY
CASE status
WHEN 'not-started' THEN 1
WHEN 'in-progress' THEN 2
ELSE 3
END,
id DESC
"""
).fetchall()
return render_template(
"index.html",
todos=rows,
status_values=STATUS_VALUES,
)
@app.route("/todo/create", methods=["POST"])
def create_todo():
"""
Skapar en ny todo
"""
title = (request.form.get("title") or "").strip()
description = (request.form.get("description") or "").strip()
status = request.form.get("status") or "not-started"
if not title or not description:
flash("Title och description krävs.", "danger")
return redirect(url_for("dashboard"))
if status not in STATUS_VALUES:
status = "not-started"
now = datetime.now().isoformat(timespec="seconds")
with get_db(app) as conn:
conn.execute(
"""
INSERT INTO todo (title, description, status, created)
VALUES (?, ?, ?, ?)
""",
(title, description, status, now),
)
flash("Todo skapad!", "success")
return redirect(url_for("dashboard"))
@app.route("/todo/<int:todo_id>/status", methods=["POST"])
def update_status(todo_id: int):
"""
Uppdaterar status en todo
"""
status = request.form.get("status")
if status not in STATUS_VALUES:
flash("Ogiltig status.", "danger")
return redirect(url_for("dashboard"))
now = datetime.now().isoformat(timespec="seconds")
with get_db(app) as conn:
cur = conn.execute(
"""
UPDATE todo
SET status = ?, edited = ?
WHERE id = ?
""",
(status, now, todo_id),
)
flash(
"Status uppdaterad." if cur.rowcount else "Todo hittades inte.",
"success" if cur.rowcount else "danger",
)
return redirect(url_for("dashboard"))
@app.route("/todo/<int:todo_id>/delete", methods=["POST"])
def delete_todo(todo_id: int):
"""
Tar bort en todo
"""
with get_db(app) as conn:
cur = conn.execute(
"DELETE FROM todo WHERE id = ?",
(todo_id,),
)
flash(
"Todo borttagen." if cur.rowcount else "Todo hittades inte.",
"success" if cur.rowcount else "danger",
)
return redirect(url_for("dashboard"))
return app
# =====================================================
# Main
# =====================================================
if __name__ == "__main__":
app = create_app()
app.run(host="127.0.0.1", port=5001, debug=True)

34
db.py Normal file
View File

@ -0,0 +1,34 @@
import sqlite3
from flask import Flask
# =====================================================
# Database Helpers
# =====================================================
def get_db(app: Flask):
"""
Returnerar en SQLite-connection baserat appens DB_PATH
"""
conn = sqlite3.connect(app.config["DB_PATH"])
conn.row_factory = sqlite3.Row
return conn
def init_db(app: Flask):
"""
Skapar databasen och todo-tabellen om den inte redan finns
"""
with get_db(app) as conn:
conn.execute(
"""
CREATE TABLE IF NOT EXISTS todo (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
description TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'not-started'
CHECK (status IN ('not-started','in-progress','done')),
created TEXT NOT NULL,
edited TEXT
);
"""
)

3
pytest.ini Normal file
View File

@ -0,0 +1,3 @@
[pytest]
addopts = -q
pythonpath = .

2
requirements-dev.txt Normal file
View File

@ -0,0 +1,2 @@
pytest>=8.0
pytest-cov>=5.0

1
requirements.txt Normal file
View File

@ -0,0 +1 @@
Flask>=3.0

11
static/style.css Normal file
View File

@ -0,0 +1,11 @@
body {
background: #f6f7fb;
}
.card {
border-radius: 16px;
}
.navbar .nav-link.active {
font-weight: 600;
}

63
templates/base.html Normal file
View File

@ -0,0 +1,63 @@
<!DOCTYPE html>
<html lang="sv">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>{% block title %}TODO{% endblock %}</title>
<link
href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css"
rel="stylesheet"
/>
<link
rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.css"
/>
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}" />
<meta name="theme-color" content="#ffffff" />
<meta name="background-color" content="#ffffff" />
</head>
<body class="bg-light text-dark">
<nav class="navbar navbar-expand-lg navbar-light bg-white border-bottom">
<div class="container-fluid">
<button
class="navbar-toggler"
type="button"
data-bs-toggle="collapse"
data-bs-target="#navbarNav"
>
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarNav">
<ul class="navbar-nav me-auto mb-2 mb-lg-0">
<li class="nav-item">
<a class="nav-link {% if request.endpoint == 'dashboard' %}active{% endif %}" href="{{ url_for('dashboard') }}">
<i class="bi bi-house-door me-1"></i> Dashboard
</a>
</li>
</ul>
</div>
</div>
</nav>
<div class="container-xl py-4" style="max-width: 1100px">
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
<div class="mb-3">
{% for category, msg in messages %}
<div class="alert alert-{{ category }} mb-2" role="alert">{{ msg }}</div>
{% endfor %}
</div>
{% endif %}
{% endwith %}
{% block content %}{% endblock %}
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script>
</body>
</html>

96
templates/index.html Normal file
View File

@ -0,0 +1,96 @@
{% extends "base.html" %}
{% block title %}Dashboard - TODO{% endblock %}
{% block content %}
<div class="row g-3">
<div class="col-lg-4">
<div class="card shadow-sm">
<div class="card-body">
<h2 class="h6 mb-3">Ny todo</h2>
<form method="POST" action="{{ url_for('create_todo') }}">
<div class="mb-2">
<label class="form-label">Title</label>
<input class="form-control" name="title" required />
</div>
<div class="mb-2">
<label class="form-label">Description</label>
<textarea class="form-control" name="description" rows="3" required></textarea>
</div>
<div class="mb-3">
<label class="form-label">Status</label>
<select class="form-select" name="status">
<option value="not-started">Not started</option>
<option value="in-progress">In progress</option>
<option value="done">Done</option>
</select>
</div>
<button class="btn btn-primary w-100" type="submit">
<i class="bi bi-plus-lg me-1"></i> Skapa
</button>
</form>
</div>
</div>
</div>
<div class="col-lg-8">
<div class="card shadow-sm">
<div class="card-body">
<h2 class="h6 mb-3">Todos</h2>
{% if not todos %}
<div class="text-muted">Inga todos än.</div>
{% else %}
<div class="table-responsive">
<table class="table align-middle mb-0">
<thead>
<tr>
<th>Title</th>
<th>Status</th>
<th class="d-none d-md-table-cell">Created</th>
<th style="width: 1%"></th>
</tr>
</thead>
<tbody>
{% for t in todos %}
<tr>
<td>
<div class="fw-semibold">{{ t.title }}</div>
<div class="text-muted small">{{ t.description }}</div>
</td>
<td>
<form method="POST" action="{{ url_for('update_status', todo_id=t.id) }}" class="d-flex gap-2">
<select class="form-select form-select-sm" name="status" onchange="this.form.submit()">
<option value="not-started" {% if t.status=='not-started' %}selected{% endif %}>Not started</option>
<option value="in-progress" {% if t.status=='in-progress' %}selected{% endif %}>In progress</option>
<option value="done" {% if t.status=='done' %}selected{% endif %}>Done</option>
</select>
</form>
</td>
<td class="d-none d-md-table-cell text-muted small">
{{ t.created }}
</td>
<td>
<form method="POST" action="{{ url_for('delete_todo', todo_id=t.id) }}">
<button class="btn btn-outline-danger btn-sm" type="submit" title="Delete">
<i class="bi bi-trash"></i>
</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endif %}
</div>
</div>
</div>
</div>
{% endblock %}

0
tests/__init__.py Normal file
View File

Binary file not shown.

171
tests/test_app.py Normal file
View File

@ -0,0 +1,171 @@
import os
import sys
import sqlite3
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
from app import create_app
# =====================================================
# Helpers
# =====================================================
def fetch_one(db_path: str, sql: str, params=()):
con = sqlite3.connect(db_path)
try:
return con.execute(sql, params).fetchone()
finally:
con.close()
def fetch_all(db_path: str, sql: str, params=()):
con = sqlite3.connect(db_path)
try:
return con.execute(sql, params).fetchall()
finally:
con.close()
# =====================================================
# Tests
# =====================================================
def test_dashboard_loads(tmp_path):
db_path = tmp_path / "test.db"
app = create_app({"TESTING": True, "DB_PATH": str(db_path)})
client = app.test_client()
resp = client.get("/dashboard")
assert resp.status_code == 200
def test_create_todo_inserts_row(tmp_path):
db_path = tmp_path / "test.db"
app = create_app({"TESTING": True, "DB_PATH": str(db_path)})
client = app.test_client()
resp = client.post(
"/todo/create",
data={"title": "Test", "description": "Desc", "status": "not-started"},
follow_redirects=True,
)
assert resp.status_code == 200
row = fetch_one(str(db_path), "SELECT title, description, status FROM todo")
assert row == ("Test", "Desc", "not-started")
def test_create_requires_fields(tmp_path):
db_path = tmp_path / "test.db"
app = create_app({"TESTING": True, "DB_PATH": str(db_path)})
client = app.test_client()
resp = client.post(
"/todo/create",
data={"title": "", "description": "", "status": "not-started"},
follow_redirects=True,
)
assert resp.status_code == 200
row = fetch_one(str(db_path), "SELECT COUNT(*) FROM todo")
assert row[0] == 0
def test_invalid_status_is_rejected_on_update(tmp_path):
db_path = tmp_path / "test.db"
app = create_app({"TESTING": True, "DB_PATH": str(db_path)})
client = app.test_client()
# skapa en todo
client.post("/todo/create", data={"title": "A", "description": "B", "status": "not-started"})
todo_id = fetch_one(str(db_path), "SELECT id FROM todo")[0]
resp = client.post(
f"/todo/{todo_id}/status",
data={"status": "hax-status"},
follow_redirects=True,
)
assert resp.status_code == 200
# status ska vara kvar som innan
status = fetch_one(str(db_path), "SELECT status FROM todo WHERE id=?", (todo_id,))[0]
assert status == "not-started"
def test_update_status_to_done(tmp_path):
db_path = tmp_path / "test.db"
app = create_app({"TESTING": True, "DB_PATH": str(db_path)})
client = app.test_client()
client.post("/todo/create", data={"title": "A", "description": "B", "status": "not-started"})
todo_id = fetch_one(str(db_path), "SELECT id FROM todo")[0]
resp = client.post(
f"/todo/{todo_id}/status",
data={"status": "done"},
follow_redirects=True,
)
assert resp.status_code == 200
status, edited = fetch_one(str(db_path), "SELECT status, edited FROM todo WHERE id=?", (todo_id,))
assert status == "done"
assert edited is not None
def test_delete_todo(tmp_path):
db_path = tmp_path / "test.db"
app = create_app({"TESTING": True, "DB_PATH": str(db_path)})
client = app.test_client()
client.post("/todo/create", data={"title": "A", "description": "B", "status": "not-started"})
todo_id = fetch_one(str(db_path), "SELECT id FROM todo")[0]
resp = client.post(
f"/todo/{todo_id}/delete",
follow_redirects=True,
)
assert resp.status_code == 200
row = fetch_one(str(db_path), "SELECT COUNT(*) FROM todo")
assert row[0] == 0
def test_dashboard_lists_created_todo(tmp_path):
db_path = tmp_path / "test.db"
app = create_app({"TESTING": True, "DB_PATH": str(db_path)})
client = app.test_client()
client.post("/todo/create", data={"title": "X", "description": "Y", "status": "not-started"})
resp = client.get("/dashboard")
assert resp.status_code == 200
assert b"X" in resp.data
assert b"Y" in resp.data
def test_create_invalid_status_defaults_to_not_started(tmp_path):
db_path = tmp_path / "test.db"
app = create_app({"TESTING": True, "DB_PATH": str(db_path)})
client = app.test_client()
client.post("/todo/create", data={"title": "T", "description": "D", "status": "weird"})
row = fetch_one(str(db_path), "SELECT status FROM todo")
assert row[0] == "not-started"
def test_delete_nonexistent_does_not_crash(tmp_path):
db_path = tmp_path / "test.db"
app = create_app({"TESTING": True, "DB_PATH": str(db_path)})
client = app.test_client()
resp = client.post("/todo/999/delete", follow_redirects=True)
assert resp.status_code == 200
def test_update_nonexistent_does_not_crash(tmp_path):
db_path = tmp_path / "test.db"
app = create_app({"TESTING": True, "DB_PATH": str(db_path)})
client = app.test_client()
resp = client.post("/todo/999/status", data={"status": "done"}, follow_redirects=True)
assert resp.status_code == 200

BIN
todo.db Normal file

Binary file not shown.