Compare commits

..

9 Commits

Author SHA1 Message Date
pateljannat
f9b4fe468e fix: removed unused import 2021-08-09 13:27:09 +05:30
pateljannat
6cbca8d1bb feat: notify me 2021-08-09 13:13:48 +05:30
Jannat Patel
d5067a4bcd Merge pull request #169 from fossunited/username-validations
fix: Username validations
2021-08-06 15:05:36 +05:30
pateljannat
04d44510de fix: redirect after edit profile save 2021-08-06 14:41:37 +05:30
pateljannat
844fcc9bca fix: username validations 2021-08-06 14:41:11 +05:30
Jannat Patel
145b5efab0 Merge pull request #168 from fossunited/minor-issues
fix: quiz, course outline, and lesson indexing
2021-08-05 18:41:15 +05:30
pateljannat
4079ed97b9 fix: quiz, course outline, and lesson indexing 2021-08-05 18:26:41 +05:30
pateljannat
63d70fc037 fix: username space and empty validations 2021-08-05 15:51:21 +05:30
Jannat Patel
ce86b5deda Merge pull request #167 from fossunited/issues
fix: cleanup
2021-08-04 19:18:45 +05:30
19 changed files with 335 additions and 13 deletions

View File

@@ -13,6 +13,38 @@ from ...utils import slugify
class LMSCourse(Document): class LMSCourse(Document):
def on_update(self):
if not self.upcoming and self.has_value_changed("upcoming"):
self.send_email_to_interested_users()
def send_email_to_interested_users(self):
interested_users = frappe.get_all("LMS Course Interest",
{
"course": self.name
},
["name", "user"])
subject = self.title + " is available!"
args = {
"title": self.title,
"course_link": "/courses/{0}".format(self.name),
"app_name": frappe.db.get_single_value("System Settings", "app_name"),
"site_url": frappe.utils.get_url()
}
for user in interested_users:
args["first_name"] = frappe.db.get_value("User", user.user, "first_name")
email_args = frappe._dict(
recipients = user.user,
sender = frappe.db.get_single_value("LMS Settings", "email_sender"),
subject = subject,
header = [subject, "green"],
template = "lms_course_interest",
args = args,
now = True
)
frappe.enqueue(method=frappe.sendmail, queue='short', timeout=300, is_async=True, **email_args)
frappe.db.set_value("LMS Course Interest", user.name, "email_sent", True)
@staticmethod @staticmethod
def find(name): def find(name):
"""Returns the course with specified name. """Returns the course with specified name.
@@ -194,7 +226,13 @@ class LMSCourse(Document):
"""Returns the {chapter_index}.{lesson_index} for the lesson. """Returns the {chapter_index}.{lesson_index} for the lesson.
""" """
lesson = frappe.db.get_value("Lessons", {"lesson": lesson_name}, ["idx", "parent"], as_dict=True) lesson = frappe.db.get_value("Lessons", {"lesson": lesson_name}, ["idx", "parent"], as_dict=True)
if not lesson:
return None
chapter = frappe.db.get_value("Chapters", {"chapter": lesson.parent}, ["idx"], as_dict=True) chapter = frappe.db.get_value("Chapters", {"chapter": lesson.parent}, ["idx"], as_dict=True)
if not chapter:
return None
return f"{chapter.idx}.{lesson.idx}" return f"{chapter.idx}.{lesson.idx}"
def reindex_exercises(self): def reindex_exercises(self):

View File

@@ -0,0 +1,8 @@
// Copyright (c) 2021, FOSS United and contributors
// For license information, please see license.txt
frappe.ui.form.on('LMS Course Interest', {
// refresh: function(frm) {
// }
});

View File

