├── .gitignore ├── Dockerfile ├── LICENSE ├── README.md ├── Vagrantfile ├── app ├── __init__.py ├── api_v1 │ ├── __init__.py │ ├── authentication.py │ ├── base.py │ ├── errors.py │ ├── notebooks.py │ ├── public.py │ └── user.py ├── auth │ ├── __init__.py │ ├── forms.py │ └── views.py ├── email.py ├── exceptions.py ├── lib │ ├── __init__.py │ └── export.py ├── main │ ├── __init__.py │ ├── errors.py │ ├── forms.py │ ├── notebooks.py │ ├── users.py │ └── views.py ├── model │ ├── __init__.py │ ├── base.py │ └── shared.py ├── models.py ├── static │ ├── css │ │ ├── app.min.css │ │ └── vendor.min.css │ ├── fonts │ │ ├── FontAwesome.otf │ │ ├── fontawesome-webfont.eot │ │ ├── fontawesome-webfont.svg │ │ ├── fontawesome-webfont.ttf │ │ ├── fontawesome-webfont.woff │ │ ├── fontawesome-webfont.woff2 │ │ ├── glyphicons-halflings-regular.eot │ │ ├── glyphicons-halflings-regular.svg │ │ ├── glyphicons-halflings-regular.ttf │ │ ├── glyphicons-halflings-regular.woff │ │ └── glyphicons-halflings-regular.woff2 │ ├── images │ │ ├── all_notes.png │ │ ├── favicon.png │ │ ├── logo.svg │ │ ├── new_note.png │ │ └── notebooks.png │ └── js │ │ └── braindump.min.js └── templates │ ├── 404.html │ ├── 500.html │ ├── app │ ├── _mobile_nav.html │ ├── _note.html │ ├── _notes.html │ ├── add.html │ ├── app.html │ ├── app_base.html │ ├── archive.html │ ├── note.html │ ├── notebook.html │ ├── notebooks.html │ ├── search.html │ ├── settings.html │ ├── share_note.html │ ├── tag.html │ └── trash.html │ ├── app_email │ ├── share_note.html │ └── share_note.txt │ ├── auth │ ├── change_email.html │ ├── change_password.html │ ├── change_username.html │ ├── email │ │ ├── change_email.html │ │ ├── change_email.txt │ │ ├── confirm.html │ │ ├── confirm.txt │ │ ├── reset_password.html │ │ └── reset_password.txt │ ├── login_modal.html │ ├── register_modal.html │ ├── reset_password.html │ └── unconfirmed.html │ ├── base.html │ └── index.html ├── circle.yml ├── config.py ├── docker-compose.yml ├── docs ├── dev │ ├── app.api_v1.authentication.html │ ├── app.api_v1.base.html │ ├── app.api_v1.errors.html │ ├── app.api_v1.html │ ├── app.api_v1.notebooks.html │ ├── app.auth.forms.html │ ├── app.auth.html │ ├── app.auth.views.html │ ├── app.email.html │ ├── app.exceptions.html │ ├── app.html │ ├── app.main.errors.html │ ├── app.main.forms.html │ ├── app.main.html │ ├── app.main.views.html │ ├── app.models.html │ ├── config.html │ └── manage.html └── index.html ├── etc ├── conf │ ├── Dockerfile │ └── nginx.conf └── cron │ └── braindump-backup ├── frontend ├── js │ ├── lib │ │ └── debounce.js │ └── src │ │ ├── Dates.js │ │ ├── braindump.editor.js │ │ ├── braindump.helpers.js │ │ ├── braindump.js │ │ ├── braindump.notebooks.js │ │ └── braindump.settings.js └── sass │ ├── _app.scss │ ├── _base.scss │ ├── _cover.scss │ ├── _editor.scss │ ├── _layout.scss │ ├── _modal.scss │ ├── _outer.scss │ ├── main.scss │ └── notebook-card.scss ├── gulpfile.js ├── manage.py ├── migrations ├── README ├── alembic.ini ├── env.py ├── script.py.mako └── versions │ ├── 3a37e844b277_alter_constraints.py │ ├── 3b4b395f61a9_initial_migration.py │ ├── 55c778dd35ab_add_default_notebook_to_users.py │ ├── 5d3c326dd901_add_created_and_updated_date_to_notebook.py │ ├── b237b9f6a2ce_update_user_model_remove_username_add_.py │ ├── c052ae5abc1d_remove_html_body_from_notes.py │ └── ffe4c66b5772_add_shared_notes.py ├── package.json ├── requirements.txt ├── scripts ├── bootstrap.sh ├── deploy.sh ├── pg_backup.sh ├── secrets.sh.example ├── start-app.sh └── start-dev.sh ├── tests ├── api_base.py ├── client_base.py ├── model_base.py ├── test_api_authentication.py ├── test_api_notebooks.py ├── test_api_public.py ├── test_basics.py ├── test_export.py ├── test_model_user.py └── test_view_auth.py └── yarn.lock /.gitignore: -------------------------------------------------------------------------------- 1 | # C extensions 2 | *.so 3 | 4 | # Distribution / packaging 5 | .Python 6 | env/ 7 | build/ 8 | develop-eggs/ 9 | dist/ 10 | downloads/ 11 | eggs/ 12 | .eggs/ 13 | lib64/ 14 | parts/ 15 | sdist/ 16 | var/ 17 | *.egg-info/ 18 | .installed.cfg 19 | *.egg 20 | *.pyc 21 | tags 22 | 23 | # PyInstaller 24 | # Usually these files are written by a python script from a template 25 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 26 | *.manifest 27 | *.spec 28 | 29 | # Installer logs 30 | pip-log.txt 31 | pip-delete-this-directory.txt 32 | 33 | # Unit test / coverage reports 34 | htmlcov/ 35 | .tox/ 36 | .coverage 37 | .coverage.* 38 | .cache 39 | nosetests.xml 40 | coverage.xml 41 | *,cover 42 | test-reports/ 43 | 44 | # Translations 45 | *.mo 46 | *.pot 47 | 48 | # Django stuff: 49 | *.log 50 | 51 | # Sphinx documentation 52 | docs/_build/ 53 | 54 | # PyBuilder 55 | target/ 56 | 57 | # DB Stuff 58 | *.sqlite* 59 | 60 | # Secrets 61 | init.sh 62 | 63 | # Vagrant 64 | .vagrant/ 65 | 66 | # Javascript 67 | node_modules/ 68 | 69 | # CircleCI 70 | *venv* 71 | 72 | # Jekyll 73 | _site/ 74 | .sass-cache/ 75 | .jekyll-metadata 76 | 77 | # vim 78 | *.swp 79 | 80 | app/assets/ 81 | *.DS_Store 82 | 83 | legacy_app/ 84 | .DS_Store 85 | 86 | __pycache__/ 87 | 88 | # Editor Specific 89 | .vscode/ -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.5 2 | ENV PYTHONUNBUFFERED 1 3 | 4 | RUN mkdir -p /usr/src/app 5 | WORKDIR /usr/src/app 6 | COPY requirements.txt /usr/src/app 7 | RUN pip install --upgrade -r requirements.txt 8 | COPY . /usr/src/app -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015-2016 Lev Lazinskiy 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | **DEPRECATED: Read more [here](https://levlaz.org/standard-notes-is-a-better-project-than-braindump/) and join me in using and contributing to [Standard Notes](https://standardnotes.org/) instead.** 2 | 3 | # braindump 4 | 5 | BrainDump is a simple, powerful, and open note taking platform that makes it easy to organize your life. 6 | 7 | # Features 8 | 9 | * RESTful API (WIP) 10 | * Full Markdown Editing 11 | * Full Markdown Viewing 12 | * Share Notes via Email 13 | * Categorize Notes into Notebooks 14 | * Categorize Notes with Tags 15 | * Full Text Search 16 | * Mark notes as Favorites 17 | 18 | # Screenshots 19 | 20 | ## Organize your Notes with Notebooks 21 | ![Notebooks](https://github.com/levlaz/braindump/blob/master/app/static/images/notebooks.png) 22 | 23 | ## Powerful Markdown based Editing with [Prose Mirror](https://prosemirror.net/) 24 | ![New Note](https://github.com/levlaz/braindump/blob/master/app/static/images/new_note.png) 25 | 26 | ## All of your Notes in One Place 27 | ![All Notes](https://github.com/levlaz/braindump/blob/master/app/static/images/all_notes.png) 28 | 29 | # Development 30 | 31 | The easiest way to hack on braindump is with Vagrant 32 | 33 | ## Requirements 34 | 1. VirtualBox 35 | 2. Vagrant 36 | 3. Git 37 | 38 | ## Development Instructions 39 | 1. Fork and Clone this repo locally 40 | 2. `cd` into the new repo 41 | 3. Run `vagrant up` 42 | 4. The first time you run `vagrant up` the provisioner (scripts/bootstrap.sh) will run which takes a bit of time. Each subequent time will be much quicker. 43 | 5. Run `vagrant ssh` to enter the Vagrant box. 44 | 6. Go to the `/vagrant` directory with `cd /vagrant` which is a synced folder of your local git repo. 45 | 6. Run `scripts/start-dev.sh` to start the application 46 | 7. Go to localhost:5000 to view the app, any changes you make locally will be reflected in the Vagrant environment. 47 | 48 | # Deploying to Production 49 | The only official method of deploying Braindump is with Docker. Braindump.pw is currently running on an Ubuntu 16.04 LTS server on [Linode](https://www.linode.com/?r=15437cfec0948d105bf7478af2422241ed5da188). You can view `scripts/deploy.sh` to see how braindump is currently being deployed to production via CircleCI. 50 | 51 | ## Requirements 52 | 1. Docker and Docker Compose 53 | 2. SMTP (Required for Creating new Accounts and Sharing Notes) 54 | 55 | ## Deployment Instructions 56 | 1. Log into your Production Server and install Docker and Docker Compose 57 | 2. Create a new directory for braindump `mkdir -p /var/www/braindump` 58 | 3. Edit `scripts/secrets.sh` and add your site specific environment credentials. 59 | 4. Edit `etc/conf/nginx.conf` and add your site specific nginx configuration 60 | 5. From your local repo, send latest scripts to production Server 61 | 62 | ``` 63 | rsync -avz scripts/ $USER@SERVER:/var/www/braindump/scripts/ 64 | rsync -avz etc/ $USER@SERVER:/var/www/braindump/etc/ 65 | scp docker-compose.yml $USER@SERVER:/var/www/braindump 66 | ``` 67 | 68 | 6. From your local repo, log into production server, pull and restart Docker 69 | 70 | ``` 71 | ssh $USER@SERVER 'cd /var/www/braindump && docker-compose pull' 72 | ssh $USER@SERVER 'cd /var/www/braindump && docker-compose build' 73 | ssh $USER@SERVER 'cd /var/www/braindump && source scripts/secrets.sh && docker-compose up -d' 74 | ``` 75 | 76 | 7. (Optional) to set up automatic backups (every 6 hours) add the backup script to your crontab `crontab scripts/braindump-backup` 77 | 78 | If all goes well, you will be able to navigate to $YOUR_SERVER in a browser and see the app. If you get a bad gateway error, or some other error try to run docker-compose in the foreground to get additional logging `cd /var/www/braindump && source scripts/secrets.sh && docker-compose up` 79 | -------------------------------------------------------------------------------- /Vagrantfile: -------------------------------------------------------------------------------- 1 | Vagrant.configure("2") do |config| 2 | # Base Box 3 | config.vm.box = "bento/ubuntu-16.04" 4 | 5 | config.vm.provider "virtualbox" do |v| 6 | v.memory = 1024 7 | end 8 | 9 | # First Time Provision 10 | config.vm.provision :shell, path: "scripts/bootstrap.sh" 11 | 12 | # Port Forwarding 13 | # Allows you to go to localhost:5000 to view the app 14 | config.vm.network :forwarded_port, guest: 5000, host: 5000 15 | config.vm.network :forwarded_port, guest: 8000, host: 8080 16 | # Allows you to connect to the DB using pgAdmin 17 | config.vm.network :forwarded_port, guest: 5432, host:15432 18 | end 19 | -------------------------------------------------------------------------------- /app/__init__.py: -------------------------------------------------------------------------------- 1 | from flask import Flask 2 | from flask_bootstrap import Bootstrap 3 | from flask_sqlalchemy import SQLAlchemy 4 | from flask_mail import Mail 5 | from flask_login import LoginManager 6 | from flask_wtf.csrf import CsrfProtect 7 | from flask_cors import CORS, cross_origin 8 | from config import config 9 | 10 | bootstrap = Bootstrap() 11 | db = SQLAlchemy() 12 | mail = Mail() 13 | login_manager = LoginManager() 14 | login_manager.session_protection = 'strong' 15 | login_manager.login_view = 'auth.login' 16 | csrf = CsrfProtect() 17 | cors = CORS() 18 | 19 | 20 | def create_app(config_name): 21 | app = Flask(__name__) 22 | app.config.from_object(config[config_name]) 23 | config[config_name].init_app(app) 24 | bootstrap.init_app(app) 25 | mail.init_app(app) 26 | login_manager.init_app(app) 27 | csrf.init_app(app) 28 | cors.init_app(app, resources={r"/api/*": {"origins": "*"}}) 29 | db.init_app(app) 30 | 31 | from .main import main as main_blueprint 32 | app.register_blueprint(main_blueprint) 33 | 34 | from .auth import auth as auth_blueprint 35 | app.register_blueprint(auth_blueprint, url_prefix='/auth') 36 | 37 | from .api_v1 import api 38 | api.init_app(app) 39 | 40 | return app 41 | -------------------------------------------------------------------------------- /app/api_v1/__init__.py: -------------------------------------------------------------------------------- 1 | from flask_restful import Api 2 | from .notebooks import Notebook, NotebookList 3 | from .authentication import Token 4 | from .user import User 5 | from .public import Statistics 6 | 7 | api = Api(prefix="/api/v1", catch_all_404s=True) 8 | api.add_resource(Token, '/token', endpoint="api.token") 9 | 10 | api.add_resource(User, '/user', endpoint="api.user") 11 | 12 | api.add_resource(NotebookList, '/notebooks', endpoint="api.notebooks") 13 | api.add_resource(Notebook, '/notebook/', endpoint="api.notebook") 14 | 15 | api.add_resource(Statistics, '/public/stats', endpoint="api.public") 16 | -------------------------------------------------------------------------------- /app/api_v1/authentication.py: -------------------------------------------------------------------------------- 1 | from flask import g 2 | from flask_httpauth import HTTPBasicAuth, HTTPTokenAuth, MultiAuth 3 | from app.models import User 4 | from flask_restful import Resource 5 | 6 | basic_auth = HTTPBasicAuth() 7 | token_auth = HTTPTokenAuth('Bearer') 8 | multi_auth = MultiAuth(basic_auth, token_auth) 9 | 10 | 11 | class Token(Resource): 12 | """Generate Auth Token""" 13 | decorators = [multi_auth.login_required] 14 | 15 | def get(self): 16 | """Return JWT Token""" 17 | token = g.user.generate_auth_token() 18 | return {'token': token.decode('ascii')} 19 | 20 | 21 | @basic_auth.verify_password 22 | def verify_password(email, password): 23 | g.user = None 24 | try: 25 | user = User.query.filter_by(email=email).first() 26 | if user.verify_password(password): 27 | g.user = user 28 | return True 29 | except: 30 | return False 31 | 32 | 33 | @token_auth.verify_token 34 | def verify_token(token): 35 | try: 36 | g.user = User.verify_auth_token(token) 37 | return True 38 | except: 39 | False 40 | -------------------------------------------------------------------------------- /app/api_v1/base.py: -------------------------------------------------------------------------------- 1 | from flask_restful import Resource 2 | from .authentication import multi_auth 3 | from app import csrf 4 | 5 | 6 | class ProtectedBase(Resource): 7 | """Base Protected Class for API Resources 8 | 9 | Defines that all API methods are expempt from CSRF, and 10 | requires either password or token based authentication. 11 | 12 | Basic Authentication can be passed via the headers. A token can be obtained 13 | from /api/v1/token and passed as a "Bearer" in the Authentication headers. 14 | """ 15 | decorators = [multi_auth.login_required, csrf.exempt] 16 | -------------------------------------------------------------------------------- /app/api_v1/errors.py: -------------------------------------------------------------------------------- 1 | from flask import jsonify 2 | from app.exceptions import ValidationError 3 | 4 | 5 | def bad_request(message): 6 | response = jsonify({'error': 'bad request', 'message': message}) 7 | response.status_code = 400 8 | return response 9 | 10 | 11 | def unauthorized(message): 12 | response = jsonify({'error': 'unauthorized', 'message': message}) 13 | response.status_code = 401 14 | return response 15 | 16 | 17 | def forbidden(message): 18 | response = jsonify({'error': 'forbidden', 'message': message}) 19 | response.status_code = 403 20 | return response 21 | 22 | 23 | def not_allowed(message): 24 | response = jsonify({'error': 'method not allowed', 'message': message}) 25 | response.status_code = 405 26 | return response 27 | -------------------------------------------------------------------------------- /app/api_v1/notebooks.py: -------------------------------------------------------------------------------- 1 | from flask import g 2 | from flask_restful import reqparse 3 | from app import db 4 | from app.models import Notebook as NewNotebook 5 | from app.api_v1.base import ProtectedBase 6 | 7 | 8 | class NotebookList(ProtectedBase): 9 | """Show all notebooks, and add new notebook""" 10 | 11 | parser = reqparse.RequestParser() 12 | 13 | def get(self): 14 | """Return list of all notebooks.""" 15 | return {'notebooks': list(map( 16 | lambda notebook: notebook.to_json(), g.user.notebooks.all()))} 17 | 18 | def post(self): 19 | """Create new notebook. 20 | 21 | Args: (Via Request Parameters) 22 | title (string, required): The title of the new Notebook 23 | 24 | Returns: 25 | JSON representation of the newly created notebook 26 | """ 27 | self.parser.add_argument( 28 | 'title', required=True, 29 | type=str, help='Missing Title of the Notebook') 30 | args = self.parser.parse_args() 31 | notebook = NewNotebook( 32 | title=args['title'], 33 | author_id=g.user.id, 34 | ) 35 | db.session.add(notebook) 36 | db.session.commit() 37 | return {'notebook': notebook.to_json()}, 201 38 | 39 | 40 | class Notebook(ProtectedBase): 41 | """Work with individual notebooks.""" 42 | 43 | parser = reqparse.RequestParser() 44 | 45 | @staticmethod 46 | def get_notebook(notebook_id): 47 | return g.user.notebooks.filter_by( 48 | id=notebook_id).first_or_404() 49 | 50 | def get(self, notebook_id): 51 | """Get single notebook. 52 | 53 | Args: 54 | notebook_id (int, required): The id of the Notebook 55 | 56 | Returns: 57 | JSON representation of the notebook 58 | """ 59 | return {'notebook': self.get_notebook(notebook_id).to_json()} 60 | 61 | def put(self, notebook_id): 62 | """Update single notebook. 63 | 64 | Args: 65 | notebook_id (int, required): The id of the Notebook 66 | 67 | Args: (Via Request Paramaters) 68 | title: (string, optional): New Title for the Notebook 69 | is_deleted: (bool, optional): Is the notebook deleted? 70 | 71 | Returns: 72 | JSON representation of the notebook 73 | """ 74 | self.parser.add_argument( 75 | 'title', type=str, 76 | help="Title of the Notebook") 77 | self.parser.add_argument( 78 | 'is_deleted', type=bool, help="True if notebook is deleted") 79 | args = self.parser.parse_args() 80 | notebook = self.get_notebook(notebook_id) 81 | 82 | for arg in args: 83 | if args[str(arg)] is not None: 84 | setattr(notebook, arg, args[str(arg)]) 85 | 86 | db.session.add(notebook) 87 | db.session.commit() 88 | return {'notebook': notebook.to_json()} 89 | 90 | def delete(self, notebook_id): 91 | """Delete single notebook. 92 | 93 | Args: 94 | notebook_id (int, required): The id of the Notebook 95 | 96 | Returns: 97 | "deleted" if succeesful 98 | """ 99 | db.session.delete(self.get_notebook(notebook_id)) 100 | db.session.commit() 101 | return {'notebook': 'deleted'} 102 | -------------------------------------------------------------------------------- /app/api_v1/public.py: -------------------------------------------------------------------------------- 1 | from flask_restful import Resource 2 | from app.models import User, Note 3 | 4 | 5 | class Statistics(Resource): 6 | def get(self): 7 | stats = {} 8 | stats['users'] = User.query.count() 9 | stats['notes'] = Note.query.count() 10 | return stats 11 | -------------------------------------------------------------------------------- /app/api_v1/user.py: -------------------------------------------------------------------------------- 1 | from flask import g 2 | from flask_restful import reqparse 3 | from app import db 4 | from app.models import User as NewUser 5 | from app.api_v1.base import ProtectedBase 6 | 7 | 8 | class User(ProtectedBase): 9 | """Work with individual notebooks.""" 10 | 11 | parser = reqparse.RequestParser() 12 | 13 | # def get(self, notebook_id): 14 | # """Get single notebook. 15 | 16 | # Args: 17 | # notebook_id (int, required): The id of the Notebook 18 | 19 | # Returns: 20 | # JSON representation of the notebook 21 | # """ 22 | # return {'notebook': self.get_notebook(notebook_id).to_json()} 23 | 24 | def put(self): 25 | """Update single user. 26 | """ 27 | self.parser.add_argument( 28 | 'default_notebook', type=int, 29 | help="ID of the Default Notebook") 30 | args = self.parser.parse_args() 31 | user = g.user 32 | 33 | for arg in args: 34 | if args[str(arg)] is not None: 35 | setattr(user, arg, args[str(arg)]) 36 | 37 | db.session.add(user) 38 | db.session.commit() 39 | return {'notebook': notebook.to_json()} 40 | 41 | # def delete(self, notebook_id): 42 | # """Delete single notebook. 43 | 44 | # Args: 45 | # notebook_id (int, required): The id of the Notebook 46 | 47 | # Returns: 48 | # "deleted" if succeesful 49 | # """ 50 | # db.session.delete(self.get_notebook(notebook_id)) 51 | # db.session.commit() 52 | # return {'notebook': 'deleted'} 53 | -------------------------------------------------------------------------------- /app/auth/__init__.py: -------------------------------------------------------------------------------- 1 | from flask import Blueprint 2 | 3 | auth = Blueprint('auth', __name__) 4 | 5 | from . import views # noqa 6 | -------------------------------------------------------------------------------- /app/auth/forms.py: -------------------------------------------------------------------------------- 1 | from flask_wtf import FlaskForm 2 | from wtforms import StringField, \ 3 | PasswordField, BooleanField, \ 4 | SubmitField, ValidationError 5 | from wtforms.validators import Required, Length, Email, Regexp, EqualTo 6 | 7 | from ..models import User 8 | 9 | 10 | class LoginForm(FlaskForm): 11 | email = StringField( 12 | 'Email', 13 | validators=[ 14 | Required(), Length(1, 254), Email()]) 15 | password = PasswordField('Password', validators=[Required()]) 16 | remember_me = BooleanField('Keep me logged in') 17 | submit = SubmitField('Log In') 18 | 19 | 20 | class RegistrationForm(FlaskForm): 21 | email = StringField( 22 | 'Email', 23 | validators=[Required(), Length(1, 254), Email()]) 24 | password = PasswordField('Password', validators=[ 25 | Required()]) 26 | submit = SubmitField('Register') 27 | 28 | # def validate_email(self, field): 29 | # if User.query.filter_by(email=field.data).first(): 30 | # raise ValidationError('Email already registered.') 31 | 32 | # def validate_username(self, field): 33 | # if User.query.filter_by(username=field.data).first(): 34 | # raise ValidationError('Username already in use.') 35 | 36 | 37 | class ChangePasswordForm(FlaskForm): 38 | old_password = PasswordField('Old password', validators=[Required()]) 39 | password = PasswordField('New password', validators=[ 40 | Required(), EqualTo('password2', message='Passwords must match')]) 41 | password2 = PasswordField('Confirm new password', validators=[Required()]) 42 | submit = SubmitField('Update Password') 43 | 44 | 45 | class PasswordResetRequestForm(FlaskForm): 46 | email = StringField('Email', validators=[Required(), Length(1, 254), 47 | Email()]) 48 | submit = SubmitField('Reset Password') 49 | 50 | 51 | class PasswordResetForm(FlaskForm): 52 | email = StringField('Email', validators=[Required(), Length(1, 254), 53 | Email()]) 54 | password = PasswordField('New Password', validators=[ 55 | Required(), EqualTo('password2', message='Passwords must match')]) 56 | password2 = PasswordField('Confirm password', validators=[Required()]) 57 | submit = SubmitField('Reset Password') 58 | 59 | def validate_email(self, field): 60 | if User.query.filter_by(email=field.data).first() is None: 61 | raise ValidationError('Unknown email address.') 62 | 63 | 64 | class ChangeEmailForm(FlaskForm): 65 | email = StringField('New Email', validators=[Required(), Length(1, 254), 66 | Email()]) 67 | password = PasswordField('Password', validators=[Required()]) 68 | submit = SubmitField('Update Email Address') 69 | 70 | def validate_email(self, field): 71 | if User.query.filter_by(email=field.data).first(): 72 | raise ValidationError('Email already registered.') 73 | -------------------------------------------------------------------------------- /app/auth/views.py: -------------------------------------------------------------------------------- 1 | from flask import render_template, \ 2 | redirect, request, url_for, flash, jsonify 3 | from flask_login import login_user, \ 4 | logout_user, login_required, current_user 5 | 6 | from app import db 7 | from app.auth import auth 8 | from app.models import User, Notebook 9 | from app.email import send_email 10 | from app.auth.forms import LoginForm, RegistrationForm, \ 11 | ChangePasswordForm, PasswordResetRequestForm, \ 12 | PasswordResetForm, ChangeEmailForm 13 | 14 | 15 | @auth.before_app_request 16 | def before_request(): 17 | if current_user.is_authenticated \ 18 | and not current_user.confirmed \ 19 | and request.endpoint[:5] != 'auth.' \ 20 | and request.endpoint != 'static': 21 | return redirect(url_for('auth.unconfirmed')) 22 | 23 | 24 | @auth.route('/login', methods=['GET', 'POST']) 25 | def login(): 26 | form = LoginForm() 27 | if form.validate_on_submit(): 28 | user = User.query.filter_by( 29 | email=form.email.data.lower().strip()).first() 30 | if user is not None and user.verify_password(form.password.data): 31 | user.log_login() 32 | login_user(user, form.remember_me.data) 33 | return redirect(request.args.get('next') or url_for('main.index')) 34 | flash('Invalid username or password') 35 | return redirect(url_for('main.index')) 36 | 37 | 38 | @auth.route('/logout') 39 | @login_required 40 | def logout(): 41 | logout_user() 42 | flash('You have been logged out.') 43 | return redirect(url_for('main.index')) 44 | 45 | 46 | @auth.route('/register', methods=['GET', 'POST']) 47 | def register(): 48 | form = RegistrationForm() 49 | 50 | if form.validate_on_submit(): 51 | user_email = form.email.data.lower().strip() 52 | user_password = form.password.data 53 | 54 | # Check if user is already registered 55 | user = User.query.filter_by(email=user_email).first() 56 | if user: 57 | # Attempt to log the user in 58 | if user.verify_password(user_password): 59 | login_user(user) 60 | return redirect(request.args.get('next') or url_for('main.index')) 61 | flash('Invalid username or password') 62 | return redirect(url_for('main.index')) 63 | 64 | # Register the user 65 | user = User( 66 | email=form.email.data.lower().strip(), 67 | password=form.password.data) 68 | db.session.add(user) 69 | db.session.commit() 70 | default_notebook = Notebook( 71 | title='Default', author_id=user.id) 72 | db.session.add(default_notebook) 73 | db.session.commit() 74 | user.default_notebook = default_notebook.id 75 | db.session.commit() 76 | token = user.generate_confirmation_token() 77 | send_email( 78 | user.email, 'Confirm Your Account', 79 | 'auth/email/confirm', user=user, token=token) 80 | flash('A confirmation email has been sent.') 81 | return redirect(url_for('main.index')) 82 | return redirect(url_for('main.index')) 83 | 84 | 85 | @auth.route('/confirm') 86 | @login_required 87 | def resend_confirmation(): 88 | token = current_user.generate_confirmation_token() 89 | send_email( 90 | current_user.email, 'Confirm Your Account', 91 | '/auth/email/confirm', user=current_user, token=token) 92 | flash('A new confirmation email has been sent.') 93 | return redirect(url_for('main.index')) 94 | 95 | 96 | @auth.route('/confirm/') 97 | @login_required 98 | def confirm(token): 99 | if current_user.confirmed: 100 | return redirect(url_for('main.index')) 101 | if current_user.confirm(token): 102 | flash('You have confirmed your account. Thanks!') 103 | else: 104 | flash('The confirmation link is invalid or has expired.') 105 | return redirect(url_for('main.index')) 106 | 107 | 108 | @auth.route('/unconfirmed') 109 | def unconfirmed(): 110 | if current_user.is_anonymous or current_user.confirmed: 111 | return redirect(url_for('main.index')) 112 | return render_template('auth/unconfirmed.html') 113 | 114 | 115 | @auth.route('/change-password', methods=['GET', 'POST']) 116 | @login_required 117 | def change_password(): 118 | form = ChangePasswordForm() 119 | if form.validate_on_submit(): 120 | if current_user.verify_password(form.old_password.data): 121 | current_user.password = form.password.data 122 | db.session.add(current_user) 123 | db.session.commit() 124 | flash('Your password has been updated.') 125 | return redirect(url_for('main.index')) 126 | else: 127 | flash('Invalid password.') 128 | return render_template("auth/change_password.html", form=form) 129 | 130 | 131 | @auth.route('/reset', methods=['GET', 'POST']) 132 | def password_reset_request(): 133 | if not current_user.is_anonymous: 134 | return redirect(url_for('main.index')) 135 | form = PasswordResetRequestForm() 136 | if form.validate_on_submit(): 137 | user = User.query.filter_by( 138 | email=form.email.data.lower().strip()).first() 139 | if user: 140 | token = user.generate_reset_token() 141 | send_email(user.email, 'Reset Your Password', 142 | 'auth/email/reset_password', 143 | user=user, token=token, 144 | next=request.args.get('next')) 145 | flash('An email with instructions to reset your password has been ' 146 | 'sent to you.') 147 | return redirect(url_for('auth.login')) 148 | return render_template('auth/reset_password.html', form=form) 149 | 150 | 151 | @auth.route('/reset/', methods=['GET', 'POST']) 152 | def password_reset(token): 153 | if not current_user.is_anonymous: 154 | return redirect(url_for('main.index')) 155 | form = PasswordResetForm() 156 | if form.validate_on_submit(): 157 | user = User.query.filter_by( 158 | email=form.email.data.lower().strip()).first() 159 | if user is None: 160 | return redirect(url_for('main.index')) 161 | if user.reset_password(token, form.password.data): 162 | flash('Your password has been updated.') 163 | return redirect(url_for('auth.login')) 164 | else: 165 | return redirect(url_for('main.index')) 166 | return render_template('auth/reset_password.html', form=form) 167 | 168 | 169 | @auth.route('/change-email', methods=['GET', 'POST']) 170 | @login_required 171 | def change_email_request(): 172 | form = ChangeEmailForm() 173 | if form.validate_on_submit(): 174 | if current_user.verify_password(form.password.data): 175 | new_email = form.email.data.lower().strip() 176 | token = current_user.generate_email_change_token(new_email) 177 | send_email(new_email, 'Confirm your email address', 178 | 'auth/email/change_email', 179 | user=current_user, token=token) 180 | flash('An email with instructions to confirm your new email ' 181 | 'address has been sent to you.') 182 | return redirect(url_for('main.index')) 183 | else: 184 | flash('Invalid email or password.') 185 | return render_template("auth/change_email.html", form=form) 186 | 187 | 188 | @auth.route('/change-email/') 189 | @login_required 190 | def change_email(token): 191 | if current_user.change_email(token): 192 | flash('Your email address has been updated.') 193 | else: 194 | flash('Invalid request.') 195 | return redirect(url_for('main.index')) 196 | -------------------------------------------------------------------------------- /app/email.py: -------------------------------------------------------------------------------- 1 | from threading import Thread 2 | from flask import current_app, render_template 3 | from flask_mail import Message 4 | from . import mail 5 | 6 | 7 | def send_async_email(app, msg): 8 | with app.app_context(): 9 | mail.send(msg) 10 | 11 | 12 | def send_email(to, subject, template, **kwargs): 13 | app = current_app._get_current_object() 14 | msg = Message(app.config['MAIL_SUBJECT_PREFIX'] + ' ' + subject, 15 | sender=app.config['MAIL_SENDER'], recipients=[to]) 16 | msg.body = render_template(template + '.txt', **kwargs) 17 | msg.html = render_template(template + '.html', **kwargs) 18 | thr = Thread(target=send_async_email, args=[app, msg]) 19 | thr.start() 20 | return thr 21 | -------------------------------------------------------------------------------- /app/exceptions.py: -------------------------------------------------------------------------------- 1 | class ValidationError(ValueError): 2 | pass 3 | -------------------------------------------------------------------------------- /app/lib/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/levlaz/braindump/9640dd03f99851dbd34dd6cac98a747a4a591b01/app/lib/__init__.py -------------------------------------------------------------------------------- /app/lib/export.py: -------------------------------------------------------------------------------- 1 | import os 2 | import zipfile 3 | from slugify import slugify 4 | 5 | 6 | class Exporter(object): 7 | """Exports Notes to Markdown Files""" 8 | 9 | def __init__(self, user): 10 | self.directory = "/tmp/braindump-export/{0}".format(user.id) 11 | self.notes = user.notes.all() 12 | self.create_export_dir() 13 | self.zip_file = "/tmp/braindump-export/{0}/braindump_export.zip".format(user.id) 14 | 15 | def create_export_dir(self): 16 | if not os.path.exists(self.directory): 17 | os.makedirs(self.directory) 18 | 19 | def export(self): 20 | with zipfile.ZipFile("{0}/braindump_export.zip".format(self.directory), "w") as export_file: 21 | for note in self.notes: 22 | file_name = "{0}/{1}.md".format(self.directory, slugify(note.title)) 23 | zip_file_name = "{0}.md".format(slugify(note.title)) 24 | note_file = open(file_name, "w") 25 | note_file.write(self.add_front_matter( 26 | note.title, note.created_date, note.notebook.title)) 27 | note_file.write(note.body) 28 | note_file.close() 29 | export_file.write(file_name, zip_file_name) 30 | 31 | @staticmethod 32 | def add_front_matter(title, date, notebook): 33 | """Add metadata to each note""" 34 | # FIXME clean this up 35 | frontmatter = "---\ntitle: {0}\ndate: {1}\nnotebook: {2}\n---\n".format( 36 | title, date, notebook) 37 | return frontmatter 38 | 39 | -------------------------------------------------------------------------------- /app/main/__init__.py: -------------------------------------------------------------------------------- 1 | from flask import Blueprint 2 | 3 | main = Blueprint('main', __name__) 4 | 5 | from . import views, notebooks, users, errors, forms # noqa 6 | -------------------------------------------------------------------------------- /app/main/errors.py: -------------------------------------------------------------------------------- 1 | from flask import render_template, request, jsonify 2 | from . import main 3 | 4 | 5 | @main.app_errorhandler(404) 6 | def page_not_found(e): 7 | if request.accept_mimetypes.accept_json and \ 8 | not request.accept_mimetypes.accept_html: 9 | resp = jsonify({'error': 'not found'}) 10 | resp.status_code = 404 11 | return resp 12 | return render_template('404.html'), 404 13 | 14 | 15 | @main.app_errorhandler(500) 16 | def internal_server_error(e): 17 | if request.accept_mimetypes.accept_json and \ 18 | not request.accept_mimetypes.accept_html: 19 | resp = jsonify({'error': 'internal server error'}) 20 | resp.status_code = 500 21 | return resp 22 | return render_template('500.html'), 500 23 | -------------------------------------------------------------------------------- /app/main/forms.py: -------------------------------------------------------------------------------- 1 | from flask_wtf import FlaskForm 2 | from wtforms import StringField, SubmitField, \ 3 | TextAreaField, SelectField, HiddenField 4 | from wtforms.validators import Required, Length, Email, ValidationError 5 | 6 | 7 | def validate_tags(form, field): 8 | if field.data: 9 | for x in field.data.split(','): 10 | if len(x) not in range(200+1): 11 | raise ValidationError( 12 | 'All tags must be less than 200 characters') 13 | 14 | 15 | class NoteForm(FlaskForm): 16 | title = StringField('Title:', validators=[Required(), Length(1, 200)]) 17 | body = HiddenField() 18 | tags = StringField(validators=[validate_tags]) 19 | notebook = SelectField(coerce=int) 20 | submit = SubmitField('Submit') 21 | 22 | 23 | class ShareForm(FlaskForm): 24 | recipient_email = StringField( 25 | 'Recipient Email', validators=[Required(), Length(1, 254), Email()]) 26 | submit = SubmitField('Share') 27 | 28 | 29 | class NotebookForm(FlaskForm): 30 | title = StringField('Title:', validators=[Required(), Length(1, 200)]) 31 | submit = SubmitField('Submit') 32 | 33 | 34 | class SearchForm(FlaskForm): 35 | search_field = StringField() 36 | submit = SubmitField('Search') 37 | -------------------------------------------------------------------------------- /app/main/notebooks.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from flask import render_template, redirect, \ 3 | url_for, flash, abort, current_app, request, \ 4 | jsonify 5 | from flask_login import current_user, login_required 6 | 7 | from . import main 8 | from app import db, csrf 9 | from app.main.forms import NotebookForm 10 | from app.models import Notebook 11 | 12 | 13 | @main.route('/notebooks', methods=['GET', 'POST']) 14 | @login_required 15 | def notebooks(): 16 | form = NotebookForm() 17 | if form.validate_on_submit(): 18 | if Notebook.query.filter_by( 19 | title=form.title.data, 20 | author_id=current_user.id).first() is None: 21 | notebook = Notebook( 22 | title=form.title.data, 23 | author_id=current_user.id) 24 | db.session.add(notebook) 25 | db.session.commit() 26 | else: 27 | flash('A notebook with name {0} already exists.'.format( 28 | form.title.data)) 29 | return redirect(url_for('.notebooks')) 30 | notebooks = Notebook.query.filter_by( 31 | author_id=current_user.id, 32 | is_deleted=False).all() 33 | return render_template( 34 | 'app/notebooks.html', 35 | notebooks=notebooks, 36 | form=form) 37 | 38 | 39 | @main.route('/notebook/') 40 | @login_required 41 | def notebook(id): 42 | notebook = Notebook.query.filter_by(id=id).first() 43 | if current_user != notebook.author: 44 | abort(403) 45 | return render_template( 46 | 'app/notebook.html', 47 | notebook=notebook, 48 | notes=notebook.active_notes()) 49 | 50 | 51 | @main.route('/notebook/', methods=['DELETE']) 52 | @login_required 53 | def delete_notebook(id): 54 | notebook = Notebook.query.filter_by(id=id).first() 55 | if current_user != notebook.author: 56 | abort(403) 57 | else: 58 | 59 | if notebook.id == current_user.default_notebook: 60 | return jsonify({"error": "You cannot delete your default notebook!"}), 400 61 | else: 62 | notebook.is_deleted = True 63 | notebook.updated_date = datetime.utcnow() 64 | db.session.commit() 65 | 66 | for note in notebook.notes: 67 | note.is_deleted = True 68 | note.updated_date = datetime.utcnow() 69 | db.session.commit() 70 | 71 | return jsonify(notebook.to_json()) -------------------------------------------------------------------------------- /app/main/users.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from flask import render_template, redirect, \ 3 | url_for, flash, abort, current_app, request, \ 4 | jsonify 5 | from flask_login import current_user, login_required 6 | 7 | from . import main 8 | from app import db, csrf 9 | 10 | 11 | @main.route('/users', methods=['PUT']) 12 | @login_required 13 | def update_user(): 14 | if request.is_json: 15 | current_user.default_notebook = request.json.get('default_notebook') 16 | db.session.commit() 17 | return jsonify(current_user.to_json()) -------------------------------------------------------------------------------- /app/main/views.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from markdown import markdown 3 | from flask import render_template, redirect, \ 4 | url_for, flash, abort, current_app, request, \ 5 | jsonify, send_file 6 | from flask_login import current_user, login_required 7 | 8 | from . import main 9 | from app import db, csrf 10 | from app.main.forms import NoteForm, ShareForm, \ 11 | NotebookForm, SearchForm 12 | from app.email import send_email 13 | from app.models import User, Note, Tag, Notebook 14 | from app.model.shared import SharedNote 15 | from app.lib.export import Exporter 16 | 17 | 18 | @main.route('/', methods=['GET', 'POST']) 19 | def index(): 20 | if current_user.is_authenticated: 21 | notes = Note.query.filter_by( 22 | author_id=current_user.id, 23 | is_deleted=False, is_archived=False).order_by( 24 | Note.is_favorite.desc(), 25 | Note.updated_date.desc()).all() 26 | return render_template('app/app.html', notes=notes) 27 | else: 28 | stats = {} 29 | stats['users'] = User.query.count() 30 | stats['notes'] = Note.query.count() 31 | return render_template('index.html', stats=stats) 32 | 33 | 34 | @main.route('/add', methods=['GET', 'POST']) 35 | @login_required 36 | def add(): 37 | if request.args.get('notebook'): 38 | notebook = Notebook.query.filter_by( 39 | id=int(request.args.get('notebook'))).first() 40 | form = NoteForm(notebook=notebook.id) 41 | else: 42 | form = NoteForm() 43 | form.notebook.choices = [ 44 | (n.id, n.title) for n in 45 | Notebook.query.filter_by( 46 | author_id=current_user.id, is_deleted=False).all()] 47 | if form.validate_on_submit(): 48 | note = Note( 49 | title=form.title.data, 50 | body=form.body.data, 51 | notebook_id=form.notebook.data, 52 | author=current_user._get_current_object()) 53 | db.session.add(note) 54 | db.session.commit() 55 | 56 | tags = [] 57 | if not len(form.tags.data) == 0: 58 | for tag in form.tags.data.split(','): 59 | tags.append(tag.replace(" ", "")) 60 | note.str_tags = (tags) 61 | db.session.commit() 62 | return redirect(url_for('main.notebook', id=note.notebook_id)) 63 | return render_template('app/add.html', form=form) 64 | 65 | 66 | @main.route('/settings') 67 | @login_required 68 | def settings(): 69 | version = current_app.config['BRAINDUMP_VERSION'] 70 | notebooks = current_user.notebooks.filter_by(is_deleted=False) 71 | shared_notes = current_user.shared_notes.order_by(SharedNote.created_date.desc()) 72 | return render_template( 73 | 'app/settings.html', 74 | shared_notes=shared_notes, 75 | notebooks=notebooks, 76 | version=version) 77 | 78 | 79 | @main.route('/settings/export') 80 | @login_required 81 | def export_notes(): 82 | e = Exporter(current_user) 83 | e.export() 84 | return send_file(e.zip_file, as_attachment=True) 85 | 86 | 87 | @main.route('/trash') 88 | @login_required 89 | def trash(): 90 | if current_user.is_authenticated: 91 | notes = current_user.get_deleted_notes() 92 | if len(notes) == 0: 93 | flash("Trash is empty, you are so Tidy!") 94 | return redirect(url_for('.index')) 95 | return render_template('app/trash.html', notes=notes) 96 | else: 97 | return render_template('index.html') 98 | 99 | 100 | @main.route('/empty-trash') 101 | @login_required 102 | def empty_trash(): 103 | list(map(lambda x: delete_forever(x.id), current_user.get_deleted_notes())) 104 | flash("Took out the Trash") 105 | return redirect(url_for('.index')) 106 | 107 | 108 | @main.route('/note/') 109 | @login_required 110 | def note(id): 111 | note = Note.query.get_or_404(id) 112 | if current_user != note.author: 113 | abort(403) 114 | return render_template('app/note.html', notes=[note]) 115 | 116 | 117 | @main.route('/edit/', methods=['PUT']) 118 | @login_required 119 | def edit(id): 120 | if request.is_json: 121 | note = Note.query.get_or_404(id) 122 | if current_user != note.author: 123 | return abort(403) 124 | note.body = request.json.get('body', note.body) 125 | note.updated_date = datetime.utcnow() 126 | db.session.add(note) 127 | db.session.commit() 128 | return jsonify(note.to_json()) 129 | 130 | 131 | @main.route('/delete/', methods=['GET', 'POST']) 132 | @login_required 133 | def delete(id): 134 | note = Note.query.get_or_404(id) 135 | if current_user != note.author: 136 | abort(403) 137 | else: 138 | note.is_deleted = True 139 | note.updated_date = datetime.utcnow() 140 | db.session.commit() 141 | flash('The note has been deleted.') 142 | return redirect(request.referrer) 143 | 144 | 145 | @main.route('/delete-forever/', methods=['GET', 'POST']) 146 | @login_required 147 | def delete_forever(id): 148 | note = Note.query.get_or_404(id) 149 | if current_user != note.author: 150 | abort(403) 151 | else: 152 | db.session.delete(note) 153 | db.session.commit() 154 | flash('So Long! The note has been deleted forever.') 155 | return redirect(request.referrer) 156 | 157 | 158 | @main.route('/restore/', methods=['GET', 'POST']) 159 | @login_required 160 | def restore(id): 161 | note = Note.query.get_or_404(id) 162 | if current_user != note.author: 163 | abort(403) 164 | else: 165 | note.is_deleted = False 166 | note.updated_date = datetime.utcnow() 167 | db.session.commit() 168 | flash('The note has been restored.') 169 | return redirect(request.referrer) 170 | 171 | 172 | @main.route('/share/', methods=['GET', 'POST']) 173 | @login_required 174 | def share(id): 175 | note = Note.query.get_or_404(id) 176 | if current_user != note.author: 177 | abort(403) 178 | form = ShareForm() 179 | if form.validate_on_submit(): 180 | send_email( 181 | form.recipient_email.data, 182 | '{0} has shared a braindump with you!' 183 | .format(current_user.email), 184 | 'app_email/share_note', 185 | user=current_user, note=note, html=markdown(note.body)) 186 | shared = SharedNote( 187 | author_id=current_user.id, 188 | note_id=note.id, 189 | recipient_email=form.recipient_email.data) 190 | db.session.add(shared) 191 | db.session.commit() 192 | flash('The note has been shared!') 193 | return redirect(url_for('.index')) 194 | return render_template('app/share_note.html', form=form, notes=[note]) 195 | 196 | 197 | @main.route('/tag/') 198 | @login_required 199 | def tag(name): 200 | tag = Tag.query.filter_by(tag=name).first() 201 | return render_template('app/tag.html', notes=tag._get_notes(), tag=name) 202 | 203 | 204 | @main.route('/favorites', methods=['GET']) 205 | @login_required 206 | def favorites(): 207 | heading = "Favorite Notes" 208 | notes = Note.query.filter_by( 209 | author_id=current_user.id, 210 | is_deleted=False, 211 | is_favorite=True, is_archived=False).order_by( 212 | Note.updated_date.desc()).all() 213 | if len(notes) == 0: 214 | flash("No favorites yet, click on the star in a note to mark \ 215 | it as a favorite.") 216 | return redirect(url_for('.index')) 217 | return render_template('app/app.html', notes=notes, heading=heading) 218 | 219 | 220 | @main.route('/search') 221 | @login_required 222 | def search(): 223 | form = SearchForm() 224 | if request.args.get('search_field', ''): 225 | query = request.args.get('search_field', '') 226 | results = Note.query.search(query) \ 227 | .filter_by(author_id=current_user.id) \ 228 | .order_by(Note.updated_date.desc()).all() 229 | if len(results) == 0: 230 | flash('Hmm, we did not find any \ 231 | braindumps matching your search. Try again?') 232 | return render_template( 233 | 'app/search.html', 234 | form=form, 235 | notes=results) 236 | return render_template( 237 | 'app/search.html', 238 | form=form) 239 | 240 | 241 | @main.route('/favorite/', methods=['GET', 'POST']) 242 | @login_required 243 | def favorite(id): 244 | note = Note.query.get_or_404(id) 245 | if current_user != note.author: 246 | abort(403) 247 | else: 248 | if not note.is_favorite: 249 | note.is_favorite = True 250 | note.updated_date = datetime.utcnow() 251 | db.session.commit() 252 | flash('Note marked as favorite') 253 | else: 254 | note.is_favorite = False 255 | note.updated_date = datetime.utcnow() 256 | db.session.commit() 257 | flash('Note removed as favorite') 258 | return redirect(request.referrer) 259 | 260 | 261 | @main.route('/archive') 262 | @login_required 263 | def view_archive(): 264 | if current_user.is_authenticated: 265 | notes = Note.query.filter_by( 266 | author_id=current_user.id, 267 | is_deleted=False, is_archived=True).order_by( 268 | Note.updated_date.desc()).all() 269 | if len(notes) == 0: 270 | flash("Archive is empty") 271 | return redirect(url_for('.index')) 272 | return render_template('app/archive.html', notes=notes) 273 | else: 274 | return render_template('index.html') 275 | 276 | 277 | @main.route('/archive/') 278 | @login_required 279 | def archive(id): 280 | note = Note.query.get_or_404(id) 281 | if current_user != note.author: 282 | abort(403) 283 | else: 284 | note.is_archived = True 285 | note.updated_date = datetime.utcnow() 286 | db.session.commit() 287 | flash('The note has been archived.') 288 | return redirect(request.referrer) 289 | 290 | 291 | @main.route('/shutdown') 292 | def server_shutdown(): 293 | if not current_app.testing: 294 | abort(404) 295 | shutdown = request.environ.get('werkzeug.server.shutdown') 296 | if not shutdown: 297 | abort(500) 298 | shutdown() 299 | return 'Shutting down...' 300 | -------------------------------------------------------------------------------- /app/model/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/levlaz/braindump/9640dd03f99851dbd34dd6cac98a747a4a591b01/app/model/__init__.py -------------------------------------------------------------------------------- /app/model/base.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from app import db 3 | 4 | 5 | class Base(db.Model): 6 | __abstract__ = True 7 | id = db.Column(db.Integer, primary_key=True) 8 | created_date = db.Column(db.DateTime, index=True, default=datetime.utcnow) 9 | updated_date = db.Column(db.DateTime, index=True, default=datetime.utcnow) 10 | -------------------------------------------------------------------------------- /app/model/shared.py: -------------------------------------------------------------------------------- 1 | from app import db 2 | from app.model.base import Base 3 | 4 | 5 | class SharedNote(Base): 6 | """Model that keeps a record of shared notes""" 7 | __tablename__ = 'shared_notes' 8 | author_id = db.Column(db.Integer, db.ForeignKey('users.id')) 9 | note_id = db.Column(db.Integer, db.ForeignKey('notes.id')) 10 | recipient_email = db.Column(db.String(254)) 11 | -------------------------------------------------------------------------------- /app/static/css/app.min.css: -------------------------------------------------------------------------------- 1 | .alert,.nav-pills>li>a{border-radius:0}.cover .btn-lg,.heading,.masthead-nav>li>button{font-weight:700}.notebook-card a,.sidebar-nav li a,.sidebar-nav li a:active,.sidebar-nav li a:focus{text-decoration:none}@font-face{font-family:Ubuntu;font-style:normal;font-weight:300;src:local('Ubuntu Light'),local('Ubuntu-Light'),url(https://fonts.gstatic.com/s/ubuntu/v9/_aijTyevf54tkVDLy-dlnKCWcynf_cDxXwCLxiixG1c.ttf) format('truetype')}@font-face{font-family:Ubuntu;font-style:normal;font-weight:400;src:local('Ubuntu'),url(https://fonts.gstatic.com/s/ubuntu/v9/2Q-AW1e_taO6pHwMXcXW5w.ttf) format('truetype')}body{font-family:Ubuntu,sans-serif}body,html{height:100%}.site-wrapper{display:table;width:100%;height:100%;min-height:100%;-webkit-box-shadow:inset 0 0 100px rgba(0,0,0,.5);box-shadow:inset 0 0 100px rgba(0,0,0,.5);background-color:#2196f3;color:#fff;text-align:center;text-shadow:0 1px 3px rgba(0,0,0,.5)}.site-wrapper a,.site-wrapper a:focus,.site-wrapper a:hover{color:#fff}.site-wrapper .btn-default,.site-wrapper .btn-default:focus,.site-wrapper .btn-default:hover{color:#333;text-shadow:none}.site-wrapper-inner{display:table-cell;vertical-align:top}.cover-container{margin-right:auto;margin-left:auto}.inner{padding:30px}.masthead-brand{margin-top:10px;margin-bottom:10px}.masthead-nav>li{display:inline-block}.masthead-nav>li+li{margin-left:5px}.masthead-nav>li>a:focus,.masthead-nav>li>a:hover{background-color:transparent;border-bottom-color:#a9a9a9;border-bottom-color:rgba(255,255,255,.25)}.masthead-nav>.active>a,.masthead-nav>.active>a:focus,.masthead-nav>.active>a:hover{color:#fff;border-bottom-color:#fff}.cover{padding:0 20px}.cover .btn-lg{padding:10px 20px}*,.container{padding:0;margin:0}.mastfoot{color:#b2dbfb}@media (min-width:768px){.masthead-brand{float:left}.masthead-nav{float:right}.masthead{position:fixed;top:0}.mastfoot{position:fixed;bottom:0}.site-wrapper-inner{vertical-align:middle}.cover-container,.mastfoot,.masthead{width:100%}}@media (min-width:992px){.cover-container,.mastfoot,.masthead{width:700px}}.col-sm-1,.col-sm-11,.col-sm-4,.col-sm-7,.container,.row,body,html{height:100%;margin:0;padding:0}.container{min-height:100%;overflow:hidden;width:100%}.heading{padding-top:15px}.alert{position:fixed;top:0;right:0;width:50%;padding:10px;z-index:10;margin:0}.app-logo{width:50px;height:50px}.col-sm-1{background-color:#1976d2;padding:0;overflow-y:auto}.col-sm-11,.col-sm-7{padding-right:15px;word-wrap:break-word}@media screen and (max-width:768px){.col-sm-1{display:none}}.col-sm-1 li,.col-sm-1 ul{list-style:none}.sidebar-nav li a{padding-top:10px;padding-bottom:10px;display:block;color:#fff;text-align:center;font-size:25px}.col-sm-4,.col-sm-7{padding-bottom:100px;overflow-y:auto}.nav-description,.note-date{font-size:12px}.sidebar-nav li:hover{color:#fff;background:rgba(255,255,255,.2)}.col-sm-4{border-top:solid 1px #2196f3;border-right:solid 1px #BDBDBD}.col-sm-7,.note-date{border-top:solid 1px #BDBDBD}.col-sm-4 li{border-bottom:solid 1px #BDBDBD}.nav-stacked>li+li{margin:0}.note-actions,.note-notebook{margin-top:5px}.note-notebook,.task-heading{margin-bottom:10px}.nav-pills>li.active>a,.nav-pills>li.active>a:focus,.nav-pills>li.active>a:hover{color:#fff;background-color:#2196f3}.col-sm-7{padding-left:30px;background-color:#fff}.col-sm-11,.col-sm-11 ol,.col-sm-11 ul{padding-left:15px}.note-updated-date{float:right;margin-right:10px;font-style:italic;color:#7A7A7A}.label-notebook,.label-notebook:hover{border:1px solid #1976D2;margin-right:5px;color:#1976D2}.label-tag{background-color:rgba(25,118,210,.8);margin-right:5px;line-height:2}.label-tag:hover{background-color:#1976d2;margin-right:5px}.label-notebook{background-color:#BBDEFB}.label-notebook:hover{background-color:#d3eafc}.note-actions{float:right;margin-right:10px}.note-actions a,.note-content ol,.note-content ul{margin-left:20px}.favorite{color:#fff16a;text-shadow:-1px 0 #000,0 1px #000,1px 0 #000,0 -1px #000}.col-sm-11{padding-top:15px;overflow-y:auto;background-color:#fff}.good{color:green}.bad{color:red}.task-heading>.btn{width:100px;margin-right:10px}.mobile-nav{position:absolute;bottom:0;height:50px;width:100%;background-color:#2196f3;text-align:center}@media screen and (min-width:768px){.mobile-nav{display:none}}.mobile-nav ul{padding-top:13px}.mobile-nav li{display:inline;padding:15px}.mobile-nav a{color:#FFF;font-size:2em}.modal,.modal-title{color:#555}#noteTabs-accordion{margin-bottom:100px}.modal{text-shadow:none}.editor{margin-bottom:10px}.ProseMirror-content{min-height:200px}.outer-header{border-top:solid 2px red}.jumbotron{color:#00f}.alert-outer{position:fixed;width:100%;bottom:0;right:unset;top:unset}.notebook-card{width:200px;box-shadow:0 4px 8px 0 rgba(0,0,0,.2);transition:.3s;margin-right:20px;margin-bottom:20px;float:left;border-top:solid 2px #1976d2}.notebook-card p{text-align:center;padding-top:20px;padding-left:5px;padding-right:5px;font-weight:700}.notebook-card-title{height:100px;background-color:#2196f3;color:#fff}.notebook-card-actions{padding:10px;text-align:right;font-size:16px;color:#1976d2}.notebook-card-actions i{margin-left:10px}.notebook-card:hover{box-shadow:0 8px 16px 0 rgba(0,0,0,.2)}.badge{margin-top:10px;padding:5px;background-color:#fff;box-shadow:0 3px 10px 0 rgba(0,0,0,.2);color:#1976d2} -------------------------------------------------------------------------------- /app/static/fonts/FontAwesome.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/levlaz/braindump/9640dd03f99851dbd34dd6cac98a747a4a591b01/app/static/fonts/FontAwesome.otf -------------------------------------------------------------------------------- /app/static/fonts/fontawesome-webfont.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/levlaz/braindump/9640dd03f99851dbd34dd6cac98a747a4a591b01/app/static/fonts/fontawesome-webfont.eot -------------------------------------------------------------------------------- /app/static/fonts/fontawesome-webfont.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/levlaz/braindump/9640dd03f99851dbd34dd6cac98a747a4a591b01/app/static/fonts/fontawesome-webfont.ttf -------------------------------------------------------------------------------- /app/static/fonts/fontawesome-webfont.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/levlaz/braindump/9640dd03f99851dbd34dd6cac98a747a4a591b01/app/static/fonts/fontawesome-webfont.woff -------------------------------------------------------------------------------- /app/static/fonts/fontawesome-webfont.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/levlaz/braindump/9640dd03f99851dbd34dd6cac98a747a4a591b01/app/static/fonts/fontawesome-webfont.woff2 -------------------------------------------------------------------------------- /app/static/fonts/glyphicons-halflings-regular.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/levlaz/braindump/9640dd03f99851dbd34dd6cac98a747a4a591b01/app/static/fonts/glyphicons-halflings-regular.eot -------------------------------------------------------------------------------- /app/static/fonts/glyphicons-halflings-regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/levlaz/braindump/9640dd03f99851dbd34dd6cac98a747a4a591b01/app/static/fonts/glyphicons-halflings-regular.ttf -------------------------------------------------------------------------------- /app/static/fonts/glyphicons-halflings-regular.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/levlaz/braindump/9640dd03f99851dbd34dd6cac98a747a4a591b01/app/static/fonts/glyphicons-halflings-regular.woff -------------------------------------------------------------------------------- /app/static/fonts/glyphicons-halflings-regular.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/levlaz/braindump/9640dd03f99851dbd34dd6cac98a747a4a591b01/app/static/fonts/glyphicons-halflings-regular.woff2 -------------------------------------------------------------------------------- /app/static/images/all_notes.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/levlaz/braindump/9640dd03f99851dbd34dd6cac98a747a4a591b01/app/static/images/all_notes.png -------------------------------------------------------------------------------- /app/static/images/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/levlaz/braindump/9640dd03f99851dbd34dd6cac98a747a4a591b01/app/static/images/favicon.png -------------------------------------------------------------------------------- /app/static/images/logo.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/static/images/new_note.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/levlaz/braindump/9640dd03f99851dbd34dd6cac98a747a4a591b01/app/static/images/new_note.png -------------------------------------------------------------------------------- /app/static/images/notebooks.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/levlaz/braindump/9640dd03f99851dbd34dd6cac98a747a4a591b01/app/static/images/notebooks.png -------------------------------------------------------------------------------- /app/templates/404.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block title %} BrainDump | Not Found {% endblock %} 4 | 5 | {% block page_content %} 6 |

