From a85ba8a97f39b510379089edcf9c2c1000692d20 Mon Sep 17 00:00:00 2001
From: Pavel Kirilin <win10@list.ru>
Date: Thu, 5 Nov 2020 02:45:02 +0400
Subject: [PATCH] Added ElasticSearch. Description: - Added Elastic mixin; -
 Added DumyDB model elastic logic; - Fixed scalar bug; - Removed conf endpoint
 for dev.

Signed-off-by: Pavel Kirilin <win10@list.ru>
---
 cookiecutter.json                             |  5 ++
 hooks/post_gen_project.py                     |  2 +-
 .../conditional_files.json                    | 15 +++-
 .../docker-compose.prod.yml                   | 18 +++-
 .../docker-compose.yml                        | 18 +++-
 {{cookiecutter.project_name}}/envs/.env       |  3 +
 .../envs/example.env                          |  3 +
 {{cookiecutter.project_name}}/envs/test.env   |  8 +-
 .../7ae297ab5ac1_created_dummy_model.py       | 19 +++--
 {{cookiecutter.project_name}}/pyproject.toml  |  4 +
 .../src/api/dummy_db/routes.py                | 40 ++++++++-
 .../src/api/dummy_db/schema.py                | 13 ++-
 .../src/models/dummy_db_model.py              | 28 +++++--
 {{cookiecutter.project_name}}/src/server.py   |  8 --
 .../src/services/db/session.py                |  2 +-
 .../src/services/elastic/__init__.py          |  6 ++
 .../src/services/elastic/client.py            |  4 +
 .../src/services/elastic/mixin.py             | 84 +++++++++++++++++++
 .../src/services/elastic/schema.py            | 15 ++++
 {{cookiecutter.project_name}}/src/settings.py |  3 +
 20 files changed, 261 insertions(+), 37 deletions(-)

diff --git a/cookiecutter.json b/cookiecutter.json
index c1e99d6..d56cffb 100644
--- a/cookiecutter.json
+++ b/cookiecutter.json
@@ -3,6 +3,7 @@
   "full_name": "Pavel Kirilin",
   "email": "win10@list.ru",
   "project_description": "",
+  "default_port": 8401,
   "add_redis": [
     true,
     false
@@ -15,6 +16,10 @@
     true,
     false
   ],
