diff --git a/.gitignore b/.gitignore index 72364f9..9463cc2 100644 --- a/.gitignore +++ b/.gitignore @@ -23,6 +23,7 @@ var/ *.egg-info/ .installed.cfg *.egg +*.sqlite # PyInstaller # Usually these files are written by a python script from a template diff --git a/CHANGES.txt b/CHANGES.txt new file mode 100644 index 0000000..35a34f3 --- /dev/null +++ b/CHANGES.txt @@ -0,0 +1,4 @@ +0.0 +--- + +- Initial version diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 0000000..7224bcf --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1,2 @@ +include *.txt *.ini *.cfg *.rst +recursive-include pylistener *.ico *.png *.css *.gif *.jpg *.jinja2 *.pt *.txt *.mak *.mako *.js *.html *.xml diff --git a/Procfile b/Procfile new file mode 100644 index 0000000..e645050 --- /dev/null +++ b/Procfile @@ -0,0 +1 @@ +web: ./run diff --git a/README.md b/README.md index 3848fee..9b864a4 100644 --- a/README.md +++ b/README.md @@ -1 +1,88 @@ -# CF401-Project-1---PyListener \ No newline at end of file +# PyListener + +PyListener is an application written on the Python Pyramid framework, designed to assist people with apraxia communicate with loved-ones and caretakers using a series of pictograms translated to text and shareable via email or sms. + +The purpose of this project was to create a visual communication tool in the form of a web-based app intended for use on mobile platforms to assist users with communication disabilities. +Instances include users with a communication disability such as Apraxia, a motor disorder in which the patient is unable to perform certain tasks or movements despite full understanding and willingness. In [Apraxia of speech](https://en.wikipedia.org/wiki/Apraxia_of_speech), patients have difficulty conveying ideas and thoughts from the brain to the spoken words. Apraxia of speech can manifest itself after incidents such as a stroke or trauma or be present in early infancy (Childhood Apraxia of Speech). + + +# How it works + +A caregiver can register with a username, password, email, phone number and a profile picture. The caregiver is also prompted to enter the user's name. +Once registered, the caregiver becomes the primary contact for the user and a pictogram is created in the address book. + +The account is immediately logged in and taken to the configuration page. +There, the caregiver can add or delete pictograms for contacts, categories and/or attributes. Fill the form with an image file, label, description (which will be used to build the sentence) and upload your customized pictogram. +Once done, simply click on the home button and you are ready to go! + +The app comes with pre-loaded pictograms so any user can create an account and start building sentences right away. + +The first screen for the user is the Address Book. There, the user can pick whom they wish to talk to. Once selected, it takes you to the second screen: Categories. There, the user can select the subject of the sentence. Which takes you to the third screen: Attributes. Attributes are always related to the category selected. + +Once the sentence fully built, the user is taken to the fourth and final screen: Display. +The sentence will be, indeed, displayed and the user is offered three choices: +Send an SMS, send an Email or start over. + +If the user picks SMS or Email, a message will immediately be sent to the contact selected at the begining of the sentence. If the user chooses to start over, they will be taken back to the first screen and can pick a new contact to talk to. + +# How we built it + +PyListener was created during our Code Fellows advanced python development midterm project by Ted Callahan, Maelle Vance and Rick Valenzuela. We had four days total to build the full application. + +We chose the Pyramid framework for its robustness. We are currently looking to rebuild the application using Django as we have plan to expand. + +We used Postgresql and sqlalchemy to build our database. The main challenge was to build all the relational databases as well as handling image uploads by users. + +To send sms and emails, we used two external apis: [Twilio](https://github.com/twilio/twilio-python) and [Yagmail](https://github.com/kootenpv/yagmail). We are very thankful for their extensive documentation! + +We deployed on Heroku: (http://pylistener.herokuapp.com/) + +# How to run it yourself + +Clone this repository into whatever directory you want to work from. + +```bash +$ git clone https://github.com/PyListener/CF401-Project-1---PyListener.git +``` + +Assuming that you have access to Python 3 at the system level, start up a new virtual environment. + +```bash +$ cd CF401-Project-1---PyListener +$ python3 -m venv venv +``` + +Open `venv/bin/activate` in your editor and pass your own environment variable as following : +- export DATABASE_URL= *your chosen database* +- export TEST_DB= *your test database* +- export EMAIL= *the gmail account you want to user to send emails from the app.* +- export PASSWORD= *your gmail password* +- export TWILIO_SID= *the twilio account sid you want to use to send emails from the app.* +- export TWILIO_TOKEN= *your twilio token* +- export TWILIO_NUMBER= *your twilio number in the following format: "+12345678910"* + +Then activate your environment: + +```bash +$ source venv/bin/activate +``` + +Once your environment has been activated, make sure to install the requirements: + +```bash +(venv) $ pip install -e . +``` + +You are then ready to initialize your database and run the app! + +```bash +$ initialize_db development.ini +$ pserve development.ini +``` + +# Special Thanks: + +[Nicholas Hunt-Walker](https://github.com/nhuntwalker/) +[Twilio](https://github.com/twilio/twilio-python) +[Yagmail](https://github.com/kootenpv/yagmail) +Charlie, Maelle's son, who suffers from Apraxia diff --git a/development.ini b/development.ini new file mode 100644 index 0000000..ca1719b --- /dev/null +++ b/development.ini @@ -0,0 +1,71 @@ +### +# app configuration +# http://docs.pylonsproject.org/projects/pyramid/en/1.7-branch/narr/environment.html +### + +[app:main] +use = egg:pylistener + +pyramid.reload_templates = true +pyramid.debug_authorization = false +pyramid.debug_notfound = false +pyramid.debug_routematch = false +pyramid.default_locale_name = en +pyramid.includes = + pyramid_debugtoolbar + +; sqlalchemy.url = os.environ["DATABASE_URL"] + + +# By default, the toolbar only appears for clients from IP addresses +# '127.0.0.1' and '::1'. +# debugtoolbar.hosts = 127.0.0.1 ::1 + +### +# wsgi server configuration +### + +[server:main] +use = egg:waitress#main +host = 127.0.0.1 +port = 6543 + +### +# logging configuration +# http://docs.pylonsproject.org/projects/pyramid/en/1.7-branch/narr/logging.html +### + +[loggers] +keys = root, pylistener, sqlalchemy + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = INFO +handlers = console + +[logger_pylistener] +level = DEBUG +handlers = +qualname = pylistener + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine +# "level = INFO" logs SQL queries. +# "level = DEBUG" logs SQL queries and results. +# "level = WARN" logs neither. (Recommended for production systems.) + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(asctime)s %(levelname)-5.5s [%(name)s:%(lineno)s][%(threadName)s] %(message)s diff --git a/production.ini b/production.ini new file mode 100644 index 0000000..68ae71d --- /dev/null +++ b/production.ini @@ -0,0 +1,68 @@ +### +# app configuration +# http://docs.pylonsproject.org/projects/pyramid/en/1.7-branch/narr/environment.html +### + +[app:pylistener] +use = egg:pylistener + +pyramid.reload_templates = false +pyramid.debug_authorization = false +pyramid.debug_notfound = false +pyramid.debug_routematch = false +pyramid.default_locale_name = en + +sqlalchemy.url = sqlite:///%(here)s/pylistener.sqlite + +[filter:paste_prefix] +use = egg:PasteDeploy#prefix + +[pipeline:main] +pipeline = + paste_prefix + pylistener + +[server:main] +use = egg:waitress#main +host = 0.0.0.0 +port = 6543 + +### +# logging configuration +# http://docs.pylonsproject.org/projects/pyramid/en/1.7-branch/narr/logging.html +### + +[loggers] +keys = root, pylistener, sqlalchemy + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console + +[logger_pylistener] +level = WARN +handlers = +qualname = pylistener + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine +# "level = INFO" logs SQL queries. +# "level = DEBUG" logs SQL queries and results. +# "level = WARN" logs neither. (Recommended for production systems.) + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(asctime)s %(levelname)-5.5s [%(name)s:%(lineno)s][%(threadName)s] %(message)s diff --git a/pylistener/__init__.py b/pylistener/__init__.py new file mode 100644 index 0000000..505fa45 --- /dev/null +++ b/pylistener/__init__.py @@ -0,0 +1,15 @@ +from pyramid.config import Configurator +import os + +def main(global_config, **settings): + """ This function returns a Pyramid WSGI application. + """ + if "sqlalchemy.url" not in settings: + settings["sqlalchemy.url"] = os.environ["DATABASE_URL"] + config = Configurator(settings=settings) + config.include('pyramid_jinja2') + config.include('.models') + config.include('.routes') + config.include('.security') + config.scan() + return config.make_wsgi_app() diff --git a/pylistener/models/__init__.py b/pylistener/models/__init__.py new file mode 100644 index 0000000..0ef5bf3 --- /dev/null +++ b/pylistener/models/__init__.py @@ -0,0 +1,73 @@ +from sqlalchemy import engine_from_config +from sqlalchemy.orm import sessionmaker +from sqlalchemy.orm import configure_mappers +import zope.sqlalchemy + +# import or define all models here to ensure they are attached to the +# Base.metadata prior to any initialization routines +from .mymodel import User, AddressBook, Attribute, Category, UserAttributeLink # noqa + +# run configure_mappers after defining all of the models to ensure +# all relationships can be setup +configure_mappers() + + +def get_engine(settings, prefix='sqlalchemy.'): + return engine_from_config(settings, prefix) + + +def get_session_factory(engine): + factory = sessionmaker() + factory.configure(bind=engine) + return factory + + +def get_tm_session(session_factory, transaction_manager): + """ + Get a ``sqlalchemy.orm.Session`` instance backed by a transaction. + + This function will hook the session to the transaction manager which + will take care of committing any changes. + + - When using pyramid_tm it will automatically be committed or aborted + depending on whether an exception is raised. + + - When using scripts you should wrap the session in a manager yourself. + For example:: + + import transaction + + engine = get_engine(settings) + session_factory = get_session_factory(engine) + with transaction.manager: + dbsession = get_tm_session(session_factory, transaction.manager) + + """ + dbsession = session_factory() + zope.sqlalchemy.register( + dbsession, transaction_manager=transaction_manager) + return dbsession + + +def includeme(config): + """ + Initialize the model for a Pyramid app. + + Activate this setup using ``config.include('pylistener.models')``. + + """ + settings = config.get_settings() + + # use pyramid_tm to hook the transaction lifecycle to the request + config.include('pyramid_tm') + + session_factory = get_session_factory(get_engine(settings)) + config.registry['dbsession_factory'] = session_factory + + # make request.dbsession available for use in Pyramid + config.add_request_method( + # r.tm is the transaction manager used by pyramid_tm + lambda r: get_tm_session(session_factory, r.tm), + 'dbsession', + reify=True + ) diff --git a/pylistener/models/meta.py b/pylistener/models/meta.py new file mode 100644 index 0000000..0682247 --- /dev/null +++ b/pylistener/models/meta.py @@ -0,0 +1,16 @@ +from sqlalchemy.ext.declarative import declarative_base +from sqlalchemy.schema import MetaData + +# Recommended naming convention used by Alembic, as various different database +# providers will autogenerate vastly different names making migrations more +# difficult. See: http://alembic.zzzcomputing.com/en/latest/naming.html +NAMING_CONVENTION = { + "ix": 'ix_%(column_0_label)s', + "uq": "uq_%(table_name)s_%(column_0_name)s", + "ck": "ck_%(table_name)s_%(constraint_name)s", + "fk": "fk_%(table_name)s_%(column_0_name)s_%(referred_table_name)s", + "pk": "pk_%(table_name)s" +} + +metadata = MetaData(naming_convention=NAMING_CONVENTION) +Base = declarative_base(metadata=metadata) diff --git a/pylistener/models/mymodel.py b/pylistener/models/mymodel.py new file mode 100644 index 0000000..a64d2c5 --- /dev/null +++ b/pylistener/models/mymodel.py @@ -0,0 +1,66 @@ +from sqlalchemy import ( + Column, + Index, + Integer, + Text, + LargeBinary, + Unicode, + ForeignKey, + Table +) +from sqlalchemy.orm import relationship + +from .meta import Base + + +class User(Base): + """This class defines a User model.""" + __tablename__ = 'users' + id = Column(Integer, primary_key=True) + username = Column(Unicode, unique=True) + password = Column(Unicode) + sub_user = Column(Unicode) + address_rel = relationship('AddressBook') + attr_assoc_rel = relationship('UserAttributeLink') + + +class AddressBook(Base): + __tablename__ = 'addresses' + id = Column(Integer, primary_key=True) + name = Column(Unicode) + phone = Column(Unicode) + email = Column(Unicode) + picture = Column(LargeBinary) + pic_mime = Column(Text) + user = Column(Integer, ForeignKey('users.id')) + + +class Category(Base): + __tablename__ = 'categories' + id = Column(Integer, primary_key=True) + label = Column(Unicode) + desc = Column(Unicode) + picture = Column(LargeBinary) + pic_mime = Column(Text) + children = relationship('Attribute') + + +class Attribute(Base): + __tablename__ = 'attributes' + id = Column(Integer, primary_key=True) + label = Column(Unicode) + desc = Column(Unicode) + picture = Column(LargeBinary) + pic_mime = Column(Text) + cat_id = Column(Integer, ForeignKey('categories.id')) + user_assoc_rel = relationship('UserAttributeLink') + + +class UserAttributeLink(Base): + __tablename__ = "users_attributes_link" + user_id = Column(Integer, ForeignKey('users.id'), nullable=False, primary_key=True) + attr_id = Column(Integer, ForeignKey('attributes.id'), nullable=False, primary_key=True) + priority = Column(Integer, default=1, nullable=False) + num_hits = Column(Integer, default=0, nullable=False) + user_rel = relationship("User") + attr_rel = relationship("Attribute") diff --git a/pylistener/placeholder.jpg b/pylistener/placeholder.jpg new file mode 100644 index 0000000..eed03ac Binary files /dev/null and b/pylistener/placeholder.jpg differ diff --git a/pylistener/routes.py b/pylistener/routes.py new file mode 100644 index 0000000..aa201c2 --- /dev/null +++ b/pylistener/routes.py @@ -0,0 +1,13 @@ +def includeme(config): + config.add_static_view('static', 'pylistener:static', cache_max_age=3600) + config.add_route('home', '/') + config.add_route('login', '/login') + config.add_route('logout', '/logout') + config.add_route('manage', '/manage/{id}') + config.add_route('register', '/register') + config.add_route('category', '/category/{add_id:\d+}') + config.add_route('attribute', '/attribute/{add_id:\d+}/{cat_id:\d+}') + config.add_route('display', '/display/{add_id:\d+}/{cat_id:\d+}/{att_id:\d+}') + config.add_route('test_img', 'test/{id:\d+}') + config.add_route('picture', '/pic/{db_id:\w+}/{pic_id:\d+}') + config.add_route('delete', '/delete/{id:\d+}') diff --git a/pylistener/scripts/__init__.py b/pylistener/scripts/__init__.py new file mode 100644 index 0000000..5bb534f --- /dev/null +++ b/pylistener/scripts/__init__.py @@ -0,0 +1 @@ +# package diff --git a/pylistener/scripts/contacts.json b/pylistener/scripts/contacts.json new file mode 100644 index 0000000..c8b5699 --- /dev/null +++ b/pylistener/scripts/contacts.json @@ -0,0 +1,44 @@ +[ + { + "name": "Joe Schmoe", + "phone": "(555) 555-5555", + "email": "oops@whoops.com", + "picture": "img_address/joe.jpg", + "pic_mime": "image/jpeg" + }, + { + "name": "Danny Devito", + "phone": "(555) 555-5556", + "email": "short@loud.com", + "picture": "img_address/danny_devito.jpg", + "pic_mime": "image/jpeg" + }, + { + "name": "Mary Poppins", + "phone": "+44 303 123 7300", + "email": "antiquated@adventure.com", + "picture": "img_address/mary.jpg", + "pic_mime": "image/jpeg" + }, + { + "name": "Zer0 Cool", + "phone": "(360) 550-3355", + "email": "hack@planet.com", + "picture": "img_address/zero_cool.jpg", + "pic_mime": "image/jpeg" + }, + { + "name": "The Dude", + "phone": "(555) 555-5555", + "email": "the@dude.abides", + "picture": "img_address/the_dude.jpg", + "pic_mime": "image/jpeg" + }, + { + "name": "Tig Notaro", + "phone": "(555) 123-4567", + "email": "still@alive.com", + "picture": "img_address/tig.jpg", + "pic_mime": "image/jpeg" + } +] \ No newline at end of file diff --git a/pylistener/scripts/data.json b/pylistener/scripts/data.json new file mode 100644 index 0000000..ea98dba --- /dev/null +++ b/pylistener/scripts/data.json @@ -0,0 +1,219 @@ +[{ + "label": "wants", + "desc": "I would like", + "picture": "img_wants/want.jpg", + "pic_mime": "image/jpeg", + "attributes": [{ + "label": "play", + "desc": "to play.", + "picture": "img_wants/play.png", + "pic_mime": "image/png" + }, { + "label": "toy", + "desc": "my toy.", + "picture": "img_wants/toys.png", + "pic_mime": "image/png" + }, { + "label": "leave", + "desc": "to leave.", + "picture": "img_wants/leave.png", + "pic_mime": "image/png" + }, { + "label": "game", + "desc": "to play a game.", + "picture": "img_wants/TV.png", + "pic_mime": "image/png" + }, { + "label": "movie", + "desc": "to watch a movie.", + "picture": "img_wants/movie_theater.png", + "pic_mime": "image/png" + }, { + "label": "book", + "desc": "to read a book.", + "picture": "img_wants/book.png", + "pic_mime": "image/png" + }, { + "label": "that", + "desc": "that thing.", + "picture": "img_wants/that_finger.jpg", + "pic_mime": "image/jpeg" + }, { + "label": "wine", + "desc": "some wine.", + "picture": "img_wants/wine.jpg", + "pic_mime": "image/jpeg" + }, { + "label": "music", + "desc": "to listen to music.", + "picture": "img_wants/music.jpg", + "pic_mime": "image/jpeg" + }, { + "label": "clothes", + "desc": "a change of clothes.", + "picture": "img_wants/clothes.png", + "pic_mime": "image/png" + }] +}, { + "label": "needs", + "desc": "I need", + "picture": "img_needs/need.jpg", + "pic_mime": "image/jpeg", + "attributes": [{ + "label": "food", + "desc": "something to eat.", + "picture": "img_needs/food.png", + "pic_mime": "image/png" + }, { + "label": "medicine", + "desc": "my medicine.", + "picture": "img_needs/medicine.jpg", + "pic_mime": "image/jpeg" + }, { + "label": "restroom", + "desc": "to use the restroom", + "picture": "img_needs/bathroom.png", + "pic_mime": "image/png" + }, { + "label": "sleep", + "desc": "to sleep", + "picture": "img_needs/bed.png", + "pic_mime": "image/png" + }, { + "label": "help", + "desc": "help.", + "picture": "img_needs/doctor.jpg", + "pic_mime": "image/jpeg" + }, { + "label": "shower", + "desc": "to bathe.", + "picture": "img_needs/shower.png", + "pic_mime": "image/png" + }] +}, { + "label": "emotions", + "desc": "I feel", + "picture": "img_emotions/emotions.jpg", + "pic_mime": "image/jpeg", + "attributes": [{ + "label": "happy.", + "desc": "happy.", + "picture": "img_emotions/happy.png", + "pic_mime": "image/png" + }, { + "label": "sad", + "desc": "sad.", + "picture": "img_emotions/sad.png", + "pic_mime": "image/png" + }, { + "label": "lonely", + "desc": "lonely.", + "picture": "img_emotions/bored_lonely.jpg", + "pic_mime": "image/jpeg" + }, { + "label": "mad", + "desc": "mad.", + "picture": "img_emotions/mad.png", + "pic_mime": "image/png" + }, { + "label": "tired", + "desc": "tired.", + "picture": "img_emotions/tired.png", + "pic_mime": "image/png" + }, { + "label": "scared", + "desc": "scared.", + "picture": "img_emotions/scared.png", + "pic_mime": "image/png" + }, { + "label": "confused", + "desc": "confused.", + "picture": "img_emotions/confused.png", + "pic_mime": "image/png" + }, { + "label": "surprised", + "desc": "surprised", + "picture": "img_emotions/surprised.png", + "pic_mime": "image/png" + }, { + "label": "sick", + "desc": "sick.", + "picture": "img_emotions/sick.png", + "pic_mime": "image/png" + }, { + "label": "worried", + "desc": "worried.", + "picture": "img_emotions/worried.png", + "pic_mime": "image/png" + } + +] + +}, { + "label": "tell", + "desc": "", + "picture": "img_tell/tell.jpg", + "pic_mime": "image/jpeg", + "attributes": [{ + "label": "thank you", + "desc": "thank you.", + "picture": "img_tell/thank_you.jpg", + "pic_mime": "image/jpeg" + }, { + "label": "love", + "desc": "I love you.", + "picture": "img_tell/love_heart.jpg", + "pic_mime": "image/jpeg" + }, { + "label": "stop", + "desc": "please stop.", + "picture": "img_emotions/no_forbidden_circle_slash.png", + "pic_mime": "image/png" + }, { + "label": "yes", + "desc": "yes.", + "picture": "img_tell/yes_head_nod.jpg", + "pic_mime": "image/jpeg" + }, { + "label": "no", + "desc": "no.", + "picture": "img_tell/no_head_shake.jpg", + "pic_mime": "image/jpeg" + }, { + "label": "don't know", + "desc": "I don't know.", + "picture": "img_emotions/confused.png", + "pic_mime": "image/png" + }] +}, { + "label": "questions", + "desc": "Can you tell me,", + "picture": "img_questions/question.jpg", + "pic_mime": "image/jpeg", + "attributes": [{ + "label": "why", + "desc": "why?", + "picture": "img_questions/why.jpg", + "pic_mime": "image/jpeg" + }, { + "label": "how", + "desc": "how?", + "picture": "img_questions/how.jpg", + "pic_mime": "image/jpeg" + }, { + "label": "when", + "desc": "when?", + "picture": "img_questions/when.jpg", + "pic_mime": "image/jpeg" + }, { + "label": "who", + "desc": "who?", + "picture": "img_questions/who.jpg", + "pic_mime": "image/jpeg" + }, { + "label": "what", + "desc": "WAT?", + "picture": "img_questions/what.jpg", + "pic_mime": "image/jpeg" + }] +}] \ No newline at end of file diff --git a/pylistener/scripts/img_address/danny_devito.jpg b/pylistener/scripts/img_address/danny_devito.jpg new file mode 100644 index 0000000..6031466 Binary files /dev/null and b/pylistener/scripts/img_address/danny_devito.jpg differ diff --git a/pylistener/scripts/img_address/joe.jpg b/pylistener/scripts/img_address/joe.jpg new file mode 100644 index 0000000..0a33e03 Binary files /dev/null and b/pylistener/scripts/img_address/joe.jpg differ diff --git a/pylistener/scripts/img_address/mary.jpg b/pylistener/scripts/img_address/mary.jpg new file mode 100644 index 0000000..8679bba Binary files /dev/null and b/pylistener/scripts/img_address/mary.jpg differ diff --git a/pylistener/scripts/img_address/the_dude.jpg b/pylistener/scripts/img_address/the_dude.jpg new file mode 100644 index 0000000..150cc77 Binary files /dev/null and b/pylistener/scripts/img_address/the_dude.jpg differ diff --git a/pylistener/scripts/img_address/tig.jpg b/pylistener/scripts/img_address/tig.jpg new file mode 100644 index 0000000..aaf64b7 Binary files /dev/null and b/pylistener/scripts/img_address/tig.jpg differ diff --git a/pylistener/scripts/img_address/zero_cool.jpg b/pylistener/scripts/img_address/zero_cool.jpg new file mode 100644 index 0000000..8e21701 Binary files /dev/null and b/pylistener/scripts/img_address/zero_cool.jpg differ diff --git a/pylistener/scripts/img_emotions/.DS_Store b/pylistener/scripts/img_emotions/.DS_Store new file mode 100644 index 0000000..5008ddf Binary files /dev/null and b/pylistener/scripts/img_emotions/.DS_Store differ diff --git a/pylistener/scripts/img_emotions/bored_lonely.jpg b/pylistener/scripts/img_emotions/bored_lonely.jpg new file mode 100644 index 0000000..c14bd77 Binary files /dev/null and b/pylistener/scripts/img_emotions/bored_lonely.jpg differ diff --git a/pylistener/scripts/img_emotions/confused.png b/pylistener/scripts/img_emotions/confused.png new file mode 100644 index 0000000..1ad9416 Binary files /dev/null and b/pylistener/scripts/img_emotions/confused.png differ diff --git a/pylistener/scripts/img_emotions/emotions.jpg b/pylistener/scripts/img_emotions/emotions.jpg new file mode 100644 index 0000000..1767577 Binary files /dev/null and b/pylistener/scripts/img_emotions/emotions.jpg differ diff --git a/pylistener/scripts/img_emotions/happy.png b/pylistener/scripts/img_emotions/happy.png new file mode 100644 index 0000000..6887d6b Binary files /dev/null and b/pylistener/scripts/img_emotions/happy.png differ diff --git a/pylistener/scripts/img_emotions/mad.png b/pylistener/scripts/img_emotions/mad.png new file mode 100644 index 0000000..99adeca Binary files /dev/null and b/pylistener/scripts/img_emotions/mad.png differ diff --git a/pylistener/scripts/img_emotions/no_forbidden_circle_slash.png b/pylistener/scripts/img_emotions/no_forbidden_circle_slash.png new file mode 100644 index 0000000..d0d37ac Binary files /dev/null and b/pylistener/scripts/img_emotions/no_forbidden_circle_slash.png differ diff --git a/pylistener/scripts/img_emotions/sad.png b/pylistener/scripts/img_emotions/sad.png new file mode 100644 index 0000000..38764cd Binary files /dev/null and b/pylistener/scripts/img_emotions/sad.png differ diff --git a/pylistener/scripts/img_emotions/scared.png b/pylistener/scripts/img_emotions/scared.png new file mode 100644 index 0000000..7f05309 Binary files /dev/null and b/pylistener/scripts/img_emotions/scared.png differ diff --git a/pylistener/scripts/img_emotions/sick.png b/pylistener/scripts/img_emotions/sick.png new file mode 100644 index 0000000..46ead34 Binary files /dev/null and b/pylistener/scripts/img_emotions/sick.png differ diff --git a/pylistener/scripts/img_emotions/surprised.png b/pylistener/scripts/img_emotions/surprised.png new file mode 100644 index 0000000..6394d41 Binary files /dev/null and b/pylistener/scripts/img_emotions/surprised.png differ diff --git a/pylistener/scripts/img_emotions/tired.png b/pylistener/scripts/img_emotions/tired.png new file mode 100644 index 0000000..4ccf3af Binary files /dev/null and b/pylistener/scripts/img_emotions/tired.png differ diff --git a/pylistener/scripts/img_emotions/worried.png b/pylistener/scripts/img_emotions/worried.png new file mode 100644 index 0000000..3e03752 Binary files /dev/null and b/pylistener/scripts/img_emotions/worried.png differ diff --git a/pylistener/scripts/img_needs/.DS_Store b/pylistener/scripts/img_needs/.DS_Store new file mode 100644 index 0000000..5008ddf Binary files /dev/null and b/pylistener/scripts/img_needs/.DS_Store differ diff --git a/pylistener/scripts/img_needs/bathroom.png b/pylistener/scripts/img_needs/bathroom.png new file mode 100644 index 0000000..bf084c5 Binary files /dev/null and b/pylistener/scripts/img_needs/bathroom.png differ diff --git a/pylistener/scripts/img_needs/bed.png b/pylistener/scripts/img_needs/bed.png new file mode 100644 index 0000000..f15893c Binary files /dev/null and b/pylistener/scripts/img_needs/bed.png differ diff --git a/pylistener/scripts/img_needs/doctor.jpg b/pylistener/scripts/img_needs/doctor.jpg new file mode 100644 index 0000000..7f03e85 Binary files /dev/null and b/pylistener/scripts/img_needs/doctor.jpg differ diff --git a/pylistener/scripts/img_needs/food.png b/pylistener/scripts/img_needs/food.png new file mode 100644 index 0000000..7fbe9c4 Binary files /dev/null and b/pylistener/scripts/img_needs/food.png differ diff --git a/pylistener/scripts/img_needs/medicine.jpg b/pylistener/scripts/img_needs/medicine.jpg new file mode 100644 index 0000000..a2bfb8a Binary files /dev/null and b/pylistener/scripts/img_needs/medicine.jpg differ diff --git a/pylistener/scripts/img_needs/need.jpg b/pylistener/scripts/img_needs/need.jpg new file mode 100644 index 0000000..0ada63b Binary files /dev/null and b/pylistener/scripts/img_needs/need.jpg differ diff --git a/pylistener/scripts/img_needs/shower.png b/pylistener/scripts/img_needs/shower.png new file mode 100644 index 0000000..3d361df Binary files /dev/null and b/pylistener/scripts/img_needs/shower.png differ diff --git a/pylistener/scripts/img_questions/how.jpg b/pylistener/scripts/img_questions/how.jpg new file mode 100644 index 0000000..8d2f6e1 Binary files /dev/null and b/pylistener/scripts/img_questions/how.jpg differ diff --git a/pylistener/scripts/img_questions/question.jpg b/pylistener/scripts/img_questions/question.jpg new file mode 100644 index 0000000..9352aa7 Binary files /dev/null and b/pylistener/scripts/img_questions/question.jpg differ diff --git a/pylistener/scripts/img_questions/what.jpg b/pylistener/scripts/img_questions/what.jpg new file mode 100644 index 0000000..4c55a49 Binary files /dev/null and b/pylistener/scripts/img_questions/what.jpg differ diff --git a/pylistener/scripts/img_questions/when.jpg b/pylistener/scripts/img_questions/when.jpg new file mode 100644 index 0000000..3d6e74c Binary files /dev/null and b/pylistener/scripts/img_questions/when.jpg differ diff --git a/pylistener/scripts/img_questions/where.jpg b/pylistener/scripts/img_questions/where.jpg new file mode 100644 index 0000000..22cfdae Binary files /dev/null and b/pylistener/scripts/img_questions/where.jpg differ diff --git a/pylistener/scripts/img_questions/who.jpg b/pylistener/scripts/img_questions/who.jpg new file mode 100644 index 0000000..5a263a8 Binary files /dev/null and b/pylistener/scripts/img_questions/who.jpg differ diff --git a/pylistener/scripts/img_questions/why.jpg b/pylistener/scripts/img_questions/why.jpg new file mode 100644 index 0000000..c1273c5 Binary files /dev/null and b/pylistener/scripts/img_questions/why.jpg differ diff --git a/pylistener/scripts/img_tell/love_heart.jpg b/pylistener/scripts/img_tell/love_heart.jpg new file mode 100644 index 0000000..ba5664c Binary files /dev/null and b/pylistener/scripts/img_tell/love_heart.jpg differ diff --git a/pylistener/scripts/img_tell/no_forbidden_circle_slash.jpg b/pylistener/scripts/img_tell/no_forbidden_circle_slash.jpg new file mode 100644 index 0000000..8ef4d67 Binary files /dev/null and b/pylistener/scripts/img_tell/no_forbidden_circle_slash.jpg differ diff --git a/pylistener/scripts/img_tell/no_head_shake.jpg b/pylistener/scripts/img_tell/no_head_shake.jpg new file mode 100644 index 0000000..402fdec Binary files /dev/null and b/pylistener/scripts/img_tell/no_head_shake.jpg differ diff --git a/pylistener/scripts/img_tell/tell.jpg b/pylistener/scripts/img_tell/tell.jpg new file mode 100644 index 0000000..ae60bcb Binary files /dev/null and b/pylistener/scripts/img_tell/tell.jpg differ diff --git a/pylistener/scripts/img_tell/thank_you.jpg b/pylistener/scripts/img_tell/thank_you.jpg new file mode 100644 index 0000000..548cdb4 Binary files /dev/null and b/pylistener/scripts/img_tell/thank_you.jpg differ diff --git a/pylistener/scripts/img_tell/yes_head_nod.jpg b/pylistener/scripts/img_tell/yes_head_nod.jpg new file mode 100644 index 0000000..a75baad Binary files /dev/null and b/pylistener/scripts/img_tell/yes_head_nod.jpg differ diff --git a/pylistener/scripts/img_wants/.DS_Store b/pylistener/scripts/img_wants/.DS_Store new file mode 100644 index 0000000..5008ddf Binary files /dev/null and b/pylistener/scripts/img_wants/.DS_Store differ diff --git a/pylistener/scripts/img_wants/TV.png b/pylistener/scripts/img_wants/TV.png new file mode 100644 index 0000000..fa50e8f Binary files /dev/null and b/pylistener/scripts/img_wants/TV.png differ diff --git a/pylistener/scripts/img_wants/book.png b/pylistener/scripts/img_wants/book.png new file mode 100644 index 0000000..ffbdfa7 Binary files /dev/null and b/pylistener/scripts/img_wants/book.png differ diff --git a/pylistener/scripts/img_wants/clothes.png b/pylistener/scripts/img_wants/clothes.png new file mode 100644 index 0000000..948f55d Binary files /dev/null and b/pylistener/scripts/img_wants/clothes.png differ diff --git a/pylistener/scripts/img_wants/coffee.jpg b/pylistener/scripts/img_wants/coffee.jpg new file mode 100644 index 0000000..1a6150f Binary files /dev/null and b/pylistener/scripts/img_wants/coffee.jpg differ diff --git a/pylistener/scripts/img_wants/drink.jpg b/pylistener/scripts/img_wants/drink.jpg new file mode 100644 index 0000000..f7eadcf Binary files /dev/null and b/pylistener/scripts/img_wants/drink.jpg differ diff --git a/pylistener/scripts/img_wants/food.png b/pylistener/scripts/img_wants/food.png new file mode 100644 index 0000000..7fbe9c4 Binary files /dev/null and b/pylistener/scripts/img_wants/food.png differ diff --git a/pylistener/scripts/img_wants/juice.png b/pylistener/scripts/img_wants/juice.png new file mode 100644 index 0000000..66a28d4 Binary files /dev/null and b/pylistener/scripts/img_wants/juice.png differ diff --git a/pylistener/scripts/img_wants/leave.png b/pylistener/scripts/img_wants/leave.png new file mode 100644 index 0000000..3e4d924 Binary files /dev/null and b/pylistener/scripts/img_wants/leave.png differ diff --git a/pylistener/scripts/img_wants/movie_theater.png b/pylistener/scripts/img_wants/movie_theater.png new file mode 100644 index 0000000..9e5f548 Binary files /dev/null and b/pylistener/scripts/img_wants/movie_theater.png differ diff --git a/pylistener/scripts/img_wants/music.jpg b/pylistener/scripts/img_wants/music.jpg new file mode 100644 index 0000000..75ec732 Binary files /dev/null and b/pylistener/scripts/img_wants/music.jpg differ diff --git a/pylistener/scripts/img_wants/play.png b/pylistener/scripts/img_wants/play.png new file mode 100644 index 0000000..ca4fab7 Binary files /dev/null and b/pylistener/scripts/img_wants/play.png differ diff --git a/pylistener/scripts/img_wants/that_finger.jpg b/pylistener/scripts/img_wants/that_finger.jpg new file mode 100644 index 0000000..149fd55 Binary files /dev/null and b/pylistener/scripts/img_wants/that_finger.jpg differ diff --git a/pylistener/scripts/img_wants/toys.png b/pylistener/scripts/img_wants/toys.png new file mode 100644 index 0000000..26f91f2 Binary files /dev/null and b/pylistener/scripts/img_wants/toys.png differ diff --git a/pylistener/scripts/img_wants/want.jpg b/pylistener/scripts/img_wants/want.jpg new file mode 100644 index 0000000..97eb177 Binary files /dev/null and b/pylistener/scripts/img_wants/want.jpg differ diff --git a/pylistener/scripts/img_wants/wine.jpg b/pylistener/scripts/img_wants/wine.jpg new file mode 100644 index 0000000..60d9b16 Binary files /dev/null and b/pylistener/scripts/img_wants/wine.jpg differ diff --git a/pylistener/scripts/initializedb.py b/pylistener/scripts/initializedb.py new file mode 100644 index 0000000..c56fff6 --- /dev/null +++ b/pylistener/scripts/initializedb.py @@ -0,0 +1,162 @@ +import os +import sys +import transaction +import json + +from pyramid.paster import ( + get_appsettings, + setup_logging, + ) + +from pyramid.scripts.common import parse_vars + +from pylistener.models.meta import Base +from pylistener.models import ( + get_engine, + get_session_factory, + get_tm_session, + ) +from pylistener.models import User, AddressBook, Attribute, Category, UserAttributeLink +from passlib.apps import custom_app_context as pwd_context + + +def usage(argv): + cmd = os.path.basename(argv[0]) + print('usage: %s [var=value]\n' + '(example: "%s development.ini")' % (cmd, cmd)) + sys.exit(1) + + +def main(argv=sys.argv): + if len(argv) < 2: + usage(argv) + config_uri = argv[1] + options = parse_vars(argv[2:]) + setup_logging(config_uri) + settings = get_appsettings(config_uri, options=options) + settings["sqlalchemy.url"] = os.environ["DATABASE_URL"] + engine = get_engine(settings) + Base.metadata.drop_all(engine) + Base.metadata.create_all(engine) + session_factory = get_session_factory(engine) + + here = os.path.abspath(os.path.dirname(__file__)) + with open(os.path.join(here, 'data.json')) as data: + json_data = data.read() + j_data = json.loads(json_data) + + with open(os.path.join(here, 'contacts.json')) as contacts: + test_contacts = contacts.read() + j_test_contacts = json.loads(test_contacts) + + with transaction.manager: + dbsession = get_tm_session(session_factory, transaction.manager) + + test_user = create_user_object("testted", "password", "Tedley Lamar") + test_user2 = create_user_object("Nurse Jackie", "password1234", "Charlie") + dbsession.add(test_user) + dbsession.add(test_user2) + + u_id = dbsession.query(User).first().id + u_id2 = dbsession.query(User).filter(User.username == "Nurse Jackie").first().id + for i in range(3): + add_row = create_address_object( + j_test_contacts[i]["name"], + j_test_contacts[i]["phone"], + j_test_contacts[i]["email"], + u_id, + get_picture_binary(os.path.join(here, j_test_contacts[i]["picture"])), + j_test_contacts[i]["pic_mime"] + ) + add_row2 = create_address_object( + j_test_contacts[i + 3]["name"], + j_test_contacts[i + 3]["phone"], + j_test_contacts[i + 3]["email"], + u_id2, + get_picture_binary(os.path.join(here, j_test_contacts[i + 3]["picture"])), + j_test_contacts[i]["pic_mime"] + ) + dbsession.add(add_row) + dbsession.add(add_row2) + + for category in j_data: + cat_row = create_cat_object( + category["label"], + category["desc"], + get_picture_binary(os.path.join(here, category["picture"])), + j_data[i]["pic_mime"] + ) + dbsession.add(cat_row) + cat_id_query = dbsession.query(Category) + cat_id = cat_id_query.filter(Category.label == category["label"]).first() + for attribute in category["attributes"]: + attr_row = create_att_object( + attribute["label"], + attribute["desc"], + get_picture_binary(os.path.join(here, attribute["picture"])), + attribute["pic_mime"], + cat_id.id + ) + dbsession.add(attr_row) + attr_id = dbsession.query(Attribute).filter(Attribute.label == attribute["label"]).first().id + + link_row = create_user_att_link_object(u_id, attr_id) + link_row2 = create_user_att_link_object(u_id2, attr_id) + dbsession.add(link_row) + dbsession.add(link_row2) + + +def get_picture_binary(path): + """Open an image to save binary data.""" + with open(path, "rb") as pic_data: + return pic_data.read() + + +def create_cat_object(lbl, des, pic, pic_mime): + """Return a Category object with necessary information.""" + return Category( + label=lbl, + desc=des, + picture=pic, + pic_mime=pic_mime, + ) + + +def create_att_object(lbl, des, pic, pic_mime, c_id): + """Return an Attribute object with given information.""" + return Attribute( + label=lbl, + desc=des, + picture=pic, + pic_mime=pic_mime, + cat_id=c_id + ) + + +def create_user_object(uname, psswd,sub_u): + """Return a User object with given information.""" + return User( + username=uname, + password=pwd_context.hash(psswd), + sub_user=sub_u + ) + + +def create_address_object(nme, phn, eml, u, pic, pic_mime): + """Return an AddressBook object with given information.""" + return AddressBook( + name=nme, + phone=phn, + email=eml, + user=u, + picture=pic, + pic_mime=pic_mime, + ) + + +def create_user_att_link_object(u, att): + """Return a UserAttributeLink object with given information.""" + return UserAttributeLink( + user_id=u, + attr_id=att + ) diff --git a/pylistener/scripts/pytextbelt.py b/pylistener/scripts/pytextbelt.py new file mode 100644 index 0000000..9126bc6 --- /dev/null +++ b/pylistener/scripts/pytextbelt.py @@ -0,0 +1,63 @@ +# The Wrapper Implementation Of Textbelt SMS API +# @author ksdme + +# Based On Requests +import requests + +# The Namespace Class +class Textbelt(object): # pragma: no cover + + # API URL + API_URL = 'http://textbelt.com/' + + # The Recipient Class + class Recipient(object): + + # Available Regions + REGIONS = { "us": "text", "ca": "canada", "intl": "intl" } + + def __init__(self, phone, region="us", tag = None): + self.region = region + self.phone = phone + self.tag = tag + + @property + def phone(self): + return self._phone + + @phone.setter + def phone(self, phone): + self._phone = str(phone) + + @property + def region(self): + return self._region + + @region.setter + def region(self, region): + assert region in Textbelt.Recipient.REGIONS, "Bad Region Code" + self._region = Textbelt.Recipient.REGIONS[region] + + @property + def tag(self): + return self._tag + + @tag.setter + def tag(self, tag): + self._tag = tag + return self + + # Send The Message + def send(self, message): + message = str(message) + assert len(message) > 1, "Message Too Short" + + # API URL + mAPI = Textbelt.API_URL + self.region + + mResponse = requests.post(mAPI, { + 'number': self.phone, + 'message': message + }) + + return mResponse.json() \ No newline at end of file diff --git a/pylistener/security.py b/pylistener/security.py new file mode 100644 index 0000000..2203e82 --- /dev/null +++ b/pylistener/security.py @@ -0,0 +1,40 @@ +import os +from pyramid.authentication import AuthTktAuthenticationPolicy +from pyramid.authorization import ACLAuthorizationPolicy +from pyramid.security import Allow, Authenticated +from pyramid.session import SignedCookieSessionFactory + +from passlib.apps import custom_app_context as pwd_context + + +class NewRoot(object): + def __init__(self, request): + self.request = request + + """TODO: create second level of authentication?""" + __acl__ = [ + (Allow, Authenticated, 'manage'), + ] + + +def check_credentials(input_password, real_password): + """Return True if correct password, else False.""" + return pwd_context.verify(input_password, real_password) + + +def includeme(config): + """Pyramid security configuration.""" + auth_secret = os.environ.get("AUTH_SECRET", "potato") + authn_policy = AuthTktAuthenticationPolicy( + secret=auth_secret, + hashalg="sha512" + ) + authz_policy = ACLAuthorizationPolicy() + config.set_authentication_policy(authn_policy) + config.set_authorization_policy(authz_policy) + config.set_root_factory(NewRoot) + # Session stuff for CSRF Protection + session_secret = os.environ.get("SESSION_SECRET", "itsaseekrit") + session_factory = SignedCookieSessionFactory(session_secret) + config.set_session_factory(session_factory) + # config.set_default_csrf_options(require_csrf=True) diff --git a/pylistener/static/base.css b/pylistener/static/base.css new file mode 100644 index 0000000..ad4b87b --- /dev/null +++ b/pylistener/static/base.css @@ -0,0 +1,20 @@ +h1, h2, h3, h4, h5, h6, p, a, form, label, span{ + font-family: 'Ramabhadra', sans-serif; +} + +.flashes { + background: -webkit-linear-gradient(bottom, #97D0A7, #FFFFFF 35px); + background: -moz-linear-gradient(bottom, #97D0A7, #FFFFFF 35px); + background: linear-gradient(bottom, #97D0A7, #FFFFFF 35px); + color: #009999; + width: 75%; + margin: auto; + border: 1px solid #999; + border-radius: 5px; + text-align: center; + margin-bottom: 15px; + font-size: medium; + padding: 5px; + font-weight: bold; + font-family: 'Ramabhadra', sans-serif; +} \ No newline at end of file diff --git a/pylistener/static/delete.js b/pylistener/static/delete.js new file mode 100644 index 0000000..8d64b4a --- /dev/null +++ b/pylistener/static/delete.js @@ -0,0 +1,45 @@ +$(document).ready(function(){ + var del_contacts = $(".del_contact"); + del_contacts.on("click", function(event){ + // send ajax request to delete this contact + event.preventDefault(); + $.ajax({ + method: 'DELETE', + url: '/delete/' + $(this).attr("data-id"), + data: "add", + success: function(){ + console.log("deleted"); + } + }); + // fade out expense + this_row = $(this.parentNode.parentNode); + // delete the containing row + this_row.animate({ + opacity: 0 + }, 500, function(){ + $(this).remove(); + }) + }); + + var del_att = $(".del_att"); + del_att.on("click", function(event){ + // send ajax request to delete this attribute + event.preventDefault(); + $.ajax({ + method: 'DELETE', + url: '/delete/' + $(this).attr("data-id"), + data: "att", + success: function(){ + console.log("deleted"); + } + }); + // fade out expense + this_row = $(this.parentNode.parentNode); + // delete the containing row + this_row.animate({ + opacity: 0 + }, 500, function(){ + $(this).remove(); + }) + }); +}); diff --git a/pylistener/static/layout.css b/pylistener/static/layout.css new file mode 100644 index 0000000..6f7380c --- /dev/null +++ b/pylistener/static/layout.css @@ -0,0 +1,7 @@ +ul li { + display: inline; +} + +img { + width: 100%; +} \ No newline at end of file diff --git a/pylistener/static/module.css b/pylistener/static/module.css new file mode 100644 index 0000000..ed4c957 --- /dev/null +++ b/pylistener/static/module.css @@ -0,0 +1,315 @@ +body { + width: 100%; +} + +body p { + text-align: center; +} + +body h1 { + text-align: center; +} + +nav { + background-color: #009999; + width: 100%; + height: 80px; + margin-bottom: 20px; + display: inline-block; +} + +nav ul { + margin-top: 14px; + margin-right: 25px; +} + +nav ul li { + padding-left: 25px; +} + +nav ul li a{ + color: white; + text-decoration: none; +} + +nav ul li a:hover{ + text-decoration: underline; +} + +nav ul li h1 { + float: right; + color: white; + font-size: 40px; +} + +.manage_icon { + float: right; + padding-right: 30px; +} + +.main_container { + text-align: center; + } + +.main_container ul { + display: inline-block; + margin: auto; + padding: 0; +/* For IE, the outcast +*/ zoom:1; + display: inline; + } + + +.list_item { + display: inline-block; + width: 240px; + height: 240px; + padding: 20px; + background-color: #97D0A7; + border-radius: 10px; + margin-top: 5px; + margin-right: 5px; + margin-left: 5px; + line-height: 60px; +} + +.list_item img{ + display: block; + max-height: 200px; + max-width: 200px; + width: auto; + height: auto; + vertical-align: bottom; + margin: auto; +} + +.list_item p { + text-align: center; + color: black; +} + +.list_item a { + text-decoration: none; + height: 100%; + vertical-align: bottom; +} + +.link_item { + display: inline-block; +} + +.del_contact a:link { + color: black; +} + +h2 { + text-align: center; +} + +.display_main { + text-align: center; + font-size: 3em; + margin-top: 7%; +} + +.display_main form { + margin-top: 3%; + text-decoration: none; + font: menu; + display: inline-block; + padding: 2px 8px; +} + +.display_main button { + border: 1px solid #777777; + background: #009999; + color: white; + font: bold; + font-family: 'Ramabhadra', sans-serif; + padding: .5e 2em .5em 2em; + margin: 2em 4em 2em 4em; + width: 200px; + font-size: 25px; + cursor: pointer; + -moz-border-radius: 15px; + -webkit-border-radius: 15px; +} + +.display_main i { + display: block; + font-size: 50px; +} + +.display_main a { + color: #009999; + font-weight: bold; + text-decoration: none; +} + +.display_main a:visited { + color: #009999; + text-decoration: none; +} + +.display_refresh i { + font-size: 100px; +} + +h1.manageTitle { + font-size: 50px; +} + +.manage_form { + background: -webkit-linear-gradient(bottom, #97D0A7, #FFFFFF 500px); + background: -moz-linear-gradient(bottom, #97D0A7, #FFFFFF 500px); + background: linear-gradient(bottom, #97D0A7, #FFFFFF 500px); + margin: auto; + position: relative; + width: 550px; + height: 100%; + font-size: 14px; + line-height: 24px; + color: #009999; + text-decoration: none; + border-radius: 10px; + padding: 10px; + border: 1px solid #999; + border: inset 1px solid #333; + -webkit-box-shadow: 0px 0px 8px rgba(0, 0, 0, 0.3); + -moz-box-shadow: 0px 0px 8px rgba(0, 0, 0, 0.3); + box-shadow: 0px 0px 8px rgba(0, 0, 0, 0.3); +} + +.manage_form input { + width: 75%; + height: 2em; + margin: auto; +} + +.manage_form select{ + margin-left: 30px; + width: 40%; +} + +.manage_form label { + margin-left: 12.5%; +} + +.manage_form_title h1{ + font-size: 60px; +} + +input.manage_button { + background-color: #fff; + width: 50%; + display: block; + border: 1px solid #999; + font-size: 14px; + color: #009999; + font-weight: bold; + -webkit-box-shadow: 0px 0px 8px rgba(0, 0, 0, 0.3); + -moz-box-shadow: 0px 0px 8px rgba(0, 0, 0, 0.3); + box-shadow: 0px 0px 8px rgba(0, 0, 0, 0.3); +} + +.manage_button:hover { + background: #009999; + color: #fff; +} + +.manage_list_item { + display: inline-block; + width: 110px; + padding: 20px; + background-color: #97D0A7; + border-radius: 10px; + margin-top: 5px; + margin-right: 5px; + margin-left: 5px; +} + +.manage_list_item p { + text-align: center; + color: black; +} + +/*.del_contact a { + text-decoration: none; + height: 100%; + vertical-align: bottom; + color: black; +} +*/ +/*.del_contact a:hover { + color: red; +}*/ + +.manage_link_item { + display: inline-block; +} + +.manage_link_item img{ + max-height: 100px; + vertical-align: bottom; +} + +.manage_link_item a:link { + text-decoration: none; + color: black; +} + +.manage_link_item a:hover { + text-decoration: none; + color: red; +} + +.login_form: { + background: -webkit-linear-gradient(bottom, #97D0A7, #FFFFFF 35px); + background: -moz-linear-gradient(bottom, #97D0A7, #FFFFFF 35px); + background: linear-gradient(bottom, #97D0A7, #FFFFFF 35px); + color: #009999; + width: 75%; + margin: auto; + border: 1px solid #999; + border-radius: 5px; +} + + +.small_list_item { + display: inline-block; + width: 120px; + height: 80px; + padding: 10px; + background-color: #97D0A7; + border-radius: 6px; + margin-top: 5px; + margin-right: 5px; + margin-left: 5px; + line-height: 20px; +} + +.small_list_item img{ + display: block; + max-height: 60px; + width: auto; + height: auto; + vertical-align: bottom; + margin: auto; +} + +.small_list_item p { + text-align: center; + color: black; +} + +.small_list_item a { + text-decoration: none; + color: black; + height: 100%; + vertical-align: bottom; +} + +.small_link_item { + display: inline-block; +} \ No newline at end of file diff --git a/pylistener/static/pyramid-16x16.png b/pylistener/static/pyramid-16x16.png new file mode 100644 index 0000000..9792031 Binary files /dev/null and b/pylistener/static/pyramid-16x16.png differ diff --git a/pylistener/static/pyramid.png b/pylistener/static/pyramid.png new file mode 100644 index 0000000..4ab837b Binary files /dev/null and b/pylistener/static/pyramid.png differ diff --git a/pylistener/static/reset.css b/pylistener/static/reset.css new file mode 100644 index 0000000..fd4e2e6 --- /dev/null +++ b/pylistener/static/reset.css @@ -0,0 +1,46 @@ +/* http://meyerweb.com/eric/tools/css/reset/ + v2.0 | 20110126 + License: none (public domain) +*/ + +html, body, div, span, applet, object, iframe, +h1, h2, h3, h4, h5, h6, p, blockquote, pre, +a, abbr, acronym, address, big, cite, code, +del, dfn, em, img, ins, kbd, q, s, samp, +small, strike, strong, sub, sup, tt, var, +b, u, i, center, +dl, dt, dd, ol, ul, li, +fieldset, form, label, legend, +table, caption, tbody, tfoot, thead, tr, th, td, +article, aside, canvas, details, embed, +figure, figcaption, footer, header, hgroup, +menu, nav, output, ruby, section, summary, +time, mark, audio, video { + margin: 0; + padding: 0; + border: 0; + font: inherit; +} +/* HTML5 display-role reset for older browsers */ +article, aside, details, figcaption, figure, +footer, header, hgroup, menu, nav, section, form, input { + display: block; +} +body { + line-height: 1; +} +ol, ul { + list-style: none; +} +blockquote, q { + quotes: none; +} +blockquote:before, blockquote:after, +q:before, q:after { + content: ''; + content: none; +} +table { + border-collapse: collapse; + border-spacing: 0; +} \ No newline at end of file diff --git a/pylistener/templates/404.jinja2 b/pylistener/templates/404.jinja2 new file mode 100644 index 0000000..1917f83 --- /dev/null +++ b/pylistener/templates/404.jinja2 @@ -0,0 +1,8 @@ +{% extends "layout.jinja2" %} + +{% block content %} +
+

