├── user-auth ├── .flaskenv ├── dev-requirements.txt ├── migrations │ ├── README │ ├── script.py.mako │ ├── versions │ │ ├── 20210722_173642_.py │ │ ├── 20210816_151855_.py │ │ └── 20210712_174530_.py │ ├── alembic.ini │ └── env.py ├── react-app │ ├── src │ │ ├── index.css │ │ ├── components │ │ │ ├── splash │ │ │ │ ├── splashImage.jpg │ │ │ │ ├── Splash.jsx │ │ │ │ └── splash.css │ │ │ ├── auth │ │ │ │ ├── logoutButton.css │ │ │ │ ├── ProtectedRoute.js │ │ │ │ ├── LogoutButton.js │ │ │ │ ├── loginForm.css │ │ │ │ ├── signUpForm.css │ │ │ │ ├── LoginForm.js │ │ │ │ └── SignUpForm.js │ │ │ ├── footer │ │ │ │ ├── footer.css │ │ │ │ └── Footer.jsx │ │ │ ├── title │ │ │ │ ├── title.css │ │ │ │ └── Title.jsx │ │ │ ├── interests │ │ │ │ ├── DeleteInterest.jsx │ │ │ │ ├── interests.css │ │ │ │ ├── Interests.jsx │ │ │ │ └── UpdateInterests.jsx │ │ │ ├── gifs │ │ │ │ ├── Gif.jsx │ │ │ │ └── gifs.css │ │ │ ├── navbar.css │ │ │ ├── Google Analytics │ │ │ │ └── GoogleAnalytics.js │ │ │ ├── User.js │ │ │ ├── articles │ │ │ │ ├── Headline.jsx │ │ │ │ ├── Article.jsx │ │ │ │ └── articles.css │ │ │ ├── default │ │ │ │ └── Default.jsx │ │ │ ├── saved │ │ │ │ ├── RemoveFromSaved.jsx │ │ │ │ ├── saved.css │ │ │ │ ├── SavedList.jsx │ │ │ │ └── AddToSaved.jsx │ │ │ ├── content │ │ │ │ └── Content.jsx │ │ │ ├── weather │ │ │ │ ├── getDays.js │ │ │ │ ├── weather.css │ │ │ │ └── Weather.jsx │ │ │ ├── content.css │ │ │ └── NavBar.jsx │ │ ├── index.js │ │ ├── store │ │ │ ├── default.js │ │ │ ├── index.js │ │ │ ├── weather.js │ │ │ ├── articles.js │ │ │ ├── session.js │ │ │ ├── saved.js │ │ │ └── interests.js │ │ ├── services │ │ │ └── auth.js │ │ └── App.js │ ├── .env.example │ ├── public │ │ ├── favicon.ico │ │ └── index.html │ ├── .gitignore │ ├── README.md │ └── package.json ├── app │ ├── models │ │ ├── db.py │ │ ├── __init__.py │ │ ├── tag.py │ │ ├── interest.py │ │ ├── userTag.py │ │ ├── homeData.py │ │ ├── saved.py │ │ └── user.py │ ├── forms │ │ ├── __init__.py │ │ ├── tag_form.py │ │ ├── interest_form.py │ │ ├── update_interest_form.py │ │ ├── saved_form.py │ │ ├── signup_form.py │ │ └── login_form.py │ ├── config.py │ ├── api │ │ ├── default_routes.py │ │ ├── tag_routes.py │ │ ├── user_routes.py │ │ ├── weather_routes.py │ │ ├── saved_routes.py │ │ ├── article_routes.py │ │ ├── interest_routes.py │ │ └── auth_routes.py │ ├── seeds │ │ ├── __init__.py │ │ ├── users.py │ │ ├── tags.py │ │ └── default.py │ └── __init__.py ├── .dockerignore ├── architecture.pdf ├── .env.example ├── .vscode │ └── settings.json ├── Dockerfile ├── Pipfile ├── requirements.txt ├── redux-steps.md ├── .gitignore └── Pipfile.lock ├── README.md ├── .vscode └── settings.json ├── news-connector db schema (3).png └── SETUP INSTRUCTIONS.md /user-auth/.flaskenv: -------------------------------------------------------------------------------- 1 | FLASK_APP=app -------------------------------------------------------------------------------- /user-auth/dev-requirements.txt: -------------------------------------------------------------------------------- 1 | psycopg2-binary==2.8.6 2 | -------------------------------------------------------------------------------- /user-auth/migrations/README: -------------------------------------------------------------------------------- 1 | Generic single-database configuration. -------------------------------------------------------------------------------- /user-auth/react-app/src/index.css: -------------------------------------------------------------------------------- 1 | /* TODO Add site wide styles */ 2 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # news-connector 2 | 3 | https://news-connector.herokuapp.com/ 4 | -------------------------------------------------------------------------------- /user-auth/react-app/.env.example: -------------------------------------------------------------------------------- 1 | REACT_APP_BASE_URL=http://localhost:5000 2 | -------------------------------------------------------------------------------- /user-auth/app/models/db.py: -------------------------------------------------------------------------------- 1 | from flask_sqlalchemy import SQLAlchemy 2 | db = SQLAlchemy() 3 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "python.pythonPath": "/Users/mikehat/.pyenv/versions/3.9.4/bin/python" 3 | } -------------------------------------------------------------------------------- /user-auth/.dockerignore: -------------------------------------------------------------------------------- 1 | react-app/node_modules 2 | .venv 3 | Pipfile 4 | Pipfile.lock 5 | .env 6 | .DS_Store 7 | -------------------------------------------------------------------------------- /user-auth/architecture.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/John-Witter/news-connector/HEAD/user-auth/architecture.pdf -------------------------------------------------------------------------------- /news-connector db schema (3).png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/John-Witter/news-connector/HEAD/news-connector db schema (3).png -------------------------------------------------------------------------------- /user-auth/react-app/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/John-Witter/news-connector/HEAD/user-auth/react-app/public/favicon.ico -------------------------------------------------------------------------------- /user-auth/react-app/src/components/splash/splashImage.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/John-Witter/news-connector/HEAD/user-auth/react-app/src/components/splash/splashImage.jpg -------------------------------------------------------------------------------- /user-auth/app/models/__init__.py: -------------------------------------------------------------------------------- 1 | from .db import db 2 | from .user import User 3 | from.tag import Tag 4 | from .saved import Saved 5 | from .interest import Interest 6 | from .homeData import HomeData -------------------------------------------------------------------------------- /user-auth/.env.example: -------------------------------------------------------------------------------- 1 | FLASK_APP=app 2 | FLASK_ENV=development 3 | SECRET_KEY=lkasjdf09ajsdkfljalsiorj12n3490re9485309irefvn,u90818734902139489230 4 | DATABASE_URL=postgresql://starter_app_dev@localhost/starter_app 5 | -------------------------------------------------------------------------------- /user-auth/app/forms/__init__.py: -------------------------------------------------------------------------------- 1 | from .login_form import LoginForm 2 | from .signup_form import SignUpForm 3 | from .saved_form import SavedForm 4 | from .tag_form import TagForm 5 | from .interest_form import InterestForm -------------------------------------------------------------------------------- /user-auth/.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "python.pythonPath": ".venv/bin/python", 3 | "python.linting.pylintEnabled": false, 4 | "python.linting.enabled": true, 5 | "python.linting.pycodestyleEnabled": true 6 | } -------------------------------------------------------------------------------- /user-auth/app/config.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | 4 | class Config: 5 | SECRET_KEY = os.environ.get('SECRET_KEY') 6 | SQLALCHEMY_TRACK_MODIFICATIONS = False 7 | SQLALCHEMY_DATABASE_URI = os.environ.get('DATABASE_URL') 8 | SQLALCHEMY_ECHO = True 9 | -------------------------------------------------------------------------------- /user-auth/app/forms/tag_form.py: -------------------------------------------------------------------------------- 1 | from flask_wtf import FlaskForm 2 | from wtforms import StringField, IntegerField 3 | from wtforms.validators import DataRequired 4 | 5 | 6 | class TagForm(FlaskForm): 7 | title = StringField('title', [DataRequired()]) 8 | -------------------------------------------------------------------------------- /user-auth/app/forms/interest_form.py: -------------------------------------------------------------------------------- 1 | from flask_wtf import FlaskForm 2 | from wtforms import StringField, IntegerField 3 | from wtforms.validators import DataRequired 4 | 5 | class InterestForm(FlaskForm): 6 | userId = IntegerField('userId', [DataRequired()]) 7 | title = StringField('title', [DataRequired()]) -------------------------------------------------------------------------------- /user-auth/app/api/default_routes.py: -------------------------------------------------------------------------------- 1 | from flask import Blueprint 2 | from app.models import HomeData 3 | 4 | default_routes = Blueprint('homeData', __name__) 5 | 6 | @default_routes.route('/') 7 | def get_defaults(): 8 | defaults = HomeData.query.all() 9 | return {"defaults": [item.to_dict() for item in defaults]} 10 | -------------------------------------------------------------------------------- /user-auth/app/models/tag.py: -------------------------------------------------------------------------------- 1 | from .db import db 2 | 3 | class Tag(db.Model): 4 | __tablename__ = 'tags' 5 | 6 | id = db.Column(db.Integer, primary_key=True) 7 | title = db.Column(db.String, nullable=False, unique=True) 8 | 9 | def to_dict(self): 10 | return { 11 | "id": self.id, 12 | "title": self.title 13 | } -------------------------------------------------------------------------------- /user-auth/app/api/tag_routes.py: -------------------------------------------------------------------------------- 1 | from flask import Blueprint 2 | from app.models import db, Tag 3 | from app.forms import TagForm 4 | 5 | tag_routes = Blueprint('tags', __name__) 6 | 7 | 8 | # return all tags in the db 9 | @tag_routes.route('/', methods=["GET"]) 10 | def get_tags(): 11 | db_tags = Tag.query.all() 12 | return {"tags": [tags.to_dict() for tags in db_tags]} -------------------------------------------------------------------------------- /user-auth/app/forms/update_interest_form.py: -------------------------------------------------------------------------------- 1 | from flask_wtf import FlaskForm 2 | from wtforms import StringField, IntegerField 3 | from wtforms.validators import DataRequired 4 | 5 | 6 | class UpdateInterestForm(FlaskForm): 7 | userId = IntegerField('userId', [DataRequired()]) 8 | interestId = IntegerField('interestId', [DataRequired()]) 9 | title = StringField('title', [DataRequired()]) 10 | -------------------------------------------------------------------------------- /user-auth/react-app/src/components/auth/logoutButton.css: -------------------------------------------------------------------------------- 1 | .logoutBtn { 2 | /* background-color: #e3e2d7; */ 3 | color: #416894; 4 | font-size: 1rem; 5 | border: none; 6 | padding: 0; 7 | margin: 0; 8 | font-family: sans-serif; 9 | align-self: flex-start; 10 | } 11 | 12 | .logoutBtn:hover { 13 | cursor: pointer; 14 | text-decoration: underline; 15 | color: #4b256b; 16 | } -------------------------------------------------------------------------------- /user-auth/react-app/src/components/splash/Splash.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import './splash.css' 3 | 4 | const Splash = () => { 5 | return ( 6 |
7 |

Connect to Your Interests

8 |
9 | 10 |
11 |
12 | ) 13 | } 14 | 15 | export default Splash 16 | -------------------------------------------------------------------------------- /user-auth/react-app/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /build 13 | 14 | # misc 15 | .DS_Store 16 | .env.local 17 | .env.development.local 18 | .env.test.local 19 | .env.production.local 20 | 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | -------------------------------------------------------------------------------- /user-auth/react-app/src/components/auth/ProtectedRoute.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { useSelector } from 'react-redux'; 3 | import { Route, Redirect } from 'react-router-dom'; 4 | 5 | const ProtectedRoute = props => { 6 | const user = useSelector(state => state.session.user) 7 | return ( 8 | 9 | {(user)? props.children : } 10 | 11 | ) 12 | }; 13 | 14 | 15 | export default ProtectedRoute; 16 | -------------------------------------------------------------------------------- /user-auth/react-app/src/components/footer/footer.css: -------------------------------------------------------------------------------- 1 | .footer{ 2 | 3 | } 4 | 5 | .footerUL { 6 | display: flex; 7 | justify-content: space-around; 8 | align-items: center; 9 | } 10 | 11 | .footerLI { 12 | list-style: none; 13 | } 14 | 15 | .linkText { 16 | color: #416894; 17 | letter-spacing: .03125rem; 18 | font-weight: 500; 19 | font-size: 1rem; 20 | } 21 | 22 | .footerLI:hover { 23 | cursor: pointer; 24 | text-decoration: underline; 25 | } -------------------------------------------------------------------------------- /user-auth/react-app/src/components/title/title.css: -------------------------------------------------------------------------------- 1 | .titleContainer { 2 | width: 100%; 3 | display: flex; 4 | justify-content: center; 5 | align-items: center; 6 | margin: 0 auto; 7 | } 8 | 9 | .title-link { 10 | margin: 0 auto; 11 | } 12 | 13 | .title { 14 | color: #7E3EB3; 15 | font-family: cursive; 16 | } 17 | 18 | .title:hover { 19 | /* font-weight: bolder; */ 20 | color: #4b256b; 21 | } 22 | 23 | a { 24 | font-size: 1.5rem; 25 | } -------------------------------------------------------------------------------- /user-auth/react-app/src/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import { Provider } from 'react-redux' 4 | import './components/content.css' 5 | import App from './App'; 6 | import configureStore from './store'; 7 | 8 | const store = configureStore(); 9 | 10 | ReactDOM.render( 11 | 12 | 13 | 14 | 15 | , 16 | document.getElementById('root') 17 | ); 18 | -------------------------------------------------------------------------------- /user-auth/app/models/interest.py: -------------------------------------------------------------------------------- 1 | from .db import db 2 | 3 | class Interest(db.Model): 4 | __tablename__ = 'interests' 5 | 6 | id = db.Column(db.Integer, primary_key=True) 7 | title = db.Column(db.String, nullable=False, unique=True) 8 | userId = db.Column(db.Integer, db.ForeignKey('users.id'),nullable=False) 9 | 10 | def to_dict(self): 11 | return { 12 | 'id': self.id, 13 | 'title': self.title, 14 | 'userId': self.userId 15 | } -------------------------------------------------------------------------------- /user-auth/react-app/src/components/auth/LogoutButton.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { useDispatch } from "react-redux"; 3 | import { logout } from "../../store/session"; 4 | import './logoutButton.css' 5 | 6 | const LogoutButton = () => { 7 | const dispatch = useDispatch(); 8 | const onLogout = async (e) => { 9 | await dispatch(logout()); 10 | }; 11 | 12 | return ; 13 | }; 14 | 15 | export default LogoutButton; 16 | -------------------------------------------------------------------------------- /user-auth/app/api/user_routes.py: -------------------------------------------------------------------------------- 1 | from flask import Blueprint, jsonify 2 | from flask_login import login_required 3 | from app.models import User 4 | 5 | user_routes = Blueprint('users', __name__) 6 | 7 | 8 | @user_routes.route('/') 9 | @login_required 10 | def users(): 11 | users = User.query.all() 12 | return {"users": [user.to_dict() for user in users]} 13 | 14 | 15 | @user_routes.route('/') 16 | @login_required 17 | def user(id): 18 | user = User.query.get(id) 19 | return user.to_dict() 20 | -------------------------------------------------------------------------------- /user-auth/app/forms/saved_form.py: -------------------------------------------------------------------------------- 1 | from flask_wtf import FlaskForm 2 | from wtforms import StringField, IntegerField 3 | from wtforms.validators import DataRequired 4 | 5 | class SavedForm(FlaskForm): 6 | userId = IntegerField('userId', [DataRequired()]) 7 | itemURL = StringField('itemURL', [DataRequired()]) 8 | imageURL = StringField('imageURL', [DataRequired()]) 9 | title = StringField('title', [DataRequired()]) 10 | description = StringField('description') 11 | contentSource = StringField('contentSource') 12 | publishedTime = StringField('publishedTime') -------------------------------------------------------------------------------- /user-auth/react-app/README.md: -------------------------------------------------------------------------------- 1 | This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app). 2 | 3 | Your React App will live here. While is development, run this application from this location using `npm start`. 4 | 5 | 6 | No environment variables are needed to run this application in development, but be sure to set the REACT_APP_BASE_URL environment variable in heroku! 7 | 8 | This app will be automatically built when you deploy to heroku, please see the `heroku-postbuild` script in your `express.js` applications `package.json` to see how this works. 9 | -------------------------------------------------------------------------------- /user-auth/react-app/src/components/title/Title.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { NavLink } from 'react-router-dom' 3 | import LogoutButton from '../auth/LogoutButton' 4 | import './title.css' 5 | 6 | const Title = () => { 7 | return ( 8 |
9 | 10 |

news-connector

