This commit is contained in:
2021-11-14 10:38:47 +03:00
commit 30835e31fa
43 changed files with 2366 additions and 0 deletions

View File

@@ -0,0 +1,98 @@
# A generic, single database configuration.
[alembic]
# path to migration scripts
script_location = ./app/alembic
# template used to generate migration files
# file_template = %%(rev)s_%%(slug)s
# sys.path path, will be prepended to sys.path if present.
# defaults to the current working directory.
prepend_sys_path = .
# timezone to use when rendering the date within the migration file
# as well as the filename.
# If specified, requires the python-dateutil library that can be
# installed by adding `alembic[tz]` to the pip requirements
# string value is passed to dateutil.tz.gettz()
# 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 alembic/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 "version_path_separator"
# version_locations = %(here)s/bar:%(here)s/bat:alembic/versions
# version path separator; As mentioned above, this is the character used to split
# version_locations. Valid values are:
#
# version_path_separator = :
# version_path_separator = ;
# version_path_separator = space
version_path_separator = os # default: use os.pathsep
# the output encoding used when revision files
# are written from script.py.mako
# output_encoding = utf-8
[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
# Logging configuration
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
qualname =
[logger_sqlalchemy]
level = WARN
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

View File

@@ -0,0 +1 @@
Generic single-database configuration.

View File

@@ -0,0 +1,66 @@
from logging.config import fileConfig
from alembic import context
import sys, os
from sqlalchemy.engine import create_engine
from core.db import DATABASE_URL
myPath = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, myPath + '/../../')
config = context.config
from app.models import BaseMeta
target_metadata = BaseMeta.metadata
def run_migrations_offline():
"""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 run_migrations_online():
"""Run migrations in 'online' mode.
In this scenario we need to create an Engine
and associate a connection with the context.
"""
connectable = create_engine(DATABASE_URL)
with connectable.connect() as connection:
context.configure(
connection=connection, target_metadata=target_metadata
)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()

View File

@@ -0,0 +1,24 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision = ${repr(up_revision)}
down_revision = ${repr(down_revision)}
branch_labels = ${repr(branch_labels)}
depends_on = ${repr(depends_on)}
def upgrade():
${upgrades if upgrades else "pass"}
def downgrade():
${downgrades if downgrades else "pass"}

View File

@@ -0,0 +1,30 @@
"""empty message
Revision ID: 6d0dbf1e4998
Revises: 9c4b98440632
Create Date: 2021-11-07 20:23:39.656518
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '6d0dbf1e4998'
down_revision = '9c4b98440632'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_unique_constraint('uc_sequence_infos_sequence_position', 'sequence_infos', ['sequence', 'position'])
op.create_unique_constraint('uc_translations_book_position', 'translations', ['book', 'position'])
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_constraint('uc_translations_book_position', 'translations', type_='unique')
op.drop_constraint('uc_sequence_infos_sequence_position', 'sequence_infos', type_='unique')
# ### end Alembic commands ###

View File

