Init repo
This commit is contained in:
parent
e94c07b9d6
commit
93c6b44e93
26
.github/workflows/tests.yml
vendored
Normal file
26
.github/workflows/tests.yml
vendored
Normal 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
0
__init__.py
Normal file
BIN
__pycache__/__init__.cpython-314.pyc
Normal file
BIN
__pycache__/__init__.cpython-314.pyc
Normal file
Binary file not shown.
BIN
__pycache__/app.cpython-314.pyc
Normal file
BIN
__pycache__/app.cpython-314.pyc
Normal file
Binary file not shown.
BIN
__pycache__/db.cpython-314.pyc
Normal file
BIN
__pycache__/db.cpython-314.pyc
Normal file
Binary file not shown.
163
app.py
Normal file
163
app.py
Normal 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 på 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
34
db.py
Normal file
@ -0,0 +1,34 @@
|
||||
import sqlite3
|
||||
from flask import Flask
|
||||
|
||||
# =====================================================
|
||||
# Database Helpers
|
||||
# =====================================================
|
||||
|
||||
def get_db(app: Flask):
|
||||
"""
|
||||
Returnerar en SQLite-connection baserat på 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
3
pytest.ini
Normal file
@ -0,0 +1,3 @@
|
||||
[pytest]
|
||||
addopts = -q
|
||||
pythonpath = .
|
||||
2
requirements-dev.txt
Normal file
2
requirements-dev.txt
Normal file
@ -0,0 +1,2 @@
|
||||
pytest>=8.0
|
||||
pytest-cov>=5.0
|
||||
1
requirements.txt
Normal file
1
requirements.txt
Normal file
@ -0,0 +1 @@
|
||||
Flask>=3.0
|
||||
11
static/style.css
Normal file
11
static/style.css
Normal 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
63
templates/base.html
Normal 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
96
templates/index.html
Normal 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
0
tests/__init__.py
Normal file
BIN
tests/__pycache__/__init__.cpython-314.pyc
Normal file
BIN
tests/__pycache__/__init__.cpython-314.pyc
Normal file
Binary file not shown.
BIN
tests/__pycache__/test_app.cpython-314-pytest-9.0.2.pyc
Normal file
BIN
tests/__pycache__/test_app.cpython-314-pytest-9.0.2.pyc
Normal file
Binary file not shown.
171
tests/test_app.py
Normal file
171
tests/test_app.py
Normal 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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user