# Likes A generic "like" feature for content. Logged-in users can like a content object once and withdraw their like again; everybody sees the resulting count. Likes are not tied to a specific content type — the feature is switched on per type through a behavior, typically on News. ## Enabling the feature The behavior is called **Likeable**, dotted name `wcs.backend.likes.behavior.ILikeable`. It is deliberately **not** enabled on any content type out of the box. To switch it on, go to **Site Setup → Content Types → *type* → Behaviors** and tick *Likeable*. The change takes effect immediately and applies to existing content of that type as well — no reindex, no upgrade step, no migration. Turning the behavior off again hides the feature everywhere (viewlet and REST endpoint), but does not delete the likes that were already cast. Re-enabling it brings the previous counts back. ## Who may do what | | Sees the count | Likes / unlikes | Sees who liked | |---|---|---|---| | Anonymous visitor | yes, through the REST API | no | no | | Logged-in user | yes | yes | no | | Site Administrator / Manager | yes | yes | yes | Each user has at most one like per object. Liking twice is an error, and so is withdrawing a like that was never cast — a client never has to guess at the current state, because every response says whether the calling user has liked. Seeing *who* liked requires the permission titled `wcs.backend Likes: Inspect likes`, granted to **Manager** and **Site Administrator**. It can be handed to other roles the usual way through **Site Setup → Security** if needed. ## Where likes live Likes are stored on the content object itself, as a mapping of user id to the time of the like. Two consequences follow from that: - **Copies start empty.** Duplicating a content object does not carry the original's likes over; the copy starts at zero. The same holds for the working copy created by staging. - **Likes survive staging.** Because a check-in writes the working copy's field values back onto the baseline rather than replacing the object, the baseline's likes stay untouched through a checkout/check-in cycle. Likes cast while a working copy exists land on the baseline and are still there afterwards. ## Classic UI A viewlet below the content body shows the current count together with a **Like** / **Unlike** button. The button toggles without reloading the page. The backend never serves pages to anonymous visitors, so the viewlet always renders for a known user; anonymous consumers read the count through the REST API instead. Users holding the inspect permission additionally get a **Who liked this?** link next to the button. It leads to an inspection page listing every user who liked the content — full name and the date they liked, newest first. ## REST API The `@likes` endpoint is available on every content object whose type has the behavior enabled. On all other content it answers **404**, which is also the quickest way for a client to find out whether the feature is on for a given type. ### GET @likes ```http GET /Plone/news/my-item/@likes HTTP/1.1 Host: localhost:8080 Accept: application/json ``` ```json { "@id": "http://localhost:8080/Plone/news/my-item/@likes", "total": 5, "can_like": true, "user_liked": false } ``` `can_like` is `false` for anonymous callers — use it to decide whether to render a button at all. `user_liked` says whether the calling user has already liked, and therefore whether the button should trigger a POST or a DELETE. Callers holding the inspect permission get an additional `users` array, newest like first: ```json { "@id": "http://localhost:8080/Plone/news/my-item/@likes", "total": 5, "can_like": true, "user_liked": false, "users": [ { "userid": "jane.doe", "fullname": "Jane Doe", "liked_at": "2026-09-22T09:12:03+00:00" } ] } ``` The key is absent for everybody else — do not treat a missing `users` as an empty list. ### POST @likes Likes the content as the current user. Takes no body and returns the same payload as GET, already updated, so no follow-up request is needed. ### DELETE @likes Withdraws the current user's like. Returns the same updated payload. ### Expanding likes into a content request Likeable content advertises the endpoint in its `@components`, so a plain content request already tells a client that the feature is switched on: ```json { "@id": "http://localhost:8080/Plone/news/my-item", "@components": { "likes": {"@id": "http://localhost:8080/Plone/news/my-item/@likes"} } } ``` Add `?expand=likes` to pull the whole payload into that same response and save a round trip: ```http GET /Plone/news/my-item?expand=likes HTTP/1.1 Accept: application/json ``` ```json { "@id": "http://localhost:8080/Plone/news/my-item", "@components": { "likes": { "@id": "http://localhost:8080/Plone/news/my-item/@likes", "total": 5, "can_like": true, "user_liked": false } } } ``` The expanded value is byte-for-byte what `GET @likes` returns, `users` array included when the caller may inspect. Expansion combines with the other components, e.g. `?expand=likes,breadcrumbs`. Content whose type does not have the behavior enabled has no `likes` entry in `@components` at all. ### CSRF Both writing verbs require a CSRF token, whatever the authentication method — cookie, Basic auth or JWT alike. Send it as an `X-CSRF-TOKEN` header; the token is available from the `@@authenticator/token` view on any context. A write without a valid token answers **403**. The token is bound to the user it was minted for, so fetch it with the same credentials used for the write. ### Errors | Status | Cause | |---|---| | 400 | Liking twice, or withdrawing a like that does not exist | | 401 | Not logged in | | 403 | Missing or invalid CSRF token | | 404 | The behavior is not enabled on this content type | ## Integration ### Like and unlike from JavaScript ```javascript const itemUrl = '/Plone/news/my-item'; async function csrfToken() { const response = await fetch(`${itemUrl}/@@authenticator/token`); return (await response.text()).trim(); } async function vote(method, token) { const response = await fetch(`${itemUrl}/@likes`, { method: method, headers: { 'Accept': 'application/json', 'X-CSRF-TOKEN': token, }, }); if (!response.ok) { throw new Error(`Vote failed with status ${response.status}`); } return response.json(); } // Read the item and its likes in one request, then toggle. const item = await (await fetch(`${itemUrl}?expand=likes`, { headers: { 'Accept': 'application/json' }, })).json(); const state = item['@components'].likes; if (state.can_like) { const token = await csrfToken(); const updated = await vote(state.user_liked ? 'DELETE' : 'POST', token); console.log(updated.total, updated.user_liked); } ``` The response of the POST / DELETE is the new state — render it directly instead of re-fetching. ### Read the count from Python ```python import requests response = requests.get( 'http://localhost:8080/Plone/news/my-item/@likes', headers={'Accept': 'application/json'}, ) data = response.json() print(data['total']) # 5 print(data['user_liked']) # False ``` Authenticate the request to get a meaningful `user_liked`; an unauthenticated call always reports `can_like: false` and `user_liked: false`.