Skip to content

feat: Notifications mailing system #327

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 10 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions devops/lms.yml
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,25 @@ services:
- ./rabbitmq.cookie:/var/lib/rabbitmq/.erlang.cookie
networks:
- lms

utils:
image: lms:latest
command: celery -A lms.utils worker
volumes:
- ../lms/utils/:/app_dir/lms/utils/
environment:
- CELERY_RABBITMQ_ERLANG_COOKIE=AAVyo5djdSMGIZXiwEQs3JeVaBx5l14z
- CELERY_RABBITMQ_DEFAULT_USER=rabbit-user
- CELERY_RABBITMQ_DEFAULT_PASS=YgKlCvnYVzpTa3T9adG3NrMoUNe4Z5aZ
- CELERY_UTILS_VHOST=utils
- CELERY_RABBITMQ_HOST=rabbitmq
- CELERY_RABBITMQ_PORT=5672
links:
- rabbitmq
depends_on:
- rabbitmq
networks:
- lms

checks-sandbox:
image: lms:latest
Expand Down Expand Up @@ -78,6 +97,7 @@ services:
- CELERY_RABBITMQ_ERLANG_COOKIE=AAVyo5djdSMGIZXiwEQs3JeVaBx5l14z
- CELERY_RABBITMQ_DEFAULT_USER=rabbit-user
- CELERY_RABBITMQ_DEFAULT_PASS=YgKlCvnYVzpTa3T9adG3NrMoUNe4Z5aZ
- CELERY_UTILS_VHOST=utils
- CELERY_CHECKS_PUBLIC_VHOST=lmstests-public
- CELERY_CHECKS_SANDBOX_VHOST=lmstests-sandbox
- CELERY_RABBITMQ_HOST=rabbitmq
Expand Down
20 changes: 20 additions & 0 deletions lms/lmsdb/bootstrap.py
Original file line number Diff line number Diff line change
Expand Up @@ -255,6 +255,18 @@ def _add_uuid_to_users_table(table: Model, _column: Field) -> None:
user.save()


def _add_mail_subscription_to_users_table(
table: Model, _column: Field,
) -> None:
log.info(
'Adding mail subscription for users, might take some extra time...',
)
with db_config.database.transaction():
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why not update query instead of iterating all users in the system? will be much faster than that

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

And if its default true, are you sure you need this?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I tried to migrate it on my environment and it created the column with null for all the objects

for user in table:
user.mail_subscription = True
user.save()


def _api_keys_migration() -> bool:
User = models.User
_add_not_null_column(User, User.api_key, _add_api_keys_to_users_table)
Expand Down Expand Up @@ -309,6 +321,13 @@ def _uuid_migration() -> bool:
return True


def _mail_subscription() -> bool:
User = models.User
_add_not_null_column(
User, User.mail_subscription, _add_mail_subscription_to_users_table,
)


def _assessment_migration() -> bool:
Solution = models.Solution
_add_not_null_column(Solution, Solution.assessment)
Expand All @@ -328,6 +347,7 @@ def main():
_api_keys_migration()
_last_course_viewed_migration()
_uuid_migration()
_mail_subscription()

if models.database.table_exists(models.UserCourse.__name__.lower()):
_add_user_course_constaint()
Expand Down
24 changes: 24 additions & 0 deletions lms/lmsdb/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,7 @@ class User(UserMixin, BaseModel):
api_key = CharField()
last_course_viewed = ForeignKeyField(Course, null=True)
uuid = UUIDField(default=uuid4, unique=True)
mail_subscription = BooleanField(default=True)

