feat: Add HTTP methods and URL whitelist system for plugins
Backend Changes: - Extended SafeScriptAPI to support all HTTP methods (GET, POST, PUT, PATCH, DELETE) - Created WhitelistedURL model for per-plugin and platform-wide URL whitelisting - Added _validate_url() method with SSRF protection and private IP blocking - Updated SafeScriptAPI to accept scheduled_task parameter for whitelist checking - All HTTP methods now validate against whitelist before making requests WhitelistedURL Model: - Supports two scopes: PLATFORM (all plugins) and PLUGIN (specific plugin) - Stores URL patterns with wildcard support (e.g., https://api.example.com/*) - Tracks allowed HTTP methods per URL - Includes approval workflow (approved_by, approved_at) - Stores original plugin code for verification - Domain-based indexing for fast lookup - Database constraint ensures platform-wide entries have no plugin assigned Security Features: - SSRF prevention: blocks localhost, loopback, and private IP ranges - Per-plugin whitelist: each ScheduledTask can only access its whitelisted URLs - Platform-wide whitelist: approved URLs accessible by all plugins - HTTP method validation: URLs must explicitly allow each method - URL pattern matching with wildcard support Related Models: - WhitelistedURL.scheduled_task -> ScheduledTask (plugin that owns the whitelist) - WhitelistedURL.approved_by -> User (platform user who approved the URL) Migration: schedule/migrations/0014_whitelistedurl.py 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
39
smoothschedule/schedule/migrations/0014_whitelistedurl.py
Normal file
39
smoothschedule/schedule/migrations/0014_whitelistedurl.py
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
# Generated by Django 5.2.8 on 2025-11-29 01:59
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('schedule', '0013_scheduledtask_taskexecutionlog_and_more'),
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='WhitelistedURL',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('url_pattern', models.CharField(help_text='URL or URL pattern (e.g., https://api.example.com/v1/* or https://hooks.slack.com/*)', max_length=500)),
|
||||||
|
('domain', models.CharField(db_index=True, help_text='Extracted domain for quick lookup (e.g., api.example.com)', max_length=255)),
|
||||||
|
('scope', models.CharField(choices=[('PLATFORM', 'Platform-wide (all plugins)'), ('PLUGIN', 'Plugin-specific')], db_index=True, default='PLUGIN', max_length=10)),
|
||||||
|
('allowed_methods', models.JSONField(default=list, help_text="List of allowed HTTP methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE']")),
|
||||||
|
('description', models.TextField(help_text="Why this URL is whitelisted and what it's used for")),
|
||||||
|
('approved_at', models.DateTimeField(blank=True, null=True)),
|
||||||
|
('is_active', models.BooleanField(db_index=True, default=True, help_text='Whether this whitelist entry is currently active')),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('updated_at', models.DateTimeField(auto_now=True)),
|
||||||
|
('original_plugin_code', models.TextField(blank=True, help_text='Original plugin code submitted for approval (for verification)')),
|
||||||
|
('approved_by', models.ForeignKey(blank=True, help_text='Platform user who approved this whitelist entry', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='approved_whitelisted_urls', to=settings.AUTH_USER_MODEL)),
|
||||||
|
('scheduled_task', models.ForeignKey(blank=True, help_text='Scheduled task (plugin) that owns this whitelist entry (null for platform-wide)', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='whitelisted_urls', to='schedule.scheduledtask')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'ordering': ['-created_at'],
|
||||||
|
'indexes': [models.Index(fields=['domain', 'is_active'], name='schedule_wh_domain_796c4c_idx'), models.Index(fields=['scope', 'is_active'], name='schedule_wh_scope_a0bce0_idx'), models.Index(fields=['scheduled_task', 'is_active'], name='schedule_wh_schedul_7c025c_idx')],
|
||||||
|
'constraints': [models.CheckConstraint(condition=models.Q(models.Q(('scheduled_task__isnull', True), ('scope', 'PLATFORM')), models.Q(('scheduled_task__isnull', False), ('scope', 'PLUGIN')), _connector='OR'), name='platform_scope_no_task')],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -431,3 +431,178 @@ class TaskExecutionLog(models.Model):
|
|||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"{self.scheduled_task.name} - {self.status} at {self.started_at}"
|
return f"{self.scheduled_task.name} - {self.status} at {self.started_at}"
|
||||||
|
|
||||||
|
|
||||||
|
class WhitelistedURL(models.Model):
|
||||||
|
"""
|
||||||
|
URL whitelist for plugin HTTP access.
|
||||||
|
|
||||||
|
Supports two scopes:
|
||||||
|
- Platform-wide: accessible by all plugins (approved_by platform user with can_whitelist_urls permission)
|
||||||
|
- Plugin-specific: accessible only by specific plugin
|
||||||
|
"""
|
||||||
|
|
||||||
|
class Scope(models.TextChoices):
|
||||||
|
PLATFORM = 'PLATFORM', 'Platform-wide (all plugins)'
|
||||||
|
PLUGIN = 'PLUGIN', 'Plugin-specific'
|
||||||
|
|
||||||
|
# URL Configuration
|
||||||
|
url_pattern = models.CharField(
|
||||||
|
max_length=500,
|
||||||
|
help_text="URL or URL pattern (e.g., https://api.example.com/v1/* or https://hooks.slack.com/*)"
|
||||||
|
)
|
||||||
|
|
||||||
|
domain = models.CharField(
|
||||||
|
max_length=255,
|
||||||
|
db_index=True,
|
||||||
|
help_text="Extracted domain for quick lookup (e.g., api.example.com)"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Scope & Ownership
|
||||||
|
scope = models.CharField(
|
||||||
|
max_length=10,
|
||||||
|
choices=Scope.choices,
|
||||||
|
default=Scope.PLUGIN,
|
||||||
|
db_index=True
|
||||||
|
)
|
||||||
|
|
||||||
|
scheduled_task = models.ForeignKey(
|
||||||
|
'ScheduledTask',
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
related_name='whitelisted_urls',
|
||||||
|
help_text="Scheduled task (plugin) that owns this whitelist entry (null for platform-wide)"
|
||||||
|
)
|
||||||
|
|
||||||
|
# HTTP Methods
|
||||||
|
allowed_methods = models.JSONField(
|
||||||
|
default=list,
|
||||||
|
help_text="List of allowed HTTP methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE']"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Metadata
|
||||||
|
description = models.TextField(
|
||||||
|
help_text="Why this URL is whitelisted and what it's used for"
|
||||||
|
)
|
||||||
|
|
||||||
|
approved_by = models.ForeignKey(
|
||||||
|
'users.User',
|
||||||
|
on_delete=models.SET_NULL,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
related_name='approved_whitelisted_urls',
|
||||||
|
help_text="Platform user who approved this whitelist entry"
|
||||||
|
)
|
||||||
|
|
||||||
|
approved_at = models.DateTimeField(null=True, blank=True)
|
||||||
|
|
||||||
|
is_active = models.BooleanField(
|
||||||
|
default=True,
|
||||||
|
db_index=True,
|
||||||
|
help_text="Whether this whitelist entry is currently active"
|
||||||
|
)
|
||||||
|
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
|
# Security metadata
|
||||||
|
original_plugin_code = models.TextField(
|
||||||
|
blank=True,
|
||||||
|
help_text="Original plugin code submitted for approval (for verification)"
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ['-created_at']
|
||||||
|
indexes = [
|
||||||
|
models.Index(fields=['domain', 'is_active']),
|
||||||
|
models.Index(fields=['scope', 'is_active']),
|
||||||
|
models.Index(fields=['scheduled_task', 'is_active']),
|
||||||
|
]
|
||||||
|
constraints = [
|
||||||
|
models.CheckConstraint(
|
||||||
|
check=models.Q(scope='PLATFORM', scheduled_task__isnull=True) | models.Q(scope='PLUGIN', scheduled_task__isnull=False),
|
||||||
|
name='platform_scope_no_task'
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
scope_label = f"{self.get_scope_display()}"
|
||||||
|
methods = ', '.join(self.allowed_methods) if self.allowed_methods else 'No methods'
|
||||||
|
return f"{self.url_pattern} ({scope_label}) - {methods}"
|
||||||
|
|
||||||
|
def save(self, *args, **kwargs):
|
||||||
|
"""Extract domain from URL pattern"""
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
# Extract domain from URL pattern
|
||||||
|
if not self.domain:
|
||||||
|
# Remove wildcard for parsing
|
||||||
|
url_for_parsing = self.url_pattern.replace('*', '')
|
||||||
|
parsed = urlparse(url_for_parsing)
|
||||||
|
self.domain = parsed.hostname or ''
|
||||||
|
|
||||||
|
super().save(*args, **kwargs)
|
||||||
|
|
||||||
|
def matches_url(self, url: str) -> bool:
|
||||||
|
"""
|
||||||
|
Check if this whitelist entry matches the given URL.
|
||||||
|
|
||||||
|
Supports wildcard patterns:
|
||||||
|
- https://api.example.com/* matches all paths under /
|
||||||
|
- https://api.example.com/v1/* matches all paths under /v1/
|
||||||
|
"""
|
||||||
|
# Simple implementation: check if URL starts with pattern (minus wildcard)
|
||||||
|
pattern = self.url_pattern.replace('*', '')
|
||||||
|
return url.startswith(pattern)
|
||||||
|
|
||||||
|
def allows_method(self, method: str) -> bool:
|
||||||
|
"""Check if this whitelist entry allows the given HTTP method"""
|
||||||
|
return method.upper() in [m.upper() for m in self.allowed_methods]
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def is_url_whitelisted(cls, url: str, method: str, scheduled_task=None) -> bool:
|
||||||
|
"""
|
||||||
|
Check if a URL and HTTP method combination is whitelisted.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
url: The URL to check
|
||||||
|
method: HTTP method (GET, POST, etc.)
|
||||||
|
scheduled_task: Optional ScheduledTask (plugin) to check task-specific whitelist
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if URL is whitelisted for the given method
|
||||||
|
"""
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
parsed = urlparse(url)
|
||||||
|
domain = parsed.hostname
|
||||||
|
|
||||||
|
if not domain:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Check platform-wide whitelist
|
||||||
|
platform_entries = cls.objects.filter(
|
||||||
|
domain=domain,
|
||||||
|
scope=cls.Scope.PLATFORM,
|
||||||
|
is_active=True
|
||||||
|
)
|
||||||
|
|
||||||
|
for entry in platform_entries:
|
||||||
|
if entry.matches_url(url) and entry.allows_method(method):
|
||||||
|
return True
|
||||||
|
|
||||||
|
# Check task-specific whitelist if scheduled_task provided
|
||||||
|
if scheduled_task:
|
||||||
|
task_entries = cls.objects.filter(
|
||||||
|
domain=domain,
|
||||||
|
scope=cls.Scope.PLUGIN,
|
||||||
|
scheduled_task=scheduled_task,
|
||||||
|
is_active=True
|
||||||
|
)
|
||||||
|
|
||||||
|
for entry in task_entries:
|
||||||
|
if entry.matches_url(url) and entry.allows_method(method):
|
||||||
|
return True
|
||||||
|
|
||||||
|
return False
|
||||||
|
|||||||
@@ -40,10 +40,11 @@ class SafeScriptAPI:
|
|||||||
Only exposes whitelisted operations that interact with their own data.
|
Only exposes whitelisted operations that interact with their own data.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, business, user, execution_context):
|
def __init__(self, business, user, execution_context, scheduled_task=None):
|
||||||
self.business = business
|
self.business = business
|
||||||
self.user = user
|
self.user = user
|
||||||
self.context = execution_context
|
self.context = execution_context
|
||||||
|
self.scheduled_task = scheduled_task # ScheduledTask instance for whitelist checking
|
||||||
self._api_call_count = 0
|
self._api_call_count = 0
|
||||||
self._max_api_calls = 50 # Prevent API spam
|
self._max_api_calls = 50 # Prevent API spam
|
||||||
|
|
||||||
@@ -190,40 +191,61 @@ class SafeScriptAPI:
|
|||||||
logger.info(f"[Customer Script] {message}")
|
logger.info(f"[Customer Script] {message}")
|
||||||
return message
|
return message
|
||||||
|
|
||||||
|
def _validate_url(self, url, method='GET'):
|
||||||
|
"""
|
||||||
|
Validate URL against whitelist and check for SSRF attacks.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
url: URL to validate
|
||||||
|
method: HTTP method (GET, POST, PUT, PATCH, DELETE)
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ScriptExecutionError: If URL is not whitelisted or is unsafe
|
||||||
|
"""
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
from .models import WhitelistedURL
|
||||||
|
|
||||||
|
parsed = urlparse(url)
|
||||||
|
|
||||||
|
# Prevent SSRF attacks - check localhost
|
||||||
|
if parsed.hostname in ['localhost', '127.0.0.1', '0.0.0.0', '::1']:
|
||||||
|
raise ScriptExecutionError("Cannot access localhost")
|
||||||
|
|
||||||
|
# Prevent access to private IP ranges
|
||||||
|
import ipaddress
|
||||||
|
try:
|
||||||
|
ip = ipaddress.ip_address(parsed.hostname)
|
||||||
|
if ip.is_private or ip.is_loopback or ip.is_link_local:
|
||||||
|
raise ScriptExecutionError("Cannot access private IP addresses")
|
||||||
|
except ValueError:
|
||||||
|
# Not an IP address, continue with domain validation
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Check whitelist using database model
|
||||||
|
if not WhitelistedURL.is_url_whitelisted(url, method, self.scheduled_task):
|
||||||
|
raise ScriptExecutionError(
|
||||||
|
f"URL '{url}' with method '{method}' is not whitelisted for this plugin. "
|
||||||
|
f"Contact support at pluginaccess@smoothschedule.com to request whitelisting."
|
||||||
|
)
|
||||||
|
|
||||||
def http_get(self, url, headers=None):
|
def http_get(self, url, headers=None):
|
||||||
"""
|
"""
|
||||||
Make an HTTP GET request to approved domains.
|
Make an HTTP GET request to approved domains.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
url: URL to fetch (must be in approved list)
|
url: URL to fetch (must be whitelisted)
|
||||||
headers: Optional headers dictionary
|
headers: Optional headers dictionary
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Response text
|
Response text
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ScriptExecutionError: If URL not whitelisted or request fails
|
||||||
"""
|
"""
|
||||||
self._check_api_limit()
|
self._check_api_limit()
|
||||||
|
self._validate_url(url, 'GET')
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
from urllib.parse import urlparse
|
|
||||||
|
|
||||||
# Whitelist of approved domains
|
|
||||||
APPROVED_DOMAINS = [
|
|
||||||
'api.example.com',
|
|
||||||
'hooks.slack.com',
|
|
||||||
'api.mailchimp.com',
|
|
||||||
# Add more approved domains
|
|
||||||
]
|
|
||||||
|
|
||||||
parsed = urlparse(url)
|
|
||||||
if parsed.hostname not in APPROVED_DOMAINS:
|
|
||||||
raise ScriptExecutionError(
|
|
||||||
f"Domain '{parsed.hostname}' not in approved list. "
|
|
||||||
f"Contact support to add it."
|
|
||||||
)
|
|
||||||
|
|
||||||
# Prevent SSRF attacks
|
|
||||||
if parsed.hostname in ['localhost', '127.0.0.1', '0.0.0.0']:
|
|
||||||
raise ScriptExecutionError("Cannot access localhost")
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
response = requests.get(
|
response = requests.get(
|
||||||
@@ -234,7 +256,160 @@ class SafeScriptAPI:
|
|||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
return response.text
|
return response.text
|
||||||
except requests.RequestException as e:
|
except requests.RequestException as e:
|
||||||
raise ScriptExecutionError(f"HTTP request failed: {e}")
|
raise ScriptExecutionError(f"HTTP GET request failed: {e}")
|
||||||
|
|
||||||
|
def http_post(self, url, data=None, headers=None):
|
||||||
|
"""
|
||||||
|
Make an HTTP POST request to approved domains.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
url: URL to post to (must be whitelisted)
|
||||||
|
data: Data to send (dict or string)
|
||||||
|
headers: Optional headers dictionary
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Response text
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ScriptExecutionError: If URL not whitelisted or request fails
|
||||||
|
"""
|
||||||
|
self._check_api_limit()
|
||||||
|
self._validate_url(url, 'POST')
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Auto-detect JSON content
|
||||||
|
if isinstance(data, dict):
|
||||||
|
response = requests.post(
|
||||||
|
url,
|
||||||
|
json=data,
|
||||||
|
headers=headers or {},
|
||||||
|
timeout=10,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
response = requests.post(
|
||||||
|
url,
|
||||||
|
data=data,
|
||||||
|
headers=headers or {},
|
||||||
|
timeout=10,
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
return response.text
|
||||||
|
except requests.RequestException as e:
|
||||||
|
raise ScriptExecutionError(f"HTTP POST request failed: {e}")
|
||||||
|
|
||||||
|
def http_put(self, url, data=None, headers=None):
|
||||||
|
"""
|
||||||
|
Make an HTTP PUT request to approved domains.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
url: URL to put to (must be whitelisted)
|
||||||
|
data: Data to send (dict or string)
|
||||||
|
headers: Optional headers dictionary
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Response text
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ScriptExecutionError: If URL not whitelisted or request fails
|
||||||
|
"""
|
||||||
|
self._check_api_limit()
|
||||||
|
self._validate_url(url, 'PUT')
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Auto-detect JSON content
|
||||||
|
if isinstance(data, dict):
|
||||||
|
response = requests.put(
|
||||||
|
url,
|
||||||
|
json=data,
|
||||||
|
headers=headers or {},
|
||||||
|
timeout=10,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
response = requests.put(
|
||||||
|
url,
|
||||||
|
data=data,
|
||||||
|
headers=headers or {},
|
||||||
|
timeout=10,
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
return response.text
|
||||||
|
except requests.RequestException as e:
|
||||||
|
raise ScriptExecutionError(f"HTTP PUT request failed: {e}")
|
||||||
|
|
||||||
|
def http_patch(self, url, data=None, headers=None):
|
||||||
|
"""
|
||||||
|
Make an HTTP PATCH request to approved domains.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
url: URL to patch (must be whitelisted)
|
||||||
|
data: Data to send (dict or string)
|
||||||
|
headers: Optional headers dictionary
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Response text
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ScriptExecutionError: If URL not whitelisted or request fails
|
||||||
|
"""
|
||||||
|
self._check_api_limit()
|
||||||
|
self._validate_url(url, 'PATCH')
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Auto-detect JSON content
|
||||||
|
if isinstance(data, dict):
|
||||||
|
response = requests.patch(
|
||||||
|
url,
|
||||||
|
json=data,
|
||||||
|
headers=headers or {},
|
||||||
|
timeout=10,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
response = requests.patch(
|
||||||
|
url,
|
||||||
|
data=data,
|
||||||
|
headers=headers or {},
|
||||||
|
timeout=10,
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
return response.text
|
||||||
|
except requests.RequestException as e:
|
||||||
|
raise ScriptExecutionError(f"HTTP PATCH request failed: {e}")
|
||||||
|
|
||||||
|
def http_delete(self, url, headers=None):
|
||||||
|
"""
|
||||||
|
Make an HTTP DELETE request to approved domains.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
url: URL to delete (must be whitelisted)
|
||||||
|
headers: Optional headers dictionary
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Response text
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ScriptExecutionError: If URL not whitelisted or request fails
|
||||||
|
"""
|
||||||
|
self._check_api_limit()
|
||||||
|
self._validate_url(url, 'DELETE')
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = requests.delete(
|
||||||
|
url,
|
||||||
|
headers=headers or {},
|
||||||
|
timeout=10,
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
return response.text
|
||||||
|
except requests.RequestException as e:
|
||||||
|
raise ScriptExecutionError(f"HTTP DELETE request failed: {e}")
|
||||||
|
|
||||||
def create_appointment(self, title, start_time, end_time, **kwargs):
|
def create_appointment(self, title, start_time, end_time, **kwargs):
|
||||||
"""
|
"""
|
||||||
|
|||||||
Reference in New Issue
Block a user