Repository: internetarchive/openlibrary

Issue:
Lists can exist on global namespace
Subtask of #6718

Currently, lists can only exist under a user's namespace (eg `/people/FOO/lists/OL123L`). Make it possible to have lists in the global namespace (`/lists/OL123L`). Lists like these can be editing by anyone, like book records.

- Normal list endpoints should work with `/lists/` pages
- list.owner can be `None`
- Creating a way to convert a user list to a global list is _not_ part of this issue


Discussion:

Resolving patch:
diff --git a/openlibrary/coverstore/code.py b/openlibrary/coverstore/code.py
--- a/openlibrary/coverstore/code.py
+++ b/openlibrary/coverstore/code.py
@@ -591,7 +591,10 @@ def render_list_preview_image(lst_key):
 
     para = textwrap.wrap(lst.name, width=45)
     current_h = 42
-    author_text = f"A list by {lst.get_owner().displayname}"
+
+    author_text = "A list on Open Library"
+    if owner := lst.get_owner():
+        author_text = f"A list by {owner.displayname}"
     w, h = draw.textsize(author_text, font=font_author)
     draw.text(((W - w) / 2, current_h), author_text, font=font_author, fill=(0, 0, 0))
     current_h += h + 5
diff --git a/openlibrary/olbase/events.py b/openlibrary/olbase/events.py
--- a/openlibrary/olbase/events.py
+++ b/openlibrary/olbase/events.py
@@ -86,11 +86,11 @@ def find_lists(self, changeset):
         seed are invalidated.
         """
         docs = changeset['docs'] + changeset['old_docs']
-        rx = web.re_compile(r"(/people/[^/]*)/lists/OL\d+L")
+        rx = web.re_compile(r"(/people/[^/]*)?/lists/OL\d+L")
         for doc in docs:
-            match = doc and rx.match(doc['key'])
-            if match:
-                yield "d" + match.group(1)  # d/users/foo
+            if match := doc and rx.match(doc['key']):
+                if owner := match.group(1):
+                    yield "d" + owner  # d/people/foo
                 for seed in doc.get('seeds', []):
                     yield "d" + self.seed_to_key(seed)
 
diff --git a/openlibrary/plugins/openlibrary/lists.py b/openlibrary/plugins/openlibrary/lists.py
--- a/openlibrary/plugins/openlibrary/lists.py
+++ b/openlibrary/plugins/openlibrary/lists.py
@@ -137,8 +137,10 @@ def get_list_data(list, seed, include_cover_url=True):
         d['cover_url'] = cover and cover.url("S") or "/images/icons/avatar_book-sm.png"
         if 'None' in d['cover_url']:
             d['cover_url'] = "/images/icons/avatar_book-sm.png"
-    owner = list.get_owner()
-    d['owner'] = web.storage(displayname=owner.displayname or "", key=owner.key)
+
+    d['owner'] = None
+    if owner := list.get_owner():
+        d['owner'] = web.storage(displayname=owner.displayname or "", key=owner.key)
     return d
 
 
@@ -220,10 +222,10 @@ def render(self, doc, lists):
 
 
 class lists_edit(delegate.page):
-    path = r"(/people/[^/]+)(/lists/OL\d+L)/edit"
+    path = r"(/people/[^/]+)?(/lists/OL\d+L)/edit"
 
-    def GET(self, user_key: str, list_key: str):  # type: ignore[override]
-        key = user_key + list_key
+    def GET(self, user_key: str | None, list_key: str):  # type: ignore[override]
+        key = (user_key or '') + list_key
         if not web.ctx.site.can_write(key):
             return render_template(
                 "permission_denied",
@@ -236,10 +238,8 @@ def GET(self, user_key: str, list_key: str):  # type: ignore[override]
             raise web.notfound()
         return render_template("type/list/edit", lst, new=False)
 
-    def POST(self, user_key: str, list_key: str | None = None):  # type: ignore[override]
-        key = user_key
-        if list_key:
-            key += list_key
+    def POST(self, user_key: str | None, list_key: str | None = None):  # type: ignore[override]
+        key = (user_key or '') + (list_key or '')
 
         if not web.ctx.site.can_write(key):
             return render_template(
@@ -256,7 +256,7 @@ def POST(self, user_key: str, list_key: str | None = None):  # type: ignore[over
         if not list_key:
             list_num = web.ctx.site.seq.next_value("list")
             list_key = f"/lists/OL{list_num}L"
-            list_record.key = user_key + list_key
+            list_record.key = (user_key or '') + list_key
 
         web.ctx.site.save(
             list_record.to_thing_json(),
@@ -267,24 +267,28 @@ def POST(self, user_key: str, list_key: str | None = None):  # type: ignore[over
 
 
 class lists_add(delegate.page):
-    path = r"(/people/[^/]+)/lists/add"
+    path = r"(/people/[^/]+)?/lists/add"
 
-    def GET(self, user_key: str):  # type: ignore[override]
-        if not web.ctx.site.can_write(user_key):
+    def GET(self, user_key: str | None):  # type: ignore[override]
+        if user_key and not web.ctx.site.can_write(user_key):
             return render_template(
                 "permission_denied",
                 web.ctx.fullpath,
                 f"Permission denied to edit {user_key}.",
             )
         list_record = ListRecord.from_input()
-        return render_template("type/list/edit", list_record, new=True)
+        # Only admins can add global lists for now
+        admin_only = not user_key
+        return render_template(
+            "type/list/edit", list_record, new=True, admin_only=admin_only
+        )
 
-    def POST(self, user_key: str):  # type: ignore[override]
+    def POST(self, user_key: str | None):  # type: ignore[override]
         return lists_edit().POST(user_key, None)
 
 
 class lists_delete(delegate.page):
-    path = r"(/people/[^/]+/lists/OL\d+L)/delete"
+    path = r"((?:/people/[^/]+)?/lists/OL\d+L)/delete"
     encoding = "json"
 
     def POST(self, key):
@@ -452,7 +456,7 @@ def get_list(key, raw=False):
 
 
 class list_view_json(delegate.page):
-    path = r"(/people/[^/]+/lists/OL\d+L)"
+    path = r"((?:/people/[^/]+)?/lists/OL\d+L)"
     encoding = "json"
     content_type = "application/json"
 
@@ -483,7 +487,7 @@ def get_list_seeds(key):
 
 
 class list_seeds(delegate.page):
-    path = r"(/people/[^/]+/lists/OL\d+L)/seeds"
+    path = r"((?:/people/[^/]+)?/lists/OL\d+L)/seeds"
     encoding = "json"
 
     content_type = "application/json"
@@ -561,7 +565,7 @@ def get_list_editions(key, offset=0, limit=50, api=False):
 
 
 class list_editions_json(delegate.page):
-    path = r"(/people/[^/]+/lists/OL\d+L)/editions"
+    path = r"((?:/people/[^/]+)?/lists/OL\d+L)/editions"
     encoding = "json"
 
     content_type = "application/json"
@@ -607,7 +611,7 @@ def make_collection(size, entries, limit, offset, key=None):
 
 
 class list_subjects_json(delegate.page):
-    path = r"(/people/[^/]+/lists/OL\d+L)/subjects"
+    path = r"((?:/people/[^/]+)?/lists/OL\d+L)/subjects"
     encoding = "json"
     content_type = "application/json"
 
@@ -646,7 +650,7 @@ class list_subjects_yaml(list_subjects_json):
 
 
 class lists_embed(delegate.page):
-    path = r"(/people/[^/]+/lists/OL\d+L)/embed"
+    path = r"((?:/people/[^/]+)?/lists/OL\d+L)/embed"
 
     def GET(self, key):
         doc = web.ctx.site.get(key)
@@ -656,7 +660,7 @@ def GET(self, key):
 
 
 class export(delegate.page):
-    path = r"(/people/[^/]+/lists/OL\d+L)/export"
+    path = r"((?:/people/[^/]+)?/lists/OL\d+L)/export"
 
     def GET(self, key):
         lst = web.ctx.site.get(key)
@@ -758,7 +762,7 @@ def make_doc(self, rawdata):
 
 
 class feeds(delegate.page):
-    path = r"(/people/[^/]+/lists/OL\d+L)/feeds/(updates).(atom)"
+    path = r"((?:/people/[^/]+)?/lists/OL\d+L)/feeds/(updates).(atom)"
 
     def GET(self, key, name, fmt):
         lst = web.ctx.site.get(key)
@@ -819,7 +823,8 @@ def _preload_lists(lists):
             xlist = xlist.dict()
 
         owner = xlist['key'].rsplit("/lists/", 1)[0]
-        keys.add(owner)
+        if owner:
+            keys.add(owner)
 
         for seed in xlist.get("seeds", []):
             if isinstance(seed, dict) and "key" in seed:
@@ -874,7 +879,7 @@ def get_active_lists_in_random(limit=20, preload=True):
 
 
 class lists_preview(delegate.page):
-    path = r"(/people/[^/]+/lists/OL\d+L)/preview.png"
+    path = r"((?:/people/[^/]+)?/lists/OL\d+L)/preview.png"
 
     def GET(self, lst_key):
         image_bytes = render_list_preview_image(lst_key)


Test patch: