Merge pull request #135 from simple-login/alias-pagination

Alias pagination, support sorting
This commit is contained in:
Son Nguyen Kim 2020-04-25 14:02:50 +02:00 committed by GitHub
commit 51676f02b5
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
23 changed files with 220 additions and 160 deletions

View File

@ -1009,7 +1009,8 @@ Update alias note. In the future, the endpoint will support other updates (e.g.
Input:
- `Authentication` header that contains the api key
- `alias_id` in url.
- `note` in request body
- (optional) `note` in request body
- (optional) `mailbox_id` in request body
Output:
If success, return 200

View File

@ -3,6 +3,7 @@ from functools import wraps
import arrow
from flask import Blueprint, request, jsonify, g
from flask_login import current_user
from app.extensions import db
from app.models import ApiKey

View File

@ -2,6 +2,7 @@ from dataclasses import dataclass
from arrow import Arrow
from sqlalchemy import or_, func, case
from sqlalchemy.orm import joinedload
from app.config import PAGE_LIMIT
from app.extensions import db
@ -11,6 +12,7 @@ from app.models import Alias, Contact, EmailLog, Mailbox
@dataclass
class AliasInfo:
alias: Alias
mailbox: Mailbox
nb_forward: int
nb_blocked: int
@ -89,6 +91,7 @@ def get_alias_infos_with_pagination(user, page_id=0, query=None) -> [AliasInfo]:
ret = []
q = (
db.session.query(Alias)
.options(joinedload(Alias.mailbox))
.filter(Alias.user_id == user.id)
.order_by(Alias.created_at.desc())
)
@ -106,7 +109,9 @@ def get_alias_infos_with_pagination(user, page_id=0, query=None) -> [AliasInfo]:
return ret
def get_alias_infos_with_pagination_v2(user, page_id=0, query=None) -> [AliasInfo]:
def get_alias_infos_with_pagination_v2(
user, page_id=0, query=None, sort=None
) -> [AliasInfo]:
ret = []
latest_activity = func.max(
case(
@ -119,12 +124,11 @@ def get_alias_infos_with_pagination_v2(user, page_id=0, query=None) -> [AliasInf
).label("latest")
q = (
db.session.query(Alias, latest_activity)
db.session.query(Alias, Mailbox, latest_activity)
.join(Contact, Alias.id == Contact.alias_id, isouter=True)
.join(EmailLog, Contact.id == EmailLog.contact_id, isouter=True)
.filter(Alias.user_id == user.id)
.group_by(Alias.id)
.order_by(latest_activity.desc())
.filter(Alias.mailbox_id == Mailbox.id)
)
if query:
@ -132,10 +136,18 @@ def get_alias_infos_with_pagination_v2(user, page_id=0, query=None) -> [AliasInf
or_(Alias.email.ilike(f"%{query}%"), Alias.note.ilike(f"%{query}%"))
)
if sort == "old2new":
q = q.order_by(Alias.created_at)
else:
# default sorting
q = q.order_by(latest_activity.desc())
q = q.group_by(Alias.id, Mailbox.id)
q = q.limit(PAGE_LIMIT).offset(page_id * PAGE_LIMIT)
for alias, latest_activity in q:
ret.append(get_alias_info_v2(alias))
for alias, mailbox, latest_activity in q:
ret.append(get_alias_info_v2(alias, mailbox))
return ret
@ -147,7 +159,9 @@ def get_alias_info(alias: Alias) -> AliasInfo:
.filter(EmailLog.contact_id == Contact.id)
)
alias_info = AliasInfo(alias=alias, nb_blocked=0, nb_forward=0, nb_reply=0,)
alias_info = AliasInfo(
alias=alias, nb_blocked=0, nb_forward=0, nb_reply=0, mailbox=alias.mailbox
)
for _, el in q:
if el.is_reply:
@ -160,7 +174,7 @@ def get_alias_info(alias: Alias) -> AliasInfo:
return alias_info
def get_alias_info_v2(alias: Alias) -> AliasInfo:
def get_alias_info_v2(alias: Alias, mailbox) -> AliasInfo:
q = (
db.session.query(Contact, EmailLog)
.filter(Contact.alias_id == alias.id)
@ -171,7 +185,9 @@ def get_alias_info_v2(alias: Alias) -> AliasInfo:
latest_email_log = None
latest_contact = None
alias_info = AliasInfo(alias=alias, nb_blocked=0, nb_forward=0, nb_reply=0,)
alias_info = AliasInfo(
alias=alias, nb_blocked=0, nb_forward=0, nb_reply=0, mailbox=mailbox
)
for contact, email_log in q:
if email_log.is_reply:

View File

@ -19,7 +19,7 @@ from app.dashboard.views.alias_log import get_alias_log
from app.email_utils import parseaddr_unicode
from app.extensions import db
from app.log import LOG
from app.models import Alias, Contact
from app.models import Alias, Contact, Mailbox
from app.utils import random_string
@ -234,8 +234,6 @@ def update_alias(alias_id):
note: in body
Output:
200
"""
data = request.get_json()
if not data:
@ -247,11 +245,25 @@ def update_alias(alias_id):
if alias.user_id != user.id:
return jsonify(error="Forbidden"), 403
new_note = data.get("note")
alias.note = new_note
db.session.commit()
changed = False
if "note" in data:
new_note = data.get("note")
alias.note = new_note
changed = True
return jsonify(note=new_note), 200
if "mailbox_id" in data:
mailbox_id = int(data.get("mailbox_id"))
mailbox = Mailbox.get(mailbox_id)
if not mailbox or mailbox.user_id != user.id or not mailbox.verified:
return jsonify(error="Forbidden"), 400
alias.mailbox_id = mailbox_id
changed = True
if changed:
db.session.commit()
return jsonify(ok=True), 200
@api_bp.route("/aliases/<int:alias_id>", methods=["GET"])
@ -374,8 +386,6 @@ def delete_contact(contact_id):
contact_id: in url
Output:
200
"""
user = g.user
contact = Contact.get(contact_id)

View File

@ -1,7 +1,7 @@
import pyotp
from flask import jsonify, request
from flask_cors import cross_origin
from itsdangerous import Signer, BadSignature
from itsdangerous import Signer
from app.api.base import api_bp
from app.config import FLASK_SECRET

View File

@ -6,11 +6,11 @@ from wtforms import StringField, validators
from app import email_utils, config
from app.auth.base import auth_bp
from app.auth.views.login_utils import get_referral
from app.config import URL, DISABLE_REGISTRATION
from app.config import URL
from app.email_utils import can_be_used_as_personal_email, email_already_used
from app.extensions import db
from app.log import LOG
from app.models import User, ActivationCode, Referral
from app.models import User, ActivationCode
from app.utils import random_string, encode_url

View File

@ -1,4 +1,3 @@
import arrow
from flask import request, flash, render_template, redirect, url_for
from flask_login import login_user
from flask_wtf import FlaskForm

View File

@ -2,8 +2,6 @@ import os
import random
import string
import subprocess
import tempfile
from uuid import uuid4
from dotenv import load_dotenv

View File

@ -63,8 +63,19 @@
<div class="col-lg-6 pt-1">
<div class="float-right">
<form method="get" class="form-inline">
<select name="sort"
onchange="this.form.submit()"
class="form-control custom-select mr-3">
<option value="" {% if sort == "" %} selected {% endif %}>
Sort by most recent activity
</option>
<option value="old2new" {% if sort == "old2new" %} selected {% endif %}>
Oldest Alias to Newest
</option>
</select>
<input type="search" name="query" placeholder="Enter to search for alias"
class="form-control shadow text-right"
class="form-control shadow"
style="max-width: 15em"
value="{{ query }}">
</form>
@ -77,7 +88,7 @@
{% set alias = alias_info.alias %}
<div class="col-12 col-lg-6">
<div class="card p-4 shadow-sm {% if alias_info.highlight %} highlight-row {% endif %} ">
<div class="card p-4 shadow-sm {% if alias_info.alias.id == highlight_alias_id %} highlight-row {% endif %} ">
<div class="row">
<div class="col-8">
@ -179,7 +190,8 @@
<div class="row">
<div class="col">
<a href="{{ url_for('dashboard.alias_contact_manager', alias_id=alias.id) }}"
{% if alias_info.show_intro_test_send_email %}
id="send-email-{{ alias.id }}"
{% if loop.index ==1 %}
data-intro="Not only alias can receive emails, it can <em>send</em> emails too! <br><br>
You can add a new <em>contact</em> to for your alias here. <br><br>
To send an email to your contact, SimpleLogin will create a <em>special</em> email address. <br><br>
@ -210,10 +222,11 @@
<div class="small-text mt-2">Current mailbox</div>
<div class="d-flex">
<div class="flex-grow-1 mr-2">
<select class="form-control form-control-sm custom-select" name="mailbox">
<select id="mailbox-{{ alias.id }}"
class="form-control form-control-sm custom-select" name="mailbox">
{% for mailbox in mailboxes %}
<option value="{{ mailbox }}" {% if mailbox == alias_info.mailbox.email %} selected {% endif %}>
{{ mailbox }}
<option value="{{ mailbox.id }}" {% if mailbox.id == alias_info.mailbox.id %} selected {% endif %}>
{{ mailbox.email }}
</option>
{% endfor %}
</select>
@ -223,9 +236,10 @@
<input type="hidden" name="form-name" value="set-mailbox">
<input type="hidden" name="alias-id" value="{{ alias.id }}">
<button class="btn btn-sm btn-outline-info w-100">
<a data-alias="{{ alias.id }}"
class="save-mailbox btn btn-sm btn-outline-info w-100">
Update
</button>
</a>
</div>
</div>
@ -241,6 +255,7 @@
<div class="flex-grow-1 mr-2">
<textarea
id="note-{{ alias.id }}"
name="note"
class="form-control"
rows="2"
@ -251,9 +266,10 @@
<input type="hidden" name="form-name" value="set-note">
<input type="hidden" name="alias-id" value="{{ alias.id }}">
<button class="btn btn-sm btn-outline-success w-100">
<a data-alias="{{ alias.id }}"
class="save-note btn btn-sm btn-outline-success w-100">
Save
</button>
</a>
</div>
</div>
</form>
@ -279,6 +295,23 @@
{% endfor %}
</div>
<div class="row">
<div class="col">
<nav aria-label="Alias navigation">
<ul class="pagination">
<li class="page-item {% if page == 0 %}disabled{% endif %}">
<a class="page-link"
href="{{ url_for('dashboard.index', page=page-1, query=query, sort=sort) }}">Previous</a>
</li>
<li class="page-item {% if last_page %}disabled{% endif %}">
<a class="page-link"
href="{{ url_for('dashboard.index', page=page+1, query=query, sort=sort) }}">Next</a>
</li>
</ul>
</nav>
</div>
</div>
{% if client_users %}
<div class="page-header row">
@ -393,7 +426,6 @@
let aliasId = $(this).data("alias");
let alias = $(this).parent().find(".alias").val();
try {
let res = await fetch(`/api/aliases/${aliasId}/toggle`, {
method: "POST",
@ -424,6 +456,69 @@
var oldValue = !$(this).prop("checked");
$(this).prop("checked", oldValue);
}
})
$(".save-note").on("click", async function () {
let aliasId = $(this).data("alias");
let note = $(`#note-${aliasId}`).val();
try {
let res = await fetch(`/api/aliases/${aliasId}`, {
method: "PUT",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
note: note,
}),
});
if (res.ok) {
toastr.success(`Saved`);
} else {
toastr.error("Sorry for the inconvenience! Could you refresh the page & retry please?", "Unknown Error");
// reset to the original value
var oldValue = !$(this).prop("checked");
$(this).prop("checked", oldValue);
}
} catch (e) {
toastr.error("Sorry for the inconvenience! Could you refresh the page & retry please?", "Unknown Error");
// reset to the original value
var oldValue = !$(this).prop("checked");
$(this).prop("checked", oldValue);
}
})
$(".save-mailbox").on("click", async function () {
let aliasId = $(this).data("alias");
let mailbox_id = $(`#mailbox-${aliasId}`).val();
try {
let res = await fetch(`/api/aliases/${aliasId}`, {
method: "PUT",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
mailbox_id: mailbox_id,
}),
});
if (res.ok) {
toastr.success(`Mailbox Updated`);
} else {
toastr.error("Sorry for the inconvenience! Could you refresh the page & retry please?", "Unknown Error");
// reset to the original value
var oldValue = !$(this).prop("checked");
$(this).prop("checked", oldValue);
}
} catch (e) {
toastr.error("Sorry for the inconvenience! Could you refresh the page & retry please?", "Unknown Error");
// reset to the original value
var oldValue = !$(this).prop("checked");
$(this).prop("checked", oldValue);
}
})
</script>