Pyramid Alchemy scaffold

+

404 Page Not Found

+
+{% endblock content %} diff --git a/pylistener/templates/attributes.jinja2 b/pylistener/templates/attributes.jinja2 new file mode 100644 index 0000000..5330e88 --- /dev/null +++ b/pylistener/templates/attributes.jinja2 @@ -0,0 +1,36 @@ +{% extends 'layout.jinja2' %} +{% block content %} +{% if request.authenticated_userid %} +
+
+ +
+
+ +
+{% else %} + Log In + Register + +

You are not authenticated.

+{% endif %} + +{% endblock %} \ No newline at end of file diff --git a/pylistener/templates/categories.jinja2 b/pylistener/templates/categories.jinja2 new file mode 100644 index 0000000..af41456 --- /dev/null +++ b/pylistener/templates/categories.jinja2 @@ -0,0 +1,33 @@ +{% extends 'layout.jinja2' %} +{% block content %} +{% if request.authenticated_userid %} +
+ +
+{% else %} + Log In + Register + +

You are not authenticated.

+{% endif %} + +{% endblock %} \ No newline at end of file diff --git a/pylistener/templates/display.jinja2 b/pylistener/templates/display.jinja2 new file mode 100644 index 0000000..9b14065 --- /dev/null +++ b/pylistener/templates/display.jinja2 @@ -0,0 +1,18 @@ +{% extends 'layout.jinja2' %} +{% block content %} +
+

