Akoda35 commited on
Commit
d6ee72a
·
verified ·
1 Parent(s): 292687a

Upload 12 files

Browse files
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