Skip to content

Commit

Permalink
commit
Browse files Browse the repository at this point in the history
  • Loading branch information
hilr committed Nov 27, 2023
1 parent dfdc4ae commit cb123e7
Show file tree
Hide file tree
Showing 44 changed files with 3,132 additions and 0 deletions.
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ __pycache__/
*.py[cod]
*$py.class

*.sqlite
requirements.txt
# C extensions
*.so

Expand Down
15 changes: 15 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
FROM python:3.11
RUN apt-get update && \
apt-get -y install cron && \
mkdir -p /etc/cron.d && \
mkdir -p /app/db

WORKDIR /app
COPY requirements.txt /
RUN pip install -r /requirements.txt
COPY app /app/app
COPY scripts /app/scripts
VOLUME /app/db
RUN mv /app/scripts/crontab /etc/cron.d/crontab && \
crontab /etc/cron.d/crontab
CMD cron && bash -x /app/scripts/start-server.sh
23 changes: 23 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@

start_dev:
docker-compose -f docker-compose-dev.yml up -d
docker logs -f api-service

stop_dev:
docker-compose -f docker-compose-dev.yml down

start:
docker-compose -f docker-compose.yml up -d

stop:
docker-compose -f docker-compose.yml stop

build:
poetry export -f requirements.txt --output requirements.txt
docker build -t api-service .

gen_sample:
bash -x ./scripts/gen_sample_db.sh

test:
bash -x ./scripts/test.sh
63 changes: 63 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
README
=======

### Develop requirements
* python 3.11
* poetry
* docker-compose

### Use poetry to manage project, check poetry for usage details
```
poetry env use 3.11
poetry install --sync
```

### Start/Stop develop environment
```
make start_dev
make stop_dev
```

### Swagger/Redoc API Documents

* swagger: `http://localhost:8080/docs`
* redoc: `http://localhost:8080/redoc`

### Configuration
application is configed by environments. Most of them have default values. Check docker-compose.yml for the values needed to be set
```
TESTING: Optional[str] = None
API_STR: str = "/api"
PROJECT_NAME: str = 'tvmaze'
API_KEY: str = secrets.token_urlsafe(64)
SECRET_KEY: str = secrets.token_urlsafe(32)
DEFAULT_JOB_COUNTRY: str = 'US'
SQLALCHEMY_DATABASE_URI: str = 'sqlite://'
REDIS_HOST: Optional[str] = None
REDIS_PORT: int = 6379
ACCESS_TOKEN_EXPIRE_MINUTES: int = 60 * 24 * 7
SMTP_TLS: bool = True
SMTP_PORT: Optional[int] = None
SMTP_HOST: Optional[str] = None
SMTP_USER: Optional[str] = None
SMTP_PASSWORD: Optional[str] = None
EMAILS_FROM_EMAIL: Optional[EmailStr] = None
EMAILS_FROM_NAME: Optional[str] = None
EMAIL_TO: Optional[EmailStr] = None
```

### Start/Stop application
```
make start
make stop
```

### Generate sample data
```
make gen_sample
```

### Unit test
```
make test
```
Empty file added app/__init__.py
Empty file.
38 changes: 38 additions & 0 deletions app/api/deps.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
from typing import Generator, Annotated

from fastapi import Depends, HTTPException, Header, Response
from app.config import settings
from app.db import models
from uuid import UUID
from app.db.session import SessionLocal
from sqlalchemy.orm import Session
from . import session as http_session

def get_db() -> Generator:
try:
db = SessionLocal()
yield db
finally:
db.close()

def get_api_key(
api_key: Annotated[str | None, Header()],
) -> str:
if api_key and api_key == settings.API_KEY:
return api_key
raise HTTPException(
status_code=400,
detail="Unauthorized",
)

async def get_session(
session_id: Annotated[UUID, Depends(http_session.cookie)],
session_data: Annotated[http_session.SessionData, Depends(http_session.session)],
response: Response
) -> Generator:
if not session_data:
if isinstance(session_id, UUID):
await http_session.session_storage.delete(session_id)
http_session.cookie.delete_from_response(response)
raise HTTPException(status_code=400, detail="Unauthorized")
return session_data
8 changes: 8 additions & 0 deletions app/api/endpoints/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
from fastapi import APIRouter