Not found

7 | 8 |

The page you requested could not be found

9 | 10 | {% endblock %} 11 | -------------------------------------------------------------------------------- /app/templates/500.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block title %} BrainDump | Internal Error {% endblock %} 4 | 5 | {% block page_content %} 6 |

Server Error

7 | 8 |

Oops! Something went wrong, we have been notified about this issue and are looking into it now.

9 | 10 | {% endblock %} 11 | -------------------------------------------------------------------------------- /app/templates/app/_mobile_nav.html: -------------------------------------------------------------------------------- 1 |
    2 | {% block add %} 3 |
  • 4 | 5 |
  • 6 | {% endblock %} 7 |
  • 8 | 9 |
  • 10 |
  • 11 | 12 |
  • 13 |
  • 14 | 15 |
  • 16 |
  • 17 | 18 |
  • 19 |
20 | -------------------------------------------------------------------------------- /app/templates/app/_note.html: -------------------------------------------------------------------------------- 1 | {% for note in notes %} 2 | {% if request.query_string == '' and loop.index == 1 %} 3 |
4 | {% elif request.args.get('active_note')|int == note.id %} 5 |
6 | {% else %} 7 |
8 | {% endif %} 9 | 10 |

{{ note.title }}

11 | 12 |
13 | 14 | 15 |
16 | 17 |
18 | {% if not note.is_deleted %} 19 | {% if note.is_favorite %} 20 | 21 | {% else %} 22 | 23 | {% endif %} 24 | 25 | 26 | 27 | {% else %} 28 | 29 | 30 | {% endif %} 31 |
32 | 33 | 36 | 37 |
38 |

