Repository: django-wiki/django-wiki

Issue:
get_cached_content fails with 'SafeString object has no attribute append' error
`get_cached_content()` expects the `cached_items` is a list. It is... often. But sometimes it is a SafeString and then the line https://github.com/django-wiki/django-wiki/blob/e5d6b9701f9cef4323de7748a037f78f323c11a8/src/wiki/models/article.py#L258 fails because string does not have append method.

It looks that the cache content is set outside the `get_cached_content` method sometimes, but I do not know where...

There is the exception:

```
  File "/tmp/venv/lib/python3.8/site-packages/django/core/handlers/exception.py", line 34, in inner
    response = get_response(request)
  File "/tmp/venv/lib/python3.8/site-packages/django/core/handlers/base.py", line 145, in _get_response
    response = self.process_exception_by_middleware(e, request)
  File "/tmp/venv/lib/python3.8/site-packages/django/core/handlers/base.py", line 143, in _get_response
    response = response.render()
  File "/tmp/venv/lib/python3.8/site-packages/django/template/response.py", line 105, in render
    self.content = self.rendered_content
  File "/tmp/venv/lib/python3.8/site-packages/django/template/response.py", line 83, in rendered_content
    return template.render(context, self._request)
  File "/tmp/venv/lib/python3.8/site-packages/django/template/backends/django.py", line 61, in render
    return self.template.render(context)
  File "/tmp/venv/lib/python3.8/site-packages/django/template/base.py", line 171, in render
    return self._render(context)
  File "/tmp/venv/lib/python3.8/site-packages/django/test/utils.py", line 95, in instrumented_test_render
    return self.nodelist.render(context)
  File "/tmp/venv/lib/python3.8/site-packages/django/template/base.py", line 936, in render
    bit = node.render_annotated(context)
  File "/tmp/venv/lib/python3.8/site-packages/django/template/base.py", line 903, in render_annotated
    return self.render(context)
  File "/tmp/venv/lib/python3.8/site-packages/django/template/loader_tags.py", line 150, in render
    return compiled_parent._render(context)
  File "/tmp/venv/lib/python3.8/site-packages/django/test/utils.py", line 95, in instrumented_test_render
    return self.nodelist.render(context)
  File "/tmp/venv/lib/python3.8/site-packages/django/template/base.py", line 936, in render
    bit = node.render_annotated(context)
  File "/tmp/venv/lib/python3.8/site-packages/django/template/base.py", line 903, in render_annotated
    return self.render(context)
  File "/tmp/venv/lib/python3.8/site-packages/django/template/loader_tags.py", line 150, in render
    return compiled_parent._render(context)
  File "/tmp/venv/lib/python3.8/site-packages/django/test/utils.py", line 95, in instrumented_test_render
    return self.nodelist.render(context)
  File "/tmp/venv/lib/python3.8/site-packages/django/template/base.py", line 936, in render
    bit = node.render_annotated(context)
  File "/tmp/venv/lib/python3.8/site-packages/django/template/base.py", line 903, in render_annotated
    return self.render(context)
  File "/tmp/venv/lib/python3.8/site-packages/django/template/loader_tags.py", line 150, in render
    return compiled_parent._render(context)
  File "/tmp/venv/lib/python3.8/site-packages/django/test/utils.py", line 95, in instrumented_test_render
    return self.nodelist.render(context)
  File "/tmp/venv/lib/python3.8/site-packages/django/template/base.py", line 936, in render
    bit = node.render_annotated(context)
  File "/tmp/venv/lib/python3.8/site-packages/django/template/base.py", line 903, in render_annotated
    return self.render(context)
  File "/tmp/venv/lib/python3.8/site-packages/classytags/core.py", line 151, in render
    return self.render_tag(context, **kwargs)
  File "/tmp/venv/lib/python3.8/site-packages/sekizai/templatetags/sekizai_tags.py", line 87, in render_tag
    rendered_contents = nodelist.render(context)
  File "/tmp/venv/lib/python3.8/site-packages/django/template/base.py", line 936, in render
    bit = node.render_annotated(context)
  File "/tmp/venv/lib/python3.8/site-packages/django/template/base.py", line 903, in render_annotated
    return self.render(context)
  File "/tmp/venv/lib/python3.8/site-packages/django/template/loader_tags.py", line 62, in render
    result = block.nodelist.render(context)
  File "/tmp/venv/lib/python3.8/site-packages/django/template/base.py", line 936, in render
    bit = node.render_annotated(context)
  File "/tmp/venv/lib/python3.8/site-packages/django/template/base.py", line 903, in render_annotated
    return self.render(context)
  File "/tmp/venv/lib/python3.8/site-packages/django/template/loader_tags.py", line 62, in render
    result = block.nodelist.render(context)
  File "/tmp/venv/lib/python3.8/site-packages/django/template/base.py", line 936, in render
    bit = node.render_annotated(context)
  File "/tmp/venv/lib/python3.8/site-packages/django/template/base.py", line 903, in render_annotated
    return self.render(context)
  File "/tmp/venv/lib/python3.8/site-packages/django/template/loader_tags.py", line 62, in render
    result = block.nodelist.render(context)
  File "/tmp/venv/lib/python3.8/site-packages/django/template/base.py", line 936, in render
    bit = node.render_annotated(context)
  File "/tmp/venv/lib/python3.8/site-packages/django/template/base.py", line 903, in render_annotated
    return self.render(context)
  File "/tmp/venv/lib/python3.8/site-packages/django/template/library.py", line 214, in render
    _dict = self.func(*resolved_args, **resolved_kwargs)
  File "/tmp/venv/lib/python3.8/site-packages/wiki/templatetags/wiki_tags.py", line 53, in wiki_render
    content = article.get_cached_content(user=context.get("user"))
  File "/tmp/venv/lib/python3.8/site-packages/wiki/models/article.py", line 258, in get_cached_content
    cached_items.append(cache_content_key)
AttributeError: 'SafeString' object has no attribute 'append'
```


