Welcome | Get started | Dive | Contribute | Topics | Reference | Changes | More

comments in Noi

The lino.modlib.comments plugin in Lino Noi is configured and used to satisfy the application requirements.

This page contains code snippets (lines starting with >>>), which are being tested during our development workflow. The following snippet initializes the demo project used throughout this page.

>>> from lino_book.projects.noi1r.startup import *

Overview

Public comments in Lino Noi are visible even to anonymous users.

There are seven Commentable models in Lino Noi, but only tickets have a CommentsByRFC panel in their detail.

>>> pprint(list(rt.models_by_base(comments.Commentable)))
[<class 'lino_xl.lib.contacts.models.Company'>,
 <class 'lino_xl.lib.contacts.models.Partner'>,
 <class 'lino_xl.lib.contacts.models.Person'>,
 <class 'lino_noi.lib.groups.models.Group'>,
 <class 'lino_noi.lib.tickets.models.Ticket'>,
 <class 'lino.modlib.uploads.models.Upload'>,
 <class 'lino.modlib.users.models.User'>]

Whether a comment is private (confidential) or not depends on its discussion topic: Comments on a ticket are public when neither the ticket nor its site are marked private.

Comments aren’t private by default in Noi:

>>> dd.plugins.users.private_default
False

Comments on a team are public when the team is not private.

Visibility of comments

The demo database contains 84 comments, of which 42 are marked as confidential (private). There is no comment without a group. All comments are about some ticket.

>>> comments.Comment.objects.all().count()
84
>>> comments.Comment.objects.filter(group__isnull=False).count()
84
>>> comments.Comment.objects.filter(private=True).count()
42
>>> comments.Comment.objects.filter(private=False).count()
42
>>> comments.Comment.objects.filter(ticket__isnull=False).count()
84
>>> comments.Comment.objects.filter(ticket__isnull=False).first()
Comment #1 ('Comment #1')
>>> comments.Comment.objects.filter(ticket=None).count()
0
>>> from django.db.models import Q
>>> rt.login("robin").show(comments.Comments,
...     column_names="id ticket__group user owner",
...     filter=Q(ticket__isnull=False),
...     limit=20, display_mode=DISPLAY_MODE_GRID)
...
==== ============ ================= =============================================
 ID   Team         Author            Topic
---- ------------ ----------------- ---------------------------------------------
 1    Sales team   Jean              `#116 (Why <p> tags are so bar) <…>`__
 2    Sales team   Luc               `#116 (Why <p> tags are so bar) <…>`__
 3    Sales team   Marc              `#116 (Why <p> tags are so bar) <…>`__
 4    Sales team   Mathieu           `#116 (Why <p> tags are so bar) <…>`__
 5    Sales team   Romain Raffault   `#116 (Why <p> tags are so bar) <…>`__
 6    Sales team   Rolf Rompen       `#116 (Why <p> tags are so bar) <…>`__
 7    Sales team   Robin Rood        `#116 (Why <p> tags are so bar) <…>`__
 8    Managers     Jean              `#115 (Cannot delete foo) <…>`__
 9    Managers     Luc               `#115 (Cannot delete foo) <…>`__
 10   Managers     Marc              `#115 (Cannot delete foo) <…>`__
 11   Managers     Mathieu           `#115 (Cannot delete foo) <…>`__
 12   Managers     Romain Raffault   `#115 (Cannot delete foo) <…>`__
 13   Managers     Rolf Rompen       `#115 (Cannot delete foo) <…>`__
 14   Managers     Robin Rood        `#115 (Cannot delete foo) <…>`__
 15   Developers   Jean              `#114 (No more foo when bar is gone) <…>`__
 16   Developers   Luc               `#114 (No more foo when bar is gone) <…>`__
 17   Developers   Marc              `#114 (No more foo when bar is gone) <…>`__
 18   Developers   Mathieu           `#114 (No more foo when bar is gone) <…>`__
 19   Developers   Romain Raffault   `#114 (No more foo when bar is gone) <…>`__
 20   Developers   Rolf Rompen       `#114 (No more foo when bar is gone) <…>`__
==== ============ ================= =============================================

Marc is a customer, so he can see only comments that are (1) public OR (2) his own OR (3) about a group (“team”) that he can see OR (4) about something that he can see.

Comments in Noi can be about tickets or about groups.

  • marc can see tickets that are (public OR his own) AND in a group that he can see

  • marc can see groups that are (public OR of which he is a member)

>>> rt.login('marc').user.user_type
<users.UserTypes.customer:100>
>>> qs = rt.login('marc').spawn(comments.RecentComments).data_iterator
>>> printsql(qs)
...
SELECT DISTINCT comments_comment.id,
                comments_comment.modified,
                comments_comment.created,
                comments_comment.body,
                comments_comment.body_short_preview,
                comments_comment.body_full_preview,
                comments_comment.user_id,
                comments_comment.private,
                comments_comment.group_id,
                comments_comment.owner_type_id,
                comments_comment.owner_id,
                comments_comment.reply_to_id,
                comments_comment.comment_type_id,
                COUNT(T6.id) AS num_replies,
                COUNT(comments_reaction.id) AS num_reactions
