Merge pull request #836 from pateljannat/notifications

feat: notifications
This commit is contained in:
Jannat Patel
2024-05-24 15:13:39 +05:30
committed by GitHub
29 changed files with 4915 additions and 2129 deletions

Submodule frappe-ui updated: 5c0513c2df...38728b80aa

View File

@@ -21,7 +21,7 @@
"chart.js": "^4.4.1", "chart.js": "^4.4.1",
"dayjs": "^1.11.6", "dayjs": "^1.11.6",
"feather-icons": "^4.28.0", "feather-icons": "^4.28.0",
"frappe-ui": "^0.1.54", "frappe-ui": "^0.1.56",
"lucide-vue-next": "^0.309.0", "lucide-vue-next": "^0.309.0",
"markdown-it": "^14.0.0", "markdown-it": "^14.0.0",
"pinia": "^2.0.33", "pinia": "^2.0.33",

View File

@@ -10,7 +10,7 @@
<UserDropdown class="p-2" :isCollapsed="isSidebarCollapsed" /> <UserDropdown class="p-2" :isCollapsed="isSidebarCollapsed" />
<div class="flex flex-col overflow-y-auto"> <div class="flex flex-col overflow-y-auto">
<SidebarLink <SidebarLink
v-for="link in links" v-for="link in sidebarLinks"
:link="link" :link="link"
:isCollapsed="isSidebarCollapsed" :isCollapsed="isSidebarCollapsed"
class="mx-2 my-0.5" class="mx-2 my-0.5"
@@ -42,10 +42,53 @@ import UserDropdown from '@/components/UserDropdown.vue'
import CollapseSidebar from '@/components/Icons/CollapseSidebar.vue' import CollapseSidebar from '@/components/Icons/CollapseSidebar.vue'
import SidebarLink from '@/components/SidebarLink.vue' import SidebarLink from '@/components/SidebarLink.vue'
import { useStorage } from '@vueuse/core' import { useStorage } from '@vueuse/core'
import { ref } from 'vue' import { ref, onMounted, inject, computed } from 'vue'
import { getSidebarLinks } from '../utils' import { getSidebarLinks } from '../utils'
import { sessionStore } from '@/stores/session'
import { Bell } from 'lucide-vue-next'
import { createResource } from 'frappe-ui'
const links = getSidebarLinks() const { user } = sessionStore()
const socket = inject('$socket')
const unreadCount = ref(0)
onMounted(() => {
socket.on('publish_lms_notifications', (data) => {
unreadNotifications.reload()
})
})
const unreadNotifications = createResource({
cache: 'Unread Notifications Count',
url: 'frappe.client.get_count',
makeParams(values) {
return {
doctype: 'Notification Log',
filters: {
for_user: user,
read: 0,
},
}
},
onSuccess(data) {
unreadCount.value = data
},
auto: true,
})
const sidebarLinks = computed(() => {
const links = getSidebarLinks()
if (user) {
links.push({
label: 'Notifications',
icon: Bell,
to: 'Notifications',
activeFor: ['Notifications'],
count: unreadCount.value,
})
}
return links
})
const getSidebarFromStorage = () => { const getSidebarFromStorage = () => {
return useStorage('sidebar_is_collapsed', false) return useStorage('sidebar_is_collapsed', false)

View File

@@ -141,7 +141,6 @@ function enrollStudent() {
const enrollStudentResource = createResource({ const enrollStudentResource = createResource({
url: 'lms.lms.doctype.lms_enrollment.lms_enrollment.create_membership', url: 'lms.lms.doctype.lms_enrollment.lms_enrollment.create_membership',
}) })
console.log(props.course)
enrollStudentResource enrollStudentResource
.submit({ .submit({
course: props.course.data.name, course: props.course.data.name,

View File

@@ -5,7 +5,6 @@
<div <div
class="relative block min-h-0 flex-shrink-0 overflow-hidden hover:overflow-auto" class="relative block min-h-0 flex-shrink-0 overflow-hidden hover:overflow-auto"
> >
<slot name="sidebar" />
<AppSidebar /> <AppSidebar />
</div> </div>
<div class="w-full overflow-auto" id="scrollContainer"> <div class="w-full overflow-auto" id="scrollContainer">

View File

@@ -69,9 +69,11 @@
/> />
</div> </div>
</div> </div>
<TextEditor <TextEditor
class="mt-5" class="mt-5"
:content="newReply" :content="newReply"
:mentions="mentionUsers"
@change="(val) => (newReply = val)" @change="(val) => (newReply = val)"
placeholder="Type your reply here..." placeholder="Type your reply here..."
:fixedMenu="true" :fixedMenu="true"
@@ -92,13 +94,14 @@ import { createResource, TextEditor, Button, Dropdown } from 'frappe-ui'
import { timeAgo } from '../utils' import { timeAgo } from '../utils'
import UserAvatar from '@/components/UserAvatar.vue' import UserAvatar from '@/components/UserAvatar.vue'
import { ChevronLeft, MoreHorizontal } from 'lucide-vue-next' import { ChevronLeft, MoreHorizontal } from 'lucide-vue-next'
import { ref, inject, onMounted } from 'vue' import { ref, inject, onMounted, computed } from 'vue'
import { createToast } from '../utils' import { createToast } from '../utils'
const showTopics = defineModel('showTopics') const showTopics = defineModel('showTopics')
const newReply = ref('') const newReply = ref('')
const socket = inject('$socket') const socket = inject('$socket')
const user = inject('$user') const user = inject('$user')
const allUsers = inject('$allUsers')
const props = defineProps({ const props = defineProps({
topic: { topic: {
@@ -147,6 +150,16 @@ const newReplyResource = createResource({
}, },
}) })
const mentionUsers = computed(() => {
let users = Object.values(allUsers.data).map((user) => {
return {
value: user.name,
label: user.full_name,
}
})
return users
})
const postReply = () => { const postReply = () => {
newReplyResource.submit( newReplyResource.submit(
{}, {},

View File

@@ -42,14 +42,14 @@
</div> </div>
<div <div
v-else v-else
class="flex items-center justify-center border mt-5 p-5 rounded-md" class="flex flex-col items-center justify-center border-2 border-dashed mt-5 py-8 rounded-md"
> >
<MessageSquareIcon class="w-5 h-5 stroke-1.5 mr-2" /> <MessageSquareText class="w-7 h-7 text-gray-500 stroke-1.5 mr-2" />
<div> <div class="">
<div v-if="emptyStateTitle" class="font-medium mb-2"> <div v-if="emptyStateTitle" class="font-medium mb-2">
{{ __(emptyStateTitle) }} {{ __(emptyStateTitle) }}
</div> </div>
<div class=""> <div class="text-gray-600">
{{ __(emptyStateText) }} {{ __(emptyStateText) }}
</div> </div>
</div> </div>
@@ -63,13 +63,14 @@
/> />
</template> </template>
<script setup> <script setup>
import { createResource, Button, TextEditor } from 'frappe-ui' import { createResource, Button } from 'frappe-ui'
import UserAvatar from '@/components/UserAvatar.vue' import UserAvatar from '@/components/UserAvatar.vue'
import { timeAgo } from '../utils' import { timeAgo } from '../utils'
import { ref, onMounted, inject } from 'vue' import { ref, onMounted, inject } from 'vue'
import DiscussionReplies from '@/components/DiscussionReplies.vue' import DiscussionReplies from '@/components/DiscussionReplies.vue'
import DiscussionModal from '@/components/Modals/DiscussionModal.vue' import DiscussionModal from '@/components/Modals/DiscussionModal.vue'
import { MessageSquareIcon } from 'lucide-vue-next' import { MessageSquareText } from 'lucide-vue-next'
import { getScrollContainer } from '@/utils/scrollContainer'
const showTopics = ref(true) const showTopics = ref(true)
const currentTopic = ref(null) const currentTopic = ref(null)
@@ -96,12 +97,16 @@ const props = defineProps({
}, },
emptyStateText: { emptyStateText: {
type: String, type: String,
default: 'Be the first to start a discussion', default: 'Start a discussion',
}, },
singleThread: { singleThread: {
type: Boolean, type: Boolean,
default: false, default: false,
}, },
scrollToBottom: {
type: Boolean,
default: false,
},
}) })
onMounted(() => { onMounted(() => {
@@ -110,8 +115,19 @@ onMounted(() => {
socket.on('new_discussion_topic', (data) => { socket.on('new_discussion_topic', (data) => {
topics.refresh() topics.refresh()
}) })
if (props.scrollToBottom) {
setTimeout(() => {
scrollToEnd()
}, 100)
}
}) })
const scrollToEnd = () => {
let scrollContainer = getScrollContainer()
scrollContainer.scrollTop = scrollContainer.scrollHeight
}
const topics = createResource({ const topics = createResource({
url: 'lms.lms.utils.get_discussion_topics', url: 'lms.lms.utils.get_discussion_topics',
cache: ['topics', props.doctype, props.docname], cache: ['topics', props.doctype, props.docname],

View File

@@ -34,7 +34,7 @@ import { sessionStore } from '@/stores/session'
import { usersStore } from '@/stores/user' import { usersStore } from '@/stores/user'
import { LogOut, LogIn, UserRound } from 'lucide-vue-next' import { LogOut, LogIn, UserRound } from 'lucide-vue-next'
const { logout, user, username } = sessionStore() const { logout, user } = sessionStore()
let { isLoggedIn } = sessionStore() let { isLoggedIn } = sessionStore()
const router = useRouter() const router = useRouter()
let { userResource } = usersStore() let { userResource } = usersStore()

View File

@@ -6,7 +6,7 @@
@click="handleClick" @click="handleClick"
> >
<div <div
class="flex items-center duration-300 ease-in-out" class="flex items-center w-full duration-300 ease-in-out"
:class="isCollapsed ? 'p-1' : 'px-2 py-1'" :class="isCollapsed ? 'p-1' : 'px-2 py-1'"
> >
<Tooltip :text="link.label" placement="right"> <Tooltip :text="link.label" placement="right">
@@ -29,6 +29,9 @@
> >
{{ link.label }} {{ link.label }}
</span> </span>
<span v-if="link.count" class="!ml-auto block text-xs text-gray-600">
{{ link.count }}
</span>
</div> </div>
</button> </button>
</template> </template>

View File

@@ -34,9 +34,7 @@ const props = defineProps({
default: 'Tags', default: 'Tags',
}, },
}) })
console.log(props.modelValue)
let tags = ref(props.modelValue) let tags = ref(props.modelValue)
console.log(tags.value)
const emit = defineEmits(['update:modelValue']) const emit = defineEmits(['update:modelValue'])
let newTag = ref('') let newTag = ref('')

View File

@@ -30,8 +30,9 @@ app.provide('$dayjs', dayjs)
app.provide('$socket', initSocket()) app.provide('$socket', initSocket())
app.mount('#app') app.mount('#app')
const { userResource } = usersStore() const { userResource, allUsers } = usersStore()
let { isLoggedIn } = sessionStore() let { isLoggedIn } = sessionStore()
app.provide('$user', userResource) app.provide('$user', userResource)
app.provide('$allUsers', allUsers)
app.config.globalProperties.$user = userResource app.config.globalProperties.$user = userResource

View File

@@ -66,9 +66,10 @@
<Discussions <Discussions
doctype="LMS Batch" doctype="LMS Batch"
:docname="batch.data.name" :docname="batch.data.name"
title="Discussions" :title="__('Discussions')"
:key="batch.data.name" :key="batch.data.name"
:singleThread="true" :singleThread="true"
:scrollToBottom="true"
/> />
</div> </div>
</div> </div>

View File

@@ -85,7 +85,7 @@
<script setup> <script setup>
import { computed, inject } from 'vue' import { computed, inject } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { BookOpen, Calendar, Clock } from 'lucide-vue-next' import { BookOpen, Clock } from 'lucide-vue-next'
import { formatTime } from '@/utils' import { formatTime } from '@/utils'
import { Breadcrumbs, createResource } from 'frappe-ui' import { Breadcrumbs, createResource } from 'frappe-ui'
import CourseCard from '@/components/CourseCard.vue' import CourseCard from '@/components/CourseCard.vue'

View File

@@ -65,8 +65,8 @@
name: 'Lesson', name: 'Lesson',
params: { params: {
courseName: course.name, courseName: course.name,
chapterNumber: course.current_lesson.split('.')[0], chapterNumber: course.current_lesson.split('-')[0],
lessonNumber: course.current_lesson.split('.')[1], lessonNumber: course.current_lesson.split('-')[1],
}, },
} }
: course.membership : course.membership

View File

@@ -0,0 +1,157 @@
<template>
<header
class="sticky top-0 z-10 flex flex-col md:flex-row md:items-center justify-between border-b bg-white px-3 py-2.5 sm:px-5"
>
<Breadcrumbs :items="breadcrumbs" />
<div class="flex items-center space-x-2">
<Button
@click="markAllAsRead.submit"
:loading="markAllAsRead.loading"
v-if="activeTab === 'Unread' && unReadNotifications.data?.length > 0"
>
{{ __('Mark all as read') }}
</Button>
<TabButtons
class="inline-block"
:buttons="[{ label: 'Unread', active: true }, { label: 'Read' }]"
v-model="activeTab"
/>
</div>
</header>
<div class="w-3/4 mx-auto px-5 pt-6 divide-y">
<div
v-if="notifications?.length"
v-for="log in notifications"
class="flex items-center py-2 justify-between"
>
<div class="flex items-center">
<UserAvatar :user="allUsers.data[log.from_user]" class="mr-2" />
<div class="notification" v-html="log.subject"></div>
</div>
<div class="flex items-center space-x-2">
<Link
v-if="log.link"
:to="log.link"
@click="markAsRead.submit({ name: log.name })"
class="text-gray-600 font-medium text-sm hover:text-gray-700"
>
{{ __('View') }}
</Link>
<Tooltip :text="__('Mark as read')">
<Button
variant="ghost"
v-if="!log.read"
@click="markAsRead.submit({ name: log.name })"
>
<template #icon>
<X class="h-4 w-4 text-gray-700 stroke-1.5" />
</template>
</Button>
</Tooltip>
</div>
</div>
<div v-else class="text-gray-600">
{{ __('Nothing to see here.') }}
</div>
</div>
</template>
<script setup>
import {
createListResource,
createResource,
Breadcrumbs,
Link,
TabButtons,
Button,
Tooltip,
} from 'frappe-ui'
import { computed, inject, ref, onMounted } from 'vue'
import UserAvatar from '@/components/UserAvatar.vue'
import { useRouter } from 'vue-router'
import { X } from 'lucide-vue-next'
const user = inject('$user')
const socket = inject('$socket')
const allUsers = inject('$allUsers')
const activeTab = ref('Unread')
const router = useRouter()
onMounted(() => {
if (!user.data) router.push({ name: 'Courses' })
socket.on('publish_lms_notifications', (data) => {
unReadNotifications.reload()
})
})
const notifications = computed(() => {
return activeTab.value === 'Unread'
? unReadNotifications.data
: readNotifications.data
})
const unReadNotifications = createListResource({
doctype: 'Notification Log',
fields: ['subject', 'from_user', 'link', 'read', 'name'],
filters: {
for_user: user.data?.name,
read: 0,
},
orderBy: 'creation desc',
auto: true,
cache: 'Unread Notifications',
})
const readNotifications = createListResource({
doctype: 'Notification Log',
fields: ['subject', 'from_user', 'link', 'read', 'name'],
filters: {
for_user: user.data?.name,
read: 1,
},
orderBy: 'creation desc',
auto: true,
cache: 'Read Notifications',
})
const markAsRead = createResource({
url: 'lms.lms.api.mark_as_read',
makeParams(values) {
return {
name: values.name,
}
},
onSuccess(data) {
unReadNotifications.reload()
readNotifications.reload()
},
})
const markAllAsRead = createResource({
url: 'lms.lms.api.mark_all_as_read',
onSuccess(data) {
unReadNotifications.reload()
readNotifications.reload()
},
})
const breadcrumbs = computed(() => {
let crumbs = [
{
label: 'Notifications',
route: {
name: 'Notifications',
},
},
]
return crumbs
})
</script>
<style>
.notification strong {
font-weight: 400;
}
.notification b {
font-weight: 400;
}
</style>

View File

@@ -130,6 +130,11 @@ const routes = [
name: 'CertifiedParticipants', name: 'CertifiedParticipants',
component: () => import('@/pages/CertifiedParticipants.vue'), component: () => import('@/pages/CertifiedParticipants.vue'),
}, },
{
path: '/notifications',
name: 'Notifications',
component: () => import('@/pages/Notifications.vue'),
},
] ]
let router = createRouter({ let router = createRouter({
@@ -138,13 +143,21 @@ let router = createRouter({
}) })
router.beforeEach(async (to, from, next) => { router.beforeEach(async (to, from, next) => {
const { userResource } = usersStore() const { userResource, allUsers } = usersStore()
let { isLoggedIn } = sessionStore() let { isLoggedIn } = sessionStore()
try { try {
if (isLoggedIn) { if (isLoggedIn) {
await userResource.reload() await userResource.reload()
} }
if (
isLoggedIn &&
(to.name == 'Lesson' ||
to.name == 'Batch' ||
to.name == 'Notifications')
) {
await allUsers.reload()
}
} catch (error) { } catch (error) {
isLoggedIn = false isLoggedIn = false
} }

View File

@@ -11,7 +11,13 @@ export const usersStore = defineStore('lms-users', () => {
}, },
}) })
const allUsers = createResource({
url: 'lms.lms.api.get_all_users',
cache: ['allUsers'],
})
return { return {
userResource, userResource,
allUsers,
} }
}) })

View File

@@ -6,6 +6,7 @@ import {
TrendingUp, TrendingUp,
Briefcase, Briefcase,
GraduationCap, GraduationCap,
Bell,
} from 'lucide-vue-next' } from 'lucide-vue-next'
import { Quiz } from '@/utils/quiz' import { Quiz } from '@/utils/quiz'
import { Upload } from '@/utils/upload' import { Upload } from '@/utils/upload'
@@ -350,6 +351,7 @@ export function getSidebarLinks() {
label: 'Statistics', label: 'Statistics',
icon: TrendingUp, icon: TrendingUp,
to: 'Statistics', to: 'Statistics',
activeFor: ['Statistics'],
}, },
] ]
} }

