diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md deleted file mode 100644 index dfece01..0000000 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ /dev/null @@ -1,25 +0,0 @@ ---- -name: Bug report -about: Something isn't working as expected -labels: bug ---- - -## What happened - - - -## Steps to reproduce - -1. -2. -3. - -## Expected behavior - - - -## Environment - -- OS: -- Browser: -- Python version: diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md deleted file mode 100644 index 3074f6a..0000000 --- a/.github/ISSUE_TEMPLATE/feature_request.md +++ /dev/null @@ -1,17 +0,0 @@ ---- -name: Feature request -about: Suggest an idea or improvement -labels: enhancement ---- - -## Problem - - - -## Proposed solution - - - -## Alternatives considered - - diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md deleted file mode 100644 index 00b052c..0000000 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ /dev/null @@ -1,19 +0,0 @@ -## What this PR does - - - -## Type of change - -- [ ] `feat` — new feature -- [ ] `fix` — bug fix -- [ ] `refactor` — internal change, no behavior impact -- [ ] `docs` — documentation only -- [ ] `ci` — CI/CD or tooling -- [ ] `test` — tests only - -## Checklist - -- [ ] Pre-commit hooks pass (`pre-commit run --all-files`) -- [ ] Python tests pass (`poetry run pytest`) -- [ ] JS tests pass (`npm run test:js`) -- [ ] Docs updated if routes, terminal commands, or data model changed diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7d66bd0..fa489b9 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -37,9 +37,9 @@ All of these run in CI on every PR — a failing check won't get merged. ## Style -- New features go in `src/features/`, following the existing structure (`api.py`, `services.py`, `dto.py`). +- New features go in `src/features/`, following the existing structure; add `api.py`, `services.py`, or `dto.py` only when the feature needs them. - Templates live in `src/templates/`, JavaScript in `src/static/js/`. -- Keep template-linked CSS/JS versioned with `asset_version(...)` so browser caches invalidate on file changes. +- Keep template-linked static assets referenced through `static_url(request, ...)` so browser caches invalidate on file changes and URLs respect ASGI `root_path`. - The terminal bar is a navigation layer, not a shell — keep commands simple and purposeful. - Form validation belongs in DTOs (`dto.py`), not in route handlers. - Tests are expected for new Python logic; JS tests for anything in `src/static/js/`. diff --git a/docs/architecture.md b/docs/architecture.md index ae1fd47..c20b47c 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -11,7 +11,7 @@ echo_ is a personal milestone log — a small web app built with FastAPI, server | Web framework | FastAPI | | Templates | Jinja2 | | ORM | SQLAlchemy 2.x (Mapped API) | -| Database | SQLite (`echo.db` in project root) | +| Database | SQLite (`user_data/echo.db` locally by default; configurable via `DATABASE_URL`) | | Migrations | Alembic | | Form validation | Pydantic v2 | | Configuration | Pydantic Settings | @@ -35,7 +35,7 @@ src/ migrations/ — env.py, script.py.mako, versions/ web/ - templates.py — shared Jinja2Templates, asset_version, settings in context + templates.py — shared Jinja2Templates, static_url, asset_version, settings in context features/ auth/ @@ -51,7 +51,6 @@ src/ tags/ api.py — /tags, /tags/{tag_name} - services.py — get_milestones_for_tag terminal/ api.py — /help, /random, /search, /terminal/commands, /tree @@ -69,6 +68,7 @@ src/ help.html, search.html static/ + site.webmanifest css/ base.css, forms.css pages/ timeline.css, milestone.css @@ -78,7 +78,10 @@ src/ keyboard/ global.js terminal/ input.js, input-mobile.js, navigation.js, table.js icons/ - favicon.ico, favicon.svg, apple-touch-icon.png + favicon.ico, favicon-16x16.png, favicon-32x32.png + apple-touch-icon-180x180.png + pwa-icon-192x192.png, pwa-icon-256x256.png, pwa-icon-384x384.png, pwa-icon-512x512.png + larger source/reserve icons ``` ## Routes @@ -114,7 +117,7 @@ Read from `.env` via `pydantic-settings`. Variables: | Variable | Default | Description | |----------------------|--------------------------------|----------------------------------------| -| `DATABASE_URL` | `sqlite:///echo.db` | Database URL | +| `DATABASE_URL` | `sqlite:///.../user_data/echo.db` | Database URL | | `SESSION_SECRET_KEY` | — | Session signing key (required) | | `ECHO_PASSWORD` | — | Login password (required) | | `ECHO_USERNAME` | `katrin` | Username | @@ -171,7 +174,7 @@ The bottom bar is a navigation layer, not a shell. Commands: `help`, `new`, `tag - HTML responses should not be cached long-term. - Versioned static assets should be cached long-term (`Cache-Control: public, max-age=31536000, immutable`). -- All CSS and JS references in templates should include `asset_version(...)` so the URL changes when file contents change. +- All template static references should use `static_url(request, ...)` so URLs include cache versions and respect ASGI `root_path`. ## Timeline @@ -203,9 +206,9 @@ poetry run alembic -c src/orm/alembic.ini stamp 4f1b2d9c7a11 ## Code quality Pre-commit hooks: Ruff, MyPy, djLint, Stylelint, ESLint, pytest, JS tests (Vitest), poetry check, alembic check. -Linters (except MyPy and pytest) run only on changed files. +In pre-commit, linters run on changed files where possible; MyPy, pytest, JS tests, poetry check and alembic check run as full-project checks. CI runs full checks. CI (`.github/workflows/`): - `quality_gates.yml` — Python style, Frontend style, Python tests, JS tests, Migrations -- `smoke.yml` — runs after quality gates, starts the server and checks `/login` +- `smoke.yml` — runs after quality gates and executes middleware smoke tests - `release.yml` — semantic-release, publishes a draft release on push to main diff --git a/src/app.py b/src/app.py index 5762827..dac561d 100644 --- a/src/app.py +++ b/src/app.py @@ -34,7 +34,12 @@ async def lifespan(app: FastAPI): async def cache_control(request: Request, call_next): response = await call_next(request) - if request.url.path.startswith("/static/"): + path = request.scope.get("path", request.url.path) + root_path = request.scope.get("root_path", "") + if root_path and path.startswith(root_path): + path = path.removeprefix(root_path) or "/" + + if path.startswith("/static/"): response.headers.setdefault( "Cache-Control", "public, max-age=31536000, immutable", diff --git a/src/features/auth/middleware.py b/src/features/auth/middleware.py index e6a4b5d..8697c3d 100644 --- a/src/features/auth/middleware.py +++ b/src/features/auth/middleware.py @@ -15,7 +15,10 @@ class AuthMiddleware(BaseHTTPMiddleware): async def dispatch(self, request: Request, call_next): - path = request.url.path + path = request.scope.get("path", request.url.path) + root_path = request.scope.get("root_path", "") + if root_path and path.startswith(root_path): + path = path.removeprefix(root_path) or "/" if path.startswith("/static/"): return await call_next(request) diff --git a/src/features/tags/services.py b/src/features/tags/services.py deleted file mode 100644 index a4f2fc7..0000000 --- a/src/features/tags/services.py +++ /dev/null @@ -1,9 +0,0 @@ -from src.orm.milestone import Milestone -from src.orm.tag import Tag - - -def get_milestones_for_tag(tag_name: str) -> list[Milestone]: - tag = Tag.get_by_name(tag_name.upper()) - if tag is None: - return [] - return list(tag.milestones) diff --git a/src/static/icons/apple-touch-icon-180x180.png b/src/static/icons/apple-touch-icon-180x180.png new file mode 100644 index 0000000..5daf72b Binary files /dev/null and b/src/static/icons/apple-touch-icon-180x180.png differ diff --git a/src/static/icons/apple-touch-icon.png b/src/static/icons/apple-touch-icon.png deleted file mode 100644 index 4b39d7e..0000000 Binary files a/src/static/icons/apple-touch-icon.png and /dev/null differ diff --git a/src/static/icons/favicon-128x128.png b/src/static/icons/favicon-128x128.png new file mode 100644 index 0000000..37a0e2c Binary files /dev/null and b/src/static/icons/favicon-128x128.png differ diff --git a/src/static/icons/favicon-16x16.png b/src/static/icons/favicon-16x16.png new file mode 100644 index 0000000..6011eaa Binary files /dev/null and b/src/static/icons/favicon-16x16.png differ diff --git a/src/static/icons/favicon-32x32.png b/src/static/icons/favicon-32x32.png new file mode 100644 index 0000000..d16605b Binary files /dev/null and b/src/static/icons/favicon-32x32.png differ diff --git a/src/static/icons/favicon-48x48.png b/src/static/icons/favicon-48x48.png new file mode 100644 index 0000000..c90f297 Binary files /dev/null and b/src/static/icons/favicon-48x48.png differ diff --git a/src/static/icons/favicon-64x64.png b/src/static/icons/favicon-64x64.png new file mode 100644 index 0000000..be9b5ca Binary files /dev/null and b/src/static/icons/favicon-64x64.png differ diff --git a/src/static/icons/favicon-96x96.png b/src/static/icons/favicon-96x96.png new file mode 100644 index 0000000..93af35e Binary files /dev/null and b/src/static/icons/favicon-96x96.png differ diff --git a/src/static/icons/favicon.ico b/src/static/icons/favicon.ico index 4565a97..a1b7ea4 100644 Binary files a/src/static/icons/favicon.ico and b/src/static/icons/favicon.ico differ diff --git a/src/static/icons/favicon.svg b/src/static/icons/favicon.svg deleted file mode 100644 index 4b339b3..0000000 --- a/src/static/icons/favicon.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - e_ - \ No newline at end of file diff --git a/src/static/icons/icon-master-1024x1024.png b/src/static/icons/icon-master-1024x1024.png new file mode 100644 index 0000000..d164b7b Binary files /dev/null and b/src/static/icons/icon-master-1024x1024.png differ diff --git a/src/static/icons/pwa-icon-1024x1024.png b/src/static/icons/pwa-icon-1024x1024.png new file mode 100644 index 0000000..58f216b Binary files /dev/null and b/src/static/icons/pwa-icon-1024x1024.png differ diff --git a/src/static/icons/pwa-icon-192x192.png b/src/static/icons/pwa-icon-192x192.png new file mode 100644 index 0000000..d2626d0 Binary files /dev/null and b/src/static/icons/pwa-icon-192x192.png differ diff --git a/src/static/icons/pwa-icon-256x256.png b/src/static/icons/pwa-icon-256x256.png new file mode 100644 index 0000000..af36130 Binary files /dev/null and b/src/static/icons/pwa-icon-256x256.png differ diff --git a/src/static/icons/pwa-icon-384x384.png b/src/static/icons/pwa-icon-384x384.png new file mode 100644 index 0000000..4c4d2fe Binary files /dev/null and b/src/static/icons/pwa-icon-384x384.png differ diff --git a/src/static/icons/pwa-icon-512x512.png b/src/static/icons/pwa-icon-512x512.png new file mode 100644 index 0000000..197ed16 Binary files /dev/null and b/src/static/icons/pwa-icon-512x512.png differ diff --git a/src/static/js/terminal/input.js b/src/static/js/terminal/input.js index e43ee3d..4d3a3b5 100644 --- a/src/static/js/terminal/input.js +++ b/src/static/js/terminal/input.js @@ -23,15 +23,15 @@ const COMMAND_HANDLERS = { return `Тег "${tagName}" не найден.`; }, -search: (args) => { - const query = args.trim(); + search: (args) => { + const query = args.trim(); - if (!query) { - return "Введите поисковый запрос."; - } + if (!query) { + return "Введите поисковый запрос."; + } - return `/search?q=${encodeURIComponent(query)}`; -}, + return `/search?q=${encodeURIComponent(query)}`; + }, }; window.TERMINAL_COMMAND_HANDLERS = COMMAND_HANDLERS; diff --git a/src/static/site.webmanifest b/src/static/site.webmanifest new file mode 100644 index 0000000..0a18c09 --- /dev/null +++ b/src/static/site.webmanifest @@ -0,0 +1,35 @@ +{ + "name": "Echo", + "short_name": "Echo", + "start_url": ".", + "scope": ".", + "display": "standalone", + "theme_color": "#111111", + "background_color": "#111111", + "icons": [ + { + "src": "icons/pwa-icon-192x192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "any" + }, + { + "src": "icons/pwa-icon-256x256.png", + "sizes": "256x256", + "type": "image/png", + "purpose": "any" + }, + { + "src": "icons/pwa-icon-384x384.png", + "sizes": "384x384", + "type": "image/png", + "purpose": "any" + }, + { + "src": "icons/pwa-icon-512x512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "any" + } + ] +} diff --git a/src/templates/base.html b/src/templates/base.html index d4d3240..bb2756c 100644 --- a/src/templates/base.html +++ b/src/templates/base.html @@ -3,48 +3,45 @@ + + + sizes="16x16" + type="image/png" + href="{{ static_url(request, 'icons/favicon-16x16.png') }}"> + + href="{{ static_url(request, 'icons/favicon.ico') }}"> + sizes="180x180" + href="{{ static_url(request, 'icons/apple-touch-icon-180x180.png') }}"> {% block title %}echo_.log{% endblock %} + + - - + href="{{ static_url(request, 'css/pages/timeline.css') }}"> + href="{{ static_url(request, 'css/pages/milestone.css') }}"> + href="{{ static_url(request, 'css/components/terminal.css') }}"> + href="{{ static_url(request, 'css/components/terminal-table.css') }}"> + href="{{ static_url(request, 'css/components/terminal-tree.css') }}"> {% block styles %}{% endblock %} - - - - - - - - - + + + + + + + + +
diff --git a/src/templates/milestones/edit.html b/src/templates/milestones/edit.html index 6a21ec9..55da07a 100644 --- a/src/templates/milestones/edit.html +++ b/src/templates/milestones/edit.html @@ -39,6 +39,5 @@

