From db2fb91b548ddb756f81d0ec7e7154632d5e183f Mon Sep 17 00:00:00 2001
From: Lennart Franken <lennart@franken-familie.de>
Date: Tue, 16 Aug 2022 21:22:56 +0200
Subject: [PATCH 1/2] Added notification & activity Log app

---
 code/app/database.py                         | 18 ++--
 code/app/sessionhelper.py                    |  5 +-
 code/apps/activityLog/code/mainapp.py        | 21 +++++
 code/apps/activityLog/code/render.py         | 18 ++++
 code/apps/activityLog/code/reutil.py         | 32 +++++++
 code/apps/activityLog/meta.json              |  9 ++
 code/apps/activityLog/static/activity.css    |  3 +
 code/apps/activityLog/translations/de.json   |  4 +
 code/apps/activityLog/translations/en.json   |  4 +
 code/apps/adminarea/code/mainapp.py          |  9 ++
 code/apps/adminarea/code/notification.py     | 10 +++
 code/apps/adminarea/translations/de.json     |  3 +
 code/apps/adminarea/translations/en.json     |  3 +
 code/apps/lists/code/mainapp.py              | 32 ++++++-
 code/apps/lists/code/notification.py         | 25 ++++++
 code/apps/lists/code/reutil.py               | 12 +--
 code/apps/lists/static/default.css           | 24 -----
 code/apps/lists/translations/de.json         | 21 ++++-
 code/apps/lists/translations/en.json         | 21 ++++-
 code/apps/mail/code/mail.py                  |  6 +-
 code/apps/mail/code/send.py                  | 14 ++-
 code/apps/mail/translations/de.json          |  3 +-
 code/apps/mail/translations/en.json          |  3 +-
 code/apps/notifications/code/mainapp.py      | 41 ++++++++-
 code/apps/notifications/code/render.py       | 44 +++++++++
 code/apps/notifications/code/reutil.py       | 95 ++++++++++++++++++--
 code/apps/notifications/static/default.css   |  9 ++
 code/apps/notifications/static/default.js    | 20 +++++
 code/apps/notifications/translations/de.json | 12 ++-
 code/apps/notifications/translations/en.json | 11 +++
 code/apps/profile/code/render.py             |  6 +-
 code/apps/root/meta.json                     |  2 +-
 code/apps/root/static/default.css            | 28 +++++-
 33 files changed, 503 insertions(+), 65 deletions(-)
 create mode 100644 code/apps/activityLog/code/mainapp.py
 create mode 100644 code/apps/activityLog/code/render.py
 create mode 100644 code/apps/activityLog/code/reutil.py
 create mode 100644 code/apps/activityLog/meta.json
 create mode 100644 code/apps/activityLog/static/activity.css
 create mode 100644 code/apps/activityLog/translations/de.json
 create mode 100644 code/apps/activityLog/translations/en.json
 create mode 100644 code/apps/adminarea/code/notification.py
 create mode 100644 code/apps/lists/code/notification.py
 create mode 100644 code/apps/notifications/code/render.py
 create mode 100644 code/apps/notifications/static/default.css
 create mode 100644 code/apps/notifications/static/default.js
 create mode 100644 code/apps/notifications/translations/en.json

diff --git a/code/app/database.py b/code/app/database.py
index eead4bc..1dcf4ef 100644
--- a/code/app/database.py
+++ b/code/app/database.py
@@ -4,6 +4,8 @@ import util, errors, config, event
 
 import sessionhelper as r
 
+notification = util.import_app_module("adminarea", "notification")
+
 class database:
     def __init__(self, datafolder, scriptpath):
         self.datafolder = datafolder+"/"
@@ -68,11 +70,11 @@ class database:
         self.logToUsermessage(userid, category, message)
 
     def logToUsermessage(self, userid, category, message):
-        if "adminarea" in self.apps:
-            for user_id in self.users:
-                user = self.users[user_id]
-                if user.appPermissions.get("adminarea") == "admin":
-                    user.addUsermessage(ntcenter={"content":r.link(visibleas=f"[{category}] {r.username(self, userid)}: ", linkto="/adminarea/log")+message[:300], "classes":["logusermessage"]})
+        for user_id in self.users:
+            user = self.users[user_id]
+            if user.appPermissions.get("adminarea") == "admin":
+                notification.triggerNewLogEntrie(user, self, userid, category, message)
+
     def logSave(self):
         util.write(self.logfile, self.logs)
 
@@ -226,11 +228,13 @@ class User:
     def log(self, category, message):
         self.database.log(userid=self.id, category=category, message=message)
 
-    def addUsermessage(self, event=None, ntcenter=None, email=None):
+    def addUsermessage(self, ntcenter=None, email=None, activityLog=None):
         id = None
         if ntcenter: # notification center
             id = util.generate_token()
             self.usermessages[id] = ntcenter
         if email and "mail" in self.database.enabledApps():
-            self.database.apps["mail"].code.send.sendEmail(self, email["subject"], email["content"], email.get("overwriteVerify") == True)
+            self.database.apps["mail"].code.send.sendEmail(self, email["subject"], email["content"], email.get("overwriteVerify") == True, pack=email.get("pack")!=False, linkto = email.get("linkto"), blockFurtherMessages=email.get("blockFurtherMessages"))
+        if activityLog:
+            self.activity.push(activityLog["appid"], activityLog["eventname"], activityLog["message"], linkto=activityLog.get("linkto"))
         return(id)
