Merge pull request #207 from simple-login/notification

Notification
This commit is contained in:
Son Nguyen Kim 2020-05-23 20:55:00 +02:00 committed by GitHub
commit d371b2ec2d
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
10 changed files with 335 additions and 8 deletions

View file

@ -1219,6 +1219,34 @@ If success, 200.
} }
``` ```
### Notification endpoints
#### GET /api/notifications
Get notifications
Input:
- `Authentication` in header: the api key
- page in url: the page number, starts at 0
Output:
List of notification, each notification has:
- id
- message: the message in html
- read: whether the user has read the notification
- created_at: when the notification is created
#### POST /api/notifications/:notification_id
Mark a notification as read
Input:
- `Authentication` in header: the api key
- notification_id in url: the page number, starts at 0
Output:
200 if success
### Misc endpoints ### Misc endpoints
#### POST /api/apple/process_payment #### POST /api/apple/process_payment

View file

@ -8,4 +8,5 @@ from .views import (
alias, alias,
apple, apple,
mailbox, mailbox,
notification,
) )

View file

@ -0,0 +1,81 @@
from time import sleep
from flask import g
from flask import jsonify
from flask import request
from flask_cors import cross_origin
from app.api.base import api_bp, require_api_auth
from app.config import PAGE_LIMIT
from app.extensions import db
from app.models import Notification
@api_bp.route("/notifications", methods=["GET"])
@cross_origin()
@require_api_auth
def get_notifications():
"""
Get notifications
Input:
- page: in url. Starts at 0
Output: list of notifications. Each notification has the following field:
- id
- message
- read
- created_at
"""
user = g.user
try:
page = int(request.args.get("page"))
except (ValueError, TypeError):
return jsonify(error="page must be provided in request query"), 400
notifications = (
Notification.query.filter_by(user_id=user.id)
.order_by(Notification.read, Notification.created_at.desc())
.limit(PAGE_LIMIT)
.offset(page * PAGE_LIMIT)
.all()
)
return (
jsonify(
[
{
"id": notification.id,
"message": notification.message,
"read": notification.read,
"created_at": notification.created_at.humanize(),
}
for notification in notifications
]
),
200,
)
@api_bp.route("/notifications/<notification_id>/read", methods=["POST"])
@cross_origin()
@require_api_auth
def mark_as_read(notification_id):
"""
Mark a notification as read
Input:
notification_id: in url
Output:
200 if updated successfully
"""
user = g.user
notification = Notification.get(notification_id)
if not notification or notification.user_id != user.id:
return jsonify(error="Forbidden"), 403
notification.read = True
db.session.commit()
return jsonify(done=True), 200

View file

