xn-model 0.10.3.dev1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,5 @@
1
+ POSTGRES_USER=user
2
+ POSTGRES_PASSWORD=password
3
+ POSTGRES_DB=bd_name
4
+ POSTGRES_HOST=127.0.0.1
5
+ POSTGRES_PORT=5432
@@ -0,0 +1,7 @@
1
+ /dist
2
+ /.idea
3
+ /*.egg-info
4
+ /.env
5
+ /venv
6
+ __pycache__
7
+ /build
@@ -0,0 +1,44 @@
1
+ repos:
2
+ - repo: local
3
+ hooks:
4
+ - id: pytest
5
+ name: pytest
6
+ entry: pytest tests -v
7
+ language: python
8
+ types: [python]
9
+ verbose: true
10
+ stages: [pre-commit]
11
+
12
+ - id: tag
13
+ name: tag
14
+ ### make tag with next ver only if "fix" in commit_msg or starts with "feat"
15
+ entry: bash -c 'grep -e "^feat:" -e "^fix:" .git/COMMIT_EDITMSG && make patch || exit 0'
16
+ language: system
17
+ verbose: true
18
+ pass_filenames: false
19
+ always_run: true
20
+ stages: [post-commit]
21
+
22
+ - id: build
23
+ name: build
24
+ ### build & upload package only for "main" branch push
25
+ entry: bash -c 'echo $PRE_COMMIT_LOCAL_BRANCH | grep /master && make build || echo 0'
26
+ language: system
27
+ pass_filenames: false
28
+ verbose: true
29
+ require_serial: true
30
+ stages: [pre-push]
31
+
32
+ - repo: https://github.com/astral-sh/ruff-pre-commit
33
+ ### Ruff version.
34
+ rev: v0.6.4
35
+ hooks:
36
+ ### Run the linter.
37
+ - id: ruff
38
+ args: [--fix, --unsafe-fixes]
39
+ stages: [pre-commit]
40
+ ### Run the formatter.
41
+ - id: ruff-format
42
+ types_or: [python, pyi]
43
+ verbose: true
44
+ stages: [pre-commit]
@@ -0,0 +1,43 @@
1
+ Metadata-Version: 2.1
2
+ Name: xn-model
3
+ Version: 0.10.3.dev1
4
+ Summary: Base model for xn-api
5
+ Author-email: Mike Artemiev <mixartemev@gmail.com>
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/XyncNet/x-model
8
+ Project-URL: Repository, https://github.com/XyncNet/x-model
9
+ Keywords: tortoise,model,crud,generator,api,admin
10
+ Requires-Python: >=3.12
11
+ Description-Content-Type: text/markdown
12
+ Requires-Dist: tortoise-orm[accel,asyncpg]
13
+ Requires-Dist: passlib[bcrypt]
14
+ Requires-Dist: pydantic
15
+ Requires-Dist: python-dotenv
16
+ Provides-Extra: dev
17
+ Requires-Dist: pytest; extra == "dev"
18
+ Requires-Dist: build; extra == "dev"
19
+ Requires-Dist: twine; extra == "dev"
20
+ Requires-Dist: setuptools_scm; extra == "dev"
21
+
22
+ ## INSTALL
23
+ ```bash
24
+ # Create python virtual environment
25
+ python3 -m venv venv
26
+ # Activate this environment
27
+ source venv/bin/activate
28
+ # Install dependencies
29
+ pip install .
30
+
31
+ # Create pg db
32
+ createdb --U username -W dbname
33
+ ## set password for db user
34
+
35
+ # Copy .env file from sample template
36
+ cp .env.sample .env
37
+ ## set your pg creds in .env file
38
+ ```
39
+
40
+ ## TEST
41
+ ```bash
42
+ pytest
43
+ ```
@@ -0,0 +1,22 @@
1
+ ## INSTALL
2
+ ```bash
3
+ # Create python virtual environment
4
+ python3 -m venv venv
5
+ # Activate this environment
6
+ source venv/bin/activate
7
+ # Install dependencies
8
+ pip install .
9
+
10
+ # Create pg db
11
+ createdb --U username -W dbname
12
+ ## set password for db user
13
+
14
+ # Copy .env file from sample template
15
+ cp .env.sample .env
16
+ ## set your pg creds in .env file
17
+ ```
18
+
19
+ ## TEST
20
+ ```bash
21
+ pytest
22
+ ```
@@ -0,0 +1,27 @@
1
+ PACKAGE := x_model
2
+ VENV := venv
3
+ VPYTHON := . $(VENV)/bin/activate && python
4
+
5
+ .PHONY: all install pre-commit test clean build twine patch
6
+
7
+ all:
8
+ make install test clean build
9
+
10
+ install: $(VENV)
11
+ $(VPYTHON) -m pip install -e .[dev]; make pre-commit
12
+ pre-commit: .pre-commit-config.yaml
13
+ pre-commit install -t pre-commit -t post-commit -t pre-push
14
+
15
+ test:
16
+ $(VPYTHON) -m pytest
17
+
18
+ clean: .pytest_cache dist $(PACKAGE).egg-info
19
+ rm -rf .pytest_cache dist/* $(PACKAGE).egg-info $(PACKAGE)/__pycache__ dist/__pycache__
20
+
21
+ build:
22
+ $(VPYTHON) -m build; make twine
23
+ twine: dist
24
+ $(VPYTHON) -m twine upload dist/* --skip-existing
25
+
26
+ patch:
27
+ git tag `$(VPYTHON) -m setuptools_scm --strip-dev`; git push --tags --prune -f
@@ -0,0 +1,43 @@
1
+ [project]
2
+ name = "xn-model"
3
+ requires-python = ">=3.12"
4
+ authors = [
5
+ {name = "Mike Artemiev", email = "mixartemev@gmail.com"},
6
+ ]
7
+ dependencies = [
8
+ 'tortoise-orm[accel,asyncpg]',
9
+ 'passlib[bcrypt]',
10
+ 'pydantic',
11
+ "python-dotenv"
12
+ ]
13
+ keywords = ["tortoise", "model", "crud", "generator", "api", "admin"]
14
+ description = 'Base model for xn-api'
15
+ readme = "README.md"
16
+ license = {text = "MIT"}
17
+ dynamic = ["version"]
18
+
19
+ [project.urls]
20
+ Homepage = "https://github.com/XyncNet/x-model"
21
+ Repository = "https://github.com/XyncNet/x-model"
22
+
23
+ [build-system]
24
+ requires = ["setuptools>=64", "setuptools-scm[toml]>=8"]
25
+ build-backend = "setuptools.build_meta"
26
+
27
+ [tool.setuptools]
28
+ packages = ["x_model"]
29
+
30
+ [tool.setuptools_scm]
31
+ version_scheme = "python-simplified-semver" # if "feature" in `branch_name` SEMVER_MINOR++ else SEMVER_PATCH++
32
+ local_scheme = "no-local-version"
33
+
34
+ [project.optional-dependencies]
35
+ dev = [
36
+ "pytest",
37
+ "build",
38
+ "twine",
39
+ "setuptools_scm",
40
+ ]
41
+
42
+ [tool.ruff]
43
+ line-length = 120
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
File without changes
@@ -0,0 +1,14 @@
1
+ from asyncio import run
2
+ from os import getenv as env
3
+ from dotenv import load_dotenv
4
+ from tortoise.backends.asyncpg import AsyncpgDBClient
5
+
6
+ from x_model import init_db, model
7
+
8
+ load_dotenv()
9
+
10
+ PG_DSN = f"postgres://{env('POSTGRES_USER')}:{env('POSTGRES_PASSWORD')}@{env('POSTGRES_HOST', 'xyncdbs')}:{env('POSTGRES_PORT', 5432)}/{env('POSTGRES_DB', env('POSTGRES_USER'))}"
11
+
12
+
13
+ def test_init_db():
14
+ assert isinstance(run(init_db(PG_DSN, model)), AsyncpgDBClient), "DB corrupt"
@@ -0,0 +1,34 @@
1
+ from types import ModuleType
2
+ from tortoise import Tortoise, connections, ConfigurationError
3
+ from tortoise.backends.asyncpg import AsyncpgDBClient
4
+ from tortoise.exceptions import DBConnectionError
5
+
6
+ from .enum import FieldType
7
+ from .field import PointField, RangeField, PolygonField, CollectionField, ListField, DatetimeSecField
8
+ from .model import Model, TsModel, User
9
+ from .func import Array
10
+
11
+ __all__ = [
12
+ "FieldType",
13
+ "PointField",
14
+ "RangeField",
15
+ "PolygonField",
16
+ "CollectionField",
17
+ "ListField",
18
+ "DatetimeSecField",
19
+ "Model",
20
+ "TsModel",
21
+ "User",
22
+ "Array",
23
+ ]
24
+
25
+
26
+ async def init_db(dsn: str, models: ModuleType, create_tables: bool = False) -> AsyncpgDBClient | str:
27
+ try:
28
+ await Tortoise.init(db_url=dsn, modules={"models": [models]})
29
+ if create_tables:
30
+ await Tortoise.generate_schemas()
31
+ cn: AsyncpgDBClient = connections.get("default")
32
+ except (ConfigurationError, DBConnectionError) as ce:
33
+ return ce.args[0]
34
+ return cn
@@ -0,0 +1,31 @@
1
+ from enum import IntEnum
2
+
3
+
4
+ class FieldType(IntEnum):
5
+ input = 1
6
+ checkbox = 2
7
+ select = 3
8
+ textarea = 4
9
+ collection = 5
10
+ list = 6
11
+
12
+
13
+ class UserStatus(IntEnum):
14
+ BANNED = 0
15
+ WAIT = 1 # waiting for approve
16
+ TEST = 2 # trial
17
+ ACTIVE = 3
18
+ PREMIUM = 4
19
+
20
+
21
+ class Scope(IntEnum):
22
+ READ = 4
23
+ WRITE = 2
24
+ ALL = 1 # not only my
25
+
26
+
27
+ class UserRole(IntEnum):
28
+ CLIENT = Scope.READ # 4
29
+ AUTHOR = Scope.WRITE # 2
30
+ MANAGER = Scope.READ + Scope.WRITE # 6
31
+ ADMIN = Scope.READ + Scope.WRITE + Scope.ALL # 7
@@ -0,0 +1,95 @@
1
+ from enum import IntEnum
2
+ from typing import Any
3
+
4
+ from asyncpg import Point, Polygon, Range, Box
5
+ from tortoise import Model
6
+ from tortoise.contrib.postgres.fields import ArrayField
7
+ from tortoise.fields import Field, SmallIntField, IntField, FloatField, DatetimeField
8
+ from tortoise.fields.base import VALUE
9
+
10
+
11
+ class ListField(Field[VALUE]):
12
+ base_field = Field[VALUE]
13
+ labels: tuple
14
+
15
+ def to_python_value(self, value):
16
+ if value is not None and not isinstance(value, self.field_type):
17
+ value = self.field_type(*value)
18
+ self.validate(value)
19
+ return value
20
+
21
+
22
+ class CollectionField(ListField[VALUE]):
23
+ labels: tuple
24
+ step: str = None
25
+
26
+ def __new__(cls, precision: int = 0, *args, **kwargs):
27
+ if precision:
28
+ cls.step = f"0.{'0'*(precision-1)}1"
29
+ cls.base_field = FloatField if precision else IntField
30
+ return super().__new__(cls)
31
+
32
+
33
+ class RangeField(CollectionField[Range]):
34
+ field_type = Range
35
+ labels = ("from", "to")
36
+
37
+ def __new__(cls, precision: int = 0, *args, **kwargs):
38
+ cls.SQL_TYPE = "numrange" if precision else "int4range"
39
+ return super().__new__(cls)
40
+
41
+ def to_python_value(self, value: tuple):
42
+ if value is not None and not isinstance(value, self.field_type):
43
+ value = self.field_type(*[float(v) for v in value])
44
+ self.validate(value)
45
+ return (value.lower, value.upper) if value else None
46
+
47
+ def to_db_value(self, value: Any, instance: Model) -> Any:
48
+ if value is not None and not isinstance(value, self.field_type):
49
+ value = self.field_type(*value) # pylint: disable=E1102
50
+ self.validate(value)
51
+ return value
52
+
53
+
54
+ class PointField(CollectionField[Point]):
55
+ SQL_TYPE = "POINT"
56
+ field_type = Point
57
+ base_field = FloatField
58
+ labels = ("lat", "lon")
59
+
60
+
61
+ class PolygonField(ListField[Polygon]):
62
+ SQL_TYPE = "POLYGON"
63
+ field_type = Polygon
64
+ base_field = PointField
65
+
66
+
67
+ class BoxField(ListField[Box]):
68
+ SQL_TYPE = "BOX"
69
+ field_type = Box
70
+ base_field = PointField
71
+
72
+
73
+ class DatetimeSecField(DatetimeField):
74
+ class _db_postgres:
75
+ SQL_TYPE = "TIMESTAMPTZ(0)"
76
+
77
+
78
+ class SetField(ListField[IntEnum]):
79
+ SQL_TYPE = "smallint[]"
80
+ field_type = ArrayField
81
+ base_field = SmallIntField
82
+ enum_type: type[IntEnum]
83
+
84
+ def __init__(self, enum_type: type[IntEnum], **kwargs: Any):
85
+ super().__init__(**kwargs)
86
+ self.enum_type = enum_type
87
+ self.field_type = enum_type
88
+ self.base_field = enum_type
89
+
90
+ def to_python_value(self, value):
91
+ for val in value:
92
+ if val is not None and not isinstance(val, self.enum_type):
93
+ val = self.enum_type(val)
94
+ self.validate(val)
95
+ return value
@@ -0,0 +1,11 @@
1
+ from pypika.terms import AggregateFunction
2
+ from tortoise.expressions import Function
3
+
4
+
5
+ class ArrayAgg(AggregateFunction):
6
+ def __init__(self, term, alias=None):
7
+ super(ArrayAgg, self).__init__("array_agg", term, alias=alias)
8
+
9
+
10
+ class Array(Function):
11
+ database_func = ArrayAgg
@@ -0,0 +1,330 @@
1
+ from datetime import datetime
2
+ from passlib.context import CryptContext
3
+ from pydantic import create_model
4
+ from tortoise import Model as BaseModel
5
+ from tortoise.contrib.postgres.fields import ArrayField
6
+ from tortoise.contrib.pydantic import pydantic_model_creator, PydanticModel
7
+ from tortoise.fields import (
8
+ Field,
9
+ CharField,
10
+ IntField,
11
+ SmallIntField,
12
+ BigIntField,
13
+ DecimalField,
14
+ FloatField,
15
+ TextField,
16
+ BooleanField,
17
+ DatetimeField,
18
+ DateField,
19
+ TimeField,
20
+ JSONField,
21
+ ForeignKeyRelation,
22
+ OneToOneRelation,
23
+ ManyToManyRelation,
24
+ ForeignKeyNullableRelation,
25
+ OneToOneNullableRelation,
26
+ IntEnumField,
27
+ )
28
+ from tortoise.fields.data import IntEnumFieldInstance, CharEnumFieldInstance
29
+ from tortoise.fields.relational import (
30
+ BackwardFKRelation,
31
+ ForeignKeyFieldInstance,
32
+ ManyToManyFieldInstance,
33
+ OneToOneFieldInstance,
34
+ BackwardOneToOneRelation,
35
+ RelationalField,
36
+ )
37
+ from tortoise.models import MetaInfo
38
+ from tortoise.queryset import QuerySet
39
+
40
+ from x_model import FieldType, PointField, PolygonField, RangeField
41
+ from x_model.enum import UserStatus, UserRole
42
+ from x_model.field import DatetimeSecField, SetField
43
+ from x_model.pydantic import PydList
44
+
45
+
46
+ class Model(BaseModel):
47
+ id: int = IntField(pk=True)
48
+ _name: set[str] = {"name"}
49
+ _icon: str = "" # https://unpkg.com/@tabler/icons@2.30.0/icons/icon_name.svg
50
+ _sorts: list[str] = ["-id"]
51
+ _ownable_fields: dict[str, str | None] = {"one": None, "list": None, "in": None}
52
+ _pydIn: type[PydanticModel] = None
53
+ _pyd: type[PydanticModel] = None
54
+ _pydListItem: type[PydanticModel] = None
55
+ _permissions: tuple[bool, bool, bool] = True, True, True
56
+
57
+ @classmethod
58
+ def cols(cls) -> list[dict]:
59
+ meta = cls._meta
60
+ return [
61
+ {"data": c, "orderable": c not in meta.fetch_fields or c in meta.fk_fields}
62
+ for c in meta.fields_map
63
+ if not c.endswith("_id")
64
+ ]
65
+
66
+ @classmethod
67
+ def pyd(cls) -> type[PydanticModel]:
68
+ cls._pyd = cls._pyd or pydantic_model_creator(cls)
69
+ return cls._pyd
70
+
71
+ @classmethod
72
+ def pydIn(cls) -> type[PydanticModel]:
73
+ if not cls._pydIn:
74
+ opts = tuple(k for k, v in cls._meta.fields_map.items() if not v.required)
75
+ cls._pydIn = pydantic_model_creator(
76
+ cls,
77
+ name=cls.__name__ + "In",
78
+ meta_override=cls.PydanticMetaIn,
79
+ optional=opts,
80
+ exclude_readonly=True,
81
+ exclude=("created_at", "updated_at"),
82
+ )
83
+ if m2ms := cls._meta.m2m_fields: # hack for direct inserting m2m values
84
+ cls._pydIn = create_model(
85
+ cls._pydIn.__name__, __base__=cls._pydIn, **{m2m: (list[int] | None, None) for m2m in m2ms}
86
+ )
87
+ return cls._pydIn
88
+
89
+ @classmethod
90
+ def pydListItem(cls) -> type[PydanticModel]:
91
+ if not cls._pydListItem:
92
+ cls._pydListItem = pydantic_model_creator(
93
+ cls, name=cls.__name__ + "ListItem", meta_override=cls.PydanticMetaListItem
94
+ )
95
+ return cls._pydListItem
96
+
97
+ @classmethod
98
+ def pydsList(cls) -> type[PydList]:
99
+ return create_model(
100
+ cls.__name__ + "List",
101
+ data=(list[cls.pydListItem()], []),
102
+ total=(int, 0),
103
+ filtered=(int | None, None),
104
+ __base__=PydList[cls.pydListItem()],
105
+ )
106
+
107
+ @classmethod
108
+ async def one(cls, uid: int, owner: int = None, **kwargs) -> PydanticModel:
109
+ if owner and (of := cls._ownable_fields.get("one")):
110
+ kwargs.update({of: owner})
111
+ q = cls.get(id=uid, **kwargs)
112
+ return await cls.pyd().from_queryset_single(q)
113
+
114
+ @classmethod
115
+ def pageQuery(
116
+ cls, sorts: list[str], limit: int = 1000, offset: int = 0, q: str = None, owner: int = None, **kwargs
117
+ ) -> QuerySet:
118
+ rels, keys = [], ["id"]
119
+ for nam in cls._name:
120
+ parts = nam.split("__")
121
+ if len(parts) > 1:
122
+ rels.append("__".join(parts[:-1]))
123
+ keys.append(nam)
124
+ query = (
125
+ cls.filter(**kwargs)
126
+ .order_by(*sorts)
127
+ .limit(limit)
128
+ .offset(offset)
129
+ .prefetch_related(*(cls._meta.fetch_fields & set(kwargs)), *rels)
130
+ )
131
+ if q:
132
+ query = query.filter(**{f"{cls._name}__icontains": q})
133
+ if owner and (of := cls._ownable_fields.get("list")):
134
+ query = query.filter(**{of: owner})
135
+ return query
136
+
137
+ @classmethod
138
+ async def pagePyd(
139
+ cls, sorts: list[str], limit: int = 1000, offset: int = 0, q: str = None, owner: int = None, **kwargs
140
+ ) -> PydList:
141
+ kwargs = {k: v for k, v in kwargs.items() if v is not None}
142
+ pyd = cls.pydListItem()
143
+ query = cls.pageQuery(sorts, limit, offset, q, owner, **kwargs)
144
+ await query
145
+ data = await pyd.from_queryset(query)
146
+ if limit - (li := len(data)):
147
+ filtered = total = li + offset
148
+ else:
149
+ total = await cls.all().count()
150
+ filtered_query = cls.filter(**kwargs)
151
+ if q:
152
+ filtered_query = filtered_query.filter(**{f"{cls._name}__icontains": q})
153
+ filtered = await filtered_query.count()
154
+ pyds = cls.pydsList()
155
+ return pyds(data=data, total=total, filtered=filtered)
156
+
157
+ def repr(self) -> str:
158
+ if self._name in self._meta.db_fields:
159
+ return " ".join(getattr(self, name_fragment) for name_fragment in self._name)
160
+ return self.__repr__()
161
+
162
+ @classmethod
163
+ async def getOrCreateByName(cls, name: str, attr_name: str = None, def_dict: dict = None) -> BaseModel:
164
+ attr_name = attr_name or list(cls._name)[0]
165
+ if not (obj := await cls.get_or_none(**{attr_name: name})):
166
+ next_id = (await cls.all().order_by("-id").first()).id + 1
167
+ obj = await cls.create(id=next_id, **{attr_name: name}, **(def_dict or {}))
168
+ return obj
169
+
170
+ @classmethod
171
+ async def upsert(cls, data: dict, oid=None):
172
+ meta: MetaInfo = cls._meta
173
+
174
+ # pop fields for relations from general data dict
175
+ m2ms = {k: data.pop(k) for k in meta.m2m_fields if k in data}
176
+ # bfks = {k: data.pop(k) for k in meta.backward_fk_fields if k in data}
177
+ # bo2os = {k: data.pop(k) for k in meta.backward_o2o_fields if k in data}
178
+
179
+ # save general model
180
+ # if pk := meta.pk_attr in data.keys():
181
+ # unq = {pk: data.pop(pk)}
182
+ # else:
183
+ # unq = {key: data.pop(key) for key, ft in meta.fields_map.items() if ft.unique and key in data.keys()}
184
+ # # unq = meta.unique_together
185
+ # obj, is_created = await cls.update_or_create(data, **unq)
186
+ obj = (await cls.update_or_create(data, **{meta.pk_attr: oid}))[0] if oid else await cls.create(**data)
187
+
188
+ # save relations
189
+ for k, ids in m2ms.items():
190
+ if ids:
191
+ m2m_rel: ManyToManyRelation = getattr(obj, k)
192
+ items = [await m2m_rel.remote_model[i] for i in ids]
193
+ await m2m_rel.clear() # for updating, not just adding
194
+ await m2m_rel.add(*items)
195
+ # for k, ids in bfks.items():
196
+ # bfk_rel: ReverseRelation = getattr(obj, k)
197
+ # items = [await bfk_rel.remote_model[i] for i in ids]
198
+ # [await item.update_from_dict({bfk_rel.relation_field: obj.pk}).save() for item in items]
199
+ # for k, oid in bo2os.items():
200
+ # bo2o_rel: QuerySet = getattr(obj, k)
201
+ # item = await bo2o_rel.model[oid]
202
+ # await item.update_from_dict({obj._meta.db_table: obj}).save()
203
+
204
+ await obj.fetch_related(*cls._meta.fetch_fields)
205
+ return obj
206
+
207
+ @classmethod
208
+ def field_input_map(cls) -> dict:
209
+ def type2input(ft: type[Field]):
210
+ dry = {
211
+ "base_field": hasattr(ft, "base_field") and {**type2input(ft.base_field)},
212
+ "step": hasattr(ft, "step") and ft.step,
213
+ "labels": hasattr(ft, "labels") and ft.labels,
214
+ }
215
+ type2inputs: {Field: dict} = {
216
+ CharField: {"input": FieldType.input.name},
217
+ IntField: {"input": FieldType.input.name, "type": "number"},
218
+ SmallIntField: {"input": FieldType.input.name, "type": "number"},
219
+ BigIntField: {"input": FieldType.input.name, "type": "number"},
220
+ DecimalField: {"input": FieldType.input.name, "type": "number", "step": "0.01"},
221
+ FloatField: {"input": FieldType.input.name, "type": "number", "step": "0.001"},
222
+ TextField: {"input": FieldType.textarea.name, "rows": "2"},
223
+ BooleanField: {"input": FieldType.checkbox.name},
224
+ DatetimeField: {"input": FieldType.input.name, "type": "datetime"},
225
+ DatetimeSecField: {"input": FieldType.input.name, "type": "datetime"},
226
+ DateField: {"input": FieldType.input.name, "type": "date"},
227
+ TimeField: {"input": FieldType.input.name, "type": "time"},
228
+ JSONField: {"input": FieldType.input.name},
229
+ IntEnumFieldInstance: {"input": FieldType.select.name},
230
+ CharEnumFieldInstance: {"input": FieldType.select.name},
231
+ ForeignKeyFieldInstance: {"input": FieldType.select.name},
232
+ OneToOneFieldInstance: {"input": FieldType.select.name},
233
+ ManyToManyFieldInstance: {"input": FieldType.select.name, "multiple": True},
234
+ ForeignKeyRelation: {"input": FieldType.select.name, "multiple": True},
235
+ OneToOneRelation: {"input": FieldType.select.name},
236
+ BackwardOneToOneRelation: {"input": FieldType.select.name},
237
+ ManyToManyRelation: {"input": FieldType.select.name, "multiple": True},
238
+ ForeignKeyNullableRelation: {"input": FieldType.select.name, "multiple": True},
239
+ BackwardFKRelation: {"input": FieldType.select.name, "multiple": True},
240
+ ArrayField: {"input": FieldType.select.name, "multiple": True},
241
+ SetField: {"input": FieldType.select.name, "multiple": True},
242
+ OneToOneNullableRelation: {"input": FieldType.select.name},
243
+ PointField: {"input": FieldType.collection.name, **dry},
244
+ PolygonField: {"input": FieldType.list.name, **dry},
245
+ RangeField: {"input": FieldType.collection.name, **dry},
246
+ }
247
+ return type2inputs[ft]
248
+
249
+ def field2input(_key: str, field: Field):
250
+ attrs: dict = {"required": not field.null}
251
+ if isinstance(field, CharEnumFieldInstance):
252
+ attrs.update({"options": {en.name: en.value for en in field.enum_type}})
253
+ elif isinstance(field, IntEnumFieldInstance) or isinstance(field, SetField):
254
+ attrs.update({"options": {en.value: en.name.replace("_", " ") for en in field.enum_type}})
255
+ elif isinstance(field, RelationalField):
256
+ attrs.update({"source_field": field.source_field}) # 'table': attrs[key]['multiple'],
257
+ elif field.generated or ("auto_now" in field.__dict__ and (field.auto_now or field.auto_now_add)): # noqa
258
+ attrs.update({"auto": True})
259
+ return {**type2input(type(field)), **attrs}
260
+
261
+ return {key: field2input(key, field) for key, field in cls._meta.fields_map.items() if not key.endswith("_id")}
262
+
263
+ class Meta:
264
+ abstract = True
265
+
266
+ class PydanticMeta:
267
+ #: If not empty, only fields this property contains will be in the pydantic model
268
+ # include: tuple[str, ...] = ()
269
+ # #: Fields listed in this property will be excluded from pydantic model
270
+ # exclude: tuple[str, ...] = ()
271
+ # #: Computed fields can be listed here to use in pydantic model
272
+ # computed: tuple[str, ...] = ()
273
+
274
+ exclude_raw_fields = False # default: True
275
+ max_recursion: int = 1 # default: 3
276
+
277
+ class PydanticMetaIn:
278
+ max_recursion: int = 0 # default: 3
279
+ backward_relations: bool = False # no need to disable when max_recursion=0 # default: True
280
+ exclude_raw_fields: bool = False # default: True
281
+
282
+ class PydanticMetaListItem:
283
+ max_recursion: int = 0 # default: 3
284
+ backward_relations: bool = False # default: True
285
+ exclude_raw_fields = False # default: True
286
+ sort_alphabetically: bool = True # default: False
287
+
288
+
289
+ class TsModel(Model):
290
+ created_at: datetime | None = DatetimeSecField(auto_now_add=True)
291
+ updated_at: datetime | None = DatetimeSecField(auto_now=True)
292
+
293
+ class Meta:
294
+ abstract = True
295
+
296
+
297
+ class User(TsModel):
298
+ status: UserStatus = IntEnumField(UserStatus, default=UserStatus.WAIT)
299
+ username: str | None = CharField(95, unique=True, null=True)
300
+ email: str | None = CharField(100, unique=True, null=True)
301
+ password: str | None = CharField(60, null=True)
302
+ phone: int | None = BigIntField(null=True)
303
+ role: UserRole = IntEnumField(UserRole, default=UserRole.CLIENT)
304
+
305
+ _icon = "user"
306
+ _name = {"username"}
307
+
308
+ class Meta:
309
+ table_description = "Users"
310
+
311
+
312
+ class UserPasswordTrait(TsModel):
313
+ password: str | None = CharField(60, null=True)
314
+
315
+ __cc = CryptContext(schemes=["bcrypt"])
316
+
317
+ def pwd_vrf(self, pwd: str) -> bool:
318
+ return self.__cc.verify(pwd, self.password)
319
+
320
+ @classmethod
321
+ async def create(cls, using_db=None, **kwargs) -> "User":
322
+ user: "User" | Model = await super().create(using_db, **kwargs)
323
+ if pwd := kwargs.get("password"):
324
+ # noinspection PyUnresolvedReferences
325
+ await user.set_pwd(pwd)
326
+ return user
327
+
328
+ async def set_pwd(self, pwd: str = password) -> None:
329
+ self.password = self.__cc.hash(pwd)
330
+ await self.save()
@@ -0,0 +1,54 @@
1
+ from typing import TypeVar, Generic
2
+ from pydantic import BaseModel, ConfigDict
3
+
4
+ from x_model.enum import UserStatus, UserRole
5
+
6
+
7
+ RootModelType = TypeVar("RootModelType")
8
+
9
+
10
+ class PydList(BaseModel, Generic[RootModelType]):
11
+ model_config = ConfigDict(arbitrary_types_allowed=True)
12
+ data: list[RootModelType]
13
+ total: int
14
+
15
+
16
+ class UserPwd(BaseModel):
17
+ password: str
18
+
19
+
20
+ class UserReg(UserPwd):
21
+ username: str
22
+ email: str | None = None
23
+ phone: int | None = None
24
+
25
+
26
+ class UserUpdate(BaseModel):
27
+ username: str
28
+ status: UserStatus
29
+ email: str | None
30
+ phone: int | None
31
+ role: UserRole
32
+
33
+
34
+ class UserAuth(UserUpdate):
35
+ id: int
36
+ username: str
37
+ status: UserStatus
38
+ role: UserRole
39
+ # ref_id: int | None
40
+
41
+
42
+ class Names(BaseModel):
43
+ # models for name endpoint for select2 inputs
44
+ class Name(BaseModel):
45
+ id: int
46
+ text: str
47
+ logo: str | None = None
48
+ selected: bool | None = None
49
+
50
+ class Pagination(BaseModel):
51
+ more: bool
52
+
53
+ results: list[Name]
54
+ pagination: Pagination
@@ -0,0 +1,43 @@
1
+ Metadata-Version: 2.1
2
+ Name: xn-model
3
+ Version: 0.10.3.dev1
4
+ Summary: Base model for xn-api
5
+ Author-email: Mike Artemiev <mixartemev@gmail.com>
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/XyncNet/x-model
8
+ Project-URL: Repository, https://github.com/XyncNet/x-model
9
+ Keywords: tortoise,model,crud,generator,api,admin
10
+ Requires-Python: >=3.12
11
+ Description-Content-Type: text/markdown
12
+ Requires-Dist: tortoise-orm[accel,asyncpg]
13
+ Requires-Dist: passlib[bcrypt]
14
+ Requires-Dist: pydantic
15
+ Requires-Dist: python-dotenv
16
+ Provides-Extra: dev
17
+ Requires-Dist: pytest; extra == "dev"
18
+ Requires-Dist: build; extra == "dev"
19
+ Requires-Dist: twine; extra == "dev"
20
+ Requires-Dist: setuptools_scm; extra == "dev"
21
+
22
+ ## INSTALL
23
+ ```bash
24
+ # Create python virtual environment
25
+ python3 -m venv venv
26
+ # Activate this environment
27
+ source venv/bin/activate
28
+ # Install dependencies
29
+ pip install .
30
+
31
+ # Create pg db
32
+ createdb --U username -W dbname
33
+ ## set password for db user
34
+
35
+ # Copy .env file from sample template
36
+ cp .env.sample .env
37
+ ## set your pg creds in .env file
38
+ ```
39
+
40
+ ## TEST
41
+ ```bash
42
+ pytest
43
+ ```
@@ -0,0 +1,19 @@
1
+ .env.sample
2
+ .gitignore
3
+ .pre-commit-config.yaml
4
+ README.md
5
+ makefile
6
+ pyproject.toml
7
+ tests/__init__.py
8
+ tests/test_db.py
9
+ x_model/__init__.py
10
+ x_model/enum.py
11
+ x_model/field.py
12
+ x_model/func.py
13
+ x_model/model.py
14
+ x_model/pydantic.py
15
+ xn_model.egg-info/PKG-INFO
16
+ xn_model.egg-info/SOURCES.txt
17
+ xn_model.egg-info/dependency_links.txt
18
+ xn_model.egg-info/requires.txt
19
+ xn_model.egg-info/top_level.txt
@@ -0,0 +1,10 @@
1
+ tortoise-orm[accel,asyncpg]
2
+ passlib[bcrypt]
3
+ pydantic
4
+ python-dotenv
5
+
6
+ [dev]
7
+ pytest
8
+ build
9
+ twine
10
+ setuptools_scm
@@ -0,0 +1 @@
1
+ x_model