FROM comments_comment
LEFT OUTER JOIN groups_group ON (comments_comment.group_id = groups_group.id)
LEFT OUTER JOIN groups_membership ON (groups_group.id = groups_membership.group_id)
LEFT OUTER JOIN comments_comment T6 ON (comments_comment.id = T6.reply_to_id)
LEFT OUTER JOIN comments_reaction ON (comments_comment.id = comments_reaction.comment_id)
WHERE (NOT comments_comment.private
       OR comments_comment.user_id = 4
       OR groups_membership.user_id = 4)
GROUP BY comments_comment.id,
         comments_comment.modified,
         comments_comment.created,
         comments_comment.body,
         comments_comment.body_short_preview,
         comments_comment.body_full_preview,
         comments_comment.user_id,
         comments_comment.private,
         comments_comment.group_id,
         comments_comment.owner_type_id,
         comments_comment.owner_id,
         comments_comment.reply_to_id,
         comments_comment.comment_type_id
ORDER BY comments_comment.created DESC
>>> rt.login("robin").show(comments.RecentComments,
...     column_names="id ticket__group user owner",
...     filter=Q(ticket__isnull=False),
...     limit=10, display_mode=DISPLAY_MODE_GRID)
...
==== ============ ================= ============================================
 ID   Team         Author            Topic
---- ------------ ----------------- --------------------------------------------
 84   Developers   Robin Rood        `#105 (Irritating message when bar) <…>`__
 83   Developers   Rolf Rompen       `#105 (Irritating message when bar) <…>`__
 82   Developers   Romain Raffault   `#105 (Irritating message when bar) <…>`__
 81   Developers   Mathieu           `#105 (Irritating message when bar) <…>`__
 80   Developers   Marc              `#105 (Irritating message when bar) <…>`__
 79   Developers   Luc               `#105 (Irritating message when bar) <…>`__
 78   Developers   Jean              `#105 (Irritating message when bar) <…>`__
 77   Managers     Robin Rood        `#106 (How can I see where bar?) <…>`__
 76   Managers     Rolf Rompen       `#106 (How can I see where bar?) <…>`__
 75   Managers     Romain Raffault   `#106 (How can I see where bar?) <…>`__
==== ============ ================= ============================================
>>> rt.login("marc").show(comments.RecentComments,
...     column_names="id user group ticket__group owner",
...     filter=Q(ticket__isnull=False),
...     limit=10, display_mode=DISPLAY_MODE_GRID)
...
==== ================= ============ ============ ============================================
 ID   Author            Team         Team         Topic
---- ----------------- ------------ ------------ --------------------------------------------
 84   Robin Rood        Developers   Developers   `#105 (Irritating message when bar) <…>`__
 83   Rolf Rompen       Developers   Developers   `#105 (Irritating message when bar) <…>`__
 82   Romain Raffault   Developers   Developers   `#105 (Irritating message when bar) <…>`__
 81   Mathieu           Developers   Developers   `#105 (Irritating message when bar) <…>`__
 80   Marc              Developers   Developers   `#105 (Irritating message when bar) <…>`__
 79   Luc               Developers   Developers   `#105 (Irritating message when bar) <…>`__
 78   Jean              Developers   Developers   `#105 (Irritating message when bar) <…>`__
 73   Marc              Managers     Managers     `#106 (How can I see where bar?) <…>`__
 70   Robin Rood        Sales team   Sales team   `#107 (Misc optimizations in Baz) <…>`__
 69   Rolf Rompen       Sales team   Sales team   `#107 (Misc optimizations in Baz) <…>`__
==== ================= ============ ============ ============================================

Anonymous users can see only public comments.

>>> rt.show(comments.RecentComments,
...     column_names="id group user owner",
...     filter=Q(ticket__isnull=False),
...     limit=10, display_mode=DISPLAY_MODE_GRID)
...
==== ============ ================= ============================================
 ID   Team         Author            Topic
---- ------------ ----------------- --------------------------------------------
 84   Developers   Robin Rood        `#105 (Irritating message when bar) <…>`__
 83   Developers   Rolf Rompen       `#105 (Irritating message when bar) <…>`__
 82   Developers   Romain Raffault   `#105 (Irritating message when bar) <…>`__
 81   Developers   Mathieu           `#105 (Irritating message when bar) <…>`__
 80   Developers   Marc              `#105 (Irritating message when bar) <…>`__
 79   Developers   Luc               `#105 (Irritating message when bar) <…>`__
 78   Developers   Jean              `#105 (Irritating message when bar) <…>`__
 70   Sales team   Robin Rood        `#107 (Misc optimizations in Baz) <…>`__
 69   Sales team   Rolf Rompen       `#107 (Misc optimizations in Baz) <…>`__
 68   Sales team   Romain Raffault   `#107 (Misc optimizations in Baz) <…>`__
==== ============ ================= ============================================

Just to make sure: is comment 68 really public? Yes, because neither the group, nor the owner, nor the comment itself have been marked as private (“confidential”).