View File

@@ -43,7 +43,6 @@ export class Quiz {
} }
save(blockContent) { save(blockContent) {
console.log(blockContent)
return { return {
quiz: this.data.quiz, quiz: this.data.quiz,
} }

View File

@@ -0,0 +1,11 @@
export function scrollTo(...options) {
if (!options || options.length === 0) return
const container = getScrollContainer()
if (!container) return
container.scrollTo(...options)
}
export function getScrollContainer() {
// window.scrollContainer is reference to the scroll container in DesktopLayout.vue and MobileLayout.vue
return window.scrollContainer
}

File diff suppressed because it is too large Load Diff

View File

@@ -103,6 +103,7 @@ doc_events = {
] ]
}, },
"Discussion Reply": {"after_insert": "lms.lms.utils.handle_notifications"}, "Discussion Reply": {"after_insert": "lms.lms.utils.handle_notifications"},
"Notification Log": {"on_change": "lms.lms.utils.publish_notifications"},
} }
# Scheduled Tasks # Scheduled Tasks

View File

@@ -385,3 +385,33 @@ def get_certificates(member):
fields=["name", "course", "course_title", "issue_date", "template"], fields=["name", "course", "course_title", "issue_date", "template"],
order_by="creation desc", order_by="creation desc",
) )
@frappe.whitelist()
def get_all_users():
users = frappe.get_all(
"User",
{
"enabled": 1,
},
["name", "full_name", "user_image"],
)
return {user.name: user for user in users}
@frappe.whitelist()
def mark_as_read(name):
doc = frappe.get_doc("Notification Log", name)
doc.read = 1
doc.save(ignore_permissions=True)
@frappe.whitelist()
def mark_all_as_read():
notifications = frappe.get_all(
"Notification Log", {"for_user": frappe.session.user, "read": 0}, pluck="name"
)
for notification in notifications:
mark_as_read(notification)

