Skip to content
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

progressive web app #789

Merged
merged 72 commits into from
Mar 10, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
72 commits
Select commit Hold shift + click to select a range
830c49f
feat: add blob to CSP
dodumosu Feb 6, 2021
3df0ebc
feat: add dependencies and settings
dodumosu Feb 6, 2021
11473db
feat: enable CORS and JWT extensions
dodumosu Feb 6, 2021
5e08764
feat: add custom JWT event hooks
dodumosu Feb 6, 2021
a78d1e0
feat: start PWA package
dodumosu Feb 6, 2021
4942ea5
feat: change decorators & add submission endpoint
dodumosu Feb 6, 2021
9b9e202
feat: app basic stub for app
dodumosu Feb 6, 2021
159a321
feat: add login form
dodumosu Feb 7, 2021
6d19d88
fix: unset cookies only if cookies are used
dodumosu Feb 7, 2021
1409212
feat: update PWA app
dodumosu Feb 7, 2021
ca7bbe9
fix: correct Dexie indexes
dodumosu Feb 7, 2021
46ae957
feat: update getForms
dodumosu Feb 8, 2021
1c1a6c1
feat: add form list component
dodumosu Feb 8, 2021
612e059
feat: change submit to match API
dodumosu Feb 9, 2021
7b4ed46
feat: update main template
dodumosu Feb 9, 2021
3a1b754
feat: update template
dodumosu Feb 9, 2021
b287d90
fix: set intial location
dodumosu Feb 9, 2021
c13fe8b
feat: add notification on submit success
dodumosu Feb 9, 2021
d42a244
fix: only if it's not part of a batch
dodumosu Feb 9, 2021
5409ec1
feat: install service worker
dodumosu Feb 9, 2021
934c92b
feat: update service worker and template
dodumosu Feb 9, 2021
9f137fa
fix: correct path in manifest
dodumosu Feb 9, 2021
63a56b8
feat: add cancel buttons
dodumosu Feb 10, 2021
226e359
feat: force reactivity
dodumosu Feb 10, 2021
d207b92
feat: add question codes to labels
dodumosu Feb 10, 2021
73b6b55
feat: update caching behaviour
dodumosu Feb 10, 2021
82cdd0c
feat: add transitions to components
dodumosu Feb 10, 2021
1b1b6b5
fix: force load from database
dodumosu Feb 10, 2021
c1f80e0
feat: attempt a refresh
dodumosu Feb 10, 2021
a2c97c3
feat: add location rendering
dodumosu Feb 10, 2021
6591711
fix: add missing calls to jsonify()
dodumosu Feb 12, 2021
ffeecb3
fix: restrict JWT-using endpoints to JWT logins
dodumosu Feb 12, 2021
a83c1fa
chore: make cookies default JWT location
dodumosu Feb 13, 2021
e821501
feat: automatically refresh access tokens
dodumosu Feb 13, 2021
15a96fe
chore: remove use of refresh tokens
dodumosu Feb 13, 2021
5cc9b0f
chore: change messages
dodumosu Feb 13, 2021
0322bf9
feat: move frontend to cookie-based JWT storage
dodumosu Feb 13, 2021
afacabf
fix: revocation acts as blocklist
dodumosu Feb 16, 2021
13494a6
chore: upgrade flask-jwt-extended to 4.x
dodumosu Feb 16, 2021
e7b1530
feat: add svg-gauge to static assets
dodumosu Feb 16, 2021
99efdde
fix: use ```jwt_required``` decorator properly
dodumosu Feb 16, 2021
0ad927b
fix: change expiration to integer
dodumosu Feb 16, 2021
48a8724
feat: various changes to PWA template
dodumosu Feb 16, 2021
abd5254
feat: display notification on local data save
dodumosu Feb 16, 2021
1035928
feat: post finalized but unposted submissions
dodumosu Feb 16, 2021
e45fab3
feat: disable editing for posted incidents
dodumosu Feb 16, 2021
515f64f
fix: fix error with incident forms with images
dodumosu Feb 16, 2021
a2eae86
chore: clean up submission post, remove svg-gauge
dodumosu Feb 17, 2021
85c4d0b
fix: remove svg-gauge from the cache list
dodumosu Feb 17, 2021
a94d75c
feat: fail silently if bg form refresh errors out
dodumosu Feb 17, 2021
862ed3a
feat: add completion for submissions
dodumosu Feb 17, 2021
e34f7fb
feat: remove notification on automatic submit
dodumosu Feb 17, 2021
8f1dccb
feat: use icons for completion status
dodumosu Feb 17, 2021
358ded4
fix: don't error out if submission has no master
dodumosu Feb 17, 2021
3697d37
feat: add "add new" button to form list
dodumosu Feb 17, 2021
0344945
feat: sub buttons for each incident/survey form
dodumosu Feb 22, 2021
a83642c
feat: add gettext.js to static resources
dodumosu Mar 4, 2021
0e6e6df
feat: various changes
dodumosu Mar 7, 2021
dd18174
feat: move gettext.js (and reduce number of files)
dodumosu Mar 8, 2021
58fc4c2
feat: add luxon and use minified vue
dodumosu Mar 9, 2021
cffa6f9
feat: change completion implementation
dodumosu Mar 9, 2021
5c457f5
feat: color coding for section headers
dodumosu Mar 9, 2021
85eec2e
feat: display last update
dodumosu Mar 9, 2021
91be660
feat: add top margins
dodumosu Mar 9, 2021
461a530
feat: collapsible list of incidents/surveys
dodumosu Mar 9, 2021
1c0dcf4
chore: extract update prompt
dodumosu Mar 9, 2021
cf67984
fix: clear typo
dodumosu Mar 9, 2021
cf59074
feat: add version checks
dodumosu Mar 9, 2021
b0b3020
feat: replace error dialog with toast
dodumosu Mar 9, 2021
4823e62
chore: remove unused code
dodumosu Mar 9, 2021
be8c896
chore: remove unused code
dodumosu Mar 9, 2021
689f1f2
chore: add extra JWT settings
dodumosu Mar 9, 2021
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
25 changes: 22 additions & 3 deletions apollo/__init__.py
Original file line number Diff line number Diff line change
@@ -1,18 +1,22 @@
# -*- coding: utf-8 -*-
from datetime import datetime, timedelta