@@ -0,0 +1,35 @@
"""empty message
Revision ID: 9c4b98440632
Revises: d172032adeaf
Create Date: 2021-11-07 16:21:08.210333
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '9c4b98440632'
down_revision = 'd172032adeaf'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('books_authors',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('author', sa.Integer(), nullable=True),
sa.Column('book', sa.Integer(), nullable=True),
sa.ForeignKeyConstraint(['author'], ['authors.id'], name='fk_books_authors_authors_author_id', onupdate='CASCADE', ondelete='CASCADE'),
sa.ForeignKeyConstraint(['book'], ['books.id'], name='fk_books_authors_books_book_id', onupdate='CASCADE', ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id')
)
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table('books_authors')
# ### end Alembic commands ###

View File

@@ -0,0 +1,112 @@
"""empty message
Revision ID: d172032adeaf
Revises:
Create Date: 2021-11-05 17:22:11.717389
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'd172032adeaf'
down_revision = None
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('sources',
sa.Column('id', sa.SmallInteger(), nullable=False),
sa.Column('name', sa.String(length=32), nullable=False),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('name')
)
op.create_table('authors',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('source', sa.SmallInteger(), nullable=False),
sa.Column('remote_id', sa.Integer(), nullable=False),
sa.Column('first_name', sa.String(length=256), nullable=False),
sa.Column('last_name', sa.String(length=256), nullable=False),
sa.Column('middle_name', sa.String(length=256), nullable=True),
sa.ForeignKeyConstraint(['source'], ['sources.id'], name='fk_authors_sources_id_source'),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('source', 'remote_id', name='uc_authors_source_remote_id')
)
op.create_table('books',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('source', sa.SmallInteger(), nullable=False),
sa.Column('remote_id', sa.Integer(), nullable=False),
sa.Column('title', sa.String(length=256), nullable=False),
sa.Column('lang', sa.String(length=2), nullable=False),
sa.Column('file_type', sa.String(length=4), nullable=False),
sa.Column('uploaded', sa.Date(), nullable=False),
sa.ForeignKeyConstraint(['source'], ['sources.id'], name='fk_books_sources_id_source'),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('source', 'remote_id', name='uc_books_source_remote_id')
)
op.create_table('sequences',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('source', sa.SmallInteger(), nullable=False),
sa.Column('remote_id', sa.Integer(), nullable=False),
sa.Column('name', sa.String(length=256), nullable=False),
sa.ForeignKeyConstraint(['source'], ['sources.id'], name='fk_sequences_sources_id_source'),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('source', 'remote_id', name='uc_sequences_source_remote_id')
)
op.create_table('author_annotations',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('author', sa.Integer(), nullable=False),
sa.Column('title', sa.String(length=256), nullable=False),
sa.Column('text', sa.Text(), nullable=False),
sa.Column('file', sa.String(length=256), nullable=True),
sa.ForeignKeyConstraint(['author'], ['authors.id'], name='fk_author_annotations_authors_id_author'),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('author')
)
op.create_table('book_annotations',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('book', sa.Integer(), nullable=False),
sa.Column('title', sa.String(length=256), nullable=False),
sa.Column('text', sa.Text(), nullable=False),
sa.Column('file', sa.String(length=256), nullable=True),
sa.ForeignKeyConstraint(['book'], ['books.id'], name='fk_book_annotations_books_id_book'),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('book')
)
op.create_table('sequence_infos',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('book', sa.Integer(), nullable=False),
sa.Column('sequence', sa.Integer(), nullable=False),
sa.Column('position', sa.SmallInteger(), nullable=False),
sa.ForeignKeyConstraint(['book'], ['books.id'], name='fk_sequence_infos_books_id_book'),
sa.ForeignKeyConstraint(['sequence'], ['sequences.id'], name='fk_sequence_infos_sequences_id_sequence'),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('book', 'sequence', name='uc_sequence_infos_book_sequence')
)
op.create_table('translations',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('book', sa.Integer(), nullable=False),
sa.Column('translator', sa.Integer(), nullable=False),
sa.Column('position', sa.SmallInteger(), nullable=False),
sa.ForeignKeyConstraint(['book'], ['books.id'], name='fk_translations_books_id_book'),
sa.ForeignKeyConstraint(['translator'], ['authors.id'], name='fk_translations_authors_id_translator'),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('book', 'translator', name='uc_translations_book_translator')
)
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table('translations')
op.drop_table('sequence_infos')
op.drop_table('book_annotations')
op.drop_table('author_annotations')
op.drop_table('sequences')
op.drop_table('books')
op.drop_table('authors')
op.drop_table('sources')
# ### end Alembic commands ###

View File

