From 66c2ab944a792c1a41cad006e21bed17ad660fac Mon Sep 17 00:00:00 2001 From: Phuoc Cao Date: Wed, 30 Sep 2026 17:12:54 +0200 Subject: [PATCH] after running opencode /init --- AGENTS.md | 146 +++++++++++++++++++++++++++++++++++++ MANIFEST.in | 2 +- README.md | 10 +-- ctp_blogr/routes.py | 10 +-- ctp_blogr/views/blog.py | 12 +-- ctp_blogr/views/default.py | 7 -- setup.py | 1 - 7 files changed, 159 insertions(+), 29 deletions(-) create mode 100644 AGENTS.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..641c332 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,146 @@ +# AGENTS.md + +Personal blog app: legacy **Pyramid 2.0 WSGI** + **SQLAlchemy 1.4** + **SQLite**, server-rendered +**Jinja2**, Bootstrap 3.4.1 from CDN. UI, templates, routes and DB status values are all in **French** — +match that when adding user-facing text or filtering by post status. + +## Commands + +```bash +pip install -e ".[testing]" # deps come from setup.py install_requires; NO requirements.txt/lockfile +initialize_ctp_blogr_db development.ini # inserts the 'admin' user (see warning below) +pserve development.ini # waitress on http://localhost:9280, debugtoolbar on, template reload on +pserve production.ini # binds *:9280, debugtoolbar off, log level WARN +pshell development.ini # ctp_blogr/pshell.py already calls request.tm.begin() for you +pytest # pytest.ini: testpaths=ctp_blogr, python_files=test*.py +pytest ctp_blogr/tests.py::TestMyViewSuccessCondition::test_passing_view # single test +alembic -c development.ini upgrade head # there is NO alembic.ini — the [alembic] section lives in the .ini files +``` + +**`pytest` is not currently installed in the venv** (`python -m pytest` → "No module named pytest"). Run +`pip install -e ".[testing]"` first, or there is no automated check at all. + +**No linter, formatter, typechecker, pre-commit, Makefile, or CI config exists.** Do not invent a +`lint`/`typecheck` step or run `make`. `pytest` is the only automated check. + +## Stack: trust the installed env, not the metadata + +Actually installed and in use: Python 3.9.6, Pyramid 2.0.2, SQLAlchemy 1.4.54, WTForms 3.2.1, +WebHelpers2 2.1, Markdown 3.9, pyramid_jinja2 2.10.1, passlib 1.7.4, zope.sqlalchemy 2.0. +`setup.py` pins only `SQLAlchemy==1.4.54` and `zope.sqlalchemy==2.0`; everything else floats. + +`pyramid_layout` is **no longer** in `install_requires` (removed in the working tree) and is not imported +anywhere, though it is still present in the shared venv for sibling projects. + +The package was renamed twice (`pyramid_blogr` → `cao_blogr` → `ctp_blogr`), so stale names survive in +metadata: `.coveragerc` says `source = pyramid_blogr`, `rtd.txt` says `pylons-sphinx-themes`, +`CHANGES.txt` says 0.1 while `setup.py` says 1.0. (`README.md` and `MANIFEST.in` were corrected in the +working tree — don't "fix" them back.) + +## Architecture + +``` +ctp_blogr/__init__.py main() — Configurator, root_factory=security.RootFactory, + config.include('pyramid_jinja2') then '.models' then '.routes' then config.scan() +ctp_blogr/models/ SQLAlchemy models + session/engine factories. includeme() hooks + pyramid_tm (explicit_manager) and pyramid_retry, and registers + `request.dbsession` (reified, transaction-managed) +ctp_blogr/services/ all DB queries live here (BlogRecordService, UserService) +ctp_blogr/views/ scanned by config.scan(); default.py, blog.py, notfound.py +ctp_blogr/forms.py WTForms; still holds dead portfolio forms (HistoForm, AllocationForm, ActifForm…) +ctp_blogr/templates/ Jinja2; static/blog/*.png is image content written into post bodies +``` + +Write path: view → service → `request.dbsession`. **Never call `session.commit()`** — `pyramid_tm` +commits or aborts at the end of the request. Pass `request` into services; they read `request.dbsession` +and `request.authenticated_userid` themselves. + +Renderer paths are relative to the *calling package*, so `views/*.py` use `'../templates/x.jinja2'`. +One outlier remains: `views/default.py::user_edit` uses the asset spec `'ctp_blogr:templates/user_edit.jinja2'`. +Both styles work — match the file you're editing. + +## Schema & migrations — the biggest trap + +`alembic upgrade head` **does not create the blog schema.** Verified on an empty DB: the only migration +(`ctp_blogr/alembic/versions/20230129_b8f8216d72c7.py`) creates a single portfolio-era `allocation` table. +The real `users` / `entries` / `tags` tables exist *only* inside the committed `ctp_blogr.sqlite`, created ad +hoc. (`actifs`, `histo` and `allocation` are also in that file, hand-made; `alembic_version` is pinned at +`b8f8216d72c7`.) + +- **Never delete or recreate `ctp_blogr.sqlite`** — it is the only source of the schema and of all content. +- To add a table/column: add the model, then either write a new hand-authored migration or mirror + `ctp_blogr/tests.py::BaseTest.init_database` (`Base.metadata.create_all(engine)`). +- `scripts/initialize_db.py` only inserts the `admin` user; it does **not** create tables (its + "run alembic" hint is misleading). + +**`ctp_blogr.sqlite` is git-tracked even though `.gitignore` lists `*.sqlite`.** Any local run dirties +`git status`. Don't stage it in an unrelated commit. + +## Auth model + +Username-based, no roles column. `services/user.py::groupfinder` grants `group:administrators` to the +literal username `admin` and `[]` to everyone else. `security.py::RootFactory` allows `view` to +`Authenticated` and `manage` to `group:administrators`; `DENY_ALL` catches the rest. + +Permissions: `users` → `manage` (admin only); `blog_edit`, `user_edit`, `tags`, `tag_edit` → `view`. + +Passwords: `passlib.apps.custom_app_context`. `verify_password` transparently upgrades a matching +cleartext value to a hash, so `User.password` can legitimately contain a plaintext legacy value. +`set_password()` takes **bytes** — callers must `.encode('utf8')`. + +`initialize_ctp_blogr_db` writes the admin password as **plaintext** (`scripts/initialize_db.py`), relying on +that upgrade path. It is not idempotent — a second run violates the `users.name` unique constraint. + +## Known-broken / surprising behavior — don't mistake these for your regression + +Verified by running `pserve` in both dev and prod on 2026-09-30: + +- **Every logged-in page returns HTTP 500.** Uncommitted `routes.py` deleted the `portfolio` route, but + `templates/layout.jinja2:50` still calls `request.route_url('portfolio')` in the logged-in dropdown → + `KeyError: 'No such route named portfolio'` during render. Anonymous pages are fine. This is the + regression in progress in the working tree — fix the template or restore the route. +- **Post id 2 links to a dead `/portfolio`.** `views/blog.py:26` hardcodes `return_url = "/portfolio"` for + `blog_id == '2'`; that URL is now a 404. +- **`/portal` → HTTP 404.** Route and view were removed in the working tree (no template was ever + committed either). +- **Protected pages return HTTP 200 with the login page, not 403.** `login` is double-registered as + `@forbidden_view_config`, so the 403 status is lost when the view returns a plain dict. Do not use HTTP + status codes to test authorization — check the body, or log in. +- **`pyramid_debugtoolbar` squashes exceptions into HTTP 200** in `development.ini`, so status codes there + are unreliable too. Use `production.ini` when you need trustworthy status codes. +- **`production.ini` sets `url_scheme = https` while waitress serves plain HTTP.** Every generated URL and + redirect becomes `https://localhost:9280/`, which breaks a local plain-HTTP run (confirmed: login redirects + to `https://localhost:9280/`). Use `development.ini` for local work. +- **Post id 95 is load-bearing.** `views/default.py::home` does `BlogRecordService.by_id(request, 95)` and + reads `.title` / `.body` unconditionally, so `/` raises if that row is gone or renumbered. + `home.jinja2` also links `blog_edit id=95`. Row 95 currently holds a `privé` Chappatte cartoon whose + `body` is used as an image URL. (`get_last_created` is named for 10 but limits to 15.) +- **Post bodies assume the literal substring `static/`.** `views/blog.py` rewrites `static/` to an absolute + URL before running Markdown, so image references in existing post bodies depend on it. + +## Conventions + +- Post status values are French strings: `brouillon`, `privé`, `publié`. Anonymity filtering happens in + `services/blog_record.py` (`by_criteria`, `by_tag`, `get_last_created` all restrict to `publié` when + `request.authenticated_userid is None`) — the list on `/` is the only place this is *not* applied. +- `blog_bytag`'s `{retour}` matchdict is a **route name**, fed straight into `request.route_url(retour)` + (`views/blog.py:106`). Templates pass `'home'` / `'blog_search'`, never a path. +- Post titles must not contain `/`: create/edit replaces it with `.` because the title feeds the URL slug + (`webhelpers2.text.urlify`). DB also enforces `title` and `users.name` as unique. +- Views use `id='0'` as a magic sentinel meaning "create new" (`blog_edit`, `user_edit`, `tag_edit`). +- Forms are plain WTForms bound directly to model instances with `form.populate_obj(entry)`. Before that, + handlers delete the bound primary key to avoid clobbering it — `del form.id` in `views/blog.py`, + `del form.name` in `views/default.py::user_edit`. Preserve that. +- Every flash message is French and rendered through `layout.jinja2`'s queue loop. +- `ctp_blogr/tests.py` has only two tests and both assert the same tautology (`apropos` returns + `page_title == 'A propos'`). Treat the suite as a smoke test, not coverage. + +## Secrets & repo hygiene + +Hardcoded secrets already committed — do not copy this pattern and do not add new ones: +`ctp_blogr/__init__.py` holds the session-factory secret (`SignedCookieSessionFactory`) and the +`AuthTktAuthenticationPolicy` secret inline; `scripts/initialize_db.py` hardcodes the admin password. + +The root-level `*.py` files (`gmail_*_purge.py`, `imap-*.py`, `smtp-*.py`, `email_avnes_purge.py`) are +standalone one-off IMAP/SMTP cleanup utilities with **plaintext mail credentials**. They do not import the +app, are not part of the Pyramid package, and are unrelated to any blog work — leave them alone. diff --git a/MANIFEST.in b/MANIFEST.in index 168dcff..8ebe596 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,2 +1,2 @@ include *.txt *.ini *.cfg *.rst -recursive-include cao_blogr *.ico *.png *.css *.gif *.jpg *.pt *.txt *.mak *.mako *.js *.html *.xml *.jinja2 +recursive-include ctp_blogr *.ico *.png *.css *.gif *.jpg *.pt *.txt *.mak *.mako *.js *.html *.xml *.jinja2 diff --git a/README.md b/README.md index bbbbd6c..4be441b 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,10 @@ # README # -**cao_blogr** est une application pour créer un blog simple. Elle est inspirée du tutorial [**pyramid_blogr**](https://docs.pylonsproject.org/projects/pyramid-blogr/en/latest/index.html). +**ctp_blogr** est une application pour créer un blog simple. ## Fonctionnalités ## -Bien que **cao_blogr** soit une application minimale et simple, elle possède toutes les fonctions nécessaire pour gérer un blog : +Bien que **ctp_blogr** soit une application minimale et simple, elle possède toutes les fonctions nécessaire pour gérer un blog : - Gestion des utilisateurs du blog (admin ou rédacteur) - Authentification et autorisation des utilisateurs @@ -15,11 +15,11 @@ Bien que **cao_blogr** soit une application minimale et simple, elle possède to ## Add-on requis ## -- Python 3.7.1 -- Pyramid 1.10 +- Python 3.9.6 +- Pyramid 2.0.2 - SQLite 3.35.5 - pyramid-jinja2 2.7 : view templating -- wtforms 2.2.1 : form library +- wtforms 3.2.1 : form library - webhelpers2 2.0 : various web building related helpers - Markdown 3.4.1 : diff --git a/ctp_blogr/routes.py b/ctp_blogr/routes.py index 2257c9b..42557c1 100644 --- a/ctp_blogr/routes.py +++ b/ctp_blogr/routes.py @@ -12,12 +12,4 @@ def includeme(config): config.add_route('tag_edit', '/tag_edit/{id}') config.add_route('users', '/users') config.add_route('user_edit', '/user_edit/{name}') - # portfolio - config.add_route('actif_edit', '/actif_edit/{no_id}') - config.add_route('actif2_edit', '/actif2_edit/{no_id}') - config.add_route('allocation_edit', '/allocation_edit/{no_cat}') - config.add_route('histo_list', '/histo_list') - config.add_route('histo_edit', '/histo_edit/{no_id}') - config.add_route('portfolio', '/portfolio') - # personal - config.add_route('portal', '/portal') + \ No newline at end of file diff --git a/ctp_blogr/views/blog.py b/ctp_blogr/views/blog.py index 85d6e1f..4e1eaf7 100644 --- a/ctp_blogr/views/blog.py +++ b/ctp_blogr/views/blog.py @@ -6,7 +6,7 @@ from ..forms import BlogCreateForm, BlogUpdateForm, BlogSearchForm, TagForm import markdown import datetime #<- will be used to set default dates on models -@view_config(route_name='blog', renderer='ctp_blogr:templates/blog.jinja2') +@view_config(route_name='blog', renderer='../templates/blog.jinja2') def blog(request): # get post id from request blog_id = request.matchdict['id'] @@ -35,7 +35,7 @@ def blog(request): } -@view_config(route_name='blog_edit', renderer='ctp_blogr:templates/blog_edit.jinja2', permission='view') +@view_config(route_name='blog_edit', renderer='../templates/blog_edit.jinja2', permission='view') def blog_edit(request): # get post id from request blog_id = request.matchdict['id'] @@ -98,7 +98,7 @@ def blog_edit(request): 'blog_id': blog_id, 'entry': entry, } -@view_config(route_name='blog_bytag', renderer='ctp_blogr:templates/blog_bytag.jinja2') +@view_config(route_name='blog_bytag', renderer='../templates/blog_bytag.jinja2') def blog_bytag(request): # get tag parameters from request tag = request.matchdict['tag'] @@ -117,7 +117,7 @@ def blog_bytag(request): } -@view_config(route_name='blog_search', renderer='ctp_blogr:templates/blog_search.jinja2') +@view_config(route_name='blog_search', renderer='../templates/blog_search.jinja2') def blog_search(request): criteria = '' @@ -139,7 +139,7 @@ def blog_search(request): 'tags': tags, } -@view_config(route_name='tags', renderer='ctp_blogr:templates/tags.jinja2', permission='view') +@view_config(route_name='tags', renderer='../templates/tags.jinja2', permission='view') def tags(request): # get the list of tags @@ -150,7 +150,7 @@ def tags(request): 'tags': tags, } -@view_config(route_name='tag_edit', renderer='ctp_blogr:templates/tag_edit.jinja2', permission='view') +@view_config(route_name='tag_edit', renderer='../templates/tag_edit.jinja2', permission='view') def tag_edit(request): # get tag parameters from request tag_id = request.matchdict['id'] diff --git a/ctp_blogr/views/default.py b/ctp_blogr/views/default.py index bd91799..5d31b2a 100644 --- a/ctp_blogr/views/default.py +++ b/ctp_blogr/views/default.py @@ -151,10 +151,3 @@ def user_edit(request): 'url_retour': url_retour, 'name': name, } - -@view_config(route_name='portal', renderer='../templates/portal.jinja2') -def portal(request): - - return { - 'page_title': "A propos", - } diff --git a/setup.py b/setup.py index da407f7..5c390e6 100644 --- a/setup.py +++ b/setup.py @@ -16,7 +16,6 @@ requires = [ 'waitress', 'alembic', 'pyramid_retry', - 'pyramid_layout', 'pyramid_tm', 'SQLAlchemy==1.4.54', 'transaction',