Metadata-Version: 2.4
Name: splent_feature_profile
Version: 1.7.2
Author-email: DiversoLab <diversolab@us.es>
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_profile

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

Every account has a profile with a name and a surname. The person edits it at
`/profile/edit`, and a client reads it as JSON at `/api/v1/profile/me`. Other
features extend all three without touching profile's code. They add fields to
the edit form, sections below it and keys to the JSON. It needs `auth`, which
owns the account. With `signup` in the product, the sign-up asks for the name
and surname as well.

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.

## A profile for every account

auth sends `user-registered` when an account is opened through
`AuthenticationService.create_user`, as the sign-up and the external sign-in
features do, and profile creates its `UserProfile` then. The name and surname
come with the signal when the sign-up asked for them, and the profile is empty
otherwise.

An account opened without that signal, from auth's accounts screen, with its
`create-user` command or by a seeder, has no profile at first. It gets an empty
one the first time it is needed, when the person opens the edit page or a
client asks `/api/v1/profile/me`, and the person fills it in on the edit page.
An account that already has a profile keeps it.

## Name and surname at sign-up

When the product has `signup`, profile registers two fields on its form with
`register_signup_field`, `name` at order 10 and `surname` at order 20, so both
are asked for before the address. Both are required. signup is optional, and
without it nothing is registered. A signup older than `register_signup_field`
(0.2.0 and before) asks for no name, and the product starts all the same.

## Fields other features add

A feature that keeps more about the person asks for it on the edit page with
`register_profile_field`. The field is drawn on the page, validated with the
form, filled from the profile attribute of the same name and saved onto it
when `UserProfile` has a column of that name. The feature that registers the
field adds the column, with a declared model extension of `UserProfile` and a
migration of its own.

```python
# __init__.py of a feature that extends UserProfile with an affiliation
from flask_babel import lazy_gettext as _l
from wtforms import StringField
from wtforms.validators import Length, Optional

from splent_io.splent_feature_profile.forms import register_profile_field


def init_feature(app):
    register_profile_field(
        "affiliation",
        StringField(_l("Affiliation"), validators=[Optional(), Length(max=255)]),
    )
```

`order` places the field. Lower comes first.

| Field | Order |
|---|---|
| A registered field that says so | its own |
| `name` | 100 (`NAME_ORDER`) |
| `surname` | 200 (`SURNAME_ORDER`) |
| A registered field that does not say | 300 (`DEFAULT_FIELD_ORDER`) |

The registry is read when a request needs the form, so a field registered
after profile has loaded is asked for too. The form class is built once for
each set of registered fields and reused while that set stays the same.
Registering a name again replaces the earlier field, and `remove_profile_field(name)` stops asking for one. A field's own
`render_kw` (a placeholder) and `description` (a line of help) are shown. A
field without a column is validated and drawn, and profile does not store it.

Some names are refused with a `ValueError` when the field is registered. `id`,
`user_id` and `user` say whose profile it is, which is never the person's to
edit. The form's own fields (`name`, `surname`, `submit`, `csrf_token`) and the
form's attributes (`data`, `errors`, `meta`...) cannot be taken either.

## Sections below the form

The edit page opens the slot `profile.edit.sections`, right below the profile
form. Linked accounts, notification preferences and the like go there. Each
section is a block of its own, outside the profile form, so it can carry forms
of its own. A callback takes no arguments and returns HTML, or an empty string
to add nothing on this request. When no callback returns anything, nothing is
drawn.

```python
# hooks.py of a feature that links external accounts
from flask import render_template
from splent_framework.hooks.template_hooks import register_template_hook


def linked_accounts():
    return render_template("hooks/linked_accounts.html")


register_template_hook("profile.edit.sections", linked_accounts, order=10)
```

## The profile as JSON

`GET /api/v1/profile/me` answers the signed-in person's profile.

```json
{"name": "Ada", "surname": "Lovelace", "email": "ada@example.com"}
```

Nobody signed in gets 401, as JSON with an `error` key, so a page that calls
it with `fetch` never receives the login page instead. An account without a
profile gets an empty one, and answers with empty `name` and `surname`. Every
answer carries `Cache-Control: no-store`, so no browser or proxy keeps one
person's data for the next.

A feature adds its own keys with `register_profile_payload`. The function
takes the user and their profile and returns a dict, and runs on every
request.

```python
from splent_io.splent_feature_profile.payload import register_profile_payload


def affiliation(user, profile):
    return {"affiliation": profile.affiliation}


def init_feature(app):
    register_profile_payload(affiliation)
```

The functions run in the order they were registered, so a later one wins a key
two of them send. `name`, `surname` and `email` always hold the profile's and
the account's own values. Registering a function again does nothing, and
`remove_profile_payload(fn)` takes one out.

## Menus and the account menu

The feature declares Edit profile in the framework's menu registry, first in
the Account section, and an application shell that draws `menu_sections()`
and `user_menu_sections()` shows it to every signed-in account.

| Key | Menu | Section | Order | Opens |
|---|---|---|---|---|
| `profile` | Sidebar | `account` | 10 | `profile.edit_profile` |
| `user_profile` | User menu | `account` | 10 | `profile.edit_profile` |

A product hides or reorders them by key in `[tool.splent.layout.menu]`. The
`layout.sidebar.authenticated.items` and `layout.navbar.authenticated` hooks
still draw the same entry and the name for one minor, for the shells that
draw those slots. A shell draws the registry or those slots, never both. The
next minor drops the hooks.

A shell that opens the `layout.navbar.user` slot at the top of its account
menu, inside the `.dropdown-menu` and before the entries of
`user_menu_sections()`, gets who is signed in from this feature: the
initials, the name ("Surname, Name") and the address, so the shell draws the
account's entries without knowing anything of profiles. A visitor gets
nothing. The block is only that: the button that opens the menu and the
divider under the block are the shell's, and it draws the divider when the
slot renders something.

```jinja
{% set who = render_template_hooks("layout.navbar.user") %}
{% if who %}{{ who }}<div class="dropdown-divider"></div>{% endif %}
```

The edit page extends `shell('app')`, so it follows `SPLENT_SHELL_APP`.

## Translations

The edit page, its form and the sign-up fields ship with a Spanish catalog.

## Use it in a product

```bash
splent feature:add splent_feature_profile
```

## Develop

```bash
splent feature:test splent_feature_profile
splent feature:contract splent_feature_profile
```

## Release

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

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.