View File

@ -1,5 +1,4 @@
import re
from email.utils import parseaddr
from flask import render_template, request, redirect, url_for, flash
from flask_login import login_required, current_user

View File

@ -3,9 +3,9 @@ from flask_login import login_required, current_user
from app.config import PADDLE_MONTHLY_PRODUCT_ID, PADDLE_YEARLY_PRODUCT_ID
from app.dashboard.base import dashboard_bp
from app.extensions import db
from app.log import LOG
from app.models import Subscription, PlanEnum
from app.extensions import db
from app.paddle_utils import cancel_subscription, change_plan

View File

@ -40,7 +40,7 @@ def custom_alias():
)
)
mailboxes = current_user.mailboxes()
mailboxes = [mb.email for mb in current_user.mailboxes()]
if request.method == "POST":
alias_prefix = request.form.get("prefix")

View File

@ -1,48 +1,32 @@
from dataclasses import dataclass
from arrow import Arrow
from flask import render_template, request, redirect, url_for, flash
from flask_login import login_required, current_user
from sqlalchemy import or_
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import joinedload
from app import email_utils
from app.api.serializer import get_alias_infos_with_pagination_v2
from app.dashboard.base import dashboard_bp
from app.extensions import db
from app.log import LOG
from app.models import (
Alias,
ClientUser,
Contact,
EmailLog,
DeletedAlias,
AliasGeneratorEnum,
Mailbox,
)
@dataclass
class AliasInfo:
alias: Alias
mailbox: Mailbox
nb_forward: int
nb_blocked: int
nb_reply: int
latest_activity: Arrow
latest_email_log: EmailLog = None
latest_contact: Contact = None
show_intro_test_send_email: bool = False
highlight: bool = False
@dashboard_bp.route("/", methods=["GET", "POST"])
@login_required
def index():
query = request.args.get("query") or ""
sort = request.args.get("sort") or ""
page = 0
if request.args.get("page"):
page = int(request.args.get("page"))
highlight_alias_id = None
if request.args.get("highlight_alias_id"):
highlight_alias_id = int(request.args.get("highlight_alias_id"))
@ -187,80 +171,12 @@ def index():
return render_template(
"dashboard/index.html",
client_users=client_users,
alias_infos=get_alias_infos(current_user, query, highlight_alias_id),
alias_infos=get_alias_infos_with_pagination_v2(current_user, page, query, sort),
highlight_alias_id=highlight_alias_id,
query=query,
AliasGeneratorEnum=AliasGeneratorEnum,
mailboxes=mailboxes,
show_intro=show_intro,
page=page,
sort=sort,
)
def get_alias_infos(user, query=None, highlight_alias_id=None) -> [AliasInfo]:
if query:
query = query.strip().lower()
aliases = {} # dict of alias email and AliasInfo
q = (
db.session.query(Alias, Contact, EmailLog, Mailbox)
.join(Contact, Alias.id == Contact.alias_id, isouter=True)
.join(EmailLog, Contact.id == EmailLog.contact_id, isouter=True)
.join(Mailbox, Alias.mailbox_id == Mailbox.id, isouter=True)
.filter(Alias.user_id == user.id)
.order_by(Alias.created_at.desc())
)
if query:
q = q.filter(
or_(Alias.email.ilike(f"%{query}%"), Alias.note.ilike(f"%{query}%"))
)
for alias, contact, email_log, mailbox in q:
if alias.email not in aliases:
aliases[alias.email] = AliasInfo(
alias=alias,
mailbox=mailbox,
nb_blocked=0,
nb_forward=0,
nb_reply=0,
highlight=alias.id == highlight_alias_id,
latest_activity=alias.created_at,
)
alias_info = aliases[alias.email]
if not email_log:
continue
if email_log.created_at > alias_info.latest_activity:
alias_info.latest_activity = email_log.created_at
alias_info.latest_email_log = email_log
alias_info.latest_contact = contact
if email_log.is_reply:
alias_info.nb_reply += 1
elif email_log.blocked:
alias_info.nb_blocked += 1
else:
alias_info.nb_forward += 1
ret = list(aliases.values())
ret = sorted(ret, key=lambda a: a.latest_activity, reverse=True)
# make sure the highlighted alias is the first element
highlight_index = None
for ix, alias in enumerate(ret):
if alias.highlight:
highlight_index = ix
break
if highlight_index:
ret.insert(0, ret.pop(highlight_index))
# only show intro on the first enabled alias
for alias in ret:
if alias.alias.enabled:
alias.show_intro_test_send_email = True
break
return ret

