mirror of
https://github.com/simple-login/app.git
synced 2024-11-14 08:01:13 +01:00
commit
d371b2ec2d
10 changed files with 335 additions and 8 deletions
28
README.md
28
README.md
|
@ -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
|
||||||
|
|
||||||
|
|
|
@ -8,4 +8,5 @@ from .views import (
|
||||||
alias,
|
alias,
|
||||||
apple,
|
apple,
|
||||||
mailbox,
|
mailbox,
|
||||||
|
notification,
|
||||||
)
|
)
|
||||||
|
|
81
app/api/views/notification.py
Normal file
81
app/api/views/notification.py
Normal 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
|
|
@ -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',
|
||||||
|
|
|
@ -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)
|
||||||
|
|
38
migrations/versions/2020_052319_00532ac6d4bc_.py
Normal file
38
migrations/versions/2020_052319_00532ac6d4bc_.py
Normal 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 ###
|
|
@ -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",
|
||||||
|
|
|
@ -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>
|
|
@ -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>
|
|
||||||
|
|
63
tests/api/test_notification.py
Normal file
63
tests/api/test_notification.py
Normal 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
|
Loading…
Reference in a new issue