fastapi-mongo-base 0.8.30__py3-none-any.whl

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,384 @@
1
+ import uuid
2
+ from datetime import date, datetime
3
+ from decimal import Decimal
4
+
5
+ from beanie import Document, Insert, Replace, Save, SaveChanges, Update, before_event
6
+ from beanie.odm.queries.find import FindMany
7
+ from pydantic import ConfigDict
8
+ from pymongo import ASCENDING, IndexModel
9
+
10
+ try:
11
+ from server.config import Settings
12
+ except ImportError:
13
+ from .core.config import Settings
14
+
15
+ from .schemas import (
16
+ BaseEntitySchema,
17
+ BusinessEntitySchema,
18
+ BusinessOwnedEntitySchema,
19
+ OwnedEntitySchema,
20
+ )
21
+ from .tasks import TaskMixin
22
+
23
+
24
+ class BaseEntity(BaseEntitySchema, Document):
25
+ class Settings:
26
+ __abstract__ = True
27
+
28
+ keep_nulls = False
29
+ validate_on_save = True
30
+
31
+ indexes = [
32
+ IndexModel([("uid", ASCENDING)], unique=True),
33
+ ]
34
+
35
+ @classmethod
36
+ def is_abstract(cls):
37
+ # Use `__dict__` to check if `__abstract__` is defined in the class itself
38
+ return "__abstract__" in cls.__dict__ and cls.__dict__["__abstract__"]
39
+
40
+ @before_event([Insert, Replace, Save, SaveChanges, Update])
41
+ async def pre_save(self):
42
+ self.updated_at = datetime.now()
43
+
44
+ @classmethod
45
+ def get_queryset(
46
+ cls,
47
+ user_id: uuid.UUID = None,
48
+ business_name: str = None,
49
+ is_deleted: bool = False,
50
+ uid: uuid.UUID = None,
51
+ *args,
52
+ **kwargs,
53
+ ) -> list[dict]:
54
+ """Build a MongoDB query filter based on provided parameters.
55
+
56
+ Args:
57
+ user_id: Filter by user ID if the model has user_id field
58
+ business_name: Filter by business name if the model has business_name field
59
+ is_deleted: Filter by deletion status
60
+ uid: Filter by unique identifier
61
+ **kwargs: Additional filters that can include range queries with _from/_to suffixes
62
+
63
+ Returns:
64
+ List of MongoDB query conditions
65
+ """
66
+ # Start with basic filters
67
+ base_query = []
68
+
69
+ # Add standard filters if applicable
70
+ base_query.append({"is_deleted": is_deleted})
71
+
72
+ if hasattr(cls, "user_id") and user_id:
73
+ base_query.append({"user_id": user_id})
74
+
75
+ if hasattr(cls, "business_name"):
76
+ base_query.append({"business_name": business_name})
77
+
78
+ if uid:
79
+ base_query.append({"uid": uid})
80
+
81
+ # Process additional filters from kwargs
82
+ for key, value in kwargs.items():
83
+ if value is None:
84
+ continue
85
+
86
+ # Extract base field name without _from/_to suffix
87
+ base_field = cls._get_base_field_name(key)
88
+
89
+ # Validate field is allowed for searching
90
+ if cls.search_field_set() and base_field not in cls.search_field_set():
91
+ continue
92
+ if cls.search_exclude_set() and base_field in cls.search_exclude_set():
93
+ continue
94
+ if not hasattr(cls, base_field):
95
+ continue
96
+
97
+ # Handle range queries and normal filters
98
+ if (
99
+ key.endswith("_from") or key.endswith("_to")
100
+ ) and cls._is_valid_range_value(value):
101
+ if key.endswith("_from"):
102
+ base_query.append({base_field: {"$gte": value}})
103
+ elif key.endswith("_to"):
104
+ base_query.append({base_field: {"$lte": value}})
105
+ else:
106
+ base_query.append({key: value})
107
+
108
+ return base_query
109
+
110
+ @classmethod
111
+ def _get_base_field_name(cls, field: str) -> str:
112
+ """Extract the base field name by removing _from/_to suffixes."""
113
+ if field.endswith("_from"):
114
+ return field[:-5]
115
+ elif field.endswith("_to"):
116
+ return field[:-3]
117
+ return field
118
+
119
+ @classmethod
120
+ def _is_valid_range_value(cls, value) -> bool:
121
+ """Check if value is valid for range comparison."""
122
+ return isinstance(value, (int, float, Decimal, datetime, date, str))
123
+
124
+ @classmethod
125
+ def get_query(
126
+ cls,
127
+ user_id: uuid.UUID = None,
128
+ business_name: str = None,
129
+ is_deleted: bool = False,
130
+ uid: uuid.UUID = None,
131
+ created_at_from: datetime = None,
132
+ created_at_to: datetime = None,
133
+ *args,
134
+ **kwargs,
135
+ ) -> FindMany:
136
+ base_query = cls.get_queryset(
137
+ user_id=user_id,
138
+ business_name=business_name,
139
+ is_deleted=is_deleted,
140
+ uid=uid,
141
+ created_at_from=created_at_from,
142
+ created_at_to=created_at_to,
143
+ *args,
144
+ **kwargs,
145
+ )
146
+ query = cls.find(*base_query)
147
+ return query
148
+
149
+ @classmethod
150
+ async def get_item(
151
+ cls,
152
+ uid: uuid.UUID,
153
+ user_id: uuid.UUID = None,
154
+ business_name: str = None,
155
+ is_deleted: bool = False,
156
+ *args,
157
+ **kwargs,
158
+ ) -> "BaseEntity":
159
+ query = cls.get_query(
160
+ user_id=user_id,
161
+ business_name=business_name,
162
+ is_deleted=is_deleted,
163
+ uid=uid,
164
+ *args,
165
+ **kwargs,
166
+ )
167
+ items = await query.to_list()
168
+ if not items:
169
+ return None
170
+ if len(items) > 1:
171
+ raise ValueError("Multiple items found")
172
+ return items[0]
173
+
174
+ @classmethod
175
+ def adjust_pagination(cls, offset: int, limit: int):
176
+ from fastapi import params
177
+
178
+ if isinstance(offset, params.Query):
179
+ offset = offset.default
180
+ if isinstance(limit, params.Query):
181
+ limit = limit.default
182
+
183
+ offset = max(offset or 0, 0)
184
+ limit = max(1, min(limit or 10, Settings.page_max_limit))
185
+ return offset, limit
186
+
187
+ @classmethod
188
+ async def list_items(
189
+ cls,
190
+ user_id: uuid.UUID = None,
191
+ business_name: str = None,
192
+ offset: int = 0,
193
+ limit: int = 10,
194
+ is_deleted: bool = False,
195
+ *args,
196
+ **kwargs,
197
+ ):
198
+ offset, limit = cls.adjust_pagination(offset, limit)
199
+
200
+ query = cls.get_query(
201
+ user_id=user_id,
202
+ business_name=business_name,
203
+ is_deleted=is_deleted,
204
+ *args,
205
+ **kwargs,
206
+ )
207
+
208
+ items_query = query.sort("-created_at").skip(offset).limit(limit)
209
+ items = await items_query.to_list()
210
+ return items
211
+
212
+ @classmethod
213
+ async def total_count(
214
+ cls,
215
+ user_id: uuid.UUID = None,
216
+ business_name: str = None,
217
+ is_deleted: bool = False,
218
+ *args,
219
+ **kwargs,
220
+ ):
221
+ query = cls.get_query(
222
+ user_id=user_id,
223
+ business_name=business_name,
224
+ is_deleted=is_deleted,
225
+ *args,
226
+ **kwargs,
227
+ )
228
+ return await query.count()
229
+
230
+ @classmethod
231
+ async def list_total_combined(
232
+ cls,
233
+ user_id: uuid.UUID = None,
234
+ business_name: str = None,
235
+ offset: int = 0,
236
+ limit: int = 10,
237
+ is_deleted: bool = False,
238
+ *args,
239
+ **kwargs,
240
+ ) -> tuple[list["BaseEntity"], int]:
241
+ items = await cls.list_items(
242
+ user_id=user_id,
243
+ business_name=business_name,
244
+ offset=offset,
245
+ limit=limit,
246
+ is_deleted=is_deleted,
247
+ **kwargs,
248
+ )
249
+ total = await cls.total_count(
250
+ user_id=user_id,
251
+ business_name=business_name,
252
+ is_deleted=is_deleted,
253
+ **kwargs,
254
+ )
255
+
256
+ return items, total
257
+
258
+ @classmethod
259
+ async def create_item(cls, data: dict):
260
+ # for key in data.keys():
261
+ # if cls.create_exclude_set() and key not in cls.create_field_set():
262
+ # data.pop(key, None)
263
+ # elif cls.create_exclude_set() and key in cls.create_exclude_set():
264
+ # data.pop(key, None)
265
+
266
+ item = cls(**data)
267
+ await item.save()
268
+ return item
269
+
270
+ @classmethod
271
+ async def update_item(cls, item: "BaseEntity", data: dict):
272
+ for key, value in data.items():
273
+ if cls.update_field_set() and key not in cls.update_field_set():
274
+ continue
275
+ if cls.update_exclude_set() and key in cls.update_exclude_set():
276
+ continue
277
+
278
+ if hasattr(item, key):
279
+ setattr(item, key, value)
280
+
281
+ await item.save()
282
+ return item
283
+
284
+ @classmethod
285
+ async def delete_item(cls, item: "BaseEntity"):
286
+ item.is_deleted = True
287
+ await item.save()
288
+ return item
289
+
290
+
291
+ class OwnedEntity(OwnedEntitySchema, BaseEntity):
292
+
293
+ class Settings(BaseEntity.Settings):
294
+ __abstract__ = True
295
+
296
+ indexes = BaseEntity.Settings.indexes + [IndexModel([("user_id", ASCENDING)])]
297
+
298
+ @classmethod
299
+ async def get_item(cls, uid, user_id, *args, **kwargs) -> "OwnedEntity":
300
+ if user_id == None and kwargs.get("ignore_user_id") != True:
301
+ raise ValueError("user_id is required")
302
+ return await super().get_item(uid, user_id=user_id, *args, **kwargs)
303
+
304
+
305
+ class BusinessEntity(BusinessEntitySchema, BaseEntity):
306
+
307
+ class Settings(BaseEntity.Settings):
308
+ __abstract__ = True
309
+
310
+ indexes = BaseEntity.Settings.indexes + [
311
+ IndexModel([("business_name", ASCENDING)])
312
+ ]
313
+
314
+ @classmethod
315
+ async def get_item(cls, uid, business_name, *args, **kwargs) -> "BusinessEntity":
316
+ if business_name == None:
317
+ raise ValueError("business_name is required")
318
+ return await super().get_item(uid, business_name=business_name, *args, **kwargs)
319
+
320
+ async def get_business(self):
321
+ raise NotImplementedError
322
+ from apps.business_mongo.models import Business
323
+
324
+ return await Business.get_by_name(self.business_name)
325
+
326
+
327
+ class BusinessOwnedEntity(BusinessOwnedEntitySchema, BaseEntity):
328
+
329
+ class Settings(BusinessEntity.Settings):
330
+ __abstract__ = True
331
+
332
+ indexes = BusinessEntity.Settings.indexes + [
333
+ IndexModel([("user_id", ASCENDING)])
334
+ ]
335
+
336
+ @classmethod
337
+ async def get_item(
338
+ cls, uid, business_name, user_id, *args, **kwargs
339
+ ) -> "BusinessOwnedEntity":
340
+ if business_name == None:
341
+ raise ValueError("business_name is required")
342
+ # if user_id == None:
343
+ # raise ValueError("user_id is required")
344
+ return await super().get_item(
345
+ uid, business_name=business_name, user_id=user_id, *args, **kwargs
346
+ )
347
+
348
+
349
+ class BaseEntityTaskMixin(BaseEntity, TaskMixin):
350
+ class Settings(BaseEntity.Settings):
351
+ __abstract__ = True
352
+
353
+
354
+ class ImmutableBase(BaseEntity):
355
+ model_config = ConfigDict(frozen=True)
356
+
357
+ class Settings(BaseEntity.Settings):
358
+ __abstract__ = True
359
+
360
+ @classmethod
361
+ async def update_item(cls, item: "BaseEntity", data: dict):
362
+ raise ValueError("Immutable items cannot be updated")
363
+
364
+ @classmethod
365
+ async def delete_item(cls, item: "BaseEntity"):
366
+ raise ValueError("Immutable items cannot be deleted")
367
+
368
+
369
+ class ImmutableOwnedEntity(ImmutableBase, OwnedEntity):
370
+
371
+ class Settings(OwnedEntity.Settings):
372
+ __abstract__ = True
373
+
374
+
375
+ class ImmutableBusinessEntity(ImmutableBase, BusinessEntity):
376
+
377
+ class Settings(BusinessEntity.Settings):
378
+ __abstract__ = True
379
+
380
+
381
+ class ImmutableBusinessOwnedEntity(ImmutableBase, BusinessOwnedEntity):
382
+
383
+ class Settings(BusinessOwnedEntity.Settings):
384
+ __abstract__ = True
@@ -0,0 +1,293 @@
1
+ import asyncio
2
+ import uuid
3
+ from datetime import datetime
4
+ from typing import Any, Generic, Type, TypeVar
5
+
6
+ import singleton
7
+ from fastapi import APIRouter, BackgroundTasks, Query, Request
8
+
9
+ try:
10
+ from core.exceptions import BaseHTTPException
11
+ except ImportError:
12
+ from .core.exceptions import BaseHTTPException
13
+
14
+ try:
15
+ from server.config import Settings
16
+ except ImportError:
17
+ from .core.config import Settings
18
+
19
+ from .handlers import create_dto
20
+ from .models import BaseEntity, BaseEntityTaskMixin
21
+ from .schemas import BaseEntitySchema, PaginatedResponse
22
+
23
+ # Define a type variable
24
+ T = TypeVar("T", bound=BaseEntity)
25
+ TE = TypeVar("TE", bound=BaseEntityTaskMixin)
26
+ TS = TypeVar("TS", bound=BaseEntitySchema)
27
+
28
+
29
+ class AbstractBaseRouter(Generic[T, TS], metaclass=singleton.Singleton):
30
+
31
+ def __init__(
32
+ self,
33
+ model: Type[T],
34
+ user_dependency: Any,
35
+ *args,
36
+ prefix: str = None,
37
+ tags: list[str] = None,
38
+ schema: Type[TS] = None,
39
+ **kwargs,
40
+ ):
41
+ self.model = model
42
+ if schema is None:
43
+ schema = self.model
44
+ self.schema = schema
45
+ self.user_dependency = user_dependency
46
+ if prefix is None:
47
+ prefix = f"/{self.model.__name__.lower()}s"
48
+ if tags is None:
49
+ tags = [self.model.__name__]
50
+ self.router = APIRouter(prefix=prefix, tags=tags, **kwargs)
51
+
52
+ self.config_schemas(self.schema, **kwargs)
53
+ self.config_routes(**kwargs)
54
+
55
+ def config_schemas(self, schema, **kwargs):
56
+ self.schema = schema
57
+ self.list_response_schema = PaginatedResponse[schema]
58
+ self.list_item_schema = schema
59
+ self.retrieve_response_schema = schema
60
+ self.create_response_schema = schema
61
+ self.update_response_schema = schema
62
+ self.delete_response_schema = schema
63
+
64
+ self.create_request_schema = schema
65
+ self.update_request_schema = schema
66
+
67
+ def config_routes(self, **kwargs):
68
+ prefix: str = kwargs.get("prefix", "")
69
+ prefix = prefix.strip("/")
70
+ prefix = f"/{prefix}" if prefix else ""
71
+
72
+ if kwargs.get("list_route", True):
73
+ self.router.add_api_route(
74
+ f"{prefix}/",
75
+ self.list_items,
76
+ methods=["GET"],
77
+ response_model=self.list_response_schema,
78
+ status_code=200,
79
+ )
80
+
81
+ if kwargs.get("retrieve_route", True):
82
+ self.router.add_api_route(
83
+ f"{prefix}/{{uid:uuid}}",
84
+ self.retrieve_item,
85
+ methods=["GET"],
86
+ response_model=self.retrieve_response_schema,
87
+ status_code=200,
88
+ )
89
+
90
+ if kwargs.get("create_route", True):
91
+ self.router.add_api_route(
92
+ f"{prefix}/",
93
+ self.create_item,
94
+ methods=["POST"],
95
+ response_model=self.create_response_schema,
96
+ status_code=201,
97
+ )
98
+
99
+ if kwargs.get("update_route", True):
100
+ self.router.add_api_route(
101
+ f"{prefix}/{{uid:uuid}}",
102
+ self.update_item,
103
+ methods=["PATCH"],
104
+ response_model=self.update_response_schema,
105
+ status_code=200,
106
+ )
107
+
108
+ if kwargs.get("delete_route", True):
109
+ self.router.add_api_route(
110
+ f"{prefix}/{{uid:uuid}}",
111
+ self.delete_item,
112
+ methods=["DELETE"],
113
+ response_model=self.delete_response_schema,
114
+ # status_code=204,
115
+ )
116
+
117
+ async def get_item(
118
+ self,
119
+ uid: uuid.UUID,
120
+ user_id: uuid.UUID = None,
121
+ business_name: str = None,
122
+ **kwargs,
123
+ ):
124
+ item = await self.model.get_item(
125
+ uid, user_id=user_id, business_name=business_name, **kwargs
126
+ )
127
+ if item is None:
128
+ raise BaseHTTPException(
129
+ status_code=404,
130
+ error="item_not_found",
131
+ message=f"{self.model.__name__.capitalize()} not found",
132
+ )
133
+ return item
134
+
135
+ async def get_user(self, request: Request, *args, **kwargs):
136
+ if self.user_dependency is None:
137
+ return None
138
+ if asyncio.iscoroutinefunction(self.user_dependency):
139
+ return await self.user_dependency(request)
140
+ return self.user_dependency(request)
141
+
142
+ async def get_user_id(self, request: Request, *args, **kwargs):
143
+ user = await self.get_user(request)
144
+ user_id = user.uid if user else None
145
+ return user_id
146
+
147
+ async def list_items(
148
+ self,
149
+ request: Request,
150
+ offset: int = Query(0, ge=0),
151
+ limit: int = Query(10, ge=1, le=Settings.page_max_limit),
152
+ created_at_from: datetime | None = None,
153
+ created_at_to: datetime | None = None,
154
+ ):
155
+ user_id = await self.get_user_id(request)
156
+ limit = max(1, min(limit, Settings.page_max_limit))
157
+
158
+ items, total = await self.model.list_total_combined(
159
+ user_id=user_id,
160
+ offset=offset,
161
+ limit=limit,
162
+ created_at_from=created_at_from,
163
+ created_at_to=created_at_to,
164
+ )
165
+ items_in_schema = [self.list_item_schema(**item.model_dump()) for item in items]
166
+
167
+ return PaginatedResponse(
168
+ items=items_in_schema,
169
+ total=total,
170
+ offset=offset,
171
+ limit=limit,
172
+ )
173
+
174
+ async def retrieve_item(
175
+ self,
176
+ request: Request,
177
+ uid: uuid.UUID,
178
+ ):
179
+ user_id = await self.get_user_id(request)
180
+ item = await self.get_item(uid, user_id=user_id)
181
+ return item
182
+
183
+ async def create_item(
184
+ self,
185
+ request: Request,
186
+ data: dict,
187
+ ):
188
+ user_id = await self.get_user_id(request)
189
+ item_data: TS = await create_dto(self.create_response_schema)(
190
+ request, user_id=user_id
191
+ )
192
+ item = await self.model.create_item(item_data.model_dump())
193
+ # item: T = await create_dto(self.create_request_schema)(request, user)
194
+ await item.save()
195
+ return item
196
+
197
+ async def update_item(
198
+ self,
199
+ request: Request,
200
+ uid: uuid.UUID,
201
+ data: dict,
202
+ ):
203
+ user_id = await self.get_user_id(request)
204
+ item = await self.get_item(uid, user_id=user_id)
205
+ # item = await update_dto(self.model)(request, user)
206
+ item = await self.model.update_item(item, data)
207
+ return item
208
+
209
+ async def delete_item(
210
+ self,
211
+ request: Request,
212
+ uid: uuid.UUID,
213
+ ):
214
+ user_id = await self.get_user_id(request)
215
+ item = await self.get_item(uid, user_id=user_id)
216
+
217
+ item = await self.model.delete_item(item)
218
+ return item
219
+
220
+
221
+ class AbstractTaskRouter(AbstractBaseRouter[TE, TS]):
222
+ def __init__(
223
+ self,
224
+ model: Type[TE],
225
+ user_dependency: Any,
226
+ schema: TS,
227
+ draftable: bool = True,
228
+ *args,
229
+ **kwargs,
230
+ ):
231
+ super().__init__(model, user_dependency, schema=schema, *args, **kwargs)
232
+ self.draftable = draftable
233
+
234
+ def config_routes(self, **kwargs):
235
+ super().config_routes(**kwargs)
236
+
237
+ self.router.add_api_route(
238
+ "/{uid:uuid}/start",
239
+ self.start_item,
240
+ methods=["POST"],
241
+ response_model=self.retrieve_response_schema,
242
+ )
243
+
244
+ async def create_item(
245
+ self, request: Request, data: dict, background_tasks: BackgroundTasks
246
+ ):
247
+ if not self.draftable:
248
+ data["task_status"] = "init"
249
+
250
+ item: TE = await super().create_item(request, data)
251
+
252
+ if item.task_status == "init" or not self.draftable:
253
+ background_tasks.add_task(item.start_processing)
254
+ return item
255
+
256
+ async def start_item(
257
+ self, request: Request, uid: uuid.UUID, background_tasks: BackgroundTasks
258
+ ):
259
+ user_id = await self.get_user_id(request)
260
+ item: TE = await self.get_item(uid, user_id=user_id)
261
+ background_tasks.add_task(item.start_processing)
262
+ return item.model_dump()
263
+
264
+
265
+ def copy_router(router: APIRouter, new_prefix: str):
266
+ new_router = APIRouter(prefix=new_prefix)
267
+ for route in router.routes:
268
+ new_router.add_api_route(
269
+ route.path.replace(router.prefix, ""),
270
+ route.endpoint,
271
+ methods=[
272
+ method
273
+ for method in route.methods
274
+ if method in ["GET", "POST", "PUT", "DELETE", "PATCH"]
275
+ ],
276
+ name=route.name,
277
+ response_class=route.response_class,
278
+ status_code=route.status_code,
279
+ tags=route.tags,
280
+ dependencies=route.dependencies,
281
+ summary=route.summary,
282
+ description=route.description,
283
+ response_description=route.response_description,
284
+ responses=route.responses,
285
+ deprecated=route.deprecated,
286
+ include_in_schema=route.include_in_schema,
287
+ response_model=route.response_model,
288
+ response_model_include=route.response_model_include,
289
+ response_model_exclude=route.response_model_exclude,
290
+ response_model_by_alias=route.response_model_by_alias,
291
+ )
292
+
293
+ return new_router