@@ -0,0 +1,127 @@
from datetime import date
import ormar
from core.db import metadata, database
class BaseMeta(ormar.ModelMeta):
metadata = metadata
database = database
class Source(ormar.Model):
class Meta(BaseMeta):
tablename = "sources"
id: int = ormar.SmallInteger(primary_key=True, nullable=False) # type: ignore
name: str = ormar.String(max_length=32, nullable=False, unique=True) # type: ignore
class Author(ormar.Model):
class Meta(BaseMeta):
tablename = "authors"
constraints = [
ormar.UniqueColumns("source", "remote_id"),
]
id: int = ormar.Integer(primary_key=True, nullable=False) # type: ignore
source: Source = ormar.ForeignKey(Source, nullable=False)
remote_id: int = ormar.Integer(minimum=0, nullable=False) # type: ignore
first_name: str = ormar.String(max_length=256, nullable=False) # type: ignore
last_name: str = ormar.String(max_length=256, nullable=False) # type: ignore
middle_name: str = ormar.String(max_length=256, nullable=True, default="") # type: ignore
class AuthorAnnotation(ormar.Model):
class Meta(BaseMeta):
tablename = "author_annotations"
id = ormar.Integer(primary_key=True, nullable=False)
author: Author = ormar.ForeignKey(Author, nullable=False, unique=True)
title: str = ormar.String(max_length=256, nullable=False, default="") # type: ignore
text: str = ormar.Text(nullable=False, default="") # type: ignore
file: str = ormar.String(max_length=256, nullable=True) # type: ignore
class Book(ormar.Model):
class Meta(BaseMeta):
tablename = "books"
constraints = [
ormar.UniqueColumns("source", "remote_id"),
]
id: int = ormar.Integer(primary_key=True, nullable=False) # type: ignore
source: Source = ormar.ForeignKey(Source, nullable=False)
remote_id: int = ormar.Integer(minimum=0, nullable=False) # type: ignore
title: str = ormar.String(max_length=256, nullable=False) # type: ignore
lang: str = ormar.String(max_length=2, nullable=False) # type: ignore
file_type: str = ormar.String(max_length=4, nullable=False) # type: ignore
uploaded: date = ormar.Date() # type: ignore
authors = ormar.ManyToMany(Author)
class BookAnnotation(ormar.Model):
class Meta(BaseMeta):
tablename = "book_annotations"
id = ormar.Integer(primary_key=True, nullable=False)
book: Book = ormar.ForeignKey(Book, nullable=False, unique=True)
title: str = ormar.String(max_length=256, nullable=False, default="") # type: ignore
text: str = ormar.Text(nullable=False, default="") # type: ignore
file: str = ormar.String(max_length=256, nullable=True) # type: ignore
class Translation(ormar.Model):
class Meta(BaseMeta):
tablename = "translations"
constraints = [
ormar.UniqueColumns("book", "translator"),
ormar.UniqueColumns("book", "position"),
]
id: int = ormar.Integer(primary_key=True, nullable=False) # type: ignore
book: Book = ormar.ForeignKey(Book, nullable=False)
translator: Author = ormar.ForeignKey(Author, nullable=False)
position: int = ormar.SmallInteger(nullable=False) # type: ignore
class Sequence(ormar.Model):
class Meta(BaseMeta):
tablename = "sequences"
constraints = [
ormar.UniqueColumns("source", "remote_id"),
]
id: int = ormar.Integer(primary_key=True, nullable=False) # type: ignore
source: Source = ormar.ForeignKey(Source, nullable=False)
remote_id: int = ormar.Integer(minimum=0, nullable=False) # type: ignore
name: str = ormar.String(max_length=256, nullable=False) # type: ignore
class SequenceInfo(ormar.Model):
class Meta(BaseMeta):
tablename = "sequence_infos"
constraints = [
ormar.UniqueColumns("book", "sequence"),
ormar.UniqueColumns("sequence", "position"),
]
id: int = ormar.Integer(primary_key=True, nullable=False) # type: ignore
book: Book = ormar.ForeignKey(Book, nullable=False)
sequence: Sequence = ormar.ForeignKey(Sequence, nullable=False)
position: int = ormar.SmallInteger(minimum=0, nullable=False) # type: ignore

View File

@@ -0,0 +1,48 @@
from typing import Optional
from datetime import date
from pydantic import BaseModel
class Author(BaseModel):
id: int
first_name: str
last_name: str
middle_name: Optional[str]
class CreateAuthor(BaseModel):
source: int
remote_id: int
first_name: str
last_name: str
middle_name: Optional[str]
class UpdateAuthor(BaseModel):
first_name: str
last_name: str
middle_name: Optional[str]
class AuthorBook(BaseModel):
id: int
title: str
lang: str
file_type: str
class Translation(BaseModel):
translator: Author
position: int
class TranslatedBook(BaseModel):
id: int
title: str
lang: str
file_type: str
authors: list[Author]
translations: list[Translation]

View File

@@ -0,0 +1,23 @@
from typing import Optional
from pydantic import BaseModel
class AuthorAnnotation(BaseModel):
id: int
title: str
text: str
file: Optional[str]
class CreateAuthorAnnotation(BaseModel):
author: int
title: str
text: str
file: Optional[str]
class UpdateAuthorAnnotation(BaseModel):
title: str
text: str
file: Optional[str]

View File

@@ -0,0 +1,42 @@
from datetime import date
from pydantic import BaseModel
from app.serializers.author import Author
class Book(BaseModel):
id: int
title: str
lang: str
file_type: str
uploaded: date
authors: list[Author]
class CreateBook(BaseModel):
source: int
remote_id: int
title: str
lang: str
file_type: str
uploaded: date
authors: list[int]
class UpdateBook(BaseModel):
title: str
lang: str
file_type: str
uploaded: date
authors: list[int]
class CreateRemoteBook(BaseModel):
source: int
remote_id: int
title: str
lang: str
file_type: str
uploaded: date
remote_authors: list[int]

View File

@@ -0,0 +1,24 @@
from typing import Optional
from pydantic import BaseModel
class BookAnnotation(BaseModel):
id: int
title: str
text: str
file: Optional[str]
class CreateBookAnnotation(BaseModel):
id: int
title: str
text: str
file: Optional[str]
class UpdateBookAnnotation(BaseModel):
id: int
title: str
text: str
file: Optional[str]

View File

@@ -0,0 +1,12 @@
from pydantic import BaseModel
class Sequence(BaseModel):
id: int
name: str
class CreateSequence(BaseModel):
source: int
remote_id: int
name: str

View File