from apispec import APISpec
from apispec.ext.marshmallow import MarshmallowPlugin
from flask import (
g, redirect, render_template, request, session, url_for
)
from flask_admin import AdminIndexView
from flask_jwt_extended import (
create_access_token, get_jwt_identity, get_jwt, set_access_cookies)
from flask_login import user_logged_out
from flask_principal import identity_loaded
from flask_security import SQLAlchemyUserDatastore, current_user
from flask_security.utils import login_user, url_for_security
from loginpass import create_flask_blueprint, Facebook, Google
from werkzeug.urls import url_encode
from whitenoise import WhiteNoise
from apollo import assets, models, services
from apollo import assets, models, services, utils

from apollo.frontend import permissions, template_filters
from apollo.core import (
Expand Down Expand Up @@ -131,13 +135,28 @@ def frame_buster(response):
# content security policy
@app.after_request
def content_security_policy(response):
response.headers['Content-Security-Policy'] = "default-src 'self' " + \
response.headers['Content-Security-Policy'] = "default-src 'self' blob: " + \
"*.googlecode.com *.google-analytics.com fonts.gstatic.com fonts.googleapis.com " + \
"*.googletagmanager.com " + \
"cdn.heapanalytics.com heapanalytics.com " + \
"'unsafe-inline' 'unsafe-eval' data:; img-src * data:"
"'unsafe-inline' 'unsafe-eval' data:; img-src * data: blob:"
return response

# automatic token refresh
@app.after_request
def refresh_expiring_jwts(response):
try:
expiration_timestamp = get_jwt()['exp']
now = utils.current_timestamp()
target_timestamp = datetime.timestamp(now + timedelta(minutes=30))
if target_timestamp > expiration_timestamp:
access_token = create_access_token(identity=get_jwt_identity())
set_access_cookies(response, access_token)

return response
except (KeyError, RuntimeError):
return response

def handle_authorize(remote, token, user_info):
if user_info and 'email' in user_info:
user = models.User.query.filter_by(
Expand Down
4 changes: 2 additions & 2 deletions apollo/api/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,15 @@
from flask_apispec import MethodResource, use_kwargs
from webargs import fields

from apollo.api.decorators import login_or_api_key_required
from apollo.api.decorators import protect
from apollo.settings import API_PAGE_SIZE


class BaseListResource(MethodResource):
schema = None

@use_kwargs({'page': fields.Int(missing=1)})
@login_or_api_key_required
@protect
def get(self, **kwargs):
page = kwargs.get('page')
query_items = self.get_items(**kwargs)
Expand Down
40 changes: 26 additions & 14 deletions apollo/api/decorators.py
Original file line number Diff line number Diff line change
@@ -1,22 +1,34 @@
# -*- coding: utf-8 -*-
from functools import wraps
from http import HTTPStatus

from flask import current_app, request
import wrapt
from flask import abort, current_app, jsonify, request
from flask_jwt_extended import verify_jwt_in_request
from flask_security import current_user


def login_or_api_key_required(func):
@wraps(func)
def decorated_view(*args, **kwargs):
if current_user and current_user.is_authenticated:
return func(*args, **kwargs)
@wrapt.decorator
def protect(wrapped, instance, args, kwargs):
# first, check if we have a user logged in
if current_user and current_user.is_authenticated:
return wrapped(*args, **kwargs)

api_key = current_app.config.get('API_KEY', None)
request_api_key = request.args.get('api_key', None)
# if not, check if the API key was sent, and verify
# if it is correct. send an unauthorized response
# if it is not
api_key = current_app.config.get('API_KEY', None)
request_api_key = request.args.get('api_key', None)

if request_api_key and api_key == request_api_key:
return func(*args, **kwargs)
if request_api_key:
if api_key == request_api_key:
return wrapped(*args, **kwargs)
response = jsonify({
'status': 'error',
'message': 'Access denied'
})
response.status_code = HTTPStatus.UNAUTHORIZED
abort(response)

return current_app.login_manager.unauthorized()

return decorated_view
# finally, assume that a JWT was sent
verify_jwt_in_request()
return wrapped(*args, **kwargs)
35 changes: 35 additions & 0 deletions apollo/api/hooks.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# -*- coding: utf-8 -*-
from http import HTTPStatus

from flask import jsonify
from flask_babelex import gettext as _

from apollo.core import red


def process_expired_token(jwt_header, jwt_payload):
return jsonify({
'status': _('error'),
'message': _('Token has expired')
}), HTTPStatus.UNAUTHORIZED


def process_invalid_token(reason):
return jsonify({
'status': _('error'),
'message': _(reason)
}), HTTPStatus.UNPROCESSABLE_ENTITY


def process_revoked_token(jwt_header, jwt_payload):
return jsonify({
'status': _('error'),
'message': _('Token has been revoked')
}), HTTPStatus.UNAUTHORIZED


def check_if_token_is_blocklisted(jwt_header, jwt_payload):
jti = jwt_payload['jti']
entry = red.get(jti)

return entry is not None
4 changes: 4 additions & 0 deletions apollo/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,13 @@
from flask_apispec import FlaskApiSpec
from flask_babelex import Babel
from flask_caching import Cache
from flask_cors import CORS
try:
from flask_debugtoolbar import DebugToolbarExtension
fdt_available = True
except ImportError:
fdt_available = False
from flask_jwt_extended import JWTManager
from flask_mail import Mail
from flask_menu import Menu
from flask_migrate import Migrate
Expand Down Expand Up @@ -39,7 +41,9 @@ def index(self):
name='Apollo', index_view=AdminHome(name='Dashboard'))
babel = Babel()
cache = Cache()
cors = CORS()
db = SQLAlchemy(session_options={'expire_on_commit': False})
jwt_manager = JWTManager()
mail = Mail()
menu = Menu()
migrate = Migrate()
Expand Down
14 changes: 12 additions & 2 deletions apollo/factory.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,10 @@
from raven.contrib.celery import register_signal, register_logger_signal

from apollo import settings
from apollo.api import hooks as jwt_hooks
from apollo.core import (
babel, cache, db, fdt_available, debug_toolbar, mail, migrate, red,
sentry, uploads)
babel, cache, db, cors, debug_toolbar, fdt_available, jwt_manager,
mail, migrate, red, sentry, uploads)
from apollo.helpers import register_blueprints


