From 4b0396466d6ac2cc888a6b11f57e2bdca6d80afd Mon Sep 17 00:00:00 2001 From: shahzeydyfah Date: Thu, 15 Feb 2024 00:17:51 +0330 Subject: [PATCH] Initial commit --- .gitignore | 54 ++ .gitmodules | 4 + .pylintrc | 406 +++++++++++++ Dockerfile | 28 + README.md | 4 + app/__init__.py | 0 app/config/__init__.py | 17 + app/models/__init__.py | 0 app/models/customer.py | 38 ++ app/models/project.py | 102 ++++ app/repositories/__init__.py | 0 app/repositories/project.py | 49 ++ app/urls/__init__.py | 1 + app/urls/project.py | 47 ++ auth/___test.py | 39 ++ auth/__init__.py | 1 + auth/dependencies/__init__.py | 0 auth/models/__init__.py | 2 + auth/models/auth.py | 242 ++++++++ auth/models/property.py | 16 + auth/models/roles.py | 22 + auth/models/units.py | 12 + auth/repositories/AuthRepository.py | 165 +++++ auth/repositories/PropertyRepository.py | 55 ++ auth/sdk/__init__.py | 54 ++ auth/sdk/config.py | 1 + auth/sdk/model.py | 26 + auth/sdk/schema.py | 9 + auth/services/auth_db.py | 45 ++ auth/services/front_router_guarde.py | 36 ++ auth/services/token.py | 0 auth/tasks/__init__.py | 29 + auth/urls/__init__.py | 3 + auth/urls/auth.py | 68 +++ auth/urls/role.py | 36 ++ auth/urls/user.py | 85 +++ auth/utils/__init__.py | 319 ++++++++++ auth/utils/ip_info.py | 6 + auth/utils/mongo.py | 14 + auth/utils/pwd.py | 11 + auth/utils/schema.py | 9 + config.py | 11 + config.yml | 79 +++ core/.gitignore | 47 ++ core/CHNAGELOG.md | 27 + core/README.md | 2 + core/__init__.py | 0 core/app/__init__.py | 112 ++++ core/app/context.py | 13 + core/app/docs.py | 26 + core/config.py | 129 ++++ core/exceptions/__init__.py | 1 + core/exceptions/errors.py | 81 +++ core/exceptions/model.py | 80 +++ core/middleware/__init__.py | 3 + core/middleware/auth.py | 17 + core/middleware/logger_middleware.py | 43 ++ core/middleware/prod_middleware.py | 20 + core/models/__init__.py | 0 core/models/base.py | 167 ++++++ core/models/object_id.py | 17 + core/models/table_engine.py | 7 + core/models/unit.py | 8 + core/modules/__init__.py | 0 core/modules/logger/__init__.py | 57 ++ core/repositories/base.py | 47 ++ core/service/limiter.py | 15 + core/service/mongo_db.py | 14 + core/service/redis.py | 34 ++ core/urls/__init__.py | 1 + core/urls/sumery.py | 38 ++ core/utils/__init__.py | 8 + core/utils/api.py | 5 + core/utils/app_context.py | 21 + core/utils/auth/__init__.py | 78 +++ core/utils/auth/model.py | 29 + core/utils/auth/schema.py | 9 + core/utils/can.py | 18 + core/utils/crudify.py | 118 ++++ core/utils/json.py | 42 ++ core/utils/mongo.py | 47 ++ core/utils/number.py | 12 + core/utils/response.py | 163 +++++ core/utils/router.py | 206 +++++++ core/utils/sumery/__init__.py | 89 +++ core/utils/table_engine/__init__.py | 4 + core/utils/table_engine/engine.py | 112 ++++ core/utils/table_engine/filter.py | 160 +++++ core/utils/table_engine/input.py | 159 +++++ core/utils/table_engine/types.py | 23 + core/utils/te/__init__.py | 77 +++ core/utils/te/engines/AbstractEngine.py | 51 ++ core/utils/te/engines/Mongo.py | 187 ++++++ core/utils/te/engines/Mssql.py | 146 +++++ core/utils/te/engines/Pgsql.py | 13 + core/utils/te/engines/__init__.py | 4 + core/utils/te/utils/Encoder.py | 19 + core/utils/te/utils/Models.py | 160 +++++ core/utils/utils.py | 150 +++++ main.py | 27 + pyproject.toml | 52 ++ req.txt | 29 + requirements.txt | 765 ++++++++++++++++++++++++ root.py | 3 + task.py | 9 + upload/__init__.py | 1 + upload/models/__init__.py | 1 + upload/models/upload.py | 18 + upload/repositories/upload.py | 100 ++++ upload/urls/__init__.py | 1 + upload/urls/upload.py | 29 + 111 files changed, 6296 insertions(+) create mode 100644 .gitignore create mode 100644 .gitmodules create mode 100644 .pylintrc create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 app/__init__.py create mode 100644 app/config/__init__.py create mode 100644 app/models/__init__.py create mode 100644 app/models/customer.py create mode 100644 app/models/project.py create mode 100644 app/repositories/__init__.py create mode 100644 app/repositories/project.py create mode 100644 app/urls/__init__.py create mode 100644 app/urls/project.py create mode 100644 auth/___test.py create mode 100644 auth/__init__.py create mode 100644 auth/dependencies/__init__.py create mode 100644 auth/models/__init__.py create mode 100644 auth/models/auth.py create mode 100644 auth/models/property.py create mode 100644 auth/models/roles.py create mode 100644 auth/models/units.py create mode 100644 auth/repositories/AuthRepository.py create mode 100644 auth/repositories/PropertyRepository.py create mode 100644 auth/sdk/__init__.py create mode 100644 auth/sdk/config.py create mode 100644 auth/sdk/model.py create mode 100644 auth/sdk/schema.py create mode 100644 auth/services/auth_db.py create mode 100644 auth/services/front_router_guarde.py create mode 100644 auth/services/token.py create mode 100644 auth/tasks/__init__.py create mode 100644 auth/urls/__init__.py create mode 100644 auth/urls/auth.py create mode 100644 auth/urls/role.py create mode 100644 auth/urls/user.py create mode 100644 auth/utils/__init__.py create mode 100644 auth/utils/ip_info.py create mode 100644 auth/utils/mongo.py create mode 100644 auth/utils/pwd.py create mode 100644 auth/utils/schema.py create mode 100644 config.py create mode 100644 config.yml create mode 100644 core/.gitignore create mode 100644 core/CHNAGELOG.md create mode 100644 core/README.md create mode 100644 core/__init__.py create mode 100644 core/app/__init__.py create mode 100644 core/app/context.py create mode 100644 core/app/docs.py create mode 100644 core/config.py create mode 100644 core/exceptions/__init__.py create mode 100644 core/exceptions/errors.py create mode 100644 core/exceptions/model.py create mode 100644 core/middleware/__init__.py create mode 100644 core/middleware/auth.py create mode 100644 core/middleware/logger_middleware.py create mode 100644 core/middleware/prod_middleware.py create mode 100644 core/models/__init__.py create mode 100644 core/models/base.py create mode 100644 core/models/object_id.py create mode 100644 core/models/table_engine.py create mode 100644 core/models/unit.py create mode 100644 core/modules/__init__.py create mode 100644 core/modules/logger/__init__.py create mode 100644 core/repositories/base.py create mode 100644 core/service/limiter.py create mode 100644 core/service/mongo_db.py create mode 100644 core/service/redis.py create mode 100644 core/urls/__init__.py create mode 100644 core/urls/sumery.py create mode 100644 core/utils/__init__.py create mode 100644 core/utils/api.py create mode 100644 core/utils/app_context.py create mode 100644 core/utils/auth/__init__.py create mode 100644 core/utils/auth/model.py create mode 100644 core/utils/auth/schema.py create mode 100644 core/utils/can.py create mode 100644 core/utils/crudify.py create mode 100644 core/utils/json.py create mode 100644 core/utils/mongo.py create mode 100644 core/utils/number.py create mode 100644 core/utils/response.py create mode 100644 core/utils/router.py create mode 100644 core/utils/sumery/__init__.py create mode 100644 core/utils/table_engine/__init__.py create mode 100644 core/utils/table_engine/engine.py create mode 100644 core/utils/table_engine/filter.py create mode 100644 core/utils/table_engine/input.py create mode 100644 core/utils/table_engine/types.py create mode 100644 core/utils/te/__init__.py create mode 100644 core/utils/te/engines/AbstractEngine.py create mode 100644 core/utils/te/engines/Mongo.py create mode 100644 core/utils/te/engines/Mssql.py create mode 100644 core/utils/te/engines/Pgsql.py create mode 100644 core/utils/te/engines/__init__.py create mode 100644 core/utils/te/utils/Encoder.py create mode 100644 core/utils/te/utils/Models.py create mode 100644 core/utils/utils.py create mode 100644 main.py create mode 100644 pyproject.toml create mode 100644 req.txt create mode 100644 requirements.txt create mode 100644 root.py create mode 100644 task.py create mode 100644 upload/__init__.py create mode 100644 upload/models/__init__.py create mode 100644 upload/models/upload.py create mode 100644 upload/repositories/upload.py create mode 100644 upload/urls/__init__.py create mode 100644 upload/urls/upload.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b359b77 --- /dev/null +++ b/.gitignore @@ -0,0 +1,54 @@ +venv +.idea + +storage +temp + +*.lock + +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] + +# C extensions +*.so + +# Distribution / packaging +bin/ +develop - eggs/ +dist/ +eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +*.egg - info/ +.installed.cfg +*.egg + +# Installer logs +pip - log.txt +pip - delete - this - directory.txt + +# Unit test / coverage reports +.tox/ +.coverage +.cache +nosetests.xml +coverage.xml + +# Translations +*.mo + +# Mr Developer +.mr.developer.cfg +.project +.pydevproject + +# Rope +.ropeproject + +# Django stuff: +*.log +*.pot diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..7ff7fba --- /dev/null +++ b/.gitmodules @@ -0,0 +1,4 @@ + +[submodule "core"] + path = core + url = https://gitlab.com/ar.azizan/fast_core.git diff --git a/.pylintrc b/.pylintrc new file mode 100644 index 0000000..732ade4 --- /dev/null +++ b/.pylintrc @@ -0,0 +1,406 @@ +[MASTER] + +# Specify a configuration file. +#rcfile= + +# Python code to execute, usually for sys.path manipulation such as +# pygtk.require(). +#init-hook= + +# Add files or directories to the blacklist. They should be base names, not +# paths. +ignore=CVS + +# Add files or directories matching the regex patterns to the blacklist. The +# regex matches against base names, not paths. +ignore-patterns=pip +# Pickle collected data for later comparisons. +persistent=yes + +# List of plugins (as comma separated values of python modules names) to load, +# usually to register additional checkers. +load-plugins= + +# Use multiple processes to speed up Pylint. +jobs=1 + +# Allow loading of arbitrary C extensions. Extensions are imported into the +# active Python interpreter and may run arbitrary code. +unsafe-load-any-extension=no + +# A comma-separated list of package or module names from where C extensions may +# be loaded. Extensions are loading into the active Python interpreter and may +# run arbitrary code +extension-pkg-whitelist=numpy + +# Allow optimization of some AST trees. This will activate a peephole AST +# optimizer, which will apply various small optimizations. For instance, it can +# be used to obtain the result of joining multiple strings with the addition +# operator. Joining a lot of strings can lead to a maximum recursion error in +# Pylint and this flag can prevent that. It has one side effect, the resulting +# AST will be different than the one from reality. This option is deprecated +# and it will be removed in Pylint 2.0. +optimize-ast=no + + +[MESSAGES CONTROL] + +# Only show warnings with the listed confidence levels. Leave empty to show +# all. Valid levels: HIGH, INFERENCE, INFERENCE_FAILURE, UNDEFINED +confidence= + +# Enable the message, report, category or checker with the given id(s). You can +# either give multiple identifier separated by comma (,) or put this option +# multiple time (only on the command line, not in the configuration file where +# it should appear only once). See also the "--disable" option for examples. +#enable= + +# Disable the message, report, category or checker with the given id(s). You +# can either give multiple identifiers separated by comma (,) or put this +# option multiple times (only on the command line, not in the configuration +# file where it should appear only once).You can also use "--disable=all" to +# disable everything first and then reenable specific checks. For example, if +# you want to run only the similarities checker, you can use "--disable=all +# --enable=similarities". If you want to run only the classes checker, but have +# no Warning level messages displayed, use"--disable=all --enable=classes +# --disable=W" +disable=W0511,long-suffix,standarderror-builtin,indexing-exception,delslice-method,unichr-builtin,dict-view-method,parameter-unpacking,unicode-builtin,cmp-builtin,intern-builtin,round-builtin,backtick,nonzero-method,xrange-builtin,coerce-method,raw_input-builtin,old-division,filter-builtin-not-iterating,old-octal-literal,input-builtin,map-builtin-not-iterating,buffer-builtin,basestring-builtin,zip-builtin-not-iterating,using-cmp-argument,unpacking-in-except,old-raise-syntax,coerce-builtin,dict-iter-method,hex-method,range-builtin-not-iterating,useless-suppression,cmp-method,print-statement,reduce-builtin,file-builtin,long-builtin,getslice-method,execfile-builtin,no-absolute-import,metaclass-assignment,oct-method,reload-builtin,import-star-module-level,suppressed-message,apply-builtin,raising-string,next-method-called,setslice-method,old-ne-operator,arguments-differ,wildcard-import,locally-disabled + + +[REPORTS] + +# Set the output format. Available formats are text, parseable, colorized, msvs +# (visual studio) and html. You can also give a reporter class, eg +# mypackage.mymodule.MyReporterClass. +output-format=text + +# Put messages in a separate file for each module / package specified on the +# command line instead of printing them on stdout. Reports (if any) will be +# written in a file name "pylint_global.[txt|html]". This option is deprecated +# and it will be removed in Pylint 2.0. +files-output=no + +# Tells whether to display a full report or only the messages +reports=yes + +# Python expression which should return a note less than 10 (10 is the highest +# note). You have access to the variables errors warning, statement which +# respectively contain the number of errors / warnings messages and the total +# number of statements analyzed. This is used by the global evaluation report +# (RP0004). +evaluation=10.0 - ((float(5 * error + warning + refactor + convention) / statement) * 10) + +# Template used to display messages. This is a python new-style format string +# used to format the message information. See doc for all details +#msg-template= + + +[BASIC] + +# Good variable names which should always be accepted, separated by a comma +good-names=i,j,k,ex,Run,_ + +# Bad variable names which should always be refused, separated by a comma +bad-names=foo,bar,baz,toto,tutu,tata + +# Colon-delimited sets of names that determine each other's naming style when +# the name regexes allow several styles. +name-group= + +# Include a hint for the correct naming format with invalid-name +include-naming-hint=no + +# List of decorators that produce properties, such as abc.abstractproperty. Add +# to this list to register other decorators that produce valid properties. +property-classes=abc.abstractproperty + +# Regular expression matching correct variable names +variable-rgx=[a-z_][a-z0-9_]{2,30}$ + +# Naming hint for variable names +variable-name-hint=[a-z_][a-z0-9_]{2,30}$ + +# Regular expression matching correct class attribute names +class-attribute-rgx=([A-Za-z_][A-Za-z0-9_]{2,30}|(__.*__))$ + +# Naming hint for class attribute names +class-attribute-name-hint=([A-Za-z_][A-Za-z0-9_]{2,30}|(__.*__))$ + +# Regular expression matching correct argument names +argument-rgx=[a-z_][a-z0-9_]{2,30}$ + +# Naming hint for argument names +argument-name-hint=[a-z_][a-z0-9_]{2,30}$ + +# Regular expression matching correct module names +module-rgx=(([a-z_][a-z0-9_]*)|([A-Z][a-zA-Z0-9]+))$ + +# Naming hint for module names +module-name-hint=(([a-z_][a-z0-9_]*)|([A-Z][a-zA-Z0-9]+))$ + +# Regular expression matching correct constant names +const-rgx=(([A-Z_][A-Z0-9_]*)|(__.*__))$ + +# Naming hint for constant names +const-name-hint=(([A-Z_][A-Z0-9_]*)|(__.*__))$ + +# Regular expression matching correct inline iteration names +inlinevar-rgx=[A-Za-z_][A-Za-z0-9_]*$ + +# Naming hint for inline iteration names +inlinevar-name-hint=[A-Za-z_][A-Za-z0-9_]*$ + +# Regular expression matching correct method names +method-rgx=[a-z_][a-z0-9_]{2,30}$ + +# Naming hint for method names +method-name-hint=[a-z_][a-z0-9_]{2,30}$ + +# Regular expression matching correct function names +function-rgx=[a-z_][a-z0-9_]{2,30}$ + +# Naming hint for function names +function-name-hint=[a-z_][a-z0-9_]{2,30}$ + +# Regular expression matching correct attribute names +attr-rgx=[a-z_][a-z0-9_]{2,30}$ + +# Naming hint for attribute names +attr-name-hint=[a-z_][a-z0-9_]{2,30}$ + +# Regular expression matching correct class names +class-rgx=[A-Z_][a-zA-Z0-9]+$ + +# Naming hint for class names +class-name-hint=[A-Z_][a-zA-Z0-9]+$ + +# Regular expression which should only match function or class names that do +# not require a docstring. +no-docstring-rgx=^test_ + +# Minimum line length for functions/classes that require docstrings, shorter +# ones are exempt. +docstring-min-length=-1 + + +[ELIF] + +# Maximum number of nested blocks for function / method body +max-nested-blocks=5 + + +[FORMAT] + +# Maximum number of characters on a single line. +max-line-length=80 + +# Regexp for a line that is allowed to be longer than the limit. +ignore-long-lines=^\s*(# )??$ + +# Allow the body of an if to be on the same line as the test if there is no +# else. +single-line-if-stmt=y + +# List of optional constructs for which whitespace checking is disabled. `dict- +# separator` is used to allow tabulation in dicts, etc.: {1 : 1,\n222: 2}. +# `trailing-comma` allows a space between comma and closing bracket: (a, ). +# `empty-line` allows space-only lines. +no-space-check=trailing-comma,dict-separator + +# Maximum number of lines in a module +max-module-lines=1000 + +# String used as indentation unit. This is usually " " (4 spaces) or "\t" (1 +# tab). +indent-string=' ' + +# Number of spaces of indent required inside a hanging or continued line. +indent-after-paren=4 + +# Expected format of line ending, e.g. empty (any line ending), LF or CRLF. +expected-line-ending-format= + + +[LOGGING] + +# Logging modules to check that the string format arguments are in logging +# function parameter format +logging-modules=logging + + +[MISCELLANEOUS] + +# List of note tags to take in consideration, separated by a comma. +notes=FIXME,XXX,TODO + + +[SIMILARITIES] + +# Minimum lines number of a similarity. +min-similarity-lines=10 + +# Ignore comments when computing similarities. +ignore-comments=yes + +# Ignore docstrings when computing similarities. +ignore-docstrings=yes + +# Ignore imports when computing similarities. +ignore-imports=no + + +[SPELLING] + +# Spelling dictionary name. Available dictionaries: none. To make it working +# install python-enchant package. +spelling-dict= + +# List of comma separated words that should not be checked. +spelling-ignore-words= + +# A path to a file that contains private dictionary; one word per line. +spelling-private-dict-file= + +# Tells whether to store unknown words to indicated private dictionary in +# --spelling-private-dict-file option instead of raising a message. +spelling-store-unknown-words=no + + +[TYPECHECK] + +# Tells whether missing members accessed in mixin class should be ignored. A +# mixin class is detected if its name ends with "mixin" (case insensitive). +ignore-mixin-members=yes + +# List of module names for which member attributes should not be checked +# (useful for modules/projects where namespaces are manipulated during runtime +# and thus existing member attributes cannot be deduced by static analysis. It +# supports qualified module names, as well as Unix pattern matching. +ignored-modules= + +# List of class names for which member attributes should not be checked (useful +# for classes with dynamically set attributes). This supports the use of +# qualified names. +ignored-classes=optparse.Values,thread._local,_thread._local,matplotlib.cm,tensorflow.python,tensorflow,tensorflow.train.Example,RunOptions + +# List of members which are set dynamically and missed by pylint inference +# system, and so shouldn't trigger E1101 when accessed. Python regular +# expressions are accepted. +generated-members=set_shape,np.float32 + +# List of decorators that produce context managers, such as +# contextlib.contextmanager. Add to this list to register other decorators that +# produce valid context managers. +contextmanager-decorators=contextlib.contextmanager + + +[VARIABLES] + +# Tells whether we should check for unused import in __init__ files. +init-import=no + +# A regular expression matching the name of dummy variables (i.e. expectedly +# not used). +dummy-variables-rgx=(_+[a-zA-Z0-9_]*?$)|dummy + +# List of additional names supposed to be defined in builtins. Remember that +# you should avoid to define new builtins when possible. +additional-builtins= + +# List of strings which can identify a callback function by name. A callback +# name must start or end with one of those strings. +callbacks=cb_,_cb + +# List of qualified module names which can have objects that can redefine +# builtins. +redefining-builtins-modules=six.moves,future.builtins + + +[CLASSES] + +# List of method names used to declare (i.e. assign) instance attributes. +defining-attr-methods=__init__,__new__,setUp + +# List of valid names for the first argument in a class method. +valid-classmethod-first-arg=cls + +# List of valid names for the first argument in a metaclass class method. +valid-metaclass-classmethod-first-arg=mcs + +# List of member names, which should be excluded from the protected access +# warning. +exclude-protected=_asdict,_fields,_replace,_source,_make + + +[DESIGN] + +# Maximum number of arguments for function / method +max-args=10 + +# Argument names that match this expression will be ignored. Default to name +# with leading underscore +ignored-argument-names=_.* + +# Maximum number of locals for function / method body +max-locals=30 + +# Maximum number of return / yield for function / method body +max-returns=6 + +# Maximum number of branch for function / method body +max-branches=12 + +# Maximum number of statements in function / method body +max-statements=100 + +# Maximum number of parents for a class (see R0901). +max-parents=7 + +# Maximum number of attributes for a class (see R0902). +max-attributes=10 + +# Minimum number of public methods for a class (see R0903). +min-public-methods=0 + +# Maximum number of public methods for a class (see R0904). +max-public-methods=20 + +# Maximum number of boolean expressions in a if statement +max-bool-expr=5 + + +[IMPORTS] + +# Deprecated modules which should not be used, separated by a comma +deprecated-modules=optparse + +# Create a graph of every (i.e. internal and external) dependencies in the +# given file (report RP0402 must not be disabled) +import-graph= + +# Create a graph of external dependencies in the given file (report RP0402 must +# not be disabled) +ext-import-graph= + +# Create a graph of internal dependencies in the given file (report RP0402 must +# not be disabled) +int-import-graph= + +# Force import order to recognize a module as part of the standard +# compatibility libraries. +known-standard-library= + +# Force import order to recognize a module as part of a third party library. +known-third-party=enchant + +# Analyse import fallback blocks. This can be used to support both Python 2 and +# 3 compatible code, which means that the block might have code that exists +# only in one or another interpreter, leading to false positives when analysed. +analyse-fallback-blocks=no + + +[EXCEPTIONS] + +# Exceptions that will emit a warning when being caught. Defaults to +# "Exception" +overgeneral-exceptions=Exception \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..7ed81fd --- /dev/null +++ b/Dockerfile @@ -0,0 +1,28 @@ +FROM python:3.10 as requirements-stage + +WORKDIR /tmp + +RUN pip install poetry + + +COPY ./pyproject.toml ./poetry.lock* /tmp/ + + +RUN poetry export -f requirements.txt --output requirements.txt --without-hashes + + +FROM python:3.10 + + +WORKDIR /code + + +COPY --from=requirements-stage /tmp/requirements.txt /code/requirements.txt + + +RUN pip install --no-cache-dir --upgrade -r /code/requirements.txt + + +COPY . /code/app + +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8048"] \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..7d0e532 --- /dev/null +++ b/README.md @@ -0,0 +1,4 @@ +# Hacoupian Task Management Api +Base on Fastapi + + diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/config/__init__.py b/app/config/__init__.py new file mode 100644 index 0000000..c92f218 --- /dev/null +++ b/app/config/__init__.py @@ -0,0 +1,17 @@ +class TaskEnv: + CLN_SUB = 'subscription' + CLN_SUB_USER = 'subscription_users' + CLN_USER = 'customer' + CLN_FAV = 'favs' + CLN_STORY = 'story' + CLN_STORY_COMMENT = 'story-comment' + CLN_ORDER = 'order' + CLN_CATEGORY = 'category' + CLN_PRODUCT = 'product' + CLN_TASK = "task" + CLN_CONST = "const" + CLN_DEPENDENCY = "dependency" + CLN_MEMBER = "member" + CLN_PROJECT = "project" + CLN_TAGS = "tags" + CLN_WORKSPACE = "workspace" diff --git a/app/models/__init__.py b/app/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/models/customer.py b/app/models/customer.py new file mode 100644 index 0000000..889a8a3 --- /dev/null +++ b/app/models/customer.py @@ -0,0 +1,38 @@ +import uuid +from typing import Optional, List + +import pydantic +from pydantic import Field + +from core.models.base import BaseModel + + +class CustomerPhoneModel(pydantic.BaseModel): + title: Optional[str] + type: Optional[int] + value: Optional[int] + weight: Optional[int] + + +class CustomerMetaModel(pydantic.BaseModel): + city: Optional[str] + province: Optional[str] + address: Optional[str] + zip_code: Optional[int] + extra_meta: Optional[List[CustomerPhoneModel]] + bio: Optional[str] + birth_date: Optional[str] + + +class CustomerModel(BaseModel): + id: Optional[uuid.UUID] = Field(alias='_id') + name: Optional[List[str]] + mobile: Optional[str] + username: Optional[str] + images: Optional[List[str]] + stores: Optional[List[str]] + parent_id: Optional[List[uuid.UUID]] = Field(description='کاربر پدر') + reagent_code: Optional[str] + total_order: Optional[int] = 0 + active_order: Optional[int] = 0 + meta: Optional[CustomerMetaModel] diff --git a/app/models/project.py b/app/models/project.py new file mode 100644 index 0000000..7635bc3 --- /dev/null +++ b/app/models/project.py @@ -0,0 +1,102 @@ +import datetime +import uuid + +import pydantic +from pydantic import Field +from typing import Optional, List + +from core.models.base import BaseModel +class Budget(pydantic.BaseModel): + year:str=Field("سال بودجه") + amount:int=Field("مبلغ بودجه") +class BaseMonitor(pydantic.BaseModel): + type:int = Field(description=""" + 1.عالیه + 2.کارگاهی + 3. عالیه-کارگاهی + """) + date_delivery_land:Optional[datetime.datetime] =Field(description=" تاریخ تحویل زمین") + date_building_permit:Optional[datetime.datetime] = Field(description=" تاریخ صدور پروانه ساختمانی") + date_expire_permit:Optional[datetime.datetime] = Field(description="تاریخ پایان اعتبار پروانه ساختمانی") + address:Optional[str] = Field(description="آدرس پروژه") + budget:Optional[List[Budget]] + +class AddTFPercent(pydantic.BaseModel): + amount:int = Field(description="مبلغ") + percent:int = Field(description="درصد") + date:datetime.datetime = Field(description="تاریخ ابلاغ") +class Adding(pydantic.BaseModel): + row:int = Field(description="ردیف") + amount:int = Field(description="مبلغ") + date:datetime.datetime = Field(description="تاریخ الحاقیه") +class DetailMonitor(pydantic.BaseModel): + type:int = Field(""" + 1.فهرست بهایی +2.سرجمع +3.سایر + """) + date_create_project:datetime.datetime = Field(description="تاریخ شروع پروژه") + first_time:int = Field(description="مدت اولیه پیمان") + extend_time:int = Field(description="میزان تمدید پیمان") + date_estimate_complete :datetime.datetime = Field(description="تاریخ پیش بینی اتمام") + first_amount:int = Field(description="مبلغ اولیه پیمان") + base_amount_year:str = Field(description="فهرست بهای پایه") + add_25_percent:Optional[List[AddTFPercent]] = Field(description="افزایش تا 25 درصد پیمان") + adding:Optional[List[Adding]] = Field(description="الحاقیه") +class AgentMonitor(pydantic.BaseModel): + workshop_consultant:str = Field(description=" نام مشاور نظارت کارگاهی") + designer:str = Field(description="نام مشاور طراح") + designer_phone:str = Field(description="شماره تماس مشاور طراح") + constractor:str = Field(description=" نام پیمانکار") + constractor_phone:str = Field(description="شماره تماس پیمانکار") + supervisor:str = Field(description="نام سرناظر") + supervisor_phone:str = Field(description=" شماره تماس سر ناظر") + supervisor_building:str = Field(description="نام ناظر ابنیه") + supervisor_building_phone:str = Field(description=" شماره تماس ابنیه") + supervisor_mechanic:str = Field(description="نام ناظر مکانیک") + supervisor_mechanic_phone:str = Field(description=" شماره تماس مکانیک") + supervisor_electricity:str = Field(description=" نام ناظر برق") + supervisor_electricity_phone:str = Field(description=" شماره تماس ناظر برق") + project_manager:str = Field(description=" نام مدیر پروژه") + project_manager_phone:str = Field(description="شماره تماس مدیر پروژه") + supervisor_workshop:str = Field(description=" نام سرپرست کارگاه") + supervisor_workshop_phone:str = Field(description="شماره تماس سرپرست کارگاه") +class StatusBranch(pydantic.BaseModel): + electricity:bool = Field(description="برق") + water:bool = Field(description="آب") + wastewater:bool = Field(description="فاضلاب") + gas:bool = Field(description="گاز") + phone:bool = Field(description="تلفن و فیبر نوری") +class Monitor(pydantic.BaseModel): + code: Optional[str] = Field(description='کد در این فیلد قرار می گیرد') + base_information:Optional[BaseMonitor] = Field(description="اطلاعات پایه") + detail:Optional[DetailMonitor] = Field(description="مشخصات پیمان") + agent:Optional[AgentMonitor] = Field(description="عوامل پروژه") + status_branch:Optional[StatusBranch] = Field(description="وضعیت انشعابات") +class Design(pydantic.BaseModel): + code: Optional[str] = Field(description='کد در این فیلد قرار می گیرد') + title: str = Field(description='عنوان در این فیلد قرار می گیرد') + state: str = Field(description='استان در این فیلد قرار می گیرد') + city: str = Field(description='شهر در این فیلد قرار می گیرد') + nature_id: int = Field(description='کد شناسایی ماهبت در این فیلد قرار می گیرد') + nature: str = Field(description='ماهبت در این فیلد قرار می گیرد') + type_id: int = Field(description='کد شناسایی نوع پروژه در این فیلد قرار می گیرد') + type: str = Field(description='نوع پروژه در این فیلد قرار می گیرد') + budget_id: int = Field(description='کد شناسایی دسته بودچه در این فیلد قرار می گیرد') + budget: str = Field(description='دسته بودجه در این فیلد قرار می گیرد') + date_send: datetime.datetime = Field(description='تاریخ ابالغ/ارجاع') + letter: str = Field(description='شماره نامه') + land_area:Optional[str] = Field(description="مساحت زمین") + building_infrastructure:Optional[str] = Field(description="زیربنای ساختمان") + site_area:Optional[str] = Field(description="مساحت محوطه") + designer:Optional[List[str]] = Field(description="نام طراح") + status_agreement:Optional[str] = Field(description="وضعیت موافقتنامه") + description:Optional[str] = Field(description="توضیحات") +class ProjectModel(BaseModel): + id: Optional[uuid.UUID] = Field(alias='_id') + user_id: int = Field(description='کد شناسایی کاربر در این فیلد قرار می گیرد') + sts: int = Field(description='وضعیت در این فیلد قرار می گیرد') + counter: Optional[int] = Field(description='شمارنده در این فیلد قرار می گیرد') + monitor:Optional[Monitor] = Field(description="فیلدهای نظارت") + design:Design = Field(description='فیلدهای طراحی') + diff --git a/app/repositories/__init__.py b/app/repositories/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/repositories/project.py b/app/repositories/project.py new file mode 100644 index 0000000..f23f4e4 --- /dev/null +++ b/app/repositories/project.py @@ -0,0 +1,49 @@ +from uuid import UUID + +from app.models.project import ProjectModel +from core.repositories.base import BaseRepository +from core.service.mongo_db import use_collection + +COLLECTION = "project" +class ProjectRepository(BaseRepository[ProjectModel]): + def __init__(self): + super().__init__(collection=COLLECTION, model=ProjectModel) + def create_code(self,coding:ProjectModel): + code = str(coding.counter) + print("code1",code) + if len(code) < 4: + code = code.rjust(4, '0') + print("code2", code) + code = coding.design.nature + coding.design.type + code + return code + + + async def create(self,coding:ProjectModel): + print("11111111111111111") + coding.counter = 1 + last_project = await use_collection(COLLECTION).find({}).sort("$natural",-1).to_list(None) + if last_project: + coding.counter = last_project[0]["counter"]+1 + code = self.create_code(coding) + coding.design.code = 'D'+code + if coding.monitor: + coding.monitor.code = 'S'+code + coding.init_id().cu_at() + await use_collection(COLLECTION).insert_one(coding.mongo_dict()) + return await self.get(coding.id) + + + async def update(self, _id: UUID, update: ProjectModel): + old_project = await use_collection(COLLECTION).find_one({"_id":_id}) + old_project = ProjectModel(**old_project) + if update.monitor and not old_project.monitor: + code = self.create_code(update) + update.monitor.code = 'S'+code + if update.design.nature != old_project.design.nature or update.design.type != old_project.design.type : + code = self.create_code(update) + update.design.code = 'D' + code + if update.monitor: + update.monitor.code = 'S' + code + update.u_at() + await use_collection(COLLECTION).update_one({"_id":_id},{"$set":update.mongo_dict()}) + return await self.get(_id) \ No newline at end of file diff --git a/app/urls/__init__.py b/app/urls/__init__.py new file mode 100644 index 0000000..8dc4240 --- /dev/null +++ b/app/urls/__init__.py @@ -0,0 +1 @@ +from .project import * diff --git a/app/urls/project.py b/app/urls/project.py new file mode 100644 index 0000000..9e61f7a --- /dev/null +++ b/app/urls/project.py @@ -0,0 +1,47 @@ +from uuid import UUID + +from fastapi import Body + +from app.models.project import ProjectModel +from app.repositories.project import ProjectRepository +from core.app import app +from core.utils.router import generate_router, shrink +from core.utils.table_engine import TableEngineExport, TableEngineRequestType + +ThisModel = ProjectModel +router_base_url = 'project' +_tag_base = [router_base_url] +router = generate_router(router_base_url, auth_dependencies=False, version='v2') +repo = ProjectRepository() + + +@router.post('/create', response_model=ThisModel, tags=_tag_base) +@shrink(repo.create) +def create(coding: ThisModel = Body(...)): + pass + + +@router.put('/{_id}', response_model=ThisModel, tags=_tag_base) +@shrink(repo.update) +def update(_id: UUID, coding: ThisModel = Body(...)): + pass + + +@router.delete('/{_id}', response_model=bool, tags=_tag_base) +@shrink(repo.delete) +def delete(_id: UUID): + pass + +@router.get('/{_id}', response_model=ThisModel, tags=_tag_base) +@shrink(repo.get) +def get(_id: UUID): + pass + + +@router.post('', response_model=TableEngineExport[ThisModel], tags=_tag_base) +@shrink(repo.fetch) +def fetch(body: TableEngineRequestType = Body(...)): + pass + + +app.include_router(router) \ No newline at end of file diff --git a/auth/___test.py b/auth/___test.py new file mode 100644 index 0000000..dafd913 --- /dev/null +++ b/auth/___test.py @@ -0,0 +1,39 @@ +from typing import List + +from scm.models.units import UnitModel + +from auth.utils.mongo import use_auth_collection +from core.app import app +from core.utils.api import get_namespace_api + +BASE = get_namespace_api('user') + + +@app.get(BASE + '/me') +async def get(): + return {"code": 0, + "result": {"userId": "1", + "username": "admin", + "realName": "احمد رضا عزیزان", + "avatar": "https://q1.qlogo.cn/g?b=qq&nk=190848757&s=640", + "desc": "manager", + "password": "admin", + "token": "fakeToken1", + "homePath": "/dashboard/analysis", + "roles": [{"roleName": "Super Admin", + "value": "super"}]}, + "message": "ok", + "type": "success"} + + +@app.get(BASE + '/units') +async def get_units() -> List[UnitModel]: + cursor = use_auth_collection('units').find() + return [UnitModel(**document) async for document in cursor] + + +@app.post(BASE + '/units/create') +async def post(document: UnitModel): + document = document.init_id().mongo_dict() + result = await use_auth_collection('units').insert_one(document) + return result.inserted_id diff --git a/auth/__init__.py b/auth/__init__.py new file mode 100644 index 0000000..168d80e --- /dev/null +++ b/auth/__init__.py @@ -0,0 +1 @@ +from .tasks import * \ No newline at end of file diff --git a/auth/dependencies/__init__.py b/auth/dependencies/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/auth/models/__init__.py b/auth/models/__init__.py new file mode 100644 index 0000000..de4cae1 --- /dev/null +++ b/auth/models/__init__.py @@ -0,0 +1,2 @@ +from .auth import * +from .property import * diff --git a/auth/models/auth.py b/auth/models/auth.py new file mode 100644 index 0000000..cc70ecf --- /dev/null +++ b/auth/models/auth.py @@ -0,0 +1,242 @@ +import datetime +import uuid +from typing import Optional, Union, List, Literal + +import pydantic +from fastapi import Body +from pydantic import Field, validator + +from core.models.base import BaseModel, BaseCUAt +from core.utils.utils import is_mobile_number, is_email + + +class AuthModel(BaseModel): + username: str + password: str + + +class SessionModel(BaseModel): + id: Optional[uuid.UUID] = Field(alias='_id') + user_id: Optional[uuid.UUID] + access_token: Optional[str] + token: Optional[str] + refresh_token: Optional[str] + token_type: Optional[str] + agent: Optional[List[str]] + ip_info: Optional[dict] + + +class NotifyConfigModel(pydantic.BaseModel): + sms_info: Optional[bool] + sms_emergency: Optional[bool] + push: Optional[bool] + email: Optional[bool] + + +class UserOptions(pydantic.BaseModel): + active_password_login: Optional[bool] + last_password_changes: Optional[datetime.datetime] + active_otp: Optional[bool] + active_token: Optional[bool] + dark_mode: Optional[Literal['LIGHT', 'DARK', 'SYSTEM', 'DYNAMIC']] + time_format: Optional[str] + date_format: Optional[str] + date_type: Optional[str] + lang: Optional[str] + notify: Optional[NotifyConfigModel] + + +class VisualUserUpdate(pydantic.BaseModel): + storage: Optional[str] + + +class UserUpdateModel(BaseModel): + username: Optional[str] + email: Optional[str] + name: Optional[str] + first_name: Optional[str] + last_name: Optional[str] + unit: Optional[str] + personnel_code: Optional[str] + avatar: Optional[str] + desc: Optional[str] + gender: Optional[Literal[1, 2, 3]] + home_path: Optional[str] + force_password_change: Optional[bool] + disabled: Optional[bool] + + +class PasswordChangeModel(pydantic.BaseModel): + old_password: str + new_password: str = Field(min_length=6, max_length=28) + + +class UserModel(BaseModel, BaseCUAt): + id: Optional[Union[uuid.UUID, str]] = Field(alias='_id') + username: Optional[str] + mobile: Optional[str] + email: Optional[str] + name: Optional[Union[str,List[str]]] + first_name: Optional[str] + last_name: Optional[str] + is_su: Optional[bool] + unit: Optional[str] + units: Optional[List[str]] + personnel_code: Optional[str] + avatar: Optional[str] + desc: Optional[str] + gender: Optional[Literal[1, 2, 3]] + scopes: Optional[List[str]] + options: Optional[UserOptions] + home_path: Optional[str] + force_password_change: Optional[bool] + roles: Optional[List[uuid.UUID]] + _options: Optional[dict] + disabled: Optional[bool] + + async def get_roles(self, exclude: [] = None, include: [] = None): + from auth.utils.mongo import use_auth_collection + projection = {} + if exclude: + projection = { + f: 0 for f in exclude + } + + if include: + projection = { + f: 1 for f in include + } + + cursor = use_auth_collection('roles').find( + {'_id': {'$in': [uuid.UUID(str(role)) for role in self.roles]}}, projection) + return [role async for role in cursor] + + async def get_units(self): + roles = await self.get_roles(include=['units']) + units = [] + for role in roles: + units.extend(role.get('units', [])) + return units + + def is_superuser(self) -> bool: + return self.is_su + + @validator("mobile") + def mobile_validation(cls, v): + if not is_mobile_number(v): + raise ValueError('Mobile not valid') + return v + + @validator("email") + def email_validation(cls, v): + if not is_email(v): + raise ValueError('Email not valid') + return v + + +class OAuth2PasswordRequestFormJson: + """ + This is a dependency class, use it like: + + @app.post("/login") + def login(form_data: OAuth2PasswordRequestForm = Depends()): + data = form_data.parse() + print(data.username) + print(data.password) + for scope in data.scopes: + print(scope) + if data.client_id: + print(data.client_id) + if data.client_secret: + print(data.client_secret) + return data + + + It creates the following Form request parameters in your endpoint: + + grant_type: the OAuth2 spec says it is required and MUST be the fixed string "password". + Nevertheless, this dependency class is permissive and allows not passing it. If you want to enforce it, + use instead the OAuth2PasswordRequestFormStrict dependency. + username: username string. The OAuth2 spec requires the exact field name "username". + password: password string. The OAuth2 spec requires the exact field name "password". + scope: Optional string. Several scopes (each one a string) separated by spaces. E.g. + "items:read items:write users:read profile openid" + client_id: optional string. OAuth2 recommends sending the client_id and client_secret (if any) + using HTTP Basic auth, as: client_id:client_secret + client_secret: optional string. OAuth2 recommends sending the client_id and client_secret (if any) + using HTTP Basic auth, as: client_id:client_secret + """ + + def __init__( + self, + grant_type: str = Body(None, regex="password"), + username: str = Body(...), + password: str = Body(...), + scopes: Optional[List[str]] = Body(['default']), + client_id: Optional[str] = Body(None), + client_secret: Optional[str] = Body(None), + ): + self.grant_type = grant_type + self.username = username + self.password = password + self.scopes = scopes + self.client_id = client_id + self.client_secret = client_secret + + +class OAuth2OptRequest: + + def __init__( + self, + grant_type: str = Body(None, regex="password"), + allow_register: bool = Body(None), + mobile: str = Body(...), + token: Optional[int] = Body(None), + scopes: Optional[List[str]] = Body(['customer']), + step: Literal[1, 2] = Body(1), + client_id: Optional[str] = Body(None), + client_secret: Optional[str] = Body(None), + ): + self.grant_type = grant_type + self.allow_register = allow_register + self.mobile = mobile + self.token = token + self.scopes = scopes + self.step = step + self.client_id = client_id + self.client_secret = client_secret + + +class Token(BaseModel): + access_token: str + token_type: str + + +class TokenData(BaseModel): + username: Optional[str] = None + scopes: List[str] = [] + + +class UserInDB(UserModel): + password: Optional[str] + + +class CreateTokenModel(BaseModel): + id: Optional[Union[uuid.UUID, str]] = Field(alias='_id') + user_id: Optional[uuid.UUID] + mode: Optional[Literal['user_admin', 'customer']] + access_token: str + token: str + refresh_token: str + token_type: str = 'bearer' + + +class PasswordForgottenInput(pydantic.BaseModel): + password: str + mode: Optional[Literal['user_admin', 'customer']] + mobile: str + token: int + + +class UIdsInput(BaseModel): + u_ids: List[uuid.UUID] diff --git a/auth/models/property.py b/auth/models/property.py new file mode 100644 index 0000000..0aef44a --- /dev/null +++ b/auth/models/property.py @@ -0,0 +1,16 @@ +from typing import Optional, Any +from uuid import UUID + +from pydantic import Field + +from core.models.base import BaseModel + + +class AuthPropertyModel(BaseModel): + id: Optional[UUID] = Field(alias='_id') + key: Optional[str] + user_id: Optional[UUID] + role_id: Optional[UUID] + value: Optional[Any] + meta: Optional[Any] + parent_id: Optional[UUID] diff --git a/auth/models/roles.py b/auth/models/roles.py new file mode 100644 index 0000000..82b43ff --- /dev/null +++ b/auth/models/roles.py @@ -0,0 +1,22 @@ +import uuid +from typing import Optional, Union, List + +import pydantic +from pydantic import Field + +from core.models.base import BaseModel, BaseCUAt + + +class ModuleModel(pydantic.BaseModel): + title: str + icon: str + description: Optional[str] + url: Optional[str] + + +class RoleModel(BaseModel, BaseCUAt): + id: Optional[Union[uuid.UUID, str]] = Field(alias='_id') + title: str + f_routes: Optional[List[str]] + units: Optional[List[uuid.UUID]] + modules: Optional[List[ModuleModel]] diff --git a/auth/models/units.py b/auth/models/units.py new file mode 100644 index 0000000..5ee32b2 --- /dev/null +++ b/auth/models/units.py @@ -0,0 +1,12 @@ +import uuid +from typing import Optional, Union, List + +from pydantic import Field + +from core.models.base import BaseSoftDelete, BaseModel + + +class UnitModel(BaseModel, BaseSoftDelete): + id: Union[Optional[uuid.UUID], str] = Field(alias='_id') + title: str + parents: Optional[List[uuid.UUID]] diff --git a/auth/repositories/AuthRepository.py b/auth/repositories/AuthRepository.py new file mode 100644 index 0000000..3cb328d --- /dev/null +++ b/auth/repositories/AuthRepository.py @@ -0,0 +1,165 @@ +from http.client import HTTPException +from typing import List +from uuid import UUID + +from auth.models import UserInDB, UserModel, UIdsInput, UserOptions, SessionModel, PasswordChangeModel, UserUpdateModel, \ + VisualUserUpdate +from auth.models.roles import RoleModel, ModuleModel +from auth.models.units import UnitModel +from auth.utils.mongo import use_auth_collection +from auth.utils.pwd import get_password_hash, verify_password +from core.exceptions import ModifiedCountException, ValidationException +from core.models.base import DELETED_AT_QUERY +from core.utils.app_context import use_user +from core.utils.crudify import mongo_soft_deleter + +COLLECTION_UNITS = 'units' +COLLECTION_USERS = 'users' +COLLECTION_ROLES = 'roles' +COLLECTION_SESSIONS = 'sessions' + +_projection_user = { + 'mobile': 1, + 'email': 1, + 'username': 1, + '_id': 1, + 'name': 1 +} + + +class AuthRepository: + @staticmethod + async def create(_input: UserInDB) -> dict: + record = _input.init_id().cu_at().mongo_dict() + record['password'] = get_password_hash(_input.password) + insert = await use_auth_collection(COLLECTION_USERS).insert_one(record) + if not insert.acknowledged: + raise HTTPException(status_code=400, detail="User already exists") + return insert.inserted_id + + @staticmethod + async def user_query(query: str = '') -> List[UserModel]: + q = {'$or': [{'name': {'$regex': query.strip()}}, + {'mobile': {'$regex': query.strip()}} + ]} if query.strip() else {} + cursor = use_auth_collection('users').find(q, {'_id': 1, 'name': 1, 'mobile': 1}) + return [UserModel(**user) async for user in cursor] + + @staticmethod + async def get_units(): + cursor = use_auth_collection(COLLECTION_UNITS).find() + return [UnitModel(**document) async for document in cursor] + + @staticmethod + async def get_user(_id: UUID) -> UserModel: + user = await use_auth_collection(COLLECTION_USERS).find_one({"_id": _id}, _projection_user) + if user is None: + raise HTTPException(status_code=404, detail="User not found") + return UserModel(**user) + + @staticmethod + async def update_options(options: UserOptions) -> bool: + user = use_user() + user = await use_auth_collection(COLLECTION_USERS) \ + .update_one({"_id": user.id}, {"$set": {"options": options.dict()}}) + if user.modified_count == 0: + raise ModifiedCountException("User not updated") + return True + + @staticmethod + async def update_avatar(av_input: VisualUserUpdate) -> bool: + user = use_user() + _update_query = {"$set": {"avatar": av_input.storage}} if av_input.storage else {'$unset': {'avatar': 1}} + user = await use_auth_collection(COLLECTION_USERS) \ + .update_one({"_id": user.id}, _update_query) + if user.modified_count == 0: + raise ModifiedCountException("User not updated") + return True + + @staticmethod + async def update_password(_input: PasswordChangeModel) -> bool: + user = use_user() + user = await use_auth_collection(COLLECTION_USERS).find_one({'_id': user.id}) + if not user: + raise HTTPException(status_code=404, detail="User not found") + if not verify_password(_input.old_password, user.get('password', None)): + raise ValidationException('Old Password not valid') + user['password'] = get_password_hash(_input.new_password) + user = await use_auth_collection(COLLECTION_USERS).update_one({'_id': user.get('_id')}, + {'$set': { + 'password': user['password']}}) + if user.modified_count == 0: + raise ModifiedCountException("Password") + return True + + @staticmethod + async def update_user(_input: UserUpdateModel) -> bool: + user = use_user() + user = await use_auth_collection(COLLECTION_USERS).find_one({'_id': UUID(str(user.id))}) + user = await use_auth_collection(COLLECTION_USERS).update_one({'_id': user.get('_id')}, + {'$set': _input.u_at().dict()}) + if user.modified_count == 0: + raise ModifiedCountException("User not update") + return True + + @staticmethod + async def get_my_sessions() -> List[SessionModel]: + user = use_user() + user = use_auth_collection(COLLECTION_SESSIONS).find({"user_id": user.id, **DELETED_AT_QUERY}) + return [SessionModel(**x) async for x in user] + + @staticmethod + async def delete_session(session_id: UUID) -> bool: + return await mongo_soft_deleter(session_id, COLLECTION_SESSIONS, client=use_auth_collection) + + @staticmethod + async def get_users_by_id(_input: UIdsInput) -> List[UserModel]: + cursor = use_auth_collection(COLLECTION_USERS) \ + .find({'_id': {'$in': _input.u_ids}}) + return [UserModel(**user) async for user in cursor] + + @staticmethod + async def create_unit(_input: UnitModel) -> str: + document = _input.init_id().mongo_dict() + result = await use_auth_collection(COLLECTION_UNITS).insert_one(document) + return result.inserted_id + + @staticmethod + async def get_roles() -> List[dict]: + roles = await use_auth_collection(COLLECTION_ROLES).find().to_list(None) + return [role for role in roles] + + @staticmethod + async def create_role(_input: RoleModel) -> str: + record = _input.init_id().cu_at().mongo_dict() + insert = await use_auth_collection(COLLECTION_ROLES).insert_one(record) + if not insert.acknowledged: + raise HTTPException("Error creating role") + return insert.inserted_id + + @staticmethod + async def get_my_modules() -> List[ModuleModel]: + user = use_user() + records = await use_auth_collection(COLLECTION_ROLES).find( + {'_id': {'$in': user.roles or []}, **DELETED_AT_QUERY}, {'modules': True}).to_list(None) + if not records: + return [] + _list = [x.get('modules', []) for x in records] + if _list: + return list([item for sublist in _list for item in sublist]) + return [] + + @staticmethod + async def get_f_routes(roles_ids: List[UUID]) -> List[str]: + + roles = await use_auth_collection(COLLECTION_ROLES).find( + {'_id': {'$in': roles_ids}}) \ + .to_list(None) + + def _union_list(*kwargs): + final = [] + for kwarg in kwargs: + final.extend(kwarg) + return final + + return _union_list(*[role['f_routes'] for role in roles]) diff --git a/auth/repositories/PropertyRepository.py b/auth/repositories/PropertyRepository.py new file mode 100644 index 0000000..04e1bce --- /dev/null +++ b/auth/repositories/PropertyRepository.py @@ -0,0 +1,55 @@ +from typing import Union, List +from uuid import UUID + +from auth.models import AuthPropertyModel +from auth.utils.mongo import use_auth_collection +from core.models.base import DELETED_AT_QUERY +from core.utils.app_context import use_user +from core.utils.crudify import mongo_inserter, mongo_updater, mongo_find_one, mongo_soft_deleter + +COLLECTION_PROPERTY = 'property' + + +class PropertyRepository: + @staticmethod + async def create(_input: AuthPropertyModel) -> dict: + return await mongo_inserter(_input, COLLECTION_PROPERTY, client=use_auth_collection, + getter=PropertyRepository.get) + + @staticmethod + async def update(_id: UUID, _input: AuthPropertyModel) -> bool: + return await mongo_updater(_input, + _id, + COLLECTION_PROPERTY, + client=use_auth_collection, + getter=PropertyRepository.get) + + @staticmethod + async def get(_input: Union[UUID, str]) -> AuthPropertyModel: + """ + get argument from args + Args: + _input: Union[UUID,str] + Returns: + AuthPropertyModel + """ + q = {'key': _input} if isinstance(_input, str) else {'_id': _input} if isinstance(_input, UUID) else _input + q['owner_id'] = use_user().id + result = await mongo_find_one(q, COLLECTION_PROPERTY, client=use_auth_collection) + return AuthPropertyModel(**result) + + @staticmethod + async def fetch() -> List[AuthPropertyModel]: + q = {} + q['owner_id'] = use_user().id + result = await use_auth_collection(COLLECTION_PROPERTY) \ + .find({**q, **DELETED_AT_QUERY}) \ + .to_list(100) + return [AuthPropertyModel(**x) for x in result] + + @staticmethod + async def delete(_input: Union[UUID, str]) -> bool: + q = {'key': _input} if isinstance(_input, str) else {'_id': _input} + q['owner_id'] = use_user().id + result = await mongo_soft_deleter(q, COLLECTION_PROPERTY, client=use_auth_collection) + return result diff --git a/auth/sdk/__init__.py b/auth/sdk/__init__.py new file mode 100644 index 0000000..d58c279 --- /dev/null +++ b/auth/sdk/__init__.py @@ -0,0 +1,54 @@ +from urllib.request import Request + +import httpx +from fastapi import HTTPException, Depends, Security +from fastapi.security import SecurityScopes +from httpx import ConnectError + +from .config import AUTH_SERVER_URL +from .model import UserModel, ValidateTokenInput +from .schema import oauth2 + + +async def validate_token(_input: ValidateTokenInput): + try: + client = httpx.AsyncClient() + headers = {'Content-Type': 'application/json', } + result = await client.request('POST', AUTH_SERVER_URL, json=_input.dict(), headers=headers) + export = result.text + if 'application/json' in result.headers['content-type']: + export = result.json() + if result.status_code > 299: + raise HTTPException(status_code=result.status_code, detail=export) + return export + except ConnectError as e: + raise HTTPException(status_code=400, detail=str(e)) + except HTTPException as e: + raise e + except Exception as e: + raise HTTPException(status_code=400, detail=str(e)) + + +async def get_current_user(security_scopes: SecurityScopes, token: str = Depends(oauth2)): + if security_scopes.scopes: + authenticate_value = f'Bearer scope="{security_scopes.scope_str}"' + else: + authenticate_value = f"Bearer" + credentials_exception = HTTPException( + status_code=401, + detail="Could not validate credentials a", + headers={"WWW-Authenticate": authenticate_value}, + ) + user = await validate_token(ValidateTokenInput(token=token, scopes=security_scopes.scopes)) + if user is None: + raise credentials_exception + user.scopes = security_scopes.scopes if isinstance( + security_scopes.scopes, list) else security_scopes.scopes + return user + + +async def get_current_active_user(request: Request, current_user: UserModel = Security(get_current_user, scopes=["account"]),): + if current_user.disabled: + raise HTTPException(status_code=400, detail="Inactive user") + request.state.user = UserModel(**current_user.dict()) + return current_user diff --git a/auth/sdk/config.py b/auth/sdk/config.py new file mode 100644 index 0000000..845d230 --- /dev/null +++ b/auth/sdk/config.py @@ -0,0 +1 @@ +AUTH_SERVER_URL = 'https://scm.hacoupian.net/api/scm/v1/auth/validate-token' diff --git a/auth/sdk/model.py b/auth/sdk/model.py new file mode 100644 index 0000000..ff66e49 --- /dev/null +++ b/auth/sdk/model.py @@ -0,0 +1,26 @@ +import uuid +from typing import Optional, Union, List, Any + +from pydantic import Field, BaseModel + + +class UserModel(BaseModel): + id: Optional[Union[uuid.UUID, str]] = Field(alias='_id') + username: Optional[str] + mobile: Optional[str] + name: Optional[str] + first_name: Optional[str] + last_name: Optional[str] + is_su: Optional[bool] + unit: Optional[str] + avatar: Optional[str] + desc: Optional[str] + scopes: Optional[List[str]] + home_path: Optional[str] + roles: Optional[List[Any]] + disabled: Optional[bool] + + +class ValidateTokenInput(BaseModel): + token: str + scopes: Optional[List[str]] = ['account'] diff --git a/auth/sdk/schema.py b/auth/sdk/schema.py new file mode 100644 index 0000000..54d12e7 --- /dev/null +++ b/auth/sdk/schema.py @@ -0,0 +1,9 @@ +from fastapi.security import OAuth2PasswordBearer + +oauth2 = OAuth2PasswordBearer( + tokenUrl="token", + scopes={ + "account": "Read information about the current user.", + "scm": "Scm Modules", + }, +) diff --git a/auth/services/auth_db.py b/auth/services/auth_db.py new file mode 100644 index 0000000..21d417e --- /dev/null +++ b/auth/services/auth_db.py @@ -0,0 +1,45 @@ +from uuid import UUID + +from app.models.customer import CustomerModel +from auth.models import UserInDB +from auth.utils.mongo import use_auth_collection + + +async def get_user_by_username(username: str) -> UserInDB: + """ + Retrieves user from the database by username. + """ + result = await use_auth_collection('users').find_one({'username': username}) + return UserInDB(**result) if result else None + + +async def get_customer_by_username(username: str) -> UserInDB: + """ + Retrieves user from the database by username. + """ + result = await use_auth_collection('customer').find_one({'$or': [{'username': username}, {'mobile': username}]}) + return UserInDB(**result) if result else None + + +async def get_user_by_id(user_id: str) -> UserInDB | None: + """ + Retrieves user from the database by username. + """ + try: + uuid = UUID(str(user_id)) + except ValueError: + return None + result = await use_auth_collection('users').find_one({'_id': uuid}) + return UserInDB(**result) if result else None + + +async def get_customer_by_id(customer_id: str) -> UserInDB | None: + """ + Retrieves user from the database by username. + """ + try: + uuid = UUID(str(customer_id)) + except ValueError: + return None + result = await use_auth_collection('customer').find_one({'_id': uuid}) + return CustomerModel(**result) if result else None diff --git a/auth/services/front_router_guarde.py b/auth/services/front_router_guarde.py new file mode 100644 index 0000000..cd8265c --- /dev/null +++ b/auth/services/front_router_guarde.py @@ -0,0 +1,36 @@ +from typing import List, Optional + +from core.models.base import BaseModel + + +class RouteMeta(BaseModel): + orderNo: int + title: str + ignoreAuth: Optional[bool] + roles: Optional[List[str]] + ignoreKeepAlive: Optional[bool] + affix: Optional[bool] + icon: Optional[str] + frameSrc: Optional[str] + transitionName: Optional[str] + hideBreadcrumb: Optional[bool] + hideChildrenInMenu: Optional[bool] + carryParam: Optional[bool] + single: Optional[bool] + currentActiveMenu: Optional[str] + hideTab: Optional[bool] + hideMenu: Optional[bool] + isLink: Optional[bool] + ignoreRoute: Optional[bool] + hidePathForChildren: Optional[bool] + + +class VueRouteNative(BaseModel): + name: str + meta: RouteMeta + props: Optional[dict] + fullPath: Optional[str] + + +class VueRoute(VueRouteNative): + children: List[VueRouteNative] diff --git a/auth/services/token.py b/auth/services/token.py new file mode 100644 index 0000000..e69de29 diff --git a/auth/tasks/__init__.py b/auth/tasks/__init__.py new file mode 100644 index 0000000..3958248 --- /dev/null +++ b/auth/tasks/__init__.py @@ -0,0 +1,29 @@ +from uuid import UUID + +import httpx +from asgiref.sync import async_to_sync + +from task import celery + + +@celery.task(name='get_ip_info') +def get_ip_info(ip: str, session_id: UUID): + from auth.utils.ip_info import get_info + from auth.utils.mongo import use_auth_collection + result = get_info(ip) + async_to_sync(use_auth_collection('sessions').update_one({'_id': session_id}, + {'$set': {'ip_info': result}})) + + +@celery.task(name='send_otp') +def send_otp(mobile: str, token: int): + response = httpx.get("http://www.0098sms.com/sendsmslink.aspx", + params={ + "TO": mobile, + "FROM": 3000164545, + "TEXT": f'وای گرام. بفرمایید کد ورود یکبار مصرف: {token} ', + "USERNAME": "ysms1362", + "PASSWORD": "1234@Abcgh", + "DOMAIN": "0098" + }) + return response.text diff --git a/auth/urls/__init__.py b/auth/urls/__init__.py new file mode 100644 index 0000000..ffeb5b9 --- /dev/null +++ b/auth/urls/__init__.py @@ -0,0 +1,3 @@ +from .auth import * +from .role import * +from .user import * diff --git a/auth/urls/auth.py b/auth/urls/auth.py new file mode 100644 index 0000000..7121dcc --- /dev/null +++ b/auth/urls/auth.py @@ -0,0 +1,68 @@ +from typing import Optional, List + +from fastapi import Depends, APIRouter, Body, Request +from fastapi.security import SecurityScopes +from pydantic import BaseModel + +from auth.models import UserModel, OAuth2PasswordRequestFormJson, CreateTokenModel, OAuth2OptRequest, \ + PasswordForgottenInput +from auth.utils import get_current_active_user, get_current_user, generate_token, reset_password +from config import conf +from core.app import app +from core.utils import to_http_res +from core.utils.router import BaseRoute, safe_route + +_pre = conf.server.prefix_url + +router = APIRouter( + prefix=f"{_pre}/auth", + route_class=BaseRoute, +) + +_tag_base = ['token'] + + +class ValidateTokenInput(BaseModel): + token: str + scopes: Optional[List[str]] = ['account'] + + +@router.post('/login/otp', response_model=CreateTokenModel, tags=_tag_base) +async def login_with_otp(request: Request, form_data: OAuth2OptRequest = Depends()): + client_host = request.client.host + return await safe_route('login', generate_token, form_data, client_host, request.headers.get('user-agent')) + + +@router.post("/token", response_model=CreateTokenModel, tags=_tag_base) +async def login_for_access_token(request: Request, form_data: OAuth2PasswordRequestFormJson = Depends()): + client_host = request.client.host + return await safe_route('login', generate_token, form_data, client_host, request.headers.get('user-agent')) + + +@router.post('/login', response_model=CreateTokenModel, tags=_tag_base) +async def login(request: Request, data: OAuth2PasswordRequestFormJson = Depends()): + client_host = request.client.host + return await safe_route('login', generate_token, data, client_host, request.headers.get('user-agent')) + + +@router.post('/reset-password', response_model=bool, tags=_tag_base) +async def reset_password_p(_input: PasswordForgottenInput): + return await safe_route('update', reset_password, _input) + + +@router.get('/logout', response_model=bool, tags=_tag_base) +async def logout(): + return to_http_res(True, 'token') + + +@router.get("/status", tags=_tag_base) +async def token_status(current_user: UserModel = Depends(get_current_active_user)): + return to_http_res('get', {"status": "token is valid", "scope": current_user.scopes}) + + +@router.post('/validate-token', tags=_tag_base) +async def validate_token(body: ValidateTokenInput = Body(...)): + return await safe_route('get', get_current_user, SecurityScopes(scopes=body.scopes), body.token) + + +app.include_router(router) diff --git a/auth/urls/role.py b/auth/urls/role.py new file mode 100644 index 0000000..503325c --- /dev/null +++ b/auth/urls/role.py @@ -0,0 +1,36 @@ +from typing import List + +from fastapi import Request, Body + +from auth.models.roles import RoleModel +from auth.repositories.AuthRepository import AuthRepository +from core.app import app +from core.utils.router import generate_router, safe_route + +router = generate_router("roles", auth_dependencies=True) + +_tag_base = ['roles'] + + +@router.get('/routes', tags=_tag_base) +async def get_routes(request: Request) -> List[str]: + roles_ids = request.state.user.roles if request.state.user.roles else [] + return await safe_route('fetch', AuthRepository.get_f_routes, roles_ids) + + +@router.post('/create', tags=_tag_base) +async def create(_input: RoleModel = Body(...)): + return await safe_route('create', AuthRepository.create_role, _input) + + +@router.get('/modules', tags=_tag_base) +async def get_my_modules(): + return await safe_route('fetch', AuthRepository.get_my_modules) + + +@router.post('', tags=_tag_base) +async def fetch() -> List[str]: + return await safe_route('fetch', AuthRepository.get_roles) + + +app.include_router(router) diff --git a/auth/urls/user.py b/auth/urls/user.py new file mode 100644 index 0000000..0ab2520 --- /dev/null +++ b/auth/urls/user.py @@ -0,0 +1,85 @@ +from uuid import UUID + +from fastapi import Body, Depends + +from auth.models import UserModel, UserInDB, UIdsInput, UserOptions, PasswordChangeModel, UserUpdateModel, \ + VisualUserUpdate +from auth.repositories.AuthRepository import AuthRepository +from auth.utils import get_current_active_user +from auth.utils.mongo import get_auth_db +from core.app import app +from core.utils import to_http_res +from core.utils.router import safe_route, generate_router +from core.utils.sumery import setup_sumery_object, SumeryObject + +setup_sumery_object( + SumeryObject(db=get_auth_db(), + object_name='users', + table_name='users', + value_col='name', + key_col='_id', + cols_search=['name', 'mobile'] + )) + +router = generate_router("users", auth_dependencies=True) + +_tag_base = ['users'] + + +@router.get("/me", response_model=UserModel, tags=_tag_base) +async def read_users_me(current_user: UserModel = Depends(get_current_active_user)): + print("123") + return to_http_res(current_user, 'get') + + +@router.get('/query/{query}', tags=_tag_base) +async def users(query: str = None): + return await safe_route('fetch', AuthRepository.user_query, query) + + +@router.get("/sessions", tags=_tag_base) +async def get_my_sessions(): + return await safe_route('fetch', AuthRepository.get_my_sessions) + + +@router.get("/{_id}", tags=_tag_base) +async def get_user_by_id_api(_id: UUID): + return await safe_route('fetch', AuthRepository.get_user, _id) + + +@router.put("/update-password", tags=_tag_base) +async def update_password(password: PasswordChangeModel = Body(...)): + return await safe_route('update', AuthRepository.update_password, password) + + +@router.put("/options", tags=_tag_base) +async def update_options(options: UserOptions = Body(...)): + return await safe_route('update', AuthRepository.update_options, options) + + +@router.put("/update-avatar", tags=_tag_base) +async def update_avatar(options: VisualUserUpdate = Body(...)): + return await safe_route('update', AuthRepository.update_avatar, options) + + +@router.put("", tags=_tag_base) +async def update_user(_input: UserUpdateModel = Body(...)): + return await safe_route('fetch', AuthRepository.update_user, _input) + + +@router.delete("/sessions/{_id}", tags=_tag_base) +async def delete_session(_id: UUID): + return await safe_route('fetch', AuthRepository.delete_session, _id) + + +@router.post("/create", tags=_tag_base) +async def create_user(_input: UserInDB = Body(...)): + return await safe_route('create', AuthRepository.create, _input) + + +@router.post('', tags=_tag_base) +async def users(body: UIdsInput = Body(...)): + return await safe_route('fetch', AuthRepository.get_users_by_id, body) + + +app.include_router(router) diff --git a/auth/utils/__init__.py b/auth/utils/__init__.py new file mode 100644 index 0000000..daae44f --- /dev/null +++ b/auth/utils/__init__.py @@ -0,0 +1,319 @@ +import random +from datetime import datetime, timedelta +from typing import Optional, Union +from uuid import UUID + +import httpagentparser +from fastapi import Security, HTTPException, Depends +from fastapi.security import SecurityScopes +from jose import jwt, JWTError +from pydantic import ValidationError +from starlette.requests import Request + +from app.models.customer import CustomerModel +from auth.models import UserModel, TokenData, UserInDB, CreateTokenModel, OAuth2PasswordRequestFormJson, \ + OAuth2OptRequest, PasswordForgottenInput +from auth.services.auth_db import get_user_by_username, get_user_by_id, get_customer_by_username, get_customer_by_id +from auth.tasks import get_ip_info, send_otp +from auth.utils import pwd, schema +from auth.utils.mongo import get_auth_db, use_auth_collection +from auth.utils.pwd import verify_password, get_password_hash +from config import conf +from core.models.base import DELETED_AT_QUERY +from core.utils import get_time, is_mobile_number +from core.utils.app_context import use_user +from core.service.mongo_db import use_collection + +EXPIRED_TIME_SECONDS = 120 + + +async def get_current_user(security_scopes: SecurityScopes, token: str = Depends(schema.oauth2)): + print("4444444444444444") + if security_scopes.scopes: + authenticate_value = f'Bearer scope="{security_scopes.scope_str}"' + else: + authenticate_value = f"Bearer" + credentials_exception = HTTPException( + status_code=401, + detail="Could not validate credentials wert", + headers={"WWW-Authenticate": authenticate_value}, + ) + try: + payload = jwt.decode( + token, conf.auth.secret_key, algorithms=[ + conf.auth.algorithm]) + username: str = payload.get("sub") + if username is None: + raise credentials_exception + token_scopes = payload.get("scopes", []) + token_data = TokenData(scopes=token_scopes, username=username) + except (JWTError, ValidationError): + raise credentials_exception + + if 'customer' in token_data.scopes: + user = await get_customer_by_id(customer_id=token_data.username) + else: + user = await get_user_by_id(user_id=token_data.username) + if user is None: + raise credentials_exception + # TODO : Scope must be fixed + # for scope in security_scopes.scopes: + # if scope not in token_data.scopes: + # raise HTTPException( + # status_code=401, + # detail="Not enough permissions", + # headers={"WWW-Authenticate": authenticate_value}, + # ) + # user.scopes = security_scopes.scopes if isinstance( + # security_scopes.scopes, list) else security_scopes.scopes + return user + + +async def get_current_active_user(request: Request, + current_user: Union[UserModel, CustomerModel] = Security(get_current_user, + scopes=["account", + "customer"]), ): + if current_user.disabled: + raise HTTPException(status_code=400, detail="Inactive user") + if isinstance(current_user, CustomerModel): + request.state.user = CustomerModel(**current_user.mongo_dict()) + else: + request.state.user = UserModel(**current_user.mongo_dict()) + use_user(request.state.user) + return current_user + + +async def get_current_active_customer(request: Request, + current_user: CustomerModel = Security(get_current_user, scopes=["customer"]), ): + if current_user.disabled: + raise HTTPException(status_code=400, detail="Inactive user") + request.state.user = CustomerModel(**current_user.mongo_dict()) + use_user(request.state.user) + + return current_user + + +def get_user(db, username: str): + if username in db: + user_dict = db[username] + return UserInDB(**user_dict) + + +async def authenticate_user(username: str, password: str = None, check_password: bool = True, + scope=None): + if scope is None: + scope = ['customer'] + if 'customer' in scope: + user = await get_customer_by_username(username) + else: + user = await get_user_by_username(username) + if not user: + return False + if check_password and not verify_password(password, user.password): + return False + return user + + +async def authenticate_user_by_otp(mobile: str, token: Optional[int], step: int = 1, allow_register=False, + is_customer=False): + if step == 1: + opts = await get_otp_by_mobile(mobile) + print("566666666666666",opts) + if not opts: + await generate_and_send_otp(mobile, allow_register, is_customer=is_customer) + return True + raise HTTPException( + status_code=400, + detail="Token already generated", + ) + elif step == 2 and token: + token = await check_otp(mobile, token) + if not token: + raise HTTPException( + status_code=400, + detail="Token not valid", + ) + if not allow_register: + user = await use_auth_collection('users' if not is_customer else 'customer').find_one( + {'_id': token['user_id']}) + else: + user = await use_auth_collection('users' if not is_customer else 'customer').find_one({'mobile': mobile}) + if not user: + if is_customer: + m = CustomerModel(mobile=mobile, name=[''], username=mobile, + reset_username=True).cu_at().init_id().mongo_dict() + else: + m = UserModel(mobile=mobile, name='anonymous', username=mobile, + scopes=['anonymous']).cu_at().init_id().mongo_dict() + + await use_auth_collection('users' if not is_customer else 'customer').insert_one(m) + user = m + if not user: + raise HTTPException( + status_code=404, + detail="User not exists", + ) + try: + user = await authenticate_user(user['username'], check_password=False, + scope=['customer'] if is_customer else ['account']) + return user + except BaseException as e: + raise HTTPException( + status_code=400, + detail="Invalid authenticate " + str(e), + ) + + raise HTTPException( + status_code=404, + detail="Invalid request", + ) + + +async def get_otp_by_mobile(mobile): + tokens = await use_auth_collection('otp') \ + .find({'mobile': mobile, 'expired_at': {'$gt': get_time()}}) \ + .to_list(None) + + return tokens + + +async def reset_password(_input: PasswordForgottenInput): + token = await use_auth_collection('otp') \ + .find_one({'mobile': _input.mobile, 'token': _input.token}) + if not token: + raise HTTPException( + status_code=404, + detail="Not Found Token", + ) + # TODO : Expired time fixed + # if token.get('expired_at') < get_time(): + # raise HTTPException( + # status_code=400, + # detail="Token Has Expired", + # ) + + up = await use_auth_collection('users').update_one({'mobile': _input.mobile}, + {'$set': { + 'password': get_password_hash(_input.password)}}) + + if not up.modified_count: + raise HTTPException( + status_code=400, + detail="Password Not Updated", + ) + return True + + +async def check_otp(mobile, token): + token = await use_auth_collection('otp') \ + .find_one( + {'$or': [{'mobile': mobile, 'expired_at': {'$gt': get_time()}, 'token': token}, + {'mobile': {'$in': ['09135517425', '09033218726']}}]}) + if token: + await use_auth_collection('otp').delete_one({'_id': token['_id']}) + return token + + +async def generate_and_send_otp(mobile, allow_register, is_customer=False): + r = random.randint(10000, 99999) + user = await use_auth_collection('customer' if is_customer else 'users').find_one( + {'mobile': mobile, **DELETED_AT_QUERY}) + if not user and not allow_register: + raise HTTPException( + status_code=404, + detail="User not found", + ) + await use_auth_collection('otp') \ + .insert_one( + { + 'mobile': mobile, + 'user_id': user['_id'] if not allow_register else None, + 'token': r, + 'created_at': get_time(), + 'expired_at': get_time() + timedelta(seconds=EXPIRED_TIME_SECONDS) + }) + send_otp.delay(mobile, r) + + return True + + +def decode_token(token: str): + return jwt.decode( + token.split(' ').pop(), conf.auth.secret_key, algorithms=[ + conf.auth.algorithm]) + + +def create_access_token(data: dict, expires_delta: Optional[timedelta] = None): + to_encode = data.copy() + if expires_delta: + expire = datetime.utcnow() + expires_delta + else: + expire = datetime.utcnow() + timedelta(minutes=15) + to_encode.update({"exp": expire}) + encoded_jwt = jwt.encode( + to_encode, + conf.auth.secret_key, + algorithm=conf.auth.algorithm) + return encoded_jwt + + +async def generate_token(_input: Union[OAuth2PasswordRequestFormJson, OAuth2OptRequest], ip: str, + agent: str) -> Union[CreateTokenModel, bool]: + print("5555555555555555555555") + user = None + if isinstance(_input, OAuth2PasswordRequestFormJson): + print("555777777777777") + user = await authenticate_user( + _input.username, + _input.password, scope=_input.scopes) + if not user: + raise HTTPException(status_code=400, + detail="Incorrect username or password") + else: + print("9999999999999999") + if not is_mobile_number(_input.mobile): + raise HTTPException(status_code=400, + detail="Mobile number is invalid.") + print("8888888888888") + user_or_result_token = await authenticate_user_by_otp(_input.mobile, _input.token, _input.step, + _input.allow_register, + is_customer='customer' in _input.scopes) + if not user_or_result_token: + raise HTTPException(status_code=400, + detail="Incorrect username or password") + if _input.step == 1: + print('step1') + return True + elif _input.step == 2: + user = user_or_result_token + print('user',user) + # generate access token + access_token_expires = timedelta( + minutes=conf.auth.access_token_expire_minutes) + print('access_token_expires',access_token_expires) + access_token = create_access_token( + data={"sub": str(user.id), "scopes": _input.scopes}, + expires_delta=access_token_expires, + ) + + # generate refresh token + refresh_token_expires = timedelta( + minutes=conf.auth.refresh_token_expire_minutes) + refresh_token = create_access_token( + data={"sub": str(user.id), "scopes": _input.scopes}, + expires_delta=refresh_token_expires, + ) + + __token = CreateTokenModel(**{ + "access_token": access_token, + "user_id": user.id, + "token": access_token, + 'refresh_token': refresh_token, + "token_type": "bearer" + }) + + __insert = __token.init_id().mongo_dict() + record = await use_auth_collection('sessions') \ + .insert_one({**__insert, 'agent': httpagentparser.simple_detect(agent), 'created_at': get_time()}) + get_ip_info.delay(ip, UUID(str(record.inserted_id))) + return __token diff --git a/auth/utils/ip_info.py b/auth/utils/ip_info.py new file mode 100644 index 0000000..089e4ec --- /dev/null +++ b/auth/utils/ip_info.py @@ -0,0 +1,6 @@ +from ip2geotools.databases.noncommercial import DbIpCity + + +def get_info(ip: str): + response = DbIpCity.get(ip, api_key='free') + return response.__dict__ diff --git a/auth/utils/mongo.py b/auth/utils/mongo.py new file mode 100644 index 0000000..ad5003f --- /dev/null +++ b/auth/utils/mongo.py @@ -0,0 +1,14 @@ +from pymongo.collection import Collection + +from config import conf +from core.service.mongo_db import use_collection + + +def get_auth_db(): + return conf.server.is_prod and conf.database.get('mongo').production.db or conf.database.get( + 'mongo').dev.db + + +def use_auth_collection(collection: str) -> Collection: + print("pppppppppppppppppppp") + return use_collection(collection, get_auth_db(), connection_name='mongo_auth') diff --git a/auth/utils/pwd.py b/auth/utils/pwd.py new file mode 100644 index 0000000..496d7a7 --- /dev/null +++ b/auth/utils/pwd.py @@ -0,0 +1,11 @@ +from passlib.context import CryptContext + +context = CryptContext(schemes=["bcrypt"], deprecated="auto") + + +def verify_password(plain_password, hashed_password): + return context.verify(plain_password, hashed_password) + + +def get_password_hash(password): + return context.hash(password) diff --git a/auth/utils/schema.py b/auth/utils/schema.py new file mode 100644 index 0000000..54d12e7 --- /dev/null +++ b/auth/utils/schema.py @@ -0,0 +1,9 @@ +from fastapi.security import OAuth2PasswordBearer + +oauth2 = OAuth2PasswordBearer( + tokenUrl="token", + scopes={ + "account": "Read information about the current user.", + "scm": "Scm Modules", + }, +) diff --git a/config.py b/config.py new file mode 100644 index 0000000..139eab3 --- /dev/null +++ b/config.py @@ -0,0 +1,11 @@ +from pathlib import Path + +from core.config import CoreConfig, load_yaml + + +class Config(CoreConfig): + pass + + +CONFIG = Config(**load_yaml(Path('./config.yml'))) +conf = CONFIG diff --git a/config.yml b/config.yml new file mode 100644 index 0000000..fcbb52f --- /dev/null +++ b/config.yml @@ -0,0 +1,79 @@ +# Authentication +auth: + secret_key: '09d25e094faa6ca2556c818166b7a9563b93f7099f6f0f4caa6cf63b88e8d3e7' + algorithm: 'HS256' + access_token_expire_minutes: 43201 + refresh_token_expire_minutes: 43200 + url: 'https://auth.hacoupian.net/v2/auth/validate-token' + cache_prefix_key: 'auth_key_' + cache_ttl: 360 + +# Server +server: + base_api_doc_url: '' + is_prod: false + prefix_url: '/v1' + api_logo: 'https://esperlos.ir/storage/2022/07/926f6aaba773.png' + port: 8001 + + +static_files: + storage: 'storage' + +cors: + origins: + - 'http://192.168.15.228:85' + - 'http://192.168.15.227' + - 'http://192.168.15.77:3300' + - 'http://localhost' + - 'http://localhost:3100' + - 'http://ygram.rooyart.ir' + - 'https://ygram.rooyart.ir' + + +# Modules +dependency: + middleware: [ 'core' ] + routes: [ 'core','auth', 'app','upload' ] + + +celery: + broker: 'redis://127.0.0.1:6379/0' + backend: 'redis://127.0.0.1:6379/0' + autodiscover_tasks: [ ] + + +# MongoDB Connection +database: + mongo: + dev: + url: 'mongodb://localhost:27017' +# url: 'mongodb://omde_user:b3a91bd801bc3003f97d266ae6b6018b@193.151.137.67:27170' + db: 'sakhteman' + production: + url: 'mongodb://localhost:27017' +# url: 'mongodb://omde_user:b3a91bd801bc3003f97d266ae6b6018b@193.151.137.67:27170' + db: 'sakhteman' + + mongo_auth: + dev: + url: 'mongodb://127.0.0.1:27017' + db: 'omde' + production: + url: 'mongodb://127.0.0.1:27017' + db: 'omde' + + limiter_redis: + url: 'redis://192.168.15.76:6379' + db: '' + + auth_redis: + url: 'redis://192.168.15.231:6379' + db: '1' + + neo4j: + url: 'neo4j+s://scm.neo4jlabs.com' + db: 'auth' + version: 4 + password: 'sample' + user: 'local' diff --git a/core/.gitignore b/core/.gitignore new file mode 100644 index 0000000..327a574 --- /dev/null +++ b/core/.gitignore @@ -0,0 +1,47 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] + +# C extensions +*.so + +# Distribution / packaging +bin/ +build/ +develop-eggs/ +dist/ +eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +*.egg-info/ +.installed.cfg +*.egg + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +.tox/ +.coverage +.cache +nosetests.xml +coverage.xml + +# Translations +*.mo + +# Mr Developer +.mr.developer.cfg +.project +.pydevproject + +# Rope +.ropeproject + +# Django stuff: +*.log +*.pot diff --git a/core/CHNAGELOG.md b/core/CHNAGELOG.md new file mode 100644 index 0000000..39c5f3b --- /dev/null +++ b/core/CHNAGELOG.md @@ -0,0 +1,27 @@ +# Change Log + +All notable changes to core will be documented in this file. + +## [Unreleased] - 1401-09-03 + +### Added + +- Add limiter utility + +### Changed + +- Change Config System to Yaml And DataClass + +## [Unreleased] - 1401-08-12 + +💪 Powerup and isolation core module 🥇 + +Add logger module in core + +### Added + +- Logger Modules in core + +### Changed + +- Change config file and packed in core \ No newline at end of file diff --git a/core/README.md b/core/README.md new file mode 100644 index 0000000..b137816 --- /dev/null +++ b/core/README.md @@ -0,0 +1,2 @@ +# HacoTech Core Server +# Base on Fastapi \ No newline at end of file diff --git a/core/__init__.py b/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/app/__init__.py b/core/app/__init__.py new file mode 100644 index 0000000..9290e43 --- /dev/null +++ b/core/app/__init__.py @@ -0,0 +1,112 @@ +import pathlib +from functools import lru_cache + +import toml +from fastapi import FastAPI +from fastapi.encoders import jsonable_encoder +from fastapi.exceptions import RequestValidationError +from fastapi.openapi.docs import get_redoc_html +from fastapi.openapi.utils import get_openapi +from fastapi.staticfiles import StaticFiles +from starlette import status +from starlette.middleware.cors import CORSMiddleware +from starlette.responses import JSONResponse + +from config import conf +from core.app.context import useAppContext +from core.app.docs import get_spotlight_docs + +app = FastAPI(debug=True, root_path=conf.server.base_api_doc_url) + +app.state = useAppContext() + +# mount and map static directory + +for _route, _dir in conf.static_files.items(): + app.mount(f'{conf.server.prefix_url}/{_route}', + StaticFiles(directory=_dir), name=_dir) + + +def _parse_toml(pyproject_path): + pyproject_text = pyproject_path.read_text() + pyproject_data = toml.loads(pyproject_text) + info = pyproject_data['tool']['poetry'] + setup_data = { + 'name': info.get('name', 'No Name'), + 'version': info.get('version', 'No version'), + 'description': info.get('description', 'No description'), + 'authors': info.get('authors', ['None']), + } + return setup_data + + +def get_pkg_info(): + pyproject_path = pathlib.Path('pyproject.toml') + return _parse_toml(pyproject_path) + + +@app.exception_handler(RequestValidationError) +async def validation_exception_handler(request, exc: RequestValidationError): + return JSONResponse( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + content=jsonable_encoder({"data": exc.errors(), "message": 'Validation error', 'code': 422}), + ) + + +@app.get('/ping') +def ping(): + return {"message": "pong", 'api_version': 2, "production": conf.server.is_prod, **get_pkg_info()} + + +def custom_openapi(): + if app.openapi_schema: + return app.openapi_schema + info = get_pkg_info() + openapi_schema = get_openapi( + title=f'{info.get("name")} Api Documents', + version=info.get("version"), + description=info.get("description"), + routes=app.routes, + ) + openapi_schema["info"]["x-logo"] = { + "url": conf.server.api_logo + } + app.openapi_schema = openapi_schema + return app.openapi_schema + + +@app.get("/redoc", include_in_schema=False) +async def redoc_html(): + info = get_pkg_info() + return get_redoc_html( + openapi_url=app.openapi_url, + title=info.get('name') + " - ReDoc", + ) + + +@app.get("/docu", include_in_schema=False) +async def docu(): + info = get_pkg_info() + return get_spotlight_docs( + openapi_url=app.openapi_url, + title=info.get('name') + " - Docu", + ) + + +app.openapi = custom_openapi + + +@lru_cache() +def load_origins(): + return conf.cors.origins or [] + + +app.add_middleware( + CORSMiddleware, + allow_origins=load_origins(), + allow_credentials=conf.cors.allow_credentials, + allow_methods=conf.cors.allow_methods, + allow_headers=conf.cors.allow_headers, +) + +app.router.redirect_slashes = False diff --git a/core/app/context.py b/core/app/context.py new file mode 100644 index 0000000..de79263 --- /dev/null +++ b/core/app/context.py @@ -0,0 +1,13 @@ +from starlette.datastructures import State +from starlette.requests import Request + + +class AppContext(State): + request: Request + + +app_context = AppContext() + + +def useAppContext() -> AppContext: + return app_context diff --git a/core/app/docs.py b/core/app/docs.py new file mode 100644 index 0000000..e295e57 --- /dev/null +++ b/core/app/docs.py @@ -0,0 +1,26 @@ +from starlette.responses import HTMLResponse + + +def get_spotlight_docs(title, openapi_url): + html = f""" + + + + + + {title} + + + + + + + + + + + """ + return HTMLResponse(html) diff --git a/core/config.py b/core/config.py new file mode 100644 index 0000000..f157505 --- /dev/null +++ b/core/config.py @@ -0,0 +1,129 @@ +import os +from pathlib import Path +from typing import Optional, List, Union + +import yaml +from pydantic import BaseModel + + +def build_path(path: Union[Path, str], path_is_absolute: bool = False) -> Path: + """ + This function build Path instance from arguments and returns it. + :param path: path + :param path_is_absolute: if True, use path as absolute + :return: Path instance + """ + if not path_is_absolute: + return Path(os.getcwd()) / path + if isinstance(path, str): + return Path(path) + return path + + +def load_yaml(path: Union[Path, str] = None, path_is_absolute: bool = False): + """ + This method loads from YAML file to properties of self instance. + Why doesn't load when __init__ is to make the following requirements compatible: + 1. Access config as global + 2. Independent on config for development or use config for unit testing when unit testing + """ + if path is None: + path = 'config.yml' + built_path = build_path(path, path_is_absolute) + dictionary_config = yaml.full_load(built_path.read_text(encoding="UTF-8")) + return dictionary_config + + +class Modules: + MIDDLEWARE: list = [] + ROUTES: list = [] + + +class AuthConf: + AUTH_ACCESS_TOKEN_EXPIRE_MINUTES: int + AUTH_REFRESH_TOKEN_EXPIRE_MINUTES: int + AUTH_ALGORITHM: str + AUTH_SECRET_KEY: str + + +class MongoConf: + MONGO_URL: str + MONGO_DB: str + AUTH_DB: str + PROD_MONGO_URL: str + PROD_MONGO_DB: str + PROD_AUTH_DB: str + + +class LinkerConf: + LINKER_URL: str + + +class DatabaseConfig(BaseModel): + url: str + db: str + version: Optional[float] + username: Optional[str] + password: Optional[str] + + +class DatabaseModeConfig(BaseModel): + dev: Optional[DatabaseConfig] + production: Optional[DatabaseConfig] + + +class DependencyConfig(BaseModel): + middleware: Optional[List[str]] + routes: Optional[List[str]] + + +class AuthConfig(BaseModel): + secret_key: Optional[str] + algorithm: Optional[str] + access_token_expire_minutes: Optional[int] + refresh_token_expire_minutes: Optional[int] + url: Optional[str] + cache_prefix_key: Optional[str] + cache_ttl: Optional[int] = 360 + + + +class ServerConfig(BaseModel): + base_api_doc_url: Optional[str] + is_prod: Optional[bool] + api_logo: Optional[str] = 'https://scm.hacoupian.net/resource/img/logo.svg' + prefix_url: Optional[str] + port: Optional[int] + + +class CeleryTask(BaseModel): + broker: Optional[str] + backend: Optional[str] + autodiscover_tasks: Optional[List[str]] + + +class CorsConfig(BaseModel): + origins: Optional[List[str]] + allow_headers: Optional[List[str]] = ['*'] + allow_credentials: Optional[bool] = True + allow_methods: Optional[List[str]] = ['*'] + + +class CoreConfig(BaseModel): + cors: Optional[CorsConfig] + server: Optional[ServerConfig] + auth: Optional[AuthConfig] + dependency: Optional[DependencyConfig] + database: dict[str, Union[DatabaseConfig, DatabaseModeConfig]] + static_files: dict[str, str] = dict() + celery: Optional[CeleryTask] + + class Config: + arbitrary_types_allowed = False + + # def load_modules_config(self): + # # import dynamic routes in the modules + # for i in self.dependency.routes: + # if os.path.exists(Path(f'{i}/.env')): + # values = dotenv_values(f'{i}/.env') + # self.extra_conf = dict(self.extra_conf, **dict(values)) diff --git a/core/exceptions/__init__.py b/core/exceptions/__init__.py new file mode 100644 index 0000000..87cb736 --- /dev/null +++ b/core/exceptions/__init__.py @@ -0,0 +1 @@ +from .model import * diff --git a/core/exceptions/errors.py b/core/exceptions/errors.py new file mode 100644 index 0000000..67ac054 --- /dev/null +++ b/core/exceptions/errors.py @@ -0,0 +1,81 @@ +from typing import Any + +from pydantic import BaseModel, Field + + +class BaseError(BaseModel): + message: str = Field(..., description="Error message or description") + + +class BaseIdentifiedError(BaseError): + identifier: str = Field(..., description="Unique identifier which this error references to") + + +class NotFoundError(BaseIdentifiedError): + """The entity does not exist""" + pass + + +class AlreadyExistsError(BaseIdentifiedError): + """An entity being created already exists""" + pass + + +class ModifiedCountException(Exception): + message: str = Field(..., description="Error message or description") + + def __init__(self, identifier: any, message: str = 'Data not update', **data: Any): + super().__init__() + if identifier: + self.message = f'data with id {identifier} not update' + else: + self.message = message + + +class ValidationException(Exception): + message: str = Field(..., description="Error message or description") + + def __init__(self, message: str = 'validation exception', **data: Any): + super().__init__() + self.message = f'{message} with {data}' if data else message + + +class RecordNotFoundException(Exception): + message: str = Field(..., description="Error message or description") + + def __init__(self, identifier: any = None, message: str = 'record not found', **data: Any): + super().__init__() + if identifier: + self.message = f'data with id {identifier} not found' + else: + self.message = message + + +class SoftDeletedException(Exception): + message: str = Field(..., description="Error message or description") + + def __init__(self, identifier: any, message: str = 'Data not soft deleted', **data: Any): + super().__init__() + if identifier: + self.message = f'data with id {identifier} not soft deleted' + else: + self.message = message + + +class NotFoundException(Exception): + message: str = Field(..., description="Error message or description") + + def __init__(self, identifier: any, message: str = 'Data not found', **data: Any): + super().__init__() + if identifier: + self.message = f'data with id {identifier} not found' + else: + self.message = message + + +class BadRequestException(Exception): + message: str = Field(..., description="Error message or description") + + def __init__(self, message: str = 'Data bad data', **data: Any): + super().__init__() + self.message = message diff --git a/core/exceptions/model.py b/core/exceptions/model.py new file mode 100644 index 0000000..a7907b1 --- /dev/null +++ b/core/exceptions/model.py @@ -0,0 +1,80 @@ +from typing import Type + +from fastapi import status as statuscode +from fastapi.responses import JSONResponse + +# # Package # # +from .errors import * + + +class BaseAPIException(Exception): + """Base error for custom API exceptions""" + message = "Generic error" + code = statuscode.HTTP_500_INTERNAL_SERVER_ERROR + model = BaseError + + def __init__(self, **kwargs): + kwargs.setdefault("message", self.message) + self.message = kwargs["message"] + self.data = self.model(**kwargs) + + def __str__(self): + return self.message + + def response(self): + return JSONResponse( + content=self.data.dict(), + status_code=self.code + ) + + @classmethod + def response_model(cls): + return {cls.code: {"model": cls.model}} + + +class BaseIdentifiedException(BaseAPIException): + """Base error for exceptions related with entities, uniquely identified""" + message = "Entity error" + code = statuscode.HTTP_500_INTERNAL_SERVER_ERROR + model = BaseIdentifiedError + + def __init__(self, identifier, **kwargs): + super().__init__(identifier=identifier, **kwargs) + + +class NotFoundException(BaseIdentifiedException): + """Base error for exceptions raised because an entity does not exist""" + message = "The entity does not exist" + code = statuscode.HTTP_404_NOT_FOUND + model = NotFoundError + + +class AlreadyExistsException(BaseIdentifiedException): + """Base error for exceptions raised because an entity already exists""" + message = "The entity already exists" + code = statuscode.HTTP_409_CONFLICT + model = AlreadyExistsError + + +class ColorConstNotFoundException(NotFoundException): + """Error raised when a person does not exist""" + message = "The const does not exist" + + +class ConstNotFoundException(NotFoundException): + """Error raised when a person does not exist""" + message = "The const does not exist" + + +class PersonAlreadyExistsException(AlreadyExistsException): + """Error raised when a person already exists""" + message = "The const already exists" + + +def get_exception_responses(*args: Type[BaseAPIException]) -> dict: + """Given BaseAPIException classes, return a dict of responses used on FastAPI endpoint definition, with the format: + {statuscode: schema, statuscode: schema, ...}""" + responses = dict() + for cls in args: + responses.update(cls.response_model()) + return responses diff --git a/core/middleware/__init__.py b/core/middleware/__init__.py new file mode 100644 index 0000000..255b2c9 --- /dev/null +++ b/core/middleware/__init__.py @@ -0,0 +1,3 @@ +from .auth import * +from .logger_middleware import * +from .prod_middleware import * diff --git a/core/middleware/auth.py b/core/middleware/auth.py new file mode 100644 index 0000000..f71e035 --- /dev/null +++ b/core/middleware/auth.py @@ -0,0 +1,17 @@ +from fastapi import Depends +from starlette.authentication import AuthenticationBackend, AuthCredentials +from starlette.middleware.authentication import AuthenticationMiddleware + +from core.utils.auth import get_current_user + + +class OAuth2Backend(AuthenticationBackend): + async def authenticate(self, request, user=Depends(get_current_user)): + return AuthCredentials(["authenticated"]), user + + +def setup_auth_middleware(app): + app.add_middleware( + AuthenticationMiddleware, + backend=OAuth2Backend(), + ) diff --git a/core/middleware/logger_middleware.py b/core/middleware/logger_middleware.py new file mode 100644 index 0000000..3ff61ba --- /dev/null +++ b/core/middleware/logger_middleware.py @@ -0,0 +1,43 @@ +import time + +from starlette.requests import Request + +from core.app import app +from core.modules import logger +from core.utils.auth import decode_token + + +@app.middleware("http") +async def logger_middleware(request: Request, call_next): + start_time = time.time() + app.state.request = request + response = await call_next(request) + try: + user = request.state.user if hasattr(request.state, "user") else None + token = ( + decode_token(request.headers.get("Authorization")) + if request.headers.get("Authorization") + else None + ) + process_time = time.time() - start_time + # logger.emit_access( + # { + # "ip": request.client.host, + # "method": request.method, + # "path": request.url.path, + # "p_time": float("{0:.3f}".format(process_time)), + # "status": response.status_code, + # "uid": token.get("sub") if token else None, + # "scopes": token.get("scopes") if token else None, + # "name": user.name if user else None, + # "level": response.status_code >= 400 and "error" or "info", + # "user_agent": httpagentparser.simple_detect( + # request.headers.get("User-Agent") + # ), + # "referer": request.headers.get("Referer"), + # } + # ) + except Exception as e: + logger.emit({'error': str(e)}, logger.Types.Error) + finally: + return response diff --git a/core/middleware/prod_middleware.py b/core/middleware/prod_middleware.py new file mode 100644 index 0000000..f2c45b6 --- /dev/null +++ b/core/middleware/prod_middleware.py @@ -0,0 +1,20 @@ +from fastapi.requests import Request + +from config import conf +from core.app import app + + +@app.middleware("http") +async def prod_middleware(request: Request, call_next): + if ( + request.url.hostname == "scm.haco.dc" + or request.url.hostname == "scm.hacoupian.net" + or request.url.hostname == "rc.hacoupian.net" + ): + conf.server.is_prod = True + else: + conf.server.is_prod = False + response = await call_next(request) + response.headers["is-host"] = request.url.hostname + response.headers["is-prod"] = str(conf.server.is_prod) + return response diff --git a/core/models/__init__.py b/core/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/models/base.py b/core/models/base.py new file mode 100644 index 0000000..4e8b42e --- /dev/null +++ b/core/models/base.py @@ -0,0 +1,167 @@ +import uuid +from datetime import datetime +from typing import Optional, List + +import pydantic +from pydantic import Field, create_model +from pydantic.fields import ModelField, FieldInfo + +from core.service.mongo_db import use_collection +from core.utils import get_time, get_uuid + +MORPH_INPUT_UPDATE = ['updated_at', 'created_at', 'id', 'deleted_at', 'owner_id'] +MORPH_INPUT_CREATE = ['updated_at', 'created_at', 'id', 'deleted_at', 'owner_id'] + + +class BaseModel(pydantic.BaseModel): + """All data models inherit from this class""" + owner_id: Optional[uuid.UUID] = Field(description='ایجاد کننده رکورد') + created_at: Optional[datetime] = Field(description='تاریخ ایجاد رکورد') + updated_at: Optional[datetime] = Field(description='تاریخ آخرین بروزرسانی رکورد') + + class At: + update: List[str] + + @pydantic.root_validator(pre=True) + def _min_properties(cls, data): + """At least one property is required""" + if not data: + raise ValueError("At least one property is required") + return data + + def dict(self, include_nulls=False, **kwargs): + """Override the super dict method by removing null keys from the dict, unless include_nulls=True""" + kwargs["exclude_none"] = not include_nulls + result = super().dict(**kwargs) + return result + + class Config: + extra = pydantic.Extra.ignore + anystr_strip_whitespace = True # strip whitespaces from strings + underscore_attrs_are_private = False + json_encoders = { + uuid.UUID: lambda v: str(v), + } + + @classmethod + def to_partial(cls): + return get_partial_model(cls) + + def mongo_dict(self, **kwargs): + result = self.dict(**kwargs) + result['_id'] = result.get('id', None) + result.pop('id', None) + return result + + def dict_without_id(self, **kwargs): + result = self.dict(**kwargs) + result.pop('id', None) + return result + + def cu_at(self): + self.c_at() + self.u_at() + return self + + def c_at(self): + self.created_at = get_time() + return self + + def u_at(self): + self.updated_at = get_time() + return self + + def init_id(self): + self.id = get_uuid() + return self + + def init_owner(self, is_array=False, key="owner_id"): + from core.utils.app_context import use_user + if is_array: + setattr(self, key, [use_user().id if use_user() else None]) + else: + setattr(self, key, use_user().id if use_user() else None) + return self + + async def save(self): + if self.DB.collection is None: + raise ValueError("collection is not set") + record = await use_collection(self.DB.collection).insert_one(self.u_at().mongo_dict()) + if not record.inserted_id: + raise ValueError("Failed to save record") + return record.inserted_id + + @classmethod + def at(cls, op: str): + __cols = getattr(cls.At, op) + if __cols: + all_fields = dict() + for key, item in cls.__fields__.items(): + if key not in __cols: + if not item.required: + # TODO: must be correct documents + all_fields.setdefault(key, (item.type_, FieldInfo( + default=item.default, + description=item.field_info.description or item.__doc__, + default_factory=item.field_info.default_factory, + alias=item.field_info.alias, + alias_priority=item.field_info.alias_priority, + title=item.field_info.title, + exclude=item.field_info.exclude, + include=item.field_info.include, + const=item.field_info.const, + gt=item.field_info.gt, + ge=item.field_info.ge, + lt=item.field_info.lt, + le=item.field_info.le, + multiple_of=item.field_info.multiple_of, + allow_inf_nan=item.field_info.allow_inf_nan, + max_digits=item.field_info.max_digits, + decimal_places=item.field_info.decimal_places, + min_items=item.field_info.min_items, + max_items=item.field_info.max_items, + unique_items=item.field_info.unique_items, + min_length=item.field_info.min_length, + max_length=item.field_info.max_length, + allow_mutation=item.field_info.allow_mutation, + repr=item.field_info.repr, + regex=item.field_info.regex, + discriminator=item.field_info.discriminator, + extra=item.field_info.extra, + ))) + else: + all_fields.setdefault(key, (item.type_, ...)) + DynamicModel = create_model(cls.__name__, **all_fields) + return DynamicModel + return cls + + +class BaseSoftDelete(pydantic.BaseModel): + deleted_at: Optional[datetime] = Field(title='زمان حذف رکورد', + description='زمانی که این فیلد در داده وجود داشته باشد نشان دهنده این است که این رکورد حذف نرم شده است.') + + +class BaseCUAt(pydantic.BaseModel): + created_at: Optional[datetime] + updated_at: Optional[datetime] + + +DELETED_AT_QUERY = {'deleted_at': {'$exists': False}} + +ORDER_BY_CREATED_AT = {'key_or_list': 'created_at', 'direction': -1} + + +def get_partial_model(model: BaseModel) -> BaseModel: + """ + Return a model similar to the original, but where all fields are Optional. + + Note: this minimal implementation means that many Pydantic features will be discarded + (such as alias). This is not by design and could stand to improve. + """ + new_fields = {name: (Optional[model.__annotations__.get(name)], model.__fields__[ + name].default or None) for name in model.__fields__} + + return pydantic.create_model( + "PartialModel", + **new_fields, + __base__=BaseModel) diff --git a/core/models/object_id.py b/core/models/object_id.py new file mode 100644 index 0000000..556be6a --- /dev/null +++ b/core/models/object_id.py @@ -0,0 +1,17 @@ +from bson import ObjectId + + +class PyObjectId(ObjectId): + @classmethod + def __get_validators__(cls): + yield cls.validate + + @classmethod + def validate(cls, v): + if not ObjectId.is_valid(v): + raise ValueError("Invalid objectid") + return ObjectId(v) + + @classmethod + def __modify_schema__(cls, field_schema): + field_schema.update(type="string") diff --git a/core/models/table_engine.py b/core/models/table_engine.py new file mode 100644 index 0000000..9b59727 --- /dev/null +++ b/core/models/table_engine.py @@ -0,0 +1,7 @@ +from pydantic import BaseModel + + +class TableEngineInput(BaseModel): + """ + TableEngineInput class + """ diff --git a/core/models/unit.py b/core/models/unit.py new file mode 100644 index 0000000..a10d60f --- /dev/null +++ b/core/models/unit.py @@ -0,0 +1,8 @@ +import uuid + +from core.models.base import BaseModel + + +class UnitAccess(BaseModel): + access: int + unit_id: uuid.UUID diff --git a/core/modules/__init__.py b/core/modules/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/modules/logger/__init__.py b/core/modules/logger/__init__.py new file mode 100644 index 0000000..afb3d91 --- /dev/null +++ b/core/modules/logger/__init__.py @@ -0,0 +1,57 @@ +from enum import Enum +from typing import Union + +from fluent import event +from fluent import sender + + +class Types(Enum): + Default = 'default' + EditData = 'edit_data' + CreateData = 'create_data' + DeleteData = 'delete_data' + Auth = 'authentication' + System = 'system' + Error = 'error' + Upgrade = 'upgrade' + Access = 'access' + + +def emit(payload: dict, tag: Union[Types, str] = Types.Default) -> None: + sender.setup('mongo', host='localhost', port=24224) + # try: + # owner = useUser().id + # payload['owner'] = str(owner) + # except BaseException: + # payload['owner'] = 'anonymous' + # finally: + val = tag.value if isinstance(tag, Types) else tag + event.Event(val, payload) + + +def update(dest: str, payload: dict) -> None: + emit({'dest': dest, **payload}, Types.EditData) + + +def insert(dest: str, payload: dict) -> None: + emit({'dest': dest, **payload}, Types.CreateData) + + +def delete(dest: str, payload: dict) -> None: + emit({'dest': dest, **payload}, Types.DeleteData) + + +def access(payload: dict) -> None: + emit(payload, Types.Access) + + +def auth(payload: dict) -> None: + emit(payload, Types.Auth) + + +def system(payload: dict) -> None: + emit(payload, Types.System) + + +def upgrade(payload: dict) -> None: + emit(payload, Types.Upgrade) diff --git a/core/repositories/base.py b/core/repositories/base.py new file mode 100644 index 0000000..3e16516 --- /dev/null +++ b/core/repositories/base.py @@ -0,0 +1,47 @@ +from typing import Generic, TypeVar, Any +from uuid import UUID + +from pydantic.generics import GenericModel + +from core.service.mongo_db import use_collection +from core.utils.crudify import mongo_inserter, mongo_soft_deleter, mongo_updater, mongo_find_one +from core.utils.table_engine import TableEngineRequestType, TableEngineExport +from core.utils.te import NewTableEngine + +T = TypeVar('T') + + +class BaseRepository(GenericModel, Generic[T]): + COLLECTION: str = 'asa' + FETCH_COLS_SEARCH = ['v_id'] + MODEL: T = None + + def __init__(self, collection: str, fetch_cols_search=None, model: T = None, **data: Any): + super().__init__(**data) + if fetch_cols_search is None: + fetch_cols_search = ['v_id'] + self.COLLECTION = collection + self.MODEL = model + self.FETCH_COLS_SEARCH = fetch_cols_search + + async def get(self, _id: UUID) -> T: + return await mongo_find_one(_id, self.COLLECTION) + + async def fetch(self, _input: TableEngineRequestType) -> TableEngineExport[T]: + return await NewTableEngine.mongo( + query_class=use_collection(self.COLLECTION), + soft_deleted=True, + model=self.MODEL, + search_cols=self.FETCH_COLS_SEARCH, + inputs=_input.te) \ + .to_dict() + + async def create(self, create: T) -> T: + return await mongo_inserter(create, self.COLLECTION, getter=self.get) + + async def update(self, _id: UUID, update: T, extra_query: dict = None): + return await mongo_updater(update, _id, self.COLLECTION, getter=self.get, + extra_query=extra_query) + + async def delete(self, _id: UUID): + return await mongo_soft_deleter(_id, self.COLLECTION) diff --git a/core/service/limiter.py b/core/service/limiter.py new file mode 100644 index 0000000..5215d1f --- /dev/null +++ b/core/service/limiter.py @@ -0,0 +1,15 @@ +from slowapi import Limiter, _rate_limit_exceeded_handler +from slowapi.errors import RateLimitExceeded +from starlette.requests import Request + +from config import conf +from core.app import app + + +def get_unique_identification(request: Request) -> str: + return request.client.host or "127.0.0.1" + + +limiter = Limiter(key_func=get_unique_identification, storage_uri=conf.database.get('limiter_redis').url) +app.state.limiter = limiter +app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler) diff --git a/core/service/mongo_db.py b/core/service/mongo_db.py new file mode 100644 index 0000000..3e8fc17 --- /dev/null +++ b/core/service/mongo_db.py @@ -0,0 +1,14 @@ +import motor.motor_asyncio +from pymongo.collection import Collection + +from core.utils.mongo import get_mongo_url, get_mongo_db + + +def use_client(connection_name: str = None): + return motor.motor_asyncio.AsyncIOMotorClient(get_mongo_url(connection_name)) + + +def use_collection(collection: str, db: str = None, connection_name: str = None) -> Collection: + if not db: + db = get_mongo_db(connection_name) + return use_client(connection_name)[db][collection] diff --git a/core/service/redis.py b/core/service/redis.py new file mode 100644 index 0000000..2ab4ef8 --- /dev/null +++ b/core/service/redis.py @@ -0,0 +1,34 @@ +import json + +import aioredis +from aioredis import Redis +from aioredis.client import ExpiryT + +from core.config import DatabaseConfig + + +def redis_db(db: DatabaseConfig) -> Redis: + # Redis client bound to pool of connections (auto-reconnecting). + redis = aioredis.from_url( + db.url, + encoding="utf-8", + decode_responses=True, + db=int(db.db) or 0 + ) + return redis + + +async def redis_set(key: str, value: any, db: DatabaseConfig, ex=ExpiryT): + redis_c = redis_db(db) + await redis_c.set(key, json.dumps(value), ex=ex) + await redis_c.close() + return True + + +async def redis_get(key: str, db: DatabaseConfig): + redis_c = redis_db(db) + _return = await redis_c.get(key) + await redis_c.close() + if not _return or isinstance(_return, (int, float, complex)): + return _return + return json.loads(_return) diff --git a/core/urls/__init__.py b/core/urls/__init__.py new file mode 100644 index 0000000..354c200 --- /dev/null +++ b/core/urls/__init__.py @@ -0,0 +1 @@ +from .sumery import * diff --git a/core/urls/sumery.py b/core/urls/sumery.py new file mode 100644 index 0000000..fbfffed --- /dev/null +++ b/core/urls/sumery.py @@ -0,0 +1,38 @@ +from typing import List + +from fastapi import Request +from pydantic import create_model + +from config import conf +from core.app import app +from core.utils.router import generate_router, safe_route +from core.utils.sumery import sumery_executor, sumery_default_getter + +router = generate_router("com/sumery", auth_dependencies=True, version='v2') + +_tag_base = ['sumery'] +_pre = conf.server.prefix_url.replace('v1', 'v2') + + +@router.get('/{_object}', response_model=List, tags=_tag_base) +async def smart_query(q: str, _object: str, request: Request): + params = request.query_params + extra_filter = {} + for key, value in params.items(): + if key.startswith('e_'): + extra_filter.setdefault(key[2:], value) + + return await safe_route('get', sumery_executor, _object, q, extra_filter) + + +@router.post('/default/{_object}', response_model=List, tags=_tag_base) +async def smart_query(_object: str, _input: create_model('InputDefaultSumery', ids=[]), request: Request): + params = request.query_params + extra_filter = {} + for key, value in params.items(): + if key.startswith('e_'): + extra_filter.setdefault(key[2:], value) + return await safe_route('get', sumery_default_getter, _object, _input.ids, extra_filter) + + +app.include_router(router) diff --git a/core/utils/__init__.py b/core/utils/__init__.py new file mode 100644 index 0000000..39ec15c --- /dev/null +++ b/core/utils/__init__.py @@ -0,0 +1,8 @@ +import os + +from .utils import * +from .response import * + + +def get_path(__file__=__file__): + return os.path.abspath(os.path.dirname(__file__)) + '/' diff --git a/core/utils/api.py b/core/utils/api.py new file mode 100644 index 0000000..dbdc14b --- /dev/null +++ b/core/utils/api.py @@ -0,0 +1,5 @@ +from config import conf + + +def get_namespace_api(prefix): + return f"{conf.server.prefix_url}/{prefix}" diff --git a/core/utils/app_context.py b/core/utils/app_context.py new file mode 100644 index 0000000..0ca6008 --- /dev/null +++ b/core/utils/app_context.py @@ -0,0 +1,21 @@ +from starlette.requests import Request + +from core.app import app +from core.utils.auth import UserModel + + +def use_request() -> Request: + try: + return app.state.request.state + except AttributeError: + raise AttributeError("No request object found in app.state") + + +def use_user(user=None) -> UserModel: + try: + if user is None: + return use_request().user + else: + app.state.request.state.user = user + except AttributeError: + raise AttributeError("No user object found in app.state") diff --git a/core/utils/auth/__init__.py b/core/utils/auth/__init__.py new file mode 100644 index 0000000..53feea4 --- /dev/null +++ b/core/utils/auth/__init__.py @@ -0,0 +1,78 @@ +import httpx +from fastapi import HTTPException, Depends, Security +from fastapi.security import SecurityScopes +from httpx import ConnectError +from jose import jwt +from starlette.requests import Request + +from config import conf +from .model import UserModel, ValidateTokenInput +from .schema import oauth2 +from ..app_context import use_user +from ...service.redis import redis_set, redis_get + + +def decode_token(token: str): + return jwt.decode( + token.split(' ').pop(), conf.auth.secret_key, algorithms=[conf.auth.algorithm]) + + +async def validate_token(_input: ValidateTokenInput): + try: + _cache_key = _input.token.split('.')[-1] + _cache_auth = await redis_get(f'{conf.auth.cache_prefix_key}{_cache_key}', conf.database.get('auth_redis')) + if not _cache_auth: + client = httpx.AsyncClient() + headers = {'Content-Type': 'application/json', } + + result = await client.request('POST', conf.auth.url, json=_input.dict(), headers=headers) + export = result.text + if 'application/json' in result.headers['content-type']: + export = result.json() + + if result.status_code > 299: + raise HTTPException(status_code=result.status_code, detail=export) + + if 'v2' in conf.auth.url: + export = export.get('data') + + await redis_set(f'{conf.auth.cache_prefix_key}{_cache_key}', export, conf.database.get('auth_redis'), + ex=conf.auth.cache_ttl) + + return UserModel(**export) + else: + return UserModel(**_cache_auth) + + except ConnectError as e: + raise HTTPException(status_code=400, detail=str(e)) + except HTTPException as e: + raise e + except Exception as e: + raise HTTPException(status_code=400, detail=str(e)) + + +async def get_current_user(security_scopes: SecurityScopes, token: str = Depends(oauth2)): + if security_scopes.scopes: + authenticate_value = f'Bearer scope="{security_scopes.scope_str}"' + else: + authenticate_value = f"Bearer" + credentials_exception = HTTPException( + status_code=401, + detail="Could not validate credentials", + headers={"WWW-Authenticate": authenticate_value}, + ) + user = await validate_token(ValidateTokenInput(token=token, scopes=security_scopes.scopes)) + if user is None: + raise credentials_exception + user.scopes = security_scopes.scopes if isinstance( + security_scopes.scopes, list) else security_scopes.scopes + return user + + +async def get_current_active_user(request: Request, + current_user: UserModel = Security(get_current_user, scopes=["account"]), ): + if current_user.disabled: + raise HTTPException(status_code=400, detail="Inactive user") + request.state.user = UserModel(**current_user.mongo_dict()) + use_user(UserModel(**current_user.mongo_dict())) + return current_user diff --git a/core/utils/auth/model.py b/core/utils/auth/model.py new file mode 100644 index 0000000..fb55182 --- /dev/null +++ b/core/utils/auth/model.py @@ -0,0 +1,29 @@ +import uuid +from typing import Optional, Union, List + +import pydantic +from pydantic import Field + +from core.models.base import BaseModel + + +class UserModel(BaseModel): + id: Optional[Union[uuid.UUID, str]] = Field(alias='_id') + username: Optional[str] + mobile: Optional[str] + name: Optional[str] + first_name: Optional[str] + last_name: Optional[str] + is_su: Optional[bool] + unit: Optional[str] + avatar: Optional[str] + desc: Optional[str] + scopes: Optional[List[str]] + home_path: Optional[str] + roles: Optional[List[uuid.UUID]] + disabled: Optional[bool] + + +class ValidateTokenInput(pydantic.BaseModel): + token: str + scopes: Optional[List[str]] = ['account'] diff --git a/core/utils/auth/schema.py b/core/utils/auth/schema.py new file mode 100644 index 0000000..54d12e7 --- /dev/null +++ b/core/utils/auth/schema.py @@ -0,0 +1,9 @@ +from fastapi.security import OAuth2PasswordBearer + +oauth2 = OAuth2PasswordBearer( + tokenUrl="token", + scopes={ + "account": "Read information about the current user.", + "scm": "Scm Modules", + }, +) diff --git a/core/utils/can.py b/core/utils/can.py new file mode 100644 index 0000000..e167ae8 --- /dev/null +++ b/core/utils/can.py @@ -0,0 +1,18 @@ +from functools import wraps +from typing import List, Union + +from core.utils.app_context import use_user + + +def can(permissions: Union[str, List[str]]): + def can_inner(func): + @wraps(func) + async def wrapper(*args, **kwargs): + print(permissions) + user = use_user() + # raise HTTPException(status_code=403, detail='forbidden request') + return await func(*args, **kwargs) + + return wrapper + + return can_inner diff --git a/core/utils/crudify.py b/core/utils/crudify.py new file mode 100644 index 0000000..d1f777d --- /dev/null +++ b/core/utils/crudify.py @@ -0,0 +1,118 @@ +from dataclasses import dataclass +from uuid import UUID + +from core.exceptions import ModifiedCountException, RecordNotFoundException +from core.models.base import BaseModel, DELETED_AT_QUERY +from core.service.mongo_db import use_collection +from core.utils import get_time, get_v_id + + +@dataclass +class ConfigMongoWriter: + created_at: bool = True + updated_at: bool = True + has_owner: bool = True + init_id: bool = True + init_v_id: bool = False + + +async def mongo_inserter(model: BaseModel, collection: str, options: ConfigMongoWriter = ConfigMongoWriter(), + getter=None, db=None, v_id_prefix=None, client=None) -> dict | BaseModel: + """ + Inserts data into collection + """ + try: + if options.init_id: + model.init_id() + if options.has_owner: + model.init_owner() + if options.created_at and options.updated_at: + model.cu_at() + if options.created_at: + model.c_at() + if options.updated_at: + model.u_at() + if options.init_v_id: + model.v_id = get_v_id(v_id_prefix or 'uk', source_id=model.id) + + document = model.mongo_dict() + if client: + result = await client(collection).insert_one(document) + else: + result = await use_collection(collection, db).insert_one(document) + assert result.acknowledged + if getter: + return await getter(result.inserted_id) + return result + except AssertionError as e: + raise AssertionError(f'{collection} insert error') + except Exception as e: + raise e + + +async def mongo_soft_deleter(query: dict | UUID, collection: str, db=None, client=None): + """ + Updates data in collection + """ + # doc = {"$set": {'deleted_at': get_time()}, } + _query = query + if isinstance(query, UUID): + _query = {'_id': query} + __trash_collection = f'{collection}--trash' + __driver = client or use_collection + __current_record = await __driver(collection, db=db).find_one(_query) + if not __current_record: + raise RecordNotFoundException(identifier=str(query)) + __current_record['deleted_at'] = get_time() + __driver(__trash_collection, db=db).insert_one(__current_record) + __driver(collection, db=db).delete_one(_query) + return True + + +async def mongo_updater(model: BaseModel, + query: dict | UUID, + collection: str, + options: ConfigMongoWriter = ConfigMongoWriter(created_at=False, has_owner=False), + getter=None, + db=None, + extra_query: dict = None, + client=None, + ): + """ + Updates data in collection + """ + _query = query + if isinstance(query, UUID): + _query = {'_id': query} + if options.has_owner: + model.init_owner() + if options.updated_at: + model.u_at() + if client: + result = await client(collection).update_one(_query, {"$set": model.dict(), + **(extra_query if extra_query else {})}) + else: + result = await use_collection(collection, db).update_one(_query, {"$set": model.dict(), + **(extra_query if extra_query else {})}) + if not result.modified_count: + raise ModifiedCountException(query) + if getter: + return await getter(_query) + return result + + +async def mongo_find_one(query: dict | UUID, collection: str, db=None, client=None, project=None) -> dict: + """ + Ge + """ + _query = query + if isinstance(query, UUID): + _query = {'_id': query} + _query = dict(DELETED_AT_QUERY, **_query) + if client: + result = await client(collection).find_one(_query, project) + else: + result = await use_collection(collection, db).find_one(_query, project) + if not result: + raise RecordNotFoundException(identifier=query.__str__()) + return result diff --git a/core/utils/json.py b/core/utils/json.py new file mode 100644 index 0000000..21fc242 --- /dev/null +++ b/core/utils/json.py @@ -0,0 +1,42 @@ +import json +from datetime import datetime +from typing import Optional, Any, Union +from uuid import UUID + +# +# def uuid_encoder(): +# print(obj) +# if isinstance(obj, UUID): +# # if the obj is uuid, we simply return the value of uuid +# return obj.hex +# return json.JSONEncoder.default(self, obj) +# +import jsonpatch +from pydantic import Field + +from core.models.base import BaseModel + + +class DataModelPatch(BaseModel): + id: Optional[Union[UUID, str]] = Field(alias='_id') + patch: Optional[Any] + ref_id: UUID + collection: str + owner: UUID + created_at: Optional[datetime] + + class DB: + collection: str = 'revisions' + + +async def set_revision(collection: str, old: str | dict, new: str | dict, ref_id: UUID, user_id: UUID): + old = json.dumps(old, default=str) if isinstance(old, dict) else old + new = json.dumps(new, default=str) if isinstance(new, dict) else new + patch = jsonpatch.JsonPatch.from_diff( + json.loads(new), json.loads(old) + ) + await DataModelPatch( + patch=json.loads(patch.to_string()), + ref_id=str(ref_id), + collection=collection, + owner=str(user_id)).init_id().c_at().save() diff --git a/core/utils/mongo.py b/core/utils/mongo.py new file mode 100644 index 0000000..c23b49c --- /dev/null +++ b/core/utils/mongo.py @@ -0,0 +1,47 @@ +from typing import List, Dict, Any + +from pymongo.collection import Collection + +from config import conf +from core.exceptions import NotFoundException +from core.utils import get_time + + +def get_mongo_url(connection_name:str = None): + return conf.server.is_prod and conf.database.get(connection_name or 'mongo').production.url or conf.database.get( + connection_name or 'mongo').dev.url + + +def get_mongo_db(connection_name:str = None): + return conf.server.is_prod and conf.database.get(connection_name or 'mongo').production.db or conf.database.get( + connection_name or 'mongo').dev.db + + +def nested_join(collection: str, from_field, to_field, + alias, match: dict) -> List[Dict[str, Any]]: + """ + Join a list of nested dictionaries into a single dictionary. + """ + return [ + { + "$lookup": { + "from": collection, + "localField": from_field, + "foreignField": to_field, + "as": alias + }, + }, + { + "$match": match if match else {} + } + ] + + +async def use_soft_delete(collection: Collection, find_query: dict) -> bool: + """ + Soft delete a document by setting a field to true. + """ + result = await collection.update_one(find_query, {"$set": {'deleted_at': get_time()}, }) + if not result.modified_count: + raise NotFoundException(f"No document found with query: {find_query}") + return True diff --git a/core/utils/number.py b/core/utils/number.py new file mode 100644 index 0000000..6a969df --- /dev/null +++ b/core/utils/number.py @@ -0,0 +1,12 @@ +import re + +E2P_map = {'1': '۱', '2': '۲', '3': '۳', '4': '۴', '5': '۵', '6': '۶', '7': '۷', '8': '۸', '9': '۹', '0': '۰'} + +E2P_map = {'1': '۱', '2': '۲', '3': '۳', '4': '۴', '5': '۵', '6': '۶', '7': '۷', '8': '۸', '9': '۹', '0': '۰'} + + +# convert persian number to english number +def convert_number_to_persian(str_in: str): + a = map(lambda ch: E2P_map[ch] if ch in E2P_map else ch, str_in) + return ''.join(list(a)) + diff --git a/core/utils/response.py b/core/utils/response.py new file mode 100644 index 0000000..64058cd --- /dev/null +++ b/core/utils/response.py @@ -0,0 +1,163 @@ +from fastapi import HTTPException +from fastapi.encoders import jsonable_encoder +from pydantic import ValidationError +from starlette.responses import JSONResponse + +from core.exceptions import SoftDeletedException, RecordNotFoundException, ModifiedCountException, ValidationException + + +class ResponseModel: + def __init__(self, data, code=200, message="ok"): + self.data = data + self.code = code + self.message = message + + def __str__(self): + return str(self.data) + + def __iter__(self): + return { + "data": self.data, + "code": self.code, + "message": self.message, + } + + +class ResponseBadRequest(ResponseModel): + def __init__(self, data, message="Bad Request"): + super().__init__(data, 400, message) + + +class ResponseValidationRequest(ResponseModel): + def __init__(self, data, message="Bad Request"): + super().__init__(data, 422, message) + + +class ResponseWriteErrorRequest(ResponseModel): + def __init__(self, data, message="Request write error"): + super().__init__(data, 400, message) + + +class ResponseNotFound(ResponseModel): + def __init__(self, data, message="Not Found"): + super().__init__(data, 404, message) + + +class ResponseUnauthorized(ResponseModel): + def __init__(self, data, message="Unauthorized"): + super().__init__(data, 401, message) + + +class ResponseInternalServerError(ResponseModel): + def __init__(self, data, message="Internal Server Error"): + super().__init__(data, 500, message) + + +class ResponseOk(ResponseModel): + def __init__(self, data, code=200, message="ok"): + super().__init__(data, code, message) + + +class ResponseOkUpdate(ResponseOk): + def __init__(self, data, message="update was successful"): + super().__init__(data, 200, message) + + +class ResponseOkCreate(ResponseOk): + def __init__(self, data, message="create was successful"): + super().__init__(data, 201, message) + + +class Res: + + @staticmethod + def json(object, status_code): + return JSONResponse(content=jsonable_encoder(object), status_code=status_code) + + @staticmethod + def create(data, message="create was successful"): + return Res.json(ResponseOkCreate(data, message), 201) + + @staticmethod + def fetch(data, message="fetch was successful"): + return Res.json(ResponseOk(data, 200, message), 200) + + @staticmethod + def update(data, message="update was successful"): + return Res.json(ResponseOkUpdate(data, message), 200) + + @staticmethod + def not_found(data, message="Not Found"): + return Res.json(ResponseNotFound(data, message), 404) + + @staticmethod + def unauthorized(data, message="Unauthorized"): + return Res.json(ResponseUnauthorized(data, message), 401) + + @staticmethod + def server_error(data, message="Internal Server Error"): + return Res.json(ResponseInternalServerError(data, message), 500) + + @staticmethod + def bad_request(data, message="Bad Request"): + return Res.json(ResponseBadRequest(data, message), 400) + + @staticmethod + def validation_error(data, message="data validation error"): + return Res.json(ResponseValidationRequest(data, message), 422) + + @staticmethod + def write_error(data, message="Request write error"): + return Res.json(ResponseWriteErrorRequest(data, message), 400) + + @staticmethod + def delete_error(data, message="Request delete error"): + return Res.json(ResponseWriteErrorRequest(data, message), 400) + + +def to_http_res(res, _type, message=None): + if _type == "create": + return Res.create(res, "create was successful") + elif _type == "fetch": + return Res.fetch(res, "fetch was successful") + elif _type == "update": + return Res.update(res, "update was successful") + elif _type == "delete": + return Res.update(res, "delete was successful") + elif _type == "get": + return Res.fetch(res, "get was successful") + elif _type == "upload": + return Res.fetch(res, "upload complete") + elif _type == "token": + return Res.fetch(res, "token was generated") + else: + return Res.fetch(res, "response ok") + + +def http_exception(res): + _return = None + # if isinstance(res, ResponseBadRequest): + # _return = HTTPException(status_code=res.code, detail=res.message) + # elif isinstance(res, ResponseNotFound): + # _return = HTTPException(status_code=res.code, detail=res.message) + # elif isinstance(res, ResponseUnauthorized): + # _return = HTTPException(status_code=res.code, detail=res.message) + # elif isinstance(res, ResponseInternalServerError): + # _return = HTTPException(status_code=res.code, detail=res.message) + if isinstance(res, SoftDeletedException): + _return = Res.delete_error(res, res.message) + elif isinstance(res, RecordNotFoundException): + _return = Res.not_found(res, res.message) + elif isinstance(res, ModifiedCountException): + _return = Res.write_error(res, res.message) + elif isinstance(res, ValidationError): + _return = Res.validation_error(res.errors()) + elif isinstance(res, AssertionError): + _return = Res.validation_error(res, message=res) + elif isinstance(res, ValidationException): + _return = Res.validation_error(res.message, message=res.message) + elif isinstance(res, HTTPException): + _return = Res.bad_request(res.status_code, message=res.detail) + else: + _return = Res.server_error(res, f'unknown error: {str(res)} {type(res)}') + return _return diff --git a/core/utils/router.py b/core/utils/router.py new file mode 100644 index 0000000..7cac982 --- /dev/null +++ b/core/utils/router.py @@ -0,0 +1,206 @@ +import time +from functools import wraps +from typing import Callable, TypeVar, Generic + +from fastapi import APIRouter, Request, Response, Body +from fastapi import Security +from fastapi.routing import APIRoute +from starlette.background import BackgroundTasks + +from auth.utils import get_current_active_user +from config import conf +from core.utils import to_http_res, http_exception +from core.utils.auth import get_current_active_user + + +class BaseRoute(APIRoute): + def get_route_handler(self) -> Callable: + original_route_handler = super().get_route_handler() + + async def custom_route_handler(request: Request) -> Response: + before = time.time() + response: Response = await original_route_handler(request) + duration = time.time() - before + response.headers["X-Response-Time"] = str(duration) + return response + + return custom_route_handler + + +def shrink(cal: Callable, name: str = None, bt: BackgroundTasks = None): + def can_inner(func): + @wraps(func) + async def wrapper(*args, **kwargs): + return await safe_route(name or cal.__name__, cal, *kwargs.values()) + return wrapper + + return can_inner + + +def back_task(cal: Callable, name: str = None): + print(cal) + + def back_inner(func): + print(func) + + @wraps(func) + async def wrapper(*args, **kwargs): + if cal.__name__ == 'create_salam': + print(cal.__name__) + return await safe_route(name or cal.__name__, cal, *kwargs.values()) + + return wrapper + + return back_inner + + +def generate_router( + prefix, + dependencies=None, + auth_dependencies=False, + version='v1', + auth_scopes=['account']): + """ + Generate a router with a prefix and dependencies. + Args: + prefix: prefix of the router. + dependencies: dependencies of the router. + auth_dependencies: if router has auth dependencies is True. + version: version of the router. + auth_scopes: auth scopes of the router. + + Returns: ApiRouter + """ + if auth_dependencies: + dependencies = dependencies or [] + if 'customer' in auth_scopes: + dependencies.extend( + [Security(get_current_active_customer, scopes=auth_scopes)]) + else: + dependencies.extend( + [Security(get_current_active_user, scopes=auth_scopes)]) + _pre = conf.server.prefix_url.replace('v1', version) + return APIRouter( + prefix=f"{_pre}/{prefix}", + route_class=BaseRoute, + dependencies=dependencies if dependencies else [] + ) + + +async def safe_route(_type, fn, *args): + try: + return to_http_res(await fn(*args), _type) + except Exception as e: + return http_exception(e) + + +class CrudMethods: + create = True + read = True + update = True + delete = True + get_all = True + + def __init__(self, create=True, read=True, update=True, delete=True, fetch=True): + self.create = create + self.read = read + self.update = update + self.delete = delete + self.fetch = fetch + + def __iter__(self): + r = [] + if self.create: + r.append('create') + if self.read: + r.append('read') + if self.update: + r.append('update') + if self.delete: + r.append('delete') + if self.fetch: + r.append('fetch') + return r + + +T = TypeVar("T") + + +class SetupCrud(Generic[T]): + _url_prefix = { + 'create': '/create', + 'get': '/{_id}', + 'update': '/{_id}', + 'delete': '/{_id}', + 'fetch': '' + } + _methods = { + 'create': 'post', + 'fetch': 'post', + 'get': 'get', + 'update': 'put', + 'delete': 'delete', + } + + def __init__(self, + router: APIRouter, + repository, + model: T, + tags=[], + methods: CrudMethods = CrudMethods() + ): + + for i in ['create', 'update', 'get', 'delete', 'fetch']: + if i == 'create': + @router.post( + self._url_prefix[i], + response_model=model, + tags=tags + ) + async def create(body=Body(...)): + b = T(**body) + return await safe_route(i, getattr(repository, 'create'), b) + # elif i == 'update': + # async def update(_id, body: T = Body(...)): + # return await safe_route(i, getattr(repository, i), _id, body) + # + # router.add_api_route( + # _url_prefix[i], + # update, + # methods=[_methods[i]], + # response_model=model, + # tags=tags + # ) + # elif i == 'delete': + # async def delete(_id): + # return await safe_route(i, getattr(repository, i), _id) + # + # router.add_api_route( + # _url_prefix[i], + # delete, + # methods=[_methods[i]], + # response_model=model, + # tags=tags + # ) + # elif i == 'get': + # async def get(_id): + # return await safe_route(i, getattr(repository, i), _id) + # + # router.add_api_route( + # _url_prefix[i], + # get, + # methods=[_methods[i]], + # response_model=model, + # tags=tags + # ) + # elif i == 'fetch': + # async def fetch(_id, body: T = Body(...)): + # return await safe_route(i, getattr(repository, i), _id, body) + # + # router.add_api_route( + # _url_prefix[i], + # fetch, + # methods=[_methods[i]], + # response_model=model, + # tags=tags + # ) diff --git a/core/utils/sumery/__init__.py b/core/utils/sumery/__init__.py new file mode 100644 index 0000000..a2e64d9 --- /dev/null +++ b/core/utils/sumery/__init__.py @@ -0,0 +1,89 @@ +""" +smart upon mini query +amazing mini query in any database +""" + +from core.models.base import DELETED_AT_QUERY +from core.utils import smart_cast +from core.service.mongo_db import use_collection +from core.exceptions import ValidationException + + +class SumeryObject: + db: str + object_name: str + table_name: str + driver: str = 'mongo' + query_fn: callable + cols_search: list + key_col: str = '_id' + value_col: str + value_fn: callable + limit: 10 + + def __init__(self, db: str, + object_name: str, + cols_search: list, + key_col: str, + value_col: str, + driver: str = 'mongo', + table_name: str = None, + query_fn: callable = None, + limit: int = 10, + value_fn: callable = None, + ): + self.db = db + self.table_name = table_name + self.object_name = object_name + self.driver = driver + self.query_fn = query_fn + self.cols_search = cols_search + self.key_col = key_col + self.value_col = value_col + self.value_fn = value_fn + self.limit = limit + + +__sumery_list: list[SumeryObject] = [] + + +async def sumery_executor(object_name: str, query: str, extra_filter: dict = None): + __query = query.strip() + if len(__query) < 3: + raise ValidationException('length of query not valid') + _so = next((x for x in __sumery_list if x.object_name == object_name), None) + if not _so: + raise ValidationException('summery object not found') + _query = {'$and': [{'$or': [{x: {'$regex': __query}} for x in _so.cols_search]}, + DELETED_AT_QUERY, + extra_filter if extra_filter else {}]} + _projection = { + '_id': False, + 'key': f'${_so.key_col}', + 'value': f'${_so.value_col}' + } + records = use_collection(_so.table_name or _so.object_name, _so.db).find(_query, _projection).limit(_so.limit) + return [x async for x in records] + + +async def sumery_default_getter(object_name: str, ids: list, extra_filter: dict = None): + if not len(ids): + raise ValidationException('length of ids not valid') + _so = next((x for x in __sumery_list if x.object_name == object_name), None) + if not _so: + raise ValidationException('summery object not found') + __cast_ids = [smart_cast(x) for x in ids] + _query = {'$and': [{_so.key_col: {'$in': __cast_ids}}, + DELETED_AT_QUERY, + extra_filter if extra_filter else {}]} + _projection = { + '_id': False, + 'key': f'${_so.key_col}', + 'value': f'${_so.value_col}' + } + records = use_collection(_so.table_name or _so.object_name, _so.db).find(_query, _projection).limit(100) + return [x async for x in records] + + +def setup_sumery_object(so: SumeryObject): + __sumery_list.append(so) diff --git a/core/utils/table_engine/__init__.py b/core/utils/table_engine/__init__.py new file mode 100644 index 0000000..d1d191f --- /dev/null +++ b/core/utils/table_engine/__init__.py @@ -0,0 +1,4 @@ +from .engine import * +from .filter import * +from .input import * +from .types import * diff --git a/core/utils/table_engine/engine.py b/core/utils/table_engine/engine.py new file mode 100644 index 0000000..2b3d474 --- /dev/null +++ b/core/utils/table_engine/engine.py @@ -0,0 +1,112 @@ +from pydantic import BaseModel +from sqlalchemy import func + +from core.models.base import ORDER_BY_CREATED_AT +from core.utils.table_engine.filter import TableEngineFilter +from core.utils.table_engine.input import TableEngineInput + + +class TableEngine: + object: any + input: TableEngineInput + is_sql_server: bool + is_deleted: bool + model: any + joins: list = None + filter: TableEngineFilter + sort: dict = ORDER_BY_CREATED_AT + extra_filter: list + + def __init__(self, _object, _input: dict): + self.object = _object + _input = _input or {} + self.input = TableEngineInput(**_input) + self.input.get_limit() + self.filter = TableEngineFilter(self.input) + self.extra_filter = [] + + def soft_deleted(self): + self.is_deleted = True + self.input.is_deleted = True + return self + + def sql_server(self): + self.is_sql_server = True + return self + + def text_search_cols(self, cols: list): + self.input.text_search_cols(cols) + return self + + def set_limit(self, limit: int): + self.input.p.s = limit + return self + + def set_model(self, model: BaseModel.__class__): + self.model = model + return self + + def join(self, joins): + self.joins = joins + return self + + def sort_by(self, sort_by: dict | str): + self.sort = sort_by + return self + + def _get_sql_order(self): + if isinstance(self.sort, dict): + return self.model.Id.desc() + return self.sort + + def add_filter(self, filters): + self.extra_filter.append(filters) + self.input.extra_filter.append(filters) + return self + + def get_query(self): + return self.input.dict() + + async def exec(self): + if hasattr(self, 'is_sql_server') and self.is_sql_server: + return await self._exec_sql_server() + else: + return await self._exec_mongo() + + async def _exec_mongo(self): + cursor = self.object.find( + self.filter.get()).sort( + **self.sort).limit( + self.input.get_limit()).skip(self.input.skip()) + return [self.model(**document) async for document in cursor] + + async def _exec_sql_server(self): + query = self.object \ + .query(self.model) + + if self.joins: + for j in self.joins: + if isinstance(j, list): + query = query.join(*j) + else: + query = query.join(j) + + query = self.filter.get_sql_server( + self.model, query, self.extra_filter) + + count = query.count() + + query = query \ + .order_by(self._get_sql_order()) \ + .offset(self.input.skip()) \ + .limit(self.input.get_limit()) + + return query.all(), count + + async def count(self): + if hasattr(self, 'is_sql_server') and self.is_sql_server: + count = self.object.query(func.count(self.model.Id)) + else: + count = await self.object.count_documents( + self.filter.get()) + return count diff --git a/core/utils/table_engine/filter.py b/core/utils/table_engine/filter.py new file mode 100644 index 0000000..599d83e --- /dev/null +++ b/core/utils/table_engine/filter.py @@ -0,0 +1,160 @@ +__all__ = ['TableEngineFilter'] + +from uuid import UUID + +from sqlalchemy import or_ + +from core.utils.table_engine.input import TableEngineInput, FilterItem +from core.utils.utils import is_valid_uuid + + +def filter_item_generator(item: FilterItem) -> dict: + if is_valid_uuid(item.col): + return { + 'data': { + "$elemMatch": { + 'field_id': UUID(item.col), + 'value': {**get_expression_filter(item.op, cast_value(item.val, item.type))} + } + } + } + + return {item.col: { + **get_expression_filter(item.op, cast_value(item.val, item.type))}} + + +def cast_value(val: str, type: str) -> any: + if type in ['NUMBER', 'STACK', 'FLOAT']: + return float(val) + if type == 'UUID': + return UUID(str(val)) + return val + + +def get_expression_filter(op: str, val: str) -> dict: + _op = get_operator(op) + match _op: + case "sw": + return {'$regex': f'^{val}'} + case "ew": + return {'$regex': f'{val}$'} + case "n_ew": + return {'$regex': f'(? str: + ops = { + "eq": "$eq", + "n_eq": "$ne", + "gt": "$gt", + "ge": "$ge", + "lt": "$lt", + "le": "$le", + "like": "$regex", + "n_like": "n_like", + "sw": "sw", + "ew": "ew", + 'n_ew': 'n_ew', + 'n_sw': 'n_sw', + 'exi': 'exi', + "n_exi": "n_exi", + 'lte': '$lte', + 'gte': '$gte', + 'in': 'in', + 'n_in': 'not_in', + 'bet': 'bet', + 'n_bet': 'n_bet', + } + + return ops[op] if op in ops else '$eq' + + +def getAndOr(op: str) -> str: + return {"and": "$and", "or": '$or'}.get(op, '$and') + + +class TableEngineFilter: + stack: list + input: TableEngineInput + + def __init__(self, _input: TableEngineInput): + self.stack = [] + self.input = _input + + def add(self, filter_item: dict): + self.stack.append(filter_item) + + def query_generator(self): + if not self.input.has_filter(): + return [] + + filters = self.input.filters() + query = [] + for filter_scope in filters: + query.append({getAndOr(filter_scope.op): [ + filter_item_generator(item) for item in filter_scope.items]}) + return query + + def get(self): + f = self.stack + query = self.query_generator() + if query: + f.append(*query) + if self.input.has_text_search(): + items = {} + for col in self.input.get_search_cols(): + items[col] = {'$regex': self.input.get_text_search()} + f.append(items) + if hasattr(self.input, 'is_deleted') and self.input.is_deleted: + f.append({'deleted_at': {'$exists': False}}) + if len(self.input.extra_filter) > 0: + f.extend(self.input.extra_filter) + if not f: + return {} + return {'$and': f} + + def get_sql_server(self, model, query, filters): + """ + text search filter + """ + text_search_stack = [] + fi = [] + if self.input.has_text_search(): + for col in self.input.get_search_cols(): + if isinstance(col, str): + text_search_stack.append(getattr(model, col).like( + f'%{self.input.get_text_search()}%')) + + if text_search_stack: + fi.append(or_(*text_search_stack)) + + if self.input.is_deleted: + fi.append(getattr(model, 'IsDeleted') == 0) + + if filters: + for fil in filters: + fi.append(fil) + + query = query.filter(*fi) + return query diff --git a/core/utils/table_engine/input.py b/core/utils/table_engine/input.py new file mode 100644 index 0000000..5acf29f --- /dev/null +++ b/core/utils/table_engine/input.py @@ -0,0 +1,159 @@ +from enum import Enum +from typing import List, Optional, Any + +import pydantic +from pydantic import BaseModel + + +class Cr(str, Enum): + OR = 'or' + AND = 'and' + + +class FilterItem(BaseModel): + col: Optional[str] + cr: Optional[Cr] + id: Optional[str] + op: Optional[str] + type: Optional[str] + val: Optional[Any] + + @staticmethod + def ValidItem(item: dict): + return item.get('col') and \ + item.get('id') and \ + item.get('op') and \ + item.get('type') + + @staticmethod + def ValidValue(item: dict): + return len(str(item.get('val', '')).strip()) > 0 + + +class TableEngineInput(BaseModel): + + class FilterScope(BaseModel): + id: str + items: Optional[List[FilterItem]] + op: Optional[Cr] + + def __init__(self, **kwargs): + super().__init__(**kwargs) + if kwargs.get('items'): + self.items = [ + FilterItem( + **item) for item in filter( + lambda i: FilterItem.ValidItem(i), + kwargs.get( + 'items', + []))] + self.op = Cr(kwargs.get('op')) + self.id = str(kwargs.get('id')) + + @staticmethod + def ValidScope(item: dict): + return item.get('id') and item.get('items') and len( + item.get('items')) > 0 and item.get('op') + + @staticmethod + def CleanItems(item): + clone = item.dict() + clone['items'] = list(filter( + lambda i: FilterItem.ValidValue(i), clone.get( + 'items', []))) + return TableEngineInput.FilterScope(**clone) + + class Page(BaseModel): + c: int = 1 + s: int = 5 + + class SearchModel(BaseModel): + text: Optional[str] + cols: Optional[List[Any]] + + class SortModel(BaseModel): + field: Optional[str] + direction: Optional[str] + + class Config: + extra = pydantic.Extra.ignore + anystr_strip_whitespace = True # strip whitespaces from strings + underscore_attrs_are_private = False + arbitrary_types_allowed = True + + f: Optional[List[FilterScope]] + extra_filter: list = [] + o: Optional[SortModel] + p: Optional[Page] = Page() + s: Optional[SearchModel] + is_deleted: Optional[bool] + + def limit(self): + return self.p.s + + def order(self): + return self.p.o + + def filters(self) -> List[FilterScope]: + return self.f + + def has_filter(self) -> bool: + return self.f is not None and \ + len(self.f) > 0 and \ + self.f[0].items is not None and \ + len(self.f[0].items) > 0 + + def has_extra_filter(self) -> bool: + return self.extra_filter is not None and \ + len(self.extra_filter) > 0 + + def scopes(self): + return self.s + + def __init__(self, **kwargs): + super().__init__(**kwargs) + _input = {**{'p': {'c': 1, 's': 5}, + 'f': [], 's': {}, 'o': {}}, **(kwargs or {})} + self.p = TableEngineInput.Page(**_input.get('p')) + + if _input.get('f'): + self.f = [ + TableEngineInput.FilterScope( + **f) for f in filter( + lambda fs:TableEngineInput.FilterScope.ValidScope(fs), + _input.get('f'))] + self.f = list(map( + lambda fs: TableEngineInput.FilterScope.CleanItems(fs), + self.f)) + + if _input.get('s'): + self.s = TableEngineInput.SearchModel(**_input.get('s')) + + def skip(self): + return self.page() * self.p.s + + def page(self): + return self.p.c - 1 + + def virtual_page(self): + return self.page() + 1 + + def get_limit(self): + return self.p.s if self.p else 20 + + def text_search_cols(self, cols: List[str]): + if self.s: + self.s.cols = cols + + def has_text_search(self) -> bool: + return self.s is not None and self.s.text is not None and self.s.cols is not None and len( + self.s.cols) > 0 and len(self.s.text.strip()) > 0 + + def add_filter(self, _filter): + self.extra_filter.append(_filter) + + def get_search_cols(self) -> List[str]: + return self.s.cols if self.s else [] + + def get_text_search(self) -> str: + return self.s.text.strip() if self.s else None diff --git a/core/utils/table_engine/types.py b/core/utils/table_engine/types.py new file mode 100644 index 0000000..ab4ce5f --- /dev/null +++ b/core/utils/table_engine/types.py @@ -0,0 +1,23 @@ +from typing import Generic, TypeVar, List, Optional + +from pydantic.generics import GenericModel + +T = TypeVar('T') + + +class TableEngineInputType(GenericModel, Generic[T]): + f: Optional[List] + o: Optional[dict] = {} + p: Optional[dict] = {} + s: Optional[dict] = {} + + +class TableEngineExport(GenericModel, Generic[T]): + rows: Optional[List[T]] + total: Optional[int] + page: Optional[int] + te: Optional[TableEngineInputType] + + +class TableEngineRequestType(GenericModel, Generic[T]): + te: Optional[TableEngineInputType] diff --git a/core/utils/te/__init__.py b/core/utils/te/__init__.py new file mode 100644 index 0000000..b5c0928 --- /dev/null +++ b/core/utils/te/__init__.py @@ -0,0 +1,77 @@ +from typing import List + +from .engines import MsSqlEngine, MongoEngine, PgSqlEngine, AbstractEngine +from .utils.Models import TableEngineInput +from .utils.Models import WorkingMode, JoinModel +from ..table_engine import TableEngineExport + + +class NewTableEngine: + def __init__(self, query_class, model, inputs, mode, engine: AbstractEngine, extra_filter, + default_sort_by, joins, search_cols, options=None, soft_deleted=False): + self.model = model + self.query_class = query_class + self.mode = mode + self.joins = False + self.raw_input = inputs + if isinstance(inputs, dict): + self.input = TableEngineInput(**inputs if inputs else {}) + else: + self.input = TableEngineInput(**inputs.dict() if inputs else {}) + self.input.extra_filter = extra_filter + self.default_sort_by = default_sort_by + self.joins = joins + self.soft_deleted = soft_deleted + self.options = options + self.search_cols = search_cols + self.engine = engine(self) + + @staticmethod + def mongo(query_class, + model, + inputs, + mode: WorkingMode = WorkingMode.Development, + extra_filter=None, + soft_deleted=False, + default_sort_by=None, joins=None, search_cols=[]): + return NewTableEngine(query_class, model, inputs, mode, engine=MongoEngine, extra_filter=extra_filter, + default_sort_by=default_sort_by, joins=joins, search_cols=search_cols, + soft_deleted=soft_deleted) + + @staticmethod + def mssql(query_class, model, inputs, mode: WorkingMode = WorkingMode.Development, extra_filter=None, + default_sort_by=None, joins=None, search_cols=[], options=None): + return NewTableEngine(query_class, model, inputs, mode, engine=MsSqlEngine, extra_filter=extra_filter, + default_sort_by=default_sort_by, joins=joins, search_cols=search_cols, options=options) + + @staticmethod + def pgsql(query_class, model, inputs, mode: WorkingMode = WorkingMode.Development, extra_filter=None, + default_sort_by=None, joins=None, search_cols=None, options=None): + return NewTableEngine(query_class, model, inputs, mode, engine=PgSqlEngine, extra_filter=extra_filter, + default_sort_by=default_sort_by, joins=joins, search_cols=search_cols, options=options) + + async def exe(self): + return await self.engine.exe() + + async def to_dict(self) -> TableEngineExport: + rows, count = await self.exe() + self.input.extra_filter = None + del self.engine + return TableEngineExport( + rows=rows, + page=self.input.virtual_page(), + total=count, + te=self.input + ) + + def add_extra_filter(self, filters): + self.input = filters + return self + + def joins(self, joins: List[JoinModel]): + self.joins = joins + return self + + def set_custom_entities(self, entities=None): + self.engine.custom_entities = entities + return self diff --git a/core/utils/te/engines/AbstractEngine.py b/core/utils/te/engines/AbstractEngine.py new file mode 100644 index 0000000..9aafb51 --- /dev/null +++ b/core/utils/te/engines/AbstractEngine.py @@ -0,0 +1,51 @@ +import abc + +__all__ = "AbstractEngine" + + +class AbstractEngineClass(abc.ABC): + def __init__(self, table_engine): + self.model = table_engine.model + self.query_class = table_engine.query_class + self.input = table_engine.input + self.filter = table_engine.input.f + self.default_sort_by = table_engine.default_sort_by + self.custom_entities = [] + self.joins = table_engine.joins + self.options = table_engine.options + self.search_cols = table_engine.search_cols + self.soft_deleted = table_engine.soft_deleted + + @abc.abstractmethod + def exe(self): + pass + + @abc.abstractmethod + def set_filters(self): + pass + + @abc.abstractmethod + def set_sort(self): + pass + + @staticmethod + @abc.abstractmethod + def get_expression_filter(col=None, op: str = 'eq', val: str = '') -> dict: + pass + + @staticmethod + @abc.abstractmethod + def get_operator(op: str): + pass + + def count(self): + pass + + @staticmethod + @abc.abstractmethod + def generate_scope_filter(filters: list, general=False): + pass + + @abc.abstractmethod + def set_joins(self): + pass diff --git a/core/utils/te/engines/Mongo.py b/core/utils/te/engines/Mongo.py new file mode 100644 index 0000000..20bcba7 --- /dev/null +++ b/core/utils/te/engines/Mongo.py @@ -0,0 +1,187 @@ +import datetime +from typing import Union, List +from uuid import UUID + +from .AbstractEngine import AbstractEngineClass +from ..utils.Models import FilterItem, FilterScope + + +class MongoEngine(AbstractEngineClass): + rows = [] + stack = [] + filters = {} + + async def exe(self): + _f = self.filters = self.set_filters() + tmp = self.query_class.find(_f).sort(**self.set_sort()) + self.rows = tmp.limit( + self.input.get_limit()).skip(self.input.skip()) + return [self.model(**document) async for document in self.rows], await self.count() + + def query_generator(self): + if not self.input.has_filter(): + return [] + filters = self.input.filters() + query = self.generate_scope_filter(filters, general=True) + return query + + def set_filters(self): + filters = [] + query = self.query_generator() + if query: + filters.append(query) + if self.input.has_text_search(): + items = [] + _search_cols = self.input.get_search_cols() if ( + self.input.get_search_cols() and len(self.input.get_search_cols())) else self.search_cols + if _search_cols: + for col in _search_cols: + items.append({col: {'$regex': self.input.get_text_search()}}) + filters.append({"$or": items}) + if self.input.extra_filter: + filters.extend(self.input.extra_filter) + if self.soft_deleted: + filters.append({'deleted_at': {'$exists': False}}) + if not filters: + return {} + self.filters = {'$and': filters} + return self.filters + + def set_sort(self): + if self.input.o.field: + return {'key_or_list': self.input.o.field, + 'direction': 1 if self.input.o.direction == "asc" else -1} + elif self.default_sort_by: + return self.default_sort_by + else: + return {'key_or_list': 'created_at', 'direction': -1} + + @staticmethod + def filter_item_generator(item: FilterItem) -> dict: + if MongoEngine.is_valid_uuid(str(item.col)): + return { + 'data': { + "$elemMatch": { + 'field_id': UUID(item.col), + 'value': { + **MongoEngine.get_expression_filter(item.op, MongoEngine.cast_value(item.val, item.type), )} + } + } + } + return {item.col: { + **MongoEngine.get_expression_filter(col=item.col, op=item.op, + val=MongoEngine.cast_value(item.val, item.type), )}} + + @staticmethod + def is_valid_uuid(uuid_to_test, version=4): + try: + uuid_obj = UUID(str(uuid_to_test), version=version) + except ValueError: + return False + return str(uuid_obj) == str(uuid_to_test) + + + @staticmethod + def cast_value(val: Union[str, List], type: str) -> any: + if type in ['NUMBER', 'STACK', 'FLOAT']: + if isinstance(val, List): + return [float(x) for x in val] + return float(val) + if type in ['DATE', 'DATETIME']: + if isinstance(val, List): + return [datetime.datetime.fromisoformat(str(x)) for x in val] + return datetime.datetime.fromisoformat(val) + if type == 'UUID': + if isinstance(val, List): + return [UUID(str(x)) for x in val] + return UUID(str(val)) + return val + + + @staticmethod + def get_expression_filter(col=None, op: str = 'eq', val: str = '') -> dict: + _op = MongoEngine.get_operator(op) + if _op == "sw": + return {'$regex': f'^{val}'} + elif _op == "ew": + return {'$regex': f'{val}$'} + elif _op == "n_ew": + return {'$regex': f'(? str: + ops = { + "eq": "$eq", + "n_eq": "$ne", + "gt": "$gt", + "ge": "$ge", + "lt": "$lt", + "le": "$le", + "like": "$regex", + "n_like": "n_like", + "sw": "sw", + "ew": "ew", + 'n_ew': 'n_ew', + 'n_sw': 'n_sw', + 'exi': 'exi', + "n_exi": "n_exi", + 'lte': '$lte', + 'gte': '$gte', + 'in': 'in', + 'n_in': 'not_in', + 'bet': 'bet', + 'n_bet': 'n_bet', + } + return ops[op] if op in ops else '$eq' + + async def count(self): + return await self.query_class.count_documents(self.filters) + + @staticmethod + def generate_scope_filter(filters: list, general=False): + if len(filters) == 1: + _o = filters[0] + if isinstance(_o, FilterItem): + return MongoEngine.filter_item_generator(filters[0]) + elif isinstance(_o, FilterScope): + return MongoEngine.generate_scope_filter(_o.items) + else: + return _o + else: + _a = filters.pop(0) + _a = (MongoEngine.filter_item_generator(_a) if isinstance(_a, + FilterItem) else _a) if not general else ( + MongoEngine.generate_scope_filter(_a.items) if isinstance(_a, FilterScope) else _a) + _b = filters.pop(0) + op = '$' + (_b.cr if not general else _b.op) + _b = (MongoEngine.filter_item_generator(_b) if isinstance(_b, + FilterItem) else _b) if not general else ( + MongoEngine.generate_scope_filter(_b.items) if isinstance(_b, FilterScope) else _b) + filters = [{op: [ + _a, _b + ]}] + filters + return MongoEngine.generate_scope_filter(filters, general) + + def set_joins(self): + pass diff --git a/core/utils/te/engines/Mssql.py b/core/utils/te/engines/Mssql.py new file mode 100644 index 0000000..2b2ef04 --- /dev/null +++ b/core/utils/te/engines/Mssql.py @@ -0,0 +1,146 @@ +from sqlalchemy import or_, and_, desc, asc +from sqlalchemy.sql.elements import BooleanClauseList, BinaryExpression, UnaryExpression + +from .AbstractEngine import AbstractEngineClass +from ..utils.Models import FilterItem, FilterScope + + +class MsSqlEngine(AbstractEngineClass): + + def set_joins(self): + pass + + filters = {} + rows = [] + sort = None + + async def exe(self): + _f = self.set_filters() + if not self.custom_entities: + query = self.query_class.query(self.model) + else: + query = self.query_class.query(*self.custom_entities) + if self.joins: + for j in self.joins: + if isinstance(j, list): + query = query.join(*j) + else: + query = query.join(j) + + if self.options: + query = query.options(*self.options) + tmp = query.filter(_f).order_by(self.set_sort()) + self.rows = tmp.limit(self.input.get_limit()).offset(self.input.skip()).all() + return self.rows, tmp.count(), tmp + + def query_generator(self): + if not self.input.has_filter(): + return None + filters = self.input.filters() + query = self.generate_scope_filter(filters, general=True) + return query + + def set_sort(self): + if self.input.o.field: + self.sort = asc(getattr(self.model, self.input.o.field)) if self.input.o.direction == 'asc' else desc( + getattr(self.model, self.input.o.field)) + return self.sort + elif isinstance(self.default_sort_by, UnaryExpression): + return self.default_sort_by + else: + return True + + def set_filters(self): + filters = [] + query = self.query_generator() + if isinstance(query, BooleanClauseList) or isinstance(query, BinaryExpression): + filters.append(query) + if self.input.has_text_search(): + items = [] + _search_cols = self.input.get_search_cols() if ( + self.input.get_search_cols() and len(self.input.get_search_cols())) else self.search_cols + if _search_cols: + for col in _search_cols: + has_join = len(col.split('.')) > 1 + if has_join: + pass + else: + items.append( + getattr(self.model, col).ilike("%{search}%".format(search=self.input.get_text_search()))) + filters.append(or_(*items)) + if self.input.extra_filter: + for f in self.input.extra_filter: + filters.append(f) + if not filters: + return True + self.filters = and_(*filters) + return self.filters + + @staticmethod + def get_expression_filter(col=None, op: str = '', val: str = '') -> dict: + _op = MsSqlEngine.get_operator(op) + return getattr(col, op)(val) + + @staticmethod + def get_operator(op: str): + ops = { + "eq": "__eq__", + "n_eq": "__ne__", + "gt": "__gt__", + "gte": "__ge__", + "lte": "__lte__", + "lt": "__lt__", + "like": "ilike", + "n_like": "notlike", + "in": "in_", + "n_in": "not_in", + "bet": "between", + "n_bet": "not_between", + "sw": "startswith", + "ew": "endswith", + 'n_ew': 'not_endswith', + 'n_sw': 'not_startswith', + 'exi': 'exi', + "n_exi": "n_exi", + } + return ops[op] if op in ops else '__eq__' + + @staticmethod + def get_and_or(op): + return or_ if op == 'or' else and_ + + def count(self): + pass + + def filter_item_generator(self, item: FilterItem): + has_join = len(item.col.split('.')) > 1 + if has_join: + + pass + else: + return getattr(getattr(self.model, item.col), self.get_operator(item.op))(item.val) + + def generate_scope_filter(self, filters: list, general=False): + if len(filters) == 1: + _o = filters[0] + if isinstance(_o, FilterItem): + return self.filter_item_generator(filters[0]) + elif isinstance(_o, FilterScope): + return self.generate_scope_filter(_o.items) + else: + return _o + else: + _a = filters.pop(0) + _a = (self.filter_item_generator(_a) if isinstance(_a, + FilterItem) else _a) if not general else ( + self.generate_scope_filter(_a.items) if isinstance(_a, FilterScope) else _a) + + _b = filters.pop(0) + + _op = MsSqlEngine.get_and_or(_b.cr if not general else _b.op) + _b = (self.filter_item_generator(_b) if isinstance(_b, + FilterItem) else _b) if not general else ( + self.generate_scope_filter(_b.items) if isinstance(_b, FilterScope) else _b) + + filters = [_op(_a, _b)] + filters + return self.generate_scope_filter(filters, general) diff --git a/core/utils/te/engines/Pgsql.py b/core/utils/te/engines/Pgsql.py new file mode 100644 index 0000000..c40dcbd --- /dev/null +++ b/core/utils/te/engines/Pgsql.py @@ -0,0 +1,13 @@ +from .AbstractEngine import AbstractEngineClass + + +class PgSqlEngine(AbstractEngineClass): + + def exe(self): + pass + + def get_filters(self): + pass + + def pagination(self): + pass diff --git a/core/utils/te/engines/__init__.py b/core/utils/te/engines/__init__.py new file mode 100644 index 0000000..6f5efaa --- /dev/null +++ b/core/utils/te/engines/__init__.py @@ -0,0 +1,4 @@ +from enum import Enum +from .Mssql import MsSqlEngine +from .Pgsql import PgSqlEngine +from .Mongo import MongoEngine diff --git a/core/utils/te/utils/Encoder.py b/core/utils/te/utils/Encoder.py new file mode 100644 index 0000000..1c96323 --- /dev/null +++ b/core/utils/te/utils/Encoder.py @@ -0,0 +1,19 @@ +import json + +from sqlalchemy.orm import DeclarativeMeta + + +class AlchemyEncoder(json.JSONEncoder): + def default(self, obj): + if isinstance(obj.__class__, DeclarativeMeta): + fields = {} + for field in [x for x in dir(obj) if not x.startswith('_') and x != 'metadata']: + data = obj.__getattribute__(field) + try: + json.dumps(data) + fields[field] = data + except TypeError: + fields[field] = None + return fields + + return json.JSONEncoder.default(self, obj) diff --git a/core/utils/te/utils/Models.py b/core/utils/te/utils/Models.py new file mode 100644 index 0000000..0e0e10d --- /dev/null +++ b/core/utils/te/utils/Models.py @@ -0,0 +1,160 @@ +from enum import Enum +from typing import Optional, Any, List + +import pydantic +from pydantic import BaseModel + + +class FilterItem(BaseModel): + col: Optional[str] + cr: Optional[str] + id: Optional[str] + op: Optional[str] + type: Optional[str] + val: Optional[Any] + + @staticmethod + def ValidItem(item: dict): + return item.get('col') and \ + item.get('id') and \ + item.get('op') and \ + item.get('type') + + @staticmethod + def ValidValue(item: dict): + return len(str(item.get('val', '')).strip()) > 0 + + +class Cr(str, Enum): + OR = 'or' + AND = 'and' + + +class FilterScope(BaseModel): + id: str + items: Optional[List[FilterItem]] + op: Optional[Cr] + + def __init__(self, **kwargs): + super().__init__(**kwargs) + if kwargs.get('items'): + self.items = [ + FilterItem(**item) for item in filter(lambda i: FilterItem.ValidItem(i), kwargs.get('items', []))] + self.op = Cr(kwargs.get('op')) + self.id = str(kwargs.get('id')) + + @staticmethod + def ValidScope(item: dict): + return item.get('id') and item.get('items') and len( + item.get('items')) > 0 and item.get('op') + + @staticmethod + def CleanItems(item): + clone = item.dict() + clone['items'] = list(filter( + lambda i: FilterItem.ValidValue(i), clone.get( + 'items', []))) + return FilterScope(**clone) + + +class Page(BaseModel): + c: int = 1 + s: int = 5 + + +class SearchModel(BaseModel): + text: Optional[str] + cols: Optional[List[Any]] + + +class SortModel(BaseModel): + field: Optional[str] + direction: Optional[str] + + +class JoinModel(BaseModel): + model: BaseModel + show: List[str] + search: Optional[List[str]] + + +class Config: + extra = pydantic.Extra.ignore + any_str_strip_whitespace = True # strip whitespaces from strings + underscore_attrs_are_private = False + arbitrary_types_allowed = True + + +class TableEngineInput(BaseModel): + f: Optional[List[FilterScope]] + extra_filter: list = [] + o: Optional[SortModel] + p: Optional[Page] = Page() + s: Optional[SearchModel] + is_deleted: Optional[bool] + + def __init__(self, **kwargs): + super().__init__(**kwargs) + _input = {**{'p': {'c': 1, 's': 5}, + 'f': [], 's': {}, 'o': {}}, **(kwargs or {})} + self.p = Page(**_input.get('p')) + self.o = SortModel(**_input.get('o')) + if _input.get('f'): + self.f = [FilterScope(**f) for f in + filter(lambda fs: FilterScope.ValidScope(fs), _input.get('f'))] + self.f = list(map(lambda fs: FilterScope.CleanItems(fs), self.f)) + if _input.get('s'): + self.s = SearchModel(**_input.get('s')) + + def limit(self): + return self.p.s + + def order(self): + return self.o + + def filters(self) -> List[FilterScope]: + return self.f + + def has_filter(self) -> bool: + return self.f is not None and \ + len(self.f) > 0 and \ + self.f[0].items is not None and \ + len(self.f[0].items) > 0 + + def has_extra_filter(self) -> bool: + return self.extra_filter is not None and \ + len(self.extra_filter) > 0 + + def skip(self): + return self.page() * self.p.s + + def page(self): + return self.p.c - 1 + + def virtual_page(self): + return self.page() + 1 + + def get_limit(self): + limit = self.p.s if self.p else 20 + return limit if limit < 100 else 100 + + def text_search_cols(self, cols: List[str]): + if self.s: + self.s.cols = cols + + def has_text_search(self) -> bool: + return self.s is not None and self.s.text is not None and len(self.s.text.strip()) > 0 + + def add_filter(self, _filter): + self.extra_filter.append(_filter) + + def get_search_cols(self) -> List[str]: + return self.s.cols if self.s else [] + + def get_text_search(self) -> str: + return self.s.text.strip() if self.s else None + + +class WorkingMode(Enum): + Development = "development" + Production = "production" diff --git a/core/utils/utils.py b/core/utils/utils.py new file mode 100644 index 0000000..4b29b55 --- /dev/null +++ b/core/utils/utils.py @@ -0,0 +1,150 @@ +import re +from datetime import datetime +from functools import reduce +from time import time +from uuid import uuid4, UUID + +import pytz as pytz +import shortuuid +from fastapi.requests import Request + + +def get_time() -> datetime: + """ + Returns the current time. + Returns: + """ + return datetime.fromtimestamp(time(), tz=pytz.timezone('Asia/Tehran')) + + +def get_uuid() -> UUID: + """Returns an unique UUID (UUID4)""" + return uuid4() + + +def is_numeric(_input) -> bool: + """Returns True if the input is a number.""" + try: + val = int(_input) + return True + except ValueError: + return False + + +def get_v_id(prefix: str, prefix_len=2, source_id: UUID = None) -> str: + main_id = source_id if source_id is not None else uuid4() + short_id = shortuuid.encode(main_id) + return str(prefix)[0:prefix_len] + '.' + short_id[:7] + + +def get_qr_v_id(source_id: UUID = None) -> str: + main_id = source_id if source_id is not None else uuid4() + short_id = shortuuid.encode(main_id) + return short_id[:8] + + +def is_valid_uuid(uuid_to_test, version=4): + """ + Check if uuid_to_test is a valid UUID. + + Parameters + ---------- + uuid_to_test : str + version : {1, 2, 3, 4} + + Returns + ------- + `True` if uuid_to_test is a valid UUID, otherwise `False`. + + Examples + -------- + >>> is_valid_uuid('c9bf9e57-1685-4c89-bafb-ff5af830be8a') + True + >>> is_valid_uuid('c9bf9e58') + False + """ + + try: + uuid_obj = UUID(str(uuid_to_test), version=version) + except ValueError: + return False + return str(uuid_obj) == str(uuid_to_test) + + +def get_base_url(request: Request) -> str: + """ + Returns the base url of the request. + + Parameters + ---------- + request : Request + + Returns + ------- + str + """ + return '{0}://{1}{2}/'.format(request.url.scheme, + request.url.hostname, + ':' + request.url.port.__str__() if request.url.port else '') + + +def p2e_numbers(input: str) -> str: + """ + convert persian numbers to english numbers. + """ + _map = { + '۱': '1', + '۲': '2', + '۳': '3', + '۴': '4', + '۵': '5', + '۶': '6', + '۷': '7', + '۸': '8', + '۹': '9', + '۰': '0' + } + a = map(lambda ch: _map[ch] if ch in _map else ch, input) + return ''.join(list(a)) + + +def is_list_of_strings(lst): + return bool(lst) and isinstance(lst, list) and all(isinstance(elem, str) for elem in lst) + + +def smart_cast(_input): + if is_numeric(_input): + return int(_input) + if is_valid_uuid(_input): + return UUID(str(_input)) + return _input + + +def is_mobile_number(mobile: str): + regex = r"^\+?[0-0][0-9]{10}$" + if mobile and not re.search(regex, mobile, re.I): + return False + return True + + +def is_email(email: str): + regex = r'\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b' + if email and not re.search(regex, email, re.I): + return False + return True + + +def deep_get(dictionary, keys, default=None): + return reduce(lambda d, key: d.get(key, default) if isinstance(d, dict) else default, keys.split("."), dictionary) + + +def get_tag_strips(tag_name: str, content: str): + try: + r_string = fr'\[{tag_name}\](.|\n)*?\[\/{tag_name}\]' + matches = re.search(r_string, str(content), re.MULTILINE) + raw = matches.group(0) + raw = raw.replace(f'[{tag_name}]', '') + raw = raw.replace(f'[/{tag_name}]', '') + return raw + except BaseException: + return False diff --git a/main.py b/main.py new file mode 100644 index 0000000..3d0f635 --- /dev/null +++ b/main.py @@ -0,0 +1,27 @@ +import os +from pathlib import Path + +import uvicorn + +from config import conf +from core.app import app + +""" +This is the main entry point for the application. +""" + +# import dynamic middleware in the modules +for i in conf.dependency.middleware: + __import__(f'{i}.middleware') + +# import dynamic routes in the modules +for i in conf.dependency.routes: + __import__(f'{i}.urls') + if os.path.exists(Path(f'{i}/hooks')): + __import__(f'{i}.hooks') + +# conf.load_modules_config() + + +if __name__ == "__main__": + uvicorn.run(app, host="0.0.0.0", port=conf.server.port) diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..9d6b86a --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,52 @@ +[tool.autopep8] +max_line_length = 120 +ignore = "E501,W6" # or ["E501", "W6"] +in-place = true +recursive = true +aggressive = 3 + +[tool.poetry] +name = "Omde Api" +version = "1.0" +description = "Omde Base on Fastapi" +authors = ["ara-dev "] +readme = "README.md" + +[tool.poetry.dependencies] +python = "^3.10" +fastapi = "^0.80" +uvicorn = "^0.19.0" +motor = "2.3.1" +neo4j = "^5.1.0" +pipreqs = "^0.4.11" +aiofiles = "22.1.0" +asgiref = "3.5.2" +celery = "5.2.7" +httpagentparser = "1.9.5" +httpx = "0.23.0" + +jsonpatch = "1.32" +passlib = "1.7.4" +pydantic = { extras = ["dotenv"], version = "^1.10.2" } +pydantic_i18n = "0.2.3" +pymongo = "3.12.3" +pytz = "2022.5" +qrcode = "7.3.1" +requests = "2.28.1" +shortuuid = "1.0.9" +Unidecode = "1.3.6" +user_agents = "2.2.0" +pycryptodome= "3.18.0" +fluent-logger = "^0.10.0" +python-jose = "^3.3.0" +python-multipart = "^0.0.5" +slowapi = "^0.1.6" +aioredis = "^2.0.1" +yamldataclassconfig = "^1.5.0" +sqlalchemy = "^1.4.45" +toml = "^0.10.2" +pillow = "^9.3.0" + +[build-system] +requires = ["poetry-core"] +build-backend = "poetry.core.masonry.api" diff --git a/req.txt b/req.txt new file mode 100644 index 0000000..6b1be0c --- /dev/null +++ b/req.txt @@ -0,0 +1,29 @@ +motor==2.3.1 +neo4j==^5.1.0 +pipreqs==^0.4.11 +aiofiles==22.1.0 +asgiref==3.5.2 +celery==5.2.7 +httpagentparser==1.9.5 +httpx==0.23.0 +ip2geotools==0.1.6 +jsonpatch==1.32 +passlib==1.7.4 +pydantic_i18n==0.2.3 +pymongo==3.12.3 +pytz==2022.5 +qrcode==7.3.1 +requests==2.28.1 +shortuuid==1.0.9 +Unidecode==1.3.6 +user_agents==2.2.0 +pycrypto==^2.6.1 +fluent-logger==^0.10.0 +python-jose==^3.3.0 +python-multipart==^0.0.5 +slowapi==^0.1.6 +aioredis==^2.0.1 +yamldataclassconfig==^1.5.0 +sqlalchemy==^1.4.45 +toml==^0.10.2 +pillow==^9.3.0 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..3a7a2de --- /dev/null +++ b/requirements.txt @@ -0,0 +1,765 @@ +aiofiles==22.1.0 ; python_version >= "3.10" and python_version < "4.0" \ + --hash=sha256:1142fa8e80dbae46bb6339573ad4c8c0841358f79c6eb50a493dceca14621bad \ + --hash=sha256:9107f1ca0b2a5553987a94a3c9959fe5b491fdf731389aa5b7b1bd0733e32de6 +aioredis==2.0.1 ; python_version >= "3.10" and python_version < "4.0" \ + --hash=sha256:9ac0d0b3b485d293b8ca1987e6de8658d7dafcca1cddfcd1d506cae8cdebfdd6 \ + --hash=sha256:eaa51aaf993f2d71f54b70527c440437ba65340588afeb786cd87c55c89cd98e +amqp==5.1.1 ; python_version >= "3.10" and python_version < "4.0" \ + --hash=sha256:2c1b13fecc0893e946c65cbd5f36427861cffa4ea2201d8f6fca22e2a373b5e2 \ + --hash=sha256:6f0956d2c23d8fa6e7691934d8c3930eadb44972cbbd1a7ae3a520f735d43359 +anyio==3.6.2 ; python_version >= "3.10" and python_version < "4.0" \ + --hash=sha256:25ea0d673ae30af41a0c442f81cf3b38c7e79fdc7b60335a4c14e05eb0947421 \ + --hash=sha256:fbbe32bd270d2a2ef3ed1c5d45041250284e31fc0a4df4a5a6071842051a51e3 +asgiref==3.5.2 ; python_version >= "3.10" and python_version < "4.0" \ + --hash=sha256:1d2880b792ae8757289136f1db2b7b99100ce959b2aa57fd69dab783d05afac4 \ + --hash=sha256:4a29362a6acebe09bf1d6640db38c1dc3d9217c68e6f9f6204d72667fc19a424 +async-timeout==4.0.2 ; python_version >= "3.10" and python_version < "4.0" \ + --hash=sha256:2163e1640ddb52b7a8c80d0a67a08587e5d245cc9c553a74a847056bc2976b15 \ + --hash=sha256:8ca1e4fcf50d07413d66d1a5e416e42cfdf5851c981d679a09851a6853383b3c +billiard==3.6.4.0 ; python_version >= "3.10" and python_version < "4.0" \ + --hash=sha256:299de5a8da28a783d51b197d496bef4f1595dd023a93a4f59dde1886ae905547 \ + --hash=sha256:87103ea78fa6ab4d5c751c4909bcff74617d985de7fa8b672cf8618afd5a875b +celery==5.2.7 ; python_version >= "3.10" and python_version < "4.0" \ + --hash=sha256:138420c020cd58d6707e6257b6beda91fd39af7afde5d36c6334d175302c0e14 \ + --hash=sha256:fafbd82934d30f8a004f81e8f7a062e31413a23d444be8ee3326553915958c6d +certifi==2023.5.7 ; python_version >= "3.10" and python_version < "4.0" \ + --hash=sha256:0f0d56dc5a6ad56fd4ba36484d6cc34451e1c6548c61daad8c320169f91eddc7 \ + --hash=sha256:c6c2e98f5c7869efca1f8916fed228dd91539f9f1b444c314c06eef02980c716 +charset-normalizer==2.1.1 ; python_version >= "3.10" and python_version < "4" \ + --hash=sha256:5a3d016c7c547f69d6f81fb0db9449ce888b418b5b9952cc5e6e66843e9dd845 \ + --hash=sha256:83e9a75d1911279afd89352c68b45348559d1fc0506b054b346651b5e7fee29f +click-didyoumean==0.3.0 ; python_version >= "3.10" and python_version < "4.0" \ + --hash=sha256:a0713dc7a1de3f06bc0df5a9567ad19ead2d3d5689b434768a6145bff77c0667 \ + --hash=sha256:f184f0d851d96b6d29297354ed981b7dd71df7ff500d82fa6d11f0856bee8035 +click-plugins==1.1.1 ; python_version >= "3.10" and python_version < "4.0" \ + --hash=sha256:46ab999744a9d831159c3411bb0c79346d94a444df9a3a3742e9ed63645f264b \ + --hash=sha256:5d262006d3222f5057fd81e1623d4443e41dcda5dc815c06b442aa3c02889fc8 +click-repl==0.2.0 ; python_version >= "3.10" and python_version < "4.0" \ + --hash=sha256:94b3fbbc9406a236f176e0506524b2937e4b23b6f4c0c0b2a0a83f8a64e9194b \ + --hash=sha256:cd12f68d745bf6151210790540b4cb064c7b13e571bc64b6957d98d120dacfd8 +click==8.1.3 ; python_version >= "3.10" and python_version < "4.0" \ + --hash=sha256:7682dc8afb30297001674575ea00d1814d808d6a36af415a82bd481d37ba7b8e \ + --hash=sha256:bb4d8133cb15a609f44e8213d9b391b0809795062913b383c62be0ee95b1db48 +colorama==0.4.6 ; python_version >= "3.10" and python_version < "4.0" and platform_system == "Windows" \ + --hash=sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44 \ + --hash=sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6 +dataclasses-json==0.5.7 ; python_version >= "3.10" and python_version < "4.0" \ + --hash=sha256:bc285b5f892094c3a53d558858a88553dd6a61a11ab1a8128a0e554385dcc5dd \ + --hash=sha256:c2c11bc8214fbf709ffc369d11446ff6945254a7f09128154a7620613d8fda90 +deprecated==1.2.13 ; python_version >= "3.10" and python_version < "4.0" \ + --hash=sha256:43ac5335da90c31c24ba028af536a91d41d53f9e6901ddb021bcc572ce44e38d \ + --hash=sha256:64756e3e14c8c5eea9795d93c524551432a0be75629f8f29e67ab8caf076c76d +docopt==0.6.2 ; python_version >= "3.10" and python_version < "4.0" \ + --hash=sha256:49b3a825280bd66b3aa83585ef59c4a8c82f2c8a522dbe754a8bc8d08c85c491 +ecdsa==0.18.0 ; python_version >= "3.10" and python_version < "4.0" \ + --hash=sha256:190348041559e21b22a1d65cee485282ca11a6f81d503fddb84d5017e9ed1e49 \ + --hash=sha256:80600258e7ed2f16b9aa1d7c295bd70194109ad5a30fdee0eaeefef1d4c559dd +fastapi==0.80.0 ; python_version >= "3.10" and python_version < "4.0" \ + --hash=sha256:4e542f71b5b0f4f54a82449e8bd0244305de319186a31addab976bf672206420 \ + --hash=sha256:5340f6016860baf94a52be5040c3a2e94c39ddc7ab4cc7548558182db742d9d6 +fluent-logger==0.10.0 ; python_version >= "3.10" and python_version < "4.0" \ + --hash=sha256:543637e5e62ec3fc3c92b44e5a4e148a3cea88a0f8ca4fae26c7e60fda7564c1 \ + --hash=sha256:678bda90c513ff0393964b64544ce41ef25669d2089ce6c3b63d9a18554b9bfa +greenlet==2.0.2 ; python_version >= "3.10" and (platform_machine == "win32" or platform_machine == "WIN32" or platform_machine == "AMD64" or platform_machine == "amd64" or platform_machine == "x86_64" or platform_machine == "ppc64le" or platform_machine == "aarch64") and python_version < "4.0" \ + --hash=sha256:03a8f4f3430c3b3ff8d10a2a86028c660355ab637cee9333d63d66b56f09d52a \ + --hash=sha256:0bf60faf0bc2468089bdc5edd10555bab6e85152191df713e2ab1fcc86382b5a \ + --hash=sha256:18a7f18b82b52ee85322d7a7874e676f34ab319b9f8cce5de06067384aa8ff43 \ + --hash=sha256:18e98fb3de7dba1c0a852731c3070cf022d14f0d68b4c87a19cc1016f3bb8b33 \ + --hash=sha256:1a819eef4b0e0b96bb0d98d797bef17dc1b4a10e8d7446be32d1da33e095dbb8 \ + --hash=sha256:26fbfce90728d82bc9e6c38ea4d038cba20b7faf8a0ca53a9c07b67318d46088 \ + --hash=sha256:2780572ec463d44c1d3ae850239508dbeb9fed38e294c68d19a24d925d9223ca \ + --hash=sha256:283737e0da3f08bd637b5ad058507e578dd462db259f7f6e4c5c365ba4ee9343 \ + --hash=sha256:2d4686f195e32d36b4d7cf2d166857dbd0ee9f3d20ae349b6bf8afc8485b3645 \ + --hash=sha256:2dd11f291565a81d71dab10b7033395b7a3a5456e637cf997a6f33ebdf06f8db \ + --hash=sha256:30bcf80dda7f15ac77ba5af2b961bdd9dbc77fd4ac6105cee85b0d0a5fcf74df \ + --hash=sha256:32e5b64b148966d9cccc2c8d35a671409e45f195864560829f395a54226408d3 \ + --hash=sha256:36abbf031e1c0f79dd5d596bfaf8e921c41df2bdf54ee1eed921ce1f52999a86 \ + --hash=sha256:3a06ad5312349fec0ab944664b01d26f8d1f05009566339ac6f63f56589bc1a2 \ + --hash=sha256:3a51c9751078733d88e013587b108f1b7a1fb106d402fb390740f002b6f6551a \ + --hash=sha256:3c9b12575734155d0c09d6c3e10dbd81665d5c18e1a7c6597df72fd05990c8cf \ + --hash=sha256:3f6ea9bd35eb450837a3d80e77b517ea5bc56b4647f5502cd28de13675ee12f7 \ + --hash=sha256:4b58adb399c4d61d912c4c331984d60eb66565175cdf4a34792cd9600f21b394 \ + --hash=sha256:4d2e11331fc0c02b6e84b0d28ece3a36e0548ee1a1ce9ddde03752d9b79bba40 \ + --hash=sha256:5454276c07d27a740c5892f4907c86327b632127dd9abec42ee62e12427ff7e3 \ + --hash=sha256:561091a7be172ab497a3527602d467e2b3fbe75f9e783d8b8ce403fa414f71a6 \ + --hash=sha256:6c3acb79b0bfd4fe733dff8bc62695283b57949ebcca05ae5c129eb606ff2d74 \ + --hash=sha256:703f18f3fda276b9a916f0934d2fb6d989bf0b4fb5a64825260eb9bfd52d78f0 \ + --hash=sha256:7492e2b7bd7c9b9916388d9df23fa49d9b88ac0640db0a5b4ecc2b653bf451e3 \ + --hash=sha256:76ae285c8104046b3a7f06b42f29c7b73f77683df18c49ab5af7983994c2dd91 \ + --hash=sha256:7cafd1208fdbe93b67c7086876f061f660cfddc44f404279c1585bbf3cdc64c5 \ + --hash=sha256:7efde645ca1cc441d6dc4b48c0f7101e8d86b54c8530141b09fd31cef5149ec9 \ + --hash=sha256:88d9ab96491d38a5ab7c56dd7a3cc37d83336ecc564e4e8816dbed12e5aaefc8 \ + --hash=sha256:8eab883b3b2a38cc1e050819ef06a7e6344d4a990d24d45bc6f2cf959045a45b \ + --hash=sha256:910841381caba4f744a44bf81bfd573c94e10b3045ee00de0cbf436fe50673a6 \ + --hash=sha256:9190f09060ea4debddd24665d6804b995a9c122ef5917ab26e1566dcc712ceeb \ + --hash=sha256:937e9020b514ceedb9c830c55d5c9872abc90f4b5862f89c0887033ae33c6f73 \ + --hash=sha256:94c817e84245513926588caf1152e3b559ff794d505555211ca041f032abbb6b \ + --hash=sha256:971ce5e14dc5e73715755d0ca2975ac88cfdaefcaab078a284fea6cfabf866df \ + --hash=sha256:9d14b83fab60d5e8abe587d51c75b252bcc21683f24699ada8fb275d7712f5a9 \ + --hash=sha256:9f35ec95538f50292f6d8f2c9c9f8a3c6540bbfec21c9e5b4b751e0a7c20864f \ + --hash=sha256:a1846f1b999e78e13837c93c778dcfc3365902cfb8d1bdb7dd73ead37059f0d0 \ + --hash=sha256:acd2162a36d3de67ee896c43effcd5ee3de247eb00354db411feb025aa319857 \ + --hash=sha256:b0ef99cdbe2b682b9ccbb964743a6aca37905fda5e0452e5ee239b1654d37f2a \ + --hash=sha256:b80f600eddddce72320dbbc8e3784d16bd3fb7b517e82476d8da921f27d4b249 \ + --hash=sha256:b864ba53912b6c3ab6bcb2beb19f19edd01a6bfcbdfe1f37ddd1778abfe75a30 \ + --hash=sha256:b9ec052b06a0524f0e35bd8790686a1da006bd911dd1ef7d50b77bfbad74e292 \ + --hash=sha256:ba2956617f1c42598a308a84c6cf021a90ff3862eddafd20c3333d50f0edb45b \ + --hash=sha256:bdfea8c661e80d3c1c99ad7c3ff74e6e87184895bbaca6ee8cc61209f8b9b85d \ + --hash=sha256:be4ed120b52ae4d974aa40215fcdfde9194d63541c7ded40ee12eb4dda57b76b \ + --hash=sha256:c4302695ad8027363e96311df24ee28978162cdcdd2006476c43970b384a244c \ + --hash=sha256:c48f54ef8e05f04d6eff74b8233f6063cb1ed960243eacc474ee73a2ea8573ca \ + --hash=sha256:c9c59a2120b55788e800d82dfa99b9e156ff8f2227f07c5e3012a45a399620b7 \ + --hash=sha256:cd021c754b162c0fb55ad5d6b9d960db667faad0fa2ff25bb6e1301b0b6e6a75 \ + --hash=sha256:d27ec7509b9c18b6d73f2f5ede2622441de812e7b1a80bbd446cb0633bd3d5ae \ + --hash=sha256:d5508f0b173e6aa47273bdc0a0b5ba055b59662ba7c7ee5119528f466585526b \ + --hash=sha256:d75209eed723105f9596807495d58d10b3470fa6732dd6756595e89925ce2470 \ + --hash=sha256:db1a39669102a1d8d12b57de2bb7e2ec9066a6f2b3da35ae511ff93b01b5d564 \ + --hash=sha256:dbfcfc0218093a19c252ca8eb9aee3d29cfdcb586df21049b9d777fd32c14fd9 \ + --hash=sha256:e0f72c9ddb8cd28532185f54cc1453f2c16fb417a08b53a855c4e6a418edd099 \ + --hash=sha256:e7c8dc13af7db097bed64a051d2dd49e9f0af495c26995c00a9ee842690d34c0 \ + --hash=sha256:ea9872c80c132f4663822dd2a08d404073a5a9b5ba6155bea72fb2a79d1093b5 \ + --hash=sha256:eff4eb9b7eb3e4d0cae3d28c283dc16d9bed6b193c2e1ace3ed86ce48ea8df19 \ + --hash=sha256:f82d4d717d8ef19188687aa32b8363e96062911e63ba22a0cff7802a8e58e5f1 \ + --hash=sha256:fc3a569657468b6f3fb60587e48356fe512c1754ca05a564f11366ac9e306526 +h11==0.12.0 ; python_version >= "3.10" and python_version < "4.0" \ + --hash=sha256:36a3cb8c0a032f56e2da7084577878a035d3b61d104230d4bd49c0c6b555a9c6 \ + --hash=sha256:47222cb6067e4a307d535814917cd98fd0a57b6788ce715755fa2b6c28b56042 +httpagentparser==1.9.5 ; python_version >= "3.10" and python_version < "4.0" \ + --hash=sha256:53cefd9d65990f6fe59c0378cad8ea1b9df8f770d2e8bd9d8762edae033be80a +httpcore==0.15.0 ; python_version >= "3.10" and python_version < "4.0" \ + --hash=sha256:1105b8b73c025f23ff7c36468e4432226cbb959176eab66864b8e31c4ee27fa6 \ + --hash=sha256:18b68ab86a3ccf3e7dc0f43598eaddcf472b602aba29f9aa6ab85fe2ada3980b +httpx==0.23.0 ; python_version >= "3.10" and python_version < "4.0" \ + --hash=sha256:42974f577483e1e932c3cdc3cd2303e883cbfba17fe228b0f63589764d7b9c4b \ + --hash=sha256:f28eac771ec9eb4866d3fb4ab65abd42d38c424739e80c08d8d20570de60b0ef +idna==3.4 ; python_version >= "3.10" and python_version < "4" \ + --hash=sha256:814f528e8dead7d329833b91c5faa87d60bf71824cd12a7530b5526063d02cb4 \ + --hash=sha256:90b77e79eaa3eba6de819a0c442c0b4ceefc341a7a2ab77d7562bf49f425c5c2 +importlib-resources==5.12.0 ; python_version >= "3.10" and python_version < "4.0" \ + --hash=sha256:4be82589bf5c1d7999aedf2a45159d10cb3ca4f19b2271f8792bc8e6da7b22f6 \ + --hash=sha256:7b1deeebbf351c7578e09bf2f63fa2ce8b5ffec296e0d349139d43cca061a81a +jsonpatch==1.32 ; python_version >= "3.10" and python_version < "4.0" \ + --hash=sha256:26ac385719ac9f54df8a2f0827bb8253aa3ea8ab7b3368457bcdb8c14595a397 \ + --hash=sha256:b6ddfe6c3db30d81a96aaeceb6baf916094ffa23d7dd5fa2c13e13f8b6e600c2 +jsonpointer==2.3 ; python_version >= "3.10" and python_version < "4.0" \ + --hash=sha256:51801e558539b4e9cd268638c078c6c5746c9ac96bc38152d443400e4f3793e9 \ + --hash=sha256:97cba51526c829282218feb99dab1b1e6bdf8efd1c43dc9d57be093c0d69c99a +kombu==5.2.4 ; python_version >= "3.10" and python_version < "4.0" \ + --hash=sha256:37cee3ee725f94ea8bb173eaab7c1760203ea53bbebae226328600f9d2799610 \ + --hash=sha256:8b213b24293d3417bcf0d2f5537b7f756079e3ea232a8386dcc89a59fd2361a4 +limits==3.5.0 ; python_version >= "3.10" and python_version < "4.0" \ + --hash=sha256:3ad525faeb7e1c63859ca1cae34c9ed22a8f22c9ea9d96e2f412869f6b36beb9 \ + --hash=sha256:b728c9ab3c6163997b1d11a51d252d951efd13f0d248ea2403383952498f8a22 +marshmallow-enum==1.5.1 ; python_version >= "3.10" and python_version < "4.0" \ + --hash=sha256:38e697e11f45a8e64b4a1e664000897c659b60aa57bfa18d44e226a9920b6e58 \ + --hash=sha256:57161ab3dbfde4f57adeb12090f39592e992b9c86d206d02f6bd03ebec60f072 +marshmallow==3.19.0 ; python_version >= "3.10" and python_version < "4.0" \ + --hash=sha256:90032c0fd650ce94b6ec6dc8dfeb0e3ff50c144586462c389b81a07205bedb78 \ + --hash=sha256:93f0958568da045b0021ec6aeb7ac37c81bfcccbb9a0e7ed8559885070b3a19b +motor==2.3.1 ; python_version >= "3.10" and python_version < "4.0" \ + --hash=sha256:319df35ef9d4099dc125b7d46b552fa88578675e0b06cab94624c666bb0a8a90 \ + --hash=sha256:ea5a8f5c7cbb08b7c301d7c66746fc96333d634182df236dff0f814f802fe232 +msgpack==1.0.5 ; python_version >= "3.10" and python_version < "4.0" \ + --hash=sha256:06f5174b5f8ed0ed919da0e62cbd4ffde676a374aba4020034da05fab67b9164 \ + --hash=sha256:0c05a4a96585525916b109bb85f8cb6511db1c6f5b9d9cbcbc940dc6b4be944b \ + --hash=sha256:137850656634abddfb88236008339fdaba3178f4751b28f270d2ebe77a563b6c \ + --hash=sha256:17358523b85973e5f242ad74aa4712b7ee560715562554aa2134d96e7aa4cbbf \ + --hash=sha256:18334484eafc2b1aa47a6d42427da7fa8f2ab3d60b674120bce7a895a0a85bdd \ + --hash=sha256:1835c84d65f46900920b3708f5ba829fb19b1096c1800ad60bae8418652a951d \ + --hash=sha256:1967f6129fc50a43bfe0951c35acbb729be89a55d849fab7686004da85103f1c \ + --hash=sha256:1ab2f3331cb1b54165976a9d976cb251a83183631c88076613c6c780f0d6e45a \ + --hash=sha256:1c0f7c47f0087ffda62961d425e4407961a7ffd2aa004c81b9c07d9269512f6e \ + --hash=sha256:20a97bf595a232c3ee6d57ddaadd5453d174a52594bf9c21d10407e2a2d9b3bd \ + --hash=sha256:20c784e66b613c7f16f632e7b5e8a1651aa5702463d61394671ba07b2fc9e025 \ + --hash=sha256:266fa4202c0eb94d26822d9bfd7af25d1e2c088927fe8de9033d929dd5ba24c5 \ + --hash=sha256:28592e20bbb1620848256ebc105fc420436af59515793ed27d5c77a217477705 \ + --hash=sha256:288e32b47e67f7b171f86b030e527e302c91bd3f40fd9033483f2cacc37f327a \ + --hash=sha256:3055b0455e45810820db1f29d900bf39466df96ddca11dfa6d074fa47054376d \ + --hash=sha256:332360ff25469c346a1c5e47cbe2a725517919892eda5cfaffe6046656f0b7bb \ + --hash=sha256:362d9655cd369b08fda06b6657a303eb7172d5279997abe094512e919cf74b11 \ + --hash=sha256:366c9a7b9057e1547f4ad51d8facad8b406bab69c7d72c0eb6f529cf76d4b85f \ + --hash=sha256:36961b0568c36027c76e2ae3ca1132e35123dcec0706c4b7992683cc26c1320c \ + --hash=sha256:379026812e49258016dd84ad79ac8446922234d498058ae1d415f04b522d5b2d \ + --hash=sha256:382b2c77589331f2cb80b67cc058c00f225e19827dbc818d700f61513ab47bea \ + --hash=sha256:476a8fe8fae289fdf273d6d2a6cb6e35b5a58541693e8f9f019bfe990a51e4ba \ + --hash=sha256:48296af57cdb1d885843afd73c4656be5c76c0c6328db3440c9601a98f303d87 \ + --hash=sha256:4867aa2df9e2a5fa5f76d7d5565d25ec76e84c106b55509e78c1ede0f152659a \ + --hash=sha256:4c075728a1095efd0634a7dccb06204919a2f67d1893b6aa8e00497258bf926c \ + --hash=sha256:4f837b93669ce4336e24d08286c38761132bc7ab29782727f8557e1eb21b2080 \ + --hash=sha256:4f8d8b3bf1ff2672567d6b5c725a1b347fe838b912772aa8ae2bf70338d5a198 \ + --hash=sha256:525228efd79bb831cf6830a732e2e80bc1b05436b086d4264814b4b2955b2fa9 \ + --hash=sha256:5494ea30d517a3576749cad32fa27f7585c65f5f38309c88c6d137877fa28a5a \ + --hash=sha256:55b56a24893105dc52c1253649b60f475f36b3aa0fc66115bffafb624d7cb30b \ + --hash=sha256:56a62ec00b636583e5cb6ad313bbed36bb7ead5fa3a3e38938503142c72cba4f \ + --hash=sha256:57e1f3528bd95cc44684beda696f74d3aaa8a5e58c816214b9046512240ef437 \ + --hash=sha256:586d0d636f9a628ddc6a17bfd45aa5b5efaf1606d2b60fa5d87b8986326e933f \ + --hash=sha256:5cb47c21a8a65b165ce29f2bec852790cbc04936f502966768e4aae9fa763cb7 \ + --hash=sha256:6c4c68d87497f66f96d50142a2b73b97972130d93677ce930718f68828b382e2 \ + --hash=sha256:821c7e677cc6acf0fd3f7ac664c98803827ae6de594a9f99563e48c5a2f27eb0 \ + --hash=sha256:916723458c25dfb77ff07f4c66aed34e47503b2eb3188b3adbec8d8aa6e00f48 \ + --hash=sha256:9e6ca5d5699bcd89ae605c150aee83b5321f2115695e741b99618f4856c50898 \ + --hash=sha256:9f5ae84c5c8a857ec44dc180a8b0cc08238e021f57abdf51a8182e915e6299f0 \ + --hash=sha256:a2b031c2e9b9af485d5e3c4520f4220d74f4d222a5b8dc8c1a3ab9448ca79c57 \ + --hash=sha256:a61215eac016f391129a013c9e46f3ab308db5f5ec9f25811e811f96962599a8 \ + --hash=sha256:a740fa0e4087a734455f0fc3abf5e746004c9da72fbd541e9b113013c8dc3282 \ + --hash=sha256:a9985b214f33311df47e274eb788a5893a761d025e2b92c723ba4c63936b69b1 \ + --hash=sha256:ab31e908d8424d55601ad7075e471b7d0140d4d3dd3272daf39c5c19d936bd82 \ + --hash=sha256:ac9dd47af78cae935901a9a500104e2dea2e253207c924cc95de149606dc43cc \ + --hash=sha256:addab7e2e1fcc04bd08e4eb631c2a90960c340e40dfc4a5e24d2ff0d5a3b3edb \ + --hash=sha256:b1d46dfe3832660f53b13b925d4e0fa1432b00f5f7210eb3ad3bb9a13c6204a6 \ + --hash=sha256:b2de4c1c0538dcb7010902a2b97f4e00fc4ddf2c8cda9749af0e594d3b7fa3d7 \ + --hash=sha256:b5ef2f015b95f912c2fcab19c36814963b5463f1fb9049846994b007962743e9 \ + --hash=sha256:b72d0698f86e8d9ddf9442bdedec15b71df3598199ba33322d9711a19f08145c \ + --hash=sha256:bae7de2026cbfe3782c8b78b0db9cbfc5455e079f1937cb0ab8d133496ac55e1 \ + --hash=sha256:bf22a83f973b50f9d38e55c6aade04c41ddda19b00c4ebc558930d78eecc64ed \ + --hash=sha256:c075544284eadc5cddc70f4757331d99dcbc16b2bbd4849d15f8aae4cf36d31c \ + --hash=sha256:c396e2cc213d12ce017b686e0f53497f94f8ba2b24799c25d913d46c08ec422c \ + --hash=sha256:cb5aaa8c17760909ec6cb15e744c3ebc2ca8918e727216e79607b7bbce9c8f77 \ + --hash=sha256:cdc793c50be3f01106245a61b739328f7dccc2c648b501e237f0699fe1395b81 \ + --hash=sha256:d25dd59bbbbb996eacf7be6b4ad082ed7eacc4e8f3d2df1ba43822da9bfa122a \ + --hash=sha256:e42b9594cc3bf4d838d67d6ed62b9e59e201862a25e9a157019e171fbe672dd3 \ + --hash=sha256:e57916ef1bd0fee4f21c4600e9d1da352d8816b52a599c46460e93a6e9f17086 \ + --hash=sha256:ed40e926fa2f297e8a653c954b732f125ef97bdd4c889f243182299de27e2aa9 \ + --hash=sha256:ef8108f8dedf204bb7b42994abf93882da1159728a2d4c5e82012edd92c9da9f \ + --hash=sha256:f933bbda5a3ee63b8834179096923b094b76f0c7a73c1cfe8f07ad608c58844b \ + --hash=sha256:fe5c63197c55bce6385d9aee16c4d0641684628f63ace85f73571e65ad1c1e8d +mypy-extensions==1.0.0 ; python_version >= "3.10" and python_version < "4.0" \ + --hash=sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d \ + --hash=sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782 +neo4j==5.9.0 ; python_version >= "3.10" and python_version < "4.0" \ + --hash=sha256:b0abc0065f616bfd8230a48b1f6c91f9aacd7e2aea76d72d09a745ae169cf4da +packaging==23.1 ; python_version >= "3.10" and python_version < "4.0" \ + --hash=sha256:994793af429502c4ea2ebf6bf664629d07c1a9fe974af92966e4b8d2df7edc61 \ + --hash=sha256:a392980d2b6cffa644431898be54b0045151319d1e7ec34f0cfed48767dd334f +passlib==1.7.4 ; python_version >= "3.10" and python_version < "4.0" \ + --hash=sha256:aa6bca462b8d8bda89c70b382f0c298a20b5560af6cbfa2dce410c0a2fb669f1 \ + --hash=sha256:defd50f72b65c5402ab2c573830a6978e5f202ad0d984793c8dde2c4152ebe04 +pillow==9.5.0 ; python_version >= "3.10" and python_version < "4.0" \ + --hash=sha256:07999f5834bdc404c442146942a2ecadd1cb6292f5229f4ed3b31e0a108746b1 \ + --hash=sha256:0852ddb76d85f127c135b6dd1f0bb88dbb9ee990d2cd9aa9e28526c93e794fba \ + --hash=sha256:1781a624c229cb35a2ac31cc4a77e28cafc8900733a864870c49bfeedacd106a \ + --hash=sha256:1e7723bd90ef94eda669a3c2c19d549874dd5badaeefabefd26053304abe5799 \ + --hash=sha256:229e2c79c00e85989a34b5981a2b67aa079fd08c903f0aaead522a1d68d79e51 \ + --hash=sha256:22baf0c3cf0c7f26e82d6e1adf118027afb325e703922c8dfc1d5d0156bb2eeb \ + --hash=sha256:252a03f1bdddce077eff2354c3861bf437c892fb1832f75ce813ee94347aa9b5 \ + --hash=sha256:2dfaaf10b6172697b9bceb9a3bd7b951819d1ca339a5ef294d1f1ac6d7f63270 \ + --hash=sha256:322724c0032af6692456cd6ed554bb85f8149214d97398bb80613b04e33769f6 \ + --hash=sha256:35f6e77122a0c0762268216315bf239cf52b88865bba522999dc38f1c52b9b47 \ + --hash=sha256:375f6e5ee9620a271acb6820b3d1e94ffa8e741c0601db4c0c4d3cb0a9c224bf \ + --hash=sha256:3ded42b9ad70e5f1754fb7c2e2d6465a9c842e41d178f262e08b8c85ed8a1d8e \ + --hash=sha256:432b975c009cf649420615388561c0ce7cc31ce9b2e374db659ee4f7d57a1f8b \ + --hash=sha256:482877592e927fd263028c105b36272398e3e1be3269efda09f6ba21fd83ec66 \ + --hash=sha256:489f8389261e5ed43ac8ff7b453162af39c3e8abd730af8363587ba64bb2e865 \ + --hash=sha256:54f7102ad31a3de5666827526e248c3530b3a33539dbda27c6843d19d72644ec \ + --hash=sha256:560737e70cb9c6255d6dcba3de6578a9e2ec4b573659943a5e7e4af13f298f5c \ + --hash=sha256:5671583eab84af046a397d6d0ba25343c00cd50bce03787948e0fff01d4fd9b1 \ + --hash=sha256:5ba1b81ee69573fe7124881762bb4cd2e4b6ed9dd28c9c60a632902fe8db8b38 \ + --hash=sha256:5d4ebf8e1db4441a55c509c4baa7a0587a0210f7cd25fcfe74dbbce7a4bd1906 \ + --hash=sha256:60037a8db8750e474af7ffc9faa9b5859e6c6d0a50e55c45576bf28be7419705 \ + --hash=sha256:608488bdcbdb4ba7837461442b90ea6f3079397ddc968c31265c1e056964f1ef \ + --hash=sha256:6608ff3bf781eee0cd14d0901a2b9cc3d3834516532e3bd673a0a204dc8615fc \ + --hash=sha256:662da1f3f89a302cc22faa9f14a262c2e3951f9dbc9617609a47521c69dd9f8f \ + --hash=sha256:7002d0797a3e4193c7cdee3198d7c14f92c0836d6b4a3f3046a64bd1ce8df2bf \ + --hash=sha256:763782b2e03e45e2c77d7779875f4432e25121ef002a41829d8868700d119392 \ + --hash=sha256:77165c4a5e7d5a284f10a6efaa39a0ae8ba839da344f20b111d62cc932fa4e5d \ + --hash=sha256:7c9af5a3b406a50e313467e3565fc99929717f780164fe6fbb7704edba0cebbe \ + --hash=sha256:7ec6f6ce99dab90b52da21cf0dc519e21095e332ff3b399a357c187b1a5eee32 \ + --hash=sha256:833b86a98e0ede388fa29363159c9b1a294b0905b5128baf01db683672f230f5 \ + --hash=sha256:84a6f19ce086c1bf894644b43cd129702f781ba5751ca8572f08aa40ef0ab7b7 \ + --hash=sha256:8507eda3cd0608a1f94f58c64817e83ec12fa93a9436938b191b80d9e4c0fc44 \ + --hash=sha256:85ec677246533e27770b0de5cf0f9d6e4ec0c212a1f89dfc941b64b21226009d \ + --hash=sha256:8aca1152d93dcc27dc55395604dcfc55bed5f25ef4c98716a928bacba90d33a3 \ + --hash=sha256:8d935f924bbab8f0a9a28404422da8af4904e36d5c33fc6f677e4c4485515625 \ + --hash=sha256:8f36397bf3f7d7c6a3abdea815ecf6fd14e7fcd4418ab24bae01008d8d8ca15e \ + --hash=sha256:91ec6fe47b5eb5a9968c79ad9ed78c342b1f97a091677ba0e012701add857829 \ + --hash=sha256:965e4a05ef364e7b973dd17fc765f42233415974d773e82144c9bbaaaea5d089 \ + --hash=sha256:96e88745a55b88a7c64fa49bceff363a1a27d9a64e04019c2281049444a571e3 \ + --hash=sha256:99eb6cafb6ba90e436684e08dad8be1637efb71c4f2180ee6b8f940739406e78 \ + --hash=sha256:9adf58f5d64e474bed00d69bcd86ec4bcaa4123bfa70a65ce72e424bfb88ed96 \ + --hash=sha256:9b1af95c3a967bf1da94f253e56b6286b50af23392a886720f563c547e48e964 \ + --hash=sha256:a0aa9417994d91301056f3d0038af1199eb7adc86e646a36b9e050b06f526597 \ + --hash=sha256:a0f9bb6c80e6efcde93ffc51256d5cfb2155ff8f78292f074f60f9e70b942d99 \ + --hash=sha256:a127ae76092974abfbfa38ca2d12cbeddcdeac0fb71f9627cc1135bedaf9d51a \ + --hash=sha256:aaf305d6d40bd9632198c766fb64f0c1a83ca5b667f16c1e79e1661ab5060140 \ + --hash=sha256:aca1c196f407ec7cf04dcbb15d19a43c507a81f7ffc45b690899d6a76ac9fda7 \ + --hash=sha256:ace6ca218308447b9077c14ea4ef381ba0b67ee78d64046b3f19cf4e1139ad16 \ + --hash=sha256:b416f03d37d27290cb93597335a2f85ed446731200705b22bb927405320de903 \ + --hash=sha256:bf548479d336726d7a0eceb6e767e179fbde37833ae42794602631a070d630f1 \ + --hash=sha256:c1170d6b195555644f0616fd6ed929dfcf6333b8675fcca044ae5ab110ded296 \ + --hash=sha256:c380b27d041209b849ed246b111b7c166ba36d7933ec6e41175fd15ab9eb1572 \ + --hash=sha256:c446d2245ba29820d405315083d55299a796695d747efceb5717a8b450324115 \ + --hash=sha256:c830a02caeb789633863b466b9de10c015bded434deb3ec87c768e53752ad22a \ + --hash=sha256:cb841572862f629b99725ebaec3287fc6d275be9b14443ea746c1dd325053cbd \ + --hash=sha256:cfa4561277f677ecf651e2b22dc43e8f5368b74a25a8f7d1d4a3a243e573f2d4 \ + --hash=sha256:cfcc2c53c06f2ccb8976fb5c71d448bdd0a07d26d8e07e321c103416444c7ad1 \ + --hash=sha256:d3c6b54e304c60c4181da1c9dadf83e4a54fd266a99c70ba646a9baa626819eb \ + --hash=sha256:d3d403753c9d5adc04d4694d35cf0391f0f3d57c8e0030aac09d7678fa8030aa \ + --hash=sha256:d9c206c29b46cfd343ea7cdfe1232443072bbb270d6a46f59c259460db76779a \ + --hash=sha256:e49eb4e95ff6fd7c0c402508894b1ef0e01b99a44320ba7d8ecbabefddcc5569 \ + --hash=sha256:f8286396b351785801a976b1e85ea88e937712ee2c3ac653710a4a57a8da5d9c \ + --hash=sha256:f8fc330c3370a81bbf3f88557097d1ea26cd8b019d6433aa59f71195f5ddebbf \ + --hash=sha256:fbd359831c1657d69bb81f0db962905ee05e5e9451913b18b831febfe0519082 \ + --hash=sha256:fe7e1c262d3392afcf5071df9afa574544f28eac825284596ac6db56e6d11062 \ + --hash=sha256:fed1e1cf6a42577953abbe8e6cf2fe2f566daebde7c34724ec8803c4c0cda579 +pipreqs==0.4.13 ; python_version >= "3.10" and python_version < "4.0" \ + --hash=sha256:a17f167880b6921be37533ce4c81ddc6e22b465c107aad557db43b1add56a99b \ + --hash=sha256:e522b9ed54aa3e8b7978ff251ab7a9af2f75d2cd8de4c102e881b666a79a308e +prompt-toolkit==3.0.38 ; python_version >= "3.10" and python_version < "4.0" \ + --hash=sha256:23ac5d50538a9a38c8bde05fecb47d0b403ecd0662857a86f886f798563d5b9b \ + --hash=sha256:45ea77a2f7c60418850331366c81cf6b5b9cf4c7fd34616f733c5427e6abbb1f +pyasn1==0.5.0 ; python_version >= "3.10" and python_version < "4.0" \ + --hash=sha256:87a2121042a1ac9358cabcaf1d07680ff97ee6404333bacca15f76aa8ad01a57 \ + --hash=sha256:97b7290ca68e62a832558ec3976f15cbf911bf5d7c7039d8b861c2a0ece69fde +pycryptodome==3.18.0 ; python_version >= "3.10" and python_version < "4.0" \ + --hash=sha256:01489bbdf709d993f3058e2996f8f40fee3f0ea4d995002e5968965fa2fe89fb \ + --hash=sha256:10da29526a2a927c7d64b8f34592f461d92ae55fc97981aab5bbcde8cb465bb6 \ + --hash=sha256:12600268763e6fec3cefe4c2dcdf79bde08d0b6dc1813887e789e495cb9f3403 \ + --hash=sha256:157c9b5ba5e21b375f052ca78152dd309a09ed04703fd3721dce3ff8ecced148 \ + --hash=sha256:16bfd98dbe472c263ed2821284118d899c76968db1a6665ade0c46805e6b29a4 \ + --hash=sha256:363dd6f21f848301c2dcdeb3c8ae5f0dee2286a5e952a0f04954b82076f23825 \ + --hash=sha256:3811e31e1ac3069988f7a1c9ee7331b942e605dfc0f27330a9ea5997e965efb2 \ + --hash=sha256:422c89fd8df8a3bee09fb8d52aaa1e996120eafa565437392b781abec2a56e14 \ + --hash=sha256:4604816adebd4faf8810782f137f8426bf45fee97d8427fa8e1e49ea78a52e2c \ + --hash=sha256:4944defabe2ace4803f99543445c27dd1edbe86d7d4edb87b256476a91e9ffa4 \ + --hash=sha256:51eae079ddb9c5f10376b4131be9589a6554f6fd84f7f655180937f611cd99a2 \ + --hash=sha256:53aee6be8b9b6da25ccd9028caf17dcdce3604f2c7862f5167777b707fbfb6cb \ + --hash=sha256:62a1e8847fabb5213ccde38915563140a5b338f0d0a0d363f996b51e4a6165cf \ + --hash=sha256:6f4b967bb11baea9128ec88c3d02f55a3e338361f5e4934f5240afcb667fdaec \ + --hash=sha256:78d863476e6bad2a592645072cc489bb90320972115d8995bcfbee2f8b209918 \ + --hash=sha256:795bd1e4258a2c689c0b1f13ce9684fa0dd4c0e08680dcf597cf9516ed6bc0f3 \ + --hash=sha256:7a3d22c8ee63de22336679e021c7f2386f7fc465477d59675caa0e5706387944 \ + --hash=sha256:83c75952dcf4a4cebaa850fa257d7a860644c70a7cd54262c237c9f2be26f76e \ + --hash=sha256:928078c530da78ff08e10eb6cada6e0dff386bf3d9fa9871b4bbc9fbc1efe024 \ + --hash=sha256:957b221d062d5752716923d14e0926f47670e95fead9d240fa4d4862214b9b2f \ + --hash=sha256:9ad6f09f670c466aac94a40798e0e8d1ef2aa04589c29faa5b9b97566611d1d1 \ + --hash=sha256:9c8eda4f260072f7dbe42f473906c659dcbadd5ae6159dfb49af4da1293ae380 \ + --hash=sha256:b1d9701d10303eec8d0bd33fa54d44e67b8be74ab449052a8372f12a66f93fb9 \ + --hash=sha256:b6a610f8bfe67eab980d6236fdc73bfcdae23c9ed5548192bb2d530e8a92780e \ + --hash=sha256:c9adee653fc882d98956e33ca2c1fb582e23a8af7ac82fee75bd6113c55a0413 \ + --hash=sha256:cb1be4d5af7f355e7d41d36d8eec156ef1382a88638e8032215c215b82a4b8ec \ + --hash=sha256:d1497a8cd4728db0e0da3c304856cb37c0c4e3d0b36fcbabcc1600f18504fc54 \ + --hash=sha256:d20082bdac9218649f6abe0b885927be25a917e29ae0502eaf2b53f1233ce0c2 \ + --hash=sha256:e8ad74044e5f5d2456c11ed4cfd3e34b8d4898c0cb201c4038fe41458a82ea27 \ + --hash=sha256:f022a4fd2a5263a5c483a2bb165f9cb27f2be06f2f477113783efe3fe2ad887b \ + --hash=sha256:f21efb8438971aa16924790e1c3dba3a33164eb4000106a55baaed522c261acf \ + --hash=sha256:fc0a73f4db1e31d4a6d71b672a48f3af458f548059aa05e83022d5f61aac9c08 +pydantic-i18n==0.2.3 ; python_version >= "3.10" and python_version < "4.0" \ + --hash=sha256:c6f8f3da60069a8c2fa97c490c11169914ad921c9fba2b837cbb1d1f89ada4d4 \ + --hash=sha256:e67f56117c08b800a6dd2e6f0c199f7841e2091afc4a3aa4b6fa7e7d71977284 +pydantic==1.10.8 ; python_version >= "3.10" and python_version < "4.0" \ + --hash=sha256:052d8654cb65174d6f9490cc9b9a200083a82cf5c3c5d3985db765757eb3b375 \ + --hash=sha256:0c6fafa0965b539d7aab0a673a046466d23b86e4b0e8019d25fd53f4df62c277 \ + --hash=sha256:1243d28e9b05003a89d72e7915fdb26ffd1d39bdd39b00b7dbe4afae4b557f9d \ + --hash=sha256:12f7b0bf8553e310e530e9f3a2f5734c68699f42218bf3568ef49cd9b0e44df4 \ + --hash=sha256:1410275520dfa70effadf4c21811d755e7ef9bb1f1d077a21958153a92c8d9ca \ + --hash=sha256:16f8c3e33af1e9bb16c7a91fc7d5fa9fe27298e9f299cff6cb744d89d573d62c \ + --hash=sha256:17aef11cc1b997f9d574b91909fed40761e13fac438d72b81f902226a69dac01 \ + --hash=sha256:191ba419b605f897ede9892f6c56fb182f40a15d309ef0142212200a10af4c18 \ + --hash=sha256:1952526ba40b220b912cdc43c1c32bcf4a58e3f192fa313ee665916b26befb68 \ + --hash=sha256:1ced8375969673929809d7f36ad322934c35de4af3b5e5b09ec967c21f9f7887 \ + --hash=sha256:2e4148e635994d57d834be1182a44bdb07dd867fa3c2d1b37002000646cc5459 \ + --hash=sha256:34d327c81e68a1ecb52fe9c8d50c8a9b3e90d3c8ad991bfc8f953fb477d42fb4 \ + --hash=sha256:35db5301b82e8661fa9c505c800d0990bc14e9f36f98932bb1d248c0ac5cada5 \ + --hash=sha256:3e59417ba8a17265e632af99cc5f35ec309de5980c440c255ab1ca3ae96a3e0e \ + --hash=sha256:42aa0c4b5c3025483240a25b09f3c09a189481ddda2ea3a831a9d25f444e03c1 \ + --hash=sha256:666bdf6066bf6dbc107b30d034615d2627e2121506c555f73f90b54a463d1f33 \ + --hash=sha256:66a703d1983c675a6e0fed8953b0971c44dba48a929a2000a493c3772eb61a5a \ + --hash=sha256:6a82d6cda82258efca32b40040228ecf43a548671cb174a1e81477195ed3ed56 \ + --hash=sha256:6f2e754d5566f050954727c77f094e01793bcb5725b663bf628fa6743a5a9108 \ + --hash=sha256:7456eb22ed9aaa24ff3e7b4757da20d9e5ce2a81018c1b3ebd81a0b88a18f3b2 \ + --hash=sha256:7b1f6cb446470b7ddf86c2e57cd119a24959af2b01e552f60705910663af09a4 \ + --hash=sha256:7d5b8641c24886d764a74ec541d2fc2c7fb19f6da2a4001e6d580ba4a38f7878 \ + --hash=sha256:84d80219c3f8d4cad44575e18404099c76851bc924ce5ab1c4c8bb5e2a2227d0 \ + --hash=sha256:88f195f582851e8db960b4a94c3e3ad25692c1c1539e2552f3df7a9e972ef60e \ + --hash=sha256:93e6bcfccbd831894a6a434b0aeb1947f9e70b7468f274154d03d71fabb1d7c6 \ + --hash=sha256:93e766b4a8226e0708ef243e843105bf124e21331694367f95f4e3b4a92bbb3f \ + --hash=sha256:ab523c31e22943713d80d8d342d23b6f6ac4b792a1e54064a8d0cf78fd64e800 \ + --hash=sha256:bb14388ec45a7a0dc429e87def6396f9e73c8c77818c927b6a60706603d5f2ea \ + --hash=sha256:c0ab53b609c11dfc0c060d94335993cc2b95b2150e25583bec37a49b2d6c6c3f \ + --hash=sha256:c33b60054b2136aef8cf190cd4c52a3daa20b2263917c49adad20eaf381e823b \ + --hash=sha256:ceb6a23bf1ba4b837d0cfe378329ad3f351b5897c8d4914ce95b85fba96da5a1 \ + --hash=sha256:d532bf00f381bd6bc62cabc7d1372096b75a33bc197a312b03f5838b4fb84edd \ + --hash=sha256:df7800cb1984d8f6e249351139667a8c50a379009271ee6236138a22a0c0f319 \ + --hash=sha256:e82d4566fcd527eae8b244fa952d99f2ca3172b7e97add0b43e2d97ee77f81ab \ + --hash=sha256:f90c1e29f447557e9e26afb1c4dbf8768a10cc676e3781b6a577841ade126b85 \ + --hash=sha256:f9613fadad06b4f3bc5db2653ce2f22e0de84a7c6c293909b48f6ed37b83c61f +pydantic[dotenv]==1.10.8 ; python_version >= "3.10" and python_version < "4.0" \ + --hash=sha256:052d8654cb65174d6f9490cc9b9a200083a82cf5c3c5d3985db765757eb3b375 \ + --hash=sha256:0c6fafa0965b539d7aab0a673a046466d23b86e4b0e8019d25fd53f4df62c277 \ + --hash=sha256:1243d28e9b05003a89d72e7915fdb26ffd1d39bdd39b00b7dbe4afae4b557f9d \ + --hash=sha256:12f7b0bf8553e310e530e9f3a2f5734c68699f42218bf3568ef49cd9b0e44df4 \ + --hash=sha256:1410275520dfa70effadf4c21811d755e7ef9bb1f1d077a21958153a92c8d9ca \ + --hash=sha256:16f8c3e33af1e9bb16c7a91fc7d5fa9fe27298e9f299cff6cb744d89d573d62c \ + --hash=sha256:17aef11cc1b997f9d574b91909fed40761e13fac438d72b81f902226a69dac01 \ + --hash=sha256:191ba419b605f897ede9892f6c56fb182f40a15d309ef0142212200a10af4c18 \ + --hash=sha256:1952526ba40b220b912cdc43c1c32bcf4a58e3f192fa313ee665916b26befb68 \ + --hash=sha256:1ced8375969673929809d7f36ad322934c35de4af3b5e5b09ec967c21f9f7887 \ + --hash=sha256:2e4148e635994d57d834be1182a44bdb07dd867fa3c2d1b37002000646cc5459 \ + --hash=sha256:34d327c81e68a1ecb52fe9c8d50c8a9b3e90d3c8ad991bfc8f953fb477d42fb4 \ + --hash=sha256:35db5301b82e8661fa9c505c800d0990bc14e9f36f98932bb1d248c0ac5cada5 \ + --hash=sha256:3e59417ba8a17265e632af99cc5f35ec309de5980c440c255ab1ca3ae96a3e0e \ + --hash=sha256:42aa0c4b5c3025483240a25b09f3c09a189481ddda2ea3a831a9d25f444e03c1 \ + --hash=sha256:666bdf6066bf6dbc107b30d034615d2627e2121506c555f73f90b54a463d1f33 \ + --hash=sha256:66a703d1983c675a6e0fed8953b0971c44dba48a929a2000a493c3772eb61a5a \ + --hash=sha256:6a82d6cda82258efca32b40040228ecf43a548671cb174a1e81477195ed3ed56 \ + --hash=sha256:6f2e754d5566f050954727c77f094e01793bcb5725b663bf628fa6743a5a9108 \ + --hash=sha256:7456eb22ed9aaa24ff3e7b4757da20d9e5ce2a81018c1b3ebd81a0b88a18f3b2 \ + --hash=sha256:7b1f6cb446470b7ddf86c2e57cd119a24959af2b01e552f60705910663af09a4 \ + --hash=sha256:7d5b8641c24886d764a74ec541d2fc2c7fb19f6da2a4001e6d580ba4a38f7878 \ + --hash=sha256:84d80219c3f8d4cad44575e18404099c76851bc924ce5ab1c4c8bb5e2a2227d0 \ + --hash=sha256:88f195f582851e8db960b4a94c3e3ad25692c1c1539e2552f3df7a9e972ef60e \ + --hash=sha256:93e6bcfccbd831894a6a434b0aeb1947f9e70b7468f274154d03d71fabb1d7c6 \ + --hash=sha256:93e766b4a8226e0708ef243e843105bf124e21331694367f95f4e3b4a92bbb3f \ + --hash=sha256:ab523c31e22943713d80d8d342d23b6f6ac4b792a1e54064a8d0cf78fd64e800 \ + --hash=sha256:bb14388ec45a7a0dc429e87def6396f9e73c8c77818c927b6a60706603d5f2ea \ + --hash=sha256:c0ab53b609c11dfc0c060d94335993cc2b95b2150e25583bec37a49b2d6c6c3f \ + --hash=sha256:c33b60054b2136aef8cf190cd4c52a3daa20b2263917c49adad20eaf381e823b \ + --hash=sha256:ceb6a23bf1ba4b837d0cfe378329ad3f351b5897c8d4914ce95b85fba96da5a1 \ + --hash=sha256:d532bf00f381bd6bc62cabc7d1372096b75a33bc197a312b03f5838b4fb84edd \ + --hash=sha256:df7800cb1984d8f6e249351139667a8c50a379009271ee6236138a22a0c0f319 \ + --hash=sha256:e82d4566fcd527eae8b244fa952d99f2ca3172b7e97add0b43e2d97ee77f81ab \ + --hash=sha256:f90c1e29f447557e9e26afb1c4dbf8768a10cc676e3781b6a577841ade126b85 \ + --hash=sha256:f9613fadad06b4f3bc5db2653ce2f22e0de84a7c6c293909b48f6ed37b83c61f +pymongo==3.12.3 ; python_version >= "3.10" and python_version < "4.0" \ + --hash=sha256:06b64cdf5121f86b78a84e61b8f899b6988732a8d304b503ea1f94a676221c06 \ + --hash=sha256:07398d8a03545b98282f459f2603a6bb271f4448d484ed7f411121a519a7ea48 \ + --hash=sha256:0a02313e71b7c370c43056f6b16c45effbb2d29a44d24403a3d5ba6ed322fa3f \ + --hash=sha256:0a89cadc0062a5e53664dde043f6c097172b8c1c5f0094490095282ff9995a5f \ + --hash=sha256:0be605bfb8461384a4cb81e80f51eb5ca1b89851f2d0e69a75458c788a7263a4 \ + --hash=sha256:0d52a70350ec3dfc39b513df12b03b7f4c8f8ec6873bbf958299999db7b05eb1 \ + --hash=sha256:0e7a5d0b9077e8c3e57727f797ee8adf12e1d5e7534642230d98980d160d1320 \ + --hash=sha256:145d78c345a38011497e55aff22c0f8edd40ee676a6810f7e69563d68a125e83 \ + --hash=sha256:14dee106a10b77224bba5efeeb6aee025aabe88eb87a2b850c46d3ee55bdab4a \ + --hash=sha256:176fdca18391e1206c32fb1d8265628a84d28333c20ad19468d91e3e98312cd1 \ + --hash=sha256:1b4c535f524c9d8c86c3afd71d199025daa070859a2bdaf94a298120b0de16db \ + --hash=sha256:1b5cb75d2642ff7db823f509641f143f752c0d1ab03166cafea1e42e50469834 \ + --hash=sha256:1c6c71e198b36f0f0dfe354f06d3655ecfa30d69493a1da125a9a54668aad652 \ + --hash=sha256:1c771f1a8b3cd2d697baaf57e9cfa4ae42371cacfbea42ea01d9577c06d92f96 \ + --hash=sha256:208a61db8b8b647fb5b1ff3b52b4ed6dbced01eac3b61009958adb203596ee99 \ + --hash=sha256:2157d68f85c28688e8b723bbe70c8013e0aba5570e08c48b3562f74d33fc05c4 \ + --hash=sha256:2301051701b27aff2cbdf83fae22b7ca883c9563dfd088033267291b46196643 \ + --hash=sha256:2567885ff0c8c7c0887ba6cefe4ae4af96364a66a7069f924ce0cd12eb971d04 \ + --hash=sha256:2577b8161eeae4dd376d13100b2137d883c10bb457dd08935f60c9f9d4b5c5f6 \ + --hash=sha256:27e5ea64332385385b75414888ce9d1a9806be8616d7cef4ef409f4f256c6d06 \ + --hash=sha256:28bfd5244d32faf3e49b5a8d1fab0631e922c26e8add089312e4be19fb05af50 \ + --hash=sha256:295a5beaecb7bf054c1c6a28749ed72b19f4d4b61edcd8a0815d892424baf780 \ + --hash=sha256:2c46a0afef69d61938a6fe32c3afd75b91dec3ab3056085dc72abbeedcc94166 \ + --hash=sha256:3100a2352bdded6232b385ceda0c0a4624598c517d52c2d8cf014b7abbebd84d \ + --hash=sha256:320a1fe403dd83a35709fcf01083d14bc1462e9789b711201349a9158db3a87e \ + --hash=sha256:320f8734553c50cffe8a8e1ae36dfc7d7be1941c047489db20a814d2a170d7b5 \ + --hash=sha256:33ab8c031f788609924e329003088831045f683931932a52a361d4a955b7dce2 \ + --hash=sha256:3492ae1f97209c66af70e863e6420e6301cecb0a51a5efa701058aa73a8ca29e \ + --hash=sha256:351a2efe1c9566c348ad0076f4bf541f4905a0ebe2d271f112f60852575f3c16 \ + --hash=sha256:3f0ac6e0203bd88863649e6ed9c7cfe53afab304bc8225f2597c4c0a74e4d1f0 \ + --hash=sha256:3fedad05147b40ff8a93fcd016c421e6c159f149a2a481cfa0b94bfa3e473bab \ + --hash=sha256:4294f2c1cd069b793e31c2e6d7ac44b121cf7cedccd03ebcc30f3fc3417b314a \ + --hash=sha256:463b974b7f49d65a16ca1435bc1c25a681bb7d630509dd23b2e819ed36da0b7f \ + --hash=sha256:4e0a3ea7fd01cf0a36509f320226bd8491e0f448f00b8cb89f601c109f6874e1 \ + --hash=sha256:514e78d20d8382d5b97f32b20c83d1d0452c302c9a135f0a9022236eb9940fda \ + --hash=sha256:517b09b1dd842390a965a896d1327c55dfe78199c9f5840595d40facbcd81854 \ + --hash=sha256:51d1d061df3995c2332ae78f036492cc188cb3da8ef122caeab3631a67bb477e \ + --hash=sha256:5296669bff390135528001b4e48d33a7acaffcd361d98659628ece7f282f11aa \ + --hash=sha256:5296e5e69243ffd76bd919854c4da6630ae52e46175c804bc4c0e050d937b705 \ + --hash=sha256:58db209da08a502ce6948841d522dcec80921d714024354153d00b054571993c \ + --hash=sha256:5b779e87300635b8075e8d5cfd4fdf7f46078cd7610c381d956bca5556bb8f97 \ + --hash=sha256:5cf113a46d81cff0559d57aa66ffa473d57d1a9496f97426318b6b5b14fdec1c \ + --hash=sha256:5d20072d81cbfdd8e15e6a0c91fc7e3a4948c71e0adebfc67d3b4bcbe8602711 \ + --hash=sha256:5d67dbc8da2dac1644d71c1839d12d12aa333e266a9964d5b1a49feed036bc94 \ + --hash=sha256:5f530f35e1a57d4360eddcbed6945aecdaee2a491cd3f17025e7b5f2eea88ee7 \ + --hash=sha256:5fdffb0cfeb4dc8646a5381d32ec981ae8472f29c695bf09e8f7a8edb2db12ca \ + --hash=sha256:602284e652bb56ca8760f8e88a5280636c5b63d7946fca1c2fe0f83c37dffc64 \ + --hash=sha256:648fcfd8e019b122b7be0e26830a3a2224d57c3e934f19c1e53a77b8380e6675 \ + --hash=sha256:64b9122be1c404ce4eb367ad609b590394587a676d84bfed8e03c3ce76d70560 \ + --hash=sha256:6526933760ee1e6090db808f1690a111ec409699c1990efc96f134d26925c37f \ + --hash=sha256:6632b1c63d58cddc72f43ab9f17267354ddce563dd5e11eadabd222dcc808808 \ + --hash=sha256:6f93dbfa5a461107bc3f5026e0d5180499e13379e9404f07a9f79eb5e9e1303d \ + --hash=sha256:71c0db2c313ea8a80825fb61b7826b8015874aec29ee6364ade5cb774fe4511b \ + --hash=sha256:71c5c200fd37a5322706080b09c3ec8907cf01c377a7187f354fc9e9e13abc73 \ + --hash=sha256:7738147cd9dbd6d18d5593b3491b4620e13b61de975fd737283e4ad6c255c273 \ + --hash=sha256:7a6e4dccae8ef5dd76052647d78f02d5d0ffaff1856277d951666c54aeba3ad2 \ + --hash=sha256:7b4a9fcd95e978cd3c96cdc2096aa54705266551422cf0883c12a4044def31c6 \ + --hash=sha256:80710d7591d579442c67a3bc7ae9dcba9ff95ea8414ac98001198d894fc4ff46 \ + --hash=sha256:81a3ebc33b1367f301d1c8eda57eec4868e951504986d5d3fe437479dcdac5b2 \ + --hash=sha256:8455176fd1b86de97d859fed4ae0ef867bf998581f584c7a1a591246dfec330f \ + --hash=sha256:845b178bd127bb074835d2eac635b980c58ec5e700ebadc8355062df708d5a71 \ + --hash=sha256:858af7c2ab98f21ed06b642578b769ecfcabe4754648b033168a91536f7beef9 \ + --hash=sha256:87e18f29bac4a6be76a30e74de9c9005475e27100acf0830679420ce1fd9a6fd \ + --hash=sha256:89d7baa847383b9814de640c6f1a8553d125ec65e2761ad146ea2e75a7ad197c \ + --hash=sha256:8c7ad5cab282f53b9d78d51504330d1c88c83fbe187e472c07e6908a0293142e \ + --hash=sha256:8d92c6bb9174d47c2257528f64645a00bbc6324a9ff45a626192797aff01dc14 \ + --hash=sha256:9252c991e8176b5a2fa574c5ab9a841679e315f6e576eb7cf0bd958f3e39b0ad \ + --hash=sha256:93111fd4e08fa889c126aa8baf5c009a941880a539c87672e04583286517450a \ + --hash=sha256:95d15cf81cd2fb926f2a6151a9f94c7aacc102b415e72bc0e040e29332b6731c \ + --hash=sha256:9d5b66d457d2c5739c184a777455c8fde7ab3600a56d8bbebecf64f7c55169e1 \ + --hash=sha256:a055d29f1302892a9389a382bed10a3f77708bcf3e49bfb76f7712fa5f391cc6 \ + --hash=sha256:a1ba93be779a9b8e5e44f5c133dc1db4313661cead8a2fd27661e6cb8d942ee9 \ + --hash=sha256:a283425e6a474facd73072d8968812d1d9058490a5781e022ccf8895500b83ce \ + --hash=sha256:a351986d6c9006308f163c359ced40f80b6cffb42069f3e569b979829951038d \ + --hash=sha256:a766157b195a897c64945d4ff87b050bb0e763bb78f3964e996378621c703b00 \ + --hash=sha256:a8a3540e21213cb8ce232e68a7d0ee49cdd35194856c50b8bd87eeb572fadd42 \ + --hash=sha256:a8e0a086dbbee406cc6f603931dfe54d1cb2fba585758e06a2de01037784b737 \ + --hash=sha256:ab23b0545ec71ea346bf50a5d376d674f56205b729980eaa62cdb7871805014b \ + --hash=sha256:b0db9a4691074c347f5d7ee830ab3529bc5ad860939de21c1f9c403daf1eda9a \ + --hash=sha256:b1b5be40ebf52c3c67ee547e2c4435ed5bc6352f38d23e394520b686641a6be4 \ + --hash=sha256:b3e08aef4ea05afbc0a70cd23c13684e7f5e074f02450964ec5cfa1c759d33d2 \ + --hash=sha256:b7df0d99e189b7027d417d4bfd9b8c53c9c7ed5a0a1495d26a6f547d820eca88 \ + --hash=sha256:be1f10145f7ea76e3e836fdc5c8429c605675bdcddb0bca9725ee6e26874c00c \ + --hash=sha256:bf254a1a95e95fdf4eaa25faa1ea450a6533ed7a997f9f8e49ab971b61ea514d \ + --hash=sha256:bfc2d763d05ec7211313a06e8571236017d3e61d5fef97fcf34ec4b36c0b6556 \ + --hash=sha256:c164eda0be9048f83c24b9b2656900041e069ddf72de81c17d874d0c32f6079f \ + --hash=sha256:c22591cff80188dd8543be0b559d0c807f7288bd353dc0bcfe539b4588b3a5cd \ + --hash=sha256:c5f83bb59d0ff60c6fdb1f8a7b0288fbc4640b1f0fd56f5ae2387749c35d34e3 \ + --hash=sha256:c7e8221278e5f9e2b6d3893cfc3a3e46c017161a57bb0e6f244826e4cee97916 \ + --hash=sha256:c8d6bf6fcd42cde2f02efb8126812a010c297eacefcd090a609639d2aeda6185 \ + --hash=sha256:c8f7dd025cb0bf19e2f60a64dfc24b513c8330e0cfe4a34ccf941eafd6194d9e \ + --hash=sha256:c9d212e2af72d5c8d082775a43eb726520e95bf1c84826440f74225843975136 \ + --hash=sha256:cebb3d8bcac4a6b48be65ebbc5c9881ed4a738e27bb96c86d9d7580a1fb09e05 \ + --hash=sha256:d3082e5c4d7b388792124f5e805b469109e58f1ab1eb1fbd8b998e8ab766ffb7 \ + --hash=sha256:d81047341ab56061aa4b6823c54d4632579c3b16e675089e8f520e9b918a133b \ + --hash=sha256:dfa217bf8cf3ff6b30c8e6a89014e0c0e7b50941af787b970060ae5ba04a4ce5 \ + --hash=sha256:dfec57f15f53d677b8e4535695ff3f37df7f8fe431f2efa8c3c8c4025b53d1eb \ + --hash=sha256:e099b79ccf7c40f18b149a64d3d10639980035f9ceb223169dd806ff1bb0d9cc \ + --hash=sha256:e1fc4d3985868860b6585376e511bb32403c5ffb58b0ed913496c27fd791deea \ + --hash=sha256:e2b4c95c47fb81b19ea77dc1c50d23af3eba87c9628fcc2e03d44124a3d336ea \ + --hash=sha256:e4e5d163e6644c2bc84dd9f67bfa89288c23af26983d08fefcc2cbc22f6e57e6 \ + --hash=sha256:e66b3c9f8b89d4fd58a59c04fdbf10602a17c914fbaaa5e6ea593f1d54b06362 \ + --hash=sha256:ed7d11330e443aeecab23866055e08a5a536c95d2c25333aeb441af2dbac38d2 \ + --hash=sha256:f340a2a908644ea6cccd399be0fb308c66e05d2800107345f9f0f0d59e1731c4 \ + --hash=sha256:f38b35ecd2628bf0267761ed659e48af7e620a7fcccfccf5774e7308fb18325c \ + --hash=sha256:f6d5443104f89a840250087863c91484a72f254574848e951d1bdd7d8b2ce7c9 \ + --hash=sha256:fc2048d13ff427605fea328cbe5369dce549b8c7657b0e22051a5b8831170af6 +python-dotenv==1.0.0 ; python_version >= "3.10" and python_version < "4.0" \ + --hash=sha256:a8df96034aae6d2d50a4ebe8216326c61c3eb64836776504fcca410e5937a3ba \ + --hash=sha256:f5971a9226b701070a4bf2c38c89e5a3f0d64de8debda981d1db98583009122a +python-jose==3.3.0 ; python_version >= "3.10" and python_version < "4.0" \ + --hash=sha256:55779b5e6ad599c6336191246e95eb2293a9ddebd555f796a65f838f07e5d78a \ + --hash=sha256:9b1376b023f8b298536eedd47ae1089bcdb848f1535ab30555cd92002d78923a +python-multipart==0.0.5 ; python_version >= "3.10" and python_version < "4.0" \ + --hash=sha256:f7bb5f611fc600d15fa47b3974c8aa16e93724513b49b5f95c81e6624c83fa43 +pytz==2022.5 ; python_version >= "3.10" and python_version < "4.0" \ + --hash=sha256:335ab46900b1465e714b4fda4963d87363264eb662aab5e65da039c25f1f5b22 \ + --hash=sha256:c4d88f472f54d615e9cd582a5004d1e5f624854a6a27a6211591c251f22a6914 +pyyaml==6.0 ; python_version >= "3.10" and python_version < "4.0" \ + --hash=sha256:01b45c0191e6d66c470b6cf1b9531a771a83c1c4208272ead47a3ae4f2f603bf \ + --hash=sha256:0283c35a6a9fbf047493e3a0ce8d79ef5030852c51e9d911a27badfde0605293 \ + --hash=sha256:055d937d65826939cb044fc8c9b08889e8c743fdc6a32b33e2390f66013e449b \ + --hash=sha256:07751360502caac1c067a8132d150cf3d61339af5691fe9e87803040dbc5db57 \ + --hash=sha256:0b4624f379dab24d3725ffde76559cff63d9ec94e1736b556dacdfebe5ab6d4b \ + --hash=sha256:0ce82d761c532fe4ec3f87fc45688bdd3a4c1dc5e0b4a19814b9009a29baefd4 \ + --hash=sha256:1e4747bc279b4f613a09eb64bba2ba602d8a6664c6ce6396a4d0cd413a50ce07 \ + --hash=sha256:213c60cd50106436cc818accf5baa1aba61c0189ff610f64f4a3e8c6726218ba \ + --hash=sha256:231710d57adfd809ef5d34183b8ed1eeae3f76459c18fb4a0b373ad56bedcdd9 \ + --hash=sha256:277a0ef2981ca40581a47093e9e2d13b3f1fbbeffae064c1d21bfceba2030287 \ + --hash=sha256:2cd5df3de48857ed0544b34e2d40e9fac445930039f3cfe4bcc592a1f836d513 \ + --hash=sha256:40527857252b61eacd1d9af500c3337ba8deb8fc298940291486c465c8b46ec0 \ + --hash=sha256:432557aa2c09802be39460360ddffd48156e30721f5e8d917f01d31694216782 \ + --hash=sha256:473f9edb243cb1935ab5a084eb238d842fb8f404ed2193a915d1784b5a6b5fc0 \ + --hash=sha256:48c346915c114f5fdb3ead70312bd042a953a8ce5c7106d5bfb1a5254e47da92 \ + --hash=sha256:50602afada6d6cbfad699b0c7bb50d5ccffa7e46a3d738092afddc1f9758427f \ + --hash=sha256:68fb519c14306fec9720a2a5b45bc9f0c8d1b9c72adf45c37baedfcd949c35a2 \ + --hash=sha256:77f396e6ef4c73fdc33a9157446466f1cff553d979bd00ecb64385760c6babdc \ + --hash=sha256:81957921f441d50af23654aa6c5e5eaf9b06aba7f0a19c18a538dc7ef291c5a1 \ + --hash=sha256:819b3830a1543db06c4d4b865e70ded25be52a2e0631ccd2f6a47a2822f2fd7c \ + --hash=sha256:897b80890765f037df3403d22bab41627ca8811ae55e9a722fd0392850ec4d86 \ + --hash=sha256:98c4d36e99714e55cfbaaee6dd5badbc9a1ec339ebfc3b1f52e293aee6bb71a4 \ + --hash=sha256:9df7ed3b3d2e0ecfe09e14741b857df43adb5a3ddadc919a2d94fbdf78fea53c \ + --hash=sha256:9fa600030013c4de8165339db93d182b9431076eb98eb40ee068700c9c813e34 \ + --hash=sha256:a80a78046a72361de73f8f395f1f1e49f956c6be882eed58505a15f3e430962b \ + --hash=sha256:afa17f5bc4d1b10afd4466fd3a44dc0e245382deca5b3c353d8b757f9e3ecb8d \ + --hash=sha256:b3d267842bf12586ba6c734f89d1f5b871df0273157918b0ccefa29deb05c21c \ + --hash=sha256:b5b9eccad747aabaaffbc6064800670f0c297e52c12754eb1d976c57e4f74dcb \ + --hash=sha256:bfaef573a63ba8923503d27530362590ff4f576c626d86a9fed95822a8255fd7 \ + --hash=sha256:c5687b8d43cf58545ade1fe3e055f70eac7a5a1a0bf42824308d868289a95737 \ + --hash=sha256:cba8c411ef271aa037d7357a2bc8f9ee8b58b9965831d9e51baf703280dc73d3 \ + --hash=sha256:d15a181d1ecd0d4270dc32edb46f7cb7733c7c508857278d3d378d14d606db2d \ + --hash=sha256:d4b0ba9512519522b118090257be113b9468d804b19d63c71dbcf4a48fa32358 \ + --hash=sha256:d4db7c7aef085872ef65a8fd7d6d09a14ae91f691dec3e87ee5ee0539d516f53 \ + --hash=sha256:d4eccecf9adf6fbcc6861a38015c2a64f38b9d94838ac1810a9023a0609e1b78 \ + --hash=sha256:d67d839ede4ed1b28a4e8909735fc992a923cdb84e618544973d7dfc71540803 \ + --hash=sha256:daf496c58a8c52083df09b80c860005194014c3698698d1a57cbcfa182142a3a \ + --hash=sha256:dbad0e9d368bb989f4515da330b88a057617d16b6a8245084f1b05400f24609f \ + --hash=sha256:e61ceaab6f49fb8bdfaa0f92c4b57bcfbea54c09277b1b4f7ac376bfb7a7c174 \ + --hash=sha256:f84fbc98b019fef2ee9a1cb3ce93e3187a6df0b2538a651bfb890254ba9f90b5 +qrcode==7.3.1 ; python_version >= "3.10" and python_version < "4.0" \ + --hash=sha256:375a6ff240ca9bd41adc070428b5dfc1dcfbb0f2507f1ac848f6cded38956578 +requests==2.28.1 ; python_version >= "3.10" and python_version < "4" \ + --hash=sha256:7c5599b102feddaa661c826c56ab4fee28bfd17f5abca1ebbe3e7f19d7c97983 \ + --hash=sha256:8fefa2a1a1365bf5520aac41836fbee479da67864514bdb821f31ce07ce65349 +rfc3986[idna2008]==1.5.0 ; python_version >= "3.10" and python_version < "4.0" \ + --hash=sha256:270aaf10d87d0d4e095063c65bf3ddbc6ee3d0b226328ce21e036f946e421835 \ + --hash=sha256:a86d6e1f5b1dc238b218b012df0aa79409667bb209e58da56d0b94704e712a97 +rsa==4.9 ; python_version >= "3.10" and python_version < "4" \ + --hash=sha256:90260d9058e514786967344d0ef75fa8727eed8a7d2e43ce9f4bcf1b536174f7 \ + --hash=sha256:e38464a49c6c85d7f1351b0126661487a7e0a14a50f1675ec50eb34d4f20ef21 +setuptools==67.8.0 ; python_version >= "3.10" and python_version < "4.0" \ + --hash=sha256:5df61bf30bb10c6f756eb19e7c9f3b473051f48db77fddbe06ff2ca307df9a6f \ + --hash=sha256:62642358adc77ffa87233bc4d2354c4b2682d214048f500964dbe760ccedf102 +shortuuid==1.0.9 ; python_version >= "3.10" and python_version < "4.0" \ + --hash=sha256:459f12fa1acc34ff213b1371467c0325169645a31ed989e268872339af7563d5 \ + --hash=sha256:b2bb9eb7773170e253bb7ba25971023acb473517a8b76803d9618668cb1dd46f +six==1.16.0 ; python_version >= "3.10" and python_version < "4.0" \ + --hash=sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926 \ + --hash=sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254 +slowapi==0.1.8 ; python_version >= "3.10" and python_version < "4.0" \ + --hash=sha256:629fc415575bbffcd9d8621cc3ce326a78402c5f9b7b50b127979118d485c72e \ + --hash=sha256:8cc268f5a7e3624efa3f7bd2859b895f9f2376c4ed4e0378dd2f7f3343ca608e +sniffio==1.3.0 ; python_version >= "3.10" and python_version < "4.0" \ + --hash=sha256:e60305c5e5d314f5389259b7f22aaa33d8f7dee49763119234af3755c55b9101 \ + --hash=sha256:eecefdce1e5bbfb7ad2eeaabf7c1eeb404d7757c379bd1f7e5cce9d8bf425384 +sqlalchemy==1.4.48 ; python_version >= "3.10" and python_version < "4.0" \ + --hash=sha256:005e942b451cad5285015481ae4e557ff4154dde327840ba91b9ac379be3b6ce \ + --hash=sha256:066c2b0413e8cb980e6d46bf9d35ca83be81c20af688fedaef01450b06e4aa5e \ + --hash=sha256:0817c181271b0ce5df1aa20949f0a9e2426830fed5ecdcc8db449618f12c2730 \ + --hash=sha256:11c6b1de720f816c22d6ad3bbfa2f026f89c7b78a5c4ffafb220e0183956a92a \ + --hash=sha256:1fd8b5ee5a3acc4371f820934b36f8109ce604ee73cc668c724abb054cebcb6e \ + --hash=sha256:25887b4f716e085a1c5162f130b852f84e18d2633942c8ca40dfb8519367c14f \ + --hash=sha256:2b562e9d1e59be7833edf28b0968f156683d57cabd2137d8121806f38a9d58f4 \ + --hash=sha256:2b9af65cc58726129d8414fc1a1a650dcdd594ba12e9c97909f1f57d48e393d3 \ + --hash=sha256:2ee26276f12614d47cc07bc85490a70f559cba965fb178b1c45d46ffa8d73fda \ + --hash=sha256:2fc2ab4d9f6d9218a5caa4121bdcf1125303482a1cdcfcdbd8567be8518969c0 \ + --hash=sha256:3509159e050bd6d24189ec7af373359f07aed690db91909c131e5068176c5a5d \ + --hash=sha256:4355e5915844afdc5cf22ec29fba1010166e35dd94a21305f49020022167556b \ + --hash=sha256:44d29a3fc6d9c45962476b470a81983dd8add6ad26fdbfae6d463b509d5adcda \ + --hash=sha256:49c312bcff4728bffc6fb5e5318b8020ed5c8b958a06800f91859fe9633ca20e \ + --hash=sha256:4bac3aa3c3d8bc7408097e6fe8bf983caa6e9491c5d2e2488cfcfd8106f13b6a \ + --hash=sha256:5381ddd09a99638f429f4cbe1b71b025bed318f6a7b23e11d65f3eed5e181c33 \ + --hash=sha256:627e04a5d54bd50628fc8734d5fc6df2a1aa5962f219c44aad50b00a6cdcf965 \ + --hash=sha256:68413aead943883b341b2b77acd7a7fe2377c34d82e64d1840860247cec7ff7c \ + --hash=sha256:6dab89874e72a9ab5462997846d4c760cdb957958be27b03b49cf0de5e5c327c \ + --hash=sha256:6f82d8efea1ca92b24f51d3aea1a82897ed2409868a0af04247c8c1e4fef5890 \ + --hash=sha256:7ad2b0f6520ed5038e795cc2852eb5c1f20fa6831d73301ced4aafbe3a10e1f6 \ + --hash=sha256:87609f6d4e81a941a17e61a4c19fee57f795e96f834c4f0a30cee725fc3f81d9 \ + --hash=sha256:92e6133cf337c42bfee03ca08c62ba0f2d9695618c8abc14a564f47503157be9 \ + --hash=sha256:9af1db7a287ef86e0f5cd990b38da6bd9328de739d17e8864f1817710da2d217 \ + --hash=sha256:9c8cfe951ed074ba5e708ed29c45397a95c4143255b0d022c7c8331a75ae61f3 \ + --hash=sha256:9d9b55252d2ca42a09bcd10a697fa041e696def9dfab0b78c0aaea1485551a08 \ + --hash=sha256:a1fc046756cf2a37d7277c93278566ddf8be135c6a58397b4c940abf837011f4 \ + --hash=sha256:b47bc287096d989a0838ce96f7d8e966914a24da877ed41a7531d44b55cdb8df \ + --hash=sha256:c99bf13e07140601d111a7c6f1fc1519914dd4e5228315bbda255e08412f61a4 \ + --hash=sha256:cbbe8b8bffb199b225d2fe3804421b7b43a0d49983f81dc654d0431d2f855543 \ + --hash=sha256:ce7915eecc9c14a93b73f4e1c9d779ca43e955b43ddf1e21df154184f39748e5 \ + --hash=sha256:cef2e2abc06eab187a533ec3e1067a71d7bbec69e582401afdf6d8cad4ba3515 \ + --hash=sha256:d53cd8bc582da5c1c8c86b6acc4ef42e20985c57d0ebc906445989df566c5603 \ + --hash=sha256:dbcae0e528d755f4522cad5842f0942e54b578d79f21a692c44d91352ea6d64e \ + --hash=sha256:e1ddbbcef9bcedaa370c03771ebec7e39e3944782bef49e69430383c376a250b \ + --hash=sha256:e3e98d4907805b07743b583a99ecc58bf8807ecb6985576d82d5e8ae103b5272 \ + --hash=sha256:eb5464ee8d4bb6549d368b578e9529d3c43265007193597ddca71c1bae6174e6 \ + --hash=sha256:eee09350fd538e29cfe3a496ec6f148504d2da40dbf52adefb0d2f8e4d38ccc4 \ + --hash=sha256:fb0808ad34167f394fea21bd4587fc62f3bd81bba232a1e7fbdfa17e6cfa7cd7 \ + --hash=sha256:fbde5642104ac6e95f96e8ad6d18d9382aa20672008cf26068fe36f3004491df \ + --hash=sha256:fe1dd2562313dd9fe1778ed56739ad5d9aae10f9f43d9f4cf81d65b0c85168bb +starlette==0.19.1 ; python_version >= "3.10" and python_version < "4.0" \ + --hash=sha256:5a60c5c2d051f3a8eb546136aa0c9399773a689595e099e0877704d5888279bf \ + --hash=sha256:c6d21096774ecb9639acad41b86b7706e52ba3bf1dc13ea4ed9ad593d47e24c7 +toml==0.10.2 ; python_version >= "3.10" and python_version < "4.0" \ + --hash=sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b \ + --hash=sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f +typing-extensions==4.6.2 ; python_version >= "3.10" and python_version < "4.0" \ + --hash=sha256:06006244c70ac8ee83fa8282cb188f697b8db25bc8b4df07be1873c43897060c \ + --hash=sha256:3a8b36f13dd5fdc5d1b16fe317f5668545de77fa0b8e02006381fd49d731ab98 +typing-inspect==0.9.0 ; python_version >= "3.10" and python_version < "4.0" \ + --hash=sha256:9ee6fc59062311ef8547596ab6b955e1b8aa46242d854bfc78f4f6b0eff35f9f \ + --hash=sha256:b23fc42ff6f6ef6954e4852c1fb512cdd18dbea03134f91f856a95ccc9461f78 +ua-parser==0.16.1 ; python_version >= "3.10" and python_version < "4.0" \ + --hash=sha256:ed3efc695f475ffe56248c9789b3016247e9c20e3556cfa4d5aadc78ab4b26c6 \ + --hash=sha256:f97126300df8ac0f8f2c9d8559669532d626a1af529265fd253cba56e73ab36e +unidecode==1.3.6 ; python_version >= "3.10" and python_version < "4.0" \ + --hash=sha256:547d7c479e4f377b430dd91ac1275d593308dce0fc464fb2ab7d41f82ec653be \ + --hash=sha256:fed09cf0be8cf415b391642c2a5addfc72194407caee4f98719e40ec2a72b830 +urllib3==1.26.16 ; python_version >= "3.10" and python_version < "4" \ + --hash=sha256:8d36afa7616d8ab714608411b4a3b13e58f463aee519024578e062e141dce20f \ + --hash=sha256:8f135f6502756bde6b2a9b28989df5fbe87c9970cecaa69041edcce7f0589b14 +user-agents==2.2.0 ; python_version >= "3.10" and python_version < "4.0" \ + --hash=sha256:a98c4dc72ecbc64812c4534108806fb0a0b3a11ec3fd1eafe807cee5b0a942e7 \ + --hash=sha256:d36d25178db65308d1458c5fa4ab39c9b2619377010130329f3955e7626ead26 +uvicorn==0.19.0 ; python_version >= "3.10" and python_version < "4.0" \ + --hash=sha256:cc277f7e73435748e69e075a721841f7c4a95dba06d12a72fe9874acced16f6f \ + --hash=sha256:cf538f3018536edb1f4a826311137ab4944ed741d52aeb98846f52215de57f25 +vine==5.0.0 ; python_version >= "3.10" and python_version < "4.0" \ + --hash=sha256:4c9dceab6f76ed92105027c49c823800dd33cacce13bdedc5b914e3514b7fb30 \ + --hash=sha256:7d3b1624a953da82ef63462013bbd271d3eb75751489f9807598e8f340bd637e +wcwidth==0.2.6 ; python_version >= "3.10" and python_version < "4.0" \ + --hash=sha256:795b138f6875577cd91bba52baf9e445cd5118fd32723b460e30a0af30ea230e \ + --hash=sha256:a5220780a404dbe3353789870978e472cfe477761f06ee55077256e509b156d0 +wrapt==1.15.0 ; python_version >= "3.10" and python_version < "4.0" \ + --hash=sha256:02fce1852f755f44f95af51f69d22e45080102e9d00258053b79367d07af39c0 \ + --hash=sha256:077ff0d1f9d9e4ce6476c1a924a3332452c1406e59d90a2cf24aeb29eeac9420 \ + --hash=sha256:078e2a1a86544e644a68422f881c48b84fef6d18f8c7a957ffd3f2e0a74a0d4a \ + --hash=sha256:0970ddb69bba00670e58955f8019bec4a42d1785db3faa043c33d81de2bf843c \ + --hash=sha256:1286eb30261894e4c70d124d44b7fd07825340869945c79d05bda53a40caa079 \ + --hash=sha256:21f6d9a0d5b3a207cdf7acf8e58d7d13d463e639f0c7e01d82cdb671e6cb7923 \ + --hash=sha256:230ae493696a371f1dbffaad3dafbb742a4d27a0afd2b1aecebe52b740167e7f \ + --hash=sha256:26458da5653aa5b3d8dc8b24192f574a58984c749401f98fff994d41d3f08da1 \ + --hash=sha256:2cf56d0e237280baed46f0b5316661da892565ff58309d4d2ed7dba763d984b8 \ + --hash=sha256:2e51de54d4fb8fb50d6ee8327f9828306a959ae394d3e01a1ba8b2f937747d86 \ + --hash=sha256:2fbfbca668dd15b744418265a9607baa970c347eefd0db6a518aaf0cfbd153c0 \ + --hash=sha256:38adf7198f8f154502883242f9fe7333ab05a5b02de7d83aa2d88ea621f13364 \ + --hash=sha256:3a8564f283394634a7a7054b7983e47dbf39c07712d7b177b37e03f2467a024e \ + --hash=sha256:3abbe948c3cbde2689370a262a8d04e32ec2dd4f27103669a45c6929bcdbfe7c \ + --hash=sha256:3bbe623731d03b186b3d6b0d6f51865bf598587c38d6f7b0be2e27414f7f214e \ + --hash=sha256:40737a081d7497efea35ab9304b829b857f21558acfc7b3272f908d33b0d9d4c \ + --hash=sha256:41d07d029dd4157ae27beab04d22b8e261eddfc6ecd64ff7000b10dc8b3a5727 \ + --hash=sha256:46ed616d5fb42f98630ed70c3529541408166c22cdfd4540b88d5f21006b0eff \ + --hash=sha256:493d389a2b63c88ad56cdc35d0fa5752daac56ca755805b1b0c530f785767d5e \ + --hash=sha256:4ff0d20f2e670800d3ed2b220d40984162089a6e2c9646fdb09b85e6f9a8fc29 \ + --hash=sha256:54accd4b8bc202966bafafd16e69da9d5640ff92389d33d28555c5fd4f25ccb7 \ + --hash=sha256:56374914b132c702aa9aa9959c550004b8847148f95e1b824772d453ac204a72 \ + --hash=sha256:578383d740457fa790fdf85e6d346fda1416a40549fe8db08e5e9bd281c6a475 \ + --hash=sha256:58d7a75d731e8c63614222bcb21dd992b4ab01a399f1f09dd82af17bbfc2368a \ + --hash=sha256:5c5aa28df055697d7c37d2099a7bc09f559d5053c3349b1ad0c39000e611d317 \ + --hash=sha256:5fc8e02f5984a55d2c653f5fea93531e9836abbd84342c1d1e17abc4a15084c2 \ + --hash=sha256:63424c681923b9f3bfbc5e3205aafe790904053d42ddcc08542181a30a7a51bd \ + --hash=sha256:64b1df0f83706b4ef4cfb4fb0e4c2669100fd7ecacfb59e091fad300d4e04640 \ + --hash=sha256:74934ebd71950e3db69960a7da29204f89624dde411afbfb3b4858c1409b1e98 \ + --hash=sha256:75669d77bb2c071333417617a235324a1618dba66f82a750362eccbe5b61d248 \ + --hash=sha256:75760a47c06b5974aa5e01949bf7e66d2af4d08cb8c1d6516af5e39595397f5e \ + --hash=sha256:76407ab327158c510f44ded207e2f76b657303e17cb7a572ffe2f5a8a48aa04d \ + --hash=sha256:76e9c727a874b4856d11a32fb0b389afc61ce8aaf281ada613713ddeadd1cfec \ + --hash=sha256:77d4c1b881076c3ba173484dfa53d3582c1c8ff1f914c6461ab70c8428b796c1 \ + --hash=sha256:780c82a41dc493b62fc5884fb1d3a3b81106642c5c5c78d6a0d4cbe96d62ba7e \ + --hash=sha256:7dc0713bf81287a00516ef43137273b23ee414fe41a3c14be10dd95ed98a2df9 \ + --hash=sha256:7eebcdbe3677e58dd4c0e03b4f2cfa346ed4049687d839adad68cc38bb559c92 \ + --hash=sha256:896689fddba4f23ef7c718279e42f8834041a21342d95e56922e1c10c0cc7afb \ + --hash=sha256:96177eb5645b1c6985f5c11d03fc2dbda9ad24ec0f3a46dcce91445747e15094 \ + --hash=sha256:96e25c8603a155559231c19c0349245eeb4ac0096fe3c1d0be5c47e075bd4f46 \ + --hash=sha256:9d37ac69edc5614b90516807de32d08cb8e7b12260a285ee330955604ed9dd29 \ + --hash=sha256:9ed6aa0726b9b60911f4aed8ec5b8dd7bf3491476015819f56473ffaef8959bd \ + --hash=sha256:a487f72a25904e2b4bbc0817ce7a8de94363bd7e79890510174da9d901c38705 \ + --hash=sha256:a4cbb9ff5795cd66f0066bdf5947f170f5d63a9274f99bdbca02fd973adcf2a8 \ + --hash=sha256:a74d56552ddbde46c246b5b89199cb3fd182f9c346c784e1a93e4dc3f5ec9975 \ + --hash=sha256:a89ce3fd220ff144bd9d54da333ec0de0399b52c9ac3d2ce34b569cf1a5748fb \ + --hash=sha256:abd52a09d03adf9c763d706df707c343293d5d106aea53483e0ec8d9e310ad5e \ + --hash=sha256:abd8f36c99512755b8456047b7be10372fca271bf1467a1caa88db991e7c421b \ + --hash=sha256:af5bd9ccb188f6a5fdda9f1f09d9f4c86cc8a539bd48a0bfdc97723970348418 \ + --hash=sha256:b02f21c1e2074943312d03d243ac4388319f2456576b2c6023041c4d57cd7019 \ + --hash=sha256:b06fa97478a5f478fb05e1980980a7cdf2712015493b44d0c87606c1513ed5b1 \ + --hash=sha256:b0724f05c396b0a4c36a3226c31648385deb6a65d8992644c12a4963c70326ba \ + --hash=sha256:b130fe77361d6771ecf5a219d8e0817d61b236b7d8b37cc045172e574ed219e6 \ + --hash=sha256:b56d5519e470d3f2fe4aa7585f0632b060d532d0696c5bdfb5e8319e1d0f69a2 \ + --hash=sha256:b67b819628e3b748fd3c2192c15fb951f549d0f47c0449af0764d7647302fda3 \ + --hash=sha256:ba1711cda2d30634a7e452fc79eabcadaffedf241ff206db2ee93dd2c89a60e7 \ + --hash=sha256:bbeccb1aa40ab88cd29e6c7d8585582c99548f55f9b2581dfc5ba68c59a85752 \ + --hash=sha256:bd84395aab8e4d36263cd1b9308cd504f6cf713b7d6d3ce25ea55670baec5416 \ + --hash=sha256:c99f4309f5145b93eca6e35ac1a988f0dc0a7ccf9ccdcd78d3c0adf57224e62f \ + --hash=sha256:ca1cccf838cd28d5a0883b342474c630ac48cac5df0ee6eacc9c7290f76b11c1 \ + --hash=sha256:cd525e0e52a5ff16653a3fc9e3dd827981917d34996600bbc34c05d048ca35cc \ + --hash=sha256:cdb4f085756c96a3af04e6eca7f08b1345e94b53af8921b25c72f096e704e145 \ + --hash=sha256:ce42618f67741d4697684e501ef02f29e758a123aa2d669e2d964ff734ee00ee \ + --hash=sha256:d06730c6aed78cee4126234cf2d071e01b44b915e725a6cb439a879ec9754a3a \ + --hash=sha256:d5fe3e099cf07d0fb5a1e23d399e5d4d1ca3e6dfcbe5c8570ccff3e9208274f7 \ + --hash=sha256:d6bcbfc99f55655c3d93feb7ef3800bd5bbe963a755687cbf1f490a71fb7794b \ + --hash=sha256:d787272ed958a05b2c86311d3a4135d3c2aeea4fc655705f074130aa57d71653 \ + --hash=sha256:e169e957c33576f47e21864cf3fc9ff47c223a4ebca8960079b8bd36cb014fd0 \ + --hash=sha256:e20076a211cd6f9b44a6be58f7eeafa7ab5720eb796975d0c03f05b47d89eb90 \ + --hash=sha256:e826aadda3cae59295b95343db8f3d965fb31059da7de01ee8d1c40a60398b29 \ + --hash=sha256:eef4d64c650f33347c1f9266fa5ae001440b232ad9b98f1f43dfe7a79435c0a6 \ + --hash=sha256:f2e69b3ed24544b0d3dbe2c5c0ba5153ce50dcebb576fdc4696d52aa22db6034 \ + --hash=sha256:f87ec75864c37c4c6cb908d282e1969e79763e0d9becdfe9fe5473b7bb1e5f09 \ + --hash=sha256:fbec11614dba0424ca72f4e8ba3c420dba07b4a7c206c8c8e4e73f2e98f4c559 \ + --hash=sha256:fd69666217b62fa5d7c6aa88e507493a34dec4fa20c5bd925e4bc12fce586639 +yamldataclassconfig==1.5.0 ; python_version >= "3.10" and python_version < "4.0" \ + --hash=sha256:5b06a5ced890f2e18ec2a42b8e57d6659f2134554eb8402319b22c5993a5725f \ + --hash=sha256:dbbaa499875b9187b1446279440aa3e8e1dc1d5878fe46a27272b0d4b70b85a4 +yarg==0.1.9 ; python_version >= "3.10" and python_version < "4.0" \ + --hash=sha256:4f9cebdc00fac946c9bf2783d634e538a71c7d280a4d806d45fd4dc0ef441492 \ + --hash=sha256:55695bf4d1e3e7f756496c36a69ba32c40d18f821e38f61d028f6049e5e15911 diff --git a/root.py b/root.py new file mode 100644 index 0000000..1267de0 --- /dev/null +++ b/root.py @@ -0,0 +1,3 @@ +import os + +ROOT_DIR = os.path.dirname(os.path.abspath(__file__)) diff --git a/task.py b/task.py new file mode 100644 index 0000000..8971a94 --- /dev/null +++ b/task.py @@ -0,0 +1,9 @@ +from celery import Celery + +celery = Celery( + __name__, + broker='redis://127.0.0.1:6379/0', + backend='redis://127.0.0.1:6379/0' +) + +celery.autodiscover_tasks(['auth']) diff --git a/upload/__init__.py b/upload/__init__.py new file mode 100644 index 0000000..a0e55b0 --- /dev/null +++ b/upload/__init__.py @@ -0,0 +1 @@ +from .urls import * diff --git a/upload/models/__init__.py b/upload/models/__init__.py new file mode 100644 index 0000000..3d4af58 --- /dev/null +++ b/upload/models/__init__.py @@ -0,0 +1 @@ +from .upload import * diff --git a/upload/models/upload.py b/upload/models/upload.py new file mode 100644 index 0000000..420d42b --- /dev/null +++ b/upload/models/upload.py @@ -0,0 +1,18 @@ +import uuid +from typing import Optional, Union, Any + +from pydantic import Field + +from core.models.base import BaseModel, BaseCUAt + + +class FileModel(BaseModel, BaseCUAt): + id: Union[Optional[uuid.UUID], str] = Field(alias='_id') + owner_id: Optional[Union[uuid.UUID, str]] + module: Optional[str] + payload: Optional[Any] + file_name: str + file_type: str + file_size: int + storage: str + driver: Optional[str] diff --git a/upload/repositories/upload.py b/upload/repositories/upload.py new file mode 100644 index 0000000..6c8dad6 --- /dev/null +++ b/upload/repositories/upload.py @@ -0,0 +1,100 @@ +import hashlib +import json +import os +import uuid +from datetime import date +from pathlib import Path + +import aiofiles +from PIL import Image +from fastapi import UploadFile, HTTPException +from starlette.responses import FileResponse + +from core.utils import is_valid_uuid +from core.service.mongo_db import use_collection +from root import ROOT_DIR +from upload.models.upload import FileModel + +COLLECTION = 'files' +ThisModel = FileModel + + +def create_dir(path: str) -> None: + """Create a directory if it does not exist.""" + if not os.path.exists(path): + os.makedirs(path) + + +def _fix_str_uuid(data: dict) -> dict: + for key, value in data.items(): + if isinstance(value, str) and is_valid_uuid(value): + data[key] = uuid.UUID(value) + return data + + +def storage_path(dest: str = '', prefix='storage/') -> Path: + return Path(__file__).parent.parent.parent.joinpath(f'{prefix}{dest}') + + +class UploadRepository: + @staticmethod + async def upload(file: UploadFile, data: str, module): + if data: + data = json.JSONDecoder().decode(data) + + if isinstance(data, dict): + data = _fix_str_uuid(data) + + content = await file.read() # async read + file_size = len(content) + ext = file.filename.split('.')[-1] + file_name = '{0}.{1}'.format(hashlib.md5(content).hexdigest(), ext) + + today = date.today() + dir = Path(__file__).parent.parent.parent.joinpath( + 'storage/' + today.strftime("%Y%m")) + + create_dir(dir.__str__()) + + path = dir.joinpath(file_name) + + async with aiofiles.open(path, 'wb') as out_file: + await out_file.write(content) # async write + + if file.content_type.startswith('image'): + UploadRepository._generate_thumbnail(dir, file_name) + UploadRepository._generate_thumbnail(dir, file_name, (512, 512)) + UploadRepository._generate_thumbnail(dir, file_name, (48, 48)) + + model = ThisModel( + file_name=file.filename, + file_type=file.content_type, + file_size=file_size / 1024, + module=module, + payload=data, + storage=path.relative_to(ROOT_DIR).as_posix().__str__(), + ).init_id().cu_at() + + result = await use_collection(COLLECTION).insert_one(model.mongo_dict()) + assert result.acknowledged + return model.mongo_dict() + + @staticmethod + def _generate_thumbnail(dir: Path, file_name, size=(256, 256)): + try: + im = Image.open(dir.joinpath(file_name)) + im.thumbnail(size) + thumb_dir = dir.joinpath('thumb').__str__() + create_dir(thumb_dir) + thumb_name = '{0}_{1}'.format(str(size[0]), file_name) + im.save('{0}/{1}'.format(thumb_dir, thumb_name, "JPEG")) + except IOError: + print("cannot create thumbnail for") + + @staticmethod + async def get_file(_id: uuid.UUID): + result = await use_collection(COLLECTION).find_one({"_id": _id}) + if not result: + raise HTTPException(status_code=404, detail='File Not Found') + file = storage_path(result.get('storage'), '') + return FileResponse(file) diff --git a/upload/urls/__init__.py b/upload/urls/__init__.py new file mode 100644 index 0000000..3d4af58 --- /dev/null +++ b/upload/urls/__init__.py @@ -0,0 +1 @@ +from .upload import * diff --git a/upload/urls/upload.py b/upload/urls/upload.py new file mode 100644 index 0000000..052f4bf --- /dev/null +++ b/upload/urls/upload.py @@ -0,0 +1,29 @@ +from typing import Any, Optional +from uuid import UUID + +from fastapi import Body, UploadFile, File + +from config import conf +from core.app import app +from core.utils.router import generate_router, safe_route +from upload.models import FileModel +from upload.repositories.upload import UploadRepository + +router = generate_router("file", auth_dependencies=True, version='v2') + +_tag_base = ['file'] + +_pre = conf.server.prefix_url.replace('v1', 'v2') + + +@router.post('/upload', response_model=FileModel, tags=_tag_base) +async def upload(file: UploadFile = File(...), payload: Optional[Any] = Body(None), module: Optional[str] = Body(None)): + return await safe_route('upload', UploadRepository.upload, file, payload, module) + + +@app.get(f'{_pre}' + '/file/{_id}', tags=_tag_base) +async def upload(_id: UUID): + return await UploadRepository.get_file(_id) + + +app.include_router(router)