-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
Showing
44 changed files
with
3,132 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -3,6 +3,8 @@ __pycache__/ | |
*.py[cod] | ||
*$py.class | ||
|
||
*.sqlite | ||
requirements.txt | ||
# C extensions | ||
*.so | ||
|
||
|
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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"]) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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") |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
Oops, something went wrong.