Skip to content

Commit

Permalink
Infinite scroll (#47)
Browse files Browse the repository at this point in the history
* setup

* inf scroll

* refactor service

* delete post

* fix issue

* sidebar style

* sidebar search

* profile search

* url path

* postgrid loading logic

* only show sidebar when expanded

* profile list API

* black

* main

* ProfileCRUDService.list

* profile UI

* rename to data

* tweak sleep
  • Loading branch information
automactic authored Jul 16, 2023
1 parent a0edba8 commit 57c6049
Show file tree
Hide file tree
Showing 19 changed files with 216 additions and 157 deletions.
6 changes: 3 additions & 3 deletions app/entities/profiles.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,7 @@

class Profile(BaseModel):
username: str
full_name: str
display_name: str
biography: Optional[str] = None
image_filename: str


Expand All @@ -21,6 +19,8 @@ class BaseStats(BaseModel):


class ProfileWithDetail(Profile):
full_name: str
biography: Optional[str] = None
stats: BaseStats
tasks: List[BaseTask] = []

Expand All @@ -31,7 +31,7 @@ class ProfileStats(BaseStats):


class ProfileListResult(BaseModel):
profiles: List[Profile]
data: List[Profile]
limit: int
offset: int
count: int
Expand Down
2 changes: 1 addition & 1 deletion app/entities/tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ class TaskCreateRequest(BaseModel):


class TaskListResponse(BaseModel):
tasks: List[Task]
data: List[Task]
limit: int
offset: int
count: int
147 changes: 96 additions & 51 deletions app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,19 @@
from fastapi.websockets import WebSocket, WebSocketDisconnect

from entities.enums import TaskStatus
from entities.posts import Post, PostListResult, PostCreationFromShortcode, PostArchiveRequest, PostUpdateRequest
from entities.profiles import ProfileWithDetail, ProfileListResult, ProfileUpdates, ProfileStats
from entities.posts import (
Post,
PostListResult,
PostCreationFromShortcode,
PostArchiveRequest,
PostUpdateRequest,
)
from entities.profiles import (
ProfileWithDetail,
ProfileListResult,
ProfileUpdates,
ProfileStats,
)
from entities.tasks import TaskCreateRequest, TaskListResponse
from services import schema
from services.exceptions import PostNotFound
Expand All @@ -23,114 +34,130 @@
from services.task import TaskExecutor
from services.crud import TaskCRUDService, ProfileCRUDService

logging.basicConfig(level=os.environ.get('LOGLEVEL', 'INFO'))
logging.basicConfig(level=os.environ.get("LOGLEVEL", "INFO"))
logger = logging.getLogger(__name__)

app = FastAPI()
database = databases.Database(schema.database_url)
http_session = aiohttp.ClientSession()


@app.on_event('startup')
@app.on_event("startup")
async def startup():
await database.connect()


@app.on_event('shutdown')
@app.on_event("shutdown")
async def shutdown():
await database.disconnect()
await http_session.close()


@app.get('/api/profiles/', response_model=ProfileListResult)
async def list_profiles(search: Optional[str] = None, offset: Optional[int] = 0, limit: Optional[int] = 100):
@app.get("/api/profiles/", response_model=ProfileListResult)
async def list_profiles(
search: Optional[str] = None, offset: Optional[int] = 0, limit: Optional[int] = 100
):
return await ProfileCRUDService(database, http_session).list(search, offset, limit)


@app.get('/api/profiles/{username:str}/', response_model=ProfileWithDetail)
@app.get("/api/profiles/{username:str}/", response_model=ProfileWithDetail)
async def get_profile(username: str):
profile = await ProfileCRUDService(database, http_session).get(username)
return profile if profile else Response(status_code=HTTPStatus.NOT_FOUND)


@app.patch('/api/profiles/{username:str}/', response_model=ProfileWithDetail)
# refactor marker
@app.patch("/api/profiles/{username:str}/", response_model=ProfileWithDetail)
async def update_profile(username: str, updates: ProfileUpdates):
await ProfileService(database, http_session).update(username, updates)
return Response(status_code=HTTPStatus.NO_CONTENT)


@app.delete('/api/profiles/{username:str}/')
@app.delete("/api/profiles/{username:str}/")
async def delete_profile(username: str):
await ProfileService(database, http_session).delete(username)
return Response(status_code=HTTPStatus.NO_CONTENT)


@app.get('/api/stats/', response_model=List[ProfileStats])
@app.get("/api/stats/", response_model=List[ProfileStats])
async def get_profile_statistics(username: Optional[str] = None):
stats = await ProfileCRUDService(database, http_session).get_stats(username=username)
stats = await ProfileCRUDService(database, http_session).get_stats(
username=username
)
return list(stats.values())


@app.get('/api/posts/', response_model=PostListResult)
@app.get("/api/posts/", response_model=PostListResult)
async def list_posts(
offset: int = 0,
limit: int = 10,
username: Optional[str] = None,
start_time: Optional[datetime] = None,
end_time: Optional[datetime] = None,
):
return await PostService(database, http_session).list(offset, limit, username, start_time, end_time)
return await PostService(database, http_session).list(
offset, limit, username, start_time, end_time
)


@app.get('/api/posts/{shortcode:str}/', response_model=Post)
@app.get("/api/posts/{shortcode:str}/", response_model=Post)
async def get_post(shortcode: str):
return await PostService(database, http_session).get(shortcode)


@app.post('/api/posts/from_shortcode/')
def create_post_from_shortcode(request: PostCreationFromShortcode, background_tasks: BackgroundTasks):
@app.post("/api/posts/from_shortcode/")
def create_post_from_shortcode(
request: PostCreationFromShortcode, background_tasks: BackgroundTasks
):
service = PostService(database, http_session)
background_tasks.add_task(service.create_from_shortcode, request.shortcode)
return Response(status_code=HTTPStatus.ACCEPTED)


@app.post('/api/posts/from_time_range/')
def create_post_from_time_range(request: PostArchiveRequest.FromTimeRange, background_tasks: BackgroundTasks):
@app.post("/api/posts/from_time_range/")
def create_post_from_time_range(
request: PostArchiveRequest.FromTimeRange, background_tasks: BackgroundTasks
):
service = PostService(database, http_session)
background_tasks.add_task(service.create_from_time_range, request)
return Response(status_code=HTTPStatus.ACCEPTED)


@app.post('/api/posts/from_saved/')
def create_post_from_saved(request: PostArchiveRequest.FromSaved, background_tasks: BackgroundTasks):
@app.post("/api/posts/from_saved/")
def create_post_from_saved(
request: PostArchiveRequest.FromSaved, background_tasks: BackgroundTasks
):
service = PostService(database, http_session)
background_tasks.add_task(service.archive_saved, request.count)
return Response(status_code=HTTPStatus.ACCEPTED)


@app.patch('/api/posts/{shortcode:str}/')
@app.patch("/api/posts/{shortcode:str}/")
async def update_post(shortcode: str, request: PostUpdateRequest):
try:
await PostService(database, http_session).update_username(shortcode, request.username)
await PostService(database, http_session).update_username(
shortcode, request.username
)
except PostNotFound:
return Response(status_code=HTTPStatus.NOT_FOUND)


@app.delete('/api/posts/{shortcode:str}/')
@app.delete("/api/posts/{shortcode:str}/")
async def delete_post(shortcode: str):
await PostService(database, http_session).delete(shortcode)


@app.delete('/api/posts/{shortcode:str}/{index:int}/')
@app.delete("/api/posts/{shortcode:str}/{index:int}/")
async def delete_post_item(shortcode: str, index: int):
await PostService(database, http_session).delete(shortcode, index)


@app.post('/api/tasks/')
@app.post("/api/tasks/")
async def create_tasks(request: TaskCreateRequest, background_tasks: BackgroundTasks):
# get non-terminal tasks
service = TaskCRUDService(database, http_session)
non_terminal_tasks = await service.list(limit=1, status=[TaskStatus.PENDING, TaskStatus.IN_PROGRESS])
non_terminal_tasks = await service.list(
limit=1, status=[TaskStatus.PENDING, TaskStatus.IN_PROGRESS]
)

# create new tasks
await service.create(request)
Expand All @@ -141,66 +168,84 @@ async def create_tasks(request: TaskCreateRequest, background_tasks: BackgroundT
return Response(status_code=HTTPStatus.CREATED)


@app.get('/api/tasks/', response_model=TaskListResponse)
async def list_tasks(offset: Optional[int] = 0, limit: Optional[int] = 100, username: Optional[str] = None):
@app.get("/api/tasks/", response_model=TaskListResponse)
async def list_tasks(
offset: Optional[int] = 0,
limit: Optional[int] = 100,
username: Optional[str] = None,
):
return await TaskCRUDService(database, http_session).list(
offset, limit, username=username, is_ascending=False
)


@app.get('/media/{path:path}')
@app.get("/media/{path:path}")
async def get_media(path: str, request: Request):
path = Path('/media').joinpath(path)
path = Path("/media").joinpath(path)
if not path.exists():
return Response(status_code=HTTPStatus.NOT_FOUND)
if range_header := request.headers.get('Range'):
if range_header := request.headers.get("Range"):
size = path.stat().st_size

try:
start, end = range_header.strip('bytes=').split('-')
start, end = range_header.strip("bytes=").split("-")
start = int(start)
end = size - 1 if end == '' else int(end)
end = size - 1 if end == "" else int(end)
chunk_size = end - start + 1
if chunk_size <= 0:
raise ValueError
except ValueError:
return Response(status_code=HTTPStatus.REQUESTED_RANGE_NOT_SATISFIABLE)

async with aiofiles.open(path, mode='rb') as file:
async with aiofiles.open(path, mode="rb") as file:
await file.seek(start)
chunk = await file.read(chunk_size)
return Response(chunk, status_code=HTTPStatus.PARTIAL_CONTENT, headers={
'Accept-Ranges': 'bytes',
'Content-Range': f'bytes {start}-{end}/{size}',
'Content-Length': str(chunk_size),
})
return Response(
chunk,
status_code=HTTPStatus.PARTIAL_CONTENT,
headers={
"Accept-Ranges": "bytes",
"Content-Range": f"bytes {start}-{end}/{size}",
"Content-Length": str(chunk_size),
},
)
else:
return FileResponse(path)


@app.websocket('/web_socket/posts/')
@app.websocket("/web_socket/posts/")
async def posts(web_socket: WebSocket):
await web_socket.accept()
try:
while True:
data = await web_socket.receive_json()
if shortcode := data.get('shortcode'):
if shortcode := data.get("shortcode"):
try:
post = await PostService(database, http_session).create_from_shortcode(shortcode)
post = await PostService(
database, http_session
).create_from_shortcode(shortcode)
response = {
'shortcode': post.shortcode, 'username': post.username, 'timestamp': post.timestamp.isoformat()
"shortcode": post.shortcode,
"username": post.username,
"timestamp": post.timestamp.isoformat(),
}
await web_socket.send_json({'event': 'post.saved', 'shortcode': shortcode, 'post': response})
await web_socket.send_json(
{
"event": "post.saved",
"shortcode": shortcode,
"post": response,
}
)
except PostNotFound as e:
await web_socket.send_json(e.response)
except WebSocketDisconnect:
logger.debug('Web socket disconnected.')
logger.debug("Web socket disconnected.")


@app.get('/{path:path}')
@app.get("/{path:path}")
async def web(path: str):
path = Path(path)
if len(path.parts) == 1 and path.suffix in ['.html', '.css', '.js']:
return FileResponse(Path('/web/').joinpath(path.parts[-1]))
if len(path.parts) == 1 and path.suffix in [".html", ".css", ".js"]:
return FileResponse(Path("/web/").joinpath(path.parts[-1]))
else:
return FileResponse(Path('/web/index.html'))
return FileResponse(Path("/web/index.html"))
18 changes: 5 additions & 13 deletions app/services/crud/profile.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,13 +22,7 @@ class ProfileCRUDService(BaseService):
async def list(
self, search: Optional[str] = None, offset: int = 0, limit: int = 100
) -> ProfileListResult:
"""List profiles.
:param search: search text filter list of profiles
:param offset: the number of profiles to skip
:param limit: the number of profiles to fetch
:return: the list query result
"""
"""List profiles."""

# fetch data
base_query = schema.profiles.select()
Expand All @@ -48,18 +42,16 @@ async def list(
query = (
sa.select(
base_query.c.username,
base_query.c.full_name,
base_query.c.display_name,
base_query.c.biography,
base_query.c.image_filename,
count_query.c.total_count,
)
.select_from(
base_query.outerjoin(count_query, onclause=sa.sql.true(), full=True)
)
.order_by(base_query.c.display_name)
.offset(offset)
.limit(limit)
.offset(offset if offset > 0 else None)
.limit(limit if limit > 0 else None)
)
rows = await self.database.fetch_all(query)

Expand All @@ -68,7 +60,7 @@ async def list(
profiles = [Profile(**dict(row)) for row in rows] if total_count > 0 else []

return ProfileListResult(
profiles=profiles, limit=limit, offset=offset, count=total_count
data=profiles, limit=limit, offset=offset, count=total_count
)

async def get(self, username: str) -> Optional[ProfileWithDetail]:
Expand Down Expand Up @@ -96,7 +88,7 @@ async def get(self, username: str) -> Optional[ProfileWithDetail]:
profile = ProfileWithDetail(
**dict(profile_info),
stats=BaseStats(**stats[username].dict()),
tasks=[BaseTask(**task.dict()) for task in tasks.tasks],
tasks=[BaseTask(**task.dict()) for task in tasks.data],
)
return profile

Expand Down
6 changes: 3 additions & 3 deletions app/services/crud/task.py
Original file line number Diff line number Diff line change
Expand Up @@ -124,18 +124,18 @@ async def list(
except pydantic.error_wrappers.ValidationError:
continue

return TaskListResponse(tasks=tasks, limit=limit, offset=offset, count=count)
return TaskListResponse(data=tasks, limit=limit, offset=offset, count=count)

async def get_next(self) -> Optional[Task]:
"""Get the next task to execute.
:return: next task to execute
"""

result = await self.list(
tasks = await self.list(
offset=0, limit=1, status=[TaskStatus.PENDING], is_ascending=True
)
return result.tasks[0] if result.tasks else None
return tasks.data[0] if tasks.data else None

async def set_in_progress(self, task):
"""Set task status to in_progress.
Expand Down
Loading

0 comments on commit 57c6049

Please sign in to comment.