>>> comment = comments.Comment.objects.get(pk=68)
>>> comment.private
False
>>> comment.group.private
False
>>> comment.owner.private
False

Overview how many database rows each user sees:

>>> rows = []
>>> views = (comments.Comments, tickets.Tickets, groups.Groups)
>>> headers = ["User", "type"] + [i.__name__ for i in views]
>>> user_list = [users.User.get_anonymous_user()] + list(users.User.objects.all())
>>> for u in user_list:
...    cells = [str(u.username), u.user_type.name]
...    for dv in views:
...       qs = dv.create_request(user=u).data_iterator
...       cells.append(str(qs.count()))
...    rows.append(cells)
>>> print(rstgen.table(headers, rows))
...
=========== ============= ========== ========= ========
 User        type          Comments   Tickets   Groups
----------- ------------- ---------- --------- --------
 anonymous   anonymous     42         58        0
 jean        developer     54         67        2
 luc         developer     72         97        3
 marc        customer      54         68        2
 mathieu     contributor   54         67        2
 romain      admin         84         116       3
 rolf        admin         84         116       3
 robin       admin         84         116       3
=========== ============= ========== ========= ========

#5759 (Anonymous can GET private comments)

Ticket #5759 (Anonymous can GET private comments) was a security issue between 20240920 and 20241001.

Let’s pick a confidential comment for the following tests:

>>> obj = comments.Comment.objects.filter(private=True).last()
>>> obj.private
True
>>> obj.pk
77

For example the following request failed to cause an exception.

An anonymous request may of course not see it:

>>> test_client.cookies  # nobody is signed in
<SimpleCookie: >

When processing the incoming request, Lino logs a warning in the logger and then returns a 404 error:

>>> res = test_client.get("/api/comments/RecentComments/77")
Error during ApiElement.get(): Invalid request for '77' on comments.RecentComments (Row 77 does not exist on comments.RecentComments)
Row 77 does not exist on comments.RecentComments
Traceback (most recent call last):
...
django.http.response.Http404: Row 77 does not exist on comments.RecentComments
Not Found: /api/comments/RecentComments/77
>>> res.status_code
404

It would be more correct to return 403 (Forbidden) instead of 404 (Not found), at least in above case, because the requested comment 77 does exist, only the user lacks permission to see it. The case below is quite similar, except that a comment with id 123456789 does not exist:

>>> res = test_client.get("/api/comments/RecentComments/123456789")
...
Error during ApiElement.get(): Invalid request for '123456789' on comments.RecentComments (Row 123456789 does not exist on comments.RecentComments)
Row 123456789 does not exist on comments.RecentComments
Traceback (most recent call last):
...
django.http.response.Http404: Row 123456789 does not exist on comments.RecentComments
Not Found: /api/comments/RecentComments/123456789
>>> res.status_code
404

In both cases we return the same error code until further notice because differentiating them would need an additional database query.

Ticket #5759 had been introduced by #5751 (ObjectDoesNotExist: Invalid primary key 4968 for avanti.Clients), which came because avanti.Clients has default table parameters that show only registered clients. When the user manually removed that filter and then double-clicked on a client that was usually filtered out, Lino gave this (false) error.

#5763 (Big unreadable warning with HTML tags)

#5763 (A permalink with Bad request shows a big unreadable warning with HTML tags)

TODO: The React front end does above call as an AJAX call and expects a JSON-encoded response. But Lino returns Django’s default HttpResponseNotFound response, which is in HTML. That’s why the user sees a big red warning saying

Bad request

<!DOCTYPE html> <html lang=”en”> <head> <meta http-equiv=”content-type” content=”text/html; charset=utf-8”> <title>Page not found at /media/cache/json/Lino_comments.Comments.77_000_en.json</title> <meta name=”robots” content=”NONE,NOARCHIVE”> <style> html * { padding:0; margin:0; …

>>> print(res.content.decode())
<!DOCTYPE html>
<html lang="en">
<head>
...

How to find the models that are de facto commentable

Lino Noi uses quite a few models that are theoretically commentable:

>>> [fmn(m) for m in rt.models_by_base(comments.Commentable)]
...
['contacts.Company', 'contacts.Partner', 'contacts.Person', 'groups.Group',
'tickets.Ticket', 'uploads.Upload', 'users.User']

But the only way for end users to comment on something is the CommentsByRFC slave table.

For a model to be de facto commentable, the application must define a detail layout that contains a CommentsByRFC slave panel.

That’s why we have the collect_my_masters() method.

>>> pprint(comments.CommentsByRFC.collect_my_masters())
...
{<lino_noi.lib.tickets.models.TicketDetail object at ...>: lino_xl.lib.working.ui.TicketsByReport}
>>> show_master_layouts(comments.CommentsByRFC)
lino_noi.lib.tickets.models.TicketDetail on lino_xl.lib.tickets.ui.Tickets

The demo2 fixture of the comments plugin uses this:

>>> sorted({fmn(a.model) for a in comments.CommentsByRFC.collect_my_masters().values()})
['tickets.Ticket']