1
0
mirror of https://github.com/aquatix/digimarks.git synced 2025-12-06 23:05:10 +01:00

Moved Bookmark operations to service, added logging

This commit is contained in:
2025-09-13 22:11:30 +02:00
parent 6047302e09
commit 425b9441ed
3 changed files with 200 additions and 81 deletions

View File

@@ -1,18 +1,25 @@
"""Bookmark helper functions, like content scrapers, favicon extractor, updater functions.""" """Bookmark helper functions, like content scrapers, favicon extractor, updater functions."""
import logging
from datetime import UTC, datetime
from typing import Annotated, Sequence
from urllib.parse import urlparse, urlunparse from urllib.parse import urlparse, urlunparse
import bs4 import bs4
import httpx import httpx
from extract_favicon import from_html from extract_favicon import from_html
from fastapi import Request from fastapi import Query, Request
from pydantic import AnyUrl from pydantic import AnyUrl
from sqlmodel import select
from src.digimarks import tags_service from src.digimarks import tags_service, utils
from src.digimarks.models import Bookmark from src.digimarks.exceptions import BookmarkNotFound
from src.digimarks.models import Bookmark, Visibility
DIGIMARKS_USER_AGENT = 'digimarks/2.0.0-dev' DIGIMARKS_USER_AGENT = 'digimarks/2.0.0-dev'
logger = logging.getLogger('digimarks')
def get_favicon(html_content: str, root_url: str) -> str: def get_favicon(html_content: str, root_url: str) -> str:
"""Fetch the favicon from `html_content` using `root_url`.""" """Fetch the favicon from `html_content` using `root_url`."""
@@ -76,3 +83,131 @@ def update_bookmark_with_info(bookmark: Bookmark, request: Request, strip_params
# Sort and deduplicate tags # Sort and deduplicate tags
tags_service.set_tags(bookmark, bookmark.tags) tags_service.set_tags(bookmark, bookmark.tags)
async def list_bookmarks_for_user(
session,
user_key: str,
offset: int = 0,
limit: Annotated[int, Query(le=10000)] = 100,
) -> Sequence[Bookmark]:
"""List all bookmarks in the database. By default, 100 items are returned."""
result = await session.exec(
select(Bookmark)
.where(Bookmark.user_key == user_key, Bookmark.status != Visibility.DELETED)
.offset(offset)
.limit(limit)
)
bookmarks = result.all()
return bookmarks
async def get_bookmark_for_user_with_url_hash(session, user_key: str, url_hash: str) -> Bookmark:
"""Get a bookmark from the database by its URL hash."""
result = await session.exec(
select(Bookmark).where(
Bookmark.user_key == user_key, Bookmark.url_hash == url_hash, Bookmark.status != Visibility.DELETED
)
)
if not result.first():
raise BookmarkNotFound(f'url_hash: {url_hash}')
return result.first()
async def autocomplete_bookmark(
session,
request: Request,
user_key: str,
bookmark: Bookmark,
strip_params: bool = False,
):
"""Autofill some fields for this (new) bookmark for user `user_key`."""
bookmark.user_key = user_key
# Auto-fill title, fix tags etc.
update_bookmark_with_info(bookmark, request, strip_params)
url_hash = utils.generate_hash(str(bookmark.url))
result = await session.exec(
select(Bookmark).where(
Bookmark.user_key == user_key, Bookmark.url_hash == url_hash, Bookmark.status != Visibility.DELETED
)
)
bookmark_db = result.first()
if bookmark_db:
# Bookmark with this URL already exists, provide the hash so the frontend can look it up and the user can
# merge them if so wanted
bookmark.url_hash = url_hash
return bookmark
async def add_bookmark(
session,
request: Request,
user_key: str,
bookmark: Bookmark,
strip_params: bool = False,
):
"""Add new bookmark for user `user_key`."""
bookmark.user_key = user_key
# Auto-fill title, fix tags etc.
update_bookmark_with_info(bookmark, request, strip_params)
bookmark.url_hash = utils.generate_hash(str(bookmark.url))
logger.info('Adding bookmark %s for user %s', bookmark.url_hash, user_key)
session.add(bookmark)
await session.commit()
await session.refresh(bookmark)
return bookmark
async def update_bookmark(
session,
request: Request,
user_key: str,
bookmark: Bookmark,
url_hash: str,
strip_params: bool = False,
):
"""Update existing bookmark `bookmark_key` for user `user_key`."""
result = await session.exec(
select(Bookmark).where(
Bookmark.user_key == user_key, Bookmark.url_hash == url_hash, Bookmark.status != Visibility.DELETED
)
)
bookmark_db = result.first()
if not bookmark_db:
raise BookmarkNotFound(message='Bookmark with hash {url_hash} not found')
bookmark.modified_date = datetime.now(UTC)
# 'patch' endpoint, which means that you can send only the data that you want to update, leaving the rest intact
bookmark_data = bookmark.model_dump(exclude_unset=True)
# Merge the changed fields into the existing object
bookmark_db.sqlmodel_update(bookmark_data)
# Autofill title, fix tags, etc. where (still) needed
update_bookmark_with_info(bookmark, request, strip_params)
session.add(bookmark_db)
await session.commit()
await session.refresh(bookmark_db)
return bookmark_db
async def delete_bookmark(
session,
user_key: str,
url_hash: str,
):
"""(Soft)Delete bookmark `bookmark_key` for user `user_key`."""
result = await session.get(Bookmark, {'url_hash': url_hash, 'user_key': user_key})
bookmark = result
if not bookmark:
raise BookmarkNotFound(message='Bookmark with hash {url_hash} not found')
bookmark.deleted_date = datetime.now(UTC)
bookmark.status = Visibility.DELETED
session.add(bookmark)
await session.commit()

View File

@@ -0,0 +1,21 @@
"""Exceptions that could be encountered managing digimarks."""
class BookmarkNotFound(Exception):
def __init__(self, message='Bookmark not found'):
"""Initialise the exception.
:param str message: The message for the exception
"""
super().__init__(message)
self.message = message
class BookmarkAlreadyExists(Exception):
def __init__(self, message='Bookmark already exists'):
"""Initialise the exception.
:param str message: The message for the exception
"""
super().__init__(message)
self.message = message

View File

@@ -18,7 +18,8 @@ from sqlalchemy.orm import sessionmaker
from sqlmodel import desc, select from sqlmodel import desc, select
from sqlmodel.ext.asyncio.session import AsyncSession from sqlmodel.ext.asyncio.session import AsyncSession
from src.digimarks import bookmarks_service, tags_service, utils from src.digimarks import bookmarks_service, tags_service
from src.digimarks.exceptions import BookmarkNotFound
from src.digimarks.models import DEFAULT_THEME, Bookmark, User, Visibility from src.digimarks.models import DEFAULT_THEME, Bookmark, User, Visibility
DIGIMARKS_VERSION = '2.0.0a1' DIGIMARKS_VERSION = '2.0.0a1'
@@ -72,6 +73,11 @@ app.mount('/content/favicons', StaticFiles(directory=settings.favicons_dir), nam
templates = Jinja2Templates(directory=settings.template_dir) templates = Jinja2Templates(directory=settings.template_dir)
# Set up logging # Set up logging
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
datefmt='%Y-%m-%d %H:%M:%S',
)
logger = logging.getLogger('digimarks') logger = logging.getLogger('digimarks')
if settings.debug: if settings.debug:
logger.setLevel(logging.DEBUG) logger.setLevel(logging.DEBUG)
@@ -109,6 +115,7 @@ def file_type(filename: str) -> str:
@app.head('/', response_class=HTMLResponse) @app.head('/', response_class=HTMLResponse)
def index(request: Request): def index(request: Request):
"""Homepage, point visitors to project page.""" """Homepage, point visitors to project page."""
logger.info('Root page requested')
return templates.TemplateResponse( return templates.TemplateResponse(
request=request, request=request,
name='index.html', name='index.html',
@@ -119,11 +126,15 @@ def index(request: Request):
@app.get('/api/v1/admin/{system_key}/users/{user_id}', response_model=User) @app.get('/api/v1/admin/{system_key}/users/{user_id}', response_model=User)
async def get_user(session: SessionDep, system_key: str, user_id: int) -> Type[User]: async def get_user(session: SessionDep, system_key: str, user_id: int) -> Type[User]:
"""Show user information.""" """Show user information."""
logger.info('User %d requested', user_id)
if system_key != settings.system_key: if system_key != settings.system_key:
logger.error('User %s requested but incorrect system key %s provided', user_id, system_key)
raise HTTPException(status_code=404) raise HTTPException(status_code=404)
user = session.get(User, user_id) result = await session.get(User, user_id)
user = result
if not user: if not user:
logger.error('User %s not found', user_id)
raise HTTPException(status_code=404, detail='User not found') raise HTTPException(status_code=404, detail='User not found')
return user return user
@@ -145,12 +156,13 @@ async def list_users(
:return: list of users in the system :return: list of users in the system
:rtype: list[User] :rtype: list[User]
""" """
logger.info('User listing requested')
if system_key != settings.system_key: if system_key != settings.system_key:
logger.error('User listing requested but incorrect system key %s provided', system_key)
raise HTTPException(status_code=404) raise HTTPException(status_code=404)
result = await session.exec(select(User).offset(offset).limit(limit)) result = await session.exec(select(User).offset(offset).limit(limit))
users = result.all() return result.all()
return users
@app.get('/api/v1/{user_key}/bookmarks/') @app.get('/api/v1/{user_key}/bookmarks/')
@@ -159,16 +171,10 @@ async def list_bookmarks(
user_key: str, user_key: str,
offset: int = 0, offset: int = 0,
limit: Annotated[int, Query(le=10000)] = 100, limit: Annotated[int, Query(le=10000)] = 100,
) -> list[Bookmark]: ) -> Sequence[Bookmark]:
"""List all bookmarks in the database. By default, 100 items are returned.""" """List all bookmarks in the database. By default, 100 items are returned."""
result = await session.exec( logger.info('List bookmarks for user %s with offset %d, limit %d', user_key, offset, limit)
select(Bookmark) return await bookmarks_service.list_bookmarks_for_user(session, user_key, offset, limit)
.where(Bookmark.user_key == user_key, Bookmark.status != Visibility.DELETED)
.offset(offset)
.limit(limit)
)
bookmarks = result.all()
return bookmarks
@app.get('/api/v1/{user_key}/bookmarks/{url_hash}') @app.get('/api/v1/{user_key}/bookmarks/{url_hash}')
@@ -178,13 +184,12 @@ async def get_bookmark(
url_hash: str, url_hash: str,
) -> Bookmark: ) -> Bookmark:
"""Show bookmark details.""" """Show bookmark details."""
result = await session.exec( logger.info('Bookmark details for user %s with url_hash %s', user_key, url_hash)
select(Bookmark).where( try:
Bookmark.user_key == user_key, Bookmark.url_hash == url_hash, Bookmark.status != Visibility.DELETED return await bookmarks_service.get_bookmark_for_user_with_url_hash(session, user_key, url_hash)
) except BookmarkNotFound as exc:
) logger.error('Bookmark not found: %s', exc)
bookmark = result.first() raise HTTPException(status_code=404, detail=f'Bookmark not found: {exc.message}')
return bookmark
@app.post('/api/v1/{user_key}/autocomplete_bookmark/', response_model=Bookmark) @app.post('/api/v1/{user_key}/autocomplete_bookmark/', response_model=Bookmark)
@@ -196,24 +201,8 @@ async def autocomplete_bookmark(
strip_params: bool = False, strip_params: bool = False,
): ):
"""Autofill some fields for this (new) bookmark for user `user_key`.""" """Autofill some fields for this (new) bookmark for user `user_key`."""
bookmark.user_key = user_key logger.info('Autocompleting bookmark %s for user %s', bookmark.url_hash, user_key)
return await bookmarks_service.autocomplete_bookmark(session, request, user_key, bookmark, strip_params)
# Auto-fill title, fix tags etc.
bookmarks_service.update_bookmark_with_info(bookmark, request, strip_params)
url_hash = utils.generate_hash(str(bookmark.url))
result = await session.exec(
select(Bookmark).where(
Bookmark.user_key == user_key, Bookmark.url_hash == url_hash, Bookmark.status != Visibility.DELETED
)
)
bookmark_db = result.first()
if bookmark_db:
# Bookmark with this URL already exists, provide the hash so the frontend can look it up and the user can
# merge them if so wanted
bookmark.url_hash = url_hash
return bookmark
@app.post('/api/v1/{user_key}/bookmarks/', response_model=Bookmark) @app.post('/api/v1/{user_key}/bookmarks/', response_model=Bookmark)
@@ -225,16 +214,8 @@ async def add_bookmark(
strip_params: bool = False, strip_params: bool = False,
): ):
"""Add new bookmark for user `user_key`.""" """Add new bookmark for user `user_key`."""
bookmark.user_key = user_key logger.info('Adding bookmark %s for user %s', bookmark.url, user_key)
return await bookmarks_service.add_bookmark(session, request, user_key, bookmark, strip_params)
# Auto-fill title, fix tags etc.
bookmarks_service.update_bookmark_with_info(bookmark, request, strip_params)
bookmark.url_hash = utils.generate_hash(str(bookmark.url))
session.add(bookmark)
await session.commit()
await session.refresh(bookmark)
return bookmark
@app.patch('/api/v1/{user_key}/bookmarks/{url_hash}', response_model=Bookmark) @app.patch('/api/v1/{user_key}/bookmarks/{url_hash}', response_model=Bookmark)
@@ -247,30 +228,13 @@ async def update_bookmark(
strip_params: bool = False, strip_params: bool = False,
): ):
"""Update existing bookmark `bookmark_key` for user `user_key`.""" """Update existing bookmark `bookmark_key` for user `user_key`."""
result = await session.exec( logger.info('Updating bookmark %s for user %s', url_hash, user_key)
select(Bookmark).where( try:
Bookmark.user_key == user_key, Bookmark.url_hash == url_hash, Bookmark.status != Visibility.DELETED return await bookmarks_service.update_bookmark(session, request, user_key, bookmark, url_hash, strip_params)
) except Exception:
) logger.exception('Failed to update bookmark %s', bookmark.id)
bookmark_db = result.first()
if not bookmark_db:
raise HTTPException(status_code=404, detail='Bookmark not found') raise HTTPException(status_code=404, detail='Bookmark not found')
bookmark.modified_date = datetime.now(UTC)
# 'patch' endpoint, which means that you can send only the data that you want to update, leaving the rest intact
bookmark_data = bookmark.model_dump(exclude_unset=True)
# Merge the changed fields into the existing object
bookmark_db.sqlmodel_update(bookmark_data)
# Autofill title, fix tags, etc. where (still) needed
bookmarks_service.update_bookmark_with_info(bookmark, request, strip_params)
session.add(bookmark_db)
await session.commit()
await session.refresh(bookmark_db)
return bookmark_db
@app.delete('/api/v1/{user_key}/bookmarks/{url_hash}', response_model=Bookmark) @app.delete('/api/v1/{user_key}/bookmarks/{url_hash}', response_model=Bookmark)
async def delete_bookmark( async def delete_bookmark(
@@ -279,15 +243,13 @@ async def delete_bookmark(
url_hash: str, url_hash: str,
): ):
"""(Soft)Delete bookmark `bookmark_key` for user `user_key`.""" """(Soft)Delete bookmark `bookmark_key` for user `user_key`."""
result = await session.get(Bookmark, {'url_hash': url_hash, 'user_key': user_key}) logger.info('Deleting bookmark %s for user %s', url_hash, user_key)
bookmark = result try:
if not bookmark: result = await bookmarks_service.delete_bookmark(session, user_key, url_hash)
raise HTTPException(status_code=404, detail='Bookmark not found')
bookmark.deleted_date = datetime.now(UTC)
bookmark.status = Visibility.DELETED
session.add(bookmark)
await session.commit()
return {'ok': True} return {'ok': True}
except Exception:
logger.exception('Failed to delete bookmark %s', url_hash)
raise HTTPException(status_code=404, detail='Bookmark not found')
@app.get('/api/v1/{user_key}/latest_changes/') @app.get('/api/v1/{user_key}/latest_changes/')
@@ -296,6 +258,7 @@ async def bookmarks_changed_since(
user_key: str, user_key: str,
): ):
"""Last update on server, so the (browser) client knows whether to fetch an update.""" """Last update on server, so the (browser) client knows whether to fetch an update."""
logger.info('Retrieving latest changes for user %s', user_key)
result = await session.exec( result = await session.exec(
select(Bookmark) select(Bookmark)
.where(Bookmark.user_key == user_key, Bookmark.status != Visibility.DELETED) .where(Bookmark.user_key == user_key, Bookmark.status != Visibility.DELETED)