diff --git a/code/app/sessionhelper.py b/code/app/sessionhelper.py
index 054a080..a8ea5ee 100644
--- a/code/app/sessionhelper.py
+++ b/code/app/sessionhelper.py
@@ -66,6 +66,9 @@ def button(visibleas="", name=None, value=None, id=None, onclick=None, classes=[
         connectedInputFieldScript = ""
     return(f"""<button {_createattibutesstr(attributes)}>{visibleas}</button>{connectedInputFieldScript}""")
 
+def createcheckboxButton(checked = False, onchange="", otherattribues=[], name="", id=None):
+    return(button(nobutton = True, visibleas = ".", classes = ["checkbox"] + (["checked"] if checked else []), onclick=onchange, otherattribues=otherattribues, name=name, id=id))
+
 def link(visibleas="", classes=[], style=None, linkto=None, isbutton=False, pressable=True, target=None):
     if isbutton:
         if pressable:
@@ -240,7 +243,7 @@ def createtable(headers=[], rows=[], classes=[], id=None):
             if type(item) == str:
                 row_html += f"<td>{item}</td>"
             else:
-                row_html += f"<td {_createclassesstr(item['classes'])}>{item['str']}</td>"
+                row_html += f"<td {_createclassesstr(item['classes'])} colspan = '{item['colspan'] if 'colspan' in item else 1}'>{item['str']}</td>"
         row_html += "</tr>"
         rows_html += row_html
     return(f"<table {_createclassesstr(classes)} {('id = ' + id) if id else ''}>{headers_html}{rows_html}</table>")
diff --git a/code/apps/activityLog/code/mainapp.py b/code/apps/activityLog/code/mainapp.py
new file mode 100644
index 0000000..73202bb
--- /dev/null
+++ b/code/apps/activityLog/code/mainapp.py
@@ -0,0 +1,21 @@
+import util
+
+reutil = util.import_app_module("activityLog", "reutil")
+render = util.import_app_module("activityLog", "render")
+
+def init(data):
+    for userid in data.users:
+        user = data.getUser(id=userid)
+        user.activity = reutil.activity(user)
+
+def onNewuser(user):
+    user.activity = reutil.activity(user)
+
+def activityLog(user, data, path, modules):
+    return(render.renderActivity(user))
+
+def activityLogPacked(user, data, path, modules):
+    return({"type":"packed", "content":activityLog(user, data, path, modules), "css":["activityLog/activity.css"]})
+
+
+redirects={"/activityLog":{"directto":activityLogPacked, "type":"nosession", "require_login":True}}
diff --git a/code/apps/activityLog/code/render.py b/code/apps/activityLog/code/render.py
new file mode 100644
index 0000000..97ffbad
--- /dev/null
+++ b/code/apps/activityLog/code/render.py
@@ -0,0 +1,18 @@
+import sessionhelper as r
+
+def renderActivity(user):
+    result = r.heading(3, "{translation:activityLog/title}")
+    if user.activity.getEntries():
+        for entrie in user.activity.getEntries()[::-1]:
+            result += renderEntrie(user, entrie)
+    else:
+        result += r.center(r.p("{translation:activityLog/noEntriesYet}"))
+    result += r.center(r.link(visibleas="{translation:notifications/changeNotifications}", linkto="/notifications", isbutton=True))
+    return(result)
+
+def renderEntrie(user, entrie):
+    fieldset = r.createfieldset(
+    legend="{translation:"+entrie["app"]+"/title}: " + entrie["eventname"],
+    content=r.p(r.date(unix=entrie["time"]), tag="div", classes=["floatDate"]) + r.p(entrie["message"], tag="div")
+    )
+    return(r.link(visibleas=fieldset, linkto=entrie["linkto"], classes=["nolink"]) if entrie["linkto"] else fieldset)
diff --git a/code/apps/activityLog/code/reutil.py b/code/apps/activityLog/code/reutil.py
new file mode 100644
index 0000000..ae38903
--- /dev/null
+++ b/code/apps/activityLog/code/reutil.py
@@ -0,0 +1,32 @@
+import util, time
+
+class activity:
+    def __init__(self, user):
+        self.user = user
+        self.data = self.user.database
+        self.logpath = self.data.apps["activityLog"].userdatapath(user=user)+"log.json"
+
+        try:
+            self.entries = util.read(self.logpath, importjson=True)
+        except FileNotFoundError:
+            self.entries = []
+            self.saveEntries()
+
+    def saveEntries(self):
+        util.write(self.logpath, self.entries)
+
+    def push(self, appid, eventname, message, linkto=None):
+        self.entries.append({
+        "app":appid,
+        "time":time.time(),
+        "message":message,
+        "eventname":eventname,
+        "linkto":linkto
+        })
+        self.saveEntries()
+
+    def getEntries(self):
+        while self.entries and self.entries[0]["time"] + 86400*7 < time.time():
+            self.entries.pop(0)
+        self.saveEntries()
+        return(self.entries)
diff --git a/code/apps/activityLog/meta.json b/code/apps/activityLog/meta.json
new file mode 100644
index 0000000..afad55a
--- /dev/null
+++ b/code/apps/activityLog/meta.json
@@ -0,0 +1,9 @@
+{
+  "version":"0.0.0",
+  "id":"activityLog",
+  "showinheader":[
+    ["{translation:activityLog/title}", "/activityLog", -9]
+  ],
+  "showinfooter":[],
+  "showapprights":false
+}
diff --git a/code/apps/activityLog/static/activity.css b/code/apps/activityLog/static/activity.css
new file mode 100644
index 0000000..49fa6c7
--- /dev/null
+++ b/code/apps/activityLog/static/activity.css
@@ -0,0 +1,3 @@
+.floatDate{
+  float: right;
+}
diff --git a/code/apps/activityLog/translations/de.json b/code/apps/activityLog/translations/de.json
new file mode 100644
index 0000000..9c3ef0e
--- /dev/null
+++ b/code/apps/activityLog/translations/de.json
@@ -0,0 +1,4 @@
+{
+  "title":"Aktivität",
+  "noEntriesYet":"Bisher keine Benachrichtungen"
+}
diff --git a/code/apps/activityLog/translations/en.json b/code/apps/activityLog/translations/en.json
new file mode 100644
index 0000000..bc7378c
--- /dev/null
+++ b/code/apps/activityLog/translations/en.json
@@ -0,0 +1,4 @@
+{
+  "title":"Activity",
+  "noEntriesYet":"No recent notifications"
+}
diff --git a/code/apps/adminarea/code/mainapp.py b/code/apps/adminarea/code/mainapp.py
index c6beb2b..a216bf7 100644
--- a/code/apps/adminarea/code/mainapp.py
+++ b/code/apps/adminarea/code/mainapp.py
@@ -6,6 +6,15 @@ import sessionhelper as r
 api = util.import_app_module("adminarea", "api")
 reutil = util.import_app_module("adminarea", "reutil")
 
+notify = util.import_app_module("notifications", "reutil")
+
+def getNotificicationEvents(user):
+    result = []
+    if user.appPermissions["adminarea"] == "admin":
+        result.append(notify.notificationEvent(user, "adminarea", "newLogEntrie"))
+    return(result)
+
+
 def restart_server():
     threading.Thread(target=restart_server_run).start()
 def restart_server_run():
diff --git a/code/apps/adminarea/code/notification.py b/code/apps/adminarea/code/notification.py
new file mode 100644
index 0000000..c9c7318
--- /dev/null
+++ b/code/apps/adminarea/code/notification.py
@@ -0,0 +1,10 @@
+import sessionhelper as r
+import util
+
+notify = util.import_app_module("notifications", "reutil")
+
+def triggerNewLogEntrie(user, data, userid, category, message):
+    ntcenter = {"content":r.link(visibleas=f"[{category}] {r.username(data, userid)}: ", linkto="/adminarea/log")+message[:300], "classes":["logusermessage"]}
+    activityLog = r.p(f"[{category}] {r.username(data, userid)}:", tag="span", style="font-weight: bold")+r.br+message
+    email = {"subject":"{translation:adminarea/notificationEvents/newLogEntrie}", "content":r.link(visibleas=f"[{category}] {r.username(data, userid)}: ", linkto=data.meta["domain"]+"/adminarea/log")+r.br+message}
+    notify.triggerEvent(user=user, appid="adminarea", eventid = "newLogEntrie", ntcenter=ntcenter, email=email, activityLog=activityLog, linkto="/adminarea/log/"+user.database.logfile.split("/")[-1])
diff --git a/code/apps/adminarea/translations/de.json b/code/apps/adminarea/translations/de.json
index cd15684..0df7fda 100644
--- a/code/apps/adminarea/translations/de.json
+++ b/code/apps/adminarea/translations/de.json
@@ -113,5 +113,8 @@
     "logs":"Logs",
     "path":"Speicherort",
     "linkdomain":"Adresse für Links (z.B. http://localhost:8080)"
+  },
+  "notificationEvents":{
+    "newLogEntrie":"Neuer Log Eintrag"
   }
 }