View File

@ -3,13 +3,7 @@ from flask_login import login_required, current_user
from flask_wtf import FlaskForm
from wtforms import StringField, validators
from app.config import (
PADDLE_VENDOR_ID,
PADDLE_MONTHLY_PRODUCT_ID,
PADDLE_YEARLY_PRODUCT_ID,
URL,
ADMIN_EMAIL,
)
from app.config import ADMIN_EMAIL
from app.dashboard.base import dashboard_bp
from app.email_utils import send_email
from app.extensions import db

View File

@ -1,7 +1,7 @@
from flask import render_template, request, redirect, url_for, flash
from flask_login import login_required, current_user
from flask_wtf import FlaskForm
from itsdangerous import Signer, BadSignature
from itsdangerous import Signer
from wtforms import validators
from wtforms.fields.html5 import EmailField
@ -9,7 +9,6 @@ from app.config import EMAIL_DOMAIN, ALIAS_DOMAINS, MAILBOX_SECRET, URL
from app.dashboard.base import dashboard_bp
from app.email_utils import (
can_be_used_as_personal_email,
email_already_used,
mailbox_already_used,
render,
send_email,

View File

@ -1,21 +1,22 @@
from smtplib import SMTPRecipientsRefused
from flask import render_template, request, redirect, url_for, flash
from flask_login import login_required, current_user
from flask_wtf import FlaskForm
from itsdangerous import Signer, BadSignature
from itsdangerous import Signer
from wtforms import validators
from wtforms.fields.html5 import EmailField
from app.config import MAILBOX_SECRET
from app.config import URL
from app.dashboard.base import dashboard_bp
from app.email_utils import can_be_used_as_personal_email, email_already_used
from app.email_utils import can_be_used_as_personal_email
from app.email_utils import mailbox_already_used, render, send_email
from app.extensions import db
from app.log import LOG
from app.models import Alias, DeletedAlias
from app.models import Mailbox
from app.pgp_utils import PGPException, load_public_key
from smtplib import SMTPRecipientsRefused
class ChangeEmailForm(FlaskForm):

View File

@ -4,7 +4,7 @@ from flask_login import login_required, current_user
from app.dashboard.base import dashboard_bp
from app.extensions import db
from app.log import LOG
from app.models import EmailLog, Referral
from app.models import Referral
from app.utils import random_string

View File

@ -3,10 +3,8 @@ from flask_login import current_user, login_required
from flask_wtf import FlaskForm
from wtforms import StringField, validators
from app import email_utils
from app.developer.base import developer_bp
from app.extensions import db
from app.log import LOG
from app.models import Client

View File

@ -1,7 +1,8 @@
import enum
import random
import uuid
from email.utils import parseaddr, formataddr
from email.utils import formataddr
from typing import List
import arrow
import bcrypt
@ -357,12 +358,12 @@ class User(db.Model, ModelMixin, UserMixin):
def verified_custom_domains(self):
return CustomDomain.query.filter_by(user_id=self.id, verified=True).all()
def mailboxes(self) -> [str]:
"""list of mailbox emails that user own"""
def mailboxes(self) -> List["Mailbox"]:
"""list of mailbox that user own"""
mailboxes = []
for mailbox in Mailbox.query.filter_by(user_id=self.id, verified=True):
mailboxes.append(mailbox.email)
mailboxes.append(mailbox)
return mailboxes

View File

@ -28,7 +28,7 @@ from app.oauth_models import (
SUPPORTED_OPENID_FLOWS_STR,
response_types_to_str,
)
from app.utils import random_string, encode_url, convert_to_id, random_word
from app.utils import random_string, encode_url, random_word
@oauth_bp.route("/authorize", methods=["GET", "POST"])

View File

@ -1,5 +1,5 @@
from io import BytesIO
import os
from io import BytesIO
import boto3
import requests

View File

@ -160,11 +160,12 @@ def fake_data():
m1 = Mailbox.create(user_id=user.id, email="m1@cd.ef", verified=True)
db.session.commit()
user.default_mailbox_id = m1.id
Alias.create_new(user, "e1@", mailbox_id=m1.id)
for i in range(10):
Alias.create_new(user, f"e{i}@", mailbox_id=m1.id)
for i in range(30):
if i % 2 == 0:
Alias.create_new(user, f"e{i}@", mailbox_id=m1.id)
else:
Alias.create_new(user, f"e{i}@")
CustomDomain.create(user_id=user.id, domain="ab.cd", verified=True)
CustomDomain.create(

View File

@ -6,7 +6,7 @@ from flask import url_for
from app.config import PAGE_LIMIT
from app.extensions import db
from app.models import User, ApiKey, Alias, Contact, EmailLog
from app.models import User, ApiKey, Alias, Contact, EmailLog, Mailbox
def test_get_aliases_error_without_pagination(flask_client):
@ -292,7 +292,38 @@ def test_update_alias(flask_client):
)
assert r.status_code == 200
assert r.json == {"note": "test note"}
def test_update_alias_mailbox(flask_client):
user = User.create(
email="a@b.c", password="password", name="Test User", activated=True
)
db.session.commit()
mb = Mailbox.create(user_id=user.id, email="ab@cd.com", verified=True)
# create api_key
api_key = ApiKey.create(user.id, "for test")
db.session.commit()
alias = Alias.create_new_random(user)
db.session.commit()
r = flask_client.put(
url_for("api.update_alias", alias_id=alias.id),
headers={"Authentication": api_key.code},
json={"mailbox_id": mb.id},
)
assert r.status_code == 200
# fail when update with non-existing mailbox
r = flask_client.put(
url_for("api.update_alias", alias_id=alias.id),
headers={"Authentication": api_key.code},
json={"mailbox_id": -1},
)
assert r.status_code == 400
def test_alias_contacts(flask_client):