11 |
12 | {/* < LogoutButton /> */} 13 |
14 | ) 15 | } 16 | 17 | export default Title 18 | -------------------------------------------------------------------------------- /user-auth/react-app/src/components/interests/DeleteInterest.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { useDispatch } from "react-redux"; 3 | import { removeInterest } from "../../store/interests"; 4 | 5 | const DeleteInterest = ({ userId, interest}) => { 6 | const dispatch = useDispatch() 7 | const handleDelete = () => { 8 | dispatch(removeInterest(userId, interest.id, interest.title)) 9 | } 10 | return ( 11 | 14 | ) 15 | } 16 | 17 | export default DeleteInterest 18 | -------------------------------------------------------------------------------- /user-auth/app/models/userTag.py: -------------------------------------------------------------------------------- 1 | from .db import db 2 | # from .user import User 3 | from .tag import Tag 4 | 5 | class UserTag(db.Model): 6 | __tablename__ = 'userTags' 7 | id = db.Column(db.Integer, primary_key=True) 8 | userId = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False) 9 | tagId = db.Column(db.Integer, db.ForeignKey('tags.id'), nullable=False) 10 | 11 | 12 | def to_dict(self): 13 | return { 14 | "id": self.id, 15 | "userId": self.userId, 16 | "tag": Tag.query.filter(Tag.id == self.tagId).first().to_dict(), 17 | } 18 | -------------------------------------------------------------------------------- /user-auth/migrations/script.py.mako: -------------------------------------------------------------------------------- 1 | """${message} 2 | 3 | Revision ID: ${up_revision} 4 | Revises: ${down_revision | comma,n} 5 | Create Date: ${create_date} 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | ${imports if imports else ""} 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = ${repr(up_revision)} 14 | down_revision = ${repr(down_revision)} 15 | branch_labels = ${repr(branch_labels)} 16 | depends_on = ${repr(depends_on)} 17 | 18 | 19 | def upgrade(): 20 | ${upgrades if upgrades else "pass"} 21 | 22 | 23 | def downgrade(): 24 | ${downgrades if downgrades else "pass"} 25 | -------------------------------------------------------------------------------- /user-auth/app/models/homeData.py: -------------------------------------------------------------------------------- 1 | from .db import db 2 | 3 | 4 | class HomeData(db.Model): 5 | __tablename__ = 'homeData' 6 | 7 | id = db.Column(db.Integer, primary_key=True) 8 | itemURL = db.Column(db.String, nullable=False, unique=True) 9 | imageURL = db.Column(db.String, unique=True) 10 | title = db.Column(db.String, nullable=False) 11 | description = db.Column(db.String) 12 | 13 | def to_dict(self): 14 | return { 15 | "id": self.id, 16 | "itemURL": self.itemURL, 17 | "imageURL": self.imageURL, 18 | "title": self.title, 19 | "description": self.description 20 | } 21 | -------------------------------------------------------------------------------- /user-auth/app/seeds/__init__.py: -------------------------------------------------------------------------------- 1 | from flask.cli import AppGroup 2 | from .users import seed_users, undo_users 3 | from .tags import seed_tags, undo_tags 4 | from .default import seed_default 5 | 6 | # Creates a seed group to hold our commands 7 | # So we can type `flask seed --help` 8 | seed_commands = AppGroup('seed') 9 | 10 | # Creates the `flask seed all` command 11 | @seed_commands.command('all') 12 | def seed(): 13 | seed_users() 14 | seed_default() 15 | # Add other seed functions here 16 | seed_tags() 17 | 18 | # Creates the `flask seed undo` command 19 | @seed_commands.command('undo') 20 | def undo(): 21 | undo_users() 22 | # Add other undo functions here 23 | undo_tags() -------------------------------------------------------------------------------- /user-auth/app/seeds/users.py: -------------------------------------------------------------------------------- 1 | from werkzeug.security import generate_password_hash 2 | from app.models import db, User 3 | 4 | # Adds a demo user, you can add other users here if you want 5 | def seed_users(): 6 | 7 | demo = User(username='Demo', email='demo@aa.io', 8 | password='password') 9 | 10 | db.session.add(demo) 11 | 12 | db.session.commit() 13 | 14 | # Uses a raw SQL query to TRUNCATE the users table. 15 | # SQLAlchemy doesn't have a built in function to do this 16 | # TRUNCATE Removes all the data from the table, and resets 17 | # the auto incrementing primary key 18 | def undo_users(): 19 | db.session.execute('TRUNCATE users RESTART IDENTITY CASCADE;') 20 | db.session.commit() 21 | -------------------------------------------------------------------------------- /user-auth/app/forms/signup_form.py: -------------------------------------------------------------------------------- 1 | from flask_wtf import FlaskForm 2 | from wtforms import StringField 3 | from wtforms.validators import DataRequired, Email, ValidationError 4 | from app.models import User 5 | 6 | 7 | def user_exists(form, field): 8 | print("Checking if user exits", field.data) 9 | email = field.data 10 | user = User.query.filter(User.email == email).first() 11 | if user: 12 | raise ValidationError("User is already registered.") 13 | 14 | 15 | class SignUpForm(FlaskForm): 16 | username = StringField('username', validators=[DataRequired()]) 17 | email = StringField('email', validators=[DataRequired(), user_exists]) 18 | password = StringField('password', validators=[DataRequired()]) 19 | -------------------------------------------------------------------------------- /user-auth/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:12 AS build-stage 2 | 3 | WORKDIR /react-app 4 | COPY react-app/. . 5 | 6 | # You have to set this because it should be set during build time. 7 | ENV REACT_APP_BASE_URL="https://news-connector.herokuapp.com/" 8 | 9 | # Build our React App 10 | RUN npm install 11 | RUN npm run build 12 | 13 | FROM python:3.8 14 | 15 | # Setup Flask environment 16 | ENV FLASK_APP=app 17 | ENV FLASK_ENV=production 18 | ENV SQLALCHEMY_ECHO=True 19 | 20 | EXPOSE 8000 21 | 22 | WORKDIR /var/www 23 | COPY . . 24 | COPY --from=build-stage /react-app/build/* app/static/ 25 | 26 | # Install Python Dependencies 27 | RUN pip install -r requirements.txt 28 | RUN pip install psycopg2 29 | 30 | # Run flask environment 31 | CMD gunicorn app:app 32 | -------------------------------------------------------------------------------- /user-auth/react-app/src/components/auth/loginForm.css: -------------------------------------------------------------------------------- 1 | .login-outermost-container { 2 | height: 75vh; 3 | width: 100vw; 4 | margin-top: 2rem; 5 | } 6 | 7 | .login-form { 8 | height: 100%; 9 | /* padding-left: 10px; */ 10 | width: 100%; 11 | display: flex; 12 | flex-direction: column; 13 | align-items: center; 14 | } 15 | 16 | .login-input-container { 17 | width: 310px; 18 | display: flex; 19 | } 20 | 21 | .login-input-label { 22 | margin-right: 41px; 23 | } 24 | 25 | .login-input { 26 | color: #000; 27 | } 28 | 29 | 30 | .password-input-container { 31 | width: 300px; 32 | display: flex; 33 | } 34 | 35 | .password-input-label { 36 | margin-right: 10px; 37 | } 38 | 39 | .password-input { 40 | margin-right: 15px; 41 | } -------------------------------------------------------------------------------- /user-auth/react-app/src/components/splash/splash.css: -------------------------------------------------------------------------------- 1 | .mainImageContainer { 2 | display: flex; 3 | align-content: center; 4 | flex-direction: column; 5 | width: 100vw; 6 | } 7 | 8 | .tagLine { 9 | display: block; 10 | align-self: center; 11 | margin-top: -1rem; 12 | margin-bottom: 2rem; 13 | font-weight: 540; 14 | font-size: 1.125rem; 15 | } 16 | 17 | .mainImage { 18 | background: linear-gradient(rgba(0, 0, 0, 0.1), rgba(0, 0, 0, 0.3)), url("./splashImage.jpg"); 19 | /* background-image: url("https://images.unsplash.com/photo-1490775949603-0e355e8e01ba?ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&ixlib=rb-1.2.1&auto=format&fit=crop&w=1047&q=80"); */ 20 | background-size: contain; 21 | height: 33rem; 22 | width: 49rem; 23 | margin: 0 auto; 24 | 25 | } -------------------------------------------------------------------------------- /user-auth/migrations/versions/20210722_173642_.py: -------------------------------------------------------------------------------- 1 | """empty message 2 | 3 | Revision ID: c5ab4546c252 4 | Revises: 486b230f1f36 5 | Create Date: 2021-07-22 17:36:42.126805 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = 'c5ab4546c252' 14 | down_revision = '6735414ea296' 15 | branch_labels = None 16 | depends_on = None 17 | 18 | 19 | def upgrade(): 20 | # ### commands auto generated by Alembic - please adjust! ### 21 | op.add_column('saved', sa.Column('contentSource', sa.String(), nullable=True)) 22 | # ### end Alembic commands ### 23 | 24 | 25 | def downgrade(): 26 | # ### commands auto generated by Alembic - please adjust! ### 27 | op.drop_column('saved', 'contentSource') 28 | # ### end Alembic commands ### 29 | -------------------------------------------------------------------------------- /user-auth/migrations/versions/20210816_151855_.py: -------------------------------------------------------------------------------- 1 | """empty message 2 | 3 | Revision ID: 9fd082dd664d 4 | Revises: c5ab4546c252 5 | Create Date: 2021-08-16 15:18:55.490223 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = '9fd082dd664d' 14 | down_revision = 'c5ab4546c252' 15 | branch_labels = None 16 | depends_on = None 17 | 18 | 19 | def upgrade(): 20 | # ### commands auto generated by Alembic - please adjust! ### 21 | op.add_column('saved', sa.Column('publishedTime', sa.String(), nullable=True)) 22 | # ### end Alembic commands ### 23 | 24 | 25 | def downgrade(): 26 | # ### commands auto generated by Alembic - please adjust! ### 27 | op.drop_column('saved', 'publishedTime') 28 | # ### end Alembic commands ### 29 | -------------------------------------------------------------------------------- /user-auth/react-app/src/components/auth/signUpForm.css: -------------------------------------------------------------------------------- 1 | .signup-outermost-container { 2 | height: 75vh; 3 | width: 100vw; 4 | margin-top: 2rem; 5 | } 6 | 7 | .signup-form { 8 | height: 100%; 9 | /* padding-left: 10px; */ 10 | width: 100%; 11 | display: flex; 12 | flex-direction: column; 13 | align-items: center; 14 | } 15 | 16 | .name-input-container { 17 | width: 310px; 18 | } 19 | 20 | .name-label { 21 | margin-right: 59px; 22 | } 23 | 24 | .email-input-container { 25 | width: 310px; 26 | } 27 | 28 | .email-label { 29 | margin-right: 101px; 30 | } 31 | 32 | .password-input-container { 33 | width: 310px; 34 | } 35 | 36 | .password-label { 37 | margin-right: 70px; 38 | } 39 | 40 | .repeat-password-input-container { 41 | width: 310px; 42 | } 43 | 44 | .repeat-password-label { 45 | margin-right: 10px; 46 | } -------------------------------------------------------------------------------- /user-auth/app/api/weather_routes.py: -------------------------------------------------------------------------------- 1 | from flask import Blueprint 2 | import requests 3 | import os 4 | 5 | weather_routes = Blueprint('weather', __name__) 6 | 7 | 8 | @weather_routes.route('/', methods=['GET']) 9 | def get_weather(loc): 10 | api_key = os.environ.get('WEATHER_API') 11 | 12 | # https://ipapi.co/api/?python#location-of-clients-ip 13 | try: 14 | # response = requests.get('https://ipapi.co/json/').json() 15 | # lat = response['latitude'] 16 | # lon = response['longitude'] 17 | strToArr = loc.split('+') 18 | url = f'http://api.openweathermap.org/data/2.5/onecall?lat={strToArr[1]}&lon={strToArr[2]}&appid={api_key}&units=imperial' 19 | 20 | res = requests.get(url) 21 | 22 | return {'weather': res.json(), 'location': strToArr} 23 | 24 | except: 25 | return {'error': res.json()} 26 | -------------------------------------------------------------------------------- /user-auth/react-app/src/components/gifs/Gif.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import AddToSaved from '../saved/AddToSaved' 3 | import './gifs.css' 4 | import '../content.css' 5 | 6 | const Gif = ({ gif }) => { 7 | gif['itemURL'] = gif.url 8 | gif['imageURL'] = gif.images.original.url 9 | gif['contentSource'] = 'Giphy' 10 | 11 | return ( 12 |
13 |

14 | {gif.title} 15 | Giphy 16 |

17 | 18 | {gif.title} 19 | 20 | 21 |
22 | ) 23 | } 24 | 25 | export default Gif 26 | -------------------------------------------------------------------------------- /user-auth/react-app/src/store/default.js: -------------------------------------------------------------------------------- 1 | // constants 2 | const READ_DEFAULT = 'default/READ_DEFAULT' 3 | 4 | // actions 5 | const readDefault = (articles) => ({ 6 | type: READ_DEFAULT, 7 | articles 8 | }) 9 | 10 | // thunks 11 | export const loadDefault = () => async (dispatch) => { 12 | const res = await fetch ('/api/homeData/') 13 | 14 | if (res.ok) { 15 | const data = await res.json() 16 | dispatch(readDefault(data)) 17 | } 18 | } 19 | 20 | // reducer 21 | export default function DefaultReducer (state={}, action) { 22 | let newState = {...state} 23 | 24 | switch (action.type) { 25 | case READ_DEFAULT: 26 | action.articles.defaults.forEach(article => { 27 | newState[article.id] = article 28 | }) 29 | return newState 30 | default: 31 | return state 32 | } 33 | } -------------------------------------------------------------------------------- /user-auth/Pipfile: -------------------------------------------------------------------------------- 1 | [[source]] 2 | url = "https://pypi.org/simple" 3 | verify_ssl = true 4 | name = "pypi" 5 | 6 | [packages] 7 | click = "==7.1.2" 8 | gunicorn = "==20.0.4" 9 | itsdangerous = "==1.1.0" 10 | python-dotenv = "==0.14.0" 11 | six = "==1.15.0" 12 | Flask = "==1.1.2" 13 | Flask-Cors = "==3.0.8" 14 | Flask-SQLAlchemy = "==2.4.4" 15 | Flask-WTF = "==0.14.3" 16 | Jinja2 = "==2.11.2" 17 | MarkupSafe = "==1.1.1" 18 | SQLAlchemy = "==1.3.19" 19 | Werkzeug = "==1.0.1" 20 | WTForms = "==2.3.3" 21 | Flask-JWT-Extended = "==3.24.1" 22 | email-validator = "*" 23 | Flask-Migrate = "==2.5.3" 24 | Flask-Login = "==0.5.0" 25 | alembic = "==1.4.3" 26 | python-dateutil = "==2.8.1" 27 | python-editor = "==1.0.4" 28 | Mako = "==1.1.3" 29 | PyJWT = "==1.7.1" 30 | requests = "*" 31 | 32 | [dev-packages] 33 | psycopg2-binary = "==2.8.6" 34 | autopep8 = "*" 35 | pylint = "*" 36 | 37 | [requires] 38 | python_version = "3.9" 39 | -------------------------------------------------------------------------------- /user-auth/react-app/src/components/navbar.css: -------------------------------------------------------------------------------- 1 | /* 2 | navbar links 3 | interests 4 | */ 5 | 6 | .navbar { 7 | display: flex; 8 | flex-direction: row; 9 | justify-content: space-around; 10 | align-items: center; 11 | } 12 | 13 | .navbar li { 14 | list-style: none; 15 | font-family: sans-serif; 16 | letter-spacing: .03125rem; 17 | } 18 | 19 | .navbar li a { 20 | font-size: 1rem; 21 | } 22 | 23 | .authLink { 24 | letter-spacing: .03125rem; 25 | color: #416894; 26 | } 27 | 28 | .demoBtn { 29 | color: #416894; 30 | font-size: 1rem; 31 | } 32 | 33 | .navbar li:hover { 34 | cursor: pointer; 35 | text-decoration: underline; 36 | /* font-weight: bolder; 37 | color: #4b256b; */ 38 | } 39 | 40 | .demoBtn:hover { 41 | cursor: pointer; 42 | text-decoration: underline; 43 | /* font-weight: bolder; 44 | color: #4b256b; */ 45 | } 46 | 47 | /* .interests { 48 | 49 | } */ -------------------------------------------------------------------------------- /user-auth/react-app/src/components/Google Analytics/GoogleAnalytics.js: -------------------------------------------------------------------------------- 1 | // Google Analytics 2 | // Initialize ReactGA with standardImplementation: true option. 3 | // https://www.npmjs.com/package/react-ga?activeTab=readme 4 | // https://malith-dev.medium.com/track-users-in-your-react-app-with-google-analytics-6364ebfcbae8 5 | 6 | import ReactGA from "react-ga"; 7 | 8 | // method to inititialize ReactGA 9 | export const initGA = (trackingID) => { 10 | ReactGA.initialize("UA-213353187-1"); 11 | }; 12 | 13 | // Page View 14 | export const PageView = () => { 15 | ReactGA.pageview(window.location.pathname + window.location.search); 16 | }; 17 | 18 | /** 19 | * Event - Add custom tracking event. 20 | * @param {string} category 21 | * @param {string} action 22 | * @param {string} label 23 | */ 24 | export const Event = (category, action, label) => { 25 | ReactGA.event({ 26 | category: category, 27 | action: action, 28 | label: label 29 | }); 30 | }; -------------------------------------------------------------------------------- /user-auth/app/models/saved.py: -------------------------------------------------------------------------------- 1 | from .db import db 2 | 3 | class Saved(db.Model): 4 | __tablename__ = 'saved' 5 | 6 | id = db.Column(db.Integer, primary_key=True) 7 | userId = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False) 8 | itemURL = db.Column(db.String, nullable=False, unique=True) 9 | imageURL = db.Column(db.String, nullable=False) 10 | title = db.Column(db.String, nullable=False) 11 | description = db.Column(db.String) 12 | contentSource = db.Column(db.String) 13 | publishedTime = db.Column(db.String) 14 | 15 | def to_dict(self): 16 | return { 17 | "id": self.id, 18 | "userId": self.userId, 19 | "itemURL": self.itemURL, 20 | "imageURL": self.imageURL, 21 | "title": self.title, 22 | "description": self.description, 23 | "contentSource": self.contentSource, 24 | "publishedTime": self.publishedTime 25 | } 26 | -------------------------------------------------------------------------------- /user-auth/react-app/src/components/User.js: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from "react"; 2 | import { useParams } from "react-router-dom"; 3 | 4 | function User() { 5 | const [user, setUser] = useState({}); 6 | // Notice we use useParams here instead of getting the params 7 | // From props. 8 | const { userId } = useParams(); 9 | 10 | useEffect(() => { 11 | if (!userId) { 12 | return 13 | } 14 | (async () => { 15 | const response = await fetch(`/api/users/${userId}`); 16 | const user = await response.json(); 17 | setUser(user); 18 | })(); 19 | }, [userId]); 20 | 21 | if (!user) { 22 | return null; 23 | } 24 | 25 | return ( 26 | 37 | ); 38 | } 39 | export default User; 40 | -------------------------------------------------------------------------------- /user-auth/react-app/src/components/gifs/gifs.css: -------------------------------------------------------------------------------- 1 | h2 { 2 | /* font-size: 16px; 3 | color: #7e3eb3; */ 4 | } 5 | 6 | img { 7 | max-width: 66%; 8 | } 9 | 10 | a { 11 | text-decoration: none; 12 | display: flex; 13 | flex-direction: column; 14 | } 15 | 16 | .gifTitle { 17 | text-decoration: none; 18 | color: #416894; 19 | display: flex; 20 | flex-direction: column; 21 | align-items: center; 22 | text-align: left; 23 | font-size: 1rem; 24 | padding: 0 2rem; 25 | } 26 | 27 | .gifSource { 28 | text-decoration: none; 29 | color: #416894; 30 | display: flex; 31 | flex-direction: column; 32 | align-items: flex-start; 33 | text-align: left; 34 | font-size: .75rem; 35 | padding: 0 2rem; 36 | padding-top: .5rem; 37 | font-weight: 300; 38 | } 39 | 40 | .gifTitle:hover { 41 | /* font-size: larger; */ 42 | /* color: #8a51ba; */ 43 | text-decoration: underline; 44 | } 45 | 46 | .gifSource:hover { 47 | text-decoration: underline; 48 | } -------------------------------------------------------------------------------- /user-auth/app/seeds/tags.py: -------------------------------------------------------------------------------- 1 | from app.models import db, Tag 2 | 3 | # Tags are only seeded / users can't add tags 4 | def seed_tags(): 5 | 6 | business = Tag(title='business') 7 | entertainment = Tag(title='entertainment') 8 | general = Tag(title='general') 9 | health = Tag(title='health') 10 | science = Tag(title='science') 11 | sports = Tag(title='sports') 12 | technology = Tag(title='technology') 13 | 14 | db.session.add(business) 15 | db.session.add(entertainment) 16 | db.session.add(general) 17 | db.session.add(health) 18 | db.session.add(science) 19 | db.session.add(sports) 20 | db.session.add(technology) 21 | db.session.commit() 22 | 23 | # Uses a raw SQL query to TRUNCATE the users table. 24 | # SQLAlchemy doesn't have a built in function to do this 25 | # TRUNCATE Removes all the data from the table, and resets 26 | # the auto incrementing primary key 27 | 28 | def undo_tags(): 29 | db.session.execute('TRUNCATE users RESTART IDENTITY CASCADE;') 30 | db.session.commit() 31 | -------------------------------------------------------------------------------- /user-auth/migrations/alembic.ini: -------------------------------------------------------------------------------- 1 | # A generic, single database configuration. 2 | 3 | [alembic] 4 | # template used to generate migration files 5 | file_template = %%(year)d%%(month).2d%%(day).2d_%%(hour).2d%%(minute).2d%%(second).2d_%%(slug)s 6 | 7 | # set to 'true' to run the environment during 8 | # the 'revision' command, regardless of autogenerate 9 | # revision_environment = false 10 | 11 | 12 | # Logging configuration 13 | [loggers] 14 | keys = root,sqlalchemy,alembic 15 | 16 | [handlers] 17 | keys = console 18 | 19 | [formatters] 20 | keys = generic 21 | 22 | [logger_root] 23 | level = WARN 24 | handlers = console 25 | qualname = 26 | 27 | [logger_sqlalchemy] 28 | level = WARN 29 | handlers = 30 | qualname = sqlalchemy.engine 31 | 32 | [logger_alembic] 33 | level = INFO 34 | handlers = 35 | qualname = alembic 36 | 37 | [handler_console] 38 | class = StreamHandler 39 | args = (sys.stderr,) 40 | level = NOTSET 41 | formatter = generic 42 | 43 | [formatter_generic] 44 | format = %(levelname)-5.5s [%(name)s] %(message)s 45 | datefmt = %H:%M:%S 46 | -------------------------------------------------------------------------------- /user-auth/app/forms/login_form.py: -------------------------------------------------------------------------------- 1 | from flask_wtf import FlaskForm 2 | from wtforms import StringField 3 | from wtforms.validators import DataRequired, Email, ValidationError 4 | from app.models import User 5 | 6 | 7 | def user_exists(form, field): 8 | print("Checking if user exists", field.data) 9 | email = field.data 10 | user = User.query.filter(User.email == email).first() 11 | if not user: 12 | raise ValidationError("Email provided not found.") 13 | 14 | 15 | def password_matches(form, field): 16 | print("Checking if password matches") 17 | password = field.data 18 | email = form.data['email'] 19 | user = User.query.filter(User.email == email).first() 20 | if not user: 21 | raise ValidationError("No such user exists.") 22 | if not user.check_password(password): 23 | raise ValidationError("Password was incorrect.") 24 | 25 | 26 | class LoginForm(FlaskForm): 27 | email = StringField('email', validators=[DataRequired(), user_exists]) 28 | password = StringField('password', validators=[ 29 | DataRequired(), password_matches]) 30 | -------------------------------------------------------------------------------- /user-auth/requirements.txt: -------------------------------------------------------------------------------- 1 | # 2 | # These requirements were autogenerated by pipenv 3 | # To regenerate from the project's Pipfile, run: 4 | # 5 | # pipenv lock --requirements 6 | # 7 | 8 | -i https://pypi.org/simple 9 | alembic==1.4.3 10 | certifi==2021.5.30 11 | chardet==4.0.0; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4' 12 | click==7.1.2 13 | dnspython==2.1.0; python_version >= '3.6' 14 | email-validator==1.1.3 15 | flask-cors==3.0.8 16 | flask-jwt-extended==3.24.1 17 | flask-login==0.5.0 18 | flask-migrate==2.5.3 19 | flask-sqlalchemy==2.4.4 20 | flask-wtf==0.14.3 21 | flask==1.1.2 22 | gunicorn==20.0.4 23 | idna==2.10; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3' 24 | itsdangerous==1.1.0 25 | jinja2==2.11.2 26 | mako==1.1.3 27 | markupsafe==1.1.1 28 | pyjwt==1.7.1 29 | python-dateutil==2.8.1 30 | python-dotenv==0.14.0 31 | python-editor==1.0.4 32 | requests==2.25.1 33 | six==1.15.0 34 | sqlalchemy==1.3.19 35 | urllib3==1.26.6; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4' and python_version < '4' 36 | werkzeug==1.0.1 37 | wtforms==2.3.3 38 | -------------------------------------------------------------------------------- /user-auth/react-app/src/components/articles/Headline.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import AddToSaved from '../saved/AddToSaved' 3 | import './articles.css' 4 | import '../content.css' 5 | 6 | const Headline = ({ article }) => { 7 | article['itemURL'] = article['url'] 8 | article['imageURL'] = article['urlToImage'] 9 | article['contentSource'] = article.source.name 10 | 11 | return ( 12 |
13 |

14 | {article.title} 15 | {article.contentSource} {article.publishedAt.split('T')[0]} 16 |