View File

@@ -89,7 +89,6 @@ class CourseLesson(Document):
@frappe.whitelist() @frappe.whitelist()
def save_progress(lesson, course): def save_progress(lesson, course):
print("save progress")
membership = frappe.db.exists( membership = frappe.db.exists(
"LMS Enrollment", {"course": course, "member": frappe.session.user} "LMS Enrollment", {"course": course, "member": frappe.session.user}
) )

View File

@@ -281,21 +281,21 @@ def get_lesson_index(lesson_name):
"Lesson Reference", {"lesson": lesson_name}, ["idx", "parent"], as_dict=True "Lesson Reference", {"lesson": lesson_name}, ["idx", "parent"], as_dict=True
) )
if not lesson: if not lesson:
return "1.1" return "1-1"
chapter = frappe.db.get_value( chapter = frappe.db.get_value(
"Chapter Reference", {"chapter": lesson.parent}, ["idx"], as_dict=True "Chapter Reference", {"chapter": lesson.parent}, ["idx"], as_dict=True
) )
if not chapter: if not chapter:
return "1.1" return "1-1"
return f"{chapter.idx}.{lesson.idx}" return f"{chapter.idx}-{lesson.idx}"
def get_lesson_url(course, lesson_number): def get_lesson_url(course, lesson_number):
if not lesson_number: if not lesson_number:
return return
return f"/lms/courses/{course}/learn/{lesson_number}" return f"/courses/{course}/learn/{lesson_number}"
def get_batch(course, batch_name): def get_batch(course, batch_name):
@@ -640,37 +640,91 @@ def handle_notifications(doc, method):
if topic.reference_doctype not in ["Course Lesson", "LMS Batch"]: if topic.reference_doctype not in ["Course Lesson", "LMS Batch"]:
return return
create_notification_log(doc, topic) create_notification_log(doc, topic)
notify_mentions(doc, topic) notify_mentions_on_portal(doc, topic)
notify_mentions_via_email(doc, topic)
def create_notification_log(doc, topic): def create_notification_log(doc, topic):
course = frappe.db.get_value("Course Lesson", topic.reference_docname, "course") users = []
instructors = frappe.db.get_all( if topic.reference_doctype == "LMS Course":
"Course Instructor", {"parent": course}, pluck="instructor" course = frappe.db.get_value("Course Lesson", topic.reference_docname, "course")
) course_title = frappe.db.get_value("LMS Course", course, "title")
instructors = frappe.db.get_all(
"Course Instructor", {"parent": course}, pluck="instructor"
)
users.append(topic.owner)
users += instructors
subject = _("New reply on the topic {0} in course {1}").format(
topic.title, course_title
)
link = get_lesson_url(course, get_lesson_index(topic.reference_docname))
else:
batch_title = frappe.db.get_value("LMS Batch", topic.reference_docname, "title")
subject = _("New comment in batch {0}").format(batch_title)
link = f"/batches/{topic.reference_docname}"
moderators = frappe.get_all("Has Role", {"role": "Moderator"}, pluck="parent")
users += moderators
notification = frappe._dict( notification = frappe._dict(
{ {
"subject": _("New reply on the topic {0}").format(topic.title), "subject": subject,
"email_content": doc.reply, "email_content": doc.reply,
"document_type": topic.reference_doctype, "document_type": topic.reference_doctype,
"document_name": topic.reference_docname, "document_name": topic.reference_docname,
"for_user": topic.owner, "for_user": topic.owner,
"from_user": doc.owner, "from_user": doc.owner,
"type": "Alert", "type": "Alert",
"link": link,
} }
) )
users = []
if doc.owner != topic.owner:
users.append(topic.owner)
if doc.owner not in instructors:
users += instructors
make_notification_logs(notification, users) make_notification_logs(notification, users)
def notify_mentions(doc, topic): def notify_mentions_on_portal(doc, topic):
mentions = extract_mentions(doc.reply)
if not mentions:
return
from_user_name = get_fullname(doc.owner)
if topic.reference_doctype == "LMS Course":
course = frappe.db.get_value("Course Lesson", topic.reference_docname, "course")
subject = _("{0} mentioned you in a comment in {1}").format(
from_user_name, topic.title
)
link = get_lesson_url(course, get_lesson_index(topic.reference_docname))
else:
batch_title = frappe.db.get_value("LMS Batch", topic.reference_docname, "title")
subject = _("{0} mentioned you in a comment in {1}").format(
from_user_name, batch_title
)
link = f"/batches/{topic.reference_docname}"
for user in mentions:
notification = frappe._dict(
{
"subject": subject,
"email_content": doc.reply,
"document_type": topic.reference_doctype,
"document_name": topic.reference_docname,
"for_user": user,
"from_user": doc.owner,
"type": "Alert",
"link": link,
}
)
make_notification_logs(notification, user)
def notify_mentions_via_email(doc, topic):
outgoing_email_account = frappe.get_cached_value(
"Email Account", {"default_outgoing": 1, "enable_outgoing": 1}, "name"
)
if not outgoing_email_account or not frappe.conf.get("mail_login"):
return
mentions = extract_mentions(doc.reply) mentions = extract_mentions(doc.reply)
if not mentions: if not mentions:
return return
@@ -1708,6 +1762,7 @@ def create_discussion_topic(doctype, docname):
doc = frappe.new_doc("Discussion Topic") doc = frappe.new_doc("Discussion Topic")
doc.update( doc.update(
{ {
"title": docname,
"reference_doctype": doctype, "reference_doctype": doctype,
"reference_docname": docname, "reference_docname": docname,
} }
@@ -1817,3 +1872,9 @@ def get_roles(name):
"batch_evaluator": has_course_evaluator_role(name), "batch_evaluator": has_course_evaluator_role(name),
"lms_student": has_student_role(name), "lms_student": has_student_role(name),
} }
def publish_notifications(doc, method):
frappe.publish_realtime(
"publish_lms_notifications", user=doc.for_user, after_commit=True
)

