mirror of
https://github.com/simple-login/app.git
synced 2024-11-13 07:31:12 +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
|
||||
#### POST /api/apple/process_payment
|
||||
|
||||
|
|
|
@ -8,4 +8,5 @@ from .views import (
|
|||
alias,
|
||||
apple,
|
||||
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 src="{{ url_for('static', filename='node_modules/vue/dist/vue.min.js') }}"></script>
|
||||
<script>
|
||||
var app = new Vue({
|
||||
el: '#filter-app',
|
||||
|
|
|
@ -1417,3 +1417,11 @@ class RecoveryCode(db.Model, ModelMixin):
|
|||
"""Delete all recovery codes for user"""
|
||||
cls.query.filter_by(user_id=user.id).delete()
|
||||
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,
|
||||
Referral,
|
||||
AliasMailbox,
|
||||
Notification,
|
||||
)
|
||||
from app.monitor.base import monitor_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")
|
||||
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(
|
||||
email="winston@continental.com",
|
||||
name="Winston",
|
||||
|
|
|
@ -38,4 +38,81 @@
|
|||
</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>
|
||||
{% 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">
|
||||
<a href="#" class="nav-link pr-0 leading-none" data-toggle="dropdown">
|
||||
{% if current_user.profile_picture_id %}
|
||||
|
@ -74,9 +106,4 @@
|
|||
</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