dataman-engine 0.1.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.
- dataman/__init__.py +18 -0
- dataman/cli.py +731 -0
- dataman/core/__init__.py +0 -0
- dataman/core/apps.py +9 -0
- dataman/core/audit.py +171 -0
- dataman/core/auth.py +106 -0
- dataman/core/crypto.py +173 -0
- dataman/core/fields.py +120 -0
- dataman/core/masking.py +107 -0
- dataman/core/middleware.py +46 -0
- dataman/core/models.py +175 -0
- dataman/core/permissions.py +110 -0
- dataman/core/router.py +112 -0
- dataman/core/signals.py +56 -0
- dataman/core/templates/admin/dashboard.html +1399 -0
- dataman/core/templates/admin/login.html +377 -0
- dataman/core/urls.py +472 -0
- dataman/core/views.py +678 -0
- dataman/django_setup.py +192 -0
- dataman/py.typed +0 -0
- dataman_engine-0.1.0.dist-info/METADATA +259 -0
- dataman_engine-0.1.0.dist-info/RECORD +24 -0
- dataman_engine-0.1.0.dist-info/WHEEL +4 -0
- dataman_engine-0.1.0.dist-info/entry_points.txt +3 -0
dataman/cli.py
ADDED
|
@@ -0,0 +1,731 @@
|
|
|
1
|
+
import secrets
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
import click
|
|
5
|
+
import inflection
|
|
6
|
+
from django.core.management import call_command
|
|
7
|
+
|
|
8
|
+
from dataman import django_setup
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _ensure_initialized():
|
|
12
|
+
"""Helper to ensure the current directory is a DataMan project."""
|
|
13
|
+
cwd = Path.cwd()
|
|
14
|
+
if not (cwd / ".env").exists() or not (cwd / "database.py").exists():
|
|
15
|
+
click.echo(
|
|
16
|
+
click.style(
|
|
17
|
+
"Error: Not a DataMan project. Run 'dataman init' first.", fg="red"
|
|
18
|
+
)
|
|
19
|
+
)
|
|
20
|
+
raise click.Abort()
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@click.group()
|
|
24
|
+
def cli():
|
|
25
|
+
"""Data MiddleMan (DataMan) CLI"""
|
|
26
|
+
pass
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@cli.command()
|
|
30
|
+
def init():
|
|
31
|
+
"""Initialize a new DataMan project in the current directory."""
|
|
32
|
+
cwd = Path.cwd()
|
|
33
|
+
env_path = cwd / ".env"
|
|
34
|
+
database_file = cwd / "database.py"
|
|
35
|
+
config_file = cwd / "config.py"
|
|
36
|
+
tables_dir = cwd / "tables"
|
|
37
|
+
migrations_dir = tables_dir / "migrations"
|
|
38
|
+
|
|
39
|
+
if env_path.exists() and (database_file.exists() or tables_dir.exists()):
|
|
40
|
+
click.echo(
|
|
41
|
+
click.style("Project already initialized in this directory.", fg="yellow")
|
|
42
|
+
)
|
|
43
|
+
return
|
|
44
|
+
|
|
45
|
+
# Create .env
|
|
46
|
+
secret_key = secrets.token_urlsafe(50)
|
|
47
|
+
with open(env_path, "w") as f:
|
|
48
|
+
f.write(f"DATAMAN_SECRET_KEY='{secret_key}'\n")
|
|
49
|
+
f.write("DEBUG=True\n")
|
|
50
|
+
f.write("DATABASE_URL=sqlite:///db.sqlite3\n")
|
|
51
|
+
f.write("ALLOWED_HOSTS=127.0.0.1,localhost\n")
|
|
52
|
+
click.echo(click.style("Created .env file.", fg="green"))
|
|
53
|
+
|
|
54
|
+
# Create database.py
|
|
55
|
+
with open(database_file, "w") as f:
|
|
56
|
+
f.write(
|
|
57
|
+
'"""\n'
|
|
58
|
+
"DataMan Database Configuration\n"
|
|
59
|
+
"Configure your primary and replica database connections here.\n"
|
|
60
|
+
'"""\n'
|
|
61
|
+
"import os\n"
|
|
62
|
+
"from pathlib import Path\n"
|
|
63
|
+
"import dj_database_url\n\n"
|
|
64
|
+
"BASE_DIR = Path(__file__).resolve().parent\n\n"
|
|
65
|
+
"# Default SQLite database configuration\n"
|
|
66
|
+
"DATABASES = {\n"
|
|
67
|
+
' "default": dj_database_url.config(\n'
|
|
68
|
+
' default=os.getenv("DATABASE_URL", f"sqlite:///{BASE_DIR}/db.sqlite3"),\n'
|
|
69
|
+
" conn_max_age=600,\n"
|
|
70
|
+
" conn_health_checks=True,\n"
|
|
71
|
+
" )\n"
|
|
72
|
+
"}\n\n"
|
|
73
|
+
"# --- Examples for Other Database Engines ---\n"
|
|
74
|
+
"# PostgreSQL (Production recommended):\n"
|
|
75
|
+
'# DATABASES["analytics"] = dj_database_url.parse(\n'
|
|
76
|
+
'# os.getenv("ANALYTICS_DATABASE_URL", "postgres://user:password@localhost:5432/analytics_db"),\n'
|
|
77
|
+
"# conn_max_age=600,\n"
|
|
78
|
+
"# conn_health_checks=True,\n"
|
|
79
|
+
"# )\n"
|
|
80
|
+
"#\n"
|
|
81
|
+
"# MySQL / MariaDB:\n"
|
|
82
|
+
'# DATABASES["legacy"] = dj_database_url.parse(\n'
|
|
83
|
+
'# os.getenv("LEGACY_DATABASE_URL", "mysql://user:password@localhost:3306/legacy_db"),\n'
|
|
84
|
+
"# conn_max_age=600,\n"
|
|
85
|
+
"# )\n"
|
|
86
|
+
)
|
|
87
|
+
click.echo(click.style("Created database.py.", fg="green"))
|
|
88
|
+
|
|
89
|
+
# Create config.py
|
|
90
|
+
with open(config_file, "w") as f:
|
|
91
|
+
f.write(
|
|
92
|
+
'"""\n'
|
|
93
|
+
"DataMan Project Configuration\n"
|
|
94
|
+
"Customize global settings, security policies, CORS, pagination, and middleware.\n"
|
|
95
|
+
'"""\n'
|
|
96
|
+
"import os\n\n"
|
|
97
|
+
"# Security & Environment\n"
|
|
98
|
+
'DEBUG = os.getenv("DEBUG", "True").lower() in ("true", "1", "yes")\n'
|
|
99
|
+
'ALLOWED_HOSTS = [h.strip() for h in os.getenv("ALLOWED_HOSTS", "*").split(",") if h.strip()]\n\n'
|
|
100
|
+
"# Global Pagination & Throttling\n"
|
|
101
|
+
"PAGE_SIZE = 100\n"
|
|
102
|
+
"MAX_PAGE_SIZE = 500\n\n"
|
|
103
|
+
"# Audit & Telemetry\n"
|
|
104
|
+
"ENABLE_AUDIT_LOGGING = True\n"
|
|
105
|
+
"ENABLE_TELEMETRY = True\n\n"
|
|
106
|
+
"# Custom Installed Apps (e.g. third-party Django apps)\n"
|
|
107
|
+
"EXTRA_INSTALLED_APPS = []\n\n"
|
|
108
|
+
"# Custom Middleware (appended to request/response pipeline)\n"
|
|
109
|
+
"EXTRA_MIDDLEWARE = []\n"
|
|
110
|
+
)
|
|
111
|
+
click.echo(click.style("Created config.py.", fg="green"))
|
|
112
|
+
|
|
113
|
+
# Create tables/ directory and migrations
|
|
114
|
+
tables_dir.mkdir(exist_ok=True)
|
|
115
|
+
(tables_dir / "__init__.py").touch()
|
|
116
|
+
migrations_dir.mkdir(exist_ok=True)
|
|
117
|
+
(migrations_dir / "__init__.py").touch()
|
|
118
|
+
click.echo(click.style("Created tables/ directory.", fg="green"))
|
|
119
|
+
|
|
120
|
+
click.echo(
|
|
121
|
+
click.style("DataMan project initialized successfully!", fg="green", bold=True)
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
@cli.group()
|
|
126
|
+
def create():
|
|
127
|
+
"""Create resources like databases and tables."""
|
|
128
|
+
pass
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
@create.command(name="database")
|
|
132
|
+
@click.argument("name")
|
|
133
|
+
def create_database(name):
|
|
134
|
+
"""Scaffold a new database directory and configuration."""
|
|
135
|
+
_ensure_initialized()
|
|
136
|
+
|
|
137
|
+
db_name = inflection.underscore(name)
|
|
138
|
+
if (
|
|
139
|
+
"/" in db_name
|
|
140
|
+
or "\\" in db_name
|
|
141
|
+
or "." in db_name
|
|
142
|
+
or not db_name.isidentifier()
|
|
143
|
+
):
|
|
144
|
+
click.echo(click.style(f"Invalid database name: '{db_name}'.", fg="red"))
|
|
145
|
+
raise click.Abort()
|
|
146
|
+
|
|
147
|
+
db_dir = Path.cwd() / db_name
|
|
148
|
+
if db_dir.exists():
|
|
149
|
+
click.echo(
|
|
150
|
+
click.style(f"Database directory '{db_name}' already exists.", fg="yellow")
|
|
151
|
+
)
|
|
152
|
+
raise click.Abort()
|
|
153
|
+
|
|
154
|
+
db_dir.mkdir(parents=True)
|
|
155
|
+
(db_dir / "__init__.py").touch()
|
|
156
|
+
|
|
157
|
+
# Append database configuration to database.py if not already present
|
|
158
|
+
database_file = Path.cwd() / "database.py"
|
|
159
|
+
if database_file.exists():
|
|
160
|
+
import re
|
|
161
|
+
|
|
162
|
+
content = database_file.read_text(encoding="utf-8")
|
|
163
|
+
has_active_entry = bool(
|
|
164
|
+
re.search(
|
|
165
|
+
r'^\s*DATABASES\s*\[\s*["\']' + re.escape(db_name) + r'["\']\s*\]\s*=',
|
|
166
|
+
content,
|
|
167
|
+
re.MULTILINE,
|
|
168
|
+
)
|
|
169
|
+
)
|
|
170
|
+
if not has_active_entry:
|
|
171
|
+
entry = (
|
|
172
|
+
f'\nDATABASES["{db_name}"] = dj_database_url.config(\n'
|
|
173
|
+
f' env="{db_name.upper()}_DATABASE_URL",\n'
|
|
174
|
+
f' default=f"sqlite:///{{BASE_DIR}}/{db_name}.sqlite3",\n'
|
|
175
|
+
f" conn_max_age=600,\n"
|
|
176
|
+
f" conn_health_checks=True,\n"
|
|
177
|
+
f")\n"
|
|
178
|
+
)
|
|
179
|
+
with open(database_file, "a", encoding="utf-8") as f:
|
|
180
|
+
f.write(entry)
|
|
181
|
+
|
|
182
|
+
click.echo(
|
|
183
|
+
click.style(
|
|
184
|
+
f"Successfully created database directory and configuration for '{db_name}'!",
|
|
185
|
+
fg="green",
|
|
186
|
+
bold=True,
|
|
187
|
+
)
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
@create.command(name="table")
|
|
192
|
+
@click.argument("name")
|
|
193
|
+
@click.option(
|
|
194
|
+
"--database",
|
|
195
|
+
"-d",
|
|
196
|
+
default=None,
|
|
197
|
+
help="Target database name for this table (e.g. 'default', 'analytics').",
|
|
198
|
+
)
|
|
199
|
+
@click.option(
|
|
200
|
+
"--operations",
|
|
201
|
+
"-o",
|
|
202
|
+
default="crud",
|
|
203
|
+
help="Allowed operations: c (create), r (read), u (update), d (delete).",
|
|
204
|
+
)
|
|
205
|
+
def create_table(name, database, operations):
|
|
206
|
+
"""Scaffold a new database table and API endpoint."""
|
|
207
|
+
_ensure_initialized()
|
|
208
|
+
|
|
209
|
+
# Sanitize inputs
|
|
210
|
+
if "/" in name or "\\" in name or "." in name:
|
|
211
|
+
click.echo(click.style("Invalid table name.", fg="red"))
|
|
212
|
+
raise click.Abort()
|
|
213
|
+
|
|
214
|
+
table_name = inflection.camelize(name)
|
|
215
|
+
|
|
216
|
+
if not table_name.isidentifier():
|
|
217
|
+
click.echo(
|
|
218
|
+
click.style(
|
|
219
|
+
f"Invalid table name: '{table_name}' is not a valid Python identifier.",
|
|
220
|
+
fg="red",
|
|
221
|
+
)
|
|
222
|
+
)
|
|
223
|
+
raise click.Abort()
|
|
224
|
+
|
|
225
|
+
cwd = Path.cwd()
|
|
226
|
+
target_db = database.strip() if database else "default"
|
|
227
|
+
|
|
228
|
+
# Determine table directory location
|
|
229
|
+
if database and (cwd / "databases" / target_db).exists():
|
|
230
|
+
table_dir = cwd / "databases" / target_db / table_name
|
|
231
|
+
elif database and (cwd / target_db).is_dir():
|
|
232
|
+
table_dir = cwd / target_db / table_name
|
|
233
|
+
elif database:
|
|
234
|
+
target_db_dir = cwd / target_db
|
|
235
|
+
target_db_dir.mkdir(parents=True, exist_ok=True)
|
|
236
|
+
(target_db_dir / "__init__.py").touch()
|
|
237
|
+
table_dir = target_db_dir / table_name
|
|
238
|
+
elif (cwd / "tables").is_dir():
|
|
239
|
+
table_dir = cwd / "tables" / table_name
|
|
240
|
+
else:
|
|
241
|
+
table_dir = cwd / "tables" / table_name
|
|
242
|
+
table_dir.parent.mkdir(parents=True, exist_ok=True)
|
|
243
|
+
|
|
244
|
+
if table_dir.exists():
|
|
245
|
+
click.echo(click.style(f"Table '{table_name}' already exists.", fg="yellow"))
|
|
246
|
+
raise click.Abort()
|
|
247
|
+
|
|
248
|
+
table_dir.mkdir(parents=True)
|
|
249
|
+
(table_dir / "__init__.py").touch()
|
|
250
|
+
|
|
251
|
+
ops_map = {"c": "C", "r": "R", "u": "U", "d": "D"}
|
|
252
|
+
ops_list = [ops_map[char] for char in operations.lower() if char in ops_map]
|
|
253
|
+
|
|
254
|
+
# Write config.py
|
|
255
|
+
with open(table_dir / "config.py", "w") as f:
|
|
256
|
+
f.write(f'DATABASE = "{target_db}"\n')
|
|
257
|
+
f.write(f"ALLOWED_OPERATIONS = {ops_list}\n")
|
|
258
|
+
f.write("REQUIRE_AUTH = True\n")
|
|
259
|
+
scopes = [f"{table_name.lower()}:read", f"{table_name.lower()}:write"]
|
|
260
|
+
f.write(f"TABLE_SCOPES = {scopes}\n")
|
|
261
|
+
f.write("THROTTLE_RATES = {'anon': '100/day', 'user': '1000/day'}\n")
|
|
262
|
+
f.write("WEBHOOK_URLS = []\n")
|
|
263
|
+
f.write(
|
|
264
|
+
"DEPTH = 0 # Set to 1 or higher to automatically serialize nested relationships\n"
|
|
265
|
+
)
|
|
266
|
+
f.write(
|
|
267
|
+
"# FILTER_FIELDS = {'price': ['gte', 'lte', 'exact'], 'name': ['icontains']} # or ['name', 'price']\n"
|
|
268
|
+
)
|
|
269
|
+
f.write("# SEARCH_FIELDS = ['name']\n")
|
|
270
|
+
f.write("# ORDERING_FIELDS = ['created_at', 'price']\n")
|
|
271
|
+
f.write(
|
|
272
|
+
"# Dynamic Masking (e.g., 'partial', 'last4', 'email', 'phone', 'full'):\n"
|
|
273
|
+
)
|
|
274
|
+
f.write(
|
|
275
|
+
"# MASKED_FIELDS = {'ssn': 'partial', 'card_number': 'last4', 'email': 'email'}\n"
|
|
276
|
+
)
|
|
277
|
+
f.write(f"# UNMASK_SCOPES = ['{table_name.lower()}:unmask']\n")
|
|
278
|
+
|
|
279
|
+
# Write models.py
|
|
280
|
+
with open(table_dir / "models.py", "w") as f:
|
|
281
|
+
f.write("from django.db import models\n")
|
|
282
|
+
f.write(
|
|
283
|
+
"# from dataman.core.fields import EncryptedCharField, EncryptedTextField, EncryptedEmailField\n\n\n"
|
|
284
|
+
)
|
|
285
|
+
f.write(f"class {table_name}(models.Model):\n")
|
|
286
|
+
f.write(" # Add your fields here\n")
|
|
287
|
+
f.write(" created_at = models.DateTimeField(auto_now_add=True)\n")
|
|
288
|
+
f.write(" updated_at = models.DateTimeField(auto_now=True)\n\n")
|
|
289
|
+
f.write(" class Meta:\n")
|
|
290
|
+
f.write(" app_label = 'dataman'\n")
|
|
291
|
+
f.write(f" db_table = '{table_name.lower()}'\n")
|
|
292
|
+
|
|
293
|
+
# Write validation.py
|
|
294
|
+
with open(table_dir / "validation.py", "w") as f:
|
|
295
|
+
f.write(
|
|
296
|
+
"# Auto-generated by DataMan\n"
|
|
297
|
+
"# Define custom validation logic or Pydantic Schemas here.\n"
|
|
298
|
+
"# \n"
|
|
299
|
+
"# Example:\n"
|
|
300
|
+
"# from pydantic import BaseModel, Field\n"
|
|
301
|
+
"# \n"
|
|
302
|
+
"# class Schema(BaseModel):\n"
|
|
303
|
+
"# name: str = Field(..., min_length=2, max_length=255)\n"
|
|
304
|
+
)
|
|
305
|
+
|
|
306
|
+
# Write service.py
|
|
307
|
+
with open(table_dir / "service.py", "w") as f:
|
|
308
|
+
f.write(
|
|
309
|
+
"# Auto-generated by DataMan\n"
|
|
310
|
+
"# DataMan automatically handles standard CRUD.\n"
|
|
311
|
+
"# You can override insert/fetch behavior here if needed.\n"
|
|
312
|
+
)
|
|
313
|
+
|
|
314
|
+
# Write analytics.py
|
|
315
|
+
with open(table_dir / "analytics.py", "w") as f:
|
|
316
|
+
f.write(
|
|
317
|
+
"# Auto-generated by DataMan\n"
|
|
318
|
+
"# Define custom tracking or metrics hooks for this table here.\n"
|
|
319
|
+
)
|
|
320
|
+
|
|
321
|
+
msg = (
|
|
322
|
+
f"Successfully created table structure for {table_name} in database '{target_db}'!"
|
|
323
|
+
if database
|
|
324
|
+
else f"Successfully created table structure for {table_name}!"
|
|
325
|
+
)
|
|
326
|
+
click.echo(click.style(msg, fg="green"))
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
@cli.command()
|
|
330
|
+
def makemigration():
|
|
331
|
+
"""Create new migrations based on the models you have defined."""
|
|
332
|
+
_ensure_initialized()
|
|
333
|
+
django_setup.setup()
|
|
334
|
+
try:
|
|
335
|
+
call_command("makemigrations", "dataman")
|
|
336
|
+
click.echo(click.style("Migrations created successfully!", fg="green"))
|
|
337
|
+
except SystemExit as e:
|
|
338
|
+
raise click.Abort() from e
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
@cli.command()
|
|
342
|
+
@click.option(
|
|
343
|
+
"--database",
|
|
344
|
+
"-d",
|
|
345
|
+
default=None,
|
|
346
|
+
help="Specify database alias to migrate. If omitted, migrates all configured databases.",
|
|
347
|
+
)
|
|
348
|
+
def migrate(database):
|
|
349
|
+
"""Apply migrations to database(s)."""
|
|
350
|
+
_ensure_initialized()
|
|
351
|
+
django_setup.setup()
|
|
352
|
+
from django.conf import settings
|
|
353
|
+
|
|
354
|
+
try:
|
|
355
|
+
if database:
|
|
356
|
+
call_command("migrate", database=database)
|
|
357
|
+
click.echo(
|
|
358
|
+
click.style(f"Database '{database}' migrated successfully!", fg="green")
|
|
359
|
+
)
|
|
360
|
+
else:
|
|
361
|
+
for db_name in settings.DATABASES:
|
|
362
|
+
call_command("migrate", database=db_name)
|
|
363
|
+
click.echo(click.style("Database migrated successfully!", fg="green"))
|
|
364
|
+
except SystemExit as e:
|
|
365
|
+
raise click.Abort() from e
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
@cli.group()
|
|
369
|
+
def server():
|
|
370
|
+
"""Manage the DataMan server."""
|
|
371
|
+
pass
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
@server.command()
|
|
375
|
+
@click.option("--host", default="127.0.0.1", help="Host interface to bind to.")
|
|
376
|
+
@click.option("--port", default=8000, type=int, help="Port to bind to.")
|
|
377
|
+
@click.option(
|
|
378
|
+
"--asgi",
|
|
379
|
+
is_flag=True,
|
|
380
|
+
default=False,
|
|
381
|
+
help="Run with high-concurrency ASGI server (uvicorn).",
|
|
382
|
+
)
|
|
383
|
+
@click.option(
|
|
384
|
+
"--workers",
|
|
385
|
+
default=1,
|
|
386
|
+
type=int,
|
|
387
|
+
help="Number of worker processes (ASGI mode only).",
|
|
388
|
+
)
|
|
389
|
+
def start(host, port, asgi, workers):
|
|
390
|
+
"""Start the DataMan API server."""
|
|
391
|
+
_ensure_initialized()
|
|
392
|
+
django_setup.setup()
|
|
393
|
+
|
|
394
|
+
if asgi:
|
|
395
|
+
import uvicorn
|
|
396
|
+
|
|
397
|
+
from dataman.django_setup import get_asgi_application
|
|
398
|
+
|
|
399
|
+
click.echo(
|
|
400
|
+
click.style(
|
|
401
|
+
f"Starting DataMan ASGI server at http://{host}:{port}/",
|
|
402
|
+
fg="green",
|
|
403
|
+
bold=True,
|
|
404
|
+
)
|
|
405
|
+
)
|
|
406
|
+
uvicorn.run(get_asgi_application(), host=host, port=port, workers=workers)
|
|
407
|
+
else:
|
|
408
|
+
try:
|
|
409
|
+
click.echo(
|
|
410
|
+
click.style(
|
|
411
|
+
f"Starting DataMan server at http://{host}:{port}/",
|
|
412
|
+
fg="green",
|
|
413
|
+
bold=True,
|
|
414
|
+
)
|
|
415
|
+
)
|
|
416
|
+
call_command("runserver", f"{host}:{port}", use_reloader=False)
|
|
417
|
+
except SystemExit as e:
|
|
418
|
+
raise click.Abort() from e
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
@cli.group()
|
|
422
|
+
def users():
|
|
423
|
+
"""Manage DataMan users and tokens."""
|
|
424
|
+
pass
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
@users.command(name="create-admin")
|
|
428
|
+
def create_admin():
|
|
429
|
+
"""Create a superuser for the admin panel."""
|
|
430
|
+
_ensure_initialized()
|
|
431
|
+
django_setup.setup()
|
|
432
|
+
try:
|
|
433
|
+
call_command("createsuperuser")
|
|
434
|
+
except SystemExit as e:
|
|
435
|
+
raise click.Abort() from e
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
@users.command(name="create-token")
|
|
439
|
+
@click.argument("name")
|
|
440
|
+
@click.option(
|
|
441
|
+
"--scopes",
|
|
442
|
+
default="*",
|
|
443
|
+
help=(
|
|
444
|
+
"Comma separated list of scopes (e.g. 'customer:read,invoice:write'). "
|
|
445
|
+
"Use '*' for all scopes."
|
|
446
|
+
),
|
|
447
|
+
)
|
|
448
|
+
@click.option(
|
|
449
|
+
"--expires-in",
|
|
450
|
+
default="30d",
|
|
451
|
+
help="Expiration duration (e.g. '30d', '7d', '24h', '1y', 'never'). Default is 30 days.",
|
|
452
|
+
)
|
|
453
|
+
def create_token(name, scopes, expires_in):
|
|
454
|
+
"""Generate a programmatic API token."""
|
|
455
|
+
_ensure_initialized()
|
|
456
|
+
django_setup.setup()
|
|
457
|
+
|
|
458
|
+
import hashlib
|
|
459
|
+
import json
|
|
460
|
+
import secrets
|
|
461
|
+
from datetime import timedelta
|
|
462
|
+
|
|
463
|
+
from django.utils import timezone
|
|
464
|
+
|
|
465
|
+
from dataman.core.audit import log_audit_event
|
|
466
|
+
from dataman.core.models import APIToken
|
|
467
|
+
|
|
468
|
+
scope_list = [s.strip() for s in scopes.split(",")]
|
|
469
|
+
|
|
470
|
+
prefix = secrets.token_hex(4)
|
|
471
|
+
secret = secrets.token_hex(16)
|
|
472
|
+
hashed_secret = hashlib.sha256(secret.encode()).hexdigest()
|
|
473
|
+
|
|
474
|
+
expires_at = None
|
|
475
|
+
if expires_in and expires_in.lower() != "never":
|
|
476
|
+
now = timezone.now()
|
|
477
|
+
exp_str = expires_in.strip().lower()
|
|
478
|
+
if exp_str.endswith("d"):
|
|
479
|
+
expires_at = now + timedelta(days=int(exp_str[:-1]))
|
|
480
|
+
elif exp_str.endswith("h"):
|
|
481
|
+
expires_at = now + timedelta(hours=int(exp_str[:-1]))
|
|
482
|
+
elif exp_str.endswith("y"):
|
|
483
|
+
expires_at = now + timedelta(days=int(exp_str[:-1]) * 365)
|
|
484
|
+
elif exp_str.isdigit():
|
|
485
|
+
expires_at = now + timedelta(days=int(exp_str))
|
|
486
|
+
|
|
487
|
+
token = APIToken.objects.create(
|
|
488
|
+
name=name,
|
|
489
|
+
scopes=scope_list,
|
|
490
|
+
prefix=prefix,
|
|
491
|
+
hashed_secret=hashed_secret,
|
|
492
|
+
expires_at=expires_at,
|
|
493
|
+
is_active=True,
|
|
494
|
+
)
|
|
495
|
+
|
|
496
|
+
log_audit_event(
|
|
497
|
+
event_type="TOKEN_GENERATED",
|
|
498
|
+
actor="CLI",
|
|
499
|
+
details={
|
|
500
|
+
"token_id": token.id,
|
|
501
|
+
"name": token.name,
|
|
502
|
+
"prefix": token.prefix,
|
|
503
|
+
"scopes": token.scopes,
|
|
504
|
+
"expires_at": token.expires_at.isoformat() if token.expires_at else None,
|
|
505
|
+
},
|
|
506
|
+
severity="INFO",
|
|
507
|
+
status_code=201,
|
|
508
|
+
)
|
|
509
|
+
|
|
510
|
+
click.echo(f"Created new token: {name}")
|
|
511
|
+
click.echo(f"Scopes: {json.dumps(scope_list)}")
|
|
512
|
+
if expires_at:
|
|
513
|
+
click.echo(f"Expires: {expires_at.strftime('%Y-%m-%d %H:%M:%S UTC')}")
|
|
514
|
+
else:
|
|
515
|
+
click.echo("Expires: Never")
|
|
516
|
+
click.echo(click.style(f"Token Key: {prefix}_{secret}", fg="green", bold=True))
|
|
517
|
+
click.echo(
|
|
518
|
+
click.style(
|
|
519
|
+
"Please save this token key securely. It will not be shown again.",
|
|
520
|
+
fg="yellow",
|
|
521
|
+
)
|
|
522
|
+
)
|
|
523
|
+
|
|
524
|
+
|
|
525
|
+
@cli.group()
|
|
526
|
+
def logs():
|
|
527
|
+
"""Export and inspect system logs."""
|
|
528
|
+
pass
|
|
529
|
+
|
|
530
|
+
|
|
531
|
+
@logs.command(name="export-audit")
|
|
532
|
+
@click.option(
|
|
533
|
+
"--format",
|
|
534
|
+
"-f",
|
|
535
|
+
type=click.Choice(["csv", "json"], case_sensitive=False),
|
|
536
|
+
default="csv",
|
|
537
|
+
help="Export format (csv or json).",
|
|
538
|
+
)
|
|
539
|
+
@click.option(
|
|
540
|
+
"--output",
|
|
541
|
+
"-o",
|
|
542
|
+
default=None,
|
|
543
|
+
help="Output file path. Defaults to ./audit_logs_<timestamp>.<ext>",
|
|
544
|
+
)
|
|
545
|
+
@click.option("--event-type", default=None, help="Filter by specific event type.")
|
|
546
|
+
@click.option(
|
|
547
|
+
"--severity",
|
|
548
|
+
default=None,
|
|
549
|
+
help="Filter by severity level (INFO, WARNING, ERROR, CRITICAL).",
|
|
550
|
+
)
|
|
551
|
+
@click.option("--actor", default=None, help="Filter by actor identifier.")
|
|
552
|
+
@click.option("--search", default=None, help="Search query across event, actor, or IP.")
|
|
553
|
+
@click.option(
|
|
554
|
+
"--limit", default=10000, type=int, help="Maximum number of records to export."
|
|
555
|
+
)
|
|
556
|
+
def export_audit(format, output, event_type, severity, actor, search, limit):
|
|
557
|
+
"""Export security and compliance audit logs."""
|
|
558
|
+
_ensure_initialized()
|
|
559
|
+
django_setup.setup()
|
|
560
|
+
|
|
561
|
+
import csv
|
|
562
|
+
import json
|
|
563
|
+
|
|
564
|
+
from django.db.models import Q
|
|
565
|
+
from django.utils import timezone
|
|
566
|
+
|
|
567
|
+
from dataman.core.models import AuditLog
|
|
568
|
+
|
|
569
|
+
qs = AuditLog.objects.all().order_by("-timestamp")
|
|
570
|
+
if event_type:
|
|
571
|
+
qs = qs.filter(event_type=event_type)
|
|
572
|
+
if severity:
|
|
573
|
+
qs = qs.filter(severity__iexact=severity)
|
|
574
|
+
if actor:
|
|
575
|
+
qs = qs.filter(actor__icontains=actor)
|
|
576
|
+
if search:
|
|
577
|
+
qs = qs.filter(
|
|
578
|
+
Q(event_type__icontains=search)
|
|
579
|
+
| Q(actor__icontains=search)
|
|
580
|
+
| Q(ip_address__icontains=search)
|
|
581
|
+
)
|
|
582
|
+
|
|
583
|
+
records = list(qs[:limit])
|
|
584
|
+
timestamp_str = timezone.now().strftime("%Y%m%d_%H%M%S")
|
|
585
|
+
ext = format.lower()
|
|
586
|
+
out_path = Path(output) if output else Path(f"audit_logs_{timestamp_str}.{ext}")
|
|
587
|
+
|
|
588
|
+
if ext == "json":
|
|
589
|
+
data = [
|
|
590
|
+
{
|
|
591
|
+
"id": r.id,
|
|
592
|
+
"timestamp": r.timestamp.isoformat(),
|
|
593
|
+
"event_type": r.event_type,
|
|
594
|
+
"actor": r.actor,
|
|
595
|
+
"ip_address": r.ip_address,
|
|
596
|
+
"user_agent": r.user_agent,
|
|
597
|
+
"status_code": r.status_code,
|
|
598
|
+
"severity": r.severity,
|
|
599
|
+
"details": r.details,
|
|
600
|
+
}
|
|
601
|
+
for r in records
|
|
602
|
+
]
|
|
603
|
+
with open(out_path, "w", encoding="utf-8") as f:
|
|
604
|
+
json.dump(data, f, indent=2, default=str)
|
|
605
|
+
else:
|
|
606
|
+
with open(out_path, "w", newline="", encoding="utf-8") as f:
|
|
607
|
+
writer = csv.writer(f)
|
|
608
|
+
writer.writerow(
|
|
609
|
+
[
|
|
610
|
+
"id",
|
|
611
|
+
"timestamp",
|
|
612
|
+
"severity",
|
|
613
|
+
"event_type",
|
|
614
|
+
"actor",
|
|
615
|
+
"ip_address",
|
|
616
|
+
"status_code",
|
|
617
|
+
"user_agent",
|
|
618
|
+
"details",
|
|
619
|
+
]
|
|
620
|
+
)
|
|
621
|
+
for r in records:
|
|
622
|
+
writer.writerow(
|
|
623
|
+
[
|
|
624
|
+
r.id,
|
|
625
|
+
r.timestamp.isoformat(),
|
|
626
|
+
r.severity,
|
|
627
|
+
r.event_type,
|
|
628
|
+
r.actor,
|
|
629
|
+
r.ip_address or "",
|
|
630
|
+
r.status_code if r.status_code is not None else "",
|
|
631
|
+
r.user_agent or "",
|
|
632
|
+
json.dumps(r.details, default=str),
|
|
633
|
+
]
|
|
634
|
+
)
|
|
635
|
+
|
|
636
|
+
click.echo(
|
|
637
|
+
click.style(
|
|
638
|
+
f"Successfully exported {len(records)} audit log records to {out_path.resolve()}",
|
|
639
|
+
fg="green",
|
|
640
|
+
bold=True,
|
|
641
|
+
)
|
|
642
|
+
)
|
|
643
|
+
|
|
644
|
+
|
|
645
|
+
@logs.command(name="export-telemetry")
|
|
646
|
+
@click.option(
|
|
647
|
+
"--format",
|
|
648
|
+
"-f",
|
|
649
|
+
type=click.Choice(["csv", "json"], case_sensitive=False),
|
|
650
|
+
default="csv",
|
|
651
|
+
help="Export format (csv or json).",
|
|
652
|
+
)
|
|
653
|
+
@click.option(
|
|
654
|
+
"--output",
|
|
655
|
+
"-o",
|
|
656
|
+
default=None,
|
|
657
|
+
help="Output file path. Defaults to ./api_telemetry_<timestamp>.<ext>",
|
|
658
|
+
)
|
|
659
|
+
@click.option(
|
|
660
|
+
"--limit", default=10000, type=int, help="Maximum number of records to export."
|
|
661
|
+
)
|
|
662
|
+
def export_telemetry(format, output, limit):
|
|
663
|
+
"""Export API request telemetry logs."""
|
|
664
|
+
_ensure_initialized()
|
|
665
|
+
django_setup.setup()
|
|
666
|
+
|
|
667
|
+
import csv
|
|
668
|
+
import json
|
|
669
|
+
|
|
670
|
+
from django.utils import timezone
|
|
671
|
+
|
|
672
|
+
from dataman.core.models import APILog
|
|
673
|
+
|
|
674
|
+
records = list(APILog.objects.all().order_by("-timestamp")[:limit])
|
|
675
|
+
timestamp_str = timezone.now().strftime("%Y%m%d_%H%M%S")
|
|
676
|
+
ext = format.lower()
|
|
677
|
+
out_path = Path(output) if output else Path(f"api_telemetry_{timestamp_str}.{ext}")
|
|
678
|
+
|
|
679
|
+
if ext == "json":
|
|
680
|
+
data = [
|
|
681
|
+
{
|
|
682
|
+
"id": r.id,
|
|
683
|
+
"timestamp": r.timestamp.isoformat(),
|
|
684
|
+
"method": r.method,
|
|
685
|
+
"path": r.path,
|
|
686
|
+
"status_code": r.status_code,
|
|
687
|
+
"duration_ms": r.duration_ms,
|
|
688
|
+
"ip_address": r.ip_address,
|
|
689
|
+
}
|
|
690
|
+
for r in records
|
|
691
|
+
]
|
|
692
|
+
with open(out_path, "w", encoding="utf-8") as f:
|
|
693
|
+
json.dump(data, f, indent=2)
|
|
694
|
+
else:
|
|
695
|
+
with open(out_path, "w", newline="", encoding="utf-8") as f:
|
|
696
|
+
writer = csv.writer(f)
|
|
697
|
+
writer.writerow(
|
|
698
|
+
[
|
|
699
|
+
"id",
|
|
700
|
+
"timestamp",
|
|
701
|
+
"method",
|
|
702
|
+
"path",
|
|
703
|
+
"status_code",
|
|
704
|
+
"duration_ms",
|
|
705
|
+
"ip_address",
|
|
706
|
+
]
|
|
707
|
+
)
|
|
708
|
+
for r in records:
|
|
709
|
+
writer.writerow(
|
|
710
|
+
[
|
|
711
|
+
r.id,
|
|
712
|
+
r.timestamp.isoformat(),
|
|
713
|
+
r.method,
|
|
714
|
+
r.path,
|
|
715
|
+
r.status_code,
|
|
716
|
+
r.duration_ms,
|
|
717
|
+
r.ip_address or "",
|
|
718
|
+
]
|
|
719
|
+
)
|
|
720
|
+
|
|
721
|
+
click.echo(
|
|
722
|
+
click.style(
|
|
723
|
+
f"Successfully exported {len(records)} telemetry log records to {out_path.resolve()}",
|
|
724
|
+
fg="green",
|
|
725
|
+
bold=True,
|
|
726
|
+
)
|
|
727
|
+
)
|
|
728
|
+
|
|
729
|
+
|
|
730
|
+
if __name__ == "__main__":
|
|
731
|
+
cli()
|