Discussion:
Thanks for reporting @jenda1 - seems like an issue that could perhaps be addressed with a test case and a fix.
me too:

```
Traceback:

File "/home/braas/projects/django_wiki/ve-djang-wiki/lib/python3.8/site-packages/django/core/handlers/exception.py" in inner
  34.             response = get_response(request)

......

File "/home/braas/projects/django_wiki/ve-djang-wiki/lib/python3.8/site-packages/django/template/library.py" in render
  214.         _dict = self.func(*resolved_args, **resolved_kwargs)

File "/home/braas/projects/django_wiki/django-wiki/src/wiki/templatetags/wiki_tags.py" in wiki_render
  53.         content = article.get_cached_content(user=context.get("user"))

File "/home/braas/projects/django_wiki/django-wiki/src/wiki/models/article.py" in get_cached_content
  262.         cached_items.append(cache_content_key)

Exception Type: AttributeError at /
Exception Value: 'SafeText' object has no attribute 'append'
```
If there is agreement, I'll remove the "per-article-user" caching schema. It simplify the code, speed up the wiki, and fix this issue.  I'll revert to "per-article" caching.

TLDR; the "per-article-user" caching schema was added in https://github.com/django-wiki/django-wiki/commit/8cc19e5bc7814b622e267b1a56234a566e33cde2 by me. The idea was to allow the creation of a plugin that renders the page differently for each user. But I have not created such plugin, I came up with an easier way to achieve what I needed.  AFIK there is no plugin that uses the functionality.
The "per-article-user" is useless now, it causes problems and it is a bit expensive because cache entry can not be used for another user.

> If there is agreement, I'll remove the "per-article-user" caching schema. It simplify the code, speed up the wiki, and fix this issue. I'll revert to "per-article" caching.

I agree. 
Fyi: for me the problem appeared when i upgraded my codebase to the latest development version
I use cached sessions with LocMemCache and found the problem to be reproducible. I also couldn't find a cause in the django-wiki code. My guess would be that the cache pollution is related to sessions. But anyway, 1e4b2fd solves the problem and calls for a bugfix release!
I confirm  https://github.com/django-wiki/django-wiki/issues/1072 solves the problem
#1080 may fix the exception but it will produce wrong cache results because the user session isn't taken into account.
in models/Article.py, near line 260
The first view of an Article shows correctly.
Then, for each Article entry, two entries are stored.
The second view of  the same Article throws the exeption

`cache_key = self.get_cache_key() # value: wikiarticle85nl-nl `
 `cache_content_key = self.get_cache_content_key(user) # value: wikiarticle85nl-nl `
  . . . . .
 `cache.set(cache_key, cached_items, settings.CACHE_TIMEOUT) `
 `cache.set(cache_content_key, cached_content, settings.CACHE_TIMEOUT) `

I think the first cache entry will be overwritten with the second one.




ah. Found it.

the user key is sluggified, and that removes the ':'. AnonymousUser has an empty lastname, so the resulting cache_content_key
is equal to the cache_key.
models/article.py, line 235:
`key_raw = "{key}:{user}".format()`
should be:
`key_raw = "{key}-{user}".format()`

then, everything woks correctly.


@jenda1 "It looks that the cache content is set outside the get_cached_content method sometimes, but I do not know where..."
you where almost right :-)
I wil update test_cache test in test_models. Then I will fix the issue and submit a pull request.