diff --git a/code/apps/adminarea/translations/en.json b/code/apps/adminarea/translations/en.json
index a6b9435..d9f1e4e 100644
--- a/code/apps/adminarea/translations/en.json
+++ b/code/apps/adminarea/translations/en.json
@@ -113,5 +113,8 @@
     "logs":"Logs",
     "path":"Storage location",
     "linkdomain":"Address for links (e.g. http://localhost:8080)"
+  },
+  "notificationEvents":{
+    "newLogEntrie":"New log entrie"
   }
 }
diff --git a/code/apps/lists/code/mainapp.py b/code/apps/lists/code/mainapp.py
index 19f58b9..2172f75 100644
--- a/code/apps/lists/code/mainapp.py
+++ b/code/apps/lists/code/mainapp.py
@@ -5,6 +5,23 @@ from flask import request
 reutil = util.import_app_module("lists", "reutil")
 render = util.import_app_module("lists", "render")
 latex = util.import_app_module("lists", "latex")
+notification = util.import_app_module("lists", "notification")
+
+notify = util.import_app_module("notifications", "reutil")
+
+
+def getNotificicationEvents(user):
+    events = [
+        notify.notificationEvent(user, "lists", "newListShared"),
+        notify.notificationEvent(user, "lists", "listDeleted")
+    ]
+    listChanged, groups = [], []
+    for list_obj_id in reutil.getListsWithAccess(user.database, user.id):
+        list_obj = reutil.checklist(list_obj_id, user.database)
+        listChanged.append(notify.notificationEvent(user, "lists", "listChanged?"+list_obj.fullid, list_obj.meta["name"], settingsfile=list_obj.path+"notify/"+user.id+".json", timeBlocked=600))
+    if listChanged:
+        events.append(notify.notificationEventGroup(user, "lists", "listChanged", events=listChanged))
+    return(events)
 
 def backtostart():
     return(r.unpackedRedirect("/lists"))
@@ -90,8 +107,9 @@ def lists(user, data, path, modules):
         if "deleteList" in request.form:
             list_obj = getList(data, user, request.form.get("deleteList"))
             if list_obj:
-                shutil.rmtree(list_obj.path)
+                notification.triggerListDeleted(user, list_obj)
                 user.addUsermessage(ntcenter={"content":"{translation:lists/usermessages/deletedList;name=%s}" % (list_obj.meta["name"])})
+                shutil.rmtree(list_obj.path)
                 return(r.unpackedRedirect("/lists"))
 
         if "saveDescription" in path:
@@ -99,6 +117,7 @@ def lists(user, data, path, modules):
             htmlid, id = json_data["id"].split("/", 1)
             list_obj = getList(data, user, id)
             if list_obj:
+                notification.triggerListEdited(user, list_obj)
                 list_obj.meta["description"] = r.tohtml(r.indentMobileMarkdown(str(json_data.get("newtext"))))
                 list_obj.saveMeta()
             return({"type":"unpacked", "content":"success"})
@@ -107,6 +126,7 @@ def lists(user, data, path, modules):
             userid, listid, newName = request.form.get("renameList").split("/")
             list_obj = getList(data, user, f"{userid}/{listid}")
             if list_obj:
+                notification.triggerListEdited(user, list_obj)
                 list_obj.meta["name"] = r.tohtml(newName)
                 list_obj.saveMeta()
             return(r.unpackedRedirect(list_obj.linkto))
@@ -121,6 +141,7 @@ def lists(user, data, path, modules):
                         user.addUsermessage(ntcenter={"content":"{translation:lists/usermessages/releaseAlreadyShared;name=%s}" % (newUser.username), "classes":["warning"], "requireRemove":True})
                     else:
                         list_obj.addSharing(newUser)
+                        notification.triggerNewRelease(newUser, list_obj, user)
                 else:
                     user.addUsermessage(ntcenter={"content":"{translation:lists/usermessages/releaseUserNotExists;name=%s}" % (r.tohtml(newUser_name)), "classes":["warning"], "requireRemove":True})
                 return({"type":"unpacked", "content":render.renderShared(data, user, list_obj), "translate":True})
@@ -148,6 +169,7 @@ def lists(user, data, path, modules):
                         if fullid in list_obj.getAppendedLists() or fullid in list_obj.getAppendedLists(direction="to"):
                             user.addUsermessage(ntcenter={"content":"{translation:lists/usermessages/alreadyAppended}", "requireRemove":True, "classes":["warning"]})
                         else:
+                            notification.triggerListEdited(user, list_obj)
                             list_obj.appendList(fullid)
                         break
                 if not found:
@@ -160,6 +182,7 @@ def lists(user, data, path, modules):
             list_obj2 = getList(data, user, f"{removeuserid}/{removelistid}")
             if list_obj and list_obj2:
                 if list_obj2.fullid in list_obj.getAppendedLists(recursive=False):