from . import episode, user, job

api_router = APIRouter()
api_router.include_router(episode.router, prefix="/episodes", tags=["episodes"])
api_router.include_router(user.router, prefix="/users", tags=["users"])
api_router.include_router(job.router, prefix="/jobs", tags=["jobs"])
142 changes: 142 additions & 0 deletions app/api/endpoints/episode.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
from typing import Any, List, Annotated, Optional, Dict

from fastapi import APIRouter, Depends, HTTPException
from fastapi.encoders import jsonable_encoder
from sqlalchemy.orm import Session
from sqlalchemy import select, and_, func, distinct, desc
from pydantic import PositiveInt
import logging
logger = logging.getLogger(__name__)
import app.db.models as models
from .. import schema, deps, session as http_session

router = APIRouter()

@router.get("/")
async def search_episodes(
db: Annotated[Session, Depends(deps.get_db)],
session_data: Annotated[http_session.SessionData, Depends(deps.get_session)],
limit: PositiveInt = 10,
offset: PositiveInt = 0,
search_field: Optional[str] = None,
search_value: Optional[str | int | float] = None,
order: Optional[str] = 'asc',
orderby: Optional[str] = 'id',
) -> List[schema.TVEpisode]:
if search_field and search_value:
if search_field in ['id', 'season', 'number', 'runtime']:
search_value = int(search_value)
elif search_field == 'rating_average':
search_value = float(search_value)
schema.TVEpisode(**{search_field: search_value})
stmt = select(models.TVEpisode).where(getattr(models.TVEpisode, search_field) == search_value).limit(limit).offset(offset)
else:
stmt = select(models.TVEpisode).limit(limit).offset(offset)
if orderby not in schema.TVEpisode.__fields__.keys():
raise HTTPException(status_code=400, detail="Invalid orderby")
if order not in ['asc', 'desc']:
raise HTTPException(status_code=400, detail="Invalid order")
if order == 'asc':
stmt = stmt.order_by(getattr(models.TVEpisode, orderby))
else:
stmt = stmt.order_by(desc(getattr(models.TVEpisode, orderby)))
return db.scalars(stmt).all()

@router.get('/likes')
async def get_liked_episodes(
db: Annotated[Session, Depends(deps.get_db)],
session_data: Annotated[http_session.SessionData, Depends(deps.get_session)],
) -> List[schema.TVEpisode]:
stmt = select(models.UserEpisode).where(and_(models.UserEpisode.user_id == session_data.user_id, models.UserEpisode.like == True))
links = db.scalars(stmt).all()
ids = [i.episode_id for i in links]
stmt = select(models.TVEpisode).where(models.TVEpisode.id.in_(ids))
return db.scalars(stmt).all()

@router.get('/bookmarks')
async def get_liked_episodes(
db: Annotated[Session, Depends(deps.get_db)],
session_data: Annotated[http_session.SessionData, Depends(deps.get_session)],
person: Optional[str] = None,
charactor: Optional[str] = None,
) -> List[schema.TVEpisode]:
stmt = select(models.UserEpisode).where(and_(models.UserEpisode.user_id == session_data.user_id, models.UserEpisode.bookmark == True))
links = db.scalars(stmt).all()
ids = [i.episode_id for i in links]
if person:
stmt = select(models.TVEpisodeGuestcast.episode_id).select_from(models.TVPeople).join(models.TVPeople.guestcast).where(models.TVPeople.name == person)
filter_ids = db.scalars(stmt).all()
ids = list(set(ids) & set(filter_ids))

if charactor:
stmt = select(models.TVEpisodeGuestcast.episode_id).select_from(models.TVCharacter).join(models.TVPeople.guestcast).where(models.TVCharacter.name == charactor)
filter_ids = db.scalars(stmt).all()
ids = list(set(ids) & set(filter_ids))
stmt = select(models.TVEpisode).where(models.TVEpisode.id.in_(ids))
return db.scalars(stmt).all()

@router.get("/{episode_id}")
async def get_episodes(
db: Annotated[Session, Depends(deps.get_db)],
session_data: Annotated[http_session.SessionData, Depends(deps.get_session)],
episode_id: int,
) -> schema.TVEpisode:
return db.get(models.TVEpisode, episode_id)