@@ -0,0 +1,54 @@
from pydantic import BaseModel
class SequenceBookAuthor(BaseModel):
id: int
first_name: str
last_name: str
middle_name: str
class SeqTranslationTranslator(BaseModel):
id: int
first_name: str
last_name: str
middle_name: str
class SequenceBookTranslation(BaseModel):
id: int
translator: SeqTranslationTranslator
class SequenceBook(BaseModel):
id: int
title: str
lang: str
file_type: str
authors: SequenceBookAuthor
translation: SequenceBookTranslation
class Sequence(BaseModel):
id: int
name: str
class SequenceInfo(BaseModel):
id: int
book: SequenceBook
sequence: Sequence
position: int
class CreateSequenceInfo(BaseModel):
book: int
sequence: int
position: int
class CreateRemoteSequenceInfo(BaseModel):
source: int
remote_book: int
remote_sequence: int
position: int

View File

@@ -0,0 +1,10 @@
from pydantic import BaseModel
class Source(BaseModel):
id: int
name: str
class CreateSource(BaseModel):
name: str

View File

@@ -0,0 +1,34 @@
from pydantic import BaseModel
class TranslationBook(BaseModel):
id: int
title: str
lang: str
file_type: str
class TranslationTranslator(BaseModel):
id: int
first_name: str
last_name: str
middle_name: str
class Translation(BaseModel):
book: TranslationBook
translator: TranslationTranslator
position: int
class CreateTranslation(BaseModel):
book: int
translator: int
position: int
class CreateRemoteTranslation(BaseModel):
source: int
remote_book: int
remote_translator: int
position: int

View File

@@ -0,0 +1,13 @@
from app.models import Author
from app.services.common import TRGMSearchService
class AuthorTGRMSearchService(TRGMSearchService):
MODEL = Author
FIELDS = [
Author.Meta.table.c.last_name,
Author.Meta.table.c.first_name,
Author.Meta.table.c.middle_name
]
PREFETCH_RELATED = ["source"]

View File

@@ -0,0 +1,67 @@
from typing import Union
from fastapi import HTTPException, status
from app.models import Book as BookDB, Author as AuthorDB
from app.services.common import TRGMSearchService
from app.serializers.book import CreateBook, CreateRemoteBook
class BookTGRMSearchService(TRGMSearchService):
MODEL = BookDB
FIELDS = [
BookDB.Meta.table.c.title
]
PREFETCH_RELATED = ["source"]
class BookCreator:
@classmethod
def _raise_bad_request(cls):
raise HTTPException(status.HTTP_404_NOT_FOUND)
@classmethod
async def _create_book(cls, data: CreateBook) -> BookDB:
data_dict = data.dict()
author_ids = data_dict.pop("authors", [])
authors = await AuthorDB.objects.filter(id__in=author_ids).all()
if len(author_ids) != len(authors):
cls._raise_bad_request()
book = await BookDB.objects.create(
**data_dict
)
for author in authors:
await book.authors.add(author)
return book
@classmethod
async def _create_remote_book(cls, data: CreateRemoteBook) -> BookDB:
data_dict = data.dict()
author_ids = data_dict.pop("remote_authors", [])
authors = await AuthorDB.objects.filter(source__id=data.source, remote_id__in=author_ids).all()
if len(author_ids) != len(authors):
cls._raise_bad_request()
book = await BookDB.objects.create(
**data_dict
)
for author in authors:
await book.authors.add(author)
return book
@classmethod
async def create(cls, data: Union[CreateBook, CreateRemoteBook]) -> BookDB:
if isinstance(data, CreateBook):
return await cls._create_book(data)
if isinstance(data, CreateRemoteBook):
return await cls._create_remote_book(data)

View File