+                    notification.triggerListEdited(user, list_obj)
                     list_obj.removeAppend(list_obj2.fullid)
                     user.addUsermessage(ntcenter={"content":"{translation:lists/usermessages/removedAppend}"})
                 return({"type":"unpacked", "content":render.renderAppend(data, user, list_obj), "translate":True})
@@ -172,6 +195,7 @@ def lists(user, data, path, modules):
                 while newItem.startswith(" "):
                     indent += 1
                     newItem = newItem.replace(" ", "", 1)
+                notification.triggerListEdited(user, list_obj)
                 list_obj.addEntrie(newItem, indent)
                 return({"content":render.renderItems(data, user, list_obj), "type":"unpacked", "translate":True})
 
@@ -179,6 +203,7 @@ def lists(user, data, path, modules):
             userid, listid, entrieid, viewuserid, viewlistid, checked_str = ajaxdata(5)
             list_obj, entrie = getEntrie(data, user, f"{userid}/{listid}/{entrieid}")
             if entrie:
+                notification.triggerListEdited(user, list_obj)
                 entrie.checked = checked_str == "True"
                 entrie.save()
             else:
@@ -189,6 +214,7 @@ def lists(user, data, path, modules):
             userid, listid, entrieid, viewuserid, viewlistid = ajaxdata(4)
             list_obj, entrie = getEntrie(data, user, f"{userid}/{listid}/{entrieid}")
             if entrie:
+                notification.triggerListEdited(user, list_obj)
                 entrie.delete()
             else:
                 entrieNotFound(user)
@@ -199,6 +225,7 @@ def lists(user, data, path, modules):
             listitem, userid, listid, entrieid = json_data["id"].split("/")
             list_obj, entrie = getEntrie(data, user, f"{userid}/{listid}/{entrieid}")
             if entrie:
+                notification.triggerListEdited(user, list_obj)
                 entrie.value = r.tohtml(json_data["newtext"])
                 entrie.save()
                 return({"content":"success", "type":"unpacked", "translate":False})
@@ -219,6 +246,8 @@ def lists(user, data, path, modules):
                 error = list_obj.clearChecked(user)
                 if error:
                     entrieNotFound(user)
+                else:
+                    notification.triggerListEdited(user, list_obj)
                 return({"content":render.renderItems(data, user, list_obj), "type":"unpacked", "translate":True})
 
         if "setarchived" in path:
@@ -234,6 +263,7 @@ def lists(user, data, path, modules):
             targetlist, targetentrie = getEntrie(data, user, f"{targetuserid}/{targetlistid}/{targetentrieid}")
             sourcelist, sourceentrie = getEntrie(data, user, f"{sourceuserid}/{sourcelistid}/{sourceentrieid}")
             if targetlist.fullid == sourcelist.fullid and targetentrie and sourceentrie:
+                notification.triggerListEdited(user, list_obj)
                 if targetentrie.id == sourceentrie.id:
                     if indent in ["right", "left"]:
                         targetlist.setIndent(targetentrie, targetentrie.indent + (-1 if indent == "left" else 1))
diff --git a/code/apps/lists/code/notification.py b/code/apps/lists/code/notification.py
new file mode 100644
index 0000000..2242336
--- /dev/null
+++ b/code/apps/lists/code/notification.py
@@ -0,0 +1,25 @@
+import util
+notify = util.import_app_module("notifications", "reutil")
+
+def triggerNewRelease(user, list_obj, userShared):
+    text = "{translation:lists/notifications/newrelease/text;listname=%s;userSharedName=%s}" % (list_obj.meta["name"], userShared.username)
+    ntcenter = {"content":text}
+    activityLog = text
+    email = {"subject":"{translation:lists/notifications/newrelease/subject}", "content":text}
+    notify.triggerEvent(user=user, appid="lists", eventid = "newListShared", ntcenter=ntcenter, email=email, activityLog=activityLog, linkto=user.database.getDomain()+list_obj.linkto)
+
+def triggerListDeleted(userActing, list_obj):
+    text = "{translation:lists/notifications/listDeleted/text;listname=%s;userActing=%s}" % (list_obj.meta["name"], userActing.username)
+    ntcenter = {"content":text}
+    activityLog = text
+    email = {"subject":"{translation:lists/notifications/listDeleted/subject}", "content":text}
+    for user in notify.filterOutUser(userActing, list_obj.getUsersWithAccess()):
+        notify.triggerEvent(user=user, appid="lists", eventid = "listDeleted", ntcenter=ntcenter, email=email, activityLog=activityLog)
+
+def triggerListEdited(userActing, list_obj):
+    text = "{translation:lists/notifications/listEdited/text;listname=%s;userActing=%s}" % (list_obj.meta["name"], userActing.username)
+    ntcenter = {"content":text}
+    activityLog = text
+    email = {"subject":"{translation:lists/notifications/listEdited/subject;listname=%s}" % list_obj.meta["name"], "content":text}
+    for user in notify.filterOutUser(userActing, list_obj.getUsersWithAccess()):
+        notify.triggerEvent(user=user, appid="lists", eventid = "listChanged?"+list_obj.fullid, ntcenter=ntcenter, email=email, activityLog=activityLog)
diff --git a/code/apps/lists/code/reutil.py b/code/apps/lists/code/reutil.py
index 9556b0f..768ccba 100644
--- a/code/apps/lists/code/reutil.py
+++ b/code/apps/lists/code/reutil.py
@@ -90,6 +90,9 @@ class checklist:
             return(shares["toothers"][self.id])
         return([])
 
+    def getUsersWithAccess(self):
+        return (self.getSharedTo()+[self.userid])
+
     def addSharing(self, newUser):
         shares = getShared(self.data, self.userid)
         if not self.id in shares["toothers"]:
@@ -219,10 +222,10 @@ class entrie:
     def render(self, viewfullid, checked):
         return(r.p(
         tag="div",
-        content=createcheckboxButton(checked=self.checked, onchange=f"return checkitem('{not self.checked}', this.name)", name=f"{self.fullid}/{viewfullid}", otherattribues=["data-indent = 'left' "]) +
+        content=r.createcheckboxButton(checked=self.checked, onchange=f"return checkitem('{not self.checked}', this.name)", name=f"{self.fullid}/{viewfullid}", otherattribues=["data-indent = 'left' "]) +
         createReorder() +
         r.p(tag="div", content=self.value, classes=["editItem", "autosaveTextarea", "autosaveData", "inputFieldPreventSubmit"], id=f"listitem/{self.fullid}", otherattribues=["role='textbox'", "contenteditable= 'true'", "data-address='lists/editItem'", "data-indent='right'"]) +
-        r.button(visibleas="", classes=["deleteEntrieButton"], nobutton=True, confirm="{translation:lists/list/items/confirmdelete;name=%s}" % r.clearForTranslation(self.value), onclick=f"return deleteitem('{self.fullid}/{viewfullid}')"),
+        r.button(visibleas="", classes=["deleteEntrieButton", "backgroudImageButton"], nobutton=True, confirm="{translation:lists/list/items/confirmdelete;name=%s}" % r.clearForTranslation(self.value), onclick=f"return deleteitem('{self.fullid}/{viewfullid}')"),
         classes=["item"] + (["checked"] if (self.checked or checked) else []),
         style=f"margin-left: {self.indent}em",
         id = util.generate_token()
@@ -244,8 +247,5 @@ class entrie:
     def __repr__(self):
         return(f"{self.value}/{self.checked}")
 
-def createcheckboxButton(checked = False, onchange="", otherattribues=[], name=""):
-    return(r.button(nobutton = True, visibleas = ".", classes = ["checkbox"] + (["checked"] if checked else []), onclick=onchange, otherattribues=otherattribues, name=name))
-
 def createReorder():
-    return(r.button(nobutton=True, visibleas="." ,classes = ["reorder"], onclick="return false", otherattribues=["draggable = 'true' "]))
+    return(r.button(nobutton=True, visibleas="." ,classes = ["reorder", "backgroudImageButton"], onclick="return false", otherattribues=["draggable = 'true' "]))
diff --git a/code/apps/lists/static/default.css b/code/apps/lists/static/default.css
index 6bcae65..cbf8b98 100644
--- a/code/apps/lists/static/default.css
+++ b/code/apps/lists/static/default.css
@@ -37,36 +37,12 @@ div.item.checked div{
   text-decoration: line-through;
 }
 
-.checkbox, .deleteEntrieButton, .reorder{
-  height: 1em;
-  min-width: 0em;
-  min-height: 0em;
-  width: 1em;
-  background-size: contain;
-  background-repeat: no-repeat;
-  background-position: center;
-  display: inline-block;
-  color: rgba(0,0,0,0)
-}
-
 .reorder{
   background-image: url("/static/startid/root/drag.png");
   position: relative;
   top: 0.15em;
   margin-right: 0.5em;
 }
-.checkbox{
-  border: 0.1em solid black;
-  border-radius: 0.4em;
-  background-color: rgb(200,200,200);
-  margin-right: 0.5em;
-  position: relative;
-  top: 0.25em;
-}
-.checkbox.checked{
-  background-color: blue;
-  background-image: url("/static/startid/root/checkbox.png");
-}
 
 .item .deleteEntrieButton{
   margin-left: 0.5em;
diff --git a/code/apps/lists/translations/de.json b/code/apps/lists/translations/de.json
index cde19d6..1d65bb3 100644
--- a/code/apps/lists/translations/de.json
+++ b/code/apps/lists/translations/de.json
@@ -76,5 +76,24 @@
   "export":{
     "state":"Stand: {date}"
   },
-  "unusedNameToCreateNew":"Um eine neue Liste zu erstellen, gib einfach einen nicht vergebenen Namen ein."
+  "unusedNameToCreateNew":"Um eine neue Liste zu erstellen, gib einfach einen nicht vergebenen Namen ein.",
+  "notificationEvents":{
+    "newListShared":"Liste wird mit dir geteilt",
+    "listDeleted":"Liste wird gelöscht",
+    "listChanged":"Liste wird bearbeitet"
+  },
+  "notifications":{
+    "newrelease":{
+      "text":"{userSharedName} hat die Liste {listname} mit dir geteilt.",
+      "subject":"Eine Liste wurde mit dir geteilt"
+    },
+    "listDeleted":{
+      "text":"{userActing} hat die Liste {listname} gelöscht.",
+      "subject":"Eine Liste wurde gelöscht"
+    },
+    "listEdited":{
+      "text":"{userActing} hat die Liste {listname} geändert.",
+      "subject":"{listname} wurde geändert"
+    }
+  }
 }
diff --git a/code/apps/lists/translations/en.json b/code/apps/lists/translations/en.json
index be73cea..f303ecd 100644
--- a/code/apps/lists/translations/en.json
+++ b/code/apps/lists/translations/en.json
@@ -76,5 +76,24 @@
   "export":{
     "state":"State: {date}"
   },
-  "unusedNameToCreateNew":"To create a new list, simply enter an unused name."
+  "unusedNameToCreateNew":"To create a new list, simply enter an unused name.",
+  "notificationEvents":{
+    "newListShared":"List is shared with you",
+    "listDeleted":"Liste is deleted",
+    "listChanged":"Liste is edited"
+  },
+  "notifications":{
+    "newrelease":{
+      "text":"{userSharedName} has shared {listname} with you.",
+      "subject":"A list has been shared with you"
+    },
+    "listDeleted":{
+      "text":"{userActing} deleted {listname}",
+      "subject":"A list has been deleted"
+    },
+    "listEdited":{
+      "text":"{userActing} has changed {listname}",
+      "subject":"{listname} has been changed"
+    }
+  }
 }
diff --git a/code/apps/mail/code/mail.py b/code/apps/mail/code/mail.py
index de4aa2d..644778b 100644
--- a/code/apps/mail/code/mail.py
+++ b/code/apps/mail/code/mail.py
@@ -1,13 +1,11 @@
 import sessionhelper as r
 
 def VerificationEmail(user):
-    result = r.p("{translation:mail/hello;name=%s}" % user.username)
     link = user.database.getDomain()+"/verifyEmail/"+user.emailAddress["tokenToVerify"]
-    result += r.p("{translation:mail/newEmail/verificationEmailLink}: " + r.link(visibleas = link, linkto = link))
+    result = r.p("{translation:mail/newEmail/verificationEmailLink}: " + r.link(visibleas = link, linkto = link))
     return({"subject":"{translation:mail/newEmail/verificationEmailSubject}", "content":result, "overwriteVerify":True})
 
 def ResetPassword(user):
-    result = r.p("{translation:mail/hello;name=%s}" % user.username)
     link = user.database.getDomain()+"/resetPassword/"+user.id + "/" + user.passwordResetToken
-    result += r.p("{translation:mail/resetPassword/emailContent}: " + r.link(visibleas = link, linkto = link))
+    result = r.p("{translation:mail/resetPassword/emailContent}: " + r.link(visibleas = link, linkto = link))
     return({"subject":"{translation:mail/resetPassword/emailSubject}", "content":result})
diff --git a/code/apps/mail/code/send.py b/code/apps/mail/code/send.py
index 8400c0e..38963c3 100644
--- a/code/apps/mail/code/send.py
+++ b/code/apps/mail/code/send.py
@@ -8,10 +8,10 @@ from threading import Thread
 
 reutil = util.import_app_module("mail", "reutil")
 
-def sendEmail(user, subject, content, overwriteVerify=False):
-    Thread(target=sendEmailSend, args = (user, subject, content, overwriteVerify)).start()
+def sendEmail(user, subject, content, overwriteVerify=False, pack=True, linkto=None, blockFurtherMessages=False):
+    Thread(target=sendEmailSend, args = (user, subject, content, overwriteVerify, pack, linkto, blockFurtherMessages)).start()
 
-def sendEmailSend(user, subject, content, overwriteVerify):
+def sendEmailSend(user, subject, content, overwriteVerify, pack, linkto, blockFurtherMessages):
     if (user.emailAddress["verified"] or overwriteVerify) and user.emailAddress["address"]:
         try:
             setupData = reutil.getSetupData(user.database)
@@ -19,7 +19,13 @@ def sendEmailSend(user, subject, content, overwriteVerify):
             email_message.add_header('To', user.emailAddress["address"])
             email_message.add_header('From', f"{setupData['name']} <{setupData['emailAddress']}>")
             email_message.add_header('Subject', translations.translate(subject, user.lang))
-            html = f"<html><body>{translations.translate(content, user.lang)}{r.p(setupData['name']) if setupData['name'] else ''}</body></html>"
+            if pack:
+                content = r.p("{translation:mail/hello;name=%s}" % user.username) + content + (r.p(setupData['name']) if setupData['name'] else '')
+                if linkto:
+                    content += r.p("{translation:mail/directLink}: "+r.link(linkto, linkto=linkto))
+                if blockFurtherMessages:
+                    content += r.p("{translation:mail/blockFurtherMessages}")
+            html = f"<html><body>{translations.translate(content, user.lang)}</body></html>"
             email_message.attach(MIMEText(util.htmlToText(html.replace("</p>", "</p>\n")), 'plain'))
             email_message.attach(MIMEText(html, 'html'))
 
diff --git a/code/apps/mail/translations/de.json b/code/apps/mail/translations/de.json
index d79e192..7c2223c 100644
--- a/code/apps/mail/translations/de.json
+++ b/code/apps/mail/translations/de.json
@@ -26,7 +26,8 @@
     "emailVerified":"Deine Email Adresse wurde verifiziert."
   },
   "hello":"Hallo {name},",