39 | {% if current_user == note.author %} 40 | {% for tag in note._get_tags() %} 41 | {{ tag }} 42 | {% endfor %} 43 | {% endif %} 44 |

45 |
46 | 47 |
48 |
49 | 50 |
51 |
52 |
53 | {% endfor %} 54 | -------------------------------------------------------------------------------- /app/templates/app/_notes.html: -------------------------------------------------------------------------------- 1 | {% for note in notes %} 2 | 3 |
4 | 5 |
6 | 7 |
8 | 9 | 12 | 13 |
14 | 15 | {% endfor %} 16 | -------------------------------------------------------------------------------- /app/templates/app/add.html: -------------------------------------------------------------------------------- 1 | {% extends "app/app_base.html" %} 2 | {% import "bootstrap/wtf.html" as wtf %} 3 | 4 | {% block title %}BrainDump{% endblock %} 5 | 6 | {% block page_content %} 7 | 8 |
9 |
10 | {{ form.csrf_token }} 11 | {{ wtf.form_field(form.title, autofocus=true) }} 12 | {{ form.body() }} 13 |
14 |
15 | {{ form.tags( **{'id':"tag-input", 'class': "form-group", 'placeholder': "Enter Tags (Optional)", 'data-role':"tagsinput"}) }} 16 | {{ wtf.form_field(form.notebook) }} 17 | {{ wtf.form_field(form.submit, class="btn btn-default form-group") }} 18 |
19 | 20 |
21 |
22 |
23 | {% endblock %} 24 | -------------------------------------------------------------------------------- /app/templates/app/app.html: -------------------------------------------------------------------------------- 1 | {% extends "app/app_base.html" %} 2 | 3 | {% block title %}BrainDump{% endblock %} 4 | 5 | {% block page_content %} 6 | 7 |
8 | {% if heading %} 9 |

{{ heading }}

10 | {% else %} 11 |

Welcome to Braindump! 12 |

13 | {% endif %} 14 |
15 | 16 |
17 |
18 | 37 |
38 | 39 |
40 |
41 | {% include 'app/_note.html' %} 42 |
43 |
44 | 45 |
46 | 47 | {% endblock %} 48 | -------------------------------------------------------------------------------- /app/templates/app/app_base.html: -------------------------------------------------------------------------------- 1 | {% extends 'bootstrap/base.html' %} 2 | 3 | {% block head %} 4 | {{ super() }} 5 | 6 | 7 | 8 | 9 | 10 | {% endblock %} 11 | 12 | {% block title %} BrainDump {% endblock %} 13 | 14 | {% block navbar %} 15 | 16 |
17 |
18 | 19 |
20 | 51 |
52 | 53 | 54 | {% endblock %} 55 | 56 | {% block content %} 57 | 58 | {% for message in get_flashed_messages() %} 59 |
60 | 61 | {{ message }} 62 |
63 | {% endfor %} 64 | {% block page_content %}{% endblock %} 65 | 66 |
67 | 68 |
69 | {% include 'app/_mobile_nav.html' %} 70 |
71 | {% endblock %} 72 | 73 | {% block scripts %} 74 | 75 | {% endblock %} 76 | -------------------------------------------------------------------------------- /app/templates/app/archive.html: -------------------------------------------------------------------------------- 1 | {% extends 'app/app_base.html' %} 2 | 3 | {% block title %} Braindump | Archive {% endblock %} 4 | 5 | {% block page_content %} 6 |
7 |

Archived Notes

8 |
9 | 10 |
11 | 12 |
13 | 32 |
33 | 34 |
35 |
36 | {% include 'app/_note.html' %} 37 |
38 |
39 | 40 |
41 | {% endblock %} 42 | -------------------------------------------------------------------------------- /app/templates/app/note.html: -------------------------------------------------------------------------------- 1 | {% extends 'app/app_base.html' %} 2 | 3 | {% block title %} Braindump | Note {% endblock %} 4 | 5 | {% block page_content %} 6 |
7 | {% include 'app/_note.html' %} 8 |
9 | {% endblock %} 10 | -------------------------------------------------------------------------------- /app/templates/app/notebook.html: -------------------------------------------------------------------------------- 1 | {% extends 'app/app_base.html' %} 2 | 3 | {% block title %} Braindump | Note {% endblock %} 4 | 5 | {% block add %} 6 | 9 | {% endblock %} 10 | 11 | {% block page_content %} 12 | 13 |
14 |

Notes in {{ notebook.title }}

15 |
16 | 17 |
18 | 19 |
20 | 37 |
38 | 39 |
40 |
41 | {% include 'app/_note.html' %} 42 |
43 |
44 | 45 |
46 | 47 | {% endblock %} 48 | -------------------------------------------------------------------------------- /app/templates/app/notebooks.html: -------------------------------------------------------------------------------- 1 | {% extends "app/app_base.html" %} 2 | {% import "bootstrap/wtf.html" as wtf %} 3 | 4 | {% block title %}BrainDump{% endblock %} 5 | 6 | {% block page_content %} 7 | 8 |
9 |