+  "add_elastic": [
+    true,
+    false
+  ],
   "add_scheduler": [
     true,
     false
diff --git a/hooks/post_gen_project.py b/hooks/post_gen_project.py
index 5036290..2715205 100644
--- a/hooks/post_gen_project.py
+++ b/hooks/post_gen_project.py
@@ -24,7 +24,7 @@ def delete_resources_for_disabled_features():
     with open(MANIFEST) as manifest_file:
         manifest = json.load(manifest_file)
         for feature in manifest['features']:
-            if not feature['enabled']:
+            if not feature['enabled'] == "true":
                 print("removing resources for disabled feature {}...".format(feature['name']))
                 for resource in feature['resources']:
                     delete_resource(resource)
diff --git a/{{cookiecutter.project_name}}/conditional_files.json b/{{cookiecutter.project_name}}/conditional_files.json
index 0186768..d755a9d 100644
--- a/{{cookiecutter.project_name}}/conditional_files.json
+++ b/{{cookiecutter.project_name}}/conditional_files.json
@@ -2,7 +2,7 @@
   "features": [
     {
       "name": "Redis support",
-      "enabled": {{cookiecutter.add_redis|lower}},
+      "enabled": "{{cookiecutter.add_redis|lower}}",
       "resources": [
         "src/services/redis.py",
         "src/api/redis_api"
@@ -10,7 +10,7 @@
     },
     {
       "name": "Task scheduler",
-      "enabled": {{cookiecutter.add_scheduler|lower}},
+      "enabled": "{{cookiecutter.add_scheduler|lower}}",
       "resources": [
         "scheduler.py",
         "systemd/{{ cookiecutter.project_name }}_scheduler.service"
@@ -18,20 +18,27 @@
     },
     {
       "name": "Systemd support",
-      "enabled": {{cookiecutter.add_systemd|lower}},
+      "enabled": "{{cookiecutter.add_systemd|lower}}",
       "resources": [
         "systemd"
       ]
     },
     {
       "name": "Dummy DB model",
-      "enabled": {{cookiecutter.add_dummy_model|lower}},
+      "enabled": "{{cookiecutter.add_dummy_model|lower}}",
       "resources": [
         "src/models/dummy_db_model.py",
         "migrations/versions/7ae297ab5ac1_created_dummy_model.py",
         "tests/dummy_db_test.py",
         "src/api/dummy_db"
       ]
+    },
+    {
+      "name": "Elastic search",
+      "enabled": "{{cookiecutter.add_elastic|lower}}",
+      "resources": [
+        "src/services/elastic"
+      ]
     }
   ]
 }
\ No newline at end of file
diff --git a/{{cookiecutter.project_name}}/docker-compose.prod.yml b/{{cookiecutter.project_name}}/docker-compose.prod.yml
index 39cefc8..1ed7b83 100644
--- a/{{cookiecutter.project_name}}/docker-compose.prod.yml
+++ b/{{cookiecutter.project_name}}/docker-compose.prod.yml
@@ -12,8 +12,6 @@ services:
         {{ cookiecutter.project_name }}.description: {{ cookiecutter.project_description }}
     env_file:
       - envs/.env
-    ports:
-      - 8402:8000
     depends_on:
       - db
     networks:
@@ -59,9 +57,25 @@ services:
       - {{ cookiecutter.project_name }}_network
   {% endif %}
 
+  {% if cookiecutter.add_elastic == "True" -%}
+  es:
+    restart: always
+    container_name: {{ cookiecutter.project_name }}_es_dev
+    image: elasticsearch:7.3.0
+    volumes:
+      - {{ cookiecutter.project_name }}_es_data_dev:/usr/share/elasticsearch/data
+    environment:
+      - discovery.type=single-node
+      - bootstrap.memory_lock=true
+      - "ES_JAVA_OPTS=-Xms512m -Xmx512m"
+    networks:
+      - {{ cookiecutter.project_name }}_network_dev
+  {% endif %}
+
 volumes:
   {{ cookiecutter.project_name }}_db_data:
   {{ cookiecutter.project_name }}_redis_data:
+  {{ cookiecutter.project_name }}_es_data_dev:
 
 networks:
   {{ cookiecutter.project_name }}_network:
diff --git a/{{cookiecutter.project_name}}/docker-compose.yml b/{{cookiecutter.project_name}}/docker-compose.yml
index c9821bf..8888331 100644
--- a/{{cookiecutter.project_name}}/docker-compose.yml
+++ b/{{cookiecutter.project_name}}/docker-compose.yml
@@ -13,7 +13,7 @@ services:
     env_file:
       - envs/.env
     ports:
-      - 8401:8000
+      - {{ cookiecutter.default_port }}:8000
     volumes:
       - ./:/app
     depends_on:
@@ -65,9 +65,25 @@ services:
       - {{ cookiecutter.project_name }}_network_dev
   {% endif %}
 
+  {% if cookiecutter.add_elastic == "True" -%}
+  es:
+    restart: always
+    container_name: {{ cookiecutter.project_name }}_es_dev
+    image: elasticsearch:7.3.0
+    volumes:
+      - {{ cookiecutter.project_name }}_es_data_dev:/usr/share/elasticsearch/data
+    environment:
+      - discovery.type=single-node
+      - bootstrap.memory_lock=true
+      - "ES_JAVA_OPTS=-Xms512m -Xmx512m"
+    networks:
+      - {{ cookiecutter.project_name }}_network_dev
+  {% endif %}
+
 volumes:
   {{ cookiecutter.project_name }}_db_data_dev:
   {{ cookiecutter.project_name }}_redis_data_dev:
+  {{ cookiecutter.project_name }}_es_data_dev:
 
 networks:
   {{ cookiecutter.project_name }}_network_dev:
diff --git a/{{cookiecutter.project_name}}/envs/.env b/{{cookiecutter.project_name}}/envs/.env
index 16929a7..9a9fab0 100644
--- a/{{cookiecutter.project_name}}/envs/.env
+++ b/{{cookiecutter.project_name}}/envs/.env
@@ -11,4 +11,7 @@ REDIS_PORT=6379
 {% endif %}
 {% if cookiecutter.add_scheduler == "True" -%}
 SCHEDULE_TIMER=20
+{% endif %}
+{% if cookiecutter.add_elastic == "True" -%}
+ELASTIC_HOST=http://es:9200
 {% endif %}
\ No newline at end of file
diff --git a/{{cookiecutter.project_name}}/envs/example.env b/{{cookiecutter.project_name}}/envs/example.env
index 187bc79..469d2c6 100644
--- a/{{cookiecutter.project_name}}/envs/example.env
+++ b/{{cookiecutter.project_name}}/envs/example.env
@@ -12,4 +12,7 @@ REDIS_PORT=6379
 HTTPBIN_HOST=https://httpbin.org/
 {% if cookiecutter.add_scheduler == "True" -%}
 SCHEDULE_TIMER=20
+{% endif %}
+{% if cookiecutter.add_elastic == "True" -%}
+ELASTIC_HOST=http://es:9200
 {% endif %}
\ No newline at end of file
diff --git a/{{cookiecutter.project_name}}/envs/test.env b/{{cookiecutter.project_name}}/envs/test.env
index 649a738..8cc07f1 100644
--- a/{{cookiecutter.project_name}}/envs/test.env
+++ b/{{cookiecutter.project_name}}/envs/test.env
@@ -9,4 +9,10 @@ REDIS_PASSWORD={{cookiecutter.redis_password}}
 REDIS_HOST=localhost
 REDIS_PORT=6379
 {% endif %}
-HTTPBIN_HOST=https://httpbin.org/
\ No newline at end of file
+HTTPBIN_HOST=https://httpbin.org/
+{% if cookiecutter.add_scheduler == "True" -%}
+SCHEDULE_TIMER=20
+{% endif %}
+{% if cookiecutter.add_elastic == "True" -%}
+ELASTIC_HOST=http://es:9200
+{% endif %}
\ No newline at end of file
diff --git a/{{cookiecutter.project_name}}/migrations/versions/7ae297ab5ac1_created_dummy_model.py b/{{cookiecutter.project_name}}/migrations/versions/7ae297ab5ac1_created_dummy_model.py
index 00a6d9f..d55c371 100644
--- a/{{cookiecutter.project_name}}/migrations/versions/7ae297ab5ac1_created_dummy_model.py
+++ b/{{cookiecutter.project_name}}/migrations/versions/7ae297ab5ac1_created_dummy_model.py
@@ -5,8 +5,8 @@ Revises:
 Create Date: 2020-10-05 23:56:58.658606
 
 """
-from alembic import op
 import sqlalchemy as sa
+from alembic import op
 from sqlalchemy.dialects import postgresql
 
 # revision identifiers, used by Alembic.
@@ -19,13 +19,16 @@ depends_on = None
 def upgrade():
     # ### commands auto generated by Alembic - please adjust! ###
     op.create_table('dummydbmodel',
-    sa.Column('id', postgresql.UUID(as_uuid=True), nullable=False),
-    sa.Column('name', sa.String(), nullable=False),
-    sa.Column('surname', sa.String(), nullable=False),
-    sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('clock_timestamp()'), nullable=False),
-    sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('clock_timestamp()'), nullable=False),
-    sa.PrimaryKeyConstraint('id')
-    )
+                    sa.Column('id', postgresql.UUID(as_uuid=True), nullable=False),
+                    sa.Column('name', sa.String(), nullable=False),
+                    sa.Column('surname', sa.String(), nullable=False),
+                    {% if cookiecutter.add_elastic == "True" -%}sa.Column('tags', sa.String(), nullable=False, default=""),{% endif %}
+                    sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('clock_timestamp()'),
+                              nullable=False),
+                    sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('clock_timestamp()'),
+                              nullable=False),
+                    sa.PrimaryKeyConstraint('id')
+                    )
     op.create_index(op.f('ix_dummydbmodel_name'), 'dummydbmodel', ['name'], unique=False)
     op.create_index(op.f('ix_dummydbmodel_surname'), 'dummydbmodel', ['surname'], unique=False)
     # ### end Alembic commands ###
diff --git a/{{cookiecutter.project_name}}/pyproject.toml b/{{cookiecutter.project_name}}/pyproject.toml
index dfc579c..d4bcc2d 100644
--- a/{{cookiecutter.project_name}}/pyproject.toml
+++ b/{{cookiecutter.project_name}}/pyproject.toml
@@ -19,6 +19,10 @@ aiopg = "^1.0.0"
 {% if cookiecutter.add_redis == "True" -%}
 aioredis = "^1.3.1"
 {% endif %}
+{% if cookiecutter.add_elastic == "True" -%}
+elasticsearch-dsl = "^7.3.0"
+elasticsearch = {extras = ["async"], version = "^7.9.1"}
+{% endif %}
 {% if cookiecutter.add_scheduler == "True" -%}
 aioschedule = "^0.5.2"
 {% endif %}
diff --git a/{{cookiecutter.project_name}}/src/api/dummy_db/routes.py b/{{cookiecutter.project_name}}/src/api/dummy_db/routes.py
index 0efaf8c..12f7adc 100644
--- a/{{cookiecutter.project_name}}/src/api/dummy_db/routes.py
+++ b/{{cookiecutter.project_name}}/src/api/dummy_db/routes.py
@@ -4,10 +4,16 @@ from typing import Optional
 from fastapi import APIRouter, Depends
 
 from src.api.dummy_db.schema import (
-    BaseDummyModel,
     UpdateDummyModel,
-    GetDummyResponse
+    GetDummyResponse,
 )
+{% if cookiecutter.add_elastic == "True" -%}
+from src.api.dummy_db.schema import DummyElasticResponse, ElasticAdd
+from src.services.elastic.schema import ElasticFilterModel
+{%else%}
+from src.api.dummy_db.schema import BaseDummyModel
+{% endif %}
+
 from src.models import DummyDBModel
 from src.services.db.session import Session, db_session
 
@@ -16,9 +22,17 @@ URL_PREFIX = "/dummy_db_obj"
 
 
 @router.put("/")
-async def create_dummy(dummy_obj: BaseDummyModel, session: Session = Depends(db_session)) -> None:
+async def create_dummy(dummy_obj: {% if cookiecutter.add_elastic == "True" -%}ElasticAdd{% else %}BaseDummyModel{% endif %}, session: Session = Depends(db_session)) -> None:
+    {% if cookiecutter.add_elastic == "True" -%}
+    insert_query = DummyDBModel.create(**dummy_obj.dict()).returning(DummyDBModel.id)
+    model_id = await session.scalar(insert_query)
+    await DummyDBModel.elastic_add(
+        model_id=model_id,
+        **dummy_obj.dict()
+    )
+    {% else %}
     await session.execute(DummyDBModel.create(**dummy_obj.dict()))
-
+    {% endif %}
 
 @router.post("/{dummy_id}")
 async def update_dummy_model(
@@ -27,11 +41,20 @@ async def update_dummy_model(
         session: Session = Depends(db_session)
 ) -> None:
     await session.execute(DummyDBModel.update(dummy_id, **new_values.dict()))
+    {% if cookiecutter.add_elastic == "True" -%}
+    await DummyDBModel.elastic_update(
+        model_id=dummy_id,
+        **new_values.dict(exclude_unset=True)
+    )
+    {% endif %}
 
 
 @router.delete("/{dummy_id}")
 async def delete_dummy_model(dummy_id: uuid.UUID, session: Session = Depends(db_session)) -> None:
     await session.execute(DummyDBModel.delete(dummy_id))
+    {% if cookiecutter.add_elastic == "True" -%}
+    await DummyDBModel.elastic_delete(model_id=dummy_id)
+    {% endif %}
 
 
 @router.get("/", response_model=GetDummyResponse)
@@ -50,3 +73,12 @@ async def filter_dummy_models(
     return GetDummyResponse(
         results=results
     )
+
+{% if cookiecutter.add_elastic == "True" -%}
+@router.get("/elastic", response_model=DummyElasticResponse)
+async def dummy_elastic_filter(query: ElasticFilterModel = Depends(ElasticFilterModel)) -> DummyElasticResponse:
+    results = await DummyDBModel.elastic_filter(**query.dict())
+    return DummyElasticResponse(
+        results=results
+    )
+{% endif %}
diff --git a/{{cookiecutter.project_name}}/src/api/dummy_db/schema.py b/{{cookiecutter.project_name}}/src/api/dummy_db/schema.py
index 08778b6..d367ecb 100644
--- a/{{cookiecutter.project_name}}/src/api/dummy_db/schema.py
+++ b/{{cookiecutter.project_name}}/src/api/dummy_db/schema.py
@@ -1,7 +1,7 @@
 import uuid
 from datetime import datetime
 from typing import Optional, List
-
+from src.models.dummy_db_model import DummyElasticFilter
 from pydantic import Field, BaseConfig
 from pydantic.main import BaseModel
 
@@ -23,11 +23,20 @@ class ReturnDummyModel(BaseDummyModel):
 class GetDummyResponse(BaseModel):
     results: List[ReturnDummyModel]
 
+{% if cookiecutter.add_elastic == "True" -%}
+class DummyElasticResponse(BaseModel):
+    results: List[DummyElasticFilter]
+
+class ElasticAdd(BaseDummyModel):
+    tags: str = Field(default="")
+{% endif %}
 
 class UpdateDummyModel(BaseModel):
     name: Optional[str] = Field(default=None, example="New name")
     surname: Optional[str] = Field(default=None, example="New surname")
-
+    {% if cookiecutter.add_elastic == "True" -%}
+    tags: Optional[str] = Field(default=None, example="tag1,tag2")
+    {% endif %}
 
 class DummyFiltersModel(BaseModel):
     dummy_id: Optional[uuid.UUID] = Field(default=None)
diff --git a/{{cookiecutter.project_name}}/src/models/dummy_db_model.py b/{{cookiecutter.project_name}}/src/models/dummy_db_model.py
index 2e188cf..90215f4 100644
--- a/{{cookiecutter.project_name}}/src/models/dummy_db_model.py
+++ b/{{cookiecutter.project_name}}/src/models/dummy_db_model.py
@@ -2,15 +2,28 @@ import uuid
 from typing import Optional
 
 from sqlalchemy import Column, String, sql
-from sqlalchemy.dialects.postgresql import UUID
-
+{% if cookiecutter.add_elastic == "True" -%}
+from src.services.elastic import ElasticModelMixin
+from src.services.elastic.schema import ESReturnModel
+{% endif %}
 from src.services.db import Base
 
+{% if cookiecutter.add_elastic == "True" -%}
+class DummyElasticFilter(ESReturnModel):
+    name: str
+    surname: str
+{% endif %}
 
-class DummyDBModel(Base):
-    id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
+class DummyDBModel(Base{% if cookiecutter.add_elastic == "True" -%}, ElasticModelMixin[DummyElasticFilter]{% endif %}):
     name = Column(String, nullable=False, index=True)
     surname = Column(String, nullable=False, index=True)
+    {% if cookiecutter.add_elastic == "True" -%}
+    tags = Column(String, nullable=False, default="")
+
+    __es_index_name = "dummy_elastic_index"
+    __es_search_fields = ["name", "surname"]
+    __es_search_type = DummyElasticFilter
+    {% endif %}
 
     @classmethod
     def create(
@@ -18,10 +31,12 @@ class DummyDBModel(Base):
             *,
             name: str,
             surname: str,
+            {% if cookiecutter.add_elastic == "True" -%}tags: str = "",{% endif %}
     ) -> sql.Insert:
         return cls.insert_query(
             name=name,
             surname=surname,
+            {% if cookiecutter.add_elastic == "True" -%}tags=tags,{% endif %}
         )
 
     @classmethod
@@ -33,13 +48,16 @@ class DummyDBModel(Base):
                dummy_id: uuid.UUID,
                *,
                name: Optional[str] = None,
-               surname: Optional[str] = None
+               surname: Optional[str] = None,
+               {% if cookiecutter.add_elastic == "True" -%}tags: Optional[str] = None,{% endif %}
                ) -> sql.Update:
         new_values = {}
         if name:
             new_values[cls.name] = name
         if surname:
             new_values[cls.surname] = surname
+        if tags is not None:
+            new_values[cls.tags] = tags
         return cls.update_query().where(cls.id == dummy_id).values(new_values)
 
     @classmethod
diff --git a/{{cookiecutter.project_name}}/src/server.py b/{{cookiecutter.project_name}}/src/server.py
index f451117..6dd3c51 100644
--- a/{{cookiecutter.project_name}}/src/server.py
+++ b/{{cookiecutter.project_name}}/src/server.py
@@ -68,11 +68,3 @@ if settings.is_dev:
             "head",
         ]
     )
-
-    @app.get("/conf")
-    def reveal_current_configuration() -> Settings:
-        """
-        Show current application settings
-        ## Available only under development
-        """
-        return settings
\ No newline at end of file
diff --git a/{{cookiecutter.project_name}}/src/services/db/session.py b/{{cookiecutter.project_name}}/src/services/db/session.py
index 34d15ab..cfb036e 100644
--- a/{{cookiecutter.project_name}}/src/services/db/session.py
+++ b/{{cookiecutter.project_name}}/src/services/db/session.py
@@ -14,7 +14,7 @@ class Session:
 
     async def fetchone(self, query: Any) -> Any:
         cursor = await self.connection.execute(query)
-        return cursor.fetchone()
+        return await cursor.fetchone()
 
     async def scalar(self, query: Any) -> Any:
         result = await self.fetchone(query)
diff --git a/{{cookiecutter.project_name}}/src/services/elastic/__init__.py b/{{cookiecutter.project_name}}/src/services/elastic/__init__.py
index e69de29..9c2b1be 100644
--- a/{{cookiecutter.project_name}}/src/services/elastic/__init__.py
+++ b/{{cookiecutter.project_name}}/src/services/elastic/__init__.py
@@ -0,0 +1,6 @@
+from src.services.elastic.mixin import ElasticModelMixin
+
+__all__ = [
+    'ElasticModelMixin',
+    'schema'
+]
diff --git a/{{cookiecutter.project_name}}/src/services/elastic/client.py b/{{cookiecutter.project_name}}/src/services/elastic/client.py
index e69de29..8f89602 100644
--- a/{{cookiecutter.project_name}}/src/services/elastic/client.py
+++ b/{{cookiecutter.project_name}}/src/services/elastic/client.py
@@ -0,0 +1,4 @@
+from elasticsearch import AsyncElasticsearch
+from src.settings import settings
+
+elastic_client = AsyncElasticsearch(hosts=[settings.elastic_host])
diff --git a/{{cookiecutter.project_name}}/src/services/elastic/mixin.py b/{{cookiecutter.project_name}}/src/services/elastic/mixin.py
index e69de29..ec33a44 100644
--- a/{{cookiecutter.project_name}}/src/services/elastic/mixin.py
+++ b/{{cookiecutter.project_name}}/src/services/elastic/mixin.py
@@ -0,0 +1,84 @@
+import uuid
+from typing import TypeVar, List, Optional, Any, Generic, Union, Dict
+
+from elasticsearch import NotFoundError
+from elasticsearch_dsl import Search
+from elasticsearch_dsl.query import MultiMatch
+from loguru import logger
+
+from src.services.elastic.client import elastic_client
+from src.services.elastic.schema import ESReturnModel
+
+SearchModel = TypeVar("SearchModel", covariant=True, bound=ESReturnModel)
+
+
+class ElasticModelMixin(Generic[SearchModel]):
+    __es_index_name: str = "default_index"
+    __es_search_fields: List[str] = []
+    __es_search_type: Optional[SearchModel] = None
+
+    @classmethod
+    async def elastic_add(
+        cls, model_id: uuid.UUID, *, tags: Optional[str] = "", **kwargs: Any
+    ) -> None:
+        ret = await elastic_client.index(
+            index=cls.__es_index_name,
+            body={"tags": tags.split(",") if tags else [], **kwargs},
+            id=str(model_id),
+        )
+        result = ret["result"]
+        logger.debug(f"{result} '{cls.__name__}:{model_id}' in elastic")
+
+    @classmethod
+    async def elastic_filter(
+        cls, *, query: str, offset: int, limit: int
+    ) -> List[Union[SearchModel, Dict[str, Any]]]:
+        elastic_query = Search()
+        if query:
+            elastic_query = elastic_query.query(
+                MultiMatch(
+                    type="phrase_prefix", query=query, fields=cls.__es_search_fields
+                )
+            )
+        elastic_query = elastic_query[offset : offset + limit]
+        search_res = await elastic_client.search(elastic_query.to_dict())
+        hits = search_res.get("hits", {}).get("hits", [])
+        results = []
+        constructor = cls.__es_search_type or dict
+        for hit in hits:
+            logger.debug(hit)
+            results.append(constructor(id=hit.get("_id"), **hit.get("_source", {})))
+        return results
+
+    @classmethod
+    async def elastic_update(
+        cls,
+        model_id: uuid.UUID,
+        **body: Any,
+    ) -> None:
+        tags = body.get("tags", None)
+        if tags is not None:
+            body["tags"] = tags.split(",") if tags else []
+        logger.debug(f"Updating '{cls.__name__}:{model_id}' in ES")
+        try:
+            await elastic_client.update(
+                index=cls.__es_index_name,
+                id=str(model_id),
+                body={"doc": body},
+                refresh=True,
+            )
+        except NotFoundError:
+            logger.debug(f"Can't update unknown {cls.__name__} in es")
+
+    @classmethod
+    async def elastic_delete(cls, model_id: uuid.UUID) -> None:
+        try:
+            await elastic_client.delete(index=cls.__es_index_name, id=str(model_id))
+        except NotFoundError:
+            logger.debug(f"'{cls.__name__}:{model_id}' was not found in ES")
+
+    @classmethod
+    async def elastic_create_index(cls) -> None:
+        if not await elastic_client.indices.exists(cls.__es_index_name):
+            logger.debug(f"Creating elastic index {cls.__es_index_name}")
+            await elastic_client.indices.create(cls.__es_index_name)
diff --git a/{{cookiecutter.project_name}}/src/services/elastic/schema.py b/{{cookiecutter.project_name}}/src/services/elastic/schema.py
index e69de29..1b70c8d 100644
--- a/{{cookiecutter.project_name}}/src/services/elastic/schema.py
+++ b/{{cookiecutter.project_name}}/src/services/elastic/schema.py
@@ -0,0 +1,15 @@
+import uuid
+from typing import Optional, List
+
+from pydantic import Field, BaseModel
+
+
+class ESReturnModel(BaseModel):
+    id: uuid.UUID
+    tags: Optional[List[str]]
+
+
+class ElasticFilterModel(BaseModel):
+    query: str = Field(default="")
+    limit: int = Field(default=100, lt=400)
+    offset: int = Field(default=0)
diff --git a/{{cookiecutter.project_name}}/src/settings.py b/{{cookiecutter.project_name}}/src/settings.py
index 438f704..8b66172 100644
--- a/{{cookiecutter.project_name}}/src/settings.py
+++ b/{{cookiecutter.project_name}}/src/settings.py
@@ -32,6 +32,9 @@ class Settings(BaseSettings):
     {% endif %}
     # httpbin client settings
     httpbin_host: str = Field(default="https://httpbin.org/")
+    {% if cookiecutter.add_elastic == "True" -%}
+    elastic_host: str = Field(...)
+    {% endif %}
 
     @property
     def is_dev(self) -> bool:
-- 
GitLab