mirror of
https://github.com/aquatix/digimarks.git
synced 2025-12-06 22:05:09 +01:00
Compare commits
10 Commits
e55b3c0ea1
...
9890eafb69
| Author | SHA1 | Date | |
|---|---|---|---|
| 9890eafb69 | |||
| fd2708247d | |||
| ad7f7df21c | |||
| ad92e23804 | |||
| f4afa34f69 | |||
| 59205166cb | |||
| b6a81fded4 | |||
| 3a87485b9a | |||
| 1219371185 | |||
| 80f585487a |
22
README.rst
22
README.rst
@@ -33,18 +33,36 @@ necessary packages:
|
|||||||
pip install -r requirements.txt
|
pip install -r requirements.txt
|
||||||
|
|
||||||
|
|
||||||
|
Migrating from version 1
|
||||||
|
------------------------
|
||||||
|
|
||||||
|
To be able to use the new database schema's, you will need to migrate your existing ``bookmarks.db`` to one under the control of the ``alembic`` migrations tool.
|
||||||
|
|
||||||
|
To do so, start with making a backup of this ``bookmarks.db`` file to a safe place.
|
||||||
|
|
||||||
|
Then, stamp the initial migration into the database, and migrate to the latest version:
|
||||||
|
|
||||||
|
.. code-block:: bash
|
||||||
|
|
||||||
|
alembic stamp 115bcd2e1a38
|
||||||
|
|
||||||
|
alembic upgrade head
|
||||||
|
|
||||||
|
|
||||||
Usage / example configuration
|
Usage / example configuration
|
||||||
-----------------------------
|
-----------------------------
|
||||||
|
|
||||||
|
OUT OF DATE!
|
||||||
|
|
||||||
Copy ``settings.py`` from example_config to the parent directory and
|
Copy ``settings.py`` from example_config to the parent directory and
|
||||||
configure to your needs (*at the least* change the value of `SYSTEMKEY`).
|
configure to your needs (*at the least* change the value of `SYSTEMKEY`).
|
||||||
|
|
||||||
Do not forget to fill in the `MASHAPE_API_KEY` value, which you [can request on the RapidAPI website](https://rapidapi.com/realfavicongenerator/api/realfavicongenerator).
|
Do not forget to fill in the `MASHAPE_API_KEY` value, which you ``can request on the RapidAPI website <https://rapidapi.com/realfavicongenerator/api/realfavicongenerator>`_.
|
||||||
|
|
||||||
Run digimarks as a service under nginx or apache and call the appropriate
|
Run digimarks as a service under nginx or apache and call the appropriate
|
||||||
url's when wanted.
|
url's when wanted.
|
||||||
|
|
||||||
Url's are of the form https://marks.example.com/<userkey>/<action>
|
Url's are of the form ``https://marks.example.com/<userkey>/<action>``
|
||||||
|
|
||||||
digimarks can also be run from the command line: ``uvicorn digimarks:app --reload``
|
digimarks can also be run from the command line: ``uvicorn digimarks:app --reload``
|
||||||
|
|
||||||
|
|||||||
147
alembic.ini
Normal file
147
alembic.ini
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
# A generic, single database configuration.
|
||||||
|
|
||||||
|
[alembic]
|
||||||
|
# path to migration scripts.
|
||||||
|
# this is typically a path given in POSIX (e.g. forward slashes)
|
||||||
|
# format, relative to the token %(here)s which refers to the location of this
|
||||||
|
# ini file
|
||||||
|
script_location = %(here)s/migrations
|
||||||
|
|
||||||
|
# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
|
||||||
|
# Uncomment the line below if you want the files to be prepended with date and time
|
||||||
|
# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file
|
||||||
|
# for all available tokens
|
||||||
|
# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
|
||||||
|
|
||||||
|
# sys.path path, will be prepended to sys.path if present.
|
||||||
|
# defaults to the current working directory. for multiple paths, the path separator
|
||||||
|
# is defined by "path_separator" below.
|
||||||
|
prepend_sys_path = .
|
||||||
|
|
||||||
|
# timezone to use when rendering the date within the migration file
|
||||||
|
# as well as the filename.
|
||||||
|
# If specified, requires the python>=3.9 or backports.zoneinfo library and tzdata library.
|
||||||
|
# Any required deps can installed by adding `alembic[tz]` to the pip requirements
|
||||||
|
# string value is passed to ZoneInfo()
|
||||||
|
# leave blank for localtime
|
||||||
|
# timezone =
|
||||||
|
|
||||||
|
# max length of characters to apply to the "slug" field
|
||||||
|
# truncate_slug_length = 40
|
||||||
|
|
||||||
|
# set to 'true' to run the environment during
|
||||||
|
# the 'revision' command, regardless of autogenerate
|
||||||
|
# revision_environment = false
|
||||||
|
|
||||||
|
# set to 'true' to allow .pyc and .pyo files without
|
||||||
|
# a source .py file to be detected as revisions in the
|
||||||
|
# versions/ directory
|
||||||
|
# sourceless = false
|
||||||
|
|
||||||
|
# version location specification; This defaults
|
||||||
|
# to <script_location>/versions. When using multiple version
|
||||||
|
# directories, initial revisions must be specified with --version-path.
|
||||||
|
# The path separator used here should be the separator specified by "path_separator"
|
||||||
|
# below.
|
||||||
|
# version_locations = %(here)s/bar:%(here)s/bat:%(here)s/alembic/versions
|
||||||
|
|
||||||
|
# path_separator; This indicates what character is used to split lists of file
|
||||||
|
# paths, including version_locations and prepend_sys_path within configparser
|
||||||
|
# files such as alembic.ini.
|
||||||
|
# The default rendered in new alembic.ini files is "os", which uses os.pathsep
|
||||||
|
# to provide os-dependent path splitting.
|
||||||
|
#
|
||||||
|
# Note that in order to support legacy alembic.ini files, this default does NOT
|
||||||
|
# take place if path_separator is not present in alembic.ini. If this
|
||||||
|
# option is omitted entirely, fallback logic is as follows:
|
||||||
|
#
|
||||||
|
# 1. Parsing of the version_locations option falls back to using the legacy
|
||||||
|
# "version_path_separator" key, which if absent then falls back to the legacy
|
||||||
|
# behavior of splitting on spaces and/or commas.
|
||||||
|
# 2. Parsing of the prepend_sys_path option falls back to the legacy
|
||||||
|
# behavior of splitting on spaces, commas, or colons.
|
||||||
|
#
|
||||||
|
# Valid values for path_separator are:
|
||||||
|
#
|
||||||
|
# path_separator = :
|
||||||
|
# path_separator = ;
|
||||||
|
# path_separator = space
|
||||||
|
# path_separator = newline
|
||||||
|
#
|
||||||
|
# Use os.pathsep. Default configuration used for new projects.
|
||||||
|
path_separator = os
|
||||||
|
|
||||||
|
|
||||||
|
# set to 'true' to search source files recursively
|
||||||
|
# in each "version_locations" directory
|
||||||
|
# new in Alembic version 1.10
|
||||||
|
# recursive_version_locations = false
|
||||||
|
|
||||||
|
# the output encoding used when revision files
|
||||||
|
# are written from script.py.mako
|
||||||
|
# output_encoding = utf-8
|
||||||
|
|
||||||
|
# database URL. This is consumed by the user-maintained env.py script only.
|
||||||
|
# other means of configuring database URLs may be customized within the env.py
|
||||||
|
# file.
|
||||||
|
sqlalchemy.url = sqlite+aiosqlite:///bookmarks.db
|
||||||
|
|
||||||
|
|
||||||
|
[post_write_hooks]
|
||||||
|
# post_write_hooks defines scripts or Python functions that are run
|
||||||
|
# on newly generated revision scripts. See the documentation for further
|
||||||
|
# detail and examples
|
||||||
|
|
||||||
|
# format using "black" - use the console_scripts runner, against the "black" entrypoint
|
||||||
|
# hooks = black
|
||||||
|
# black.type = console_scripts
|
||||||
|
# black.entrypoint = black
|
||||||
|
# black.options = -l 79 REVISION_SCRIPT_FILENAME
|
||||||
|
|
||||||
|
# lint with attempts to fix using "ruff" - use the module runner, against the "ruff" module
|
||||||
|
# hooks = ruff
|
||||||
|
# ruff.type = module
|
||||||
|
# ruff.module = ruff
|
||||||
|
# ruff.options = check --fix REVISION_SCRIPT_FILENAME
|
||||||
|
|
||||||
|
# Alternatively, use the exec runner to execute a binary found on your PATH
|
||||||
|
# hooks = ruff
|
||||||
|
# ruff.type = exec
|
||||||
|
# ruff.executable = ruff
|
||||||
|
# ruff.options = check --fix REVISION_SCRIPT_FILENAME
|
||||||
|
|
||||||
|
# Logging configuration. This is also consumed by the user-maintained
|
||||||
|
# env.py script only.
|
||||||
|
[loggers]
|
||||||
|
keys = root,sqlalchemy,alembic
|
||||||
|
|
||||||
|
[handlers]
|
||||||
|
keys = console
|
||||||
|
|
||||||
|
[formatters]
|
||||||
|
keys = generic
|
||||||
|
|
||||||
|
[logger_root]
|
||||||
|
level = WARNING
|
||||||
|
handlers = console
|
||||||
|
qualname =
|
||||||
|
|
||||||
|
[logger_sqlalchemy]
|
||||||
|
level = WARNING
|
||||||
|
handlers =
|
||||||
|
qualname = sqlalchemy.engine
|
||||||
|
|
||||||
|
[logger_alembic]
|
||||||
|
level = INFO
|
||||||
|
handlers =
|
||||||
|
qualname = alembic
|
||||||
|
|
||||||
|
[handler_console]
|
||||||
|
class = StreamHandler
|
||||||
|
args = (sys.stderr,)
|
||||||
|
level = NOTSET
|
||||||
|
formatter = generic
|
||||||
|
|
||||||
|
[formatter_generic]
|
||||||
|
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||||
|
datefmt = %H:%M:%S
|
||||||
1
migrations/README
Normal file
1
migrations/README
Normal file
@@ -0,0 +1 @@
|
|||||||
|
Generic single-database configuration with an async dbapi.
|
||||||
86
migrations/env.py
Normal file
86
migrations/env.py
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
import asyncio
|
||||||
|
from logging.config import fileConfig
|
||||||
|
|
||||||
|
from alembic import context
|
||||||
|
from sqlalchemy import pool
|
||||||
|
from sqlalchemy.engine import Connection
|
||||||
|
from sqlalchemy.ext.asyncio import async_engine_from_config
|
||||||
|
from sqlmodel import SQLModel
|
||||||
|
|
||||||
|
from src.digimarks.models import Bookmark, PublicTag, User
|
||||||
|
|
||||||
|
# this is the Alembic Config object, which provides
|
||||||
|
# access to the values within the .ini file in use.
|
||||||
|
config = context.config
|
||||||
|
|
||||||
|
# Interpret the config file for Python logging.
|
||||||
|
# This line sets up loggers basically.
|
||||||
|
if config.config_file_name is not None:
|
||||||
|
fileConfig(config.config_file_name)
|
||||||
|
|
||||||
|
# add your model's MetaData object here
|
||||||
|
# for 'autogenerate' support
|
||||||
|
# from myapp import mymodel
|
||||||
|
# target_metadata = mymodel.Base.metadata
|
||||||
|
target_metadata = SQLModel.metadata
|
||||||
|
|
||||||
|
# other values from the config, defined by the needs of env.py,
|
||||||
|
# can be acquired:
|
||||||
|
# my_important_option = config.get_main_option("my_important_option")
|
||||||
|
# ... etc.
|
||||||
|
|
||||||
|
|
||||||
|
def run_migrations_offline() -> None:
|
||||||
|
"""Run migrations in 'offline' mode.
|
||||||
|
|
||||||
|
This configures the context with just a URL
|
||||||
|
and not an Engine, though an Engine is acceptable
|
||||||
|
here as well. By skipping the Engine creation
|
||||||
|
we don't even need a DBAPI to be available.
|
||||||
|
|
||||||
|
Calls to context.execute() here emit the given string to the
|
||||||
|
script output.
|
||||||
|
|
||||||
|
"""
|
||||||
|
url = config.get_main_option('sqlalchemy.url')
|
||||||
|
context.configure(
|
||||||
|
url=url,
|
||||||
|
target_metadata=target_metadata,
|
||||||
|
literal_binds=True,
|
||||||
|
dialect_opts={'paramstyle': 'named'},
|
||||||
|
)
|
||||||
|
|
||||||
|
with context.begin_transaction():
|
||||||
|
context.run_migrations()
|
||||||
|
|
||||||
|
|
||||||
|
def do_run_migrations(connection: Connection) -> None:
|
||||||
|
context.configure(connection=connection, target_metadata=target_metadata)
|
||||||
|
|
||||||
|
with context.begin_transaction():
|
||||||
|
context.run_migrations()
|
||||||
|
|
||||||
|
|
||||||
|
async def run_async_migrations() -> None:
|
||||||
|
"""In this scenario we need to create an Engine and associate a connection with the context."""
|
||||||
|
connectable = async_engine_from_config(
|
||||||
|
config.get_section(config.config_ini_section, {}),
|
||||||
|
prefix='sqlalchemy.',
|
||||||
|
poolclass=pool.NullPool,
|
||||||
|
)
|
||||||
|
|
||||||
|
async with connectable.connect() as connection:
|
||||||
|
await connection.run_sync(do_run_migrations)
|
||||||
|
|
||||||
|
await connectable.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
def run_migrations_online() -> None:
|
||||||
|
"""Run migrations in 'online' mode."""
|
||||||
|
asyncio.run(run_async_migrations())
|
||||||
|
|
||||||
|
|
||||||
|
if context.is_offline_mode():
|
||||||
|
run_migrations_offline()
|
||||||
|
else:
|
||||||
|
run_migrations_online()
|
||||||
29
migrations/script.py.mako
Normal file
29
migrations/script.py.mako
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
"""${message}
|
||||||
|
|
||||||
|
Revision ID: ${up_revision}
|
||||||
|
Revises: ${down_revision | comma,n}
|
||||||
|
Create Date: ${create_date}
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
import sqlmodel
|
||||||
|
${imports if imports else ""}
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = ${repr(up_revision)}
|
||||||
|
down_revision: Union[str, Sequence[str], None] = ${repr(down_revision)}
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
|
||||||
|
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
"""Upgrade schema."""
|
||||||
|
${upgrades if upgrades else "pass"}
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
"""Downgrade schema."""
|
||||||
|
${downgrades if downgrades else "pass"}
|
||||||
66
migrations/versions/115bcd2e1a38_initial_migration.py
Normal file
66
migrations/versions/115bcd2e1a38_initial_migration.py
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
"""Initial migration
|
||||||
|
|
||||||
|
Revision ID: 115bcd2e1a38
|
||||||
|
Revises:
|
||||||
|
Create Date: 2025-09-12 16:06:16.479075
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = '115bcd2e1a38'
|
||||||
|
down_revision: Union[str, Sequence[str], None] = None
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
"""Upgrade schema."""
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.create_table('bookmark',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('userkey', sa.String(length=255), nullable=False),
|
||||||
|
sa.Column('title', sa.String(length=255), nullable=False),
|
||||||
|
sa.Column('url', sa.String(length=255), nullable=False),
|
||||||
|
sa.Column('created_date', sa.DateTime(), nullable=False),
|
||||||
|
sa.Column('url_hash', sa.String(length=255), nullable=False),
|
||||||
|
sa.Column('tags', sa.String(length=255), nullable=False),
|
||||||
|
sa.Column('http_status', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('modified_date', sa.DateTime(), nullable=True),
|
||||||
|
sa.Column('favicon', sa.String(length=255), nullable=True),
|
||||||
|
sa.Column('starred', sa.Boolean(), server_default=sa.text('0'), nullable=True),
|
||||||
|
sa.Column('deleted_date', sa.DateTime(), server_default=sa.text('(null)'), nullable=True),
|
||||||
|
sa.Column('status', sa.Integer(), server_default=sa.text('0'), nullable=True),
|
||||||
|
sa.Column('note', sa.Text(), server_default=sa.text('(null)'), nullable=True),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
op.create_table('publictag',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('tagkey', sa.String(length=255), nullable=False),
|
||||||
|
sa.Column('userkey', sa.String(length=255), nullable=False),
|
||||||
|
sa.Column('tag', sa.String(length=255), nullable=False),
|
||||||
|
sa.Column('created_date', sa.DateTime(), server_default=sa.text('(null)'), nullable=True),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
op.create_table('user',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('username', sa.String(length=255), nullable=False),
|
||||||
|
sa.Column('key', sa.String(length=255), nullable=False),
|
||||||
|
sa.Column('created_date', sa.DateTime(), nullable=False),
|
||||||
|
sa.Column('theme', sa.String(length=20), server_default=sa.text("'green'"), nullable=True),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
"""Downgrade schema."""
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.drop_table('user')
|
||||||
|
op.drop_table('publictag')
|
||||||
|
op.drop_table('bookmark')
|
||||||
|
# ### end Alembic commands ###
|
||||||
95
migrations/versions/a8d8e45f60a1_migrate_to_sqlmodel.py
Normal file
95
migrations/versions/a8d8e45f60a1_migrate_to_sqlmodel.py
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
"""Migrate to sqlmodel.
|
||||||
|
|
||||||
|
Revision ID: a8d8e45f60a1
|
||||||
|
Revises: 115bcd2e1a38
|
||||||
|
Create Date: 2025-09-12 16:10:41.378716
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
import sqlalchemy as sa
|
||||||
|
import sqlmodel
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = 'a8d8e45f60a1'
|
||||||
|
down_revision: Union[str, Sequence[str], None] = '115bcd2e1a38'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
"""Upgrade schema."""
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
with op.batch_alter_table('bookmark', schema=None) as batch_op:
|
||||||
|
batch_op.alter_column('note',
|
||||||
|
existing_type=sa.TEXT(),
|
||||||
|
type_=sqlmodel.sql.sqltypes.AutoString(),
|
||||||
|
nullable=True,
|
||||||
|
existing_server_default=sa.text('(null)'))
|
||||||
|
batch_op.alter_column('starred',
|
||||||
|
existing_type=sa.BOOLEAN(),
|
||||||
|
nullable=False,
|
||||||
|
existing_server_default=sa.text('0'))
|
||||||
|
batch_op.alter_column('modified_date',
|
||||||
|
existing_type=sa.DATETIME(),
|
||||||
|
nullable=True)
|
||||||
|
batch_op.alter_column('deleted_date',
|
||||||
|
existing_type=sa.DATETIME(),
|
||||||
|
nullable=True,
|
||||||
|
existing_server_default=sa.text('(null)'))
|
||||||
|
batch_op.alter_column('status',
|
||||||
|
existing_type=sa.INTEGER(),
|
||||||
|
nullable=False,
|
||||||
|
existing_server_default=sa.text('0'))
|
||||||
|
batch_op.create_foreign_key('bookmark_user', 'user', ['userkey'], ['key'])
|
||||||
|
with op.batch_alter_table('publictag', schema=None) as batch_op:
|
||||||
|
batch_op.alter_column('created_date',
|
||||||
|
existing_type=sa.DATETIME(),
|
||||||
|
nullable=True,
|
||||||
|
existing_server_default=sa.text(str(datetime.now(UTC))))
|
||||||
|
batch_op.create_foreign_key('publictag_user', 'user', ['userkey'], ['key'])
|
||||||
|
with op.batch_alter_table('user', schema=None) as batch_op:
|
||||||
|
batch_op.alter_column('theme',
|
||||||
|
existing_type=sa.VARCHAR(length=20),
|
||||||
|
nullable=False,
|
||||||
|
existing_server_default=sa.text("'green'"))
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
"""Downgrade schema."""
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.alter_column('user', 'theme',
|
||||||
|
existing_type=sa.VARCHAR(length=20),
|
||||||
|
nullable=True,
|
||||||
|
existing_server_default=sa.text("'green'"))
|
||||||
|
op.drop_constraint(None, 'publictag', type_='foreignkey')
|
||||||
|
op.alter_column('publictag', 'created_date',
|
||||||
|
existing_type=sa.DATETIME(),
|
||||||
|
nullable=True,
|
||||||
|
existing_server_default=sa.text('(null)'))
|
||||||
|
op.drop_constraint(None, 'bookmark', type_='foreignkey')
|
||||||
|
op.alter_column('bookmark', 'status',
|
||||||
|
existing_type=sa.INTEGER(),
|
||||||
|
nullable=True,
|
||||||
|
existing_server_default=sa.text('0'))
|
||||||
|
op.alter_column('bookmark', 'deleted_date',
|
||||||
|
existing_type=sa.DATETIME(),
|
||||||
|
nullable=True,
|
||||||
|
existing_server_default=sa.text('(null)'))
|
||||||
|
op.alter_column('bookmark', 'modified_date',
|
||||||
|
existing_type=sa.DATETIME(),
|
||||||
|
nullable=True)
|
||||||
|
op.alter_column('bookmark', 'starred',
|
||||||
|
existing_type=sa.BOOLEAN(),
|
||||||
|
nullable=True,
|
||||||
|
existing_server_default=sa.text('0'))
|
||||||
|
op.alter_column('bookmark', 'note',
|
||||||
|
existing_type=sqlmodel.sql.sqltypes.AutoString(),
|
||||||
|
type_=sa.TEXT(),
|
||||||
|
nullable=True,
|
||||||
|
existing_server_default=sa.text('(null)'))
|
||||||
|
# ### end Alembic commands ###
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
# Core application
|
# Core application
|
||||||
fastapi[all]
|
fastapi[all]
|
||||||
sqlmodel
|
sqlmodel
|
||||||
|
alembic
|
||||||
|
aiosqlite
|
||||||
|
|
||||||
# Fetch title etc from links
|
# Fetch title etc from links
|
||||||
beautifulsoup4
|
beautifulsoup4
|
||||||
|
|||||||
@@ -6,8 +6,7 @@ import logging
|
|||||||
import os
|
import os
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
from http import HTTPStatus
|
from typing import Annotated, Sequence, Type
|
||||||
from typing import Annotated, Optional, Sequence, Type, TypeVar
|
|
||||||
from urllib.parse import urlparse, urlunparse
|
from urllib.parse import urlparse, urlunparse
|
||||||
|
|
||||||
import bs4
|
import bs4
|
||||||
@@ -18,15 +17,18 @@ from fastapi.middleware.cors import CORSMiddleware
|
|||||||
from fastapi.responses import HTMLResponse
|
from fastapi.responses import HTMLResponse
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
from fastapi.templating import Jinja2Templates
|
from fastapi.templating import Jinja2Templates
|
||||||
from pydantic import AnyUrl, DirectoryPath, FilePath, computed_field
|
from pydantic import AnyUrl, DirectoryPath, FilePath
|
||||||
from pydantic_settings import BaseSettings
|
from pydantic_settings import BaseSettings
|
||||||
from sqlmodel import AutoString, Field, Session, SQLModel, create_engine, desc, select
|
from sqlalchemy.ext.asyncio import create_async_engine
|
||||||
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
from sqlmodel import desc, select
|
||||||
|
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||||
|
|
||||||
|
from src.digimarks.models import DEFAULT_THEME, Bookmark, User, Visibility
|
||||||
|
|
||||||
DIGIMARKS_USER_AGENT = 'digimarks/2.0.0-dev'
|
DIGIMARKS_USER_AGENT = 'digimarks/2.0.0-dev'
|
||||||
DIGIMARKS_VERSION = '2.0.0a1'
|
DIGIMARKS_VERSION = '2.0.0a1'
|
||||||
|
|
||||||
DEFAULT_THEME = 'freshgreen'
|
|
||||||
|
|
||||||
|
|
||||||
class Settings(BaseSettings):
|
class Settings(BaseSettings):
|
||||||
"""Configuration needed for digimarks to find its database, favicons, API integrations."""
|
"""Configuration needed for digimarks to find its database, favicons, API integrations."""
|
||||||
@@ -49,16 +51,17 @@ class Settings(BaseSettings):
|
|||||||
settings = Settings()
|
settings = Settings()
|
||||||
print(settings.model_dump())
|
print(settings.model_dump())
|
||||||
|
|
||||||
engine = create_engine(f'sqlite:///{settings.database_file}', connect_args={'check_same_thread': False})
|
engine = create_async_engine(f'sqlite+aiosqlite:///{settings.database_file}', connect_args={'check_same_thread': False})
|
||||||
|
|
||||||
|
|
||||||
def get_session():
|
async def get_session() -> AsyncSession:
|
||||||
"""SQLAlchemy session factory."""
|
"""SQLAlchemy session factory."""
|
||||||
with Session(engine) as session:
|
async_session = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
|
||||||
|
async with async_session() as session:
|
||||||
yield session
|
yield session
|
||||||
|
|
||||||
|
|
||||||
SessionDep = Annotated[Session, Depends(get_session)]
|
SessionDep = Annotated[AsyncSession, Depends(get_session)]
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
@@ -172,39 +175,6 @@ def generate_key() -> str:
|
|||||||
return str(binascii.hexlify(os.urandom(24)))
|
return str(binascii.hexlify(os.urandom(24)))
|
||||||
|
|
||||||
|
|
||||||
# Type var used for building custom types for the DB
|
|
||||||
T = TypeVar('T')
|
|
||||||
|
|
||||||
|
|
||||||
def build_custom_type(internal_type: Type[T]) -> Type[AutoString]:
|
|
||||||
"""Create a type that is compatible with the database.
|
|
||||||
|
|
||||||
Based on https://github.com/fastapi/sqlmodel/discussions/847
|
|
||||||
"""
|
|
||||||
|
|
||||||
class CustomType(AutoString):
|
|
||||||
def process_bind_param(self, value, dialect) -> Optional[str]:
|
|
||||||
if value is None:
|
|
||||||
return None
|
|
||||||
|
|
||||||
if isinstance(value, str):
|
|
||||||
# Test if value is valid to avoid `process_result_value` failing
|
|
||||||
try:
|
|
||||||
internal_type(value) # type: ignore[call-arg]
|
|
||||||
except ValueError as e:
|
|
||||||
raise ValueError(f'Invalid value for {internal_type.__name__}: {e}') from e
|
|
||||||
|
|
||||||
return str(value)
|
|
||||||
|
|
||||||
def process_result_value(self, value, dialect) -> Optional[T]:
|
|
||||||
if value is None:
|
|
||||||
return None
|
|
||||||
|
|
||||||
return internal_type(value) # type: ignore[call-arg]
|
|
||||||
|
|
||||||
return CustomType
|
|
||||||
|
|
||||||
|
|
||||||
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`."""
|
||||||
favicons = from_html(html_content, root_url=root_url, include_fallbacks=True)
|
favicons = from_html(html_content, root_url=root_url, include_fallbacks=True)
|
||||||
@@ -213,60 +183,6 @@ def get_favicon(html_content: str, root_url: str) -> str:
|
|||||||
# TODO: save the preferred image to file and return
|
# TODO: save the preferred image to file and return
|
||||||
|
|
||||||
|
|
||||||
class User(SQLModel, table=True):
|
|
||||||
"""User account."""
|
|
||||||
|
|
||||||
__tablename__ = 'user'
|
|
||||||
|
|
||||||
id: int = Field(primary_key=True)
|
|
||||||
username: str
|
|
||||||
key: str
|
|
||||||
theme: str = Field(default=DEFAULT_THEME)
|
|
||||||
created_date: datetime
|
|
||||||
|
|
||||||
|
|
||||||
class Visibility:
|
|
||||||
"""Options for visibility of an object."""
|
|
||||||
|
|
||||||
VISIBLE = 0
|
|
||||||
DELETED = 1
|
|
||||||
|
|
||||||
|
|
||||||
class Bookmark(SQLModel, table=True):
|
|
||||||
"""Bookmark object."""
|
|
||||||
|
|
||||||
__tablename__ = 'bookmark'
|
|
||||||
|
|
||||||
id: int = Field(primary_key=True)
|
|
||||||
userkey: str = Field(foreign_key='user.key')
|
|
||||||
title: str = Field(default='')
|
|
||||||
url: AnyUrl = Field(default='', sa_type=build_custom_type(AnyUrl))
|
|
||||||
note: str = Field(default='')
|
|
||||||
# image: str = Field(default='')
|
|
||||||
url_hash: str = Field(default='')
|
|
||||||
tags: str = Field(default='')
|
|
||||||
starred: bool = Field(default=False)
|
|
||||||
|
|
||||||
favicon: str | None = Field(default=None)
|
|
||||||
|
|
||||||
http_status: int = Field(default=HTTPStatus.OK)
|
|
||||||
|
|
||||||
created_date: datetime = Field(default=datetime.now(UTC))
|
|
||||||
modified_date: datetime = Field(default=None)
|
|
||||||
deleted_date: datetime = Field(default=None)
|
|
||||||
|
|
||||||
status: int = Field(default=Visibility.VISIBLE)
|
|
||||||
|
|
||||||
@computed_field
|
|
||||||
@property
|
|
||||||
def tag_list(self) -> list:
|
|
||||||
"""The tags but as a proper list."""
|
|
||||||
if self.tags:
|
|
||||||
return self.tags.split(',')
|
|
||||||
# Not tags, return empty list instead of [''] that split returns in that case
|
|
||||||
return []
|
|
||||||
|
|
||||||
|
|
||||||
async def set_information_from_source(bookmark: Bookmark, request: Request) -> Bookmark:
|
async def set_information_from_source(bookmark: Bookmark, request: Request) -> Bookmark:
|
||||||
"""Request the title by requesting the source url."""
|
"""Request the title by requesting the source url."""
|
||||||
logger.info('Extracting information from url %s', bookmark.url)
|
logger.info('Extracting information from url %s', bookmark.url)
|
||||||
@@ -334,18 +250,6 @@ def update_bookmark_with_info(bookmark: Bookmark, request: Request, strip_params
|
|||||||
set_tags(bookmark, bookmark.tags)
|
set_tags(bookmark, bookmark.tags)
|
||||||
|
|
||||||
|
|
||||||
class PublicTag(SQLModel, table=True):
|
|
||||||
"""Public tag object."""
|
|
||||||
|
|
||||||
__tablename__ = 'public_tag'
|
|
||||||
|
|
||||||
id: int = Field(primary_key=True)
|
|
||||||
tagkey: str
|
|
||||||
userkey: str = Field(foreign_key='user.key')
|
|
||||||
tag: str
|
|
||||||
created_date: datetime = Field(default=datetime.now(UTC))
|
|
||||||
|
|
||||||
|
|
||||||
@app.get('/', response_class=HTMLResponse)
|
@app.get('/', response_class=HTMLResponse)
|
||||||
@app.head('/', response_class=HTMLResponse)
|
@app.head('/', response_class=HTMLResponse)
|
||||||
def index(request: Request):
|
def index(request: Request):
|
||||||
@@ -358,7 +262,7 @@ 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)
|
||||||
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."""
|
||||||
if system_key != settings.system_key:
|
if system_key != settings.system_key:
|
||||||
raise HTTPException(status_code=404)
|
raise HTTPException(status_code=404)
|
||||||
@@ -371,7 +275,7 @@ def get_user(session: SessionDep, system_key: str, user_id: int) -> Type[User]:
|
|||||||
|
|
||||||
# @app.get('/admin/{system_key}/users/', response_model=list[User])
|
# @app.get('/admin/{system_key}/users/', response_model=list[User])
|
||||||
@app.get('/api/v1/admin/{system_key}/users/')
|
@app.get('/api/v1/admin/{system_key}/users/')
|
||||||
def list_users(
|
async def list_users(
|
||||||
session: SessionDep,
|
session: SessionDep,
|
||||||
system_key: str,
|
system_key: str,
|
||||||
offset: int = 0,
|
offset: int = 0,
|
||||||
@@ -394,40 +298,42 @@ def list_users(
|
|||||||
|
|
||||||
|
|
||||||
@app.get('/api/v1/{user_key}/bookmarks/')
|
@app.get('/api/v1/{user_key}/bookmarks/')
|
||||||
def list_bookmarks(
|
async def list_bookmarks(
|
||||||
session: SessionDep,
|
session: SessionDep,
|
||||||
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]:
|
) -> list[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."""
|
||||||
bookmarks = session.exec(
|
result = await session.exec(
|
||||||
select(Bookmark)
|
select(Bookmark)
|
||||||
.where(Bookmark.userkey == user_key, Bookmark.status != Visibility.DELETED)
|
.where(Bookmark.userkey == user_key, Bookmark.status != Visibility.DELETED)
|
||||||
.offset(offset)
|
.offset(offset)
|
||||||
.limit(limit)
|
.limit(limit)
|
||||||
).all()
|
)
|
||||||
|
bookmarks = result.all()
|
||||||
return bookmarks
|
return bookmarks
|
||||||
|
|
||||||
|
|
||||||
@app.get('/api/v1/{user_key}/bookmarks/{url_hash}')
|
@app.get('/api/v1/{user_key}/bookmarks/{url_hash}')
|
||||||
def get_bookmark(
|
async def get_bookmark(
|
||||||
session: SessionDep,
|
session: SessionDep,
|
||||||
user_key: str,
|
user_key: str,
|
||||||
url_hash: str,
|
url_hash: str,
|
||||||
) -> Bookmark:
|
) -> Bookmark:
|
||||||
"""Show bookmark details."""
|
"""Show bookmark details."""
|
||||||
bookmark = session.exec(
|
result = await session.exec(
|
||||||
select(Bookmark).where(
|
select(Bookmark).where(
|
||||||
Bookmark.userkey == user_key, Bookmark.url_hash == url_hash, Bookmark.status != Visibility.DELETED
|
Bookmark.userkey == user_key, Bookmark.url_hash == url_hash, Bookmark.status != Visibility.DELETED
|
||||||
)
|
)
|
||||||
).first()
|
)
|
||||||
|
bookmark = result.first()
|
||||||
# bookmark = session.get(Bookmark, {'url_hash': url_hash, 'userkey': user_key})
|
# bookmark = session.get(Bookmark, {'url_hash': url_hash, 'userkey': user_key})
|
||||||
return bookmark
|
return bookmark
|
||||||
|
|
||||||
|
|
||||||
@app.post('/api/v1/{user_key}/autocomplete_bookmark/', response_model=Bookmark)
|
@app.post('/api/v1/{user_key}/autocomplete_bookmark/', response_model=Bookmark)
|
||||||
def autocomplete_bookmark(
|
async def autocomplete_bookmark(
|
||||||
session: SessionDep,
|
session: SessionDep,
|
||||||
request: Request,
|
request: Request,
|
||||||
user_key: str,
|
user_key: str,
|
||||||
@@ -441,11 +347,12 @@ def autocomplete_bookmark(
|
|||||||
update_bookmark_with_info(bookmark, request, strip_params)
|
update_bookmark_with_info(bookmark, request, strip_params)
|
||||||
|
|
||||||
url_hash = generate_hash(str(bookmark.url))
|
url_hash = generate_hash(str(bookmark.url))
|
||||||
bookmark_db = session.exec(
|
result = await session.exec(
|
||||||
select(Bookmark).where(
|
select(Bookmark).where(
|
||||||
Bookmark.userkey == user_key, Bookmark.url_hash == url_hash, Bookmark.status != Visibility.DELETED
|
Bookmark.userkey == user_key, Bookmark.url_hash == url_hash, Bookmark.status != Visibility.DELETED
|
||||||
)
|
)
|
||||||
).first()
|
)
|
||||||
|
bookmark_db = result.first()
|
||||||
if bookmark_db:
|
if bookmark_db:
|
||||||
# Bookmark with this URL already exists, provide the hash so the frontend can look it up and the user can
|
# 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
|
# merge them if so wanted
|
||||||
@@ -455,7 +362,7 @@ def autocomplete_bookmark(
|
|||||||
|
|
||||||
|
|
||||||
@app.post('/api/v1/{user_key}/bookmarks/', response_model=Bookmark)
|
@app.post('/api/v1/{user_key}/bookmarks/', response_model=Bookmark)
|
||||||
def add_bookmark(
|
async def add_bookmark(
|
||||||
session: SessionDep,
|
session: SessionDep,
|
||||||
request: Request,
|
request: Request,
|
||||||
user_key: str,
|
user_key: str,
|
||||||
@@ -470,13 +377,13 @@ def add_bookmark(
|
|||||||
bookmark.url_hash = generate_hash(str(bookmark.url))
|
bookmark.url_hash = generate_hash(str(bookmark.url))
|
||||||
|
|
||||||
session.add(bookmark)
|
session.add(bookmark)
|
||||||
session.commit()
|
await session.commit()
|
||||||
session.refresh(bookmark)
|
await session.refresh(bookmark)
|
||||||
return 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)
|
||||||
def update_bookmark(
|
async def update_bookmark(
|
||||||
session: SessionDep,
|
session: SessionDep,
|
||||||
request: Request,
|
request: Request,
|
||||||
user_key: str,
|
user_key: str,
|
||||||
@@ -485,11 +392,12 @@ 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`."""
|
||||||
bookmark_db = session.exec(
|
result = await session.exec(
|
||||||
select(Bookmark).where(
|
select(Bookmark).where(
|
||||||
Bookmark.userkey == user_key, Bookmark.url_hash == url_hash, Bookmark.status != Visibility.DELETED
|
Bookmark.userkey == user_key, Bookmark.url_hash == url_hash, Bookmark.status != Visibility.DELETED
|
||||||
)
|
)
|
||||||
).first()
|
)
|
||||||
|
bookmark_db = result.first()
|
||||||
if not bookmark_db:
|
if not bookmark_db:
|
||||||
raise HTTPException(status_code=404, detail='Bookmark not found')
|
raise HTTPException(status_code=404, detail='Bookmark not found')
|
||||||
|
|
||||||
@@ -510,13 +418,14 @@ def update_bookmark(
|
|||||||
|
|
||||||
|
|
||||||
@app.delete('/api/v1/{user_key}/bookmarks/{url_hash}', response_model=Bookmark)
|
@app.delete('/api/v1/{user_key}/bookmarks/{url_hash}', response_model=Bookmark)
|
||||||
def delete_bookmark(
|
async def delete_bookmark(
|
||||||
session: SessionDep,
|
session: SessionDep,
|
||||||
user_key: str,
|
user_key: str,
|
||||||
url_hash: str,
|
url_hash: str,
|
||||||
):
|
):
|
||||||
"""(Soft)Delete bookmark `bookmark_key` for user `user_key`."""
|
"""(Soft)Delete bookmark `bookmark_key` for user `user_key`."""
|
||||||
bookmark = session.get(Bookmark, {'url_hash': url_hash, 'userkey': user_key})
|
result = await session.get(Bookmark, {'url_hash': url_hash, 'userkey': user_key})
|
||||||
|
bookmark = result
|
||||||
if not bookmark:
|
if not bookmark:
|
||||||
raise HTTPException(status_code=404, detail='Bookmark not found')
|
raise HTTPException(status_code=404, detail='Bookmark not found')
|
||||||
bookmark.deleted_date = datetime.now(UTC)
|
bookmark.deleted_date = datetime.now(UTC)
|
||||||
@@ -527,21 +436,23 @@ def delete_bookmark(
|
|||||||
|
|
||||||
|
|
||||||
@app.get('/api/v1/{user_key}/latest_changes/')
|
@app.get('/api/v1/{user_key}/latest_changes/')
|
||||||
def bookmarks_changed_since(
|
async def bookmarks_changed_since(
|
||||||
session: SessionDep,
|
session: SessionDep,
|
||||||
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."""
|
||||||
latest_modified_bookmark = session.exec(
|
result = await session.exec(
|
||||||
select(Bookmark)
|
select(Bookmark)
|
||||||
.where(Bookmark.userkey == user_key, Bookmark.status != Visibility.DELETED)
|
.where(Bookmark.userkey == user_key, Bookmark.status != Visibility.DELETED)
|
||||||
.order_by(desc(Bookmark.modified_date))
|
.order_by(desc(Bookmark.modified_date))
|
||||||
).first()
|
)
|
||||||
latest_created_bookmark = session.exec(
|
latest_modified_bookmark = result.first()
|
||||||
|
result = await session.exec(
|
||||||
select(Bookmark)
|
select(Bookmark)
|
||||||
.where(Bookmark.userkey == user_key, Bookmark.status != Visibility.DELETED)
|
.where(Bookmark.userkey == user_key, Bookmark.status != Visibility.DELETED)
|
||||||
.order_by(desc(Bookmark.created_date))
|
.order_by(desc(Bookmark.created_date))
|
||||||
).first()
|
)
|
||||||
|
latest_created_bookmark = result.first()
|
||||||
|
|
||||||
latest_modification = max(latest_modified_bookmark.modified_date, latest_created_bookmark.created_date)
|
latest_modification = max(latest_modified_bookmark.modified_date, latest_created_bookmark.created_date)
|
||||||
|
|
||||||
@@ -554,14 +465,15 @@ def bookmarks_changed_since(
|
|||||||
|
|
||||||
|
|
||||||
@app.get('/api/v1/{user_key}/tags/')
|
@app.get('/api/v1/{user_key}/tags/')
|
||||||
def list_tags_for_user(
|
async def list_tags_for_user(
|
||||||
session: SessionDep,
|
session: SessionDep,
|
||||||
user_key: str,
|
user_key: str,
|
||||||
) -> list[str]:
|
) -> list[str]:
|
||||||
"""List all tags in use by the user."""
|
"""List all tags in use by the user."""
|
||||||
bookmarks = session.exec(
|
result = await session.exec(
|
||||||
select(Bookmark).where(Bookmark.userkey == user_key, Bookmark.status != Visibility.DELETED)
|
select(Bookmark).where(Bookmark.userkey == user_key, Bookmark.status != Visibility.DELETED)
|
||||||
).all()
|
)
|
||||||
|
bookmarks = result.all()
|
||||||
tags = []
|
tags = []
|
||||||
for bookmark in bookmarks:
|
for bookmark in bookmarks:
|
||||||
tags += bookmark.tag_list
|
tags += bookmark.tag_list
|
||||||
@@ -569,23 +481,25 @@ def list_tags_for_user(
|
|||||||
|
|
||||||
|
|
||||||
@app.get('/api/v1/{user_key}/tags/{tag_key}')
|
@app.get('/api/v1/{user_key}/tags/{tag_key}')
|
||||||
def list_tags_for_user(
|
async def list_tags_for_user(
|
||||||
session: SessionDep,
|
session: SessionDep,
|
||||||
user_key: str,
|
user_key: str,
|
||||||
) -> list[str]:
|
) -> list[str]:
|
||||||
"""List all tags in use by the user."""
|
"""List all tags in use by the user."""
|
||||||
bookmarks = session.exec(select(Bookmark).where(Bookmark.userkey == user_key)).all()
|
result = await session.exec(select(Bookmark).where(Bookmark.userkey == user_key))
|
||||||
|
bookmarks = result.all()
|
||||||
return list_tags_for_bookmarks(bookmarks)
|
return list_tags_for_bookmarks(bookmarks)
|
||||||
|
|
||||||
|
|
||||||
@app.get('/{user_key}', response_class=HTMLResponse)
|
@app.get('/{user_key}', response_class=HTMLResponse)
|
||||||
def page_user_landing(
|
async def page_user_landing(
|
||||||
session: SessionDep,
|
session: SessionDep,
|
||||||
request: Request,
|
request: Request,
|
||||||
user_key: str,
|
user_key: str,
|
||||||
):
|
):
|
||||||
"""HTML page with the main view for the user."""
|
"""HTML page with the main view for the user."""
|
||||||
user = session.exec(select(User).where(User.key == user_key)).first()
|
result = await session.exec(select(User).where(User.key == user_key))
|
||||||
|
user = result.first()
|
||||||
if not user:
|
if not user:
|
||||||
raise HTTPException(status_code=404, detail='User not found')
|
raise HTTPException(status_code=404, detail='User not found')
|
||||||
language = 'en'
|
language = 'en'
|
||||||
|
|||||||
107
src/digimarks/models.py
Normal file
107
src/digimarks/models.py
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
from datetime import UTC, datetime
|
||||||
|
from http import HTTPStatus
|
||||||
|
from typing import Optional, Type, TypeVar
|
||||||
|
|
||||||
|
from pydantic import AnyUrl, computed_field
|
||||||
|
from sqlmodel import AutoString, Field, SQLModel
|
||||||
|
|
||||||
|
DEFAULT_THEME = 'freshgreen'
|
||||||
|
|
||||||
|
|
||||||
|
class User(SQLModel, table=True):
|
||||||
|
"""User account."""
|
||||||
|
|
||||||
|
__tablename__ = 'user'
|
||||||
|
|
||||||
|
id: int = Field(primary_key=True)
|
||||||
|
username: str
|
||||||
|
key: str
|
||||||
|
theme: str = Field(default=DEFAULT_THEME)
|
||||||
|
created_date: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class Visibility:
|
||||||
|
"""Options for visibility of an object."""
|
||||||
|
|
||||||
|
VISIBLE = 0
|
||||||
|
DELETED = 1
|
||||||
|
|
||||||
|
|
||||||
|
# Type var used for building custom types for the DB
|
||||||
|
T = TypeVar('T')
|
||||||
|
|
||||||
|
|
||||||
|
def build_custom_type(internal_type: Type[T]) -> Type[AutoString]:
|
||||||
|
"""Create a type that is compatible with the database.
|
||||||
|
|
||||||
|
Based on https://github.com/fastapi/sqlmodel/discussions/847
|
||||||
|
"""
|
||||||
|
|
||||||
|
class CustomType(AutoString):
|
||||||
|
def process_bind_param(self, value, dialect) -> Optional[str]:
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if isinstance(value, str):
|
||||||
|
# Test if value is valid to avoid `process_result_value` failing
|
||||||
|
try:
|
||||||
|
internal_type(value) # type: ignore[call-arg]
|
||||||
|
except ValueError as e:
|
||||||
|
raise ValueError(f'Invalid value for {internal_type.__name__}: {e}') from e
|
||||||
|
|
||||||
|
return str(value)
|
||||||
|
|
||||||
|
def process_result_value(self, value, dialect) -> Optional[T]:
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return internal_type(value) # type: ignore[call-arg]
|
||||||
|
|
||||||
|
return CustomType
|
||||||
|
|
||||||
|
|
||||||
|
class Bookmark(SQLModel, table=True):
|
||||||
|
"""Bookmark object."""
|
||||||
|
|
||||||
|
__tablename__ = 'bookmark'
|
||||||
|
|
||||||
|
id: int = Field(primary_key=True)
|
||||||
|
userkey: str = Field(foreign_key='user.key')
|
||||||
|
title: str = Field(default='')
|
||||||
|
url: AnyUrl = Field(default='', sa_type=build_custom_type(AnyUrl))
|
||||||
|
note: str = Field(default='', nullable=True)
|
||||||
|
# image: str = Field(default='')
|
||||||
|
url_hash: str = Field(default='')
|
||||||
|
tags: str = Field(default='')
|
||||||
|
starred: bool = Field(default=False)
|
||||||
|
|
||||||
|
favicon: str | None = Field(default=None)
|
||||||
|
|
||||||
|
http_status: int = Field(default=HTTPStatus.OK)
|
||||||
|
|
||||||
|
created_date: datetime = Field(default=datetime.now(UTC))
|
||||||
|
modified_date: datetime = Field(default=None, nullable=True)
|
||||||
|
deleted_date: datetime = Field(default=None, nullable=True)
|
||||||
|
|
||||||
|
status: int = Field(default=Visibility.VISIBLE)
|
||||||
|
|
||||||
|
@computed_field
|
||||||
|
@property
|
||||||
|
def tag_list(self) -> list:
|
||||||
|
"""The tags but as a proper list."""
|
||||||
|
if self.tags:
|
||||||
|
return self.tags.split(',')
|
||||||
|
# Not tags, return empty list instead of [''] that split returns in that case
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
class PublicTag(SQLModel, table=True):
|
||||||
|
"""Public tag object."""
|
||||||
|
|
||||||
|
__tablename__ = 'publictag'
|
||||||
|
|
||||||
|
id: int = Field(primary_key=True)
|
||||||
|
tagkey: str
|
||||||
|
userkey: str = Field(foreign_key='user.key')
|
||||||
|
tag: str
|
||||||
|
created_date: datetime = Field(default=datetime.now(UTC))
|
||||||
Reference in New Issue
Block a user