@@ -0,0 +1,134 @@
from typing import Optional, Generic, TypeVar, Union, Any, cast
from itertools import permutations
from fastapi_pagination.api import resolve_params
from fastapi_pagination.bases import RawParams
from app.utils.pagination import CustomPage
from ormar import Model, QuerySet
from sqlalchemy import text, func, select, desc, Table, Column
from databases import Database
def join_fields(fields):
result = fields[0]
for el in fields[1:]:
result += text("' '") + el
return result
T = TypeVar('T', bound=Model)
class TRGMSearchService(Generic[T]):
MODEL_CLASS: Optional[T] = None
FIELDS: Optional[list[Column]] = None
SELECT_RELATED: Optional[Union[list[str], str]] = None
PREFETCH_RELATED: Optional[Union[list[str], str]] = None
@classmethod
def get_params(cls) -> RawParams:
return resolve_params().to_raw_params()
@classmethod
@property
def model(cls) -> T:
assert cls.MODEL_CLASS is not None, f"MODEL in {cls.__name__} don't set!"
return cls.MODEL_CLASS
@classmethod
@property
def table(cls) -> Table:
return cls.model.Meta.table
@classmethod
@property
def database(cls) -> Database:
return cls.model.Meta.database
@classmethod
@property
def fields_combinations(cls):
assert cls.FIELDS is not None, f"FIELDS in {cls.__name__} don't set!"
assert len(cls.FIELDS) == 0, f"FIELDS in {cls.__name__} must be not empty!"
if len(cls.FIELDS) == 1:
return cls.FIELDS
combinations = []
for i in range(1, len(cls.FIELDS)):
combinations += permutations(cls.FIELDS, i)
return combinations
@classmethod
def get_similarity_subquery(cls, query: str):
return func.greatest(
*[func.similarity(join_fields(comb), f"{query}::text") for comb in cls.fields_combinations]
).label("sml")
@classmethod
def get_object_ids_query(cls, query: str):
similarity = cls.get_similarity_subquery(query)
params = cls.get_params()
return select(
[cls.table.c.id],
).where(
similarity > 0.5
).order_by(
desc(similarity)
).limit(params.limit).offset(params.offset)
@classmethod
def get_objects_count_query(cls, query: str):
similarity = cls.get_similarity_subquery(query)
return select(
func.count(cls.table.c.id)
).where(
similarity > 0.5
)
@classmethod
async def get_objects_count(cls, query: str) -> int:
count_query = cls.get_objects_count_query(query)
count_row = await cls.database.fetch_one(count_query)
assert count_row is not None
return cast(int, count_row.get("count_1"))
@classmethod
async def get_objects(cls, query: str) -> list[T]:
ids_query = cls.get_object_ids_query(query)
ids = await cls.database.fetch_all(ids_query)
queryset: QuerySet[T] = cls.model.objects
if cls.PREFETCH_RELATED is not None:
queryset = queryset.prefetch_related(cls.PREFETCH_RELATED)
if cls.SELECT_RELATED:
queryset = queryset.select_related(cls.SELECT_RELATED)
return await queryset.filter(id__in=[r.get("id") for r in ids]).all()
@classmethod
async def get(cls, query: str) -> CustomPage[T]:
params = cls.get_params()
authors = await cls.get_objects(query)
total = await cls.get_objects_count(query)
return CustomPage(
items=authors,
total=total,
limit=params.limit,
offset=params.offset
)

View File

@@ -0,0 +1,11 @@
from app.models import Sequence
from app.services.common import TRGMSearchService
class SequenceTGRMSearchService(TRGMSearchService):
MODEL = Sequence
FIELDS = [
Sequence.Meta.table.c.name
]
PREFETCH_RELATED = ["source"]

View File

@@ -0,0 +1,46 @@
from typing import Union
from fastapi import HTTPException, status
from app.models import SequenceInfo as SequenceInfoDB, Source as SourceDB, Book as BookDB, Sequence as SequenceDB
from app.serializers.sequence_info import CreateSequenceInfo, CreateRemoteSequenceInfo
class SequenceInfoCreator:
@classmethod
def _raise_bad_request(cls):
raise HTTPException(status.HTTP_404_NOT_FOUND)
@classmethod
async def _create_sequence_info(cls, data: CreateSequenceInfo) -> SequenceInfoDB:
return await SequenceInfoDB.objects.create(**data.dict())
@classmethod
async def _create_remote_sequence_info(cls, data: CreateRemoteSequenceInfo) -> SequenceInfoDB:
source = await SourceDB.objects.get_or_none(id=data.source)
if source is None:
cls._raise_bad_request()
book = await BookDB.objects.get_or_none(source__id=source.id, remote_id=data.remote_book)
if book is None:
cls._raise_bad_request()
sequence = await SequenceDB.objects.get_or_none(source__id=source.id, remote_id=data.remote_sequence)
if sequence is None:
cls._raise_bad_request()
return await SequenceInfoDB.objects.create(
book=book.id,
sequence=sequence.id,
position=data.position,
)
@classmethod
async def create(cls, data: Union[CreateSequenceInfo, CreateRemoteSequenceInfo]) -> SequenceInfoDB:
if isinstance(data, CreateSequenceInfo):
return await cls._create_sequence_info(data)
if isinstance(data, CreateRemoteSequenceInfo):
return await cls._create_remote_sequence_info(data)

View File

