commit 10eb6071544503d9149c66d2d01be4fa3ffdb85f Author: sbps-test user Date: Wed Sep 13 20:37:14 2023 +0300 refs #0 The root commit. diff --git a/.env b/.env new file mode 100644 index 0000000..77d0f8a --- /dev/null +++ b/.env @@ -0,0 +1,9 @@ +# Маппинг на юзера, под которым все файлы, замонтированные внутрь +UID=1000 +GID=1000 + +POSTGRES_DB=postgres +POSTGRES_USER=postgres +POSTGRES_PASSWORD=postgres + +POSTGRES_DATABASE_URL=postgresql://postgres:postgres@db/postgres diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c8f8064 --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +.npmrc +.svelte-kit/ +.idea/ +node_modules/ +__pycache__/ +/postgres +tags +back/media +package-lock.json diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..ce85fb2 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,6 @@ +FROM python:3.11.4 +WORKDIR /code +COPY ./requirements.txt /code/requirements.txt +RUN pip install --no-cache-dir --upgrade -r /code/requirements.txt +COPY ./back/core /code/core +CMD ["uvicorn", "core.main:app", "--host", "0.0.0.0", "--reload"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..b6c7195 --- /dev/null +++ b/README.md @@ -0,0 +1,5 @@ +## Building + +```bash +docker-compose up -d --build +``` diff --git a/back/alembic.ini b/back/alembic.ini new file mode 100644 index 0000000..b3116dd --- /dev/null +++ b/back/alembic.ini @@ -0,0 +1,110 @@ +# A generic, single database configuration. + +[alembic] +# path to migration scripts +script_location = alembic + +# 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. +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" below. +# 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. The default within new alembic.ini files is "os", which uses os.pathsep. +# If this key is omitted entirely, it falls back to the legacy behavior of splitting on spaces and/or commas. +# Valid values for version_path_separator are: +# +# version_path_separator = : +# version_path_separator = ; +# version_path_separator = space +version_path_separator = os # Use os.pathsep. Default configuration used for new projects. + +# 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 + +sqlalchemy.url = postgresql://postgres:postgres@db/postgres + + +[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 diff --git a/back/alembic/README b/back/alembic/README new file mode 100644 index 0000000..98e4f9c --- /dev/null +++ b/back/alembic/README @@ -0,0 +1 @@ +Generic single-database configuration. \ No newline at end of file diff --git a/back/alembic/env.py b/back/alembic/env.py new file mode 100644 index 0000000..a555a41 --- /dev/null +++ b/back/alembic/env.py @@ -0,0 +1,76 @@ +from logging.config import fileConfig + +from sqlalchemy import engine_from_config +from sqlalchemy import pool + +from alembic import context + +# 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 core.main import Base + +target_metadata = Base.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 run_migrations_online() -> None: + """Run migrations in 'online' mode. + + In this scenario we need to create an Engine + and associate a connection with the context. + + """ + connectable = engine_from_config( + config.get_section(config.config_ini_section, {}), + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + + 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() diff --git a/back/alembic/script.py.mako b/back/alembic/script.py.mako new file mode 100644 index 0000000..55df286 --- /dev/null +++ b/back/alembic/script.py.mako @@ -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() -> None: + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + ${downgrades if downgrades else "pass"} diff --git a/back/alembic/versions/2023_09_13_1727-ceaf19d25957_.py b/back/alembic/versions/2023_09_13_1727-ceaf19d25957_.py new file mode 100644 index 0000000..b1f5634 --- /dev/null +++ b/back/alembic/versions/2023_09_13_1727-ceaf19d25957_.py @@ -0,0 +1,28 @@ +"""empty message + +Revision ID: ceaf19d25957 +Revises: +Create Date: 2023-09-13 17:27:54.299295 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'ceaf19d25957' +down_revision = None +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + pass + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + pass + # ### end Alembic commands ### diff --git a/back/config/__init__.py b/back/config/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/back/config/create_schema.py b/back/config/create_schema.py new file mode 100644 index 0000000..7fdb23b --- /dev/null +++ b/back/config/create_schema.py @@ -0,0 +1,19 @@ +from core.config_factory import CreateSchemaConfig +from core.typing import CreateSchema +from pydantic import create_model + + +def create_create_schema(module_name: str) -> CreateSchema: + """ + Creates pydantic module for item creating. + + Returns pydantic model. + + :param module_name: The name of the module. + """ + + config = CreateSchemaConfig(module_name) + + return create_model( + config.module.get_name("create_schema"), **config.get_create_field_defs() + ) diff --git a/back/config/model.py b/back/config/model.py new file mode 100644 index 0000000..7bb8e2b --- /dev/null +++ b/back/config/model.py @@ -0,0 +1,17 @@ +from core.config_factory import ModelConfig +from core.database import Base +from core.typing import Model + + +def create_model(module_name: str) -> Model: + """ + Creates SQLAlchemy model. + + Returns created SQLAlchemy model. + + :param module_name: The name of the module. + """ + + config = ModelConfig(module_name) + + return type(config.module.get_name("model"), (Base,), config.get_model_attrs()) diff --git a/back/config/module/__init__.py b/back/config/module/__init__.py new file mode 100644 index 0000000..8bf42c6 --- /dev/null +++ b/back/config/module/__init__.py @@ -0,0 +1,60 @@ +from importlib import import_module +from os import listdir +from os.path import isdir, join +from typing import Literal + +from core.helper import snake_to_camel +from yaml import safe_load + +exclude = "__pycache__" + +PATH = join(*__name__.split(".")) +MODULES = tuple( + filter( + lambda item: isdir(join(PATH, item)) and item not in exclude, + listdir(PATH), + ) +) + + +class Module: + """ + Represents a module of application. + """ + + def __init__(self, title: str) -> None: + self.title = title + + @property + def config_path(self) -> str: + return f"config/module/{self.title}/config.yaml" + + @property + def create_schema_name(self) -> str: + return snake_to_camel(self.title) + "Create" + + @property + def model_name(self) -> str: + return snake_to_camel(self.title) + + @property + def schema_name(self) -> str: + return self.model_name + + def get_name(self, _type: Literal["model", "schema", "create_schema"]) -> str: + match _type: + case "model" | "schema": + name = snake_to_camel(self.title) + case "create_schema": + name = snake_to_camel(self.title) + "Create" + + return name + + def get_item(self, _type: Literal["model", "schema", "create_schema"]): + return getattr(import_module(f"config.{_type}"), f"create_{_type}")(self.title) + + def get_body(self) -> dict: + with open(self.config_path, "r") as stream: + project = safe_load(stream) + + return project diff --git a/back/config/module/book/__init__.py b/back/config/module/book/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/back/config/module/book/config.yaml b/back/config/module/book/config.yaml new file mode 100644 index 0000000..45bbc81 --- /dev/null +++ b/back/config/module/book/config.yaml @@ -0,0 +1,9 @@ +fields: + author: + type: str + title: + type: str + year: + type: int + pdf: + type: file diff --git a/back/config/module/book/create_schema.py b/back/config/module/book/create_schema.py new file mode 100644 index 0000000..fcef7e5 --- /dev/null +++ b/back/config/module/book/create_schema.py @@ -0,0 +1,3 @@ +from core.util import update_module_locals + +update_module_locals(__name__, locals()) diff --git a/back/config/module/book/model.py b/back/config/module/book/model.py new file mode 100644 index 0000000..fcef7e5 --- /dev/null +++ b/back/config/module/book/model.py @@ -0,0 +1,3 @@ +from core.util import update_module_locals + +update_module_locals(__name__, locals()) diff --git a/back/config/module/book/schema.py b/back/config/module/book/schema.py new file mode 100644 index 0000000..fcef7e5 --- /dev/null +++ b/back/config/module/book/schema.py @@ -0,0 +1,3 @@ +from core.util import update_module_locals + +update_module_locals(__name__, locals()) diff --git a/back/config/schema.py b/back/config/schema.py new file mode 100644 index 0000000..f506c75 --- /dev/null +++ b/back/config/schema.py @@ -0,0 +1,21 @@ +from core.config_factory import SchemaConfig +from core.typing import Schema +from pydantic import create_model + + +def create_schema(module_name: str) -> Schema: + """ + Creates pydantic module for item returning. + + Returns pydantic model. + + :param module_name: The name of the module. + """ + + config = SchemaConfig(module_name) + + return create_model( + config.module.get_name("schema"), + __config__=config.Config, + **config.get_field_defs(), + ) diff --git a/back/core/__init__.py b/back/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/back/core/command.py b/back/core/command.py new file mode 100644 index 0000000..5813b69 --- /dev/null +++ b/back/core/command.py @@ -0,0 +1,35 @@ +from os import mkdir + + +def create_project(name: str) -> None: + """ + Creates project folder and all required files. + + from core import command; command.create_project("project") + + Args: + name (str): The desired project name. + """ + + def _create_file( + file_name: str, # Literal["__init__", "config", "create_schema", "model", "schema"], + content: str = None, # Optional[str] = None, + ) -> None: + file = open("config/module/" + name + "/" + file_name, "w") + if content: + file.write(content) + file.close() + + mkdir("config/module/" + name) + + files_without_content = ("__init__.py", "config.yaml") + + files_with_content = ("create_schema.py", "model.py", "schema.py") + + content = "from core.util import update_module_locals\n\nupdate_module_locals(__name__, locals())\n" + + for file_name in files_without_content: + _create_file(file_name) + + for file_name in files_with_content: + _create_file(file_name, content) diff --git a/back/core/config_factory.py b/back/core/config_factory.py new file mode 100644 index 0000000..a445112 --- /dev/null +++ b/back/core/config_factory.py @@ -0,0 +1,99 @@ +from config.module import Module +from core.field import get_field +from core.pydantic_field import get_pydantic_field +from sqlalchemy import Column, Integer +from yaml import safe_load + + +class Config: + """ + Base class for ModelConfig, SchemaConfig and CreateSchemaConfig. + """ + + def __init__(self, module_name: str) -> None: + """ + Initialize configuration. + + :param module_name: The name of module. + """ + + self.module = Module(module_name) + self.project = self.get_project() + + def get_project(self) -> dict: + """ + Construct and return dictionary with project attributes from yaml file. + """ + + with open(self.module.config_path, "r") as stream: + project = safe_load(stream) + + return project + + class Config: + orm_mode = True + + +class ModelConfig(Config): + """ + Configurates sqlalchemy model of module. + """ + + def get_model_attrs(self) -> dict: + """ + Returns model configuration for the module. + """ + + file_fields = set() + + model_attrs = { + "__tablename__": self.module.title, + "id": Column(Integer, primary_key=True, index=True), + } + for field_name, field_attrs in self.project.get("fields").items(): + model_attrs[field_name] = get_field(field_attrs) + + if field_attrs["type"] == "file": + file_fields.add(field_name) + + model_attrs["_file_fields"] = file_fields + + return model_attrs + + +class CreateSchemaConfig(Config): + """ + Configurates pydantic model for creating items of module. + """ + + def get_create_field_defs(self): + """ + Returns all pydantic model fields. + """ + + create_field_definitions = { + field_name: get_pydantic_field(field_attrs) + for field_name, field_attrs in self.project.get("fields").items() + if field_attrs["type"] != "file" and get_pydantic_field(field_attrs) + } + return create_field_definitions + + +class SchemaConfig(Config): + """ + Configurates pydantic model for updating and returning items of module. + """ + + def get_field_defs(self): + """ + Return all pydantic model fields and ID field. + """ + + field_definitions = {} + field_definitions["id"] = (int, ...) + + for field_name, field_attrs in self.project.get("fields").items(): + if get_pydantic_field(field_attrs): + field_definitions[field_name] = get_pydantic_field(field_attrs) + + return field_definitions diff --git a/back/core/crud.py b/back/core/crud.py new file mode 100644 index 0000000..0d03db2 --- /dev/null +++ b/back/core/crud.py @@ -0,0 +1,68 @@ +import os + +from sqlalchemy.orm import Session + + +def add_item(db: Session, cls, item): + db_item = cls(**item.dict()) + db.add(db_item) + db.commit() + db.refresh(db_item) + return db_item + + +def view_item(db: Session, cls, item_id: int): + return db.query(cls).filter(cls.id == item_id).first() + + +def edit_item(db: Session, cls, item_id: int, item): + db_item = db.query(cls).filter(cls.id == item_id).first() + for key, value in item.dict().items(): + setattr(db_item, key, value) + db.commit() + db.refresh(db_item) + return db_item + + +def remove_item(db: Session, cls, item_id: int): + db_item = db.query(cls).filter(cls.id == item_id).first() + db.delete(db_item) + db.commit() + return db_item + + +def view_items(db: Session, cls, skip: int = 0, limit: int = 100): + return db.query(cls).offset(skip).limit(limit).all() + + +def add_file(db: Session, cls, item_id: int, field: str, file): + content = file.file.read() + + if content: + file_path = os.path.join("media", file.filename) + else: + file_path = None + + if file_path: + with open(file_path, "wb") as f: + f.write(content) + + db_item = db.query(cls).filter(cls.id == item_id).first() + setattr(db_item, field, file_path) + db.commit() + db.refresh(db_item) + + return db_item + + +def remove_file(db: Session, cls, item_id: int, field: str): + db_item = db.query(cls).filter(cls.id == item_id).first() + file_path = getattr(db_item, field) + if file_path: + os.remove(file_path) + + setattr(db_item, field, None) + db.commit() + db.refresh(db_item) + + return db_item diff --git a/back/core/database.py b/back/core/database.py new file mode 100644 index 0000000..bd3615b --- /dev/null +++ b/back/core/database.py @@ -0,0 +1,13 @@ +import os + +from sqlalchemy import create_engine +from sqlalchemy.ext.declarative import declarative_base +from sqlalchemy.orm import sessionmaker + +SQLALCHEMY_DATABASE_URL = os.getenv("POSTGRES_DATABASE_URL") + + +engine = create_engine(SQLALCHEMY_DATABASE_URL) +SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) + +Base = declarative_base() diff --git a/back/core/exception.py b/back/core/exception.py new file mode 100644 index 0000000..e69de29 diff --git a/back/core/field.py b/back/core/field.py new file mode 100644 index 0000000..eda8466 --- /dev/null +++ b/back/core/field.py @@ -0,0 +1,27 @@ +from sqlalchemy import Column, Integer, String + + +def get_field(field_attrs: dict) -> Column: + """ + Construct sqlalchemy field using dictionary with field attributes. + + Returns the sqlalchemy column. + + :param field_attrs: The dictionary with field attributes. + """ + + column_attrs = {} + for key, value in field_attrs.items(): + if key == "type": + match value: + case "str": + column_attrs["type_"] = String + case "int": + column_attrs["type_"] = Integer + case "file": + column_attrs["type_"] = String + + elif key == "nullable": + column_attrs["nullable"] = value + + return Column(**column_attrs) diff --git a/back/core/helper.py b/back/core/helper.py new file mode 100644 index 0000000..f8bff04 --- /dev/null +++ b/back/core/helper.py @@ -0,0 +1,18 @@ +def snake_to_camel(snake_string: str) -> str: + """ + Transform string from snake case to string in camel case. + + Returns the string in tme camel case. + + :param snake_string: The string in the snake case. + """ + + return "".join(part.capitalize() for part in snake_string.split("_")) + + +def get_file_name(name: str) -> str: + return name.split(".")[-1] + + +def get_parent_directory_name(name: str) -> str: + return name.split(".")[-2] diff --git a/back/core/main.py b/back/core/main.py new file mode 100644 index 0000000..9d6db4d --- /dev/null +++ b/back/core/main.py @@ -0,0 +1,108 @@ +from typing import Optional + +from config.module import MODULES, Module +from core import crud +from core.database import Base, engine +from core.util import get_session +from fastapi import APIRouter, Depends, FastAPI, HTTPException, UploadFile +from fastapi.responses import FileResponse +from sqlalchemy.orm import Session + +app = FastAPI() + + +def get_router(module: Module) -> APIRouter: + cls = module.get_item("model") + Item = module.get_item("schema") + ItemCreate = module.get_item("create_schema") + + router = APIRouter(prefix="/" + module.title, tags=[module.title]) + + @router.post("/", response_model=Item) + async def create_item(item: ItemCreate, db: Session = Depends(get_session)): + return crud.add_item(db, cls, item) + + @router.get("/", response_model=list[Item]) + async def list_item( + skip: int = 0, limit: int = 100, db: Session = Depends(get_session) + ): + items = crud.view_items(db, cls, skip, limit) + return items + + @router.get("/{item_id}", response_model=Item) + async def read_item(item_id: int, db: Session = Depends(get_session)): + db_item = crud.view_item(db, cls, item_id) + if db_item is None: + raise HTTPException(status_code=404, detail="Item not found") + return db_item + + @router.put("/{item_id}", response_model=Item) + async def update_item( + item_id: int, + item: ItemCreate, + db: Session = Depends(get_session), + ): + return crud.edit_item(db, cls, item_id, item) + + @router.delete("/{item_id}", response_model=Item) + async def delete_item(item_id: int, db: Session = Depends(get_session)): + db_item = crud.view_item(db, cls, item_id) + if db_item is None: + raise HTTPException(status_code=404, detail="Item not found") + + for field in db_item._file_fields: + crud.remove_file(db, cls, item_id, field) + crud.remove_item(db, cls, item_id) + + return db_item + + @router.get("/{item_id}/{field}", response_class=FileResponse) + async def read_file(item_id: int, field: str, db: Session = Depends(get_session)): + db_item = crud.view_item(db, cls, item_id) + return getattr(db_item, field) + + @router.post("/{item_id}/{field}", response_model=Optional[str]) + async def create_file( + item_id: int, field: str, file: UploadFile, db: Session = Depends(get_session) + ): + try: + db_item = crud.add_file(db, cls, item_id, field, file) + + except Exception: + raise HTTPException(status_code=500, detail="Can not upload file") + + finally: + file.file.close() + + return getattr(db_item, field) + + @router.delete("/{item_id}/{field}", response_model=Item) + async def delete_file(item_id: int, field: str, db: Session = Depends(get_session)): + db_item = crud.remove_file(db, cls, item_id, field) + return db_item + + return router + + +for module in tuple(Module(module_name) for module_name in MODULES): + app.include_router(get_router(module)) + +module_router = APIRouter(prefix="/_module", tags=["_module"]) + + +@module_router.get("/", response_model=list[str]) +def list_module(skip: int = 0, limit: int = 100): + return MODULES + + +@module_router.get("/{module}", response_model=dict) +def read_module(module: str): + if module not in MODULES: + raise HTTPException(status_code=404, detail="Module not found") + return Module(module).get_body() + + +app.include_router(module_router) + + +Base.metadata.create_all(bind=engine) diff --git a/back/core/pydantic_field.py b/back/core/pydantic_field.py new file mode 100644 index 0000000..c5be4d4 --- /dev/null +++ b/back/core/pydantic_field.py @@ -0,0 +1,10 @@ +def get_pydantic_field(field_attrs: dict) -> tuple: + match field_attrs["type"]: + case "int" | "str": + field = (field_attrs["type"], ...) + case "file": + field = (str, None) + case _: + return () + + return field diff --git a/back/core/test.py b/back/core/test.py new file mode 100644 index 0000000..ada9483 --- /dev/null +++ b/back/core/test.py @@ -0,0 +1,75 @@ +from re import match +from os.path import exists +from unittest import TestCase, main +from shutil import rmtree + +from command import create_project + + +def increment_name(full_name: str) -> str: + name, index = match( + pattern="^([a-z\d]+)(?:_(\d+))?", string=full_name.lower() + ).groups() + + if not index: + postfix = "_1" + else: + postfix = f"_{index+1}" + + return name + postfix + + +def get_temporary_directory_name() -> str: + dir_name = "test" + while exists("config/module/" + dir_name + "/"): + dir_name = increment_name(dir_name) + + return dir_name + + +class TestCommandMethods(TestCase): + def test_create_project_creates_project_directory(self: TestCase): + directory = get_temporary_directory_name() + create_project(directory) + directory_exists = exists(f"config/module/{directory}/") + rmtree(f"config/module/{directory}/") + self.assertTrue(directory_exists) + + def test_create_project_creates_init(self: TestCase): + directory = get_temporary_directory_name() + create_project(directory) + file_exists = exists(f"config/module/{directory}/__init__.py") + rmtree(f"config/module/{directory}/") + self.assertTrue(file_exists) + + def test_create_project_creates_config(self: TestCase): + directory = get_temporary_directory_name() + create_project(directory) + file_exists = exists(f"config/module/{directory}/config.yaml") + rmtree(f"config/module/{directory}/") + self.assertTrue(file_exists) + + def test_create_project_creates_create_schema(self: TestCase): + directory = get_temporary_directory_name() + create_project(directory) + file_exists = exists(f"config/module/{directory}/create_schema.py") + rmtree(f"config/module/{directory}/") + self.assertTrue(file_exists) + + def test_create_project_creates_model(self: TestCase): + directory = get_temporary_directory_name() + create_project(directory) + file_exists = exists(f"config/module/{directory}/model.py") + rmtree(f"config/module/{directory}/") + self.assertTrue(file_exists) + + def test_create_project_creates_schema(self: TestCase): + directory = get_temporary_directory_name() + create_project(directory) + file_exists = exists(f"config/module/{directory}/schema.py") + rmtree(f"config/module/{directory}/") + self.assertTrue(file_exists) + + +if __name__ == "__main__": + main() diff --git a/back/core/typing.py b/back/core/typing.py new file mode 100644 index 0000000..03ec3bc --- /dev/null +++ b/back/core/typing.py @@ -0,0 +1,9 @@ +from typing import TypeVar + +from pydantic import BaseModel + +from core.database import Base + +Model = TypeVar("Model", bound=Base) +Schema = TypeVar("Schema", bound=BaseModel) +CreateSchema = TypeVar("CreateSchema", bound=BaseModel) diff --git a/back/core/util.py b/back/core/util.py new file mode 100644 index 0000000..81e04a2 --- /dev/null +++ b/back/core/util.py @@ -0,0 +1,24 @@ +from config.module import Module +from core.database import SessionLocal +from core.helper import get_file_name, get_parent_directory_name + + +def update_module_locals(path, _locals): + item = get_file_name(path) + module_name = get_parent_directory_name(path) + + module = Module(module_name) + + _locals[module.get_name(item)] = module.get_item(item) + + +def get_session(): + session = SessionLocal() + try: + yield session + finally: + session.close() + + +def get_module(module: str) -> dict: + return Module(module).get_item("create_schema").construct().schema() diff --git a/back/help.py b/back/help.py new file mode 100755 index 0000000..d6613e7 --- /dev/null +++ b/back/help.py @@ -0,0 +1,38 @@ +#!/usr/local/bin/python + +from importlib import import_module + +from click import argument, command + + +@command() +@argument("path") +def pyhelp(path: str): + """ + Print the object documentation from docstring. + + :param path: The full path to the module, function or class. + """ + + module_path = path.rsplit(".", 1)[0] + object_name = path.split(".")[-1] + + try: + module = import_module(module_path) + except ModuleNotFoundError: + module = None + + if module: + if module_path == object_name: + _object = module + else: + _object = getattr(module, object_name, None) + else: + _object = getattr(__builtins__, object_name, None) + + if _object: + help(_object) + + +if __name__ == "__main__": + pyhelp() diff --git a/back/manage.py b/back/manage.py new file mode 100644 index 0000000..db3d00c --- /dev/null +++ b/back/manage.py @@ -0,0 +1,35 @@ +#!/usr/local/bin/python + +from importlib import import_module + +from click import argument, command + + +@command() +@argument("path") +def pyhelp(path: str): + """ + Вывести документацию к объекту. + """ + module_path = path.rsplit(".", 1)[0] + object_name = path.split(".")[-1] + + try: + module = import_module(module_path) + except ModuleNotFoundError: + module = None + + if module: + if module_path == object_name: + _object = module + else: + _object = getattr(module, object_name, None) + else: + _object = getattr(__builtins__, object_name, None) + + if _object: + help(_object) + + +if __name__ == "__main__": + pyhelp() diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..bf4362a --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,49 @@ +version: "3.3" + +networks: + shared: + external: true + +services: + app: + build: . + container_name: library-back + restart: always + depends_on: + - db + env_file: + - .env + volumes: + - ./back:/code + - /var/run/docker.sock:/var/run/docker.sock + ports: + - "127.0.0.1:8000:8000" + user: "1000:1000" + networks: + - shared + + front: + image: node:latest + container_name: library-front + restart: always + working_dir: /code + volumes: + - ./front:/code + command: bash -c "npm install && npm run dev -- --host 0.0.0.0" + ports: + - "127.0.0.1:5173:5173" + networks: + - shared + + db: + image: postgres:latest + container_name: library-db + restart: always + volumes: + - ./postgres:/var/lib/postgresql/data + env_file: + - .env + ports: + - "127.0.0.1:5432:5432" + networks: + - shared diff --git a/front/.env b/front/.env new file mode 100644 index 0000000..d8cbae0 --- /dev/null +++ b/front/.env @@ -0,0 +1 @@ +BACKEND_URL="http://app:8000" diff --git a/front/package.json b/front/package.json new file mode 100644 index 0000000..8c4f198 --- /dev/null +++ b/front/package.json @@ -0,0 +1,17 @@ +{ + "name": "library", + "version": "0.0.1", + "private": true, + "scripts": { + "dev": "vite dev", + "build": "vite build", + "preview": "vite preview" + }, + "devDependencies": { + "@sveltejs/adapter-auto": "^2.0.0", + "@sveltejs/kit": "^1.5.0", + "svelte": "^3.54.0", + "vite": "^4.0.0" + }, + "type": "module" +} diff --git a/front/src/app.html b/front/src/app.html new file mode 100644 index 0000000..6769ed5 --- /dev/null +++ b/front/src/app.html @@ -0,0 +1,12 @@ + + + + + + + %sveltekit.head% + + +
%sveltekit.body%
+ + diff --git a/front/src/lib/components/AddForm.svelte b/front/src/lib/components/AddForm.svelte new file mode 100644 index 0000000..17f7450 --- /dev/null +++ b/front/src/lib/components/AddForm.svelte @@ -0,0 +1,26 @@ + + +
+ + + {#each keys as key} + + {/each} + + +
+ +
+ +
+
diff --git a/front/src/lib/components/EditForm.svelte b/front/src/lib/components/EditForm.svelte new file mode 100644 index 0000000..46b4b0d --- /dev/null +++ b/front/src/lib/components/EditForm.svelte @@ -0,0 +1,35 @@ + + +
+ + + {#each keys as key} + + {/each} + + + +
+ {#if getType(project, key) == 'file'} + + {:else} + + {/if} +
+ +
+ +
+
diff --git a/front/src/lib/components/Field.svelte b/front/src/lib/components/Field.svelte new file mode 100644 index 0000000..fb3cda4 --- /dev/null +++ b/front/src/lib/components/Field.svelte @@ -0,0 +1,17 @@ + + + + diff --git a/front/src/lib/components/FileField.svelte b/front/src/lib/components/FileField.svelte new file mode 100644 index 0000000..72626f8 --- /dev/null +++ b/front/src/lib/components/FileField.svelte @@ -0,0 +1,21 @@ + + + + +{#if value} + {fileName} + +{/if} diff --git a/front/src/lib/components/HeadRow.svelte b/front/src/lib/components/HeadRow.svelte new file mode 100644 index 0000000..27edefe --- /dev/null +++ b/front/src/lib/components/HeadRow.svelte @@ -0,0 +1,12 @@ + + + + + {#each keys as key} + {capitalize(key)} + {/each} + diff --git a/front/src/lib/components/Row.svelte b/front/src/lib/components/Row.svelte new file mode 100644 index 0000000..7555faa --- /dev/null +++ b/front/src/lib/components/Row.svelte @@ -0,0 +1,21 @@ + + + + + {item.id} + {#each keys as key} + + {#if getType(project, key) == 'file'} + {getFileName(getValue(item, key) || "")} + {:else} + {getValue(item, key) || ""} + {/if} + + {/each} + diff --git a/front/src/lib/components/Table.svelte b/front/src/lib/components/Table.svelte new file mode 100644 index 0000000..1a6fc19 --- /dev/null +++ b/front/src/lib/components/Table.svelte @@ -0,0 +1,20 @@ + + +{#if items.length !== 0} + + + + + + {#each items as item (item.id)} + + {/each} + +
+{/if} diff --git a/front/src/lib/components/util.js b/front/src/lib/components/util.js new file mode 100644 index 0000000..e42f32f --- /dev/null +++ b/front/src/lib/components/util.js @@ -0,0 +1,85 @@ +import { goto, invalidate } from '$app/navigation'; + +export function capitalize(str) { + return str.charAt(0).toUpperCase() + str.slice(1) +}; + +export function getFileName(str) { + if (str !== null) { + return str.replace(/^.*[\\\/]/, ''); + } +}; + +export function getKeys(item) { + return Object.keys(item).filter(key => key !== 'id'); +}; + +export function getValue(item, key) { + return item[key]; +}; + +export function getTableField(project, item, key) { + const value = getValue(item, key) || ''; + const type = getType(project, key); + if (type == 'file') { + return getFileName(value); + } else { + return value; + } +}; + +export function getType(project, key) { + const type = project.fields[key].type; + const type_map = { + str: 'text', + int: 'number', + file: 'file' + } + return type_map[type] +}; + +export async function handleCreateItem(project) { + const form = document.querySelector('form'); + const data = new FormData(form); + const response = await fetch( + `/${project}/add`, + { + method: 'POST', + body: data, + } + ) + const item = await response.json(); + goto(`/${project}/${item.id}`, {invalidateAll: true}); +}; + +export async function handleUpdateItem(project, item_id) { + const form = document.querySelector('form'); + const data = new FormData(form); + const response = await fetch( + `/${project}/${item_id}`, + { + method: 'PUT', + body: data, + } + ); + invalidate('data:item'); + invalidate('data:items'); +}; + +export async function handleDeleteItem(project, item_id) { + const response = await fetch( + `/${project}/${item_id}`, + { + method: 'DELETE', + } + ); + goto(`/${project}`, {invalidateAll: true}); +}; + + +export async function handleDeleteFile(project, item_id, field) { + const response = await fetch(`/${project}/${item_id}/${field}`, {method: 'DELETE'}) + document.querySelector(`input[name=${field}]`).value = ""; + invalidate('data:item'); + invalidate('data:items'); +}; diff --git a/front/src/lib/server/database.js b/front/src/lib/server/database.js new file mode 100644 index 0000000..15a7238 --- /dev/null +++ b/front/src/lib/server/database.js @@ -0,0 +1,147 @@ +import { error, fail } from '@sveltejs/kit'; +import { BACKEND_URL } from '$env/static/private'; + +const module = "book" + +export async function getItems(project) { + const response = await fetch(`${BACKEND_URL}/${project}/`); + return response.json(); +} + +export async function getItem(project, id) { + const response = await fetch(`${BACKEND_URL}/${project}/${id}`); + if (response.status !== 200) throw error(response.status); + const item = await response.json(); + return item; +} + +export async function getEmptyItem(project) { + const response = await fetch(`${BACKEND_URL}/_module/${project}`); + if (response.status !== 200) throw error(response.status); + const result = await response.json(); + const fields = Object.keys(result.fields); + const entries = new Map(fields.map(field => [field, null])) + return Object.fromEntries(entries); +} + +export async function getProjects() { + const response = await fetch(`${BACKEND_URL}/_module/`); + if (response.status !== 200) throw error(response.status); + return response.json(); +} + +export async function getProject(project) { + const response = await fetch(`${BACKEND_URL}/_module/${project}`); + if (response.status !== 200) throw error(response.status); + return response.json(); +} + + +export async function createItem(project, data) { + const fields = Object.fromEntries( + Array.from(data.entries()).filter( + ([key, value]) => !(value instanceof File) + ) + ); + + const options = { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify(fields) + } + const response = await fetch(`${BACKEND_URL}/${project}/`, options); + if (response.status !== 200) throw error(response.status); + + const item = await response.json(); + + const files = Object.fromEntries( + Array.from(data.entries()).filter( + ([key, value]) => value instanceof File && value.size !== 0 + ) + ); + + for (let key in files) + { + const data = new FormData() + data.append('file', files[key]); + await uploadFile(project, item["id"], key, data); + } + + return item; +} + +export async function updateItem(project, id, data) { + const files = Object.fromEntries( + Array.from(data.entries()).filter( + ([key, value]) => value instanceof File && value.size !== 0 + ) + ); + + for (let key in files) + { + const data = new FormData() + data.append('file', files[key]); + await deleteFile(project, id, key); + await uploadFile(project, id, key, data); + } + + const fields = Object.fromEntries( + Array.from(data.entries()).filter( + ([key, value]) => !(value instanceof File) + ) + ); + + const options = { + method: 'PUT', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify(fields) + } + const response = await fetch(`${BACKEND_URL}/${project}/${id}`, options); + if (response.status !== 200) throw error(response.status); + + const item = response.json(); + return item; +} + +export async function deleteItem(project, id) { + const options = { + method: 'DELETE', + headers: { + 'Content-Type': 'application/json' + }, + } + const response = await fetch(`${BACKEND_URL}/${project}/${id}`, options); + if (response.status !== 200) throw error(response.status); + return response.json(); +} + +export async function readFile(project, id, field) { + const response = await fetch(`${BACKEND_URL}/${project}/${id}/${field}`); + return response; +} + +export async function uploadFile(project, id, field, data) { + const options = { + method: 'POST', + body: data, + } + const response = await fetch(`${BACKEND_URL}/${project}/${id}/${field}`, options); + if (response.status !== 200) throw error(response.status); + return response.json(); +} + +export async function deleteFile(project, id, field) { + const options = { + method: 'DELETE', + headers: { + 'Content-Type': 'application/json' + }, + } + const response = await fetch(`${BACKEND_URL}/${project}/${id}/${field}`, options); + if (response.status !== 200) throw error(response.status); + return response.json(); +} diff --git a/front/src/routes/+layout.server.js b/front/src/routes/+layout.server.js new file mode 100644 index 0000000..fd13528 --- /dev/null +++ b/front/src/routes/+layout.server.js @@ -0,0 +1,8 @@ +import { getProjects } from '$lib/server/database.js'; + +export async function load() { + const projects = await getProjects(); + return { + projects: projects + }; +} diff --git a/front/src/routes/+layout.svelte b/front/src/routes/+layout.svelte new file mode 100644 index 0000000..9d248e0 --- /dev/null +++ b/front/src/routes/+layout.svelte @@ -0,0 +1,12 @@ + + + + + diff --git a/front/src/routes/+page.svelte b/front/src/routes/+page.svelte new file mode 100644 index 0000000..415b7b2 --- /dev/null +++ b/front/src/routes/+page.svelte @@ -0,0 +1 @@ +

home

diff --git a/front/src/routes/[project]/+layout.server.js b/front/src/routes/[project]/+layout.server.js new file mode 100644 index 0000000..5a845cf --- /dev/null +++ b/front/src/routes/[project]/+layout.server.js @@ -0,0 +1,16 @@ +import { error } from '@sveltejs/kit'; +import { getItems, getProject, getProjects } from '$lib/server/database.js'; + +export async function load({ params, depends }) { + depends('data:items'); + + const projects = await getProjects(); + if (!projects.includes(params.project)) throw error(404); + const items = await getItems(params.project); + const project = await getProject(params.project); + return { + title: params.project, + project: project, + items: items + }; +} diff --git a/front/src/routes/[project]/+layout.svelte b/front/src/routes/[project]/+layout.svelte new file mode 100644 index 0000000..ba9894b --- /dev/null +++ b/front/src/routes/[project]/+layout.svelte @@ -0,0 +1,7 @@ + + +

{data.title}

+ + diff --git a/front/src/routes/[project]/+page.svelte b/front/src/routes/[project]/+page.svelte new file mode 100644 index 0000000..7dfe859 --- /dev/null +++ b/front/src/routes/[project]/+page.svelte @@ -0,0 +1,12 @@ + + + + + + + diff --git a/front/src/routes/[project]/Page.svelte b/front/src/routes/[project]/Page.svelte new file mode 100644 index 0000000..e69de29 diff --git a/front/src/routes/[project]/[id]/+page.server.js b/front/src/routes/[project]/[id]/+page.server.js new file mode 100644 index 0000000..e68d0de --- /dev/null +++ b/front/src/routes/[project]/[id]/+page.server.js @@ -0,0 +1,8 @@ +import { getItem } from '$lib/server/database.js'; + +export async function load({ params, depends }) { + depends('data:item'); + + const item = await getItem(params.project, params.id); + return { item }; +} diff --git a/front/src/routes/[project]/[id]/+page.svelte b/front/src/routes/[project]/[id]/+page.svelte new file mode 100644 index 0000000..4524b38 --- /dev/null +++ b/front/src/routes/[project]/[id]/+page.svelte @@ -0,0 +1,7 @@ + + + diff --git a/front/src/routes/[project]/[id]/+server.js b/front/src/routes/[project]/[id]/+server.js new file mode 100644 index 0000000..237a78d --- /dev/null +++ b/front/src/routes/[project]/[id]/+server.js @@ -0,0 +1,14 @@ +import { deleteItem, updateItem } from '$lib/server/database.js'; +import { redirect } from '@sveltejs/kit'; +import { json } from '@sveltejs/kit'; + +export async function PUT({ params, request }) { + const updateData = await request.formData(); + const item = await updateItem(params.project, params.id, updateData); + return json(item); +} + +export async function DELETE({ params }) { + const item = await deleteItem(params.project, params.id); + return json(item); +} diff --git a/front/src/routes/[project]/[id]/[field]/+server.js b/front/src/routes/[project]/[id]/[field]/+server.js new file mode 100644 index 0000000..68dd917 --- /dev/null +++ b/front/src/routes/[project]/[id]/[field]/+server.js @@ -0,0 +1,12 @@ +import { readFile, deleteFile } from '$lib/server/database.js'; +import { json } from '@sveltejs/kit'; + +export async function DELETE({params}) { + const response = await deleteFile(params.project, params.id, params.field); + return json(response); +} + +export async function GET({params}) { + const response = await readFile(params.project, params.id, params.field); + return new Response(response.body); +} diff --git a/front/src/routes/[project]/add/+page.server.js b/front/src/routes/[project]/add/+page.server.js new file mode 100644 index 0000000..11e7f33 --- /dev/null +++ b/front/src/routes/[project]/add/+page.server.js @@ -0,0 +1,8 @@ +import { fail, redirect } from '@sveltejs/kit'; +import { getEmptyItem } from '$lib/server/database.js'; + +export async function load({ params }) { + const item = await getEmptyItem(params.project); + + return { item }; +} diff --git a/front/src/routes/[project]/add/+page.svelte b/front/src/routes/[project]/add/+page.svelte new file mode 100644 index 0000000..bff8eee --- /dev/null +++ b/front/src/routes/[project]/add/+page.svelte @@ -0,0 +1,8 @@ + + + diff --git a/front/src/routes/[project]/add/+server.js b/front/src/routes/[project]/add/+server.js new file mode 100644 index 0000000..52d14ea --- /dev/null +++ b/front/src/routes/[project]/add/+server.js @@ -0,0 +1,8 @@ +import { createItem } from '$lib/server/database.js'; +import { json } from '@sveltejs/kit'; + +export async function POST({ params, request }) { + const data = await request.formData(); + const item = await createItem(params.project, data); + return json(item); +} diff --git a/front/static/favicon.png b/front/static/favicon.png new file mode 100644 index 0000000..825b9e6 Binary files /dev/null and b/front/static/favicon.png differ diff --git a/front/svelte.config.js b/front/svelte.config.js new file mode 100644 index 0000000..301e785 --- /dev/null +++ b/front/svelte.config.js @@ -0,0 +1,10 @@ +import adapter from '@sveltejs/adapter-auto'; + +/** @type {import('@sveltejs/kit').Config} */ +const config = { + kit: { + adapter: adapter() + } +}; + +export default config; diff --git a/front/vite.config.js b/front/vite.config.js new file mode 100644 index 0000000..bbf8c7d --- /dev/null +++ b/front/vite.config.js @@ -0,0 +1,6 @@ +import { sveltekit } from '@sveltejs/kit/vite'; +import { defineConfig } from 'vite'; + +export default defineConfig({ + plugins: [sveltekit()] +}); diff --git a/nginx.test b/nginx.test new file mode 100644 index 0000000..e2edaf1 --- /dev/null +++ b/nginx.test @@ -0,0 +1,28 @@ +server { + listen 80; + server_name library.sbps.ru; + + location / { + rewrite ^(.*)$ http://library.sbps.ru$1 permanent; + } +} + + +server { + listen 443 ssl; + server_name library.sbps.ru; + + location /lib/media/ { + alias /home/www/projects/library_test/media/; + access_log off; + } + + location / { + access_log off; + + include proxy_params; + proxy_send_timeout 600; + proxy_read_timeout 600; + proxy_pass http://127.0.0.1:5173; + } +} diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..2551a53 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,53 @@ +alembic==1.11.1 +anyio==3.6.2 +asttokens==2.2.1 +backcall==0.2.0 +certifi==2023.5.7 +click==8.1.3 +decorator==5.1.1 +dnspython==2.3.0 +email-validator==2.0.0.post2 +executing==1.2.0 +fastapi==0.95.2 +greenlet==2.0.2 +h11==0.14.0 +httpcore==0.17.1 +httptools==0.5.0 +httpx==0.24.1 +idna==3.4 +ipython==8.13.2 +itsdangerous==2.1.2 +jedi==0.18.2 +Jinja2==3.1.2 +Mako==1.2.4 +MarkupSafe==2.1.2 +matplotlib-inline==0.1.6 +orjson==3.8.12 +parso==0.8.3 +pexpect==4.8.0 +pickleshare==0.7.5 +prompt-toolkit==3.0.38 +psycopg2-binary==2.9.6 +ptyprocess==0.7.0 +pure-eval==0.2.2 +pydantic==1.10.7 +Pygments==2.15.1 +python-dotenv==1.0.0 +python-multipart==0.0.6 +PyYAML==6.0 +rfc3986==2.0.0 +six==1.16.0 +sniffio==1.3.0 +SQLAlchemy==2.0.15 +stack-data==0.6.2 +starlette==0.27.0 +traitlets==5.9.0 +typing_extensions==4.5.0 +ujson==5.7.0 +uvicorn==0.22.0 +uvloop==0.17.0 +watchfiles==0.19.0 +wcwidth==0.2.6 +websockets==11.0.3 +click +pysnooper