{{content}}

+
+
+ + +
+ +
+ + +
+ + +
+{% endblock %} \ No newline at end of file diff --git a/pylistener/templates/home.jinja2 b/pylistener/templates/home.jinja2 new file mode 100644 index 0000000..2d8a845 --- /dev/null +++ b/pylistener/templates/home.jinja2 @@ -0,0 +1,22 @@ +{% extends 'layout.jinja2' %} +{% block content %} +{% if request.authenticated_userid %} +
+

With whom would you like to speak?

+
+ +
+{% else %} +

You are not authenticated.

+{% endif %} + +{% endblock %} \ No newline at end of file diff --git a/pylistener/templates/layout.jinja2 b/pylistener/templates/layout.jinja2 new file mode 100644 index 0000000..4f73ad4 --- /dev/null +++ b/pylistener/templates/layout.jinja2 @@ -0,0 +1,42 @@ + + + + + + + + + + PyListener + + + + + + + + + + + + {% block content %} + {% endblock content %} + + + + + diff --git a/pylistener/templates/login.jinja2 b/pylistener/templates/login.jinja2 new file mode 100644 index 0000000..a75d442 --- /dev/null +++ b/pylistener/templates/login.jinja2 @@ -0,0 +1,13 @@ +{% extends 'layout.jinja2' %} +{% block content %} +
+ + + + + +
+ +
+
+{% endblock content %} \ No newline at end of file diff --git a/pylistener/templates/manage.jinja2 b/pylistener/templates/manage.jinja2 new file mode 100644 index 0000000..80020ab --- /dev/null +++ b/pylistener/templates/manage.jinja2 @@ -0,0 +1,114 @@ +{% extends 'layout.jinja2' %} +{% block content %} +{% if request.session.peek_flash() %} +
+ {% for message in request.session.pop_flash()%} + {{ message }} + {% endfor %} +
+{% endif %} +