@@ -0,0 +1,49 @@
from typing import Union
from fastapi import HTTPException, status
from app.serializers.translation import CreateTranslation, CreateRemoteTranslation
from app.models import Translation as TranslationDB, Source as SourceDB, Book as BookDB, Author as AuthorDB
class TranslationCreator:
@classmethod
def _raise_bad_request(cls):
raise HTTPException(status.HTTP_404_NOT_FOUND)
@classmethod
async def _create_translation(cls, data: CreateTranslation) -> TranslationDB:
return await TranslationDB.objects.create(
**data.dict()
)
@classmethod
async def _create_remote_translation(cls, data: CreateRemoteTranslation) -> TranslationDB:
source = await SourceDB.objects.get_or_none(id=data.source)
if source is None:
cls._raise_bad_request()
book = await BookDB.objects.get_or_none(source__id=source.id, remote_id=data.remote_book)
if book is None:
cls._raise_bad_request()
translator = await AuthorDB.objects.get_or_none(source__id=source.id, remote_id=data.remote_translator)
if translator is None:
cls._raise_bad_request()
return await TranslationDB.objects.create(
book=book.id,
translator=translator.id,
position=data.position,
)
@classmethod
async def create(cls, data: Union[CreateTranslation, CreateRemoteTranslation]) -> TranslationDB:
if isinstance(data, CreateTranslation):
return await cls._create_translation(data)
if isinstance(data, CreateRemoteTranslation):
return await cls._create_remote_translation(data)

View File

@@ -0,0 +1,33 @@
from typing import Protocol, TypeVar, Any, Generic, Sequence, runtime_checkable
from dataclasses import asdict
from fastapi_pagination import Page, Params
from fastapi_pagination.bases import AbstractParams
@runtime_checkable
class ToDict(Protocol):
def dict(self) -> dict:
...
T = TypeVar('T', ToDict, Any)
class CustomPage(Page[T], Generic[T]):
@classmethod
def create(
cls,
items: Sequence[T],
total: int,
params: AbstractParams,
) -> Page[T]:
if not isinstance(params, Params):
raise ValueError("Page should be used with Params")
return cls(
total=total,
items=[item.dict() for item in items],
page=params.page,
size=params.size,
)

View File

@@ -0,0 +1,24 @@
from app.views.source import source_router
from app.views.author import author_router
from app.views.author_annotation import author_annotation_router
from app.views.book import book_router
from app.views.book_annotation import book_annotation_router
from app.views.translation import translation_router
from app.views.sequence import sequence_router
from app.views.sequence_info import sequence_info_router
routers = [
source_router,
author_router,
author_annotation_router,
book_router,
book_annotation_router,
translation_router,
sequence_router,
sequence_info_router,
]

View File

@@ -0,0 +1,83 @@
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi_pagination import Params, Page
from fastapi_pagination.ext.ormar import paginate
from fastapi_book_server.app.utils.pagination import CustomPage
from app.models import Author as AuthorDB, AuthorAnnotation as AuthorAnnotationDB, Book as BookDB
from app.serializers.author import Author, CreateAuthor, UpdateAuthor, AuthorBook, TranslatedBook
from app.serializers.author_annotation import AuthorAnnotation
from app.services.author import AuthorTGRMSearchService
author_router = APIRouter(
prefix="/api/v1/authors",
tags=["author"],
)
@author_router.get("/", response_model=Page[Author], dependencies=[Depends(Params)])
async def get_authors():
return await paginate(
AuthorDB.objects.prefetch_related("source")
)
@author_router.post("/", response_model=Author)
async def create_author(data: CreateAuthor):
author = await AuthorDB.objects.create(
**data.dict()
)
return await AuthorDB.objects.prefetch_related("source").get(id=author.id)
@author_router.get("/{id}", response_model=Author)
async def get_author(id: int):
author = await AuthorDB.objects.prefetch_related("source").get_or_none(id=id)
if author is None:
raise HTTPException(status.HTTP_404_NOT_FOUND)
return author
@author_router.put("/{id}", response_model=Author)
async def update_author(id: int, data: UpdateAuthor):
author = await AuthorDB.objects.get_or_none(id=id)
if author is None:
raise HTTPException(status.HTTP_404_NOT_FOUND)
author.update_from_dict(data.dict())
return await author.save()
@author_router.get("/{id}/annotation", response_model=AuthorAnnotation)
async def get_author_annotation(id: int):
annotation = await AuthorAnnotationDB.objects.get_or_none(author__id=id)
if annotation is None:
raise HTTPException(status.HTTP_404_NOT_FOUND)
return annotation
@author_router.get("/{id}/books", response_model=CustomPage[AuthorBook], dependencies=[Depends(Params)])
async def get_author_books(id: int):
return await paginate(
BookDB.objects.filter(author__id=id).order_by('title')
)
@author_router.get("/{id}/translated_books", response_model=CustomPage[TranslatedBook])
async def get_translated_books(id: int):
return await paginate(
BookDB.objects.select_related(["translations", "translations__translator"]).filter(translations__translator__id=id)
)
@author_router.get("/search/{query}", response_model=Page[Author], dependencies=[Depends(Params)])
async def search_authors(query: str):
return await AuthorTGRMSearchService.get(query)

View File