@@ -0,0 +1,61 @@
{
"actions": [],
"creation": "2021-08-06 17:37:20.184849",
"doctype": "DocType",
"editable_grid": 1,
"engine": "InnoDB",
"field_order": [
"course",
"user",
"email_sent"
],
"fields": [
{
"fieldname": "course",
"fieldtype": "Link",
"in_list_view": 1,
"in_standard_filter": 1,
"label": "Course",
"options": "LMS Course"
},
{
"fieldname": "user",
"fieldtype": "Link",
"in_list_view": 1,
"in_standard_filter": 1,
"label": "User",
"options": "User"
},
{
"default": "0",
"fieldname": "email_sent",
"fieldtype": "Check",
"label": "Email Sent",
"options": "email_sent"
}
],
"index_web_pages_for_search": 1,
"links": [],
"modified": "2021-08-06 18:06:21.370741",
"modified_by": "Administrator",
"module": "LMS",
"name": "LMS Course Interest",
"owner": "Administrator",
"permissions": [
{
"create": 1,
"delete": 1,
"email": 1,
"export": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "System Manager",
"share": 1,
"write": 1
}
],
"sort_field": "modified",
"sort_order": "DESC",
"track_changes": 1
}

View File

@@ -0,0 +1,17 @@
# Copyright (c) 2021, FOSS United and contributors
# For license information, please see license.txt
import frappe
from frappe.model.document import Document
class LMSCourseInterest(Document):
pass
@frappe.whitelist()
def capture_interest(course):
frappe.get_doc({
"doctype": "LMS Course Interest",
"course": course,
"user": frappe.session.user
}).save(ignore_permissions=True)
return "OK"

View File

@@ -0,0 +1,8 @@
# Copyright (c) 2021, FOSS United and Contributors
# See license.txt
# import frappe
import unittest
class TestLMSCourseInterest(unittest.TestCase):
pass

View File

@@ -1,7 +1,14 @@
frappe.ready(function () { frappe.ready(function () {
frappe.web_form.after_load = () => { frappe.web_form.after_load = () => {
if (!frappe.utils.get_url_arg("name")) { if (!frappe.utils.get_url_arg("name")) {
window.location.href = `/edit-profile?name=${frappe.session.user}`; window.location.href = `/edit-profile?name=${frappe.session.user}`;
} }
} }
frappe.web_form.after_save = () => {
setTimeout(() => {
window.location.href = `/${frappe.web_form.get_value(["username"])}`;
})
}
}) })

View File