def get_id(self):
return str(self.uuid)
Expand Down Expand Up @@ -375,6 +376,29 @@ def on_notification_saved(
instance.delete_instance()


class NotificationMail(BaseModel):
user = ForeignKeyField(User, unique=True)
number = IntegerField(default=1)
message = TextField()

@classmethod
def get_or_create_notification_mail(cls, user: User, message: str):
instance, created = cls.get_or_create(**{
cls.user.name: user,
}, defaults={
cls.message.name: message,
})
if not created:
instance.message += f'\n{message}'
instance.number += 1
instance.save()
return instance

@classmethod
def get_instances_number(cls):
return cls.select(fn.Count(cls.id))


class Exercise(BaseModel):
subject = CharField()
date = DateTimeField()
Expand Down
4 changes: 4 additions & 0 deletions lms/lmsweb/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import typing

from flask import Flask
from flask_apscheduler import APScheduler # type: ignore
from flask_babel import Babel # type: ignore
from flask_httpauth import HTTPBasicAuth
from flask_limiter import Limiter # type: ignore
Expand Down Expand Up @@ -48,6 +49,9 @@

webmail = Mail(webapp)

webscheduler = APScheduler(app=webapp)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why this scheduler is part of lmsweb? is it running under the WSGI app?
if so, it's bad. you should use the celery scheduler and run specific daemon for that

webscheduler.start()


# Must import files after app's creation
from lms.lmsdb import models # NOQA: F401, E402, I202
Expand Down
4 changes: 4 additions & 0 deletions lms/lmsweb/config.py.example
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,10 @@ LOCALE = 'en'
LIMITS_PER_MINUTE = 5
LIMITS_PER_HOUR = 50

# Scheduler
SCHEDULER_API_ENABLED = True
DEFAULT_DO_TASKS_EVERY_HOURS = 2

# Change password settings
MAX_INVALID_PASSWORD_TRIES = 5

Expand Down
44 changes: 30 additions & 14 deletions lms/lmsweb/translations/he/LC_MESSAGES/messages.po
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ msgid ""
msgstr ""
"Project-Id-Version: 1.0\n"
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
"POT-Creation-Date: 2021-10-03 22:01+0300\n"
"POT-Creation-Date: 2021-10-05 21:56+0300\n"
"PO-Revision-Date: 2021-09-29 11:30+0300\n"
"Last-Translator: Or Ronai\n"
"Language: he\n"
Expand Down Expand Up @@ -293,7 +293,7 @@ msgstr "חמ\"ל תרגילים"
msgid "Name"
msgstr "שם"

#: templates/status.html:13 templates/user.html:44
#: templates/status.html:13 templates/user.html:49
msgid "Checked"
msgstr "נבדק/ו"

Expand Down Expand Up @@ -341,27 +341,31 @@ msgstr "פרטי משתמש"
msgid "Actions"
msgstr "פעולות"

#: templates/user.html:24
#: templates/user.html:23
msgid "Mail Subscription"
msgstr "מנוי לדואר"

#: templates/user.html:30
msgid "Exercises Submitted"
msgstr "תרגילים שהוגשו"

#: templates/user.html:29
#: templates/user.html:35
msgid "Course name"
msgstr "שם קורס"

#: templates/user.html:30
#: templates/user.html:36
msgid "Exercise name"
msgstr "שם תרגיל"

#: templates/user.html:31
#: templates/user.html:37
msgid "Submission status"
msgstr "מצב הגשה"

#: templates/user.html:32
#: templates/user.html:38
msgid "Submission"
msgstr "הגשה"

#: templates/user.html:33
#: templates/user.html:39
msgid "Checker"
msgstr "בודק"

Expand Down Expand Up @@ -465,12 +469,12 @@ msgstr "הערות בודק"
msgid "Done Checking"
msgstr "סיום בדיקה"

#: utils/mail.py:25
#: utils/mail.py:28
#, python-format
msgid "Confirmation mail - %(site_name)s"
msgstr "מייל אימות - %(site_name)s"

#: utils/mail.py:32
#: utils/mail.py:35
#, python-format
msgid ""
"Hello %(fullname)s,\n"
Expand All @@ -479,12 +483,12 @@ msgstr ""
"שלום %(fullname)s,\n"
"לינק האימות שלך למערכת הוא: %(link)s"

#: utils/mail.py:42
#: utils/mail.py:45
#, python-format
msgid "Reset password mail - %(site_name)s"
msgstr "מייל איפוס סיסמה - %(site_name)s"

#: utils/mail.py:49
#: utils/mail.py:52
#, python-format
msgid ""
"Hello %(fullname)s,\n"
Expand All @@ -493,12 +497,12 @@ msgstr ""
"שלום %(fullname)s,\n"
"לינק לצורך איפוס הסיסמה שלך הוא: %(link)s"

#: utils/mail.py:58
#: utils/mail.py:61
#, python-format
msgid "Changing password - %(site_name)s"
msgstr "שינוי סיסמה - %(site_name)s"

#: utils/mail.py:62
#: utils/mail.py:65
#, python-format
msgid ""
"Hello %(fullname)s. Your password in %(site_name)s site has been changed."
Expand All @@ -510,3 +514,15 @@ msgstr ""
"אם אתה לא עשית את זה צור קשר עם הנהלת האתר.\n"
"כתובת המייל: %(site_mail)s"

#: utils/mail.py:77
#, fuzzy, python-format
msgid "New notification - %(site_name)s"
msgstr "התראה חדשה - %(site_name)s"

#: utils/mail.py:81
#, python-format
msgid ""
"Hello %(fullname)s. You have %(number)d new notification/s:\n"
"%(message)s"
msgstr "היי %(fullname)s. יש לך %(number)d התראה/ות חדשה/ות:\n%(message)s"

8 changes: 7 additions & 1 deletion lms/lmsweb/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@
PERMISSIVE_CORS, get_next_url, login_manager,
)
from lms.models import (
comments, notes, notifications, share_link, solutions, upload,
comments, notes, notifications, share_link, solutions, upload, users,
)
from lms.models.errors import (
FileSizeError, ForbiddenPermission, LmsError,
Expand Down Expand Up @@ -370,6 +370,12 @@ def read_all_notification():
return jsonify({'success': success_state})


@webapp.route('/subscribe/<act>', methods=['PATCH'])
def mail_subscription(act: str):
success_state = users.change_mail_subscription(current_user, act)
return jsonify({'success': success_state})


@webapp.route('/share', methods=['POST'])
@login_required
def share():
Expand Down
6 changes: 4 additions & 2 deletions lms/models/notifications.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import enum
from typing import Iterable, Optional

from lms.lmsdb.models import Notification, User
from lms.lmsdb.models import Notification, NotificationMail, User


class NotificationKind(enum.Enum):
Expand Down Expand Up @@ -45,6 +45,8 @@ def send(
related_id: Optional[int] = None,
action_url: Optional[str] = None,
) -> Notification:
return Notification.send(
notification = Notification.send(
user, kind.value, message, related_id, action_url,
)
NotificationMail.get_or_create_notification_mail(user, message)
return notification
11 changes: 11 additions & 0 deletions lms/models/users.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,3 +38,14 @@ def auth(username: str, password: str) -> User:

def generate_user_token(user: User) -> str:
return SERIALIZER.dumps(user.mail_address, salt=retrieve_salt(user))


def change_mail_subscription(user: User, act: str):
if act == 'subscribe':
user.mail_subscription = True
elif act == 'unsubscribe':
user.mail_subscription = False
else:
return False
user.save()
return True
6 changes: 6 additions & 0 deletions lms/static/my.css
Original file line number Diff line number Diff line change
Expand Up @@ -857,6 +857,12 @@ code .grader-add .fa {
margin-bottom: 5em;
}

.mail-subscription {
position: relative;
display: inline-block;
margin-right: -1.4rem;
}

.user-notes {
display: flex;
flex-flow: row wrap;
Expand Down
15 changes: 15 additions & 0 deletions lms/static/my.js
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,20 @@ function trackReadAllNotificationsButton(button) {
});
}

function trackMailSubscriptionCheckbox() {
const checkbox = document.getElementById('mail-subscription-checkbox');
if (checkbox === null) {
return;
}

checkbox.addEventListener('change', (e) => {
const act = (e.currentTarget.checked) ? 'subscribe' : 'unsubscribe';
const request = new XMLHttpRequest();
request.open('PATCH', `/subscribe/${act}`);
return request.send();
});
}

function postUploadMessageUpdate(feedbacks, uploadStatus, matchesSpan, missesSpan) {
const matches = uploadStatus.exercise_matches;
const misses = uploadStatus.exercise_misses;
Expand Down Expand Up @@ -158,6 +172,7 @@ window.isUserGrader = isUserGrader;
window.addEventListener('load', () => {
updateNotificationsBadge();
trackReadAllNotificationsButton(document.getElementById('read-notifications'));
trackMailSubscriptionCheckbox();
const codeElement = document.getElementById('code-view');
if (codeElement !== null) {
const codeElementData = codeElement.dataset;
Expand Down
6 changes: 6 additions & 0 deletions lms/templates/user.html
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,12 @@ <h2>{{ _('Actions') }}:</h2>
<div id="change-password-user">
<ul>
<li><a href="{{ url_for('change_password') }}" role="button">{{ _('Change Password') }}</a></li>
<div class="form-check mail-subscription">
<input class="form-check-input" type="checkbox" value="" id="mail-subscription-checkbox"{% if current_user.mail_subscription %} checked{% endif %}>
<label class="form-check-label" for="mail-subscription-checkbox">
{{ _('Mail Subscription') }}
</label>
</div>
</ul>
</div>
</div>
Expand Down
5 changes: 5 additions & 0 deletions lms/utils/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
from lms.utils.config import celery
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You put this both in utils/ and in utils/config/. Are they both necessary?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd like to have some help there

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@gal432 , can you give a hand?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am not sure I understand why did you do that...


celery_app = celery.app

__all__ = ('celery_app',)
5 changes: 5 additions & 0 deletions lms/utils/config/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
from lms.utils.config import celery

celery_app = celery.app

__all__ = ('celery_app',)
Loading