@router.get("/{episode_id}/guestcast")
async def get_guestcast(
db: Annotated[Session, Depends(deps.get_db)],
session_data: Annotated[http_session.SessionData, Depends(deps.get_session)],
episode_id: int,
) -> List[Dict[str, str]]:
user = db.get(models.User, session_data.user_id)
if not user:
raise HTTPException(status_code=400, detail="Unauthorized")
stmt = select(models.TVEpisodeGuestcast).join(models.TVEpisodeGuestcast.character).join(models.TVEpisodeGuestcast.person).where(models.TVEpisodeGuestcast.episode_id == episode_id)
guestcast = db.scalars(stmt).all()
for i in guestcast:
db.refresh(i.person)
db.refresh(i.character)
return [{'person': i.person.name, 'character': i.character.name} for i in guestcast]

@router.post("/{episode_id}/do")
async def like_episode(
db: Annotated[Session, Depends(deps.get_db)],
session_data: Annotated[http_session.SessionData, Depends(deps.get_session)],
episode_id: int,
action: schema.EnumAction,
) -> schema.Message:
episode = db.get(models.TVEpisode, episode_id)
if not episode:
raise HTTPException(status_code=400, detail="Episode not found")
user = db.get(models.User, session_data.user_id)
if not user:
raise HTTPException(status_code=400, detail="Unauthorized")

stmt = select(models.UserEpisode).where(and_(models.UserEpisode.user_id == user.id, models.UserEpisode.episode_id == episode.id))
user_episode = db.scalars(stmt).first()
data = {}
if action == schema.EnumAction.like:
data['like'] = True
elif action == schema.EnumAction.bookmark:
data['bookmark'] = True
elif action == schema.EnumAction.unlike:
data['like'] = False
elif action == schema.EnumAction.unbookmark:
data['bookmark'] = False
if not user_episode:
user_episode = models.UserEpisode(**data, user_id=user.id, episode_id=episode.id)
else:
for k, v in data.items():
setattr(user_episode, k, v)
db.add(user_episode)
db.commit()
db.refresh(user_episode)

stmt = select(func.count(distinct(models.UserEpisode.user_id))).select_from(models.UserEpisode).where(and_(models.UserEpisode.episode_id == episode.id, models.UserEpisode.like == True))
likes = db.scalar(stmt)
episode.likes = likes
db.add(episode)
db.commit()
db.refresh(episode)
return schema.Message(message="Success")
52 changes: 52 additions & 0 deletions app/api/endpoints/job.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
from typing import Any, List, Annotated

from fastapi import APIRouter, Depends, HTTPException, Response, BackgroundTasks, WebSocket
from fastapi.encoders import jsonable_encoder
from sqlalchemy.orm import Session
from sqlalchemy import select, and_, desc
from uuid import UUID, uuid4
import app.db.models as models
from app.job.crawl import crawl
import time
from .. import schema, deps
router = APIRouter()

@router.post("/")
def create_job(
db: Annotated[Session, Depends(deps.get_db)],
api_key: Annotated[str, Depends(deps.get_api_key)],
background_tasks: BackgroundTasks,
job_obj_in: schema.JobCreate,
) -> schema.Job:
job_obj = models.Job(date=job_obj_in.date, country=job_obj_in.country)
db.add(job_obj)
db.commit()
db.refresh(job_obj)
background_tasks.add_task(crawl, job_obj.id)
return job_obj

@router.get("/")
def list_job(
db: Annotated[Session, Depends(deps.get_db)],
api_key: Annotated[str, Depends(deps.get_api_key)],
) -> List[schema.Job]:
return db.scalars(select(models.Job).order_by(desc(models.Job.date))).all()

@router.get("/{job_id}")
def get_job(
db: Annotated[Session, Depends(deps.get_db)],
api_key: Annotated[str, Depends(deps.get_api_key)],
job_id: int,
) -> schema.Job:
return db.get(models.Job, job_id)

@router.post("/{job_id}/restart")
def restart_job(
db: Annotated[Session, Depends(deps.get_db)],
api_key: Annotated[str, Depends(deps.get_api_key)],
background_tasks: BackgroundTasks,
job_id: int,
) -> schema.Job:
job = db.get(models.Job, job_id)
background_tasks.add_task(crawl, job.id)
return job
Loading

0 comments on commit cb123e7

Please sign in to comment.