17 | 18 | {article.title} 19 | 20 |
21 | {article.description} 22 |
23 | 24 |
25 | ) 26 | } 27 | 28 | export default Headline 29 | -------------------------------------------------------------------------------- /user-auth/react-app/src/components/articles/Article.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import AddToSaved from '../saved/AddToSaved' 3 | import './articles.css' 4 | import '../content.css' 5 | 6 | 7 | const Article = ({ article }) => { 8 | 9 | article['itemURL'] = article['url'] 10 | article['imageURL'] = article['urlToImage'] 11 | article['contentSource'] = article.source.name 12 | 13 | return ( 14 |
15 |

16 | {article.title} 17 | {article.contentSource} {article.publishedAt.split('T')[0]} 18 |

19 | 20 | {article.title} 21 | 22 |
23 | {article.description} 24 |
25 | 26 |
27 | ) 28 | } 29 | 30 | export default Article 31 | -------------------------------------------------------------------------------- /user-auth/react-app/src/store/index.js: -------------------------------------------------------------------------------- 1 | import { createStore, combineReducers, applyMiddleware, compose } from "redux"; 2 | import thunk from "redux-thunk"; 3 | import session from "./session" 4 | import InterestReducer from "./interests"; 5 | import ArticleReducer from "./articles"; 6 | import SavedReducer from "./saved"; 7 | import DefaultReducer from "./default"; 8 | import WeatherReducer from "./weather"; 9 | 10 | const rootReducer = combineReducers({ 11 | session, 12 | interest: InterestReducer, 13 | article: ArticleReducer, 14 | saved: SavedReducer, 15 | default: DefaultReducer, 16 | weather: WeatherReducer 17 | }); 18 | 19 | 20 | let enhancer; 21 | 22 | if (process.env.NODE_ENV === 'production') { 23 | enhancer = applyMiddleware(thunk); 24 | } else { 25 | const logger = require('redux-logger').default; 26 | const composeEnhancers = 27 | window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose; 28 | enhancer = composeEnhancers(applyMiddleware(thunk, logger)); 29 | } 30 | 31 | const configureStore = (preloadedState) => { 32 | return createStore(rootReducer, preloadedState, enhancer); 33 | }; 34 | 35 | export default configureStore; 36 | -------------------------------------------------------------------------------- /user-auth/react-app/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-app", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "@testing-library/jest-dom": "^4.2.4", 7 | "@testing-library/react": "^9.3.2", 8 | "@testing-library/user-event": "^7.1.2", 9 | "http-proxy-middleware": "^1.0.5", 10 | "react": "^17.0.0", 11 | "react-dom": "^17.0.0", 12 | "react-ga": "^3.3.0", 13 | "react-redux": "^7.2.4", 14 | "react-router-dom": "^5.2.0", 15 | "react-scripts": "3.4.3", 16 | "redux": "^4.1.0", 17 | "redux-logger": "^3.0.6", 18 | "redux-thunk": "^2.3.0" 19 | }, 20 | "scripts": { 21 | "start": "react-scripts start", 22 | "build": "react-scripts build", 23 | "test": "react-scripts test", 24 | "eject": "react-scripts eject" 25 | }, 26 | "eslintConfig": { 27 | "extends": "react-app" 28 | }, 29 | "browserslist": { 30 | "production": [ 31 | ">0.2%", 32 | "not dead", 33 | "not op_mini all" 34 | ], 35 | "development": [ 36 | "last 1 chrome version", 37 | "last 1 firefox version", 38 | "last 1 safari version" 39 | ] 40 | }, 41 | "proxy": "http://localhost:5000" 42 | } 43 | -------------------------------------------------------------------------------- /user-auth/react-app/src/components/default/Default.jsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect } from 'react' 2 | import { useDispatch, useSelector } from "react-redux"; 3 | import { loadDefault } from '../../store/default' 4 | import '../content.css' 5 | import '../articles/articles.css' 6 | 7 | const Default = () => { 8 | const dispatch = useDispatch() 9 | const defaults = useSelector(state => state.default) 10 | 11 | useEffect(() => { 12 | dispatch(loadDefault()) 13 | }, [dispatch]) 14 | 15 | return ( 16 |
17 | {defaults && Object.values(defaults).map(item => { 18 | return ( 19 |
20 |

{item.title}

21 | 22 | {item.title} 23 | 24 | {item.description && (
{item.description}
)} 25 |
26 | ) 27 | })} 28 |
29 | ) 30 | } 31 | 32 | export default Default 33 | -------------------------------------------------------------------------------- /user-auth/react-app/src/services/auth.js: -------------------------------------------------------------------------------- 1 | export const authenticate = async() => { 2 | const response = await fetch('/api/auth/',{ 3 | headers: { 4 | 'Content-Type': 'application/json' 5 | } 6 | }); 7 | return await response.json(); 8 | } 9 | 10 | export const login = async (email, password) => { 11 | const response = await fetch('/api/auth/login', { 12 | method: 'POST', 13 | headers: { 14 | 'Content-Type': 'application/json' 15 | }, 16 | body: JSON.stringify({ 17 | email, 18 | password 19 | }) 20 | }); 21 | return await response.json(); 22 | } 23 | 24 | export const logout = async () => { 25 | const response = await fetch("/api/auth/logout", { 26 | headers: { 27 | "Content-Type": "application/json", 28 | } 29 | }); 30 | return await response.json(); 31 | }; 32 | 33 | 34 | export const signUp = async (username, email, password) => { 35 | const response = await fetch("/api/auth/signup", { 36 | method: "POST", 37 | headers: { 38 | "Content-Type": "application/json", 39 | }, 40 | body: JSON.stringify({ 41 | username, 42 | email, 43 | password, 44 | }), 45 | }); 46 | return await response.json(); 47 | } -------------------------------------------------------------------------------- /user-auth/react-app/src/components/saved/RemoveFromSaved.jsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from 'react' 2 | import { useDispatch, useSelector } from 'react-redux' 3 | import { removeFromSaved } from '../../store/saved' 4 | import '../content.css' 5 | import './saved.css' 6 | 7 | const RemoveFromSaved = ({ item }) => { 8 | const dispatch = useDispatch() 9 | const user = useSelector(state => state.session.user) 10 | const userId = user['id'] 11 | const savedArticles = useSelector(state => state.saved) 12 | const [savedText, setSavedText] = useState('Remove from Saved') 13 | 14 | const handleRemoveFromSaved = () => { 15 | const { title, itemURL, imageURL, description } = item 16 | 17 | if (savedText === 'Remove from Saved') { 18 | dispatch(removeFromSaved(userId, itemURL, imageURL, title, description)) 19 | } 20 | } 21 | 22 | return ( 23 |
24 |
handleRemoveFromSaved()} 27 | > 28 | {savedText} 29 |
30 |
31 | ) 32 | } 33 | 34 | export default RemoveFromSaved 35 | -------------------------------------------------------------------------------- /user-auth/app/models/user.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy.orm import backref, relationship 2 | from .db import db 3 | from .saved import Saved 4 | from .userTag import UserTag 5 | from werkzeug.security import generate_password_hash, check_password_hash 6 | from flask_login import UserMixin 7 | 8 | 9 | 10 | class User(db.Model, UserMixin): 11 | __tablename__ = 'users' 12 | 13 | id = db.Column(db.Integer, primary_key=True) 14 | username = db.Column(db.String(40), nullable=False, unique=True) 15 | email = db.Column(db.String(255), nullable=False, unique=True) 16 | hashed_password = db.Column(db.String(255), nullable=False) 17 | 18 | saved = db.relationship("Saved", backref="User") 19 | tags = db.relationship('UserTag', backref='User') 20 | 21 | @property 22 | def password(self): 23 | return self.hashed_password 24 | 25 | @password.setter 26 | def password(self, password): 27 | self.hashed_password = generate_password_hash(password) 28 | 29 | def check_password(self, password): 30 | return check_password_hash(self.password, password) 31 | 32 | def to_dict(self): 33 | return { 34 | "id": self.id, 35 | "username": self.username, 36 | "email": self.email 37 | } 38 | -------------------------------------------------------------------------------- /user-auth/react-app/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | news-connector 8 | 9 | 10 |
11 | 12 | 13 | 14 | 15 | 38 | 39 | 40 | 41 | 42 | -------------------------------------------------------------------------------- /user-auth/react-app/src/components/footer/Footer.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import '../content.css' 3 | import './footer.css' 4 | 5 | const Footer = () => { 6 | return ( 7 |
8 | 32 |
33 | ) 34 | } 35 | 36 | export default Footer 37 | -------------------------------------------------------------------------------- /user-auth/react-app/src/store/weather.js: -------------------------------------------------------------------------------- 1 | // constants 2 | const GET_WEATHER = "weather/GET_WEATHER"; 3 | 4 | // actions 5 | const loadWeather = (weather) => ({ 6 | type: GET_WEATHER, 7 | weather, 8 | }); 9 | 10 | // thunks 11 | export const getWeather = () => async (dispatch) => { 12 | try { 13 | const location = await fetch("https://ipapi.co/json/"); 14 | const locData = await location.json(); 15 | 16 | const { city, latitude, longitude } = locData; 17 | const loc = city + "+" + latitude + "+" + longitude; 18 | const res = await fetch(`/api/weather/${loc}`); 19 | 20 | if (res.ok) { 21 | const data = await res.json(); 22 | dispatch(loadWeather(data)); 23 | } 24 | } catch (error) { 25 | // if ip location is not found 26 | // set location to Miami 27 | 28 | const defaultLocation = 'Miami' + "+" + 25.7381 + "+" + -80.312; 29 | const defaultWeatherRes = await fetch( 30 | `/api/weather/${defaultLocation}` 31 | ); 32 | 33 | const defaultWeather = await defaultWeatherRes.json(); 34 | 35 | dispatch(loadWeather(defaultWeather)); 36 | } 37 | }; 38 | 39 | // reducer 40 | export default function WeatherReducer(state = {}, action) { 41 | switch (action.type) { 42 | case GET_WEATHER: 43 | return action.weather; 44 | default: 45 | return state; 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /user-auth/react-app/src/components/interests/interests.css: -------------------------------------------------------------------------------- 1 | .deleteBtn { 2 | /* display: inline-block; */ 3 | margin: 0 5px; 4 | max-height: 21px; 5 | } 6 | 7 | .editBtn { 8 | margin: 0 5px; 9 | max-height: 21px; 10 | } 11 | 12 | .interests { 13 | display: flex; 14 | flex-direction: column; 15 | margin-bottom: 1rem; 16 | } 17 | 18 | .interestsContainer { 19 | /* display: flex; */ 20 | margin: 0 auto; 21 | } 22 | 23 | .inputHTMLForm { 24 | /* padding-left: 15px; */ 25 | } 26 | 27 | .interestListItem { 28 | text-align: left; 29 | display: flex; 30 | } 31 | 32 | .interestTitle { 33 | display: inline; 34 | /* text-align: left; */ 35 | margin-right: .8rem; 36 | /* justify-self: ; */ 37 | width: 119.76px; 38 | } 39 | 40 | .titleInput { 41 | /* color: blue; */ 42 | } 43 | 44 | .interestFormListContainer { 45 | color: #222; 46 | display: inline; 47 | } 48 | 49 | .interestForm { 50 | display: flex; 51 | flex-direction: row; 52 | list-style: none; 53 | justify-content: flex-end; 54 | margin-bottom: .25rem; 55 | } 56 | 57 | .viewInterests { 58 | border-bottom: 1px solid #c3c1b4; 59 | padding-bottom: .5rem; 60 | color: #416894; 61 | align-self: center; 62 | } 63 | 64 | .viewInterests:hover { 65 | /* font-weight: bolder; 66 | color: #4b256b; */ 67 | } 68 | 69 | .viewInterests:hover { 70 | cursor: pointer; 71 | text-decoration: underline; 72 | } -------------------------------------------------------------------------------- /user-auth/react-app/src/store/articles.js: -------------------------------------------------------------------------------- 1 | // constants 2 | const READ_ARTICLES = 'articles/LOAD_ARTICLES' 3 | 4 | 5 | // actions 6 | const readArticles = (articles) => ({ 7 | type: READ_ARTICLES, 8 | articles 9 | }) 10 | 11 | 12 | // thunks 13 | export const loadArticles = (userId) => async (dispatch) => { 14 | const res = await fetch('/api/articles/') 15 | 16 | if (res.ok) { 17 | const data = await res.json() 18 | dispatch(readArticles(data)) 19 | return data 20 | } 21 | } 22 | 23 | 24 | // reducer 25 | export default function ArticleReducer (state={}, action) { 26 | let newState = {...state} 27 | 28 | switch (action.type) { 29 | case READ_ARTICLES: 30 | // news api articles 31 | let articles = action.articles.articles.articles 32 | let gifs = action.articles.gifs 33 | let gifs1 = [] 34 | for (let i = 0; i < gifs.length; i++) { 35 | let iGifs = gifs[i].data 36 | for (let j = 0; j < iGifs.length; j++) { 37 | let jGifs = iGifs[j] 38 | gifs1.push(jGifs) 39 | } 40 | } 41 | // console.log('!!!!!!READ_ARTICLES action', action) 42 | newState['articles'] = articles 43 | newState['gifs'] = gifs1 44 | newState['headlines'] = action.articles.headlines 45 | return newState 46 | default: 47 | return state 48 | } 49 | } -------------------------------------------------------------------------------- /user-auth/react-app/src/components/saved/saved.css: -------------------------------------------------------------------------------- 1 | .addToSavedContainer { 2 | color: #000; 3 | margin-bottom: -.5rem; 4 | } 5 | 6 | .addToSavedText { 7 | border: .0625rem solid #656560; 8 | color: #416894; 9 | padding: .125rem; 10 | margin-top: .5rem; 11 | background-color: #C5C4BC; 12 | border-radius: .125rem; 13 | font-size: .75rem; 14 | } 15 | 16 | .addToSavedText:hover { 17 | cursor: pointer; 18 | /* font-weight: bold; */ 19 | color: #f1f0eb; 20 | background-color: #416894; 21 | } 22 | 23 | .savedText { 24 | border-bottom: 1px solid #c3c1b4; 25 | padding-bottom: .5rem 26 | } 27 | 28 | .savedTitle { 29 | text-decoration: none; 30 | /* color: #7e3eb3; */ 31 | color: #4974a5; 32 | display: flex; 33 | flex-direction: column; 34 | align-items: center; 35 | text-align: left; 36 | font-size: 1rem; 37 | padding: 0 2rem; 38 | } 39 | 40 | .savedSource { 41 | text-decoration: none; 42 | /* color: #7e3eb3; */ 43 | color: #4974a5; 44 | display: flex; 45 | flex-direction: column; 46 | align-items: flex-start; 47 | text-align: left; 48 | font-size: .75rem; 49 | padding: 0 2rem; 50 | padding-top: .5rem; 51 | font-weight: 300; 52 | } 53 | 54 | .savedImage { 55 | max-width: 100%; 56 | border-radius: .25rem; 57 | align-items: center; 58 | text-align: center; 59 | } 60 | 61 | .savedTitle:hover { 62 | text-decoration: underline; 63 | } 64 | 65 | .savedSource:hover { 66 | text-decoration: underline; 67 | } 68 | 69 | -------------------------------------------------------------------------------- /user-auth/react-app/src/components/articles/articles.css: -------------------------------------------------------------------------------- 1 | /* h2 { 2 | font-size: 16px; 3 | color: #7e3eb3; 4 | } */ 5 | 6 | .articleTitle{ 7 | /* font-size: 1rem; */ 8 | } 9 | 10 | 11 | .contentImage { 12 | max-width: 100%; 13 | border-radius: .25rem; 14 | align-items: center; 15 | text-align: center; 16 | } 17 | 18 | .articleTitle { 19 | text-decoration: none; 20 | /* color: #7e3eb3; */ 21 | color: #416894; 22 | display: flex; 23 | flex-direction: column; 24 | align-items: center; 25 | text-align: left; 26 | font-size: 1rem; 27 | padding-left: 2rem; 28 | letter-spacing: .03125rem; 29 | } 30 | 31 | .articleSource { 32 | text-decoration: none; 33 | /* color: #7e3eb3; */ 34 | color: #416894; 35 | /* display: flex; 36 | flex-direction: column; 37 | align-items: flex-start; 38 | text-align: left; */ 39 | font-size: .75rem; 40 | padding-left: 2rem; 41 | letter-spacing: .03125rem; 42 | font-weight: 300; 43 | padding-top: .5rem; 44 | } 45 | 46 | p { 47 | font-weight: bold; 48 | } 49 | 50 | .description { 51 | font-weight: 500; 52 | letter-spacing: .015625rem; 53 | word-wrap: break-word; 54 | text-align: left; 55 | padding: 1rem 0; 56 | max-width: 80%; 57 | font-size: .8rem; 58 | margin-bottom: -1rem; 59 | } 60 | 61 | .articleTitle:hover { 62 | /* font-weight: bolder; */ 63 | /* color: #4b256b; */ 64 | text-decoration: underline; 65 | } 66 | 67 | .articleSource:hover{ 68 | text-decoration: underline; 69 | } -------------------------------------------------------------------------------- /user-auth/redux-steps.md: -------------------------------------------------------------------------------- 1 | # Walkthrough & redux refactor 2 | 1. Delete the .git folder and push to my empty repo 3 | - if you have files in your repo already, just clone the repo and copy all of the files except the .git folder into your existing repo. 4 | 2. Go through steps in README 5 | 6 | 7 | 8 | 9 | ## Redux Refactor 10 | 1. install redux, redux-thunk, react-redux, redux-logger 11 | 2. set up your store 12 | - make store folder 13 | - in index.js, create your root reducer and set up redux middleware 14 | - export your configure store function 15 | 3. create your store 16 | - top level index.js file, create store and wrap the provider around your app 17 | 4. create a reducer for auth 18 | 5. create constants 19 | 6. create action-creators 20 | 7. create thunks (replace each of the functions in the "services" folder with a thunk version) 21 | 8. replace all my state based logic for auth with the use of my redux store 22 | - anytime i use a value from a useState, i will want to use useSelector 23 | - anytime i use the function to change the state, i will want to use a dispatch 24 | 25 | ```javascript 26 | // if i am refactoring some piece of state to use redux... 27 | const [ myState, setMyState ] = useState(null); 28 | 29 | // any time i see that i'm using the "myState" value, i want to use a selector instead to get the value from my redux store 30 | const myState = useSelector( state => state.stateSlice.myState ) 31 | 32 | // any time i see that i'm using "setMyState" to change the value of myState 33 | // I will need to dispatch a redux thunk or action creator instead 34 | dispatch(updateMyState(newState)); 35 | 36 | // anytime i see that i am ONLY using the value to pass it as a prop 37 | // i should just remove it from that component all together 38 | ``` 39 | -------------------------------------------------------------------------------- /user-auth/react-app/src/components/saved/SavedList.jsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect} from 'react' 2 | import { useDispatch, useSelector } from 'react-redux' 3 | import { loadSavedArticles } from '../../store/saved' 4 | import RemoveFromSaved from './RemoveFromSaved' 5 | import '../content.css' 6 | import '../articles/articles.css' 7 | 8 | const SavedList = () => { 9 | const dispatch = useDispatch() 10 | const user = useSelector(state => state.session.user) 11 | const savedArticles = useSelector(state => state.saved) 12 | 13 | useEffect(() => { 14 | if(!user) return 15 | dispatch(loadSavedArticles()) 16 | }, [dispatch]) 17 | 18 | if (!user) { 19 | return null 20 | } 21 | 22 | return ( 23 |
24 |

Saved

