Akoda35 commited on
Commit
a4ebfba
·
verified ·
1 Parent(s): 6b59959

Upload 11 files

Browse files
core/__init__.py ADDED
File without changes
core/admin.py ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ from django.contrib import admin
2
+
3
+ # Register your models here.
core/apps.py ADDED
@@ -0,0 +1,59 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from django.apps import AppConfig
2
+ from django.utils import timezone
3
+
4
+ # Module-level variable to store app start time
5
+ APP_START_TIME = None
6
+
7
+
8
+ class CoreConfig(AppConfig):
9
+ name = "core"
10
+
11
+ def ready(self):
12
+ global APP_START_TIME
13
+ APP_START_TIME = timezone.now()
14
+
15
+ # Register signal handlers for worker heartbeat
16
+ self._register_worker_signals()
17
+
18
+ # Use post_migrate signal to ensure heartbeat schedule exists after migrations
19
+ # This avoids database access during app initialization
20
+ from django.db.models.signals import post_migrate
21
+
22
+ post_migrate.connect(self._on_post_migrate, sender=self)
23
+
24
+ def _on_post_migrate(self, sender, **kwargs):
25
+ """Run after migrations are complete to set up heartbeat schedule."""
26
+ self._ensure_heartbeat_schedule()
27
+
28
+ def _register_worker_signals(self):
29
+ """Register django-q2 signals for worker heartbeat."""
30
+ try:
31
+ from django_q.signals import post_execute
32
+
33
+ def update_heartbeat(sender, task, **kwargs):
34
+ """Update heartbeat timestamp after each task execution."""
35
+ try:
36
+ from core.models import GlobalSettings
37
+
38
+ settings = GlobalSettings.get_settings()
39
+ settings.worker_heartbeat_at = timezone.now()
40
+ settings.save(update_fields=["worker_heartbeat_at"])
41
+ except Exception:
42
+ pass # Fail silently - don't break task execution
43
+
44
+ post_execute.connect(update_heartbeat, dispatch_uid="worker_heartbeat")
45
+ except Exception:
46
+ pass # django-q signals not available
47
+
48
+ def _ensure_heartbeat_schedule(self):
49
+ """Ensure the worker heartbeat schedule exists in database."""
50
+ try:
51
+ # Only run if database tables exist (not during migrations)
52
+ from django.db import connection
53
+
54
+ if "django_q_schedule" in connection.introspection.table_names():
55
+ from core.services.schedule_service import ScheduleService
56
+
57
+ ScheduleService.ensure_heartbeat_schedule()
58
+ except Exception:
59
+ pass # Database not ready yet
core/context_processors.py ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Context processors for PyRunner templates.
3
+ """
4
+
5
+ from pyrunner.version import __version__
6
+
7
+
8
+ def pyrunner_version(request):
9
+ """Add PyRunner version to template context."""
10
+ return {
11
+ "pyrunner_version": __version__,
12
+ }
core/email.py ADDED
@@ -0,0 +1,278 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Email sending abstraction for magic link authentication.
3
+ Supports multiple backends: Console (dev), Resend API (production).
4
+ """
5
+ import logging
6
+ from django.conf import settings
7
+ from django.core.mail import EmailMultiAlternatives, send_mail
8
+ from django.urls import reverse
9
+
10
+ logger = logging.getLogger(__name__)
11
+
12
+
13
+ def send_magic_link_email(request, magic_token) -> bool:
14
+ """
15
+ Send magic link email using configured backend.
16
+
17
+ Args:
18
+ request: HttpRequest object (for building absolute URL)
19
+ magic_token: MagicToken instance
20
+
21
+ Returns:
22
+ bool: True if email sent successfully
23
+ """
24
+ verify_path = reverse("auth:verify", kwargs={"token": magic_token.token})
25
+ verify_url = request.build_absolute_uri(verify_path)
26
+
27
+ # Print URL directly to console in development (bypasses MIME encoding)
28
+ if settings.DEBUG:
29
+ print("\n" + "=" * 60)
30
+ print("MAGIC LINK (click or copy):")
31
+ print(verify_url)
32
+ print("=" * 60 + "\n")
33
+
34
+ subject = "Your PyRunner Login Link"
35
+
36
+ text_message = f"""Hi there!
37
+
38
+ Click the link below to sign in to PyRunner:
39
+
40
+ ========================================
41
+ {verify_url}
42
+ ========================================
43
+
44
+ This link will expire in 15 minutes and can only be used once.
45
+
46
+ If you didn't request this link, you can safely ignore this email.
47
+
48
+ ---
49
+ PyRunner - Python Script Automation
50
+ """
51
+
52
+ html_message = f"""<!DOCTYPE html>
53
+ <html>
54
+ <head>
55
+ <meta charset="utf-8">
56
+ <style>
57
+ body {{ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; line-height: 1.6; color: #333; }}
58
+ .container {{ max-width: 600px; margin: 0 auto; padding: 40px 20px; }}
59
+ .button {{ display: inline-block; background: #2563eb; color: #ffffff !important; padding: 14px 28px; text-decoration: none; border-radius: 6px; font-weight: 600; }}
60
+ .footer {{ margin-top: 40px; padding-top: 20px; border-top: 1px solid #e5e7eb; color: #6b7280; font-size: 14px; }}
61
+ </style>
62
+ </head>
63
+ <body>
64
+ <div class="container">
65
+ <h2>Sign in to PyRunner</h2>
66
+ <p>Click the button below to sign in:</p>
67
+ <p style="margin: 30px 0;">
68
+ <a href="{verify_url}" class="button">Sign In to PyRunner</a>
69
+ </p>
70
+ <p style="color: #6b7280; font-size: 14px;">
71
+ This link expires in 15 minutes and can only be used once.
72
+ </p>
73
+ <p style="color: #6b7280; font-size: 14px;">
74
+ Or copy and paste this URL into your browser:<br>
75
+ <code style="background: #f3f4f6; padding: 2px 6px; border-radius: 4px;">{verify_url}</code>
76
+ </p>
77
+ <div class="footer">
78
+ <p>If you didn't request this link, you can safely ignore this email.</p>
79
+ <p><strong>PyRunner</strong> - Python Script Automation</p>
80
+ </div>
81
+ </div>
82
+ </body>
83
+ </html>"""
84
+
85
+ return _send_email(magic_token.email, subject, text_message, html_message)
86
+
87
+
88
+ def send_password_reset_email(request, user, reset_token) -> bool:
89
+ """
90
+ Send password reset email.
91
+
92
+ Args:
93
+ request: HttpRequest object (for building absolute URL)
94
+ user: User instance
95
+ reset_token: PasswordResetToken instance
96
+
97
+ Returns:
98
+ bool: True if email sent successfully
99
+ """
100
+ reset_path = reverse("auth:reset_password", kwargs={"token": reset_token.token})
101
+ reset_url = request.build_absolute_uri(reset_path)
102
+
103
+ # Print URL directly to console in development
104
+ if settings.DEBUG:
105
+ print("\n" + "=" * 60)
106
+ print("PASSWORD RESET LINK (click or copy):")
107
+ print(reset_url)
108
+ print("=" * 60 + "\n")
109
+
110
+ subject = "Reset Your PyRunner Password"
111
+
112
+ text_message = f"""Hi there!
113
+
114
+ You requested a password reset for your PyRunner account.
115
+
116
+ Click the link below to reset your password:
117
+
118
+ ========================================
119
+ {reset_url}
120
+ ========================================
121
+
122
+ This link will expire in 24 hours and can only be used once.
123
+
124
+ If you didn't request this, you can safely ignore this email.
125
+
126
+ ---
127
+ PyRunner - Python Script Automation
128
+ """
129
+
130
+ html_message = f"""<!DOCTYPE html>
131
+ <html>
132
+ <head>
133
+ <meta charset="utf-8">
134
+ <style>
135
+ body {{ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; line-height: 1.6; color: #333; }}
136
+ .container {{ max-width: 600px; margin: 0 auto; padding: 40px 20px; }}
137
+ .button {{ display: inline-block; background: #2563eb; color: #ffffff !important; padding: 14px 28px; text-decoration: none; border-radius: 6px; font-weight: 600; }}
138
+ .footer {{ margin-top: 40px; padding-top: 20px; border-top: 1px solid #e5e7eb; color: #6b7280; font-size: 14px; }}
139
+ </style>
140
+ </head>
141
+ <body>
142
+ <div class="container">
143
+ <h2>Reset Your Password</h2>
144
+ <p>You requested a password reset for your PyRunner account ({user.email}).</p>
145
+ <p style="margin: 30px 0;">
146
+ <a href="{reset_url}" class="button">Reset Password</a>
147
+ </p>
148
+ <p style="color: #6b7280; font-size: 14px;">
149
+ This link expires in 24 hours and can only be used once.
150
+ </p>
151
+ <p style="color: #6b7280; font-size: 14px;">
152
+ Or copy and paste this URL into your browser:<br>
153
+ <code style="background: #f3f4f6; padding: 2px 6px; border-radius: 4px;">{reset_url}</code>
154
+ </p>
155
+ <div class="footer">
156
+ <p>If you didn't request this, you can safely ignore this email.</p>
157
+ <p><strong>PyRunner</strong> - Python Script Automation</p>
158
+ </div>
159
+ </div>
160
+ </body>
161
+ </html>"""
162
+
163
+ return _send_email(user.email, subject, text_message, html_message)
164
+
165
+
166
+ def _get_db_backend_and_from_email():
167
+ """
168
+ Returns (backend, from_email) if GlobalSettings has a non-DISABLED email
169
+ backend configured, else (None, None). Local imports avoid a circular
170
+ import with core.services.notification_service.
171
+ """
172
+ from core.models import GlobalSettings
173
+ from core.services.notification_service import NotificationService
174
+
175
+ gs = GlobalSettings.get_settings()
176
+ if gs.email_backend == GlobalSettings.EmailBackend.DISABLED:
177
+ return None, None
178
+
179
+ backend = NotificationService._get_email_backend(gs)
180
+ if backend is None:
181
+ return None, None
182
+
183
+ from_email = (
184
+ gs.smtp_from_email
185
+ if gs.email_backend == GlobalSettings.EmailBackend.SMTP
186
+ else gs.resend_from_email
187
+ )
188
+ return backend, from_email
189
+
190
+
191
+ def _send_email(to_email: str, subject: str, text_content: str, html_content: str) -> bool:
192
+ """
193
+ Unified sender for auth emails.
194
+ Resolution order: DB-configured backend → env Resend API → Django send_mail().
195
+ A DB backend failure is NOT retried via env — it is surfaced as False.
196
+ """
197
+ backend, from_email = _get_db_backend_and_from_email()
198
+ if backend is not None:
199
+ if not from_email:
200
+ logger.error(
201
+ f"DB email backend configured but from_email is empty; cannot send to {to_email}"
202
+ )
203
+ return False
204
+ try:
205
+ msg = EmailMultiAlternatives(
206
+ subject=subject,
207
+ body=text_content,
208
+ from_email=from_email,
209
+ to=[to_email],
210
+ connection=backend,
211
+ )
212
+ msg.attach_alternative(html_content, "text/html")
213
+ msg.send()
214
+ logger.info(f"Auth email sent to {to_email} via DB backend")
215
+ return True
216
+ except Exception as e:
217
+ logger.error(f"DB backend failed to send auth email to {to_email}: {e}")
218
+ return False
219
+
220
+ if _should_use_resend():
221
+ return _send_via_resend(to_email, subject, text_content, html_content)
222
+
223
+ try:
224
+ send_mail(
225
+ subject=subject,
226
+ message=text_content,
227
+ from_email=getattr(settings, "DEFAULT_FROM_EMAIL", "noreply@localhost"),
228
+ recipient_list=[to_email],
229
+ html_message=html_content,
230
+ fail_silently=False,
231
+ )
232
+ logger.info(f"Auth email sent to {to_email} via env backend")
233
+ return True
234
+ except Exception as e:
235
+ logger.error(f"Env backend failed to send auth email to {to_email}: {e}")
236
+ return False
237
+
238
+
239
+ def _should_use_resend() -> bool:
240
+ """Check if Resend API should be used."""
241
+ return (
242
+ hasattr(settings, "RESEND_API_KEY") and
243
+ settings.RESEND_API_KEY and
244
+ getattr(settings, "USE_RESEND", False)
245
+ )
246
+
247
+
248
+ def _send_via_resend(to_email: str, subject: str, text_content: str, html_content: str) -> bool:
249
+ """
250
+ Send email via Resend API.
251
+
252
+ Requires:
253
+ - RESEND_API_KEY in settings
254
+ - USE_RESEND = True in settings
255
+ - resend package installed
256
+ """
257
+ try:
258
+ import resend
259
+ resend.api_key = settings.RESEND_API_KEY
260
+
261
+ params = {
262
+ "from": getattr(settings, "DEFAULT_FROM_EMAIL", "noreply@localhost"),
263
+ "to": [to_email],
264
+ "subject": subject,
265
+ "text": text_content,
266
+ "html": html_content,
267
+ }
268
+
269
+ response = resend.Emails.send(params)
270
+ logger.info(f"Resend email sent to {to_email}, id: {response.get('id')}")
271
+ return True
272
+
273
+ except ImportError:
274
+ logger.error("resend package not installed. Run: pip install resend")
275
+ return False
276
+ except Exception as e:
277
+ logger.error(f"Resend API error: {e}")
278
+ return False
core/executor.py ADDED
@@ -0,0 +1,358 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Script executor module for PyRunner.
3
+
4
+ This module handles the execution of Python scripts in isolated environments.
5
+ It is designed to be called from django-q2 async tasks.
6
+ """
7
+
8
+ import json
9
+ import logging
10
+ import os
11
+ import subprocess
12
+ import tempfile
13
+ import traceback
14
+ from pathlib import Path
15
+
16
+ from django.conf import settings
17
+ from django.utils import timezone
18
+
19
+ from core.models import Run, Secret
20
+ from core.services import EncryptionService
21
+
22
+ logger = logging.getLogger(__name__)
23
+
24
+ # Maximum output size (1MB) to prevent database bloat
25
+ MAX_OUTPUT_BYTES = 1_000_000
26
+
27
+
28
+ def _get_secrets_env() -> dict:
29
+ """
30
+ Get all secrets as environment variables.
31
+
32
+ Returns:
33
+ Dict of {key: decrypted_value} for all secrets
34
+ """
35
+ secrets_env = {}
36
+
37
+ # Only try to get secrets if encryption is configured
38
+ if not EncryptionService.is_configured():
39
+ logger.debug("Encryption not configured - secrets will not be injected")
40
+ return secrets_env
41
+
42
+ try:
43
+ for secret in Secret.objects.all():
44
+ try:
45
+ secrets_env[secret.key] = secret.get_decrypted_value()
46
+ except Exception as e:
47
+ logger.error(f"Failed to decrypt secret {secret.key}: {e}")
48
+ except Exception as e:
49
+ logger.error(f"Failed to load secrets: {e}")
50
+
51
+ return secrets_env
52
+
53
+
54
+ def _build_script_environment(webhook_data: dict | None = None) -> dict:
55
+ """
56
+ Build the environment dict for script execution.
57
+
58
+ Combines system environment with secrets, webhook data, and DataStore access.
59
+ Secrets override any same-named system variables.
60
+ Webhook data is added with WEBHOOK_ prefix.
61
+
62
+ Args:
63
+ webhook_data: Optional webhook data from HTTP request
64
+
65
+ Returns:
66
+ Environment dict to pass to subprocess
67
+ """
68
+ # Start with system environment
69
+ env = os.environ.copy()
70
+
71
+ # Add secrets (overriding any existing vars with same name)
72
+ secrets = _get_secrets_env()
73
+ env.update(secrets)
74
+
75
+ # Add webhook data if present
76
+ if webhook_data:
77
+ env["WEBHOOK_METHOD"] = webhook_data.get("method", "")
78
+ env["WEBHOOK_QUERY"] = json.dumps(webhook_data.get("query", {}))
79
+ env["WEBHOOK_CONTENT_TYPE"] = webhook_data.get("content_type", "")
80
+
81
+ if "body" in webhook_data:
82
+ env["WEBHOOK_BODY"] = webhook_data["body"]
83
+
84
+ if "body_json" in webhook_data:
85
+ env["WEBHOOK_BODY_JSON"] = json.dumps(webhook_data["body_json"])
86
+
87
+ # Add DataStore support
88
+ # Set the database path for the pyrunner_datastore module
89
+ env["PYRUNNER_DB_PATH"] = str(settings.DATABASES["default"]["NAME"])
90
+
91
+ # Add script_helpers to PYTHONPATH so scripts can import pyrunner_datastore
92
+ helpers_path = str(Path(settings.BASE_DIR) / "core" / "script_helpers")
93
+ existing_pythonpath = env.get("PYTHONPATH", "")
94
+ if existing_pythonpath:
95
+ env["PYTHONPATH"] = f"{helpers_path}{os.pathsep}{existing_pythonpath}"
96
+ else:
97
+ env["PYTHONPATH"] = helpers_path
98
+
99
+ return env
100
+
101
+
102
+ def _mask_secrets_in_output(output: str, secrets: dict) -> str:
103
+ """
104
+ Mask secret values in output to prevent accidental exposure.
105
+
106
+ Args:
107
+ output: The script output
108
+ secrets: Dict of {key: value} secrets
109
+
110
+ Returns:
111
+ Output with secret values replaced with [KEY:MASKED]
112
+ """
113
+ if not output or not secrets:
114
+ return output
115
+
116
+ masked = output
117
+ for key, value in secrets.items():
118
+ if value and len(value) >= 4: # Only mask non-trivial values
119
+ masked = masked.replace(value, f"[{key}:MASKED]")
120
+
121
+ return masked
122
+
123
+
124
+ class ExecutorError(Exception):
125
+ """Base exception for executor errors."""
126
+
127
+ pass
128
+
129
+
130
+ class EnvironmentNotFoundError(ExecutorError):
131
+ """Raised when the environment directory does not exist."""
132
+
133
+ pass
134
+
135
+
136
+ class PythonNotFoundError(ExecutorError):
137
+ """Raised when the Python executable is not found."""
138
+
139
+ pass
140
+
141
+
142
+ def _truncate_output(output: str, max_bytes: int = MAX_OUTPUT_BYTES) -> str:
143
+ """
144
+ Truncate output if it exceeds max_bytes.
145
+
146
+ Args:
147
+ output: The output string to potentially truncate
148
+ max_bytes: Maximum size in bytes (default 1MB)
149
+
150
+ Returns:
151
+ The original or truncated output with notice
152
+ """
153
+ if not output:
154
+ return output
155
+
156
+ encoded = output.encode("utf-8", errors="replace")
157
+ if len(encoded) <= max_bytes:
158
+ return output
159
+
160
+ # Truncate and decode back, keeping a buffer for the notice
161
+ notice = "\n\n[OUTPUT TRUNCATED - exceeded maximum size]"
162
+ truncated = encoded[: max_bytes - len(notice.encode())].decode(
163
+ "utf-8", errors="replace"
164
+ )
165
+ return truncated + notice
166
+
167
+
168
+ def _validate_environment(run: Run) -> str:
169
+ """
170
+ Validate the environment and return the Python executable path.
171
+
172
+ Args:
173
+ run: The Run instance containing the script and environment
174
+
175
+ Returns:
176
+ The absolute path to the Python executable
177
+
178
+ Raises:
179
+ EnvironmentNotFoundError: If environment directory doesn't exist
180
+ PythonNotFoundError: If Python executable doesn't exist
181
+ """
182
+ environment = run.script.environment
183
+
184
+ if not environment.exists():
185
+ raise EnvironmentNotFoundError(
186
+ f"Environment directory not found: {environment.get_full_path()}"
187
+ )
188
+
189
+ python_path = environment.get_python_executable()
190
+ if not os.path.isfile(python_path):
191
+ raise PythonNotFoundError(f"Python executable not found: {python_path}")
192
+
193
+ return python_path
194
+
195
+
196
+ def execute_run(run: Run, webhook_data: dict | None = None) -> None:
197
+ """
198
+ Execute a script run and update the Run record with results.
199
+
200
+ This function is designed to be called from a django-q2 async task.
201
+ It handles all aspects of script execution including:
202
+ - Writing script code to a temporary file
203
+ - Running the script with the appropriate Python executable
204
+ - Capturing stdout/stderr
205
+ - Handling timeouts
206
+ - Updating the Run record with results
207
+
208
+ Args:
209
+ run: The Run model instance to execute
210
+ webhook_data: Optional webhook data to inject as environment variables
211
+
212
+ Note:
213
+ This function always saves the Run state, even on errors.
214
+ The Run status will be updated to one of:
215
+ SUCCESS, FAILED, TIMEOUT, or remain FAILED on errors.
216
+ """
217
+ script_file_path = None
218
+
219
+ try:
220
+ # Phase 1: Pre-execution validation
221
+ if run.status != Run.Status.PENDING:
222
+ logger.warning(
223
+ f"Run {run.id} is not in PENDING status (current: {run.status}). "
224
+ "Skipping execution."
225
+ )
226
+ return
227
+
228
+ # Update to RUNNING status
229
+ run.status = Run.Status.RUNNING
230
+ run.started_at = timezone.now()
231
+ run.save(update_fields=["status", "started_at"])
232
+
233
+ # Validate environment
234
+ try:
235
+ python_path = _validate_environment(run)
236
+ except EnvironmentNotFoundError as e:
237
+ run.status = Run.Status.FAILED
238
+ run.stderr = str(e)
239
+ run.ended_at = timezone.now()
240
+ run.save()
241
+ logger.error(f"Run {run.id} failed: {e}")
242
+ return
243
+ except PythonNotFoundError as e:
244
+ run.status = Run.Status.FAILED
245
+ run.stderr = str(e)
246
+ run.ended_at = timezone.now()
247
+ run.save()
248
+ logger.error(f"Run {run.id} failed: {e}")
249
+ return
250
+
251
+ # Ensure working directory exists
252
+ workdir = Path(settings.SCRIPTS_WORKDIR)
253
+ workdir.mkdir(parents=True, exist_ok=True)
254
+
255
+ # Phase 2: Create temporary script file
256
+ # Use delete=False for Windows compatibility (must close before subprocess reads)
257
+ with tempfile.NamedTemporaryFile(
258
+ mode="w",
259
+ suffix=".py",
260
+ delete=False,
261
+ encoding="utf-8",
262
+ dir=str(workdir),
263
+ ) as script_file:
264
+ # Use code_snapshot if available (preserves code at queue time)
265
+ code = run.code_snapshot if run.code_snapshot else run.script.code
266
+ script_file.write(code)
267
+ script_file_path = script_file.name
268
+
269
+ # Phase 3: Execute script
270
+ try:
271
+ # Build subprocess arguments
272
+ cmd = [python_path, script_file_path]
273
+
274
+ # Build environment with secrets and webhook data injected
275
+ script_env = _build_script_environment(webhook_data)
276
+ secrets = _get_secrets_env()
277
+
278
+ # Subprocess kwargs
279
+ kwargs = {
280
+ "capture_output": True,
281
+ "timeout": run.script.timeout_seconds,
282
+ "cwd": str(workdir),
283
+ "text": True,
284
+ "encoding": "utf-8",
285
+ "errors": "replace",
286
+ "env": script_env,
287
+ }
288
+
289
+ # Windows-specific: prevent console window popup
290
+ if os.name == "nt":
291
+ kwargs["creationflags"] = subprocess.CREATE_NO_WINDOW
292
+
293
+ result = subprocess.run(cmd, **kwargs)
294
+
295
+ # Process results - mask secrets in output
296
+ run.stdout = _truncate_output(_mask_secrets_in_output(result.stdout, secrets))
297
+ run.stderr = _truncate_output(_mask_secrets_in_output(result.stderr, secrets))
298
+ run.exit_code = result.returncode
299
+ run.status = (
300
+ Run.Status.SUCCESS if result.returncode == 0 else Run.Status.FAILED
301
+ )
302
+
303
+ except subprocess.TimeoutExpired as e:
304
+ # Handle timeout - process is automatically killed
305
+ run.status = Run.Status.TIMEOUT
306
+ # TimeoutExpired provides stdout/stderr as bytes even with text=True
307
+ # Decode them to strings for consistent processing
308
+ stdout_raw = ""
309
+ if e.stdout:
310
+ stdout_raw = e.stdout.decode("utf-8", errors="replace") if isinstance(e.stdout, bytes) else e.stdout
311
+ stderr_raw = ""
312
+ if e.stderr:
313
+ stderr_raw = e.stderr.decode("utf-8", errors="replace") if isinstance(e.stderr, bytes) else e.stderr
314
+ run.stdout = _truncate_output(_mask_secrets_in_output(stdout_raw, secrets))
315
+ run.stderr = _truncate_output(_mask_secrets_in_output(stderr_raw, secrets))
316
+ if run.stderr:
317
+ run.stderr += "\n\n[TIMEOUT: Script exceeded maximum execution time]"
318
+ else:
319
+ run.stderr = (
320
+ f"[TIMEOUT: Script exceeded {run.script.timeout_seconds} seconds]"
321
+ )
322
+ run.exit_code = -1
323
+ logger.warning(f"Run {run.id} timed out after {run.script.timeout_seconds}s")
324
+
325
+ except subprocess.SubprocessError as e:
326
+ # Handle other subprocess errors
327
+ run.status = Run.Status.FAILED
328
+ run.stderr = f"Subprocess error: {str(e)}"
329
+ run.exit_code = -1
330
+ logger.error(f"Run {run.id} subprocess error: {e}")
331
+
332
+ except Exception as e:
333
+ # Catch-all for unexpected errors
334
+ run.status = Run.Status.FAILED
335
+ run.stderr = f"Unexpected executor error: {str(e)}\n\n{traceback.format_exc()}"
336
+ run.exit_code = -1
337
+ logger.exception(f"Run {run.id} unexpected error")
338
+
339
+ finally:
340
+ # Phase 4: Cleanup and save
341
+ # Always set end time if not already set
342
+ if not run.ended_at:
343
+ run.ended_at = timezone.now()
344
+
345
+ # Always save the run state
346
+ run.save()
347
+
348
+ # Cleanup temporary file
349
+ if script_file_path is not None:
350
+ try:
351
+ os.unlink(script_file_path)
352
+ except OSError as e:
353
+ logger.warning(f"Failed to delete temp script file: {e}")
354
+
355
+ logger.info(
356
+ f"Run {run.id} completed with status {run.status} "
357
+ f"(exit_code={run.exit_code})"
358
+ )
core/forms.py ADDED
@@ -0,0 +1,1973 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Forms for the core app.
3
+ """
4
+ import re
5
+ from zoneinfo import available_timezones
6
+
7
+ from django import forms
8
+ from django.utils.text import slugify
9
+
10
+ from core.models import Script, Environment, ScriptSchedule, Tag, DataStore, DataStoreEntry, DataStoreAPIToken
11
+ from core.services import EnvironmentService
12
+
13
+
14
+ # Regex pattern for secret key validation (uppercase, numbers, underscores, must start with letter)
15
+ SECRET_KEY_PATTERN = re.compile(r"^[A-Z][A-Z0-9_]*$")
16
+
17
+
18
+ # Common timezone choices (sorted, common ones first)
19
+ COMMON_TIMEZONES = [
20
+ "UTC",
21
+ "America/New_York",
22
+ "America/Chicago",
23
+ "America/Denver",
24
+ "America/Los_Angeles",
25
+ "Europe/London",
26
+ "Europe/Paris",
27
+ "Europe/Berlin",
28
+ "Asia/Tokyo",
29
+ "Asia/Shanghai",
30
+ "Asia/Singapore",
31
+ "Australia/Sydney",
32
+ ]
33
+
34
+
35
+ def get_timezone_choices():
36
+ """Generate timezone choices with common ones first."""
37
+ all_tz = sorted(available_timezones())
38
+ common = [(tz, tz) for tz in COMMON_TIMEZONES if tz in all_tz]
39
+ others = [(tz, tz) for tz in all_tz if tz not in COMMON_TIMEZONES]
40
+ return [("", "---")] + common + [("---", "─" * 20)] + others
41
+
42
+
43
+ class ScriptForm(forms.ModelForm):
44
+ """Form for creating and editing scripts."""
45
+
46
+ class Meta:
47
+ model = Script
48
+ fields = [
49
+ "name",
50
+ "description",
51
+ "code",
52
+ "environment",
53
+ "tags",
54
+ "timeout_seconds",
55
+ "is_enabled",
56
+ "notify_on",
57
+ "notify_email",
58
+ "notify_webhook_url",
59
+ "notify_webhook_enabled",
60
+ ]
61
+ widgets = {
62
+ "name": forms.TextInput(
63
+ attrs={
64
+ "class": "w-full px-4 py-3 bg-code-bg border border-code-border rounded-lg text-code-text placeholder-code-muted/50 focus:outline-none focus:ring-2 focus:ring-code-accent/50 focus:border-code-accent",
65
+ "placeholder": "My Script Name",
66
+ }
67
+ ),
68
+ "description": forms.Textarea(
69
+ attrs={
70
+ "class": "w-full px-4 py-3 bg-code-bg border border-code-border rounded-lg text-code-text placeholder-code-muted/50 focus:outline-none focus:ring-2 focus:ring-code-accent/50 focus:border-code-accent",
71
+ "rows": 2,
72
+ "placeholder": "What does this script do?",
73
+ }
74
+ ),
75
+ "code": forms.Textarea(
76
+ attrs={
77
+ "class": "w-full px-4 py-3 bg-code-bg border border-code-border rounded-lg text-code-text font-mono text-sm placeholder-code-muted/50 focus:outline-none focus:ring-2 focus:ring-code-accent/50 focus:border-code-accent",
78
+ "rows": 15,
79
+ "placeholder": '# Your Python code here\nprint("Hello, World!")',
80
+ }
81
+ ),
82
+ "environment": forms.Select(
83
+ attrs={
84
+ "class": "w-full px-4 py-3 bg-code-bg border border-code-border rounded-lg text-code-text focus:outline-none focus:ring-2 focus:ring-code-accent/50 focus:border-code-accent",
85
+ }
86
+ ),
87
+ "tags": forms.CheckboxSelectMultiple(
88
+ attrs={
89
+ "class": "tag-checkbox",
90
+ }
91
+ ),
92
+ "timeout_seconds": forms.NumberInput(
93
+ attrs={
94
+ "class": "w-full px-4 py-3 bg-code-bg border border-code-border rounded-lg text-code-text focus:outline-none focus:ring-2 focus:ring-code-accent/50 focus:border-code-accent",
95
+ "min": 1,
96
+ "max": 86400,
97
+ }
98
+ ),
99
+ "is_enabled": forms.CheckboxInput(
100
+ attrs={
101
+ "class": "w-5 h-5 text-code-accent bg-code-bg border-code-border rounded focus:ring-code-accent focus:ring-2",
102
+ }
103
+ ),
104
+ "notify_on": forms.Select(
105
+ attrs={
106
+ "class": "w-full px-4 py-3 bg-code-bg border border-code-border rounded-lg text-code-text focus:outline-none focus:ring-2 focus:ring-code-accent/50 focus:border-code-accent",
107
+ }
108
+ ),
109
+ "notify_email": forms.EmailInput(
110
+ attrs={
111
+ "class": "w-full px-4 py-3 bg-code-bg border border-code-border rounded-lg text-code-text placeholder-code-muted/50 focus:outline-none focus:ring-2 focus:ring-code-accent/50 focus:border-code-accent",
112
+ "placeholder": "Override default email (optional)",
113
+ }
114
+ ),
115
+ "notify_webhook_url": forms.URLInput(
116
+ attrs={
117
+ "class": "w-full px-4 py-3 bg-code-bg border border-code-border rounded-lg text-code-text placeholder-code-muted/50 focus:outline-none focus:ring-2 focus:ring-code-accent/50 focus:border-code-accent",
118
+ "placeholder": "https://your-service.com/webhook",
119
+ }
120
+ ),
121
+ "notify_webhook_enabled": forms.CheckboxInput(
122
+ attrs={
123
+ "class": "w-5 h-5 text-code-accent bg-code-bg border-code-border rounded focus:ring-code-accent focus:ring-2",
124
+ }
125
+ ),
126
+ }
127
+ labels = {
128
+ "name": "Script Name",
129
+ "description": "Description",
130
+ "code": "Python Code",
131
+ "environment": "Environment",
132
+ "tags": "Tags",
133
+ "timeout_seconds": "Timeout (seconds)",
134
+ "is_enabled": "Enabled",
135
+ "notify_on": "Notify On",
136
+ "notify_email": "Notification Email",
137
+ "notify_webhook_url": "Webhook URL",
138
+ "notify_webhook_enabled": "Enable Webhook",
139
+ }
140
+ help_texts = {
141
+ "timeout_seconds": "Maximum execution time (1 second to 24 hours)",
142
+ "notify_email": "Leave empty to use global default",
143
+ "notify_webhook_url": "URL to POST notifications to when script completes",
144
+ }
145
+
146
+ def __init__(self, *args, **kwargs):
147
+ super().__init__(*args, **kwargs)
148
+ # Only show active environments
149
+ self.fields["environment"].queryset = Environment.objects.filter(is_active=True)
150
+
151
+ def clean_code(self):
152
+ code = self.cleaned_data.get("code", "").strip()
153
+ if not code:
154
+ raise forms.ValidationError("Script code cannot be empty.")
155
+ return code
156
+
157
+ def clean_timeout_seconds(self):
158
+ timeout = self.cleaned_data.get("timeout_seconds")
159
+ if timeout is not None and (timeout < 1 or timeout > 86400):
160
+ raise forms.ValidationError("Timeout must be between 1 and 86400 seconds (24 hours).")
161
+ return timeout
162
+
163
+
164
+ class TagForm(forms.ModelForm):
165
+ """Form for creating and editing tags."""
166
+
167
+ class Meta:
168
+ model = Tag
169
+ fields = ["name", "color"]
170
+ widgets = {
171
+ "name": forms.TextInput(
172
+ attrs={
173
+ "class": "w-full px-4 py-3 bg-code-bg border border-code-border rounded-lg text-code-text placeholder-code-muted/50 focus:outline-none focus:ring-2 focus:ring-code-accent/50 focus:border-code-accent",
174
+ "placeholder": "Tag name",
175
+ }
176
+ ),
177
+ "color": forms.Select(
178
+ attrs={
179
+ "class": "w-full px-4 py-3 bg-code-bg border border-code-border rounded-lg text-code-text focus:outline-none focus:ring-2 focus:ring-code-accent/50 focus:border-code-accent",
180
+ }
181
+ ),
182
+ }
183
+ labels = {
184
+ "name": "Tag Name",
185
+ "color": "Color",
186
+ }
187
+
188
+ def clean_name(self):
189
+ name = self.cleaned_data.get("name", "").strip()
190
+ if not name:
191
+ raise forms.ValidationError("Tag name is required.")
192
+ # Check uniqueness (excluding current instance for edits)
193
+ qs = Tag.objects.filter(name__iexact=name)
194
+ if self.instance.pk:
195
+ qs = qs.exclude(pk=self.instance.pk)
196
+ if qs.exists():
197
+ raise forms.ValidationError("A tag with this name already exists.")
198
+ return name
199
+
200
+
201
+ class ScheduleForm(forms.ModelForm):
202
+ """Form for configuring script schedules."""
203
+
204
+ WEEKDAY_CHOICES = [
205
+ (0, "Monday"),
206
+ (1, "Tuesday"),
207
+ (2, "Wednesday"),
208
+ (3, "Thursday"),
209
+ (4, "Friday"),
210
+ (5, "Saturday"),
211
+ (6, "Sunday"),
212
+ ]
213
+
214
+ MONTHDAY_CHOICES = [(i, str(i)) for i in range(1, 32)]
215
+
216
+ # Custom field for daily times (comma-separated input)
217
+ daily_times_input = forms.CharField(
218
+ required=False,
219
+ widget=forms.TextInput(
220
+ attrs={
221
+ "class": "w-full px-4 py-3 bg-code-bg border border-code-border rounded-lg text-code-text placeholder-code-muted/50 focus:outline-none focus:ring-2 focus:ring-code-accent/50",
222
+ "placeholder": "09:00, 18:00",
223
+ }
224
+ ),
225
+ label="Run Times",
226
+ help_text="Comma-separated times in HH:MM format (24-hour)",
227
+ )
228
+
229
+ # Weekly mode fields
230
+ weekly_days_input = forms.MultipleChoiceField(
231
+ required=False,
232
+ choices=WEEKDAY_CHOICES,
233
+ widget=forms.CheckboxSelectMultiple(
234
+ attrs={
235
+ "class": "sr-only peer",
236
+ }
237
+ ),
238
+ label="Days of Week",
239
+ )
240
+
241
+ weekly_times_input = forms.CharField(
242
+ required=False,
243
+ widget=forms.TextInput(
244
+ attrs={
245
+ "class": "w-full px-4 py-3 bg-code-bg border border-code-border rounded-lg text-code-text placeholder-code-muted/50 focus:outline-none focus:ring-2 focus:ring-code-accent/50",
246
+ "placeholder": "09:00, 18:00",
247
+ }
248
+ ),
249
+ label="Run Times",
250
+ help_text="Comma-separated times in HH:MM format (24-hour)",
251
+ )
252
+
253
+ # Monthly mode fields
254
+ monthly_days_input = forms.MultipleChoiceField(
255
+ required=False,
256
+ choices=MONTHDAY_CHOICES,
257
+ widget=forms.CheckboxSelectMultiple(
258
+ attrs={
259
+ "class": "sr-only peer",
260
+ }
261
+ ),
262
+ label="Days of Month",
263
+ )
264
+
265
+ monthly_times_input = forms.CharField(
266
+ required=False,
267
+ widget=forms.TextInput(
268
+ attrs={
269
+ "class": "w-full px-4 py-3 bg-code-bg border border-code-border rounded-lg text-code-text placeholder-code-muted/50 focus:outline-none focus:ring-2 focus:ring-code-accent/50",
270
+ "placeholder": "09:00, 18:00",
271
+ }
272
+ ),
273
+ label="Run Times",
274
+ help_text="Comma-separated times in HH:MM format (24-hour)",
275
+ )
276
+
277
+ timezone = forms.ChoiceField(
278
+ choices=get_timezone_choices,
279
+ initial="UTC",
280
+ widget=forms.Select(
281
+ attrs={
282
+ "class": "w-full px-4 py-3 bg-code-bg border border-code-border rounded-lg text-code-text focus:outline-none focus:ring-2 focus:ring-code-accent/50",
283
+ }
284
+ ),
285
+ )
286
+
287
+ class Meta:
288
+ model = ScriptSchedule
289
+ fields = ["run_mode", "interval_minutes", "timezone", "is_active"]
290
+ widgets = {
291
+ "run_mode": forms.RadioSelect(
292
+ attrs={
293
+ "class": "sr-only peer",
294
+ }
295
+ ),
296
+ "interval_minutes": forms.Select(
297
+ attrs={
298
+ "class": "w-full px-4 py-3 bg-code-bg border border-code-border rounded-lg text-code-text focus:outline-none focus:ring-2 focus:ring-code-accent/50",
299
+ }
300
+ ),
301
+ "is_active": forms.CheckboxInput(
302
+ attrs={
303
+ "class": "w-5 h-5 text-code-accent bg-code-bg border-code-border rounded focus:ring-code-accent focus:ring-2",
304
+ }
305
+ ),
306
+ }
307
+ labels = {
308
+ "run_mode": "Run Mode",
309
+ "interval_minutes": "Interval",
310
+ "is_active": "Schedule Active",
311
+ }
312
+
313
+ def __init__(self, *args, **kwargs):
314
+ super().__init__(*args, **kwargs)
315
+
316
+ # Populate daily_times_input from instance
317
+ if self.instance and self.instance.pk and self.instance.daily_times:
318
+ self.fields["daily_times_input"].initial = ", ".join(
319
+ self.instance.daily_times
320
+ )
321
+
322
+ # Populate weekly fields from instance
323
+ if self.instance and self.instance.pk:
324
+ if self.instance.weekly_days:
325
+ self.fields["weekly_days_input"].initial = [
326
+ str(d) for d in self.instance.weekly_days
327
+ ]
328
+ if self.instance.weekly_times:
329
+ self.fields["weekly_times_input"].initial = ", ".join(
330
+ self.instance.weekly_times
331
+ )
332
+
333
+ # Populate monthly fields from instance
334
+ if self.instance and self.instance.pk:
335
+ if self.instance.monthly_days:
336
+ self.fields["monthly_days_input"].initial = [
337
+ str(d) for d in self.instance.monthly_days
338
+ ]
339
+ if self.instance.monthly_times:
340
+ self.fields["monthly_times_input"].initial = ", ".join(
341
+ self.instance.monthly_times
342
+ )
343
+
344
+ def _parse_times(self, value):
345
+ """Parse and validate comma-separated times input."""
346
+ if not value:
347
+ return []
348
+
349
+ value = value.strip()
350
+ if not value:
351
+ return []
352
+
353
+ times = []
354
+ for time_str in value.split(","):
355
+ time_str = time_str.strip()
356
+ if not time_str:
357
+ continue
358
+
359
+ # Validate HH:MM format
360
+ try:
361
+ parts = time_str.split(":")
362
+ if len(parts) != 2:
363
+ raise ValueError
364
+ hour, minute = int(parts[0]), int(parts[1])
365
+ if not (0 <= hour <= 23 and 0 <= minute <= 59):
366
+ raise ValueError
367
+ times.append(f"{hour:02d}:{minute:02d}")
368
+ except ValueError:
369
+ raise forms.ValidationError(
370
+ f"Invalid time format: '{time_str}'. Use HH:MM (e.g., 09:00)"
371
+ )
372
+
373
+ return times
374
+
375
+ def clean_daily_times_input(self):
376
+ """Parse and validate daily times input."""
377
+ return self._parse_times(self.cleaned_data.get("daily_times_input", ""))
378
+
379
+ def clean_weekly_times_input(self):
380
+ """Parse and validate weekly times input."""
381
+ return self._parse_times(self.cleaned_data.get("weekly_times_input", ""))
382
+
383
+ def clean_monthly_times_input(self):
384
+ """Parse and validate monthly times input."""
385
+ return self._parse_times(self.cleaned_data.get("monthly_times_input", ""))
386
+
387
+ def clean(self):
388
+ cleaned_data = super().clean()
389
+ run_mode = cleaned_data.get("run_mode")
390
+
391
+ if run_mode == ScriptSchedule.RunMode.INTERVAL:
392
+ if not cleaned_data.get("interval_minutes"):
393
+ self.add_error(
394
+ "interval_minutes", "Interval is required for interval mode."
395
+ )
396
+
397
+ elif run_mode == ScriptSchedule.RunMode.DAILY:
398
+ daily_times = cleaned_data.get("daily_times_input", [])
399
+ if not daily_times:
400
+ self.add_error(
401
+ "daily_times_input",
402
+ "At least one time is required for daily mode.",
403
+ )
404
+
405
+ elif run_mode == ScriptSchedule.RunMode.WEEKLY:
406
+ weekly_days = cleaned_data.get("weekly_days_input", [])
407
+ weekly_times = cleaned_data.get("weekly_times_input", [])
408
+ if not weekly_days:
409
+ self.add_error(
410
+ "weekly_days_input",
411
+ "At least one day is required for weekly mode.",
412
+ )
413
+ if not weekly_times:
414
+ self.add_error(
415
+ "weekly_times_input",
416
+ "At least one time is required for weekly mode.",
417
+ )
418
+
419
+ elif run_mode == ScriptSchedule.RunMode.MONTHLY:
420
+ monthly_days = cleaned_data.get("monthly_days_input", [])
421
+ monthly_times = cleaned_data.get("monthly_times_input", [])
422
+ if not monthly_days:
423
+ self.add_error(
424
+ "monthly_days_input",
425
+ "At least one day is required for monthly mode.",
426
+ )
427
+ if not monthly_times:
428
+ self.add_error(
429
+ "monthly_times_input",
430
+ "At least one time is required for monthly mode.",
431
+ )
432
+
433
+ return cleaned_data
434
+
435
+ def save(self, commit=True):
436
+ instance = super().save(commit=False)
437
+
438
+ # Set daily_times from parsed input
439
+ instance.daily_times = self.cleaned_data.get("daily_times_input", [])
440
+
441
+ # Set weekly fields from parsed input
442
+ weekly_days = self.cleaned_data.get("weekly_days_input", [])
443
+ instance.weekly_days = [int(d) for d in weekly_days] if weekly_days else []
444
+ instance.weekly_times = self.cleaned_data.get("weekly_times_input", [])
445
+
446
+ # Set monthly fields from parsed input
447
+ monthly_days = self.cleaned_data.get("monthly_days_input", [])
448
+ instance.monthly_days = [int(d) for d in monthly_days] if monthly_days else []
449
+ instance.monthly_times = self.cleaned_data.get("monthly_times_input", [])
450
+
451
+ if commit:
452
+ instance.save()
453
+
454
+ return instance
455
+
456
+
457
+ class EnvironmentCreateForm(forms.ModelForm):
458
+ """Form for creating a new environment."""
459
+
460
+ python_path = forms.ChoiceField(
461
+ choices=[],
462
+ widget=forms.Select(
463
+ attrs={
464
+ "class": "w-full px-4 py-3 bg-code-bg border border-code-border rounded-lg text-code-text focus:outline-none focus:ring-2 focus:ring-code-accent/50 focus:border-code-accent",
465
+ }
466
+ ),
467
+ label="Python Version",
468
+ help_text="Select Python installation to use for this environment",
469
+ )
470
+
471
+ class Meta:
472
+ model = Environment
473
+ fields = ["name", "description"]
474
+ widgets = {
475
+ "name": forms.TextInput(
476
+ attrs={
477
+ "class": "w-full px-4 py-3 bg-code-bg border border-code-border rounded-lg text-code-text placeholder-code-muted/50 focus:outline-none focus:ring-2 focus:ring-code-accent/50 focus:border-code-accent",
478
+ "placeholder": "My Environment",
479
+ }
480
+ ),
481
+ "description": forms.Textarea(
482
+ attrs={
483
+ "class": "w-full px-4 py-3 bg-code-bg border border-code-border rounded-lg text-code-text placeholder-code-muted/50 focus:outline-none focus:ring-2 focus:ring-code-accent/50 focus:border-code-accent",
484
+ "rows": 2,
485
+ "placeholder": "Environment description (optional)",
486
+ }
487
+ ),
488
+ }
489
+ labels = {
490
+ "name": "Environment Name",
491
+ "description": "Description",
492
+ }
493
+
494
+ def __init__(self, *args, **kwargs):
495
+ super().__init__(*args, **kwargs)
496
+ # Populate python_path choices from discovered Python installations
497
+ pythons = EnvironmentService.discover_python_versions()
498
+ choices = [(p["path"], p["display"]) for p in pythons]
499
+ if not choices:
500
+ choices = [("", "No Python installations found")]
501
+ self.fields["python_path"].choices = choices
502
+
503
+ def clean_name(self):
504
+ """Validate name and check for path uniqueness."""
505
+ name = self.cleaned_data.get("name", "").strip()
506
+ if not name:
507
+ raise forms.ValidationError("Environment name is required.")
508
+
509
+ # Generate path from slugified name
510
+ base_path = slugify(name)
511
+ if not base_path:
512
+ base_path = "environment"
513
+
514
+ # Ensure path is unique
515
+ path = base_path
516
+ counter = 1
517
+ while Environment.objects.filter(path=path).exists():
518
+ path = f"{base_path}-{counter}"
519
+ counter += 1
520
+
521
+ # Store generated path for use in view
522
+ self._generated_path = path
523
+ return name
524
+
525
+ def get_generated_path(self) -> str:
526
+ """Return the generated path after validation."""
527
+ return getattr(self, "_generated_path", slugify(self.cleaned_data.get("name", "env")))
528
+
529
+
530
+ class EnvironmentEditForm(forms.ModelForm):
531
+ """Form for editing environment details (name/description only)."""
532
+
533
+ class Meta:
534
+ model = Environment
535
+ fields = ["name", "description"]
536
+ widgets = {
537
+ "name": forms.TextInput(
538
+ attrs={
539
+ "class": "w-full px-4 py-3 bg-code-bg border border-code-border rounded-lg text-code-text placeholder-code-muted/50 focus:outline-none focus:ring-2 focus:ring-code-accent/50 focus:border-code-accent",
540
+ "placeholder": "My Environment",
541
+ }
542
+ ),
543
+ "description": forms.Textarea(
544
+ attrs={
545
+ "class": "w-full px-4 py-3 bg-code-bg border border-code-border rounded-lg text-code-text placeholder-code-muted/50 focus:outline-none focus:ring-2 focus:ring-code-accent/50 focus:border-code-accent",
546
+ "rows": 2,
547
+ "placeholder": "Environment description (optional)",
548
+ }
549
+ ),
550
+ }
551
+ labels = {
552
+ "name": "Environment Name",
553
+ "description": "Description",
554
+ }
555
+
556
+
557
+ class PackageInstallForm(forms.Form):
558
+ """Form for installing a single package."""
559
+
560
+ package_spec = forms.CharField(
561
+ max_length=200,
562
+ widget=forms.TextInput(
563
+ attrs={
564
+ "class": "w-full px-4 py-3 bg-code-bg border border-code-border rounded-lg text-code-text font-mono placeholder-code-muted/50 focus:outline-none focus:ring-2 focus:ring-code-accent/50 focus:border-code-accent",
565
+ "placeholder": "requests==2.31.0",
566
+ }
567
+ ),
568
+ label="Package",
569
+ help_text="Package name with optional version (e.g., requests, django>=4.0)",
570
+ )
571
+
572
+ def clean_package_spec(self):
573
+ spec = self.cleaned_data.get("package_spec", "").strip()
574
+ if not spec:
575
+ raise forms.ValidationError("Package specification is required.")
576
+ if not EnvironmentService.validate_package_spec(spec):
577
+ raise forms.ValidationError(
578
+ "Invalid package specification. Use format: package or package==version"
579
+ )
580
+ return spec
581
+
582
+
583
+ class BulkInstallForm(forms.Form):
584
+ """Form for bulk package installation from requirements."""
585
+
586
+ requirements = forms.CharField(
587
+ widget=forms.Textarea(
588
+ attrs={
589
+ "class": "w-full px-4 py-3 bg-code-bg border border-code-border rounded-lg text-code-text font-mono text-sm placeholder-code-muted/50 focus:outline-none focus:ring-2 focus:ring-code-accent/50 focus:border-code-accent",
590
+ "rows": 10,
591
+ "placeholder": "requests==2.31.0\ndjango>=4.0\nnumpy",
592
+ }
593
+ ),
594
+ label="Requirements",
595
+ help_text="Paste requirements.txt content (one package per line)",
596
+ required=False,
597
+ )
598
+
599
+ requirements_file = forms.FileField(
600
+ required=False,
601
+ widget=forms.FileInput(
602
+ attrs={
603
+ "class": "block w-full text-sm text-code-muted file:mr-4 file:py-2 file:px-4 file:rounded-lg file:border-0 file:text-sm file:font-semibold file:bg-code-accent file:text-white hover:file:bg-code-accent/90 cursor-pointer",
604
+ "accept": ".txt",
605
+ }
606
+ ),
607
+ label="Or upload requirements.txt",
608
+ )
609
+
610
+ def clean(self):
611
+ cleaned_data = super().clean()
612
+ text = cleaned_data.get("requirements", "").strip()
613
+ file = cleaned_data.get("requirements_file")
614
+
615
+ if not text and not file:
616
+ raise forms.ValidationError(
617
+ "Provide requirements text or upload a file."
618
+ )
619
+
620
+ # If file provided, read its content
621
+ if file:
622
+ try:
623
+ content = file.read().decode("utf-8")
624
+ cleaned_data["requirements"] = content
625
+ except UnicodeDecodeError:
626
+ raise forms.ValidationError(
627
+ "Could not read file. Ensure it's a valid text file."
628
+ )
629
+
630
+ # Validate each line
631
+ requirements_text = cleaned_data.get("requirements", "")
632
+ for line in requirements_text.splitlines():
633
+ line = line.strip()
634
+ # Skip empty lines and comments
635
+ if not line or line.startswith("#") or line.startswith("-"):
636
+ continue
637
+ # Extract package spec (first word before any whitespace)
638
+ pkg_spec = line.split()[0] if line.split() else ""
639
+ if pkg_spec and not EnvironmentService.validate_package_spec(pkg_spec):
640
+ raise forms.ValidationError(f"Invalid package specification: {line}")
641
+
642
+ return cleaned_data
643
+
644
+
645
+ class SecretCreateForm(forms.Form):
646
+ """Form for creating a new secret."""
647
+
648
+ key = forms.CharField(
649
+ max_length=100,
650
+ widget=forms.TextInput(
651
+ attrs={
652
+ "class": "w-full px-4 py-3 bg-code-bg border border-code-border rounded-lg text-code-text font-mono placeholder-code-muted/50 focus:outline-none focus:ring-2 focus:ring-code-accent/50 focus:border-code-accent uppercase",
653
+ "placeholder": "API_KEY",
654
+ "autocomplete": "off",
655
+ }
656
+ ),
657
+ label="Key Name",
658
+ help_text="Uppercase letters, numbers, and underscores only. Must start with a letter.",
659
+ )
660
+
661
+ value = forms.CharField(
662
+ widget=forms.Textarea(
663
+ attrs={
664
+ "class": "w-full px-4 py-3 bg-code-bg border border-code-border rounded-lg text-code-text font-mono placeholder-code-muted/50 focus:outline-none focus:ring-2 focus:ring-code-accent/50 focus:border-code-accent",
665
+ "rows": 3,
666
+ "placeholder": "sk-your-secret-value-here",
667
+ "autocomplete": "off",
668
+ }
669
+ ),
670
+ label="Secret Value",
671
+ help_text="The secret value (will be encrypted at rest)",
672
+ )
673
+
674
+ description = forms.CharField(
675
+ required=False,
676
+ widget=forms.Textarea(
677
+ attrs={
678
+ "class": "w-full px-4 py-3 bg-code-bg border border-code-border rounded-lg text-code-text placeholder-code-muted/50 focus:outline-none focus:ring-2 focus:ring-code-accent/50 focus:border-code-accent",
679
+ "rows": 2,
680
+ "placeholder": "What is this secret used for?",
681
+ }
682
+ ),
683
+ label="Description",
684
+ help_text="Optional description to help remember what this secret is for",
685
+ )
686
+
687
+ def clean_key(self):
688
+ """Validate and normalize the key."""
689
+ key = self.cleaned_data.get("key", "").strip().upper()
690
+
691
+ if not key:
692
+ raise forms.ValidationError("Key name is required.")
693
+
694
+ if not SECRET_KEY_PATTERN.match(key):
695
+ raise forms.ValidationError(
696
+ "Key must start with a letter and contain only uppercase letters, numbers, and underscores."
697
+ )
698
+
699
+ # Check for reserved environment variable names
700
+ reserved = {
701
+ "PATH",
702
+ "HOME",
703
+ "USER",
704
+ "SHELL",
705
+ "PWD",
706
+ "PYTHONPATH",
707
+ "VIRTUAL_ENV",
708
+ "PYTHONHOME",
709
+ "PYTHONDONTWRITEBYTECODE",
710
+ "PYTHONUNBUFFERED",
711
+ }
712
+ if key in reserved:
713
+ raise forms.ValidationError(
714
+ f"'{key}' is a reserved environment variable name."
715
+ )
716
+
717
+ # Check if key already exists
718
+ from core.models import Secret
719
+
720
+ if Secret.objects.filter(key=key).exists():
721
+ raise forms.ValidationError(f"A secret with key '{key}' already exists.")
722
+
723
+ return key
724
+
725
+ def clean_value(self):
726
+ """Validate the secret value."""
727
+ value = self.cleaned_data.get("value", "")
728
+
729
+ if not value:
730
+ raise forms.ValidationError("Secret value is required.")
731
+
732
+ # Reasonable max length for secrets
733
+ if len(value) > 10000:
734
+ raise forms.ValidationError(
735
+ "Secret value is too long (max 10,000 characters)."
736
+ )
737
+
738
+ return value
739
+
740
+
741
+ class SecretEditForm(forms.Form):
742
+ """Form for editing an existing secret (value and description only)."""
743
+
744
+ value = forms.CharField(
745
+ required=False,
746
+ widget=forms.Textarea(
747
+ attrs={
748
+ "class": "w-full px-4 py-3 bg-code-bg border border-code-border rounded-lg text-code-text font-mono placeholder-code-muted/50 focus:outline-none focus:ring-2 focus:ring-code-accent/50 focus:border-code-accent",
749
+ "rows": 3,
750
+ "placeholder": "Leave blank to keep current value",
751
+ "autocomplete": "off",
752
+ }
753
+ ),
754
+ label="New Secret Value",
755
+ help_text="Leave blank to keep the current value",
756
+ )
757
+
758
+ description = forms.CharField(
759
+ required=False,
760
+ widget=forms.Textarea(
761
+ attrs={
762
+ "class": "w-full px-4 py-3 bg-code-bg border border-code-border rounded-lg text-code-text placeholder-code-muted/50 focus:outline-none focus:ring-2 focus:ring-code-accent/50 focus:border-code-accent",
763
+ "rows": 2,
764
+ "placeholder": "What is this secret used for?",
765
+ }
766
+ ),
767
+ label="Description",
768
+ )
769
+
770
+ def clean_value(self):
771
+ """Validate the secret value if provided."""
772
+ value = self.cleaned_data.get("value", "")
773
+
774
+ if value and len(value) > 10000:
775
+ raise forms.ValidationError(
776
+ "Secret value is too long (max 10,000 characters)."
777
+ )
778
+
779
+ return value
780
+
781
+
782
+ class NotificationSettingsForm(forms.Form):
783
+ """Form for global notification settings."""
784
+
785
+ from core.models import GlobalSettings
786
+
787
+ EMAIL_BACKEND_CHOICES = [
788
+ (GlobalSettings.EmailBackend.DISABLED, "Disabled"),
789
+ (GlobalSettings.EmailBackend.SMTP, "SMTP"),
790
+ (GlobalSettings.EmailBackend.RESEND, "Resend API"),
791
+ ]
792
+
793
+ email_backend = forms.ChoiceField(
794
+ choices=EMAIL_BACKEND_CHOICES,
795
+ initial=GlobalSettings.EmailBackend.DISABLED,
796
+ widget=forms.RadioSelect(
797
+ attrs={
798
+ "class": "sr-only peer",
799
+ }
800
+ ),
801
+ label="Email Backend",
802
+ )
803
+
804
+ # SMTP Configuration
805
+ smtp_host = forms.CharField(
806
+ required=False,
807
+ max_length=255,
808
+ widget=forms.TextInput(
809
+ attrs={
810
+ "class": "w-full px-4 py-3 bg-code-bg border border-code-border rounded-lg text-code-text placeholder-code-muted/50 focus:outline-none focus:ring-2 focus:ring-code-accent/50 focus:border-code-accent",
811
+ "placeholder": "smtp.example.com",
812
+ }
813
+ ),
814
+ label="SMTP Host",
815
+ )
816
+
817
+ smtp_port = forms.IntegerField(
818
+ required=False,
819
+ initial=587,
820
+ widget=forms.NumberInput(
821
+ attrs={
822
+ "class": "w-full px-4 py-3 bg-code-bg border border-code-border rounded-lg text-code-text focus:outline-none focus:ring-2 focus:ring-code-accent/50 focus:border-code-accent",
823
+ "min": 1,
824
+ "max": 65535,
825
+ }
826
+ ),
827
+ label="SMTP Port",
828
+ )
829
+
830
+ smtp_username = forms.CharField(
831
+ required=False,
832
+ max_length=255,
833
+ widget=forms.TextInput(
834
+ attrs={
835
+ "class": "w-full px-4 py-3 bg-code-bg border border-code-border rounded-lg text-code-text placeholder-code-muted/50 focus:outline-none focus:ring-2 focus:ring-code-accent/50 focus:border-code-accent",
836
+ "placeholder": "username@example.com",
837
+ }
838
+ ),
839
+ label="SMTP Username",
840
+ )
841
+
842
+ smtp_password = forms.CharField(
843
+ required=False,
844
+ widget=forms.PasswordInput(
845
+ attrs={
846
+ "class": "w-full px-4 py-3 bg-code-bg border border-code-border rounded-lg text-code-text placeholder-code-muted/50 focus:outline-none focus:ring-2 focus:ring-code-accent/50 focus:border-code-accent",
847
+ "placeholder": "Leave blank to keep current",
848
+ "autocomplete": "new-password",
849
+ }
850
+ ),
851
+ label="SMTP Password",
852
+ )
853
+
854
+ smtp_use_tls = forms.BooleanField(
855
+ required=False,
856
+ initial=True,
857
+ widget=forms.CheckboxInput(
858
+ attrs={
859
+ "class": "w-5 h-5 text-code-accent bg-code-bg border-code-border rounded focus:ring-code-accent focus:ring-2",
860
+ }
861
+ ),
862
+ label="Use TLS",
863
+ )
864
+
865
+ smtp_from_email = forms.EmailField(
866
+ required=False,
867
+ widget=forms.EmailInput(
868
+ attrs={
869
+ "class": "w-full px-4 py-3 bg-code-bg border border-code-border rounded-lg text-code-text placeholder-code-muted/50 focus:outline-none focus:ring-2 focus:ring-code-accent/50 focus:border-code-accent",
870
+ "placeholder": "noreply@example.com",
871
+ }
872
+ ),
873
+ label="From Email",
874
+ )
875
+
876
+ # Resend Configuration
877
+ resend_api_key = forms.CharField(
878
+ required=False,
879
+ widget=forms.PasswordInput(
880
+ attrs={
881
+ "class": "w-full px-4 py-3 bg-code-bg border border-code-border rounded-lg text-code-text placeholder-code-muted/50 focus:outline-none focus:ring-2 focus:ring-code-accent/50 focus:border-code-accent",
882
+ "placeholder": "Leave blank to keep current",
883
+ "autocomplete": "new-password",
884
+ }
885
+ ),
886
+ label="Resend API Key",
887
+ )
888
+
889
+ resend_from_email = forms.EmailField(
890
+ required=False,
891
+ widget=forms.EmailInput(
892
+ attrs={
893
+ "class": "w-full px-4 py-3 bg-code-bg border border-code-border rounded-lg text-code-text placeholder-code-muted/50 focus:outline-none focus:ring-2 focus:ring-code-accent/50 focus:border-code-accent",
894
+ "placeholder": "noreply@yourdomain.com",
895
+ }
896
+ ),
897
+ label="From Email",
898
+ )
899
+
900
+ # Default notification email
901
+ default_notification_email = forms.EmailField(
902
+ required=False,
903
+ widget=forms.EmailInput(
904
+ attrs={
905
+ "class": "w-full px-4 py-3 bg-code-bg border border-code-border rounded-lg text-code-text placeholder-code-muted/50 focus:outline-none focus:ring-2 focus:ring-code-accent/50 focus:border-code-accent",
906
+ "placeholder": "notifications@example.com",
907
+ }
908
+ ),
909
+ label="Default Notification Email",
910
+ help_text="All script notifications will be sent here unless overridden per-script.",
911
+ )
912
+
913
+ def __init__(self, *args, instance=None, **kwargs):
914
+ """Initialize form with existing settings."""
915
+ super().__init__(*args, **kwargs)
916
+ if instance:
917
+ self.fields["email_backend"].initial = instance.email_backend
918
+ self.fields["smtp_host"].initial = instance.smtp_host
919
+ self.fields["smtp_port"].initial = instance.smtp_port
920
+ self.fields["smtp_username"].initial = instance.smtp_username
921
+ self.fields["smtp_use_tls"].initial = instance.smtp_use_tls
922
+ self.fields["smtp_from_email"].initial = instance.smtp_from_email
923
+ self.fields["resend_from_email"].initial = instance.resend_from_email
924
+ self.fields["default_notification_email"].initial = instance.default_notification_email
925
+
926
+ def clean(self):
927
+ """Validate configuration based on selected backend."""
928
+ cleaned_data = super().clean()
929
+ backend = cleaned_data.get("email_backend")
930
+
931
+ from core.models import GlobalSettings
932
+
933
+ if backend == GlobalSettings.EmailBackend.SMTP:
934
+ if not cleaned_data.get("smtp_host"):
935
+ self.add_error("smtp_host", "SMTP host is required for SMTP backend.")
936
+ if not cleaned_data.get("smtp_from_email"):
937
+ self.add_error("smtp_from_email", "From email is required for SMTP backend.")
938
+
939
+ elif backend == GlobalSettings.EmailBackend.RESEND:
940
+ if not cleaned_data.get("resend_from_email"):
941
+ self.add_error("resend_from_email", "From email is required for Resend backend.")
942
+
943
+ return cleaned_data
944
+
945
+ def save(self, instance):
946
+ """Save the notification settings to the GlobalSettings instance."""
947
+ from core.services import EncryptionService
948
+
949
+ instance.email_backend = self.cleaned_data["email_backend"]
950
+ instance.smtp_host = self.cleaned_data.get("smtp_host") or ""
951
+ instance.smtp_port = self.cleaned_data.get("smtp_port") or 587
952
+ instance.smtp_username = self.cleaned_data.get("smtp_username") or ""
953
+ instance.smtp_use_tls = self.cleaned_data.get("smtp_use_tls", True)
954
+ instance.smtp_from_email = self.cleaned_data.get("smtp_from_email") or ""
955
+ instance.resend_from_email = self.cleaned_data.get("resend_from_email") or ""
956
+ instance.default_notification_email = self.cleaned_data.get("default_notification_email") or ""
957
+
958
+ # Encrypt and save SMTP password if provided
959
+ smtp_password = self.cleaned_data.get("smtp_password")
960
+ if smtp_password:
961
+ instance.smtp_password_encrypted = EncryptionService.encrypt(smtp_password)
962
+
963
+ # Encrypt and save Resend API key if provided
964
+ resend_api_key = self.cleaned_data.get("resend_api_key")
965
+ if resend_api_key:
966
+ instance.resend_api_key_encrypted = EncryptionService.encrypt(resend_api_key)
967
+
968
+ instance.save()
969
+ return instance
970
+
971
+
972
+ class GeneralSettingsForm(forms.Form):
973
+ """Form for general instance settings."""
974
+
975
+ from core.models import GlobalSettings
976
+
977
+ DATE_FORMAT_CHOICES = [
978
+ (GlobalSettings.DateFormat.ISO, "YYYY-MM-DD (ISO)"),
979
+ (GlobalSettings.DateFormat.US, "MM/DD/YYYY (US)"),
980
+ (GlobalSettings.DateFormat.EU, "DD/MM/YYYY (EU)"),
981
+ (GlobalSettings.DateFormat.DOT, "DD.MM.YYYY"),
982
+ ]
983
+
984
+ TIME_FORMAT_CHOICES = [
985
+ (GlobalSettings.TimeFormat.H24, "24-hour (14:30)"),
986
+ (GlobalSettings.TimeFormat.H12, "12-hour (2:30 PM)"),
987
+ ]
988
+
989
+ instance_name = forms.CharField(
990
+ max_length=100,
991
+ required=False,
992
+ widget=forms.TextInput(
993
+ attrs={
994
+ "class": "w-full px-4 py-3 bg-code-bg border border-code-border rounded-lg text-code-text placeholder-code-muted/50 focus:outline-none focus:ring-2 focus:ring-code-accent/50 focus:border-code-accent",
995
+ "placeholder": "PyRunner",
996
+ }
997
+ ),
998
+ label="Instance Name",
999
+ help_text="Displayed in the header and email notifications",
1000
+ )
1001
+
1002
+ timezone = forms.ChoiceField(
1003
+ choices=get_timezone_choices,
1004
+ initial="UTC",
1005
+ widget=forms.Select(
1006
+ attrs={
1007
+ "class": "w-full px-4 py-3 bg-code-bg border border-code-border rounded-lg text-code-text focus:outline-none focus:ring-2 focus:ring-code-accent/50 focus:border-code-accent",
1008
+ }
1009
+ ),
1010
+ label="Timezone",
1011
+ help_text="Default timezone for displaying dates and times",
1012
+ )
1013
+
1014
+ date_format = forms.ChoiceField(
1015
+ choices=DATE_FORMAT_CHOICES,
1016
+ widget=forms.Select(
1017
+ attrs={
1018
+ "class": "w-full px-4 py-3 bg-code-bg border border-code-border rounded-lg text-code-text focus:outline-none focus:ring-2 focus:ring-code-accent/50 focus:border-code-accent",
1019
+ }
1020
+ ),
1021
+ label="Date Format",
1022
+ )
1023
+
1024
+ time_format = forms.ChoiceField(
1025
+ choices=TIME_FORMAT_CHOICES,
1026
+ widget=forms.Select(
1027
+ attrs={
1028
+ "class": "w-full px-4 py-3 bg-code-bg border border-code-border rounded-lg text-code-text focus:outline-none focus:ring-2 focus:ring-code-accent/50 focus:border-code-accent",
1029
+ }
1030
+ ),
1031
+ label="Time Format",
1032
+ )
1033
+
1034
+ admin_url_slug = forms.CharField(
1035
+ max_length=100,
1036
+ required=False,
1037
+ widget=forms.TextInput(
1038
+ attrs={
1039
+ "class": "w-full px-4 py-3 bg-code-bg border border-code-border rounded-lg text-code-text placeholder-code-muted/50 focus:outline-none focus:ring-2 focus:ring-code-accent/50 focus:border-code-accent",
1040
+ "placeholder": "django-admin",
1041
+ }
1042
+ ),
1043
+ label="Django Admin URL",
1044
+ help_text="URL path for Django admin (e.g., 'django-admin' → /django-admin/). Requires app restart.",
1045
+ )
1046
+
1047
+ def clean_admin_url_slug(self):
1048
+ """Validate admin URL slug format."""
1049
+ import re
1050
+ slug = self.cleaned_data.get("admin_url_slug", "django-admin").strip().lower()
1051
+ if not slug:
1052
+ slug = "django-admin"
1053
+ # Remove leading/trailing slashes
1054
+ slug = slug.strip("/")
1055
+ # Validate: alphanumeric, hyphens, underscores only
1056
+ if not re.match(r"^[a-z0-9_-]+$", slug):
1057
+ raise forms.ValidationError(
1058
+ "Admin URL can only contain lowercase letters, numbers, hyphens, and underscores."
1059
+ )
1060
+ # Prevent conflicts with existing routes
1061
+ reserved = ["setup", "auth", "cpanel", "webhook", "static", "media"]
1062
+ if slug in reserved:
1063
+ raise forms.ValidationError(
1064
+ f"'{slug}' is a reserved URL path. Please choose a different name."
1065
+ )
1066
+ return slug
1067
+
1068
+ def __init__(self, *args, instance=None, **kwargs):
1069
+ """Initialize form with existing settings."""
1070
+ super().__init__(*args, **kwargs)
1071
+ if instance:
1072
+ self.fields["instance_name"].initial = instance.instance_name
1073
+ self.fields["timezone"].initial = instance.timezone
1074
+ self.fields["date_format"].initial = instance.date_format
1075
+ self.fields["time_format"].initial = instance.time_format
1076
+ self.fields["admin_url_slug"].initial = instance.admin_url_slug
1077
+
1078
+ def save(self, instance):
1079
+ """Save the general settings to the GlobalSettings instance."""
1080
+ instance.instance_name = self.cleaned_data.get("instance_name") or "PyRunner"
1081
+ instance.timezone = self.cleaned_data.get("timezone") or "UTC"
1082
+ instance.date_format = self.cleaned_data.get("date_format")
1083
+ instance.time_format = self.cleaned_data.get("time_format")
1084
+ instance.admin_url_slug = self.cleaned_data.get("admin_url_slug") or "django-admin"
1085
+ instance.save(update_fields=[
1086
+ "instance_name", "timezone", "date_format", "time_format", "admin_url_slug", "updated_at"
1087
+ ])
1088
+ return instance
1089
+
1090
+
1091
+ class LogRetentionForm(forms.Form):
1092
+ """Form for log retention settings."""
1093
+
1094
+ retention_days = forms.IntegerField(
1095
+ min_value=0,
1096
+ initial=0,
1097
+ widget=forms.NumberInput(
1098
+ attrs={
1099
+ "class": "w-full px-4 py-3 bg-code-bg border border-code-border rounded-lg text-code-text focus:outline-none focus:ring-2 focus:ring-code-accent/50 focus:border-code-accent",
1100
+ "min": 0,
1101
+ }
1102
+ ),
1103
+ label="Retention Days",
1104
+ help_text="Delete runs older than this many days (0 = keep forever)",
1105
+ )
1106
+
1107
+ retention_count = forms.IntegerField(
1108
+ min_value=0,
1109
+ initial=0,
1110
+ widget=forms.NumberInput(
1111
+ attrs={
1112
+ "class": "w-full px-4 py-3 bg-code-bg border border-code-border rounded-lg text-code-text focus:outline-none focus:ring-2 focus:ring-code-accent/50 focus:border-code-accent",
1113
+ "min": 0,
1114
+ }
1115
+ ),
1116
+ label="Retention Count",
1117
+ help_text="Keep only the last N runs per script (0 = unlimited)",
1118
+ )
1119
+
1120
+ auto_cleanup_enabled = forms.BooleanField(
1121
+ required=False,
1122
+ initial=False,
1123
+ widget=forms.CheckboxInput(
1124
+ attrs={
1125
+ "class": "w-5 h-5 text-code-accent bg-code-bg border-code-border rounded focus:ring-code-accent focus:ring-2",
1126
+ }
1127
+ ),
1128
+ label="Auto Cleanup",
1129
+ help_text="Automatically clean up old runs daily at 2 AM",
1130
+ )
1131
+
1132
+ def __init__(self, *args, instance=None, **kwargs):
1133
+ """Initialize form with existing settings."""
1134
+ super().__init__(*args, **kwargs)
1135
+ if instance:
1136
+ self.fields["retention_days"].initial = instance.retention_days
1137
+ self.fields["retention_count"].initial = instance.retention_count
1138
+ self.fields["auto_cleanup_enabled"].initial = instance.auto_cleanup_enabled
1139
+
1140
+ def save(self, instance):
1141
+ """Save the retention settings to the GlobalSettings instance."""
1142
+ from core.services import RetentionService
1143
+
1144
+ instance.retention_days = self.cleaned_data.get("retention_days") or 0
1145
+ instance.retention_count = self.cleaned_data.get("retention_count") or 0
1146
+
1147
+ # Handle auto cleanup schedule
1148
+ new_auto_cleanup = self.cleaned_data.get("auto_cleanup_enabled", False)
1149
+ old_auto_cleanup = instance.auto_cleanup_enabled
1150
+
1151
+ instance.auto_cleanup_enabled = new_auto_cleanup
1152
+ instance.save(update_fields=[
1153
+ "retention_days", "retention_count", "auto_cleanup_enabled", "updated_at"
1154
+ ])
1155
+
1156
+ # Manage the django-q2 schedule
1157
+ if new_auto_cleanup and not old_auto_cleanup:
1158
+ RetentionService.enable_auto_cleanup()
1159
+ elif not new_auto_cleanup and old_auto_cleanup:
1160
+ RetentionService.disable_auto_cleanup()
1161
+
1162
+ return instance
1163
+
1164
+
1165
+ class WorkerSettingsForm(forms.Form):
1166
+ """Form for Django-Q2 worker configuration."""
1167
+
1168
+ q_workers = forms.IntegerField(
1169
+ min_value=1,
1170
+ max_value=16,
1171
+ initial=2,
1172
+ widget=forms.NumberInput(
1173
+ attrs={
1174
+ "class": "w-full px-4 py-3 bg-code-bg border border-code-border rounded-lg text-code-text focus:outline-none focus:ring-2 focus:ring-code-accent/50 focus:border-code-accent",
1175
+ "min": 1,
1176
+ "max": 16,
1177
+ }
1178
+ ),
1179
+ label="Worker Count",
1180
+ help_text="Number of worker processes (1-16). More workers can process more tasks simultaneously.",
1181
+ )
1182
+
1183
+ q_timeout = forms.IntegerField(
1184
+ min_value=0,
1185
+ max_value=86400,
1186
+ initial=600,
1187
+ widget=forms.NumberInput(
1188
+ attrs={
1189
+ "class": "w-full px-4 py-3 bg-code-bg border border-code-border rounded-lg text-code-text focus:outline-none focus:ring-2 focus:ring-code-accent/50 focus:border-code-accent",
1190
+ "min": 0,
1191
+ "max": 86400,
1192
+ }
1193
+ ),
1194
+ label="Task Timeout (seconds)",
1195
+ help_text="Maximum time a task can run before worker timeout. Use 0 for no timeout (required on Windows). For long-running scripts, also increase the script's own timeout.",
1196
+ )
1197
+
1198
+ q_retry = forms.IntegerField(
1199
+ min_value=60,
1200
+ max_value=86400,
1201
+ initial=660,
1202
+ widget=forms.NumberInput(
1203
+ attrs={
1204
+ "class": "w-full px-4 py-3 bg-code-bg border border-code-border rounded-lg text-code-text focus:outline-none focus:ring-2 focus:ring-code-accent/50 focus:border-code-accent",
1205
+ "min": 60,
1206
+ "max": 86400,
1207
+ }
1208
+ ),
1209
+ label="Retry Delay (seconds)",
1210
+ help_text="Time before retrying a failed/timed-out task. Should be greater than timeout.",
1211
+ )
1212
+
1213
+ q_queue_limit = forms.IntegerField(
1214
+ min_value=5,
1215
+ max_value=100,
1216
+ initial=20,
1217
+ widget=forms.NumberInput(
1218
+ attrs={
1219
+ "class": "w-full px-4 py-3 bg-code-bg border border-code-border rounded-lg text-code-text focus:outline-none focus:ring-2 focus:ring-code-accent/50 focus:border-code-accent",
1220
+ "min": 5,
1221
+ "max": 100,
1222
+ }
1223
+ ),
1224
+ label="Queue Limit",
1225
+ help_text="Maximum number of tasks that can be queued at once.",
1226
+ )
1227
+
1228
+ def __init__(self, *args, instance=None, **kwargs):
1229
+ """Initialize form with existing settings."""
1230
+ super().__init__(*args, **kwargs)
1231
+ if instance:
1232
+ self.fields["q_workers"].initial = instance.q_workers
1233
+ self.fields["q_timeout"].initial = instance.q_timeout
1234
+ self.fields["q_retry"].initial = instance.q_retry
1235
+ self.fields["q_queue_limit"].initial = instance.q_queue_limit
1236
+
1237
+ def clean(self):
1238
+ """Validate that retry > timeout."""
1239
+ cleaned_data = super().clean()
1240
+ timeout = cleaned_data.get("q_timeout", 0)
1241
+ retry = cleaned_data.get("q_retry", 660)
1242
+
1243
+ if timeout > 0 and retry <= timeout:
1244
+ self.add_error(
1245
+ "q_retry",
1246
+ f"Retry delay ({retry}s) must be greater than timeout ({timeout}s).",
1247
+ )
1248
+
1249
+ return cleaned_data
1250
+
1251
+ def save(self, instance):
1252
+ """Save the worker settings to the GlobalSettings instance."""
1253
+ from django.utils import timezone
1254
+
1255
+ # Check if any values actually changed
1256
+ changed = (
1257
+ instance.q_workers != self.cleaned_data["q_workers"]
1258
+ or instance.q_timeout != self.cleaned_data["q_timeout"]
1259
+ or instance.q_retry != self.cleaned_data["q_retry"]
1260
+ or instance.q_queue_limit != self.cleaned_data["q_queue_limit"]
1261
+ )
1262
+
1263
+ instance.q_workers = self.cleaned_data["q_workers"]
1264
+ instance.q_timeout = self.cleaned_data["q_timeout"]
1265
+ instance.q_retry = self.cleaned_data["q_retry"]
1266
+ instance.q_queue_limit = self.cleaned_data["q_queue_limit"]
1267
+
1268
+ if changed:
1269
+ instance.worker_settings_updated_at = timezone.now()
1270
+
1271
+ instance.save(
1272
+ update_fields=[
1273
+ "q_workers",
1274
+ "q_timeout",
1275
+ "q_retry",
1276
+ "q_queue_limit",
1277
+ "worker_settings_updated_at",
1278
+ "updated_at",
1279
+ ]
1280
+ )
1281
+ return instance
1282
+
1283
+
1284
+ class BackupCreateForm(forms.Form):
1285
+ """Form for configuring backup creation."""
1286
+
1287
+ backup_format = forms.ChoiceField(
1288
+ choices=[
1289
+ ("gzip", "Compressed (recommended) - .json.gz"),
1290
+ ("json", "Plain JSON - .json"),
1291
+ ],
1292
+ initial="gzip",
1293
+ widget=forms.RadioSelect(
1294
+ attrs={
1295
+ "class": "w-4 h-4 text-code-accent bg-code-bg border-code-border focus:ring-code-accent",
1296
+ }
1297
+ ),
1298
+ label="Backup format",
1299
+ help_text="Compressed backups are 80-95% smaller",
1300
+ )
1301
+
1302
+ include_datastores = forms.BooleanField(
1303
+ required=False,
1304
+ initial=True,
1305
+ widget=forms.CheckboxInput(
1306
+ attrs={
1307
+ "class": "w-5 h-5 text-code-accent bg-code-bg border-code-border rounded focus:ring-code-accent focus:ring-2",
1308
+ }
1309
+ ),
1310
+ label="Include DataStores",
1311
+ help_text="Include all DataStores and their key-value entries",
1312
+ )
1313
+
1314
+ include_runs = forms.BooleanField(
1315
+ required=False,
1316
+ initial=True,
1317
+ widget=forms.CheckboxInput(
1318
+ attrs={
1319
+ "class": "w-5 h-5 text-code-accent bg-code-bg border-code-border rounded focus:ring-code-accent focus:ring-2",
1320
+ }
1321
+ ),
1322
+ label="Include run history",
1323
+ help_text="Include execution history (stdout/stderr)",
1324
+ )
1325
+
1326
+ max_runs = forms.IntegerField(
1327
+ initial=1000,
1328
+ min_value=0,
1329
+ max_value=10000,
1330
+ widget=forms.NumberInput(
1331
+ attrs={
1332
+ "class": "w-full px-4 py-2 bg-code-bg text-code-text border border-code-border rounded-lg focus:ring-2 focus:ring-code-accent focus:border-transparent",
1333
+ "placeholder": "1000",
1334
+ }
1335
+ ),
1336
+ label="Maximum runs to include",
1337
+ help_text="Limit run history to most recent N runs (0 = all runs)",
1338
+ )
1339
+
1340
+ include_package_operations = forms.BooleanField(
1341
+ required=False,
1342
+ initial=False,
1343
+ widget=forms.CheckboxInput(
1344
+ attrs={
1345
+ "class": "w-5 h-5 text-code-accent bg-code-bg border-code-border rounded focus:ring-code-accent focus:ring-2",
1346
+ }
1347
+ ),
1348
+ label="Include package operations",
1349
+ help_text="Include pip installation history",
1350
+ )
1351
+
1352
+
1353
+ class BackupRestoreForm(forms.Form):
1354
+ """Form for restoring from backup."""
1355
+
1356
+ backup_file = forms.FileField(
1357
+ widget=forms.FileInput(
1358
+ attrs={
1359
+ "class": "block w-full text-sm text-code-text file:mr-4 file:py-2 file:px-4 file:rounded-lg file:border-0 file:text-sm file:font-semibold file:bg-code-accent file:text-white hover:file:bg-opacity-90",
1360
+ "accept": ".json,.json.gz,.gz",
1361
+ }
1362
+ ),
1363
+ label="Backup file",
1364
+ help_text="JSON or compressed backup file (.json or .json.gz)",
1365
+ )
1366
+
1367
+ restore_runs = forms.BooleanField(
1368
+ required=False,
1369
+ initial=True,
1370
+ widget=forms.CheckboxInput(
1371
+ attrs={
1372
+ "class": "w-5 h-5 text-code-accent bg-code-bg border-code-border rounded focus:ring-code-accent focus:ring-2",
1373
+ }
1374
+ ),
1375
+ label="Restore run history",
1376
+ help_text="Import execution history from backup",
1377
+ )
1378
+
1379
+ confirm_delete = forms.BooleanField(
1380
+ required=True,
1381
+ widget=forms.CheckboxInput(
1382
+ attrs={
1383
+ "class": "w-5 h-5 text-red-500 bg-code-bg border-code-border rounded focus:ring-red-500 focus:ring-2",
1384
+ }
1385
+ ),
1386
+ label="I understand all existing data will be deleted",
1387
+ help_text="This action cannot be undone without the automatic backup",
1388
+ )
1389
+
1390
+
1391
+ # =============================================================================
1392
+ # Data Store Forms
1393
+ # =============================================================================
1394
+
1395
+
1396
+ class DataStoreForm(forms.ModelForm):
1397
+ """Form for creating and editing data stores."""
1398
+
1399
+ class Meta:
1400
+ model = DataStore
1401
+ fields = ["name", "description"]
1402
+ widgets = {
1403
+ "name": forms.TextInput(
1404
+ attrs={
1405
+ "class": "w-full px-4 py-3 bg-code-bg border border-code-border rounded-lg text-code-text placeholder-code-muted/50 focus:outline-none focus:ring-2 focus:ring-code-accent/50 focus:border-code-accent",
1406
+ "placeholder": "my_data_store",
1407
+ }
1408
+ ),
1409
+ "description": forms.Textarea(
1410
+ attrs={
1411
+ "class": "w-full px-4 py-3 bg-code-bg border border-code-border rounded-lg text-code-text placeholder-code-muted/50 focus:outline-none focus:ring-2 focus:ring-code-accent/50 focus:border-code-accent",
1412
+ "rows": 2,
1413
+ "placeholder": "What is this data store used for?",
1414
+ }
1415
+ ),
1416
+ }
1417
+ labels = {
1418
+ "name": "Store Name",
1419
+ "description": "Description",
1420
+ }
1421
+ help_texts = {
1422
+ "name": "Used in scripts as: DataStore(\"name\")",
1423
+ }
1424
+
1425
+ def clean_name(self):
1426
+ name = self.cleaned_data.get("name", "").strip()
1427
+ if not name:
1428
+ raise forms.ValidationError("Store name is required.")
1429
+ # Check for valid identifier-like name
1430
+ if not name.replace("_", "").replace("-", "").isalnum():
1431
+ raise forms.ValidationError(
1432
+ "Name can only contain letters, numbers, underscores, and hyphens."
1433
+ )
1434
+ # Check uniqueness (excluding current instance for edits)
1435
+ qs = DataStore.objects.filter(name__iexact=name)
1436
+ if self.instance.pk:
1437
+ qs = qs.exclude(pk=self.instance.pk)
1438
+ if qs.exists():
1439
+ raise forms.ValidationError("A data store with this name already exists.")
1440
+ return name
1441
+
1442
+
1443
+ class DataStoreEntryForm(forms.Form):
1444
+ """Form for creating and editing data store entries."""
1445
+
1446
+ key = forms.CharField(
1447
+ max_length=255,
1448
+ widget=forms.TextInput(
1449
+ attrs={
1450
+ "class": "w-full px-4 py-3 bg-code-bg border border-code-border rounded-lg text-code-text font-mono placeholder-code-muted/50 focus:outline-none focus:ring-2 focus:ring-code-accent/50 focus:border-code-accent",
1451
+ "placeholder": "my_key",
1452
+ }
1453
+ ),
1454
+ label="Key",
1455
+ help_text="Unique identifier for this entry",
1456
+ )
1457
+
1458
+ value = forms.CharField(
1459
+ widget=forms.Textarea(
1460
+ attrs={
1461
+ "class": "w-full px-4 py-3 bg-code-bg border border-code-border rounded-lg text-code-text font-mono text-sm placeholder-code-muted/50 focus:outline-none focus:ring-2 focus:ring-code-accent/50 focus:border-code-accent",
1462
+ "rows": 6,
1463
+ "placeholder": '{"example": "value"}\nor just a string\nor a number like 42',
1464
+ }
1465
+ ),
1466
+ label="Value (JSON)",
1467
+ help_text="JSON value: string, number, boolean, array, or object",
1468
+ )
1469
+
1470
+ def __init__(self, *args, datastore=None, instance=None, **kwargs):
1471
+ super().__init__(*args, **kwargs)
1472
+ self.datastore = datastore
1473
+ self.instance = instance
1474
+
1475
+ # Pre-populate for editing
1476
+ if instance:
1477
+ self.fields["key"].initial = instance.key
1478
+ self.fields["value"].initial = instance.value_json
1479
+
1480
+ def clean_key(self):
1481
+ key = self.cleaned_data.get("key", "").strip()
1482
+ if not key:
1483
+ raise forms.ValidationError("Key is required.")
1484
+ if len(key) > 255:
1485
+ raise forms.ValidationError("Key cannot exceed 255 characters.")
1486
+
1487
+ # Check uniqueness within the data store
1488
+ if self.datastore:
1489
+ qs = DataStoreEntry.objects.filter(datastore=self.datastore, key=key)
1490
+ if self.instance:
1491
+ qs = qs.exclude(pk=self.instance.pk)
1492
+ if qs.exists():
1493
+ raise forms.ValidationError(
1494
+ f"Key '{key}' already exists in this data store."
1495
+ )
1496
+ return key
1497
+
1498
+ def clean_value(self):
1499
+ import json
1500
+
1501
+ value = self.cleaned_data.get("value", "").strip()
1502
+ if not value:
1503
+ raise forms.ValidationError("Value is required.")
1504
+
1505
+ try:
1506
+ # Validate it's valid JSON
1507
+ json.loads(value)
1508
+ except json.JSONDecodeError as e:
1509
+ raise forms.ValidationError(f"Invalid JSON: {e}")
1510
+
1511
+ return value
1512
+
1513
+
1514
+ # =============================================================================
1515
+ # API Token Forms
1516
+ # =============================================================================
1517
+
1518
+
1519
+ class DataStoreAPITokenForm(forms.ModelForm):
1520
+ """Form for creating API tokens."""
1521
+
1522
+ class Meta:
1523
+ model = DataStoreAPIToken
1524
+ fields = ["name", "datastore", "expires_at"]
1525
+ widgets = {
1526
+ "name": forms.TextInput(
1527
+ attrs={
1528
+ "class": "w-full px-4 py-3 bg-code-bg border border-code-border rounded-lg text-code-text placeholder-code-muted/50 focus:outline-none focus:ring-2 focus:ring-code-accent/50 focus:border-code-accent",
1529
+ "placeholder": "My Dashboard Token",
1530
+ }
1531
+ ),
1532
+ "datastore": forms.Select(
1533
+ attrs={
1534
+ "class": "w-full px-4 py-3 bg-code-bg border border-code-border rounded-lg text-code-text focus:outline-none focus:ring-2 focus:ring-code-accent/50 focus:border-code-accent",
1535
+ }
1536
+ ),
1537
+ "expires_at": forms.DateTimeInput(
1538
+ attrs={
1539
+ "class": "w-full px-4 py-3 bg-code-bg border border-code-border rounded-lg text-code-text focus:outline-none focus:ring-2 focus:ring-code-accent/50 focus:border-code-accent",
1540
+ "type": "datetime-local",
1541
+ },
1542
+ format="%Y-%m-%dT%H:%M",
1543
+ ),
1544
+ }
1545
+ labels = {
1546
+ "name": "Token Name",
1547
+ "datastore": "Scope",
1548
+ "expires_at": "Expires At",
1549
+ }
1550
+ help_texts = {
1551
+ "name": "A friendly name to identify this token",
1552
+ "datastore": "Leave empty for access to all datastores, or select a specific datastore",
1553
+ "expires_at": "Optional. Leave empty for no expiration.",
1554
+ }
1555
+
1556
+ def __init__(self, *args, **kwargs):
1557
+ super().__init__(*args, **kwargs)
1558
+ # Make datastore optional with a clear empty choice
1559
+ self.fields["datastore"].required = False
1560
+ self.fields["datastore"].empty_label = "All Datastores (Global Access)"
1561
+ self.fields["expires_at"].required = False
1562
+
1563
+
1564
+ # =============================================================================
1565
+ # Authentication Forms
1566
+ # =============================================================================
1567
+
1568
+ INPUT_CLASS = "w-full px-4 py-3 bg-code-bg border border-code-border rounded-lg text-code-text placeholder-code-muted/50 focus:outline-none focus:ring-2 focus:ring-code-accent/50 focus:border-code-accent"
1569
+
1570
+
1571
+ class PasswordLoginForm(forms.Form):
1572
+ """Form for password-based login."""
1573
+
1574
+ email = forms.EmailField(
1575
+ widget=forms.EmailInput(
1576
+ attrs={
1577
+ "class": INPUT_CLASS,
1578
+ "placeholder": "you@example.com",
1579
+ "autocomplete": "email",
1580
+ }
1581
+ ),
1582
+ label="Email address",
1583
+ )
1584
+ password = forms.CharField(
1585
+ widget=forms.PasswordInput(
1586
+ attrs={
1587
+ "class": INPUT_CLASS,
1588
+ "placeholder": "Your password",
1589
+ "autocomplete": "current-password",
1590
+ }
1591
+ ),
1592
+ label="Password",
1593
+ )
1594
+
1595
+
1596
+ class SetPasswordForm(forms.Form):
1597
+ """Form for setting or changing password."""
1598
+
1599
+ password = forms.CharField(
1600
+ min_length=8,
1601
+ widget=forms.PasswordInput(
1602
+ attrs={
1603
+ "class": INPUT_CLASS,
1604
+ "placeholder": "New password",
1605
+ "autocomplete": "new-password",
1606
+ }
1607
+ ),
1608
+ label="New Password",
1609
+ help_text="Minimum 8 characters",
1610
+ )
1611
+ password_confirm = forms.CharField(
1612
+ widget=forms.PasswordInput(
1613
+ attrs={
1614
+ "class": INPUT_CLASS,
1615
+ "placeholder": "Confirm new password",
1616
+ "autocomplete": "new-password",
1617
+ }
1618
+ ),
1619
+ label="Confirm Password",
1620
+ )
1621
+
1622
+ def clean(self):
1623
+ cleaned_data = super().clean()
1624
+ password = cleaned_data.get("password")
1625
+ confirm = cleaned_data.get("password_confirm")
1626
+ if password and confirm and password != confirm:
1627
+ raise forms.ValidationError("Passwords do not match.")
1628
+ return cleaned_data
1629
+
1630
+
1631
+ class AdminSetupForm(forms.Form):
1632
+ """Form for initial admin setup with password."""
1633
+
1634
+ email = forms.EmailField(
1635
+ widget=forms.EmailInput(
1636
+ attrs={
1637
+ "class": INPUT_CLASS,
1638
+ "placeholder": "admin@example.com",
1639
+ "autocomplete": "email",
1640
+ }
1641
+ ),
1642
+ label="Admin Email",
1643
+ )
1644
+ password = forms.CharField(
1645
+ min_length=8,
1646
+ widget=forms.PasswordInput(
1647
+ attrs={
1648
+ "class": INPUT_CLASS,
1649
+ "placeholder": "Create a strong password",
1650
+ "autocomplete": "new-password",
1651
+ }
1652
+ ),
1653
+ label="Password",
1654
+ help_text="Minimum 8 characters",
1655
+ )
1656
+ password_confirm = forms.CharField(
1657
+ widget=forms.PasswordInput(
1658
+ attrs={
1659
+ "class": INPUT_CLASS,
1660
+ "placeholder": "Confirm password",
1661
+ "autocomplete": "new-password",
1662
+ }
1663
+ ),
1664
+ label="Confirm Password",
1665
+ )
1666
+
1667
+ def clean(self):
1668
+ cleaned_data = super().clean()
1669
+ password = cleaned_data.get("password")
1670
+ confirm = cleaned_data.get("password_confirm")
1671
+ if password and confirm and password != confirm:
1672
+ raise forms.ValidationError("Passwords do not match.")
1673
+ return cleaned_data
1674
+
1675
+
1676
+ # =============================================================================
1677
+ # Services Forms
1678
+ # =============================================================================
1679
+
1680
+
1681
+ class S3SettingsForm(forms.Form):
1682
+ """Form for S3 storage configuration."""
1683
+
1684
+ s3_enabled = forms.BooleanField(
1685
+ required=False,
1686
+ initial=False,
1687
+ widget=forms.CheckboxInput(
1688
+ attrs={
1689
+ "class": "w-5 h-5 text-code-accent bg-code-bg border-code-border rounded focus:ring-code-accent focus:ring-2",
1690
+ }
1691
+ ),
1692
+ label="Enable S3 Storage",
1693
+ )
1694
+
1695
+ s3_endpoint_url = forms.CharField(
1696
+ required=False,
1697
+ max_length=500,
1698
+ widget=forms.TextInput(
1699
+ attrs={
1700
+ "class": INPUT_CLASS,
1701
+ "placeholder": "https://s3.amazonaws.com or https://minio.example.com:9000",
1702
+ }
1703
+ ),
1704
+ label="Endpoint URL",
1705
+ help_text="Leave empty for AWS S3. Required for MinIO, DigitalOcean Spaces, etc.",
1706
+ )
1707
+
1708
+ s3_region = forms.CharField(
1709
+ required=False,
1710
+ max_length=50,
1711
+ initial="us-east-1",
1712
+ widget=forms.TextInput(
1713
+ attrs={
1714
+ "class": INPUT_CLASS,
1715
+ "placeholder": "us-east-1",
1716
+ }
1717
+ ),
1718
+ label="Region",
1719
+ )
1720
+
1721
+ s3_bucket_name = forms.CharField(
1722
+ required=False,
1723
+ max_length=255,
1724
+ widget=forms.TextInput(
1725
+ attrs={
1726
+ "class": INPUT_CLASS,
1727
+ "placeholder": "my-backup-bucket",
1728
+ }
1729
+ ),
1730
+ label="Bucket Name",
1731
+ )
1732
+
1733
+ s3_access_key = forms.CharField(
1734
+ required=False,
1735
+ widget=forms.PasswordInput(
1736
+ attrs={
1737
+ "class": INPUT_CLASS,
1738
+ "placeholder": "Leave blank to keep current",
1739
+ "autocomplete": "new-password",
1740
+ }
1741
+ ),
1742
+ label="Access Key",
1743
+ )
1744
+
1745
+ s3_secret_key = forms.CharField(
1746
+ required=False,
1747
+ widget=forms.PasswordInput(
1748
+ attrs={
1749
+ "class": INPUT_CLASS,
1750
+ "placeholder": "Leave blank to keep current",
1751
+ "autocomplete": "new-password",
1752
+ }
1753
+ ),
1754
+ label="Secret Key",
1755
+ )
1756
+
1757
+ s3_use_ssl = forms.BooleanField(
1758
+ required=False,
1759
+ initial=True,
1760
+ widget=forms.CheckboxInput(
1761
+ attrs={
1762
+ "class": "w-5 h-5 text-code-accent bg-code-bg border-code-border rounded focus:ring-code-accent focus:ring-2",
1763
+ }
1764
+ ),
1765
+ label="Use SSL/TLS",
1766
+ help_text="Recommended for security. Disable only for local development.",
1767
+ )
1768
+
1769
+ s3_path_style = forms.BooleanField(
1770
+ required=False,
1771
+ initial=False,
1772
+ widget=forms.CheckboxInput(
1773
+ attrs={
1774
+ "class": "w-5 h-5 text-code-accent bg-code-bg border-code-border rounded focus:ring-code-accent focus:ring-2",
1775
+ }
1776
+ ),
1777
+ label="Path-style addressing",
1778
+ help_text="Required for MinIO and some S3-compatible providers.",
1779
+ )
1780
+
1781
+ def __init__(self, *args, instance=None, **kwargs):
1782
+ """Initialize form with existing settings."""
1783
+ super().__init__(*args, **kwargs)
1784
+ if instance:
1785
+ self.fields["s3_enabled"].initial = instance.s3_enabled
1786
+ self.fields["s3_endpoint_url"].initial = instance.s3_endpoint_url
1787
+ self.fields["s3_region"].initial = instance.s3_region or "us-east-1"
1788
+ self.fields["s3_bucket_name"].initial = instance.s3_bucket_name
1789
+ self.fields["s3_use_ssl"].initial = instance.s3_use_ssl
1790
+ self.fields["s3_path_style"].initial = instance.s3_path_style
1791
+
1792
+ def save(self, instance):
1793
+ """Save the S3 settings to the GlobalSettings instance."""
1794
+ from core.services.encryption_service import EncryptionService
1795
+
1796
+ instance.s3_enabled = self.cleaned_data.get("s3_enabled", False)
1797
+ instance.s3_endpoint_url = self.cleaned_data.get("s3_endpoint_url") or ""
1798
+ instance.s3_region = self.cleaned_data.get("s3_region") or "us-east-1"
1799
+ instance.s3_bucket_name = self.cleaned_data.get("s3_bucket_name") or ""
1800
+ instance.s3_use_ssl = self.cleaned_data.get("s3_use_ssl", True)
1801
+ instance.s3_path_style = self.cleaned_data.get("s3_path_style", False)
1802
+
1803
+ # Encrypt and save access key if provided
1804
+ access_key = self.cleaned_data.get("s3_access_key")
1805
+ if access_key:
1806
+ instance.s3_access_key_encrypted = EncryptionService.encrypt(access_key)
1807
+
1808
+ # Encrypt and save secret key if provided
1809
+ secret_key = self.cleaned_data.get("s3_secret_key")
1810
+ if secret_key:
1811
+ instance.s3_secret_key_encrypted = EncryptionService.encrypt(secret_key)
1812
+
1813
+ instance.save()
1814
+ return instance
1815
+
1816
+
1817
+ class S3BackupScheduleForm(forms.Form):
1818
+ """Form for S3 scheduled backup configuration."""
1819
+
1820
+ from core.models import GlobalSettings
1821
+
1822
+ WEEKDAY_CHOICES = [
1823
+ (0, "Monday"),
1824
+ (1, "Tuesday"),
1825
+ (2, "Wednesday"),
1826
+ (3, "Thursday"),
1827
+ (4, "Friday"),
1828
+ (5, "Saturday"),
1829
+ (6, "Sunday"),
1830
+ ]
1831
+
1832
+ s3_backup_enabled = forms.BooleanField(
1833
+ required=False,
1834
+ initial=False,
1835
+ widget=forms.CheckboxInput(
1836
+ attrs={
1837
+ "class": "w-5 h-5 text-code-accent bg-code-bg border-code-border rounded focus:ring-code-accent focus:ring-2",
1838
+ }
1839
+ ),
1840
+ label="Enable Scheduled Backups",
1841
+ )
1842
+
1843
+ s3_backup_schedule = forms.ChoiceField(
1844
+ choices=GlobalSettings.S3BackupSchedule.choices,
1845
+ initial=GlobalSettings.S3BackupSchedule.DISABLED,
1846
+ widget=forms.Select(
1847
+ attrs={
1848
+ "class": INPUT_CLASS,
1849
+ }
1850
+ ),
1851
+ label="Schedule Frequency",
1852
+ )
1853
+
1854
+ s3_backup_time = forms.TimeField(
1855
+ initial="02:00",
1856
+ widget=forms.TimeInput(
1857
+ attrs={
1858
+ "class": INPUT_CLASS,
1859
+ "type": "time",
1860
+ }
1861
+ ),
1862
+ label="Backup Time",
1863
+ help_text="Time to run backups (in instance timezone)",
1864
+ )
1865
+
1866
+ s3_backup_day = forms.ChoiceField(
1867
+ choices=WEEKDAY_CHOICES,
1868
+ initial=0,
1869
+ widget=forms.Select(
1870
+ attrs={
1871
+ "class": INPUT_CLASS,
1872
+ }
1873
+ ),
1874
+ label="Day of Week",
1875
+ help_text="For weekly backups",
1876
+ )
1877
+
1878
+ s3_backup_prefix = forms.CharField(
1879
+ required=False,
1880
+ max_length=255,
1881
+ initial="pyrunner-backups/",
1882
+ widget=forms.TextInput(
1883
+ attrs={
1884
+ "class": INPUT_CLASS,
1885
+ "placeholder": "pyrunner-backups/",
1886
+ }
1887
+ ),
1888
+ label="S3 Path Prefix",
1889
+ help_text="Path prefix for backup files in the bucket",
1890
+ )
1891
+
1892
+ s3_backup_retention_count = forms.IntegerField(
1893
+ min_value=0,
1894
+ initial=7,
1895
+ widget=forms.NumberInput(
1896
+ attrs={
1897
+ "class": INPUT_CLASS,
1898
+ "min": "0",
1899
+ }
1900
+ ),
1901
+ label="Retention Count",
1902
+ help_text="Keep the last N backups (0 = keep all)",
1903
+ )
1904
+
1905
+ s3_backup_include_runs = forms.BooleanField(
1906
+ required=False,
1907
+ initial=False,
1908
+ widget=forms.CheckboxInput(
1909
+ attrs={
1910
+ "class": "w-5 h-5 text-code-accent bg-code-bg border-code-border rounded focus:ring-code-accent focus:ring-2",
1911
+ }
1912
+ ),
1913
+ label="Include Run History",
1914
+ )
1915
+
1916
+ s3_backup_max_runs = forms.IntegerField(
1917
+ min_value=0,
1918
+ initial=1000,
1919
+ widget=forms.NumberInput(
1920
+ attrs={
1921
+ "class": INPUT_CLASS,
1922
+ "min": "0",
1923
+ }
1924
+ ),
1925
+ label="Max Runs",
1926
+ help_text="Maximum runs to include (0 = all)",
1927
+ )
1928
+
1929
+ s3_backup_include_datastores = forms.BooleanField(
1930
+ required=False,
1931
+ initial=True,
1932
+ widget=forms.CheckboxInput(
1933
+ attrs={
1934
+ "class": "w-5 h-5 text-code-accent bg-code-bg border-code-border rounded focus:ring-code-accent focus:ring-2",
1935
+ }
1936
+ ),
1937
+ label="Include DataStores",
1938
+ )
1939
+
1940
+ def __init__(self, *args, instance=None, **kwargs):
1941
+ """Initialize form with existing settings."""
1942
+ super().__init__(*args, **kwargs)
1943
+ if instance:
1944
+ self.fields["s3_backup_enabled"].initial = instance.s3_backup_enabled
1945
+ self.fields["s3_backup_schedule"].initial = instance.s3_backup_schedule
1946
+ self.fields["s3_backup_time"].initial = instance.s3_backup_time
1947
+ self.fields["s3_backup_day"].initial = instance.s3_backup_day
1948
+ self.fields["s3_backup_prefix"].initial = instance.s3_backup_prefix or "pyrunner-backups/"
1949
+ self.fields["s3_backup_retention_count"].initial = instance.s3_backup_retention_count
1950
+ self.fields["s3_backup_include_runs"].initial = instance.s3_backup_include_runs
1951
+ self.fields["s3_backup_max_runs"].initial = instance.s3_backup_max_runs
1952
+ self.fields["s3_backup_include_datastores"].initial = instance.s3_backup_include_datastores
1953
+
1954
+ def save(self, instance):
1955
+ """Save the backup schedule settings."""
1956
+ from core.services.backup_schedule_service import BackupScheduleService
1957
+
1958
+ instance.s3_backup_enabled = self.cleaned_data.get("s3_backup_enabled", False)
1959
+ instance.s3_backup_schedule = self.cleaned_data.get("s3_backup_schedule")
1960
+ instance.s3_backup_time = self.cleaned_data.get("s3_backup_time")
1961
+ instance.s3_backup_day = int(self.cleaned_data.get("s3_backup_day", 0))
1962
+ instance.s3_backup_prefix = self.cleaned_data.get("s3_backup_prefix") or "pyrunner-backups/"
1963
+ instance.s3_backup_retention_count = self.cleaned_data.get("s3_backup_retention_count", 7)
1964
+ instance.s3_backup_include_runs = self.cleaned_data.get("s3_backup_include_runs", False)
1965
+ instance.s3_backup_max_runs = self.cleaned_data.get("s3_backup_max_runs", 1000)
1966
+ instance.s3_backup_include_datastores = self.cleaned_data.get("s3_backup_include_datastores", True)
1967
+
1968
+ instance.save()
1969
+
1970
+ # Sync the django-q2 schedule
1971
+ BackupScheduleService.sync_schedule()
1972
+
1973
+ return instance
core/logging.py ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Custom logging formatters for PyRunner.
3
+ """
4
+ import json
5
+ import logging
6
+ from datetime import datetime
7
+
8
+
9
+ class JsonFormatter(logging.Formatter):
10
+ """
11
+ Format log records as JSON for easier parsing.
12
+ """
13
+
14
+ def format(self, record: logging.LogRecord) -> str:
15
+ log_entry = {
16
+ "timestamp": datetime.fromtimestamp(record.created).isoformat(),
17
+ "level": record.levelname,
18
+ "logger": record.name,
19
+ "message": record.getMessage(),
20
+ "module": record.module,
21
+ "function": record.funcName,
22
+ "line": record.lineno,
23
+ }
24
+
25
+ # Add exception info if present
26
+ if record.exc_info:
27
+ log_entry["exception"] = self.formatException(record.exc_info)
28
+
29
+ return json.dumps(log_entry)
core/middleware.py ADDED
@@ -0,0 +1,87 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Custom middleware for PyRunner.
3
+ """
4
+
5
+ import logging
6
+
7
+ from django.shortcuts import redirect
8
+ from django.urls import reverse
9
+
10
+ logger = logging.getLogger(__name__)
11
+
12
+
13
+ class SetupWizardMiddleware:
14
+ """
15
+ Middleware that redirects to setup wizard if initial setup is not completed.
16
+
17
+ Allows access to:
18
+ - /setup/* (the setup wizard itself)
19
+ - /static/* (static assets)
20
+ - /<admin_url>/* (emergency access, configurable)
21
+ """
22
+
23
+ # Static allowed paths (always allowed)
24
+ STATIC_ALLOWED_PREFIXES = [
25
+ "/setup/",
26
+ "/static/",
27
+ ]
28
+
29
+ def __init__(self, get_response):
30
+ self.get_response = get_response
31
+ self._admin_prefix = None
32
+
33
+ def _get_admin_prefix(self):
34
+ """Get the admin URL prefix (cached after first call)."""
35
+ if self._admin_prefix is None:
36
+ try:
37
+ from core.models import GlobalSettings
38
+ slug = GlobalSettings.get_settings().admin_url_slug or "django-admin"
39
+ self._admin_prefix = f"/{slug}/"
40
+ except Exception:
41
+ self._admin_prefix = "/django-admin/"
42
+ return self._admin_prefix
43
+
44
+ def _get_allowed_prefixes(self):
45
+ """Get all allowed path prefixes including dynamic admin URL."""
46
+ return self.STATIC_ALLOWED_PREFIXES + [self._get_admin_prefix()]
47
+
48
+ def __call__(self, request):
49
+ # Skip for allowed paths
50
+ if any(
51
+ request.path.startswith(prefix)
52
+ for prefix in self._get_allowed_prefixes()
53
+ ):
54
+ return self.get_response(request)
55
+
56
+ # Check if setup is needed
57
+ if self._is_setup_needed():
58
+ setup_url = reverse("setup:setup")
59
+ if request.path != setup_url:
60
+ return redirect(setup_url)
61
+
62
+ # Check if admin setup is needed (setup complete but no admin user)
63
+ elif self._is_admin_setup_needed():
64
+ admin_setup_url = reverse("setup:admin_setup")
65
+ if request.path != admin_setup_url:
66
+ return redirect(admin_setup_url)
67
+
68
+ return self.get_response(request)
69
+
70
+ def _is_setup_needed(self) -> bool:
71
+ """Check if initial setup has been completed."""
72
+ try:
73
+ from core.services.setup_service import SetupService
74
+ return SetupService.is_setup_needed()
75
+ except Exception as e:
76
+ # If we can't check, assume setup is needed
77
+ logger.debug(f"Setup check failed in middleware: {e}")
78
+ return True
79
+
80
+ def _is_admin_setup_needed(self) -> bool:
81
+ """Check if admin user needs to be created."""
82
+ try:
83
+ from core.services.setup_service import SetupService
84
+ return SetupService.needs_admin_setup()
85
+ except Exception as e:
86
+ logger.debug(f"Admin setup check failed in middleware: {e}")
87
+ return False
core/tasks.py ADDED
@@ -0,0 +1,413 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Async tasks for PyRunner using django-q2.
3
+
4
+ This module contains task functions that are executed asynchronously
5
+ by django-q2 workers.
6
+ """
7
+
8
+ import logging
9
+ from uuid import UUID
10
+
11
+ from django.utils import timezone
12
+ from django_q.tasks import async_task
13
+
14
+ from core.models import Run, Script, ScriptSchedule, GlobalSettings, PackageOperation
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+
19
+ def execute_run_task(run_id: str, webhook_data: dict | None = None) -> dict:
20
+ """
21
+ Execute a script run asynchronously.
22
+
23
+ This is the task function called by django-q2 workers.
24
+ It fetches the Run by ID and delegates to the executor.
25
+
26
+ Args:
27
+ run_id: The UUID of the Run record (as string)
28
+ webhook_data: Optional webhook data to inject as environment variables
29
+
30
+ Returns:
31
+ dict: Execution result summary for logging/monitoring
32
+ """
33
+ from core.executor import execute_run # Import here to avoid circular imports
34
+ from core.services.notification_service import NotificationService
35
+
36
+ try:
37
+ run = Run.objects.select_related("script", "script__environment").get(
38
+ id=UUID(run_id)
39
+ )
40
+ except Run.DoesNotExist:
41
+ logger.error(f"Run {run_id} not found - task cannot execute")
42
+ return {
43
+ "success": False,
44
+ "run_id": run_id,
45
+ "error": "Run not found",
46
+ }
47
+ except ValueError as e:
48
+ logger.error(f"Invalid run_id format: {run_id} - {e}")
49
+ return {
50
+ "success": False,
51
+ "run_id": run_id,
52
+ "error": f"Invalid UUID format: {e}",
53
+ }
54
+
55
+ execute_run(run, webhook_data=webhook_data)
56
+ run.refresh_from_db()
57
+
58
+ # Send notifications after run completion
59
+ try:
60
+ notification_results = NotificationService.send_notification(run)
61
+ if notification_results.get("email_sent") or notification_results.get("webhook_sent"):
62
+ logger.info(f"Notifications sent for run {run_id}: {notification_results}")
63
+ except Exception as e:
64
+ logger.error(f"Failed to send notifications for run {run_id}: {e}")
65
+
66
+ logger.info(f"Task completed for Run {run_id} with status {run.status}")
67
+
68
+ return {
69
+ "success": run.status == Run.Status.SUCCESS,
70
+ "run_id": run_id,
71
+ "status": run.status,
72
+ "exit_code": run.exit_code,
73
+ }
74
+
75
+
76
+ def queue_script_run(run: Run, webhook_data: dict | None = None) -> str:
77
+ """
78
+ Queue a Run for async execution.
79
+
80
+ This is the main entry point for queuing script runs.
81
+ It handles setting up the async task and storing the task_id.
82
+
83
+ Args:
84
+ run: The Run model instance to execute
85
+ webhook_data: Optional webhook data to inject as environment variables
86
+
87
+ Returns:
88
+ str: The django-q2 task ID
89
+
90
+ Raises:
91
+ ValueError: If the run is not in PENDING status
92
+ """
93
+ if run.status != Run.Status.PENDING:
94
+ raise ValueError(
95
+ f"Cannot queue run {run.id}: status is {run.status}, expected PENDING"
96
+ )
97
+
98
+ task_id = async_task(
99
+ "core.tasks.execute_run_task",
100
+ str(run.id),
101
+ webhook_data,
102
+ task_name=f"run-{run.id}",
103
+ timeout=run.script.timeout_seconds + 60,
104
+ )
105
+
106
+ run.task_id = task_id
107
+ run.save(update_fields=["task_id"])
108
+
109
+ logger.info(f"Queued Run {run.id} as task {task_id}")
110
+
111
+ return task_id
112
+
113
+
114
+ def execute_scheduled_run(script_id: str) -> dict:
115
+ """
116
+ Execute a scheduled script run.
117
+ Called by django-q2 scheduler.
118
+
119
+ Args:
120
+ script_id: The UUID of the Script (as string)
121
+
122
+ Returns:
123
+ dict: Execution result summary
124
+ """
125
+ from core.services.schedule_service import ScheduleService
126
+
127
+ # Check global pause
128
+ settings = GlobalSettings.get_settings()
129
+ if settings.schedules_paused:
130
+ logger.info(f"Scheduled run for script {script_id} skipped - schedules paused")
131
+ return {"success": False, "error": "Schedules globally paused"}
132
+
133
+ try:
134
+ script = Script.objects.get(id=UUID(script_id))
135
+ except Script.DoesNotExist:
136
+ logger.error(f"Script {script_id} not found for scheduled run")
137
+ return {"success": False, "error": "Script not found"}
138
+ except ValueError as e:
139
+ logger.error(f"Invalid script_id format: {script_id} - {e}")
140
+ return {"success": False, "error": f"Invalid UUID format: {e}"}
141
+
142
+ # Check if script can run (enabled and not archived)
143
+ if not script.can_run:
144
+ reason = "archived" if script.is_archived else "disabled"
145
+ logger.info(f"Scheduled run for script {script.name} skipped - script {reason}")
146
+ return {"success": False, "error": f"Script {reason}"}
147
+
148
+ # Check if schedule is active
149
+ try:
150
+ schedule = script.schedule
151
+ if not schedule.is_active:
152
+ logger.info(
153
+ f"Scheduled run for script {script.name} skipped - schedule inactive"
154
+ )
155
+ return {"success": False, "error": "Schedule inactive"}
156
+ except ScriptSchedule.DoesNotExist:
157
+ logger.error(f"No schedule found for script {script.name}")
158
+ return {"success": False, "error": "No schedule"}
159
+
160
+ # Create the run
161
+ run = Run.objects.create(
162
+ script=script,
163
+ status=Run.Status.PENDING,
164
+ triggered_by=None, # System-triggered
165
+ trigger_type=Run.TriggerType.SCHEDULED,
166
+ code_snapshot=script.code,
167
+ )
168
+
169
+ # Update schedule tracking
170
+ schedule.last_scheduled_run = timezone.now()
171
+ schedule.save(update_fields=["last_scheduled_run"])
172
+
173
+ # Queue for execution
174
+ queue_script_run(run)
175
+
176
+ # Update next_run cache
177
+ schedule.next_run = ScheduleService._calculate_next_run(schedule)
178
+ schedule.save(update_fields=["next_run"])
179
+
180
+ logger.info(f"Scheduled run {run.id} created for script {script.name}")
181
+
182
+ return {
183
+ "success": True,
184
+ "run_id": str(run.id),
185
+ "script_id": script_id,
186
+ }
187
+
188
+
189
+ def execute_package_operation(operation_id: str) -> dict:
190
+ """
191
+ Execute a package operation (install/uninstall) asynchronously.
192
+ Called by django-q2 workers.
193
+
194
+ Args:
195
+ operation_id: The UUID of the PackageOperation record (as string)
196
+
197
+ Returns:
198
+ dict: Operation result summary
199
+ """
200
+ from core.services.environment_service import EnvironmentService
201
+
202
+ try:
203
+ operation = PackageOperation.objects.select_related("environment").get(
204
+ id=UUID(operation_id)
205
+ )
206
+ except PackageOperation.DoesNotExist:
207
+ logger.error(f"PackageOperation {operation_id} not found")
208
+ return {"success": False, "error": "Operation not found"}
209
+ except ValueError as e:
210
+ logger.error(f"Invalid operation_id format: {operation_id} - {e}")
211
+ return {"success": False, "error": f"Invalid UUID format: {e}"}
212
+
213
+ # Update to running
214
+ operation.status = PackageOperation.Status.RUNNING
215
+ operation.started_at = timezone.now()
216
+ operation.save(update_fields=["status", "started_at"])
217
+
218
+ environment = operation.environment
219
+
220
+ try:
221
+ if operation.operation == PackageOperation.Operation.INSTALL:
222
+ success, stdout, stderr = EnvironmentService.install_package(
223
+ environment, operation.package_spec
224
+ )
225
+ elif operation.operation == PackageOperation.Operation.UNINSTALL:
226
+ success, stdout, stderr = EnvironmentService.uninstall_package(
227
+ environment, operation.package_spec
228
+ )
229
+ elif operation.operation == PackageOperation.Operation.BULK_INSTALL:
230
+ success, stdout, stderr = EnvironmentService.install_requirements(
231
+ environment, operation.package_spec
232
+ )
233
+ else:
234
+ success, stdout, stderr = False, "", "Unknown operation type"
235
+
236
+ operation.output = stdout
237
+ operation.error = stderr
238
+ operation.status = (
239
+ PackageOperation.Status.SUCCESS
240
+ if success
241
+ else PackageOperation.Status.FAILED
242
+ )
243
+
244
+ # Update environment requirements cache on success
245
+ if success:
246
+ environment.requirements = EnvironmentService.pip_freeze(environment)
247
+ environment.save(update_fields=["requirements", "updated_at"])
248
+
249
+ except Exception as e:
250
+ operation.status = PackageOperation.Status.FAILED
251
+ operation.error = str(e)
252
+ logger.exception(f"Package operation {operation_id} failed with exception")
253
+
254
+ operation.completed_at = timezone.now()
255
+ operation.save()
256
+
257
+ logger.info(
258
+ f"Package operation {operation_id} completed with status {operation.status}"
259
+ )
260
+
261
+ return {
262
+ "success": operation.status == PackageOperation.Status.SUCCESS,
263
+ "operation_id": operation_id,
264
+ "status": operation.status,
265
+ }
266
+
267
+
268
+ def cleanup_old_runs_task() -> dict:
269
+ """
270
+ Clean up old runs based on retention settings.
271
+ Called by django-q2 scheduler or manually.
272
+
273
+ Returns:
274
+ dict: Cleanup result summary with count of deleted runs
275
+ """
276
+ from core.services.retention_service import RetentionService
277
+
278
+ try:
279
+ deleted_count = RetentionService.cleanup_all_runs()
280
+
281
+ # Update last_cleanup_at timestamp
282
+ settings = GlobalSettings.get_settings()
283
+ settings.last_cleanup_at = timezone.now()
284
+ settings.save(update_fields=["last_cleanup_at"])
285
+
286
+ logger.info(f"Cleanup task completed: {deleted_count} runs deleted")
287
+ return {"success": True, "deleted_count": deleted_count}
288
+ except Exception as e:
289
+ logger.exception("Cleanup task failed")
290
+ return {"success": False, "error": str(e)}
291
+
292
+
293
+ def worker_heartbeat_task() -> dict:
294
+ """
295
+ Heartbeat task that runs periodically to indicate workers are alive.
296
+ Updates the worker_heartbeat_at timestamp in GlobalSettings.
297
+
298
+ This task is scheduled to run every minute when qcluster starts.
299
+ It allows the system to detect when workers have stopped.
300
+
301
+ Returns:
302
+ dict: Result with timestamp of heartbeat
303
+ """
304
+ try:
305
+ settings = GlobalSettings.get_settings()
306
+ settings.worker_heartbeat_at = timezone.now()
307
+ settings.save(update_fields=["worker_heartbeat_at"])
308
+
309
+ logger.debug("Worker heartbeat updated")
310
+ return {"success": True, "heartbeat_at": str(settings.worker_heartbeat_at)}
311
+ except Exception as e:
312
+ logger.exception("Worker heartbeat failed")
313
+ return {"success": False, "error": str(e)}
314
+
315
+
316
+ def scheduled_backup_task() -> dict:
317
+ """
318
+ Execute a scheduled backup to S3.
319
+ Called by django-q2 scheduler.
320
+
321
+ Creates a backup, uploads to S3, and applies retention policy.
322
+
323
+ Returns:
324
+ dict: Backup result summary
325
+ """
326
+ from core.services.backup_service import BackupService
327
+ from core.services.s3_service import S3Service
328
+ from core.services.backup_schedule_service import BackupScheduleService
329
+
330
+ settings = GlobalSettings.get_settings()
331
+
332
+ # Validate S3 is configured
333
+ if not settings.s3_enabled:
334
+ logger.warning("Scheduled backup skipped - S3 not enabled")
335
+ return {"success": False, "error": "S3 not enabled"}
336
+
337
+ if not S3Service.is_configured():
338
+ logger.warning("Scheduled backup skipped - S3 not configured")
339
+ return {"success": False, "error": "S3 not configured"}
340
+
341
+ try:
342
+ # Create backup
343
+ backup_data = BackupService.create_backup(
344
+ include_runs=settings.s3_backup_include_runs,
345
+ max_runs=settings.s3_backup_max_runs,
346
+ include_package_operations=False,
347
+ include_datastores=settings.s3_backup_include_datastores,
348
+ created_by_user=None, # System backup
349
+ )
350
+
351
+ # Serialize to gzip
352
+ file_bytes, _ = BackupService.serialize_backup(
353
+ backup_data,
354
+ format=BackupService.FORMAT_GZIP,
355
+ )
356
+
357
+ # Generate S3 key
358
+ key = S3Service.generate_backup_key()
359
+
360
+ # Upload to S3
361
+ result = S3Service.upload_file(file_bytes, key)
362
+
363
+ if result["success"]:
364
+ # Update tracking
365
+ settings.s3_backup_last_run_at = timezone.now()
366
+ settings.s3_backup_last_status = "success"
367
+ settings.s3_backup_last_error = ""
368
+ settings.s3_backup_last_size = result["size"]
369
+ settings.save(update_fields=[
370
+ "s3_backup_last_run_at",
371
+ "s3_backup_last_status",
372
+ "s3_backup_last_error",
373
+ "s3_backup_last_size",
374
+ ])
375
+
376
+ # Apply retention cleanup
377
+ deleted = BackupScheduleService.apply_retention()
378
+
379
+ logger.info(f"Scheduled backup completed: {key} ({result['size']} bytes)")
380
+
381
+ return {
382
+ "success": True,
383
+ "key": key,
384
+ "size": result["size"],
385
+ "retention_deleted": deleted,
386
+ }
387
+ else:
388
+ # Update tracking with error
389
+ settings.s3_backup_last_run_at = timezone.now()
390
+ settings.s3_backup_last_status = "failed"
391
+ settings.s3_backup_last_error = result.get("error", "Unknown error")
392
+ settings.save(update_fields=[
393
+ "s3_backup_last_run_at",
394
+ "s3_backup_last_status",
395
+ "s3_backup_last_error",
396
+ ])
397
+
398
+ logger.error(f"Scheduled backup failed: {result.get('error')}")
399
+ return {"success": False, "error": result.get("error")}
400
+
401
+ except Exception as e:
402
+ # Update tracking with error
403
+ settings.s3_backup_last_run_at = timezone.now()
404
+ settings.s3_backup_last_status = "failed"
405
+ settings.s3_backup_last_error = str(e)
406
+ settings.save(update_fields=[
407
+ "s3_backup_last_run_at",
408
+ "s3_backup_last_status",
409
+ "s3_backup_last_error",
410
+ ])
411
+
412
+ logger.exception("Scheduled backup task failed")
413
+ return {"success": False, "error": str(e)}
core/tests.py ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ from django.test import TestCase
2
+
3
+ # Create your tests here.