commit 7690d9e1b7fa682a216cfa7da310b88a7b82907b Author: Ernest Litvinenko Date: Mon Nov 13 07:38:01 2023 +0300 Initial diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2dc53ca --- /dev/null +++ b/.gitignore @@ -0,0 +1,160 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +#poetry.lock + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +#pdm.lock +# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it +# in version control. +# https://pdm.fming.dev/#use-with-ide +.pdm.toml + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +.idea/ diff --git a/core/__init__.py b/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/handlers/__init__.py b/core/handlers/__init__.py new file mode 100644 index 0000000..400595f --- /dev/null +++ b/core/handlers/__init__.py @@ -0,0 +1,11 @@ +from fastapi import APIRouter + +# Import Routers +from .office import office_router +from .profile import profile_router + +router = APIRouter(prefix='/api/v2') + +# Including Routers +router.include_router(office_router) +router.include_router(profile_router) diff --git a/core/handlers/office/__init__.py b/core/handlers/office/__init__.py new file mode 100644 index 0000000..f8692b4 --- /dev/null +++ b/core/handlers/office/__init__.py @@ -0,0 +1 @@ +from .handlers import router as office_router \ No newline at end of file diff --git a/core/handlers/office/handlers.py b/core/handlers/office/handlers.py new file mode 100644 index 0000000..5f1273f --- /dev/null +++ b/core/handlers/office/handlers.py @@ -0,0 +1,18 @@ +from fastapi import APIRouter, Depends + +from core.models.office.requests import UpdateOfficeRequest +from core.models.office.responses import JdeOfficeDetailResponse +from core.services.office import services + +router = APIRouter(prefix='/offices') + + +@router.get('/offices') +async def list_offices(offices: list[JdeOfficeDetailResponse] = Depends(services.list_offices_service)) -> list[ + JdeOfficeDetailResponse]: + return offices + + +@router.post('/offices') +async def update_office(data: UpdateOfficeRequest): + await services.update_office(data) diff --git a/core/handlers/profile/__init__.py b/core/handlers/profile/__init__.py new file mode 100644 index 0000000..1ebffac --- /dev/null +++ b/core/handlers/profile/__init__.py @@ -0,0 +1 @@ +from .handlers import router as profile_router diff --git a/core/handlers/profile/handlers.py b/core/handlers/profile/handlers.py new file mode 100644 index 0000000..0bdb20d --- /dev/null +++ b/core/handlers/profile/handlers.py @@ -0,0 +1,30 @@ +from fastapi import APIRouter, Path + +router = APIRouter(prefix='/profiles') + + +# todo implement this handlers in services and storages. Then use fastapi.Depends() + +@router.post('/') +async def create_profile(): + pass + + +@router.get('/') +async def list_profiles(): + pass + + +@router.get('/{profile_id}') +async def get_profile(profile_id: int = Path()): + pass + + +@router.delete('/{profile_id}') +async def delete_profile(profile_id: int = Path()): + pass + + +@router.put('/{profile_id}') +async def update_profile(profile_id: int = Path()): + pass diff --git a/core/models/__init__.py b/core/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/models/office/__init__.py b/core/models/office/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/models/office/db.py b/core/models/office/db.py new file mode 100644 index 0000000..9df668d --- /dev/null +++ b/core/models/office/db.py @@ -0,0 +1,38 @@ +from pydantic import BaseModel + + +class JdeOfficeDB(BaseModel): + code: str + title: str + kladr_code: str + aex_only: str + mst_pr_aex: str + mst_pr_virt: str + addr: str + features: str + coords: dict[str, str] + city: str + country_code: str + contry_name: str + max_ves: str + max_obyom: str + max_ves_gm: str + max_obyom_gm: str + max_l_gm: str + max_w_gm: str + max_h_gm: str + + +class JdeOfficeLocalDB(BaseModel): + code: str + features: str | None = None + contact_person_id: int | None = None + person_count: int | None = None + rating: int | None = None + + +class ProfileDB(BaseModel): + id: int + full_name: str | None + phone: str | None + email: str | None diff --git a/core/models/office/domain.py b/core/models/office/domain.py new file mode 100644 index 0000000..e69de29 diff --git a/core/models/office/enums.py b/core/models/office/enums.py new file mode 100644 index 0000000..e69de29 diff --git a/core/models/office/requests.py b/core/models/office/requests.py new file mode 100644 index 0000000..c079158 --- /dev/null +++ b/core/models/office/requests.py @@ -0,0 +1,16 @@ +from pydantic import BaseModel + + +class UpdateOfficeRequest(BaseModel): + code: int + features: str | None + contact_person_id: int | None + person_count: int | None + rating: int | None + + +class CreateOrUpdateProfileRequest(BaseModel): + id: int | None + full_name: str + phone: str | None + email: str | None diff --git a/core/models/office/responses.py b/core/models/office/responses.py new file mode 100644 index 0000000..09a9c6c --- /dev/null +++ b/core/models/office/responses.py @@ -0,0 +1,17 @@ +from pydantic import BaseModel, EmailStr +from .db import ProfileDB, JdeOfficeDB + + +class ProfileResponse(ProfileDB): + email: EmailStr | None + + +class ExtendedResponse(BaseModel): + features: str | None + contact_person: ProfileResponse | None + person_count: int | None + rating: int | None + + +class JdeOfficeDetailResponse(JdeOfficeDB): + changeable_info: ExtendedResponse | None diff --git a/core/registry.py b/core/registry.py new file mode 100644 index 0000000..a47f6c2 --- /dev/null +++ b/core/registry.py @@ -0,0 +1,9 @@ +from database import Database +from .storages.profile_storage import ProfileStorage +from .storages.office_storage import OfficeStorage + + +db = Database() + +profile_storage = ProfileStorage(db) +office_storage = OfficeStorage(db) diff --git a/core/services/__init__.py b/core/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/services/office/__init__.py b/core/services/office/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/services/office/helpers.py b/core/services/office/helpers.py new file mode 100644 index 0000000..53a1e90 --- /dev/null +++ b/core/services/office/helpers.py @@ -0,0 +1,9 @@ +import httpx + +from core.models.office.db import JdeOfficeDB + + +async def grab_data() -> list[JdeOfficeDB]: + async with httpx.AsyncClient() as client: + response = await client.get('http://localhost/api/v1/vD/geo/search?mode=2') + return [JdeOfficeDB.model_validate(x) for x in response.json()] \ No newline at end of file diff --git a/core/services/office/services.py b/core/services/office/services.py new file mode 100644 index 0000000..f7ba035 --- /dev/null +++ b/core/services/office/services.py @@ -0,0 +1,19 @@ +from .helpers import grab_data +from core.registry import office_storage +from core.models.office.responses import JdeOfficeDetailResponse +from ...models.office.requests import UpdateOfficeRequest + + +async def list_offices_service() -> list[JdeOfficeDetailResponse]: + offices = await grab_data() + offices.sort(key=lambda x: x.code) + offices_additional_data = {data.code: data for data in office_storage.list_all_offices()} + response_data = [ + JdeOfficeDetailResponse(**office.model_dump(), changeable_info=None or offices_additional_data.get(office.code)) for office in offices + ] + + return response_data + + +async def update_office(data: UpdateOfficeRequest): + office_storage.update_office(data) diff --git a/core/storages/__init__.py b/core/storages/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/storages/base_storage.py b/core/storages/base_storage.py new file mode 100644 index 0000000..9c2cd03 --- /dev/null +++ b/core/storages/base_storage.py @@ -0,0 +1,28 @@ +from database import Database + + +class BaseStorage: + + def __init__(self, db: Database): + self.db = db + + def __fetch_all(self, sql, *args): + return self.db.fetch_all(sql, *args) + + def __fetch_one(self, sql, *args): + return self.db.fetch_one(sql, *args) + + def __form_query(self, table_name: str, kwargs) -> str: + sql = f'select * from {table_name}' + + if len(kwargs) != 0: + filters = [f"{key}='{val}'" for key, val in kwargs.items()] + sql = f"select * from {table_name} where {' and '.join(filters)}" + + return sql + + def get_by(self, table_name: str, **kwargs): + return self.__fetch_one(self.__form_query(table_name, kwargs)) + + def list_by(self, table_name: str, **kwargs): + return self.__fetch_all(self.__form_query(table_name, kwargs)) diff --git a/core/storages/office_storage.py b/core/storages/office_storage.py new file mode 100644 index 0000000..35aa939 --- /dev/null +++ b/core/storages/office_storage.py @@ -0,0 +1,34 @@ +from fastapi.exceptions import HTTPException +from .base_storage import BaseStorage +from core.models.office.db import JdeOfficeLocalDB +from core.models.office.requests import UpdateOfficeRequest + + +class OfficeStorage(BaseStorage): + + def get_by_id(self, idx: int) -> JdeOfficeLocalDB: + row = self.get_by('office', code=idx) + if row is None: + raise HTTPException(status_code=404, detail='Office not found') + office = JdeOfficeLocalDB(code=row[0], features=row[1], contact_person_id=row[2], person_count=row[3], + rating=row[4]) + return office + + def list_all_offices(self): + rows = self.list_by('office') + return [ + JdeOfficeLocalDB(code=row[0], + features=row[1], + contact_person_id=row[2], + person_count=row[3], + rating=row[4]) + for row in rows] + + def update_office(self, data: UpdateOfficeRequest): + try: + self.get_by_id(data.code) + sql = f"update office set features = %s, contact_person_id = %s, person_count = %s, rating = %s where code = %s" + self.db.execute(sql, (data.features, data.contact_person_id, data.person_count, data.rating, data.code)) + except HTTPException as _: + sql = f"insert into office (code, features, contact_person_id, person_count, rating) values (%s, %s, %s, %s, %s)" + self.db.execute(sql, (data.code, data.features, data.contact_person_id, data.person_count, data.rating)) diff --git a/core/storages/profile_storage.py b/core/storages/profile_storage.py new file mode 100644 index 0000000..bc9b0fb --- /dev/null +++ b/core/storages/profile_storage.py @@ -0,0 +1,30 @@ +from fastapi import HTTPException + + +from .base_storage import BaseStorage +from core.models.office.db import ProfileDB +from core.models.office.requests import CreateOrUpdateProfileRequest + + +class ProfileStorage(BaseStorage): + + def get_by_id(self, id: int): + row = self.get_by('profile', id=id) + if row is None: + raise HTTPException(status_code=404, detail='Profile not found') + return ProfileDB(id=row[0], full_name=row[1], phone=row[2], email=row[3]) + + def create_or_update_profile(self, data: CreateOrUpdateProfileRequest): + try: + if not data.id: + raise KeyError('No id provided') + self.get_by_id(data.id) + sql = f"update profile set full_name = %s, phone = %s, email = %s where id = %s" + self.db.execute(sql, (data.full_name, data.phone, data.email, data.id)) + except (HTTPException, KeyError) as _: + sql = f"insert into profile (full_name, phone, email) values (%s, %s, %s)" + self.db.execute(sql, (data.full_name, data.phone, data.email)) + + def list_all_profiles(self): + rows = self.list_by('profile') + return [ProfileDB(id=row[0], full_name=row[1], phone=row[2], email=row[3]) for row in rows] diff --git a/database.py b/database.py new file mode 100644 index 0000000..679ee35 --- /dev/null +++ b/database.py @@ -0,0 +1,59 @@ +import typing + +import pymysql + + +class Database: + __instance: 'Database' = None + __client: pymysql.Connection = None + + def __new__(cls, *args, **kwargs): + if cls.__instance is None: + cls.__instance = super().__new__(cls) + return cls.__instance + + def __init__(self): + self.__create_con() + + def __create_con(self): + # TODO Set to environment variables. Use pydantic-settings + self.__client = pymysql.connect( + host='127.0.0.1', + port=3306, + user='root', + password='root', + db='jde', + charset='utf8' + ) + + def __execute_cmd(self, wrapper, *args, **kwargs): + with self.__client.cursor() as cursor: + data = wrapper(cursor, *args, **kwargs) + self.__client.commit() + return data + + @classmethod + def _fetch_all(cls, cursor: pymysql.cursors.Cursor, sql: str, *args) -> list: + #todo research formatting input values in sql queries. Which type of args? + cursor.execute(sql, *args) + return cursor.fetchall() + + @classmethod + def _fetch_one(cls, cursor: pymysql.cursors.Cursor, sql: str, *args) -> typing.Any: + #todo research formatting input values in sql queries. Which type of args? + cursor.execute(sql, *args) + return cursor.fetchone() + + @classmethod + def _execute(cls, cursor: pymysql.cursors.Cursor, sql: str, *args) -> None: + #todo research formatting input values in sql queries. Which type of args? + cursor.execute(sql, *args) + + def fetch_all(self, sql: str, *args) -> list: + return self.__execute_cmd(self._fetch_all, sql, *args) + + def fetch_one(self, sql: str, *args) -> typing.Any | None: + return self.__execute_cmd(self._fetch_one, sql, *args) + + def execute(self, sql: str, *args) -> None: + return self.__execute_cmd(self._execute, sql, *args) diff --git a/docs/ERD.drawio b/docs/ERD.drawio new file mode 100644 index 0000000..dab80e8 --- /dev/null +++ b/docs/ERD.drawio @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/main.py b/main.py new file mode 100644 index 0000000..47c0fb6 --- /dev/null +++ b/main.py @@ -0,0 +1,23 @@ +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware +import uvicorn +from core.handlers import router + + +app = FastAPI() + +app.add_middleware( + CORSMiddleware, + allow_origins=['*'] +) + +app.include_router(router) + + +@app.on_event("startup") +async def startup(): + print("startup") + + +if __name__ == '__main__': + uvicorn.run('main:app', host='0.0.0.0', port=8000, reload=True) diff --git a/migrations/initial.sql b/migrations/initial.sql new file mode 100644 index 0000000..ac99507 --- /dev/null +++ b/migrations/initial.sql @@ -0,0 +1,21 @@ +BEGIN; +create database if not exists jde; +use jde; + + +create table if not exists profile ( + id int8 primary key auto_increment, + full_name varchar(64), + phone varchar(30), + email varchar(64) +); + +create table if not exists office ( + code int8 primary key auto_increment, + features text, + contact_person_id int8 references profile(id), + person_count int8 default 0, + rating int8 default 0 +); + +COMMIT; \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..e945257 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,19 @@ +annotated-types==0.6.0 +anyio==3.7.1 +certifi==2023.7.22 +click==8.1.7 +dnspython==2.4.2 +email-validator==2.1.0.post1 +fastapi==0.104.0 +h11==0.14.0 +httpcore==0.18.0 +httpx==0.25.0 +idna==3.4 +pydantic==2.4.2 +pydantic-to-typescript==1.0.10 +pydantic_core==2.10.1 +PyMySQL==1.1.0 +sniffio==1.3.0 +starlette==0.27.0 +typing_extensions==4.8.0 +uvicorn==0.23.2