@@ -0,0 +1,49 @@
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi_pagination import Params, Page
from fastapi_pagination.ext.ormar import paginate
from app.models import AuthorAnnotation as AuthorAnnotationDB
from app.serializers.author_annotation import AuthorAnnotation, CreateAuthorAnnotation, UpdateAuthorAnnotation
author_annotation_router = APIRouter(
prefix="/api/v1/author_annotations",
tags=["author_annotation"]
)
@author_annotation_router.get("/", response_model=Page[AuthorAnnotation], dependencies=[Depends(Params)])
async def get_author_annotations():
return await paginate(
AuthorAnnotationDB.objects
)
@author_annotation_router.post("/", response_model=AuthorAnnotation)
async def create_author_annotation(data: CreateAuthorAnnotation):
return await AuthorAnnotationDB.objects.create(
**data.dict()
)
@author_annotation_router.get("/{id}", response_model=AuthorAnnotation)
async def get_author_annotation(id: int):
annotation = await AuthorAnnotationDB.objects.get_or_none(id=id)
if annotation is None:
raise HTTPException(status.HTTP_404_NOT_FOUND)
return annotation
@author_annotation_router.put("/{id}", response_model=AuthorAnnotation)
async def update_author_annotation(id: int, data: UpdateAuthorAnnotation):
annotation = await AuthorAnnotationDB.objects.get_or_none(id=id)
if annotation is None:
raise HTTPException(status.HTTP_404_NOT_FOUND)
annotation.update_from_dict(data.dict())
return await annotation.save()

View File

@@ -0,0 +1,81 @@
from typing import Union
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi_pagination import Params
from fastapi_pagination.ext.ormar import paginate
from app.utils.pagination import CustomPage
from app.models import Book as BookDB, Author as AuthorDB, AuthorAnnotation as AuthorAnnotationDB
from app.serializers.book import Book, CreateBook, UpdateBook, CreateRemoteBook
from app.services.book import BookTGRMSearchService, BookCreator
book_router = APIRouter(
prefix="/api/v1/books",
tags=["book"],
)
@book_router.get("/", response_model=CustomPage[Book], dependencies=[Depends(Params)])
async def get_books():
return await paginate(
BookDB.objects.select_related("authors")
)
@book_router.post("/", response_model=Book)
async def create_book(data: Union[CreateBook, CreateRemoteBook]):
book = await BookCreator.create(data)
return await BookDB.objects.select_related("authors").get(id=book.id)
@book_router.get("/{id}", response_model=Book)
async def get_book(id: int):
book = await BookDB.objects.select_related("authors").get_or_none(id=id)
if book is None:
raise HTTPException(status.HTTP_404_NOT_FOUND)
return book
@book_router.put("/{id}", response_model=Book)
async def update_book(id: int, data: UpdateBook):
book = await BookDB.objects.select_related("authors").get_or_none(id=id)
if book is None:
raise HTTPException(status.HTTP_404_NOT_FOUND)
for author in list(book.authors):
await book.authors.remove(author)
data_dict = data.dict()
author_ids = data_dict.pop("authors", [])
authors = await AuthorDB.objects.filter(id__in=author_ids).all()
book = await BookDB.objects.create(
**data_dict
)
for author in authors:
await book.authors.add(author)
return book
@book_router.get("/{id}/annotation")
async def get_book_annotation(id: int):
annotation = await AuthorAnnotationDB.objects.get(book__id=id)
if annotation is None:
raise HTTPException(status.HTTP_404_NOT_FOUND)
return annotation
@book_router.get("/search/{query}", response_model=CustomPage[Book], dependencies=[Depends(Params)])
async def search_books(query: str):
return await BookTGRMSearchService.get(query)

View File

@@ -0,0 +1,49 @@
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi_pagination import Params, Page
from fastapi_pagination.ext.ormar import paginate
from app.models import BookAnnotation as BookAnnotationDB
from app.serializers.book_annotation import BookAnnotation, CreateBookAnnotation, UpdateBookAnnotation
book_annotation_router = APIRouter(
prefix="/api/v1/book_annotations",
tags=["book_annotation"]
)
@book_annotation_router.get("/", response_model=Page[BookAnnotation], dependencies=[Depends(Params)])
async def get_book_annotations():
return await paginate(
BookAnnotationDB.objects
)
@book_annotation_router.post("/", response_model=BookAnnotation)
async def create_book_annotation(data: CreateBookAnnotation):
return await BookAnnotationDB.objects.create(
**data.dict()
)
@book_annotation_router.get("/{id}", response_model=BookAnnotation)
async def get_book_annotation(id: int):
annotation = await BookAnnotationDB.objects.get_or_none(id=id)
if annotation is None:
raise HTTPException(status.HTTP_404_NOT_FOUND)
return annotation
@book_annotation_router.put("/{id}", response_model=BookAnnotation)
async def update_book_annotation(id: int, data: UpdateBookAnnotation):
annotation = await BookAnnotationDB.objects.get_or_none(id=id)
if annotation is None:
raise HTTPException(status.HTTP_404_NOT_FOUND)
annotation.update_from_dict(data.dict())
return annotation.save()

