after running opencode /init
This commit is contained in:
@@ -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.
|
||||||
+1
-1
@@ -1,2 +1,2 @@
|
|||||||
include *.txt *.ini *.cfg *.rst
|
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
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
# README #
|
# 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 ##
|
## 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)
|
- Gestion des utilisateurs du blog (admin ou rédacteur)
|
||||||
- Authentification et autorisation des utilisateurs
|
- 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 ##
|
## Add-on requis ##
|
||||||
|
|
||||||
- Python 3.7.1
|
- Python 3.9.6
|
||||||
- Pyramid 1.10
|
- Pyramid 2.0.2
|
||||||
- SQLite 3.35.5
|
- SQLite 3.35.5
|
||||||
- pyramid-jinja2 2.7 : view templating
|
- 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
|
- webhelpers2 2.0 : various web building related helpers
|
||||||
- Markdown 3.4.1 :
|
- Markdown 3.4.1 :
|
||||||
|
|
||||||
|
|||||||
+1
-9
@@ -12,12 +12,4 @@ def includeme(config):
|
|||||||
config.add_route('tag_edit', '/tag_edit/{id}')
|
config.add_route('tag_edit', '/tag_edit/{id}')
|
||||||
config.add_route('users', '/users')
|
config.add_route('users', '/users')
|
||||||
config.add_route('user_edit', '/user_edit/{name}')
|
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')
|
|
||||||
@@ -6,7 +6,7 @@ from ..forms import BlogCreateForm, BlogUpdateForm, BlogSearchForm, TagForm
|
|||||||
import markdown
|
import markdown
|
||||||
import datetime #<- will be used to set default dates on models
|
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):
|
def blog(request):
|
||||||
# get post id from request
|
# get post id from request
|
||||||
blog_id = request.matchdict['id']
|
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):
|
def blog_edit(request):
|
||||||
# get post id from request
|
# get post id from request
|
||||||
blog_id = request.matchdict['id']
|
blog_id = request.matchdict['id']
|
||||||
@@ -98,7 +98,7 @@ def blog_edit(request):
|
|||||||
'blog_id': blog_id,
|
'blog_id': blog_id,
|
||||||
'entry': entry, }
|
'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):
|
def blog_bytag(request):
|
||||||
# get tag parameters from request
|
# get tag parameters from request
|
||||||
tag = request.matchdict['tag']
|
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):
|
def blog_search(request):
|
||||||
|
|
||||||
criteria = ''
|
criteria = ''
|
||||||
@@ -139,7 +139,7 @@ def blog_search(request):
|
|||||||
'tags': tags,
|
'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):
|
def tags(request):
|
||||||
|
|
||||||
# get the list of tags
|
# get the list of tags
|
||||||
@@ -150,7 +150,7 @@ def tags(request):
|
|||||||
'tags': tags,
|
'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):
|
def tag_edit(request):
|
||||||
# get tag parameters from request
|
# get tag parameters from request
|
||||||
tag_id = request.matchdict['id']
|
tag_id = request.matchdict['id']
|
||||||
|
|||||||
@@ -151,10 +151,3 @@ def user_edit(request):
|
|||||||
'url_retour': url_retour,
|
'url_retour': url_retour,
|
||||||
'name': name,
|
'name': name,
|
||||||
}
|
}
|
||||||
|
|
||||||
@view_config(route_name='portal', renderer='../templates/portal.jinja2')
|
|
||||||
def portal(request):
|
|
||||||
|
|
||||||
return {
|
|
||||||
'page_title': "A propos",
|
|
||||||
}
|
|
||||||
|
|||||||
Reference in New Issue
Block a user