Upload 12 files
Browse files- core/models/__init__.py +37 -0
- core/models/api_token.py +104 -0
- core/models/datastore.py +113 -0
- core/models/environment.py +151 -0
- core/models/package.py +117 -0
- core/models/run.py +179 -0
- core/models/schedule.py +228 -0
- core/models/script.py +205 -0
- core/models/secret.py +85 -0
- core/models/settings.py +342 -0
- core/models/tag.py +60 -0
- core/models/user.py +282 -0
core/models/__init__.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Core models for PyRunner.
|
| 3 |
+
|
| 4 |
+
This module exports all models for easy importing:
|
| 5 |
+
from core.models import User, MagicToken, UserInvite, PasswordResetToken, Environment, Script, Run, ScriptSchedule, ScheduleHistory, GlobalSettings, PackageOperation, Secret, Tag, DataStore, DataStoreEntry, DataStoreAPIToken
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from .user import User, MagicToken, UserInvite, PasswordResetToken
|
| 9 |
+
from .environment import Environment
|
| 10 |
+
from .script import Script
|
| 11 |
+
from .run import Run
|
| 12 |
+
from .schedule import ScriptSchedule, ScheduleHistory
|
| 13 |
+
from .settings import GlobalSettings
|
| 14 |
+
from .package import PackageOperation
|
| 15 |
+
from .secret import Secret
|
| 16 |
+
from .tag import Tag
|
| 17 |
+
from .datastore import DataStore, DataStoreEntry
|
| 18 |
+
from .api_token import DataStoreAPIToken
|
| 19 |
+
|
| 20 |
+
__all__ = [
|
| 21 |
+
"User",
|
| 22 |
+
"MagicToken",
|
| 23 |
+
"UserInvite",
|
| 24 |
+
"PasswordResetToken",
|
| 25 |
+
"Environment",
|
| 26 |
+
"Script",
|
| 27 |
+
"Run",
|
| 28 |
+
"ScriptSchedule",
|
| 29 |
+
"ScheduleHistory",
|
| 30 |
+
"GlobalSettings",
|
| 31 |
+
"PackageOperation",
|
| 32 |
+
"Secret",
|
| 33 |
+
"Tag",
|
| 34 |
+
"DataStore",
|
| 35 |
+
"DataStoreEntry",
|
| 36 |
+
"DataStoreAPIToken",
|
| 37 |
+
]
|
core/models/api_token.py
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
API Token model for datastore API access.
|
| 3 |
+
"""
|
| 4 |
+
|
| 5 |
+
import secrets
|
| 6 |
+
import uuid
|
| 7 |
+
|
| 8 |
+
from django.conf import settings
|
| 9 |
+
from django.db import models
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
class DataStoreAPIToken(models.Model):
|
| 13 |
+
"""
|
| 14 |
+
API token for accessing datastore data via REST API.
|
| 15 |
+
Tokens can be scoped to a single datastore or grant access to all datastores.
|
| 16 |
+
"""
|
| 17 |
+
|
| 18 |
+
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
| 19 |
+
|
| 20 |
+
# The actual token value (64-char URL-safe string)
|
| 21 |
+
token = models.CharField(
|
| 22 |
+
max_length=64,
|
| 23 |
+
unique=True,
|
| 24 |
+
db_index=True,
|
| 25 |
+
help_text="API token value (auto-generated)",
|
| 26 |
+
)
|
| 27 |
+
|
| 28 |
+
# Friendly name to identify this token
|
| 29 |
+
name = models.CharField(
|
| 30 |
+
max_length=100,
|
| 31 |
+
help_text="Friendly name for this token",
|
| 32 |
+
)
|
| 33 |
+
|
| 34 |
+
# Optional: Restrict to specific datastore (null = global access to all datastores)
|
| 35 |
+
datastore = models.ForeignKey(
|
| 36 |
+
"DataStore",
|
| 37 |
+
on_delete=models.CASCADE,
|
| 38 |
+
null=True,
|
| 39 |
+
blank=True,
|
| 40 |
+
related_name="api_tokens",
|
| 41 |
+
help_text="If set, token only grants access to this datastore. Leave empty for global access.",
|
| 42 |
+
)
|
| 43 |
+
|
| 44 |
+
# Timestamps
|
| 45 |
+
created_at = models.DateTimeField(auto_now_add=True)
|
| 46 |
+
last_used_at = models.DateTimeField(
|
| 47 |
+
null=True,
|
| 48 |
+
blank=True,
|
| 49 |
+
help_text="Last time this token was used",
|
| 50 |
+
)
|
| 51 |
+
expires_at = models.DateTimeField(
|
| 52 |
+
null=True,
|
| 53 |
+
blank=True,
|
| 54 |
+
help_text="Optional expiration date. Leave empty for no expiration.",
|
| 55 |
+
)
|
| 56 |
+
|
| 57 |
+
# Who created this token
|
| 58 |
+
created_by = models.ForeignKey(
|
| 59 |
+
settings.AUTH_USER_MODEL,
|
| 60 |
+
on_delete=models.SET_NULL,
|
| 61 |
+
null=True,
|
| 62 |
+
blank=True,
|
| 63 |
+
related_name="created_api_tokens",
|
| 64 |
+
)
|
| 65 |
+
|
| 66 |
+
# Active flag for soft-disable
|
| 67 |
+
is_active = models.BooleanField(
|
| 68 |
+
default=True,
|
| 69 |
+
help_text="Inactive tokens cannot be used for API access",
|
| 70 |
+
)
|
| 71 |
+
|
| 72 |
+
class Meta:
|
| 73 |
+
db_table = "datastore_api_tokens"
|
| 74 |
+
verbose_name = "API token"
|
| 75 |
+
verbose_name_plural = "API tokens"
|
| 76 |
+
ordering = ["-created_at"]
|
| 77 |
+
|
| 78 |
+
def __str__(self):
|
| 79 |
+
if self.datastore:
|
| 80 |
+
return f"{self.name} ({self.datastore.name})"
|
| 81 |
+
return f"{self.name} (global)"
|
| 82 |
+
|
| 83 |
+
@staticmethod
|
| 84 |
+
def generate_token() -> str:
|
| 85 |
+
"""Generate a secure random API token (64 chars, URL-safe)."""
|
| 86 |
+
return secrets.token_urlsafe(48)
|
| 87 |
+
|
| 88 |
+
def get_masked_token(self) -> str:
|
| 89 |
+
"""Return a masked version of the token for display."""
|
| 90 |
+
if len(self.token) <= 12:
|
| 91 |
+
return "*" * len(self.token)
|
| 92 |
+
return f"{self.token[:8]}...{self.token[-4:]}"
|
| 93 |
+
|
| 94 |
+
@property
|
| 95 |
+
def is_global(self) -> bool:
|
| 96 |
+
"""Return True if this is a global token (not scoped to a datastore)."""
|
| 97 |
+
return self.datastore is None
|
| 98 |
+
|
| 99 |
+
@property
|
| 100 |
+
def scope_display(self) -> str:
|
| 101 |
+
"""Return a human-readable scope description."""
|
| 102 |
+
if self.datastore:
|
| 103 |
+
return f"Datastore: {self.datastore.name}"
|
| 104 |
+
return "All datastores"
|
core/models/datastore.py
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
DataStore models for script data persistence.
|
| 3 |
+
"""
|
| 4 |
+
|
| 5 |
+
import json
|
| 6 |
+
import uuid
|
| 7 |
+
|
| 8 |
+
from django.conf import settings
|
| 9 |
+
from django.db import models
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
class DataStore(models.Model):
|
| 13 |
+
"""
|
| 14 |
+
A named data store that scripts can use for simple key-value storage.
|
| 15 |
+
Data stores are backed by the instance SQLite database.
|
| 16 |
+
"""
|
| 17 |
+
|
| 18 |
+
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
| 19 |
+
|
| 20 |
+
name = models.CharField(
|
| 21 |
+
max_length=100,
|
| 22 |
+
unique=True,
|
| 23 |
+
help_text="Unique name for this data store (used in scripts)",
|
| 24 |
+
)
|
| 25 |
+
|
| 26 |
+
description = models.TextField(
|
| 27 |
+
blank=True,
|
| 28 |
+
help_text="Optional description of what this data store is used for",
|
| 29 |
+
)
|
| 30 |
+
|
| 31 |
+
# Timestamps
|
| 32 |
+
created_at = models.DateTimeField(auto_now_add=True)
|
| 33 |
+
updated_at = models.DateTimeField(auto_now=True)
|
| 34 |
+
|
| 35 |
+
# Who created this data store
|
| 36 |
+
created_by = models.ForeignKey(
|
| 37 |
+
settings.AUTH_USER_MODEL,
|
| 38 |
+
on_delete=models.SET_NULL,
|
| 39 |
+
null=True,
|
| 40 |
+
blank=True,
|
| 41 |
+
related_name="created_datastores",
|
| 42 |
+
)
|
| 43 |
+
|
| 44 |
+
class Meta:
|
| 45 |
+
db_table = "datastores"
|
| 46 |
+
verbose_name = "data store"
|
| 47 |
+
verbose_name_plural = "data stores"
|
| 48 |
+
ordering = ["name"]
|
| 49 |
+
|
| 50 |
+
def __str__(self):
|
| 51 |
+
return self.name
|
| 52 |
+
|
| 53 |
+
@property
|
| 54 |
+
def entry_count(self) -> int:
|
| 55 |
+
"""Return the number of entries in this data store."""
|
| 56 |
+
return self.entries.count()
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
class DataStoreEntry(models.Model):
|
| 60 |
+
"""
|
| 61 |
+
A key-value entry in a data store.
|
| 62 |
+
Values are stored as JSON to support various data types.
|
| 63 |
+
"""
|
| 64 |
+
|
| 65 |
+
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
| 66 |
+
|
| 67 |
+
datastore = models.ForeignKey(
|
| 68 |
+
DataStore,
|
| 69 |
+
on_delete=models.CASCADE,
|
| 70 |
+
related_name="entries",
|
| 71 |
+
)
|
| 72 |
+
|
| 73 |
+
key = models.CharField(
|
| 74 |
+
max_length=255,
|
| 75 |
+
help_text="Unique key within this data store",
|
| 76 |
+
)
|
| 77 |
+
|
| 78 |
+
# Value stored as JSON text (supports strings, numbers, lists, dicts, etc.)
|
| 79 |
+
value_json = models.TextField(
|
| 80 |
+
help_text="JSON-encoded value",
|
| 81 |
+
)
|
| 82 |
+
|
| 83 |
+
# Timestamps
|
| 84 |
+
created_at = models.DateTimeField(auto_now_add=True)
|
| 85 |
+
updated_at = models.DateTimeField(auto_now=True)
|
| 86 |
+
|
| 87 |
+
class Meta:
|
| 88 |
+
db_table = "datastore_entries"
|
| 89 |
+
verbose_name = "data store entry"
|
| 90 |
+
verbose_name_plural = "data store entries"
|
| 91 |
+
unique_together = [["datastore", "key"]]
|
| 92 |
+
ordering = ["key"]
|
| 93 |
+
indexes = [
|
| 94 |
+
models.Index(fields=["datastore", "key"]),
|
| 95 |
+
]
|
| 96 |
+
|
| 97 |
+
def __str__(self):
|
| 98 |
+
return f"{self.datastore.name}:{self.key}"
|
| 99 |
+
|
| 100 |
+
def get_value(self):
|
| 101 |
+
"""Deserialize and return the stored value."""
|
| 102 |
+
return json.loads(self.value_json)
|
| 103 |
+
|
| 104 |
+
def set_value(self, value) -> None:
|
| 105 |
+
"""Serialize and store a value."""
|
| 106 |
+
self.value_json = json.dumps(value)
|
| 107 |
+
|
| 108 |
+
def get_display_value(self) -> str:
|
| 109 |
+
"""Return a displayable version of the value (truncated if too long)."""
|
| 110 |
+
value = self.value_json
|
| 111 |
+
if len(value) > 100:
|
| 112 |
+
return value[:100] + "..."
|
| 113 |
+
return value
|
core/models/environment.py
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Environment model for isolated Python virtual environments.
|
| 3 |
+
"""
|
| 4 |
+
|
| 5 |
+
import os
|
| 6 |
+
import re
|
| 7 |
+
import uuid
|
| 8 |
+
|
| 9 |
+
from django.conf import settings
|
| 10 |
+
from django.core.exceptions import ValidationError
|
| 11 |
+
from django.db import models
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
def validate_environment_path(value: str) -> None:
|
| 15 |
+
"""
|
| 16 |
+
Validate environment path to prevent path traversal attacks.
|
| 17 |
+
|
| 18 |
+
Only allows simple directory names: alphanumeric, hyphens, underscores.
|
| 19 |
+
Blocks: .., absolute paths, drive letters, special characters.
|
| 20 |
+
"""
|
| 21 |
+
if not value:
|
| 22 |
+
raise ValidationError("Path cannot be empty")
|
| 23 |
+
|
| 24 |
+
# Block path traversal sequences
|
| 25 |
+
if ".." in value:
|
| 26 |
+
raise ValidationError("Path cannot contain '..'")
|
| 27 |
+
|
| 28 |
+
# Block absolute paths (Unix and Windows)
|
| 29 |
+
if value.startswith("/") or value.startswith("\\"):
|
| 30 |
+
raise ValidationError("Path cannot be absolute")
|
| 31 |
+
|
| 32 |
+
# Block Windows drive letters (e.g., C:, D:)
|
| 33 |
+
if len(value) >= 2 and value[1] == ":":
|
| 34 |
+
raise ValidationError("Path cannot contain drive letters")
|
| 35 |
+
|
| 36 |
+
# Only allow safe characters: alphanumeric, hyphen, underscore
|
| 37 |
+
if not re.match(r"^[a-zA-Z0-9_-]+$", value):
|
| 38 |
+
raise ValidationError(
|
| 39 |
+
"Path can only contain letters, numbers, hyphens, and underscores"
|
| 40 |
+
)
|
| 41 |
+
|
| 42 |
+
# Length limit
|
| 43 |
+
if len(value) > 100:
|
| 44 |
+
raise ValidationError("Path cannot exceed 100 characters")
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
class Environment(models.Model):
|
| 48 |
+
"""
|
| 49 |
+
Represents an isolated Python virtual environment for script execution.
|
| 50 |
+
Each environment has its own set of installed packages.
|
| 51 |
+
"""
|
| 52 |
+
|
| 53 |
+
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
| 54 |
+
name = models.CharField(max_length=100)
|
| 55 |
+
description = models.TextField(blank=True)
|
| 56 |
+
|
| 57 |
+
# Path relative to ENVIRONMENTS_ROOT
|
| 58 |
+
path = models.CharField(
|
| 59 |
+
max_length=255,
|
| 60 |
+
unique=True,
|
| 61 |
+
validators=[validate_environment_path],
|
| 62 |
+
help_text="Directory name within the environments folder",
|
| 63 |
+
)
|
| 64 |
+
|
| 65 |
+
# Python version info (captured at creation time)
|
| 66 |
+
python_version = models.CharField(max_length=20, blank=True)
|
| 67 |
+
|
| 68 |
+
# Installed packages (pip freeze output)
|
| 69 |
+
requirements = models.TextField(
|
| 70 |
+
blank=True,
|
| 71 |
+
help_text="Installed packages in pip requirements format",
|
| 72 |
+
)
|
| 73 |
+
|
| 74 |
+
is_default = models.BooleanField(
|
| 75 |
+
default=False,
|
| 76 |
+
help_text="Whether this is the default environment for new scripts",
|
| 77 |
+
)
|
| 78 |
+
is_active = models.BooleanField(
|
| 79 |
+
default=True,
|
| 80 |
+
help_text="Whether this environment is available for use",
|
| 81 |
+
)
|
| 82 |
+
|
| 83 |
+
created_at = models.DateTimeField(auto_now_add=True)
|
| 84 |
+
updated_at = models.DateTimeField(auto_now=True)
|
| 85 |
+
created_by = models.ForeignKey(
|
| 86 |
+
settings.AUTH_USER_MODEL,
|
| 87 |
+
on_delete=models.SET_NULL,
|
| 88 |
+
null=True,
|
| 89 |
+
blank=True,
|
| 90 |
+
related_name="created_environments",
|
| 91 |
+
)
|
| 92 |
+
|
| 93 |
+
class Meta:
|
| 94 |
+
db_table = "environments"
|
| 95 |
+
verbose_name = "environment"
|
| 96 |
+
verbose_name_plural = "environments"
|
| 97 |
+
ordering = ["-is_default", "name"]
|
| 98 |
+
|
| 99 |
+
def __str__(self):
|
| 100 |
+
suffix = " (default)" if self.is_default else ""
|
| 101 |
+
return f"{self.name}{suffix}"
|
| 102 |
+
|
| 103 |
+
def save(self, *args, **kwargs):
|
| 104 |
+
# Ensure only one environment is marked as default
|
| 105 |
+
if self.is_default:
|
| 106 |
+
Environment.objects.filter(is_default=True).exclude(pk=self.pk).update(
|
| 107 |
+
is_default=False
|
| 108 |
+
)
|
| 109 |
+
super().save(*args, **kwargs)
|
| 110 |
+
|
| 111 |
+
def get_full_path(self) -> str:
|
| 112 |
+
"""Return the absolute path to this environment's directory."""
|
| 113 |
+
# Runtime validation as defense-in-depth
|
| 114 |
+
validate_environment_path(self.path)
|
| 115 |
+
return os.path.join(settings.ENVIRONMENTS_ROOT, self.path)
|
| 116 |
+
|
| 117 |
+
def get_python_executable(self) -> str:
|
| 118 |
+
"""Return the absolute path to this environment's Python executable."""
|
| 119 |
+
base_path = self.get_full_path()
|
| 120 |
+
if os.name == "nt":
|
| 121 |
+
# Windows
|
| 122 |
+
return os.path.join(base_path, "Scripts", "python.exe")
|
| 123 |
+
else:
|
| 124 |
+
# Unix/Linux/macOS
|
| 125 |
+
return os.path.join(base_path, "bin", "python")
|
| 126 |
+
|
| 127 |
+
def get_pip_executable(self) -> str:
|
| 128 |
+
"""Return the absolute path to this environment's pip executable."""
|
| 129 |
+
base_path = self.get_full_path()
|
| 130 |
+
if os.name == "nt":
|
| 131 |
+
return os.path.join(base_path, "Scripts", "pip.exe")
|
| 132 |
+
else:
|
| 133 |
+
return os.path.join(base_path, "bin", "pip")
|
| 134 |
+
|
| 135 |
+
def exists(self) -> bool:
|
| 136 |
+
"""Check if the environment directory exists on disk."""
|
| 137 |
+
return os.path.isdir(self.get_full_path())
|
| 138 |
+
|
| 139 |
+
def python_exists(self) -> bool:
|
| 140 |
+
"""Check if the Python executable exists."""
|
| 141 |
+
return os.path.isfile(self.get_python_executable())
|
| 142 |
+
|
| 143 |
+
@property
|
| 144 |
+
def script_count(self) -> int:
|
| 145 |
+
"""Return the number of scripts using this environment."""
|
| 146 |
+
return self.scripts.count()
|
| 147 |
+
|
| 148 |
+
@property
|
| 149 |
+
def can_delete(self) -> bool:
|
| 150 |
+
"""Check if this environment can be deleted (no scripts assigned)."""
|
| 151 |
+
return self.script_count == 0 and not self.is_default
|
core/models/package.py
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Package operation model for tracking async pip operations.
|
| 3 |
+
"""
|
| 4 |
+
|
| 5 |
+
import uuid
|
| 6 |
+
|
| 7 |
+
from django.conf import settings
|
| 8 |
+
from django.db import models
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
class PackageOperation(models.Model):
|
| 12 |
+
"""
|
| 13 |
+
Tracks async package installation/uninstallation operations.
|
| 14 |
+
Used to provide progress feedback in the UI.
|
| 15 |
+
"""
|
| 16 |
+
|
| 17 |
+
class Operation(models.TextChoices):
|
| 18 |
+
INSTALL = "install", "Install"
|
| 19 |
+
UNINSTALL = "uninstall", "Uninstall"
|
| 20 |
+
BULK_INSTALL = "bulk_install", "Bulk Install"
|
| 21 |
+
|
| 22 |
+
class Status(models.TextChoices):
|
| 23 |
+
PENDING = "pending", "Pending"
|
| 24 |
+
RUNNING = "running", "Running"
|
| 25 |
+
SUCCESS = "success", "Success"
|
| 26 |
+
FAILED = "failed", "Failed"
|
| 27 |
+
|
| 28 |
+
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
| 29 |
+
|
| 30 |
+
environment = models.ForeignKey(
|
| 31 |
+
"core.Environment",
|
| 32 |
+
on_delete=models.CASCADE,
|
| 33 |
+
related_name="package_operations",
|
| 34 |
+
)
|
| 35 |
+
|
| 36 |
+
operation = models.CharField(
|
| 37 |
+
max_length=20,
|
| 38 |
+
choices=Operation.choices,
|
| 39 |
+
)
|
| 40 |
+
|
| 41 |
+
package_spec = models.CharField(
|
| 42 |
+
max_length=500,
|
| 43 |
+
help_text="Package specification (e.g., 'requests==2.31.0') or requirements content for bulk install",
|
| 44 |
+
)
|
| 45 |
+
|
| 46 |
+
status = models.CharField(
|
| 47 |
+
max_length=20,
|
| 48 |
+
choices=Status.choices,
|
| 49 |
+
default=Status.PENDING,
|
| 50 |
+
)
|
| 51 |
+
|
| 52 |
+
output = models.TextField(
|
| 53 |
+
blank=True,
|
| 54 |
+
help_text="pip stdout",
|
| 55 |
+
)
|
| 56 |
+
|
| 57 |
+
error = models.TextField(
|
| 58 |
+
blank=True,
|
| 59 |
+
help_text="pip stderr",
|
| 60 |
+
)
|
| 61 |
+
|
| 62 |
+
task_id = models.CharField(
|
| 63 |
+
max_length=100,
|
| 64 |
+
blank=True,
|
| 65 |
+
help_text="django-q2 task ID for tracking",
|
| 66 |
+
)
|
| 67 |
+
|
| 68 |
+
created_at = models.DateTimeField(auto_now_add=True)
|
| 69 |
+
started_at = models.DateTimeField(null=True, blank=True)
|
| 70 |
+
completed_at = models.DateTimeField(null=True, blank=True)
|
| 71 |
+
|
| 72 |
+
created_by = models.ForeignKey(
|
| 73 |
+
settings.AUTH_USER_MODEL,
|
| 74 |
+
on_delete=models.SET_NULL,
|
| 75 |
+
null=True,
|
| 76 |
+
blank=True,
|
| 77 |
+
related_name="package_operations",
|
| 78 |
+
)
|
| 79 |
+
|
| 80 |
+
class Meta:
|
| 81 |
+
db_table = "package_operations"
|
| 82 |
+
ordering = ["-created_at"]
|
| 83 |
+
verbose_name = "package operation"
|
| 84 |
+
verbose_name_plural = "package operations"
|
| 85 |
+
|
| 86 |
+
def __str__(self):
|
| 87 |
+
return f"{self.get_operation_display()} {self.package_spec[:50]} ({self.status})"
|
| 88 |
+
|
| 89 |
+
@property
|
| 90 |
+
def is_finished(self) -> bool:
|
| 91 |
+
"""Check if the operation has completed (success or failed)."""
|
| 92 |
+
return self.status in (self.Status.SUCCESS, self.Status.FAILED)
|
| 93 |
+
|
| 94 |
+
@property
|
| 95 |
+
def is_successful(self) -> bool:
|
| 96 |
+
"""Check if the operation completed successfully."""
|
| 97 |
+
return self.status == self.Status.SUCCESS
|
| 98 |
+
|
| 99 |
+
@property
|
| 100 |
+
def duration(self):
|
| 101 |
+
"""Return the duration of the operation if completed."""
|
| 102 |
+
if self.started_at and self.completed_at:
|
| 103 |
+
return self.completed_at - self.started_at
|
| 104 |
+
return None
|
| 105 |
+
|
| 106 |
+
@property
|
| 107 |
+
def duration_display(self) -> str:
|
| 108 |
+
"""Return a human-readable duration string."""
|
| 109 |
+
duration = self.duration
|
| 110 |
+
if not duration:
|
| 111 |
+
return "-"
|
| 112 |
+
total_seconds = int(duration.total_seconds())
|
| 113 |
+
if total_seconds < 60:
|
| 114 |
+
return f"{total_seconds}s"
|
| 115 |
+
minutes = total_seconds // 60
|
| 116 |
+
seconds = total_seconds % 60
|
| 117 |
+
return f"{minutes}m {seconds}s"
|
core/models/run.py
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Run model for tracking script execution history.
|
| 3 |
+
"""
|
| 4 |
+
|
| 5 |
+
import uuid
|
| 6 |
+
|
| 7 |
+
from django.conf import settings
|
| 8 |
+
from django.db import models
|
| 9 |
+
|
| 10 |
+
from .script import Script
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
class Run(models.Model):
|
| 14 |
+
"""
|
| 15 |
+
Represents a single execution of a script.
|
| 16 |
+
Tracks timing, output, and status of each run.
|
| 17 |
+
"""
|
| 18 |
+
|
| 19 |
+
class Status(models.TextChoices):
|
| 20 |
+
PENDING = "pending", "Pending"
|
| 21 |
+
RUNNING = "running", "Running"
|
| 22 |
+
SUCCESS = "success", "Success"
|
| 23 |
+
FAILED = "failed", "Failed"
|
| 24 |
+
TIMEOUT = "timeout", "Timeout"
|
| 25 |
+
CANCELLED = "cancelled", "Cancelled"
|
| 26 |
+
|
| 27 |
+
class TriggerType(models.TextChoices):
|
| 28 |
+
MANUAL = "manual", "Manual"
|
| 29 |
+
SCHEDULED = "scheduled", "Scheduled"
|
| 30 |
+
API = "api", "API"
|
| 31 |
+
|
| 32 |
+
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
| 33 |
+
script = models.ForeignKey(
|
| 34 |
+
Script,
|
| 35 |
+
on_delete=models.CASCADE,
|
| 36 |
+
related_name="runs",
|
| 37 |
+
)
|
| 38 |
+
|
| 39 |
+
# Execution status
|
| 40 |
+
status = models.CharField(
|
| 41 |
+
max_length=20,
|
| 42 |
+
choices=Status.choices,
|
| 43 |
+
default=Status.PENDING,
|
| 44 |
+
db_index=True,
|
| 45 |
+
)
|
| 46 |
+
exit_code = models.IntegerField(
|
| 47 |
+
null=True,
|
| 48 |
+
blank=True,
|
| 49 |
+
help_text="Process exit code (0 = success)",
|
| 50 |
+
)
|
| 51 |
+
|
| 52 |
+
# Output capture
|
| 53 |
+
stdout = models.TextField(
|
| 54 |
+
blank=True,
|
| 55 |
+
help_text="Standard output from script execution",
|
| 56 |
+
)
|
| 57 |
+
stderr = models.TextField(
|
| 58 |
+
blank=True,
|
| 59 |
+
help_text="Standard error from script execution",
|
| 60 |
+
)
|
| 61 |
+
|
| 62 |
+
# Timing
|
| 63 |
+
started_at = models.DateTimeField(
|
| 64 |
+
null=True,
|
| 65 |
+
blank=True,
|
| 66 |
+
help_text="When execution started",
|
| 67 |
+
)
|
| 68 |
+
ended_at = models.DateTimeField(
|
| 69 |
+
null=True,
|
| 70 |
+
blank=True,
|
| 71 |
+
help_text="When execution ended",
|
| 72 |
+
)
|
| 73 |
+
|
| 74 |
+
# Snapshot of script code at execution time (for audit trail)
|
| 75 |
+
code_snapshot = models.TextField(
|
| 76 |
+
blank=True,
|
| 77 |
+
help_text="Copy of script code at time of execution",
|
| 78 |
+
)
|
| 79 |
+
|
| 80 |
+
# Who triggered the run
|
| 81 |
+
triggered_by = models.ForeignKey(
|
| 82 |
+
settings.AUTH_USER_MODEL,
|
| 83 |
+
on_delete=models.SET_NULL,
|
| 84 |
+
null=True,
|
| 85 |
+
blank=True,
|
| 86 |
+
related_name="triggered_runs",
|
| 87 |
+
)
|
| 88 |
+
|
| 89 |
+
# django-q2 task tracking
|
| 90 |
+
task_id = models.CharField(
|
| 91 |
+
max_length=100,
|
| 92 |
+
blank=True,
|
| 93 |
+
db_index=True,
|
| 94 |
+
help_text="django-q2 task ID for tracking async execution",
|
| 95 |
+
)
|
| 96 |
+
|
| 97 |
+
# How this run was triggered
|
| 98 |
+
trigger_type = models.CharField(
|
| 99 |
+
max_length=20,
|
| 100 |
+
choices=TriggerType.choices,
|
| 101 |
+
default=TriggerType.MANUAL,
|
| 102 |
+
help_text="How this run was triggered",
|
| 103 |
+
)
|
| 104 |
+
|
| 105 |
+
created_at = models.DateTimeField(auto_now_add=True)
|
| 106 |
+
|
| 107 |
+
class Meta:
|
| 108 |
+
db_table = "runs"
|
| 109 |
+
verbose_name = "run"
|
| 110 |
+
verbose_name_plural = "runs"
|
| 111 |
+
ordering = ["-created_at"]
|
| 112 |
+
indexes = [
|
| 113 |
+
models.Index(fields=["script", "-created_at"]),
|
| 114 |
+
models.Index(fields=["status", "-created_at"]),
|
| 115 |
+
]
|
| 116 |
+
|
| 117 |
+
def __str__(self):
|
| 118 |
+
return f"Run {self.id} - {self.script.name} ({self.status})"
|
| 119 |
+
|
| 120 |
+
@property
|
| 121 |
+
def duration(self) -> float | None:
|
| 122 |
+
"""Return the duration in seconds, or None if not completed."""
|
| 123 |
+
if self.started_at and self.ended_at:
|
| 124 |
+
return (self.ended_at - self.started_at).total_seconds()
|
| 125 |
+
return None
|
| 126 |
+
|
| 127 |
+
@property
|
| 128 |
+
def duration_display(self) -> str:
|
| 129 |
+
"""Return a human-readable duration string."""
|
| 130 |
+
d = self.duration
|
| 131 |
+
if d is None:
|
| 132 |
+
return "-"
|
| 133 |
+
if d < 60:
|
| 134 |
+
return f"{d:.1f}s"
|
| 135 |
+
minutes = int(d // 60)
|
| 136 |
+
seconds = d % 60
|
| 137 |
+
if minutes < 60:
|
| 138 |
+
return f"{minutes}m {seconds:.0f}s"
|
| 139 |
+
hours = minutes // 60
|
| 140 |
+
minutes = minutes % 60
|
| 141 |
+
return f"{hours}h {minutes}m"
|
| 142 |
+
|
| 143 |
+
@property
|
| 144 |
+
def is_finished(self) -> bool:
|
| 145 |
+
"""Check if the run has completed (successfully or not)."""
|
| 146 |
+
return self.status in [
|
| 147 |
+
self.Status.SUCCESS,
|
| 148 |
+
self.Status.FAILED,
|
| 149 |
+
self.Status.TIMEOUT,
|
| 150 |
+
self.Status.CANCELLED,
|
| 151 |
+
]
|
| 152 |
+
|
| 153 |
+
@property
|
| 154 |
+
def is_successful(self) -> bool:
|
| 155 |
+
"""Check if the run completed successfully."""
|
| 156 |
+
return self.status == self.Status.SUCCESS
|
| 157 |
+
|
| 158 |
+
@property
|
| 159 |
+
def has_output(self) -> bool:
|
| 160 |
+
"""Check if there is any output (stdout or stderr)."""
|
| 161 |
+
return bool(self.stdout or self.stderr)
|
| 162 |
+
|
| 163 |
+
def get_stdout_preview(self, max_lines: int = 10) -> str:
|
| 164 |
+
"""Return a preview of stdout (last N lines)."""
|
| 165 |
+
if not self.stdout:
|
| 166 |
+
return ""
|
| 167 |
+
lines = self.stdout.split("\n")
|
| 168 |
+
if len(lines) <= max_lines:
|
| 169 |
+
return self.stdout
|
| 170 |
+
return "...\n" + "\n".join(lines[-max_lines:])
|
| 171 |
+
|
| 172 |
+
def get_stderr_preview(self, max_lines: int = 10) -> str:
|
| 173 |
+
"""Return a preview of stderr (last N lines)."""
|
| 174 |
+
if not self.stderr:
|
| 175 |
+
return ""
|
| 176 |
+
lines = self.stderr.split("\n")
|
| 177 |
+
if len(lines) <= max_lines:
|
| 178 |
+
return self.stderr
|
| 179 |
+
return "...\n" + "\n".join(lines[-max_lines:])
|
core/models/schedule.py
ADDED
|
@@ -0,0 +1,228 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Schedule models for script execution scheduling.
|
| 3 |
+
"""
|
| 4 |
+
|
| 5 |
+
import uuid
|
| 6 |
+
|
| 7 |
+
from django.conf import settings
|
| 8 |
+
from django.db import models
|
| 9 |
+
|
| 10 |
+
from .script import Script
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
class ScriptSchedule(models.Model):
|
| 14 |
+
"""
|
| 15 |
+
Represents a schedule configuration for a Script.
|
| 16 |
+
Links to django-q2 Schedule objects for actual task scheduling.
|
| 17 |
+
"""
|
| 18 |
+
|
| 19 |
+
class RunMode(models.TextChoices):
|
| 20 |
+
MANUAL = "manual", "Manual"
|
| 21 |
+
INTERVAL = "interval", "Interval"
|
| 22 |
+
DAILY = "daily", "Daily"
|
| 23 |
+
WEEKLY = "weekly", "Weekly"
|
| 24 |
+
MONTHLY = "monthly", "Monthly"
|
| 25 |
+
|
| 26 |
+
class IntervalChoice(models.IntegerChoices):
|
| 27 |
+
FIVE_MINUTES = 5, "Every 5 minutes"
|
| 28 |
+
TEN_MINUTES = 10, "Every 10 minutes"
|
| 29 |
+
FIFTEEN_MINUTES = 15, "Every 15 minutes"
|
| 30 |
+
THIRTY_MINUTES = 30, "Every 30 minutes"
|
| 31 |
+
ONE_HOUR = 60, "Every hour"
|
| 32 |
+
TWO_HOURS = 120, "Every 2 hours"
|
| 33 |
+
SIX_HOURS = 360, "Every 6 hours"
|
| 34 |
+
TWELVE_HOURS = 720, "Every 12 hours"
|
| 35 |
+
|
| 36 |
+
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
| 37 |
+
|
| 38 |
+
# One-to-one relationship with Script
|
| 39 |
+
script = models.OneToOneField(
|
| 40 |
+
Script,
|
| 41 |
+
on_delete=models.CASCADE,
|
| 42 |
+
related_name="schedule",
|
| 43 |
+
)
|
| 44 |
+
|
| 45 |
+
# Run mode selection
|
| 46 |
+
run_mode = models.CharField(
|
| 47 |
+
max_length=20,
|
| 48 |
+
choices=RunMode.choices,
|
| 49 |
+
default=RunMode.MANUAL,
|
| 50 |
+
)
|
| 51 |
+
|
| 52 |
+
# Interval mode configuration
|
| 53 |
+
interval_minutes = models.PositiveIntegerField(
|
| 54 |
+
null=True,
|
| 55 |
+
blank=True,
|
| 56 |
+
choices=IntervalChoice.choices,
|
| 57 |
+
help_text="Interval in minutes (for interval mode)",
|
| 58 |
+
)
|
| 59 |
+
|
| 60 |
+
# Daily mode configuration - stored as JSON array of time strings
|
| 61 |
+
daily_times = models.JSONField(
|
| 62 |
+
default=list,
|
| 63 |
+
blank=True,
|
| 64 |
+
help_text='List of times in HH:MM format, e.g., ["09:00", "18:00"]',
|
| 65 |
+
)
|
| 66 |
+
|
| 67 |
+
# Timezone for scheduled runs
|
| 68 |
+
timezone = models.CharField(
|
| 69 |
+
max_length=50,
|
| 70 |
+
default="UTC",
|
| 71 |
+
help_text="Timezone for scheduled runs (e.g., 'America/New_York')",
|
| 72 |
+
)
|
| 73 |
+
|
| 74 |
+
# Weekly mode configuration
|
| 75 |
+
weekly_days = models.JSONField(
|
| 76 |
+
default=list,
|
| 77 |
+
blank=True,
|
| 78 |
+
help_text='Days of week [0-6] where 0=Monday, e.g., [0, 2, 4] for Mon/Wed/Fri',
|
| 79 |
+
)
|
| 80 |
+
weekly_times = models.JSONField(
|
| 81 |
+
default=list,
|
| 82 |
+
blank=True,
|
| 83 |
+
help_text='List of times in HH:MM format for weekly mode',
|
| 84 |
+
)
|
| 85 |
+
|
| 86 |
+
# Monthly mode configuration
|
| 87 |
+
monthly_days = models.JSONField(
|
| 88 |
+
default=list,
|
| 89 |
+
blank=True,
|
| 90 |
+
help_text='Days of month [1-31], e.g., [1, 15] for 1st and 15th',
|
| 91 |
+
)
|
| 92 |
+
monthly_times = models.JSONField(
|
| 93 |
+
default=list,
|
| 94 |
+
blank=True,
|
| 95 |
+
help_text='List of times in HH:MM format for monthly mode',
|
| 96 |
+
)
|
| 97 |
+
|
| 98 |
+
# Schedule state
|
| 99 |
+
is_active = models.BooleanField(
|
| 100 |
+
default=True,
|
| 101 |
+
help_text="Whether this schedule is active (can be paused without deleting)",
|
| 102 |
+
)
|
| 103 |
+
|
| 104 |
+
# Link to django-q2 Schedule objects (can be multiple for daily with multiple times)
|
| 105 |
+
# Stored as JSON array of django-q2 Schedule IDs
|
| 106 |
+
q_schedule_ids = models.JSONField(
|
| 107 |
+
default=list,
|
| 108 |
+
blank=True,
|
| 109 |
+
help_text="django-q2 Schedule object IDs",
|
| 110 |
+
)
|
| 111 |
+
|
| 112 |
+
# Tracking
|
| 113 |
+
next_run = models.DateTimeField(
|
| 114 |
+
null=True,
|
| 115 |
+
blank=True,
|
| 116 |
+
help_text="Cached next scheduled run time",
|
| 117 |
+
)
|
| 118 |
+
last_scheduled_run = models.DateTimeField(
|
| 119 |
+
null=True,
|
| 120 |
+
blank=True,
|
| 121 |
+
help_text="When the schedule last triggered a run",
|
| 122 |
+
)
|
| 123 |
+
|
| 124 |
+
# Metadata
|
| 125 |
+
created_at = models.DateTimeField(auto_now_add=True)
|
| 126 |
+
updated_at = models.DateTimeField(auto_now=True)
|
| 127 |
+
created_by = models.ForeignKey(
|
| 128 |
+
settings.AUTH_USER_MODEL,
|
| 129 |
+
on_delete=models.SET_NULL,
|
| 130 |
+
null=True,
|
| 131 |
+
blank=True,
|
| 132 |
+
related_name="created_schedules",
|
| 133 |
+
)
|
| 134 |
+
|
| 135 |
+
class Meta:
|
| 136 |
+
db_table = "script_schedules"
|
| 137 |
+
verbose_name = "script schedule"
|
| 138 |
+
verbose_name_plural = "script schedules"
|
| 139 |
+
|
| 140 |
+
def __str__(self):
|
| 141 |
+
return f"Schedule for {self.script.name} ({self.run_mode})"
|
| 142 |
+
|
| 143 |
+
@property
|
| 144 |
+
def is_scheduled(self) -> bool:
|
| 145 |
+
"""Check if this script has an active schedule (not manual)."""
|
| 146 |
+
return self.run_mode != self.RunMode.MANUAL and self.is_active
|
| 147 |
+
|
| 148 |
+
DAY_NAMES = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]
|
| 149 |
+
|
| 150 |
+
@property
|
| 151 |
+
def schedule_display(self) -> str:
|
| 152 |
+
"""Human-readable schedule description."""
|
| 153 |
+
if self.run_mode == self.RunMode.MANUAL:
|
| 154 |
+
return "Manual"
|
| 155 |
+
elif self.run_mode == self.RunMode.INTERVAL:
|
| 156 |
+
# Find the display label from choices
|
| 157 |
+
for value, label in self.IntervalChoice.choices:
|
| 158 |
+
if value == self.interval_minutes:
|
| 159 |
+
return label
|
| 160 |
+
return f"Every {self.interval_minutes} minutes"
|
| 161 |
+
elif self.run_mode == self.RunMode.DAILY:
|
| 162 |
+
times = ", ".join(self.daily_times) if self.daily_times else "No times set"
|
| 163 |
+
return f"Daily at {times} ({self.timezone})"
|
| 164 |
+
elif self.run_mode == self.RunMode.WEEKLY:
|
| 165 |
+
days = ", ".join(self.DAY_NAMES[d] for d in sorted(self.weekly_days)) if self.weekly_days else "No days set"
|
| 166 |
+
times = ", ".join(self.weekly_times) if self.weekly_times else "No times set"
|
| 167 |
+
return f"Weekly on {days} at {times} ({self.timezone})"
|
| 168 |
+
elif self.run_mode == self.RunMode.MONTHLY:
|
| 169 |
+
days = ", ".join(str(d) for d in sorted(self.monthly_days)) if self.monthly_days else "No days set"
|
| 170 |
+
times = ", ".join(self.monthly_times) if self.monthly_times else "No times set"
|
| 171 |
+
return f"Monthly on day {days} at {times} ({self.timezone})"
|
| 172 |
+
return "Unknown"
|
| 173 |
+
|
| 174 |
+
|
| 175 |
+
class ScheduleHistory(models.Model):
|
| 176 |
+
"""
|
| 177 |
+
Tracks changes to script schedules for audit purposes.
|
| 178 |
+
"""
|
| 179 |
+
|
| 180 |
+
class ChangeType(models.TextChoices):
|
| 181 |
+
CREATED = "created", "Created"
|
| 182 |
+
UPDATED = "updated", "Updated"
|
| 183 |
+
ENABLED = "enabled", "Enabled"
|
| 184 |
+
DISABLED = "disabled", "Disabled"
|
| 185 |
+
DELETED = "deleted", "Deleted"
|
| 186 |
+
|
| 187 |
+
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
| 188 |
+
|
| 189 |
+
schedule = models.ForeignKey(
|
| 190 |
+
ScriptSchedule,
|
| 191 |
+
on_delete=models.CASCADE,
|
| 192 |
+
related_name="history",
|
| 193 |
+
)
|
| 194 |
+
|
| 195 |
+
change_type = models.CharField(
|
| 196 |
+
max_length=20,
|
| 197 |
+
choices=ChangeType.choices,
|
| 198 |
+
)
|
| 199 |
+
|
| 200 |
+
# Snapshot of schedule configuration at time of change
|
| 201 |
+
previous_config = models.JSONField(
|
| 202 |
+
null=True,
|
| 203 |
+
blank=True,
|
| 204 |
+
help_text="Previous schedule configuration",
|
| 205 |
+
)
|
| 206 |
+
new_config = models.JSONField(
|
| 207 |
+
null=True,
|
| 208 |
+
blank=True,
|
| 209 |
+
help_text="New schedule configuration",
|
| 210 |
+
)
|
| 211 |
+
|
| 212 |
+
changed_by = models.ForeignKey(
|
| 213 |
+
settings.AUTH_USER_MODEL,
|
| 214 |
+
on_delete=models.SET_NULL,
|
| 215 |
+
null=True,
|
| 216 |
+
blank=True,
|
| 217 |
+
)
|
| 218 |
+
|
| 219 |
+
created_at = models.DateTimeField(auto_now_add=True)
|
| 220 |
+
|
| 221 |
+
class Meta:
|
| 222 |
+
db_table = "schedule_history"
|
| 223 |
+
verbose_name = "schedule history"
|
| 224 |
+
verbose_name_plural = "schedule history entries"
|
| 225 |
+
ordering = ["-created_at"]
|
| 226 |
+
|
| 227 |
+
def __str__(self):
|
| 228 |
+
return f"{self.change_type} - {self.schedule.script.name} at {self.created_at}"
|
core/models/script.py
ADDED
|
@@ -0,0 +1,205 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Script model for user-created Python scripts.
|
| 3 |
+
"""
|
| 4 |
+
|
| 5 |
+
import secrets
|
| 6 |
+
import uuid
|
| 7 |
+
|
| 8 |
+
from django.conf import settings
|
| 9 |
+
from django.db import models
|
| 10 |
+
|
| 11 |
+
from .environment import Environment
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
class Script(models.Model):
|
| 15 |
+
"""
|
| 16 |
+
Represents a Python script that can be executed.
|
| 17 |
+
Scripts are associated with an environment and can be run manually or on schedule.
|
| 18 |
+
"""
|
| 19 |
+
|
| 20 |
+
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
| 21 |
+
name = models.CharField(max_length=200)
|
| 22 |
+
description = models.TextField(blank=True)
|
| 23 |
+
|
| 24 |
+
# The actual Python code
|
| 25 |
+
code = models.TextField(help_text="Python code to execute")
|
| 26 |
+
|
| 27 |
+
# Execution settings
|
| 28 |
+
environment = models.ForeignKey(
|
| 29 |
+
Environment,
|
| 30 |
+
on_delete=models.PROTECT,
|
| 31 |
+
related_name="scripts",
|
| 32 |
+
help_text="Python environment to use for execution",
|
| 33 |
+
)
|
| 34 |
+
|
| 35 |
+
# Tags for categorization
|
| 36 |
+
tags = models.ManyToManyField(
|
| 37 |
+
"Tag",
|
| 38 |
+
blank=True,
|
| 39 |
+
related_name="scripts",
|
| 40 |
+
help_text="Tags for organizing and filtering scripts",
|
| 41 |
+
)
|
| 42 |
+
|
| 43 |
+
timeout_seconds = models.PositiveIntegerField(
|
| 44 |
+
default=3600, # 1 hour default
|
| 45 |
+
help_text="Maximum execution time in seconds (default: 1 hour, max: 24 hours)",
|
| 46 |
+
)
|
| 47 |
+
|
| 48 |
+
# Status
|
| 49 |
+
is_enabled = models.BooleanField(
|
| 50 |
+
default=True,
|
| 51 |
+
help_text="Whether this script can be executed",
|
| 52 |
+
)
|
| 53 |
+
|
| 54 |
+
# Webhook
|
| 55 |
+
webhook_token = models.CharField(
|
| 56 |
+
max_length=64,
|
| 57 |
+
unique=True,
|
| 58 |
+
null=True,
|
| 59 |
+
blank=True,
|
| 60 |
+
db_index=True,
|
| 61 |
+
help_text="Unique token for webhook URL (auto-generated)",
|
| 62 |
+
)
|
| 63 |
+
|
| 64 |
+
# Notification settings
|
| 65 |
+
class NotifyOn(models.TextChoices):
|
| 66 |
+
NEVER = "never", "Never"
|
| 67 |
+
FAILURE = "failure", "On Failure"
|
| 68 |
+
SUCCESS = "success", "On Success"
|
| 69 |
+
BOTH = "both", "On Success and Failure"
|
| 70 |
+
|
| 71 |
+
notify_on = models.CharField(
|
| 72 |
+
max_length=20,
|
| 73 |
+
choices=NotifyOn.choices,
|
| 74 |
+
default=NotifyOn.NEVER,
|
| 75 |
+
help_text="When to send notifications for this script",
|
| 76 |
+
)
|
| 77 |
+
notify_email = models.EmailField(
|
| 78 |
+
blank=True,
|
| 79 |
+
help_text="Override email for this script (uses global default if empty)",
|
| 80 |
+
)
|
| 81 |
+
notify_webhook_url = models.URLField(
|
| 82 |
+
blank=True,
|
| 83 |
+
max_length=500,
|
| 84 |
+
help_text="URL to POST notification webhooks to",
|
| 85 |
+
)
|
| 86 |
+
notify_webhook_enabled = models.BooleanField(
|
| 87 |
+
default=False,
|
| 88 |
+
help_text="Enable webhook notifications for this script",
|
| 89 |
+
)
|
| 90 |
+
|
| 91 |
+
# Retention overrides (null = use global settings)
|
| 92 |
+
retention_days_override = models.PositiveIntegerField(
|
| 93 |
+
null=True,
|
| 94 |
+
blank=True,
|
| 95 |
+
help_text="Override global retention days for this script",
|
| 96 |
+
)
|
| 97 |
+
retention_count_override = models.PositiveIntegerField(
|
| 98 |
+
null=True,
|
| 99 |
+
blank=True,
|
| 100 |
+
help_text="Override global retention count for this script",
|
| 101 |
+
)
|
| 102 |
+
|
| 103 |
+
# Archive fields (soft delete)
|
| 104 |
+
archived_at = models.DateTimeField(
|
| 105 |
+
null=True,
|
| 106 |
+
blank=True,
|
| 107 |
+
help_text="When this script was archived (null = not archived)",
|
| 108 |
+
)
|
| 109 |
+
archived_by = models.ForeignKey(
|
| 110 |
+
settings.AUTH_USER_MODEL,
|
| 111 |
+
on_delete=models.SET_NULL,
|
| 112 |
+
null=True,
|
| 113 |
+
blank=True,
|
| 114 |
+
related_name="archived_scripts",
|
| 115 |
+
)
|
| 116 |
+
|
| 117 |
+
# Metadata
|
| 118 |
+
created_at = models.DateTimeField(auto_now_add=True)
|
| 119 |
+
updated_at = models.DateTimeField(auto_now=True)
|
| 120 |
+
created_by = models.ForeignKey(
|
| 121 |
+
settings.AUTH_USER_MODEL,
|
| 122 |
+
on_delete=models.SET_NULL,
|
| 123 |
+
null=True,
|
| 124 |
+
blank=True,
|
| 125 |
+
related_name="scripts",
|
| 126 |
+
)
|
| 127 |
+
|
| 128 |
+
class Meta:
|
| 129 |
+
db_table = "scripts"
|
| 130 |
+
verbose_name = "script"
|
| 131 |
+
verbose_name_plural = "scripts"
|
| 132 |
+
ordering = ["-updated_at"]
|
| 133 |
+
|
| 134 |
+
def __str__(self):
|
| 135 |
+
if self.is_archived:
|
| 136 |
+
return f"{self.name} (archived)"
|
| 137 |
+
status = "enabled" if self.is_enabled else "disabled"
|
| 138 |
+
return f"{self.name} ({status})"
|
| 139 |
+
|
| 140 |
+
@property
|
| 141 |
+
def is_archived(self) -> bool:
|
| 142 |
+
"""Check if this script is archived."""
|
| 143 |
+
return self.archived_at is not None
|
| 144 |
+
|
| 145 |
+
@property
|
| 146 |
+
def can_run(self) -> bool:
|
| 147 |
+
"""Check if this script can be executed (enabled and not archived)."""
|
| 148 |
+
return self.is_enabled and not self.is_archived
|
| 149 |
+
|
| 150 |
+
@property
|
| 151 |
+
def last_run(self):
|
| 152 |
+
"""Return the most recent run for this script."""
|
| 153 |
+
return self.runs.order_by("-created_at").first()
|
| 154 |
+
|
| 155 |
+
@property
|
| 156 |
+
def last_successful_run(self):
|
| 157 |
+
"""Return the most recent successful run for this script."""
|
| 158 |
+
return self.runs.filter(status="success").order_by("-created_at").first()
|
| 159 |
+
|
| 160 |
+
@property
|
| 161 |
+
def run_count(self) -> int:
|
| 162 |
+
"""Return the total number of runs for this script."""
|
| 163 |
+
return self.runs.count()
|
| 164 |
+
|
| 165 |
+
@property
|
| 166 |
+
def success_rate(self) -> float | None:
|
| 167 |
+
"""Return the success rate as a percentage, or None if no runs."""
|
| 168 |
+
total = self.run_count
|
| 169 |
+
if total == 0:
|
| 170 |
+
return None
|
| 171 |
+
successful = self.runs.filter(status="success").count()
|
| 172 |
+
return (successful / total) * 100
|
| 173 |
+
|
| 174 |
+
def get_code_preview(self, max_lines: int = 5) -> str:
|
| 175 |
+
"""Return a preview of the script code (first N lines)."""
|
| 176 |
+
lines = self.code.split("\n")[:max_lines]
|
| 177 |
+
preview = "\n".join(lines)
|
| 178 |
+
if len(self.code.split("\n")) > max_lines:
|
| 179 |
+
preview += "\n..."
|
| 180 |
+
return preview
|
| 181 |
+
|
| 182 |
+
@staticmethod
|
| 183 |
+
def generate_webhook_token() -> str:
|
| 184 |
+
"""Generate a secure random webhook token (64 chars, URL-safe)."""
|
| 185 |
+
return secrets.token_urlsafe(48) # 48 bytes = 64 chars in base64
|
| 186 |
+
|
| 187 |
+
def create_webhook_token(self) -> str:
|
| 188 |
+
"""Create and save a new webhook token for this script."""
|
| 189 |
+
self.webhook_token = self.generate_webhook_token()
|
| 190 |
+
self.save(update_fields=["webhook_token", "updated_at"])
|
| 191 |
+
return self.webhook_token
|
| 192 |
+
|
| 193 |
+
def regenerate_webhook_token(self) -> str:
|
| 194 |
+
"""Regenerate the webhook token, invalidating the old one."""
|
| 195 |
+
return self.create_webhook_token()
|
| 196 |
+
|
| 197 |
+
def clear_webhook_token(self) -> None:
|
| 198 |
+
"""Remove the webhook token, disabling webhook access."""
|
| 199 |
+
self.webhook_token = None
|
| 200 |
+
self.save(update_fields=["webhook_token", "updated_at"])
|
| 201 |
+
|
| 202 |
+
@property
|
| 203 |
+
def has_webhook(self) -> bool:
|
| 204 |
+
"""Check if this script has a webhook token configured."""
|
| 205 |
+
return bool(self.webhook_token)
|
core/models/secret.py
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Secret model for encrypted credential storage.
|
| 3 |
+
"""
|
| 4 |
+
|
| 5 |
+
import uuid
|
| 6 |
+
|
| 7 |
+
from django.conf import settings
|
| 8 |
+
from django.db import models
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
class Secret(models.Model):
|
| 12 |
+
"""
|
| 13 |
+
Stores encrypted secrets (API keys, credentials) that are injected
|
| 14 |
+
as environment variables when scripts run.
|
| 15 |
+
"""
|
| 16 |
+
|
| 17 |
+
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
| 18 |
+
|
| 19 |
+
# Key name - must be uppercase with underscores (e.g., API_KEY, DATABASE_URL)
|
| 20 |
+
key = models.CharField(
|
| 21 |
+
max_length=100,
|
| 22 |
+
unique=True,
|
| 23 |
+
help_text="Environment variable name (uppercase, underscores allowed)",
|
| 24 |
+
)
|
| 25 |
+
|
| 26 |
+
# Encrypted value - stores the Fernet-encrypted bytes as base64 string
|
| 27 |
+
encrypted_value = models.TextField(
|
| 28 |
+
help_text="Fernet-encrypted secret value",
|
| 29 |
+
)
|
| 30 |
+
|
| 31 |
+
# Optional description to help remember what this secret is for
|
| 32 |
+
description = models.TextField(
|
| 33 |
+
blank=True,
|
| 34 |
+
help_text="Optional description of what this secret is used for",
|
| 35 |
+
)
|
| 36 |
+
|
| 37 |
+
# Timestamps
|
| 38 |
+
created_at = models.DateTimeField(auto_now_add=True)
|
| 39 |
+
updated_at = models.DateTimeField(auto_now=True)
|
| 40 |
+
|
| 41 |
+
# Who created this secret
|
| 42 |
+
created_by = models.ForeignKey(
|
| 43 |
+
settings.AUTH_USER_MODEL,
|
| 44 |
+
on_delete=models.SET_NULL,
|
| 45 |
+
null=True,
|
| 46 |
+
blank=True,
|
| 47 |
+
related_name="created_secrets",
|
| 48 |
+
)
|
| 49 |
+
|
| 50 |
+
class Meta:
|
| 51 |
+
db_table = "secrets"
|
| 52 |
+
verbose_name = "secret"
|
| 53 |
+
verbose_name_plural = "secrets"
|
| 54 |
+
ordering = ["key"]
|
| 55 |
+
|
| 56 |
+
def __str__(self):
|
| 57 |
+
return self.key
|
| 58 |
+
|
| 59 |
+
def get_masked_value(self) -> str:
|
| 60 |
+
"""
|
| 61 |
+
Return a masked preview of the decrypted value.
|
| 62 |
+
Shows first 3 and last 3 characters with ... in between.
|
| 63 |
+
Example: "sk-abc123xyz789" -> "sk-...789"
|
| 64 |
+
"""
|
| 65 |
+
from core.services import EncryptionService
|
| 66 |
+
|
| 67 |
+
try:
|
| 68 |
+
value = EncryptionService.decrypt(self.encrypted_value)
|
| 69 |
+
if len(value) <= 8:
|
| 70 |
+
return "*" * len(value)
|
| 71 |
+
return f"{value[:3]}...{value[-3:]}"
|
| 72 |
+
except Exception:
|
| 73 |
+
return "[decryption error]"
|
| 74 |
+
|
| 75 |
+
def get_decrypted_value(self) -> str:
|
| 76 |
+
"""Return the decrypted secret value."""
|
| 77 |
+
from core.services import EncryptionService
|
| 78 |
+
|
| 79 |
+
return EncryptionService.decrypt(self.encrypted_value)
|
| 80 |
+
|
| 81 |
+
def set_value(self, plaintext: str) -> None:
|
| 82 |
+
"""Encrypt and store a new value."""
|
| 83 |
+
from core.services import EncryptionService
|
| 84 |
+
|
| 85 |
+
self.encrypted_value = EncryptionService.encrypt(plaintext)
|
core/models/settings.py
ADDED
|
@@ -0,0 +1,342 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Global application settings model.
|
| 3 |
+
"""
|
| 4 |
+
|
| 5 |
+
from django.conf import settings
|
| 6 |
+
from django.db import models
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
class GlobalSettings(models.Model):
|
| 10 |
+
"""
|
| 11 |
+
Singleton model for global application settings.
|
| 12 |
+
Uses get_solo pattern - always ID=1.
|
| 13 |
+
"""
|
| 14 |
+
|
| 15 |
+
class EmailBackend(models.TextChoices):
|
| 16 |
+
DISABLED = "disabled", "Disabled"
|
| 17 |
+
SMTP = "smtp", "SMTP"
|
| 18 |
+
RESEND = "resend", "Resend API"
|
| 19 |
+
|
| 20 |
+
# Schedule settings
|
| 21 |
+
schedules_paused = models.BooleanField(
|
| 22 |
+
default=False,
|
| 23 |
+
help_text="Global pause for all scheduled script executions",
|
| 24 |
+
)
|
| 25 |
+
|
| 26 |
+
schedules_paused_at = models.DateTimeField(
|
| 27 |
+
null=True,
|
| 28 |
+
blank=True,
|
| 29 |
+
help_text="When schedules were paused",
|
| 30 |
+
)
|
| 31 |
+
|
| 32 |
+
schedules_paused_by = models.ForeignKey(
|
| 33 |
+
settings.AUTH_USER_MODEL,
|
| 34 |
+
on_delete=models.SET_NULL,
|
| 35 |
+
null=True,
|
| 36 |
+
blank=True,
|
| 37 |
+
related_name="+",
|
| 38 |
+
)
|
| 39 |
+
|
| 40 |
+
updated_at = models.DateTimeField(auto_now=True)
|
| 41 |
+
|
| 42 |
+
# Email notification settings
|
| 43 |
+
email_backend = models.CharField(
|
| 44 |
+
max_length=20,
|
| 45 |
+
choices=EmailBackend.choices,
|
| 46 |
+
default=EmailBackend.DISABLED,
|
| 47 |
+
help_text="Email backend for notifications",
|
| 48 |
+
)
|
| 49 |
+
|
| 50 |
+
# SMTP configuration
|
| 51 |
+
smtp_host = models.CharField(
|
| 52 |
+
max_length=255,
|
| 53 |
+
blank=True,
|
| 54 |
+
help_text="SMTP server hostname",
|
| 55 |
+
)
|
| 56 |
+
smtp_port = models.PositiveIntegerField(
|
| 57 |
+
default=587,
|
| 58 |
+
help_text="SMTP server port",
|
| 59 |
+
)
|
| 60 |
+
smtp_username = models.CharField(
|
| 61 |
+
max_length=255,
|
| 62 |
+
blank=True,
|
| 63 |
+
help_text="SMTP username",
|
| 64 |
+
)
|
| 65 |
+
smtp_password_encrypted = models.TextField(
|
| 66 |
+
blank=True,
|
| 67 |
+
help_text="SMTP password (encrypted)",
|
| 68 |
+
)
|
| 69 |
+
smtp_use_tls = models.BooleanField(
|
| 70 |
+
default=True,
|
| 71 |
+
help_text="Use TLS for SMTP connection",
|
| 72 |
+
)
|
| 73 |
+
smtp_from_email = models.EmailField(
|
| 74 |
+
blank=True,
|
| 75 |
+
help_text="From email address for SMTP",
|
| 76 |
+
)
|
| 77 |
+
|
| 78 |
+
# Resend configuration
|
| 79 |
+
resend_api_key_encrypted = models.TextField(
|
| 80 |
+
blank=True,
|
| 81 |
+
help_text="Resend API key (encrypted)",
|
| 82 |
+
)
|
| 83 |
+
resend_from_email = models.EmailField(
|
| 84 |
+
blank=True,
|
| 85 |
+
help_text="From email address for Resend",
|
| 86 |
+
)
|
| 87 |
+
|
| 88 |
+
# Default notification email
|
| 89 |
+
default_notification_email = models.EmailField(
|
| 90 |
+
blank=True,
|
| 91 |
+
help_text="Default email address for all notifications",
|
| 92 |
+
)
|
| 93 |
+
|
| 94 |
+
# General Settings
|
| 95 |
+
instance_name = models.CharField(
|
| 96 |
+
max_length=100,
|
| 97 |
+
default="PyRunner",
|
| 98 |
+
blank=True,
|
| 99 |
+
help_text="Instance name displayed in header and emails",
|
| 100 |
+
)
|
| 101 |
+
timezone = models.CharField(
|
| 102 |
+
max_length=50,
|
| 103 |
+
default="UTC",
|
| 104 |
+
help_text="Default timezone for the instance",
|
| 105 |
+
)
|
| 106 |
+
|
| 107 |
+
class DateFormat(models.TextChoices):
|
| 108 |
+
ISO = "YYYY-MM-DD", "YYYY-MM-DD (ISO)"
|
| 109 |
+
US = "MM/DD/YYYY", "MM/DD/YYYY (US)"
|
| 110 |
+
EU = "DD/MM/YYYY", "DD/MM/YYYY (EU)"
|
| 111 |
+
DOT = "DD.MM.YYYY", "DD.MM.YYYY"
|
| 112 |
+
|
| 113 |
+
date_format = models.CharField(
|
| 114 |
+
max_length=20,
|
| 115 |
+
choices=DateFormat.choices,
|
| 116 |
+
default=DateFormat.ISO,
|
| 117 |
+
help_text="Date display format",
|
| 118 |
+
)
|
| 119 |
+
|
| 120 |
+
class TimeFormat(models.TextChoices):
|
| 121 |
+
H24 = "24h", "24-hour"
|
| 122 |
+
H12 = "12h", "12-hour"
|
| 123 |
+
|
| 124 |
+
time_format = models.CharField(
|
| 125 |
+
max_length=10,
|
| 126 |
+
choices=TimeFormat.choices,
|
| 127 |
+
default=TimeFormat.H24,
|
| 128 |
+
help_text="Time display format",
|
| 129 |
+
)
|
| 130 |
+
|
| 131 |
+
# Security Settings
|
| 132 |
+
admin_url_slug = models.CharField(
|
| 133 |
+
max_length=100,
|
| 134 |
+
default="django-admin",
|
| 135 |
+
help_text="URL path for Django admin interface (requires restart)",
|
| 136 |
+
)
|
| 137 |
+
|
| 138 |
+
# Log Retention Settings
|
| 139 |
+
retention_days = models.PositiveIntegerField(
|
| 140 |
+
default=0,
|
| 141 |
+
help_text="Delete runs older than X days (0 = keep forever)",
|
| 142 |
+
)
|
| 143 |
+
retention_count = models.PositiveIntegerField(
|
| 144 |
+
default=0,
|
| 145 |
+
help_text="Keep last X runs per script (0 = unlimited)",
|
| 146 |
+
)
|
| 147 |
+
auto_cleanup_enabled = models.BooleanField(
|
| 148 |
+
default=False,
|
| 149 |
+
help_text="Automatically clean up old runs daily",
|
| 150 |
+
)
|
| 151 |
+
last_cleanup_at = models.DateTimeField(
|
| 152 |
+
null=True,
|
| 153 |
+
blank=True,
|
| 154 |
+
help_text="When the last cleanup was performed",
|
| 155 |
+
)
|
| 156 |
+
|
| 157 |
+
# Worker heartbeat for status detection
|
| 158 |
+
worker_heartbeat_at = models.DateTimeField(
|
| 159 |
+
null=True,
|
| 160 |
+
blank=True,
|
| 161 |
+
help_text="Last heartbeat from django-q workers",
|
| 162 |
+
)
|
| 163 |
+
|
| 164 |
+
# Worker Settings (Q_CLUSTER configuration)
|
| 165 |
+
q_workers = models.PositiveIntegerField(
|
| 166 |
+
default=2,
|
| 167 |
+
help_text="Number of worker processes for task queue",
|
| 168 |
+
)
|
| 169 |
+
q_timeout = models.PositiveIntegerField(
|
| 170 |
+
default=600,
|
| 171 |
+
help_text="Task timeout in seconds (0 for no timeout)",
|
| 172 |
+
)
|
| 173 |
+
q_retry = models.PositiveIntegerField(
|
| 174 |
+
default=660,
|
| 175 |
+
help_text="Seconds before a task is retried after timeout",
|
| 176 |
+
)
|
| 177 |
+
q_queue_limit = models.PositiveIntegerField(
|
| 178 |
+
default=20,
|
| 179 |
+
help_text="Maximum number of tasks in the queue",
|
| 180 |
+
)
|
| 181 |
+
worker_settings_updated_at = models.DateTimeField(
|
| 182 |
+
null=True,
|
| 183 |
+
blank=True,
|
| 184 |
+
help_text="When worker settings were last updated (requires restart)",
|
| 185 |
+
)
|
| 186 |
+
|
| 187 |
+
# Setup wizard tracking
|
| 188 |
+
setup_completed = models.BooleanField(
|
| 189 |
+
default=False,
|
| 190 |
+
help_text="Whether initial setup has been completed",
|
| 191 |
+
)
|
| 192 |
+
setup_completed_at = models.DateTimeField(
|
| 193 |
+
null=True,
|
| 194 |
+
blank=True,
|
| 195 |
+
help_text="When the initial setup was completed",
|
| 196 |
+
)
|
| 197 |
+
|
| 198 |
+
# Registration control
|
| 199 |
+
allow_registration = models.BooleanField(
|
| 200 |
+
default=True,
|
| 201 |
+
help_text="Allow new users to register without an invite (auto-disabled after first user)",
|
| 202 |
+
)
|
| 203 |
+
|
| 204 |
+
# S3 Storage Configuration
|
| 205 |
+
s3_enabled = models.BooleanField(
|
| 206 |
+
default=False,
|
| 207 |
+
help_text="Enable S3-compatible storage for backups",
|
| 208 |
+
)
|
| 209 |
+
s3_endpoint_url = models.CharField(
|
| 210 |
+
max_length=500,
|
| 211 |
+
blank=True,
|
| 212 |
+
help_text="S3 endpoint URL (leave empty for AWS S3)",
|
| 213 |
+
)
|
| 214 |
+
s3_region = models.CharField(
|
| 215 |
+
max_length=50,
|
| 216 |
+
blank=True,
|
| 217 |
+
default="us-east-1",
|
| 218 |
+
help_text="S3 region",
|
| 219 |
+
)
|
| 220 |
+
s3_bucket_name = models.CharField(
|
| 221 |
+
max_length=255,
|
| 222 |
+
blank=True,
|
| 223 |
+
help_text="S3 bucket name",
|
| 224 |
+
)
|
| 225 |
+
s3_access_key_encrypted = models.TextField(
|
| 226 |
+
blank=True,
|
| 227 |
+
help_text="S3 access key (encrypted)",
|
| 228 |
+
)
|
| 229 |
+
s3_secret_key_encrypted = models.TextField(
|
| 230 |
+
blank=True,
|
| 231 |
+
help_text="S3 secret key (encrypted)",
|
| 232 |
+
)
|
| 233 |
+
s3_use_ssl = models.BooleanField(
|
| 234 |
+
default=True,
|
| 235 |
+
help_text="Use SSL/TLS for S3 connections",
|
| 236 |
+
)
|
| 237 |
+
s3_path_style = models.BooleanField(
|
| 238 |
+
default=False,
|
| 239 |
+
help_text="Use path-style addressing (required for MinIO)",
|
| 240 |
+
)
|
| 241 |
+
s3_last_tested_at = models.DateTimeField(
|
| 242 |
+
null=True,
|
| 243 |
+
blank=True,
|
| 244 |
+
help_text="When S3 connection was last successfully tested",
|
| 245 |
+
)
|
| 246 |
+
|
| 247 |
+
# S3 Scheduled Backup Configuration
|
| 248 |
+
class S3BackupSchedule(models.TextChoices):
|
| 249 |
+
DISABLED = "disabled", "Disabled"
|
| 250 |
+
DAILY = "daily", "Daily"
|
| 251 |
+
WEEKLY = "weekly", "Weekly"
|
| 252 |
+
|
| 253 |
+
s3_backup_enabled = models.BooleanField(
|
| 254 |
+
default=False,
|
| 255 |
+
help_text="Enable scheduled backups to S3",
|
| 256 |
+
)
|
| 257 |
+
s3_backup_schedule = models.CharField(
|
| 258 |
+
max_length=20,
|
| 259 |
+
choices=S3BackupSchedule.choices,
|
| 260 |
+
default=S3BackupSchedule.DISABLED,
|
| 261 |
+
help_text="Backup schedule frequency",
|
| 262 |
+
)
|
| 263 |
+
s3_backup_time = models.TimeField(
|
| 264 |
+
default="02:00",
|
| 265 |
+
help_text="Time of day to run backup (in instance timezone)",
|
| 266 |
+
)
|
| 267 |
+
s3_backup_day = models.PositiveSmallIntegerField(
|
| 268 |
+
default=0,
|
| 269 |
+
help_text="Day of week for weekly backups (0=Monday, 6=Sunday)",
|
| 270 |
+
)
|
| 271 |
+
s3_backup_prefix = models.CharField(
|
| 272 |
+
max_length=255,
|
| 273 |
+
blank=True,
|
| 274 |
+
default="pyrunner-backups/",
|
| 275 |
+
help_text="S3 key prefix for backup files",
|
| 276 |
+
)
|
| 277 |
+
s3_backup_retention_count = models.PositiveIntegerField(
|
| 278 |
+
default=7,
|
| 279 |
+
help_text="Number of backups to keep in S3 (0 = keep all)",
|
| 280 |
+
)
|
| 281 |
+
|
| 282 |
+
# Backup content options
|
| 283 |
+
s3_backup_include_runs = models.BooleanField(
|
| 284 |
+
default=False,
|
| 285 |
+
help_text="Include run history in scheduled backups",
|
| 286 |
+
)
|
| 287 |
+
s3_backup_max_runs = models.PositiveIntegerField(
|
| 288 |
+
default=1000,
|
| 289 |
+
help_text="Maximum runs to include in backup",
|
| 290 |
+
)
|
| 291 |
+
s3_backup_include_datastores = models.BooleanField(
|
| 292 |
+
default=True,
|
| 293 |
+
help_text="Include datastores in scheduled backups",
|
| 294 |
+
)
|
| 295 |
+
|
| 296 |
+
# Backup tracking fields
|
| 297 |
+
s3_backup_last_run_at = models.DateTimeField(
|
| 298 |
+
null=True,
|
| 299 |
+
blank=True,
|
| 300 |
+
help_text="When the last scheduled backup ran",
|
| 301 |
+
)
|
| 302 |
+
s3_backup_last_status = models.CharField(
|
| 303 |
+
max_length=20,
|
| 304 |
+
blank=True,
|
| 305 |
+
default="",
|
| 306 |
+
help_text="Status of last backup (success/failed)",
|
| 307 |
+
)
|
| 308 |
+
s3_backup_last_error = models.TextField(
|
| 309 |
+
blank=True,
|
| 310 |
+
default="",
|
| 311 |
+
help_text="Error message from last failed backup",
|
| 312 |
+
)
|
| 313 |
+
s3_backup_last_size = models.PositiveIntegerField(
|
| 314 |
+
default=0,
|
| 315 |
+
help_text="Size of last backup in bytes",
|
| 316 |
+
)
|
| 317 |
+
|
| 318 |
+
class Meta:
|
| 319 |
+
db_table = "global_settings"
|
| 320 |
+
verbose_name = "global settings"
|
| 321 |
+
verbose_name_plural = "global settings"
|
| 322 |
+
|
| 323 |
+
def __str__(self):
|
| 324 |
+
status = "paused" if self.schedules_paused else "active"
|
| 325 |
+
return f"Global Settings (schedules: {status})"
|
| 326 |
+
|
| 327 |
+
def save(self, *args, **kwargs):
|
| 328 |
+
# Enforce singleton pattern
|
| 329 |
+
self.pk = 1
|
| 330 |
+
super().save(*args, **kwargs)
|
| 331 |
+
|
| 332 |
+
@classmethod
|
| 333 |
+
def get_settings(cls):
|
| 334 |
+
"""Get or create the singleton settings instance."""
|
| 335 |
+
obj, _ = cls.objects.get_or_create(pk=1)
|
| 336 |
+
return obj
|
| 337 |
+
|
| 338 |
+
def worker_restart_required(self) -> bool:
|
| 339 |
+
"""Check if worker restart is required due to pending settings changes."""
|
| 340 |
+
if not self.worker_settings_updated_at or not self.worker_heartbeat_at:
|
| 341 |
+
return False
|
| 342 |
+
return self.worker_settings_updated_at > self.worker_heartbeat_at
|
core/models/tag.py
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Tag model for categorizing scripts.
|
| 3 |
+
"""
|
| 4 |
+
|
| 5 |
+
import uuid
|
| 6 |
+
|
| 7 |
+
from django.conf import settings
|
| 8 |
+
from django.db import models
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
class Tag(models.Model):
|
| 12 |
+
"""
|
| 13 |
+
A tag for categorizing and filtering scripts.
|
| 14 |
+
"""
|
| 15 |
+
|
| 16 |
+
class Color(models.TextChoices):
|
| 17 |
+
GRAY = "gray", "Gray"
|
| 18 |
+
RED = "red", "Red"
|
| 19 |
+
ORANGE = "orange", "Orange"
|
| 20 |
+
YELLOW = "yellow", "Yellow"
|
| 21 |
+
GREEN = "green", "Green"
|
| 22 |
+
BLUE = "blue", "Blue"
|
| 23 |
+
PURPLE = "purple", "Purple"
|
| 24 |
+
PINK = "pink", "Pink"
|
| 25 |
+
|
| 26 |
+
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
| 27 |
+
name = models.CharField(
|
| 28 |
+
max_length=50,
|
| 29 |
+
unique=True,
|
| 30 |
+
help_text="Tag name (must be unique)",
|
| 31 |
+
)
|
| 32 |
+
color = models.CharField(
|
| 33 |
+
max_length=20,
|
| 34 |
+
choices=Color.choices,
|
| 35 |
+
default=Color.GRAY,
|
| 36 |
+
help_text="Tag color for visual distinction",
|
| 37 |
+
)
|
| 38 |
+
|
| 39 |
+
created_at = models.DateTimeField(auto_now_add=True)
|
| 40 |
+
created_by = models.ForeignKey(
|
| 41 |
+
settings.AUTH_USER_MODEL,
|
| 42 |
+
on_delete=models.SET_NULL,
|
| 43 |
+
null=True,
|
| 44 |
+
blank=True,
|
| 45 |
+
related_name="created_tags",
|
| 46 |
+
)
|
| 47 |
+
|
| 48 |
+
class Meta:
|
| 49 |
+
db_table = "tags"
|
| 50 |
+
verbose_name = "tag"
|
| 51 |
+
verbose_name_plural = "tags"
|
| 52 |
+
ordering = ["name"]
|
| 53 |
+
|
| 54 |
+
def __str__(self):
|
| 55 |
+
return self.name
|
| 56 |
+
|
| 57 |
+
@property
|
| 58 |
+
def script_count(self) -> int:
|
| 59 |
+
"""Return the number of scripts using this tag."""
|
| 60 |
+
return self.scripts.count()
|
core/models/user.py
ADDED
|
@@ -0,0 +1,282 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
User, MagicToken, and PasswordResetToken models for authentication.
|
| 3 |
+
Supports both password-based and magic link authentication.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import secrets
|
| 7 |
+
from datetime import timedelta
|
| 8 |
+
|
| 9 |
+
from django.contrib.auth.models import AbstractUser
|
| 10 |
+
from django.db import models
|
| 11 |
+
from django.utils import timezone
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
class User(AbstractUser):
|
| 15 |
+
"""
|
| 16 |
+
Custom user model supporting both password and magic link authentication.
|
| 17 |
+
Uses email as the primary identifier instead of username.
|
| 18 |
+
"""
|
| 19 |
+
|
| 20 |
+
email = models.EmailField(unique=True)
|
| 21 |
+
is_verified = models.BooleanField(
|
| 22 |
+
default=False,
|
| 23 |
+
help_text="Whether the user has verified their email via magic link",
|
| 24 |
+
)
|
| 25 |
+
|
| 26 |
+
# Use email as the username field
|
| 27 |
+
USERNAME_FIELD = "email"
|
| 28 |
+
REQUIRED_FIELDS = [] # Email is already required via USERNAME_FIELD
|
| 29 |
+
|
| 30 |
+
class Meta:
|
| 31 |
+
db_table = "users"
|
| 32 |
+
verbose_name = "user"
|
| 33 |
+
verbose_name_plural = "users"
|
| 34 |
+
|
| 35 |
+
def __str__(self):
|
| 36 |
+
return self.email
|
| 37 |
+
|
| 38 |
+
def save(self, *args, **kwargs):
|
| 39 |
+
# Auto-set username to email if not provided
|
| 40 |
+
if not self.username:
|
| 41 |
+
self.username = self.email
|
| 42 |
+
# Only set unusable password for new users without a password
|
| 43 |
+
# This allows password auth while keeping magic link as an option
|
| 44 |
+
if self._state.adding and not self.password:
|
| 45 |
+
self.set_unusable_password()
|
| 46 |
+
super().save(*args, **kwargs)
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
class MagicToken(models.Model):
|
| 50 |
+
"""
|
| 51 |
+
One-time use token for passwordless authentication.
|
| 52 |
+
Tokens expire after a configurable time and can only be used once.
|
| 53 |
+
"""
|
| 54 |
+
|
| 55 |
+
EXPIRY_MINUTES = 15
|
| 56 |
+
|
| 57 |
+
token = models.CharField(max_length=64, unique=True, db_index=True)
|
| 58 |
+
user = models.ForeignKey(
|
| 59 |
+
User,
|
| 60 |
+
on_delete=models.CASCADE,
|
| 61 |
+
related_name="magic_tokens",
|
| 62 |
+
null=True,
|
| 63 |
+
blank=True,
|
| 64 |
+
)
|
| 65 |
+
email = models.EmailField(help_text="Email address this token was sent to")
|
| 66 |
+
created_at = models.DateTimeField(auto_now_add=True)
|
| 67 |
+
expires_at = models.DateTimeField()
|
| 68 |
+
used_at = models.DateTimeField(null=True, blank=True)
|
| 69 |
+
ip_address = models.GenericIPAddressField(
|
| 70 |
+
null=True,
|
| 71 |
+
blank=True,
|
| 72 |
+
help_text="IP address that requested this token",
|
| 73 |
+
)
|
| 74 |
+
|
| 75 |
+
class Meta:
|
| 76 |
+
db_table = "magic_tokens"
|
| 77 |
+
verbose_name = "magic token"
|
| 78 |
+
verbose_name_plural = "magic tokens"
|
| 79 |
+
indexes = [
|
| 80 |
+
models.Index(fields=["token", "expires_at"]),
|
| 81 |
+
models.Index(fields=["email", "created_at"]),
|
| 82 |
+
]
|
| 83 |
+
|
| 84 |
+
def __str__(self):
|
| 85 |
+
status = "used" if self.used_at else ("expired" if not self.is_valid() else "valid")
|
| 86 |
+
return f"MagicToken for {self.email} ({status})"
|
| 87 |
+
|
| 88 |
+
@classmethod
|
| 89 |
+
def create_for_email(cls, email: str, ip_address: str = None) -> "MagicToken":
|
| 90 |
+
"""
|
| 91 |
+
Create a new magic token for an email address.
|
| 92 |
+
Invalidates any existing unused tokens for the same email.
|
| 93 |
+
|
| 94 |
+
The first user to register is automatically promoted to admin (superuser).
|
| 95 |
+
"""
|
| 96 |
+
# Invalidate existing unused tokens for this email
|
| 97 |
+
cls.objects.filter(email=email, used_at__isnull=True).update(
|
| 98 |
+
expires_at=timezone.now()
|
| 99 |
+
)
|
| 100 |
+
|
| 101 |
+
# Check if this will be the first user (before creating)
|
| 102 |
+
is_first_user = User.objects.count() == 0
|
| 103 |
+
|
| 104 |
+
# Get or create user for this email
|
| 105 |
+
user, created = User.objects.get_or_create(
|
| 106 |
+
email=email,
|
| 107 |
+
defaults={"is_verified": False},
|
| 108 |
+
)
|
| 109 |
+
|
| 110 |
+
# Auto-promote first user to admin and disable open registration
|
| 111 |
+
if created and is_first_user:
|
| 112 |
+
user.is_staff = True
|
| 113 |
+
user.is_superuser = True
|
| 114 |
+
user.save(update_fields=["is_staff", "is_superuser"])
|
| 115 |
+
|
| 116 |
+
# Auto-disable open registration after first user
|
| 117 |
+
from core.models.settings import GlobalSettings
|
| 118 |
+
settings = GlobalSettings.get_settings()
|
| 119 |
+
settings.allow_registration = False
|
| 120 |
+
settings.save(update_fields=["allow_registration"])
|
| 121 |
+
|
| 122 |
+
# Create new token
|
| 123 |
+
return cls.objects.create(
|
| 124 |
+
token=secrets.token_urlsafe(48),
|
| 125 |
+
user=user,
|
| 126 |
+
email=email,
|
| 127 |
+
expires_at=timezone.now() + timedelta(minutes=cls.EXPIRY_MINUTES),
|
| 128 |
+
ip_address=ip_address,
|
| 129 |
+
)
|
| 130 |
+
|
| 131 |
+
def is_valid(self) -> bool:
|
| 132 |
+
"""Check if the token is still valid (not expired and not used)."""
|
| 133 |
+
return self.used_at is None and self.expires_at > timezone.now()
|
| 134 |
+
|
| 135 |
+
def consume(self) -> User:
|
| 136 |
+
"""
|
| 137 |
+
Mark the token as used and return the associated user.
|
| 138 |
+
Also marks the user as verified.
|
| 139 |
+
"""
|
| 140 |
+
if not self.is_valid():
|
| 141 |
+
raise ValueError("Token is no longer valid")
|
| 142 |
+
|
| 143 |
+
self.used_at = timezone.now()
|
| 144 |
+
self.save(update_fields=["used_at"])
|
| 145 |
+
|
| 146 |
+
# Mark user as verified
|
| 147 |
+
self.user.is_verified = True
|
| 148 |
+
self.user.save(update_fields=["is_verified"])
|
| 149 |
+
|
| 150 |
+
return self.user
|
| 151 |
+
|
| 152 |
+
|
| 153 |
+
class UserInvite(models.Model):
|
| 154 |
+
"""
|
| 155 |
+
Invitation for a new user. Admin creates invite, gets shareable link.
|
| 156 |
+
Can be used once to create an account when registration is closed.
|
| 157 |
+
"""
|
| 158 |
+
|
| 159 |
+
EXPIRY_DAYS = 7
|
| 160 |
+
|
| 161 |
+
email = models.EmailField(
|
| 162 |
+
unique=True,
|
| 163 |
+
help_text="Email address of the invited user",
|
| 164 |
+
)
|
| 165 |
+
token = models.CharField(max_length=64, unique=True, db_index=True)
|
| 166 |
+
created_by = models.ForeignKey(
|
| 167 |
+
User,
|
| 168 |
+
on_delete=models.CASCADE,
|
| 169 |
+
related_name="sent_invites",
|
| 170 |
+
)
|
| 171 |
+
created_at = models.DateTimeField(auto_now_add=True)
|
| 172 |
+
expires_at = models.DateTimeField()
|
| 173 |
+
used_at = models.DateTimeField(null=True, blank=True)
|
| 174 |
+
used_by = models.ForeignKey(
|
| 175 |
+
User,
|
| 176 |
+
on_delete=models.SET_NULL,
|
| 177 |
+
null=True,
|
| 178 |
+
blank=True,
|
| 179 |
+
related_name="received_invite",
|
| 180 |
+
)
|
| 181 |
+
|
| 182 |
+
class Meta:
|
| 183 |
+
db_table = "user_invites"
|
| 184 |
+
verbose_name = "user invite"
|
| 185 |
+
verbose_name_plural = "user invites"
|
| 186 |
+
indexes = [
|
| 187 |
+
models.Index(fields=["token"]),
|
| 188 |
+
models.Index(fields=["email"]),
|
| 189 |
+
]
|
| 190 |
+
|
| 191 |
+
def __str__(self):
|
| 192 |
+
status = "used" if self.used_at else ("expired" if not self.is_valid() else "pending")
|
| 193 |
+
return f"Invite for {self.email} ({status})"
|
| 194 |
+
|
| 195 |
+
@classmethod
|
| 196 |
+
def create_invite(cls, email: str, created_by: User) -> "UserInvite":
|
| 197 |
+
"""
|
| 198 |
+
Create a new invitation. Deletes any existing unused invite for the same email.
|
| 199 |
+
"""
|
| 200 |
+
# Delete existing unused invite for this email
|
| 201 |
+
cls.objects.filter(email__iexact=email, used_at__isnull=True).delete()
|
| 202 |
+
|
| 203 |
+
return cls.objects.create(
|
| 204 |
+
email=email.lower().strip(),
|
| 205 |
+
token=secrets.token_urlsafe(48),
|
| 206 |
+
created_by=created_by,
|
| 207 |
+
expires_at=timezone.now() + timedelta(days=cls.EXPIRY_DAYS),
|
| 208 |
+
)
|
| 209 |
+
|
| 210 |
+
def is_valid(self) -> bool:
|
| 211 |
+
"""Check if the invite is still valid (not expired and not used)."""
|
| 212 |
+
return self.used_at is None and self.expires_at > timezone.now()
|
| 213 |
+
|
| 214 |
+
def mark_used(self, user: User) -> None:
|
| 215 |
+
"""Mark the invite as used by the specified user."""
|
| 216 |
+
self.used_at = timezone.now()
|
| 217 |
+
self.used_by = user
|
| 218 |
+
self.save(update_fields=["used_at", "used_by"])
|
| 219 |
+
|
| 220 |
+
|
| 221 |
+
class PasswordResetToken(models.Model):
|
| 222 |
+
"""
|
| 223 |
+
Token for password reset functionality.
|
| 224 |
+
Allows users to reset their password via email link.
|
| 225 |
+
"""
|
| 226 |
+
|
| 227 |
+
EXPIRY_HOURS = 24
|
| 228 |
+
|
| 229 |
+
token = models.CharField(max_length=64, unique=True, db_index=True)
|
| 230 |
+
user = models.ForeignKey(
|
| 231 |
+
User,
|
| 232 |
+
on_delete=models.CASCADE,
|
| 233 |
+
related_name="password_reset_tokens",
|
| 234 |
+
)
|
| 235 |
+
created_at = models.DateTimeField(auto_now_add=True)
|
| 236 |
+
expires_at = models.DateTimeField()
|
| 237 |
+
used_at = models.DateTimeField(null=True, blank=True)
|
| 238 |
+
|
| 239 |
+
class Meta:
|
| 240 |
+
db_table = "password_reset_tokens"
|
| 241 |
+
verbose_name = "password reset token"
|
| 242 |
+
verbose_name_plural = "password reset tokens"
|
| 243 |
+
indexes = [
|
| 244 |
+
models.Index(fields=["token", "expires_at"]),
|
| 245 |
+
]
|
| 246 |
+
|
| 247 |
+
def __str__(self):
|
| 248 |
+
status = "used" if self.used_at else ("expired" if not self.is_valid() else "valid")
|
| 249 |
+
return f"PasswordResetToken for {self.user.email} ({status})"
|
| 250 |
+
|
| 251 |
+
@classmethod
|
| 252 |
+
def create_for_user(cls, user: User) -> "PasswordResetToken":
|
| 253 |
+
"""
|
| 254 |
+
Create a new password reset token for a user.
|
| 255 |
+
Invalidates any existing unused tokens for the same user.
|
| 256 |
+
"""
|
| 257 |
+
# Invalidate existing unused tokens for this user
|
| 258 |
+
cls.objects.filter(user=user, used_at__isnull=True).update(
|
| 259 |
+
expires_at=timezone.now()
|
| 260 |
+
)
|
| 261 |
+
|
| 262 |
+
return cls.objects.create(
|
| 263 |
+
token=secrets.token_urlsafe(48),
|
| 264 |
+
user=user,
|
| 265 |
+
expires_at=timezone.now() + timedelta(hours=cls.EXPIRY_HOURS),
|
| 266 |
+
)
|
| 267 |
+
|
| 268 |
+
def is_valid(self) -> bool:
|
| 269 |
+
"""Check if the token is still valid (not expired and not used)."""
|
| 270 |
+
return self.used_at is None and self.expires_at > timezone.now()
|
| 271 |
+
|
| 272 |
+
def consume(self) -> User:
|
| 273 |
+
"""
|
| 274 |
+
Mark the token as used and return the associated user.
|
| 275 |
+
"""
|
| 276 |
+
if not self.is_valid():
|
| 277 |
+
raise ValueError("Token is no longer valid")
|
| 278 |
+
|
| 279 |
+
self.used_at = timezone.now()
|
| 280 |
+
self.save(update_fields=["used_at"])
|
| 281 |
+
|
| 282 |
+
return self.user
|