View File

@@ -88,3 +88,4 @@ lms.patches.v1_0.rename_evaluator_role
lms.patches.v1_0.change_navbar_urls lms.patches.v1_0.change_navbar_urls
lms.patches.v1_0.set_published_on lms.patches.v1_0.set_published_on
lms.patches.v2_0.fix_progress_percentage lms.patches.v2_0.fix_progress_percentage
lms.patches.v2_0.add_discussion_topic_titles

View File

@@ -0,0 +1,13 @@
import frappe
def execute():
topics = frappe.get_all(
"Discussion Topic",
{"title": ["is", "not set"]},
["name", "reference_docname", "title"],
)
for topic in topics:
if not topic.title:
frappe.db.set_value("Discussion Topic", topic.name, "title", topic.reference_docname)

View File

@@ -15,9 +15,9 @@
<meta name="twitter:title" content="{{ meta.title }}" /> <meta name="twitter:title" content="{{ meta.title }}" />
<meta name="twitter:image" content="{{ meta.image }}" /> <meta name="twitter:image" content="{{ meta.image }}" />
<meta name="twitter:description" content="{{ meta.description }}" /> <meta name="twitter:description" content="{{ meta.description }}" />
<script type="module" crossorigin src="/assets/lms/frontend/assets/index-CdhjdjEj.js"></script> <script type="module" crossorigin src="/assets/lms/frontend/assets/index-C-DogOtg.js"></script>
<link rel="modulepreload" crossorigin href="/assets/lms/frontend/assets/frappe-ui-CgFK8870.js"> <link rel="modulepreload" crossorigin href="/assets/lms/frontend/assets/frappe-ui-CGsuCsfq.js">
<link rel="stylesheet" crossorigin href="/assets/lms/frontend/assets/frappe-ui-DzKBfka9.css"> <link rel="stylesheet" crossorigin href="/assets/lms/frontend/assets/frappe-ui-B1gEXx4C.css">
<link rel="stylesheet" crossorigin href="/assets/lms/frontend/assets/index-C1pDkvO9.css"> <link rel="stylesheet" crossorigin href="/assets/lms/frontend/assets/index-C1pDkvO9.css">
</head> </head>
<body> <body>

4501
yarn.lock Normal file

File diff suppressed because it is too large Load Diff