-  "changeNotifications":"Benachrichtigungen verwalten",
+  "directLink":"Direkter Link",
+  "blockFurtherMessages":"Diese Nachricht wird nicht für jede einzelne Änderung verschickt, sondern nur für die erste in einem gewissen Zeitraum.",
   "setup":{
     "host":"Host",
     "port":"Port",
diff --git a/code/apps/mail/translations/en.json b/code/apps/mail/translations/en.json
index 8e42bb7..2a16198 100644
--- a/code/apps/mail/translations/en.json
+++ b/code/apps/mail/translations/en.json
@@ -26,7 +26,8 @@
     "emailVerified":"Your email address is not verified."
   },
   "hello":"Hello {name},",
-  "changeNotifications":"Manage notifications",
+  "directLink":"Direct link",
+  "blockFurtherMessages":"This message is not sent for every single change, but only for the first one in a certain period of time.",
   "setup":{
     "host":"Host",
     "port":"Port",
diff --git a/code/apps/notifications/code/mainapp.py b/code/apps/notifications/code/mainapp.py
index 2630ec0..b134ae9 100644
--- a/code/apps/notifications/code/mainapp.py
+++ b/code/apps/notifications/code/mainapp.py
@@ -1,8 +1,43 @@
+import util
+
+from flask import request
+
+reutil = util.import_app_module("notifications", "reutil")
+render = util.import_app_module("notifications", "render")
+
 def notifications(user, data, path, modules):
-    return({"type":"packed", "content":"Not Yet"})
 
+    loadscripts = ["root/ajax.js", "notifications/default.js"]
+    css = ["notifications/default.css"]
+    scripts = []
+    content = ""
+
+    events = reutil.getEventsByUser(user)
+
+    if request.method == "GET":
+        if path == []:
+            content += render.renderAppOverview(events)
+        elif len(path) == 1 and path[0] in events:
+            content += render.renderEventsApp(user, path[0], events[path[0]])
+
+        return({"type":"packed", "content":content, "loadscripts":loadscripts, "css":css, "scripts":scripts})
+
+    elif request.method == "POST":
+        if "setNotifyEvent" in path:
+            appid, groupid, setting, value, eventid = util.ajaxdata(4)
+            groupid = groupid if groupid != "null" else None
+            newvalue = value == "false"
+            eventsToScan = events[appid]["events"]
+            if groupid:
+                for group in events[appid]["groups"]:
+                    if group.id == groupid:
+                        eventsToScan = group.events
+            for event in eventsToScan:
+                if event.id == eventid and setting in ["push", "activityLog", "email"]:
+                    settings = event.getSettings()
+                    settings[setting] = newvalue
+                    event.saveSettings(settings)
+                    return({"type":"unpacked","content":render.renderCheckbox(settings, event, setting)})
 
-def triggerNotification(user, eventname, ntcenter=None, email=None):
-    pass
 
 redirects={"/notifications":{"directto":notifications, "type":"nosession", "require_login":True}}
diff --git a/code/apps/notifications/code/render.py b/code/apps/notifications/code/render.py
new file mode 100644
index 0000000..6d58d02
--- /dev/null
+++ b/code/apps/notifications/code/render.py
@@ -0,0 +1,44 @@
+import sessionhelper as r
+import util
+
+def renderAppOverview(events):
+    result = ""
+    for appid in events:
+        result += r.link("{translation:%s/title}" % appid, linkto="/notifications/"+appid, isbutton=True, classes=["widebutton"])
+    return(r.createfieldset(legend=r.heading(3, "{translation:notifications/changeNotifications}"), content=result))
+
+def renderEventsApp(user, appid, appEvents):
+    result = r.link("{translation:notifications/appEvents/backtoOverview}", linkto="/notifications", isbutton=True)
+
+    result += r.heading(3, "{translation:%s/title}-{translation:notifications/title}" % (appid))
+
+    rows = []
+    for item in appEvents["events"]:
+        rows.append(renderEventTableRow(item))
+
+    for group in appEvents["groups"]:
+        rows.append([{"str":group.getName(), "classes":["groupHeading"], "colspan":4}])
+        for item in group.events:
+            rows.append(renderEventTableRow(item))
+
+    headers = ["{translation:notifications/appEvents/eventname}",r.center("{translation:notifications/appEvents/push}"), r.center("{translation:notifications/appEvents/activityLog}")]
+
+    if "mail" in user.database.enabledApps():
+        headers.append(r.center("{translation:notifications/appEvents/email}"))
+
+    result += r.createtable(headers = headers, rows = rows, classes=["wide"])
+    return(result)
+
+def renderEventTableRow(event):
+    settings = event.getSettings()
+    row = [
+    event.getName(),
+    renderCheckbox(settings, event, "push"),
+    renderCheckbox(settings, event, "activityLog")
+    ]
+    if "mail" in event.data.enabledApps():
+        row.append(renderCheckbox(settings, event, "email"))
+    return(row)
+
+def renderCheckbox(settings, event, setting):
+    return(r.center(r.createcheckboxButton(checked = settings[setting], otherattribues=event.checkBoxAttributes+["data-setting = '%s'" % setting, "data-groupid = '%s'" % (event.group.id if hasattr(event.group, "id") else "null")], onchange="return setNotifyEvent(this)", id=util.generate_token())))
diff --git a/code/apps/notifications/code/reutil.py b/code/apps/notifications/code/reutil.py
index 1d94605..e0752c9 100644
--- a/code/apps/notifications/code/reutil.py
+++ b/code/apps/notifications/code/reutil.py
@@ -1,17 +1,100 @@
-class notificationEvent:
-    def __init__(self, user, appid, eventname):
+import util, time
+
+class notification:
+    def getName(self):
+        return (self.name if self.name else "{translation:%s/notificationEvents/%s}" % (self.app.id, self.id))
+
+    def getSettings(self):
+        try:
+            return(util.read(self.settingsfile, importjson = True))
+        except:
+            self.saveSettings({"activityLog":True, "push":True, "email":False})
+            return(self.getSettings())
+
+    def saveSettings(self, settings):
+        util.write(self.settingsfile, settings)
+
+
+class notificationEvent(notification):
+    def __init__(self, user, appid, eventid, eventname = None, settingsfile=None, timeBlocked=None):
+        self.data = user.database
+        self.user = user
+        self.app = self.data.apps[appid]
+        self.id = eventid
+        self.name = eventname
+        self.settingsfile = self.data.apps[appid].userdatapath(user=self.user)+"events/"+eventid+".json" if not settingsfile else settingsfile
+        self.group = None
+        self.checkBoxAttributes = ["data-appid = '%s'" % self.app.id, "data-eventid = '%s'" % str(self.id)]
+        self.timeBlocked = timeBlocked
+
+class notificationEventGroup(notification):
+    def __init__(self, user, appid, groupid, events, groupname = None, settingsfile=None):
         self.data = user.database
         self.user = user
         self.app = self.data.apps[appid]
-        self.eventname = eventname
+        self.id = groupid
+        self.name = groupname
+        self.settingsfile = self.data.apps[appid].userdatapath(user=self.user)+"eventsgroups/"+groupid+".json" if not settingsfile else settingsfile
+        self.events = events
+        for item in self.events:
+            item.group = self
+
 
 def getAppEvents(user, appid):
-    if hasattr(user.database.apps[appid].code, getNotificicationEvents):
-        return(user.database.apps[appid].code.getNotificicationEvents(user))
-    return([])
+    if hasattr(user.database.apps[appid].code, "getNotificicationEvents"):
+        eventsAndGroups = user.database.apps[appid].code.getNotificicationEvents(user)
+        events, groups = [],[]
+        for item in eventsAndGroups:
+            (events if hasattr(item, "group") else groups).append(item)
+        return({"groups":groups, "events":events})
+    return({"groups":[], "events":[]})
 
 def getEventsByUser(user):
     result = {}
     for appid in user.database.enabledApps():
         result[appid] = getAppEvents(user, appid)
+        if result[appid] == {"groups":[], "events":[]}:
+            result.pop(appid)
     return(result)
+
+
+def triggerEvent(user, appid, eventid, ntcenter=None, email=None, activityLog=None, linkto=None):
+    if user.appEnabled[appid]:
+        events = getEventsByUser(user)[appid]
+        event = None
+        for item in events["events"]:
+            if item.id == eventid:
+                event = item
+        if not event:
+            for group in events["groups"]:
+                for item in group.events:
+                    if item.id == eventid:
+                        event = item
+
+        if not (user.id in blockedEvents and eventid in blockedEvents[user.id] and blockedEvents[user.id][eventid] > time.time()):
+            if event.timeBlocked:
+                blockedEvents[user.id] = blockedEvents[user.id] if user.id in blockedEvents else {}
+                blockedEvents[user.id][eventid] = time.time() + event.timeBlocked
+            settings = event.getSettings()
+            if settings["push"] and ntcenter:
+                user.addUsermessage(ntcenter=ntcenter)
+            if settings["activityLog"] and activityLog:
+                user.addUsermessage(activityLog={"appid":appid, "eventname":event.getName(), "message":activityLog, "linkto":linkto})
+            if settings["email"] and email:
+                email["linkto"] = linkto
+                email["blockFurtherMessages"] = event.timeBlocked != None
+                user.addUsermessage(email=email)
+
+def filterOutUser(user, allusers):
+    if type(allusers[0]) == str:
+        for index in range(len(allusers)):
+            allusers[index] = user.database.getUser(id=allusers[index])
+
+    result = []
+    for userAll in allusers:
+        if userAll != user:
+            result.append(userAll)
+    return(result)
+
+
+blockedEvents = {}
diff --git a/code/apps/notifications/static/default.css b/code/apps/notifications/static/default.css
new file mode 100644
index 0000000..544231c
--- /dev/null
+++ b/code/apps/notifications/static/default.css
@@ -0,0 +1,9 @@
+fieldset{
+  margin: 0.5em 0 0.5em 0
+}
+
+.groupHeading{
+  font-weight: bold;
+  font-size: 125%;
+  padding-top: 0.7em
+}
diff --git a/code/apps/notifications/static/default.js b/code/apps/notifications/static/default.js
new file mode 100644
index 0000000..4f3cfcb
--- /dev/null
+++ b/code/apps/notifications/static/default.js
@@ -0,0 +1,20 @@
+function setNotifyEvent(button){
+  js_ajax_page_updater(null, button.id, "notifications/setNotifyEvent", value=button.dataset.appid+"/"+button.dataset.groupid+"/"+button.dataset.setting+"/"+button.classList.contains("checked")+"/"+button.dataset.eventid)
+}
+
+/*function setTableEventListener(){
+  const tds = document.getElementsByTagName("td");
+  for (i=0; i<tds.length;i++){
+    tds[i].onclick = function(){
+      const buttons = this.getElementsByClassName("checkbox")
+      if (buttons.length > 0){
+        buttons[0].click()
+      }
+    }
+  };
+}
+
+window.addEventListener("load", setTableEventListener)
+
+=> If clicked on checkbox, function is called 2 times
+*/
diff --git a/code/apps/notifications/translations/de.json b/code/apps/notifications/translations/de.json
index 0967ef4..0311cab 100644
--- a/code/apps/notifications/translations/de.json
+++ b/code/apps/notifications/translations/de.json
@@ -1 +1,11 @@
-{}
+{
+  "title":"Benachrichtigungen",
+  "changeNotifications":"Benachrichtigungen verwalten",
+  "appEvents":{
+    "backtoOverview":"Zurück zur Übersicht",
+    "eventname":"Eventname",
+    "push":"Push",
+    "activityLog":"Aktivitätsverlauf",
+    "email":"Email"
+  }
+}
diff --git a/code/apps/notifications/translations/en.json b/code/apps/notifications/translations/en.json
new file mode 100644
index 0000000..9eb4cad
--- /dev/null
+++ b/code/apps/notifications/translations/en.json
@@ -0,0 +1,11 @@
+{
+  "title":"Notifications",
+  "changeNotifications":"Manage notifications",
+  "appEvents":{
+    "backtoOverview":"Back to overview",
+    "eventname":"Eventname",
+    "push":"Push",
+    "activityLog":"Activity log",
+    "email":"Email"
+  }
+}
diff --git a/code/apps/profile/code/render.py b/code/apps/profile/code/render.py
index aeef896..3422ab6 100644
--- a/code/apps/profile/code/render.py
+++ b/code/apps/profile/code/render.py
@@ -3,6 +3,10 @@ import sessionhelper as r
 def renderProfileSettings(user):
     result = renderLoginData(user)
 
+    result += r.createfieldset(legend=r.heading(3, "{translation:notifications/title}"), content=
+        r.center(r.link(visibleas="{translation:notifications/changeNotifications}", linkto="/notifications", isbutton=True))
+    )
+
     if "mail" in user.database.enabledApps():
         result += renderEmail(user)
 
@@ -24,8 +28,6 @@ def renderEmail(user, changed = False):
         email += r.center(r.button(visibleas = "{translation:mail/newEmail/save}", onclick = "return changeEmail()", id="saveNewEmail"))
     else:
         email += r.center(r.p("{translation:mail/newEmail/verificationEmailSend}"))
-
-    #email += r.center(r.link(visibleas="{translation:mail/changeNotifications}", linkto="/notifications", isbutton=True))
     return(r.createfieldset(legend = r.heading(3, "{translation:mail/title}"), content = email, id = "emailFieldset"))
 
 def renderLoginData(user):
diff --git a/code/apps/root/meta.json b/code/apps/root/meta.json
index f20f216..3872d79 100644
--- a/code/apps/root/meta.json
+++ b/code/apps/root/meta.json
@@ -2,7 +2,7 @@
   "version":"0.0.0",
   "id":"root",
   "showinheader":[
-    ["{translation:root/header/login}", "/hello", -1]
+    ["{translation:root/header/login}", "/hello", -10]
   ],
   "showinfooter":[],
   "showapprights":false
diff --git a/code/apps/root/static/default.css b/code/apps/root/static/default.css
index f4eaa25..33c78b9 100644
--- a/code/apps/root/static/default.css
+++ b/code/apps/root/static/default.css
@@ -219,7 +219,7 @@ input, .button, select, textarea{
   background-color: rgb(70, 70, 0);
   color: rgb(150,150,150)
 }
-.widebutton, .centeredwindow{
+.widebutton, .centeredwindow, .wide{
   width: -moz-available;
   width: -webkit-fill-available;
 }
@@ -380,3 +380,29 @@ fieldset legend{
 fieldset fieldset{
   background-color: rgb(40,40,40)
 }
+
+
+.checkbox, .backgroudImageButton{
+  height: 1em;
+  min-width: 0em;
+  min-height: 0em;
+  width: 1em;
+  background-size: contain;
+  background-repeat: no-repeat;
+  background-position: center;
+  display: inline-block;
+  color: rgba(0,0,0,0)
+}
+
+.checkbox{
+  border: 0.1em solid black;
+  border-radius: 0.4em;
+  background-color: rgb(200,200,200);
+  margin-right: 0.5em;
+  position: relative;
+  top: 0.25em;
+}
+.checkbox.checked{
+  background-color: blue;
+  background-image: url("/static/startid/root/checkbox.png");
+}
-- 
GitLab


From ee84564fe3df7d592fcf42bcbd3daca079d4bd53 Mon Sep 17 00:00:00 2001
From: Lennart Franken <lennart@franken-familie.de>
Date: Tue, 16 Aug 2022 21:26:02 +0200
Subject: [PATCH 2/2] notification fix 1

---
 code/apps/lists/code/notification.py | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/code/apps/lists/code/notification.py b/code/apps/lists/code/notification.py
index 2242336..2c5dd8c 100644
--- a/code/apps/lists/code/notification.py
+++ b/code/apps/lists/code/notification.py
@@ -22,4 +22,4 @@ def triggerListEdited(userActing, list_obj):
     activityLog = text
     email = {"subject":"{translation:lists/notifications/listEdited/subject;listname=%s}" % list_obj.meta["name"], "content":text}
     for user in notify.filterOutUser(userActing, list_obj.getUsersWithAccess()):
-        notify.triggerEvent(user=user, appid="lists", eventid = "listChanged?"+list_obj.fullid, ntcenter=ntcenter, email=email, activityLog=activityLog)
+        notify.triggerEvent(user=user, appid="lists", eventid = "listChanged?"+list_obj.fullid, ntcenter=ntcenter, email=email, activityLog=activityLog, linkto=user.database.getDomain()+list_obj.linkto)
-- 
GitLab