25 | {savedArticles && Object.values(savedArticles).map(item => { 26 | return ( 27 |
28 |

29 | {item.title} 30 | 31 | {item.contentSource} {item.publishedTime} 32 | 33 |

34 | 35 | {item.title} 36 | 37 | {item.description && (
{item.description}
)} 38 | 39 |
40 | ) 41 | })} 42 |
43 | ) 44 | } 45 | 46 | export default SavedList 47 | -------------------------------------------------------------------------------- /user-auth/app/api/saved_routes.py: -------------------------------------------------------------------------------- 1 | from flask import Blueprint, request 2 | from flask_login import current_user 3 | from app.models import db, Saved 4 | from app.forms import SavedForm 5 | 6 | saved_routes = Blueprint('saved', __name__) 7 | 8 | 9 | #return all articles that a given user has saved 10 | @saved_routes.route('/', methods=["GET"]) 11 | def get_saved_articles(): 12 | saved_articles = Saved.query.filter_by(userId=current_user.id).all() 13 | return {"saved":[article.to_dict() for article in saved_articles]} 14 | 15 | 16 | # adds an article to the current user's saved articles 17 | @saved_routes.route('/', methods=["POST"]) 18 | def post_to_saved(): 19 | form = SavedForm() 20 | form['csrf_token'].data = request.cookies['csrf_token'] 21 | if form.validate_on_submit(): 22 | new_article = Saved( 23 | userId = form.data['userId'], 24 | itemURL = form.data['itemURL'], 25 | imageURL = form.data['imageURL'], 26 | title = form.data['title'], 27 | description = form.data['description'], 28 | contentSource = form.data['contentSource'], 29 | publishedTime = form.data['publishedTime'] 30 | ) 31 | db.session.add(new_article) 32 | db.session.commit() 33 | return new_article.to_dict() 34 | 35 | 36 | # returns single saved article for the given article id 37 | @saved_routes.route('/', methods=["GET"]) 38 | def get_saved(id): 39 | saved = Saved.query.filter_by(id = id).first() 40 | 41 | return {"saved": saved.to_dict()} 42 | 43 | 44 | # deletes an article from the user's saved articles list 45 | @saved_routes.route('/', methods=['DELETE']) 46 | def delete_from_saved(): 47 | form = SavedForm() 48 | itemURL = form.data['itemURL'] 49 | 50 | article = Saved.query.filter_by(itemURL = itemURL).first() 51 | 52 | db.session.delete(article) 53 | db.session.commit() 54 | 55 | return article.to_dict() 56 | -------------------------------------------------------------------------------- /user-auth/react-app/src/App.js: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from "react"; 2 | import { BrowserRouter, Route, Switch } from "react-router-dom"; 3 | import { useDispatch } from "react-redux"; 4 | import LoginForm from "./components/auth/LoginForm"; 5 | import SignUpForm from "./components/auth/SignUpForm"; 6 | import NavBar from "./components/NavBar"; 7 | import ProtectedRoute from "./components/auth/ProtectedRoute"; 8 | import Content from "./components/content/Content"; 9 | import Interests from "./components/interests/Interests"; 10 | import Title from "./components/title/Title"; 11 | import SavedList from "./components/saved/SavedList"; 12 | import Weather from "./components/weather/Weather"; 13 | import { authenticate } from "./store/session"; 14 | import Footer from "./components/footer/Footer"; 15 | import {PageView, initGA} from '../src/components/Google Analytics/GoogleAnalytics.js' 16 | 17 | function App() { 18 | const dispatch = useDispatch(); 19 | const [loaded, setLoaded] = useState(false); 20 | 21 | useEffect(() => { 22 | (async () => { 23 | await dispatch(authenticate()); 24 | setLoaded(true); 25 | })(); 26 | 27 | // initialize ReactGA 28 | initGA("UA-213353187-1"); 29 | PageView(); 30 | }, []); 31 | 32 | if (!loaded) { 33 | return null; 34 | } 35 | 36 | return ( 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | <SavedList /> 49 | <Content /> 50 | <Interests /> 51 | </Route> 52 | </Switch> 53 | <Footer /> 54 | </BrowserRouter> 55 | ); 56 | } 57 | 58 | export default App; 59 | -------------------------------------------------------------------------------- /user-auth/react-app/src/components/saved/AddToSaved.jsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from 'react' 2 | import { useDispatch, useSelector } from "react-redux"; 3 | import { addToSaved, removeFromSaved } from '../../store/saved'; 4 | import '../content.css' 5 | import './saved.css' 6 | 7 | const AddToSaved = ({ item }) => { 8 | const dispatch = useDispatch() 9 | const user = useSelector(state => state.session.user) 10 | let userId = user['id'] 11 | const savedArticles = useSelector(state => state.saved) 12 | const [savedText, setSavedText] = useState('Add to Saved') 13 | 14 | 15 | useEffect(() => { 16 | //if item is in store, set text to Remove from Saved 17 | Object.values(savedArticles).forEach(article => { 18 | if (article['itemURL'] === item['url']) { 19 | setSavedText('Remove from Saved') 20 | } 21 | }) 22 | }, []) 23 | 24 | const handleAddToSaved = () => { 25 | //todo 26 | //check if article is already saved first 27 | let { title, itemURL, imageURL, description, contentSource, publishedAt=null } = item 28 | if (savedText === 'Remove from Saved') { 29 | dispatch(removeFromSaved(userId, itemURL, imageURL, title, description, contentSource)) 30 | setSavedText('Add to Saved') 31 | return 32 | } 33 | 34 | if (savedText === 'Add to Saved') { 35 | dispatch(addToSaved(userId, itemURL, imageURL, title, description, contentSource, publishedAt)) 36 | setSavedText('Remove from Saved') 37 | return 38 | } 39 | 40 | return 41 | // change text to display if item is saved or not 42 | // savedText === 'Add to Saved' ? setSavedText('Remove from Saved') : setSavedText('Add to Saved') 43 | } 44 | return ( 45 | <div className='addToSavedContainer'> 46 | <div 47 | className='addToSavedText' 48 | onClick={() => handleAddToSaved()} 49 | > 50 | {savedText} 51 | </div> 52 | </div> 53 | ) 54 | } 55 | 56 | export default AddToSaved -------------------------------------------------------------------------------- /user-auth/react-app/src/components/content/Content.jsx: -------------------------------------------------------------------------------- 1 | // this is the 2 | 3 | import React, { useEffect } from 'react' 4 | import { useDispatch, useSelector } from "react-redux"; 5 | import { loadArticles } from "../../store/articles"; 6 | import Article from '../articles/Article'; 7 | import Gif from '../gifs/Gif'; 8 | import Default from '../default/Default'; 9 | import Splash from '../splash/Splash'; 10 | 11 | import '../content.css' 12 | import Headline from '../articles/Headline'; 13 | 14 | const Content = () => { 15 | const dispatch = useDispatch() 16 | const user = useSelector(state => state.session.user) 17 | const content = useSelector(state => state.article) 18 | const articles = content.articles 19 | const headlines = content.headlines 20 | const gifs = content.gifs 21 | let userId 22 | 23 | if (user) { 24 | userId = user['id'] 25 | } 26 | 27 | useEffect(() => { 28 | if (!userId) return 29 | dispatch(loadArticles(userId)) 30 | }, [dispatch]) 31 | 32 | if (!user) { 33 | // return <Default /> 34 | // instead of returning a garbage splash page, make it nice like 35 | return <Splash /> 36 | } 37 | 38 | return ( 39 | <div className='parent'> 40 | {/* render headlines first */} 41 | {user && headlines && headlines.map((article) => { 42 | return ( 43 | <div className="container" key={`${Math.random()} ${article.url}`}> 44 | <Headline article={article} /> 45 | </div> 46 | ) 47 | })} 48 | {user && articles && articles.map((article, idx) => { 49 | return ( 50 | <div className='container' key={`${Math.random()} ${article.url}`}> 51 | <Article article={article} /> 52 | {idx % 2 === 0 && gifs[idx / 2] && <Gif gif={gifs[idx / 2]} key={gifs[idx / 2]} />} 53 | </div> 54 | ) 55 | })} 56 | 57 | </div> 58 | ) 59 | } 60 | 61 | export default Content 62 | -------------------------------------------------------------------------------- /user-auth/react-app/src/components/auth/LoginForm.js: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from "react"; 2 | import { useDispatch, useSelector } from "react-redux"; 3 | import { Redirect } from "react-router-dom"; 4 | import { login } from "../../store/session"; 5 | import './loginForm.css' 6 | 7 | const LoginForm = () => { 8 | const dispatch = useDispatch(); 9 | const user = useSelector(state => state.session.user) 10 | const [errors, setErrors] = useState([]); 11 | const [email, setEmail] = useState(""); 12 | const [password, setPassword] = useState(""); 13 | 14 | const onLogin = async (e) => { 15 | e.preventDefault(); 16 | const data = await dispatch(login(email, password)); 17 | if (data.errors) { 18 | setErrors(data.errors); 19 | } 20 | }; 21 | 22 | const updateEmail = (e) => { 23 | setEmail(e.target.value); 24 | }; 25 | 26 | const updatePassword = (e) => { 27 | setPassword(e.target.value); 28 | }; 29 | 30 | if (user) { 31 | return <Redirect to="/" />; 32 | } 33 | 34 | return ( 35 | <div className='login-outermost-container'> 36 | <form onSubmit={onLogin} className='login-form'> 37 | <div> 38 | {errors.map((error) => ( 39 | <div>{error}</div> 40 | ))} 41 | </div> 42 | <div className='login-input-container'> 43 | <label htmlFor="email" className='login-input-label'>Email</label> 44 | <input 45 | name="email" 46 | id="email" 47 | type="text" 48 | placeholder="Email" 49 | value={email} 50 | onChange={updateEmail} 51 | className='login-input' 52 | /> 53 | </div> 54 | <div className='password-input-container'> 55 | <label htmlFor="password" className='password-input-label'>Password</label> 56 | <input 57 | name="password" 58 | id="password" 59 | type="password" 60 | placeholder="Password" 61 | value={password} 62 | onChange={updatePassword} 63 | className='password-input' 64 | /> 65 | <button type="submit">Login</button> 66 | </div> 67 | </form> 68 | </div> 69 | ); 70 | }; 71 | 72 | export default LoginForm; 73 | -------------------------------------------------------------------------------- /user-auth/app/api/article_routes.py: -------------------------------------------------------------------------------- 1 | from re import T 2 | from flask import Blueprint, jsonify, request 3 | from flask_login import current_user 4 | import requests, json, random, os 5 | from app.models import db, Interest, Tag 6 | 7 | article_routes = Blueprint('articles', __name__) 8 | 9 | @article_routes.route('/', methods=['GET']) 10 | def get_articles(): 11 | # res = requests.get( 12 | # 'https://newsapi.org/v2/everything?q=Apple&from=2021-06-26&sortBy=popularity&apiKey=13bc774f3bb545d8935600ca47e4cfcf') 13 | 14 | # get titles from user's interests 15 | interestsFromDB = Interest.query.filter_by(userId = current_user.id).all() 16 | interests = [title.to_dict()['title'] for title in interestsFromDB] 17 | random.shuffle(interests) 18 | 19 | # get titles from tags 20 | tagsFromDB = Tag.query.all() 21 | tags = [tag.to_dict()['title'] for tag in tagsFromDB] 22 | random.shuffle(tags) 23 | 24 | # merge titles from tags and interests into one list 25 | titles = interests + tags 26 | random.shuffle(titles) 27 | 28 | # 100 requests per day available 29 | news_api_key = os.environ.get('NEWS_API') 30 | 31 | news_url = ('https://newsapi.org/v2/everything?q=' + ' OR '.join(titles) 32 | ) + '&language=en' + '&apiKey=' + news_api_key + '&pageSize=100' 33 | 34 | news_res = requests.get(news_url) 35 | articles = news_res.json()['articles'] 36 | random.shuffle(articles) 37 | 38 | # get headlines 39 | headline_url = 'https://newsapi.org/v2/top-headlines?country=us' + '&language=en' + '&apiKey=' + news_api_key + '&pageSize=100' 40 | headline_res = requests.get(headline_url) 41 | headlines = headline_res.json()['articles'] 42 | 43 | # Note: All API Keys start as beta keys, which are rate limited(42 reads per hour and 1000 searches/API calls per day.) 44 | giphy_api_key = os.environ.get('GIPHY_API') 45 | 46 | gifs = [] 47 | for i in range(len(interests)): 48 | giphy_url = 'https://api.giphy.com/v1/gifs/search?api_key=' + giphy_api_key + '&q=' + interests[i] + '&limit=5&offset=0&rating=g&lang=en' 49 | giphy_res = requests.get(giphy_url) 50 | gifs.append(giphy_res.json()) 51 | 52 | 53 | articles = {'articles': articles} 54 | return {'gifs': gifs, 'articles': articles, 'headlines': headlines} 55 | -------------------------------------------------------------------------------- /user-auth/app/api/interest_routes.py: -------------------------------------------------------------------------------- 1 | from flask import Blueprint, jsonify, request 2 | from flask_login import current_user 3 | from app.models import db, Interest 4 | from app.forms import InterestForm 5 | from app.forms.update_interest_form import UpdateInterestForm 6 | 7 | interest_routes = Blueprint('interests', __name__) 8 | 9 | # return all of the user's user-created interests 10 | 11 | 12 | @interest_routes.route('/', methods=['GET']) 13 | def get_interests(): 14 | interests = Interest.query.filter_by(userId=current_user.id).all() 15 | return {"interests": [titles.to_dict() for titles in interests]} 16 | 17 | 18 | # return the interest with the given id 19 | @interest_routes.route('/<int:id>', methods=["GET"]) 20 | def get_one_interest(id): 21 | interest = Interest.query.filter_by(id=id).first() 22 | return interest.to_dict() 23 | 24 | 25 | # add and interest to view within the newsfeed 26 | @interest_routes.route('/', methods=["POST"]) 27 | def post_interests(): 28 | form = InterestForm() 29 | form['csrf_token'].data = request.cookies['csrf_token'] 30 | if form.validate_on_submit(): 31 | new_interest = Interest( 32 | userId=form.data['userId'], 33 | title=form.data['title'] 34 | ) 35 | db.session.add(new_interest) 36 | db.session.commit() 37 | 38 | return new_interest.to_dict() 39 | 40 | 41 | # update the interest with the given id 42 | @interest_routes.route('/', methods=['PUT']) 43 | def update_interest(): 44 | form = UpdateInterestForm() 45 | form['csrf_token'].data = request.cookies['csrf_token'] 46 | if form.validate_on_submit(): 47 | interestId = form.data["interestId"] 48 | interest = Interest.query.filter_by(id=interestId).\ 49 | update(dict({'title': form.data['title']})) 50 | updated_interest = Interest.query.filter_by(id=interestId).first() 51 | db.session.commit() 52 | return updated_interest.to_dict() 53 | 54 | 55 | # delete the interest with the given id from the user's interests 56 | @interest_routes.route('/', methods=['DELETE']) 57 | def delete_interest(): 58 | form = UpdateInterestForm() 59 | interestId = form.data["interestId"] 60 | interest = Interest.query.filter_by(id=interestId).first() 61 | db.session.delete(interest) 62 | db.session.commit() 63 | return interest.to_dict() 64 | -------------------------------------------------------------------------------- /user-auth/react-app/src/store/session.js: -------------------------------------------------------------------------------- 1 | // constants 2 | const SET_USER = "session/SET_USER" 3 | const REMOVE_USER = "session/REMOVE_USER" 4 | 5 | // action creators 6 | const setUser = (user) => ({ 7 | type: SET_USER, 8 | payload: user 9 | }) 10 | 11 | const removeUser = () => ({ 12 | type: REMOVE_USER, 13 | }) 14 | 15 | // thunks 16 | 17 | export const authenticate = () => async (dispatch) => { 18 | const response = await fetch('/api/auth/', { 19 | headers: { 20 | 'Content-Type': 'application/json' 21 | } 22 | }); 23 | const data = await response.json(); 24 | if (data.errors) { 25 | return; 26 | } 27 | dispatch(setUser(data)) 28 | } 29 | 30 | export const login = (email, password) => async (dispatch) => { 31 | const response = await fetch('/api/auth/login', { 32 | method: 'POST', 33 | headers: { 34 | 'Content-Type': 'application/json' 35 | }, 36 | body: JSON.stringify({ 37 | email, 38 | password 39 | }) 40 | }); 41 | const data = await response.json(); 42 | if (data.errors) { 43 | return data; 44 | } 45 | dispatch(setUser(data)) 46 | return {} 47 | } 48 | 49 | export const logout = () => async (dispatch) => { 50 | const response = await fetch("/api/auth/logout", { 51 | headers: { 52 | "Content-Type": "application/json", 53 | } 54 | }); 55 | const data = await response.json(); 56 | dispatch(removeUser()); 57 | }; 58 | 59 | 60 | export const signUp = (username, email, password) => async (dispatch) => { 61 | const response = await fetch("/api/auth/signup", { 62 | method: "POST", 63 | headers: { 64 | "Content-Type": "application/json", 65 | }, 66 | body: JSON.stringify({ 67 | username, 68 | email, 69 | password, 70 | }), 71 | }); 72 | const data = await response.json(); 73 | if (data.errors) { 74 | return 75 | } 76 | dispatch(setUser(data)) 77 | } 78 | 79 | 80 | 81 | const initialState = {user: null} 82 | 83 | export default function reducer(state = initialState, action) { 84 | switch (action.type) { 85 | case SET_USER: 86 | return {user: action.payload} 87 | case REMOVE_USER: 88 | return {user: null} 89 | default: 90 | return state; 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /user-auth/app/api/auth_routes.py: -------------------------------------------------------------------------------- 1 | from flask import Blueprint, jsonify, session, request 2 | from app.models import User, db 3 | from app.forms import LoginForm 4 | from app.forms import SignUpForm 5 | from flask_login import current_user, login_user, logout_user, login_required 6 | 7 | auth_routes = Blueprint('auth', __name__) 8 | 9 | 10 | def validation_errors_to_error_messages(validation_errors): 11 | """ 12 | Simple function that turns the WTForms validation errors into a simple list 13 | """ 14 | errorMessages = [] 15 | for field in validation_errors: 16 | for error in validation_errors[field]: 17 | errorMessages.append(f"{field} : {error}") 18 | return errorMessages 19 | 20 | 21 | @auth_routes.route('/') 22 | def authenticate(): 23 | """ 24 | Authenticates a user. 25 | """ 26 | if current_user.is_authenticated: 27 | return current_user.to_dict() 28 | return {'errors': ['Unauthorized']} 29 | 30 | 31 | @auth_routes.route('/login', methods=['POST']) 32 | def login(): 33 | """ 34 | Logs a user in 35 | """ 36 | form = LoginForm() 37 | print(request.get_json()) 38 | # Get the csrf_token from the request cookie and put it into the 39 | # form manually to validate_on_submit can be used 40 | form['csrf_token'].data = request.cookies['csrf_token'] 41 | if form.validate_on_submit(): 42 | # Add the user to the session, we are logged in! 43 | user = User.query.filter(User.email == form.data['email']).first() 44 | login_user(user) 45 | return user.to_dict() 46 | return {'errors': validation_errors_to_error_messages(form.errors)}, 401 47 | 48 | 49 | @auth_routes.route('/logout') 50 | def logout(): 51 | """ 52 | Logs a user out 53 | """ 54 | logout_user() 55 | return {'message': 'User logged out'} 56 | 57 | 58 | @auth_routes.route('/signup', methods=['POST']) 59 | def sign_up(): 60 | """ 61 | Creates a new user and logs them in 62 | """ 63 | form = SignUpForm() 64 | form['csrf_token'].data = request.cookies['csrf_token'] 65 | if form.validate_on_submit(): 66 | user = User( 67 | username=form.data['username'], 68 | email=form.data['email'], 69 | password=form.data['password'] 70 | ) 71 | db.session.add(user) 72 | db.session.commit() 73 | login_user(user) 74 | return user.to_dict() 75 | return {'errors': validation_errors_to_error_messages(form.errors)}, 401 76 | 77 | 78 | @auth_routes.route('/unauthorized') 79 | def unauthorized(): 80 | """ 81 | Returns unauthorized JSON when flask-login authentication fails 82 | """ 83 | return {'errors': ['Unauthorized']}, 401 84 | -------------------------------------------------------------------------------- /user-auth/.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | *.py,cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | cover/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | .pybuilder/ 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | # For a library or package, you might want to ignore these files since the code is 87 | # intended to run in multiple environments; otherwise, check them in: 88 | # .python-version 89 | 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 94 | # install all needed dependencies. 95 | #Pipfile.lock 96 | 97 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 98 | __pypackages__/ 99 | 100 | # Celery stuff 101 | celerybeat-schedule 102 | celerybeat.pid 103 | 104 | # SageMath parsed files 105 | *.sage.py 106 | 107 | # Environments 108 | .env 109 | .venv 110 | env/ 111 | venv/ 112 | ENV/ 113 | env.bak/ 114 | venv.bak/ 115 | 116 | # Spyder project settings 117 | .spyderproject 118 | .spyproject 119 | 120 | # Rope project settings 121 | .ropeproject 122 | 123 | # mkdocs documentation 124 | /site 125 | 126 | # mypy 127 | .mypy_cache/ 128 | .dmypy.json 129 | dmypy.json 130 | 131 | # Pyre type checker 132 | .pyre/ 133 | 134 | # pytype static type analyzer 135 | .pytype/ 136 | 137 | # Cython debug symbols 138 | cython_debug/ 139 | 140 | react-app/node_modules -------------------------------------------------------------------------------- /user-auth/react-app/src/components/weather/getDays.js: -------------------------------------------------------------------------------- 1 | export const getDays = () => { 2 | const options = { weekday: 'long' } 3 | const days = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'] 4 | const day = new Date().getDay() 5 | const week = {} 6 | 7 | switch (day) { 8 | case 0: 9 | //sunday 10 | week[0] = 'Sunday' 11 | week[1] = 'Monday' 12 | week[2] = 'Tuesday' 13 | week[3] = 'Wednesday' 14 | week[4] = 'Thursday' 15 | week[5] = 'Friday' 16 | week[6] = 'Saturday' 17 | break; 18 | case 1: 19 | //monday 20 | week[0] = 'Monday' 21 | week[1] = 'Tuesday' 22 | week[2] = 'Wednesday' 23 | week[3] = 'Thursday' 24 | week[4] = 'Friday' 25 | week[5] = 'Saturday' 26 | week[6] = 'Sunday' 27 | break; 28 | case 2: 29 | //tuesday 30 | week[0] = 'Tuesday' 31 | week[1] = 'Wednesday' 32 | week[2] = 'Thursday' 33 | week[3] = 'Friday' 34 | week[4] = 'Saturday' 35 | week[5] = 'Sunday' 36 | week[6] = 'Monday' 37 | break; 38 | case 3: 39 | //wednesday 40 | week[0] = 'Wednesday' 41 | week[1] = 'Thursday' 42 | week[2] = 'Friday' 43 | week[3] = 'Saturday' 44 | week[4] = 'Sunday' 45 | week[5] = 'Monday' 46 | week[6] = 'Tuesday' 47 | break; 48 | case 4: 49 | //thursday 50 | week[0] = 'Thursday' 51 | week[1] = 'Friday' 52 | week[2] = 'Saturday' 53 | week[3] = 'Sunday' 54 | week[4] = 'Monday' 55 | week[5] = 'Tuesday' 56 | week[6] = 'Wednesday' 57 | break; 58 | case 5: 59 | //friday 60 | week[0] = 'Friday' 61 | week[1] = 'Saturday' 62 | week[2] = 'Sunday' 63 | week[3] = 'Monday' 64 | week[4] = 'Tuesday' 65 | week[5] = 'Wednesday' 66 | week[6] = 'Thursday' 67 | break; 68 | case 6: 69 | //saturday 70 | week[0] = 'Saturday' 71 | week[1] = 'Sunday' 72 | week[2] = 'Monday' 73 | week[3] = 'Tuesday' 74 | week[4] = 'Wednesday' 75 | week[5] = 'Thursday' 76 | week[6] = 'Friday' 77 | break; 78 | } 79 | 80 | 81 | return week 82 | } 83 | 84 | export const getAbbrDays = (week) => { 85 | let abbrWeek = {} 86 | 87 | Object.values(week).forEach((day, i) => { 88 | let abbrDay = day.slice(0, 3) 89 | abbrWeek[i] = abbrDay 90 | }) 91 | 92 | return abbrWeek 93 | } -------------------------------------------------------------------------------- /user-auth/react-app/src/components/weather/weather.css: -------------------------------------------------------------------------------- 1 | .weatherParent { 2 | max-width: 100%; 3 | height: 500px; 4 | } 5 | 6 | .weatherContainer { 7 | max-height: 90%; 8 | border-radius: .25rem; 9 | display: grid; 10 | background-color: #cccbc1; 11 | grid-template-columns: 1fr 1fr 1fr 1fr 1fr 1fr 1fr; 12 | grid-template-rows: .5fr .5fr 1fr; 13 | grid-template-areas: 14 | "today today today today today today today" 15 | "todayInner todayInner todayInner todayInner todayImg todayImg todayImg" 16 | "weekDay weekDay weekDay weekDay weekDay weekDay weekDay"; 17 | } 18 | 19 | .weatherToday { 20 | grid-area: today; 21 | display: flex; 22 | flex-direction: column; 23 | max-width: 100%; 24 | justify-self: flex-start; 25 | padding-top: .5rem; 26 | } 27 | 28 | .city { 29 | align-items: center; 30 | display: flex; 31 | justify-content: space-evenly; 32 | margin-bottom: 2rem; 33 | padding-left: 1rem; 34 | } 35 | 36 | .cityName { 37 | /* color: #2D2D2B; */ 38 | letter-spacing: 1px; 39 | font-weight: bolder; 40 | font-size: larger; 41 | border-bottom: 1px solid #c3c1b4; 42 | padding-bottom: 1rem; 43 | 44 | } 45 | 46 | .currentTemp { 47 | margin: 0 auto; 48 | margin-bottom: 2rem; 49 | letter-spacing: 2px; 50 | color: #323232; 51 | } 52 | 53 | .todayImage { 54 | grid-area: todayImg; 55 | margin-top: -3rem; 56 | margin-left: 3rem; 57 | } 58 | 59 | .weekImage { 60 | color: white; 61 | margin-bottom: -1rem; 62 | max-width: 90%; 63 | } 64 | 65 | .todayDescription { 66 | align-self: center; 67 | margin-top: -1rem; 68 | font-weight: 500; 69 | letter-spacing: .03125; 70 | 71 | } 72 | 73 | .weatherWeek { 74 | /* grid-area: weekDay; */ 75 | display: flex; 76 | justify-content: space-around; 77 | align-items: center; 78 | flex-direction: column; 79 | /* align-content: flex-end; */ 80 | max-width: 100%; 81 | padding-bottom: .5rem; 82 | margin-top: -1rem; 83 | } 84 | 85 | .day { 86 | color: #323232; 87 | font-weight: 700; 88 | letter-spacing: 2px; 89 | font-size: smaller; 90 | padding-left: .25rem; 91 | margin-bottom: -.25rem; 92 | } 93 | 94 | .dayHigh { 95 | color: black; 96 | letter-spacing: 2px; 97 | font-weight: bolder; 98 | margin-bottom: -1rem; 99 | font-size: .8rem; 100 | padding-bottom: .5rem; 101 | } 102 | 103 | .dayLow { 104 | color: #323232; 105 | letter-spacing: 2px; 106 | font-weight: bolder; 107 | font-size: .8rem; 108 | } 109 | 110 | .todayDivOuter { 111 | display: flex; 112 | flex-direction: row; 113 | } 114 | 115 | .todayDivInner { 116 | grid-area: todayInner; 117 | padding-left: 1rem; 118 | margin-top: -1rem; 119 | } -------------------------------------------------------------------------------- /SETUP INSTRUCTIONS.md: -------------------------------------------------------------------------------- 1 | # Flask React Project 2 | 3 | This is the backend for the Flask React project. 4 | 5 | ## Getting started 6 | 7 | 1. Clone this repository (only this branch) 8 | 9 | ```bash 10 | git clone https://github.com/appacademy-starters/python-project-starter.git 11 | ``` 12 | 13 | 2. Install dependencies 14 | 15 | ```bash 16 | pipenv install --dev -r dev-requirements.txt && pipenv install -r requirements.txt 17 | ``` 18 | 19 | 3. Create a **.env** file based on the example with proper settings for your 20 | development environment 21 | 4. Setup your PostgreSQL user, password and database and make sure it matches your **.env** file 22 | 23 | 5. Get into your pipenv, migrate your database, seed your database, and run your flask app 24 | 25 | ```bash 26 | pipenv shell 27 | ``` 28 | 29 | ```bash 30 | flask db upgrade 31 | ``` 32 | 33 | ```bash 34 | flask seed all 35 | ``` 36 | 37 | ```bash 38 | flask run 39 | ``` 40 | 41 | 6. To run the React App in development, checkout the [README](./react-app/README.md) inside the `react-app` directory. 42 | 43 | *** 44 | *IMPORTANT!* 45 | If you add any python dependencies to your pipfiles, you'll need to regenerate your requirements.txt before deployment. 46 | You can do this by running: 47 | 48 | ```bash 49 | pipenv lock -r > requirements.txt 50 | ``` 51 | 52 | *ALSO IMPORTANT!* 53 | psycopg2-binary MUST remain a dev dependency because you can't install it on apline-linux. 54 | There is a layer in the Dockerfile that will install psycopg2 (not binary) for us. 55 | *** 56 | 57 | ## Deploy to Heroku 58 | 59 | 1. Create a new project on Heroku 60 | 2. Under Resources click "Find more add-ons" and add the add on called "Heroku Postgres" 61 | 3. Install the [Heroku CLI](https://devcenter.heroku.com/articles/heroku-command-line) 62 | 4. Run 63 | 64 | ```bash 65 | heroku login 66 | ``` 67 | 68 | 5. Login to the heroku container registry 69 | 70 | ```bash 71 | heroku container:login 72 | ``` 73 | 74 | 6. Update the `REACT_APP_BASE_URL` variable in the Dockerfile. 75 | This should be the full URL of your Heroku app: i.e. "https://flask-react-aa.herokuapp.com" 76 | 7. Push your docker container to heroku from the root directory of your project. 77 | This will build the dockerfile and push the image to your heroku container registry 78 | 79 | ```bash 80 | heroku container:push web -a {NAME_OF_HEROKU_APP} 81 | ``` 82 | 83 | 8. Release your docker container to heroku 84 | 85 | ```bash 86 | heroku container:release web -a {NAME_OF_HEROKU_APP} 87 | ``` 88 | 89 | 9. set up your database: 90 | 91 | ```bash 92 | heroku run -a {NAME_OF_HEROKU_APP} flask db upgrade 93 | heroku run -a {NAME_OF_HEROKU_APP} flask seed all 94 | ``` 95 | 96 | 10. Under Settings find "Config Vars" and add any additional/secret .env variables. 97 | 98 | 11. profit 99 | -------------------------------------------------------------------------------- /user-auth/react-app/src/components/auth/SignUpForm.js: -------------------------------------------------------------------------------- 1 | import React, { useState } from "react"; 2 | import { useDispatch, useSelector } from "react-redux"; 3 | import { Redirect } from 'react-router-dom'; 4 | import { signUp } from '../../store/session'; 5 | import './signUpForm.css' 6 | 7 | const SignUpForm = () => { 8 | const dispatch = useDispatch(); 9 | const user = useSelector(state => state.session.user) 10 | const [username, setUsername] = useState(""); 11 | const [email, setEmail] = useState(""); 12 | const [password, setPassword] = useState(""); 13 | const [repeatPassword, setRepeatPassword] = useState(""); 14 | 15 | const onSignUp = async (e) => { 16 | e.preventDefault(); 17 | if (password === repeatPassword) { 18 | const data = await dispatch(signUp(username, email, password)); 19 | } 20 | }; 21 | 22 | const updateUsername = (e) => { 23 | setUsername(e.target.value); 24 | }; 25 | 26 | const updateEmail = (e) => { 27 | setEmail(e.target.value); 28 | }; 29 | 30 | const updatePassword = (e) => { 31 | setPassword(e.target.value); 32 | }; 33 | 34 | const updateRepeatPassword = (e) => { 35 | setRepeatPassword(e.target.value); 36 | }; 37 | 38 | if (user) { 39 | return <Redirect to="/" />; 40 | } 41 | 42 | return ( 43 | <div className='signup-outermost-container'> 44 | <form onSubmit={onSignUp} className='signup-form'> 45 | <div className='name-input-container'> 46 | <label htmlFor="name" className='name-label'>User Name</label> 47 | <input 48 | type="text" 49 | id="name" 50 | name="username" 51 | onChange={updateUsername} 52 | value={username} 53 | ></input> 54 | </div> 55 | <div className='email-input-container'> 56 | <label htmlFor="email" className='email-label'>Email</label> 57 | <input 58 | type="text" 59 | id="email" 60 | name="email" 61 | onChange={updateEmail} 62 | value={email} 63 | ></input> 64 | </div> 65 | <div className='password-input-container'> 66 | <label htmlFor="password" className='password-label'>Password</label> 67 | <input 68 | type="password" 69 | id="password" 70 | name="password" 71 | onChange={updatePassword} 72 | value={password} 73 | ></input> 74 | </div> 75 | <div className='repeat-password-input-container'> 76 | <label htmlFor="repeat-password" className='repeat-password-label'>Repeat Password</label> 77 | <input 78 | type="password" 79 | id="repeat-password" 80 | name="repeat-password" 81 | onChange={updateRepeatPassword} 82 | value={repeatPassword} 83 | required={true} 84 | ></input> 85 | </div> 86 | <button type="submit">Sign Up</button> 87 | </form> 88 | </div> 89 | ); 90 | }; 91 | 92 | export default SignUpForm; 93 | -------------------------------------------------------------------------------- /user-auth/app/__init__.py: -------------------------------------------------------------------------------- 1 | import os 2 | from flask import Flask, render_template, request, session, redirect 3 | from flask_cors import CORS 4 | from flask_migrate import Migrate 5 | from flask_wtf.csrf import CSRFProtect, generate_csrf 6 | from flask_login import LoginManager 7 | 8 | from .models import db, User 9 | from .api.user_routes import user_routes 10 | from .api.auth_routes import auth_routes 11 | from .api.interest_routes import interest_routes 12 | from .api.saved_routes import saved_routes 13 | from .api.tag_routes import tag_routes 14 | from .api.article_routes import article_routes 15 | from .api.default_routes import default_routes 16 | from .api.weather_routes import weather_routes 17 | 18 | from .seeds import seed_commands 19 | 20 | from .config import Config 21 | 22 | app = Flask(__name__) 23 | 24 | # Setup login manager 25 | login = LoginManager(app) 26 | login.login_view = 'auth.unauthorized' 27 | 28 | 29 | @login.user_loader 30 | def load_user(id): 31 | return User.query.get(int(id)) 32 | 33 | 34 | # Tell flask about our seed commands 35 | app.cli.add_command(seed_commands) 36 | 37 | app.config.from_object(Config) 38 | app.register_blueprint(user_routes, url_prefix='/api/users') 39 | app.register_blueprint(auth_routes, url_prefix='/api/auth') 40 | app.register_blueprint(interest_routes, url_prefix='/api/interests') 41 | app.register_blueprint(tag_routes, url_prefix='/api/tags') 42 | app.register_blueprint(saved_routes, url_prefix='/api/saved') 43 | app.register_blueprint(article_routes, url_prefix='/api/articles') 44 | app.register_blueprint(default_routes, url_prefix='/api/homeData') 45 | app.register_blueprint(weather_routes, url_prefix='/api/weather') 46 | db.init_app(app) 47 | Migrate(app, db) 48 | 49 | # Application Security 50 | CORS(app) 51 | 52 | # Since we are deploying with Docker and Flask, 53 | # we won't be using a buildpack when we deploy to Heroku. 54 | # Therefore, we need to make sure that in production any 55 | # request made over http is redirected to https. 56 | # Well......... 57 | 58 | @app.before_request 59 | def https_redirect(): 60 | if os.environ.get('FLASK_ENV') == 'production': 61 | if request.headers.get('X-Forwarded-Proto') == 'http': 62 | url = request.url.replace('http://', 'https://', 1) 63 | code = 301 64 | return redirect(url, code=code) 65 | 66 | 67 | @app.after_request 68 | def inject_csrf_token(response): 69 | response.set_cookie('csrf_token', 70 | generate_csrf(), 71 | secure=True if os.environ.get( 72 | 'FLASK_ENV') == 'production' else False, 73 | samesite='Strict' if os.environ.get( 74 | 'FLASK_ENV') == 'production' else None, 75 | httponly=True) 76 | return response 77 | 78 | 79 | @app.route('/', defaults={'path': ''}) 80 | @app.route('/<path:path>') 81 | def react_root(path): 82 | print("path", path) 83 | if path == 'favicon.ico': 84 | return app.send_static_file('favicon.ico') 85 | return app.send_static_file('index.html') 86 | -------------------------------------------------------------------------------- /user-auth/react-app/src/components/content.css: -------------------------------------------------------------------------------- 1 | body { 2 | /* http://www.colorhunter.com/tag/newspaper/1 */ 3 | /* background-color: #e3e2d7; */ 4 | background-color: #f1f0eb; 5 | box-sizing: border-box; 6 | margin: 0; 7 | padding: 0; 8 | width: 100%; 9 | overflow-x: hidden; 10 | } 11 | 12 | #root { 13 | display: grid; 14 | grid-template-areas: 15 | "navContainer navContainer navContainer" 16 | "titleContainer titleContainer titleContainer" 17 | "interests parent saved" 18 | "interests parent saved" 19 | "foot foot foot"; 20 | grid-template-columns: 1fr 2fr 1fr; 21 | width: 100%; 22 | font-family: sans-serif; 23 | letter-spacing: .03125rem; 24 | } 25 | 26 | .parent { 27 | grid-area: parent; 28 | display: flex; 29 | flex-direction: column; 30 | align-items: center; 31 | border-top: 1px solid #C3C1B4; 32 | /* align-self: center; */ 33 | padding: 0 5rem; 34 | } 35 | 36 | .navContainer { 37 | grid-area: navContainer; 38 | padding-bottom: 1rem; 39 | border-bottom: 1px solid #C3C1B4; 40 | } 41 | 42 | .footer { 43 | grid-area: foot; 44 | /* padding-top: 1rem; 45 | padding-bottom: -5rem; */ 46 | border-top: 1px solid #C3C1B4; 47 | margin-top: 5rem; 48 | height: 50px; 49 | /* margin-bottom: -10rem; */ 50 | 51 | } 52 | 53 | .titleContainer { 54 | grid-area: titleContainer; 55 | } 56 | 57 | .interestsOuterMost { 58 | grid-area: interests; 59 | margin: 0 auto; 60 | /* display: flex; 61 | width: 100%; 62 | flex-direction: column; 63 | align-items: center; 64 | margin-top: 1rem; 65 | margin-left: 2.5rem; */ 66 | } 67 | 68 | .weatherParent { 69 | grid-area: weather; 70 | } 71 | 72 | .saved { 73 | grid-area: saved; 74 | display: flex; 75 | flex-direction: column; 76 | align-items: center; 77 | border-left: 3px solid #c3c1b4; 78 | } 79 | 80 | .container { 81 | display: flex; 82 | flex-direction: column; 83 | align-items: center; 84 | width: 75%; 85 | } 86 | 87 | .content-container { 88 | display: flex; 89 | width: 66%; 90 | flex-direction: column; 91 | align-items: center; 92 | /* background-color: #ADACA0; */ 93 | background-color: #cccbc1; 94 | border-radius: .25rem; 95 | margin: .5rem 0; 96 | padding-bottom: 1rem; 97 | overflow: hidden; 98 | } 99 | 100 | /* 101 | content 102 | div 103 | ( 104 | div className="container" 105 | ( 106 | article 107 | ( 108 | div className="content article" 109 | ( 110 | h2 111 | img 112 | p 113 | ) 114 | 115 | ) 116 | gif 117 | ( 118 | div className="content gif" 119 | ( 120 | img 121 | a 122 | ) 123 | ) 124 | ) 125 | ) 126 | 127 | */ -------------------------------------------------------------------------------- /user-auth/react-app/src/components/interests/Interests.jsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from "react"; 2 | import { useDispatch, useSelector } from "react-redux"; 3 | import { addInterest, getAllInterests } from "../../store/interests"; 4 | import UpdateInterests from "./UpdateInterests"; 5 | import '../content.css' 6 | import './interests.css' 7 | import Weather from "../weather/Weather"; 8 | 9 | const Interests = () => { 10 | const dispatch = useDispatch() 11 | const [title, setTitle] = useState('') 12 | const [viewInterests, setViewInterests] = useState('Hide') 13 | const user = useSelector((state) => state.session.user); 14 | let allInterests = useSelector(state => state.interest) 15 | let userId 16 | if (user) { 17 | userId = user["id"]; 18 | } 19 | 20 | useEffect(() => { 21 | if (!userId) return 22 | dispatch(getAllInterests()) 23 | }, [dispatch, user]) 24 | 25 | // allInterests = useSelector(state => state.interest) 26 | 27 | const handleSubmit = (e) => { 28 | e.preventDefault() 29 | dispatch(addInterest(userId, title)) 30 | setTitle('') 31 | window.location.reload() 32 | } 33 | 34 | if (!user) { 35 | return null 36 | } 37 | 38 | return ( 39 | <div className='interestsOuterMost'> 40 | <div className='interests'> 41 | <p 42 | className='viewInterests' 43 | value={viewInterests} 44 | onClick={() => viewInterests === 'View' ? setViewInterests('Hide') : setViewInterests('View')} 45 | > 46 | {viewInterests} Your Interests 47 | </p> 48 | {viewInterests === 'Hide' && !user && ( 49 | <p>Log in to add interests to your newsfeed!</p> 50 | )} 51 | {viewInterests === 'Hide' && user && ( 52 | <div className='interestsContainer'> 53 | <form onSubmit={handleSubmit} className='inputHTMLForm'> 54 | <input 55 | type="text" 56 | id="titleInput" 57 | className="titleInput" 58 | placeholder="add new interest" 59 | value={title} 60 | onChange={(e) => setTitle(e.target.value)} 61 | /> 62 | <button type="submit">Add to Feed</button> 63 | </form> 64 | <ul className='interestFormListContainer'> 65 | {Object.values(allInterests).map(interest => ( 66 | <UpdateInterests userId={userId} interest={interest} key={interest.id} /> 67 | ) 68 | )} 69 | </ul> 70 | </div> 71 | )} 72 | </div> 73 | <Weather /> 74 | </div> 75 | ) 76 | } 77 | 78 | export default Interests 79 | -------------------------------------------------------------------------------- /user-auth/migrations/versions/20210712_174530_.py: -------------------------------------------------------------------------------- 1 | """empty message 2 | 3 | Revision ID: 6735414ea296 4 | Revises: 5 | Create Date: 2021-07-12 17:45:30.959512 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = '6735414ea296' 14 | down_revision = None 15 | branch_labels = None 16 | depends_on = None 17 | 18 | 19 | def upgrade(): 20 | # ### commands auto generated by Alembic - please adjust! ### 21 | op.create_table('homeData', 22 | sa.Column('id', sa.Integer(), nullable=False), 23 | sa.Column('itemURL', sa.String(), nullable=False), 24 | sa.Column('imageURL', sa.String(), nullable=True), 25 | sa.Column('title', sa.String(), nullable=False), 26 | sa.Column('description', sa.String(), nullable=True), 27 | sa.PrimaryKeyConstraint('id'), 28 | sa.UniqueConstraint('imageURL'), 29 | sa.UniqueConstraint('itemURL') 30 | ) 31 | op.create_table('tags', 32 | sa.Column('id', sa.Integer(), nullable=False), 33 | sa.Column('title', sa.String(), nullable=False), 34 | sa.PrimaryKeyConstraint('id'), 35 | sa.UniqueConstraint('title') 36 | ) 37 | op.create_table('users', 38 | sa.Column('id', sa.Integer(), nullable=False), 39 | sa.Column('username', sa.String(length=40), nullable=False), 40 | sa.Column('email', sa.String(length=255), nullable=False), 41 | sa.Column('hashed_password', sa.String(length=255), nullable=False), 42 | sa.PrimaryKeyConstraint('id'), 43 | sa.UniqueConstraint('email'), 44 | sa.UniqueConstraint('username') 45 | ) 46 | op.create_table('interests', 47 | sa.Column('id', sa.Integer(), nullable=False), 48 | sa.Column('title', sa.String(), nullable=False), 49 | sa.Column('userId', sa.Integer(), nullable=False), 50 | sa.ForeignKeyConstraint(['userId'], ['users.id'], ), 51 | sa.PrimaryKeyConstraint('id'), 52 | sa.UniqueConstraint('title') 53 | ) 54 | op.create_table('saved', 55 | sa.Column('id', sa.Integer(), nullable=False), 56 | sa.Column('userId', sa.Integer(), nullable=False), 57 | sa.Column('itemURL', sa.String(), nullable=False), 58 | sa.Column('imageURL', sa.String(), nullable=False), 59 | sa.Column('title', sa.String(), nullable=False), 60 | sa.Column('description', sa.String(), nullable=True), 61 | sa.ForeignKeyConstraint(['userId'], ['users.id'], ), 62 | sa.PrimaryKeyConstraint('id'), 63 | sa.UniqueConstraint('itemURL') 64 | ) 65 | op.create_table('userTags', 66 | sa.Column('id', sa.Integer(), nullable=False), 67 | sa.Column('userId', sa.Integer(), nullable=False), 68 | sa.Column('tagId', sa.Integer(), nullable=False), 69 | sa.ForeignKeyConstraint(['tagId'], ['tags.id'], ), 70 | sa.ForeignKeyConstraint(['userId'], ['users.id'], ), 71 | sa.PrimaryKeyConstraint('id') 72 | ) 73 | # ### end Alembic commands ### 74 | 75 | 76 | def downgrade(): 77 | # ### commands auto generated by Alembic - please adjust! ### 78 | op.drop_table('userTags') 79 | op.drop_table('saved') 80 | op.drop_table('interests') 81 | op.drop_table('users') 82 | op.drop_table('tags') 83 | op.drop_table('homeData') 84 | # ### end Alembic commands ### 85 | -------------------------------------------------------------------------------- /user-auth/react-app/src/store/saved.js: -------------------------------------------------------------------------------- 1 | // constants 2 | const READ_SAVED_ARTICLES = 'articles/READ_SAVED_ARTICLES' 3 | const POST_SAVED_ARTICLE = 'articles/POST_SAVED_ARTICLE' 4 | const DELETE_SAVED_ARTICLE = 'articles/DELETE_SAVED_ARTICLE' 5 | 6 | 7 | // actions 8 | const postSavedArticle = (article) => ({ 9 | type: POST_SAVED_ARTICLE, 10 | article 11 | }) 12 | 13 | const readSavedArticles = (articles) => ({ 14 | type: READ_SAVED_ARTICLES, 15 | articles 16 | }) 17 | 18 | const deleteSavedArticle = (article) => ({ 19 | type: DELETE_SAVED_ARTICLE, 20 | article 21 | }) 22 | 23 | // thunks 24 | export const addToSaved = (userId, itemURL, imageURL, title, description, contentSource, publishedAt) => async (dispatch) => { 25 | let publishedTime 26 | publishedAt ? publishedTime = publishedAt.split('T')[0] : publishedTime = null 27 | const res = await fetch('/api/saved/', { 28 | method: "POST", 29 | headers: { 30 | "Content-Type": "application/json" 31 | }, 32 | body: JSON.stringify({ 33 | userId, 34 | itemURL, 35 | imageURL, 36 | title, 37 | description, 38 | contentSource, 39 | publishedTime 40 | }) 41 | }) 42 | 43 | if (res.ok) { 44 | const data = await res.json() 45 | dispatch(postSavedArticle(data)) 46 | } 47 | } 48 | 49 | export const loadSavedArticles = () => async (dispatch) => { 50 | const res = await fetch('/api/saved/') 51 | 52 | if (res.ok) { 53 | const data = await res.json() 54 | dispatch(readSavedArticles(data)) 55 | return data 56 | } 57 | } 58 | 59 | export const removeFromSaved = (userId, itemURL, imageURL, title, description, publishedAt) => async (dispatch) => { 60 | 61 | let publishedTime 62 | publishedAt ? publishedTime = publishedAt.split('T')[0] : publishedTime = null 63 | const res = await fetch('/api/saved/', { 64 | method: "DELETE", 65 | headers: { 66 | "Content-Type": "application/json" 67 | }, 68 | body: JSON.stringify({ 69 | userId, 70 | itemURL, 71 | imageURL, 72 | title, 73 | description, 74 | publishedTime 75 | }) 76 | }) 77 | 78 | if (res.ok) { 79 | const data = await res.json() 80 | dispatch(deleteSavedArticle(data)) 81 | } 82 | } 83 | 84 | // reducer 85 | export default function SavedReducer(state = {}, action) { 86 | let newState = { ...state } 87 | 88 | switch (action.type) { 89 | case READ_SAVED_ARTICLES: 90 | const articles = action.articles.saved 91 | articles.forEach(article => { 92 | newState[article.id] = article 93 | }) 94 | return newState 95 | case POST_SAVED_ARTICLE: 96 | newState[action.article.id] = action.article 97 | return newState 98 | case DELETE_SAVED_ARTICLE: 99 | delete newState[action.article.id] 100 | return newState 101 | default: 102 | return state 103 | } 104 | } -------------------------------------------------------------------------------- /user-auth/react-app/src/components/interests/UpdateInterests.jsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from 'react' 2 | import { useDispatch } from "react-redux"; 3 | import { editInterestTitle } from "../../store/interests"; 4 | import DeleteInterest from "./DeleteInterest"; 5 | import './interests.css' 6 | 7 | const UpdateInterests = ({ userId, interest }) => { 8 | const dispatch = useDispatch() 9 | const [showTitleEditor, setShowTitleEditor] = useState(false) 10 | const [selectedInterestTitle, setSelectedInterestTitle] = useState('') 11 | const [editButtonText, setEditButtonText] = useState('Edit') 12 | 13 | useEffect(() => { 14 | if (selectedInterestTitle === '') { 15 | setSelectedInterestTitle(interest.title) 16 | } else { 17 | interest.title = selectedInterestTitle 18 | } 19 | }, [interest, selectedInterestTitle]) 20 | 21 | const handleEditConfirm = (e) => { 22 | e.preventDefault() 23 | setShowTitleEditor(false) 24 | dispatch(editInterestTitle(userId, interest.id, selectedInterestTitle)) 25 | interest.title = selectedInterestTitle 26 | editButtonText === 'Cancel' ? setEditButtonText('Edit') : setEditButtonText('Cancel') 27 | window.location.reload() 28 | } 29 | 30 | if (!selectedInterestTitle) { 31 | return null 32 | } 33 | 34 | return ( 35 | <div className='interestForm'> 36 | < form onSubmit={handleEditConfirm}> 37 | <li className='interestListItem'> 38 | 39 | {showTitleEditor === false && <div className='interestTitle'>{selectedInterestTitle}</div>} 40 | {showTitleEditor && ( 41 | <> 42 | <input 43 | type="text" 44 | value={selectedInterestTitle} 45 | onChange={e => setSelectedInterestTitle(e.target.value)} 46 | /> 47 | {interest.title !== selectedInterestTitle && 48 | <button 49 | // type="submit" 50 | // value={showTitleEditor} 51 | // onClick={e => setShowTitleEditor(true)} 52 | > 53 | Confirm 54 | </button> 55 | } 56 | </> 57 | )} 58 | <button 59 | className='editBtn' 60 | onClick={(e) => { 61 | e.preventDefault() 62 | editButtonText === 'Cancel' ? setEditButtonText('Edit') : setEditButtonText('Cancel') 63 | setSelectedInterestTitle(interest.title) 64 | setShowTitleEditor(!showTitleEditor) 65 | }} 66 | > 67 | {editButtonText} 68 | </button> 69 | </li> 70 | </form > 71 | <DeleteInterest userId={userId} interest={interest} /> 72 | </div> 73 | ) 74 | } 75 | 76 | export default UpdateInterests 77 | -------------------------------------------------------------------------------- /user-auth/migrations/env.py: -------------------------------------------------------------------------------- 1 | from __future__ import with_statement 2 | 3 | import logging 4 | from logging.config import fileConfig 5 | 6 | from sqlalchemy import engine_from_config 7 | from sqlalchemy import pool 8 | 9 | from alembic import context 10 | 11 | # this is the Alembic Config object, which provides 12 | # access to the values within the .ini file in use. 13 | config = context.config 14 | 15 | # Interpret the config file for Python logging. 16 | # This line sets up loggers basically. 17 | fileConfig(config.config_file_name) 18 | logger = logging.getLogger('alembic.env') 19 | 20 | # add your model's MetaData object here 21 | # for 'autogenerate' support 22 | # from myapp import mymodel 23 | # target_metadata = mymodel.Base.metadata 24 | from flask import current_app 25 | config.set_main_option( 26 | 'sqlalchemy.url', 27 | str(current_app.extensions['migrate'].db.engine.url).replace('%', '%%')) 28 | target_metadata = current_app.extensions['migrate'].db.metadata 29 | 30 | # other values from the config, defined by the needs of env.py, 31 | # can be acquired: 32 | # my_important_option = config.get_main_option("my_important_option") 33 | # ... etc. 34 | 35 | 36 | def run_migrations_offline(): 37 | """Run migrations in 'offline' mode. 38 | 39 | This configures the context with just a URL 40 | and not an Engine, though an Engine is acceptable 41 | here as well. By skipping the Engine creation 42 | we don't even need a DBAPI to be available. 43 | 44 | Calls to context.execute() here emit the given string to the 45 | script output. 46 | 47 | """ 48 | url = config.get_main_option("sqlalchemy.url") 49 | context.configure( 50 | url=url, target_metadata=target_metadata, literal_binds=True 51 | ) 52 | 53 | with context.begin_transaction(): 54 | context.run_migrations() 55 | 56 | 57 | def run_migrations_online(): 58 | """Run migrations in 'online' mode. 59 | 60 | In this scenario we need to create an Engine 61 | and associate a connection with the context. 62 | 63 | """ 64 | 65 | # this callback is used to prevent an auto-migration from being generated 66 | # when there are no changes to the schema 67 | # reference: http://alembic.zzzcomputing.com/en/latest/cookbook.html 68 | def process_revision_directives(context, revision, directives): 69 | if getattr(config.cmd_opts, 'autogenerate', False): 70 | script = directives[0] 71 | if script.upgrade_ops.is_empty(): 72 | directives[:] = [] 73 | logger.info('No changes in schema detected.') 74 | 75 | connectable = engine_from_config( 76 | config.get_section(config.config_ini_section), 77 | prefix='sqlalchemy.', 78 | poolclass=pool.NullPool, 79 | ) 80 | 81 | with connectable.connect() as connection: 82 | context.configure( 83 | connection=connection, 84 | target_metadata=target_metadata, 85 | process_revision_directives=process_revision_directives, 86 | **current_app.extensions['migrate'].configure_args 87 | ) 88 | 89 | with context.begin_transaction(): 90 | context.run_migrations() 91 | 92 | 93 | if context.is_offline_mode(): 94 | run_migrations_offline() 95 | else: 96 | run_migrations_online() 97 | -------------------------------------------------------------------------------- /user-auth/app/seeds/default.py: -------------------------------------------------------------------------------- 1 | from app.models import db, HomeData 2 | 3 | # These are the default items that are to be displayed 4 | # on the homepage if not logged in 5 | def seed_default(): 6 | item1 = HomeData( 7 | itemURL='https://giphy.com/gifs/css-13FrpeVH09Zrb2', 8 | imageURL='https://media0.giphy.com/media/13FrpeVH09Zrb2/giphy.gif?cid=0a74af3bzgqxwfvmdp7kzqt9f8eyzsidwyawtkmesuf76iz9&rid=giphy.gif&ct=g', 9 | title='css GIF', 10 | ) 11 | 12 | item2 = HomeData( 13 | itemURL='https://www.nytimes.com/2021/06/14/podcasts/the-daily/apple-china-privacy.html', 14 | imageURL='https://static01.nyt.com/images/2021/06/14/podcasts/14DAILY-apple-china/14DAILY-apple-china-facebookJumbo.jpg', 15 | title='Apple\'s Bet on China', 16 | description="When the technology giant first started doing business in China, it thought it would change the country. Decades later, the reverse is true." 17 | ) 18 | 19 | item3 = HomeData( 20 | itemURL='https://www.wired.com/story/mystery-malware-stole-26-million-passwords-from-windows-computers/', 21 | imageURL='https://media.wired.com/photos/60c2b2a4301dd5bd8638684b/191:100/w_1280,c_limit/Security-PC-Malware-Passwords-1184216905.jpg', 22 | title="A Mystery Malware Stole 26 Million Passwords From Windows PCs", 23 | description="The credentials were part of a trove containing 1.2 terabytes of sensitive data extracted between 2018 and 2020." 24 | ) 25 | 26 | item4 = HomeData( 27 | itemURL='https://giphy.com/gifs/windows-vaporwave-error-mq5y2jHRCAqMo', 28 | imageURL='https://media3.giphy.com/media/mq5y2jHRCAqMo/giphy.gif?cid=0a74af3bae6yike8gzniaugm2rlmtus45bq5xg499vn3hodb&rid=giphy.gif&ct=g', 29 | title='Windows Error GIF', 30 | ) 31 | 32 | item5 = HomeData( 33 | itemURL='https://gizmodo.com/coffee-is-good-for-you-coffee-is-bad-for-you-1847123270', 34 | imageURL='https://i.kinja-img.com/gawker-media/image/upload/c_fill,f_auto,fl_progressive,g_center,h_675,pg_1,q_80,w_1200/a442813a35185f585a719d4a8e9ba376.jpg', 35 | title='Coffee Is Good for You, Coffee Is Bad for You', 36 | description="If you’re the sort of person who regularly scans the latest science-related headlines, you’ve probably come across the coffee shuffle at some point: Articles reporting on the latest study to show coffee may do something good for you, followed by articles repo…" 37 | ) 38 | 39 | item6 = HomeData( 40 | itemURL='https://www.reuters.com/world/us/brutal-heatwave-descend-us-west-prompting-fire-warnings-2021-06-14/', 41 | imageURL='https://www.reuters.com/resizer/fs8_6Y3NE5uwH8s8Qr8nlgTX2tM=/800x419/smart/filters:quality(80)/cloudfront-us-east-2.images.arcpublishing.com/reuters/DMR3HXQ46VK5FF2ISM2734AYMQ.jpg', 42 | title='Brutal heatwave to descend on U.S. West, prompting fire warnings - Reuters', 43 | description="A heatwave already punishing parts of the U.S. Southwest on Monday was expected to move into California this week, prompting the forecasters to warn of health and fire dangers." 44 | ) 45 | 46 | item7 = HomeData( 47 | itemURL='https://giphy.com/gifs/vaporwave-aesthetic-aesthetics-ADSFDg7OI0TDhd9ucJ', 48 | imageURL='https://media2.giphy.com/media/UT4Qm6EXiCwpGJPKLZ/giphy.gif?cid=0a74af3bkhv1tj59h7ffcsalhleeg9rxvllwn3ll4ts4fxfa&rid=giphy.gif&ct=g', 49 | title='GIF by Mr. Cody England', 50 | ) 51 | 52 | db.session.add(item1) 53 | db.session.add(item2) 54 | db.session.add(item3) 55 | db.session.add(item4) 56 | db.session.add(item5) 57 | db.session.add(item6) 58 | db.session.add(item7) 59 | db.session.commit() 60 | -------------------------------------------------------------------------------- /user-auth/react-app/src/components/NavBar.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { useDispatch, useSelector } from "react-redux"; 3 | import { NavLink } from "react-router-dom"; 4 | import LogoutButton from "./auth/LogoutButton"; 5 | import { login } from "../store/session"; 6 | import { Event } from "./Google Analytics/GoogleAnalytics"; 7 | import "./navbar.css"; 8 | import "./content.css"; 9 | 10 | const NavBar = () => { 11 | const user = useSelector((state) => state.session.user); 12 | const dispatch = useDispatch(); 13 | 14 | const demoUser = async (e) => { 15 | e.preventDefault(); 16 | const email = "demo@aa.io"; 17 | const password = "password"; 18 | await dispatch(login(email, password)); 19 | window.location.reload(); 20 | }; 21 | 22 | // remove navbar once user logs in 23 | // logout button will be rendered with title 24 | // if (user) return (null) 25 | 26 | return ( 27 | <nav className="navContainer"> 28 | <ul className="navbar"> 29 | {!user && ( 30 | <li> 31 | <NavLink 32 | className="authLink" 33 | to="/login" 34 | exact={true} 35 | activeClassName="active" 36 | onClick={() => 37 | Event( 38 | "LOGIN LINK", 39 | "Link to login clicked", 40 | "NAV_BAR" 41 | ) 42 | } 43 | > 44 | Login 45 | </NavLink> 46 | </li> 47 | )} 48 | {!user && ( 49 | <li> 50 | <NavLink 51 | className="authLink" 52 | to="/sign-up" 53 | exact={true} 54 | activeClassName="active" 55 | onClick={() => 56 | Event( 57 | "SIGNUP LINK", 58 | "Link to signup clicked", 59 | "NAV_BAR" 60 | ) 61 | } 62 | > 63 | Sign Up 64 | </NavLink> 65 | </li> 66 | )} 67 | {user && ( 68 | <li 69 | onClick={() => 70 | Event( 71 | "LOGOUT LINK", 72 | "Link to logout clicked", 73 | "NAV_BAR" 74 | ) 75 | } 76 | > 77 | <LogoutButton /> 78 | </li> 79 | )} 80 | {!user && ( 81 | <li 82 | className="demoBtn" 83 | onClick={(e) => { 84 | Event( 85 | "DEMO LINK", 86 | "Link to demo clicked", 87 | "NAV_BAR" 88 | ); 89 | demoUser(e); 90 | }} 91 | > 92 | Demo User 93 | </li> 94 | )} 95 | </ul> 96 | </nav> 97 | ); 98 | }; 99 | 100 | export default NavBar; 101 | -------------------------------------------------------------------------------- /user-auth/react-app/src/store/interests.js: -------------------------------------------------------------------------------- 1 | // constants 2 | const CREATE_INTEREST = 'interests/CREATE_INTEREST' 3 | const READ_INTERESTS = 'interests/READ_INTERESTS' 4 | const READ_ONE_INTEREST = 'interests/READ_ONE_INTEREST' // REMOVE THIS? 5 | const UPDATE_INTEREST = 'interests/UPDATE_INTEREST' 6 | const DELETE_INTEREST = 'interests/DELETE_INTEREST' 7 | 8 | // actions 9 | const createNewInterest = (interest) => ({ 10 | type: CREATE_INTEREST, 11 | interest 12 | }) 13 | 14 | const readAllInterests = (interests) => ({ 15 | type: READ_INTERESTS, 16 | interests 17 | }) 18 | 19 | const readOneInterest = (interest) => ({ 20 | type: READ_ONE_INTEREST, 21 | interest 22 | }) 23 | 24 | const updateInterest = (interest) => ({ 25 | type: UPDATE_INTEREST, 26 | interest 27 | }) 28 | 29 | const deleteInterest = (interest) => ({ 30 | type: DELETE_INTEREST, 31 | interest 32 | }) 33 | 34 | // thunks 35 | export const addInterest = (userId, title) => async (dispatch) => { 36 | const res = await fetch('/api/interests/', { 37 | method: "POST", 38 | headers: { 39 | "Content-Type": "application/json" 40 | }, 41 | body: JSON.stringify({ 42 | userId, 43 | title 44 | }) 45 | }) 46 | 47 | if (res.ok) { 48 | const data = await res.json() 49 | dispatch(createNewInterest(data)) 50 | } 51 | } 52 | 53 | export const getAllInterests = () => async (dispatch) => { 54 | const res = await fetch('/api/interests/') 55 | 56 | if (res.ok) { 57 | const data = await res.json() 58 | dispatch(readAllInterests(data)) 59 | } 60 | } 61 | 62 | export const getOneInterest = (interestId) => async (dispatch) => { 63 | const res = await fetch(`/api/interest/${interestId}/`) 64 | 65 | if (res.ok) { 66 | const data = await res.json() 67 | dispatch(readAllInterests(data)) 68 | } 69 | 70 | if (res.ok) { 71 | const data = await res.json() 72 | dispatch(readOneInterest(data)) 73 | } 74 | } 75 | 76 | export const editInterestTitle = (userId, interestId, title) => async (dispatch) => { 77 | const res = await fetch(`/api/interests/`, { 78 | method: "PUT", 79 | headers: { 80 | "Content-Type": "application/json" 81 | }, 82 | body: JSON.stringify({ 83 | userId, 84 | interestId, 85 | title 86 | }) 87 | }) 88 | if (res.ok) { 89 | const data = await res.json() 90 | const dataToDispatch = { 91 | userId, 92 | interestId, 93 | "title": data.title 94 | } 95 | dispatch(updateInterest(dataToDispatch)) 96 | return(dataToDispatch) 97 | } 98 | } 99 | 100 | export const removeInterest = (userId, interestId, title) => async (dispatch) => { 101 | const res = await fetch(`/api/interests/`, { 102 | method: "DELETE", 103 | headers: { 104 | "Content-Type": "application/json" 105 | }, 106 | body: JSON.stringify({ 107 | userId, 108 | interestId, 109 | title 110 | }) 111 | }) 112 | if (res.ok) { 113 | const data = await res.json() 114 | const dataToDispatch = { 115 | userId, 116 | interestId, 117 | "title": data.title 118 | } 119 | dispatch(deleteInterest(dataToDispatch)) 120 | } 121 | } 122 | 123 | // reducer 124 | export default function InterestReducer(state={}, action) { 125 | let newState={...state} 126 | switch(action.type) { 127 | case CREATE_INTEREST: 128 | newState[action.interest.id] = action.interest 129 | return newState 130 | case READ_INTERESTS: 131 | action.interests.interests.forEach(interest => { 132 | newState[interest.id] = interest 133 | }) 134 | return newState 135 | case READ_ONE_INTEREST: 136 | newState[action.interest.interests.id] = action.interest.interests 137 | return newState 138 | case UPDATE_INTEREST: 139 | const interest = { 140 | "id": action.interest.interestId, 141 | "tite": action.interest.title, 142 | "userId": action.interest.userId 143 | } 144 | newState[interest.id] = interest 145 | return newState 146 | case DELETE_INTEREST: 147 | delete newState[action.interest.interestId] 148 | return newState 149 | default: 150 | return state 151 | } 152 | } -------------------------------------------------------------------------------- /user-auth/react-app/src/components/weather/Weather.jsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from 'react' 2 | import { useDispatch, useSelector } from 'react-redux' 3 | import { getWeather } from '../../store/weather' 4 | import { getDays, getAbbrDays } from './getDays' 5 | import './weather.css' 6 | import '../content.css' 7 | 8 | const Weather = () => { 9 | const dispatch = useDispatch() 10 | const user = useSelector((state) => state.session.user); 11 | const weather = useSelector(state => state.weather) 12 | const [week, setWeek] = useState(getDays()) 13 | const [abbrWeek, setAbbrWeek] = useState(getAbbrDays(week)) 14 | 15 | //set these vars if weather successfully fetched 16 | const [city, setCity] = useState(weather) 17 | 18 | const [currentTemp, setCurrentTemp] = useState(0) 19 | const [currentTempIcon, setCurrentTempIcon] = useState('') 20 | const [iconImage, setIconImage] = useState('') 21 | const [todayHigh, setTodayHigh] = useState(0) 22 | const [todayLow, setTodayLow] = useState(0) 23 | const [todayDescription, setTodayDescription] = useState('') 24 | 25 | const [day1High, setday1High] = useState(0) 26 | const [day1Low, setday1Low] = useState(0) 27 | const [day1Name, setday1Name] = useState('') 28 | const [day1TempIcon, setDay1TempIcon] = useState('') 29 | 30 | const [day2High, setday2High] = useState(0) 31 | const [day2Low, setday2Low] = useState(0) 32 | const [day2Name, setday2Name] = useState('') 33 | const [day2TempIcon, setDay2TempIcon] = useState('') 34 | 35 | const [day3High, setday3High] = useState(0) 36 | const [day3Low, setday3Low] = useState(0) 37 | const [day3Name, setday3Name] = useState('') 38 | const [day3TempIcon, setDay3TempIcon] = useState('') 39 | 40 | const [day4High, setday4High] = useState(0) 41 | const [day4Low, setday4Low] = useState(0) 42 | const [day4Name, setday4Name] = useState('') 43 | const [day4TempIcon, setDay4TempIcon] = useState('') 44 | 45 | const [day5High, setday5High] = useState(0) 46 | const [day5Low, setday5Low] = useState(0) 47 | const [day5Name, setday5Name] = useState('') 48 | const [day5TempIcon, setDay5TempIcon] = useState('') 49 | 50 | const [day6High, setday6High] = useState(0) 51 | const [day6Low, setday6Low] = useState(0) 52 | const [day6Name, setday6Name] = useState('') 53 | const [day6TempIcon, setDay6TempIcon] = useState('') 54 | 55 | 56 | useEffect(() => { 57 | 58 | const getIconImage = (currentTempIcon) => { 59 | const iconURL = `http://openweathermap.org/img/wn/${currentTempIcon}@2x.png` 60 | 61 | setIconImage(iconURL) 62 | 63 | 64 | } 65 | 66 | //if weather is loaded, set the state values 67 | if (Object.values(weather).length > 0 && !weather.error) { 68 | 69 | setCity(weather.location[0]) 70 | 71 | setCurrentTemp(Math.floor(weather.weather.current.temp)) 72 | setCurrentTempIcon(weather.weather.current.weather[0].icon) 73 | 74 | getIconImage(weather.weather.current.weather[0].icon) 75 | 76 | setTodayHigh(Math.floor(weather.weather.daily[0].temp.max)) 77 | setTodayLow(Math.floor(weather.weather.daily[0].temp.min)) 78 | setTodayDescription(weather.weather.current.weather[0].description) 79 | 80 | setday1High(Math.floor(weather.weather.daily[1].temp.max)) 81 | setday1Low(Math.floor(weather.weather.daily[1].temp.min)) 82 | setday1Name(abbrWeek[1]) 83 | const day1Icon = weather.weather.daily[1].weather[0].icon 84 | setDay1TempIcon(`http://openweathermap.org/img/wn/${day1Icon}.png`) 85 | 86 | setday2High(Math.floor(weather.weather.daily[2].temp.max)) 87 | setday2Low(Math.floor(weather.weather.daily[2].temp.min)) 88 | setday2Name(abbrWeek[2]) 89 | const day2Icon = weather.weather.daily[2].weather[0].icon 90 | setDay2TempIcon(`http://openweathermap.org/img/wn/${day2Icon}.png`) 91 | 92 | setday3High(Math.floor(weather.weather.daily[3].temp.max)) 93 | setday3Low(Math.floor(weather.weather.daily[3].temp.min)) 94 | setday3Name(abbrWeek[3]) 95 | const day3Icon = weather.weather.daily[3].weather[0].icon 96 | setDay3TempIcon(`http://openweathermap.org/img/wn/${day3Icon}.png`) 97 | 98 | setday4High(Math.floor(weather.weather.daily[4].temp.max)) 99 | setday4Low(Math.floor(weather.weather.daily[4].temp.min)) 100 | setday4Name(abbrWeek[4]) 101 | const day4Icon = weather.weather.daily[4].weather[0].icon 102 | 103 | setDay4TempIcon(`http://openweathermap.org/img/wn/${day4Icon}.png`) 104 | 105 | setday5High(Math.floor(weather.weather.daily[5].temp.max)) 106 | setday5Low(Math.floor(weather.weather.daily[5].temp.min)) 107 | setday5Name(abbrWeek[5]) 108 | const day5Icon = weather.weather.daily[5].weather[0].icon 109 | setDay5TempIcon(`http://openweathermap.org/img/wn/${day5Icon}.png`) 110 | 111 | setday6High(Math.floor(weather.weather.daily[6].temp.max)) 112 | setday6Low(Math.floor(weather.weather.daily[6].temp.min)) 113 | setday6Name(abbrWeek[6]) 114 | const day6Icon = weather.weather.daily[6].weather[0].icon 115 | setDay6TempIcon(`http://openweathermap.org/img/wn/${day6Icon}.png`) 116 | } 117 | else dispatch(getWeather()) 118 | 119 | }, [dispatch, weather]) 120 | 121 | 122 | if (weather.error) { 123 | 124 | return null 125 | } else if (!user) { 126 | return null 127 | } else { 128 | 129 | 130 | return ( 131 | <div className='weatherParent'> 132 | {weather.weather && ( 133 | <div className='weatherContainer'> 134 | <div className='weatherToday'> 135 | <div className='city'> 136 | <p className='cityName'>{`Weather in ${city}`}</p> 137 | </div> 138 | </div> 139 | <div className='todayDivInner'> 140 | <p className='todayDescription'>{todayDescription}</p> 141 | <p className='currentTemp'>{currentTemp}{'\u00B0'}F</p> 142 | </div> 143 | <img className='todayImage' src={iconImage} alt="f" /> 144 | <div className='day0 weatherWeek'> 145 | <p className='day'>Today</p> 146 | {currentTempIcon && <img className='weekImage' src={`http://openweathermap.org/img/wn/${currentTempIcon}.png`} alt="" />} 147 | 148 | <p className='dayHigh'>{todayHigh}{'\u00B0'}F</p> 149 | <p className='dayLow'>{todayLow}{'\u00B0'}F</p> 150 | </div> 151 | <div className='day1 weatherWeek'> 152 | <p className='day'>{day1Name}</p> 153 | <img className='weekImage' src={day1TempIcon} alt="" /> 154 | <p className='dayHigh'>{day1High}{'\u00B0'}F</p> 155 | <p className='dayLow'>{day1Low}{'\u00B0'}F</p> 156 | </div> 157 | <div className='day2 weatherWeek'> 158 | <p className='day'>{day2Name}</p> 159 | <img className='weekImage' src={day2TempIcon} alt="" /> 160 | <p className='dayHigh'>{day2High}{'\u00B0'}F</p> 161 | <p className='dayLow'>{day2Low}{'\u00B0'}F</p> 162 | </div> 163 | <div className='day3 weatherWeek'> 164 | <p className='day'>{day3Name}</p> 165 | <img className='weekImage' src={day3TempIcon} alt="" /> 166 | <p className='dayHigh'>{day3High}{'\u00B0'}F</p> 167 | <p className='dayLow'>{day3Low}{'\u00B0'}F</p> 168 | </div> 169 | <div className='day4 weatherWeek'> 170 | <p className='day'>{day4Name}</p> 171 | <img className='weekImage' src={day4TempIcon} alt="" /> 172 | <p className='dayHigh'>{day4High}{'\u00B0'}F</p> 173 | <p className='dayLow'>{day4Low}{'\u00B0'}F</p> 174 | </div> 175 | <div className='day5 weatherWeek'> 176 | <p className='day'>{day5Name}</p> 177 | <img className='weekImage' src={day5TempIcon} alt="" /> 178 | <p className='dayHigh'>{day5High}{'\u00B0'}F</p> 179 | <p className='dayLow'>{day5Low}{'\u00B0'}F</p> 180 | </div> 181 | <div className='day6 weatherWeek'> 182 | <p className='day'>{day6Name}</p> 183 | <img className='weekImage' src={day6TempIcon} alt="" /> 184 | <p className='dayHigh'>{day6High}{'\u00B0'}F</p> 185 | <p className='dayLow'>{day6Low}{'\u00B0'}F</p> 186 | </div> 187 | </div> 188 | )} 189 | 190 | </div> 191 | ) 192 | } 193 | } 194 | export default Weather 195 | -------------------------------------------------------------------------------- /user-auth/Pipfile.lock: -------------------------------------------------------------------------------- 1 | { 2 | "_meta": { 3 | "hash": { 4 | "sha256": "228bd794ad4b112fa52e6fd2ba8c1d8d34c8e11476a526881ae94010734ea429" 5 | }, 6 | "pipfile-spec": 6, 7 | "requires": { 8 | "python_version": "3.9" 9 | }, 10 | "sources": [ 11 | { 12 | "name": "pypi", 13 | "url": "https://pypi.org/simple", 14 | "verify_ssl": true 15 | } 16 | ] 17 | }, 18 | "default": { 19 | "alembic": { 20 | "hashes": [ 21 | "sha256:4e02ed2aa796bd179965041afa092c55b51fb077de19d61835673cc80672c01c", 22 | "sha256:5334f32314fb2a56d86b4c4dd1ae34b08c03cae4cb888bc699942104d66bc245" 23 | ], 24 | "index": "pypi", 25 | "version": "==1.4.3" 26 | }, 27 | "certifi": { 28 | "hashes": [ 29 | "sha256:2bbf76fd432960138b3ef6dda3dde0544f27cbf8546c458e60baf371917ba9ee", 30 | "sha256:50b1e4f8446b06f41be7dd6338db18e0990601dce795c2b1686458aa7e8fa7d8" 31 | ], 32 | "version": "==2021.5.30" 33 | }, 34 | "chardet": { 35 | "hashes": [ 36 | "sha256:0d6f53a15db4120f2b08c94f11e7d93d2c911ee118b6b30a04ec3ee8310179fa", 37 | "sha256:f864054d66fd9118f2e67044ac8981a54775ec5b67aed0441892edb553d21da5" 38 | ], 39 | "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", 40 | "version": "==4.0.0" 41 | }, 42 | "click": { 43 | "hashes": [ 44 | "sha256:d2b5255c7c6349bc1bd1e59e08cd12acbbd63ce649f2588755783aa94dfb6b1a", 45 | "sha256:dacca89f4bfadd5de3d7489b7c8a566eee0d3676333fbb50030263894c38c0dc" 46 | ], 47 | "index": "pypi", 48 | "version": "==7.1.2" 49 | }, 50 | "dnspython": { 51 | "hashes": [ 52 | "sha256:95d12f6ef0317118d2a1a6fc49aac65ffec7eb8087474158f42f26a639135216", 53 | "sha256:e4a87f0b573201a0f3727fa18a516b055fd1107e0e5477cded4a2de497df1dd4" 54 | ], 55 | "markers": "python_version >= '3.6'", 56 | "version": "==2.1.0" 57 | }, 58 | "email-validator": { 59 | "hashes": [ 60 | "sha256:5675c8ceb7106a37e40e2698a57c056756bf3f272cfa8682a4f87ebd95d8440b", 61 | "sha256:aa237a65f6f4da067119b7df3f13e89c25c051327b2b5b66dc075f33d62480d7" 62 | ], 63 | "index": "pypi", 64 | "version": "==1.1.3" 65 | }, 66 | "flask": { 67 | "hashes": [ 68 | "sha256:4efa1ae2d7c9865af48986de8aeb8504bf32c7f3d6fdc9353d34b21f4b127060", 69 | "sha256:8a4fdd8936eba2512e9c85df320a37e694c93945b33ef33c89946a340a238557" 70 | ], 71 | "index": "pypi", 72 | "version": "==1.1.2" 73 | }, 74 | "flask-cors": { 75 | "hashes": [ 76 | "sha256:72170423eb4612f0847318afff8c247b38bd516b7737adfc10d1c2cdbb382d16", 77 | "sha256:f4d97201660e6bbcff2d89d082b5b6d31abee04b1b3003ee073a6fd25ad1d69a" 78 | ], 79 | "index": "pypi", 80 | "version": "==3.0.8" 81 | }, 82 | "flask-jwt-extended": { 83 | "hashes": [ 84 | "sha256:0aa8ee6fa7eb3be9314e39dd199ac8e19389a95371f9d54e155c7aa635e319dd" 85 | ], 86 | "index": "pypi", 87 | "version": "==3.24.1" 88 | }, 89 | "flask-login": { 90 | "hashes": [ 91 | "sha256:6d33aef15b5bcead780acc339464aae8a6e28f13c90d8b1cf9de8b549d1c0b4b", 92 | "sha256:7451b5001e17837ba58945aead261ba425fdf7b4f0448777e597ddab39f4fba0" 93 | ], 94 | "index": "pypi", 95 | "version": "==0.5.0" 96 | }, 97 | "flask-migrate": { 98 | "hashes": [ 99 | "sha256:4dc4a5cce8cbbb06b8dc963fd86cf8136bd7d875aabe2d840302ea739b243732", 100 | "sha256:a69d508c2e09d289f6e55a417b3b8c7bfe70e640f53d2d9deb0d056a384f37ee" 101 | ], 102 | "index": "pypi", 103 | "version": "==2.5.3" 104 | }, 105 | "flask-sqlalchemy": { 106 | "hashes": [ 107 | "sha256:05b31d2034dd3f2a685cbbae4cfc4ed906b2a733cff7964ada450fd5e462b84e", 108 | "sha256:bfc7150eaf809b1c283879302f04c42791136060c6eeb12c0c6674fb1291fae5" 109 | ], 110 | "index": "pypi", 111 | "version": "==2.4.4" 112 | }, 113 | "flask-wtf": { 114 | "hashes": [ 115 | "sha256:57b3faf6fe5d6168bda0c36b0df1d05770f8e205e18332d0376ddb954d17aef2", 116 | "sha256:d417e3a0008b5ba583da1763e4db0f55a1269d9dd91dcc3eb3c026d3c5dbd720" 117 | ], 118 | "index": "pypi", 119 | "version": "==0.14.3" 120 | }, 121 | "gunicorn": { 122 | "hashes": [ 123 | "sha256:1904bb2b8a43658807108d59c3f3d56c2b6121a701161de0ddf9ad140073c626", 124 | "sha256:cd4a810dd51bf497552cf3f863b575dabd73d6ad6a91075b65936b151cbf4f9c" 125 | ], 126 | "index": "pypi", 127 | "version": "==20.0.4" 128 | }, 129 | "idna": { 130 | "hashes": [ 131 | "sha256:b307872f855b18632ce0c21c5e45be78c0ea7ae4c15c828c20788b26921eb3f6", 132 | "sha256:b97d804b1e9b523befed77c48dacec60e6dcb0b5391d57af6a65a312a90648c0" 133 | ], 134 | "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", 135 | "version": "==2.10" 136 | }, 137 | "itsdangerous": { 138 | "hashes": [ 139 | "sha256:321b033d07f2a4136d3ec762eac9f16a10ccd60f53c0c91af90217ace7ba1f19", 140 | "sha256:b12271b2047cb23eeb98c8b5622e2e5c5e9abd9784a153e9d8ef9cb4dd09d749" 141 | ], 142 | "index": "pypi", 143 | "version": "==1.1.0" 144 | }, 145 | "jinja2": { 146 | "hashes": [ 147 | "sha256:89aab215427ef59c34ad58735269eb58b1a5808103067f7bb9d5836c651b3bb0", 148 | "sha256:f0a4641d3cf955324a89c04f3d94663aa4d638abe8f733ecd3582848e1c37035" 149 | ], 150 | "index": "pypi", 151 | "version": "==2.11.2" 152 | }, 153 | "mako": { 154 | "hashes": [ 155 | "sha256:8195c8c1400ceb53496064314c6736719c6f25e7479cd24c77be3d9361cddc27", 156 | "sha256:93729a258e4ff0747c876bd9e20df1b9758028946e976324ccd2d68245c7b6a9" 157 | ], 158 | "index": "pypi", 159 | "version": "==1.1.3" 160 | }, 161 | "markupsafe": { 162 | "hashes": [ 163 | "sha256:00bc623926325b26bb9605ae9eae8a215691f33cae5df11ca5424f06f2d1f473", 164 | "sha256:09027a7803a62ca78792ad89403b1b7a73a01c8cb65909cd876f7fcebd79b161", 165 | "sha256:09c4b7f37d6c648cb13f9230d847adf22f8171b1ccc4d5682398e77f40309235", 166 | "sha256:1027c282dad077d0bae18be6794e6b6b8c91d58ed8a8d89a89d59693b9131db5", 167 | "sha256:13d3144e1e340870b25e7b10b98d779608c02016d5184cfb9927a9f10c689f42", 168 | "sha256:195d7d2c4fbb0ee8139a6cf67194f3973a6b3042d742ebe0a9ed36d8b6f0c07f", 169 | "sha256:22c178a091fc6630d0d045bdb5992d2dfe14e3259760e713c490da5323866c39", 170 | "sha256:24982cc2533820871eba85ba648cd53d8623687ff11cbb805be4ff7b4c971aff", 171 | "sha256:29872e92839765e546828bb7754a68c418d927cd064fd4708fab9fe9c8bb116b", 172 | "sha256:2beec1e0de6924ea551859edb9e7679da6e4870d32cb766240ce17e0a0ba2014", 173 | "sha256:3b8a6499709d29c2e2399569d96719a1b21dcd94410a586a18526b143ec8470f", 174 | "sha256:43a55c2930bbc139570ac2452adf3d70cdbb3cfe5912c71cdce1c2c6bbd9c5d1", 175 | "sha256:46c99d2de99945ec5cb54f23c8cd5689f6d7177305ebff350a58ce5f8de1669e", 176 | "sha256:500d4957e52ddc3351cabf489e79c91c17f6e0899158447047588650b5e69183", 177 | "sha256:535f6fc4d397c1563d08b88e485c3496cf5784e927af890fb3c3aac7f933ec66", 178 | "sha256:596510de112c685489095da617b5bcbbac7dd6384aeebeda4df6025d0256a81b", 179 | "sha256:62fe6c95e3ec8a7fad637b7f3d372c15ec1caa01ab47926cfdf7a75b40e0eac1", 180 | "sha256:6788b695d50a51edb699cb55e35487e430fa21f1ed838122d722e0ff0ac5ba15", 181 | "sha256:6dd73240d2af64df90aa7c4e7481e23825ea70af4b4922f8ede5b9e35f78a3b1", 182 | "sha256:6f1e273a344928347c1290119b493a1f0303c52f5a5eae5f16d74f48c15d4a85", 183 | "sha256:6fffc775d90dcc9aed1b89219549b329a9250d918fd0b8fa8d93d154918422e1", 184 | "sha256:717ba8fe3ae9cc0006d7c451f0bb265ee07739daf76355d06366154ee68d221e", 185 | "sha256:79855e1c5b8da654cf486b830bd42c06e8780cea587384cf6545b7d9ac013a0b", 186 | "sha256:7c1699dfe0cf8ff607dbdcc1e9b9af1755371f92a68f706051cc8c37d447c905", 187 | "sha256:7fed13866cf14bba33e7176717346713881f56d9d2bcebab207f7a036f41b850", 188 | "sha256:84dee80c15f1b560d55bcfe6d47b27d070b4681c699c572af2e3c7cc90a3b8e0", 189 | "sha256:88e5fcfb52ee7b911e8bb6d6aa2fd21fbecc674eadd44118a9cc3863f938e735", 190 | "sha256:8defac2f2ccd6805ebf65f5eeb132adcf2ab57aa11fdf4c0dd5169a004710e7d", 191 | "sha256:98bae9582248d6cf62321dcb52aaf5d9adf0bad3b40582925ef7c7f0ed85fceb", 192 | "sha256:98c7086708b163d425c67c7a91bad6e466bb99d797aa64f965e9d25c12111a5e", 193 | "sha256:9add70b36c5666a2ed02b43b335fe19002ee5235efd4b8a89bfcf9005bebac0d", 194 | "sha256:9bf40443012702a1d2070043cb6291650a0841ece432556f784f004937f0f32c", 195 | "sha256:a6a744282b7718a2a62d2ed9d993cad6f5f585605ad352c11de459f4108df0a1", 196 | "sha256:acf08ac40292838b3cbbb06cfe9b2cb9ec78fce8baca31ddb87aaac2e2dc3bc2", 197 | "sha256:ade5e387d2ad0d7ebf59146cc00c8044acbd863725f887353a10df825fc8ae21", 198 | "sha256:b00c1de48212e4cc9603895652c5c410df699856a2853135b3967591e4beebc2", 199 | "sha256:b1282f8c00509d99fef04d8ba936b156d419be841854fe901d8ae224c59f0be5", 200 | "sha256:b1dba4527182c95a0db8b6060cc98ac49b9e2f5e64320e2b56e47cb2831978c7", 201 | "sha256:b2051432115498d3562c084a49bba65d97cf251f5a331c64a12ee7e04dacc51b", 202 | "sha256:b7d644ddb4dbd407d31ffb699f1d140bc35478da613b441c582aeb7c43838dd8", 203 | "sha256:ba59edeaa2fc6114428f1637ffff42da1e311e29382d81b339c1817d37ec93c6", 204 | "sha256:bf5aa3cbcfdf57fa2ee9cd1822c862ef23037f5c832ad09cfea57fa846dec193", 205 | "sha256:c8716a48d94b06bb3b2524c2b77e055fb313aeb4ea620c8dd03a105574ba704f", 206 | "sha256:caabedc8323f1e93231b52fc32bdcde6db817623d33e100708d9a68e1f53b26b", 207 | "sha256:cd5df75523866410809ca100dc9681e301e3c27567cf498077e8551b6d20e42f", 208 | "sha256:cdb132fc825c38e1aeec2c8aa9338310d29d337bebbd7baa06889d09a60a1fa2", 209 | "sha256:d53bc011414228441014aa71dbec320c66468c1030aae3a6e29778a3382d96e5", 210 | "sha256:d73a845f227b0bfe8a7455ee623525ee656a9e2e749e4742706d80a6065d5e2c", 211 | "sha256:d9be0ba6c527163cbed5e0857c451fcd092ce83947944d6c14bc95441203f032", 212 | "sha256:e249096428b3ae81b08327a63a485ad0878de3fb939049038579ac0ef61e17e7", 213 | "sha256:e8313f01ba26fbbe36c7be1966a7b7424942f670f38e666995b88d012765b9be", 214 | "sha256:feb7b34d6325451ef96bc0e36e1a6c0c1c64bc1fbec4b854f4529e51887b1621" 215 | ], 216 | "index": "pypi", 217 | "version": "==1.1.1" 218 | }, 219 | "pyjwt": { 220 | "hashes": [ 221 | "sha256:5c6eca3c2940464d106b99ba83b00c6add741c9becaec087fb7ccdefea71350e", 222 | "sha256:8d59a976fb773f3e6a39c85636357c4f0e242707394cadadd9814f5cbaa20e96" 223 | ], 224 | "index": "pypi", 225 | "version": "==1.7.1" 226 | }, 227 | "python-dateutil": { 228 | "hashes": [ 229 | "sha256:73ebfe9dbf22e832286dafa60473e4cd239f8592f699aa5adaf10050e6e1823c", 230 | "sha256:75bb3f31ea686f1197762692a9ee6a7550b59fc6ca3a1f4b5d7e32fb98e2da2a" 231 | ], 232 | "index": "pypi", 233 | "version": "==2.8.1" 234 | }, 235 | "python-dotenv": { 236 | "hashes": [ 237 | "sha256:8c10c99a1b25d9a68058a1ad6f90381a62ba68230ca93966882a4dbc3bc9c33d", 238 | "sha256:c10863aee750ad720f4f43436565e4c1698798d763b63234fb5021b6c616e423" 239 | ], 240 | "index": "pypi", 241 | "version": "==0.14.0" 242 | }, 243 | "python-editor": { 244 | "hashes": [ 245 | "sha256:1bf6e860a8ad52a14c3ee1252d5dc25b2030618ed80c022598f00176adc8367d", 246 | "sha256:51fda6bcc5ddbbb7063b2af7509e43bd84bfc32a4ff71349ec7847713882327b", 247 | "sha256:5f98b069316ea1c2ed3f67e7f5df6c0d8f10b689964a4a811ff64f0106819ec8", 248 | "sha256:c3da2053dbab6b29c94e43c486ff67206eafbe7eb52dbec7390b5e2fb05aac77", 249 | "sha256:ea87e17f6ec459e780e4221f295411462e0d0810858e055fc514684350a2f522" 250 | ], 251 | "index": "pypi", 252 | "version": "==1.0.4" 253 | }, 254 | "requests": { 255 | "hashes": [ 256 | "sha256:27973dd4a904a4f13b263a19c866c13b92a39ed1c964655f025f3f8d3d75b804", 257 | "sha256:c210084e36a42ae6b9219e00e48287def368a26d03a048ddad7bfee44f75871e" 258 | ], 259 | "index": "pypi", 260 | "version": "==2.25.1" 261 | }, 262 | "six": { 263 | "hashes": [ 264 | "sha256:30639c035cdb23534cd4aa2dd52c3bf48f06e5f4a941509c8bafd8ce11080259", 265 | "sha256:8b74bedcbbbaca38ff6d7491d76f2b06b3592611af620f8426e82dddb04a5ced" 266 | ], 267 | "index": "pypi", 268 | "version": "==1.15.0" 269 | }, 270 | "sqlalchemy": { 271 | "hashes": [ 272 | "sha256:072766c3bd09294d716b2d114d46ffc5ccf8ea0b714a4e1c48253014b771c6bb", 273 | "sha256:107d4af989831d7b091e382d192955679ec07a9209996bf8090f1f539ffc5804", 274 | "sha256:15c0bcd3c14f4086701c33a9e87e2c7ceb3bcb4a246cd88ec54a49cf2a5bd1a6", 275 | "sha256:26c5ca9d09f0e21b8671a32f7d83caad5be1f6ff45eef5ec2f6fd0db85fc5dc0", 276 | "sha256:276936d41111a501cf4a1a0543e25449108d87e9f8c94714f7660eaea89ae5fe", 277 | "sha256:3292a28344922415f939ee7f4fc0c186f3d5a0bf02192ceabd4f1129d71b08de", 278 | "sha256:33d29ae8f1dc7c75b191bb6833f55a19c932514b9b5ce8c3ab9bc3047da5db36", 279 | "sha256:3bba2e9fbedb0511769780fe1d63007081008c5c2d7d715e91858c94dbaa260e", 280 | "sha256:465c999ef30b1c7525f81330184121521418a67189053bcf585824d833c05b66", 281 | "sha256:51064ee7938526bab92acd049d41a1dc797422256086b39c08bafeffb9d304c6", 282 | "sha256:5a49e8473b1ab1228302ed27365ea0fadd4bf44bc0f9e73fe38e10fdd3d6b4fc", 283 | "sha256:618db68745682f64cedc96ca93707805d1f3a031747b5a0d8e150cfd5055ae4d", 284 | "sha256:6547b27698b5b3bbfc5210233bd9523de849b2bb8a0329cd754c9308fc8a05ce", 285 | "sha256:6557af9e0d23f46b8cd56f8af08eaac72d2e3c632ac8d5cf4e20215a8dca7cea", 286 | "sha256:73a40d4fcd35fdedce07b5885905753d5d4edf413fbe53544dd871f27d48bd4f", 287 | "sha256:8280f9dae4adb5889ce0bb3ec6a541bf05434db5f9ab7673078c00713d148365", 288 | "sha256:83469ad15262402b0e0974e612546bc0b05f379b5aa9072ebf66d0f8fef16bea", 289 | "sha256:860d0fe234922fd5552b7f807fbb039e3e7ca58c18c8d38aa0d0a95ddf4f6c23", 290 | "sha256:883c9fb62cebd1e7126dd683222b3b919657590c3e2db33bdc50ebbad53e0338", 291 | "sha256:8afcb6f4064d234a43fea108859942d9795c4060ed0fbd9082b0f280181a15c1", 292 | "sha256:96f51489ac187f4bab588cf51f9ff2d40b6d170ac9a4270ffaed535c8404256b", 293 | "sha256:9e865835e36dfbb1873b65e722ea627c096c11b05f796831e3a9b542926e979e", 294 | "sha256:aa0554495fe06172b550098909be8db79b5accdf6ffb59611900bea345df5eba", 295 | "sha256:b595e71c51657f9ee3235db8b53d0b57c09eee74dfb5b77edff0e46d2218dc02", 296 | "sha256:b6ff91356354b7ff3bd208adcf875056d3d886ed7cef90c571aef2ab8a554b12", 297 | "sha256:b70bad2f1a5bd3460746c3fb3ab69e4e0eb5f59d977a23f9b66e5bdc74d97b86", 298 | "sha256:c7adb1f69a80573698c2def5ead584138ca00fff4ad9785a4b0b2bf927ba308d", 299 | "sha256:c898b3ebcc9eae7b36bd0b4bbbafce2d8076680f6868bcbacee2d39a7a9726a7", 300 | "sha256:e49947d583fe4d29af528677e4f0aa21f5e535ca2ae69c48270ebebd0d8843c0", 301 | "sha256:eb1d71643e4154398b02e88a42fc8b29db8c44ce4134cf0f4474bfc5cb5d4dac", 302 | "sha256:f2e8a9c0c8813a468aa659a01af6592f71cd30237ec27c4cc0683f089f90dcfc", 303 | "sha256:fe7fe11019fc3e6600819775a7d55abc5446dda07e9795f5954fdbf8a49e1c37" 304 | ], 305 | "index": "pypi", 306 | "version": "==1.3.19" 307 | }, 308 | "urllib3": { 309 | "hashes": [ 310 | "sha256:39fb8672126159acb139a7718dd10806104dec1e2f0f6c88aab05d17df10c8d4", 311 | "sha256:f57b4c16c62fa2760b7e3d97c35b255512fb6b59a259730f36ba32ce9f8e342f" 312 | ], 313 | "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4' and python_version < '4'", 314 | "version": "==1.26.6" 315 | }, 316 | "werkzeug": { 317 | "hashes": [ 318 | "sha256:2de2a5db0baeae7b2d2664949077c2ac63fbd16d98da0ff71837f7d1dea3fd43", 319 | "sha256:6c80b1e5ad3665290ea39320b91e1be1e0d5f60652b964a3070216de83d2e47c" 320 | ], 321 | "index": "pypi", 322 | "version": "==1.0.1" 323 | }, 324 | "wtforms": { 325 | "hashes": [ 326 | "sha256:7b504fc724d0d1d4d5d5c114e778ec88c37ea53144683e084215eed5155ada4c", 327 | "sha256:81195de0ac94fbc8368abbaf9197b88c4f3ffd6c2719b5bf5fc9da744f3d829c" 328 | ], 329 | "index": "pypi", 330 | "version": "==2.3.3" 331 | } 332 | }, 333 | "develop": { 334 | "astroid": { 335 | "hashes": [ 336 | "sha256:38b95085e9d92e2ca06cf8b35c12a74fa81da395a6f9e65803742e6509c05892", 337 | "sha256:606b2911d10c3dcf35e58d2ee5c97360e8477d7b9f3efc3f24811c93e6fc2cd9" 338 | ], 339 | "markers": "python_version ~= '3.6'", 340 | "version": "==2.6.2" 341 | }, 342 | "autopep8": { 343 | "hashes": [ 344 | "sha256:276ced7e9e3cb22e5d7c14748384a5cf5d9002257c0ed50c0e075b68011bb6d0", 345 | "sha256:aa213493c30dcdac99537249ee65b24af0b2c29f2e83cd8b3f68760441ed0db9" 346 | ], 347 | "index": "pypi", 348 | "version": "==1.5.7" 349 | }, 350 | "isort": { 351 | "hashes": [ 352 | "sha256:83510593e07e433b77bd5bff0f6f607dbafa06d1a89022616f02d8b699cfcd56", 353 | "sha256:8e2c107091cfec7286bc0f68a547d0ba4c094d460b732075b6fba674f1035c0c" 354 | ], 355 | "markers": "python_version < '4.0' and python_full_version >= '3.6.1'", 356 | "version": "==5.9.1" 357 | }, 358 | "lazy-object-proxy": { 359 | "hashes": [ 360 | "sha256:17e0967ba374fc24141738c69736da90e94419338fd4c7c7bef01ee26b339653", 361 | "sha256:1fee665d2638491f4d6e55bd483e15ef21f6c8c2095f235fef72601021e64f61", 362 | "sha256:22ddd618cefe54305df49e4c069fa65715be4ad0e78e8d252a33debf00f6ede2", 363 | "sha256:24a5045889cc2729033b3e604d496c2b6f588c754f7a62027ad4437a7ecc4837", 364 | "sha256:410283732af311b51b837894fa2f24f2c0039aa7f220135192b38fcc42bd43d3", 365 | "sha256:4732c765372bd78a2d6b2150a6e99d00a78ec963375f236979c0626b97ed8e43", 366 | "sha256:489000d368377571c6f982fba6497f2aa13c6d1facc40660963da62f5c379726", 367 | "sha256:4f60460e9f1eb632584c9685bccea152f4ac2130e299784dbaf9fae9f49891b3", 368 | "sha256:5743a5ab42ae40caa8421b320ebf3a998f89c85cdc8376d6b2e00bd12bd1b587", 369 | "sha256:85fb7608121fd5621cc4377a8961d0b32ccf84a7285b4f1d21988b2eae2868e8", 370 | "sha256:9698110e36e2df951c7c36b6729e96429c9c32b3331989ef19976592c5f3c77a", 371 | "sha256:9d397bf41caad3f489e10774667310d73cb9c4258e9aed94b9ec734b34b495fd", 372 | "sha256:b579f8acbf2bdd9ea200b1d5dea36abd93cabf56cf626ab9c744a432e15c815f", 373 | "sha256:b865b01a2e7f96db0c5d12cfea590f98d8c5ba64ad222300d93ce6ff9138bcad", 374 | "sha256:bf34e368e8dd976423396555078def5cfc3039ebc6fc06d1ae2c5a65eebbcde4", 375 | "sha256:c6938967f8528b3668622a9ed3b31d145fab161a32f5891ea7b84f6b790be05b", 376 | "sha256:d1c2676e3d840852a2de7c7d5d76407c772927addff8d742b9808fe0afccebdf", 377 | "sha256:d7124f52f3bd259f510651450e18e0fd081ed82f3c08541dffc7b94b883aa981", 378 | "sha256:d900d949b707778696fdf01036f58c9876a0d8bfe116e8d220cfd4b15f14e741", 379 | "sha256:ebfd274dcd5133e0afae738e6d9da4323c3eb021b3e13052d8cbd0e457b1256e", 380 | "sha256:ed361bb83436f117f9917d282a456f9e5009ea12fd6de8742d1a4752c3017e93", 381 | "sha256:f5144c75445ae3ca2057faac03fda5a902eff196702b0a24daf1d6ce0650514b" 382 | ], 383 | "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4, 3.5'", 384 | "version": "==1.6.0" 385 | }, 386 | "mccabe": { 387 | "hashes": [ 388 | "sha256:ab8a6258860da4b6677da4bd2fe5dc2c659cff31b3ee4f7f5d64e79735b80d42", 389 | "sha256:dd8d182285a0fe56bace7f45b5e7d1a6ebcbf524e8f3bd87eb0f125271b8831f" 390 | ], 391 | "version": "==0.6.1" 392 | }, 393 | "psycopg2-binary": { 394 | "hashes": [ 395 | "sha256:0deac2af1a587ae12836aa07970f5cb91964f05a7c6cdb69d8425ff4c15d4e2c", 396 | "sha256:0e4dc3d5996760104746e6cfcdb519d9d2cd27c738296525d5867ea695774e67", 397 | "sha256:11b9c0ebce097180129e422379b824ae21c8f2a6596b159c7659e2e5a00e1aa0", 398 | "sha256:15978a1fbd225583dd8cdaf37e67ccc278b5abecb4caf6b2d6b8e2b948e953f6", 399 | "sha256:1fabed9ea2acc4efe4671b92c669a213db744d2af8a9fc5d69a8e9bc14b7a9db", 400 | "sha256:2dac98e85565d5688e8ab7bdea5446674a83a3945a8f416ad0110018d1501b94", 401 | "sha256:42ec1035841b389e8cc3692277a0bd81cdfe0b65d575a2c8862cec7a80e62e52", 402 | "sha256:6422f2ff0919fd720195f64ffd8f924c1395d30f9a495f31e2392c2efafb5056", 403 | "sha256:6a32f3a4cb2f6e1a0b15215f448e8ce2da192fd4ff35084d80d5e39da683e79b", 404 | "sha256:7312e931b90fe14f925729cde58022f5d034241918a5c4f9797cac62f6b3a9dd", 405 | "sha256:7d92a09b788cbb1aec325af5fcba9fed7203897bbd9269d5691bb1e3bce29550", 406 | "sha256:833709a5c66ca52f1d21d41865a637223b368c0ee76ea54ca5bad6f2526c7679", 407 | "sha256:89705f45ce07b2dfa806ee84439ec67c5d9a0ef20154e0e475e2b2ed392a5b83", 408 | "sha256:8cd0fb36c7412996859cb4606a35969dd01f4ea34d9812a141cd920c3b18be77", 409 | "sha256:950bc22bb56ee6ff142a2cb9ee980b571dd0912b0334aa3fe0fe3788d860bea2", 410 | "sha256:a0c50db33c32594305b0ef9abc0cb7db13de7621d2cadf8392a1d9b3c437ef77", 411 | "sha256:a0eb43a07386c3f1f1ebb4dc7aafb13f67188eab896e7397aa1ee95a9c884eb2", 412 | "sha256:aaa4213c862f0ef00022751161df35804127b78adf4a2755b9f991a507e425fd", 413 | "sha256:ac0c682111fbf404525dfc0f18a8b5f11be52657d4f96e9fcb75daf4f3984859", 414 | "sha256:ad20d2eb875aaa1ea6d0f2916949f5c08a19c74d05b16ce6ebf6d24f2c9f75d1", 415 | "sha256:b4afc542c0ac0db720cf516dd20c0846f71c248d2b3d21013aa0d4ef9c71ca25", 416 | "sha256:b8a3715b3c4e604bcc94c90a825cd7f5635417453b253499664f784fc4da0152", 417 | "sha256:ba28584e6bca48c59eecbf7efb1576ca214b47f05194646b081717fa628dfddf", 418 | "sha256:ba381aec3a5dc29634f20692349d73f2d21f17653bda1decf0b52b11d694541f", 419 | "sha256:bd1be66dde2b82f80afb9459fc618216753f67109b859a361cf7def5c7968729", 420 | "sha256:c2507d796fca339c8fb03216364cca68d87e037c1f774977c8fc377627d01c71", 421 | "sha256:cec7e622ebc545dbb4564e483dd20e4e404da17ae07e06f3e780b2dacd5cee66", 422 | "sha256:d14b140a4439d816e3b1229a4a525df917d6ea22a0771a2a78332273fd9528a4", 423 | "sha256:d1b4ab59e02d9008efe10ceabd0b31e79519da6fb67f7d8e8977118832d0f449", 424 | "sha256:d5227b229005a696cc67676e24c214740efd90b148de5733419ac9aaba3773da", 425 | "sha256:e1f57aa70d3f7cc6947fd88636a481638263ba04a742b4a37dd25c373e41491a", 426 | "sha256:e74a55f6bad0e7d3968399deb50f61f4db1926acf4a6d83beaaa7df986f48b1c", 427 | "sha256:e82aba2188b9ba309fd8e271702bd0d0fc9148ae3150532bbb474f4590039ffb", 428 | "sha256:ee69dad2c7155756ad114c02db06002f4cded41132cc51378e57aad79cc8e4f4", 429 | "sha256:f5ab93a2cb2d8338b1674be43b442a7f544a0971da062a5da774ed40587f18f5" 430 | ], 431 | "index": "pypi", 432 | "version": "==2.8.6" 433 | }, 434 | "pycodestyle": { 435 | "hashes": [ 436 | "sha256:514f76d918fcc0b55c6680472f0a37970994e07bbb80725808c17089be302068", 437 | "sha256:c389c1d06bf7904078ca03399a4816f974a1d590090fecea0c63ec26ebaf1cef" 438 | ], 439 | "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", 440 | "version": "==2.7.0" 441 | }, 442 | "pylint": { 443 | "hashes": [ 444 | "sha256:23a1dc8b30459d78e9ff25942c61bb936108ccbe29dd9e71c01dc8274961709a", 445 | "sha256:5d46330e6b8886c31b5e3aba5ff48c10f4aa5e76cbf9002c6544306221e63fbc" 446 | ], 447 | "index": "pypi", 448 | "version": "==2.9.3" 449 | }, 450 | "toml": { 451 | "hashes": [ 452 | "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b", 453 | "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f" 454 | ], 455 | "markers": "python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2, 3.3'", 456 | "version": "==0.10.2" 457 | }, 458 | "wrapt": { 459 | "hashes": [ 460 | "sha256:b62ffa81fb85f4332a4f609cab4ac40709470da05643a082ec1eb88e6d9b97d7" 461 | ], 462 | "version": "==1.12.1" 463 | } 464 | } 465 | } 466 | --------------------------------------------------------------------------------