Add a New Notebook

10 | 11 |
12 | {{ form.csrf_token }} 13 | {{ wtf.form_field(form.title, autofocus=true) }} 14 | {{ wtf.form_field(form.submit, class="btn btn-default form-group") }} 15 |
16 | 17 |

Notebooks

18 |
19 | {% for notebook in notebooks %} 20 | 35 | {% endfor %} 36 |
37 |
38 | 39 | 40 | {% endblock %} 41 | -------------------------------------------------------------------------------- /app/templates/app/search.html: -------------------------------------------------------------------------------- 1 | {% extends "app/app_base.html" %} 2 | {% import "bootstrap/wtf.html" as wtf %} 3 | 4 | {% block title %}BrainDump{% endblock %} 5 | 6 | {% block page_content %} 7 | 8 |
9 |

Search for Anything

10 | 11 |
12 | {{ wtf.form_field(form.search_field, autofocus="true") }} 13 | {{ wtf.form_field(form.submit, class="btn btn-default form-group") }} 14 |
15 | 16 | {% include 'app/_notes.html' %} 17 | 18 |
19 | 20 | {% endblock %} 21 | -------------------------------------------------------------------------------- /app/templates/app/settings.html: -------------------------------------------------------------------------------- 1 | {% extends "app/app_base.html" %} 2 | {% import "bootstrap/wtf.html" as wtf %} 3 | 4 | {% block title %}BrainDump{% endblock %} 5 | 6 | {% block page_content %} 7 | 8 |
9 | 10 |

Settings

11 | 12 | You are running braindump version: 13 | {{version}} 14 | 15 | 16 |
17 | 18 |

Hi! You can change your settings here.

19 | 20 |

Account Settings

21 | Change Email 22 | Change Password 23 | 24 |
25 |

Braindump Settings

26 |

These settings control various behaviors of the application.

27 | 28 |
29 | 30 |

When you add a note without specifying a notebook, this is the notebook 31 | that the note will be placed into. If you delete a notebook that contains notes 32 | all of those notes will be placed into this notebook as well. 33 | 34 |
35 | You cannot delete your default notebook 36 |

37 | 46 |
47 | 48 |
49 |

Export

50 |

Download a zip archive of all of your notes.

51 | Export your Notes 52 | 53 | 54 |
55 |

Shared Notes

56 |

Here are all of the notes you have shared:

57 | {% if current_user.shared_notes.all() %} 58 | 59 | 60 | 61 | 64 | 67 | 70 | 71 | {% for shared_note in shared_notes %} 72 | 73 | 78 | 81 | 84 | 85 | {% endfor %} 86 |
62 | Note 63 | 65 | Recipient 66 | 68 | Shared Date 69 |
74 | 75 | {{current_user.notes.filter_by(id=shared_note.note_id).first().title}} 76 | 77 | 79 | {{ shared_note.recipient_email }} 80 | 82 | {{ shared_note.created_date.strftime('%Y-%m-%d') }} 83 |
87 | {% else %} 88 |

89 | Looks like you have not yet shared anything. 90 |

91 | {% endif %} 92 |
93 | 94 | 95 | {% endblock %} 96 | -------------------------------------------------------------------------------- /app/templates/app/share_note.html: -------------------------------------------------------------------------------- 1 | {% extends "app/app_base.html" %} 2 | {% import "bootstrap/wtf.html" as wtf %} 3 | 4 | {% block title %} BrainDump | Share Note {% endblock %} 5 | 6 | {% block page_content %} 7 |
8 |

Share Note

9 | 10 |
11 | {{ form.csrf_token }} 12 | {{ wtf.form_field(form.recipient_email, placeholder="Enter Email address") }} 13 | {{ wtf.form_field(form.submit, class="btn btn-default form-group") }} 14 |
15 | 16 | {% include 'app/_note.html' %} 17 |
18 | {% endblock %} 19 | -------------------------------------------------------------------------------- /app/templates/app/tag.html: -------------------------------------------------------------------------------- 1 | {% extends 'app/app_base.html' %} 2 | 3 | {% block title %} Braindump | Note {% endblock %} 4 | 5 | {% block page_content %} 6 |
7 |

Notes tagged {{ tag }}

8 | {% include 'app/_notes.html' %} 9 |
10 | {% endblock %} 11 | -------------------------------------------------------------------------------- /app/templates/app/trash.html: -------------------------------------------------------------------------------- 1 | {% extends 'app/app_base.html' %} 2 | 3 | {% block title %} Braindump | Trash {% endblock %} 4 | 5 | {% block page_content %} 6 |
7 |

Deleted Notes

8 |
9 | 10 | Empty Trash 11 | 12 |
13 | 14 |
15 | 34 |
35 | 36 |
37 |
38 | {% include 'app/_note.html' %} 39 |
40 |
41 | 42 |
43 | {% endblock %} 44 | -------------------------------------------------------------------------------- /app/templates/app_email/share_note.html: -------------------------------------------------------------------------------- 1 |

{{ note.title }}

2 |
3 | {{ html | safe }} 4 | 5 |
6 |

Organize your life with braindump today!

7 |

Note: replies to this email address are not monitored.

8 | -------------------------------------------------------------------------------- /app/templates/app_email/share_note.txt: -------------------------------------------------------------------------------- 1 | {{ note.title }} 2 | ============================= 3 | {{ note.body }} 4 | 5 | Sincerely, 6 | The BrainDump Team 7 | Note: replies to this email address are not monitored. 8 | -------------------------------------------------------------------------------- /app/templates/auth/change_email.html: -------------------------------------------------------------------------------- 1 | {% extends "app/app_base.html" %} 2 | {% import "bootstrap/wtf.html" as wtf %} 3 | 4 | {% block title %}Braindump - Change Email Address{% endblock %} 5 | 6 | {% block page_content %} 7 |
8 |

Change Your Email Address

9 | 10 | {{ wtf.quick_form(form) }} 11 |
12 | {% endblock %} 13 | -------------------------------------------------------------------------------- /app/templates/auth/change_password.html: -------------------------------------------------------------------------------- 1 | {% extends "app/app_base.html" %} 2 | {% import "bootstrap/wtf.html" as wtf %} 3 | 4 | {% block title %}Braindump - Change Password{% endblock %} 5 | 6 | {% block page_content %} 7 | 8 |
9 |

Change Your Password

10 | 11 | {{ wtf.quick_form(form) }} 12 |
13 | 14 | {% endblock %} 15 | -------------------------------------------------------------------------------- /app/templates/auth/change_username.html: -------------------------------------------------------------------------------- 1 | {% extends "app/app_base.html" %} 2 | {% import "bootstrap/wtf.html" as wtf %} 3 | 4 | {% block title %}Braindump - Change Email Address{% endblock %} 5 | 6 | {% block page_content %} 7 |
8 |

Change Your User Name

9 | 10 | {{ wtf.quick_form(form) }} 11 |
12 | {% endblock %} 13 | -------------------------------------------------------------------------------- /app/templates/auth/email/change_email.html: -------------------------------------------------------------------------------- 1 |

Hi There!

2 |

To confirm your new email address click here.

3 |

Alternatively, you can paste the following link in your browser's address bar:

4 |

{{ url_for('auth.change_email', token=token, _external=True) }}

5 |

Sincerely,

6 |

The BrainDump Team

7 |

Note: replies to this email address are not monitored.

8 | -------------------------------------------------------------------------------- /app/templates/auth/email/change_email.txt: -------------------------------------------------------------------------------- 1 | Hi There! 2 | 3 | To confirm your new email address click on the following link: 4 | 5 | {{ url_for('auth.change_email', token=token, _external=True) }} 6 | 7 | Sincerely, 8 | 9 | The BrainDump Team 10 | 11 | Note: replies to this email address are not monitored. 12 | -------------------------------------------------------------------------------- /app/templates/auth/email/confirm.html: -------------------------------------------------------------------------------- 1 |

Hi There!

2 |

Welcome to BrainDump!

3 |

To confirm your account please click here.

4 |

Alternatively, you can paste the following link in your browser's address bar:

5 |

{{ url_for('auth.confirm', token=token, _external=True) }}

6 |

Sincerely,

7 |

The BrainDump Team

8 |

Note: replies to this email address are not monitored.

9 | -------------------------------------------------------------------------------- /app/templates/auth/email/confirm.txt: -------------------------------------------------------------------------------- 1 | Hi There! 2 | 3 | Welcome to BrainDump! 4 | 5 | To confirm your account please click on the following link: 6 | 7 | {{ url_for('auth.confirm', token=token, _external=True) }} 8 | 9 | Sincerely, 10 | 11 | The BrainDump Team 12 | 13 | Note: replies to this email address are not monitored. 14 | -------------------------------------------------------------------------------- /app/templates/auth/email/reset_password.html: -------------------------------------------------------------------------------- 1 |

Hi There!

2 |

To reset your password click here.

3 |

Alternatively, you can paste the following link in your browser's address bar:

4 |

{{ url_for('auth.password_reset', token=token, _external=True) }}

5 |

If you have not requested a password reset simply ignore this message.

6 |

Sincerely,

7 |

The BrainDump Team

8 |

Note: replies to this email address are not monitored.

9 | -------------------------------------------------------------------------------- /app/templates/auth/email/reset_password.txt: -------------------------------------------------------------------------------- 1 | Hi There! 2 | 3 | To reset your password click on the following link: 4 | 5 | {{ url_for('auth.password_reset', token=token, _external=True) }} 6 | 7 | If you have not requested a password reset simply ignore this message. 8 | 9 | Sincerely, 10 | 11 | The BrainDump Team 12 | 13 | Note: replies to this email address are not monitored. 14 | -------------------------------------------------------------------------------- /app/templates/auth/login_modal.html: -------------------------------------------------------------------------------- 1 | 2 | 35 | -------------------------------------------------------------------------------- /app/templates/auth/register_modal.html: -------------------------------------------------------------------------------- 1 | 2 | 35 | -------------------------------------------------------------------------------- /app/templates/auth/reset_password.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% import "bootstrap/wtf.html" as wtf %} 3 | 4 | {% block title %}Braindump - Password Reset{% endblock %} 5 | 6 | {% block page_content %} 7 | 10 |
11 | {{ wtf.quick_form(form) }} 12 |
13 | {% endblock %} 14 | -------------------------------------------------------------------------------- /app/templates/auth/unconfirmed.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block title %}BrainDump | Confirm Your Account {% endblock %} 4 | 5 | {% block page_content %} 6 | 7 | 20 | 21 | {% endblock %} 22 | -------------------------------------------------------------------------------- /app/templates/base.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | {% block title %} BrainDump {% endblock %} 6 | 7 | {% block head %} 8 | 9 | 10 | 11 | 12 | {% endblock %} 13 | 14 | 15 | 16 | 17 | {% block page_content %} 18 | {% endblock %} 19 | 20 | {% block scripts %} 21 | 22 | {% endblock %} 23 | 24 | 25 | -------------------------------------------------------------------------------- /app/templates/index.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block title %}BrainDump{% endblock %} 4 | 5 | {% block page_content %} 6 | 7 |
8 |
9 | 10 |
11 | 12 |
13 | {% for message in get_flashed_messages() %} 14 | 18 | {% endfor %} 19 |
20 |

Braindump

21 | 31 |
32 |
33 | 34 | {% include 'auth/login_modal.html' %} 35 | {% include 'auth/register_modal.html' %} 36 | 37 |
38 |

Welcome to Braindump

39 |

Braindump is a simple, powerful, and open note taking platform that makes it easy to organize your life. So far, braindump has helped {{stats.users}} people organize their life with {{stats.notes}} notes. Join them!

40 |

41 | 42 |

43 |
44 | 45 |
46 |
47 |

48 | © 2015-2016 braindump by levlaz 49 |

50 |

51 | Proudly Powered by Linode | 52 | News | 53 | GitHub | 54 | Bugs 55 |

56 |
57 |
58 | 59 |
60 | 61 |
62 | 63 |
64 | 65 | 66 | 79 | 80 | 81 | 82 | 83 | {% endblock %} 84 | -------------------------------------------------------------------------------- /circle.yml: -------------------------------------------------------------------------------- 1 | machine: 2 | services: 3 | - docker 4 | python: 5 | version: 3.5.1 6 | 7 | general: 8 | artifacts: 9 | - test-reports/coverage 10 | 11 | dependencies: 12 | pre: 13 | # Install Yarn 14 | - sudo apt-key adv --fetch-keys http://dl.yarnpkg.com/debian/pubkey.gpg 15 | - echo "deb http://dl.yarnpkg.com/debian/ stable main" | sudo tee /etc/apt/sources.list.d/yarn.list 16 | - sudo apt-get update -qq 17 | - sudo apt-get install -y -qq yarn 18 | override: 19 | - yarn 20 | - pip install -r requirements.txt 21 | cache_directories: 22 | - "~/.yarn-cache" 23 | 24 | test: 25 | override: 26 | - python manage.py test --coverage 27 | post: 28 | - mkdir -p $CIRCLE_TEST_REPORTS/pytest/ 29 | - mv test-reports/*.xml $CIRCLE_TEST_REPORTS/pytest/ 30 | 31 | deployment: 32 | master: 33 | branch: master 34 | commands: 35 | - docker build -t levlaz/braindump:$CIRCLE_SHA1 . 36 | - docker tag levlaz/braindump:$CIRCLE_SHA1 levlaz/braindump:latest 37 | - docker login -e $DOCKER_EMAIL -u $DOCKER_USER -p $DOCKER_PASS 38 | - docker push levlaz/braindump 39 | production: 40 | tag: /v[0-9]+(\.[0-9]+)*/ 41 | owner: levlaz 42 | commands: 43 | - docker build -t levlaz/braindump:$CIRCLE_TAG . 44 | - docker tag levlaz/braindump:$CIRCLE_TAG levlaz/braindump:stable 45 | - docker login -e $DOCKER_EMAIL -u $DOCKER_USER -p $DOCKER_PASS 46 | - docker push levlaz/braindump 47 | - scripts/deploy.sh 48 | -------------------------------------------------------------------------------- /config.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | basedir = os.path.abspath(os.path.dirname(__file__)) 4 | 5 | 6 | class Config: 7 | SECRET_KEY = os.environ.get('SECRET_KEY') or 'hard to guess string' 8 | MAIL_SERVER = 'mail.gandi.net' 9 | MAIL_PORT = 587 10 | MAIL_USE_TLS = True 11 | MAIL_USERNAME = os.environ.get('MAIL_USERNAME') 12 | MAIL_PASSWORD = os.environ.get('MAIL_PASSWORD') 13 | MAIL_SUBJECT_PREFIX = '[braindump]' 14 | MAIL_SENDER = 'braindump ' 15 | APP_ADMIN = os.environ.get('BRAINDUMP_ADMIN') 16 | SQLALCHEMY_TRACK_MODIFICATIONS = False 17 | BRAINDUMP_VERSION = 'v0.4.14' 18 | 19 | @staticmethod 20 | def init_app(app): 21 | pass 22 | 23 | 24 | class DevelopmentConfig(Config): 25 | DEBUG = True 26 | SQLALCHEMY_DATABASE_URI = 'postgresql://{0}:{1}@{2}/{3}'.format( 27 | 'braindump', 28 | 'braindump', 29 | 'localhost', 30 | 'braindump') 31 | 32 | @classmethod 33 | def init_app(cls, app): 34 | Config.init_app(app) 35 | 36 | with app.app_context(): 37 | from app.models import db 38 | from app.models import User, Notebook 39 | 40 | db.init_app(app) 41 | # db.create_all() 42 | 43 | # Check if User Already Created 44 | # u = User.query.filter_by(email='admin@example.com').first() 45 | # if u: 46 | # pass 47 | # else: 48 | # # Create Admin User 49 | # u = User( 50 | # email='admin@example.com', password='password', 51 | # confirmed=True) 52 | # db.session.add(u) 53 | # db.session.commit() 54 | 55 | # # Create Default Notebook for Admin User 56 | # nb = Notebook( 57 | # title='Default Notebook', 58 | # author_id=u.id) 59 | # db.session.add(nb) 60 | # db.session.commit() 61 | 62 | 63 | class TestingConfig(Config): 64 | DEBUG = True 65 | TESTING = True 66 | WTF_CSRF_ENABLED = False 67 | SQLALCHEMY_DATABASE_URI = 'postgresql://{0}@{1}/{2}'.format( 68 | 'ubuntu', 69 | 'localhost', 70 | 'circle_test') 71 | 72 | 73 | class ProductionConfig(Config): 74 | SQLALCHEMY_DATABASE_URI = 'postgresql://{0}:{1}@{2}/{3}'.format( 75 | os.environ.get('DB_USER'), 76 | os.environ.get('DB_PASS'), 77 | os.environ.get('DB_HOST'), 78 | os.environ.get('DB_NAME')) 79 | 80 | @classmethod 81 | def init_app(cls, app): 82 | Config.init_app(app) 83 | 84 | # email errors to the administrators 85 | import logging 86 | from logging.handlers import SMTPHandler 87 | credentials = None 88 | secure = None 89 | if getattr(cls, 'MAIL_USERNAME', None) is not None: 90 | credentials = (cls.MAIL_USERNAME, cls.MAIL_PASSWORD) 91 | if getattr(cls, 'MAIL_USE_TLS', None): 92 | secure = () 93 | mail_handler = SMTPHandler( 94 | mailhost=(cls.MAIL_SERVER, cls.MAIL_PORT), 95 | fromaddr=cls.MAIL_SENDER, 96 | toaddrs=[cls.APP_ADMIN], 97 | subject=cls.MAIL_SUBJECT_PREFIX + ' Application Error', 98 | credentials=credentials, 99 | secure=secure) 100 | mail_handler.setLevel(logging.ERROR) 101 | app.logger.addHandler(mail_handler) 102 | 103 | 104 | class UnixConfig(ProductionConfig): 105 | @classmethod 106 | def init_app(cls, app): 107 | ProductionConfig.init_app(app) 108 | 109 | # log to syslog 110 | import logging 111 | from logging.handlers import SysLogHandler 112 | syslog_handler = SysLogHandler() 113 | syslog_handler.setLevel(logging.WARNING) 114 | app.logger.addHandler(syslog_handler) 115 | 116 | config = { 117 | 'development': DevelopmentConfig, 118 | 'testing': TestingConfig, 119 | 'production': ProductionConfig, 120 | 'unix': UnixConfig, 121 | 122 | 'default': DevelopmentConfig 123 | } 124 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '2' 2 | 3 | services: 4 | app: 5 | image: quay.io/levlaz/braindump:stable 6 | environment: 7 | MAIL_USERNAME: $MAIL_USERNAME 8 | MAIL_PASSWORD: $MAIL_PASSWORD 9 | BRAINDUMP_ADMIN: $BRAINDUMP_ADMIN 10 | SECRET_KEY: $SECRET_KEY 11 | FLASK_CONFIG: $FLASK_CONFIG 12 | DB_USER: $DB_USER 13 | DB_PASS: $DB_PASS 14 | DB_HOST: $DB_HOST 15 | DB_NAME: $DB_NAME 16 | ports: 17 | - "127.0.0.1:8000:8000" 18 | extra_hosts: 19 | - "db:$DB_IP" 20 | entrypoint: ./scripts/start-app.sh 21 | -------------------------------------------------------------------------------- /docs/dev/app.api_v1.authentication.html: -------------------------------------------------------------------------------- 1 | 2 | Python: module app.api_v1.authentication 3 | 4 | 5 | 6 | 7 | 8 |
 
9 |  
app.api_v1.authentication
index
/Users/levlaz/git/levlaz/braindump/app/api_v1/authentication.py
12 |

13 |

14 | 15 | 16 | 18 | 19 | 20 |
 
17 | Classes
       
21 |
flask_restful.Resource(flask.views.MethodView) 22 |
23 |
24 |
Token 25 |
26 |
27 |
28 |

29 | 30 | 31 | 33 | 34 | 35 | 36 | 37 |
 
32 | class Token(flask_restful.Resource)
   Generate Auth Token
 
 
Method resolution order:
38 |
Token
39 |
flask_restful.Resource
40 |
flask.views.MethodView
41 |
flask.views.View
42 |
builtins.object
43 |
44 |
45 | Methods defined here:
46 |
get(self)
Return JWT Token
47 | 48 |
49 | Data and other attributes defined here:
50 |
decorators = [<bound method MultiAuth.login_required of <flask_httpauth.MultiAuth object>>]
51 | 52 |
methods = ['GET']
53 | 54 |
55 | Methods inherited from flask_restful.Resource:
56 |
dispatch_request(self, *args, **kwargs)
Subclasses have to override this method to implement the
57 | actual view function code.  This method is called with all
58 | the arguments from the URL rule.
59 | 60 |
61 | Data and other attributes inherited from flask_restful.Resource:
62 |
method_decorators = []
63 | 64 |
representations = None
65 | 66 |
67 | Class methods inherited from flask.views.View:
68 |
as_view(name, *class_args, **class_kwargs) from flask.views.MethodViewType
Converts the class into an actual view function that can be used
69 | with the routing system.  Internally this generates a function on the
70 | fly which will instantiate the :class:`View` on each request and call
71 | the :meth:`dispatch_request` method on it.
72 |  
73 | The arguments passed to :meth:`as_view` are forwarded to the
74 | constructor of the class.
75 | 76 |
77 | Data descriptors inherited from flask.views.View:
78 |
__dict__
79 |
dictionary for instance variables (if defined)
80 |
81 |
__weakref__
82 |
list of weak references to the object (if defined)
83 |
84 |

85 | 86 | 87 | 89 | 90 | 91 |
 
88 | Functions
       
verify_password(email, password)
92 |
verify_token(token)
93 |

94 | 95 | 96 | 98 | 99 | 100 |
 
97 | Data
       basic_auth = <flask_httpauth.HTTPBasicAuth object>
101 | g = <LocalProxy unbound>
102 | multi_auth = <flask_httpauth.MultiAuth object>
103 | token_auth = <flask_httpauth.HTTPTokenAuth object>
104 | -------------------------------------------------------------------------------- /docs/dev/app.api_v1.base.html: -------------------------------------------------------------------------------- 1 | 2 | Python: module app.api_v1.base 3 | 4 | 5 | 6 | 7 | 8 |
 
