Metadata-Version: 2.4
Name: splent_feature_legal
Version: 0.4.0
License-Expression: LGPL-3.0-or-later
Requires-Python: >=3.13
Description-Content-Type: text/markdown
License-File: COPYING
License-File: COPYING.LESSER
Requires-Dist: splent_framework>=1.20.0
Dynamic: license-file

# splent_feature_legal

The `legal` feature of a [SPLENT](https://splent.io) product line.

A SPLENT feature is a Python package that a product composes with others:
its models, routes, services, templates and hooks are registered when the
product starts. What this feature provides and requires is listed in the
`[tool.splent.contract]` section of `pyproject.toml`, which
`splent feature:contract --write` keeps in sync with the source.

## What it does

The legal documents of the site: the privacy notice, the terms of use, the
cookie policy, the legal notice and any other. Each one is served at
`/legal/<slug>` in the version in force, and gets a link in the footer
through the `layout.footer` hook, privacy notice first, then terms, cookies,
legal notice and the rest. A document with no version in force answers 404
and has no footer link.

### Versions

A document's words live in versions. A version is a draft until it is
published; publishing gives it the next number (1, 2, 3...) and the date it
comes into force, which is the day the panel planned for it or the moment
it is published, never earlier. From then on it is final. The models check
what the database holds when the session flushes, so nothing that goes
through the ORM edits or deletes a published version or its texts, or adds
a text to it: not the panel, a seeder, the admin feature's generic CRUD or
code in a shell, and a bulk UPDATE, DELETE or INSERT through the ORM is
refused for versions and texts altogether. SQL sent to the database past
the ORM is beyond the models' reach. A typo fixed is a new version, so what
a page said on a given day, and what each account accepted, can always be
shown again.

The numbers follow the days the versions come into force. A version cannot
come into force before one already scheduled, which it would replace for
good: the panel refuses it and asks for that day or a later one. A version
published for the same day as a scheduled one takes its place, which is how
a scheduled version is corrected; the one it replaced is marked as never in
force.

- `/legal/<slug>` shows the version in force, and says when a later one is
  scheduled.
- `/legal/<slug>/versions` lists every published version, newest first.
- `/legal/<slug>/versions/<number>` shows one of them as it was, marked as
  replaced when a later one is in force.

Each version holds one text per language of the product
(`BABEL_SUPPORTED_LOCALES`). A reader gets the text in their language,
else in the product's default, else in any the version has. A text is
stored HTML, drawn through the framework's `rich_text` filter, which keeps
the formatting and drops scripts, event handlers and `javascript:` links.

Publishing sends `legal-document-published` with `document`, `version` and
`requires_reacceptance`, for a feature that tells the accounts about the
change.

### Acceptance

A document can be asked at sign-up (its "Asked at sign-up" setting). Then:

- **Sign-up.** legal adds a box to signup's form
  (`register_signup_field`, signup 0.3.0), last, after the password: "I
  have read: Privacy notice (version 3). I accept: Terms of use (version
  2).", with a link to each version. Without the box ticked, or when the
  versions changed while the form was open, no account is created. The
  acceptance is recorded on auth's `user-registered`.
- **Afterwards.** A signed-in account that never accepted a required
  document, or that accepted an earlier version when a later one in force
  asks for acceptance again ("Ask the accounts that accepted an earlier
  version to accept this one"), is sent to `/legal/accept` before any other
  page, and comes back where it was going once it accepts. A request for
  JSON gets 403 with `accept_url`. The legal pages, logout and the static
  files stay open, and so does any page a feature keeps open with
  `register_gate_exemption("profile.delete")` (registry.py, looked up by
  name like the other two registries) or the product lists in
  `LEGAL_GATE_OPEN_ENDPOINTS`: an account that does not accept still
  deletes itself, exports its data or changes the language it reads in.
  `LEGAL_ACCEPTANCE_GATE = false` turns this off, for a product that asks
  only at sign-up. A version that does not ask again (a typo fixed) asks
  nobody.
- **The record.** Each acceptance names the account, the version, the
  moment and the channel: `signup`, `prompt` (the acceptance page, for a
  document the account had never accepted), `reaccept` (the same page,
  after a version asked again), or the name another feature passes. An account sees its own at `/legal/accepted`, and the
  panel counts them per version. They go with the account, on auth's
  `user-deleting`.

A feature that opens accounts outside signup's form (an identity
provider's "complete your sign-up" step) asks `LegalService` through the
service locator, without importing legal: `acceptance_box()` draws the same
box as HTML, `acceptance_error(posted)` says why what was posted does not
count (or None), and `accept_token(user_id, posted, "orcid")` records it
once the account exists. See the docstring of `services.py`.

### Third parties and cookies

Each feature declares, from its `init_feature`, the outside services it
sends personal data to and the cookies it sets:

```python
register_third_party(
    "zenodo",
    "Zenodo (CERN)",
    purpose=_l("Publishes the datasets you deposit and gives them a DOI."),
    data=[_l("Names, affiliations and ORCID iDs of the authors")],
    country="Switzerland",
    legal_basis=_l("Performance of the deposit you ask for"),
    privacy_url="https://about.zenodo.org/privacy-policy/",
    hosts=["zenodo.org"],
)
register_cookie(
    "remember_token",
    category="necessary",  # necessary, preferences or analytics
    purpose=_l("Keeps you signed in when you ask the site to remember you."),
    duration=_l("1 year"),
)
```

The privacy notice (a document of kind `privacy`) lists the declared third
parties under its own text, and the cookie policy (kind `cookies`) the
declared cookies by category, so nobody keeps a copy by hand that drifts
from the code. The lists are part of the version: publishing keeps them as
the features declare them at that moment, in every language of the
product, and each version shows its own, so an earlier version reads as it
did and the one in force changes only with a new version. When the
features declare something else (a deployment adds a service or a cookie),
the panel marks the document "List out of date" and says so on its page; a
new version, with the same text if nothing else changes, brings the list up
to date, and asks the accounts again when it is told to. An empty list is
not drawn (it would claim there are none). `LegalService.third_party_hosts()`
gives every declared host, for a content security policy or a test of the
hosts a page contacts. legal declares the framework's session cookie
itself, under the name and lifetime the product gives it.

legal is optional in the product line, so a feature that declares does not
import `registry.py` with an import statement: it looks it up by name and
does nothing when the product has no legal (the docstring of `registry.py`
has the five lines). The same goes for `register_gate_exemption`.

### Seeding a product's texts

legal seeds a neutral privacy notice and cookie policy that name no
organisation, so a new site starts with the pages its footer links to. A
product with its own wording takes that seeder over from
`src/<product>/seeders.py` and seeds its documents with
`seeding.seed_documents`:

```python
from splent_framework.seeders.BaseSeeder import BaseSeeder
from splent_io.splent_feature_legal.seeding import seed_documents

DOCUMENTS = [
    {
        "slug": "privacy",
        "kind": "privacy",
        "title": "Privacy notice",
        "acceptance_required": True,
        "versions": [
            {"texts": {"en": ("Privacy notice", "<p>...</p>"),
                       "es": ("Aviso de privacidad", "<p>...</p>")}},
        ],
    },
]


class ProductLegalSeeder(BaseSeeder):
    replaces = ("splent_io.splent_feature_legal.seeders",)

    def run(self):
        seed_documents(DOCUMENTS)
```

Import the function, never a seeder class: `db:seed` runs every seeder
class it finds in the module. A document whose slug is there already is
left as it is. A version with `"draft": True` is seeded as a draft, for a
text someone has to review (the data protection officer, say) before it is
published from the panel.

### Upgrading from 0.2

The migration turns each legal page into a document: a published page
becomes its version 1, in force since it was last updated, and an
unpublished one a draft. Its text is carried over as it is and, being
published, is final from then on, so a page that should not stay in force
(a text another organisation wrote, left in the database) is deleted from
`legal_page` before upgrading, and the product's seeder writes the new one.
The kind comes from the slug: the privacy-policy page the catalogue seeded
becomes the privacy notice, and so do politica-de-privacidad and the other
usual names in English and Spanish (terms, cookies, legal notice); any
other page is "other", for the panel to set. A migrated version lists
nothing, as before, and the panel marks it until a new version brings the
lists. No document is asked at sign-up until the panel says so.

- `LegalPage` is gone. A product seeder that built `LegalPage` rows seeds
  `seeding.seed_documents` instead (above), with the kind of each
  document, and replaces `splent_io.splent_feature_legal.seeders`.
- A product that overrides `legal/page.html` gets `page` (the
  `LegalDocument`), `version`, `text` (the `LegalDocumentText` in the
  reader's language), `in_force`, `scheduled`, `never_in_force`,
  `third_parties` and `cookie_groups`. `page.content`, `page.updated_at`
  and `page.published` no longer exist.

The downgrade puts `legal_page` back with the text of each version in
force, and keeps the four versioned tables, renamed with an `_archive`
suffix: legal_page cannot hold the history, the other languages, the lists
or the acceptances, and those are the record of what the site said and who
agreed. Nothing is deleted; drop the archive by hand once it is not needed.

## Access

The panel manages the documents at `/admin/legal`: their settings, their
drafts, and publishing. A document that published a version is never
deleted. The screens under `/admin/legal` open for the accounts whose role is `admin`
or `staff`, the same pair the admin dashboard lets in. Any other signed-in
account gets 403 and does not see the Legal pages link in the sidebar or the
menu, and an anonymous visitor is sent to log in. The published versions
under `/legal` and their footer links stay open to everyone, signed in or
not, and a document with no version in force answers 404 to everyone.
`/legal/accept` and `/legal/accepted` open for any signed-in account.

There is no setting for these roles, and a refinement cannot replace the
views that check them, because it may add routes to this feature but never
replace one of its own. Another feature can decide for these screens with an
access policy registered for the prefix `legal.admin`
(`register_access_policy`, splent_framework 1.20.0). The framework asks the
policy before the roles. It answers True to let an account in, False to
refuse it, or None to leave the decision to the roles, and the sidebar link
follows the same answer. That is the way to let a group of accounts manage
only this section, for example the people who keep the legal texts, without
giving them a panel role.
See [Access control](https://docs.splent.io/framework/access-control).

Without such a feature, give the person's account the staff role. Staff
opens every panel screen that asks for `PANEL_ROLES`, not only this one, so
a person who should manage nothing but the legal pages needs a policy
instead.

```bash
splent feature:auth set-role someone@example.com staff
```

## Use it in a product

```bash
splent feature:add splent_feature_legal
```

| Setting | Default | What it does |
|---|---|---|
| `LEGAL_ACCEPTANCE_GATE` | `true` | Sends a signed-in account with a required text to accept to `/legal/accept` first. `false` asks only at sign-up. |
| `LEGAL_GATE_OPEN_ENDPOINTS` | empty | Endpoints, comma separated, that such an account still opens, besides legal's own pages and the ones the features keep open with `register_gate_exemption`. |

## Develop

```bash
splent feature:test splent_feature_legal
splent feature:contract splent_feature_legal
```

## Release

```bash
splent feature:release splent_feature_legal --dry-run --bump patch
splent feature:release splent_feature_legal
```

The first command rehearses the release and changes nothing. The second
publishes it to the git forge and to PyPI, and a PyPI version is permanent.
