django-plugin-system 1.0.0__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.
- django_plugin_system-1.0.0.dist-info/METADATA +400 -0
- django_plugin_system-1.0.0.dist-info/RECORD +18 -0
- django_plugin_system-1.0.0.dist-info/WHEEL +5 -0
- django_plugin_system-1.0.0.dist-info/licenses/LICENSE +7 -0
- django_plugin_system-1.0.0.dist-info/top_level.txt +1 -0
- src/django_plugin_system/__init__.py +4 -0
- src/django_plugin_system/admin.py +64 -0
- src/django_plugin_system/apps.py +10 -0
- src/django_plugin_system/helpers.py +28 -0
- src/django_plugin_system/managment/__init__.py +0 -0
- src/django_plugin_system/managment/commands/__init__.py +0 -0
- src/django_plugin_system/managment/commands/pluginsync.py +32 -0
- src/django_plugin_system/models.py +126 -0
- src/django_plugin_system/register.py +47 -0
- src/django_plugin_system/services/__init__.py +1 -0
- src/django_plugin_system/services/sync.py +84 -0
- src/django_plugin_system/signals.py +14 -0
- src/django_plugin_system/storage.py +27 -0
|
@@ -0,0 +1,400 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: django-plugin-system
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: A lightweight plugin registry for Django with admin management and registry→DB sync.
|
|
5
|
+
Author-email: Alireza Tabatabaeian <alireza.tabatabaeian@gmail.com>
|
|
6
|
+
Project-URL: Homepage, https://github.com/Alireza-Tabatabaeian/django-plugin-system
|
|
7
|
+
Project-URL: Issues, https://github.com/Alireza-Tabatabaeian/django-plugin-system/issues
|
|
8
|
+
Keywords: django,plugins,registry,otp,extensibility
|
|
9
|
+
Classifier: Development Status :: 3 - Alpha
|
|
10
|
+
Classifier: Environment :: Web Environment
|
|
11
|
+
Classifier: Framework :: Django
|
|
12
|
+
Classifier: Framework :: Django :: 4.2
|
|
13
|
+
Classifier: Framework :: Django :: 5.0
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
16
|
+
Classifier: Programming Language :: Python
|
|
17
|
+
Classifier: Programming Language :: Python :: 3
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
21
|
+
Classifier: Topic :: Software Development :: Libraries :: Application Frameworks
|
|
22
|
+
Classifier: Topic :: Internet :: WWW/HTTP
|
|
23
|
+
Requires-Python: >=3.10
|
|
24
|
+
Description-Content-Type: text/markdown
|
|
25
|
+
License-File: LICENSE
|
|
26
|
+
Requires-Dist: Django>=4.2
|
|
27
|
+
Provides-Extra: dev
|
|
28
|
+
Requires-Dist: pytest>=8; extra == "dev"
|
|
29
|
+
Requires-Dist: pytest-django>=4.8.0; extra == "dev"
|
|
30
|
+
Requires-Dist: mypy>=1.8; extra == "dev"
|
|
31
|
+
Requires-Dist: django-stubs>=5.0.0; extra == "dev"
|
|
32
|
+
Requires-Dist: black>=24.0.0; extra == "dev"
|
|
33
|
+
Requires-Dist: ruff>=0.5.0; extra == "dev"
|
|
34
|
+
Dynamic: license-file
|
|
35
|
+
|
|
36
|
+
# Django Plugin System
|
|
37
|
+
|
|
38
|
+
A lightweight, batteries-included plugin registry for Django apps.
|
|
39
|
+
Use it to expose **swappable implementations** (e.g., multiple OTP providers) behind a stable interface, select the active plugin in **Django Admin**, and keep the database in sync with in‑code registrations via a **management command**.
|
|
40
|
+
|
|
41
|
+
---
|
|
42
|
+
|
|
43
|
+
## ✨ Features
|
|
44
|
+
|
|
45
|
+
- **Simple interface-first design** — define an abstract base class (ABC), register implementations.
|
|
46
|
+
- **Registry → DB sync** — `pluginsync` management command (and optional `post_migrate` signal).
|
|
47
|
+
- **Admin UX** — filter/search, bulk enable/disable, and quick priority nudges.
|
|
48
|
+
- **Deterministic selection** — pick a single plugin by `status` and `priority`, with caching.
|
|
49
|
+
- **Safe defaults** — `get_or_create` syncing preserves admin-edited fields.
|
|
50
|
+
- **Uniqueness guarantees** — unique constraints for types and items.
|
|
51
|
+
|
|
52
|
+
---
|
|
53
|
+
|
|
54
|
+
## 📦 Installation
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
pip install django-plugin-system
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Add the app to `INSTALLED_APPS`:
|
|
61
|
+
|
|
62
|
+
```python
|
|
63
|
+
# settings.py
|
|
64
|
+
INSTALLED_APPS = [
|
|
65
|
+
# ...
|
|
66
|
+
"django_plugin_system",
|
|
67
|
+
]
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
> The app creates two models: `PluginType` and `PluginItem`.
|
|
71
|
+
|
|
72
|
+
---
|
|
73
|
+
|
|
74
|
+
## 🔌 Core Concepts
|
|
75
|
+
|
|
76
|
+
### 1) Define an **interface** (ABC)
|
|
77
|
+
|
|
78
|
+
```python
|
|
79
|
+
# apps/otp/interfaces.py
|
|
80
|
+
from abc import ABC, abstractmethod
|
|
81
|
+
|
|
82
|
+
class AbstractOTP(ABC):
|
|
83
|
+
@abstractmethod
|
|
84
|
+
def send_otp(self, number: str, code: str) -> None: ...
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
### 2) Register a **plugin type**
|
|
88
|
+
|
|
89
|
+
```python
|
|
90
|
+
# apps/otp/apps.py (or any module imported at startup)
|
|
91
|
+
from django.apps import AppConfig
|
|
92
|
+
from django_plugin_system.register import register_plugin_type
|
|
93
|
+
|
|
94
|
+
class OtpConfig(AppConfig):
|
|
95
|
+
name = "apps.otp"
|
|
96
|
+
def ready(self):
|
|
97
|
+
register_plugin_type({
|
|
98
|
+
"name": "otp",
|
|
99
|
+
"manager": self.name, # the app providing the type
|
|
100
|
+
"interface": AbstractOTP,
|
|
101
|
+
"description": "One-time password (OTP) delivery channel",
|
|
102
|
+
})
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
### 3) Implement and register **plugin items**
|
|
106
|
+
|
|
107
|
+
```python
|
|
108
|
+
# apps/otp_sms/plugins.py
|
|
109
|
+
from django_plugin_system.register import register_plugin_item
|
|
110
|
+
from .interfaces import AbstractOTP
|
|
111
|
+
|
|
112
|
+
class SmsIrOTP(AbstractOTP):
|
|
113
|
+
def send_otp(self, number: str, code: str) -> None:
|
|
114
|
+
# call provider api...
|
|
115
|
+
pass
|
|
116
|
+
|
|
117
|
+
# Register this implementation
|
|
118
|
+
register_plugin_item({
|
|
119
|
+
"name": "sms_ir",
|
|
120
|
+
"module": "apps.otp_sms", # the app providing the item
|
|
121
|
+
"type_name": "otp",
|
|
122
|
+
"manager_name": "apps.otp", # the app providing the type
|
|
123
|
+
"plugin_class": SmsIrOTP,
|
|
124
|
+
"priority": 10,
|
|
125
|
+
"description": "Send OTP using Sms.ir provider",
|
|
126
|
+
})
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
> You can register multiple items with different priorities. Lower number means **higher** priority.
|
|
130
|
+
|
|
131
|
+
---
|
|
132
|
+
|
|
133
|
+
## 🗄️ Database Models
|
|
134
|
+
|
|
135
|
+
```python
|
|
136
|
+
# django_plugin_system.models
|
|
137
|
+
class PluginType(models.Model):
|
|
138
|
+
id = UUID PK
|
|
139
|
+
name = CharField
|
|
140
|
+
manager = CharField # the app that defines the interface
|
|
141
|
+
description = TextField
|
|
142
|
+
|
|
143
|
+
class PluginItem(models.Model):
|
|
144
|
+
id = UUID PK
|
|
145
|
+
plugin_type = FK -> PluginType
|
|
146
|
+
module = CharField # the app that provides the implementation
|
|
147
|
+
name = CharField
|
|
148
|
+
status = TextChoices('active', 'reserve', 'disable')
|
|
149
|
+
priority = SmallIntegerField # lower is better
|
|
150
|
+
description = TextField
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
### Uniqueness
|
|
154
|
+
|
|
155
|
+
- `PluginType(name, manager)` is unique.
|
|
156
|
+
- `PluginItem(name, module, plugin_type)` is unique.
|
|
157
|
+
|
|
158
|
+
---
|
|
159
|
+
|
|
160
|
+
## 🧠 Selection Logic
|
|
161
|
+
|
|
162
|
+
- **Active first:** pick the lowest-priority `active` item.
|
|
163
|
+
- **Fallback:** if no `active`, pick the lowest-priority `reserve` item.
|
|
164
|
+
- **Cache:** the chosen item is cached per `PluginType` and auto‑invalidated on save/delete.
|
|
165
|
+
|
|
166
|
+
### Helper
|
|
167
|
+
|
|
168
|
+
```python
|
|
169
|
+
from django_plugin_system.helpers import get_plugin_instance
|
|
170
|
+
|
|
171
|
+
otp = get_plugin_instance("otp", "apps.otp")
|
|
172
|
+
if otp:
|
|
173
|
+
otp.send_otp("+31123456789", "123456")
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
> Or call `PluginType.get_single_plugin()` then `.load_class()` to instantiate manually.
|
|
177
|
+
|
|
178
|
+
---
|
|
179
|
+
|
|
180
|
+
## 🛠️ Syncing the Registry
|
|
181
|
+
|
|
182
|
+
You have **two** ways to keep the DB aligned with the in‑memory registry:
|
|
183
|
+
|
|
184
|
+
1) **Automatically after migrations** (default)
|
|
185
|
+
- The `post_migrate` signal syncs in *create-only* mode (preserves admin edits).
|
|
186
|
+
|
|
187
|
+
2) **Manually via command**
|
|
188
|
+
```bash
|
|
189
|
+
python manage.py pluginsync
|
|
190
|
+
# or to refresh defaults from registry (overwrites description/priority on conflicts):
|
|
191
|
+
python manage.py pluginsync --mode update
|
|
192
|
+
# skip pruning of stale rows:
|
|
193
|
+
python manage.py pluginsync --no-prune
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
**Modes:**
|
|
197
|
+
|
|
198
|
+
- `create` → uses `get_or_create` (safe: won’t overwrite `status`/`priority` changed in Admin)
|
|
199
|
+
- `update` → uses `update_or_create` (refresh `description`/`priority` from code)
|
|
200
|
+
|
|
201
|
+
---
|
|
202
|
+
|
|
203
|
+
## 🧭 Admin Panel
|
|
204
|
+
|
|
205
|
+
- **PluginType** list shows counts per status.
|
|
206
|
+
- **PluginItem** list lets you:
|
|
207
|
+
- quick-edit `status` and `priority`,
|
|
208
|
+
- bulk mark **ACTIVE/RESERVED/DISABLED**,
|
|
209
|
+
- **Increase/Decrease priority** in-place,
|
|
210
|
+
- view a “Class loads” boolean to catch broken registrations.
|
|
211
|
+
|
|
212
|
+
> Changing status/priority automatically clears the single‑plugin selection cache.
|
|
213
|
+
|
|
214
|
+
---
|
|
215
|
+
|
|
216
|
+
## 🔄 Overriding selection
|
|
217
|
+
|
|
218
|
+
You can override the selection logic per type by providing a `get_plugin` callable in the type registry entry:
|
|
219
|
+
|
|
220
|
+
```python
|
|
221
|
+
def my_selector(plugin_type_model_obj):
|
|
222
|
+
# your custom logic (possibly data-driven)
|
|
223
|
+
return plugin_type_model_obj.get_active_plugins()[0]
|
|
224
|
+
|
|
225
|
+
register_plugin_type({
|
|
226
|
+
"name": "otp",
|
|
227
|
+
"manager": "apps.otp",
|
|
228
|
+
"interface": AbstractOTP,
|
|
229
|
+
"description": "OTP delivery",
|
|
230
|
+
"get_plugin": my_selector, # <- override
|
|
231
|
+
})
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
---
|
|
235
|
+
|
|
236
|
+
## 🧪 Testing tips
|
|
237
|
+
|
|
238
|
+
- Ensure your registry code paths are imported in test settings (e.g., via `AppConfig.ready`).
|
|
239
|
+
- Use `pluginsync --mode create` in test setup to materialize rows.
|
|
240
|
+
- Toggle item `status` in tests to verify fallback and cache invalidation.
|
|
241
|
+
|
|
242
|
+
---
|
|
243
|
+
|
|
244
|
+
## 📐 Design Notes & Guarantees
|
|
245
|
+
|
|
246
|
+
- Registry data lives in memory at import time; DB represents a **materialized view** used by Admin and runtime selection.
|
|
247
|
+
- Syncing is **idempotent** and safe to run many times.
|
|
248
|
+
- Items are validated to **implement the declared interface**.
|
|
249
|
+
- Errors on registration are not swallowed — mis-registrations fail early and loudly.
|
|
250
|
+
|
|
251
|
+
---
|
|
252
|
+
|
|
253
|
+
## 🤔 Why Use Django Plugin System?
|
|
254
|
+
|
|
255
|
+
When you build extensible Django apps — like payment gateways, OTP senders, or notification systems — you often need pluggable backends that can be swapped or prioritized without touching your core logic.
|
|
256
|
+
|
|
257
|
+
This library lets you define interfaces, register multiple implementations, and let users (or admins) pick which ones are active — all without breaking code, and with database-level configurability.
|
|
258
|
+
|
|
259
|
+
---
|
|
260
|
+
|
|
261
|
+
### 🧩 Example: Notification System
|
|
262
|
+
|
|
263
|
+
Imagine you have multiple ways to notify users:
|
|
264
|
+
|
|
265
|
+
- Email
|
|
266
|
+
|
|
267
|
+
- SMS
|
|
268
|
+
|
|
269
|
+
- Push notification
|
|
270
|
+
|
|
271
|
+
Each of these is handled by a different piece of code — maybe even from different apps.
|
|
272
|
+
|
|
273
|
+
### 🚫 **Without** Django Plugin System
|
|
274
|
+
|
|
275
|
+
- You hardcode your imports and logic:
|
|
276
|
+
|
|
277
|
+
```python
|
|
278
|
+
# notifications/core.py
|
|
279
|
+
from notifications.email_sender import send_email
|
|
280
|
+
from notifications.sms_sender import send_sms
|
|
281
|
+
from notifications.push_sender import send_push
|
|
282
|
+
|
|
283
|
+
def notify_user(user, message):
|
|
284
|
+
if user.prefers_email:
|
|
285
|
+
send_email(user.email, message)
|
|
286
|
+
elif user.prefers_sms:
|
|
287
|
+
send_sms(user.phone, message)
|
|
288
|
+
elif user.prefers_push:
|
|
289
|
+
send_push(user.device_token, message)
|
|
290
|
+
```
|
|
291
|
+
- Every time you add a new provider, you must:
|
|
292
|
+
- Write new import statements
|
|
293
|
+
- Modify your logic
|
|
294
|
+
- Re-deploy your code
|
|
295
|
+
- Possibly break something that used to work
|
|
296
|
+
|
|
297
|
+
And there’s no way for an admin to change behavior dynamically — everything is baked into code.
|
|
298
|
+
|
|
299
|
+
---
|
|
300
|
+
### ✅ **With** Django Plugin System
|
|
301
|
+
|
|
302
|
+
1. You define one interface:
|
|
303
|
+
|
|
304
|
+
```python
|
|
305
|
+
from abc import ABC, abstractmethod
|
|
306
|
+
|
|
307
|
+
class AbstractNotifier(ABC):
|
|
308
|
+
@abstractmethod
|
|
309
|
+
def send(self, user, message): ...
|
|
310
|
+
```
|
|
311
|
+
|
|
312
|
+
2. You register it as a plugin type:
|
|
313
|
+
|
|
314
|
+
```python
|
|
315
|
+
from django_plugin_system.register import register_plugin_type
|
|
316
|
+
|
|
317
|
+
register_plugin_type({
|
|
318
|
+
"name": "notifier",
|
|
319
|
+
"manager": "apps.notifications",
|
|
320
|
+
"interface": AbstractNotifier,
|
|
321
|
+
"description": "Notification channels for users",
|
|
322
|
+
})
|
|
323
|
+
```
|
|
324
|
+
|
|
325
|
+
3. You implement as many plugin items as you want (completely independent of the rest of the code):
|
|
326
|
+
|
|
327
|
+
```python
|
|
328
|
+
from django_plugin_system.register import register_plugin_item
|
|
329
|
+
from .interfaces import AbstractNotifier
|
|
330
|
+
|
|
331
|
+
class EmailNotifier(AbstractNotifier):
|
|
332
|
+
def send(self, user, message):
|
|
333
|
+
print(f"Sending email to {user.email}: {message}")
|
|
334
|
+
|
|
335
|
+
register_plugin_item({
|
|
336
|
+
"name": "email",
|
|
337
|
+
"module": "apps.notifications.email",
|
|
338
|
+
"type_name": "notifier",
|
|
339
|
+
"manager_name": "apps.notifications",
|
|
340
|
+
"plugin_class": EmailNotifier,
|
|
341
|
+
"priority": 5,
|
|
342
|
+
"description": "Send notification via Email",
|
|
343
|
+
})
|
|
344
|
+
```
|
|
345
|
+
|
|
346
|
+
4. You can now dynamically select from Admin which notifiers are active, in reserve, or disabled — even reorder them by priority.
|
|
347
|
+
5. Your code doesn’t change at all:
|
|
348
|
+
|
|
349
|
+
```python
|
|
350
|
+
from django_plugin_system.helpers import get_plugin_instance
|
|
351
|
+
|
|
352
|
+
notifier = get_plugin_instance("notifier", "apps.notifications")
|
|
353
|
+
notifier.send(user, "Your OTP is 1234")
|
|
354
|
+
```
|
|
355
|
+
|
|
356
|
+
💡 You can even expose multiple active notifiers and let users subscribe to their favorites — Email + Push for one user, Push only for another — all configurable through database records instead of code edits.
|
|
357
|
+
|
|
358
|
+
```python
|
|
359
|
+
# models.py
|
|
360
|
+
from typing import List
|
|
361
|
+
|
|
362
|
+
from django.db import models
|
|
363
|
+
from django.contrib.auth.models import User
|
|
364
|
+
|
|
365
|
+
from django_plugin_system.models import PluginItem
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
class UserNotifyPref(models.Model):
|
|
369
|
+
user = models.ForeignKey(User, on_delete=models.CASCADE)
|
|
370
|
+
favourite_plugins = models.ManyToManyField(PluginItem)
|
|
371
|
+
|
|
372
|
+
@staticmethod
|
|
373
|
+
def get_user_plugins(user: User) -> List[PluginItem]:
|
|
374
|
+
try:
|
|
375
|
+
return list(UserNotifyPref.objects.get(user=user).favourite_plugins.all())
|
|
376
|
+
except UserNotifyPref.DoesNotExist:
|
|
377
|
+
return []
|
|
378
|
+
```
|
|
379
|
+
|
|
380
|
+
Now the following code will notify user through all selected plugins:
|
|
381
|
+
```python
|
|
382
|
+
from myapp.models import UserNotifyPref
|
|
383
|
+
|
|
384
|
+
...
|
|
385
|
+
|
|
386
|
+
favourite_plugins = UserNotifyPref.get_user_plugins(user)
|
|
387
|
+
for plugin in favourite_plugins:
|
|
388
|
+
plugin_service = plugin.load_class()
|
|
389
|
+
plugin_service.send(user, message)
|
|
390
|
+
|
|
391
|
+
...
|
|
392
|
+
|
|
393
|
+
```
|
|
394
|
+
just as simple as you see, **with** django-plugin-system, you can let users decide which plugin they prefer to use.
|
|
395
|
+
|
|
396
|
+
---
|
|
397
|
+
|
|
398
|
+
## 📄 License
|
|
399
|
+
|
|
400
|
+
MIT [Alireza Tabatabaeian](https://github.com/Alireza-Tabatabaeian)
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
django_plugin_system-1.0.0.dist-info/licenses/LICENSE,sha256=8b-RN6LQqwfo9RRVLtvavkY3dmygWPluGUZ8VlsVK_E,1073
|
|
2
|
+
src/django_plugin_system/__init__.py,sha256=LFz4Y0iRCg50-iLDfdofQrmc_vdjurmjUqa1Yqki9lk,221
|
|
3
|
+
src/django_plugin_system/admin.py,sha256=W8nqESmoAUF6snJrww8zIah0ugs_a_6UX6m__d36IPg,2537
|
|
4
|
+
src/django_plugin_system/apps.py,sha256=AiVsyMEYNbNHjMUkYI4YfnkQQdMybmYbgKCu7Rv4y1E,295
|
|
5
|
+
src/django_plugin_system/helpers.py,sha256=8BGb3mkJifQPX4rAS3cQLnAHXhdJQau5D1AaPqM8X7U,774
|
|
6
|
+
src/django_plugin_system/models.py,sha256=_hU62NRAZTuUalmnuOO8kYfgffq0wT0Uf6pgxAgvvjE,4687
|
|
7
|
+
src/django_plugin_system/register.py,sha256=HCzuUmkF2LxVu5-QC01VQbvk9SsiDVSWTnNEh-1bfp4,1957
|
|
8
|
+
src/django_plugin_system/signals.py,sha256=fHhMDyrRYzk31BAaYukX2Q0vsRZJa0IBCp3BCG3frn0,458
|
|
9
|
+
src/django_plugin_system/storage.py,sha256=_sGBA6dVklJiZkOt8CHjrOT6QzC9Sa8Eaek-YQfhcZg,730
|
|
10
|
+
src/django_plugin_system/managment/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
11
|
+
src/django_plugin_system/managment/commands/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
12
|
+
src/django_plugin_system/managment/commands/pluginsync.py,sha256=sDs-aXGIAc6rJP8JfB9Vw1VeOPL1_xiIkcR08Uqvpd4,1383
|
|
13
|
+
src/django_plugin_system/services/__init__.py,sha256=E69UsyvwHNwEip-va5qqkzgb0Av-hhzSO9Lrrj8eNUk,47
|
|
14
|
+
src/django_plugin_system/services/sync.py,sha256=bgfY_Qzq7JgMUKC9UofoNMHeayCLzTd1GuhhqxGgQow,2955
|
|
15
|
+
django_plugin_system-1.0.0.dist-info/METADATA,sha256=USWx5bVf0pbjSVwocBG9FZdREJJzSm_xckmno4c4F90,12341
|
|
16
|
+
django_plugin_system-1.0.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
|
17
|
+
django_plugin_system-1.0.0.dist-info/top_level.txt,sha256=74rtVfumQlgAPzR5_2CgYN24MB0XARCg0t-gzk6gTrM,4
|
|
18
|
+
django_plugin_system-1.0.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
Copyright 2025 Alireza Tabatabaeian
|
|
2
|
+
|
|
3
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
|
4
|
+
|
|
5
|
+
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
|
6
|
+
|
|
7
|
+
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
src
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
from django.contrib import admin
|
|
2
|
+
from django.db.models import F
|
|
3
|
+
from .models import PluginType, PluginItem, PluginStatus
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
@admin.register(PluginType)
|
|
7
|
+
class PluginTypeAdmin(admin.ModelAdmin):
|
|
8
|
+
list_display = ("name", "manager", "description", "active_count", "reserved_count", "disabled_count")
|
|
9
|
+
list_filter = ("manager",)
|
|
10
|
+
search_fields = ("name", "manager", "description")
|
|
11
|
+
ordering = ("name", "manager")
|
|
12
|
+
|
|
13
|
+
def _count_with_status(self, obj, status):
|
|
14
|
+
return PluginItem.objects.filter(plugin_type=obj, status=status).count()
|
|
15
|
+
|
|
16
|
+
def active_count(self, obj): return self._count_with_status(obj, PluginStatus.ACTIVE)
|
|
17
|
+
|
|
18
|
+
def reserved_count(self, obj): return self._count_with_status(obj, PluginStatus.RESERVED)
|
|
19
|
+
|
|
20
|
+
def disabled_count(self, obj): return self._count_with_status(obj, PluginStatus.DISABLED)
|
|
21
|
+
|
|
22
|
+
active_count.short_description = "Active"
|
|
23
|
+
reserved_count.short_description = "Reserved"
|
|
24
|
+
disabled_count.short_description = "Disabled"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@admin.action(description="Mark selected as ACTIVE")
|
|
28
|
+
def mark_active(modeladmin, request, queryset):
|
|
29
|
+
queryset.update(status=PluginStatus.ACTIVE)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@admin.action(description="Mark selected as RESERVED")
|
|
33
|
+
def mark_reserved(modeladmin, request, queryset):
|
|
34
|
+
queryset.update(status=PluginStatus.RESERVED)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@admin.action(description="Mark selected as DISABLED")
|
|
38
|
+
def mark_disabled(modeladmin, request, queryset):
|
|
39
|
+
queryset.update(status=PluginStatus.DISABLED)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@admin.action(description="Increase priority (lower number)")
|
|
43
|
+
def increase_priority(modeladmin, request, queryset):
|
|
44
|
+
# Lower number => higher priority
|
|
45
|
+
queryset.update(priority=F('priority') - 1)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@admin.action(description="Decrease priority (higher number)")
|
|
49
|
+
def decrease_priority(modeladmin, request, queryset):
|
|
50
|
+
queryset.update(priority=F('priority') + 1)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@admin.register(PluginItem)
|
|
54
|
+
class PluginItemAdmin(admin.ModelAdmin):
|
|
55
|
+
list_display = ("name", "plugin_type", "module", "status", "priority", "loaded_ok")
|
|
56
|
+
list_filter = ("status", "module", "plugin_type__name", "plugin_type__manager")
|
|
57
|
+
search_fields = ("name", "module", "description", "plugin_type__name")
|
|
58
|
+
ordering = ("plugin_type__name", "priority", "name")
|
|
59
|
+
list_editable = ("status", "priority")
|
|
60
|
+
actions = [mark_active, mark_reserved, mark_disabled, increase_priority, decrease_priority]
|
|
61
|
+
|
|
62
|
+
@admin.display(boolean=True, description="Class loads")
|
|
63
|
+
def loaded_ok(self, obj: PluginItem):
|
|
64
|
+
return obj.load_class() is not None
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
from django.apps import AppConfig
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class PluginSystemConfig(AppConfig):
|
|
5
|
+
name = 'django_plugin_system'
|
|
6
|
+
verbose_name = 'Django Plugin System'
|
|
7
|
+
|
|
8
|
+
def ready(self):
|
|
9
|
+
from .signals import sync_registered_plugins_to_db
|
|
10
|
+
from .models import _clear_single_plugin_cache
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
from typing import Type, TypeVar, Optional
|
|
2
|
+
|
|
3
|
+
from .models import PluginType
|
|
4
|
+
|
|
5
|
+
T = TypeVar("T")
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def get_plugin_instance(type_name: str, manager: str) -> Optional[object]:
|
|
9
|
+
try:
|
|
10
|
+
pt = PluginType.objects.get(name=type_name, manager=manager)
|
|
11
|
+
except PluginType.DoesNotExist:
|
|
12
|
+
return None
|
|
13
|
+
item = pt.get_single_plugin()
|
|
14
|
+
if not item:
|
|
15
|
+
return None
|
|
16
|
+
cls = item.load_class()
|
|
17
|
+
return cls() if cls else None
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def get_plugin_class(type_name: str, manager: str) -> Optional[Type[T]]:
|
|
21
|
+
try:
|
|
22
|
+
pt = PluginType.objects.get(name=type_name, manager=manager)
|
|
23
|
+
except PluginType.DoesNotExist:
|
|
24
|
+
return None
|
|
25
|
+
item = pt.get_single_plugin()
|
|
26
|
+
if not item:
|
|
27
|
+
return None
|
|
28
|
+
return item.load_class()
|
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
from django.core.management.base import BaseCommand, CommandParser
|
|
2
|
+
|
|
3
|
+
from ...services import sync_registered_plugins_to_db
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class Command(BaseCommand):
|
|
7
|
+
help = "Synchronize registered plugin types/items into the database."
|
|
8
|
+
|
|
9
|
+
def add_arguments(self, parser: CommandParser) -> None:
|
|
10
|
+
parser.add_argument(
|
|
11
|
+
"--mode",
|
|
12
|
+
choices=["create", "update"],
|
|
13
|
+
default="create",
|
|
14
|
+
help='How to persist registry data. "create" = get_or_create (preserve admin edits). "update" = update_or_create.',
|
|
15
|
+
)
|
|
16
|
+
parser.add_argument(
|
|
17
|
+
"--no-prune",
|
|
18
|
+
action="store_true",
|
|
19
|
+
help="Do not prune plugin types/items whose manager/module is no longer installed.",
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
def handle(self, *args, **options):
|
|
23
|
+
mode = options["mode"]
|
|
24
|
+
prune = not options["no_prune"]
|
|
25
|
+
|
|
26
|
+
result = sync_registered_plugins_to_db(mode=mode, prune=prune)
|
|
27
|
+
|
|
28
|
+
self.stdout.write(self.style.SUCCESS("Plugin registry sync completed"))
|
|
29
|
+
self.stdout.write(f"- Types created: {result['types_created']}, found: {result['types_found']}")
|
|
30
|
+
self.stdout.write(f"- Items created: {result['items_created']}, found: {result['items_found']}")
|
|
31
|
+
if prune:
|
|
32
|
+
self.stdout.write(f"- Pruned types: {result['pruned_types']}, pruned items: {result['pruned_items']}")
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import uuid
|
|
2
|
+
from typing import ClassVar, List, Type, Dict
|
|
3
|
+
|
|
4
|
+
from django.core.cache import cache
|
|
5
|
+
from django.db import models
|
|
6
|
+
from django.db.models import UniqueConstraint, Index
|
|
7
|
+
from django.db.models.signals import post_save, post_delete
|
|
8
|
+
from django.dispatch import receiver
|
|
9
|
+
|
|
10
|
+
from .register import load_plugin_item, load_plugin_type
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class PluginStatus(models.TextChoices):
|
|
14
|
+
ACTIVE = 'active'
|
|
15
|
+
RESERVED = 'reserve' # used if no active plugin is available
|
|
16
|
+
DISABLED = 'disable'
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class PluginType(models.Model):
|
|
20
|
+
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
|
21
|
+
name = models.CharField(max_length=100, db_index=True)
|
|
22
|
+
manager = models.CharField(max_length=100) # app providing the plugin type
|
|
23
|
+
description = models.TextField()
|
|
24
|
+
|
|
25
|
+
class Meta:
|
|
26
|
+
constraints = [
|
|
27
|
+
UniqueConstraint(fields=["name", "manager"], name="uniq_plugin_type_name_manager"),
|
|
28
|
+
]
|
|
29
|
+
indexes = [
|
|
30
|
+
Index(fields=["name"]),
|
|
31
|
+
Index(fields=["manager"]),
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
def __str__(self):
|
|
35
|
+
return f"Plugin type {self.name}"
|
|
36
|
+
|
|
37
|
+
def get_all_plugins(self) -> Dict[str, List['PluginItem']]:
|
|
38
|
+
return PluginItem.get_all_plugins(self)
|
|
39
|
+
|
|
40
|
+
def get_active_plugins(self) -> 'List[PluginItem]':
|
|
41
|
+
return PluginItem.get_available_plugins(self)
|
|
42
|
+
|
|
43
|
+
def get_single_plugin(self) -> 'PluginItem | None':
|
|
44
|
+
try:
|
|
45
|
+
plugin_type = load_plugin_type(self.name, self.manager)
|
|
46
|
+
get_plugin = plugin_type.get('get_plugin')
|
|
47
|
+
if get_plugin:
|
|
48
|
+
return get_plugin(self)
|
|
49
|
+
except KeyError:
|
|
50
|
+
return None
|
|
51
|
+
return PluginItem.default_get_single_plugin(self)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class PluginItem(models.Model):
|
|
55
|
+
# CacheKey
|
|
56
|
+
CACHE_KEY_PLUGIN_TYPE_SINGLE: ClassVar[str] = 'plugin-single-item-type-{}'
|
|
57
|
+
|
|
58
|
+
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
|
59
|
+
plugin_type = models.ForeignKey(PluginType, on_delete=models.CASCADE)
|
|
60
|
+
module = models.CharField(max_length=100) # app providing the plugin item
|
|
61
|
+
name = models.CharField(max_length=100, db_index=True)
|
|
62
|
+
status = models.CharField(
|
|
63
|
+
max_length=10,
|
|
64
|
+
choices=PluginStatus.choices,
|
|
65
|
+
default=PluginStatus.ACTIVE,
|
|
66
|
+
db_index=True,
|
|
67
|
+
)
|
|
68
|
+
priority = models.SmallIntegerField(default=0) # lower is better
|
|
69
|
+
description = models.TextField(null=True, blank=True)
|
|
70
|
+
|
|
71
|
+
class Meta:
|
|
72
|
+
constraints = [
|
|
73
|
+
UniqueConstraint(
|
|
74
|
+
fields=["name", "module", "plugin_type"],
|
|
75
|
+
name="uniq_plugin_item_name_module_type",
|
|
76
|
+
)
|
|
77
|
+
]
|
|
78
|
+
indexes = [
|
|
79
|
+
Index(fields=["plugin_type", "status", "priority"]),
|
|
80
|
+
Index(fields=["module"]),
|
|
81
|
+
]
|
|
82
|
+
|
|
83
|
+
def __str__(self):
|
|
84
|
+
return f"Plugin {self.name} for {self.plugin_type} provided by {self.module}.({self.status})"
|
|
85
|
+
|
|
86
|
+
def load_class(self) -> Type | None:
|
|
87
|
+
try:
|
|
88
|
+
plugin_item = load_plugin_item(self.name, self.module, self.plugin_type.name)
|
|
89
|
+
return plugin_item['plugin_class']
|
|
90
|
+
except Exception:
|
|
91
|
+
return None
|
|
92
|
+
|
|
93
|
+
@staticmethod
|
|
94
|
+
def default_get_single_plugin(plugin_type: PluginType) -> 'PluginItem | None':
|
|
95
|
+
key = PluginItem.CACHE_KEY_PLUGIN_TYPE_SINGLE.format(plugin_type.id)
|
|
96
|
+
plugin = cache.get(key)
|
|
97
|
+
if plugin:
|
|
98
|
+
return plugin
|
|
99
|
+
qs = PluginItem.objects.filter(plugin_type=plugin_type, status=PluginStatus.ACTIVE).order_by('priority')
|
|
100
|
+
first = qs.first()
|
|
101
|
+
if first:
|
|
102
|
+
cache.set(key, first)
|
|
103
|
+
return first
|
|
104
|
+
qs = PluginItem.objects.filter(plugin_type=plugin_type, status=PluginStatus.RESERVED).order_by('priority')
|
|
105
|
+
return qs.first()
|
|
106
|
+
|
|
107
|
+
@staticmethod
|
|
108
|
+
def get_all_plugins(plugin_type: PluginType) -> Dict[str, List['PluginItem']]:
|
|
109
|
+
result = {}
|
|
110
|
+
for plugin in PluginItem.objects.filter(plugin_type=plugin_type).order_by('priority'):
|
|
111
|
+
result.setdefault(plugin.status, []).append(plugin)
|
|
112
|
+
return result
|
|
113
|
+
|
|
114
|
+
@staticmethod
|
|
115
|
+
def get_available_plugins(plugin_type: PluginType) -> 'List[PluginItem]':
|
|
116
|
+
return list(
|
|
117
|
+
PluginItem.objects.filter(plugin_type=plugin_type, status=PluginStatus.ACTIVE).order_by('priority')
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
# Cache invalidation when items change
|
|
122
|
+
@receiver(post_save, sender=PluginItem)
|
|
123
|
+
@receiver(post_delete, sender=PluginItem)
|
|
124
|
+
def _clear_single_plugin_cache(sender, instance: PluginItem, **kwargs):
|
|
125
|
+
key = PluginItem.CACHE_KEY_PLUGIN_TYPE_SINGLE.format(instance.plugin_type.id)
|
|
126
|
+
cache.delete(key)
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from abc import ABC
|
|
3
|
+
|
|
4
|
+
from .storage import (
|
|
5
|
+
_registry_plugin_items,
|
|
6
|
+
_registry_plugin_types,
|
|
7
|
+
PluginTypeRegistry,
|
|
8
|
+
PluginItemRegistry,
|
|
9
|
+
PLUGIN_TYPE_PLACEHOLDER,
|
|
10
|
+
PLUGIN_ITEM_PLACEHOLDER,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger(__name__)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def register_plugin_type(plugin_type: PluginTypeRegistry):
|
|
17
|
+
if not issubclass(plugin_type['interface'], ABC):
|
|
18
|
+
raise TypeError("Interface must be a subclass of ABC")
|
|
19
|
+
if not getattr(plugin_type['interface'], '__abstractmethods__', None):
|
|
20
|
+
raise TypeError("Interface must have at least one abstractmethod")
|
|
21
|
+
|
|
22
|
+
name = PLUGIN_TYPE_PLACEHOLDER.format(plugin_type['name'], plugin_type['manager'])
|
|
23
|
+
_registry_plugin_types[name] = plugin_type
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def load_plugin_type(type_name: str, manager: str) -> PluginTypeRegistry:
|
|
27
|
+
name = PLUGIN_TYPE_PLACEHOLDER.format(type_name, manager)
|
|
28
|
+
if name in _registry_plugin_types:
|
|
29
|
+
return _registry_plugin_types[name]
|
|
30
|
+
raise KeyError(f"Plugin type '{name}' not found")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def register_plugin_item(plugin_item: PluginItemRegistry):
|
|
34
|
+
plugin_type: PluginTypeRegistry = load_plugin_type(plugin_item['type_name'], plugin_item['manager_name'])
|
|
35
|
+
if not issubclass(plugin_item['plugin_class'], plugin_type['interface']):
|
|
36
|
+
raise TypeError(
|
|
37
|
+
f"Plugin '{plugin_item['name']}' does not implement interface {plugin_type['interface'].__name__}")
|
|
38
|
+
name = PLUGIN_ITEM_PLACEHOLDER.format(plugin_item['name'], plugin_item['module'], plugin_item['type_name'])
|
|
39
|
+
_registry_plugin_items[name] = plugin_item
|
|
40
|
+
logger.debug("Registered plugin item %s", name)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def load_plugin_item(plugin_name: str, module_name: str, type_name: str) -> PluginItemRegistry:
|
|
44
|
+
name = PLUGIN_ITEM_PLACEHOLDER.format(plugin_name, module_name, type_name)
|
|
45
|
+
if name in _registry_plugin_items:
|
|
46
|
+
return _registry_plugin_items[name]
|
|
47
|
+
raise KeyError(f"Plugin item '{name}' not found")
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
from .sync import sync_registered_plugins_to_db
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
from typing import Literal
|
|
2
|
+
|
|
3
|
+
from django.conf import settings
|
|
4
|
+
from django.db import transaction
|
|
5
|
+
from django.db.models import Q
|
|
6
|
+
|
|
7
|
+
from ..models import PluginType, PluginItem, PluginStatus
|
|
8
|
+
from ..storage import _registry_plugin_types, _registry_plugin_items
|
|
9
|
+
|
|
10
|
+
Mode = Literal["create", "update"]
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _app_list() -> set[str]:
|
|
14
|
+
return set(settings.INSTALLED_APPS)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@transaction.atomic
|
|
18
|
+
def sync_registered_plugins_to_db(
|
|
19
|
+
*,
|
|
20
|
+
mode: Mode = "create",
|
|
21
|
+
prune: bool = True,
|
|
22
|
+
) -> dict:
|
|
23
|
+
"""
|
|
24
|
+
Sync in-memory registry -> DB.
|
|
25
|
+
|
|
26
|
+
Mode="create": uses get_or_create (won't overwrite admin-edited fields)
|
|
27
|
+
mode="update": uses update_or_create (will refresh description/priority from registry)
|
|
28
|
+
|
|
29
|
+
prune: remove DB rows for managers/modules that are no longer installed
|
|
30
|
+
"""
|
|
31
|
+
result = {"types_created": 0, "types_found": 0, "items_created": 0, "items_found": 0, "pruned_types": 0,
|
|
32
|
+
"pruned_items": 0}
|
|
33
|
+
|
|
34
|
+
app_list = _app_list()
|
|
35
|
+
|
|
36
|
+
if prune:
|
|
37
|
+
# remove rows for uninstalled apps
|
|
38
|
+
result["pruned_types"] = PluginType.objects.filter(~Q(manager__in=app_list)).delete()[0]
|
|
39
|
+
result["pruned_items"] = PluginItem.objects.filter(~Q(module__in=app_list)).delete()[0]
|
|
40
|
+
|
|
41
|
+
# TYPES
|
|
42
|
+
for _, pt in _registry_plugin_types.items():
|
|
43
|
+
defaults = {"description": pt.get("description") or ""}
|
|
44
|
+
if mode == "update":
|
|
45
|
+
obj, created = PluginType.objects.update_or_create(
|
|
46
|
+
name=pt["name"], manager=pt["manager"], defaults=defaults
|
|
47
|
+
)
|
|
48
|
+
else:
|
|
49
|
+
obj, created = PluginType.objects.get_or_create(
|
|
50
|
+
name=pt["name"], manager=pt["manager"], defaults=defaults
|
|
51
|
+
)
|
|
52
|
+
if created:
|
|
53
|
+
result["types_created"] += 1
|
|
54
|
+
else:
|
|
55
|
+
result["types_found"] += 1
|
|
56
|
+
|
|
57
|
+
# ITEMS
|
|
58
|
+
for _, pi in _registry_plugin_items.items():
|
|
59
|
+
try:
|
|
60
|
+
pt_obj = PluginType.objects.get(name=pi["type_name"], manager=pi["manager_name"])
|
|
61
|
+
except PluginType.DoesNotExist:
|
|
62
|
+
# Type isn't synced (or missing) — skip
|
|
63
|
+
continue
|
|
64
|
+
|
|
65
|
+
defaults = {
|
|
66
|
+
"description": pi.get("description") or "",
|
|
67
|
+
# prefer registry priority on first creation; won’t override in "create" mode
|
|
68
|
+
"priority": pi.get("priority") or 0,
|
|
69
|
+
# first-time status ACTIVE; admin changes later will stick
|
|
70
|
+
"status": PluginStatus.ACTIVE,
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
lookup = {"name": pi["name"], "module": pi["module"], "plugin_type": pt_obj}
|
|
74
|
+
if mode == "update":
|
|
75
|
+
obj, created = PluginItem.objects.update_or_create(defaults=defaults, **lookup)
|
|
76
|
+
else:
|
|
77
|
+
obj, created = PluginItem.objects.get_or_create(defaults=defaults, **lookup)
|
|
78
|
+
|
|
79
|
+
if created:
|
|
80
|
+
result["items_created"] += 1
|
|
81
|
+
else:
|
|
82
|
+
result["items_found"] += 1
|
|
83
|
+
|
|
84
|
+
return result
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
from django.db.models.signals import post_migrate
|
|
2
|
+
from django.dispatch import receiver
|
|
3
|
+
|
|
4
|
+
from .services.sync import sync_registered_plugins_to_db
|
|
5
|
+
|
|
6
|
+
APP_LABEL = "django_plugin_system"
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@receiver(post_migrate)
|
|
10
|
+
def sync_registered_plugins_signal(sender, **kwargs):
|
|
11
|
+
if getattr(sender, "name", None) != APP_LABEL:
|
|
12
|
+
return
|
|
13
|
+
# create-only; do not overwrite admin-edited fields
|
|
14
|
+
sync_registered_plugins_to_db(mode="create", prune=True)
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
from abc import ABC
|
|
2
|
+
from typing import TypedDict, Callable, Type, Dict, NotRequired
|
|
3
|
+
|
|
4
|
+
PLUGIN_TYPE_PLACEHOLDER = 'plugin-type-{}-by-{}'
|
|
5
|
+
PLUGIN_ITEM_PLACEHOLDER = 'plugin-item-{}-by-{}-for-{}'
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class PluginTypeRegistry(TypedDict):
|
|
9
|
+
name: str
|
|
10
|
+
interface: type[ABC]
|
|
11
|
+
manager: str
|
|
12
|
+
get_plugin: NotRequired[Callable | None]
|
|
13
|
+
description: NotRequired[str | None]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class PluginItemRegistry(TypedDict):
|
|
17
|
+
name: str
|
|
18
|
+
type_name: str
|
|
19
|
+
manager_name: str
|
|
20
|
+
plugin_class: Type
|
|
21
|
+
module: str
|
|
22
|
+
description: NotRequired[str | None]
|
|
23
|
+
priority: NotRequired[int | None]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
_registry_plugin_types: Dict[str, PluginTypeRegistry] = {}
|
|
27
|
+
_registry_plugin_items: Dict[str, PluginItemRegistry] = {}
|