My main page gives me 500 because of this. Any workaround for 0.7?

Resolving patch:
diff --git a/setup.py b/setup.py
--- a/setup.py
+++ b/setup.py
@@ -22,10 +22,10 @@ def get_path(fname):
 
 
 install_requirements = [
-    "Django>=2.1,<3.2",
+    "Django>=2.1,<3.3",
     "bleach>=3.3.0,<3.4",
     "Pillow",
-    "django-nyt>=1.1.5,<1.2",
+    "django-nyt>=1.2,<1.3",
     "django-mptt>=0.11,<0.12",
     "django-sekizai>=0.10",
     "sorl-thumbnail>=12.6.2,<13",
@@ -93,6 +93,7 @@ def get_path(fname):
         "Programming Language :: Python :: 3.6",
         "Programming Language :: Python :: 3.7",
         "Programming Language :: Python :: 3.8",
+        "Programming Language :: Python :: 3.9",
         "Programming Language :: Python :: Implementation :: CPython",
         "Programming Language :: Python :: Implementation :: PyPy",
         "Topic :: Internet :: WWW/HTTP :: Dynamic Content",
diff --git a/src/wiki/__init__.py b/src/wiki/__init__.py
--- a/src/wiki/__init__.py
+++ b/src/wiki/__init__.py
@@ -17,5 +17,5 @@
 
 default_app_config = "wiki.apps.WikiConfig"
 
-VERSION = (0, 7, 4, "final", 0)
+VERSION = (0, 7, 5, "final", 0)
 __version__ = get_version(VERSION)
diff --git a/src/wiki/models/article.py b/src/wiki/models/article.py
--- a/src/wiki/models/article.py
+++ b/src/wiki/models/article.py
@@ -233,7 +233,7 @@ def get_cache_key(self):
     def get_cache_content_key(self, user=None):
         """Returns per-article-user cache key."""
         key_raw = "{key}-{user}".format(
-            key=self.get_cache_key(), user=user.get_username() if user else ""
+            key=self.get_cache_key(), user=user.get_username() if user else "-anonymous"
         )
         # https://github.com/django-wiki/django-wiki/issues/1065
         return slugify(key_raw, allow_unicode=True)
@@ -248,6 +248,9 @@ def get_cached_content(self, user=None):
         is used only if the key is in the per-article entry. To delete
         per-article invalidates all article cache entries."""
 
+        if user and user.is_anonymous:
+            user = None
+
         cache_key = self.get_cache_key()
         cache_content_key = self.get_cache_content_key(user)
 


Test patch:
diff --git a/tests/core/test_views.py b/tests/core/test_views.py
--- a/tests/core/test_views.py
+++ b/tests/core/test_views.py
@@ -1,6 +1,7 @@
 import pprint
 
 from django.contrib.messages import constants
+from django.contrib.messages import get_messages
 from django.http import JsonResponse
 from django.shortcuts import resolve_url
 from django.test import override_settings
@@ -122,12 +123,11 @@ def test_article_list_update(self):
             },
         )
 
-        message = getattr(self.client.cookies["messages"], "value")
-
         self.assertRedirects(response, resolve_url("wiki:get", path=""))
+        messages = [m.message for m in get_messages(response.wsgi_request)]
         self.assertIn(
-            "This article together with all " "its contents are now completely gone",
-            message,
+            "This article together with all its contents are now completely gone",
+            messages[0],
         )
         self.assertNotContains(self.get_by_path(""), "Sub Article 1")
 
@@ -733,8 +733,9 @@ def test_change_group(self):
         self.root_article.refresh_from_db()
         self.assertEqual(self.root_article.group, group)
         self.assertEqual(self.root_article.owner, self.superuser1)
-        self.assertEqual(len(response.context.get("messages")), 1)
-        message = response.context.get("messages")._loaded_messages[0]
+        messages = [m for m in get_messages(response.wsgi_request)]
+        self.assertEqual(len(messages), 1)
+        message = messages[0]
         self.assertEqual(message.level, constants.SUCCESS)
         self.assertEqual(
             message.message, "Permission settings for the article were updated."
@@ -768,8 +769,9 @@ def test_unchanged_message(self):
             form_values,
             follow=True,
         )
-        self.assertEqual(len(response.context.get("messages")), 1)
-        message = response.context.get("messages")._loaded_messages[0]
+        messages = [m for m in get_messages(response.wsgi_request)]
+        self.assertEqual(len(messages), 1)
+        message = messages[0]
         self.assertEqual(message.level, constants.SUCCESS)
         self.assertEqual(
             message.message,