9 |  
app.api_v1.base
index
/Users/levlaz/git/levlaz/braindump/app/api_v1/base.py
12 |

13 |

14 | 15 | 16 | 18 | 19 | 20 |
 
17 | Classes
       
21 |
flask_restful.Resource(flask.views.MethodView) 22 |
23 |
24 |
ProtectedBase 25 |
26 |
27 |
28 |

29 | 30 | 31 | 33 | 34 | 35 | 42 | 43 |
 
32 | class ProtectedBase(flask_restful.Resource)
   Base Protected Class for API Resources
36 |  
37 | Defines that all API methods are expempt from CSRF, and
38 | requires either password or token based authentication.
39 |  
40 | Basic Authentication can be passed via the headers. A token can be obtained
41 | from /api/v1/token and passed as a "Bearer" in the Authentication headers.
 
 
Method resolution order:
44 |
ProtectedBase
45 |
flask_restful.Resource
46 |
flask.views.MethodView
47 |
flask.views.View
48 |
builtins.object
49 |
50 |
51 | Data and other attributes defined here:
52 |
decorators = [<bound method MultiAuth.login_required of <flask_httpauth.MultiAuth object>>, <bound method CsrfProtect.exempt of <flask_wtf.csrf.CsrfProtect object>>]
53 | 54 |
55 | Methods inherited from flask_restful.Resource:
56 |
dispatch_request(self, *args, **kwargs)
Subclasses have to override this method to implement the
57 | actual view function code.  This method is called with all
58 | the arguments from the URL rule.
59 | 60 |
61 | Data and other attributes inherited from flask_restful.Resource:
62 |
method_decorators = []
63 | 64 |
representations = None
65 | 66 |
67 | Class methods inherited from flask.views.View:
68 |
as_view(name, *class_args, **class_kwargs) from flask.views.MethodViewType
Converts the class into an actual view function that can be used
69 | with the routing system.  Internally this generates a function on the
70 | fly which will instantiate the :class:`View` on each request and call
71 | the :meth:`dispatch_request` method on it.
72 |  
73 | The arguments passed to :meth:`as_view` are forwarded to the
74 | constructor of the class.
75 | 76 |
77 | Data descriptors inherited from flask.views.View:
78 |
__dict__
79 |
dictionary for instance variables (if defined)
80 |
81 |
__weakref__
82 |
list of weak references to the object (if defined)
83 |
84 |
85 | Data and other attributes inherited from flask.views.View:
86 |
methods = None
87 | 88 |

89 | 90 | 91 | 93 | 94 | 95 |
 
92 | Data
       csrf = <flask_wtf.csrf.CsrfProtect object>
96 | multi_auth = <flask_httpauth.MultiAuth object>
97 | -------------------------------------------------------------------------------- /docs/dev/app.api_v1.errors.html: -------------------------------------------------------------------------------- 1 | 2 | Python: module app.api_v1.errors 3 | 4 | 5 | 6 | 7 | 8 |
 
9 |  
app.api_v1.errors
index
/Users/levlaz/git/levlaz/braindump/app/api_v1/errors.py
12 |

13 |

14 | 15 | 16 | 18 | 19 | 20 |
 
17 | Functions
       
bad_request(message)
21 |
forbidden(message)
22 |
not_allowed(message)
23 |
unauthorized(message)
24 |
25 | -------------------------------------------------------------------------------- /docs/dev/app.api_v1.html: -------------------------------------------------------------------------------- 1 | 2 | Python: package app.api_v1 3 | 4 | 5 | 6 | 7 | 8 |
 
9 |  
app.api_v1
index
/Users/levlaz/git/levlaz/braindump/app/api_v1/__init__.py
12 |

13 |

14 | 15 | 16 | 18 | 19 | 20 |
 
17 | Package Contents
       
authentication
21 |
base
22 |
errors
23 |
notebooks
24 |

25 | 26 | 27 | 29 | 30 | 31 |
 
28 | Data
       api = <flask_restful.Api object>
32 | -------------------------------------------------------------------------------- /docs/dev/app.auth.html: -------------------------------------------------------------------------------- 1 | 2 | Python: package app.auth 3 | 4 | 5 | 6 | 7 | 8 |
 
9 |  
app.auth
index
/Users/levlaz/git/levlaz/braindump/app/auth/__init__.py
12 |

13 |

14 | 15 | 16 | 18 | 19 | 20 |
 
17 | Package Contents
       
forms
21 |
views
22 |

23 | 24 | 25 | 27 | 28 | 29 |
 
26 | Data
       auth = <flask.blueprints.Blueprint object>
30 | -------------------------------------------------------------------------------- /docs/dev/app.auth.views.html: -------------------------------------------------------------------------------- 1 | 2 | Python: module app.auth.views 3 | 4 | 5 | 6 | 7 | 8 |
 
9 |  
app.auth.views
index
/Users/levlaz/git/levlaz/braindump/app/auth/views.py
12 |

13 |

14 | 15 | 16 | 18 | 19 | 20 |
 
17 | Functions
       
before_request()
21 |
change_email(token)
22 |
change_email_request()
23 |
change_password()
24 |
confirm(token)
25 |
login()
26 |
logout()
27 |
password_reset(token)
28 |
password_reset_request()
29 |
register()
30 |
resend_confirmation()
31 |
unconfirmed()
32 |

33 | 34 | 35 | 37 | 38 | 39 |
 
36 | Data
       auth = <flask.blueprints.Blueprint object>
40 | current_user = None
41 | db = <SQLAlchemy engine=None>
42 | request = <LocalProxy unbound>
43 | -------------------------------------------------------------------------------- /docs/dev/app.email.html: -------------------------------------------------------------------------------- 1 | 2 | Python: module app.email 3 | 4 | 5 | 6 | 7 | 8 |
 
9 |  
app.email
index
/Users/levlaz/git/levlaz/braindump/app/email.py
12 |

13 |

14 | 15 | 16 | 18 | 19 | 20 |
 
17 | Functions
       
send_async_email(app, msg)
21 |
send_email(to, subject, template, **kwargs)
22 |

23 | 24 | 25 | 27 | 28 | 29 |
 
26 | Data
       current_app = <LocalProxy unbound>
30 | mail = <flask_mail.Mail object>
31 | -------------------------------------------------------------------------------- /docs/dev/app.exceptions.html: -------------------------------------------------------------------------------- 1 | 2 | Python: module app.exceptions 3 | 4 | 5 | 6 | 7 | 8 |
 
9 |  
app.exceptions
index
/Users/levlaz/git/levlaz/braindump/app/exceptions.py
12 |

13 |

14 | 15 | 16 | 18 | 19 | 20 |
 
17 | Classes
       
21 |
builtins.ValueError(builtins.Exception) 22 |
23 |
24 |
ValidationError 25 |
26 |
27 |
28 |

29 | 30 | 31 | 33 | 34 | 35 | 36 | 37 |
 
32 | class ValidationError(builtins.ValueError)
   Inappropriate argument value (of correct type).
 
 
Method resolution order:
38 |
ValidationError
39 |
builtins.ValueError
40 |
builtins.Exception
41 |
builtins.BaseException
42 |
builtins.object
43 |
44 |
45 | Data descriptors defined here:
46 |
__weakref__
47 |
list of weak references to the object (if defined)
48 |
49 |
50 | Methods inherited from builtins.ValueError:
51 |
__init__(self, /, *args, **kwargs)
Initialize self.  See help(type(self)) for accurate signature.
52 | 53 |
__new__(*args, **kwargs) from builtins.type
Create and return a new object.  See help(type) for accurate signature.
54 | 55 |
56 | Methods inherited from builtins.BaseException:
57 |
__delattr__(self, name, /)
Implement delattr(self, name).
58 | 59 |
__getattribute__(self, name, /)
Return getattr(self, name).
60 | 61 |
__reduce__(...)
helper for pickle
62 | 63 |
__repr__(self, /)
Return repr(self).
64 | 65 |
__setattr__(self, name, value, /)
Implement setattr(self, name, value).
66 | 67 |
__setstate__(...)
68 | 69 |
__str__(self, /)
Return str(self).
70 | 71 |
with_traceback(...)
Exception.with_traceback(tb) --
72 | set self.__traceback__ to tb and return self.
73 | 74 |
75 | Data descriptors inherited from builtins.BaseException:
76 |
__cause__
77 |
exception cause
78 |
79 |
__context__
80 |
exception context
81 |
82 |
__dict__
83 |
84 |
__suppress_context__
85 |
86 |
__traceback__
87 |
88 |
args
89 |
90 |

91 | -------------------------------------------------------------------------------- /docs/dev/app.html: -------------------------------------------------------------------------------- 1 | 2 | Python: package app 3 | 4 | 5 | 6 | 7 | 8 |
 
9 |  
app
index
/Users/levlaz/git/levlaz/braindump/app/__init__.py
12 |

13 |

14 | 15 | 16 | 18 | 19 | 20 |
 
17 | Package Contents
       
api_v1 (package)
21 | auth (package)
22 |
email
23 | exceptions
24 |
main (package)
25 | models
26 |

27 | 28 | 29 | 31 | 32 | 33 |
 
30 | Functions
       
create_app(config_name)
34 |

35 | 36 | 37 | 39 | 40 | 41 |
 
38 | Data
       bootstrap = <flask_bootstrap.Bootstrap object>
42 | config = {'default': <class 'config.DevelopmentConfig'>, 'development': <class 'config.DevelopmentConfig'>, 'production': <class 'config.ProductionConfig'>, 'testing': <class 'config.TestingConfig'>, 'unix': <class 'config.UnixConfig'>}
43 | csrf = <flask_wtf.csrf.CsrfProtect object>
44 | db = <SQLAlchemy engine=None>
45 | login_manager = <flask_login.LoginManager object>
46 | mail = <flask_mail.Mail object>
47 | -------------------------------------------------------------------------------- /docs/dev/app.main.errors.html: -------------------------------------------------------------------------------- 1 | 2 | Python: module app.main.errors 3 | 4 | 5 | 6 | 7 | 8 |
 
9 |  
app.main.errors
index
/Users/levlaz/git/levlaz/braindump/app/main/errors.py
12 |

13 |

14 | 15 | 16 | 18 | 19 | 20 |
 
17 | Functions
       
internal_server_error(e)
21 |
page_not_found(e)
22 |

23 | 24 | 25 | 27 | 28 | 29 |
 
26 | Data
       main = <flask.blueprints.Blueprint object>
30 | request = <LocalProxy unbound>
31 | -------------------------------------------------------------------------------- /docs/dev/app.main.html: -------------------------------------------------------------------------------- 1 | 2 | Python: package app.main 3 | 4 | 5 | 6 | 7 | 8 |
 
9 |  
app.main
index
/Users/levlaz/git/levlaz/braindump/app/main/__init__.py
12 |

13 |

14 | 15 | 16 | 18 | 19 | 20 |
 
17 | Package Contents
       
errors
21 |
forms
22 |
views
23 |

24 | 25 | 26 | 28 | 29 | 30 |
 
27 | Data
       main = <flask.blueprints.Blueprint object>
31 | -------------------------------------------------------------------------------- /docs/dev/app.main.views.html: -------------------------------------------------------------------------------- 1 | 2 | Python: module app.main.views 3 | 4 | 5 | 6 | 7 | 8 |
 
9 |  
app.main.views
index
/Users/levlaz/git/levlaz/braindump/app/main/views.py
12 |

13 |

14 | 15 | 16 | 18 | 19 | 20 |
 
17 | Functions
       
add()
21 |
archive(id)
22 |
delete(id)
23 |
delete_forever(id)
24 |
delete_notebook(id)
25 |
edit(id)
26 |
empty_trash()
27 |
favorite(id)
28 |
favorites()
29 |
index()
30 |
note(id)
31 |
notebook(id)
32 |
notebooks()
33 |
restore(id)
34 |
search()
35 |
server_shutdown()
36 |
settings()
37 |
share(id)
38 |
tag(name)
39 |
trash()
40 |
view_archive()
41 |

42 | 43 | 44 | 46 | 47 | 48 |
 
45 | Data
       abort = <werkzeug.exceptions.Aborter object>
49 | current_app = <LocalProxy unbound>
50 | current_user = None
51 | db = <SQLAlchemy engine=None>
52 | main = <flask.blueprints.Blueprint object>
53 | request = <LocalProxy unbound>
54 | -------------------------------------------------------------------------------- /docs/dev/manage.html: -------------------------------------------------------------------------------- 1 | 2 | Python: module manage 3 | 4 | 5 | 6 | 7 | 8 |
 
9 |  
manage
index
/Users/levlaz/git/levlaz/braindump/manage.py
12 |

13 |

14 | 15 | 16 | 18 | 19 | 20 |
 
17 | Modules
       
os
21 |

22 | 23 | 24 | 26 | 27 | 28 |
 
25 | Functions
       
deploy()
Run deployment tasks.
29 |
make_shell_context()
30 |
profile(length=25, profile_dir=None)
Start the application under the code profiler.
31 |
routes()
32 |
test(coverage=False)
Run the unit tests.
33 |

34 | 35 | 36 | 38 | 39 | 40 |
 
37 | Data
       COV = None
41 | MigrateCommand = <flask_script.Manager object>
42 | app = <Flask 'app'>
43 | db = <SQLAlchemy engine=None>
44 | manager = <flask_script.Manager object>
45 | migrate = <flask_migrate.Migrate object>
46 | -------------------------------------------------------------------------------- /docs/index.html: -------------------------------------------------------------------------------- 1 |

Braindump Documentation