Edit milestone

- + {% endblock %} diff --git a/src/templates/milestones/new.html b/src/templates/milestones/new.html index 143e140..7bf8550 100644 --- a/src/templates/milestones/new.html +++ b/src/templates/milestones/new.html @@ -39,6 +39,5 @@

New milestone

- + {% endblock %} diff --git a/src/web/templates.py b/src/web/templates.py index 3a0c189..a86263d 100644 --- a/src/web/templates.py +++ b/src/web/templates.py @@ -1,5 +1,6 @@ from pathlib import Path +from fastapi import Request from fastapi.templating import Jinja2Templates from src.config import settings @@ -12,5 +13,11 @@ def _asset_version(rel_path: str) -> int: return int((_TEMPLATES_DIR.parent / "static" / rel_path).stat().st_mtime) +def _static_url(request: Request, rel_path: str) -> str: + version = _asset_version(rel_path) + return f"{request.url_for('static', path=rel_path)}?v={version}" + + templates.env.globals["asset_version"] = _asset_version +templates.env.globals["static_url"] = _static_url templates.env.globals["settings"] = settings diff --git a/tests/test_dto.py b/tests/test_dto.py index fa65013..271ec53 100644 --- a/tests/test_dto.py +++ b/tests/test_dto.py @@ -2,6 +2,8 @@ from datetime import UTC, date, datetime, timedelta import sys from pathlib import Path +from pydantic import ValidationError + from src.features.milestones.dto import ( MilestoneCreateDTO, today_in_timezone, @@ -27,18 +29,14 @@ def test_valid_title(self) -> None: def test_title_is_stripped(self) -> None: dto = self._make(" Hello ") self.assertEqual(dto.title, "Hello") - # - # def test_empty_title_raises(self) -> None: - # with self.assertRaises(ValidationError): - # self._make("") - # - # def test_whitespace_only_title_raises(self) -> None: - # with self.assertRaises(ValidationError): - # self._make(" ") - - # def test_cyrillic_title_raises(self) -> None: - # with self.assertRaises(ValidationError): - # self._make("Привет мир") + + def test_empty_title_raises(self) -> None: + with self.assertRaises(ValidationError): + self._make("") + + def test_whitespace_only_title_raises(self) -> None: + with self.assertRaises(ValidationError): + self._make(" ") def test_allowed_special_chars(self) -> None: dto = self._make("Finpipe v1.0 - release")