View File

@@ -0,0 +1,39 @@
from fastapi import APIRouter, Depends
from fastapi_pagination import Params
from fastapi_pagination.ext.ormar import paginate
from app.utils.pagination import CustomPage
from app.models import Sequence as SequenceDB
from app.serializers.sequence import Sequence, CreateSequence
from app.services.sequence import SequenceTGRMSearchService
sequence_router = APIRouter(
prefix="/api/v1/sequences",
tags=["sequence"]
)
@sequence_router.get("/", response_model=CustomPage[Sequence], dependencies=[Depends(Params)])
async def get_sequences():
return await paginate(
SequenceDB.objects
)
@sequence_router.get("/{id}", response_model=Sequence)
async def get_sequence(id: int):
return await SequenceDB.objects.get(id=id)
@sequence_router.post("/", response_model=Sequence)
async def create_sequence(data: CreateSequence):
return await SequenceDB.objects.create(
**data.dict()
)
@sequence_router.get("/search/{query}", response_model=CustomPage[Sequence], dependencies=[Depends(Params)])
async def search_sequences(query: str):
return await SequenceTGRMSearchService.get(query)

View File

@@ -0,0 +1,46 @@
from typing import Union
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi_pagination import Params
from fastapi_pagination.ext.ormar import paginate
from app.utils.pagination import CustomPage
from app.models import SequenceInfo as SequenceInfoDB
from app.serializers.sequence_info import SequenceInfo, CreateSequenceInfo, CreateRemoteSequenceInfo
from app.services.sequence_info import SequenceInfoCreator
sequence_info_router = APIRouter(
prefix="/api/v1/sequence_info",
tags=["sequence_info"]
)
@sequence_info_router.get("/", response_model=CustomPage[SequenceInfo], dependencies=[Depends(Params)])
async def get_sequence_infos():
return await paginate(
SequenceInfoDB.objects.prefetch_related(["book", "sequence"])
.select_related(["book__authors", "book__translations", "book__translations__translator"])
)
@sequence_info_router.get("/{id}", response_model=SequenceInfo)
async def get_sequence_info(id: int):
sequence_info = SequenceInfoDB.objects.prefetch_related(["book", "sequence"]) \
.select_related(["book__authors", "book__translations", "book__translations__translator"]) \
.get_or_none(id=id)
if sequence_info is None:
raise HTTPException(status.HTTP_404_NOT_FOUND)
return sequence_info
@sequence_info_router.post("/", response_model=SequenceInfo)
async def create_sequence_info(data: Union[CreateSequenceInfo, CreateRemoteSequenceInfo]):
sequence_info = await SequenceInfoCreator.create(data)
return await SequenceInfoDB.objects.prefetch_related(["book", "sequence"]) \
.select_related(["book__authors", "book__translations", "book__translations__translator"]) \
.get(id=sequence_info.id)

View File

@@ -0,0 +1,25 @@
from fastapi import APIRouter, Depends
from fastapi_pagination import Params, Page
from fastapi_pagination.ext.ormar import paginate
from app.models import Source as SourceDB
from app.serializers.source import Source, CreateSource
source_router = APIRouter(
prefix="/api/v1/sources",
tags=["source"],
)
@source_router.get("", response_model=Page[Source], dependencies=[Depends(Params)])
async def get_sources():
return await paginate(SourceDB.objects)
@source_router.post("", response_model=Source)
async def create_source(data: CreateSource):
return await SourceDB.objects.create(
**data.dict()
)

View File

@@ -0,0 +1,43 @@
from typing import Union
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi_pagination import Params
from fastapi_pagination.ext.ormar import paginate
from app.utils.pagination import CustomPage
from app.models import Translation as TranslationDB
from app.serializers.translation import Translation, CreateTranslation, CreateRemoteTranslation
from app.services.translation import TranslationCreator
translation_router = APIRouter(
prefix="/api/v1/translation",
tags=["translation"]
)
@translation_router.get("/", response_model=CustomPage[Translation], dependencies=[Depends(Params)])
async def get_translations():
return await paginate(
TranslationDB.objects.prefetch_related(["book", "translator"])
)
@translation_router.post("/", response_model=Translation)
async def create_translation(data: Union[CreateTranslation, CreateRemoteTranslation]):
translation = await TranslationCreator.create(data)
return await TranslationDB.objects.prefetch_related(["book", "translator"]).get(id=translation.id)
@translation_router.delete("/{id}", response_model=Translation)
async def delete_translation(id: int):
translation = await TranslationDB.objects.prefetch_related(["book", "translator"]).get_or_none(id=id)
if translation is None:
raise HTTPException(status.HTTP_404_NOT_FOUND)
await translation.delete()
return translation