@ -710,7 +710,6 @@
}) })
</script> </script>
<script src="{{ url_for('static', filename='node_modules/vue/dist/vue.min.js') }}"></script>
<script> <script>
var app = new Vue({ var app = new Vue({
el: '#filter-app', el: '#filter-app',

View file

@ -1417,3 +1417,11 @@ class RecoveryCode(db.Model, ModelMixin):
"""Delete all recovery codes for user""" """Delete all recovery codes for user"""
cls.query.filter_by(user_id=user.id).delete() cls.query.filter_by(user_id=user.id).delete()
db.session.commit() db.session.commit()
class Notification(db.Model, ModelMixin):
user_id = db.Column(db.ForeignKey(User.id, ondelete="cascade"), nullable=False)
message = db.Column(db.Text, nullable=False)
# whether user has marked the notification as read
read = db.Column(db.Boolean, nullable=False, default=False)

View file

@ -0,0 +1,38 @@
"""empty message
Revision ID: 00532ac6d4bc
Revises: 0e08145f0499
Create Date: 2020-05-23 19:54:24.984674
"""
import sqlalchemy_utils
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '00532ac6d4bc'
down_revision = '0e08145f0499'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('notification',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('created_at', sqlalchemy_utils.types.arrow.ArrowType(), nullable=False),
sa.Column('updated_at', sqlalchemy_utils.types.arrow.ArrowType(), nullable=True),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('message', sa.Text(), nullable=False),
sa.Column('read', sa.Boolean(), nullable=False),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='cascade'),
sa.PrimaryKeyConstraint('id')
)
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table('notification')
# ### end Alembic commands ###

View file

@ -53,6 +53,7 @@ from app.models import (
EmailLog, EmailLog,
Referral, Referral,
AliasMailbox, AliasMailbox,
Notification,
) )
from app.monitor.base import monitor_bp from app.monitor.base import monitor_bp
from app.oauth.base import oauth_bp from app.oauth.base import oauth_bp
@ -247,6 +248,10 @@ def fake_data():
referral = Referral.create(user_id=user.id, code="REFCODE", name="First referral") referral = Referral.create(user_id=user.id, code="REFCODE", name="First referral")
db.session.commit() db.session.commit()
for i in range(6):
Notification.create(user_id=user.id, message=f"""Hey hey <b>{i}</b> """ * 10)
db.session.commit()
User.create( User.create(
email="winston@continental.com", email="winston@continental.com",
name="Winston", name="Winston",

View file

@ -38,4 +38,81 @@
</div> </div>
</div> </div>
</div> </div>
</footer> </footer>
<script>
function startIntro() {
introJs().setOption('showProgress', true).start();
introJs().start();
}
</script>
<script src="{{ url_for('static', filename='node_modules/vue/dist/vue.min.js') }}"></script>
<script>
var app = new Vue({
el: '#notification-app',
delimiters: ["[[", "]]"], // necessary to avoid conflict with jinja
data: {
notifications: [],
page: 0,
loading: true,
canLoadMore: true
},
computed: {
has_non_read_notification: function () {
for (let i = 0; i < this.notifications.length; i++) {
if (!this.notifications[i].read) return true;
}
return false;
}
},
methods: {
markAsRead: async function (notification) {
notification.read = true;
let res = await fetch(`/api/notifications/${notification.id}/read`, {
method: "POST",
headers: {
"Content-Type": "application/json",
}
});
if (!res.ok) {
toastr.error("Sorry for the inconvenience! Could you refresh the page & retry please?", "Unknown Error");
}
},
loadMore: async function () {
let that = this;
that.page += 1;
let res = await fetch(`/api/notifications?page=${that.page}`, {
method: "GET",
headers: {
"Content-Type": "application/json",
}
});
if (res.ok) {
let json = await res.json();
if (json.length == 0) that.canLoadMore = false;
that.notifications = that.notifications.concat(json);
}
}
},
async mounted() {
let that = this;
let res = await fetch(`/api/notifications?page=${that.page}`, {
method: "GET",
headers: {
"Content-Type": "application/json",
}
});
if (res.ok) {
let json = await res.json();
that.notifications = json;
that.loading = false;
}
}
})
</script>

View file

@ -15,6 +15,38 @@
</div> </div>
{% endif %} {% endif %}
<div id="notification-app" class="dropdown d-none d-md-flex">
<a class="nav-link icon" data-toggle="collapse" href="#notifications" style="height: 100%">
<i class="fe fe-bell"></i>
<span v-if="has_non_read_notification" class="nav-unread"></span>
<span v-else class="nav-read"></span>
</a>
<div class="dropdown-menu dropdown-menu-right dropdown-menu-arrow collapse" id="notifications">
<div v-if="loading">Loading ...</div>
<div class="dropdown-item d-flex" v-for="notification in notifications">
<div class="flex-grow-1">
<div v-html="notification.message"
style="width: 40em; word-wrap:break-word; white-space: normal"></div>
<div class="small text-muted">
[[notification.created_at]]
</div>
</div>
<div v-if="!notification.read">
<i class="fe fe-check"
@click="markAsRead(notification)"
data-toggle="tooltip" title="mark as read"></i>
</div>
</div>
<div class="text-center">
<button v-if="canLoadMore" @click="loadMore()" class="btn btn-link">Load more</button>
</div>
</div>
</div>
<div class="dropdown"> <div class="dropdown">
<a href="#" class="nav-link pr-0 leading-none" data-toggle="dropdown"> <a href="#" class="nav-link pr-0 leading-none" data-toggle="dropdown">
{% if current_user.profile_picture_id %} {% if current_user.profile_picture_id %}
@ -74,9 +106,4 @@
</div> </div>
</div> </div>
<script>
function startIntro() {
introJs().setOption('showProgress', true).start();
introJs().start();
}
</script>

View file

@ -0,0 +1,63 @@
from flask import url_for
from app.extensions import db
from app.models import User, ApiKey, Notification
def test_get_notifications(flask_client):
user = User.create(
email="a@b.c", password="password", name="Test User", activated=True
)
db.session.commit()
# create api_key
api_key = ApiKey.create(user.id, "for test")
db.session.commit()
# create some notifications
Notification.create(user_id=user.id, message="Test message 1")
Notification.create(user_id=user.id, message="Test message 2")
db.session.commit()
r = flask_client.get(
url_for("api.get_notifications", page=0),
headers={"Authentication": api_key.code},
)
assert r.status_code == 200
assert len(r.json) == 2
for n in r.json:
assert n["id"] > 0
assert n["message"]
assert n["read"] is False
assert n["created_at"]
# no more post at the next page
r = flask_client.get(
url_for("api.get_notifications", page=1),
headers={"Authentication": api_key.code},
)
assert len(r.json) == 0
def test_mark_notification_as_read(flask_client):
user = User.create(
email="a@b.c", password="password", name="Test User", activated=True
)
db.session.commit()
# create api_key
api_key = ApiKey.create(user.id, "for test")
db.session.commit()
Notification.create(id=1, user_id=user.id, message="Test message 1")
db.session.commit()
r = flask_client.post(
url_for("api.mark_as_read", notification_id=1),
headers={"Authentication": api_key.code},
)
assert r.status_code == 200
notification = Notification.get(1)
assert notification.read