dataman-engine 0.1.0__tar.gz → 0.2.0__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.
- {dataman_engine-0.1.0 → dataman_engine-0.2.0}/PKG-INFO +12 -5
- {dataman_engine-0.1.0 → dataman_engine-0.2.0}/README.md +5 -0
- {dataman_engine-0.1.0 → dataman_engine-0.2.0}/pyproject.toml +27 -6
- {dataman_engine-0.1.0 → dataman_engine-0.2.0}/pyproject.toml.orig +27 -8
- {dataman_engine-0.1.0 → dataman_engine-0.2.0}/src/dataman/__init__.py +1 -1
- dataman_engine-0.2.0/src/dataman/asgi.py +3 -0
- {dataman_engine-0.1.0 → dataman_engine-0.2.0}/src/dataman/cli.py +22 -3
- {dataman_engine-0.1.0 → dataman_engine-0.2.0}/src/dataman/core/apps.py +1 -1
- dataman_engine-0.2.0/src/dataman/core/middleware.py +123 -0
- dataman_engine-0.2.0/src/dataman/core/migrations/0001_initial.py +96 -0
- dataman_engine-0.2.0/src/dataman/core/migrations/__init__.py +1 -0
- {dataman_engine-0.1.0 → dataman_engine-0.2.0}/src/dataman/core/models.py +5 -3
- {dataman_engine-0.1.0 → dataman_engine-0.2.0}/src/dataman/core/router.py +3 -0
- {dataman_engine-0.1.0 → dataman_engine-0.2.0}/src/dataman/core/templates/admin/dashboard.html +158 -12
- {dataman_engine-0.1.0 → dataman_engine-0.2.0}/src/dataman/core/urls.py +132 -2
- {dataman_engine-0.1.0 → dataman_engine-0.2.0}/src/dataman/core/views.py +59 -0
- {dataman_engine-0.1.0 → dataman_engine-0.2.0}/src/dataman/django_setup.py +16 -9
- dataman_engine-0.2.0/src/dataman/wsgi.py +3 -0
- dataman_engine-0.1.0/src/dataman/core/middleware.py +0 -46
- {dataman_engine-0.1.0 → dataman_engine-0.2.0}/src/dataman/core/__init__.py +0 -0
- {dataman_engine-0.1.0 → dataman_engine-0.2.0}/src/dataman/core/audit.py +0 -0
- {dataman_engine-0.1.0 → dataman_engine-0.2.0}/src/dataman/core/auth.py +0 -0
- {dataman_engine-0.1.0 → dataman_engine-0.2.0}/src/dataman/core/crypto.py +0 -0
- {dataman_engine-0.1.0 → dataman_engine-0.2.0}/src/dataman/core/fields.py +0 -0
- {dataman_engine-0.1.0 → dataman_engine-0.2.0}/src/dataman/core/masking.py +0 -0
- {dataman_engine-0.1.0 → dataman_engine-0.2.0}/src/dataman/core/permissions.py +0 -0
- {dataman_engine-0.1.0 → dataman_engine-0.2.0}/src/dataman/core/signals.py +0 -0
- {dataman_engine-0.1.0 → dataman_engine-0.2.0}/src/dataman/core/templates/admin/login.html +0 -0
- {dataman_engine-0.1.0 → dataman_engine-0.2.0}/src/dataman/py.typed +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: dataman-engine
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: A dynamic, headless data layer framework built on Django and DRF for instant CRUD APIs and custom business logic.
|
|
5
5
|
Keywords: django,rest,api,crud,headless,generator,scaffold
|
|
6
6
|
Author: Vikash G
|
|
@@ -10,11 +10,12 @@ Classifier: Development Status :: 4 - Beta
|
|
|
10
10
|
Classifier: Environment :: Console
|
|
11
11
|
Classifier: Environment :: Web Environment
|
|
12
12
|
Classifier: Framework :: Django
|
|
13
|
-
Classifier: Framework :: Django ::
|
|
13
|
+
Classifier: Framework :: Django :: 6.0
|
|
14
14
|
Classifier: Intended Audience :: Developers
|
|
15
15
|
Classifier: Operating System :: OS Independent
|
|
16
16
|
Classifier: Programming Language :: Python
|
|
17
17
|
Classifier: Programming Language :: Python :: 3
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
19
|
Classifier: Programming Language :: Python :: 3.13
|
|
19
20
|
Classifier: Topic :: Internet :: WWW/HTTP
|
|
20
21
|
Classifier: Topic :: Internet :: WWW/HTTP :: Dynamic Content
|
|
@@ -31,15 +32,21 @@ Requires-Dist: dj-database-url>=2.3.0
|
|
|
31
32
|
Requires-Dist: inflection>=0.5.1
|
|
32
33
|
Requires-Dist: uvicorn>=0.34.0
|
|
33
34
|
Requires-Dist: cryptography>=42.0.0
|
|
34
|
-
Requires-
|
|
35
|
-
|
|
36
|
-
Project-URL:
|
|
35
|
+
Requires-Dist: psycopg2-binary>=2.9.13
|
|
36
|
+
Requires-Python: >=3.12
|
|
37
|
+
Project-URL: Homepage, https://github.com/vikashgraja/dataman
|
|
38
|
+
Project-URL: Repository, https://github.com/vikashgraja/dataman.git
|
|
37
39
|
Description-Content-Type: text/markdown
|
|
38
40
|
|
|
39
41
|
# DataMan (Data MiddleMan)
|
|
40
42
|
|
|
43
|
+
[](docs/QUICKSTART.md)
|
|
44
|
+
|
|
41
45
|
**DataMan** is a dynamic, CLI-driven backend framework built on top of Django and Django REST Framework. It eliminates the boilerplate of writing standard CRUD APIs, routing, and serializers by allowing you to scaffold endpoints instantly from the command line while preserving your ability to inject custom business logic and strict validation whenever you need it.
|
|
42
46
|
|
|
47
|
+
👉 **Looking to build an API in 5 minutes? Check out the [Developer Quickstart Tutorial](docs/QUICKSTART.md)!**
|
|
48
|
+
|
|
49
|
+
|
|
43
50
|
---
|
|
44
51
|
|
|
45
52
|
## Features
|
|
@@ -1,7 +1,12 @@
|
|
|
1
1
|
# DataMan (Data MiddleMan)
|
|
2
2
|
|
|
3
|
+
[](docs/QUICKSTART.md)
|
|
4
|
+
|
|
3
5
|
**DataMan** is a dynamic, CLI-driven backend framework built on top of Django and Django REST Framework. It eliminates the boilerplate of writing standard CRUD APIs, routing, and serializers by allowing you to scaffold endpoints instantly from the command line while preserving your ability to inject custom business logic and strict validation whenever you need it.
|
|
4
6
|
|
|
7
|
+
👉 **Looking to build an API in 5 minutes? Check out the [Developer Quickstart Tutorial](docs/QUICKSTART.md)!**
|
|
8
|
+
|
|
9
|
+
|
|
5
10
|
---
|
|
6
11
|
|
|
7
12
|
## Features
|
|
@@ -1,20 +1,21 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "dataman-engine"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.2.0"
|
|
4
4
|
description = "A dynamic, headless data layer framework built on Django and DRF for instant CRUD APIs and custom business logic."
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
license = "MIT"
|
|
7
|
-
requires-python = ">=3.
|
|
7
|
+
requires-python = ">=3.12"
|
|
8
8
|
classifiers = [
|
|
9
9
|
"Development Status :: 4 - Beta",
|
|
10
10
|
"Environment :: Console",
|
|
11
11
|
"Environment :: Web Environment",
|
|
12
12
|
"Framework :: Django",
|
|
13
|
-
"Framework :: Django ::
|
|
13
|
+
"Framework :: Django :: 6.0",
|
|
14
14
|
"Intended Audience :: Developers",
|
|
15
15
|
"Operating System :: OS Independent",
|
|
16
16
|
"Programming Language :: Python",
|
|
17
17
|
"Programming Language :: Python :: 3",
|
|
18
|
+
"Programming Language :: Python :: 3.12",
|
|
18
19
|
"Programming Language :: Python :: 3.13",
|
|
19
20
|
"Topic :: Internet :: WWW/HTTP",
|
|
20
21
|
"Topic :: Internet :: WWW/HTTP :: Dynamic Content",
|
|
@@ -42,6 +43,7 @@ dependencies = [
|
|
|
42
43
|
"inflection>=0.5.1",
|
|
43
44
|
"uvicorn>=0.34.0",
|
|
44
45
|
"cryptography>=42.0.0",
|
|
46
|
+
"psycopg2-binary>=2.9.13",
|
|
45
47
|
]
|
|
46
48
|
|
|
47
49
|
[[project.authors]]
|
|
@@ -52,8 +54,8 @@ email = "vikashgraja@gmail.com"
|
|
|
52
54
|
dataman = "dataman.cli:cli"
|
|
53
55
|
|
|
54
56
|
[project.urls]
|
|
55
|
-
Homepage = "https://github.com/
|
|
56
|
-
Repository = "https://github.com/
|
|
57
|
+
Homepage = "https://github.com/vikashgraja/dataman"
|
|
58
|
+
Repository = "https://github.com/vikashgraja/dataman.git"
|
|
57
59
|
|
|
58
60
|
[tool.coverage.run]
|
|
59
61
|
branch = true
|
|
@@ -65,13 +67,14 @@ module-name = "dataman"
|
|
|
65
67
|
|
|
66
68
|
[tool.ruff]
|
|
67
69
|
line-length = 88
|
|
68
|
-
target-version = "
|
|
70
|
+
target-version = "py312"
|
|
69
71
|
exclude = [
|
|
70
72
|
".venv",
|
|
71
73
|
"venv",
|
|
72
74
|
"__pycache__",
|
|
73
75
|
"build",
|
|
74
76
|
"dist",
|
|
77
|
+
"benchmarks",
|
|
75
78
|
]
|
|
76
79
|
|
|
77
80
|
[tool.ruff.lint]
|
|
@@ -97,6 +100,7 @@ line-ending = "auto"
|
|
|
97
100
|
exclude_dirs = [
|
|
98
101
|
"tests",
|
|
99
102
|
".venv",
|
|
103
|
+
"benchmarks",
|
|
100
104
|
]
|
|
101
105
|
skips = ["B101"]
|
|
102
106
|
|
|
@@ -104,6 +108,20 @@ skips = ["B101"]
|
|
|
104
108
|
testpaths = ["tests"]
|
|
105
109
|
addopts = "-v --tb=short"
|
|
106
110
|
|
|
111
|
+
[tool.tox]
|
|
112
|
+
legacy_tox_ini = """
|
|
113
|
+
[tox]
|
|
114
|
+
env_list = py312, py313, py314
|
|
115
|
+
min_version = 4.0
|
|
116
|
+
|
|
117
|
+
[testenv]
|
|
118
|
+
runner = uv-venv-runner
|
|
119
|
+
dependency_groups =
|
|
120
|
+
dev
|
|
121
|
+
commands =
|
|
122
|
+
pytest {posargs}
|
|
123
|
+
"""
|
|
124
|
+
|
|
107
125
|
[build-system]
|
|
108
126
|
requires = ["uv_build>=0.10.6,<0.13.0"]
|
|
109
127
|
build-backend = "uv_build"
|
|
@@ -111,9 +129,12 @@ build-backend = "uv_build"
|
|
|
111
129
|
[dependency-groups]
|
|
112
130
|
dev = [
|
|
113
131
|
"bandit>=1.9.4",
|
|
132
|
+
"locust>=2.32.0",
|
|
114
133
|
"playwright>=1.62.0",
|
|
115
134
|
"pre-commit>=4.6.1",
|
|
116
135
|
"pytest>=9.1.1",
|
|
117
136
|
"pytest-cov>=7.1.0",
|
|
118
137
|
"ruff>=0.16.1",
|
|
138
|
+
"tox>=4.24.1",
|
|
139
|
+
"tox-uv>=1.25.0",
|
|
119
140
|
]
|
|
@@ -1,23 +1,24 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "dataman-engine"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.2.0"
|
|
4
4
|
description = "A dynamic, headless data layer framework built on Django and DRF for instant CRUD APIs and custom business logic."
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
license = "MIT"
|
|
7
7
|
authors = [
|
|
8
8
|
{ name = "Vikash G", email = "vikashgraja@gmail.com" }
|
|
9
9
|
]
|
|
10
|
-
requires-python = ">=3.
|
|
10
|
+
requires-python = ">=3.12"
|
|
11
11
|
classifiers = [
|
|
12
12
|
"Development Status :: 4 - Beta",
|
|
13
13
|
"Environment :: Console",
|
|
14
14
|
"Environment :: Web Environment",
|
|
15
15
|
"Framework :: Django",
|
|
16
|
-
"Framework :: Django ::
|
|
16
|
+
"Framework :: Django :: 6.0",
|
|
17
17
|
"Intended Audience :: Developers",
|
|
18
18
|
"Operating System :: OS Independent",
|
|
19
19
|
"Programming Language :: Python",
|
|
20
20
|
"Programming Language :: Python :: 3",
|
|
21
|
+
"Programming Language :: Python :: 3.12",
|
|
21
22
|
"Programming Language :: Python :: 3.13",
|
|
22
23
|
"Topic :: Internet :: WWW/HTTP",
|
|
23
24
|
"Topic :: Internet :: WWW/HTTP :: Dynamic Content",
|
|
@@ -39,14 +40,15 @@ dependencies = [
|
|
|
39
40
|
"inflection>=0.5.1",
|
|
40
41
|
"uvicorn>=0.34.0",
|
|
41
42
|
"cryptography>=42.0.0",
|
|
43
|
+
"psycopg2-binary>=2.9.13",
|
|
42
44
|
]
|
|
43
45
|
|
|
44
46
|
[project.scripts]
|
|
45
47
|
dataman = "dataman.cli:cli"
|
|
46
48
|
|
|
47
49
|
[project.urls]
|
|
48
|
-
Homepage = "https://github.com/
|
|
49
|
-
Repository = "https://github.com/
|
|
50
|
+
Homepage = "https://github.com/vikashgraja/dataman"
|
|
51
|
+
Repository = "https://github.com/vikashgraja/dataman.git"
|
|
50
52
|
|
|
51
53
|
[tool.coverage.run]
|
|
52
54
|
branch = true
|
|
@@ -62,17 +64,20 @@ module-name = "dataman"
|
|
|
62
64
|
[dependency-groups]
|
|
63
65
|
dev = [
|
|
64
66
|
"bandit>=1.9.4",
|
|
67
|
+
"locust>=2.32.0",
|
|
65
68
|
"playwright>=1.62.0",
|
|
66
69
|
"pre-commit>=4.6.1",
|
|
67
70
|
"pytest>=9.1.1",
|
|
68
71
|
"pytest-cov>=7.1.0",
|
|
69
72
|
"ruff>=0.16.1",
|
|
73
|
+
"tox>=4.24.1",
|
|
74
|
+
"tox-uv>=1.25.0",
|
|
70
75
|
]
|
|
71
76
|
|
|
72
77
|
[tool.ruff]
|
|
73
78
|
line-length = 88
|
|
74
|
-
target-version = "
|
|
75
|
-
exclude = [".venv", "venv", "__pycache__", "build", "dist"]
|
|
79
|
+
target-version = "py312"
|
|
80
|
+
exclude = [".venv", "venv", "__pycache__", "build", "dist", "benchmarks"]
|
|
76
81
|
|
|
77
82
|
[tool.ruff.lint]
|
|
78
83
|
select = [
|
|
@@ -94,9 +99,23 @@ skip-magic-trailing-comma = false
|
|
|
94
99
|
line-ending = "auto"
|
|
95
100
|
|
|
96
101
|
[tool.bandit]
|
|
97
|
-
exclude_dirs = ["tests", ".venv"]
|
|
102
|
+
exclude_dirs = ["tests", ".venv", "benchmarks"]
|
|
98
103
|
skips = ["B101"]
|
|
99
104
|
|
|
100
105
|
[tool.pytest.ini_options]
|
|
101
106
|
testpaths = ["tests"]
|
|
102
107
|
addopts = "-v --tb=short"
|
|
108
|
+
|
|
109
|
+
[tool.tox]
|
|
110
|
+
legacy_tox_ini = """
|
|
111
|
+
[tox]
|
|
112
|
+
env_list = py312, py313, py314
|
|
113
|
+
min_version = 4.0
|
|
114
|
+
|
|
115
|
+
[testenv]
|
|
116
|
+
runner = uv-venv-runner
|
|
117
|
+
dependency_groups =
|
|
118
|
+
dev
|
|
119
|
+
commands =
|
|
120
|
+
pytest {posargs}
|
|
121
|
+
"""
|
|
@@ -287,7 +287,7 @@ def create_table(name, database, operations):
|
|
|
287
287
|
f.write(" created_at = models.DateTimeField(auto_now_add=True)\n")
|
|
288
288
|
f.write(" updated_at = models.DateTimeField(auto_now=True)\n\n")
|
|
289
289
|
f.write(" class Meta:\n")
|
|
290
|
-
f.write(" app_label = '
|
|
290
|
+
f.write(" app_label = 'tables'\n")
|
|
291
291
|
f.write(f" db_table = '{table_name.lower()}'\n")
|
|
292
292
|
|
|
293
293
|
# Write validation.py
|
|
@@ -327,12 +327,16 @@ def create_table(name, database, operations):
|
|
|
327
327
|
|
|
328
328
|
|
|
329
329
|
@cli.command()
|
|
330
|
-
|
|
330
|
+
@click.argument("app_label", required=False, default=None)
|
|
331
|
+
def makemigration(app_label):
|
|
331
332
|
"""Create new migrations based on the models you have defined."""
|
|
332
333
|
_ensure_initialized()
|
|
333
334
|
django_setup.setup()
|
|
334
335
|
try:
|
|
335
|
-
|
|
336
|
+
if app_label:
|
|
337
|
+
call_command("makemigrations", app_label)
|
|
338
|
+
else:
|
|
339
|
+
call_command("makemigrations", "tables")
|
|
336
340
|
click.echo(click.style("Migrations created successfully!", fg="green"))
|
|
337
341
|
except SystemExit as e:
|
|
338
342
|
raise click.Abort() from e
|
|
@@ -435,6 +439,21 @@ def create_admin():
|
|
|
435
439
|
raise click.Abort() from e
|
|
436
440
|
|
|
437
441
|
|
|
442
|
+
@users.command(name="change-password")
|
|
443
|
+
@click.argument("username", required=False, default=None)
|
|
444
|
+
def change_password(username):
|
|
445
|
+
"""Change the password for an admin or user."""
|
|
446
|
+
_ensure_initialized()
|
|
447
|
+
django_setup.setup()
|
|
448
|
+
try:
|
|
449
|
+
if username:
|
|
450
|
+
call_command("changepassword", username)
|
|
451
|
+
else:
|
|
452
|
+
call_command("changepassword")
|
|
453
|
+
except SystemExit as e:
|
|
454
|
+
raise click.Abort() from e
|
|
455
|
+
|
|
456
|
+
|
|
438
457
|
@users.command(name="create-token")
|
|
439
458
|
@click.argument("name")
|
|
440
459
|
@click.option(
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import atexit
|
|
2
|
+
import logging
|
|
3
|
+
import queue
|
|
4
|
+
import threading
|
|
5
|
+
import time
|
|
6
|
+
|
|
7
|
+
from django.db import close_old_connections
|
|
8
|
+
|
|
9
|
+
from .models import APILog
|
|
10
|
+
|
|
11
|
+
logger = logging.getLogger("dataman.telemetry")
|
|
12
|
+
|
|
13
|
+
_log_queue = queue.Queue(maxsize=50000)
|
|
14
|
+
_shutdown_event = threading.Event()
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _telemetry_batch_worker():
|
|
18
|
+
"""
|
|
19
|
+
High-performance background batch worker. Drains telemetry log queue
|
|
20
|
+
and persists records using bulk_create to eliminate DB lock contention.
|
|
21
|
+
"""
|
|
22
|
+
while not _shutdown_event.is_set() or not _log_queue.empty():
|
|
23
|
+
batch = []
|
|
24
|
+
try:
|
|
25
|
+
item = _log_queue.get(timeout=0.05)
|
|
26
|
+
batch.append(item)
|
|
27
|
+
while len(batch) < 100:
|
|
28
|
+
try:
|
|
29
|
+
batch.append(_log_queue.get_nowait())
|
|
30
|
+
except queue.Empty:
|
|
31
|
+
break
|
|
32
|
+
except queue.Empty:
|
|
33
|
+
continue
|
|
34
|
+
|
|
35
|
+
if not batch:
|
|
36
|
+
continue
|
|
37
|
+
|
|
38
|
+
close_old_connections()
|
|
39
|
+
try:
|
|
40
|
+
objs = [
|
|
41
|
+
APILog(
|
|
42
|
+
method=m,
|
|
43
|
+
path=p,
|
|
44
|
+
status_code=s,
|
|
45
|
+
duration_ms=d,
|
|
46
|
+
ip_address=ip,
|
|
47
|
+
)
|
|
48
|
+
for (m, p, s, d, ip) in batch
|
|
49
|
+
]
|
|
50
|
+
APILog.objects.bulk_create(objs, batch_size=100)
|
|
51
|
+
except Exception as e:
|
|
52
|
+
logger.error(f"Failed to batch write APILogs: {e}")
|
|
53
|
+
finally:
|
|
54
|
+
close_old_connections()
|
|
55
|
+
for _ in batch:
|
|
56
|
+
_log_queue.task_done()
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
_worker_thread = threading.Thread(
|
|
60
|
+
target=_telemetry_batch_worker, name="dataman-telemetry-batch", daemon=True
|
|
61
|
+
)
|
|
62
|
+
_worker_thread.start()
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def flush_telemetry_logs(timeout=5):
|
|
66
|
+
"""Barrier helper to ensure all queued background logs are flushed to database."""
|
|
67
|
+
try:
|
|
68
|
+
_log_queue.join()
|
|
69
|
+
except Exception as e:
|
|
70
|
+
logger.warning(f"Timeout flushing telemetry queue: {e}")
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _shutdown_telemetry():
|
|
74
|
+
_shutdown_event.set()
|
|
75
|
+
flush_telemetry_logs()
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
atexit.register(_shutdown_telemetry)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class APILoggingMiddleware:
|
|
82
|
+
"""Middleware to log API request metrics asynchronously for high concurrency."""
|
|
83
|
+
|
|
84
|
+
def __init__(self, get_response):
|
|
85
|
+
self.get_response = get_response
|
|
86
|
+
|
|
87
|
+
def __call__(self, request):
|
|
88
|
+
path = request.path
|
|
89
|
+
# Only log requests to actual data endpoints,
|
|
90
|
+
# exclude internal/dashboard/docs/health routes.
|
|
91
|
+
if (
|
|
92
|
+
not path.startswith("/api/")
|
|
93
|
+
or path.startswith("/api/schema")
|
|
94
|
+
or path.startswith("/api/docs")
|
|
95
|
+
or path.startswith("/api/redoc")
|
|
96
|
+
or path.startswith("/api/_internal/")
|
|
97
|
+
or path.startswith("/api/health")
|
|
98
|
+
or path.startswith("/health")
|
|
99
|
+
):
|
|
100
|
+
return self.get_response(request)
|
|
101
|
+
|
|
102
|
+
start_time = time.perf_counter()
|
|
103
|
+
response = self.get_response(request)
|
|
104
|
+
duration_ms = int((time.perf_counter() - start_time) * 1000)
|
|
105
|
+
|
|
106
|
+
# Handle IP Address correctly behind proxies
|
|
107
|
+
x_forwarded_for = request.META.get("HTTP_X_FORWARDED_FOR")
|
|
108
|
+
ip = (
|
|
109
|
+
x_forwarded_for.split(",")[0]
|
|
110
|
+
if x_forwarded_for
|
|
111
|
+
else request.META.get("REMOTE_ADDR")
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
try:
|
|
115
|
+
_log_queue.put_nowait(
|
|
116
|
+
(request.method, path, response.status_code, duration_ms, ip)
|
|
117
|
+
)
|
|
118
|
+
except queue.Full:
|
|
119
|
+
logger.warning(
|
|
120
|
+
"Telemetry log queue full, dropping record under extreme load."
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
return response
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# Generated by DataMan Engine
|
|
2
|
+
from django.db import migrations, models
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class Migration(migrations.Migration):
|
|
6
|
+
initial = True
|
|
7
|
+
|
|
8
|
+
dependencies = []
|
|
9
|
+
|
|
10
|
+
operations = [
|
|
11
|
+
migrations.CreateModel(
|
|
12
|
+
name="APILog",
|
|
13
|
+
fields=[
|
|
14
|
+
(
|
|
15
|
+
"id",
|
|
16
|
+
models.BigAutoField(
|
|
17
|
+
auto_created=True,
|
|
18
|
+
primary_key=True,
|
|
19
|
+
serialize=False,
|
|
20
|
+
verbose_name="ID",
|
|
21
|
+
),
|
|
22
|
+
),
|
|
23
|
+
("timestamp", models.DateTimeField(auto_now_add=True, db_index=True)),
|
|
24
|
+
("method", models.CharField(max_length=10)),
|
|
25
|
+
("path", models.CharField(db_index=True, max_length=255)),
|
|
26
|
+
("status_code", models.IntegerField()),
|
|
27
|
+
("duration_ms", models.IntegerField()),
|
|
28
|
+
("ip_address", models.GenericIPAddressField(blank=True, null=True)),
|
|
29
|
+
],
|
|
30
|
+
options={
|
|
31
|
+
"db_table": "dataman_apilog",
|
|
32
|
+
"ordering": ["-timestamp"],
|
|
33
|
+
},
|
|
34
|
+
),
|
|
35
|
+
migrations.CreateModel(
|
|
36
|
+
name="APIToken",
|
|
37
|
+
fields=[
|
|
38
|
+
(
|
|
39
|
+
"id",
|
|
40
|
+
models.BigAutoField(
|
|
41
|
+
auto_created=True,
|
|
42
|
+
primary_key=True,
|
|
43
|
+
serialize=False,
|
|
44
|
+
verbose_name="ID",
|
|
45
|
+
),
|
|
46
|
+
),
|
|
47
|
+
("prefix", models.CharField(max_length=8, unique=True)),
|
|
48
|
+
("hashed_secret", models.CharField(max_length=128)),
|
|
49
|
+
("name", models.CharField(max_length=255)),
|
|
50
|
+
("scopes", models.JSONField(default=list)),
|
|
51
|
+
("created_at", models.DateTimeField(auto_now_add=True)),
|
|
52
|
+
(
|
|
53
|
+
"expires_at",
|
|
54
|
+
models.DateTimeField(blank=True, db_index=True, null=True),
|
|
55
|
+
),
|
|
56
|
+
("is_active", models.BooleanField(db_index=True, default=True)),
|
|
57
|
+
],
|
|
58
|
+
options={
|
|
59
|
+
"db_table": "dataman_apitoken",
|
|
60
|
+
},
|
|
61
|
+
),
|
|
62
|
+
migrations.CreateModel(
|
|
63
|
+
name="AuditLog",
|
|
64
|
+
fields=[
|
|
65
|
+
(
|
|
66
|
+
"id",
|
|
67
|
+
models.BigAutoField(
|
|
68
|
+
auto_created=True,
|
|
69
|
+
primary_key=True,
|
|
70
|
+
serialize=False,
|
|
71
|
+
verbose_name="ID",
|
|
72
|
+
),
|
|
73
|
+
),
|
|
74
|
+
("timestamp", models.DateTimeField(auto_now_add=True, db_index=True)),
|
|
75
|
+
("event_type", models.CharField(db_index=True, max_length=60)),
|
|
76
|
+
(
|
|
77
|
+
"actor",
|
|
78
|
+
models.CharField(
|
|
79
|
+
blank=True, db_index=True, default="", max_length=255
|
|
80
|
+
),
|
|
81
|
+
),
|
|
82
|
+
("ip_address", models.GenericIPAddressField(blank=True, null=True)),
|
|
83
|
+
("user_agent", models.TextField(blank=True, default="")),
|
|
84
|
+
("status_code", models.IntegerField(blank=True, null=True)),
|
|
85
|
+
(
|
|
86
|
+
"severity",
|
|
87
|
+
models.CharField(db_index=True, default="INFO", max_length=20),
|
|
88
|
+
),
|
|
89
|
+
("details", models.JSONField(default=dict)),
|
|
90
|
+
],
|
|
91
|
+
options={
|
|
92
|
+
"db_table": "dataman_auditlog",
|
|
93
|
+
"ordering": ["-timestamp"],
|
|
94
|
+
},
|
|
95
|
+
),
|
|
96
|
+
]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# DataMan Core Migrations Package
|
|
@@ -16,6 +16,8 @@ RESERVED_ROOT_DIRS = {
|
|
|
16
16
|
".ruff_cache",
|
|
17
17
|
".system_generated",
|
|
18
18
|
".venv",
|
|
19
|
+
"benchmark",
|
|
20
|
+
"benchmarks",
|
|
19
21
|
"build",
|
|
20
22
|
"coverage_hack",
|
|
21
23
|
"dist",
|
|
@@ -43,7 +45,7 @@ class APIToken(models.Model):
|
|
|
43
45
|
is_active = models.BooleanField(default=True, db_index=True)
|
|
44
46
|
|
|
45
47
|
class Meta:
|
|
46
|
-
app_label = "
|
|
48
|
+
app_label = "dataman_core"
|
|
47
49
|
db_table = "dataman_apitoken"
|
|
48
50
|
|
|
49
51
|
def __str__(self):
|
|
@@ -61,7 +63,7 @@ class APILog(models.Model):
|
|
|
61
63
|
ip_address = models.GenericIPAddressField(null=True, blank=True)
|
|
62
64
|
|
|
63
65
|
class Meta:
|
|
64
|
-
app_label = "
|
|
66
|
+
app_label = "dataman_core"
|
|
65
67
|
db_table = "dataman_apilog"
|
|
66
68
|
ordering = ["-timestamp"]
|
|
67
69
|
|
|
@@ -82,7 +84,7 @@ class AuditLog(models.Model):
|
|
|
82
84
|
details = models.JSONField(default=dict)
|
|
83
85
|
|
|
84
86
|
class Meta:
|
|
85
|
-
app_label = "
|
|
87
|
+
app_label = "dataman_core"
|
|
86
88
|
db_table = "dataman_auditlog"
|
|
87
89
|
ordering = ["-timestamp"]
|
|
88
90
|
|
|
@@ -17,6 +17,7 @@ class DataManDatabaseRouter:
|
|
|
17
17
|
"sessions",
|
|
18
18
|
"admin",
|
|
19
19
|
"messages",
|
|
20
|
+
"dataman_core",
|
|
20
21
|
}
|
|
21
22
|
|
|
22
23
|
def _get_target_db(self, model: Any) -> str:
|
|
@@ -81,6 +82,8 @@ class DataManDatabaseRouter:
|
|
|
81
82
|
Model-level table creation is routed strictly to the designated database.
|
|
82
83
|
"""
|
|
83
84
|
# Internal DataMan models always live on 'default' database
|
|
85
|
+
if app_label == "dataman_core":
|
|
86
|
+
return db == "default"
|
|
84
87
|
if model_name and model_name.lower() in ("apitoken", "apilog", "auditlog"):
|
|
85
88
|
return db == "default"
|
|
86
89
|
|
{dataman_engine-0.1.0 → dataman_engine-0.2.0}/src/dataman/core/templates/admin/dashboard.html
RENAMED
|
@@ -693,24 +693,32 @@
|
|
|
693
693
|
</div>
|
|
694
694
|
|
|
695
695
|
<div class="sidebar-footer">
|
|
696
|
-
<div class="user-profile">
|
|
697
|
-
<div class="user-avatar">A</div>
|
|
696
|
+
<div class="user-profile" onclick="openPasswordModal()" style="cursor: pointer;" title="Change Password">
|
|
697
|
+
<div class="user-avatar">{{ request.user.username|first|upper|default:"A" }}</div>
|
|
698
698
|
<div class="user-meta">
|
|
699
|
-
<div class="name">Administrator</div>
|
|
699
|
+
<div class="name">{{ request.user.username|default:"Administrator" }}</div>
|
|
700
700
|
<div class="role">Superuser</div>
|
|
701
701
|
</div>
|
|
702
702
|
</div>
|
|
703
|
-
<
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" />
|
|
709
|
-
<polyline points="16 17 21 12 16 7" />
|
|
710
|
-
<line x1="21" y1="12" x2="9" y2="12" />
|
|
703
|
+
<div style="display: flex; align-items: center; gap: 4px;">
|
|
704
|
+
<button type="button" class="logout-btn" onclick="openPasswordModal()" title="Change Password" style="color: #818cf8;">
|
|
705
|
+
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
706
|
+
<rect width="18" height="11" x="3" y="11" rx="2" ry="2" />
|
|
707
|
+
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
|
|
711
708
|
</svg>
|
|
712
709
|
</button>
|
|
713
|
-
|
|
710
|
+
<form method="post" action="/admin/logout/">
|
|
711
|
+
{% csrf_token %}
|
|
712
|
+
<button type="submit" class="logout-btn" title="Sign Out">
|
|
713
|
+
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
|
|
714
|
+
stroke-linecap="round" stroke-linejoin="round">
|
|
715
|
+
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" />
|
|
716
|
+
<polyline points="16 17 21 12 16 7" />
|
|
717
|
+
<line x1="21" y1="12" x2="9" y2="12" />
|
|
718
|
+
</svg>
|
|
719
|
+
</button>
|
|
720
|
+
</form>
|
|
721
|
+
</div>
|
|
714
722
|
</div>
|
|
715
723
|
</nav>
|
|
716
724
|
|
|
@@ -1029,6 +1037,33 @@
|
|
|
1029
1037
|
<div style="display: flex; justify-content: flex-end; margin-top: 1.5rem;">
|
|
1030
1038
|
<button class="btn btn-primary" onclick="closeTokenModal()">Done</button>
|
|
1031
1039
|
</div>
|
|
1040
|
+
</div>
|
|
1041
|
+
|
|
1042
|
+
<!-- Change Password Modal -->
|
|
1043
|
+
<div class="modal-overlay" id="password-modal" onclick="if(event.target===this) closePasswordModal()">
|
|
1044
|
+
<div class="modal-card">
|
|
1045
|
+
<div class="modal-header">
|
|
1046
|
+
<h2>Change Admin Password</h2>
|
|
1047
|
+
<button class="modal-close" onclick="closePasswordModal()">×</button>
|
|
1048
|
+
</div>
|
|
1049
|
+
<div id="password-form-content">
|
|
1050
|
+
<div id="password-alert" class="hidden" style="padding: 10px 14px; border-radius: 8px; font-size: 0.85rem; margin-bottom: 1.25rem;"></div>
|
|
1051
|
+
<div class="modal-form-group">
|
|
1052
|
+
<label for="old-password">Current Password</label>
|
|
1053
|
+
<input type="password" id="old-password" placeholder="••••••••" autocomplete="current-password">
|
|
1054
|
+
</div>
|
|
1055
|
+
<div class="modal-form-group">
|
|
1056
|
+
<label for="new-password">New Password (minimum 8 characters)</label>
|
|
1057
|
+
<input type="password" id="new-password" placeholder="••••••••" autocomplete="new-password">
|
|
1058
|
+
</div>
|
|
1059
|
+
<div class="modal-form-group">
|
|
1060
|
+
<label for="confirm-password">Confirm New Password</label>
|
|
1061
|
+
<input type="password" id="confirm-password" placeholder="••••••••" autocomplete="new-password">
|
|
1062
|
+
</div>
|
|
1063
|
+
<div style="display: flex; justify-content: flex-end; gap: 10px; margin-top: 1.5rem;">
|
|
1064
|
+
<button class="btn btn-outline" onclick="closePasswordModal()">Cancel</button>
|
|
1065
|
+
<button class="btn btn-primary" id="btn-submit-password" onclick="submitChangePassword()">Update Password</button>
|
|
1066
|
+
</div>
|
|
1032
1067
|
</div>
|
|
1033
1068
|
</div>
|
|
1034
1069
|
</div>
|
|
@@ -1391,6 +1426,117 @@
|
|
|
1391
1426
|
}
|
|
1392
1427
|
}
|
|
1393
1428
|
|
|
1429
|
+
// --- Password Change Modal ---
|
|
1430
|
+
function getCookie(name) {
|
|
1431
|
+
let cookieValue = null;
|
|
1432
|
+
if (document.cookie && document.cookie !== '') {
|
|
1433
|
+
const cookies = document.cookie.split(';');
|
|
1434
|
+
for (let i = 0; i < cookies.length; i++) {
|
|
1435
|
+
const cookie = cookies[i].trim();
|
|
1436
|
+
if (cookie.substring(0, name.length + 1) === (name + '=')) {
|
|
1437
|
+
cookieValue = decodeURIComponent(cookie.substring(name.length + 1));
|
|
1438
|
+
break;
|
|
1439
|
+
}
|
|
1440
|
+
}
|
|
1441
|
+
}
|
|
1442
|
+
return cookieValue;
|
|
1443
|
+
}
|
|
1444
|
+
|
|
1445
|
+
function openPasswordModal() {
|
|
1446
|
+
const modal = document.getElementById('password-modal');
|
|
1447
|
+
modal.classList.add('active');
|
|
1448
|
+
document.getElementById('old-password').value = '';
|
|
1449
|
+
document.getElementById('new-password').value = '';
|
|
1450
|
+
document.getElementById('confirm-password').value = '';
|
|
1451
|
+
const alertEl = document.getElementById('password-alert');
|
|
1452
|
+
alertEl.className = 'hidden';
|
|
1453
|
+
alertEl.textContent = '';
|
|
1454
|
+
setTimeout(() => document.getElementById('old-password').focus(), 150);
|
|
1455
|
+
}
|
|
1456
|
+
|
|
1457
|
+
function closePasswordModal() {
|
|
1458
|
+
document.getElementById('password-modal').classList.remove('active');
|
|
1459
|
+
}
|
|
1460
|
+
|
|
1461
|
+
async function submitChangePassword() {
|
|
1462
|
+
const oldPassword = document.getElementById('old-password').value;
|
|
1463
|
+
const newPassword = document.getElementById('new-password').value;
|
|
1464
|
+
const confirmPassword = document.getElementById('confirm-password').value;
|
|
1465
|
+
const alertEl = document.getElementById('password-alert');
|
|
1466
|
+
const submitBtn = document.getElementById('btn-submit-password');
|
|
1467
|
+
|
|
1468
|
+
if (!oldPassword || !newPassword || !confirmPassword) {
|
|
1469
|
+
alertEl.style.background = 'rgba(239, 68, 68, 0.15)';
|
|
1470
|
+
alertEl.style.border = '1px solid rgba(239, 68, 68, 0.4)';
|
|
1471
|
+
alertEl.style.color = '#f87171';
|
|
1472
|
+
alertEl.textContent = 'All password fields are required.';
|
|
1473
|
+
alertEl.classList.remove('hidden');
|
|
1474
|
+
return;
|
|
1475
|
+
}
|
|
1476
|
+
|
|
1477
|
+
if (newPassword !== confirmPassword) {
|
|
1478
|
+
alertEl.style.background = 'rgba(239, 68, 68, 0.15)';
|
|
1479
|
+
alertEl.style.border = '1px solid rgba(239, 68, 68, 0.4)';
|
|
1480
|
+
alertEl.style.color = '#f87171';
|
|
1481
|
+
alertEl.textContent = 'New passwords do not match.';
|
|
1482
|
+
alertEl.classList.remove('hidden');
|
|
1483
|
+
return;
|
|
1484
|
+
}
|
|
1485
|
+
|
|
1486
|
+
if (newPassword.length < 8) {
|
|
1487
|
+
alertEl.style.background = 'rgba(239, 68, 68, 0.15)';
|
|
1488
|
+
alertEl.style.border = '1px solid rgba(239, 68, 68, 0.4)';
|
|
1489
|
+
alertEl.style.color = '#f87171';
|
|
1490
|
+
alertEl.textContent = 'New password must be at least 8 characters long.';
|
|
1491
|
+
alertEl.classList.remove('hidden');
|
|
1492
|
+
return;
|
|
1493
|
+
}
|
|
1494
|
+
|
|
1495
|
+
submitBtn.disabled = true;
|
|
1496
|
+
submitBtn.textContent = 'Updating...';
|
|
1497
|
+
|
|
1498
|
+
try {
|
|
1499
|
+
const csrfToken = getCookie('csrftoken') || document.querySelector('[name=csrfmiddlewaretoken]')?.value || '';
|
|
1500
|
+
const res = await fetch('/admin/api/change-password/', {
|
|
1501
|
+
method: 'POST',
|
|
1502
|
+
headers: {
|
|
1503
|
+
'Content-Type': 'application/json',
|
|
1504
|
+
'X-CSRFToken': csrfToken,
|
|
1505
|
+
},
|
|
1506
|
+
body: JSON.stringify({
|
|
1507
|
+
old_password: oldPassword,
|
|
1508
|
+
new_password: newPassword,
|
|
1509
|
+
confirm_password: confirmPassword,
|
|
1510
|
+
}),
|
|
1511
|
+
});
|
|
1512
|
+
|
|
1513
|
+
const data = await res.json();
|
|
1514
|
+
if (res.ok) {
|
|
1515
|
+
alertEl.style.background = 'rgba(52, 211, 153, 0.15)';
|
|
1516
|
+
alertEl.style.border = '1px solid rgba(52, 211, 153, 0.4)';
|
|
1517
|
+
alertEl.style.color = '#34d399';
|
|
1518
|
+
alertEl.textContent = data.message || 'Password changed successfully!';
|
|
1519
|
+
alertEl.classList.remove('hidden');
|
|
1520
|
+
setTimeout(() => closePasswordModal(), 1200);
|
|
1521
|
+
} else {
|
|
1522
|
+
alertEl.style.background = 'rgba(239, 68, 68, 0.15)';
|
|
1523
|
+
alertEl.style.border = '1px solid rgba(239, 68, 68, 0.4)';
|
|
1524
|
+
alertEl.style.color = '#f87171';
|
|
1525
|
+
alertEl.textContent = data.error || 'Failed to update password.';
|
|
1526
|
+
alertEl.classList.remove('hidden');
|
|
1527
|
+
}
|
|
1528
|
+
} catch (err) {
|
|
1529
|
+
alertEl.style.background = 'rgba(239, 68, 68, 0.15)';
|
|
1530
|
+
alertEl.style.border = '1px solid rgba(239, 68, 68, 0.4)';
|
|
1531
|
+
alertEl.style.color = '#f87171';
|
|
1532
|
+
alertEl.textContent = 'Network or server error updating password.';
|
|
1533
|
+
alertEl.classList.remove('hidden');
|
|
1534
|
+
} finally {
|
|
1535
|
+
submitBtn.disabled = false;
|
|
1536
|
+
submitBtn.textContent = 'Update Password';
|
|
1537
|
+
}
|
|
1538
|
+
}
|
|
1539
|
+
|
|
1394
1540
|
// Initial Load
|
|
1395
1541
|
loadAnalytics();
|
|
1396
1542
|
</script>
|
|
@@ -26,6 +26,7 @@ from .models import TABLE_REGISTRY
|
|
|
26
26
|
from .views import (
|
|
27
27
|
APITokenViewSet,
|
|
28
28
|
AuditLogViewSet,
|
|
29
|
+
admin_change_password_view,
|
|
29
30
|
analytics_export,
|
|
30
31
|
analytics_logs,
|
|
31
32
|
analytics_summary,
|
|
@@ -66,8 +67,25 @@ def dispatch_webhook(url, action, table_name, data):
|
|
|
66
67
|
|
|
67
68
|
|
|
68
69
|
try:
|
|
69
|
-
|
|
70
|
-
for
|
|
70
|
+
models_to_process = []
|
|
71
|
+
for entry in TABLE_REGISTRY.values():
|
|
72
|
+
m = entry.get("model")
|
|
73
|
+
if m and m not in models_to_process:
|
|
74
|
+
models_to_process.append(m)
|
|
75
|
+
|
|
76
|
+
for app_name in ("tables", "dataman", "dataman_core"):
|
|
77
|
+
try:
|
|
78
|
+
app_cfg = apps.get_app_config(app_name)
|
|
79
|
+
for model in app_cfg.get_models():
|
|
80
|
+
if (
|
|
81
|
+
model.__name__ not in ("APIToken", "APILog", "AuditLog")
|
|
82
|
+
and model not in models_to_process
|
|
83
|
+
):
|
|
84
|
+
models_to_process.append(model)
|
|
85
|
+
except LookupError:
|
|
86
|
+
pass
|
|
87
|
+
|
|
88
|
+
for model in models_to_process:
|
|
71
89
|
model_name = model.__name__
|
|
72
90
|
|
|
73
91
|
if model_name in ("APIToken", "APILog", "AuditLog"):
|
|
@@ -342,6 +360,27 @@ try:
|
|
|
342
360
|
"perform_destroy": custom_perform_destroy,
|
|
343
361
|
}
|
|
344
362
|
|
|
363
|
+
def custom_get_queryset(self, m=model, d=depth):
|
|
364
|
+
qs = m.objects.all()
|
|
365
|
+
if d is not None and d > 0:
|
|
366
|
+
fk_fields = [
|
|
367
|
+
f.name
|
|
368
|
+
for f in m._meta.fields
|
|
369
|
+
if f.is_relation and (f.many_to_one or f.one_to_one)
|
|
370
|
+
]
|
|
371
|
+
if fk_fields:
|
|
372
|
+
qs = qs.select_related(*fk_fields)
|
|
373
|
+
reverse_rel_fields = [
|
|
374
|
+
f.get_accessor_name()
|
|
375
|
+
for f in m._meta.related_objects
|
|
376
|
+
if f.get_accessor_name()
|
|
377
|
+
]
|
|
378
|
+
if reverse_rel_fields:
|
|
379
|
+
qs = qs.prefetch_related(*reverse_rel_fields)
|
|
380
|
+
return qs
|
|
381
|
+
|
|
382
|
+
viewset_attrs["get_queryset"] = custom_get_queryset
|
|
383
|
+
|
|
345
384
|
if depth is not None and depth > 0:
|
|
346
385
|
|
|
347
386
|
def custom_get_serializer_class(
|
|
@@ -368,10 +407,94 @@ try:
|
|
|
368
407
|
# Pagination
|
|
369
408
|
if page_size:
|
|
370
409
|
|
|
410
|
+
class FastPaginator:
|
|
411
|
+
def __init__(self, object_list, per_page):
|
|
412
|
+
self.object_list = object_list
|
|
413
|
+
self.per_page = per_page
|
|
414
|
+
self._count = None
|
|
415
|
+
|
|
416
|
+
@property
|
|
417
|
+
def count(self):
|
|
418
|
+
if self._count is None:
|
|
419
|
+
try:
|
|
420
|
+
self._count = self.object_list.count()
|
|
421
|
+
except Exception:
|
|
422
|
+
self._count = 0
|
|
423
|
+
return self._count
|
|
424
|
+
|
|
425
|
+
@property
|
|
426
|
+
def num_pages(self):
|
|
427
|
+
if self.count == 0:
|
|
428
|
+
return 1
|
|
429
|
+
return (self.count + self.per_page - 1) // self.per_page
|
|
430
|
+
|
|
431
|
+
@property
|
|
432
|
+
def page_range(self):
|
|
433
|
+
return range(1, self.num_pages + 1)
|
|
434
|
+
|
|
435
|
+
def page(self, number):
|
|
436
|
+
number = int(number)
|
|
437
|
+
bottom = (number - 1) * self.per_page
|
|
438
|
+
top = bottom + self.per_page
|
|
439
|
+
items = list(self.object_list[bottom : top + 1])
|
|
440
|
+
has_next = len(items) > self.per_page
|
|
441
|
+
if has_next:
|
|
442
|
+
items = items[: self.per_page]
|
|
443
|
+
|
|
444
|
+
class FastPage:
|
|
445
|
+
def __init__(
|
|
446
|
+
self, object_list, number, paginator, has_next, bottom
|
|
447
|
+
):
|
|
448
|
+
self.object_list = object_list
|
|
449
|
+
self.number = number
|
|
450
|
+
self.paginator = paginator
|
|
451
|
+
self._has_next = has_next
|
|
452
|
+
self._bottom = bottom
|
|
453
|
+
|
|
454
|
+
def __len__(self):
|
|
455
|
+
return len(self.object_list)
|
|
456
|
+
|
|
457
|
+
def __iter__(self):
|
|
458
|
+
return iter(self.object_list)
|
|
459
|
+
|
|
460
|
+
def __getitem__(self, index):
|
|
461
|
+
return self.object_list[index]
|
|
462
|
+
|
|
463
|
+
def has_next(self):
|
|
464
|
+
return self._has_next
|
|
465
|
+
|
|
466
|
+
def has_previous(self):
|
|
467
|
+
return self.number > 1
|
|
468
|
+
|
|
469
|
+
def next_page_number(self):
|
|
470
|
+
return self.number + 1
|
|
471
|
+
|
|
472
|
+
def previous_page_number(self):
|
|
473
|
+
return self.number - 1
|
|
474
|
+
|
|
475
|
+
def start_index(self):
|
|
476
|
+
return self._bottom + 1
|
|
477
|
+
|
|
478
|
+
def end_index(self):
|
|
479
|
+
return self._bottom + len(self.object_list)
|
|
480
|
+
|
|
481
|
+
return FastPage(items, number, self, has_next, bottom)
|
|
482
|
+
|
|
371
483
|
class CustomPagination(PageNumberPagination):
|
|
372
484
|
page_size_val = page_size
|
|
485
|
+
django_paginator_class = FastPaginator
|
|
486
|
+
page_size_query_param = "page_size"
|
|
487
|
+
max_page_size = 500
|
|
373
488
|
|
|
374
489
|
def get_page_size(self, request):
|
|
490
|
+
if self.page_size_query_param:
|
|
491
|
+
with contextlib.suppress(Exception):
|
|
492
|
+
val = int(
|
|
493
|
+
request.query_params.get(
|
|
494
|
+
self.page_size_query_param, self.page_size_val
|
|
495
|
+
)
|
|
496
|
+
)
|
|
497
|
+
return min(max(val, 1), self.max_page_size)
|
|
375
498
|
return self.page_size_val
|
|
376
499
|
|
|
377
500
|
viewset_attrs["pagination_class"] = CustomPagination
|
|
@@ -388,6 +511,8 @@ try:
|
|
|
388
511
|
filter_backends.append(OrderingFilter)
|
|
389
512
|
viewset_attrs["ordering_fields"] = ordering_fields
|
|
390
513
|
|
|
514
|
+
viewset_attrs["ordering"] = ["-id"]
|
|
515
|
+
|
|
391
516
|
if filter_backends:
|
|
392
517
|
viewset_attrs["filter_backends"] = filter_backends
|
|
393
518
|
|
|
@@ -442,6 +567,11 @@ urlpatterns = [
|
|
|
442
567
|
name="admin-logout",
|
|
443
568
|
),
|
|
444
569
|
path("admin/", dashboard_view, name="admin-dashboard"),
|
|
570
|
+
path(
|
|
571
|
+
"admin/api/change-password/",
|
|
572
|
+
admin_change_password_view,
|
|
573
|
+
name="admin-change-password",
|
|
574
|
+
),
|
|
445
575
|
path("dashboard/", dashboard_view, name="dashboard"),
|
|
446
576
|
path("", include(router.urls)),
|
|
447
577
|
path("health/", health_check, name="health"),
|
|
@@ -676,3 +676,62 @@ def health_ready(request):
|
|
|
676
676
|
def health_check(request):
|
|
677
677
|
"""General health check endpoint combining liveness and readiness."""
|
|
678
678
|
return health_ready(request._request)
|
|
679
|
+
|
|
680
|
+
|
|
681
|
+
@api_view(["POST"])
|
|
682
|
+
@permission_classes([permissions.IsAuthenticated])
|
|
683
|
+
def admin_change_password_view(request):
|
|
684
|
+
"""Allows logged-in admin users to update their password securely from the dashboard."""
|
|
685
|
+
user = request.user
|
|
686
|
+
if not user or not user.is_authenticated:
|
|
687
|
+
return Response(
|
|
688
|
+
{"error": "Authentication required"},
|
|
689
|
+
status=status.HTTP_401_UNAUTHORIZED,
|
|
690
|
+
)
|
|
691
|
+
|
|
692
|
+
old_password = request.data.get("old_password", "")
|
|
693
|
+
new_password = request.data.get("new_password", "")
|
|
694
|
+
confirm_password = request.data.get("confirm_password", "")
|
|
695
|
+
|
|
696
|
+
if not old_password or not new_password:
|
|
697
|
+
return Response(
|
|
698
|
+
{"error": "Current password and new password are required."},
|
|
699
|
+
status=status.HTTP_400_BAD_REQUEST,
|
|
700
|
+
)
|
|
701
|
+
|
|
702
|
+
if new_password != confirm_password:
|
|
703
|
+
return Response(
|
|
704
|
+
{"error": "New password and confirmation password do not match."},
|
|
705
|
+
status=status.HTTP_400_BAD_REQUEST,
|
|
706
|
+
)
|
|
707
|
+
|
|
708
|
+
if len(new_password) < 8:
|
|
709
|
+
return Response(
|
|
710
|
+
{"error": "New password must be at least 8 characters long."},
|
|
711
|
+
status=status.HTTP_400_BAD_REQUEST,
|
|
712
|
+
)
|
|
713
|
+
|
|
714
|
+
if not user.check_password(old_password):
|
|
715
|
+
return Response(
|
|
716
|
+
{"error": "Current password is incorrect."},
|
|
717
|
+
status=status.HTTP_400_BAD_REQUEST,
|
|
718
|
+
)
|
|
719
|
+
|
|
720
|
+
user.set_password(new_password)
|
|
721
|
+
user.save()
|
|
722
|
+
|
|
723
|
+
from django.contrib.auth import update_session_auth_hash
|
|
724
|
+
|
|
725
|
+
update_session_auth_hash(request._request, user)
|
|
726
|
+
|
|
727
|
+
log_audit_event(
|
|
728
|
+
event_type="AUTH_PASSWORD_CHANGE",
|
|
729
|
+
actor=user.username,
|
|
730
|
+
ip_address=request.META.get("REMOTE_ADDR"),
|
|
731
|
+
user_agent=request.META.get("HTTP_USER_AGENT", ""),
|
|
732
|
+
status_code=200,
|
|
733
|
+
severity="INFO",
|
|
734
|
+
details={"user_id": user.id, "username": user.username},
|
|
735
|
+
)
|
|
736
|
+
|
|
737
|
+
return Response({"status": "success", "message": "Password changed successfully."})
|
|
@@ -32,16 +32,24 @@ def setup():
|
|
|
32
32
|
)
|
|
33
33
|
|
|
34
34
|
# Ensure migrations directory exists only in initialized projects
|
|
35
|
-
|
|
35
|
+
migration_modules_dict = {
|
|
36
|
+
"dataman_core": "dataman.core.migrations",
|
|
37
|
+
"dataman": "dataman.core.migrations",
|
|
38
|
+
}
|
|
36
39
|
migrations_dir = cwd / "migrations"
|
|
37
40
|
if tables_dir.exists() and (tables_dir / "migrations").exists():
|
|
38
|
-
|
|
41
|
+
migration_modules_dict["tables"] = "tables.migrations"
|
|
39
42
|
elif migrations_dir.exists():
|
|
40
|
-
|
|
43
|
+
migration_modules_dict["tables"] = "migrations"
|
|
44
|
+
elif is_project and tables_dir.exists():
|
|
45
|
+
mig_dir = tables_dir / "migrations"
|
|
46
|
+
mig_dir.mkdir(parents=True, exist_ok=True)
|
|
47
|
+
(mig_dir / "__init__.py").touch()
|
|
48
|
+
migration_modules_dict["tables"] = "tables.migrations"
|
|
41
49
|
elif is_project:
|
|
42
50
|
migrations_dir.mkdir(parents=True, exist_ok=True)
|
|
43
51
|
(migrations_dir / "__init__.py").touch()
|
|
44
|
-
|
|
52
|
+
migration_modules_dict["tables"] = "migrations"
|
|
45
53
|
|
|
46
54
|
import dj_database_url
|
|
47
55
|
|
|
@@ -108,7 +116,10 @@ def setup():
|
|
|
108
116
|
"django.contrib.staticfiles",
|
|
109
117
|
"rest_framework",
|
|
110
118
|
"drf_spectacular",
|
|
111
|
-
]
|
|
119
|
+
]
|
|
120
|
+
if tables_dir.exists():
|
|
121
|
+
installed_apps.append("tables")
|
|
122
|
+
installed_apps.extend(extra_apps)
|
|
112
123
|
|
|
113
124
|
middleware = [
|
|
114
125
|
"django.middleware.security.SecurityMiddleware",
|
|
@@ -138,10 +149,6 @@ def setup():
|
|
|
138
149
|
if project_config and hasattr(project_config, "DATABASE_ROUTERS"):
|
|
139
150
|
database_routers = list(project_config.DATABASE_ROUTERS)
|
|
140
151
|
|
|
141
|
-
migration_modules_dict = {}
|
|
142
|
-
if migration_module:
|
|
143
|
-
migration_modules_dict["dataman"] = migration_module
|
|
144
|
-
|
|
145
152
|
settings.configure(
|
|
146
153
|
SECRET_KEY=os.getenv("DATAMAN_SECRET_KEY", "default-insecure-key-change-me"),
|
|
147
154
|
DEBUG=debug_val,
|
|
@@ -1,46 +0,0 @@
|
|
|
1
|
-
import time
|
|
2
|
-
|
|
3
|
-
from .models import APILog
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
class APILoggingMiddleware:
|
|
7
|
-
"""Middleware to log API request metrics for the dashboard."""
|
|
8
|
-
|
|
9
|
-
def __init__(self, get_response):
|
|
10
|
-
self.get_response = get_response
|
|
11
|
-
|
|
12
|
-
def __call__(self, request):
|
|
13
|
-
path = request.path
|
|
14
|
-
# Only log requests to actual data endpoints,
|
|
15
|
-
# exclude internal/dashboard/docs/health routes.
|
|
16
|
-
if (
|
|
17
|
-
not path.startswith("/api/")
|
|
18
|
-
or path.startswith("/api/schema")
|
|
19
|
-
or path.startswith("/api/docs")
|
|
20
|
-
or path.startswith("/api/redoc")
|
|
21
|
-
or path.startswith("/api/_internal/")
|
|
22
|
-
or path.startswith("/api/health")
|
|
23
|
-
or path.startswith("/health")
|
|
24
|
-
):
|
|
25
|
-
return self.get_response(request)
|
|
26
|
-
|
|
27
|
-
start_time = time.time()
|
|
28
|
-
response = self.get_response(request)
|
|
29
|
-
duration_ms = int((time.time() - start_time) * 1000)
|
|
30
|
-
|
|
31
|
-
# Handle IP Address correctly behind proxies
|
|
32
|
-
x_forwarded_for = request.META.get("HTTP_X_FORWARDED_FOR")
|
|
33
|
-
if x_forwarded_for:
|
|
34
|
-
ip = x_forwarded_for.split(",")[0]
|
|
35
|
-
else:
|
|
36
|
-
ip = request.META.get("REMOTE_ADDR")
|
|
37
|
-
|
|
38
|
-
APILog.objects.create(
|
|
39
|
-
method=request.method,
|
|
40
|
-
path=path,
|
|
41
|
-
status_code=response.status_code,
|
|
42
|
-
duration_ms=duration_ms,
|
|
43
|
-
ip_address=ip,
|
|
44
|
-
)
|
|
45
|
-
|
|
46
|
-
return response
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|