Expand Down Expand Up @@ -89,13 +90,22 @@ def create_app(
sentry.init_app(app)
babel.init_app(app)
cache.init_app(app)
cors.init_app(app)
db.init_app(app)
jwt_manager.init_app(app)
migrate.init_app(app, db)
mail.init_app(app)
red.init_app(app)

configure_uploads(app, uploads)

# set up JWT callbacks
jwt_manager.expired_token_loader(jwt_hooks.process_expired_token)
jwt_manager.invalid_token_loader(jwt_hooks.process_invalid_token)
jwt_manager.revoked_token_loader(jwt_hooks.process_revoked_token)
jwt_manager.token_in_blocklist_loader(
jwt_hooks.check_if_token_is_blocklisted)

if app.config.get('SSL_REQUIRED'):
SSLify(app)

Expand Down
25 changes: 18 additions & 7 deletions apollo/formsframework/api/views.py
Original file line number Diff line number Diff line change
@@ -1,17 +1,18 @@
# -*- coding: utf-8 -*-
from flask import g
from flask_apispec import MethodResource, marshal_with, use_kwargs
from sqlalchemy import or_
from webargs import fields

from apollo.api.common import BaseListResource
from apollo.api.decorators import login_or_api_key_required
from apollo.api.decorators import protect
from apollo.formsframework.api.schema import FormSchema
from apollo.formsframework.models import Form, events_forms


@marshal_with(FormSchema)
class FormItemResource(MethodResource):
@login_or_api_key_required
@protect
def get(self, form_id, **kwargs):
deployment = getattr(g, 'deployment', None)
if deployment:
Expand All @@ -24,11 +25,14 @@ def get(self, form_id, **kwargs):


@use_kwargs(
{'event_id': fields.Int(), 'form_type': fields.String()},
{'events': fields.DelimitedList(fields.Int()),
'form_type': fields.String(),
'last_modified_after': fields.DateTime()},
locations=['query'])
class FormListResource(BaseListResource):
schema = FormSchema()

@protect
def get_items(self, **kwargs):
deployment = getattr(g, 'deployment', None)
if deployment:
Expand All @@ -43,10 +47,17 @@ def get_items(self, **kwargs):
query = query.filter_by(
form_type=form_type, deployment_id=deployment_id)

event_id = kwargs.get('event_id')
if event_id:
query = query.join(events_forms).filter(
event_ids = kwargs.get('events')
if event_ids:
term = or_(*[
events_forms.c.event_id == event_id
)
for event_id in event_ids
])
query = query.join(events_forms).filter(term)

last_modified = kwargs.get('last_modified_after')
if last_modified:
last_modified_str = last_modified.strftime('%Y%m%d%H%M%S%f')
query = query.filter(Form.version_identifier > last_modified_str)

return query
35 changes: 35 additions & 0 deletions apollo/formsframework/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
from flask_babelex import gettext, lazy_gettext as _
from lxml import etree
from lxml.builder import E, ElementMaker
from marshmallow import Schema, fields, validate
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy_json import NestedMutableJson
from sqlalchemy_utils import ChoiceType
Expand Down Expand Up @@ -313,6 +314,40 @@ def odk_hash(self):
hash_engine.update(xform_data)
return f'md5: {hash_engine.hexdigest()}'

def create_schema(self):
attrs = {
tag: _get_schema_field(self.get_field_by_tag(tag))
for tag in self.tags
}

attrs['location'] = fields.List(
fields.Float(), validate=validate.Length(min=2, max=2))

attrs['Meta'] = type(
'GeneratedMeta',
(getattr(Schema, 'Meta', object),),
{'register': False})

schema_cls = type('GeneratedSchema', (Schema,), attrs)

return schema_cls


def _get_schema_field(form_field):
if form_field['type'] == 'integer':
return fields.Int(
validate=validate.Range(
min=form_field.get('min', 0),
max=form_field.get('max', 9999)))
elif form_field['type'] in ('comment', 'string', 'image'):
return fields.Str()
elif form_field['type'] in ('select', 'multiselect'):
choices = form_field['options'].values()
if form_field['type'] == 'select':
return fields.Int(validate=validate.OneOf(choices))
else:
return fields.List(fields.Int(validate=validate.OneOf(choices)))


class FormBuilderSerializer(object):
@classmethod
Expand Down
6 changes: 3 additions & 3 deletions apollo/locations/api/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
from webargs import fields

from apollo.api.common import BaseListResource
from apollo.api.decorators import login_or_api_key_required
from apollo.api.decorators import protect
from apollo.deployments.models import Event
from apollo.locations.api.schema import LocationSchema, LocationTypeSchema
from apollo.locations.models import (
Expand All @@ -15,7 +15,7 @@
@marshal_with(LocationTypeSchema)
@use_kwargs({'event_id': fields.Int()}, locations=['query'])
class LocationTypeItemResource(MethodResource):
@login_or_api_key_required
@protect
def get(self, loc_type_id, **kwargs):
deployment = getattr(g, 'deployment', None)
if deployment:
Expand Down Expand Up @@ -78,7 +78,7 @@ def get_items(self, **kwargs):
@marshal_with(LocationSchema)
@use_kwargs({'event_id': fields.Int()}, locations=['query'])
class LocationItemResource(MethodResource):
@login_or_api_key_required
@protect
def get(self, location_id, **kwargs):
event_id = kwargs.get('event_id')
if event_id is None:
Expand Down
Loading