commit 892c80861c82ebb2c78e37a66fb2b048a79e217d Author: zevav Date: Wed Oct 30 14:45:02 2019 +0100 first diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..04f325f --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +Pipfile +codealike.json diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/crud.py b/app/crud.py new file mode 100644 index 0000000..b4329be --- /dev/null +++ b/app/crud.py @@ -0,0 +1,72 @@ +from pydantic import BaseModel +from sqlalchemy.orm import Session + +import app.models +from app.database import Base +from . import schemas + + +class DoesntExist(Exception): + pass + + +class ArgumentError(Exception): + pass + + +def create_thing(session: Session, model: Base, data: BaseModel): + instance = model(**data.dict()) + session.add(instance) + session.commit() + session.refresh(instance) + return instance + + +def get_thing(session: Session, model: Base, thing_id: int = None, multi=False, **kwargs): + if thing_id and multi: + raise ArgumentError + + if thing_id: + return session.query(model).filter_by(id=thing_id).first() + elif kwargs: + return session.query(model).filter_by(**kwargs).first() + else: + if multi: + return session.query(model).all() + else: + return session.query(model).first() + + +def update_thing(db: Session, + model: Base, + schema: BaseModel, + thing_id: int, + **kwargs + ) -> BaseModel: + if not kwargs: + raise ArgumentError + + if any(kwarg_key not in schema.__fields__.keys() for kwarg_key in kwargs): + raise ArgumentError + + thing = get_thing(db, model, thing_id=thing_id) + if thing is None: + raise DoesntExist + + for key, value in kwargs.items(): + setattr(thing, key, value) + + db.commit() + db.refresh(thing) + return schema(**thing.to_dict()) + + +def delete_thing(db: Session, + model: Base, + thing_id: int): + thing = get_thing(db, model=model, thing_id=thing_id) + if not thing: + raise DoesntExist + else: + db.delete(thing) + db.commit() diff --git a/app/database.py b/app/database.py new file mode 100644 index 0000000..24821bb --- /dev/null +++ b/app/database.py @@ -0,0 +1,9 @@ +from sqlalchemy import create_engine +from sqlalchemy.ext.declarative import declarative_base +from sqlalchemy.orm import sessionmaker + +from config import SQLALCHEMY_DATABASE_URL + +engine = create_engine(SQLALCHEMY_DATABASE_URL) +SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) +Base = declarative_base() diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..785602a --- /dev/null +++ b/app/main.py @@ -0,0 +1,153 @@ +from typing import List + +from fastapi import FastAPI, Depends, HTTPException +from fastapi.security import HTTPBasic, HTTPBasicCredentials +from sqlalchemy.orm import Session +from starlette.requests import Request +from starlette.status import HTTP_401_UNAUTHORIZED, HTTP_409_CONFLICT + +from app import schemas, models +from config import PERSONAL_API_USERNAME, PERSONAL_API_PASS +from .crud import ( + create_thing as create, get_thing as get, update_thing as update, DoesntExist, + delete_thing as delete, +) +from .database import SessionLocal, Base, engine + +Base.metadata.create_all(bind=engine) +app = FastAPI() +security = HTTPBasic() + + +def get_db(): + try: + db = SessionLocal() + yield db + finally: + # noinspection PyUnboundLocalVariable + db.close() + + +def get_current_username(credentials: HTTPBasicCredentials = Depends(security)): + if credentials.username != PERSONAL_API_USERNAME or credentials.password != PERSONAL_API_PASS: + raise HTTPException( + status_code=HTTP_401_UNAUTHORIZED, + detail="Forbidden", + headers={"WWW-Authenticate": "Basic"}, + ) + return credentials.username + + +@app.get("/") +def read_root(_: str = Depends(get_current_username)): + return {"Hello": "World"} + + +@app.get("/availability/", response_model=schemas.Availability) +def get_availability(db: Session = Depends(get_db), _: str = Depends(get_current_username)): + return get(db, models.Availability) + + +@app.get("/resume/", response_model=schemas.Resume) +def get_resume(db: Session = Depends(get_db), _: str = Depends(get_current_username)): + return get(db, models.Resume) + + +@app.get("/projects/", response_model=List[schemas.Project]) +def get_projects(db: Session = Depends(get_db), _: str = Depends(get_current_username)): + return get(db, models.Project, multi=True) + + +@app.get("/projects/{project_id}", response_model=schemas.Project) +def get_projects(project_id: int, db: Session = Depends(get_db), _: str = Depends(get_current_username)): + return get(db, models.Project, thing_id=project_id) + + +@app.get("/posts/", response_model=List[schemas.Post]) +def get_posts(db: Session = Depends(get_db), _: str = Depends(get_current_username)): + return get(db, models.Post, multi=True) + + +@app.get("/posts/{post_id}", response_model=schemas.Post) +def get_post(post_id: int, db: Session = Depends(get_db), _: str = Depends(get_current_username)): + return get(db, models.Post, post_id) + + +@app.post("/posts/", response_model=schemas.Post) +def post_post( + post_: schemas.PostCreate, + db: Session = Depends(get_db), + _: str = Depends(get_current_username) +): + if get(db, model=models.Post, title=post_.title): + raise HTTPException(status_code=409) + return create(db, model=models.Post, data=post_) + + +@app.patch("/posts/{post_id}") +async def patch_project(post_id: int, + request: Request, + db: Session = Depends(get_db), + _: str = Depends(get_current_username), + ): + return update(db, model=models.Post, schema=schemas.PostCreate, thing_id=post_id, **await request.json()) + + +@app.post("/availability/", response_model=schemas.Availability) +def post_availability( + availability: schemas.AvailabilityCreate, + db: Session = Depends(get_db), + _: str = Depends(get_current_username) +): + return create(db, model=models.Availability, data=availability) + + +@app.post("/resume/", response_model=schemas.Resume) +def post_resume( + resume: schemas.ResumeCreate, + db: Session = Depends(get_db), + _: str = Depends(get_current_username) +): + return create(db, model=models.Resume, data=resume) + + +@app.post("/projects/", response_model=schemas.Project) +def post_project( + project: schemas.ProjectCreate, + db: Session = Depends(get_db), + _: str = Depends(get_current_username) +): + if get(db, model=models.Project, title=project.title): + raise HTTPException(status_code=409) + return create(db, model=models.Project, data=project) + + +@app.patch("/projects/{project_id}") +async def patch_project(project_id: int, + request: Request, + db: Session = Depends(get_db), + _: str = Depends(get_current_username), + ): + return update(db, models.Project, schemas.ProjectCreate, project_id, **await request.json()) + + +@app.delete("/projects/{project_id}") +def delete_project(project_id: int, + db: Session = Depends(get_db), + _: str = Depends(get_current_username), + ): + try: + delete(db, models.Project, project_id) + except DoesntExist: + raise HTTPException(status_code=404) + + +@app.delete("/posts/{post_id}") +def delete_project(post_id: int, + db: Session = Depends(get_db), + _: str = Depends(get_current_username), + ): + try: + delete(db, models.Post, post_id) + except DoesntExist: + raise HTTPException(status_code=404) diff --git a/app/models.py b/app/models.py new file mode 100644 index 0000000..550f22c --- /dev/null +++ b/app/models.py @@ -0,0 +1,75 @@ +""" +BaseMixIn include `id` as the primary key, +as well as adding a self.when value in the constructor +""" +import datetime as dt +from sqlalchemy import Column, DateTime, String, Boolean, Date + +from app.database import Base, SessionLocal +from helpers import parse_date_string, BaseMixIn + + +def update(instance, **kwargs): + for key, value in kwargs.items(): + setattr(instance, key, value) + instance.when = dt.datetime.now() + db = SessionLocal() + db.commit() + + +class Resume(BaseMixIn, Base): + __tablename__ = "résumé" + + when = Column(DateTime, nullable=False) + url = Column(String, nullable=False, unique=True) + + +class Availability(BaseMixIn, Base): + __tablename__ = "availability" + + when = Column(DateTime, nullable=False) + available = Column(Boolean, nullable=False) + next_available = Column(Date) + + def __init__(self, next_available=None, **kwargs): + super().__init__(**kwargs) + if next_available: + self.next_available = parse_date_string(next_available) + else: + self.next_available = next_available + + +class Project(BaseMixIn, Base): + # TODO: add 'tags' table and make a many-to-many relationship between projects and tags + + __tablename__ = 'projects' + + when = Column(DateTime, nullable=False) + title = Column(String, nullable=False, unique=True) + description = Column(String, nullable=False) + link = Column(String, unique=True) + + def update(self, **kwargs): + update(self, **kwargs) + + def to_dict(self): + return {attr_name: getattr(self, attr_name) for attr_name in ['title', 'description', 'link']} + + +class Post(BaseMixIn, Base): + + __tablename__ = "posts" + + when = Column(DateTime, nullable=False) + title = Column(String, nullable=False, unique=True) + description = Column(String, nullable=False) + body = Column(String, nullable=False) + custom_url = Column(String) + syndicate = Column(Boolean) + + def update(self, **kwargs): + update(self, **kwargs) + + def to_dict(self): + return {attr_name: getattr(self, attr_name) + for attr_name in ['title', 'description', 'body', 'custom_url', 'syndicate']} diff --git a/app/schemas.py b/app/schemas.py new file mode 100644 index 0000000..b703e3b --- /dev/null +++ b/app/schemas.py @@ -0,0 +1,75 @@ +from datetime import date, datetime + +from pydantic import BaseModel, UrlStr + + +class AvailabilityBase(BaseModel): + available: bool + next_available: str = None + + +class AvailabilityCreate(AvailabilityBase): + pass + + +class Availability(AvailabilityBase): + id: int + when: datetime + next_available: date = None + + class Config: + orm_mode = True + + +class ResumeBase(BaseModel): + url: UrlStr + + +class ResumeCreate(ResumeBase): + pass + + +class Resume(ResumeBase): + id: int + when: datetime + + class Config: + orm_mode = True + + +class ProjectBase(BaseModel): + title: str + description: str + link: UrlStr = None + + +class ProjectCreate(ProjectBase): + pass + + +class Project(ProjectBase): + id: int + when: datetime + + class Config: + orm_mode = True + + +class PostBase(BaseModel): + title: str + description: str + body: str + custom_url: str = None + syndicate: bool = None + + +class PostCreate(PostBase): + pass + + +class Post(PostBase): + id: int + when: datetime + + class Config: + orm_mode = True diff --git a/config.py b/config.py new file mode 100644 index 0000000..acba345 --- /dev/null +++ b/config.py @@ -0,0 +1,7 @@ +import os + +PERSONAL_API_USERNAME = os.getenv("PERSONAL_API_USERNAME") +PERSONAL_API_PASS = os.getenv("PERSONAL_API_USERNAME") + +# must be a postgres instance +SQLALCHEMY_DATABASE_URL = os.getenv("PERSONAL_API_SQLALCHEMY_DATABASE_URL") \ No newline at end of file diff --git a/helpers.py b/helpers.py new file mode 100644 index 0000000..79ab2ec --- /dev/null +++ b/helpers.py @@ -0,0 +1,34 @@ +import datetime as dt + +import dateparser as dp +from sqlalchemy import Column, Integer, desc + +from app.database import SessionLocal + + +class Invalid(Exception): + pass + + +def parse_date_string(date_string) -> dt.date: + try: + return dp.parse(date_string).date() + except Exception: + raise Invalid + + +class BaseMixIn: + + id = Column(Integer, primary_key=True, index=True) + + __mapper_args__ = {"order_by": desc("id")} + + def __init__(self, **kwargs): + super(BaseMixIn, self).__init__(**kwargs) + self.when = dt.datetime.now() + + @classmethod + def delete_most_recent(cls): + db = SessionLocal() + db.delete(db.query(cls).first()) + db.commit() \ No newline at end of file diff --git a/tests.py b/tests.py new file mode 100644 index 0000000..7737b7c --- /dev/null +++ b/tests.py @@ -0,0 +1,201 @@ +""" +TODO: set up a test db or mocks instead of relying there being at least one item available in most of the 'get' tests. +""" +from pytest import skip, mark +from starlette.testclient import TestClient + +from app.crud import create_thing +from app.models import Resume, Availability, Project, Post +from config import PERSONAL_API_USERNAME, PERSONAL_API_PASS +from app.main import app + +client = TestClient(app) + +VALID_AUTH = (PERSONAL_API_USERNAME, PERSONAL_API_PASS) + + +def test_main_no_auth(): + response = client.get('/') + assert response.status_code == 401 + assert response.json() == {"detail": "Not authenticated"} + + +def test_main_wrong_auth(): + response = client.get('/', auth=('wronguser', 'wrongpass')) + assert response.status_code == 401 + assert response.json() == {"detail": "Forbidden"} + + +def test_main_correct_auth(): + response = client.get('/', auth=VALID_AUTH) + assert response.status_code == 200 + assert response.json() == {"Hello": "World"} + + +def test_get_availability(): + response = client.get('/availability/', auth=VALID_AUTH) + assert response.status_code == 200 + + +def test_set_availability(): + response = client.post('/availability/', + auth=VALID_AUTH, + json={ + "available": False, + "next_available": "in two weeks", + }) + assert response.status_code == 200 + Availability.delete_most_recent() + assert response.json()['available'] is False + + +def test_get_resume(): + response = client.get('/resume/', auth=VALID_AUTH) + assert response.status_code == 200 + + +def test_post_resume(): + response = client.post('/resume/', auth=VALID_AUTH, json={"url": "http://hypo.thetical"}) + assert response.status_code == 200 + Resume.delete_most_recent() + assert response.json()['url'] == 'http://hypo.thetical' + + +def test_get_projects(): + response = client.get('/projects/', auth=VALID_AUTH) + assert response.status_code == 200 + + +def test_get_project(): + response = client.get('/projects/1', auth=VALID_AUTH) + assert response.status_code == 200 + + +def test_post_project(): + response = client.post('/projects/', auth=VALID_AUTH, + json={ + "title": "Project Time", + "description": "This is a project I built one time.", + "link": "http://hypo.thetical", + }) + assert response.status_code == 200 + Project.delete_most_recent() + assert response.json()['link'] == 'http://hypo.thetical' + + +def test_post_project_duplicate_title(): + projects = client.get('/projects/', auth=VALID_AUTH).json() + existing_project = projects[0] + original_title = existing_project['title'] + + response = client.post('/projects/', auth=VALID_AUTH, + json={ + "title": original_title, + "description": "yup" + }) + assert response.status_code == 409 + + +def test_patch_project(): + projects = client.get('/projects/', auth=VALID_AUTH).json() + existing_project = projects[0] + original_title = existing_project['title'] + id_ = existing_project['id'] + + response = client.patch(f'/projects/{id_}', auth=VALID_AUTH, json={"title": "Different Title"}) + assert response.status_code == 200 + new_title = response.json()['title'] + # put it back to how it was + response = client.patch(f'/projects/{id_}', auth=VALID_AUTH, json={"title": original_title}) + assert response.status_code == 200 + assert new_title == "Different Title" + + +def test_get_posts(): + response = client.get('/posts/', auth=VALID_AUTH) + assert response.status_code == 200 + + +def test_post_post(): + response = client.post('/posts/', auth=VALID_AUTH, + json={ + "title": "Project Time", + "body": "This is a project I built one time.", + "description": "yup" + }) + assert response.status_code == 200 + Post.delete_most_recent() + assert response.json()['title'] == 'Project Time' + + +def test_post_post_insufficient_data(): + response = client.post('/posts/', auth=VALID_AUTH, + json={ + "title": "Project Time", + "body": "This is a project I built one time.", + }) + assert response.status_code == 422 + + +def test_post_post_duplicate_title(): + posts = client.get('/posts/', auth=VALID_AUTH).json() + existing_post = posts[0] + original_title = existing_post['title'] + + response = client.post('/posts/', auth=VALID_AUTH, + json={ + "title": original_title, + "body": "This is a project I built one time.", + "description": "yup" + }) + assert response.status_code == 409 + + +def test_patch_post(): + posts = client.get('/posts/', auth=VALID_AUTH).json() + existing_post = posts[0] + original_title = existing_post['title'] + id_ = existing_post['id'] + + response = client.patch(f'/posts/{id_}', auth=VALID_AUTH, json={"title": "Different Title"}) + assert response.status_code == 200 + new_title = response.json()['title'] + # put it back to how it was + response = client.patch(f'/posts/{id_}', auth=VALID_AUTH, json={"title": original_title}) + assert response.status_code == 200 + assert new_title == "Different Title" + + +def test_delete_post(): + posts = client.get('/posts/', auth=VALID_AUTH).json() + existing_post = posts[0] + id_ = existing_post['id'] + response = client.delete(f"/posts/{id_}", auth=VALID_AUTH) + assert response.status_code == 200 + + # restore post + response = client.post('/posts/', auth=VALID_AUTH, + json={ + "title": existing_post['title'], + "body": existing_post['body'], + "description": existing_post['description'], + }) + assert response.status_code == 200 + + +def test_delete_project(): + projects = client.get('/projects/', auth=VALID_AUTH).json() + existing_project = projects[0] + id_ = existing_project['id'] + print(id_) + response = client.delete(f"/projects/{id_}", auth=VALID_AUTH) + assert response.status_code == 200 + + # restore project + response = client.post('/projects/', auth=VALID_AUTH, + json={ + "title": existing_project['title'], + "link": existing_project['link'], + "description": existing_project['description'], + }) + assert response.status_code == 200