Configure Application

+
+
+

Address Book


+
+

Add Contact

+ + + + +
+ + +
+ + +
+ + +
+ +
+
+
+
+ +
+ +
+

Categories


+
+

Add Category

+ + + + +
+ + +
+ + +
+ + +
+
+
+

Attributes


+
+

Add Attribute

+ + + + +
+ + +
+ + +
+
+ + +
+ + +
+
+
+

Current Attributes:

+
+
+ +
+ +{% endblock content %} \ No newline at end of file diff --git a/pylistener/templates/register.jinja2 b/pylistener/templates/register.jinja2 new file mode 100644 index 0000000..d097fca --- /dev/null +++ b/pylistener/templates/register.jinja2 @@ -0,0 +1,30 @@ +{% extends 'layout.jinja2' %} +{% block content %} +{% if request.session.peek_flash() %} +
+ {% for message in request.session.pop_flash()%} + {{ message }} + {% endfor %} +
+{% endif %} + +
+ + + + + + + + + +
+ + + + +
+ +
+
+{% endblock content%} \ No newline at end of file diff --git a/pylistener/templates/test_img.jinja2 b/pylistener/templates/test_img.jinja2 new file mode 100644 index 0000000..4c3f291 --- /dev/null +++ b/pylistener/templates/test_img.jinja2 @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/pylistener/tests.py b/pylistener/tests.py new file mode 100644 index 0000000..0a05791 --- /dev/null +++ b/pylistener/tests.py @@ -0,0 +1,481 @@ +"""Tests for Pylistener.""" + + +import pytest +import transaction +import os +import cgi + +from pyramid import testing + +from pylistener.models import User, AddressBook, Category, Attribute, UserAttributeLink, get_tm_session +from pylistener.models.meta import Base +from passlib.apps import custom_app_context as pwd_context + +TEST_DB = os.environ.get("TEST_DB", "test") + + +@pytest.fixture(scope="session") +def configuration(request): + """Set up a Configurator instance. + + This Configurator instance sets up a pointer to the location of the + database. + It also includes the models from your app's model package. + Finally it tears everything down, including the Postgres database. + + This configuration will persist for the entire duration of your PyTest run. + """ + settings = { + 'sqlalchemy.url': TEST_DB} + config = testing.setUp(settings=settings) + config.include('pylistener.models') + config.include('pylistener.routes') + + def teardown(): + testing.tearDown() + + request.addfinalizer(teardown) + return config + + +@pytest.fixture +def db_session(configuration, request): + """Create a session for interacting with the test database. + + This uses the dbsession_factory on the configurator instance to create a + new database session. It binds that session to the available engine + and returns a new session for every call of the dummy_request object. + """ + SessionFactory = configuration.registry['dbsession_factory'] + session = SessionFactory() + engine = session.bind + Base.metadata.drop_all(engine) + Base.metadata.create_all(engine) + + def teardown(): + session.transaction.rollback() + Base.metadata.drop_all(engine) + + request.addfinalizer(teardown) + return session + + +@pytest.fixture +def dummy_request(db_session): + """Instantiate a fake HTTP Request, complete with a database session. + + This is a function-level fixture, so every new request will have a + new database session. + """ + return testing.DummyRequest(dbsession=db_session) + + +@pytest.fixture +def test_user(db_session): + """Instantiate a test user account.""" + new_user = User(username="test", password=pwd_context.hash("test")) + db_session.add(new_user) + + +# ======== UNIT TESTS ========== +def test_user_table_empty(db_session): + """Test user table is initially empty.""" + query = db_session.query(User).all() + assert not len(query) + + +def test_addresses_table_empty(db_session): + """Test addresses table is initially empty.""" + query = db_session.query(AddressBook).all() + assert not len(query) + + +def test_category_table_empty(db_session): + """Test category table is initially empty.""" + query = db_session.query(Category).all() + assert not len(query) + + +def test_attribute_table_empty(db_session): + """Test attribute table is initially empty.""" + query = db_session.query(AddressBook).all() + assert not len(query) + + +def test_new_user_is_added(db_session): + """Test new user gets added to the database.""" + new_user = User(username="test", password="test") + db_session.add(new_user) + query = db_session.query(User).all() + assert len(query) == 1 + + +def test_new_user_username(db_session): + """Test new user has correct data.""" + new_user = User(username="test", password="test") + db_session.add(new_user) + user = db_session.query(User).filter(User.id == 1).first() + assert user.username == "test" + + +def test_new_contact_is_added(db_session): + """Test new contact gets added to correct table.""" + new_contact = AddressBook( + name="test_name", + phone="test_phone", + email="test_email" + ) + db_session.add(new_contact) + query = db_session.query(AddressBook).all() + assert len(query) == 1 + + +def test_new_contact_data(db_session): + """Test new contact has correct data.""" + new_contact = AddressBook( + name="test_name", + phone="test_phone", + email="test_email" + ) + db_session.add(new_contact) + contact = db_session.query(AddressBook).all() + assert contact[0].name == "test_name" + assert contact[0].phone == "test_phone" + assert contact[0].email == "test_email" + + +def test_new_category_is_added(db_session): + """Test new category is added to database.""" + new_cat = Category( + label="test_label", + desc="test_desc" + ) + db_session.add(new_cat) + query = db_session.query(Category).all() + assert len(query) == 1 + + +def test_new_category_data(db_session): + """Test new category has correct data.""" + new_cat = Category( + label="test_label", + desc="test_desc" + ) + db_session.add(new_cat) + category = db_session.query(Category).all() + assert category[0].label == "test_label" + assert category[0].desc == "test_desc" + + +def test_new_attribute_is_added(db_session): + """Test new attribute is added to database.""" + new_att = Attribute( + label="test_label", + desc="test_desc" + ) + db_session.add(new_att) + query = db_session.query(Attribute).all() + assert len(query) == 1 + + +def test_new_attribute_data(db_session): + """Test new attribute has correct data.""" + new_att = Attribute( + label="test_label", + desc="test_desc" + ) + db_session.add(new_att) + att = db_session.query(Attribute).all() + assert att[0].label == "test_label" + assert att[0].desc == "test_desc" + + +def test_login_view_bad_credentials(dummy_request): + """Test that when given bad credentials login doesn't happen.""" + from .views.default import login_view + dummy_request.POST["username"] = "testme" + dummy_request.POST["password"] = "badpassword" + result = login_view(dummy_request) + assert result == {} + + +def test_login_view_get_request(dummy_request): + """Test that you can see the login view.""" + from .views.default import login_view + result = login_view(dummy_request) + assert result == {} + + +def test_login_view_good_credentials(dummy_request, test_user): + """Test that when given good credentials login can be successful.""" + from .views.default import login_view + from pyramid.httpexceptions import HTTPFound + dummy_request.POST["username"] = "test" + dummy_request.POST["password"] = "test" + result = login_view(dummy_request) + assert isinstance(result, HTTPFound) + + +def test_logout_view_redirects(dummy_request): + """When logging out you get redirected to the home page.""" + from .views.default import logout_view + from pyramid.httpexceptions import HTTPFound + result = logout_view(dummy_request) + assert isinstance(result, HTTPFound) + + +def test_register_view(dummy_request): + """Test that you can see the register view.""" + from .views.default import register_view + result = register_view(dummy_request) + assert result == {} + + +def test_not_found_view(dummy_request): + """Test not found view.""" + from .views.notfound import notfound_view + result = notfound_view(dummy_request) + assert result == {} + + +def test_home_view(dummy_request): + """Test home view.""" + from .views.default import home_view + result = home_view(dummy_request) + assert result == {} + + +def test_categories_view(): + """Test category view.""" + from .views.default import categories_view + with pytest.raises(Exception): + categories_view(dummy_request) + + +def test_attributes_view(): + """Test attributes view.""" + from .views.default import attributes_view + with pytest.raises(Exception): + attributes_view(dummy_request) + + +# # Unit test for initialize_db # # +def test_create_cat_object(): + """Test create_cat_object returns a Category model.""" + from .scripts.initializedb import create_cat_object + cat_object = create_cat_object("a", "b", "c", "c") + assert isinstance(cat_object, Category) + + +def test_create_att_object(): + """Test create_att_object returns an Attribute model.""" + from .scripts.initializedb import create_att_object + att_object = create_att_object("a", "b", "c", "d", "c") + assert isinstance(att_object, Attribute) + + +def test_create_user_object(): + """Test create_user_object returns a User model.""" + from .scripts.initializedb import create_user_object + user_object = create_user_object("test", "test", "test") + assert isinstance(user_object, User) + + +def test_create_address_object(): + """Test create_address_object returns an AddressBook model.""" + from .scripts.initializedb import create_address_object + address_object = create_address_object("a", "b", "c", "d", "e", "f") + assert isinstance(address_object, AddressBook) + + +def test_create_user_att_link_object(): + """Test create_user_att_link_object returns a UserAttributeLink model.""" + from .scripts.initializedb import create_user_att_link_object + user_att_link_object = create_user_att_link_object("user", "attribute") + assert isinstance(user_att_link_object, UserAttributeLink) + + +def test_get_picture_binary(): + """Test get_picture_binary returns a bytes class.""" + from .scripts.initializedb import get_picture_binary + import os + here = os.path.abspath(os.path.dirname(__file__)) + path = os.path.join(here, 'scripts/img_questions/how.jpg') + rb = get_picture_binary(path) + assert isinstance(rb, bytes) + + +def test_handle_new_picture(): + """Test handle new picture function returns a bytes class.""" + import os + from .views.default import handle_new_picture + here = os.path.abspath(os.path.dirname(__file__)) + path = os.path.join(here, 'scripts/img_questions/how.jpg') + with open(path, 'rb') as ouput_file: + new_picture = handle_new_picture("name", ouput_file) + assert isinstance(new_picture, bytes) + + +# # ======== FUNCTIONAL TESTS =========== + + +@pytest.fixture +def testapp(request): + """Create an instance of webtests TestApp for testing routes. + + With the alchemy scaffold we need to add to our test application the + setting for a database to be used for the models. + We have to then set up the database by starting a database session. + Finally we have to create all of the necessary tables that our app + normally uses to function. + + The scope of the fixture is function-level, so every test will get a new + test application. + """ + from webtest import TestApp + from pyramid.config import Configurator + + def main(global_config, **settings): + config = Configurator(settings=settings) + config.include('pyramid_jinja2') + config.include('.models') + config.include('.routes') + config.include('.security') + config.scan() + return config.make_wsgi_app() + + app = main({}, **{'sqlalchemy.url': TEST_DB}) + testapp = TestApp(app) + + SessionFactory = app.registry["dbsession_factory"] + engine = SessionFactory().bind + Base.metadata.drop_all(engine) + Base.metadata.create_all(bind=engine) + + return testapp + + +@pytest.fixture +def new_user(testapp): + """Add a new user to the database.""" + SessionFactory = testapp.app.registry["dbsession_factory"] + with transaction.manager: + dbsession = get_tm_session(SessionFactory, transaction.manager) + new_user = User(username="test", password=pwd_context.hash("test")) + dbsession.add(new_user) + + +@pytest.fixture +def login_fixture(testapp, new_user): + """Test that logging redirects.""" + resp = testapp.post('/login', params={'username': 'test', 'password': 'test'}) + headers = resp.headers + return headers + + +@pytest.fixture +def fill_the_db(testapp, new_user): + """Fill the database with a contact, category and attribute.""" + from .scripts.initializedb import get_picture_binary + import os + here = here = os.path.abspath(os.path.dirname(__file__)) + SessionFactory = testapp.app.registry["dbsession_factory"] + with transaction.manager: + dbsession = get_tm_session(SessionFactory, transaction.manager) + picture = get_picture_binary(os.path.join(here, "placeholder.jpg")) + new_user = AddressBook( + name="user name", + phone="user phone", + email="user email", + picture=picture, + pic_mime="image/jpeg", + user=1 + ) + dbsession.add(new_user) + new_category = Category( + label="category label", + desc="category", + picture=picture, + pic_mime="image/jpeg" + ) + dbsession.add(new_category) + new_attribute = Attribute( + label="attribute label", + desc="attribute", + picture=picture, + cat_id=1, + pic_mime="image/jpeg" + ) + dbsession.add(new_attribute) + + +def test_login_page_has_form(testapp): + """Test that the login route brings up the login template.""" + html = testapp.get('/login').html + assert len(html.find_all('input')) + + +def test_category_view_not_logged_in(testapp): + """Test category route without logging in returns 403 error.""" + from webtest.app import AppError + with pytest.raises(AppError, message="403 Forbidden"): + testapp.get('/category/1') + + +def test_category_view_logged_in(testapp, fill_the_db, login_fixture): + """Test category view when logged in is accessible.""" + response = testapp.get('/category/1', params=login_fixture) + assert response.status_code == 200 + + +def test_404_view(testapp): + """Test a non-registered route will raise a 404.""" + from webtest.app import AppError + with pytest.raises(AppError, message="404 Not Found"): + testapp.get('/raise404') + + +def test_home_view_authenticated(testapp, login_fixture): + """Test home view is accessible authenticated.""" + response = testapp.get('/', params=login_fixture) + assert response.status_code == 200 + + +def test_home_authenticated_has_contacts(testapp, fill_the_db, login_fixture): + """Test home views renders contacts when authenticated.""" + response = testapp.get('/', params=login_fixture).html + assert len(response.find_all("img")) == 1 + + +def test_attribute_view_authenticated(testapp, fill_the_db, login_fixture): + """Test attribute view with full db and authenticated user.""" + response = testapp.get('/attribute/1/1', params=login_fixture) + assert response.status_code == 200 + + +def test_attribute_authenticated_has_attributes(testapp, login_fixture, fill_the_db): + """Test attribute view renders attributes when authenticated.""" + response = testapp.get('/attribute/1/1', params=login_fixture) + assert len(response.html.find_all("img")) == 2 + + +def test_display_view_authenticated(testapp, fill_the_db, login_fixture): + """Test display view is accessible authenticated.""" + response = testapp.get("/display/1/1/1", params=login_fixture) + assert response.status_code == 200 + + +def test_display_authenticated_has_string(testapp, fill_the_db, login_fixture): + """Test display view renders the string when authenticated.""" + response = testapp.get("/display/1/1/1", params=login_fixture) + display_h1 = response.html.find_all('h1')[1] + assert "user name, category attribute" in display_h1 + + +def test_manage_view_get_request(testapp, fill_the_db, login_fixture): + """Test the manage view returns three forms.""" + response = testapp.get('/manage/test', params=login_fixture) + assert response.status == '200 OK' + assert len(response.html.find_all('form')) == 3 + assert len(response.html.find_all('li', class_='manage_list_item')) == 1 diff --git a/pylistener/views/__init__.py b/pylistener/views/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/pylistener/views/default.py b/pylistener/views/default.py new file mode 100644 index 0000000..3480426 --- /dev/null +++ b/pylistener/views/default.py @@ -0,0 +1,392 @@ +from __future__ import unicode_literals + +from pyramid.response import Response +from pyramid.view import view_config + +from pyramid.httpexceptions import HTTPFound +from pyramid.httpexceptions import exception_response + + +from pylistener.security import check_credentials +from passlib.apps import custom_app_context as pwd_context +from pyramid.security import remember, forget + +from pylistener.models import\ + User, AddressBook, Category, Attribute, UserAttributeLink + +from pylistener.scripts.initializedb import\ + create_att_object, create_user_att_link_object, get_picture_binary + +from twilio.rest import TwilioRestClient + +import os +import sys +import shutil +import yagmail +import mimetypes +import json + + +HERE = os.path.dirname(os.path.realpath(__file__)) + + +@view_config(route_name='home', renderer='../templates/home.jinja2') +def home_view(request): + """Handle the home route.""" + if request.authenticated_userid: + user = request.authenticated_userid + contacts = request.dbsession.query(AddressBook).join(User.address_rel)\ + .filter(User.username == user).all() + return {"contacts": contacts} + return {} + + +@view_config(route_name='login', renderer='../templates/login.jinja2') +def login_view(request): + """Authenticate the user.""" + if request.POST: + query = request.dbsession.query(User) + username = request.POST["username"] + password = request.POST["password"] + user = query.filter(User.username == username).first() + if user: + real_password = user.password + if check_credentials(password, real_password): + auth_head = remember(request, username) + return HTTPFound( + location=request.route_url("manage", id=username), + headers=auth_head + ) + return {} + + +@view_config(route_name='logout') +def logout_view(request): + """Handle the logout route.""" + auth_head = forget(request) + return HTTPFound(location=request.route_url("home"), headers=auth_head) + + +@view_config( + route_name='manage', + renderer='../templates/manage.jinja2', + permission="manage") +def manage_view(request): + """Manage user uploads.""" + if request.POST: + try: + if request.POST['contact']: + handle_new_contact(request) + return HTTPFound(location=request.route_url( + 'manage', id=request.matchdict["id"])) + except KeyError: + try: + if request.POST['category']: + handle_new_category(request) + return HTTPFound(location=request.route_url( + 'manage', id=request.matchdict["id"])) + except KeyError: + if request.POST['attribute']: + handle_new_attribute(request) + return HTTPFound(location=request.route_url( + 'manage', id=request.matchdict["id"])) + + user = request.dbsession.query(User).filter(User.username == request.authenticated_userid)\ + .first().id + categories = request.dbsession.query(Category).all() + joined = request.dbsession.query(Attribute.id, Attribute.label, Attribute.picture, UserAttributeLink.user_id) \ + .join(UserAttributeLink, UserAttributeLink.attr_id == Attribute.id) + attributes = joined.filter(UserAttributeLink.user_id == user).all() + contacts = request.dbsession.query(AddressBook).filter(AddressBook.user == user) + return {"categories": categories, "attributes": set(attributes), "contacts": contacts} + + +@view_config(route_name='register', renderer='../templates/register.jinja2') +def register_view(request): + """Handle the register route.""" + if request.POST: + input_file = request.POST['contact_img'].file + input_type = mimetypes.guess_type(request.POST['contact_img'].filename)[0] + if input_type[:5] != 'image': + message = "Please try again with an image file." + request.session.flash(message) + return {} + else: + username = request.POST["contact_name"] + password = request.POST["password"] + sub_user = request.POST["sub_user"] + new_user = User( + username=username, + password=pwd_context.hash(password), + sub_user=sub_user + ) + request.dbsession.add(new_user) + add_new_contact(request, input_file, input_type, username) + initialize_new_user(username, request) + auth_head = remember(request, username) + return HTTPFound( + location=request.route_url('manage', id=new_user.username), + headers=auth_head) + return {} + + +@view_config( + route_name='category', + renderer='../templates/categories.jinja2', + permission="manage" +) +def categories_view(request): + """Handle the categories route.""" + if request.authenticated_userid: + categories = request.dbsession.query(Category).all() + return {"categories": categories, "addr_id": request.matchdict["add_id"]} + + +@view_config( + route_name='attribute', + renderer='../templates/attributes.jinja2', + permission="manage") +def attributes_view(request): + """Handle the attributes route.""" + try: + if request.authenticated_userid: + user = request.dbsession.query(User)\ + .filter(User.username == request.authenticated_userid).first().id + + joined = request.dbsession.query(Attribute.id, Attribute.label, Attribute.picture, Attribute.cat_id, UserAttributeLink.priority, UserAttributeLink.user_id) \ + .join(UserAttributeLink, UserAttributeLink.attr_id == Attribute.id) + + attributes = joined.filter(UserAttributeLink.user_id == user)\ + .filter(Attribute.cat_id == request.matchdict["cat_id"])\ + .order_by(UserAttributeLink.priority).all() + + return {"attributes": set(attributes), "addr_id": request.matchdict["add_id"], "category_id": request.matchdict["cat_id"]} + except AttributeError: + raise exception_response(403) + + +@view_config( + route_name='display', + renderer='../templates/display.jinja2', + permission="manage") +def display_view(request): + """Handle the display route.""" + user = request.dbsession.query(User).filter(User.username == request.authenticated_userid).first() + contact_id = request.matchdict["add_id"] + contact = request.dbsession.query(AddressBook).filter(AddressBook.id == contact_id).first() + cat_id = request.matchdict["cat_id"] + category = request.dbsession.query(Category).filter(Category.id == cat_id).first() + att_id = request.matchdict["att_id"] + attribute = request.dbsession.query(Attribute).filter(Attribute.id == att_id).first() + + content = "{0}, you have received a message from {1}. \n\t \"{2} {3}\"" \ + .format(contact.name, user.sub_user, category.desc, attribute.desc) + + string = "{0}, {1} {2}".format(contact.name, category.desc, attribute.desc) + + if request.POST: + try: + if request.POST['email']: + send_email(contact, content) + return HTTPFound(location=request.route_url('home')) + except KeyError: + if request.POST['sms']: + send_sms(contact, content) + return HTTPFound(location=request.route_url('home')) + return {"content": string} + + +@view_config(route_name='picture') +def picture_handler(request): + """Serve pictures from database binaries.""" + if request.matchdict["db_id"] == "add": + picture_data = request.dbsession.query(AddressBook).get(request.matchdict['pic_id']) + elif request.matchdict["db_id"] == "cat": + picture_data = request.dbsession.query(Category).get(request.matchdict['pic_id']) + elif request.matchdict["db_id"] == "att": + picture_data = request.dbsession.query(Attribute).get(request.matchdict['pic_id']) + + mime_type = picture_data.pic_mime + if sys.version_info[0] < 3: + mime_type = mime_type.encode('utf-8') + + return Response(content_type=mime_type, body=picture_data.picture) + + +@view_config(route_name='delete') +def delete_handler(request): + """Handle deleting contacts and attributes from manage page.""" + user = request.authenticated_userid + if request.text == "add": + address = request.dbsession.query(AddressBook).get(request.matchdict["id"]) + request.dbsession.delete(address) + elif request.text == "att": + att_id = request.matchdict["id"] + user_id = request.dbsession.query(User).filter(User.username == user).first().id + attribute = request.dbsession.query(UserAttributeLink) \ + .filter(UserAttributeLink.user_id == user_id) \ + .filter(UserAttributeLink.attr_id == att_id).first() + request.dbsession.delete(attribute) + + return HTTPFound(request.route_url("manage", id=user)) + + +# ------- Helper functions ------- # + +def send_email(contact, content): + """Send email via yagmail SMTP api.""" + yag = yagmail.SMTP(os.environ['EMAIL'], os.environ['PASSWORD']) + print(contact.email) + yag.send(contact.email, 'An email from Pylistener', content) + + +def send_sms(contact, content): + """Send sms via twilio api.""" + account_sid = os.environ["TWILIO_SID"] + auth_token = os.environ["TWILIO_TOKEN"] + twilio_number = os.environ["TWILIO_NUMBER"] + client = TwilioRestClient(account_sid, auth_token) + client.messages.create( + to='+1' + contact.phone, + from_=twilio_number, + body=content + ) + + +def handle_new_contact(request): + """Handler function for a new contact in manage view.""" + input_file = request.POST['contact_img'].file + input_type = mimetypes.guess_type(request.POST['contact_img'].filename)[0] + if input_type[:5] == 'image': + user_id = request.matchdict["id"] + add_new_contact(request, input_file, input_type, user_id) + message = "New Contact Added." + request.session.flash(message) + else: + message = "Please try again with an image file." + request.session.flash(message) + + +def add_new_contact(request, input_file, input_type, username): + """Add new contact to DB.""" + name = request.POST["contact_name"] + phone = request.POST["contact_phone"] + email = request.POST["contact_email"] + user = username + user_id = request.dbsession.query(User).filter(User.username == user).first() + picture = handle_new_picture(name, input_file) + new_contact = AddressBook( + name=name, + phone=phone, + email=email, + picture=picture, + pic_mime=input_type, + user=user_id.id) + request.dbsession.add(new_contact) + + +def handle_new_category(request): + """Handler function for new category in manage view.""" + input_file = request.POST['cat_img'].file + input_type = mimetypes.guess_type(request.POST['cat_img'].filename)[0] + if input_type[:5] == 'image': + add_new_category(request, input_file, input_type) + message = "New Category Added. Don't forget Attributes!" + request.session.flash(message) + else: + message = "Please try again with an image file." + request.session.flash(message) + + +def add_new_category(request, input_file, input_type): + """Add new category to DB.""" + label = request.POST["cat_label"] + cat_desc = request.POST["cat_desc"] + picture = handle_new_picture(label, input_file) + new_cat = Category( + label=label, + desc=cat_desc, + picture=picture, + pic_mime=input_type + ) + request.dbsession.add(new_cat) + + +def handle_new_attribute(request): + """Handler function for a new attribute in manage view.""" + input_file = request.POST['attr_img'].file + input_type = mimetypes.guess_type(request.POST['attr_img'].filename)[0] + if input_type[:5] == 'image': + handle_new_attribute(request, input_file, input_type) + message = "New Attribute Added." + request.session.flash(message) + else: + message = "Please try again with an image file." + request.session.flash(message) + + +def add_new_attribute(request, input_file, input_type): + """Add new attribute to DB.""" + label = request.POST["attr_label"] + desc = request.POST["attr_desc"] + category = request.POST["attr_cat"] + input_file = request.POST['attr_img'].file + picture = handle_new_picture(label, input_file) + category_query = request.dbsession.query(Category) + category_id = category_query.filter(Category.label == category).first() + new_attr = Attribute( + label=label, + desc=desc, + picture=picture, + pic_mime=input_type, + cat_id=category_id.id, + ) + request.dbsession.add(new_attr) + user_id = request.dbsession.query(User).filter_by(username=request.authenticated_userid).first().id + attr_id = attr_id = request.dbsession.query(Attribute).filter_by(label=label).first().id + new_attr_link = UserAttributeLink( + user_id=user_id, + attr_id=attr_id + ) + request.dbsession.add(new_attr_link) + + +def handle_new_picture(name, input_file): + """Handle the picture upload and return a BLOB.""" + temp_file_path = '/'.join([HERE, name]) + temp_file_path += '~' + input_file.seek(0) + with open(temp_file_path, 'wb') as output_file: + shutil.copyfileobj(input_file, output_file) + with open(temp_file_path, 'rb') as f: + blob = f.read() + picture = blob + os.remove(temp_file_path) + return picture + + +def initialize_new_user(username, request): + """Initialize attribute set for new user.""" + u_id = request.dbsession.query(User)\ + .filter(User.username == username).first().id + + with open(os.path.join(HERE, '../scripts/data.json')) as data: + json_data = data.read() + j_data = json.loads(json_data) + + for cat in j_data: + cat_id_query = request.dbsession.query(Category) + cat_id = cat_id_query.filter(Category.label == cat["label"]).first() + for attribute in cat["attributes"]: + attr_row = create_att_object( + attribute["label"], + attribute["desc"], + get_picture_binary(os.path.join(HERE, "../scripts/" + attribute["picture"])), + attribute["pic_mime"], + cat_id.id + ) + request.dbsession.add(attr_row) + attr_id = request.dbsession.query(Attribute).filter(Attribute.label == attribute["label"]).first().id + + link_row = create_user_att_link_object(u_id, attr_id) + request.dbsession.add(link_row) diff --git a/pylistener/views/notfound.py b/pylistener/views/notfound.py new file mode 100644 index 0000000..69d6e28 --- /dev/null +++ b/pylistener/views/notfound.py @@ -0,0 +1,7 @@ +from pyramid.view import notfound_view_config + + +@notfound_view_config(renderer='../templates/404.jinja2') +def notfound_view(request): + request.response.status = 404 + return {} diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..df48dc0 --- /dev/null +++ b/pytest.ini @@ -0,0 +1,3 @@ +[pytest] +testpaths = pylistener +python_files = *.py diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..8c3be5a --- /dev/null +++ b/requirements.txt @@ -0,0 +1,46 @@ +appnope==0.1.0 +beautifulsoup4==4.5.3 +coverage==4.3.1 +decorator==4.0.10 +ipython==5.1.0 +ipython-genutils==0.1.0 +Jinja2==2.9.3 +keyring==10.2 +Mako==1.0.6 +MarkupSafe==0.23 +passlib==1.7.0 +PasteDeploy==1.5.2 +pexpect==4.2.1 +pickleshare==0.7.4 +pluggy==0.4.0 +prompt-toolkit==1.0.9 +psycopg2==2.6.2 +ptyprocess==0.5.1 +py==1.4.32 +Pygments==2.1.3 +pyramid==1.7.3 +pyramid-debugtoolbar==3.0.5 +pyramid-jinja2==2.7 +pyramid-mako==1.0.2 +pyramid-tm==1.1.1 +pytest==3.0.5 +pytest-cov==2.4.0 +repoze.lru==0.6 +requests==2.12.4 +simplegeneric==0.8.1 +six==1.10.0 +SQLAlchemy==1.1.4 +tox==2.5.0 +traitlets==4.3.1 +transaction==2.0.3 +translationstring==1.3 +venusian==1.0 +virtualenv==15.1.0 +waitress==1.0.1 +wcwidth==0.1.7 +WebOb==1.7.0 +WebTest==2.0.24 +yagmail==0.5.149 +zope.deprecation==4.2.0 +zope.interface==4.3.3 +zope.sqlalchemy==0.7.7 diff --git a/run b/run new file mode 100755 index 0000000..20ed4a0 --- /dev/null +++ b/run @@ -0,0 +1,5 @@ +#!/bin/bash +set -e +python setup.py develop +initialize_db production.ini +python runapp.py \ No newline at end of file diff --git a/runapp.py b/runapp.py new file mode 100644 index 0000000..e5df1c6 --- /dev/null +++ b/runapp.py @@ -0,0 +1,10 @@ +import os + +from paste.deploy import loadapp +from waitress import serve + +if __name__ == "__main__": + port = int(os.environ.get("PORT", 5000)) + app = loadapp('config:production.ini', relative_to='.') + + serve(app, host="0.0.0.0", port=port) \ No newline at end of file diff --git a/runtime.txt b/runtime.txt new file mode 100644 index 0000000..80aea67 --- /dev/null +++ b/runtime.txt @@ -0,0 +1 @@ +python-3.6.0 \ No newline at end of file diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..5b880e1 --- /dev/null +++ b/setup.py @@ -0,0 +1,54 @@ + +from setuptools import setup, find_packages + + +requires = [ + 'pyramid', + 'pyramid_jinja2', + 'pyramid_debugtoolbar', + 'pyramid_tm', + 'SQLAlchemy', + 'transaction', + 'zope.sqlalchemy', + 'waitress', + 'psycopg2', + 'passlib', + 'yagmail', + 'keyring', + 'requests' +] + +tests_require = [ + 'WebTest >= 1.3.1', # py3 compat + 'pytest', # includes virtualenv + 'pytest-cov', +] + +setup(name='pylistener', + version='0.0', + description='''A simple tool designed to enable people with Apraxia + to communicate.''', + classifiers=[ + "Programming Language :: Python", + "Framework :: Pyramid", + "Topic :: Internet :: WWW/HTTP", + "Topic :: Internet :: WWW/HTTP :: WSGI :: Application", + ], + author='Maelle Vance, Rick Valenzuela, Ted Callahan', + author_email='', + url='https://pylistener.herokuapp.com', + keywords='web wsgi bfg pylons pyramid', + packages=find_packages(), + include_package_data=True, + zip_safe=False, + extras_require={ + 'testing': tests_require, + }, + install_requires=requires, + entry_points="""\ + [paste.app_factory] + main = pylistener:main + [console_scripts] + initialize_db = pylistener.scripts.initializedb:main + """, + ) diff --git a/tox.ini b/tox.ini new file mode 100644 index 0000000..e759267 --- /dev/null +++ b/tox.ini @@ -0,0 +1,9 @@ +[tox] +envlist = py27, py35 + +[testenv] +commands = py.test pylistener --cov pylistener --cov-report term-missing +deps = + pytest + pytest-cov + webtest \ No newline at end of file