2 | 3 | 6 | -------------------------------------------------------------------------------- /etc/conf/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM nginx 2 | COPY nginx.conf /etc/nginx/nginx.conf -------------------------------------------------------------------------------- /etc/conf/nginx.conf: -------------------------------------------------------------------------------- 1 | user www-data; 2 | worker_processes 4; 3 | pid /run/nginx.pid; 4 | 5 | events { 6 | worker_connections 1024; 7 | } 8 | 9 | http { 10 | 11 | server { 12 | listen 80; 13 | server_name braindump.pw; 14 | return 301 https://$host$request_uri; 15 | } 16 | 17 | server { 18 | listen 443 ssl; 19 | server_name braindump.pw; 20 | 21 | ssl on; 22 | ssl_certificate /etc/letsencrypt/live/braindump.pw/fullchain.pem; 23 | ssl_certificate_key /etc/letsencrypt/live/braindump.pw/privkey.pem; 24 | 25 | location / { 26 | proxy_pass http://app:8000; 27 | proxy_set_header Host $host; 28 | proxy_set_header X-Real-IP $remote_addr; 29 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 30 | } 31 | } 32 | 33 | } -------------------------------------------------------------------------------- /etc/cron/braindump-backup: -------------------------------------------------------------------------------- 1 | SHELL=/bin/bash 2 | 0 */6 * * * /var/www/braindump/scripts/pg_backup.sh -------------------------------------------------------------------------------- /frontend/js/lib/debounce.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = { 4 | 5 | debounce: function(fn, delay) { 6 | let timer = null; 7 | return function() { 8 | let context = this, args = arguments; 9 | clearTimeout(timer); 10 | timer = setTimeout(function() { 11 | fn.apply(context, args); 12 | }, delay); 13 | }; 14 | } 15 | } -------------------------------------------------------------------------------- /frontend/js/src/Dates.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const moment = require('moment'); 4 | 5 | let self = module.exports = { 6 | 7 | initDates: function() { 8 | self.setListUpdatedDates(); 9 | self.setUpdatedDates(); 10 | self.setCreatedDates(); 11 | }, 12 | 13 | setListUpdatedDates: function() { 14 | let dateListField = $(".note-updated-date-list"); 15 | 16 | dateListField.each(function() { 17 | let updatedDate = this.getAttribute('value'); 18 | this.innerHTML = moment.utc(updatedDate).local().fromNow(); 19 | }); 20 | }, 21 | 22 | setUpdatedDates: function() { 23 | let dateField = $(".note-updated-date"); 24 | 25 | dateField.each(function() { 26 | let updatedDate = this.getAttribute('value'); 27 | this.innerHTML = `Updated on: ${moment.utc(updatedDate).local().format('MMMM DD, YYYY h:mm a zz')}`; 28 | }); 29 | }, 30 | 31 | setCreatedDates: function() { 32 | let dateField = $(".note-created-date"); 33 | 34 | dateField.each(function() { 35 | let createdDate = this.getAttribute('value'); 36 | this.innerHTML = `Created on: ${moment.utc(createdDate).local().format('MMMM DD, YYYY h:mm a zz')}`; 37 | }) 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /frontend/js/src/braindump.editor.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Editor Module 3 | * @module braindump.editor 4 | */ 5 | 'use strict'; 6 | 7 | import {ProseMirror} from "prosemirror/dist/edit" 8 | import "prosemirror/dist/inputrules/autoinput" 9 | import "prosemirror/dist/menu/tooltipmenu" 10 | import "prosemirror/dist/menu/menubar" 11 | import "prosemirror/dist/markdown" 12 | const debounce = require('./../lib/debounce'); 13 | 14 | let self = module.exports = { 15 | 16 | initEditor: function() { 17 | let place = $(".editor"); 18 | 19 | place.each(function() { 20 | self.createEditor(this, this.getAttribute("note_id")); 21 | }); 22 | 23 | self.setActive(); 24 | }, 25 | 26 | createEditor: function(place, noteId) { 27 | var input = ""; 28 | 29 | if (typeof place.children[0] !== 'undefined') { 30 | var input = place.children[0].value; 31 | } 32 | 33 | let noteBody = $('input[id="body"]'); 34 | let pm = window.pm = new ProseMirror({ 35 | place: place, 36 | autoInput: true, 37 | doc: input, 38 | docFormat: "markdown" 39 | }); 40 | 41 | self.setMenuStyle(place.getAttribute("menustyle") || "bar"); 42 | 43 | let menuStyle = document.querySelector("#menustyle") 44 | if (menuStyle) menuStyle.addEventListener("change", () => setMenuStyle(menuStyle.value)) 45 | 46 | pm.on('change', (debounce.debounce(function(event){ 47 | noteBody.val(pm.getContent("markdown")); 48 | self.saveNote(noteId, noteBody.val()); 49 | }, 500))); 50 | }, 51 | 52 | setMenuStyle: function(type) { 53 | if (type == "bar") { 54 | pm.setOption("menuBar", {float: true}) 55 | pm.setOption("tooltipMenu", false) 56 | } else { 57 | pm.setOption("menuBar", false) 58 | pm.setOption("tooltipMenu", {selectedBlockMenu: true}) 59 | } 60 | }, 61 | 62 | saveNote: function(id, content) { 63 | 64 | var csrftoken = $('meta[name=csrf-token]').attr('content') 65 | 66 | $.ajax({ 67 | beforeSend: function(xhr) { 68 | xhr.setRequestHeader("X-CSRFToken", csrftoken) 69 | }, 70 | url: `/edit/${id}`, 71 | data: JSON.stringify({ 72 | 'body': content, 73 | }), 74 | contentType: 'application/json', 75 | type: 'PUT', 76 | success: function (response) { 77 | console.log(response); 78 | }, 79 | error: function (error) { 80 | console.error(error); 81 | } 82 | }); 83 | }, 84 | 85 | /** 86 | * Set the first note as active in all views 87 | */ 88 | setActive: function() { 89 | let tabs = document.getElementsByClassName('note-content') 90 | let editors = document.getElementsByClassName('tab-pane') 91 | 92 | // Check to see if element exists, this is necessary since we 93 | // are loading the same JS file on each page, but not each page 94 | // has editors. 95 | if (tabs.length) { 96 | tabs[0].className += " active"; 97 | editors[0].className += " active"; 98 | } 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /frontend/js/src/braindump.helpers.js: -------------------------------------------------------------------------------- 1 | (function(window) { 2 | /** 3 | * Braindump helpers module 4 | * 5 | * Helper functions for other modules 6 | * 7 | * @module braindump.helpers 8 | */ 9 | 'use strict'; 10 | 11 | /** 12 | * Wrapper for addEventListener assuming the target exists 13 | */ 14 | window.$on = function (target, type, callback, useCapture) { 15 | if (!!target) { 16 | target.addEventListener(type, callback, !!useCapture) 17 | } 18 | }; 19 | })(window); -------------------------------------------------------------------------------- /frontend/js/src/braindump.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import $ from 'jquery'; 4 | import jQuery from 'jquery'; 5 | window.$ = $; 6 | window.jQuery = jQuery; 7 | 8 | require('bootstrap'); 9 | require('bootstrap-tagsinput'); 10 | require('bootstrap-tabcollapse') 11 | 12 | const editor = require('./braindump.editor'); 13 | const notebooks = require('./braindump.notebooks') 14 | const dates = require('./Dates'); 15 | 16 | editor.initEditor(); 17 | notebooks.init(); 18 | dates.initDates(); 19 | 20 | $('#noteTabs').tabCollapse(); 21 | -------------------------------------------------------------------------------- /frontend/js/src/braindump.notebooks.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | /* 4 | * This modules adds helper functions to the notebooks view 5 | * which allows users to add, edit, and delete notebooks. 6 | */ 7 | let self = module.exports = { 8 | 9 | init: function() { 10 | 11 | let notebooks = $(".notebook-card"); 12 | 13 | notebooks.each(function() { 14 | let notebook = self.getNotebook(this); 15 | let deleteButton = $(this).find('.delete-notebook'); 16 | deleteButton.on('click', notebook, self.deleteNotebook); 17 | }); 18 | }, 19 | 20 | getNotebook: function(e) { 21 | let notebookId = e.getAttribute("notebook_id"); 22 | let noteCountString = $(e).find("span").text(); 23 | let noteCount = noteCountString.match(/\d+/)[0]; 24 | 25 | let notebook = { 26 | self: e, 27 | id: notebookId, 28 | noteCount: noteCount 29 | } 30 | 31 | return notebook; 32 | }, 33 | 34 | deleteNotebook: function(e) { 35 | $.ajax({ 36 | url: `/notebook/${e.data.id}`, 37 | contentType: 'application/json', 38 | type: 'DELETE', 39 | success: function (response) { 40 | e.data.self.remove(); 41 | console.log(response); 42 | }, 43 | error: function (error) { 44 | e.data.self.innerHTML += `
${error.responseJSON.error}
` 45 | console.log(error); 46 | } 47 | }); 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /frontend/js/src/braindump.settings.js: -------------------------------------------------------------------------------- 1 | (function () { 2 | 'use strict'; 3 | 4 | /** 5 | * Braindump Settings Module 6 | * 7 | * Scripts that run on the settings view of the app 8 | * 9 | * @module braindump.settings 10 | */ 11 | 12 | var defaultNotebookSelector = document.getElementById('defaultNotebook'); 13 | 14 | /** 15 | * Update default notebook 16 | * 17 | * When default notebook selection is changed on the settings page, 18 | * send a request to update the default notebook for the user. 19 | */ 20 | function updateDefaultNotebook() { 21 | 22 | var r = new XMLHttpRequest(); 23 | var csrftoken = $('meta[name=csrf-token]').attr('content'); 24 | var data = JSON.stringify({ 25 | 'default_notebook': this.value 26 | }); 27 | 28 | r.open('PUT', '/users'); 29 | 30 | r.onreadystatechange = function() { 31 | if (r.readyState != 4 || r.status != 200) return; 32 | console.log(r.responseText); 33 | } 34 | 35 | r.setRequestHeader("X-CSRFToken", csrftoken); 36 | r.setRequestHeader("Content-Type", "application/json"); 37 | r.send(data); 38 | } 39 | 40 | $on(defaultNotebookSelector, 'change', updateDefaultNotebook); 41 | })(); -------------------------------------------------------------------------------- /frontend/sass/_app.scss: -------------------------------------------------------------------------------- 1 | * { 2 | margin: 0; 3 | padding: 0; 4 | } 5 | 6 | html, body, .container, .row, .col-sm-1, .col-sm-4, .col-sm-7, .col-sm-11 { 7 | height: 100%; 8 | margin: 0; 9 | padding: 0; 10 | } 11 | 12 | .container { 13 | min-height: 100%; 14 | overflow: hidden; 15 | padding: 0; 16 | margin: 0; 17 | width: 100%; 18 | } 19 | 20 | .heading { 21 | padding-top: 15px; 22 | font-weight: bold; 23 | } 24 | 25 | /* Alerts */ 26 | .alert { 27 | position: fixed; 28 | top: 0; 29 | right: 0; 30 | width: 50%; 31 | padding: 10px; 32 | z-index: 10; 33 | border-radius: 0; 34 | margin: 0; 35 | } 36 | 37 | /* Side bar */ 38 | .app-logo { 39 | width: 50px; 40 | height: 50px; 41 | } 42 | 43 | .col-sm-1 { 44 | background-color: #1976d2; 45 | padding: 0; 46 | overflow-y: auto; 47 | 48 | @media screen and (max-width: 768px) { 49 | display: none; 50 | } 51 | } 52 | 53 | .col-sm-1 ul { 54 | list-style: none; 55 | } 56 | 57 | .col-sm-1 li { 58 | list-style: none; 59 | } 60 | 61 | .sidebar-nav li a { 62 | padding-top: 10px; 63 | padding-bottom: 10px; 64 | display: block; 65 | text-decoration: none; 66 | color: white; 67 | text-align: center; 68 | font-size: 25px; 69 | } 70 | 71 | .sidebar-nav li:hover { 72 | color: #fff; 73 | background: rgba(255,255,255,0.2); 74 | } 75 | 76 | .sidebar-nav li a:active, 77 | .sidebar-nav li a:focus { 78 | text-decoration: none; 79 | } 80 | 81 | .nav-description { 82 | font-size: 12px; 83 | } 84 | 85 | /* Note List */ 86 | .col-sm-4 { 87 | border-top: solid 1px #2196f3; 88 | overflow-y: auto; 89 | padding-bottom: 100px; 90 | border-right: solid 1px $divider-color; 91 | } 92 | 93 | .col-sm-4 li{ 94 | border-bottom: solid 1px $divider-color; 95 | } 96 | 97 | .nav-pills>li>a { 98 | border-radius: 0; 99 | } 100 | 101 | .nav-stacked>li+li { 102 | margin: 0; 103 | } 104 | 105 | .nav-pills>li.active>a, .nav-pills>li.active>a:focus, .nav-pills>li.active>a:hover { 106 | color: white; 107 | background-color: #2196f3; 108 | } 109 | 110 | /* Note Preview */ 111 | .col-sm-7 { 112 | border-top: solid 1px $divider-color; 113 | padding-left: 30px; 114 | padding-right: 15px; 115 | background-color: white; 116 | overflow-y: auto; 117 | word-wrap: break-word; 118 | padding-bottom: 100px; 119 | } 120 | 121 | .note-date { 122 | border-top: solid 1px #BDBDBD; 123 | font-size: 12px; 124 | } 125 | 126 | .note-updated-date { 127 | float: right; 128 | margin-right: 10px; 129 | font-style: italic; 130 | color: #7A7A7A; 131 | } 132 | 133 | .label-tag { 134 | background-color: rgba(25,118,210, 0.8); 135 | margin-right: 5px; 136 | line-height: 2; 137 | } 138 | 139 | .label-tag:hover { 140 | background-color: rgb(25,118,210); 141 | margin-right: 5px; 142 | } 143 | 144 | .note-notebook { 145 | margin-top: 5px; 146 | margin-bottom: 10px; 147 | } 148 | 149 | .label-notebook { 150 | border: solid 1px $primary-color-dark; 151 | background-color:$primary-color-light; 152 | margin-right: 5px; 153 | color: $primary-color-dark; 154 | } 155 | 156 | .label-notebook:hover { 157 | border: solid 1px $primary-color-dark; 158 | background-color: lighten($primary-color-light, 5%); 159 | margin-right: 5px; 160 | color: $primary-color-dark; 161 | } 162 | 163 | .note-actions { 164 | float: right; 165 | margin-top: 5px; 166 | margin-right: 10px; 167 | } 168 | 169 | .note-actions a{ 170 | margin-left: 20px; 171 | } 172 | 173 | .note-content ul{ 174 | margin-left: 20px; 175 | } 176 | 177 | .note-content ol{ 178 | margin-left: 20px; 179 | } 180 | 181 | .favorite { 182 | color: rgb(255, 241, 106); 183 | text-shadow: -1px 0 black, 0 1px black, 1px 0 black, 0 -1px black; 184 | } 185 | /* Edit View */ 186 | .col-sm-11 { 187 | padding-left: 15px; 188 | padding-right: 15px; 189 | padding-top: 15px; 190 | overflow-y: auto; 191 | word-wrap: break-word; 192 | background-color: #fff; 193 | } 194 | 195 | .col-sm-11 ul{ 196 | padding-left: 15px; 197 | } 198 | 199 | .col-sm-11 ol { 200 | padding-left: 15px; 201 | } 202 | 203 | /* Icons */ 204 | 205 | .good { 206 | color: green; 207 | } 208 | 209 | .bad { 210 | color: red; 211 | } 212 | 213 | /* Tasks View */ 214 | .task-heading { 215 | margin-bottom: 10px; 216 | } 217 | 218 | .task-heading > .btn { 219 | width: 100px; 220 | margin-right: 10px; 221 | } 222 | 223 | /* Mobile Nav */ 224 | .mobile-nav { 225 | 226 | @media screen and (min-width: 768px) { 227 | display: none; 228 | } 229 | 230 | position: absolute; 231 | bottom: 0; 232 | height: 50px; 233 | width: 100%; 234 | background-color: $brand-color; 235 | text-align: center; 236 | 237 | ul { 238 | padding-top: 13px; 239 | } 240 | li { 241 | display: inline; 242 | padding: 15px; 243 | } 244 | 245 | a { 246 | color: $primary-color-text; 247 | font-size: 2em; 248 | } 249 | } 250 | 251 | #noteTabs-accordion { 252 | margin-bottom: 100px; 253 | } 254 | -------------------------------------------------------------------------------- /frontend/sass/_base.scss: -------------------------------------------------------------------------------- 1 | body { 2 | font-family: "Ubuntu", 'sans-serif'; 3 | } -------------------------------------------------------------------------------- /frontend/sass/_cover.scss: -------------------------------------------------------------------------------- 1 | /* 2 | * Base structure 3 | */ 4 | 5 | html, 6 | body { 7 | height: 100%; 8 | } 9 | 10 | /* Extra markup and styles for table-esque vertical and horizontal centering */ 11 | .site-wrapper { 12 | display: table; 13 | width: 100%; 14 | height: 100%; /* For at least Firefox */ 15 | min-height: 100%; 16 | -webkit-box-shadow: inset 0 0 100px rgba(0,0,0,.5); 17 | box-shadow: inset 0 0 100px rgba(0,0,0,.5); 18 | background-color: $brand-color; 19 | color: #fff; 20 | text-align: center; 21 | text-shadow: 0 1px 3px rgba(0,0,0,.5); 22 | 23 | /* Links */ 24 | a, 25 | a:focus, 26 | a:hover { 27 | color: #fff; 28 | } 29 | 30 | /* Custom default button */ 31 | .btn-default, 32 | .btn-default:hover, 33 | .btn-default:focus { 34 | color: #333; 35 | text-shadow: none; /* Prevent inheritance from `body` */ 36 | } 37 | } 38 | 39 | .site-wrapper-inner { 40 | display: table-cell; 41 | vertical-align: top; 42 | } 43 | .cover-container { 44 | margin-right: auto; 45 | margin-left: auto; 46 | } 47 | 48 | /* Padding for spacing */ 49 | .inner { 50 | padding: 30px; 51 | } 52 | 53 | 54 | /* 55 | * Header 56 | */ 57 | .masthead-brand { 58 | margin-top: 10px; 59 | margin-bottom: 10px; 60 | } 61 | 62 | .masthead-nav > li { 63 | display: inline-block; 64 | } 65 | .masthead-nav > li + li { 66 | margin-left: 5px; 67 | } 68 | .masthead-nav > li > button { 69 | font-weight: bold; 70 | } 71 | .masthead-nav > li > a:hover, 72 | .masthead-nav > li > a:focus { 73 | background-color: transparent; 74 | border-bottom-color: #a9a9a9; 75 | border-bottom-color: rgba(255,255,255,.25); 76 | } 77 | .masthead-nav > .active > a, 78 | .masthead-nav > .active > a:hover, 79 | .masthead-nav > .active > a:focus { 80 | color: #fff; 81 | border-bottom-color: #fff; 82 | } 83 | 84 | @media (min-width: 768px) { 85 | .masthead-brand { 86 | float: left; 87 | } 88 | .masthead-nav { 89 | float: right; 90 | } 91 | } 92 | 93 | 94 | /* 95 | * Cover 96 | */ 97 | 98 | .cover { 99 | padding: 0 20px; 100 | } 101 | .cover .btn-lg { 102 | padding: 10px 20px; 103 | font-weight: bold; 104 | } 105 | 106 | 107 | /* 108 | * Footer 109 | */ 110 | 111 | .mastfoot { 112 | color: lighten($brand-color, 30%); 113 | } 114 | 115 | 116 | /* 117 | * Affix and center 118 | */ 119 | 120 | @media (min-width: 768px) { 121 | /* Pull out the header and footer */ 122 | .masthead { 123 | position: fixed; 124 | top: 0; 125 | } 126 | .mastfoot { 127 | position: fixed; 128 | bottom: 0; 129 | } 130 | /* Start the vertical centering */ 131 | .site-wrapper-inner { 132 | vertical-align: middle; 133 | } 134 | /* Handle the widths */ 135 | .masthead, 136 | .mastfoot, 137 | .cover-container { 138 | width: 100%; /* Must be percentage or pixels for horizontal alignment */ 139 | } 140 | } 141 | 142 | @media (min-width: 992px) { 143 | .masthead, 144 | .mastfoot, 145 | .cover-container { 146 | width: 700px; 147 | } 148 | } 149 | -------------------------------------------------------------------------------- /frontend/sass/_editor.scss: -------------------------------------------------------------------------------- 1 | .editor { 2 | margin-bottom: 10px; 3 | } 4 | 5 | .ProseMirror-content { 6 | min-height: 200px; 7 | } 8 | -------------------------------------------------------------------------------- /frontend/sass/_layout.scss: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/levlaz/braindump/9640dd03f99851dbd34dd6cac98a747a4a591b01/frontend/sass/_layout.scss -------------------------------------------------------------------------------- /frontend/sass/_modal.scss: -------------------------------------------------------------------------------- 1 | .modal { 2 | text-shadow: none; 3 | color: $text-color; 4 | } 5 | 6 | .modal-title { 7 | color: $text-color; 8 | } 9 | -------------------------------------------------------------------------------- /frontend/sass/_outer.scss: -------------------------------------------------------------------------------- 1 | /* 2 | * Outer Header 3 | */ 4 | .outer-header { 5 | border-top: solid 2px red; 6 | } 7 | 8 | .jumbotron { 9 | color: blue; 10 | } 11 | 12 | .alert-outer { 13 | position: fixed; 14 | width: 100%; 15 | bottom: 0; 16 | right: unset; 17 | top: unset; 18 | } -------------------------------------------------------------------------------- /frontend/sass/main.scss: -------------------------------------------------------------------------------- 1 | * { 2 | margin: 0; 3 | padding: 0; 4 | } 5 | 6 | /* Palette generated by Material Palette - materialpalette.com/blue/orange */ 7 | 8 | $primary-color-dark: #1976D2; 9 | $primary-color: #2196F3; 10 | $primary-color-light: #BBDEFB; 11 | $primary-color-text: #FFFFFF; 12 | $accent-color: #FF9800; 13 | $primary-text-color: #212121; 14 | $secondary-text-color: #757575; 15 | $divider-color: #BDBDBD; 16 | 17 | 18 | $brand-color: #2196f3; 19 | $text-color: #555; 20 | 21 | @import url('https://fonts.googleapis.com/css?family=Ubuntu:300,400'); 22 | 23 | // Import Partials from _partials directory 24 | @import 25 | "base", 26 | "layout", 27 | "notebook-card", 28 | "cover", 29 | "app", 30 | "modal", 31 | "editor", 32 | "outer" 33 | ; 34 | -------------------------------------------------------------------------------- /frontend/sass/notebook-card.scss: -------------------------------------------------------------------------------- 1 | .notebook-card { 2 | width: 200px; 3 | box-shadow: 0 4px 8px 0 rgba(0,0,0,0.2); 4 | transition: 0.3s; 5 | margin-right: 20px; 6 | margin-bottom: 20px; 7 | float: left; 8 | border-top: solid 2px #1976d2; 9 | } 10 | 11 | .notebook-card p { 12 | text-align: center; 13 | padding-top: 20px; 14 | padding-left: 5px; 15 | padding-right: 5px; 16 | font-weight: bold; 17 | } 18 | 19 | .notebook-card a { 20 | text-decoration: none; 21 | } 22 | 23 | .notebook-card-title { 24 | height: 100px; 25 | background-color: #2196f3; 26 | color: #fff; 27 | } 28 | 29 | .notebook-card-actions { 30 | padding: 10px; 31 | text-align: right; 32 | font-size: 16px; 33 | color: #1976d2; 34 | } 35 | 36 | .notebook-card-actions i { 37 | margin-left: 10px; 38 | } 39 | 40 | .notebook-card:hover { 41 | box-shadow: 0 8px 16px 0 rgba(0,0,0,0.2); 42 | } 43 | 44 | .badge { 45 | margin-top: 10px; 46 | padding: 5px; 47 | background-color: #fff; 48 | box-shadow: 0 3px 10px 0 rgba(0,0,0,0.2); 49 | color: #1976d2; 50 | } 51 | -------------------------------------------------------------------------------- /gulpfile.js: -------------------------------------------------------------------------------- 1 | var gulp = require('gulp'); 2 | var concat = require('gulp-concat'); 3 | var uglify = require('gulp-uglify'); 4 | var cleanCSS = require('gulp-clean-css'); 5 | var sass = require('gulp-sass'); 6 | 7 | var vendor = { 8 | css: [ 9 | 'node_modules/bootstrap/dist/css/bootstrap.min.css', 10 | 'node_modules/bootstrap-tagsinput/src/bootstrap-tagsinput.css', 11 | 'node_modules/font-awesome/css/font-awesome.min.css' 12 | ] 13 | }; 14 | 15 | var dist = { 16 | fonts: 'app/static/fonts/', 17 | css: 'app/static/css/', 18 | img: 'app/static/img/' 19 | }; 20 | 21 | gulp.task('vendor', function() { 22 | gulp.src(vendor.css) 23 | .pipe(concat('vendor.min.css')) 24 | .pipe(gulp.dest(dist.css)) 25 | ; 26 | 27 | gulp.src('node_modules/font-awesome/fonts/*') 28 | .pipe(gulp.dest(dist.fonts)) 29 | ; 30 | 31 | gulp.src('node_modules/bootstrap/dist/fonts/*') 32 | .pipe(gulp.dest(dist.fonts)) 33 | ; 34 | }); 35 | 36 | gulp.task('sass', function () { 37 | return gulp.src('./frontend/sass/**/*.scss') 38 | .pipe(sass.sync().on('error', sass.logError)) 39 | .pipe(concat('app.min.css')) 40 | .pipe(cleanCSS()) 41 | .pipe(gulp.dest(dist.css)); 42 | }); 43 | 44 | gulp.task('sass:watch', function () { 45 | gulp.watch('./frontend/sass/*.scss', ['sass']); 46 | }); 47 | 48 | gulp.task('default', ['vendor', 'sass:watch']); 49 | -------------------------------------------------------------------------------- /manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import os 3 | 4 | from app import create_app, db 5 | from app.models import User, Note, Tag, Notebook 6 | from app.model.shared import SharedNote 7 | from flask_script import Manager, Shell 8 | from flask_migrate import Migrate, MigrateCommand 9 | 10 | COV = None 11 | if os.environ.get('FLASK_COVERAGE'): 12 | import coverage 13 | COV = coverage.coverage(branch=True, include='app/*') 14 | COV.start() 15 | 16 | if os.path.exists('.env'): 17 | print('Importing environment from .env...') 18 | for line in open('.env'): 19 | var = line.strip().split('=') 20 | if len(var) == 2: 21 | os.environ[var[0]] = var[1] 22 | 23 | app = create_app(os.getenv('FLASK_CONFIG') or 'default') 24 | manager = Manager(app) 25 | migrate = Migrate(app, db) 26 | 27 | 28 | def make_shell_context(): 29 | return dict( 30 | app=app, db=db, User=User, 31 | Note=Note, Tag=Tag, 32 | Notebook=Notebook) 33 | 34 | manager.add_command( 35 | "shell", 36 | Shell(make_context=make_shell_context)) 37 | manager.add_command('db', MigrateCommand) 38 | 39 | 40 | @manager.command 41 | def test(coverage=False): 42 | """Run the unit tests.""" 43 | import sys 44 | if coverage and not os.environ.get('FLASK_COVERAGE'): 45 | os.environ['FLASK_COVERAGE'] = '1' 46 | os.execvp(sys.executable, [sys.executable] + sys.argv) 47 | import unittest 48 | import xmlrunner 49 | tests = unittest.TestLoader().discover('tests') 50 | results = xmlrunner.XMLTestRunner(output='test-reports').run(tests) 51 | if COV: 52 | COV.stop() 53 | COV.save() 54 | print('Coverage Summary:') 55 | COV.report() 56 | basedir = os.path.abspath(os.path.dirname(__file__)) 57 | covdir = os.path.join(basedir, 'test-reports/coverage') 58 | COV.html_report(directory=covdir) 59 | print('HTML version: file://%s/index.html' % covdir) 60 | COV.erase() 61 | if (len(results.failures) > 0 or len(results.errors) > 0): 62 | sys.exit(1) 63 | 64 | 65 | @manager.command 66 | def profile(length=25, profile_dir=None): 67 | """Start the application under the code profiler.""" 68 | from werkzeug.contrib.profiler import ProfilerMiddleware 69 | app.wsgi_app = ProfilerMiddleware(app.wsgi_app, restrictions=[length], 70 | profile_dir=profile_dir) 71 | app.run() 72 | 73 | 74 | @manager.command 75 | def deploy(): 76 | """Run deployment tasks.""" 77 | from flask_migrate import upgrade 78 | 79 | # migrate database to latest revision 80 | upgrade() 81 | 82 | 83 | @manager.command 84 | def routes(): 85 | import pprint 86 | pprint.pprint(list(map(lambda x: repr(x), app.url_map.iter_rules()))) 87 | 88 | if __name__ == '__main__': 89 | manager.run() 90 | -------------------------------------------------------------------------------- /migrations/README: -------------------------------------------------------------------------------- 1 | Generic single-database configuration. -------------------------------------------------------------------------------- /migrations/alembic.ini: -------------------------------------------------------------------------------- 1 | # A generic, single database configuration. 2 | 3 | [alembic] 4 | # template used to generate migration files 5 | # file_template = %%(rev)s_%%(slug)s 6 | 7 | # set to 'true' to run the environment during 8 | # the 'revision' command, regardless of autogenerate 9 | # revision_environment = false 10 | 11 | 12 | # Logging configuration 13 | [loggers] 14 | keys = root,sqlalchemy,alembic 15 | 16 | [handlers] 17 | keys = console 18 | 19 | [formatters] 20 | keys = generic 21 | 22 | [logger_root] 23 | level = WARN 24 | handlers = console 25 | qualname = 26 | 27 | [logger_sqlalchemy] 28 | level = WARN 29 | handlers = 30 | qualname = sqlalchemy.engine 31 | 32 | [logger_alembic] 33 | level = INFO 34 | handlers = 35 | qualname = alembic 36 | 37 | [handler_console] 38 | class = StreamHandler 39 | args = (sys.stderr,) 40 | level = NOTSET 41 | formatter = generic 42 | 43 | [formatter_generic] 44 | format = %(levelname)-5.5s [%(name)s] %(message)s 45 | datefmt = %H:%M:%S 46 | -------------------------------------------------------------------------------- /migrations/env.py: -------------------------------------------------------------------------------- 1 | from __future__ import with_statement 2 | from alembic import context 3 | from sqlalchemy import engine_from_config, pool 4 | from logging.config import fileConfig 5 | import logging 6 | 7 | # this is the Alembic Config object, which provides 8 | # access to the values within the .ini file in use. 9 | config = context.config 10 | 11 | # Interpret the config file for Python logging. 12 | # This line sets up loggers basically. 13 | fileConfig(config.config_file_name) 14 | logger = logging.getLogger('alembic.env') 15 | 16 | # add your model's MetaData object here 17 | # for 'autogenerate' support 18 | # from myapp import mymodel 19 | # target_metadata = mymodel.Base.metadata 20 | from flask import current_app 21 | config.set_main_option('sqlalchemy.url', 22 | current_app.config.get('SQLALCHEMY_DATABASE_URI')) 23 | target_metadata = current_app.extensions['migrate'].db.metadata 24 | 25 | # other values from the config, defined by the needs of env.py, 26 | # can be acquired: 27 | # my_important_option = config.get_main_option("my_important_option") 28 | # ... etc. 29 | 30 | 31 | def run_migrations_offline(): 32 | """Run migrations in 'offline' mode. 33 | 34 | This configures the context with just a URL 35 | and not an Engine, though an Engine is acceptable 36 | here as well. By skipping the Engine creation 37 | we don't even need a DBAPI to be available. 38 | 39 | Calls to context.execute() here emit the given string to the 40 | script output. 41 | 42 | """ 43 | url = config.get_main_option("sqlalchemy.url") 44 | context.configure(url=url) 45 | 46 | with context.begin_transaction(): 47 | context.run_migrations() 48 | 49 | 50 | def run_migrations_online(): 51 | """Run migrations in 'online' mode. 52 | 53 | In this scenario we need to create an Engine 54 | and associate a connection with the context. 55 | 56 | """ 57 | 58 | # this callback is used to prevent an auto-migration from being generated 59 | # when there are no changes to the schema 60 | # reference: http://alembic.readthedocs.org/en/latest/cookbook.html 61 | def process_revision_directives(context, revision, directives): 62 | if getattr(config.cmd_opts, 'autogenerate', False): 63 | script = directives[0] 64 | if script.upgrade_ops.is_empty(): 65 | directives[:] = [] 66 | logger.info('No changes in schema detected.') 67 | 68 | engine = engine_from_config(config.get_section(config.config_ini_section), 69 | prefix='sqlalchemy.', 70 | poolclass=pool.NullPool) 71 | 72 | connection = engine.connect() 73 | context.configure(connection=connection, 74 | target_metadata=target_metadata, 75 | process_revision_directives=process_revision_directives, 76 | **current_app.extensions['migrate'].configure_args) 77 | 78 | try: 79 | with context.begin_transaction(): 80 | context.run_migrations() 81 | finally: 82 | connection.close() 83 | 84 | if context.is_offline_mode(): 85 | run_migrations_offline() 86 | else: 87 | run_migrations_online() 88 | -------------------------------------------------------------------------------- /migrations/script.py.mako: -------------------------------------------------------------------------------- 1 | """${message} 2 | 3 | Revision ID: ${up_revision} 4 | Revises: ${down_revision} 5 | Create Date: ${create_date} 6 | 7 | """ 8 | 9 | # revision identifiers, used by Alembic. 10 | revision = ${repr(up_revision)} 11 | down_revision = ${repr(down_revision)} 12 | 13 | from alembic import op 14 | import sqlalchemy as sa 15 | ${imports if imports else ""} 16 | 17 | def upgrade(): 18 | ${upgrades if upgrades else "pass"} 19 | 20 | 21 | def downgrade(): 22 | ${downgrades if downgrades else "pass"} 23 | -------------------------------------------------------------------------------- /migrations/versions/3a37e844b277_alter_constraints.py: -------------------------------------------------------------------------------- 1 | """Alter Constraints 2 | 3 | Revision ID: 3a37e844b277 4 | Revises: b237b9f6a2ce 5 | Create Date: 2016-05-30 15:57:56.017519 6 | 7 | """ 8 | 9 | # revision identifiers, used by Alembic. 10 | revision = '3a37e844b277' 11 | down_revision = 'b237b9f6a2ce' 12 | 13 | from alembic import op 14 | import sqlalchemy as sa 15 | 16 | 17 | def upgrade(): 18 | ### commands auto generated by Alembic - please adjust! ### 19 | op.drop_column('users', 'last_name') 20 | op.drop_column('users', 'first_name') 21 | ### end Alembic commands ### 22 | with op.batch_alter_table("notes") as batch_op: 23 | batch_op.drop_constraint( 24 | "notes_author_id_fkey", type_="foreignkey") 25 | with op.batch_alter_table("notebooks") as batch_op: 26 | batch_op.drop_constraint( 27 | "notebooks_author_id_fkey", type_="foreignkey") 28 | op.create_foreign_key( 29 | "notes_author_id_fkey", "notes", "users", 30 | ["author_id"], ["id"], ondelete="CASCADE") 31 | op.create_foreign_key( 32 | "notebooks_author_id_fkey", "notebooks", "users", 33 | ["author_id"], ["id"], ondelete="CASCADE") 34 | 35 | def downgrade(): 36 | ### commands auto generated by Alembic - please adjust! ### 37 | op.add_column('users', sa.Column('first_name', sa.VARCHAR(length=200), autoincrement=False, nullable=True)) 38 | op.add_column('users', sa.Column('last_name', sa.VARCHAR(length=200), autoincrement=False, nullable=True)) 39 | ### end Alembic commands ### 40 | -------------------------------------------------------------------------------- /migrations/versions/3b4b395f61a9_initial_migration.py: -------------------------------------------------------------------------------- 1 | """Initial Migration 2 | 3 | Revision ID: 3b4b395f61a9 4 | Revises: None 5 | Create Date: 2016-05-12 18:37:31.644301 6 | 7 | """ 8 | 9 | # revision identifiers, used by Alembic. 10 | revision = '3b4b395f61a9' 11 | down_revision = None 12 | 13 | from alembic import op 14 | import sqlalchemy as sa 15 | import sqlalchemy_utils 16 | 17 | 18 | def upgrade(): 19 | ### commands auto generated by Alembic - please adjust! ### 20 | op.create_table('tags', 21 | sa.Column('id', sa.Integer(), nullable=False), 22 | sa.Column('tag', sa.String(length=200), nullable=True), 23 | sa.PrimaryKeyConstraint('id') 24 | ) 25 | op.create_table('users', 26 | sa.Column('id', sa.Integer(), nullable=False), 27 | sa.Column('email', sa.String(length=254), nullable=True), 28 | sa.Column('username', sa.String(length=64), nullable=True), 29 | sa.Column('password_hash', sa.String(length=256), nullable=True), 30 | sa.Column('confirmed', sa.Boolean(), nullable=True), 31 | sa.Column('avatar_hash', sa.String(length=32), nullable=True), 32 | sa.Column('created_date', sa.DateTime(), nullable=True), 33 | sa.Column('updated_date', sa.DateTime(), nullable=True), 34 | sa.PrimaryKeyConstraint('id') 35 | ) 36 | op.create_index(op.f('ix_users_email'), 'users', ['email'], unique=True) 37 | op.create_index(op.f('ix_users_username'), 'users', ['username'], unique=True) 38 | op.create_table('notebooks', 39 | sa.Column('id', sa.Integer(), nullable=False), 40 | sa.Column('title', sa.String(length=200), nullable=True), 41 | sa.Column('is_deleted', sa.Boolean(), nullable=True), 42 | sa.Column('author_id', sa.Integer(), nullable=True), 43 | sa.ForeignKeyConstraint(['author_id'], ['users.id'], ), 44 | sa.PrimaryKeyConstraint('id') 45 | ) 46 | op.create_table('notes', 47 | sa.Column('id', sa.Integer(), nullable=False), 48 | sa.Column('title', sa.String(length=200), nullable=True), 49 | sa.Column('body', sa.Text(), nullable=True), 50 | sa.Column('body_html', sa.Text(), nullable=True), 51 | sa.Column('created_date', sa.DateTime(), nullable=True), 52 | sa.Column('updated_date', sa.DateTime(), nullable=True), 53 | sa.Column('author_id', sa.Integer(), nullable=True), 54 | sa.Column('notebook_id', sa.Integer(), nullable=True), 55 | sa.Column('is_deleted', sa.Boolean(), nullable=True), 56 | sa.Column('is_favorite', sa.Boolean(), nullable=True), 57 | sa.Column('is_archived', sa.Boolean(), nullable=True), 58 | sa.Column('search_vector', sqlalchemy_utils.types.ts_vector.TSVectorType(), nullable=True), 59 | sa.ForeignKeyConstraint(['author_id'], ['users.id'], ), 60 | sa.ForeignKeyConstraint(['notebook_id'], ['notebooks.id'], ), 61 | sa.PrimaryKeyConstraint('id') 62 | ) 63 | op.create_index(op.f('ix_notes_created_date'), 'notes', ['created_date'], unique=False) 64 | op.create_index('ix_notes_search_vector', 'notes', ['search_vector'], unique=False, postgresql_using='gin') 65 | op.create_index(op.f('ix_notes_updated_date'), 'notes', ['updated_date'], unique=False) 66 | op.create_table('note_tag', 67 | sa.Column('note_id', sa.Integer(), nullable=True), 68 | sa.Column('tag_id', sa.Integer(), nullable=True), 69 | sa.ForeignKeyConstraint(['note_id'], ['notes.id'], ondelete='CASCADE'), 70 | sa.ForeignKeyConstraint(['tag_id'], ['tags.id'], ondelete='CASCADE') 71 | ) 72 | ### end Alembic commands ### 73 | 74 | 75 | def downgrade(): 76 | ### commands auto generated by Alembic - please adjust! ### 77 | op.drop_table('note_tag') 78 | op.drop_index(op.f('ix_notes_updated_date'), table_name='notes') 79 | op.drop_index('ix_notes_search_vector', table_name='notes') 80 | op.drop_index(op.f('ix_notes_created_date'), table_name='notes') 81 | op.drop_table('notes') 82 | op.drop_table('notebooks') 83 | op.drop_index(op.f('ix_users_username'), table_name='users') 84 | op.drop_index(op.f('ix_users_email'), table_name='users') 85 | op.drop_table('users') 86 | op.drop_table('tags') 87 | ### end Alembic commands ### 88 | -------------------------------------------------------------------------------- /migrations/versions/55c778dd35ab_add_default_notebook_to_users.py: -------------------------------------------------------------------------------- 1 | """add default notebook to users 2 | 3 | Revision ID: 55c778dd35ab 4 | Revises: c052ae5abc1d 5 | Create Date: 2016-12-17 21:24:03.788486 6 | 7 | """ 8 | 9 | # revision identifiers, used by Alembic. 10 | revision = '55c778dd35ab' 11 | down_revision = 'c052ae5abc1d' 12 | 13 | from alembic import op 14 | import sqlalchemy as sa 15 | 16 | 17 | def upgrade(): 18 | # ### commands auto generated by Alembic - please adjust! ### 19 | op.add_column('users', sa.Column('default_notebook', sa.Integer(), nullable=True)) 20 | # ### end Alembic commands ### 21 | 22 | 23 | def downgrade(): 24 | # ### commands auto generated by Alembic - please adjust! ### 25 | op.drop_column('users', 'default_notebook') 26 | # ### end Alembic commands ### 27 | -------------------------------------------------------------------------------- /migrations/versions/5d3c326dd901_add_created_and_updated_date_to_notebook.py: -------------------------------------------------------------------------------- 1 | """Add Created and Updated date to Notebook 2 | 3 | Revision ID: 5d3c326dd901 4 | Revises: 3a37e844b277 5 | Create Date: 2016-08-29 10:39:23.609605 6 | 7 | """ 8 | 9 | # revision identifiers, used by Alembic. 10 | revision = '5d3c326dd901' 11 | down_revision = '3a37e844b277' 12 | 13 | from alembic import op 14 | import sqlalchemy as sa 15 | 16 | 17 | def upgrade(): 18 | ### commands auto generated by Alembic - please adjust! ### 19 | op.add_column('notebooks', sa.Column('created_date', sa.DateTime(), nullable=True)) 20 | op.add_column('notebooks', sa.Column('updated_date', sa.DateTime(), nullable=True)) 21 | ### end Alembic commands ### 22 | 23 | 24 | def downgrade(): 25 | ### commands auto generated by Alembic - please adjust! ### 26 | op.drop_column('notebooks', 'updated_date') 27 | op.drop_column('notebooks', 'created_date') 28 | ### end Alembic commands ### 29 | -------------------------------------------------------------------------------- /migrations/versions/b237b9f6a2ce_update_user_model_remove_username_add_.py: -------------------------------------------------------------------------------- 1 | """Update User model, remove username, add first and lastname 2 | 3 | Revision ID: b237b9f6a2ce 4 | Revises: 3b4b395f61a9 5 | Create Date: 2016-05-12 18:42:21.473162 6 | 7 | """ 8 | 9 | # revision identifiers, used by Alembic. 10 | revision = 'b237b9f6a2ce' 11 | down_revision = '3b4b395f61a9' 12 | 13 | from alembic import op 14 | import sqlalchemy as sa 15 | 16 | 17 | def upgrade(): 18 | ### commands auto generated by Alembic - please adjust! ### 19 | op.add_column('users', sa.Column('first_name', sa.String(length=200), nullable=True)) 20 | op.add_column('users', sa.Column('last_name', sa.String(length=200), nullable=True)) 21 | op.drop_index('ix_users_username', table_name='users') 22 | op.drop_column('users', 'username') 23 | ### end Alembic commands ### 24 | 25 | 26 | def downgrade(): 27 | ### commands auto generated by Alembic - please adjust! ### 28 | op.add_column('users', sa.Column('username', sa.VARCHAR(length=64), autoincrement=False, nullable=True)) 29 | op.create_index('ix_users_username', 'users', ['username'], unique=True) 30 | op.drop_column('users', 'last_name') 31 | op.drop_column('users', 'first_name') 32 | ### end Alembic commands ### 33 | -------------------------------------------------------------------------------- /migrations/versions/c052ae5abc1d_remove_html_body_from_notes.py: -------------------------------------------------------------------------------- 1 | """Remove html_body from notes 2 | 3 | Revision ID: c052ae5abc1d 4 | Revises: ffe4c66b5772 5 | Create Date: 2016-08-30 10:59:26.263398 6 | 7 | """ 8 | 9 | # revision identifiers, used by Alembic. 10 | revision = 'c052ae5abc1d' 11 | down_revision = 'ffe4c66b5772' 12 | 13 | from alembic import op 14 | import sqlalchemy as sa 15 | 16 | 17 | def upgrade(): 18 | ### commands auto generated by Alembic - please adjust! ### 19 | op.drop_column('notes', 'body_html') 20 | ### end Alembic commands ### 21 | 22 | 23 | def downgrade(): 24 | ### commands auto generated by Alembic - please adjust! ### 25 | op.add_column('notes', sa.Column('body_html', sa.TEXT(), autoincrement=False, nullable=True)) 26 | ### end Alembic commands ### 27 | -------------------------------------------------------------------------------- /migrations/versions/ffe4c66b5772_add_shared_notes.py: -------------------------------------------------------------------------------- 1 | """Add Shared Notes 2 | 3 | Revision ID: ffe4c66b5772 4 | Revises: 5d3c326dd901 5 | Create Date: 2016-08-30 08:40:35.993215 6 | 7 | """ 8 | 9 | # revision identifiers, used by Alembic. 10 | revision = 'ffe4c66b5772' 11 | down_revision = '5d3c326dd901' 12 | 13 | from alembic import op 14 | import sqlalchemy as sa 15 | 16 | 17 | def upgrade(): 18 | ### commands auto generated by Alembic - please adjust! ### 19 | op.create_table('shared_notes', 20 | sa.Column('id', sa.Integer(), nullable=False), 21 | sa.Column('created_date', sa.DateTime(), nullable=True), 22 | sa.Column('updated_date', sa.DateTime(), nullable=True), 23 | sa.Column('author_id', sa.Integer(), nullable=True), 24 | sa.Column('note_id', sa.Integer(), nullable=True), 25 | sa.Column('recipient_email', sa.String(length=254), nullable=True), 26 | sa.ForeignKeyConstraint(['author_id'], ['users.id'], ), 27 | sa.ForeignKeyConstraint(['note_id'], ['notes.id'], ), 28 | sa.PrimaryKeyConstraint('id') 29 | ) 30 | op.create_index(op.f('ix_shared_notes_created_date'), 'shared_notes', ['created_date'], unique=False) 31 | op.create_index(op.f('ix_shared_notes_updated_date'), 'shared_notes', ['updated_date'], unique=False) 32 | op.add_column('users', sa.Column('last_login_date', sa.DateTime(), nullable=True)) 33 | ### end Alembic commands ### 34 | 35 | 36 | def downgrade(): 37 | ### commands auto generated by Alembic - please adjust! ### 38 | op.drop_column('users', 'last_login_date') 39 | op.drop_index(op.f('ix_shared_notes_updated_date'), table_name='shared_notes') 40 | op.drop_index(op.f('ix_shared_notes_created_date'), table_name='shared_notes') 41 | op.drop_table('shared_notes') 42 | ### end Alembic commands ### 43 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "braindump", 3 | "private": true, 4 | "scripts": { 5 | "test": "echo \"Error: no test specified\" && exit 1", 6 | "build": "browserify -t [ babelify --presets [ es2015 ] ] frontend/js/src/*.js | uglifyjs > app/static/js/braindump.min.js", 7 | "gulp": "gulp", 8 | "watch": "npm-watch" 9 | }, 10 | "watch": { 11 | "build": "frontend/js/src/*.js", 12 | "gulp": "frontend/sass/*.scss" 13 | }, 14 | "dependencies": { 15 | "bootstrap": "^3.3.6", 16 | "bootstrap-tabcollapse": "^0.2.6", 17 | "bootstrap-tagsinput": "^0.7.1", 18 | "font-awesome": "4.7.0", 19 | "jquery": "^2.2.3", 20 | "moment": "2.16.0", 21 | "prosemirror": "^0.6.1" 22 | }, 23 | "devDependencies": { 24 | "babel-preset-es2015": "^6.6.0", 25 | "babelify": "^7.3.0", 26 | "browserify": "^13.0.0", 27 | "gulp": "^3.9.1", 28 | "gulp-clean-css": "^2.0.11", 29 | "gulp-concat": "^2.6.0", 30 | "gulp-sass": "^2.3.2", 31 | "gulp-uglify": "^1.5.4", 32 | "jsdoc": "^3.4.0", 33 | "npm-watch": "^0.1.5", 34 | "uglifyjs": "^2.4.10" 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | # Flask Requirements 2 | Flask==0.11.1 3 | Flask-HTTPAuth==3.2.1 4 | Flask-Login==0.4.0 5 | Flask-Mail==0.9.1 6 | Flask-Migrate==2.0.1 7 | Flask-SQLAlchemy==2.1 8 | Flask-Script==2.0.5 9 | Flask-WTF==0.13.1 10 | Flask-Bootstrap==3.3.7.0 11 | Flask-RESTful==0.3.5 12 | Flask-JWT==0.3.2 13 | Flask-Cors==3.0.2 14 | 15 | # Testing 16 | coverage==4.2 17 | flake8==3.2.1 18 | selenium==3.0.2 19 | unittest-xml-reporting==2.1.0 20 | ForgeryPy3==0.3.1 21 | 22 | # Other Dependencies 23 | psycopg2==2.6.2 24 | sqlalchemy_searchable==0.10.2 25 | gunicorn==19.6.0 26 | markdown==2.6.7 27 | python-slugify==1.2.1 -------------------------------------------------------------------------------- /scripts/bootstrap.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Upgrade system packages 4 | apt-get update && apt-get -y upgrade 5 | 6 | # Install Package Dependencies 7 | apt-get -y install python-pip python-dev libxml2-dev libxslt1-dev libpq-dev tmux 8 | 9 | # Postgresql Configuration 10 | 11 | # Edit the following to change the name of the database user that will be created: 12 | APP_DB_USER=braindump 13 | APP_DB_PASS=braindump 14 | 15 | # Edit the following to change the name of the database that is created (defaults to the user name) 16 | APP_DB_NAME=$APP_DB_USER 17 | 18 | # Edit the following to change the version of PostgreSQL that is installed 19 | PG_VERSION=9.5 20 | 21 | ########################################################### 22 | # Changes below this line are probably not necessary 23 | ########################################################### 24 | print_db_usage () { 25 | echo "Your PostgreSQL database has been setup and can be accessed on your local machine on the forwarded port (default: 15432)" 26 | echo " Host: localhost" 27 | echo " Port: 15432" 28 | echo " Database: $APP_DB_NAME" 29 | echo " Username: $APP_DB_USER" 30 | echo " Password: $APP_DB_PASS" 31 | echo "" 32 | echo "Admin access to postgres user via VM:" 33 | echo " vagrant ssh" 34 | echo " sudo su - postgres" 35 | echo "" 36 | echo "psql access to app database user via VM:" 37 | echo " vagrant ssh" 38 | echo " sudo su - postgres" 39 | echo " PGUSER=$APP_DB_USER PGPASSWORD=$APP_DB_PASS psql -h localhost $APP_DB_NAME" 40 | echo "" 41 | echo "Env variable for application development:" 42 | echo " DATABASE_URL=postgresql://$APP_DB_USER:$APP_DB_PASS@localhost:15432/$APP_DB_NAME" 43 | echo "" 44 | echo "Local command to access the database via psql:" 45 | echo " PGUSER=$APP_DB_USER PGPASSWORD=$APP_DB_PASS psql -h localhost -p 15432 $APP_DB_NAME" 46 | } 47 | 48 | export DEBIAN_FRONTEND=noninteractive 49 | 50 | PROVISIONED_ON=/etc/vm_provision_on_timestamp 51 | if [ -f "$PROVISIONED_ON" ] 52 | then 53 | echo "VM was already provisioned at: $(cat $PROVISIONED_ON)" 54 | echo "To run system updates manually login via 'vagrant ssh' and run 'apt update && apt upgrade'" 55 | echo "" 56 | print_db_usage 57 | exit 58 | fi 59 | 60 | PG_REPO_APT_SOURCE=/etc/apt/sources.list.d/pgdg.list 61 | if [ ! -f "$PG_REPO_APT_SOURCE" ] 62 | then 63 | # Add PG apt repo: 64 | echo "deb http://apt.postgresql.org/pub/repos/apt/ xenial-pgdg main" > "$PG_REPO_APT_SOURCE" 65 | 66 | # Add PGDG repo key: 67 | wget --quiet -O - https://apt.postgresql.org/pub/repos/apt/ACCC4CF8.asc | apt-key add - 68 | fi 69 | 70 | # Update package list and upgrade all packages 71 | apt-get update 72 | apt-get -y upgrade 73 | 74 | apt-get -y install "postgresql-$PG_VERSION" "postgresql-contrib-$PG_VERSION" 75 | 76 | PG_CONF="/etc/postgresql/$PG_VERSION/main/postgresql.conf" 77 | PG_HBA="/etc/postgresql/$PG_VERSION/main/pg_hba.conf" 78 | PG_DIR="/var/lib/postgresql/$PG_VERSION/main" 79 | 80 | # Edit postgresql.conf to change listen address to '*': 81 | sed -i "s/#listen_addresses = 'localhost'/listen_addresses = '*'/" "$PG_CONF" 82 | 83 | # Append to pg_hba.conf to add password auth: 84 | echo "host all all all md5" >> "$PG_HBA" 85 | 86 | # Explicitly set default client_encoding 87 | echo "client_encoding = utf8" >> "$PG_CONF" 88 | 89 | # Restart so that all new config is loaded: 90 | systemctl restart postgresql 91 | 92 | cat << EOF | su - postgres -c psql 93 | -- Create the database user: 94 | CREATE USER $APP_DB_USER WITH PASSWORD '$APP_DB_PASS'; 95 | 96 | -- Create the database: 97 | CREATE DATABASE $APP_DB_NAME WITH OWNER=$APP_DB_USER 98 | LC_COLLATE='en_US.utf8' 99 | LC_CTYPE='en_US.utf8' 100 | ENCODING='UTF8' 101 | TEMPLATE=template0; 102 | EOF 103 | 104 | # Tag the provision time: 105 | date > "$PROVISIONED_ON" 106 | 107 | echo "Successfully created PostgreSQL dev virtual machine." 108 | echo "" 109 | print_db_usage 110 | 111 | # Install NodeJS 112 | curl -o- https://raw.githubusercontent.com/creationix/nvm/v0.31.7/install.sh | bash 113 | export NVM_DIR="/root/.nvm" 114 | [ -s "$NVM_DIR/nvm.sh" ] && . "$NVM_DIR/nvm.sh" 115 | nvm install node 116 | 117 | # Install Yarn 118 | apt-key adv --fetch-keys http://dl.yarnpkg.com/debian/pubkey.gpg 119 | echo "deb http://dl.yarnpkg.com/debian/ stable main" | sudo tee /etc/apt/sources.list.d/yarn.list 120 | apt-get update -qq 121 | apt-get install -y -qq yarn 122 | 123 | # Install Dependencies 124 | cd /vagrant 125 | pip install --upgrade -r requirements.txt 126 | yarn install 127 | -------------------------------------------------------------------------------- /scripts/deploy.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Send Latest Scripts to Production Server 4 | rsync -avz scripts/ $PROD_SERVER:/var/www/braindump/scripts/ 5 | rsync -avz etc/ $PROD_SERVER:/var/www/braindump/etc/ 6 | scp docker-compose.yml $PROD_SERVER:/var/www/braindump 7 | 8 | # Log into Production Server, Pull and Restart Docker 9 | ssh $PROD_SERVER 'cd /var/www/braindump && docker-compose pull' 10 | ssh $PROD_SERVER 'cd /var/www/braindump && docker-compose build' 11 | ssh $PROD_SERVER 'cd /var/www/braindump && source scripts/secrets.sh && docker-compose up -d' 12 | -------------------------------------------------------------------------------- /scripts/pg_backup.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e 4 | 5 | NOW=$(date +"%m-%d-%Y_%H:%M") 6 | 7 | docker exec braindump_db_1 pg_dump -Ubraindump_admin -dbraindump -f /db/backups/$NOW.braindump.bak -------------------------------------------------------------------------------- /scripts/secrets.sh.example: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | export MAIL_USERNAME='' 3 | export MAIL_PASSWORD='' 4 | export DB_USER='' 5 | export DB_PASS='' 6 | export DB_HOST='' 7 | export DB_NAME='' 8 | export DB_IP='' 9 | export BRAINDUMP_ADMIN='' 10 | export SECRET_KEY='' 11 | export FLASK_CONFIG='' 12 | -------------------------------------------------------------------------------- /scripts/start-app.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e 4 | 5 | until bash -c "python manage.py db current"; do 6 | >&2 echo "Postgres is unavailable - sleeping" 7 | sleep 1 8 | done 9 | 10 | >&2 echo "Postgres is up, starting braindump" 11 | # Run Migrations and Start App 12 | bash -c "python manage.py deploy && gunicorn manage:app -b 0.0.0.0" -------------------------------------------------------------------------------- /scripts/start-dev.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e 4 | 5 | until bash -c "python manage.py db current"; do 6 | >&2 echo "Postgres is unavailable - sleeping" 7 | sleep 1 8 | done 9 | 10 | >&2 echo "Postgres is up, starting braindump" 11 | # Run Migrations and Start App 12 | bash -c "python manage.py runserver --host='0.0.0.0'" -------------------------------------------------------------------------------- /tests/api_base.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | import json 3 | from base64 import b64encode 4 | from flask import url_for 5 | from app import create_app, db 6 | from app.models import User, Note, Notebook 7 | 8 | 9 | class ApiBaseTestCase(unittest.TestCase): 10 | 11 | @property 12 | def headers(self): 13 | return self.set_token_headers(self.get_auth_token()) 14 | 15 | def setUp(self): 16 | self.app = create_app('testing') 17 | self.app_context = self.app.app_context() 18 | self.app_context.push() 19 | db.create_all() 20 | self.client = self.app.test_client() 21 | 22 | def tearDown(self): 23 | db.session.remove() 24 | db.drop_all() 25 | self.app_context.pop() 26 | 27 | def add_user(self): 28 | u = User(email='test@example.com', password='password', confirmed=True) 29 | db.session.add(u) 30 | db.session.commit() 31 | return u 32 | 33 | def add_other_user(self): 34 | u = User( 35 | email='other@example.com', password='password', confirmed=True) 36 | db.session.add(u) 37 | db.session.commit() 38 | return u 39 | 40 | def set_auth_headers(self, username, password): 41 | return { 42 | 'Authorization': 'Basic ' + b64encode( 43 | (username + ':' + password).encode('utf-8')).decode('utf-8'), 44 | 'Accept': 'application/json', 45 | 'Content-Type': 'application/json' 46 | } 47 | 48 | def set_token_headers(self, token): 49 | return { 50 | 'Authorization': 'Bearer ' + token, 51 | 'Accept': 'application/json', 52 | 'Content-Type': 'application/json' 53 | } 54 | 55 | def get_auth_token(self): 56 | self.add_user() 57 | 58 | response = self.client.get( 59 | url_for('api.token'), 60 | headers=self.set_auth_headers('test@example.com', 'password')) 61 | 62 | json_response = json.loads( 63 | response.data.decode('utf-8')) 64 | 65 | return json_response['token'] 66 | 67 | def add_notebook(self, user): 68 | nb = Notebook(title="default", author=user) 69 | db.session.add(nb) 70 | db.session.commit() 71 | 72 | return nb 73 | 74 | def add_note(self, notebook, user): 75 | n = Note(title="5/14/3", body="test", notebook_id=notebook.id, author=user) 76 | db.session.add(n) 77 | db.session.commit() 78 | 79 | return n -------------------------------------------------------------------------------- /tests/client_base.py: -------------------------------------------------------------------------------- 1 | import os 2 | import app 3 | import unittest 4 | from app.models import User 5 | 6 | 7 | class ClientTestCase(unittest.TestCase): 8 | 9 | def setUp(self): 10 | self.app = app.create_app('testing') 11 | self.app_context = self.app.app_context() 12 | self.app_context.push() 13 | app.db.create_all() 14 | self.client = self.app.test_client() 15 | 16 | def tearDown(self): 17 | app.db.session.remove() 18 | app.db.drop_all() 19 | self.app_context.pop() 20 | 21 | def add_user(self): 22 | u = User(email='test@example.com', password='password', confirmed=True) 23 | app.db.session.add(u) 24 | app.db.session.commit() 25 | return u 26 | 27 | def add_other_user(self): 28 | u = User( 29 | email='other@example.com', password='password', confirmed=True) 30 | app.db.session.add(u) 31 | app.db.session.commit() 32 | return u 33 | 34 | def login(self, email, password): 35 | return self.client.post('/auth/login', data=dict( 36 | email=email, 37 | password=password 38 | ), follow_redirects=True) 39 | 40 | def logout(self): 41 | return self.client.get('/auth/logout', follow_redirects=True) -------------------------------------------------------------------------------- /tests/model_base.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from app import create_app, db 3 | 4 | 5 | class ModelBase(unittest.TestCase): 6 | def setUp(self): 7 | self.app = create_app('testing') 8 | self.app_context = self.app.app_context() 9 | self.app_context.push() 10 | db.create_all() 11 | 12 | def tearDown(self): 13 | db.session.remove() 14 | db.drop_all() 15 | self.app_context.pop() 16 | -------------------------------------------------------------------------------- /tests/test_api_authentication.py: -------------------------------------------------------------------------------- 1 | import json 2 | from flask import url_for 3 | from api_base import ApiBaseTestCase 4 | 5 | 6 | class AuthApiTestCase(ApiBaseTestCase): 7 | 8 | def test_basic_auth(self): 9 | self.add_user() 10 | 11 | # Issue Request with Basic Auth 12 | response = self.client.get( 13 | url_for('api.notebooks'), 14 | headers=self.set_auth_headers('test@example.com', 'password')) 15 | self.assertTrue(response.status_code == 200) 16 | 17 | # Issue Request with Bad Username 18 | response = self.client.get( 19 | url_for('api.notebooks'), 20 | headers=self.set_auth_headers('bad@example.com', 'password')) 21 | self.assertTrue(response.status_code == 401) 22 | 23 | # Issue Request with Bad Password 24 | response = self.client.get( 25 | url_for('api.notebooks'), 26 | headers=self.set_auth_headers('test@example.com', 'bad')) 27 | self.assertTrue(response.status_code == 401) 28 | 29 | # Issue Request with Bad Username and Password 30 | response = self.client.get( 31 | url_for('api.notebooks'), 32 | headers=self.set_auth_headers('bad@example.com', 'bad')) 33 | self.assertTrue(response.status_code == 401) 34 | 35 | def test_token_auth(self): 36 | self.add_user() 37 | 38 | # get a token 39 | response = self.client.get( 40 | url_for('api.token'), 41 | headers=self.set_auth_headers('test@example.com', 'password')) 42 | self.assertTrue(response.status_code == 200) 43 | json_response = json.loads( 44 | response.data.decode('utf-8')) 45 | self.assertIsNotNone(json_response.get('token')) 46 | token = json_response['token'] 47 | 48 | # issue a request with the new token 49 | response = self.client.get( 50 | url_for('api.notebooks'), 51 | headers=self.set_token_headers(token)) 52 | self.assertTrue(response.status_code == 200) 53 | self.assertTrue('notebooks' in response.get_data(as_text=True)) 54 | -------------------------------------------------------------------------------- /tests/test_api_notebooks.py: -------------------------------------------------------------------------------- 1 | import json 2 | from flask import url_for 3 | from api_base import ApiBaseTestCase 4 | from app import db 5 | from app.models import Notebook 6 | 7 | 8 | class NotebookApiTestCase(ApiBaseTestCase): 9 | 10 | def test_get_all_noteboooks(self): 11 | response = self.client.get( 12 | url_for('api.notebooks'), 13 | headers=self.headers) 14 | self.assertTrue(response.status_code == 200) 15 | json_response = json.loads(response.data.decode('utf-8')) 16 | self.assertTrue('notebooks' in json_response) 17 | 18 | def test_create_notebook(self): 19 | # Will neeed to use headers twice 20 | local_headers = self.headers 21 | 22 | response = self.client.post( 23 | url_for('api.notebooks'), 24 | headers=local_headers, 25 | data=json.dumps({ 26 | "title": "Test Notebook Title" 27 | })) 28 | 29 | json_response = json.loads(response.data.decode('utf-8')) 30 | 31 | self.assertTrue(response.status_code == 201) 32 | self.assertEqual( 33 | 'Test Notebook Title', json_response['notebook']['title']) 34 | 35 | # Test to make sure new notebook shows up in all notebooks 36 | response = self.client.get( 37 | url_for('api.notebooks'), 38 | headers=local_headers) 39 | self.assertTrue(response.status_code == 200) 40 | self.assertTrue( 41 | 'Test Notebook Title' in response.get_data(as_text=True)) 42 | 43 | def test_create_notebook_with_no_title(self): 44 | response = self.client.post( 45 | url_for('api.notebooks'), 46 | headers=self.headers, 47 | data=json.dumps({ 48 | "bad": "bad" 49 | })) 50 | 51 | json_response = json.loads(response.data.decode('utf-8')) 52 | 53 | self.assertTrue(response.status_code == 400) 54 | self.assertEqual( 55 | 'Missing Title of the Notebook', json_response['message']['title']) 56 | 57 | def test_get_single_notebook(self): 58 | # Will neeed to use headers twice 59 | local_headers = self.headers 60 | 61 | # Create new Notebook 62 | self.client.post( 63 | url_for('api.notebooks'), 64 | headers=local_headers, 65 | data=json.dumps({ 66 | "title": "Test Notebook Title" 67 | })) 68 | 69 | response = self.client.get( 70 | url_for('api.notebook', notebook_id=1), 71 | headers=local_headers) 72 | 73 | self.assertTrue(response.status_code == 200) 74 | self.assertTrue( 75 | 'Test Notebook Title' in response.get_data(as_text=True)) 76 | 77 | def test_get_single_notebook_not_owned(self): 78 | someone_else = self.add_other_user() 79 | 80 | other_nb = Notebook( 81 | title='Other Title', 82 | author_id=someone_else.id) 83 | 84 | db.session.add(other_nb) 85 | db.session.commit() 86 | 87 | response = self.client.get( 88 | url_for('api.notebook', notebook_id=other_nb.id), 89 | headers=self.headers) 90 | 91 | self.assertTrue(response.status_code == 404) 92 | 93 | def test_get_single_notebook_not_exist(self): 94 | response = self.client.get( 95 | url_for('api.notebook', notebook_id=1000), 96 | headers=self.headers) 97 | 98 | self.assertTrue(response.status_code == 404) 99 | 100 | def test_update_notebook(self): 101 | # Will neeed to use headers twice 102 | local_headers = self.headers 103 | 104 | # Create new Notebook 105 | self.client.post( 106 | url_for('api.notebooks'), 107 | headers=local_headers, 108 | data=json.dumps({ 109 | "title": "Test Notebook Title" 110 | })) 111 | 112 | # Update Notebook Title 113 | response = self.client.put( 114 | url_for('api.notebook', notebook_id=1), 115 | headers=local_headers, 116 | data=json.dumps({ 117 | "title": "Updated Test Notebook Title" 118 | })) 119 | 120 | self.assertTrue(response.status_code == 200) 121 | self.assertTrue( 122 | 'Updated Test Notebook Title' in response.get_data(as_text=True)) 123 | 124 | # Update Notebook Deleted Status 125 | response = self.client.put( 126 | url_for('api.notebook', notebook_id=1), 127 | headers=local_headers, 128 | data=json.dumps({ 129 | "is_deleted": True 130 | })) 131 | 132 | json_response = json.loads(response.data.decode('utf-8')) 133 | self.assertTrue(response.status_code == 200) 134 | self.assertTrue(json_response['notebook']['is_deleted']) 135 | 136 | # Undo Deletion 137 | response = self.client.put( 138 | url_for('api.notebook', notebook_id=1), 139 | headers=local_headers, 140 | data=json.dumps({ 141 | "is_deleted": False 142 | })) 143 | 144 | json_response = json.loads(response.data.decode('utf-8')) 145 | self.assertTrue(response.status_code == 200) 146 | self.assertFalse(json_response['notebook']['is_deleted']) 147 | 148 | def test_delete_notebook(self): 149 | # Will neeed to use headers twice 150 | local_headers = self.headers 151 | 152 | # Create new Notebook 153 | self.client.post( 154 | url_for('api.notebooks'), 155 | headers=local_headers, 156 | data=json.dumps({ 157 | "title": "Test Notebook Title" 158 | })) 159 | 160 | # Update Notebook Title 161 | response = self.client.delete( 162 | url_for('api.notebook', notebook_id=1), 163 | headers=local_headers) 164 | 165 | self.assertTrue(response.status_code == 200) 166 | -------------------------------------------------------------------------------- /tests/test_api_public.py: -------------------------------------------------------------------------------- 1 | import json 2 | from flask import url_for 3 | from api_base import ApiBaseTestCase 4 | 5 | 6 | class PublicApiTestCase(ApiBaseTestCase): 7 | 8 | def test_public_stats_empty(self): 9 | 10 | res = self.client.get('/api/v1/public/stats') 11 | 12 | json_res = json.loads(res.data.decode('utf-8')) 13 | 14 | self.assertEqual(0, json_res['users']) 15 | self.assertEqual(0, json_res['notes']) 16 | 17 | def test_public_stats_with_user(self): 18 | 19 | self.add_user() 20 | self.add_other_user() 21 | 22 | res = self.client.get('/api/v1/public/stats') 23 | 24 | json_res = json.loads(res.data.decode('utf-8')) 25 | 26 | self.assertEqual(2, json_res['users']) 27 | 28 | def test_public_states_with_notes(self): 29 | 30 | u = self.add_user() 31 | nb = self.add_notebook(u) 32 | note = self.add_note(nb, u) 33 | 34 | res = self.client.get('/api/v1/public/stats') 35 | 36 | json_res = json.loads(res.data.decode('utf-8')) 37 | 38 | self.assertEqual(1, json_res['users']) 39 | self.assertEqual(1, json_res['notes']) 40 | 41 | u1n2 = self.add_note(nb, u) 42 | u1n3 = self.add_note(nb, u) 43 | 44 | u2 = self.add_other_user() 45 | nb2 = self.add_notebook(u2) 46 | note = self.add_note(nb2, u2) 47 | 48 | res = self.client.get('/api/v1/public/stats') 49 | 50 | json_res = json.loads(res.data.decode('utf-8')) 51 | 52 | self.assertEqual(2, json_res['users']) 53 | self.assertEqual(4, json_res['notes']) -------------------------------------------------------------------------------- /tests/test_basics.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from flask import current_app 3 | from app import create_app, db 4 | 5 | 6 | class BasicTestcase(unittest.TestCase): 7 | def setUp(self): 8 | self.app = create_app('testing') 9 | self.app_context = self.app.app_context() 10 | self.app_context.push() 11 | db.create_all() 12 | 13 | def tearDown(self): 14 | db.session.remove() 15 | db.drop_all() 16 | self.app_context.pop() 17 | 18 | def test_app_exists(self): 19 | self.assertFalse(current_app is None) 20 | 21 | def test_app_is_testing(self): 22 | self.assertTrue(current_app.config['TESTING']) 23 | -------------------------------------------------------------------------------- /tests/test_export.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from app import db 4 | from app.models import Note, Notebook 5 | from app.lib.export import Exporter 6 | 7 | from api_base import ApiBaseTestCase 8 | 9 | 10 | class NoteExportTestCase(ApiBaseTestCase): 11 | 12 | def test_special_character(self): 13 | 14 | u = self.add_user() 15 | 16 | nb = Notebook(title="default", author=u) 17 | db.session.add(nb) 18 | db.session.commit() 19 | 20 | n = Note(title="5/14/3", body="test", notebook_id=nb.id, author=u) 21 | db.session.add(n) 22 | db.session.commit() 23 | 24 | e = Exporter(u) 25 | e.export() 26 | 27 | note_files = os.listdir("/tmp/braindump-export/{0}".format(u.id)) 28 | expected_note_file = "5-14-3.md" 29 | 30 | self.assertTrue(expected_note_file in note_files) 31 | 32 | def test_export_dir_created_when_not_exist(self): 33 | 34 | u = self.add_user() 35 | 36 | nb = Notebook(title="default", author=u) 37 | db.session.add(nb) 38 | db.session.commit() 39 | 40 | n = Note(title="5/14/3", body="test", notebook_id=nb.id, author=u) 41 | db.session.add(n) 42 | db.session.commit() 43 | 44 | # Create Two Exporter Instances 45 | e = Exporter(u) 46 | e2 = Exporter(u) 47 | 48 | e.export() 49 | e2.export() 50 | 51 | notebook_dir = os.listdir("/tmp/braindump-export") 52 | 53 | self.assertEqual(1, len(notebook_dir)) 54 | 55 | 56 | 57 | -------------------------------------------------------------------------------- /tests/test_model_user.py: -------------------------------------------------------------------------------- 1 | from model_base import ModelBase 2 | from app.models import User 3 | 4 | 5 | class UserModelTestCase(ModelBase): 6 | def test_password_setter(self): 7 | u = User(password='password') 8 | self.assertTrue(u.password_hash is not None) 9 | 10 | def test_no_password_getter(self): 11 | u = User(password='password') 12 | with self.assertRaises(AttributeError): 13 | u.password 14 | 15 | def test_password_verification(self): 16 | u = User(password='password') 17 | self.assertTrue(u.verify_password('password')) 18 | self.assertFalse(u.verify_password('password2')) 19 | 20 | def test_password_salts_are_random(self): 21 | u = User(password='password') 22 | u2 = User(password='password') 23 | self.assertTrue(u.password_hash != u2.password_hash) 24 | 25 | def test_user_confirmation(self): 26 | u = User(password='password') 27 | confirmation_token = u.generate_confirmation_token() 28 | u.confirm(confirmation_token) 29 | self.assertTrue(u.confirmed) 30 | 31 | def test_password_reset(self): 32 | u = User(password='password') 33 | old_password_hash = u.password_hash 34 | reset_token = u.generate_reset_token() 35 | u.reset_password(reset_token, 'new_password') 36 | self.assertTrue(u.password_hash != old_password_hash) 37 | -------------------------------------------------------------------------------- /tests/test_view_auth.py: -------------------------------------------------------------------------------- 1 | from client_base import ClientTestCase 2 | 3 | 4 | class AuthViewTestCase(ClientTestCase): 5 | 6 | def test_login_logout(self): 7 | self.add_user() 8 | response = self.login('test@example.com', 'password') 9 | self.assertTrue("Add a Note" in response.get_data(as_text=True)) 10 | 11 | response = self.logout() 12 | self.assertTrue("You have been logged out." in response.get_data(as_text=True)) 13 | 14 | def test_login_logged(self): 15 | u = self.add_user() 16 | u_last_login = u.last_login_date 17 | 18 | self.login(u.email, 'password') 19 | 20 | u_last_login_updated = u.last_login_date 21 | self.assertNotEqual(u_last_login, u_last_login_updated) --------------------------------------------------------------------------------