=> ce031e623bc7c8cabad643d4d077aa79928d9877
[1mdiff --git a/50_bubble.py b/50_bubble.py[m [1mindex a0a236c..6efc398 100644[m [1m--- a/50_bubble.py[m [1m+++ b/50_bubble.py[m [36m@@ -178,6 +178,13 @@[m [mBubble is open source:[m return True[m return post.subspace in self.user.moderated_subspace_ids[m [m [32m+[m[32m def is_moderated(self, post: Post):[m [32m+[m[32m if not self.user:[m [32m+[m[32m return False[m [32m+[m[32m if self.user.role == User.ADMIN:[m [32m+[m[32m return True[m [32m+[m[32m return post.subspace in self.user.moderated_subspace_ids[m [32m+[m def is_movable(self, post: Post):[m if post.issueid:[m return False[m [1mdiff --git a/admin.py b/admin.py[m [1mindex 82dee8e..da01c48 100644[m [1m--- a/admin.py[m [1m+++ b/admin.py[m [36m@@ -55,15 +55,20 @@[m [mdef admin_actions(session):[m [m return page[m [m [32m+[m[32m if req.path == session.path + 'admin/flair':[m [32m+[m[32m if is_empty_query(req):[m [32m+[m[32m return 10, "Edit flairs of user:"[m [32m+[m[32m return 30, session.path + 'settings/flair/' + clean_query(req) + '/'[m [32m+[m if req.path == session.path + 'admin/':[m token = session.get_token()[m [m page += "## Users\n\n"[m page += f'=> review-users/ ✔️ Review limited users\n'[m [31m- page += f'=> flair/{token} 📛 Set user flair\n'[m [31m- page += f'=> create-user/{token} 👤 Create new user\n'[m [32m+[m[32m page += f'=> flair 📛 Edit user flairs\n'[m page += f'=> password/{token} 🔑 Generate a random password for user\n'[m page += f'=> revoke/{token} 🛂 Revoke certificates of user\n'[m [32m+[m[32m page += f'=> create-user/{token} 👤 Create new user\n'[m page += f'=> delete-user/{token} ❌ Delete user\n'[m [m page += "\n## Subspaces\n\n"[m [36m@@ -153,20 +158,20 @@[m [mdef admin_actions(session):[m db.remove_certificate(user, None)[m page += f'Certificates of user "{name}" (ID: {user.id}) have been unregistered.\n'[m [m [31m- elif action == 'flair':[m [31m- try:[m [31m- parts = clean_query(req).split()[m [31m- name = parts[0][m [31m- if not is_valid_name(name):[m [31m- return 10, 'Enter user name followed by flair (e.g., "john Friendly"):'[m [31m- except:[m [31m- return 10, 'Enter user name followed by flair (e.g., "john Friendly"):'[m [31m- flair = clean_query(req)[len(name):].strip()[m [31m- user = db.get_user(name=name)[m [31m- if not user:[m [31m- return 51, 'Not found'[m [31m- db.update_user(user, flair=flair)[m [31m- page += f'Flair of user "{name}" (ID: {user.id}) has been set to: [{flair}]\n'[m [32m+[m[32m # elif action == 'flair':[m [32m+[m[32m # try:[m [32m+[m[32m # parts = clean_query(req).split()[m [32m+[m[32m # name = parts[0][m [32m+[m[32m # if not is_valid_name(name):[m [32m+[m[32m # return 10, 'Enter user name followed by flair (e.g., "john Friendly"):'[m [32m+[m[32m # except:[m [32m+[m[32m # return 10, 'Enter user name followed by flair (e.g., "john Friendly"):'[m [32m+[m[32m # flair = clean_query(req)[len(name):].strip()[m [32m+[m[32m # user = db.get_user(name=name)[m [32m+[m[32m # if not user:[m [32m+[m[32m # return 51, 'Not found'[m [32m+[m[32m # db.update_user(user, flair=flair)[m [32m+[m[32m # page += f'Flair of user "{name}" (ID: {user.id}) has been set to: [{flair}]\n'[m [m elif action == 'delete-user':[m if not is_valid_name(name):[m [1mdiff --git a/feeds.py b/feeds.py[m [1mindex 5111fa7..8753d30 100644[m [1m--- a/feeds.py[m [1m+++ b/feeds.py[m [36m@@ -256,6 +256,11 @@[m [mdef make_post_page_or_configure_feed(session):[m actions.append(f'=> /unlock/{post.id} 🔓 Unlock comments\n')[m else:[m actions.append(f'=> /lock/{post.id} 🔒 Lock comments\n')[m [32m+[m[32m if session.is_moderated(post):[m [32m+[m[32m if session.user.role == User.ADMIN:[m [32m+[m[32m actions.append(f'=> /settings/flair/{post.poster_name}/add/ 📛 Set flair on {post.poster_name}\n')[m [32m+[m[32m else:[m [32m+[m[32m actions.append(f'=> /settings/flair/{post.poster_name}/add/-/{post.sub_name} 📛 Set subspace flair on {post.poster_name}\n')[m if session.is_movable(post):[m actions.append(f'=> /edit/{post.id}/move/{session.get_token()} Move to subspace\n')[m if post.user != session.user.id and not session.is_user_mod and session.user.role != User.ADMIN:[m [36m@@ -392,6 +397,12 @@[m [mdef make_post_page(session, post):[m actions.append(f'=> /thanks/{focused_cmt.id} 🙏 Give thanks\n')[m actions.append(f'=> /remind/{focused_cmt.id} 🔔 Remind me\n')[m [m [32m+[m[32m if session.is_moderated(focused_cmt):[m [32m+[m[32m if session.user.role == User.ADMIN:[m [32m+[m[32m actions.append(f'=> /settings/flair/{focused_cmt.poster_name}/add/ 📛 Set flair on {focused_cmt.poster_name}\n')[m [32m+[m[32m else:[m [32m+[m[32m actions.append(f'=> /settings/flair/{focused_cmt.poster_name}/add/-/{focused_cmt.sub_name} 📛 Set subspace flair on {focused_cmt.poster_name}\n')[m [32m+[m actions.append(f'=> /report/{focused_cmt.id} ⚠️ Report\n')[m if not session.is_editable(focused_cmt) and session.is_deletable(focused_cmt):[m actions.append(f'=> /edit/{focused_cmt.id}/delete/{session.get_token()} ❌ Delete comment\n')[m [36m@@ -973,9 +984,14 @@[m [mdef make_feed_page(session):[m page += '=> /tag 🏷️ Tags\n'[m [m # Settings.[m [31m- if context and not c_user and session.is_user_mod:[m [31m- page += f'=> /{context.title()}/admin 🌒 Subspace admin\n'[m [31m- page += "=> /settings ⚙️ Settings\n\n"[m [32m+[m[32m page += "\n=> /settings ⚙️ Settings\n"[m [32m+[m[32m if user.role == User.ADMIN and c_user and user.id != c_user.id:[m [32m+[m[32m page += f'=> /settings/flair/{c_user.name} 📛 Flairs on {c_user.name}\n'[m [32m+[m[32m if context and (not c_user or user.id == c_user.id):[m [32m+[m[32m page += f'=> /settings/flair/{user.name}/add/-/{context.name} 📛 Set subspace flair\n'[m [32m+[m[32m if session.is_user_mod:[m [32m+[m[32m page += f'=> /{context.title()}/admin 🌒 Subspace admin\n'[m [32m+[m[32m page += '\n'[m [m if session.is_antenna_enabled() and c_user and user.id == c_user.id:[m for i, label in enumerate(session.bubble.antenna_labels):[m [1mdiff --git a/model.py b/model.py[m [1mindex 1330c77..95114ed 100644[m [1m--- a/model.py[m [1m+++ b/model.py[m [36m@@ -78,6 +78,7 @@[m [mclass Notification:[m REMINDER = 0x080000[m REPORT = 0x100000[m USER_RENAMED = 0x200000[m [32m+[m[32m USER_FLAIR_CHANGED = 0x400000[m [m ALL_MASK = 0xffffff[m [m [36m@@ -85,6 +86,7 @@[m [mclass Notification:[m PRIORITY = {[m REACTION: -1, # only one kept[m USER_RENAMED: -1,[m [32m+[m[32m USER_FLAIR_CHANGED: -1,[m [m COMMENT_IN_FOLLOWED_SUBSPACE: 0,[m POST_IN_FOLLOWED_SUBSPACE: 1,[m [36m@@ -190,6 +192,9 @@[m [mclass Notification:[m elif self.type == Notification.USER_RENAMED:[m event = f'renamed their account (ID:{self.src})'[m icon = '👤 '[m [32m+[m[32m elif self.type == Notification.USER_FLAIR_CHANGED:[m [32m+[m[32m event = 'changed their flair'[m [32m+[m[32m icon = '📛 '[m elif self.type == Notification.SUBSPACE_CREATED:[m event = f'created the subspace s/{self.subname}'[m icon = '🌘 '[m [36m@@ -229,19 +234,39 @@[m [mclass Notification:[m return f'/notif/{self.id}', f'{icon}{self.src_name if with_src else ""} {event}{hm_time}{age}'[m [m [m [32m+[m[32mclass Flair:[m [32m+[m[32m TYPES = [[m [32m+[m[32m ('🛂', 'Note from moderator'),[m [32m+[m[32m ('👤', 'Self description'),[m [32m+[m[32m ('🏝️', 'Absence'),[m [32m+[m[32m ('🗣️', 'Interaction style'),[m [32m+[m[32m ('✍️', 'Writing style'),[m [32m+[m[32m ('', 'Custom note'),[m [32m+[m[32m ][m [32m+[m[32m ENC_SYMBOLS = ['M', 'd', 'a', 'i', 'w', 'n'][m [32m+[m [32m+[m[32m MODERATOR_NOTE = 0[m [32m+[m[32m SELF_DESCRIPTION = 1[m [32m+[m[32m CUSTOM_NOTE = 5[m [32m+[m [32m+[m[32m def __init__(self, scope, flair_type, label, is_admin_assigned):[m [32m+[m[32m assert(flair_type < len(Flair.TYPES))[m [32m+[m[32m self.scope = scope[m [32m+[m[32m self.type = flair_type[m [32m+[m[32m self.label = label[m [32m+[m[32m self.is_admin_assigned = is_admin_assigned[m [32m+[m [32m+[m[32m def icon(self):[m [32m+[m[32m return Flair.TYPES[self.type][0][m [32m+[m [32m+[m[32m def description(self):[m [32m+[m[32m return Flair.TYPES[self.type][1][m [32m+[m [32m+[m class User:[m # Roles:[m BASIC, ADMIN, LIMITED = range(3)[m [m [31m- # Flair types:[m [31m- FLAIRS = {[m [31m- '♡': 'Self description',[m [31m- '🏝️': 'Absence',[m [31m- '✍️': 'Writing style',[m [31m- '🗣️': 'Interaction style',[m [31m- '🛂': 'Note from moderator',[m [31m- }[m [31m-[m # Sort modes:[m SORT_POST_RECENT = 'r'[m SORT_POST_HOTNESS = 'h'[m [36m@@ -298,6 +323,32 @@[m [mclass User:[m [m return None[m [m [32m+[m[32m def parse_flair(user_flair):[m [32m+[m[32m flairs = [][m [32m+[m [32m+[m[32m for flair in user_flair.split('\n'):[m [32m+[m[32m try:[m [32m+[m[32m flags, scope, enc_type, label = flair.split('\t')[m [32m+[m[32m is_admin_assigned = (flags == '*')[m [32m+[m[32m scope = int(scope)[m [32m+[m[32m type = Flair.ENC_SYMBOLS.index(enc_type)[m [32m+[m[32m flairs.append(Flair(scope, type, label, is_admin_assigned))[m [32m+[m[32m except Exception as x:[m [32m+[m[32m print(x)[m [32m+[m [32m+[m[32m flairs = list(sorted(flairs, key=lambda f: (f.scope, f.type, f.label)))[m [32m+[m [32m+[m[32m return flairs[m [32m+[m [32m+[m[32m def unparse_flair(flairs):[m [32m+[m[32m result = [][m [32m+[m[32m for f in flairs:[m [32m+[m[32m assert(type(f.scope) == int)[m [32m+[m[32m #icon = f'{f.icon} ' if f.icon else ''[m [32m+[m[32m label = f.label.replace('\t', '') # remove separators[m [32m+[m[32m result.append(f"{'*' if f.is_admin_assigned else ''}\t{f.scope}\t{Flair.ENC_SYMBOLS[f.type]}\t{label}")[m [32m+[m[32m return '\n'.join(result)[m [32m+[m def render_flair(user_flair, context, abbreviate=False, long_form=False, db=None,[m user_mod=False, user_op=False):[m """[m [36m@@ -316,56 +367,50 @@[m [mclass User:[m return ''[m [m out = ''[m [32m+[m[32m has_abbrev = False[m [m [31m- for flair in user_flair.split('\n'):[m [31m- pos = flair.find(':')[m [31m- scope = flair[:pos].strip() if pos >= 0 else None[m [31m- is_admin_assigned = (scope and scope.startswith('*'))[m [31m- if is_admin_assigned:[m [31m- scope = scope[1:][m [31m- label = flair[pos + 1:].strip() if pos >= 0 else flair[m [31m- icon = ''[m [31m- if len(label):[m [31m- for key in User.FLAIRS:[m [31m- if label.startswith(key):[m [31m- icon = key[m [31m- label = label[len(key):].strip()[m [31m- break[m [31m-[m [32m+[m[32m for flair in User.parse_flair(user_flair):[m #print(user_flair, icon, label, scope)[m [m [31m- has_abbrev = False[m [31m-[m if long_form:[m # Show everything in the long form.[m [31m- if icon:[m [31m- out += icon + ' '[m [31m- if scope:[m [31m- subspace = db.get_subspace(id=abs(int(scope)))[m [32m+[m[32m if flair.icon():[m [32m+[m[32m out += flair.icon() + ' '[m [32m+[m[32m if flair.scope:[m [32m+[m[32m subspace = db.get_subspace(id=flair.scope)[m if subspace:[m scope = f" (in {subspace.title()})"[m else:[m scope = " (in a deleted subspace)"[m else:[m scope = ''[m [31m- if icon:[m [31m- out += f"{User.FLAIRS[icon]}: "[m [31m- elif not scope and is_admin_assigned:[m [32m+[m[32m if flair.icon():[m [32m+[m[32m out += f"{flair.description()}: "[m [32m+[m[32m elif not scope and flair.is_admin_assigned:[m out += '📛 Assigned flair: '[m else:[m out += '📛 Personal flair: '[m [31m- out += f"{label}{scope}{' (set by admin)' if is_admin_assigned else ''}\n"[m [31m-[m [31m- elif not scope or (context and int(scope) == context.id):[m [31m- # A global flair is displayed everywhere, otherwise the scope must match[m [31m- # current context.[m [31m- if len(out): out += ' ' if abbreviate else ', '[m [31m- out += icon[m [31m- if not abbreviate or not scope:[m [31m- if len(out): out += ' '[m [31m- out += label[m [31m- elif not icon:[m [31m- has_abbrev = True[m [32m+[m[32m out += f"{flair.label}{scope}{' (set by admin)' if flair.is_admin_assigned else ''}\n"[m [32m+[m [32m+[m[32m elif flair.scope == 0 or (context and flair.scope == context.id):[m [32m+[m[32m if abbreviate:[m [32m+[m[32m if not flair.icon() or flair.type == Flair.SELF_DESCRIPTION:[m [32m+[m[32m has_abbrev = True[m [32m+[m[32m continue[m [32m+[m[32m # Just showing icons and "...".[m [32m+[m[32m out += flair.icon()[m [32m+[m[32m # elif flair.label:[m [32m+[m[32m # has_abbrev = True[m [32m+[m[32m else:[m [32m+[m[32m # Showing icons and labels.[m [32m+[m[32m if len(out):[m [32m+[m[32m out += ', '[m [32m+[m [32m+[m[32m icon = flair.icon() if flair.type != Flair.SELF_DESCRIPTION else ''[m [32m+[m[32m out += icon[m [32m+[m[32m if flair.label:[m [32m+[m[32m if icon: out += ' '[m [32m+[m[32m out += flair.label[m [m if not long_form:[m if user_op or user_mod:[m [36m@@ -1034,6 +1079,37 @@[m [mclass Database:[m cur.execute(f"UPDATE subspaces SET name=? WHERE owner=?", (name, user))[m self.commit()[m [m [32m+[m[32m def add_flair(self, user, flair, editor: User):[m [32m+[m[32m flairs = [flair][m [32m+[m[32m for f in User.parse_flair(user.flair):[m [32m+[m[32m if f.scope == flair.scope and f.type == flair.type:[m [32m+[m[32m # The new one replaces this one.[m [32m+[m[32m if f.is_admin_assigned and editor.role != User.ADMIN:[m [32m+[m[32m raise GeminiError(61, 'Not authorized')[m [32m+[m[32m continue[m [32m+[m[32m flairs.append(f)[m [32m+[m[32m self.update_user(user, flair=User.unparse_flair(flairs))[m [32m+[m[32m assert(flair.scope != None)[m [32m+[m[32m self.notify_moderators(Notification.USER_FLAIR_CHANGED,[m [32m+[m[32m editor,[m [32m+[m[32m None,[m [32m+[m[32m subspace=self.get_subspace(id=flair.scope) if flair.scope != 0 else None)[m [32m+[m [32m+[m[32m def remove_flair(self, user, flair, editor: User):[m [32m+[m[32m flairs = [][m [32m+[m[32m for f in User.parse_flair(user.flair):[m [32m+[m[32m if f.scope == flair.scope and f.type == flair.type:[m [32m+[m[32m # This will be removed.[m [32m+[m[32m if not f.is_admin_assigned or editor.role == User.ADMIN:[m [32m+[m[32m continue[m [32m+[m[32m flairs.append(f)[m [32m+[m[32m self.update_user(user, flair=User.unparse_flair(flairs))[m [32m+[m[32m assert(flair.scope != None)[m [32m+[m[32m self.notify_moderators(Notification.USER_FLAIR_CHANGED,[m [32m+[m[32m editor,[m [32m+[m[32m None,[m [32m+[m[32m subspace=self.get_subspace(id=flair.scope) if flair.scope != 0 else None)[m [32m+[m def destroy_user(self, user: User):[m # This will already delete all posts made in the user's own subspace.[m self.destroy_subspace(self.get_subspace(owner=user.id))[m [36m@@ -2267,25 +2343,29 @@[m [mclass Database:[m """, (comment_id,))[m self.commit()[m [m [31m- def notify_report(self, user: User, post: Post):[m [31m- if post.user == user.id:[m [32m+[m[32m def notify_moderators(self, notif_type, user:User, post:Post=None, subspace:Subspace=None):[m [32m+[m[32m if notif_type == Notification.REPORT and post.user == user.id:[m # No self-reporting.[m return[m cur = self.conn.cursor()[m # Always notify the administrator.[m [31m- cur.execute("""[m [31m- INSERT IGNORE INTO notifs (type, dst, src, post)[m [31m- VALUES (?, 1, ?, ?)[m [31m- """, (Notification.REPORT, user.id, post.id))[m [31m- subspace = self.get_subspace(id=post.subspace)[m [31m- # Notify each moderator of the post's subspace.[m [31m- cur.execute("""[m [31m- INSERT IGNORE INTO notifs (type, dst, src, post)[m [31m- SELECT ?, user, ?, ?[m [31m- FROM mods[m [31m- WHERE subspace=? AND NOT user IN (?, ?, ?)[m [31m- """, (Notification.REPORT, user.id, post.id, post.subspace,[m [31m- user.id, post.user, subspace.owner))[m [32m+[m[32m if user.id != 1:[m [32m+[m[32m cur.execute("""[m [32m+[m[32m INSERT IGNORE INTO notifs (type, dst, src, post)[m [32m+[m[32m VALUES (?, 1, ?, ?)[m [32m+[m[32m """, (notif_type, user.id, post.id if post else None))[m [32m+[m[32m if not subspace and post:[m [32m+[m[32m subspace = self.get_subspace(id=post.subspace)[m [32m+[m[32m if subspace:[m [32m+[m[32m # Notify each moderator of the post's subspace.[m [32m+[m[32m cur.execute("""[m [32m+[m[32m INSERT IGNORE INTO notifs (type, dst, src, post)[m [32m+[m[32m SELECT ?, user, ?, ?[m [32m+[m[32m FROM mods[m [32m+[m[32m WHERE subspace=? AND NOT user IN (?, ?, ?)[m [32m+[m[32m """, (notif_type, user.id, post.id if post else None,[m [32m+[m[32m subspace.id,[m [32m+[m[32m user.id, post.user if post else user.id, subspace.owner))[m self.commit()[m [m def notify_reminder(self, user: User, post: Post):[m [1mdiff --git a/settings.py b/settings.py[m [1mindex 9733a2c..c75a597 100644[m [1m--- a/settings.py[m [1m+++ b/settings.py[m [36m@@ -1,7 +1,7 @@[m import math[m import pytz[m from utils import *[m [31m-from model import Notification, User, Subspace, FOLLOW_USER, FOLLOW_SUBSPACE, \[m [32m+[m[32mfrom model import Notification, User, Flair, Subspace, FOLLOW_USER, FOLLOW_SUBSPACE, \[m MUTE_USER, MUTE_SUBSPACE[m [m [m [36m@@ -14,6 +14,216 @@[m [mdef modify_reactions(db, user, user_flag, notif_type):[m db.update_user(user, flags=user.flags ^ user_flag, notif=notif)[m [m [m [32m+[m[32mdef space_separated(a, b):[m [32m+[m[32m return a + ' ' + b if a and b else a + b[m [32m+[m [32m+[m [32m+[m[32mdef make_flair_composer_page(session):[m [32m+[m[32m # The flair composer can be used as a user editing their own flairs,[m [32m+[m[32m # a moderator removing subspace flairs that they moderate, or an[m [32m+[m[32m # admin editing flairs of any user without restrictions.[m [32m+[m[32m req = session.req[m [32m+[m[32m db = session.db[m [32m+[m[32m user = session.user[m [32m+[m[32m user_mod = False[m [32m+[m[32m page = ''[m [32m+[m [32m+[m[32m TEXT_LIMIT = 50[m [32m+[m[32m TEXT_PROMPT = f'Enter flair text (max. {TEXT_LIMIT} charecters):'[m [32m+[m [32m+[m[32m found = re.search(r'/settings/flair/([\w-]+)', req.path)[m [32m+[m[32m flair_user = found[1] if found else user.name[m [32m+[m[32m base_url = session.path + f'settings/flair/' + flair_user[m [32m+[m[32m moderated_subs = db.get_moderated_subspace_ids(user)[m [32m+[m [32m+[m[32m if not user:[m [32m+[m[32m return 60, 'Login required'[m [32m+[m[32m if user.role == User.LIMITED:[m [32m+[m[32m return 61, 'Not authorized'[m [32m+[m[32m if user.name != flair_user and user.role != User.ADMIN:[m [32m+[m[32m if len(moderated_subs):[m [32m+[m[32m user_mod = True[m [32m+[m[32m else:[m [32m+[m[32m return 61, 'Not authorized'[m [32m+[m[32m flair_user = db.get_user(name=flair_user)[m [32m+[m[32m if not flair_user:[m [32m+[m[32m return 51, 'Not found'[m [32m+[m [32m+[m[32m page += f"# {flair_user.name}: Flairs\n"[m [32m+[m [32m+[m[32m # Action to add a new flair. This is a multi-step wizard.[m [32m+[m[32m if req.path.startswith(base_url + '/add/'):[m [32m+[m[32m found = re.search(r'/add/(\d+|-)?(/([\w-]+)(/(edit|[A-Za-z0-9]+))?)?$', req.path)[m [32m+[m[32m flair_type = found[1][m [32m+[m[32m subspace_name = found[3][m [32m+[m[32m subspace_urlpart = '/' + subspace_name if subspace_name else ''[m [32m+[m[32m action = found[5][m [32m+[m [32m+[m[32m if not flair_type or flair_type == '-':[m [32m+[m[32m page += '\nSelect type of flair to add:\n\n'[m [32m+[m[32m index = 0[m [32m+[m[32m for index, (icon, desc) in enumerate(Flair.TYPES):[m [32m+[m[32m if index != Flair.MODERATOR_NOTE or (user.role == User.ADMIN or user_mod):[m [32m+[m[32m page += f'=> {base_url}/add/{index}{subspace_urlpart} {space_separated(icon, desc)}\n'[m [32m+[m[32m index += 1[m [32m+[m[32m #page += f'=> {base_url}/add/note{subspace_urlpart} Custom note\n'[m [32m+[m[32m page += '\nCustom notes are only visible on post and user pages. The icons of other flairs are visible in feed entries, too.\n'[m [32m+[m[32m page += '\n=> /settings/flair Back to flairs\n'[m [32m+[m[32m return page[m [32m+[m [32m+[m[32m # Flair type is set but subspace may not be.[m [32m+[m[32m try:[m [32m+[m[32m subspace = None[m [32m+[m[32m is_global = False[m [32m+[m[32m sub_title = ''[m [32m+[m[32m if subspace_name:[m [32m+[m[32m if subspace_name == '-':[m [32m+[m[32m is_global = True[m [32m+[m[32m sub_title = ' in all subspaces'[m [32m+[m[32m if user_mod:[m [32m+[m[32m return 61, 'Not authorized'[m [32m+[m[32m else:[m [32m+[m[32m subspace = db.get_subspace(name=subspace_name)[m [32m+[m[32m sub_title = f' in {subspace.title()}'[m [32m+[m[32m if user_mod and subspace.id not in moderated_subs:[m [32m+[m[32m return 61, 'Not authorized'[m [32m+[m[32m icon, desc = Flair.TYPES[int(flair_type)][m [32m+[m[32m page += f'\n## {space_separated(icon, desc)}{sub_title}\n\n'[m [32m+[m [32m+[m[32m base_url = base_url + f'/add/{flair_type}'[m [32m+[m [32m+[m[32m # Select the subspace.[m [32m+[m[32m if not subspace and not is_global:[m [32m+[m[32m page += 'Select subspace where the flair is displayed:\n\n'[m [32m+[m[32m if int(flair_type) != Flair.CUSTOM_NOTE or user.role == User.ADMIN:[m [32m+[m[32m page += f'=> {base_url}/- All subspaces\n'[m [32m+[m[32m page += f'=> {base_url}/{user.name} {flair_user.avatar} u/{flair_user.name}\n\n'[m [32m+[m[32m for sub in db.get_subspaces(owner=0):[m [32m+[m[32m page += f'=> {base_url}/{sub.name} {sub.title()}\n'[m [32m+[m[32m page += '\n=> /settings/flair Back to flairs\n'[m [32m+[m[32m return page[m [32m+[m [32m+[m[32m base_url += f'/{subspace_name}'[m [32m+[m [32m+[m[32m if action == 'edit':[m [32m+[m[32m if is_empty_query(req):[m [32m+[m[32m return 10, TEXT_PROMPT[m [32m+[m[32m return 30, base_url + '?' + req.query[m [32m+[m[32m elif action and db.verify_token(user, action):[m [32m+[m[32m if int(flair_type) == Flair.MODERATOR_NOTE and user.role != User.ADMIN and not user_mod:[m [32m+[m[32m return 61, 'Not authorized'[m [32m+[m[32m db.add_flair(flair_user,[m [32m+[m[32m Flair(subspace.id if subspace else 0,[m [32m+[m[32m int(flair_type),[m [32m+[m[32m clean_query(req)[:TEXT_LIMIT],[m [32m+[m[32m is_admin_assigned=(user.role == User.ADMIN)),[m [32m+[m[32m user)[m [32m+[m[32m return 30, session.path + f'settings/flair/' + flair_user.name[m [32m+[m [32m+[m[32m # Specify the flair text.[m [32m+[m[32m page += '### Flair text\n'[m [32m+[m[32m text = clean_query(req)[m [32m+[m[32m page += '(not set)' if not text else text[m [32m+[m[32m page += f'\n=> {base_url}/edit ✏️ Edit\n\n'[m [32m+[m [32m+[m[32m if text:[m [32m+[m[32m page += f'=> {base_url}/{session.get_token()}?{req.query} ✔️ Add the flair\n\n'[m [32m+[m[32m page += '=> /settings/flair Cancel\n'[m [32m+[m[32m return page[m [32m+[m [32m+[m[32m except Exception as x:[m [32m+[m[32m import traceback[m [32m+[m[32m traceback.print_tb(x.__traceback__)[m [32m+[m[32m print(x)[m [32m+[m[32m return 59, 'Bad request'[m [32m+[m [32m+[m[32m flairs = User.parse_flair(flair_user.flair)[m [32m+[m [32m+[m[32m # Edit and remove actions.[m [32m+[m[32m found = re.search(r'settings/flair/[\w-]+/(edit|remove)/(\d+)(/([A-Za-z0-9]+))?$', req.path)[m [32m+[m[32m if found:[m [32m+[m[32m action = found[1][m [32m+[m[32m flair_index = int(found[2])[m [32m+[m[32m token = found[4][m [32m+[m [32m+[m[32m if token and not db.verify_token(user, token):[m [32m+[m[32m return 61, 'Expired'[m [32m+[m[32m if flair_index < 0 or flair_index >= len(flairs):[m [32m+[m[32m return 51, 'Not found'[m [32m+[m [32m+[m[32m target = flairs[flair_index][m [32m+[m [32m+[m[32m if target.is_admin_assigned and user.role != User.ADMIN:[m [32m+[m[32m return 61, 'Not authorized'[m [32m+[m[32m if target.type == Flair.MODERATOR_NOTE and (user.role != User.ADMIN and not user_mod):[m [32m+[m[32m return 61, 'Not authorized'[m [32m+[m [32m+[m[32m if action == 'remove':[m [32m+[m[32m db.remove_flair(flair_user, target, user)[m [32m+[m[32m elif action == 'edit':[m [32m+[m[32m if user_mod:[m [32m+[m[32m return 61, 'Not authorized'[m [32m+[m[32m if is_empty_query(req):[m [32m+[m[32m return 10, TEXT_PROMPT[m [32m+[m[32m target.label = clean_query(req)[:TEXT_LIMIT][m [32m+[m[32m db.add_flair(flair_user, target, user)[m [32m+[m [32m+[m[32m return 30, base_url[m [32m+[m [32m+[m[32m # Compose the flair list page.[m [32m+[m[32m if user.id == flair_user.id:[m [32m+[m[32m page += f'=> /settings/profile ⚙️ Back to Profile\n'[m [32m+[m[32m page += f'=> /u/{flair_user.name} {flair_user.avatar} Back to u/{flair_user.name}\n\n'[m [32m+[m[32m elif user.role == User.ADMIN:[m [32m+[m[32m page += '=> /admin/ Back to Administration\n'[m [32m+[m[32m page += f'=> /u/{flair_user.name} {flair_user.avatar} u/{flair_user.name}\n\n'[m [32m+[m[32m elif user_mod:[m [32m+[m[32m page += '\nModerators can only edit flairs related to the moderated subspaces:\n'[m [32m+[m[32m for sid in moderated_subs:[m [32m+[m[32m msub = db.get_subspace(id=sid)[m [32m+[m[32m if msub:[m [32m+[m[32m page += f'=> {session.path}{msub.title()} {msub.title()}\n'[m [32m+[m [32m+[m[32m if not user_mod:[m [32m+[m[32m page += 'Flairs are labels that you can choose to show next to your name. They let you personalize your appearance in specific subspaces, acting as a sort of "body language" that is otherwise missing in a text-based medium. The administrator may also assign flairs if necessary.\n'[m [32m+[m[32m page += f'\n=> {base_url}/add/ Add a flair\n'[m [32m+[m [32m+[m[32m if len(flairs):[m [32m+[m[32m index = 0[m [32m+[m[32m for flair in flairs:[m [32m+[m[32m if flair.scope:[m [32m+[m[32m sub = db.get_subspace(id=flair.scope)[m [32m+[m[32m subspace = sub.title() if sub else 'a deleted subspace'[m [32m+[m[32m if user_mod and flair.scope not in moderated_subs:[m [32m+[m[32m continue[m [32m+[m[32m else:[m [32m+[m[32m subspace = 'all subspaces'[m [32m+[m [32m+[m[32m page += '\n'[m [32m+[m[32m if flair.icon():[m [32m+[m[32m item = f'{flair.icon()} {flair.description()}: '[m [32m+[m[32m else:[m [32m+[m[32m item = '📛 '[m [32m+[m[32m item += f'{flair.label}'[m [32m+[m [32m+[m[32m if flair.is_admin_assigned and user.role != User.ADMIN:[m [32m+[m[32m page += f'{item}\n'[m [32m+[m[32m page += f'In: {subspace}\n'[m [32m+[m[32m page += '(assigned by administrator)\n'[m [32m+[m[32m else:[m [32m+[m[32m if not user_mod:[m [32m+[m[32m page += f'=> {base_url}/edit/{index} {item}\n'[m [32m+[m[32m else:[m [32m+[m[32m page += f'{item}\n'[m [32m+[m[32m page += f'In: {subspace}\n'[m [32m+[m[32m page += f'=> {base_url}/remove/{index}/{session.get_token()} ❌ Remove\n'[m [32m+[m[32m index += 1[m [32m+[m[32m else:[m [32m+[m[32m page += '\nYou have no flairs.\n'[m [32m+[m [32m+[m[32m return page[m [32m+[m [32m+[m def make_settings_page(session):[m req = session.req[m db = session.db[m [36m@@ -164,6 +374,9 @@[m [mdef make_settings_page(session):[m page += '\n' + note[m return page[m [m [32m+[m[32m elif req.path.startswith(session.path + 'settings/flair'):[m [32m+[m[32m return make_flair_composer_page(session)[m [32m+[m elif req.path == session.path + 'settings/feed-title':[m if req.query is None:[m return 10, f'Enter title for the u/{user.name} Gemini and Tinylog feeds:'[m [36m@@ -244,6 +457,7 @@[m [mdef make_settings_page(session):[m page += '\n## Profile\n\n'[m page += f'=> /settings/avatar/{token} Avatar: {session.user.avatar}\n'[m page += f'=> /settings/feed-title Feed title: {user_sub.info if user_sub.info else user.name}\n'[m [32m+[m[32m page += f'=> /settings/flair 📛 Flairs\n'[m [m page += '\n### Description\n'[m page += (session.user.info if session.user.info else '(no description)') + '\n'[m [36m@@ -305,6 +519,7 @@[m [mdef make_settings_page(session):[m Notification.ADDED_AS_MODERATOR: 'Added as moderator',[m Notification.REMOVED_AS_MODERATOR: 'Removed as moderator',[m Notification.REPORT: 'Reported content (moderator)',[m [32m+[m[32m Notification.USER_FLAIR_CHANGED: 'User changed flairs (moderator)',[m [m Notification.COMMENT_ON_COMMENTED: 'Reply in discussion',[m Notification.COMMENT_ON_FOLLOWED_POST: 'Comment in followed post',[m [1mdiff --git a/user.py b/user.py[m [1mindex 2a40f5f..4aaf416 100644[m [1m--- a/user.py[m [1m+++ b/user.py[m [36m@@ -109,7 +109,7 @@[m [mdef user_actions(session):[m if not post:[m return 51, 'Not found'[m if token and db.verify_token(user, token):[m [31m- db.notify_report(session.user, post)[m [32m+[m[32m db.notify_moderators(Notification.REPORT, session.user, post)[m return 30, post.page_url()[m subspace = db.get_subspace(id=post.subspace)[m kind = 'comment' if post.parent \[m [36m@@ -308,6 +308,8 @@[m [mdef user_actions(session):[m return 30, session.path + subs.title()[m notif_src = db.get_user(id=notif.src)[m if notif_src:[m [32m+[m[32m if notif.type == Notification.USER_FLAIR_CHANGED:[m [32m+[m[32m return 30, f'{session.path}settings/flair/{notif_src.name}'[m return 30, f'{session.path}u/{notif_src.name}'[m return 42, 'Invalid notification'[m [m
text/gemini; charset=utf-8
This content has been proxied by September (3851b).