@@ -11,6 +11,7 @@
"apply_document_permissions": 0, "apply_document_permissions": 0,
"breadcrumbs": "", "breadcrumbs": "",
"button_label": "Save", "button_label": "Save",
"client_script": "",
"creation": "2021-06-30 13:48:13.682851", "creation": "2021-06-30 13:48:13.682851",
"custom_css": "[data-doctype=\"Web Form\"] {\n max-width: 720px;\n margin: 6rem auto;\n}", "custom_css": "[data-doctype=\"Web Form\"] {\n max-width: 720px;\n margin: 6rem auto;\n}",
"doc_type": "User", "doc_type": "User",
@@ -20,7 +21,7 @@
"is_standard": 1, "is_standard": 1,
"login_required": 1, "login_required": 1,
"max_attachment_size": 0, "max_attachment_size": 0,
"modified": "2021-07-14 17:15:15.424855", "modified": "2021-08-06 14:40:39.013776",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "LMS", "module": "LMS",
"name": "profile", "name": "profile",
@@ -72,6 +73,18 @@
"reqd": 0, "reqd": 0,
"show_in_filter": 0 "show_in_filter": 0
}, },
{
"allow_read_on_all_link_options": 0,
"fieldname": "username",
"fieldtype": "Data",
"hidden": 0,
"label": "Username",
"max_length": 0,
"max_value": 0,
"read_only": 0,
"reqd": 0,
"show_in_filter": 0
},
{ {
"allow_read_on_all_link_options": 0, "allow_read_on_all_link_options": 0,
"description": "Get your globally recognized avatar from Gravatar.com", "description": "Get your globally recognized avatar from Gravatar.com",

View File

@@ -27,7 +27,7 @@
{{ lesson.title }} {{ lesson.title }}
{% if membership %} {% if membership %}
<img class="lesson-progress-tick {{ course.get_progress(lesson.name) != 'Complete' and 'hide' }}" <img class="ml-1 lesson-progress-tick {{ course.get_progress(lesson.name) != 'Complete' and 'hide' }}"
src="/assets/community/icons/check.svg"> src="/assets/community/icons/check.svg">
{% endif %} {% endif %}

View File

@@ -58,7 +58,7 @@
{% set query_parameter = "?batch=" + membership.batch if membership and membership.batch else "" %} {% set query_parameter = "?batch=" + membership.batch if membership and membership.batch else "" %}
{% if course.upcoming %} {% if course.upcoming %}
<div class="view-course-link is-default"> <div class="view-course-link is-secondary border">
Upcoming Course <img class="ml-3" src="/assets/community/icons/black-arrow.svg" /> Upcoming Course <img class="ml-3" src="/assets/community/icons/black-arrow.svg" />
</div> </div>
<a class="stretched-link" href="/courses/{{ course.name }}"></a> <a class="stretched-link" href="/courses/{{ course.name }}"></a>

View File

@@ -0,0 +1,54 @@
# Copyright (c) 2021, FOSS United and Contributors
# See license.txt
# import frappe
import unittest
import frappe
class TestCustomUser(unittest.TestCase):
def test_with_basic_username(self):
new_user = frappe.get_doc({
"doctype": "User",
"email": "test_with_basic_username@example.com",
"first_name": "Username"
}).insert()
self.assertEqual(new_user.username, "username")
def test_without_username(self):
""" The user in this test has the same first name as the user of the test test_with_basic_username.
In such cases frappe makes the username of the second user empty.
The condition in community app should override this and save a username. """
new_user = frappe.get_doc({
"doctype": "User",
"email": "test-without-username@example.com",
"first_name": "Username"
}).insert()
self.assertTrue(new_user.username)
def test_with_illegal_characters(self):
new_user = frappe.get_doc({
"doctype": "User",
"email": "test_with_illegal_characters@example.com",
"first_name": "Username$$"
}).insert()
self.assertEqual(new_user.username[:8], "username")
def test_with_hyphen_at_end(self):
new_user = frappe.get_doc({
"doctype": "User",
"email": "test_with_hyphen_at_end@example.com",
"first_name": "Username---"
}).insert()
length = len(new_user.username)
self.assertNotEqual(new_user.username[length-1], "-")
@classmethod
def tearDownClass(cls) -> None:
users = [
"test_with_basic_username@example.com",
"test-without-username@example.com",
"test_with_illegal_characters@example.com",
"test_with_hyphen_at_end@example.com"
]
frappe.db.delete("User", {"name": ["in", users]})

View File

@@ -2,9 +2,50 @@ import frappe
from frappe.core.doctype.user.user import User from frappe.core.doctype.user.user import User
from frappe.utils import cint from frappe.utils import cint
import hashlib import hashlib
import random
import re
from frappe import _
class CustomUser(User): class CustomUser(User):
def validate(self):
super(CustomUser, self).validate()
self.validate_username_characters()
def validate_username_characters(self):
if self.is_new():
if self.username.find(" "):
self.username.replace(" ", "")
if not re.match("^[A-Za-z0-9_]*$", self.username):
self.username = self.remove_illegal_characters()
if not self.username:
self.username = self.get_username_from_first_name()
if self.username_exists():
self.username = self.remove_illegal_characters() + str(random.randint(0, 99))
else:
if not re.match("^[A-Za-z0-9_-]*$", self.username):
frappe.throw(_("Username can only contain alphabets, numbers, hyphen and underscore."))
if self.username[0] == "-" or self.username[len(self.username) - 1] == "-":
frappe.throw(_("First and Last character of username cannot be Hyphen(-)."))
def get_username_from_first_name(self):
return frappe.scrub(self.first_name) + str(random.randint(0, 99))
def remove_illegal_characters(self):
username = ''.join([c for c in self.username if c.isalnum() or c in ['-', '_']])
while username[0] == "-" or username[len(username) - 1] == "-":
if username[0] == "-":
username = username[1:]
if username[len(username) - 1]:
username = username[:1]
return username
def get_authored_courses(self) -> int: def get_authored_courses(self) -> int:
"""Returns the number of courses authored by this user. """Returns the number of courses authored by this user.
""" """

View File

@@ -635,6 +635,11 @@ input[type=checkbox] {
font-size: 12px; font-size: 12px;
line-height: 135%; line-height: 135%;
letter-spacing: -0.011em; letter-spacing: -0.011em;
border: none;
}
.button:disabled {
cursor: not-allowed;
} }
.wide-button { .wide-button {
@@ -660,6 +665,11 @@ input[type=checkbox] {
color: inherit; color: inherit;
} }
.is-default {
background: #98A1A9;
color: #ffffff;
}
@media (max-width: 600px) { @media (max-width: 600px) {
.video-preview { .video-preview {
margin-top: 16px; margin-top: 16px;
@@ -676,12 +686,6 @@ input[type=checkbox] {
color: #FFFFFF; color: #FFFFFF;
} }
.is-default {
background-color: white;
border: 1px solid #C8CFD5;
box-sizing: border-box;
}
.course-home-outline { .course-home-outline {
margin-top: 3rem; margin-top: 3rem;
} }
@@ -780,7 +784,7 @@ input[type=checkbox] {
} }
.lesson-links { .lesson-links {
display: flex; display: block;
padding: 0 1rem; padding: 0 1rem;
margin-bottom: .25rem; margin-bottom: .25rem;
color: inherit; color: inherit;
@@ -794,6 +798,13 @@ input[type=checkbox] {
border-radius: .25rem; border-radius: .25rem;
} }
.course-content-parent .lesson-links {
padding: 0 0 0 1rem;
margin-bottom: 0.75rem;
font-size: 0.85rem;
line-height: 200%;
}
.chapter-content { .chapter-content {
margin: 0; margin: 0;
margin-left: .875rem; margin-left: .875rem;

View File

@@ -0,0 +1,19 @@
<div>
{% set site_link = "<a href='" + site_url + "'>" + app_name + "</a>" %}
<p>{{ _("Hi {0},").format(first_name) }}</p>
<br>
<p>{{ _("The course {0} is now available on {1}.").format(frappe.bold(title), app_name) }}</p>
<br>
<p>Click on the link below to start learning.</p>
<p style="margin: 15px 0px;">
<a href="{{ course_link }}" rel="nofollow" class="btn btn-primary">{{ _("Start Learning") }}</a>
</p>
<br>
<p>
{{ _("You can also copy-paste following link in your browser") }}<br>
<a href="{{ course_link }}">{{ site_url }}{{ course_link }}</a>
</p>
<br>
<p>Thanks and Regards,</p>
<p>{{ app_name }}</p>
</div>

View File

@@ -58,6 +58,7 @@ var mark_active_question = (e = undefined) => {
$(".current-question").text(`${next_index}`); $(".current-question").text(`${next_index}`);
$("#check").removeClass("hide").attr("disabled", true); $("#check").removeClass("hide").attr("disabled", true);
$("#next").addClass("hide"); $("#next").addClass("hide");
$(".explanation").addClass("hide");
} }
var mark_progress = (e) => { var mark_progress = (e) => {

View File

@@ -17,10 +17,9 @@ def get_context(context):
index_ = get_lesson_index(context.course, context.batch, frappe.session.user) or "1.1" index_ = get_lesson_index(context.course, context.batch, frappe.session.user) or "1.1"
else: else:
index_ = "1.1" index_ = "1.1"
frappe.local.flags.redirect_location = context.course.get_learn_url(index_) + context.course.query_parameter utils.redirect_to_lesson(context.course, index_)
raise frappe.Redirect
context.lesson = list(filter(lambda x: cstr(x.number) == lesson_number, context.lessons))[0] context.lesson = get_current_lesson_details(lesson_number, context)
neighbours = context.course.get_neighbours(lesson_number, context.lessons) neighbours = context.course.get_neighbours(lesson_number, context.lessons)
context.next_url = get_learn_url(neighbours["next"], context.course) context.next_url = get_learn_url(neighbours["next"], context.course)
context.prev_url = get_learn_url(neighbours["prev"], context.course) context.prev_url = get_learn_url(neighbours["prev"], context.course)
@@ -40,6 +39,12 @@ def get_context(context):
"is_member": context.membership is not None "is_member": context.membership is not None
} }
def get_current_lesson_details(lesson_number, context):
details_list = list(filter(lambda x: cstr(x.number) == lesson_number, context.lessons))
if not len(details_list):
utils.redirect_to_lesson(context.course)
return details_list[0]
def get_learn_url(lesson_number, course): def get_learn_url(lesson_number, course):
return course.get_learn_url(lesson_number) and course.get_learn_url(lesson_number) + course.query_parameter return course.get_learn_url(lesson_number) and course.get_learn_url(lesson_number) + course.query_parameter

View File

@@ -58,6 +58,12 @@
Continue Learning <img class="ml-2" src="/assets/community/icons/white-arrow.svg" /> Continue Learning <img class="ml-2" src="/assets/community/icons/white-arrow.svg" />
</a> </a>
{% endif %} {% endif %}
{% if course.upcoming %}
<button class="button wide-button is-default" id="notify-me" data-course="{{course.name | urlencode}}"
{% if is_user_interested %} disabled="disabled" title="Your interest has already been noted." {% endif %} >
Notify me when available
</button>
{% endif %}
{% if course.video_link %} {% if course.video_link %}
<div class="button wide-button is-secondary video-preview"> <div class="button wide-button is-secondary video-preview">
Watch Video Preview Watch Video Preview

View File

@@ -41,6 +41,10 @@ frappe.ready(() => {
submit_review(e); submit_review(e);
}) })
$("#notify-me").click((e) => {
notify_user(e);
})
}) })
var check_mentor_request = () => { var check_mentor_request = () => {
@@ -209,3 +213,23 @@ var submit_review = (e) => {
} }
}) })
} }
var notify_user = (e) => {
e.preventDefault();
var course = decodeURIComponent($(e.currentTarget).attr("data-course"));
if (frappe.session.user == "Guest") {
window.location.href = `/login?redirect-to=/courses/${course}`;
return;
}
frappe.call({
method: "community.lms.doctype.lms_course_interest.lms_course_interest.capture_interest",
args: {
"course": course
},
callback: (data) => {
frappe.msgprint(__("Your interest has been noted. We'll notify you via email when this course becomes available."));
$("#notify-me").attr("disabled", true).attr("title", "Your interest has already been noted");
}
})
}

View File

@@ -18,9 +18,18 @@ def get_context(context):
membership = course.get_membership(frappe.session.user) membership = course.get_membership(frappe.session.user)
context.course.query_parameter = "?batch=" + membership.batch if membership and membership.batch else "" context.course.query_parameter = "?batch=" + membership.batch if membership and membership.batch else ""
context.membership = membership context.membership = membership
if context.course.upcoming:
context.is_user_interested = get_user_interest(context.course.name)
context.metatags = { context.metatags = {
"title": course.title, "title": course.title,
"image": course.image, "image": course.image,
"description": course.short_introduction, "description": course.short_introduction,
"keywords": course.title "keywords": course.title
} }
def get_user_interest(course):
return frappe.db.count("LMS Course Interest",
{
"course": course,
"user": frappe.session.user
})