diff --git a/.circleci/config.yml b/.circleci/config.yml new file mode 100644 index 000000000..3b626a6e1 --- /dev/null +++ b/.circleci/config.yml @@ -0,0 +1,14 @@ +version: 2 +jobs: + build: + docker: + - image: circleci/python:3.5 + steps: + - checkout + + - run: + name: Install requirements and run tests + command: | + pipenv install --dev + export PYTHONPATH=$PYTHONPATH:. # so alembic can get to Base metadata + pipenv run pytest -v diff --git a/.gitignore b/.gitignore index 894a44cc0..dea2de221 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ +*.sqlite + # Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] diff --git a/Pipfile b/Pipfile new file mode 100644 index 000000000..808d54fac --- /dev/null +++ b/Pipfile @@ -0,0 +1,7 @@ +[packages] +SQLALchemy = "*" +alembic = "*" + +[dev-packages] +pytest = "*" +pip-tools = "*" diff --git a/Pipfile.lock b/Pipfile.lock new file mode 100644 index 000000000..1fcbcb4c1 --- /dev/null +++ b/Pipfile.lock @@ -0,0 +1,140 @@ +{ + "_meta": { + "hash": { + "sha256": "a4ca821548e61e12ff5e8e1d8877605cf294ac9ae024dbc79f69aff05563bae9" + }, + "pipfile-spec": 6, + "requires": {}, + "sources": [ + { + "name": "pypi", + "url": "https://pypi.python.org/simple", + "verify_ssl": true + } + ] + }, + "default": { + "alembic": { + "hashes": [ + "sha256:52d73b1d750f1414fa90c25a08da47b87de1e4ad883935718a8f36396e19e78e", + "sha256:eb7db9b4510562ec37c91d00b00d95fde076c1030d3f661aea882eec532b3565" + ], + "index": "pypi", + "version": "==1.0.0" + }, + "mako": { + "hashes": [ + "sha256:4e02fde57bd4abb5ec400181e4c314f56ac3e49ba4fb8b0d50bba18cb27d25ae" + ], + "version": "==1.0.7" + }, + "markupsafe": { + "hashes": [ + "sha256:a6be69091dac236ea9c6bc7d012beab42010fa914c459791d627dad4910eb665" + ], + "version": "==1.0" + }, + "python-dateutil": { + "hashes": [ + "sha256:1adb80e7a782c12e52ef9a8182bebeb73f1d7e24e374397af06fb4956c8dc5c0", + "sha256:e27001de32f627c22380a688bcc43ce83504a7bc5da472209b4c70f02829f0b8" + ], + "version": "==2.7.3" + }, + "python-editor": { + "hashes": [ + "sha256:a3c066acee22a1c94f63938341d4fb374e3fdd69366ed6603d7b24bed1efc565" + ], + "version": "==1.0.3" + }, + "six": { + "hashes": [ + "sha256:70e8a77beed4562e7f14fe23a786b54f6296e34344c23bc42f07b15018ff98e9", + "sha256:832dc0e10feb1aa2c68dcc57dbb658f1c7e65b9b61af69048abc87a2db00a0eb" + ], + "version": "==1.11.0" + }, + "sqlalchemy": { + "hashes": [ + "sha256:72325e67fb85f6e9ad304c603d83626d1df684fdf0c7ab1f0352e71feeab69d8" + ], + "index": "pypi", + "version": "==1.2.10" + } + }, + "develop": { + "atomicwrites": { + "hashes": [ + "sha256:240831ea22da9ab882b551b31d4225591e5e447a68c5e188db5b89ca1d487585", + "sha256:a24da68318b08ac9c9c45029f4a10371ab5b20e4226738e150e6e7c571630ae6" + ], + "version": "==1.1.5" + }, + "attrs": { + "hashes": [ + "sha256:4b90b09eeeb9b88c35bc642cbac057e45a5fd85367b985bd2809c62b7b939265", + "sha256:e0d0eb91441a3b53dab4d9b743eafc1ac44476296a2053b6ca3af0b139faf87b" + ], + "version": "==18.1.0" + }, + "click": { + "hashes": [ + "sha256:29f99fc6125fbc931b758dc053b3114e55c77a6e4c6c3a2674a2dc986016381d", + "sha256:f15516df478d5a56180fbf80e68f206010e6d160fc39fa508b65e035fd75130b" + ], + "version": "==6.7" + }, + "first": { + "hashes": [ + "sha256:3bb3de3582cb27071cfb514f00ed784dc444b7f96dc21e140de65fe00585c95e", + "sha256:41d5b64e70507d0c3ca742d68010a76060eea8a3d863e9b5130ab11a4a91aa0e" + ], + "version": "==2.0.1" + }, + "more-itertools": { + "hashes": [ + "sha256:c187a73da93e7a8acc0001572aebc7e3c69daf7bf6881a2cea10650bd4420092", + "sha256:c476b5d3a34e12d40130bc2f935028b5f636df8f372dc2c1c01dc19681b2039e", + "sha256:fcbfeaea0be121980e15bc97b3817b5202ca73d0eae185b4550cbfce2a3ebb3d" + ], + "version": "==4.3.0" + }, + "pip-tools": { + "hashes": [ + "sha256:90bbe6731a6a34d339bf14d90cf2892475386c7d06c458208191ac9992110e0a", + "sha256:f11fc3bf1d87a0b4a68d4d595f619814e2396e92d75d7bdd2500edbf002ea6de" + ], + "index": "pypi", + "version": "==2.0.2" + }, + "pluggy": { + "hashes": [ + "sha256:6e3836e39f4d36ae72840833db137f7b7d35105079aee6ec4a62d9f80d594dd1", + "sha256:95eb8364a4708392bae89035f45341871286a333f749c3141c20573d2b3876e1" + ], + "version": "==0.7.1" + }, + "py": { + "hashes": [ + "sha256:3fd59af7435864e1a243790d322d763925431213b6b8529c6ca71081ace3bbf7", + "sha256:e31fb2767eb657cbde86c454f02e99cb846d3cd9d61b318525140214fdc0e98e" + ], + "version": "==1.5.4" + }, + "pytest": { + "hashes": [ + "sha256:8214ab8446104a1d0c17fbd218ec6aac743236c6ffbe23abc038e40213c60b88", + "sha256:e2b2c6e1560b8f9dc8dd600b0923183fbd68ba3d9bdecde04467be6dd296a384" + ], + "index": "pypi", + "version": "==3.7.0" + }, + "six": { + "hashes": [ + "sha256:70e8a77beed4562e7f14fe23a786b54f6296e34344c23bc42f07b15018ff98e9", + "sha256:832dc0e10feb1aa2c68dcc57dbb658f1c7e65b9b61af69048abc87a2db00a0eb" + ], + "version": "==1.11.0" + } + } +} diff --git a/README.md b/README.md index aecae3e6d..a6a2d9aa0 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,26 @@ # securedrop-client -SecureDrop Qubes client +[![CircleCI](https://circleci.com/gh/freedomofpress/securedrop-client.svg?style=svg)](https://circleci.com/gh/freedomofpress/securedrop-client) + +Qt-based client for working with SecureDrop submissions on the SecureDrop Qubes Workstation + +## Getting Started + +Set up a Python 3 virtual environment and set up dependencies: + +``` +pipenv install --dev +pipenv shell +``` + +## Run tests + +``` +pytest -v +``` + +## Generate and run database migrations + +``` +alembic revision --autogenerate -m "describe your revision here" +alembic upgrade head +``` diff --git a/alembic.ini b/alembic.ini new file mode 100644 index 000000000..9b23b9256 --- /dev/null +++ b/alembic.ini @@ -0,0 +1,74 @@ +# A generic, single database configuration. + +[alembic] +# path to migration scripts +script_location = alembic + +# template used to generate migration files +# file_template = %%(rev)s_%%(slug)s + +# timezone to use when rendering the date +# within the migration file as well as the filename. +# string value is passed to dateutil.tz.gettz() +# leave blank for localtime +# timezone = + +# max length of characters to apply to the +# "slug" field +#truncate_slug_length = 40 + +# set to 'true' to run the environment during +# the 'revision' command, regardless of autogenerate +# revision_environment = false + +# set to 'true' to allow .pyc and .pyo files without +# a source .py file to be detected as revisions in the +# versions/ directory +# sourceless = false + +# version location specification; this defaults +# to alembic/versions. When using multiple version +# directories, initial revisions must be specified with --version-path +# version_locations = %(here)s/bar %(here)s/bat alembic/versions + +# the output encoding used when revision files +# are written from script.py.mako +# output_encoding = utf-8 + +sqlalchemy.url = sqlite:///svs.sqlite + + +# Logging configuration +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/alembic/README b/alembic/README new file mode 100644 index 000000000..98e4f9c44 --- /dev/null +++ b/alembic/README @@ -0,0 +1 @@ +Generic single-database configuration. \ No newline at end of file diff --git a/alembic/env.py b/alembic/env.py new file mode 100644 index 000000000..8ff4f6854 --- /dev/null +++ b/alembic/env.py @@ -0,0 +1,64 @@ +from __future__ import with_statement +from alembic import context +from sqlalchemy import engine_from_config, pool +from logging.config import fileConfig + +# this is the Alembic Config object, which provides +# access to the values within the .ini file in use. +config = context.config + +# Interpret the config file for Python logging. +# This line sets up loggers basically. +fileConfig(config.config_file_name) + +# add your model's MetaData object here +# for 'autogenerate' support +from securedrop_client.models import Base +target_metadata = Base.metadata + + +def run_migrations_offline(): + """Run migrations in 'offline' mode. + + This configures the context with just a URL + and not an Engine, though an Engine is acceptable + here as well. By skipping the Engine creation + we don't even need a DBAPI to be available. + + Calls to context.execute() here emit the given string to the + script output. + + """ + url = config.get_main_option("sqlalchemy.url") + context.configure( + url=url, target_metadata=target_metadata, literal_binds=True) + + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online(): + """Run migrations in 'online' mode. + + In this scenario we need to create an Engine + and associate a connection with the context. + + """ + connectable = engine_from_config( + config.get_section(config.config_ini_section), + prefix='sqlalchemy.', + poolclass=pool.NullPool) + + with connectable.connect() as connection: + context.configure( + connection=connection, + target_metadata=target_metadata + ) + + with context.begin_transaction(): + context.run_migrations() + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/alembic/script.py.mako b/alembic/script.py.mako new file mode 100644 index 000000000..2c0156303 --- /dev/null +++ b/alembic/script.py.mako @@ -0,0 +1,24 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision = ${repr(up_revision)} +down_revision = ${repr(down_revision)} +branch_labels = ${repr(branch_labels)} +depends_on = ${repr(depends_on)} + + +def upgrade(): + ${upgrades if upgrades else "pass"} + + +def downgrade(): + ${downgrades if downgrades else "pass"} diff --git a/alembic/versions/5344fc3a3d3f_add_user_table.py b/alembic/versions/5344fc3a3d3f_add_user_table.py new file mode 100644 index 000000000..64531e319 --- /dev/null +++ b/alembic/versions/5344fc3a3d3f_add_user_table.py @@ -0,0 +1,33 @@ +"""add user table + +Revision ID: 5344fc3a3d3f +Revises: +Create Date: 2018-07-18 18:11:06.781732 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '5344fc3a3d3f' +down_revision = None +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('users', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('username', sa.String(length=255), nullable=False), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('username') + ) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('users') + # ### end Alembic commands ### diff --git a/securedrop_client/__init__.py b/securedrop_client/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/securedrop_client/models.py b/securedrop_client/models.py new file mode 100644 index 000000000..2dbfc6d9f --- /dev/null +++ b/securedrop_client/models.py @@ -0,0 +1,29 @@ +import os +import sqlite3 +import subprocess + +from sqlalchemy import Column, create_engine, Integer, String +from sqlalchemy.ext.declarative import declarative_base + + +# TODO: Store this in config file, see issue #2 +DB_PATH = os.path.abspath('svs.sqlite') + +if not os.path.exists(DB_PATH): + sqlite3.connect(DB_PATH) + subprocess.check_output('alembic upgrade head'.split()) + +engine = create_engine('sqlite:///{}'.format(DB_PATH)) +Base = declarative_base() + + +class User(Base): + __tablename__ = 'users' + id = Column(Integer, primary_key=True) + username = Column(String(255), nullable=False, unique=True) + + def __init__(self, username): + self.username = username + + def __repr__(self): + return "