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.
Files changed (29) hide show
  1. {dataman_engine-0.1.0 → dataman_engine-0.2.0}/PKG-INFO +12 -5
  2. {dataman_engine-0.1.0 → dataman_engine-0.2.0}/README.md +5 -0
  3. {dataman_engine-0.1.0 → dataman_engine-0.2.0}/pyproject.toml +27 -6
  4. {dataman_engine-0.1.0 → dataman_engine-0.2.0}/pyproject.toml.orig +27 -8
  5. {dataman_engine-0.1.0 → dataman_engine-0.2.0}/src/dataman/__init__.py +1 -1
  6. dataman_engine-0.2.0/src/dataman/asgi.py +3 -0
  7. {dataman_engine-0.1.0 → dataman_engine-0.2.0}/src/dataman/cli.py +22 -3
  8. {dataman_engine-0.1.0 → dataman_engine-0.2.0}/src/dataman/core/apps.py +1 -1
  9. dataman_engine-0.2.0/src/dataman/core/middleware.py +123 -0
  10. dataman_engine-0.2.0/src/dataman/core/migrations/0001_initial.py +96 -0
  11. dataman_engine-0.2.0/src/dataman/core/migrations/__init__.py +1 -0
  12. {dataman_engine-0.1.0 → dataman_engine-0.2.0}/src/dataman/core/models.py +5 -3
  13. {dataman_engine-0.1.0 → dataman_engine-0.2.0}/src/dataman/core/router.py +3 -0
  14. {dataman_engine-0.1.0 → dataman_engine-0.2.0}/src/dataman/core/templates/admin/dashboard.html +158 -12
  15. {dataman_engine-0.1.0 → dataman_engine-0.2.0}/src/dataman/core/urls.py +132 -2
  16. {dataman_engine-0.1.0 → dataman_engine-0.2.0}/src/dataman/core/views.py +59 -0
  17. {dataman_engine-0.1.0 → dataman_engine-0.2.0}/src/dataman/django_setup.py +16 -9
  18. dataman_engine-0.2.0/src/dataman/wsgi.py +3 -0
  19. dataman_engine-0.1.0/src/dataman/core/middleware.py +0 -46
  20. {dataman_engine-0.1.0 → dataman_engine-0.2.0}/src/dataman/core/__init__.py +0 -0
  21. {dataman_engine-0.1.0 → dataman_engine-0.2.0}/src/dataman/core/audit.py +0 -0
  22. {dataman_engine-0.1.0 → dataman_engine-0.2.0}/src/dataman/core/auth.py +0 -0
  23. {dataman_engine-0.1.0 → dataman_engine-0.2.0}/src/dataman/core/crypto.py +0 -0
  24. {dataman_engine-0.1.0 → dataman_engine-0.2.0}/src/dataman/core/fields.py +0 -0
  25. {dataman_engine-0.1.0 → dataman_engine-0.2.0}/src/dataman/core/masking.py +0 -0
  26. {dataman_engine-0.1.0 → dataman_engine-0.2.0}/src/dataman/core/permissions.py +0 -0
  27. {dataman_engine-0.1.0 → dataman_engine-0.2.0}/src/dataman/core/signals.py +0 -0
  28. {dataman_engine-0.1.0 → dataman_engine-0.2.0}/src/dataman/core/templates/admin/login.html +0 -0
  29. {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.1.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 :: 5.0
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-Python: >=3.13
35
- Project-URL: Homepage, https://github.com/vikashg/dataman
36
- Project-URL: Repository, https://github.com/vikashg/dataman.git
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
+ [![Quickstart Guide](https://img.shields.io/badge/Docs-Quickstart_Guide-blue.svg)](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
+ [![Quickstart Guide](https://img.shields.io/badge/Docs-Quickstart_Guide-blue.svg)](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.1.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.13"
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 :: 5.0",
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/vikashg/dataman"
56
- Repository = "https://github.com/vikashg/dataman.git"
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 = "py310"
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.1.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.13"
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 :: 5.0",
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/vikashg/dataman"
49
- Repository = "https://github.com/vikashg/dataman.git"
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 = "py310"
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
+ """
@@ -9,7 +9,7 @@ from dataman.core.fields import (
9
9
  EncryptedTextField,
10
10
  )
11
11
 
12
- __version__ = "0.1.0"
12
+ __version__ = "0.2.0"
13
13
  __all__ = [
14
14
  "EncryptedCharField",
15
15
  "EncryptedTextField",
@@ -0,0 +1,3 @@
1
+ from dataman.django_setup import get_asgi_application
2
+
3
+ application = get_asgi_application()
@@ -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 = 'dataman'\n")
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
- def makemigration():
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
- call_command("makemigrations", "dataman")
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(
@@ -3,7 +3,7 @@ from django.apps import AppConfig
3
3
 
4
4
  class DatamanCoreConfig(AppConfig):
5
5
  name = "dataman.core"
6
- label = "dataman"
6
+ label = "dataman_core"
7
7
 
8
8
  def ready(self):
9
9
  import dataman.core.signals # noqa: F401
@@ -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 = "dataman"
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 = "dataman"
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 = "dataman"
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
 
@@ -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
- <form method="post" action="/admin/logout/">
704
- {% csrf_token %}
705
- <button type="submit" class="logout-btn" title="Sign Out">
706
- <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
707
- stroke-linecap="round" stroke-linejoin="round">
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
- </form>
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()">&times;</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
- dataman_app = apps.get_app_config("dataman")
70
- for model in dataman_app.get_models():
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
- migration_module = None
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
- migration_module = "tables.migrations"
41
+ migration_modules_dict["tables"] = "tables.migrations"
39
42
  elif migrations_dir.exists():
40
- migration_module = "migrations"
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
- migration_module = "migrations"
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
- ] + list(extra_apps)
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,
@@ -0,0 +1,3 @@
1
+ from dataman.django_setup import get_wsgi_application
2
+
3
+ application = get_wsgi_application()
@@ -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