Merge pull request #367 from DRYTRIX/rc/v4.8.1

Rc/v4.8.1
This commit is contained in:
Dries Peeters
2025-12-29 17:03:14 +01:00
committed by GitHub
31 changed files with 2619 additions and 1559 deletions

View File

@@ -269,19 +269,102 @@ class AsanaConnector(BaseConnector):
"name": "workspace_gid",
"type": "string",
"label": "Workspace GID",
"required": True,
"placeholder": "1234567890",
"description": "Asana workspace GID to sync with",
"help": "Find your workspace GID in Asana workspace settings or API",
},
{
"name": "project_gids",
"type": "text",
"label": "Project GIDs",
"required": False,
"placeholder": "1234567890, 9876543210",
"description": "Comma-separated list of specific project GIDs to sync (leave empty to sync all)",
"help": "Optional: Limit sync to specific projects",
},
{
"name": "sync_direction",
"type": "select",
"label": "Sync Direction",
"options": [
{"value": "asana_to_timetracker", "label": "Asana → TimeTracker"},
{"value": "timetracker_to_asana", "label": "TimeTracker → Asana"},
{"value": "bidirectional", "label": "Bidirectional"},
{"value": "asana_to_timetracker", "label": "Asana → TimeTracker (Import only)"},
{"value": "timetracker_to_asana", "label": "TimeTracker → Asana (Export only)"},
{"value": "bidirectional", "label": "Bidirectional (Two-way sync)"},
],
"default": "asana_to_timetracker",
"description": "Choose how data flows between Asana and TimeTracker",
},
{
"name": "sync_items",
"type": "array",
"label": "Items to Sync",
"options": [
{"value": "projects", "label": "Projects"},
{"value": "tasks", "label": "Tasks"},
{"value": "subtasks", "label": "Subtasks"},
],
"default": ["projects", "tasks"],
"description": "Select which items to synchronize",
},
{
"name": "auto_sync",
"type": "boolean",
"label": "Auto Sync",
"default": False,
"description": "Automatically sync when changes are detected",
},
{
"name": "sync_interval",
"type": "select",
"label": "Sync Schedule",
"options": [
{"value": "manual", "label": "Manual only"},
{"value": "hourly", "label": "Every hour"},
{"value": "daily", "label": "Daily"},
],
"default": "manual",
"description": "How often to automatically sync data",
},
{
"name": "sync_completed",
"type": "boolean",
"label": "Sync Completed Tasks",
"default": False,
"description": "Include completed tasks in sync",
},
{
"name": "status_mapping",
"type": "json",
"label": "Status Mapping",
"placeholder": '{"completed": "completed", "incomplete": "todo"}',
"description": "Map Asana task completion status to TimeTracker statuses (JSON format)",
"help": "Customize how Asana task statuses map to TimeTracker task statuses",
},
],
"required": ["workspace_gid"],
"sections": [
{
"title": "Workspace Settings",
"description": "Configure your Asana workspace",
"fields": ["workspace_gid", "project_gids"],
},
{
"title": "Sync Settings",
"description": "Configure what and how to sync",
"fields": ["sync_direction", "sync_items", "sync_completed", "auto_sync", "sync_interval"],
},
{
"title": "Data Mapping",
"description": "Customize how data translates between Asana and TimeTracker",
"fields": ["status_mapping"],
},
],
"sync_settings": {
"enabled": True,
"auto_sync": False,
"sync_interval": "manual",
"sync_direction": "asana_to_timetracker",
"sync_items": ["projects", "tasks"],
},
}

View File

@@ -140,9 +140,51 @@ class BaseConnector(ABC):
Get configuration schema for this connector.
Returns:
Dict describing configuration fields
Dict describing configuration fields with structure:
{
"fields": [
{
"name": "field_name",
"type": "string|number|boolean|select|array|json|password|url|text",
"label": "Display Label",
"description": "Help text",
"placeholder": "Placeholder text",
"default": default_value,
"required": True/False,
"options": [{"value": "val", "label": "Label"}] for select,
"help": "Additional help text",
"validation": {"min": 1, "max": 100} for numbers,
}
],
"required": ["field_name"],
"sections": [
{
"title": "Section Title",
"description": "Section description",
"fields": ["field_name1", "field_name2"]
}
],
"sync_settings": {
"enabled": True/False,
"auto_sync": True/False,
"sync_interval": "hourly|daily|weekly|manual",
"sync_direction": "provider_to_timetracker|timetracker_to_provider|bidirectional",
"sync_items": ["tasks", "projects", "time_entries"],
}
}
"""
return {"fields": [], "required": []}
return {
"fields": [],
"required": [],
"sections": [],
"sync_settings": {
"enabled": True,
"auto_sync": False,
"sync_interval": "manual",
"sync_direction": "provider_to_timetracker",
"sync_items": [],
}
}
def validate_config(self, config: Dict[str, Any]) -> Dict[str, Any]:
"""
@@ -154,4 +196,104 @@ class BaseConnector(ABC):
Returns:
Dict with 'valid' (bool) and 'errors' (list)
"""
return {"valid": True, "errors": []}
schema = self.get_config_schema()
errors = []
# Check required fields
required_fields = schema.get("required", [])
for field_name in required_fields:
if field_name not in config or not config[field_name]:
# Find field label for error message
field_label = field_name
for field in schema.get("fields", []):
if field.get("name") == field_name:
field_label = field.get("label", field_name)
break
errors.append(f"{field_label} is required")
# Validate field types and constraints
for field in schema.get("fields", []):
field_name = field.get("name")
if field_name not in config:
continue
value = config[field_name]
field_type = field.get("type", "string")
# Type validation
if field_type == "number" and value is not None:
try:
float(value)
# Check min/max if specified
validation = field.get("validation", {})
if "min" in validation and float(value) < validation["min"]:
errors.append(f"{field.get('label', field_name)} must be at least {validation['min']}")
if "max" in validation and float(value) > validation["max"]:
errors.append(f"{field.get('label', field_name)} must be at most {validation['max']}")
except (ValueError, TypeError):
errors.append(f"{field.get('label', field_name)} must be a number")
elif field_type == "boolean" and value is not None:
if not isinstance(value, bool) and value not in ("true", "false", "1", "0", "on", "off", ""):
errors.append(f"{field.get('label', field_name)} must be a boolean")
elif field_type == "url" and value:
try:
from urllib.parse import urlparse
parsed = urlparse(value)
if not parsed.scheme or not parsed.netloc:
errors.append(f"{field.get('label', field_name)} must be a valid URL")
except Exception:
errors.append(f"{field.get('label', field_name)} must be a valid URL")
elif field_type == "json" and value:
try:
import json
if isinstance(value, str):
json.loads(value)
except json.JSONDecodeError:
errors.append(f"{field.get('label', field_name)} must be valid JSON")
return {"valid": len(errors) == 0, "errors": errors}
def get_sync_settings(self) -> Dict[str, Any]:
"""
Get current sync settings from integration config.
Returns:
Dict with sync settings
"""
if not self.integration or not self.integration.config:
schema = self.get_config_schema()
return schema.get("sync_settings", {})
config = self.integration.config
schema = self.get_config_schema()
default_sync_settings = schema.get("sync_settings", {})
return {
"enabled": config.get("sync_enabled", default_sync_settings.get("enabled", True)),
"auto_sync": config.get("auto_sync", default_sync_settings.get("auto_sync", False)),
"sync_interval": config.get("sync_interval", default_sync_settings.get("sync_interval", "manual")),
"sync_direction": config.get("sync_direction", default_sync_settings.get("sync_direction", "provider_to_timetracker")),
"sync_items": config.get("sync_items", default_sync_settings.get("sync_items", [])),
}
def get_field_mappings(self) -> Dict[str, str]:
"""
Get field mappings for data translation.
Returns:
Dict mapping provider fields to TimeTracker fields
"""
if not self.integration or not self.integration.config:
return {}
return self.integration.config.get("field_mappings", {})
def get_status_mappings(self) -> Dict[str, str]:
"""
Get status mappings for data translation.
Returns:
Dict mapping provider statuses to TimeTracker statuses
"""
if not self.integration or not self.integration.config:
return {}
return self.integration.config.get("status_mappings", {})

View File

@@ -896,5 +896,134 @@ class CalDAVCalendarConnector(BaseConnector):
cal.add_component(event)
return cal.to_ical().decode('utf-8')
def get_config_schema(self) -> Dict[str, Any]:
"""Get configuration schema."""
return {
"fields": [
{
"name": "server_url",
"type": "url",
"label": "Server URL",
"required": False,
"placeholder": "https://mail.example.com/dav",
"description": "CalDAV server base URL (optional if calendar_url is provided)",
"help": "Base URL of your CalDAV server (e.g., https://mail.example.com/dav)",
},
{
"name": "calendar_url",
"type": "url",
"label": "Calendar URL",
"required": False,
"placeholder": "https://mail.example.com/dav/user/calendar/",
"description": "Full URL to the calendar collection (ends with /)",
"help": "Direct URL to your calendar. Must end with a forward slash (/).",
},
{
"name": "calendar_name",
"type": "string",
"label": "Calendar Name",
"required": False,
"placeholder": "My Calendar",
"description": "Display name for the calendar (optional)",
},
{
"name": "sync_direction",
"type": "select",
"label": "Sync Direction",
"options": [
{"value": "calendar_to_time_tracker", "label": "Calendar → TimeTracker (Import only)"},
{"value": "time_tracker_to_calendar", "label": "TimeTracker → Calendar (Export only)"},
{"value": "bidirectional", "label": "Bidirectional (Two-way sync)"},
],
"default": "calendar_to_time_tracker",
"description": "Choose how data flows between CalDAV calendar and TimeTracker",
},
{
"name": "sync_items",
"type": "array",
"label": "Items to Sync",
"options": [
{"value": "time_entries", "label": "Time Entries"},
{"value": "events", "label": "Calendar Events"},
],
"default": ["events"],
"description": "Select which items to synchronize",
},
{
"name": "default_project_id",
"type": "number",
"label": "Default Project",
"required": True,
"description": "Default project to assign imported calendar events to",
"help": "Required for importing calendar events as time entries",
},
{
"name": "lookback_days",
"type": "number",
"label": "Lookback Days",
"default": 90,
"validation": {"min": 1, "max": 365},
"description": "Number of days in the past to sync (1-365)",
"help": "How far back to sync calendar events",
},
{
"name": "lookahead_days",
"type": "number",
"label": "Lookahead Days",
"default": 7,
"validation": {"min": 1, "max": 365},
"description": "Number of days in the future to sync (1-365)",
"help": "How far ahead to sync calendar events",
},
{
"name": "verify_ssl",
"type": "boolean",
"label": "Verify SSL Certificate",
"default": True,
"description": "Verify SSL certificate when connecting to CalDAV server",
"help": "Disable only if using a self-signed certificate",
},
{
"name": "auto_sync",
"type": "boolean",
"label": "Auto Sync",
"default": False,
"description": "Automatically sync on a schedule",
},
{
"name": "sync_interval",
"type": "select",
"label": "Sync Schedule",
"options": [
{"value": "manual", "label": "Manual only"},
{"value": "hourly", "label": "Every hour"},
{"value": "daily", "label": "Daily"},
],
"default": "manual",
"description": "How often to automatically sync data",
},
],
"required": ["default_project_id"],
"sections": [
{
"title": "Connection Settings",
"description": "Configure your CalDAV server connection",
"fields": ["server_url", "calendar_url", "calendar_name", "verify_ssl"],
},
{
"title": "Sync Settings",
"description": "Configure what and how to sync",
"fields": ["sync_direction", "sync_items", "default_project_id", "lookback_days", "lookahead_days", "auto_sync", "sync_interval"],
},
],
"sync_settings": {
"enabled": True,
"auto_sync": False,
"sync_interval": "manual",
"sync_direction": "calendar_to_time_tracker",
"sync_items": ["events"],
},
}

View File

@@ -433,14 +433,71 @@ class GitHubConnector(BaseConnector):
"type": "text",
"required": False,
"placeholder": "owner/repo1, owner/repo2",
"help": "Comma-separated list of repositories to sync",
"help": "Comma-separated list of repositories to sync (e.g., 'octocat/Hello-World, owner/repo'). Leave empty to sync all accessible repositories.",
"description": "Which GitHub repositories to sync",
},
{
"name": "sync_direction",
"type": "select",
"label": "Sync Direction",
"options": [
{"value": "github_to_timetracker", "label": "GitHub → TimeTracker (Import only)"},
{"value": "timetracker_to_github", "label": "TimeTracker → GitHub (Export only)"},
{"value": "bidirectional", "label": "Bidirectional (Two-way sync)"},
],
"default": "github_to_timetracker",
"description": "Choose how data flows between GitHub and TimeTracker",
},
{
"name": "sync_items",
"type": "array",
"label": "Items to Sync",
"options": [
{"value": "issues", "label": "Issues"},
{"value": "pull_requests", "label": "Pull Requests"},
{"value": "projects", "label": "Projects (Repositories)"},
],
"default": ["issues"],
"description": "Select which items to synchronize",
},
{
"name": "issue_states",
"type": "array",
"label": "Issue States to Sync",
"options": [
{"value": "open", "label": "Open Issues"},
{"value": "closed", "label": "Closed Issues"},
{"value": "all", "label": "All Issues"},
],
"default": ["open"],
"description": "Which issue states to include in sync",
},
{
"name": "auto_sync",
"type": "boolean",
"label": "Auto Sync",
"default": False,
"description": "Automatically sync when webhooks are received from GitHub",
},
{
"name": "sync_interval",
"type": "select",
"label": "Sync Schedule",
"options": [
{"value": "manual", "label": "Manual only"},
{"value": "hourly", "label": "Every hour"},
{"value": "daily", "label": "Daily"},
{"value": "weekly", "label": "Weekly"},
],
"default": "manual",
"description": "How often to automatically sync data",
},
{
"name": "create_projects",
"type": "boolean",
"label": "Create Projects",
"default": True,
"description": "Automatically sync when webhooks are received",
"description": "Automatically create projects in TimeTracker from GitHub repositories",
},
{
"name": "webhook_secret",
@@ -448,8 +505,33 @@ class GitHubConnector(BaseConnector):
"type": "password",
"required": False,
"placeholder": "Enter webhook secret from GitHub",
"help": "Secret token for verifying webhook signatures (configure in GitHub webhook settings)",
"help": "Secret token for verifying webhook signatures. Configure this in your GitHub repository webhook settings.",
"description": "Security token for webhook verification",
},
],
"required": [],
"sections": [
{
"title": "Repository Settings",
"description": "Configure which repositories to sync",
"fields": ["repositories", "create_projects"],
},
{
"title": "Sync Settings",
"description": "Configure what and how to sync",
"fields": ["sync_direction", "sync_items", "issue_states", "auto_sync", "sync_interval"],
},
{
"title": "Webhook Settings",
"description": "Configure webhook security",
"fields": ["webhook_secret"],
},
],
"sync_settings": {
"enabled": True,
"auto_sync": False,
"sync_interval": "manual",
"sync_direction": "github_to_timetracker",
"sync_items": ["issues"],
},
}

View File

@@ -236,21 +236,109 @@ class GitLabConnector(BaseConnector):
"fields": [
{
"name": "repository_ids",
"type": "array",
"type": "text",
"label": "Repository IDs",
"description": "GitLab project IDs to sync (leave empty to sync all accessible projects)",
"required": False,
"placeholder": "123456, 789012",
"description": "Comma-separated list of GitLab project IDs to sync (leave empty to sync all accessible projects)",
"help": "Find project IDs in GitLab project settings or API. Leave empty to sync all projects you have access to.",
},
{
"name": "sync_direction",
"type": "select",
"label": "Sync Direction",
"options": [
{"value": "gitlab_to_timetracker", "label": "GitLab → TimeTracker"},
{"value": "timetracker_to_gitlab", "label": "TimeTracker → GitLab"},
{"value": "bidirectional", "label": "Bidirectional"},
{"value": "gitlab_to_timetracker", "label": "GitLab → TimeTracker (Import only)"},
{"value": "timetracker_to_gitlab", "label": "TimeTracker → GitLab (Export only)"},
{"value": "bidirectional", "label": "Bidirectional (Two-way sync)"},
],
"default": "gitlab_to_timetracker",
"description": "Choose how data flows between GitLab and TimeTracker",
},
{
"name": "sync_items",
"type": "array",
"label": "Items to Sync",
"options": [
{"value": "issues", "label": "Issues"},
{"value": "merge_requests", "label": "Merge Requests"},
{"value": "projects", "label": "Projects"},
],
"default": ["issues"],
"description": "Select which items to synchronize",
},
{
"name": "issue_states",
"type": "array",
"label": "Issue States to Sync",
"options": [
{"value": "opened", "label": "Open Issues"},
{"value": "closed", "label": "Closed Issues"},
{"value": "all", "label": "All Issues"},
],
"default": ["opened"],
"description": "Which issue states to include in sync",
},
{
"name": "auto_sync",
"type": "boolean",
"label": "Auto Sync",
"default": False,
"description": "Automatically sync when webhooks are received from GitLab",
},
{
"name": "sync_interval",
"type": "select",
"label": "Sync Schedule",
"options": [
{"value": "manual", "label": "Manual only"},
{"value": "hourly", "label": "Every hour"},
{"value": "daily", "label": "Daily"},
{"value": "weekly", "label": "Weekly"},
],
"default": "manual",
"description": "How often to automatically sync data",
},
{
"name": "create_projects",
"type": "boolean",
"label": "Create Projects",
"default": True,
"description": "Automatically create projects in TimeTracker from GitLab projects",
},
{
"name": "webhook_secret",
"label": "Webhook Secret",
"type": "password",
"required": False,
"placeholder": "Enter webhook secret from GitLab",
"help": "Secret token for verifying webhook signatures. Configure this in your GitLab project webhook settings.",
"description": "Security token for webhook verification",
},
],
"required": [],
"sections": [
{
"title": "Repository Settings",
"description": "Configure which GitLab projects to sync",
"fields": ["repository_ids", "create_projects"],
},
{
"title": "Sync Settings",
"description": "Configure what and how to sync",
"fields": ["sync_direction", "sync_items", "issue_states", "auto_sync", "sync_interval"],
},
{
"title": "Webhook Settings",
"description": "Configure webhook security",
"fields": ["webhook_secret"],
},
],
"sync_settings": {
"enabled": True,
"auto_sync": False,
"sync_interval": "manual",
"sync_direction": "gitlab_to_timetracker",
"sync_items": ["issues"],
},
}

View File

@@ -474,18 +474,33 @@ class GoogleCalendarConnector(BaseConnector):
"type": "string",
"label": "Calendar ID",
"default": "primary",
"required": False,
"placeholder": "primary",
"description": "Google Calendar ID to sync with (default: primary)",
"help": "Use 'primary' for your main calendar, or enter a specific calendar ID from Google Calendar settings",
},
{
"name": "sync_direction",
"type": "select",
"label": "Sync Direction",
"options": [
{"value": "time_tracker_to_calendar", "label": "TimeTracker → Calendar"},
{"value": "calendar_to_time_tracker", "label": "Calendar → TimeTracker"},
{"value": "bidirectional", "label": "Bidirectional"},
{"value": "time_tracker_to_calendar", "label": "TimeTracker → Calendar (Export only)"},
{"value": "calendar_to_time_tracker", "label": "Calendar → TimeTracker (Import only)"},
{"value": "bidirectional", "label": "Bidirectional (Two-way sync)"},
],
"default": "time_tracker_to_calendar",
"description": "Choose how data flows between Google Calendar and TimeTracker",
},
{
"name": "sync_items",
"type": "array",
"label": "Items to Sync",
"options": [
{"value": "time_entries", "label": "Time Entries"},
{"value": "events", "label": "Calendar Events"},
],
"default": ["time_entries"],
"description": "Select which items to synchronize",
},
{
"name": "auto_sync",
@@ -494,6 +509,69 @@ class GoogleCalendarConnector(BaseConnector):
"default": True,
"description": "Automatically sync when time entries are created/updated",
},
{
"name": "sync_interval",
"type": "select",
"label": "Sync Schedule",
"options": [
{"value": "manual", "label": "Manual only"},
{"value": "hourly", "label": "Every hour"},
{"value": "daily", "label": "Daily"},
],
"default": "hourly",
"description": "How often to automatically sync data",
},
{
"name": "event_title_format",
"type": "text",
"label": "Event Title Format",
"default": "{project} - {task}",
"placeholder": "{project} - {task}",
"description": "Format for calendar event titles. Use {project}, {task}, {notes} as placeholders",
"help": "Customize how time entries appear as calendar events",
},
{
"name": "sync_past_days",
"type": "number",
"label": "Sync Past Days",
"default": 90,
"validation": {"min": 1, "max": 365},
"description": "Number of days in the past to sync (1-365)",
"help": "How far back to sync calendar events",
},
{
"name": "sync_future_days",
"type": "number",
"label": "Sync Future Days",
"default": 30,
"validation": {"min": 1, "max": 365},
"description": "Number of days in the future to sync (1-365)",
"help": "How far ahead to sync calendar events",
},
],
"required": [],
"sections": [
{
"title": "Calendar Settings",
"description": "Configure your Google Calendar connection",
"fields": ["calendar_id"],
},
{
"title": "Sync Settings",
"description": "Configure what and how to sync",
"fields": ["sync_direction", "sync_items", "auto_sync", "sync_interval", "sync_past_days", "sync_future_days"],
},
{
"title": "Display Settings",
"description": "Customize how events appear in the calendar",
"fields": ["event_title_format"],
},
],
"sync_settings": {
"enabled": True,
"auto_sync": True,
"sync_interval": "hourly",
"sync_direction": "time_tracker_to_calendar",
"sync_items": ["time_entries"],
},
}

View File

@@ -253,6 +253,12 @@ class JiraConnector(BaseConnector):
def _map_jira_status(self, jira_status: str) -> str:
"""Map Jira status to TimeTracker task status."""
# Check for custom status mapping in config
status_mapping = self.get_status_mappings()
if status_mapping and jira_status in status_mapping:
return status_mapping[jira_status]
# Default mapping
status_map = {
"To Do": "todo",
"In Progress": "in_progress",
@@ -299,22 +305,109 @@ class JiraConnector(BaseConnector):
"type": "url",
"required": True,
"placeholder": "https://your-domain.atlassian.net",
"description": "Your Jira instance URL",
"help": "Enter your Jira Cloud or Server URL",
},
{
"name": "jql",
"label": "JQL Query",
"type": "text",
"required": False,
"placeholder": "assignee = currentUser() AND status != Done",
"help": "Jira Query Language query to filter issues to sync",
"placeholder": "assignee = currentUser() AND status != Done ORDER BY updated DESC",
"help": "Jira Query Language query to filter issues to sync. Leave empty to sync all assigned issues.",
"description": "Filter which issues to sync from Jira",
},
{
"name": "sync_direction",
"type": "select",
"label": "Sync Direction",
"options": [
{"value": "jira_to_timetracker", "label": "Jira → TimeTracker (Import only)"},
{"value": "timetracker_to_jira", "label": "TimeTracker → Jira (Export only)"},
{"value": "bidirectional", "label": "Bidirectional (Two-way sync)"},
],
"default": "jira_to_timetracker",
"description": "Choose how data flows between Jira and TimeTracker",
},
{
"name": "sync_items",
"type": "array",
"label": "Items to Sync",
"options": [
{"value": "issues", "label": "Issues (Tasks)"},
{"value": "projects", "label": "Projects"},
{"value": "time_entries", "label": "Time Entries"},
],
"default": ["issues"],
"description": "Select which items to synchronize",
},
{
"name": "auto_sync",
"type": "boolean",
"label": "Auto Sync",
"default": False,
"description": "Automatically sync when webhooks are received from Jira",
},
{
"name": "sync_interval",
"type": "select",
"label": "Sync Schedule",
"options": [
{"value": "manual", "label": "Manual only"},
{"value": "hourly", "label": "Every hour"},
{"value": "daily", "label": "Daily"},
{"value": "weekly", "label": "Weekly"},
],
"default": "manual",
"description": "How often to automatically sync data",
},
{
"name": "create_projects",
"type": "boolean",
"label": "Create Projects",
"default": True,
"description": "Automatically sync when webhooks are received",
"description": "Automatically create projects in TimeTracker from Jira projects",
},
{
"name": "status_mapping",
"type": "json",
"label": "Status Mapping",
"placeholder": '{"To Do": "todo", "In Progress": "in_progress", "Done": "completed"}',
"description": "Map Jira statuses to TimeTracker statuses (JSON format)",
"help": "Customize how Jira issue statuses map to TimeTracker task statuses",
},
{
"name": "field_mapping",
"type": "json",
"label": "Field Mapping",
"placeholder": '{"summary": "name", "description": "description", "assignee": "user_id"}',
"description": "Map Jira fields to TimeTracker fields (JSON format)",
"help": "Customize how Jira issue fields map to TimeTracker task fields",
},
],
"required": ["jira_url"],
"sections": [
{
"title": "Connection Settings",
"description": "Configure your Jira connection",
"fields": ["jira_url", "jql"],
},
{
"title": "Sync Settings",
"description": "Configure what and how to sync",
"fields": ["sync_direction", "sync_items", "auto_sync", "sync_interval", "create_projects"],
},
{
"title": "Data Mapping",
"description": "Customize how data translates between Jira and TimeTracker",
"fields": ["status_mapping", "field_mapping"],
},
],
"sync_settings": {
"enabled": True,
"auto_sync": False,
"sync_interval": "manual",
"sync_direction": "jira_to_timetracker",
"sync_items": ["issues"],
},
}

View File

@@ -252,20 +252,113 @@ class MicrosoftTeamsConnector(BaseConnector):
"name": "default_channel_id",
"type": "string",
"label": "Default Channel ID",
"required": False,
"placeholder": "19:channel-id@thread.tacv2",
"description": "Default Teams channel ID for notifications",
"help": "Find channel ID in Teams channel settings or API",
},
{
"name": "sync_direction",
"type": "select",
"label": "Sync Direction",
"options": [
{"value": "teams_to_timetracker", "label": "Teams → TimeTracker (Import only)"},
{"value": "timetracker_to_teams", "label": "TimeTracker → Teams (Export only)"},
{"value": "bidirectional", "label": "Bidirectional (Two-way sync)"},
],
"default": "timetracker_to_teams",
"description": "Choose how data flows between Microsoft Teams and TimeTracker",
},
{
"name": "sync_items",
"type": "array",
"label": "Items to Sync",
"options": [
{"value": "channels", "label": "Channels"},
{"value": "teams", "label": "Teams"},
{"value": "messages", "label": "Messages (as tasks)"},
],
"default": [],
"description": "Select which items to synchronize",
},
{
"name": "notify_on_time_entry_start",
"type": "boolean",
"label": "Notify on Time Entry Start",
"default": False,
"description": "Send Teams notification when a time entry starts",
},
{
"name": "notify_on_time_entry_complete",
"type": "boolean",
"label": "Notify on Time Entry Complete",
"default": False,
"description": "Send Teams notification when a time entry is completed",
},
{
"name": "notify_on_task_complete",
"type": "boolean",
"label": "Notify on Task Complete",
"default": False,
"description": "Send Teams notification when a task is completed",
},
{
"name": "notify_on_invoice_sent",
"type": "boolean",
"label": "Notify on Invoice Sent",
"default": True,
"description": "Send Teams notification when an invoice is sent",
},
{
"name": "notify_on_project_create",
"type": "boolean",
"label": "Notify on Project Create",
"default": False,
"description": "Send Teams notification when a project is created",
},
{
"name": "auto_sync",
"type": "boolean",
"label": "Auto Sync",
"default": False,
"description": "Automatically sync when webhooks are received from Teams",
},
{
"name": "sync_interval",
"type": "select",
"label": "Sync Schedule",
"options": [
{"value": "manual", "label": "Manual only"},
{"value": "hourly", "label": "Every hour"},
{"value": "daily", "label": "Daily"},
],
"default": "manual",
"description": "How often to automatically sync data",
},
],
"required": [],
"sections": [
{
"title": "Channel Settings",
"description": "Configure Teams channel for notifications",
"fields": ["default_channel_id"],
},
{
"title": "Sync Settings",
"description": "Configure what and how to sync",
"fields": ["sync_direction", "sync_items", "auto_sync", "sync_interval"],
},
{
"title": "Notification Settings",
"description": "Configure when to send Teams notifications",
"fields": ["notify_on_time_entry_start", "notify_on_time_entry_complete", "notify_on_task_complete", "notify_on_invoice_sent", "notify_on_project_create"],
},
],
"sync_settings": {
"enabled": True,
"auto_sync": False,
"sync_interval": "manual",
"sync_direction": "timetracker_to_teams",
"sync_items": [],
},
}

View File

@@ -352,18 +352,33 @@ class OutlookCalendarConnector(BaseConnector):
"type": "string",
"label": "Calendar ID",
"default": "calendar",
"required": False,
"placeholder": "calendar",
"description": "Outlook Calendar ID to sync with (default: 'calendar' for primary calendar)",
"help": "Use 'calendar' for your primary calendar, or enter a specific calendar ID from Outlook settings",
},
{
"name": "sync_direction",
"type": "select",
"label": "Sync Direction",
"options": [
{"value": "time_tracker_to_calendar", "label": "TimeTracker → Calendar"},
{"value": "calendar_to_time_tracker", "label": "Calendar → TimeTracker"},
{"value": "bidirectional", "label": "Bidirectional"},
{"value": "time_tracker_to_calendar", "label": "TimeTracker → Calendar (Export only)"},
{"value": "calendar_to_time_tracker", "label": "Calendar → TimeTracker (Import only)"},
{"value": "bidirectional", "label": "Bidirectional (Two-way sync)"},
],
"default": "time_tracker_to_calendar",
"description": "Choose how data flows between Outlook Calendar and TimeTracker",
},
{
"name": "sync_items",
"type": "array",
"label": "Items to Sync",
"options": [
{"value": "time_entries", "label": "Time Entries"},
{"value": "events", "label": "Calendar Events"},
],
"default": ["time_entries"],
"description": "Select which items to synchronize",
},
{
"name": "auto_sync",
@@ -372,6 +387,69 @@ class OutlookCalendarConnector(BaseConnector):
"default": True,
"description": "Automatically sync when time entries are created/updated",
},
{
"name": "sync_interval",
"type": "select",
"label": "Sync Schedule",
"options": [
{"value": "manual", "label": "Manual only"},
{"value": "hourly", "label": "Every hour"},
{"value": "daily", "label": "Daily"},
],
"default": "hourly",
"description": "How often to automatically sync data",
},
{
"name": "event_title_format",
"type": "text",
"label": "Event Title Format",
"default": "{project} - {task}",
"placeholder": "{project} - {task}",
"description": "Format for calendar event titles. Use {project}, {task}, {notes} as placeholders",
"help": "Customize how time entries appear as calendar events",
},
{
"name": "sync_past_days",
"type": "number",
"label": "Sync Past Days",
"default": 90,
"validation": {"min": 1, "max": 365},
"description": "Number of days in the past to sync (1-365)",
"help": "How far back to sync calendar events",
},
{
"name": "sync_future_days",
"type": "number",
"label": "Sync Future Days",
"default": 30,
"validation": {"min": 1, "max": 365},
"description": "Number of days in the future to sync (1-365)",
"help": "How far ahead to sync calendar events",
},
],
"required": [],
"sections": [
{
"title": "Calendar Settings",
"description": "Configure your Outlook Calendar connection",
"fields": ["calendar_id"],
},
{
"title": "Sync Settings",
"description": "Configure what and how to sync",
"fields": ["sync_direction", "sync_items", "auto_sync", "sync_interval", "sync_past_days", "sync_future_days"],
},
{
"title": "Display Settings",
"description": "Customize how events appear in the calendar",
"fields": ["event_title_format"],
},
],
"sync_settings": {
"enabled": True,
"auto_sync": True,
"sync_interval": "hourly",
"sync_direction": "time_tracker_to_calendar",
"sync_items": ["time_entries"],
},
}

View File

@@ -379,7 +379,9 @@ class QuickBooksConnector(BaseConnector):
# QuickBooks query syntax: SELECT * FROM Customer WHERE DisplayName = 'CustomerName'
# URL encode the query parameter
from urllib.parse import quote
query = f"SELECT * FROM Customer WHERE DisplayName = '{customer_name.replace(\"'\", \"''\")}'"
# Escape single quotes for SQL (replace ' with '')
escaped_name = customer_name.replace("'", "''")
query = f"SELECT * FROM Customer WHERE DisplayName = '{escaped_name}'"
query_url = f"/v3/company/{realm_id}/query?query={quote(query)}"
customers_response = self._api_request(
@@ -439,7 +441,9 @@ class QuickBooksConnector(BaseConnector):
try:
# Query QuickBooks for item by Name
from urllib.parse import quote
query = f"SELECT * FROM Item WHERE Name = '{item_qb_name.replace(\"'\", \"''\")}'"
# Escape single quotes for SQL (replace ' with '')
escaped_name = item_qb_name.replace("'", "''")
query = f"SELECT * FROM Item WHERE Name = '{escaped_name}'"
query_url = f"/v3/company/{realm_id}/query?query={quote(query)}"
items_response = self._api_request(
@@ -611,7 +615,10 @@ class QuickBooksConnector(BaseConnector):
"name": "realm_id",
"type": "string",
"label": "Company ID (Realm ID)",
"required": True,
"placeholder": "123456789",
"description": "QuickBooks company ID (realm ID)",
"help": "Find your company ID in QuickBooks after connecting. It's automatically set during OAuth.",
},
{
"name": "use_sandbox",
@@ -620,33 +627,112 @@ class QuickBooksConnector(BaseConnector):
"default": True,
"description": "Use QuickBooks sandbox environment for testing",
},
{"name": "sync_invoices", "type": "boolean", "label": "Sync Invoices", "default": True},
{"name": "sync_expenses", "type": "boolean", "label": "Sync Expenses", "default": True},
{
"name": "sync_direction",
"type": "select",
"label": "Sync Direction",
"options": [
{"value": "quickbooks_to_timetracker", "label": "QuickBooks → TimeTracker (Import only)"},
{"value": "timetracker_to_quickbooks", "label": "TimeTracker → QuickBooks (Export only)"},
{"value": "bidirectional", "label": "Bidirectional (Two-way sync)"},
],
"default": "timetracker_to_quickbooks",
"description": "Choose how data flows between QuickBooks and TimeTracker",
},
{
"name": "sync_items",
"type": "array",
"label": "Items to Sync",
"options": [
{"value": "invoices", "label": "Invoices"},
{"value": "expenses", "label": "Expenses"},
{"value": "payments", "label": "Payments"},
{"value": "customers", "label": "Customers"},
],
"default": ["invoices", "expenses"],
"description": "Select which items to synchronize",
},
{"name": "sync_invoices", "type": "boolean", "label": "Sync Invoices", "default": True, "description": "Enable invoice synchronization"},
{"name": "sync_expenses", "type": "boolean", "label": "Sync Expenses", "default": True, "description": "Enable expense synchronization"},
{
"name": "auto_sync",
"type": "boolean",
"label": "Auto Sync",
"default": False,
"description": "Automatically sync when invoices or expenses are created/updated",
},
{
"name": "sync_interval",
"type": "select",
"label": "Sync Schedule",
"options": [
{"value": "manual", "label": "Manual only"},
{"value": "hourly", "label": "Every hour"},
{"value": "daily", "label": "Daily"},
],
"default": "manual",
"description": "How often to automatically sync data",
},
{
"name": "default_expense_account_id",
"type": "string",
"label": "Default Expense Account ID",
"description": "QuickBooks account ID to use for expenses when no mapping is configured",
"required": False,
"default": "1",
"description": "QuickBooks account ID to use for expenses when no mapping is configured",
"help": "Find account IDs in QuickBooks Chart of Accounts",
},
{
"name": "customer_mappings",
"type": "json",
"label": "Customer Mappings",
"description": "JSON mapping of TimeTracker client IDs to QuickBooks customer IDs (e.g., {\"1\": \"qb_customer_id_123\"})",
"required": False,
"placeholder": '{"1": "qb_customer_id_123", "2": "qb_customer_id_456"}',
"description": "JSON mapping of TimeTracker client IDs to QuickBooks customer IDs",
"help": "Map your TimeTracker clients to QuickBooks customers. Format: {\"timetracker_client_id\": \"quickbooks_customer_id\"}",
},
{
"name": "item_mappings",
"type": "json",
"label": "Item Mappings",
"required": False,
"placeholder": '{"service_1": "qb_item_id_123"}',
"description": "JSON mapping of TimeTracker invoice items to QuickBooks items",
"help": "Map your TimeTracker services/products to QuickBooks items",
},
{
"name": "account_mappings",
"type": "json",
"label": "Account Mappings",
"required": False,
"placeholder": '{"expense_category_1": "qb_account_id_123"}',
"description": "JSON mapping of TimeTracker expense category IDs to QuickBooks account IDs",
"help": "Map your TimeTracker expense categories to QuickBooks accounts",
},
],
"required": ["realm_id"],
"sections": [
{
"title": "Connection Settings",
"description": "Configure your QuickBooks connection",
"fields": ["realm_id", "use_sandbox"],
},
{
"title": "Sync Settings",
"description": "Configure what and how to sync",
"fields": ["sync_direction", "sync_items", "sync_invoices", "sync_expenses", "auto_sync", "sync_interval"],
},
{
"title": "Data Mapping",
"description": "Map TimeTracker data to QuickBooks",
"fields": ["default_expense_account_id", "customer_mappings", "item_mappings", "account_mappings"],
},
],
"sync_settings": {
"enabled": True,
"auto_sync": False,
"sync_interval": "manual",
"sync_direction": "timetracker_to_quickbooks",
"sync_items": ["invoices", "expenses"],
},
}

View File

@@ -267,3 +267,96 @@ class SlackConnector(BaseConnector):
return {"success": True, "message": "Message sent successfully"}
else:
return {"success": False, "message": f"Slack API error: {data.get('error', 'Unknown error')}"}
def get_config_schema(self) -> Dict[str, Any]:
"""Get configuration schema."""
return {
"fields": [
{
"name": "sync_direction",
"type": "select",
"label": "Sync Direction",
"options": [
{"value": "slack_to_timetracker", "label": "Slack → TimeTracker (Import only)"},
{"value": "timetracker_to_slack", "label": "TimeTracker → Slack (Export only)"},
{"value": "bidirectional", "label": "Bidirectional (Two-way sync)"},
],
"default": "slack_to_timetracker",
"description": "Choose how data flows between Slack and TimeTracker",
},
{
"name": "sync_items",
"type": "array",
"label": "Items to Sync",
"options": [
{"value": "channels", "label": "Channels"},
{"value": "users", "label": "Users"},
{"value": "messages", "label": "Messages (as tasks)"},
],
"default": ["channels", "users"],
"description": "Select which items to synchronize",
},
{
"name": "notification_channel",
"type": "text",
"label": "Notification Channel",
"required": False,
"placeholder": "#general or channel-id",
"help": "Channel ID or name where TimeTracker notifications will be sent",
"description": "Default channel for notifications",
},
{
"name": "auto_sync",
"type": "boolean",
"label": "Auto Sync",
"default": False,
"description": "Automatically sync when webhooks are received from Slack",
},
{
"name": "sync_interval",
"type": "select",
"label": "Sync Schedule",
"options": [
{"value": "manual", "label": "Manual only"},
{"value": "hourly", "label": "Every hour"},
{"value": "daily", "label": "Daily"},
],
"default": "manual",
"description": "How often to automatically sync data",
},
{
"name": "notify_on_time_entry",
"type": "boolean",
"label": "Notify on Time Entry",
"default": False,
"description": "Send Slack notifications when time entries are created",
},
{
"name": "notify_on_task_complete",
"type": "boolean",
"label": "Notify on Task Complete",
"default": False,
"description": "Send Slack notifications when tasks are completed",
},
],
"required": [],
"sections": [
{
"title": "Sync Settings",
"description": "Configure what and how to sync",
"fields": ["sync_direction", "sync_items", "auto_sync", "sync_interval"],
},
{
"title": "Notification Settings",
"description": "Configure Slack notifications",
"fields": ["notification_channel", "notify_on_time_entry", "notify_on_task_complete"],
},
],
"sync_settings": {
"enabled": True,
"auto_sync": False,
"sync_interval": "manual",
"sync_direction": "slack_to_timetracker",
"sync_items": ["channels", "users"],
},
}

View File

@@ -435,21 +435,95 @@ class TrelloConnector(BaseConnector):
"fields": [
{
"name": "board_ids",
"type": "array",
"type": "text",
"label": "Board IDs",
"description": "Trello board IDs to sync (leave empty to sync all)",
"required": False,
"placeholder": "board-id-1, board-id-2",
"description": "Comma-separated list of Trello board IDs to sync (leave empty to sync all)",
"help": "Find board IDs in Trello board URLs or API. Leave empty to sync all accessible boards.",
},
{
"name": "sync_direction",
"type": "select",
"label": "Sync Direction",
"options": [
{"value": "trello_to_timetracker", "label": "Trello → TimeTracker"},
{"value": "timetracker_to_trello", "label": "TimeTracker → Trello"},
{"value": "bidirectional", "label": "Bidirectional"},
{"value": "trello_to_timetracker", "label": "Trello → TimeTracker (Import only)"},
{"value": "timetracker_to_trello", "label": "TimeTracker → Trello (Export only)"},
{"value": "bidirectional", "label": "Bidirectional (Two-way sync)"},
],
"default": "trello_to_timetracker",
"description": "Choose how data flows between Trello and TimeTracker",
},
{
"name": "sync_items",
"type": "array",
"label": "Items to Sync",
"options": [
{"value": "boards", "label": "Boards (Projects)"},
{"value": "cards", "label": "Cards (Tasks)"},
{"value": "lists", "label": "Lists"},
],
"default": ["boards", "cards"],
"description": "Select which items to synchronize",
},
{
"name": "auto_sync",
"type": "boolean",
"label": "Auto Sync",
"default": False,
"description": "Automatically sync when webhooks are received from Trello",
},
{
"name": "sync_interval",
"type": "select",
"label": "Sync Schedule",
"options": [
{"value": "manual", "label": "Manual only"},
{"value": "hourly", "label": "Every hour"},
{"value": "daily", "label": "Daily"},
],
"default": "manual",
"description": "How often to automatically sync data",
},
{
"name": "list_status_mapping",
"type": "json",
"label": "List to Status Mapping",
"placeholder": '{"To Do": "todo", "In Progress": "in_progress", "Done": "completed"}',
"description": "Map Trello list names to TimeTracker task statuses (JSON format)",
"help": "Customize how Trello list names map to TimeTracker task statuses",
},
{
"name": "sync_archived",
"type": "boolean",
"label": "Sync Archived Items",
"default": False,
"description": "Include archived boards and cards in sync",
},
],
"required": [],
"sections": [
{
"title": "Board Settings",
"description": "Configure which Trello boards to sync",
"fields": ["board_ids", "sync_archived"],
},
{
"title": "Sync Settings",
"description": "Configure what and how to sync",
"fields": ["sync_direction", "sync_items", "auto_sync", "sync_interval"],
},
{
"title": "Data Mapping",
"description": "Customize how data translates between Trello and TimeTracker",
"fields": ["list_status_mapping"],
},
],
"sync_settings": {
"enabled": True,
"auto_sync": False,
"sync_interval": "manual",
"sync_direction": "trello_to_timetracker",
"sync_items": ["boards", "cards"],
},
}

View File

@@ -357,35 +357,117 @@ class XeroConnector(BaseConnector):
"name": "tenant_id",
"type": "string",
"label": "Tenant ID",
"required": True,
"placeholder": "tenant-uuid-123",
"description": "Xero organisation tenant ID",
"help": "Find your tenant ID in Xero after connecting. It's automatically set during OAuth.",
},
{
"name": "sync_direction",
"type": "select",
"label": "Sync Direction",
"options": [
{"value": "xero_to_timetracker", "label": "Xero → TimeTracker (Import only)"},
{"value": "timetracker_to_xero", "label": "TimeTracker → Xero (Export only)"},
{"value": "bidirectional", "label": "Bidirectional (Two-way sync)"},
],
"default": "timetracker_to_xero",
"description": "Choose how data flows between Xero and TimeTracker",
},
{
"name": "sync_items",
"type": "array",
"label": "Items to Sync",
"options": [
{"value": "invoices", "label": "Invoices"},
{"value": "expenses", "label": "Expenses"},
{"value": "payments", "label": "Payments"},
{"value": "contacts", "label": "Contacts"},
],
"default": ["invoices", "expenses"],
"description": "Select which items to synchronize",
},
{"name": "sync_invoices", "type": "boolean", "label": "Sync Invoices", "default": True, "description": "Enable invoice synchronization"},
{"name": "sync_expenses", "type": "boolean", "label": "Sync Expenses", "default": True, "description": "Enable expense synchronization"},
{
"name": "auto_sync",
"type": "boolean",
"label": "Auto Sync",
"default": False,
"description": "Automatically sync when invoices or expenses are created/updated",
},
{
"name": "sync_interval",
"type": "select",
"label": "Sync Schedule",
"options": [
{"value": "manual", "label": "Manual only"},
{"value": "hourly", "label": "Every hour"},
{"value": "daily", "label": "Daily"},
],
"default": "manual",
"description": "How often to automatically sync data",
},
{"name": "sync_invoices", "type": "boolean", "label": "Sync Invoices", "default": True},
{"name": "sync_expenses", "type": "boolean", "label": "Sync Expenses", "default": True},
{
"name": "default_expense_account_code",
"type": "string",
"label": "Default Expense Account Code",
"description": "Xero account code to use for expenses when no mapping is configured",
"required": False,
"default": "200",
"description": "Xero account code to use for expenses when no mapping is configured",
"help": "Find account codes in Xero Chart of Accounts",
},
{
"name": "contact_mappings",
"type": "json",
"label": "Contact Mappings",
"description": "JSON mapping of TimeTracker client IDs to Xero Contact IDs (e.g., {\"1\": \"contact-uuid-123\"})",
"required": False,
"placeholder": '{"1": "contact-uuid-123", "2": "contact-uuid-456"}',
"description": "JSON mapping of TimeTracker client IDs to Xero Contact IDs",
"help": "Map your TimeTracker clients to Xero contacts. Format: {\"timetracker_client_id\": \"xero_contact_id\"}",
},
{
"name": "item_mappings",
"type": "json",
"label": "Item Mappings",
"required": False,
"placeholder": '{"service_1": "item_code_123"}',
"description": "JSON mapping of TimeTracker invoice items to Xero item codes",
"help": "Map your TimeTracker services/products to Xero items",
},
{
"name": "account_mappings",
"type": "json",
"label": "Account Mappings",
"required": False,
"placeholder": '{"expense_category_1": "account_code_200"}',
"description": "JSON mapping of TimeTracker expense category IDs to Xero account codes",
"help": "Map your TimeTracker expense categories to Xero accounts",
},
],
"required": ["tenant_id"],
"sections": [
{
"title": "Connection Settings",
"description": "Configure your Xero connection",
"fields": ["tenant_id"],
},
{
"title": "Sync Settings",
"description": "Configure what and how to sync",
"fields": ["sync_direction", "sync_items", "sync_invoices", "sync_expenses", "auto_sync", "sync_interval"],
},
{
"title": "Data Mapping",
"description": "Map TimeTracker data to Xero",
"fields": ["default_expense_account_code", "contact_mappings", "item_mappings", "account_mappings"],
},
],
"sync_settings": {
"enabled": True,
"auto_sync": False,
"sync_interval": "manual",
"sync_direction": "timetracker_to_xero",
"sync_items": ["invoices", "expenses"],
},
}

View File

@@ -44,65 +44,6 @@ class Settings(db.Model):
# Privacy and analytics settings
allow_analytics = db.Column(db.Boolean, default=True, nullable=False) # Controls system info sharing for analytics
# System-wide UI feature flags - control which features are available for users to customize
# Calendar section
ui_allow_calendar = db.Column(db.Boolean, default=True, nullable=False)
# Time Tracking section items
ui_allow_project_templates = db.Column(db.Boolean, default=True, nullable=False)
ui_allow_gantt_chart = db.Column(db.Boolean, default=True, nullable=False)
ui_allow_kanban_board = db.Column(db.Boolean, default=True, nullable=False)
ui_allow_weekly_goals = db.Column(db.Boolean, default=True, nullable=False)
ui_allow_issues = db.Column(db.Boolean, default=True, nullable=False) # Show/hide Issues feature
# CRM section
ui_allow_quotes = db.Column(db.Boolean, default=True, nullable=False)
# Finance & Expenses section items
ui_allow_reports = db.Column(db.Boolean, default=True, nullable=False)
ui_allow_report_builder = db.Column(db.Boolean, default=True, nullable=False)
ui_allow_scheduled_reports = db.Column(db.Boolean, default=True, nullable=False)
ui_allow_invoice_approvals = db.Column(db.Boolean, default=True, nullable=False)
ui_allow_payment_gateways = db.Column(db.Boolean, default=True, nullable=False)
ui_allow_recurring_invoices = db.Column(db.Boolean, default=True, nullable=False)
ui_allow_payments = db.Column(db.Boolean, default=True, nullable=False)
ui_allow_mileage = db.Column(db.Boolean, default=True, nullable=False)
ui_allow_per_diem = db.Column(db.Boolean, default=True, nullable=False)
ui_allow_budget_alerts = db.Column(db.Boolean, default=True, nullable=False)
# Inventory section
ui_allow_inventory = db.Column(db.Boolean, default=True, nullable=False)
# Analytics
ui_allow_analytics = db.Column(db.Boolean, default=True, nullable=False)
# Tools & Data section
ui_allow_tools = db.Column(db.Boolean, default=True, nullable=False)
ui_allow_integrations = db.Column(db.Boolean, default=True, nullable=False)
ui_allow_import_export = db.Column(db.Boolean, default=True, nullable=False)
ui_allow_saved_filters = db.Column(db.Boolean, default=True, nullable=False)
# CRM section (additional)
ui_allow_contacts = db.Column(db.Boolean, default=True, nullable=False)
ui_allow_deals = db.Column(db.Boolean, default=True, nullable=False)
ui_allow_leads = db.Column(db.Boolean, default=True, nullable=False)
# Finance section (additional)
ui_allow_invoices = db.Column(db.Boolean, default=True, nullable=False)
ui_allow_expenses = db.Column(db.Boolean, default=True, nullable=False)
# Time Tracking section (additional)
ui_allow_time_entry_templates = db.Column(db.Boolean, default=True, nullable=False)
# Advanced features
ui_allow_workflows = db.Column(db.Boolean, default=True, nullable=False)
ui_allow_time_approvals = db.Column(db.Boolean, default=True, nullable=False)
ui_allow_activity_feed = db.Column(db.Boolean, default=True, nullable=False)
ui_allow_recurring_tasks = db.Column(db.Boolean, default=True, nullable=False)
ui_allow_team_chat = db.Column(db.Boolean, default=True, nullable=False)
ui_allow_client_portal = db.Column(db.Boolean, default=True, nullable=False)
ui_allow_kiosk = db.Column(db.Boolean, default=True, nullable=False)
# Kiosk mode settings
kiosk_mode_enabled = db.Column(db.Boolean, default=False, nullable=False)
kiosk_auto_logout_minutes = db.Column(db.Integer, default=15, nullable=False)
@@ -439,43 +380,6 @@ class Settings(db.Model):
"xero_client_secret_set": bool(getattr(self, "xero_client_secret", "")),
"created_at": self.created_at.isoformat() if self.created_at else None,
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
# UI feature flags (system-wide)
"ui_allow_calendar": getattr(self, "ui_allow_calendar", True),
"ui_allow_project_templates": getattr(self, "ui_allow_project_templates", True),
"ui_allow_gantt_chart": getattr(self, "ui_allow_gantt_chart", True),
"ui_allow_kanban_board": getattr(self, "ui_allow_kanban_board", True),
"ui_allow_weekly_goals": getattr(self, "ui_allow_weekly_goals", True),
"ui_allow_issues": getattr(self, "ui_allow_issues", True),
"ui_allow_quotes": getattr(self, "ui_allow_quotes", True),
"ui_allow_reports": getattr(self, "ui_allow_reports", True),
"ui_allow_report_builder": getattr(self, "ui_allow_report_builder", True),
"ui_allow_scheduled_reports": getattr(self, "ui_allow_scheduled_reports", True),
"ui_allow_invoice_approvals": getattr(self, "ui_allow_invoice_approvals", True),
"ui_allow_payment_gateways": getattr(self, "ui_allow_payment_gateways", True),
"ui_allow_recurring_invoices": getattr(self, "ui_allow_recurring_invoices", True),
"ui_allow_payments": getattr(self, "ui_allow_payments", True),
"ui_allow_mileage": getattr(self, "ui_allow_mileage", True),
"ui_allow_per_diem": getattr(self, "ui_allow_per_diem", True),
"ui_allow_budget_alerts": getattr(self, "ui_allow_budget_alerts", True),
"ui_allow_inventory": getattr(self, "ui_allow_inventory", True),
"ui_allow_analytics": getattr(self, "ui_allow_analytics", True),
"ui_allow_tools": getattr(self, "ui_allow_tools", True),
"ui_allow_integrations": getattr(self, "ui_allow_integrations", True),
"ui_allow_import_export": getattr(self, "ui_allow_import_export", True),
"ui_allow_saved_filters": getattr(self, "ui_allow_saved_filters", True),
"ui_allow_contacts": getattr(self, "ui_allow_contacts", True),
"ui_allow_deals": getattr(self, "ui_allow_deals", True),
"ui_allow_leads": getattr(self, "ui_allow_leads", True),
"ui_allow_invoices": getattr(self, "ui_allow_invoices", True),
"ui_allow_expenses": getattr(self, "ui_allow_expenses", True),
"ui_allow_time_entry_templates": getattr(self, "ui_allow_time_entry_templates", True),
"ui_allow_workflows": getattr(self, "ui_allow_workflows", True),
"ui_allow_time_approvals": getattr(self, "ui_allow_time_approvals", True),
"ui_allow_activity_feed": getattr(self, "ui_allow_activity_feed", True),
"ui_allow_recurring_tasks": getattr(self, "ui_allow_recurring_tasks", True),
"ui_allow_team_chat": getattr(self, "ui_allow_team_chat", True),
"ui_allow_client_portal": getattr(self, "ui_allow_client_portal", True),
"ui_allow_kiosk": getattr(self, "ui_allow_kiosk", True),
}
@classmethod

View File

@@ -429,41 +429,16 @@ def clear_cache():
return render_template("admin/clear_cache.html")
@admin_bp.route("/admin/modules", methods=["GET", "POST"])
@admin_bp.route("/admin/modules", methods=["GET"])
@login_required
@admin_or_permission_required("manage_settings")
def manage_modules():
"""Manage module visibility settings"""
"""View available modules (all modules are enabled by default)"""
from app.utils.module_registry import ModuleRegistry, ModuleCategory
# Initialize registry
ModuleRegistry.initialize_defaults()
settings_obj = Settings.get_settings()
if request.method == "POST":
# Update all module flags dynamically
updated_count = 0
for module_id, module in ModuleRegistry.get_all().items():
if module.settings_flag:
flag_name = module.settings_flag
if hasattr(settings_obj, flag_name):
new_value = request.form.get(flag_name) == "on"
old_value = getattr(settings_obj, flag_name, True)
if new_value != old_value:
setattr(settings_obj, flag_name, new_value)
updated_count += 1
if updated_count > 0:
if not safe_commit("admin_update_module_settings"):
flash(_("Could not update module settings due to a database error."), "error")
else:
flash(_("Module settings updated successfully"), "success")
else:
flash(_("No changes to save"), "info")
return redirect(url_for("admin.manage_modules"))
# Group modules by category for display
modules_by_category = {}
for category in ModuleCategory:
@@ -474,7 +449,6 @@ def manage_modules():
return render_template(
"admin/modules.html",
modules_by_category=modules_by_category,
settings=settings_obj,
ModuleCategory=ModuleCategory,
)
@@ -553,216 +527,6 @@ def settings():
# Kiosk columns don't exist yet (migration not run)
pass
# Update system-wide UI feature flags (if columns exist)
try:
# Calendar
if hasattr(settings_obj, "ui_allow_calendar"):
settings_obj.ui_allow_calendar = request.form.get("ui_allow_calendar") == "on"
# Time Tracking
if hasattr(settings_obj, "ui_allow_project_templates"):
settings_obj.ui_allow_project_templates = request.form.get("ui_allow_project_templates") == "on"
if hasattr(settings_obj, "ui_allow_gantt_chart"):
settings_obj.ui_allow_gantt_chart = request.form.get("ui_allow_gantt_chart") == "on"
if hasattr(settings_obj, "ui_allow_kanban_board"):
settings_obj.ui_allow_kanban_board = request.form.get("ui_allow_kanban_board") == "on"
if hasattr(settings_obj, "ui_allow_weekly_goals"):
settings_obj.ui_allow_weekly_goals = request.form.get("ui_allow_weekly_goals") == "on"
settings_obj.ui_allow_issues = request.form.get("ui_allow_issues") == "on"
# CRM
if hasattr(settings_obj, "ui_allow_quotes"):
settings_obj.ui_allow_quotes = request.form.get("ui_allow_quotes") == "on"
# Finance & Expenses
if hasattr(settings_obj, "ui_allow_reports"):
settings_obj.ui_allow_reports = request.form.get("ui_allow_reports") == "on"
if hasattr(settings_obj, "ui_allow_report_builder"):
settings_obj.ui_allow_report_builder = request.form.get("ui_allow_report_builder") == "on"
if hasattr(settings_obj, "ui_allow_scheduled_reports"):
settings_obj.ui_allow_scheduled_reports = request.form.get("ui_allow_scheduled_reports") == "on"
if hasattr(settings_obj, "ui_allow_invoice_approvals"):
settings_obj.ui_allow_invoice_approvals = request.form.get("ui_allow_invoice_approvals") == "on"
if hasattr(settings_obj, "ui_allow_payment_gateways"):
settings_obj.ui_allow_payment_gateways = request.form.get("ui_allow_payment_gateways") == "on"
if hasattr(settings_obj, "ui_allow_recurring_invoices"):
settings_obj.ui_allow_recurring_invoices = request.form.get("ui_allow_recurring_invoices") == "on"
if hasattr(settings_obj, "ui_allow_payments"):
settings_obj.ui_allow_payments = request.form.get("ui_allow_payments") == "on"
if hasattr(settings_obj, "ui_allow_mileage"):
settings_obj.ui_allow_mileage = request.form.get("ui_allow_mileage") == "on"
if hasattr(settings_obj, "ui_allow_per_diem"):
settings_obj.ui_allow_per_diem = request.form.get("ui_allow_per_diem") == "on"
if hasattr(settings_obj, "ui_allow_budget_alerts"):
settings_obj.ui_allow_budget_alerts = request.form.get("ui_allow_budget_alerts") == "on"
# Inventory
if hasattr(settings_obj, "ui_allow_inventory"):
settings_obj.ui_allow_inventory = request.form.get("ui_allow_inventory") == "on"
# Analytics
if hasattr(settings_obj, "ui_allow_analytics"):
settings_obj.ui_allow_analytics = request.form.get("ui_allow_analytics") == "on"
# Tools & Data
if hasattr(settings_obj, "ui_allow_tools"):
settings_obj.ui_allow_tools = request.form.get("ui_allow_tools") == "on"
if hasattr(settings_obj, "ui_allow_integrations"):
settings_obj.ui_allow_integrations = request.form.get("ui_allow_integrations") == "on"
if hasattr(settings_obj, "ui_allow_import_export"):
settings_obj.ui_allow_import_export = request.form.get("ui_allow_import_export") == "on"
if hasattr(settings_obj, "ui_allow_saved_filters"):
settings_obj.ui_allow_saved_filters = request.form.get("ui_allow_saved_filters") == "on"
# CRM (additional)
if hasattr(settings_obj, "ui_allow_contacts"):
settings_obj.ui_allow_contacts = request.form.get("ui_allow_contacts") == "on"
if hasattr(settings_obj, "ui_allow_deals"):
settings_obj.ui_allow_deals = request.form.get("ui_allow_deals") == "on"
if hasattr(settings_obj, "ui_allow_leads"):
settings_obj.ui_allow_leads = request.form.get("ui_allow_leads") == "on"
# Finance (additional)
if hasattr(settings_obj, "ui_allow_invoices"):
settings_obj.ui_allow_invoices = request.form.get("ui_allow_invoices") == "on"
if hasattr(settings_obj, "ui_allow_expenses"):
settings_obj.ui_allow_expenses = request.form.get("ui_allow_expenses") == "on"
# Time Tracking (additional)
if hasattr(settings_obj, "ui_allow_time_entry_templates"):
settings_obj.ui_allow_time_entry_templates = request.form.get("ui_allow_time_entry_templates") == "on"
# Advanced features
if hasattr(settings_obj, "ui_allow_workflows"):
settings_obj.ui_allow_workflows = request.form.get("ui_allow_workflows") == "on"
if hasattr(settings_obj, "ui_allow_time_approvals"):
settings_obj.ui_allow_time_approvals = request.form.get("ui_allow_time_approvals") == "on"
if hasattr(settings_obj, "ui_allow_activity_feed"):
settings_obj.ui_allow_activity_feed = request.form.get("ui_allow_activity_feed") == "on"
if hasattr(settings_obj, "ui_allow_recurring_tasks"):
settings_obj.ui_allow_recurring_tasks = request.form.get("ui_allow_recurring_tasks") == "on"
if hasattr(settings_obj, "ui_allow_team_chat"):
settings_obj.ui_allow_team_chat = request.form.get("ui_allow_team_chat") == "on"
if hasattr(settings_obj, "ui_allow_client_portal"):
settings_obj.ui_allow_client_portal = request.form.get("ui_allow_client_portal") == "on"
if hasattr(settings_obj, "ui_allow_kiosk"):
settings_obj.ui_allow_kiosk = request.form.get("ui_allow_kiosk") == "on"
except Exception as e:
# Log any errors but don't fail silently
import logging
logger = logging.getLogger(__name__)
logger.warning(f"Error updating UI feature flags: {e}")
# UI allow columns don't exist yet (migration not run) or other error
pass
# Update integration OAuth credentials (if columns exist)
try:
# Jira
if "jira_client_id" in request.form:
settings_obj.jira_client_id = request.form.get("jira_client_id", "").strip()
if "jira_client_secret" in request.form:
new_secret = request.form.get("jira_client_secret", "").strip()
if new_secret:
settings_obj.jira_client_secret = new_secret
# Slack
if "slack_client_id" in request.form:
settings_obj.slack_client_id = request.form.get("slack_client_id", "").strip()
if "slack_client_secret" in request.form:
new_secret = request.form.get("slack_client_secret", "").strip()
if new_secret:
settings_obj.slack_client_secret = new_secret
# GitHub
if "github_client_id" in request.form:
settings_obj.github_client_id = request.form.get("github_client_id", "").strip()
if "github_client_secret" in request.form:
new_secret = request.form.get("github_client_secret", "").strip()
if new_secret:
settings_obj.github_client_secret = new_secret
# Google Calendar
if hasattr(settings_obj, "google_calendar_client_id"):
if "google_calendar_client_id" in request.form:
settings_obj.google_calendar_client_id = request.form.get("google_calendar_client_id", "").strip()
if "google_calendar_client_secret" in request.form:
new_secret = request.form.get("google_calendar_client_secret", "").strip()
if new_secret:
settings_obj.google_calendar_client_secret = new_secret
# Outlook Calendar
if hasattr(settings_obj, "outlook_calendar_client_id"):
if "outlook_calendar_client_id" in request.form:
settings_obj.outlook_calendar_client_id = request.form.get("outlook_calendar_client_id", "").strip()
if "outlook_calendar_client_secret" in request.form:
new_secret = request.form.get("outlook_calendar_client_secret", "").strip()
if new_secret:
settings_obj.outlook_calendar_client_secret = new_secret
if "outlook_calendar_tenant_id" in request.form:
settings_obj.outlook_calendar_tenant_id = request.form.get("outlook_calendar_tenant_id", "").strip()
# Microsoft Teams
if hasattr(settings_obj, "microsoft_teams_client_id"):
if "microsoft_teams_client_id" in request.form:
settings_obj.microsoft_teams_client_id = request.form.get("microsoft_teams_client_id", "").strip()
if "microsoft_teams_client_secret" in request.form:
new_secret = request.form.get("microsoft_teams_client_secret", "").strip()
if new_secret:
settings_obj.microsoft_teams_client_secret = new_secret
if "microsoft_teams_tenant_id" in request.form:
settings_obj.microsoft_teams_tenant_id = request.form.get("microsoft_teams_tenant_id", "").strip()
# Asana
if hasattr(settings_obj, "asana_client_id"):
if "asana_client_id" in request.form:
settings_obj.asana_client_id = request.form.get("asana_client_id", "").strip()
if "asana_client_secret" in request.form:
new_secret = request.form.get("asana_client_secret", "").strip()
if new_secret:
settings_obj.asana_client_secret = new_secret
# Trello
if hasattr(settings_obj, "trello_api_key"):
if "trello_api_key" in request.form:
settings_obj.trello_api_key = request.form.get("trello_api_key", "").strip()
if "trello_api_secret" in request.form:
new_secret = request.form.get("trello_api_secret", "").strip()
if new_secret:
settings_obj.trello_api_secret = new_secret
# GitLab
if hasattr(settings_obj, "gitlab_client_id"):
if "gitlab_client_id" in request.form:
settings_obj.gitlab_client_id = request.form.get("gitlab_client_id", "").strip()
if "gitlab_client_secret" in request.form:
new_secret = request.form.get("gitlab_client_secret", "").strip()
if new_secret:
settings_obj.gitlab_client_secret = new_secret
if "gitlab_instance_url" in request.form:
settings_obj.gitlab_instance_url = request.form.get("gitlab_instance_url", "").strip()
# QuickBooks
if hasattr(settings_obj, "quickbooks_client_id"):
if "quickbooks_client_id" in request.form:
settings_obj.quickbooks_client_id = request.form.get("quickbooks_client_id", "").strip()
if "quickbooks_client_secret" in request.form:
new_secret = request.form.get("quickbooks_client_secret", "").strip()
if new_secret:
settings_obj.quickbooks_client_secret = new_secret
# Xero
if hasattr(settings_obj, "xero_client_id"):
if "xero_client_id" in request.form:
settings_obj.xero_client_id = request.form.get("xero_client_id", "").strip()
if "xero_client_secret" in request.form:
new_secret = request.form.get("xero_client_secret", "").strip()
if new_secret:
settings_obj.xero_client_secret = new_secret
except AttributeError:
# Integration credential columns don't exist yet (migration not run)
pass
# Update privacy and analytics settings
allow_analytics = request.form.get("allow_analytics") == "on"
old_analytics_state = settings_obj.allow_analytics
@@ -2588,135 +2352,13 @@ def delete_email_template(template_id):
@login_required
@admin_required
def list_integrations_admin():
"""List all integrations (admin view)."""
from app.services.integration_service import IntegrationService
service = IntegrationService()
integrations = service.list_integrations(None) # Get all integrations
available_providers = service.get_available_providers()
return render_template(
"admin/integrations/list.html", integrations=integrations, available_providers=available_providers
)
"""List all integrations (admin view). Redirect to main integrations page."""
return redirect(url_for("integrations.list_integrations"))
@admin_bp.route("/admin/integrations/<provider>/setup", methods=["GET", "POST"])
@login_required
@admin_required
def integration_setup(provider):
"""Setup page for configuring integration OAuth credentials."""
from app.services.integration_service import IntegrationService
from app.models import Settings
service = IntegrationService()
# Check if provider is available
if provider not in service._connector_registry:
flash(_("Integration provider not available."), "error")
return redirect(url_for("admin.list_integrations_admin"))
connector_class = service._connector_registry[provider]
settings = Settings.get_settings()
# Get or create global integration (except Google Calendar which is per-user)
integration = None
if provider != "google_calendar":
integration = service.get_global_integration(provider)
if not integration:
# Create global integration
result = service.create_integration(provider, user_id=None, is_global=True)
if result["success"]:
integration = result["integration"]
else:
flash(result["message"], "error")
return redirect(url_for("admin.list_integrations_admin"))
if request.method == "POST":
# Update OAuth credentials in Settings
if provider == "trello":
# Trello uses API key + token, not OAuth
api_key = request.form.get("trello_api_key", "").strip()
token = request.form.get("trello_token", "").strip()
if api_key:
settings.trello_api_key = api_key
if token:
# Save token directly to integration credentials if integration exists
if integration:
from app.services.integration_service import IntegrationService
service = IntegrationService()
service.save_credentials(
integration_id=integration.id,
access_token=token,
refresh_token=None,
expires_at=None,
token_type="Bearer",
scope="read,write",
extra_data={"api_key": api_key},
)
else:
# OAuth-based integrations
client_id = request.form.get(f"{provider}_client_id", "").strip()
client_secret = request.form.get(f"{provider}_client_secret", "").strip()
# Map provider names to Settings attributes
attr_map = {
"jira": ("jira_client_id", "jira_client_secret"),
"slack": ("slack_client_id", "slack_client_secret"),
"github": ("github_client_id", "github_client_secret"),
"google_calendar": ("google_calendar_client_id", "google_calendar_client_secret"),
"outlook_calendar": ("outlook_calendar_client_id", "outlook_calendar_client_secret"),
"microsoft_teams": ("microsoft_teams_client_id", "microsoft_teams_client_secret"),
"asana": ("asana_client_id", "asana_client_secret"),
"gitlab": ("gitlab_client_id", "gitlab_client_secret"),
"quickbooks": ("quickbooks_client_id", "quickbooks_client_secret"),
"xero": ("xero_client_id", "xero_client_secret"),
}
if provider in attr_map:
id_attr, secret_attr = attr_map[provider]
if client_id:
setattr(settings, id_attr, client_id)
if client_secret:
setattr(settings, secret_attr, client_secret)
# Handle special fields
if provider == "outlook_calendar":
tenant_id = request.form.get("outlook_calendar_tenant_id", "").strip()
if tenant_id:
settings.outlook_calendar_tenant_id = tenant_id
elif provider == "microsoft_teams":
tenant_id = request.form.get("microsoft_teams_tenant_id", "").strip()
if tenant_id:
settings.microsoft_teams_tenant_id = tenant_id
elif provider == "gitlab":
instance_url = request.form.get("gitlab_instance_url", "").strip()
if instance_url:
settings.gitlab_instance_url = instance_url
if safe_commit("update_integration_credentials", {"provider": provider}):
flash(_("Integration credentials updated successfully."), "success")
# For Google Calendar, provide option to test connection
if provider == "google_calendar":
flash(
_(
"Users can now connect their Google Calendar. They will be automatically redirected to Google for authorization."
),
"info",
)
return redirect(url_for("admin.integration_setup", provider=provider))
else:
flash(_("Failed to update credentials."), "error")
# Get current credentials
current_creds = settings.get_integration_credentials(provider)
return render_template(
"admin/integrations/setup.html",
provider=provider,
connector=connector_class,
integration=integration,
current_creds=current_creds,
display_name=getattr(connector_class, "display_name", provider.title()),
description=getattr(connector_class, "description", ""),
)
"""Setup page for configuring integration OAuth credentials. Redirect to main integrations manage page."""
return redirect(url_for("integrations.manage_integration", provider=provider))

View File

@@ -12,6 +12,12 @@ from app.utils.db import safe_commit
import secrets
import logging
# Import registry to ensure connectors are registered
try:
from app.integrations import registry # noqa: F401
except ImportError:
pass
logger = logging.getLogger(__name__)
integrations_bp = Blueprint("integrations", __name__)
@@ -44,13 +50,12 @@ def connect_integration(provider):
flash(_("Integration provider not available."), "error")
return redirect(url_for("integrations.list_integrations"))
# Trello doesn't use OAuth - redirect to admin setup
# Trello doesn't use OAuth - redirect to manage page
if provider == "trello":
if not current_user.is_admin:
flash(_("Trello integration must be configured by an administrator."), "error")
return redirect(url_for("integrations.list_integrations"))
flash(_("Trello uses API key authentication. Please configure it in Admin → Integrations."), "info")
return redirect(url_for("admin.integration_setup", provider=provider))
return redirect(url_for("integrations.manage_integration", provider=provider))
# CalDAV doesn't use OAuth - redirect to setup form
if provider == "caldav_calendar":
@@ -107,12 +112,12 @@ def connect_integration(provider):
flash(
_("Google Calendar OAuth credentials need to be configured first. Redirecting to setup..."), "info"
)
return redirect(url_for("admin.integration_setup", provider=provider))
return redirect(url_for("integrations.manage_integration", provider=provider))
else:
flash(_("Google Calendar integration needs to be configured by an administrator first."), "warning")
elif current_user.is_admin:
flash(_("OAuth credentials not configured. Please set them up in Admin → Integrations."), "error")
return redirect(url_for("admin.integration_setup", provider=provider))
flash(_("OAuth credentials not configured. Please configure them first."), "error")
return redirect(url_for("integrations.manage_integration", provider=provider))
else:
flash(_("Integration not configured. Please ask an administrator to set up OAuth credentials."), "error")
return redirect(url_for("integrations.list_integrations"))
@@ -191,10 +196,8 @@ def oauth_callback(provider):
"warning",
)
# Redirect to admin setup page for global integrations, view page for per-user
if integration.is_global and current_user.is_admin:
return redirect(url_for("admin.integration_setup", provider=provider))
return redirect(url_for("integrations.view_integration", integration_id=integration.id))
# Redirect to manage page
return redirect(url_for("integrations.manage_integration", provider=provider))
except Exception as e:
logger.error(f"Error in OAuth callback for {provider}: {e}")
@@ -202,6 +205,357 @@ def oauth_callback(provider):
return redirect(url_for("integrations.list_integrations"))
@integrations_bp.route("/integrations/<provider>/manage", methods=["GET", "POST"])
@login_required
def manage_integration(provider):
"""Manage an integration: configure OAuth credentials (admin) and connection management (all users)."""
from app.models import Settings
# Ensure registry is loaded
try:
from app.integrations import registry # noqa: F401
except ImportError:
pass
service = IntegrationService()
# Get connector class if available, otherwise use defaults
connector_class = service._connector_registry.get(provider)
if not connector_class:
# Provider not in registry - create a minimal connector class info
class MinimalConnector:
display_name = provider.replace("_", " ").title()
description = ""
icon = "plug"
connector_class = MinimalConnector
# Log warning but continue
logger.warning(f"Provider {provider} not found in registry, using defaults")
settings = Settings.get_settings()
# Get or create integration
is_global = provider not in ("google_calendar", "caldav_calendar")
integration = None
if is_global:
integration = service.get_global_integration(provider)
if not integration and current_user.is_admin:
# Create global integration (admin only)
result = service.create_integration(provider, user_id=None, is_global=True)
if result["success"]:
integration = result["integration"]
else:
# Per-user integration
integration = Integration.query.filter_by(provider=provider, user_id=current_user.id, is_global=False).first()
# Handle POST (OAuth credential updates - admin only for global integrations)
if request.method == "POST":
if is_global and not current_user.is_admin:
flash(_("Only administrators can configure global integrations."), "error")
return redirect(url_for("integrations.manage_integration", provider=provider))
# Check if this is an OAuth credential update (admin section)
if request.form.get("action") == "update_credentials":
# Update OAuth credentials in Settings
if provider == "trello":
# Trello uses API key + secret, not OAuth
api_key = request.form.get("trello_api_key", "").strip()
api_secret = request.form.get("trello_api_secret", "").strip()
# Validate required fields
if not api_key:
flash(_("Trello API Key is required."), "error")
return redirect(url_for("integrations.manage_integration", provider=provider))
# Check if we have existing credentials - if not, secret is required
existing_creds = settings.get_integration_credentials("trello")
if not existing_creds.get("api_secret") and not api_secret:
flash(_("Trello API Secret is required for new setup."), "error")
return redirect(url_for("integrations.manage_integration", provider=provider))
if api_key:
settings.trello_api_key = api_key
if api_secret:
settings.trello_api_secret = api_secret
# Also save token if provided (for backward compatibility)
token = request.form.get("trello_token", "").strip()
if token and integration:
service.save_credentials(
integration_id=integration.id,
access_token=token,
refresh_token=None,
expires_at=None,
token_type="Bearer",
scope="read,write",
extra_data={"api_key": api_key},
)
else:
# OAuth-based integrations
client_id = request.form.get(f"{provider}_client_id", "").strip()
client_secret = request.form.get(f"{provider}_client_secret", "").strip()
# Validate required fields
if not client_id:
flash(_("OAuth Client ID is required."), "error")
return redirect(url_for("integrations.manage_integration", provider=provider))
# Check if we have existing credentials - if not, secret is required
existing_creds = settings.get_integration_credentials(provider)
if not existing_creds.get("client_secret") and not client_secret:
flash(_("OAuth Client Secret is required for new setup."), "error")
return redirect(url_for("integrations.manage_integration", provider=provider))
# Map provider names to Settings attributes - support all known providers
attr_map = {
"jira": ("jira_client_id", "jira_client_secret"),
"slack": ("slack_client_id", "slack_client_secret"),
"github": ("github_client_id", "github_client_secret"),
"google_calendar": ("google_calendar_client_id", "google_calendar_client_secret"),
"outlook_calendar": ("outlook_calendar_client_id", "outlook_calendar_client_secret"),
"microsoft_teams": ("microsoft_teams_client_id", "microsoft_teams_client_secret"),
"asana": ("asana_client_id", "asana_client_secret"),
"gitlab": ("gitlab_client_id", "gitlab_client_secret"),
"quickbooks": ("quickbooks_client_id", "quickbooks_client_secret"),
"xero": ("xero_client_id", "xero_client_secret"),
}
if provider in attr_map:
id_attr, secret_attr = attr_map[provider]
if client_id:
try:
setattr(settings, id_attr, client_id)
except AttributeError:
logger.warning(f"Settings attribute {id_attr} does not exist, skipping")
if client_secret:
try:
setattr(settings, secret_attr, client_secret)
except AttributeError:
logger.warning(f"Settings attribute {secret_attr} does not exist, skipping")
else:
logger.warning(f"Provider {provider} not in attr_map, cannot save OAuth credentials")
# Handle special fields (save even if empty to allow clearing)
if provider == "outlook_calendar":
tenant_id = request.form.get("outlook_calendar_tenant_id", "").strip()
try:
# Allow empty value (will use "common" as default)
settings.outlook_calendar_tenant_id = tenant_id if tenant_id else ""
except AttributeError:
logger.warning("Settings attribute outlook_calendar_tenant_id does not exist, skipping")
elif provider == "microsoft_teams":
tenant_id = request.form.get("microsoft_teams_tenant_id", "").strip()
try:
# Allow empty value (will use "common" as default)
settings.microsoft_teams_tenant_id = tenant_id if tenant_id else ""
except AttributeError:
logger.warning("Settings attribute microsoft_teams_tenant_id does not exist, skipping")
elif provider == "gitlab":
instance_url = request.form.get("gitlab_instance_url", "").strip()
if instance_url:
# Validate URL format
try:
from urllib.parse import urlparse
parsed = urlparse(instance_url)
if not parsed.scheme or not parsed.netloc:
flash(_("GitLab Instance URL must be a valid URL (e.g., https://gitlab.com)."), "error")
return redirect(url_for("integrations.manage_integration", provider=provider))
except Exception:
flash(_("GitLab Instance URL format is invalid."), "error")
return redirect(url_for("integrations.manage_integration", provider=provider))
try:
settings.gitlab_instance_url = instance_url
except AttributeError:
logger.warning("Settings attribute gitlab_instance_url does not exist, skipping")
else:
# Set default if empty
try:
if not settings.gitlab_instance_url:
settings.gitlab_instance_url = "https://gitlab.com"
except AttributeError:
pass
if safe_commit("update_integration_credentials", {"provider": provider}):
flash(_("Integration credentials updated successfully."), "success")
if provider == "google_calendar":
flash(
_("Users can now connect their Google Calendar. They will be automatically redirected to Google for authorization."),
"info",
)
return redirect(url_for("integrations.manage_integration", provider=provider))
else:
flash(_("Failed to update credentials."), "error")
# Check if this is an integration config update
elif request.form.get("action") == "update_config":
# Get the integration to update
integration_to_update = integration if integration else user_integration
if not integration_to_update:
flash(_("Integration not found. Please connect the integration first."), "error")
return redirect(url_for("integrations.manage_integration", provider=provider))
# Get config schema from connector
config_schema = {}
if connector_class and hasattr(connector_class, "get_config_schema"):
try:
# Need a temporary instance to call get_config_schema
temp_connector = connector_class(integration_to_update, None)
config_schema = temp_connector.get_config_schema()
except Exception as e:
logger.warning(f"Could not get config schema for {provider}: {e}")
# Update config from form
if not integration_to_update.config:
integration_to_update.config = {}
# Process config fields from schema
if config_schema and "fields" in config_schema:
for field in config_schema["fields"]:
field_name = field.get("name")
if not field_name:
continue
field_type = field.get("type", "string")
if field_type == "boolean":
# Checkboxes: present = True, absent = False
value = field_name in request.form
elif field_type == "array":
# Array fields - get all selected values
values = request.form.getlist(field_name)
value = values if values else field.get("default", [])
elif field_type == "select":
# Select fields - single value
value = request.form.get(field_name, "").strip()
if not value:
value = field.get("default")
elif field_type == "number":
# Number fields - convert to int/float
value_str = request.form.get(field_name, "").strip()
if value_str:
try:
# Try int first, then float
if "." in value_str:
value = float(value_str)
else:
value = int(value_str)
except ValueError:
flash(_("Invalid number for field %(field)s", field=field.get("label", field_name)), "error")
continue
else:
value = field.get("default")
elif field_type == "json":
# JSON fields - parse if provided
value_str = request.form.get(field_name, "").strip()
if value_str:
try:
import json
value = json.loads(value_str)
except json.JSONDecodeError:
flash(_("Invalid JSON for field %(field)s", field=field.get("label", field_name)), "error")
continue
else:
value = None
else:
# String/url/text fields
value = request.form.get(field_name, "").strip()
if not value and field.get("required", False):
flash(_("Field %(field)s is required", field=field.get("label", field_name)), "error")
continue
if not value:
value = field.get("default")
# Only update if value is provided or it's a boolean/array (always set)
if value is not None and value != "":
integration_to_update.config[field_name] = value
elif field_type in ("boolean", "array"):
# Always set boolean and array fields
integration_to_update.config[field_name] = value
# Ensure config is marked as modified
from sqlalchemy.orm.attributes import flag_modified
flag_modified(integration_to_update, "config")
if safe_commit("update_integration_config", {"integration_id": integration_to_update.id}):
flash(_("Integration configuration updated successfully."), "success")
return redirect(url_for("integrations.manage_integration", provider=provider))
else:
flash(_("Failed to update configuration."), "error")
# Get current credentials for display (always get from Settings model, not .env)
# This ensures we're showing what's in the database - Settings model prioritizes DB over .env
current_creds = {}
if current_user.is_admin:
current_creds = settings.get_integration_credentials(provider)
# For Trello, ensure api_secret is included (it should already be in the dict)
if provider == "trello" and "api_secret" not in current_creds:
if hasattr(settings, "trello_api_secret"):
current_creds["api_secret"] = settings.trello_api_secret or ""
# Get user's existing integration for this provider (if per-user)
user_integration = None
if not is_global:
user_integration = Integration.query.filter_by(provider=provider, user_id=current_user.id, is_global=False).first()
# Get connector if integration exists
connector = None
connector_error = None
if integration or user_integration:
integration_to_check = integration if integration else user_integration
try:
connector = service.get_connector(integration_to_check)
except Exception as e:
logger.error(f"Error initializing connector for integration: {e}", exc_info=True)
connector_error = str(e)
credentials = None
if integration:
credentials = IntegrationCredential.query.filter_by(integration_id=integration.id).first()
elif user_integration:
credentials = IntegrationCredential.query.filter_by(integration_id=user_integration.id).first()
# Get display info from connector class or use defaults
display_name = getattr(connector_class, "display_name", None) or provider.replace("_", " ").title()
description = getattr(connector_class, "description", None) or ""
# Get config schema from connector
config_schema = {}
current_config = {}
active_integration = integration if integration else user_integration
if active_integration:
current_config = active_integration.config or {}
if connector:
try:
config_schema = connector.get_config_schema()
except Exception as e:
logger.warning(f"Could not get config schema for {provider}: {e}")
elif connector_class and hasattr(connector_class, "get_config_schema"):
try:
temp_connector = connector_class(active_integration, None)
config_schema = temp_connector.get_config_schema()
except Exception as e:
logger.warning(f"Could not get config schema for {provider}: {e}")
return render_template(
"integrations/manage.html",
provider=provider,
connector_class=connector_class,
connector=connector,
connector_error=connector_error,
integration=integration,
user_integration=user_integration,
active_integration=active_integration,
credentials=credentials,
current_creds=current_creds,
display_name=display_name,
description=description,
is_global=is_global,
config_schema=config_schema,
current_config=current_config,
)
@integrations_bp.route("/integrations/<int:integration_id>")
@login_required
def view_integration(integration_id):
@@ -304,12 +658,40 @@ def sync_integration(integration_id):
try:
sync_result = connector.sync_data()
# Update integration status
from datetime import datetime
integration.last_sync_at = datetime.utcnow()
if sync_result.get("success"):
flash(_("Sync completed successfully."), "success")
integration.last_sync_status = "success"
integration.last_error = None
message = sync_result.get("message", "Sync completed successfully.")
if sync_result.get("synced_count"):
message += f" Synced {sync_result['synced_count']} items."
flash(_("Sync completed successfully. %(details)s", details=message), "success")
else:
integration.last_sync_status = "error"
integration.last_error = sync_result.get("message", "Unknown error")
flash(_("Sync failed: %(message)s", message=sync_result.get("message", "Unknown error")), "error")
# Log sync event
service._log_event(
integration_id,
"sync",
sync_result.get("success", False),
sync_result.get("message"),
{"synced_count": sync_result.get("synced_count")} if sync_result.get("success") and sync_result.get("synced_count") else None
)
if not safe_commit("update_integration_sync_status", {"integration_id": integration_id}):
logger.warning(f"Could not update sync status for integration {integration_id}")
except Exception as e:
logger.error(f"Error syncing integration {integration_id}: {e}")
logger.error(f"Error syncing integration {integration_id}: {e}", exc_info=True)
integration.last_sync_status = "error"
integration.last_error = str(e)
from datetime import datetime
integration.last_sync_at = datetime.utcnow()
safe_commit("update_integration_sync_status_error", {"integration_id": integration_id})
flash(_("Error during sync: %(error)s", error=str(e)), "error")
return redirect(url_for("integrations.view_integration", integration_id=integration_id))

View File

@@ -238,9 +238,13 @@ def new_stock_item():
except Exception as e:
db.session.rollback()
flash(_("Error creating stock item: %(error)s", error=str(e)), "error")
return render_template("inventory/stock_items/form.html", item=None)
suppliers = Supplier.query.filter_by(is_active=True).order_by(Supplier.name).all()
suppliers_dict = [supplier.to_dict() for supplier in suppliers]
return render_template("inventory/stock_items/form.html", item=None, suppliers=suppliers_dict)
return render_template("inventory/stock_items/form.html", item=None)
suppliers = Supplier.query.filter_by(is_active=True).order_by(Supplier.name).all()
suppliers_dict = [supplier.to_dict() for supplier in suppliers]
return render_template("inventory/stock_items/form.html", item=None, suppliers=suppliers_dict)
@inventory_bp.route("/inventory/items/<int:item_id>")
@@ -288,7 +292,8 @@ def edit_stock_item(item_id):
if existing:
flash(_("SKU already exists. Please use a different SKU."), "error")
suppliers = Supplier.query.filter_by(is_active=True).order_by(Supplier.name).all()
return render_template("inventory/stock_items/form.html", item=item, suppliers=suppliers)
suppliers_dict = [supplier.to_dict() for supplier in suppliers]
return render_template("inventory/stock_items/form.html", item=item, suppliers=suppliers_dict)
item.sku = new_sku
item.name = request.form.get("name", "").strip()
@@ -410,7 +415,8 @@ def edit_stock_item(item_id):
flash(_("Error updating stock item: %(error)s", error=str(e)), "error")
suppliers = Supplier.query.filter_by(is_active=True).order_by(Supplier.name).all()
return render_template("inventory/stock_items/form.html", item=item, suppliers=suppliers)
suppliers_dict = [supplier.to_dict() for supplier in suppliers]
return render_template("inventory/stock_items/form.html", item=item, suppliers=suppliers_dict)
@inventory_bp.route("/inventory/items/<int:item_id>/delete", methods=["POST"])

View File

@@ -123,40 +123,6 @@ def settings():
flash(_("Standard hours per day must be between 0.5 and 24"), "error")
return redirect(url_for("user.settings"))
# UI feature flags - Calendar
current_user.ui_show_calendar = "ui_show_calendar" in request.form
# UI feature flags - Time Tracking
current_user.ui_show_project_templates = "ui_show_project_templates" in request.form
current_user.ui_show_gantt_chart = "ui_show_gantt_chart" in request.form
current_user.ui_show_kanban_board = "ui_show_kanban_board" in request.form
current_user.ui_show_weekly_goals = "ui_show_weekly_goals" in request.form
current_user.ui_show_issues = "ui_show_issues" in request.form
# UI feature flags - CRM
current_user.ui_show_quotes = "ui_show_quotes" in request.form
# UI feature flags - Finance & Expenses
current_user.ui_show_reports = "ui_show_reports" in request.form
current_user.ui_show_report_builder = "ui_show_report_builder" in request.form
current_user.ui_show_scheduled_reports = "ui_show_scheduled_reports" in request.form
current_user.ui_show_invoice_approvals = "ui_show_invoice_approvals" in request.form
current_user.ui_show_payment_gateways = "ui_show_payment_gateways" in request.form
current_user.ui_show_recurring_invoices = "ui_show_recurring_invoices" in request.form
current_user.ui_show_payments = "ui_show_payments" in request.form
current_user.ui_show_mileage = "ui_show_mileage" in request.form
current_user.ui_show_per_diem = "ui_show_per_diem" in request.form
current_user.ui_show_budget_alerts = "ui_show_budget_alerts" in request.form
# UI feature flags - Inventory
current_user.ui_show_inventory = "ui_show_inventory" in request.form
# UI feature flags - Analytics
current_user.ui_show_analytics = "ui_show_analytics" in request.form
# UI feature flags - Tools
current_user.ui_show_tools = "ui_show_tools" in request.form
# Save changes
if safe_commit(db.session):
# Log activity

View File

@@ -262,6 +262,17 @@ class IntegrationService:
)
db.session.add(event)
safe_commit("log_integration_event", {"integration_id": integration_id})
def update_integration_active_status(self, integration_id: int):
"""Update integration is_active status based on credentials."""
integration = Integration.query.get(integration_id)
if not integration:
return
has_credentials = IntegrationCredential.query.filter_by(integration_id=integration_id).first() is not None
if integration.is_active != has_credentials:
integration.is_active = has_credentials
safe_commit("update_integration_active_status", {"integration_id": integration_id})
@classmethod
def get_available_providers(cls) -> List[Dict[str, Any]]:

View File

@@ -43,7 +43,7 @@
<div>Webhooks</div>
<div class="text-xs mt-1 opacity-90">Outgoing Events</div>
</a>
<a href="{{ url_for('admin.list_integrations_admin') }}" class="bg-purple-600 text-white p-4 rounded-lg text-center hover:bg-purple-700">
<a href="{{ url_for('integrations.list_integrations') }}" class="bg-purple-600 text-white p-4 rounded-lg text-center hover:bg-purple-700">
<i class="fas fa-plug mb-2"></i>
<div>Integrations</div>
<div class="text-xs mt-1 opacity-90">OAuth Setup</div>

View File

@@ -26,32 +26,40 @@
<div class="space-y-4">
<div>
<label for="trello_api_key" class="block text-sm font-medium mb-2">
{{ _('Trello API Key') }}
{{ _('Trello API Key') }} <span class="text-red-500">*</span>
</label>
<input type="text"
name="trello_api_key"
id="trello_api_key"
value="{{ current_creds.get('api_key', '') }}"
class="w-full px-3 py-2 border border-border-light dark:border-border-dark rounded-lg bg-card-light dark:bg-card-dark"
placeholder="{{ _('Get from https://trello.com/app-key') }}">
placeholder="{{ _('Get from https://trello.com/app-key') }}"
required>
<p class="mt-1 text-xs text-text-muted-light dark:text-text-muted-dark">
{{ _('Get your API key from') }} <a href="https://trello.com/app-key" target="_blank" class="text-primary hover:underline">trello.com/app-key</a>
</p>
</div>
<div>
<label for="trello_token" class="block text-sm font-medium mb-2">
{{ _('Trello Token') }}
<label for="trello_api_secret" class="block text-sm font-medium mb-2">
{{ _('Trello API Secret') }} <span class="text-red-500">*</span>
</label>
<input type="password"
name="trello_token"
id="trello_token"
name="trello_api_secret"
id="trello_api_secret"
value="{{ current_creds.get('api_secret', '') }}"
class="w-full px-3 py-2 border border-border-light dark:border-border-dark rounded-lg bg-card-light dark:bg-card-dark"
placeholder="{{ _('Leave empty to keep current value') }}">
<p class="mt-1 text-xs text-text-muted-light dark:text-text-muted-dark">
{{ _('Generate a token with your API key. Visit') }}
<a href="https://trello.com/1/authorize?expiration=never&scope=read,write&response_type=token&name=TimeTracker&key=YOUR_API_KEY" target="_blank" class="text-primary hover:underline">trello.com/1/authorize</a>
{{ _('(replace YOUR_API_KEY with your actual API key)') }}
{{ _('Get your API secret from') }} <a href="https://trello.com/app-key" target="_blank" class="text-primary hover:underline">trello.com/app-key</a>
{{ _('(shown after generating API key)') }}
</p>
</div>
<div class="p-3 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg">
<p class="text-sm text-blue-800 dark:text-blue-200">
<i class="fas fa-info-circle mr-2"></i>
{{ _('After saving API Key and Secret, you can connect Trello using OAuth flow. The token will be generated automatically during connection.') }}
</p>
</div>
</div>
@@ -61,25 +69,29 @@
<div class="space-y-4">
<div>
<label for="{{ provider }}_client_id" class="block text-sm font-medium mb-2">
{{ _('OAuth Client ID') }}
{{ _('OAuth Client ID') }} <span class="text-red-500">*</span>
</label>
<input type="text"
name="{{ provider }}_client_id"
id="{{ provider }}_client_id"
value="{{ current_creds.get('client_id', '') }}"
class="w-full px-3 py-2 border border-border-light dark:border-border-dark rounded-lg bg-card-light dark:bg-card-dark"
placeholder="{{ _('OAuth Client ID') }}">
placeholder="{{ _('OAuth Client ID') }}"
required>
</div>
<div>
<label for="{{ provider }}_client_secret" class="block text-sm font-medium mb-2">
{{ _('OAuth Client Secret') }}
{{ _('OAuth Client Secret') }} <span class="text-red-500">*</span>
</label>
<input type="password"
name="{{ provider }}_client_secret"
id="{{ provider }}_client_secret"
class="w-full px-3 py-2 border border-border-light dark:border-border-dark rounded-lg bg-card-light dark:bg-card-dark"
placeholder="{{ _('Leave empty to keep current value') }}">
<p class="mt-1 text-xs text-text-muted-light dark:text-text-muted-dark">
{{ _('Required for new setup. Leave empty to keep existing secret.') }}
</p>
</div>
{% if provider in ['outlook_calendar', 'microsoft_teams'] %}
@@ -92,21 +104,28 @@
id="{{ provider }}_tenant_id"
value="{{ current_creds.get('tenant_id', '') }}"
class="w-full px-3 py-2 border border-border-light dark:border-border-dark rounded-lg bg-card-light dark:bg-card-dark"
placeholder="{{ _('Use "common" for multi-tenant') }}">
placeholder="{{ _('Leave empty for "common" (multi-tenant)') }}">
<p class="mt-1 text-xs text-text-muted-light dark:text-text-muted-dark">
{{ _('Leave empty to use "common" (multi-tenant). Enter your Azure AD tenant ID for single-tenant apps.') }}
</p>
</div>
{% endif %}
{% if provider == 'gitlab' %}
<div>
<label for="gitlab_instance_url" class="block text-sm font-medium mb-2">
{{ _('GitLab Instance URL') }} <span class="text-text-muted-light dark:text-text-muted-dark">({{ _('optional') }})</span>
{{ _('GitLab Instance URL') }}
</label>
<input type="url"
name="gitlab_instance_url"
id="gitlab_instance_url"
value="{{ current_creds.get('instance_url', 'https://gitlab.com') }}"
class="w-full px-3 py-2 border border-border-light dark:border-border-dark rounded-lg bg-card-light dark:bg-card-dark"
placeholder="https://gitlab.com">
placeholder="https://gitlab.com"
required>
<p class="mt-1 text-xs text-text-muted-light dark:text-text-muted-dark">
{{ _('URL of your GitLab instance. Use "https://gitlab.com" for GitLab.com or your self-hosted GitLab URL.') }}
</p>
</div>
{% endif %}
</div>

View File

@@ -5,104 +5,82 @@
{% block content %}
<div class="max-w-7xl mx-auto">
<div class="mb-6">
<h1 class="text-3xl font-bold text-text-light dark:text-text-dark mb-2">{{ _('Module Management') }}</h1>
<p class="text-text-muted-light dark:text-text-muted-dark">{{ _('Enable or disable modules and features system-wide. Disabled modules will be hidden from all users.') }}</p>
<h1 class="text-3xl font-bold text-text-light dark:text-text-dark mb-2">{{ _('Available Modules') }}</h1>
<p class="text-text-muted-light dark:text-text-muted-dark">{{ _('All modules are enabled by default. Users can customize which modules appear in their navigation in their profile settings.') }}</p>
</div>
<form method="POST" action="{{ url_for('admin.manage_modules') }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="space-y-6">
{% for category, modules in modules_by_category.items() %}
<div class="bg-card-light dark:bg-card-dark rounded-lg shadow-sm border border-border-light dark:border-border-dark p-6">
<h2 class="text-xl font-semibold text-text-light dark:text-text-dark mb-4">
{% if category == ModuleCategory.TIME_TRACKING %}
<i class="fas fa-clock mr-2"></i>{{ _('Time Tracking') }}
{% elif category == ModuleCategory.PROJECT_MANAGEMENT %}
<i class="fas fa-project-diagram mr-2"></i>{{ _('Project Management') }}
{% elif category == ModuleCategory.CRM %}
<i class="fas fa-handshake mr-2"></i>{{ _('CRM') }}
{% elif category == ModuleCategory.FINANCE %}
<i class="fas fa-dollar-sign mr-2"></i>{{ _('Finance & Expenses') }}
{% elif category == ModuleCategory.INVENTORY %}
<i class="fas fa-boxes mr-2"></i>{{ _('Inventory') }}
{% elif category == ModuleCategory.ANALYTICS %}
<i class="fas fa-chart-line mr-2"></i>{{ _('Analytics') }}
{% elif category == ModuleCategory.TOOLS %}
<i class="fas fa-tools mr-2"></i>{{ _('Tools & Data') }}
{% elif category == ModuleCategory.ADVANCED %}
<i class="fas fa-rocket mr-2"></i>{{ _('Advanced Features') }}
{% else %}
{{ category.value|title }}
{% endif %}
</h2>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{% for module in modules %}
<div class="flex items-start space-x-3 p-3 rounded-lg {% if module.category == ModuleCategory.CORE %}bg-background-light dark:bg-background-dark{% else %}hover:bg-background-light dark:hover:bg-background-dark{% endif %}">
<div class="flex-1">
<div class="flex items-center space-x-2">
{% if module.icon %}
<i class="fas {{ module.icon }} text-primary"></i>
<div class="space-y-6">
{% for category, modules in modules_by_category.items() %}
<div class="bg-card-light dark:bg-card-dark rounded-lg shadow-sm border border-border-light dark:border-border-dark p-6">
<h2 class="text-xl font-semibold text-text-light dark:text-text-dark mb-4">
{% if category == ModuleCategory.TIME_TRACKING %}
<i class="fas fa-clock mr-2"></i>{{ _('Time Tracking') }}
{% elif category == ModuleCategory.PROJECT_MANAGEMENT %}
<i class="fas fa-project-diagram mr-2"></i>{{ _('Project Management') }}
{% elif category == ModuleCategory.CRM %}
<i class="fas fa-handshake mr-2"></i>{{ _('CRM') }}
{% elif category == ModuleCategory.FINANCE %}
<i class="fas fa-dollar-sign mr-2"></i>{{ _('Finance & Expenses') }}
{% elif category == ModuleCategory.INVENTORY %}
<i class="fas fa-boxes mr-2"></i>{{ _('Inventory') }}
{% elif category == ModuleCategory.ANALYTICS %}
<i class="fas fa-chart-line mr-2"></i>{{ _('Analytics') }}
{% elif category == ModuleCategory.TOOLS %}
<i class="fas fa-tools mr-2"></i>{{ _('Tools & Data') }}
{% elif category == ModuleCategory.ADVANCED %}
<i class="fas fa-rocket mr-2"></i>{{ _('Advanced Features') }}
{% else %}
{{ category.value|title }}
{% endif %}
</h2>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{% for module in modules %}
<div class="flex items-start space-x-3 p-3 rounded-lg {% if module.category == ModuleCategory.CORE %}bg-background-light dark:bg-background-dark{% else %}hover:bg-background-light dark:hover:bg-background-dark{% endif %}">
<div class="flex-1">
<div class="flex items-center space-x-2">
{% if module.icon %}
<i class="fas {{ module.icon }} text-primary"></i>
{% endif %}
<div class="font-medium text-text-light dark:text-text-dark">
{% if module.category == ModuleCategory.CORE %}
<span class="text-sm text-text-muted-light dark:text-text-muted-dark">({{ _('Core') }}) </span>
{% endif %}
<label class="font-medium text-text-light dark:text-text-dark cursor-pointer flex items-center">
{% if module.category == ModuleCategory.CORE %}
<input type="checkbox"
name="{{ module.settings_flag }}"
{% if module.settings_flag and getattr(settings, module.settings_flag, True) %}checked{% endif %}
disabled
class="mr-2 rounded border-border-light dark:border-border-dark text-primary focus:ring-primary">
<span class="text-sm text-text-muted-light dark:text-text-muted-dark">({{ _('Core') }})</span>
{% else %}
<input type="checkbox"
name="{{ module.settings_flag }}"
{% if module.settings_flag and getattr(settings, module.settings_flag, True) %}checked{% endif %}
class="mr-2 rounded border-border-light dark:border-border-dark text-primary focus:ring-primary">
{% endif %}
<span>{{ module.name }}</span>
</label>
<span>{{ module.name }}</span>
</div>
{% if module.description %}
<p class="text-sm text-text-muted-light dark:text-text-muted-dark mt-1 ml-6">{{ module.description }}</p>
{% endif %}
{% if module.dependencies %}
<div class="mt-2 ml-6">
<p class="text-xs text-text-muted-light dark:text-text-muted-dark">
<i class="fas fa-link mr-1"></i>{{ _('Depends on') }}:
{% for dep_id in module.dependencies %}
{% set dep_module = None %}
{% for cat, mods in modules_by_category.items() %}
{% for mod in mods %}
{% if mod.id == dep_id %}
{% set dep_module = mod %}
{% endif %}
{% endfor %}
{% endfor %}
{% if dep_module %}
{{ dep_module.name }}{% if not loop.last %}, {% endif %}
{% else %}
{{ dep_id }}{% if not loop.last %}, {% endif %}
{% endif %}
{% endfor %}
</p>
</div>
{% endif %}
</div>
{% if module.description %}
<p class="text-sm text-text-muted-light dark:text-text-muted-dark mt-1 ml-6">{{ module.description }}</p>
{% endif %}
{% if module.dependencies %}
<div class="mt-2 ml-6">
<p class="text-xs text-text-muted-light dark:text-text-muted-dark">
<i class="fas fa-link mr-1"></i>{{ _('Depends on') }}:
{% for dep_id in module.dependencies %}
{% set dep_module = None %}
{% for cat, mods in modules_by_category.items() %}
{% for mod in mods %}
{% if mod.id == dep_id %}
{% set dep_module = mod %}
{% endif %}
{% endfor %}
{% endfor %}
{% if dep_module %}
{{ dep_module.name }}{% if not loop.last %}, {% endif %}
{% else %}
{{ dep_id }}{% if not loop.last %}, {% endif %}
{% endif %}
{% endfor %}
</p>
</div>
{% endif %}
</div>
{% endfor %}
</div>
{% endfor %}
</div>
{% endfor %}
</div>
<div class="mt-6 flex justify-end space-x-4">
<a href="{{ url_for('admin.settings') }}" class="px-4 py-2 rounded-lg border border-border-light dark:border-border-dark text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark">
{{ _('Cancel') }}
</a>
<button type="submit" class="px-4 py-2 rounded-lg bg-primary text-white hover:bg-primary-dark">
<i class="fas fa-save mr-2"></i>{{ _('Save Changes') }}
</button>
</div>
</form>
{% endfor %}
</div>
<div class="mt-6 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4">
<div class="flex items-start">
@@ -110,10 +88,9 @@
<div class="text-sm text-blue-800 dark:text-blue-200">
<p class="font-semibold mb-1">{{ _('Note') }}:</p>
<ul class="list-disc list-inside space-y-1">
<li>{{ _('All modules are enabled by default.') }}</li>
<li>{{ _('Core modules cannot be disabled as they are required for the application to function.') }}</li>
<li>{{ _('Disabling a module will hide it from all users, but existing data will be preserved.') }}</li>
<li>{{ _('If a module has dependencies, those must be enabled for the module to work properly.') }}</li>
<li>{{ _('Individual users can further customize their view in their profile settings.') }}</li>
<li>{{ _('Individual users can customize which modules appear in their navigation in their profile settings.') }}</li>
</ul>
</div>
</div>

View File

@@ -76,172 +76,6 @@
</div>
</div>
<!-- UI Features (System-wide) -->
<div class="border-b border-border-light dark:border-border-dark pb-6">
<h2 class="text-lg font-semibold mb-4">{{ _('UI Features (System-wide)') }}</h2>
<p class="text-sm text-text-muted-light dark:text-text-muted-dark mb-4">
{{ _('These switches control which navigation items are available to users. Users can only toggle features that are enabled here.') }}
</p>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<!-- Calendar -->
<div class="space-y-2">
<h3 class="text-sm font-semibold text-gray-900 dark:text-gray-100">{{ _('Calendar') }}</h3>
<div class="flex items-center">
<input type="checkbox" name="ui_allow_calendar" id="ui_allow_calendar" {% if settings.ui_allow_calendar %}checked{% endif %} class="h-4 w-4 rounded border-gray-300 text-indigo-600 shadow-sm focus:border-indigo-500 focus:ring-indigo-500">
<label for="ui_allow_calendar" class="ml-2 block text-sm text-gray-900 dark:text-gray-300">
{{ _('Allow Calendar section') }}
</label>
</div>
</div>
<!-- Time Tracking -->
<div class="space-y-2">
<h3 class="text-sm font-semibold text-gray-900 dark:text-gray-100">{{ _('Time Tracking') }}</h3>
<div class="space-y-2">
<div class="flex items-center">
<input type="checkbox" name="ui_allow_project_templates" id="ui_allow_project_templates" {% if settings.ui_allow_project_templates %}checked{% endif %} class="h-4 w-4 rounded border-gray-300 text-indigo-600 shadow-sm focus:border-indigo-500 focus:ring-indigo-500">
<label for="ui_allow_project_templates" class="ml-2 block text-sm text-gray-900 dark:text-gray-300">
{{ _('Allow Project Templates') }}
</label>
</div>
<div class="flex items-center">
<input type="checkbox" name="ui_allow_gantt_chart" id="ui_allow_gantt_chart" {% if settings.ui_allow_gantt_chart %}checked{% endif %} class="h-4 w-4 rounded border-gray-300 text-indigo-600 shadow-sm focus:border-indigo-500 focus:ring-indigo-500">
<label for="ui_allow_gantt_chart" class="ml-2 block text-sm text-gray-900 dark:text-gray-300">
{{ _('Allow Gantt Chart') }}
</label>
</div>
<div class="flex items-center">
<input type="checkbox" name="ui_allow_kanban_board" id="ui_allow_kanban_board" {% if settings.ui_allow_kanban_board %}checked{% endif %} class="h-4 w-4 rounded border-gray-300 text-indigo-600 shadow-sm focus:border-indigo-500 focus:ring-indigo-500">
<label for="ui_allow_kanban_board" class="ml-2 block text-sm text-gray-900 dark:text-gray-300">
{{ _('Allow Kanban Board') }}
</label>
</div>
<div class="flex items-center">
<input type="checkbox" name="ui_allow_weekly_goals" id="ui_allow_weekly_goals" {% if settings.ui_allow_weekly_goals %}checked{% endif %} class="h-4 w-4 rounded border-gray-300 text-indigo-600 shadow-sm focus:border-indigo-500 focus:ring-indigo-500">
<label for="ui_allow_weekly_goals" class="ml-2 block text-sm text-gray-900 dark:text-gray-300">
{{ _('Allow Weekly Goals') }}
</label>
</div>
<div class="flex items-center">
<input type="checkbox" name="ui_allow_issues" id="ui_allow_issues" {% if settings.ui_allow_issues %}checked{% endif %} class="h-4 w-4 rounded border-gray-300 text-indigo-600 shadow-sm focus:border-indigo-500 focus:ring-indigo-500">
<label for="ui_allow_issues" class="ml-2 block text-sm text-gray-900 dark:text-gray-300">
{{ _('Allow Issues') }}
</label>
</div>
</div>
</div>
<!-- CRM -->
<div class="space-y-2">
<h3 class="text-sm font-semibold text-gray-900 dark:text-gray-100">{{ _('CRM') }}</h3>
<div class="flex items-center">
<input type="checkbox" name="ui_allow_quotes" id="ui_allow_quotes" {% if settings.ui_allow_quotes %}checked{% endif %} class="h-4 w-4 rounded border-gray-300 text-indigo-600 shadow-sm focus:border-indigo-500 focus:ring-indigo-500">
<label for="ui_allow_quotes" class="ml-2 block text-sm text-gray-900 dark:text-gray-300">
{{ _('Allow Quotes') }}
</label>
</div>
</div>
<!-- Finance & Expenses -->
<div class="space-y-2">
<h3 class="text-sm font-semibold text-gray-900 dark:text-gray-100">{{ _('Finance & Expenses') }}</h3>
<div class="space-y-2">
<div class="flex items-center">
<input type="checkbox" name="ui_allow_reports" id="ui_allow_reports" {% if settings.ui_allow_reports %}checked{% endif %} class="h-4 w-4 rounded border-gray-300 text-indigo-600 shadow-sm focus:border-indigo-500 focus:ring-indigo-500">
<label for="ui_allow_reports" class="ml-2 block text-sm text-gray-900 dark:text-gray-300">
{{ _('Allow Reports') }}
</label>
</div>
<div class="flex items-center">
<input type="checkbox" name="ui_allow_report_builder" id="ui_allow_report_builder" {% if settings.ui_allow_report_builder %}checked{% endif %} class="h-4 w-4 rounded border-gray-300 text-indigo-600 shadow-sm focus:border-indigo-500 focus:ring-indigo-500">
<label for="ui_allow_report_builder" class="ml-2 block text-sm text-gray-900 dark:text-gray-300">
{{ _('Allow Report Builder') }}
</label>
</div>
<div class="flex items-center">
<input type="checkbox" name="ui_allow_scheduled_reports" id="ui_allow_scheduled_reports" {% if settings.ui_allow_scheduled_reports %}checked{% endif %} class="h-4 w-4 rounded border-gray-300 text-indigo-600 shadow-sm focus:border-indigo-500 focus:ring-indigo-500">
<label for="ui_allow_scheduled_reports" class="ml-2 block text-sm text-gray-900 dark:text-gray-300">
{{ _('Allow Scheduled Reports') }}
</label>
</div>
<div class="flex items-center">
<input type="checkbox" name="ui_allow_invoice_approvals" id="ui_allow_invoice_approvals" {% if settings.ui_allow_invoice_approvals %}checked{% endif %} class="h-4 w-4 rounded border-gray-300 text-indigo-600 shadow-sm focus:border-indigo-500 focus:ring-indigo-500">
<label for="ui_allow_invoice_approvals" class="ml-2 block text-sm text-gray-900 dark:text-gray-300">
{{ _('Allow Invoice Approvals') }}
</label>
</div>
<div class="flex items-center">
<input type="checkbox" name="ui_allow_payment_gateways" id="ui_allow_payment_gateways" {% if settings.ui_allow_payment_gateways %}checked{% endif %} class="h-4 w-4 rounded border-gray-300 text-indigo-600 shadow-sm focus:border-indigo-500 focus:ring-indigo-500">
<label for="ui_allow_payment_gateways" class="ml-2 block text-sm text-gray-900 dark:text-gray-300">
{{ _('Allow Payment Gateways') }}
</label>
</div>
<div class="flex items-center">
<input type="checkbox" name="ui_allow_recurring_invoices" id="ui_allow_recurring_invoices" {% if settings.ui_allow_recurring_invoices %}checked{% endif %} class="h-4 w-4 rounded border-gray-300 text-indigo-600 shadow-sm focus:border-indigo-500 focus:ring-indigo-500">
<label for="ui_allow_recurring_invoices" class="ml-2 block text-sm text-gray-900 dark:text-gray-300">
{{ _('Allow Recurring Invoices') }}
</label>
</div>
<div class="flex items-center">
<input type="checkbox" name="ui_allow_payments" id="ui_allow_payments" {% if settings.ui_allow_payments %}checked{% endif %} class="h-4 w-4 rounded border-gray-300 text-indigo-600 shadow-sm focus:border-indigo-500 focus:ring-indigo-500">
<label for="ui_allow_payments" class="ml-2 block text-sm text-gray-900 dark:text-gray-300">
{{ _('Allow Payments') }}
</label>
</div>
<div class="flex items-center">
<input type="checkbox" name="ui_allow_mileage" id="ui_allow_mileage" {% if settings.ui_allow_mileage %}checked{% endif %} class="h-4 w-4 rounded border-gray-300 text-indigo-600 shadow-sm focus:border-indigo-500 focus:ring-indigo-500">
<label for="ui_allow_mileage" class="ml-2 block text-sm text-gray-900 dark:text-gray-300">
{{ _('Allow Mileage') }}
</label>
</div>
<div class="flex items-center">
<input type="checkbox" name="ui_allow_per_diem" id="ui_allow_per_diem" {% if settings.ui_allow_per_diem %}checked{% endif %} class="h-4 w-4 rounded border-gray-300 text-indigo-600 shadow-sm focus:border-indigo-500 focus:ring-indigo-500">
<label for="ui_allow_per_diem" class="ml-2 block text-sm text-gray-900 dark:text-gray-300">
{{ _('Allow Per Diem') }}
</label>
</div>
<div class="flex items-center">
<input type="checkbox" name="ui_allow_budget_alerts" id="ui_allow_budget_alerts" {% if settings.ui_allow_budget_alerts %}checked{% endif %} class="h-4 w-4 rounded border-gray-300 text-indigo-600 shadow-sm focus:border-indigo-500 focus:ring-indigo-500">
<label for="ui_allow_budget_alerts" class="ml-2 block text-sm text-gray-900 dark:text-gray-300">
{{ _('Allow Budget Alerts') }}
</label>
</div>
</div>
</div>
<!-- Inventory -->
<div class="space-y-2">
<h3 class="text-sm font-semibold text-gray-900 dark:text-gray-100">{{ _('Inventory') }}</h3>
<div class="flex items-center">
<input type="checkbox" name="ui_allow_inventory" id="ui_allow_inventory" {% if settings.ui_allow_inventory %}checked{% endif %} class="h-4 w-4 rounded border-gray-300 text-indigo-600 shadow-sm focus:border-indigo-500 focus:ring-indigo-500">
<label for="ui_allow_inventory" class="ml-2 block text-sm text-gray-900 dark:text-gray-300">
{{ _('Allow Inventory section') }}
</label>
</div>
</div>
<!-- Analytics & Tools -->
<div class="space-y-2">
<h3 class="text-sm font-semibold text-gray-900 dark:text-gray-100">{{ _('Analytics & Tools') }}</h3>
<div class="space-y-2">
<div class="flex items-center">
<input type="checkbox" name="ui_allow_analytics" id="ui_allow_analytics" {% if settings.ui_allow_analytics %}checked{% endif %} class="h-4 w-4 rounded border-gray-300 text-indigo-600 shadow-sm focus:border-indigo-500 focus:ring-indigo-500">
<label for="ui_allow_analytics" class="ml-2 block text-sm text-gray-900 dark:text-gray-300">
{{ _('Allow Analytics section') }}
</label>
</div>
<div class="flex items-center">
<input type="checkbox" name="ui_allow_tools" id="ui_allow_tools" {% if settings.ui_allow_tools %}checked{% endif %} class="h-4 w-4 rounded border-gray-300 text-indigo-600 shadow-sm focus:border-indigo-500 focus:ring-indigo-500">
<label for="ui_allow_tools" class="ml-2 block text-sm text-gray-900 dark:text-gray-300">
{{ _('Allow Tools & Data section') }}
</label>
</div>
</div>
</div>
</div>
</div>
<!-- Company Branding -->
<div class="border-b border-border-light dark:border-border-dark pb-6">
<h2 class="text-lg font-semibold mb-4">{{ _('Company Branding') }}</h2>
@@ -375,251 +209,6 @@
</div>
</div>
<!-- Integration OAuth Credentials -->
<div class="border-b border-border-light dark:border-border-dark pb-6">
<button type="button" onclick="toggleIntegrationCredentials()" class="w-full flex items-center justify-between text-left mb-4 hover:text-primary transition-colors">
<h2 class="text-lg font-semibold">{{ _('Integration OAuth Credentials') }}</h2>
<i class="fas fa-chevron-down" id="integrationCredentialsToggleIcon"></i>
</button>
<div id="integrationCredentialsContent" class="integration-credentials-collapsed">
<p class="text-sm text-text-muted-light dark:text-text-muted-dark mb-4">
{{ _('Configure OAuth client credentials for integrations. These settings take precedence over environment variables. Leave client secret empty to keep the current value.') }}
</p>
<!-- Jira -->
<div class="mb-6 p-4 bg-gray-50 dark:bg-gray-800 rounded-lg">
<h3 class="text-md font-semibold mb-3 flex items-center">
<i class="fab fa-jira text-blue-600 mr-2"></i>Jira
</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label for="jira_client_id" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Client ID</label>
<input type="text" name="jira_client_id" id="jira_client_id" value="{{ settings.jira_client_id or '' }}" class="form-input" placeholder="{{ _('Jira OAuth Client ID') }}">
</div>
<div>
<label for="jira_client_secret" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Client Secret</label>
<input type="password" name="jira_client_secret" id="jira_client_secret" class="form-input" placeholder="{{ _('Leave empty to keep current value') }}">
{% if settings.jira_client_secret_set %}
<p class="mt-1 text-xs text-green-600 dark:text-green-400">{{ _('✓ Client secret is configured') }}</p>
{% endif %}
</div>
</div>
</div>
<!-- Slack -->
<div class="mb-6 p-4 bg-gray-50 dark:bg-gray-800 rounded-lg">
<h3 class="text-md font-semibold mb-3 flex items-center">
<i class="fab fa-slack text-purple-600 mr-2"></i>Slack
</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label for="slack_client_id" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Client ID</label>
<input type="text" name="slack_client_id" id="slack_client_id" value="{{ settings.slack_client_id or '' }}" class="form-input" placeholder="{{ _('Slack OAuth Client ID') }}">
</div>
<div>
<label for="slack_client_secret" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Client Secret</label>
<input type="password" name="slack_client_secret" id="slack_client_secret" class="form-input" placeholder="{{ _('Leave empty to keep current value') }}">
{% if settings.slack_client_secret_set %}
<p class="mt-1 text-xs text-green-600 dark:text-green-400">{{ _('✓ Client secret is configured') }}</p>
{% endif %}
</div>
</div>
</div>
<!-- GitHub -->
<div class="mb-6 p-4 bg-gray-50 dark:bg-gray-800 rounded-lg">
<h3 class="text-md font-semibold mb-3 flex items-center">
<i class="fab fa-github text-gray-800 dark:text-gray-200 mr-2"></i>GitHub
</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label for="github_client_id" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Client ID</label>
<input type="text" name="github_client_id" id="github_client_id" value="{{ settings.github_client_id or '' }}" class="form-input" placeholder="{{ _('GitHub OAuth Client ID') }}">
</div>
<div>
<label for="github_client_secret" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Client Secret</label>
<input type="password" name="github_client_secret" id="github_client_secret" class="form-input" placeholder="{{ _('Leave empty to keep current value') }}">
{% if settings.github_client_secret_set %}
<p class="mt-1 text-xs text-green-600 dark:text-green-400">{{ _('✓ Client secret is configured') }}</p>
{% endif %}
</div>
</div>
</div>
<!-- Google Calendar -->
<div class="mb-6 p-4 bg-gray-50 dark:bg-gray-800 rounded-lg">
<h3 class="text-md font-semibold mb-3 flex items-center">
<i class="fab fa-google text-red-600 mr-2"></i>Google Calendar
</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label for="google_calendar_client_id" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Client ID</label>
<input type="text" name="google_calendar_client_id" id="google_calendar_client_id" value="{{ settings.google_calendar_client_id or '' }}" class="form-input" placeholder="{{ _('Google OAuth Client ID') }}">
</div>
<div>
<label for="google_calendar_client_secret" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Client Secret</label>
<input type="password" name="google_calendar_client_secret" id="google_calendar_client_secret" class="form-input" placeholder="{{ _('Leave empty to keep current value') }}">
{% if settings.google_calendar_client_secret_set %}
<p class="mt-1 text-xs text-green-600 dark:text-green-400">{{ _('✓ Client secret is configured') }}</p>
{% endif %}
</div>
</div>
</div>
<!-- Outlook Calendar -->
<div class="mb-6 p-4 bg-gray-50 dark:bg-gray-800 rounded-lg">
<h3 class="text-md font-semibold mb-3 flex items-center">
<i class="fab fa-microsoft text-blue-600 mr-2"></i>Outlook Calendar
</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label for="outlook_calendar_client_id" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Client ID</label>
<input type="text" name="outlook_calendar_client_id" id="outlook_calendar_client_id" value="{{ settings.outlook_calendar_client_id or '' }}" class="form-input" placeholder="{{ _('Microsoft OAuth Client ID') }}">
</div>
<div>
<label for="outlook_calendar_client_secret" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Client Secret</label>
<input type="password" name="outlook_calendar_client_secret" id="outlook_calendar_client_secret" class="form-input" placeholder="{{ _('Leave empty to keep current value') }}">
{% if settings.outlook_calendar_client_secret_set %}
<p class="mt-1 text-xs text-green-600 dark:text-green-400">{{ _('✓ Client secret is configured') }}</p>
{% endif %}
</div>
<div class="md:col-span-2">
<label for="outlook_calendar_tenant_id" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Tenant ID (optional)</label>
<input type="text" name="outlook_calendar_tenant_id" id="outlook_calendar_tenant_id" value="{{ settings.outlook_calendar_tenant_id or '' }}" class="form-input" placeholder="{{ _('Microsoft Tenant ID (common for multi-tenant)') }}">
</div>
</div>
</div>
<!-- Microsoft Teams -->
<div class="mb-6 p-4 bg-gray-50 dark:bg-gray-800 rounded-lg">
<h3 class="text-md font-semibold mb-3 flex items-center">
<i class="fab fa-microsoft-teams text-blue-600 mr-2"></i>Microsoft Teams
</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label for="microsoft_teams_client_id" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Client ID</label>
<input type="text" name="microsoft_teams_client_id" id="microsoft_teams_client_id" value="{{ settings.microsoft_teams_client_id or '' }}" class="form-input" placeholder="{{ _('Microsoft OAuth Client ID') }}">
</div>
<div>
<label for="microsoft_teams_client_secret" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Client Secret</label>
<input type="password" name="microsoft_teams_client_secret" id="microsoft_teams_client_secret" class="form-input" placeholder="{{ _('Leave empty to keep current value') }}">
{% if settings.microsoft_teams_client_secret_set %}
<p class="mt-1 text-xs text-green-600 dark:text-green-400">{{ _('✓ Client secret is configured') }}</p>
{% endif %}
</div>
<div class="md:col-span-2">
<label for="microsoft_teams_tenant_id" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Tenant ID (optional)</label>
<input type="text" name="microsoft_teams_tenant_id" id="microsoft_teams_tenant_id" value="{{ settings.microsoft_teams_tenant_id or '' }}" class="form-input" placeholder="{{ _('Microsoft Tenant ID (common for multi-tenant)') }}">
</div>
</div>
</div>
<!-- Asana -->
<div class="mb-6 p-4 bg-gray-50 dark:bg-gray-800 rounded-lg">
<h3 class="text-md font-semibold mb-3 flex items-center">
<i class="fab fa-asana text-fuchsia-600 mr-2"></i>Asana
</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label for="asana_client_id" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Client ID</label>
<input type="text" name="asana_client_id" id="asana_client_id" value="{{ settings.asana_client_id or '' }}" class="form-input" placeholder="{{ _('Asana OAuth Client ID') }}">
</div>
<div>
<label for="asana_client_secret" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Client Secret</label>
<input type="password" name="asana_client_secret" id="asana_client_secret" class="form-input" placeholder="{{ _('Leave empty to keep current value') }}">
{% if settings.asana_client_secret_set %}
<p class="mt-1 text-xs text-green-600 dark:text-green-400">{{ _('✓ Client secret is configured') }}</p>
{% endif %}
</div>
</div>
</div>
<!-- Trello -->
<div class="mb-6 p-4 bg-gray-50 dark:bg-gray-800 rounded-lg">
<h3 class="text-md font-semibold mb-3 flex items-center">
<i class="fab fa-trello text-blue-500 mr-2"></i>Trello
</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label for="trello_api_key" class="block text-sm font-medium text-gray-700 dark:text-gray-300">API Key</label>
<input type="text" name="trello_api_key" id="trello_api_key" value="{{ settings.trello_api_key or '' }}" class="form-input" placeholder="{{ _('Trello API Key') }}">
</div>
<div>
<label for="trello_api_secret" class="block text-sm font-medium text-gray-700 dark:text-gray-300">API Secret</label>
<input type="password" name="trello_api_secret" id="trello_api_secret" class="form-input" placeholder="{{ _('Leave empty to keep current value') }}">
{% if settings.trello_api_secret_set %}
<p class="mt-1 text-xs text-green-600 dark:text-green-400">{{ _('✓ API secret is configured') }}</p>
{% endif %}
</div>
</div>
</div>
<!-- GitLab -->
<div class="mb-6 p-4 bg-gray-50 dark:bg-gray-800 rounded-lg">
<h3 class="text-md font-semibold mb-3 flex items-center">
<i class="fab fa-gitlab text-orange-600 mr-2"></i>GitLab
</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label for="gitlab_client_id" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Client ID</label>
<input type="text" name="gitlab_client_id" id="gitlab_client_id" value="{{ settings.gitlab_client_id or '' }}" class="form-input" placeholder="{{ _('GitLab OAuth Client ID') }}">
</div>
<div>
<label for="gitlab_client_secret" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Client Secret</label>
<input type="password" name="gitlab_client_secret" id="gitlab_client_secret" class="form-input" placeholder="{{ _('Leave empty to keep current value') }}">
{% if settings.gitlab_client_secret_set %}
<p class="mt-1 text-xs text-green-600 dark:text-green-400">{{ _('✓ Client secret is configured') }}</p>
{% endif %}
</div>
<div class="md:col-span-2">
<label for="gitlab_instance_url" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Instance URL</label>
<input type="text" name="gitlab_instance_url" id="gitlab_instance_url" value="{{ settings.gitlab_instance_url or 'https://gitlab.com' }}" class="form-input" placeholder="{{ _('https://gitlab.com or your self-hosted instance') }}">
</div>
</div>
</div>
<!-- QuickBooks -->
<div class="mb-6 p-4 bg-gray-50 dark:bg-gray-800 rounded-lg">
<h3 class="text-md font-semibold mb-3 flex items-center">
<i class="fas fa-dollar-sign text-green-600 mr-2"></i>QuickBooks Online
</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label for="quickbooks_client_id" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Client ID</label>
<input type="text" name="quickbooks_client_id" id="quickbooks_client_id" value="{{ settings.quickbooks_client_id or '' }}" class="form-input" placeholder="{{ _('QuickBooks OAuth Client ID') }}">
</div>
<div>
<label for="quickbooks_client_secret" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Client Secret</label>
<input type="password" name="quickbooks_client_secret" id="quickbooks_client_secret" class="form-input" placeholder="{{ _('Leave empty to keep current value') }}">
{% if settings.quickbooks_client_secret_set %}
<p class="mt-1 text-xs text-green-600 dark:text-green-400">{{ _('✓ Client secret is configured') }}</p>
{% endif %}
</div>
</div>
</div>
<!-- Xero -->
<div class="mb-6 p-4 bg-gray-50 dark:bg-gray-800 rounded-lg">
<h3 class="text-md font-semibold mb-3 flex items-center">
<i class="fas fa-chart-line text-blue-600 mr-2"></i>Xero
</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label for="xero_client_id" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Client ID</label>
<input type="text" name="xero_client_id" id="xero_client_id" value="{{ settings.xero_client_id or '' }}" class="form-input" placeholder="{{ _('Xero OAuth Client ID') }}">
</div>
<div>
<label for="xero_client_secret" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Client Secret</label>
<input type="password" name="xero_client_secret" id="xero_client_secret" class="form-input" placeholder="{{ _('Leave empty to keep current value') }}">
{% if settings.xero_client_secret_set %}
<p class="mt-1 text-xs text-green-600 dark:text-green-400">{{ _('✓ Client secret is configured') }}</p>
{% endif %}
</div>
</div>
</div>
</div>
</div>
<!-- Analytics Settings -->
<div>
<h2 class="text-lg font-semibold mb-4">{{ _('Privacy & Analytics') }}</h2>
@@ -729,8 +318,6 @@
</div>
<style>
.integration-credentials-collapsed { display: none !important; }
.integration-credentials-toggle-transition { transition: all 0.3s ease-in-out; }
</style>
<script>
function previewLogoBeforeUpload(input) {
@@ -766,28 +353,6 @@ async function confirmRemoveLogo() {
}
}
function toggleIntegrationCredentials() {
const content = document.getElementById('integrationCredentialsContent');
const toggleIcon = document.getElementById('integrationCredentialsToggleIcon');
if (!content || !toggleIcon) return;
const isCollapsed = content.classList.contains('integration-credentials-collapsed');
if (isCollapsed) {
// Show content
content.classList.remove('integration-credentials-collapsed');
toggleIcon.classList.remove('fa-chevron-down');
toggleIcon.classList.add('fa-chevron-up');
} else {
// Hide content
content.classList.add('integration-credentials-collapsed');
toggleIcon.classList.remove('fa-chevron-up');
toggleIcon.classList.add('fa-chevron-down');
}
// Add transition class after a brief delay for smooth animation
setTimeout(() => {
content.classList.add('integration-credentials-toggle-transition');
}, 10);
}

View File

@@ -222,12 +222,11 @@
{% set crm_open = ep.startswith('clients.') or ep.startswith('quotes.') or ep.startswith('contacts.') or ep.startswith('deals.') or ep.startswith('leads.') %}
{% set inventory_open = ep.startswith('inventory.') %}
{% set analytics_open = ep.startswith('analytics.') %}
{% set tools_open = ep.startswith('import_export.') or ep.startswith('saved_filters.') or ep.startswith('integrations.') %}
{% set tools_open = ep.startswith('import_export.') or ep.startswith('saved_filters.') or ep.startswith('integrations.') or ep == 'integrations.list_integrations' or ep == 'integrations.manage_integration' or ep == 'integrations.view_integration' or ep == 'integrations.connect_integration' or ep == 'integrations.caldav_setup' %}
{% set admin_open = ep.startswith('admin.') or ep.startswith('permissions.') or (ep.startswith('expense_categories.') and current_user.is_admin) or (ep.startswith('per_diem.list_rates') and current_user.is_admin) or ep.startswith('time_entry_templates.') or ep.startswith('audit_logs.') or ep.startswith('webhooks.') or ep.startswith('custom_field_definitions.') or ep.startswith('link_templates.') %}
{% set admin_user_mgmt_open = ep == 'admin.list_users' or ep.startswith('permissions.') %}
{% set admin_settings_open = ep == 'admin.settings' or ep == 'admin.email_support' or ep.startswith('admin.') and ('email_template' in ep or 'email-templates' in request.path) or ep == 'admin.pdf_layout' or ep == 'admin.quote_pdf_layout' or ep == 'admin.oidc_debug' %}
{% set admin_security_open = ep == 'admin.api_tokens' or ep.startswith('webhooks.') or ep.startswith('audit_logs.') %}
{% set admin_integrations_open = ep == 'admin.list_integrations_admin' %}
{% set admin_data_open = ep == 'expense_categories.list_categories' or ep == 'per_diem.list_rates' or ep.startswith('time_entry_templates.') or ep.startswith('custom_field_definitions.') or ep.startswith('link_templates.') %}
{% set admin_maintenance_open = ep == 'admin.system_info' or ep == 'admin.backups_management' or ep == 'admin.telemetry_dashboard' %}
{% set pdf_open = ep == 'admin.pdf_layout' or ep == 'admin.quote_pdf_layout' %}
@@ -610,7 +609,7 @@
<ul id="toolsDropdown" class="{% if not tools_open %}hidden {% endif %}mt-2 space-y-2 ml-6">
{% set nav_active_import_export = ep.startswith('import_export.') %}
{% set nav_active_filters = ep.startswith('saved_filters.') %}
{% set nav_active_integrations = ep.startswith('integrations.') %}
{% set nav_active_integrations = ep.startswith('integrations.') or ep == 'integrations.list_integrations' or ep == 'integrations.manage_integration' or ep == 'integrations.view_integration' or ep == 'integrations.connect_integration' or ep == 'integrations.caldav_setup' %}
{% if is_module_enabled('integrations') %}
<li>
<a class="block px-2 py-1 rounded {% if nav_active_integrations %}text-primary font-semibold bg-background-light dark:bg-background-dark{% else %}text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark{% endif %}" href="{{ url_for('integrations.list_integrations') }}">
@@ -771,25 +770,6 @@
</ul>
</li>
{% endif %}
<!-- Integrations Submenu -->
{% if current_user.is_admin %}
<li>
<button onclick="toggleDropdown('adminIntegrationsDropdown', event)" data-dropdown="adminIntegrationsDropdown" class="w-full flex items-center px-2 py-1 rounded {% if admin_integrations_open %}text-primary font-semibold bg-background-light dark:bg-background-dark{% else %}text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark{% endif %}">
<i class="fas fa-plug w-4 mr-2"></i>
<span class="flex-1 text-left">{{ _('Integrations') }}</span>
<i class="fas fa-chevron-down text-xs"></i>
</button>
<ul id="adminIntegrationsDropdown" class="{% if admin_integrations_open %}{% else %}hidden {% endif %}mt-2 space-y-2 ml-6">
<li>
<a onclick="event.stopPropagation();" class="block px-2 py-1 rounded {% if ep == 'admin.list_integrations_admin' %}text-primary font-semibold bg-background-light dark:bg-background-dark{% else %}text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark{% endif %}" href="{{ url_for('admin.list_integrations_admin') }}">
<i class="fas fa-plug w-4 mr-2"></i>{{ _('Integrations') }}
</a>
</li>
</ul>
</li>
{% endif %}
<!-- Data Management Submenu -->
{% if current_user.is_admin or has_permission('manage_settings') %}
<li>

View File

@@ -11,102 +11,77 @@
{{ page_header(
icon_class='fas fa-plug',
title_text='Integrations',
subtitle_text='Connect with third-party services',
subtitle_text='Connect with third-party services to extend functionality',
breadcrumbs=breadcrumbs
) }}
<div class="grid grid-cols-1 md:grid-cols-2 gap-6 mb-6">
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow mb-6">
<p class="text-sm text-text-muted-light dark:text-text-muted-dark mb-4">
{{ _('Connect your Time Tracker with external services. Configure integrations below to sync data, automate workflows, and enhance productivity.') }}
</p>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 mb-6">
{% for provider in available_providers %}
{% set existing_integration = integrations|selectattr('provider', 'equalto', provider.provider)|first if integrations else none %}
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow hover:shadow-md transition-shadow">
<div class="flex items-start justify-between mb-4">
<div class="flex items-center gap-3">
<div class="w-12 h-12 rounded-lg bg-primary/10 flex items-center justify-center">
<i class="fas fa-{{ provider.icon }} text-primary text-xl"></i>
</div>
<div>
<h3 class="text-lg font-semibold">{{ provider.display_name }}</h3>
{% if provider.description %}
<p class="text-sm text-text-muted-light dark:text-text-muted-dark">{{ provider.description }}</p>
{% endif %}
<div class="bg-card-light dark:bg-card-dark rounded-lg shadow hover:shadow-lg transition-shadow overflow-hidden">
<div class="p-6">
<div class="flex items-start justify-between mb-4">
<div class="flex items-center gap-3">
<div class="w-12 h-12 rounded-lg bg-primary/10 flex items-center justify-center flex-shrink-0">
<i class="fas fa-{{ provider.icon }} text-primary text-xl"></i>
</div>
<div class="min-w-0 flex-1">
<h3 class="text-lg font-semibold text-text-light dark:text-text-dark truncate">{{ provider.display_name }}</h3>
{% if provider.description %}
<p class="text-sm text-text-muted-light dark:text-text-muted-dark mt-1 line-clamp-2">{{ provider.description }}</p>
{% endif %}
</div>
</div>
</div>
<div class="flex items-center gap-2 mb-4">
{% if existing_integration %}
{% if existing_integration.is_active %}
<span class="px-2 py-1 text-xs rounded-full bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200 font-medium">
<i class="fas fa-check-circle mr-1"></i>{{ _('Connected') }}
</span>
{% else %}
<span class="px-2 py-1 text-xs rounded-full bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200 font-medium">
<i class="fas fa-exclamation-circle mr-1"></i>{{ _('Not Connected') }}
</span>
{% endif %}
{% if existing_integration.is_global %}
<span class="px-2 py-1 text-xs rounded-full bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200">
{{ _('Global') }}
</span>
{% endif %}
{% if existing_integration.last_sync_at %}
<span class="text-xs text-text-muted-light dark:text-text-muted-dark">
{{ _('Synced') }} {{ existing_integration.last_sync_at.strftime('%m/%d') }}
</span>
{% endif %}
{% else %}
<span class="px-2 py-1 text-xs rounded-full bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-200 font-medium">
<i class="fas fa-circle mr-1"></i>{{ _('Not Set Up') }}
</span>
{% endif %}
</div>
<a href="{{ url_for('integrations.manage_integration', provider=provider.provider) }}"
class="block w-full {% if existing_integration and existing_integration.is_active %}bg-gray-600 hover:bg-gray-700{% else %}bg-primary hover:bg-primary/90{% endif %} text-white px-4 py-2.5 rounded-lg transition-colors text-center font-medium">
{% if existing_integration %}
{% if existing_integration.is_active %}
<i class="fas fa-cog mr-2"></i>{{ _('Manage') }}
{% else %}
<i class="fas fa-link mr-2"></i>{{ _('Connect') }}
{% endif %}
{% else %}
<i class="fas fa-plus mr-2"></i>{{ _('Setup') }}
{% endif %}
</a>
</div>
{% if existing_integration %}
{% if existing_integration.is_active %}
<a href="{{ url_for('integrations.view_integration', integration_id=existing_integration.id) }}" class="block w-full bg-gray-500 text-white px-4 py-2 rounded-lg hover:bg-gray-600 transition-colors text-center">
<i class="fas fa-eye mr-2"></i>{{ _('View Integration') }}
</a>
{% else %}
{% if provider.provider == 'trello' %}
<a href="{{ url_for('admin.integration_setup', provider=provider.provider) if current_user.is_admin else '#' }}" class="block w-full bg-primary text-white px-4 py-2 rounded-lg hover:bg-primary/90 transition-colors text-center {% if not current_user.is_admin %}opacity-50 cursor-not-allowed{% endif %}">
<i class="fas fa-cog mr-2"></i>{{ _('Configure') }}
</a>
{% else %}
<a href="{{ url_for('integrations.connect_integration', provider=provider.provider) }}" class="block w-full bg-primary text-white px-4 py-2 rounded-lg hover:bg-primary/90 transition-colors text-center">
<i class="fas fa-link mr-2"></i>{{ _('Reconnect') }}
</a>
{% endif %}
{% endif %}
{% else %}
{% if provider.provider == 'trello' %}
<a href="{{ url_for('admin.integration_setup', provider=provider.provider) if current_user.is_admin else '#' }}" class="block w-full bg-primary text-white px-4 py-2 rounded-lg hover:bg-primary/90 transition-colors text-center {% if not current_user.is_admin %}opacity-50 cursor-not-allowed{% endif %}">
<i class="fas fa-cog mr-2"></i>{{ _('Setup') }}
</a>
{% elif provider.provider == 'google_calendar' %}
<a href="{{ url_for('integrations.connect_integration', provider=provider.provider) }}" class="block w-full bg-primary text-white px-4 py-2 rounded-lg hover:bg-primary/90 transition-colors text-center">
<i class="fab fa-google mr-2"></i>{{ _('Connect Google Calendar') }}
<div class="text-xs mt-1 opacity-90">{{ _('Automatically redirects to Google') }}</div>
</a>
{% elif provider.provider == 'caldav_calendar' %}
<a href="{{ url_for('integrations.caldav_setup') }}" class="block w-full bg-primary text-white px-4 py-2 rounded-lg hover:bg-primary/90 transition-colors text-center">
<i class="fas fa-calendar mr-2"></i>{{ _('Setup CalDAV Calendar') }}
<div class="text-xs mt-1 opacity-90">{{ _('Connect to Zimbra or other CalDAV server') }}</div>
</a>
{% else %}
<a href="{{ url_for('integrations.connect_integration', provider=provider.provider) }}" class="block w-full bg-primary text-white px-4 py-2 rounded-lg hover:bg-primary/90 transition-colors text-center">
<i class="fas fa-plus mr-2"></i>{{ _('Connect') }}
</a>
{% endif %}
{% endif %}
</div>
{% endfor %}
</div>
{% if integrations %}
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
<h2 class="text-lg font-semibold mb-4">{{ _('Your Integrations') }}</h2>
<div class="space-y-4">
{% for integration in integrations %}
<div class="border border-border-light dark:border-border-dark rounded-lg p-4">
<div class="flex justify-between items-start">
<div class="flex-1">
<h3 class="font-semibold">{{ integration.name }}</h3>
<p class="text-sm text-text-muted-light dark:text-text-muted-dark">
{{ integration.provider|title|replace('_', ' ') }}
{% if integration.is_global %}
<span class="ml-2 px-2 py-0.5 text-xs rounded bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200">{{ _('Global') }}</span>
{% endif %}
{% if integration.last_sync_at %}
• {{ _('Last synced') }}: {{ integration.last_sync_at.strftime('%Y-%m-%d %H:%M') }}
{% endif %}
</p>
</div>
<div class="flex items-center gap-2">
{% if integration.is_active %}
<span class="px-2 py-1 text-xs rounded bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200">{{ _('Active') }}</span>
{% else %}
<span class="px-2 py-1 text-xs rounded bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-200">{{ _('Inactive') }}</span>
{% endif %}
<a href="{{ url_for('integrations.view_integration', integration_id=integration.id) }}" class="text-primary hover:text-primary/80">
<i class="fas fa-eye"></i>
</a>
</div>
</div>
</div>
{% endfor %}
</div>
</div>
{% endif %}
{% endblock %}

View File

@@ -0,0 +1,620 @@
{% extends "base.html" %}
{% from "components/ui.html" import page_header %}
{% block title %}{{ display_name }} {{ _('Integration') }} - {{ app_name }}{% endblock %}
{% block content %}
{% set breadcrumbs = [
{'text': 'Integrations', 'url': url_for('integrations.list_integrations')},
{'text': display_name}
] %}
{{ page_header(
icon_class='fas fa-plug',
title_text=display_name,
subtitle_text=description,
breadcrumbs=breadcrumbs
) }}
<div class="space-y-6">
<!-- OAuth Credentials Setup Section (Admin only) -->
{% if current_user.is_admin %}
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
<h2 class="text-lg font-semibold mb-4">
<i class="fas fa-key mr-2"></i>{{ _('OAuth Credentials Setup') }}
</h2>
<form method="POST">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="action" value="update_credentials">
{% if provider == 'trello' %}
<!-- Trello API Key Setup -->
<div class="space-y-4">
<div>
<label for="trello_api_key" class="block text-sm font-medium mb-2">
{{ _('Trello API Key') }} <span class="text-red-500">*</span>
</label>
<input type="text"
name="trello_api_key"
id="trello_api_key"
value="{{ current_creds.get('api_key', '') }}"
class="w-full px-3 py-2 border border-border-light dark:border-border-dark rounded-lg bg-card-light dark:bg-card-dark"
placeholder="{{ _('Get from https://trello.com/app-key') }}"
required>
<p class="mt-1 text-xs text-text-muted-light dark:text-text-muted-dark">
{{ _('Get your API key from') }} <a href="https://trello.com/app-key" target="_blank" class="text-primary hover:underline">trello.com/app-key</a>
</p>
</div>
<div>
<label for="trello_api_secret" class="block text-sm font-medium mb-2">
{{ _('Trello API Secret') }} <span class="text-red-500">*</span>
</label>
<input type="password"
name="trello_api_secret"
id="trello_api_secret"
value="{{ current_creds.get('api_secret', '') }}"
class="w-full px-3 py-2 border border-border-light dark:border-border-dark rounded-lg bg-card-light dark:bg-card-dark"
placeholder="{{ _('Leave empty to keep current value') }}">
<p class="mt-1 text-xs text-text-muted-light dark:text-text-muted-dark">
{{ _('Get your API secret from') }} <a href="https://trello.com/app-key" target="_blank" class="text-primary hover:underline">trello.com/app-key</a>
{{ _('(shown after generating API key)') }}
</p>
</div>
<div class="p-3 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg">
<p class="text-sm text-blue-800 dark:text-blue-200">
<i class="fas fa-info-circle mr-2"></i>
{{ _('After saving API Key and Secret, you can connect Trello using OAuth flow.') }}
</p>
</div>
</div>
{% else %}
<!-- OAuth-based Integrations -->
<div class="space-y-4">
<div>
<label for="{{ provider }}_client_id" class="block text-sm font-medium mb-2">
{{ _('OAuth Client ID') }} <span class="text-red-500">*</span>
</label>
<input type="text"
name="{{ provider }}_client_id"
id="{{ provider }}_client_id"
value="{{ current_creds.get('client_id', '') }}"
class="w-full px-3 py-2 border border-border-light dark:border-border-dark rounded-lg bg-card-light dark:bg-card-dark"
placeholder="{{ _('OAuth Client ID') }}"
required>
</div>
<div>
<label for="{{ provider }}_client_secret" class="block text-sm font-medium mb-2">
{{ _('OAuth Client Secret') }} <span class="text-red-500">*</span>
</label>
<input type="password"
name="{{ provider }}_client_secret"
id="{{ provider }}_client_secret"
class="w-full px-3 py-2 border border-border-light dark:border-border-dark rounded-lg bg-card-light dark:bg-card-dark"
placeholder="{{ _('Leave empty to keep current value') }}">
<p class="mt-1 text-xs text-text-muted-light dark:text-text-muted-dark">
{{ _('Required for new setup. Leave empty to keep existing secret.') }}
</p>
</div>
{% if provider in ['outlook_calendar', 'microsoft_teams'] %}
<div>
<label for="{{ provider }}_tenant_id" class="block text-sm font-medium mb-2">
{{ _('Tenant ID') }} <span class="text-text-muted-light dark:text-text-muted-dark">({{ _('optional') }})</span>
</label>
<input type="text"
name="{{ provider }}_tenant_id"
id="{{ provider }}_tenant_id"
value="{{ current_creds.get('tenant_id', '') }}"
class="w-full px-3 py-2 border border-border-light dark:border-border-dark rounded-lg bg-card-light dark:bg-card-dark"
placeholder="{{ _('Use "common" for multi-tenant, or leave empty for common') }}">
<p class="mt-1 text-xs text-text-muted-light dark:text-text-muted-dark">
{{ _('Leave empty to use "common" (multi-tenant). Enter your Azure AD tenant ID for single-tenant apps.') }}
</p>
</div>
{% endif %}
{% if provider == 'gitlab' %}
<div>
<label for="gitlab_instance_url" class="block text-sm font-medium mb-2">
{{ _('GitLab Instance URL') }}
</label>
<input type="url"
name="gitlab_instance_url"
id="gitlab_instance_url"
value="{{ current_creds.get('instance_url', 'https://gitlab.com') }}"
class="w-full px-3 py-2 border border-border-light dark:border-border-dark rounded-lg bg-card-light dark:bg-card-dark"
placeholder="https://gitlab.com"
required>
<p class="mt-1 text-xs text-text-muted-light dark:text-text-muted-dark">
{{ _('URL of your GitLab instance. Use "https://gitlab.com" for GitLab.com or your self-hosted GitLab URL.') }}
</p>
</div>
{% endif %}
</div>
{% if provider != 'trello' %}
<div class="mt-6 p-4 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg">
<h3 class="font-semibold mb-2">{{ _('OAuth Redirect URI') }}</h3>
<p class="text-sm text-text-muted-light dark:text-text-muted-dark mb-2">
{{ _('Add this URL as an authorized redirect URI in your OAuth app settings:') }}
</p>
<code class="block p-2 bg-gray-100 dark:bg-gray-800 rounded text-xs break-all">
{{ url_for('integrations.oauth_callback', provider=provider, _external=True) }}
</code>
{% if provider == 'google_calendar' %}
<div class="mt-4 p-3 bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded">
<p class="text-sm text-green-800 dark:text-green-200 font-semibold mb-2">
<i class="fas fa-magic mr-2"></i>{{ _('Automatic Connection Flow') }}
</p>
<ul class="text-sm text-green-700 dark:text-green-300 space-y-1 list-disc list-inside">
<li>{{ _('After you save these credentials, users can click "Connect Google Calendar"') }}</li>
<li>{{ _('They will be automatically redirected to Google OAuth') }}</li>
<li>{{ _('No manual credential entry needed - fully automatic!') }}</li>
<li>{{ _('Each user connects their own Google Calendar account') }}</li>
</ul>
</div>
{% endif %}
</div>
{% endif %}
{% endif %}
<div class="mt-6 flex gap-4">
<button type="submit" class="bg-primary text-white px-6 py-2 rounded-lg hover:bg-primary/90 transition-colors">
<i class="fas fa-save mr-2"></i>{{ _('Save Credentials') }}
</button>
</div>
</form>
</div>
{% endif %}
<!-- Integration Configuration Section (if connector provides config schema) -->
{% if active_integration and config_schema and config_schema.get('fields') %}
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
<h2 class="text-lg font-semibold mb-4">
<i class="fas fa-cog mr-2"></i>{{ _('Integration Configuration') }}
</h2>
<p class="text-sm text-text-muted-light dark:text-text-muted-dark mb-6">
{{ _('Configure sync settings, data mappings, and other integration options.') }}
</p>
<form method="POST">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="action" value="update_config">
{% if config_schema.get('sections') %}
<!-- Display fields organized by sections -->
{% for section in config_schema.sections %}
<div class="mb-8 last:mb-0">
<div class="border-b border-border-light dark:border-border-dark pb-2 mb-4">
<h3 class="text-md font-semibold text-text-light dark:text-text-dark">
{{ section.get('title', 'Settings') }}
</h3>
{% if section.get('description') %}
<p class="text-sm text-text-muted-light dark:text-text-muted-dark mt-1">
{{ section.get('description') }}
</p>
{% endif %}
</div>
<div class="space-y-4">
{% for field_name in section.get('fields', []) %}
{% set field = config_schema.fields|selectattr('name', 'equalto', field_name)|first %}
{% if field %}
{% set field_type = field.get('type', 'string') %}
{% set field_value = current_config.get(field_name, field.get('default', '')) %}
<div>
<label for="config_{{ field_name }}" class="block text-sm font-medium mb-2">
{{ field.get('label', field_name) }}
{% if field.get('required', False) %}
<span class="text-red-500">*</span>
{% endif %}
</label>
{% if field_type == 'boolean' %}
<div class="flex items-center">
<input type="checkbox"
name="{{ field_name }}"
id="config_{{ field_name }}"
{% if field_value %}checked{% endif %}
class="h-4 w-4 rounded border-gray-300 text-primary focus:ring-primary">
<label for="config_{{ field_name }}" class="ml-2 text-sm text-text-muted-light dark:text-text-muted-dark">
{{ field.get('description', '') }}
</label>
</div>
{% elif field_type == 'select' %}
<select name="{{ field_name }}"
id="config_{{ field_name }}"
class="w-full px-3 py-2 border border-border-light dark:border-border-dark rounded-lg bg-card-light dark:bg-card-dark"
{% if field.get('required', False) %}required{% endif %}>
{% for option in field.get('options', []) %}
<option value="{{ option.get('value', option.get('label', '')) }}"
{% if field_value == option.get('value', option.get('label', '')) or (not field_value and option.get('value', option.get('label', '')) == field.get('default')) %}selected{% endif %}>
{{ option.get('label', option.get('value', '')) }}
</option>
{% endfor %}
</select>
{% elif field_type == 'array' %}
<div class="space-y-2">
{% for option in field.get('options', []) %}
{% set option_value = option.get('value', option.get('label', '')) %}
{% set is_selected = field_value and option_value in field_value %}
<div class="flex items-center">
<input type="checkbox"
name="{{ field_name }}"
id="config_{{ field_name }}_{{ loop.index }}"
value="{{ option_value }}"
{% if is_selected or (not field_value and option_value in field.get('default', [])) %}checked{% endif %}
class="h-4 w-4 rounded border-gray-300 text-primary focus:ring-primary">
<label for="config_{{ field_name }}_{{ loop.index }}" class="ml-2 text-sm text-text-light dark:text-text-dark">
{{ option.get('label', option_value) }}
</label>
</div>
{% endfor %}
</div>
{% elif field_type == 'number' %}
<input type="number"
name="{{ field_name }}"
id="config_{{ field_name }}"
value="{{ field_value }}"
{% if field.get('validation') %}
{% if field.validation.get('min') is not none %}min="{{ field.validation.min }}"{% endif %}
{% if field.validation.get('max') is not none %}max="{{ field.validation.max }}"{% endif %}
{% endif %}
class="w-full px-3 py-2 border border-border-light dark:border-border-dark rounded-lg bg-card-light dark:bg-card-dark"
placeholder="{{ field.get('placeholder', '') }}"
{% if field.get('required', False) %}required{% endif %}>
{% elif field_type == 'text' or field_type == 'textarea' %}
<textarea
name="{{ field_name }}"
id="config_{{ field_name }}"
rows="{% if field_type == 'textarea' %}4{% else %}2{% endif %}"
class="w-full px-3 py-2 border border-border-light dark:border-border-dark rounded-lg bg-card-light dark:bg-card-dark"
placeholder="{{ field.get('placeholder', '') }}"
{% if field.get('required', False) %}required{% endif %}>{{ field_value }}</textarea>
{% elif field_type == 'json' %}
<textarea
name="{{ field_name }}"
id="config_{{ field_name }}"
rows="6"
class="w-full px-3 py-2 border border-border-light dark:border-border-dark rounded-lg bg-card-light dark:bg-card-dark font-mono text-sm"
placeholder="{{ field.get('placeholder', '{}') }}"
{% if field.get('required', False) %}required{% endif %}>{% if field_value %}{{ field_value|tojson|safe }}{% endif %}</textarea>
{% else %}
<input type="{{ field_type }}"
name="{{ field_name }}"
id="config_{{ field_name }}"
value="{{ field_value }}"
class="w-full px-3 py-2 border border-border-light dark:border-border-dark rounded-lg bg-card-light dark:bg-card-dark"
placeholder="{{ field.get('placeholder', '') }}"
{% if field.get('required', False) %}required{% endif %}>
{% endif %}
{% if field.get('help') or field.get('description') %}
<p class="mt-1 text-xs text-text-muted-light dark:text-text-muted-dark">
<i class="fas fa-info-circle mr-1"></i>
{{ field.get('help', field.get('description', '')) }}
</p>
{% endif %}
</div>
{% endif %}
{% endfor %}
</div>
</div>
{% endfor %}
{% else %}
<!-- Fallback: Display all fields without sections -->
<div class="space-y-4">
{% for field in config_schema.fields %}
{% set field_name = field.name %}
{% set field_type = field.get('type', 'string') %}
{% set field_value = current_config.get(field_name, field.get('default', '')) %}
<div>
<label for="config_{{ field_name }}" class="block text-sm font-medium mb-2">
{{ field.get('label', field_name) }}
{% if field.get('required', False) %}
<span class="text-red-500">*</span>
{% endif %}
</label>
{% if field_type == 'boolean' %}
<div class="flex items-center">
<input type="checkbox"
name="{{ field_name }}"
id="config_{{ field_name }}"
{% if field_value %}checked{% endif %}
class="h-4 w-4 rounded border-gray-300 text-primary focus:ring-primary">
<label for="config_{{ field_name }}" class="ml-2 text-sm text-text-muted-light dark:text-text-muted-dark">
{{ field.get('description', '') }}
</label>
</div>
{% elif field_type == 'select' %}
<select name="{{ field_name }}"
id="config_{{ field_name }}"
class="w-full px-3 py-2 border border-border-light dark:border-border-dark rounded-lg bg-card-light dark:bg-card-dark"
{% if field.get('required', False) %}required{% endif %}>
{% for option in field.get('options', []) %}
<option value="{{ option.get('value', option.get('label', '')) }}"
{% if field_value == option.get('value', option.get('label', '')) or (not field_value and option.get('value', option.get('label', '')) == field.get('default')) %}selected{% endif %}>
{{ option.get('label', option.get('value', '')) }}
</option>
{% endfor %}
</select>
{% elif field_type == 'array' %}
<div class="space-y-2">
{% for option in field.get('options', []) %}
{% set option_value = option.get('value', option.get('label', '')) %}
{% set is_selected = field_value and option_value in field_value %}
<div class="flex items-center">
<input type="checkbox"
name="{{ field_name }}"
id="config_{{ field_name }}_{{ loop.index }}"
value="{{ option_value }}"
{% if is_selected or (not field_value and option_value in field.get('default', [])) %}checked{% endif %}
class="h-4 w-4 rounded border-gray-300 text-primary focus:ring-primary">
<label for="config_{{ field_name }}_{{ loop.index }}" class="ml-2 text-sm text-text-light dark:text-text-dark">
{{ option.get('label', option_value) }}
</label>
</div>
{% endfor %}
</div>
{% elif field_type == 'number' %}
<input type="number"
name="{{ field_name }}"
id="config_{{ field_name }}"
value="{{ field_value }}"
{% if field.get('validation') %}
{% if field.validation.get('min') is not none %}min="{{ field.validation.min }}"{% endif %}
{% if field.validation.get('max') is not none %}max="{{ field.validation.max }}"{% endif %}
{% endif %}
class="w-full px-3 py-2 border border-border-light dark:border-border-dark rounded-lg bg-card-light dark:bg-card-dark"
placeholder="{{ field.get('placeholder', '') }}"
{% if field.get('required', False) %}required{% endif %}>
{% elif field_type == 'text' or field_type == 'textarea' %}
<textarea
name="{{ field_name }}"
id="config_{{ field_name }}"
rows="{% if field_type == 'textarea' %}4{% else %}2{% endif %}"
class="w-full px-3 py-2 border border-border-light dark:border-border-dark rounded-lg bg-card-light dark:bg-card-dark"
placeholder="{{ field.get('placeholder', '') }}"
{% if field.get('required', False) %}required{% endif %}>{{ field_value }}</textarea>
{% elif field_type == 'json' %}
<textarea
name="{{ field_name }}"
id="config_{{ field_name }}"
rows="6"
class="w-full px-3 py-2 border border-border-light dark:border-border-dark rounded-lg bg-card-light dark:bg-card-dark font-mono text-sm"
placeholder="{{ field.get('placeholder', '{}') }}"
{% if field.get('required', False) %}required{% endif %}>{% if field_value %}{{ field_value|tojson|safe }}{% endif %}</textarea>
{% else %}
<input type="{{ field_type }}"
name="{{ field_name }}"
id="config_{{ field_name }}"
value="{{ field_value }}"
class="w-full px-3 py-2 border border-border-light dark:border-border-dark rounded-lg bg-card-light dark:bg-card-dark"
placeholder="{{ field.get('placeholder', '') }}"
{% if field.get('required', False) %}required{% endif %}>
{% endif %}
{% if field.get('help') or field.get('description') %}
<p class="mt-1 text-xs text-text-muted-light dark:text-text-muted-dark">
<i class="fas fa-info-circle mr-1"></i>
{{ field.get('help', field.get('description', '')) }}
</p>
{% endif %}
</div>
{% endfor %}
</div>
{% endif %}
<div class="mt-6 pt-6 border-t border-border-light dark:border-border-dark">
<button type="submit" class="bg-primary text-white px-6 py-2 rounded-lg hover:bg-primary/90 transition-colors">
<i class="fas fa-save mr-2"></i>{{ _('Save Configuration') }}
</button>
</div>
</form>
</div>
{% endif %}
<!-- Connection Management Section -->
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
<h2 class="text-lg font-semibold mb-4">
<i class="fas fa-link mr-2"></i>{{ _('Connection Management') }}
</h2>
{% set active_integration = integration if is_global else user_integration %}
{% if connector_error %}
<div class="mb-4 p-3 bg-red-100 dark:bg-red-900/20 border border-red-300 dark:border-red-800 rounded-lg">
<p class="text-sm text-red-800 dark:text-red-200">
<i class="fas fa-exclamation-triangle mr-2"></i>
{{ _('Connector Error') }}: {{ connector_error }}
</p>
</div>
{% endif %}
{% if active_integration %}
<!-- Integration exists - show status and actions -->
<div class="space-y-4">
<div class="p-4 bg-background-light dark:bg-background-dark rounded-lg">
<div class="flex items-center gap-2 mb-3">
<h3 class="font-semibold text-lg">{{ display_name }}</h3>
{% if active_integration.is_active %}
<span class="px-2 py-1 text-xs rounded-full bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200 font-medium">
<i class="fas fa-check-circle mr-1"></i>{{ _('Connected') }}
</span>
{% else %}
<span class="px-2 py-1 text-xs rounded-full bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200 font-medium">
<i class="fas fa-exclamation-circle mr-1"></i>{{ _('Not Connected') }}
</span>
{% endif %}
{% if is_global %}
<span class="px-2 py-1 text-xs rounded-full bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200">
{{ _('Global') }}
</span>
{% endif %}
{% if active_integration.last_sync_status == 'success' %}
<span class="px-2 py-1 text-xs rounded-full bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200">
<i class="fas fa-check mr-1"></i>{{ _('Sync OK') }}
</span>
{% elif active_integration.last_sync_status == 'error' %}
<span class="px-2 py-1 text-xs rounded-full bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200">
<i class="fas fa-exclamation-triangle mr-1"></i>{{ _('Sync Error') }}
</span>
{% endif %}
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 text-sm">
{% if credentials %}
<div>
<span class="font-medium text-text-light dark:text-text-dark">{{ _('Connection Status') }}:</span>
<span class="text-text-muted-light dark:text-text-muted-dark ml-2">
{% if credentials.expires_at %}
{{ _('Expires') }}: {{ credentials.expires_at.strftime('%Y-%m-%d %H:%M') }}
{% else %}
{{ _('No expiration') }}
{% endif %}
</span>
</div>
{% else %}
<div>
<span class="font-medium text-text-light dark:text-text-dark">{{ _('Connection Status') }}:</span>
<span class="text-yellow-600 dark:text-yellow-400 ml-2">{{ _('Not connected') }}</span>
</div>
{% endif %}
{% if active_integration.last_sync_at %}
<div>
<span class="font-medium text-text-light dark:text-text-dark">{{ _('Last Sync') }}:</span>
<span class="text-text-muted-light dark:text-text-muted-dark ml-2">
{{ active_integration.last_sync_at.strftime('%Y-%m-%d %H:%M') }}
</span>
</div>
{% else %}
<div>
<span class="font-medium text-text-light dark:text-text-dark">{{ _('Last Sync') }}:</span>
<span class="text-text-muted-light dark:text-text-muted-dark ml-2">{{ _('Never') }}</span>
</div>
{% endif %}
</div>
{% if active_integration.last_error %}
<div class="mt-3 p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg">
<p class="text-sm text-red-800 dark:text-red-200">
<i class="fas fa-exclamation-triangle mr-2"></i>
<strong>{{ _('Last Error') }}:</strong> {{ active_integration.last_error }}
</p>
</div>
{% endif %}
</div>
{% if connector %}
<div class="flex gap-2">
<form method="POST" action="{{ url_for('integrations.test_integration', integration_id=active_integration.id) }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 transition-colors">
<i class="fas fa-vial mr-2"></i>{{ _('Test Connection') }}
</button>
</form>
<form method="POST" action="{{ url_for('integrations.sync_integration', integration_id=active_integration.id) }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="bg-green-600 text-white px-4 py-2 rounded-lg hover:bg-green-700 transition-colors">
<i class="fas fa-sync mr-2"></i>{{ _('Sync Now') }}
</button>
</form>
<a href="{{ url_for('integrations.view_integration', integration_id=active_integration.id) }}" class="bg-gray-600 text-white px-4 py-2 rounded-lg hover:bg-gray-700 transition-colors inline-block">
<i class="fas fa-eye mr-2"></i>{{ _('View Details') }}
</a>
</div>
{% endif %}
</div>
{% elif provider == 'caldav_calendar' %}
<!-- CalDAV setup -->
<div class="space-y-4">
<p class="text-text-muted-light dark:text-text-muted-dark">
{{ _('CalDAV uses username/password authentication. Click below to set up your CalDAV connection.') }}
</p>
<a href="{{ url_for('integrations.caldav_setup') }}" class="inline-block bg-primary text-white px-6 py-2 rounded-lg hover:bg-primary/90 transition-colors">
<i class="fas fa-cog mr-2"></i>{{ _('Setup CalDAV Integration') }}
</a>
</div>
{% elif provider == 'trello' %}
<!-- Trello - requires admin setup first -->
{% if is_global and not current_user.is_admin %}
<div class="p-4 bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg">
<p class="text-sm text-yellow-800 dark:text-yellow-200">
<i class="fas fa-info-circle mr-2"></i>
{{ _('Trello integration must be configured by an administrator.') }}
</p>
</div>
{% elif current_creds.get('api_key') %}
<div class="space-y-4">
<p class="text-text-muted-light dark:text-text-muted-dark">
{{ _('OAuth credentials are configured. You can now connect Trello.') }}
</p>
<a href="{{ url_for('integrations.connect_integration', provider=provider) }}" class="inline-block bg-primary text-white px-6 py-2 rounded-lg hover:bg-primary/90 transition-colors">
<i class="fas fa-link mr-2"></i>{{ _('Connect Trello') }}
</a>
</div>
{% else %}
<div class="p-4 bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg">
<p class="text-sm text-yellow-800 dark:text-yellow-200">
<i class="fas fa-info-circle mr-2"></i>
{{ _('Please configure Trello API key and token in the OAuth Credentials Setup section above.') }}
</p>
</div>
{% endif %}
{% else %}
<!-- OAuth-based integrations - show connect button if credentials are configured -->
{% if current_creds.get('client_id') or (is_global and current_user.is_admin) or (not is_global) %}
<div class="space-y-4">
{% if is_global and not current_creds.get('client_id') and current_user.is_admin %}
<div class="p-4 bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg">
<p class="text-sm text-yellow-800 dark:text-yellow-200">
<i class="fas fa-info-circle mr-2"></i>
{{ _('Please configure OAuth credentials in the section above before connecting.') }}
</p>
</div>
{% elif not is_global and not current_creds.get('client_id') %}
<div class="p-4 bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg">
<p class="text-sm text-yellow-800 dark:text-yellow-200">
<i class="fas fa-info-circle mr-2"></i>
{{ _('OAuth credentials need to be configured by an administrator first.') }}
</p>
</div>
{% else %}
<p class="text-text-muted-light dark:text-text-muted-dark">
{% if provider == 'google_calendar' %}
{{ _('Connect your Google Calendar account. You will be redirected to Google for authorization.') }}
{% else %}
{{ _('Connect this integration. You will be redirected to the provider for authorization.') }}
{% endif %}
</p>
<a href="{{ url_for('integrations.connect_integration', provider=provider) }}" class="inline-block bg-primary text-white px-6 py-2 rounded-lg hover:bg-primary/90 transition-colors">
<i class="fas fa-link mr-2"></i>
{% if provider == 'google_calendar' %}
{{ _('Connect Google Calendar') }}
{% else %}
{{ _('Connect') }} {{ display_name }}
{% endif %}
</a>
{% endif %}
</div>
{% else %}
<div class="p-4 bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg">
<p class="text-sm text-yellow-800 dark:text-yellow-200">
<i class="fas fa-info-circle mr-2"></i>
{{ _('OAuth credentials need to be configured by an administrator first.') }}
</p>
</div>
{% endif %}
{% endif %}
</div>
</div>
{% endblock %}

View File

@@ -157,7 +157,7 @@
<!-- Right Column: Real Insights -->
<div class="space-y-6">
<!-- Weekly Goal Widget -->
{% if settings and settings.ui_allow_weekly_goals and current_user.ui_show_weekly_goals %}
{% if is_module_enabled('weekly_goals') %}
{% if current_week_goal %}
<div class="bg-gradient-to-br from-blue-500 to-purple-600 p-6 rounded-lg shadow-lg dashboard-widget animated-card text-white">
<div class="flex items-center justify-between mb-4">

View File

@@ -237,264 +237,6 @@
</div>
</div>
<!-- UI Customization -->
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6">
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-4">
<i class="fas fa-sliders-h mr-2"></i>{{ _('UI Customization') }}
</h2>
<p class="text-sm text-gray-600 dark:text-gray-400 mb-4">
{{ _('Customize which features are visible in your navigation menu. Hide features you don\'t use to simplify your interface. Core features like Dashboard, Log Time, Projects, Tasks, Invoices, Expenses, and Clients are always visible. Some options may be disabled system-wide by an administrator.') }}
</p>
<div class="space-y-6">
<!-- Calendar Section -->
<div>
<div class="flex items-center mb-3">
{% if not settings or settings.ui_allow_calendar %}
<input type="checkbox" id="ui_show_calendar" name="ui_show_calendar"
{% if user.ui_show_calendar %}checked{% endif %}
class="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded">
<label for="ui_show_calendar" class="ml-3 block text-sm font-medium text-gray-700 dark:text-gray-300">
{{ _('Show Calendar') }}
</label>
{% else %}
<p class="text-xs text-text-muted-light dark:text-text-muted-dark">
{{ _('Calendar is disabled system-wide by an administrator.') }}
</p>
{% endif %}
</div>
</div>
<!-- Time Tracking Section -->
<div class="border-l-2 border-gray-200 dark:border-gray-700 pl-4">
<p class="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-3">{{ _('Time Tracking') }}</p>
<div class="space-y-3">
{% if not settings or settings.ui_allow_project_templates %}
<div class="flex items-center">
<input type="checkbox" id="ui_show_project_templates" name="ui_show_project_templates"
{% if user.ui_show_project_templates %}checked{% endif %}
class="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded">
<label for="ui_show_project_templates" class="ml-3 block text-sm text-gray-700 dark:text-gray-300">
{{ _('Project Templates') }}
</label>
</div>
{% endif %}
{% if not settings or settings.ui_allow_gantt_chart %}
<div class="flex items-center">
<input type="checkbox" id="ui_show_gantt_chart" name="ui_show_gantt_chart"
{% if user.ui_show_gantt_chart %}checked{% endif %}
class="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded">
<label for="ui_show_gantt_chart" class="ml-3 block text-sm text-gray-700 dark:text-gray-300">
{{ _('Gantt Chart') }}
</label>
</div>
{% endif %}
{% if not settings or settings.ui_allow_kanban_board %}
<div class="flex items-center">
<input type="checkbox" id="ui_show_kanban_board" name="ui_show_kanban_board"
{% if user.ui_show_kanban_board %}checked{% endif %}
class="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded">
<label for="ui_show_kanban_board" class="ml-3 block text-sm text-gray-700 dark:text-gray-300">
{{ _('Kanban Board') }}
</label>
</div>
{% endif %}
{% if not settings or settings.ui_allow_weekly_goals %}
<div class="flex items-center">
<input type="checkbox" id="ui_show_weekly_goals" name="ui_show_weekly_goals"
{% if user.ui_show_weekly_goals %}checked{% endif %}
class="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded">
<label for="ui_show_weekly_goals" class="ml-3 block text-sm text-gray-700 dark:text-gray-300">
{{ _('Weekly Goals') }}
</label>
</div>
{% if settings.ui_allow_issues %}
<div class="flex items-center">
<input type="checkbox" id="ui_show_issues" name="ui_show_issues"
{% if user.ui_show_issues %}checked{% endif %}
class="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded">
<label for="ui_show_issues" class="ml-3 block text-sm text-gray-700 dark:text-gray-300">
{{ _('Issues') }}
</label>
</div>
{% endif %}
{% endif %}
</div>
</div>
<!-- CRM Section -->
<div class="border-l-2 border-gray-200 dark:border-gray-700 pl-4">
<p class="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-3">{{ _('CRM') }}</p>
{% if not settings or settings.ui_allow_quotes %}
<div class="flex items-center">
<input type="checkbox" id="ui_show_quotes" name="ui_show_quotes"
{% if user.ui_show_quotes %}checked{% endif %}
class="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded">
<label for="ui_show_quotes" class="ml-3 block text-sm text-gray-700 dark:text-gray-300">
{{ _('Quotes') }}
</label>
</div>
{% endif %}
</div>
<!-- Finance & Expenses Section -->
<div class="border-l-2 border-gray-200 dark:border-gray-700 pl-4">
<p class="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-3">{{ _('Finance & Expenses') }}</p>
<div class="space-y-3">
{% if not settings or settings.ui_allow_reports %}
<div class="flex items-center">
<input type="checkbox" id="ui_show_reports" name="ui_show_reports"
{% if user.ui_show_reports %}checked{% endif %}
class="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded">
<label for="ui_show_reports" class="ml-3 block text-sm text-gray-700 dark:text-gray-300">
{{ _('Reports') }}
</label>
</div>
{% endif %}
{% if not settings or settings.ui_allow_report_builder %}
<div class="flex items-center">
<input type="checkbox" id="ui_show_report_builder" name="ui_show_report_builder"
{% if user.ui_show_report_builder %}checked{% endif %}
class="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded">
<label for="ui_show_report_builder" class="ml-3 block text-sm text-gray-700 dark:text-gray-300">
{{ _('Report Builder') }}
</label>
</div>
{% endif %}
{% if not settings or settings.ui_allow_scheduled_reports %}
<div class="flex items-center">
<input type="checkbox" id="ui_show_scheduled_reports" name="ui_show_scheduled_reports"
{% if user.ui_show_scheduled_reports %}checked{% endif %}
class="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded">
<label for="ui_show_scheduled_reports" class="ml-3 block text-sm text-gray-700 dark:text-gray-300">
{{ _('Scheduled Reports') }}
</label>
</div>
{% endif %}
{% if not settings or settings.ui_allow_invoice_approvals %}
<div class="flex items-center">
<input type="checkbox" id="ui_show_invoice_approvals" name="ui_show_invoice_approvals"
{% if user.ui_show_invoice_approvals %}checked{% endif %}
class="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded">
<label for="ui_show_invoice_approvals" class="ml-3 block text-sm text-gray-700 dark:text-gray-300">
{{ _('Invoice Approvals') }}
</label>
</div>
{% endif %}
{% if not settings or settings.ui_allow_payment_gateways %}
<div class="flex items-center">
<input type="checkbox" id="ui_show_payment_gateways" name="ui_show_payment_gateways"
{% if user.ui_show_payment_gateways %}checked{% endif %}
class="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded">
<label for="ui_show_payment_gateways" class="ml-3 block text-sm text-gray-700 dark:text-gray-300">
{{ _('Payment Gateways') }}
</label>
</div>
{% endif %}
{% if not settings or settings.ui_allow_recurring_invoices %}
<div class="flex items-center">
<input type="checkbox" id="ui_show_recurring_invoices" name="ui_show_recurring_invoices"
{% if user.ui_show_recurring_invoices %}checked{% endif %}
class="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded">
<label for="ui_show_recurring_invoices" class="ml-3 block text-sm text-gray-700 dark:text-gray-300">
{{ _('Recurring Invoices') }}
</label>
</div>
{% endif %}
{% if not settings or settings.ui_allow_payments %}
<div class="flex items-center">
<input type="checkbox" id="ui_show_payments" name="ui_show_payments"
{% if user.ui_show_payments %}checked{% endif %}
class="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded">
<label for="ui_show_payments" class="ml-3 block text-sm text-gray-700 dark:text-gray-300">
{{ _('Payments') }}
</label>
</div>
{% endif %}
{% if not settings or settings.ui_allow_mileage %}
<div class="flex items-center">
<input type="checkbox" id="ui_show_mileage" name="ui_show_mileage"
{% if user.ui_show_mileage %}checked{% endif %}
class="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded">
<label for="ui_show_mileage" class="ml-3 block text-sm text-gray-700 dark:text-gray-300">
{{ _('Mileage') }}
</label>
</div>
{% endif %}
{% if not settings or settings.ui_allow_per_diem %}
<div class="flex items-center">
<input type="checkbox" id="ui_show_per_diem" name="ui_show_per_diem"
{% if user.ui_show_per_diem %}checked{% endif %}
class="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded">
<label for="ui_show_per_diem" class="ml-3 block text-sm text-gray-700 dark:text-gray-300">
{{ _('Per Diem') }}
</label>
</div>
{% endif %}
{% if not settings or settings.ui_allow_budget_alerts %}
<div class="flex items-center">
<input type="checkbox" id="ui_show_budget_alerts" name="ui_show_budget_alerts"
{% if user.ui_show_budget_alerts %}checked{% endif %}
class="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded">
<label for="ui_show_budget_alerts" class="ml-3 block text-sm text-gray-700 dark:text-gray-300">
{{ _('Budget Alerts') }}
</label>
</div>
{% endif %}
</div>
</div>
<!-- Inventory Section -->
<div class="border-l-2 border-gray-200 dark:border-gray-700 pl-4">
<p class="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-3">{{ _('Inventory') }}</p>
{% if not settings or settings.ui_allow_inventory %}
<div class="flex items-center">
<input type="checkbox" id="ui_show_inventory" name="ui_show_inventory"
{% if user.ui_show_inventory %}checked{% endif %}
class="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded">
<label for="ui_show_inventory" class="ml-3 block text-sm text-gray-700 dark:text-gray-300">
{{ _('Show Inventory Section') }}
</label>
</div>
{% else %}
<p class="text-xs text-text-muted-light dark:text-text-muted-dark">
{{ _('Inventory is disabled system-wide by an administrator.') }}
</p>
{% endif %}
</div>
<!-- Analytics Section -->
<div class="border-l-2 border-gray-200 dark:border-gray-700 pl-4">
<p class="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-3">{{ _('Analytics') }}</p>
{% if not settings or settings.ui_allow_analytics %}
<div class="flex items-center">
<input type="checkbox" id="ui_show_analytics" name="ui_show_analytics"
{% if user.ui_show_analytics %}checked{% endif %}
class="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded">
<label for="ui_show_analytics" class="ml-3 block text-sm text-gray-700 dark:text-gray-300">
{{ _('Show Analytics') }}
</label>
</div>
{% endif %}
</div>
<!-- Tools & Data Section -->
<div class="border-l-2 border-gray-200 dark:border-gray-700 pl-4">
<p class="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-3">{{ _('Tools & Data') }}</p>
{% if not settings or settings.ui_allow_tools %}
<div class="flex items-center">
<input type="checkbox" id="ui_show_tools" name="ui_show_tools"
{% if user.ui_show_tools %}checked{% endif %}
class="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded">
<label for="ui_show_tools" class="ml-3 block text-sm text-gray-700 dark:text-gray-300">
{{ _('Show Tools & Data Section') }}
</label>
</div>
{% endif %}
</div>
</div>
</div>
<!-- Regional Settings -->
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6">
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-4">

View File

@@ -33,8 +33,6 @@ class ModuleDefinition:
default_enabled: bool = True
requires_admin: bool = False
dependencies: List[str] = field(default_factory=list) # Module IDs this depends on
settings_flag: Optional[str] = None # Settings.ui_allow_* field name
user_flag: Optional[str] = None # User.ui_show_* field name
routes: List[str] = field(default_factory=list) # Route endpoints
icon: Optional[str] = None # FontAwesome icon class
order: int = 0 # Display order in navigation
@@ -81,7 +79,7 @@ class ModuleRegistry:
Args:
module_id: The module ID to check
settings: Settings instance (optional, will fetch if not provided)
settings: Settings instance (deprecated, kept for backwards compatibility)
user: User instance (optional, will use current_user if not provided)
Returns:
@@ -105,32 +103,6 @@ class ModuleRegistry:
if not getattr(user, 'is_admin', False):
return False
# Fetch settings if not provided
if settings is None:
try:
from app.models import Settings
settings = Settings.get_settings()
except Exception:
# If we can't get settings, use defaults
return module.default_enabled
# Check system-wide setting
if module.settings_flag:
flag_value = getattr(settings, module.settings_flag, None)
if flag_value is False:
return False
# Fetch user if not provided
if user is None:
from flask_login import current_user
user = current_user
# Check user-specific setting
if module.user_flag and user and getattr(user, 'is_authenticated', False):
flag_value = getattr(user, module.user_flag, None)
if flag_value is False:
return False
# Check dependencies recursively
for dep_id in module.dependencies:
if not cls.is_enabled(dep_id, settings, user):
@@ -229,8 +201,6 @@ class ModuleRegistry:
category=ModuleCategory.TIME_TRACKING,
blueprint_name="calendar",
default_enabled=True,
settings_flag="ui_allow_calendar",
user_flag="ui_show_calendar",
icon="fa-calendar-alt",
order=10
))
@@ -243,8 +213,6 @@ class ModuleRegistry:
blueprint_name="project_templates",
default_enabled=True,
dependencies=["projects"],
settings_flag="ui_allow_project_templates",
user_flag="ui_show_project_templates",
icon="fa-layer-group",
order=11
))
@@ -257,8 +225,6 @@ class ModuleRegistry:
blueprint_name="gantt",
default_enabled=True,
dependencies=["tasks"],
settings_flag="ui_allow_gantt_chart",
user_flag="ui_show_gantt_chart",
icon="fa-project-diagram",
order=12
))
@@ -271,8 +237,6 @@ class ModuleRegistry:
blueprint_name="kanban",
default_enabled=True,
dependencies=["tasks"],
settings_flag="ui_allow_kanban_board",
user_flag="ui_show_kanban_board",
icon="fa-columns",
order=13
))
@@ -284,8 +248,6 @@ class ModuleRegistry:
category=ModuleCategory.TIME_TRACKING,
blueprint_name="weekly_goals",
default_enabled=True,
settings_flag="ui_allow_weekly_goals",
user_flag="ui_show_weekly_goals",
icon="fa-bullseye",
order=14
))
@@ -297,8 +259,6 @@ class ModuleRegistry:
category=ModuleCategory.PROJECT_MANAGEMENT,
blueprint_name="issues",
default_enabled=True,
settings_flag="ui_allow_issues",
user_flag="ui_show_issues",
icon="fa-bug",
order=15
))
@@ -310,8 +270,6 @@ class ModuleRegistry:
category=ModuleCategory.TIME_TRACKING,
blueprint_name="time_entry_templates",
default_enabled=True,
settings_flag="ui_allow_time_entry_templates",
user_flag="ui_show_time_entry_templates",
icon="fa-clipboard-list",
order=16
))
@@ -325,8 +283,6 @@ class ModuleRegistry:
blueprint_name="quotes",
default_enabled=True,
dependencies=["clients"],
settings_flag="ui_allow_quotes",
user_flag="ui_show_quotes",
icon="fa-file-contract",
order=20
))
@@ -339,8 +295,6 @@ class ModuleRegistry:
blueprint_name="contacts",
default_enabled=True,
dependencies=["clients"],
settings_flag="ui_allow_contacts",
user_flag="ui_show_contacts",
icon="fa-address-book",
order=21
))
@@ -353,8 +307,6 @@ class ModuleRegistry:
blueprint_name="deals",
default_enabled=True,
dependencies=["clients"],
settings_flag="ui_allow_deals",
user_flag="ui_show_deals",
icon="fa-handshake",
order=22
))
@@ -367,8 +319,6 @@ class ModuleRegistry:
blueprint_name="leads",
default_enabled=True,
dependencies=["clients"],
settings_flag="ui_allow_leads",
user_flag="ui_show_leads",
icon="fa-user-tag",
order=23
))
@@ -381,8 +331,6 @@ class ModuleRegistry:
category=ModuleCategory.FINANCE,
blueprint_name="reports",
default_enabled=True,
settings_flag="ui_allow_reports",
user_flag="ui_show_reports",
icon="fa-chart-bar",
order=30
))
@@ -394,8 +342,6 @@ class ModuleRegistry:
category=ModuleCategory.FINANCE,
blueprint_name="custom_reports",
default_enabled=True,
settings_flag="ui_allow_report_builder",
user_flag="ui_show_report_builder",
icon="fa-magic",
order=31
))
@@ -407,8 +353,6 @@ class ModuleRegistry:
category=ModuleCategory.FINANCE,
blueprint_name="scheduled_reports",
default_enabled=True,
settings_flag="ui_allow_scheduled_reports",
user_flag="ui_show_scheduled_reports",
icon="fa-clock",
order=32
))
@@ -421,8 +365,6 @@ class ModuleRegistry:
blueprint_name="invoices",
default_enabled=True,
dependencies=["projects"],
settings_flag="ui_allow_invoices",
user_flag="ui_show_invoices",
icon="fa-file-invoice",
order=33
))
@@ -435,8 +377,6 @@ class ModuleRegistry:
blueprint_name="invoice_approvals",
default_enabled=True,
dependencies=["invoices"],
settings_flag="ui_allow_invoice_approvals",
user_flag="ui_show_invoice_approvals",
icon="fa-check-circle",
order=34
))
@@ -449,8 +389,6 @@ class ModuleRegistry:
blueprint_name="recurring_invoices",
default_enabled=True,
dependencies=["invoices"],
settings_flag="ui_allow_recurring_invoices",
user_flag="ui_show_recurring_invoices",
icon="fa-sync-alt",
order=35
))
@@ -463,8 +401,6 @@ class ModuleRegistry:
blueprint_name="payments",
default_enabled=True,
dependencies=["invoices"],
settings_flag="ui_allow_payments",
user_flag="ui_show_payments",
icon="fa-credit-card",
order=36
))
@@ -477,8 +413,6 @@ class ModuleRegistry:
blueprint_name="payment_gateways",
default_enabled=True,
dependencies=["payments"],
settings_flag="ui_allow_payment_gateways",
user_flag="ui_show_payment_gateways",
icon="fa-credit-card",
order=37
))
@@ -491,8 +425,6 @@ class ModuleRegistry:
blueprint_name="expenses",
default_enabled=True,
dependencies=["projects"],
settings_flag="ui_allow_expenses",
user_flag="ui_show_expenses",
icon="fa-receipt",
order=38
))
@@ -504,8 +436,6 @@ class ModuleRegistry:
category=ModuleCategory.FINANCE,
blueprint_name="mileage",
default_enabled=True,
settings_flag="ui_allow_mileage",
user_flag="ui_show_mileage",
icon="fa-car",
order=39
))
@@ -517,8 +447,6 @@ class ModuleRegistry:
category=ModuleCategory.FINANCE,
blueprint_name="per_diem",
default_enabled=True,
settings_flag="ui_allow_per_diem",
user_flag="ui_show_per_diem",
icon="fa-utensils",
order=40
))
@@ -531,8 +459,6 @@ class ModuleRegistry:
blueprint_name="budget_alerts",
default_enabled=True,
dependencies=["projects"],
settings_flag="ui_allow_budget_alerts",
user_flag="ui_show_budget_alerts",
icon="fa-exclamation-triangle",
order=41
))
@@ -545,8 +471,6 @@ class ModuleRegistry:
category=ModuleCategory.INVENTORY,
blueprint_name="inventory",
default_enabled=True,
settings_flag="ui_allow_inventory",
user_flag="ui_show_inventory",
icon="fa-boxes",
order=50
))
@@ -559,8 +483,6 @@ class ModuleRegistry:
category=ModuleCategory.ANALYTICS,
blueprint_name="analytics",
default_enabled=True,
settings_flag="ui_allow_analytics",
user_flag="ui_show_analytics",
icon="fa-chart-line",
order=60
))
@@ -573,8 +495,6 @@ class ModuleRegistry:
category=ModuleCategory.TOOLS,
blueprint_name="integrations",
default_enabled=True,
settings_flag="ui_allow_integrations",
user_flag="ui_show_integrations",
icon="fa-plug",
order=70
))
@@ -586,8 +506,6 @@ class ModuleRegistry:
category=ModuleCategory.TOOLS,
blueprint_name="import_export",
default_enabled=True,
settings_flag="ui_allow_import_export",
user_flag="ui_show_import_export",
icon="fa-exchange-alt",
order=71
))
@@ -599,8 +517,6 @@ class ModuleRegistry:
category=ModuleCategory.TOOLS,
blueprint_name="saved_filters",
default_enabled=True,
settings_flag="ui_allow_saved_filters",
user_flag="ui_show_saved_filters",
icon="fa-filter",
order=72
))
@@ -613,8 +529,6 @@ class ModuleRegistry:
category=ModuleCategory.ADVANCED,
blueprint_name="workflows",
default_enabled=True,
settings_flag="ui_allow_workflows",
user_flag="ui_show_workflows",
icon="fa-sitemap",
order=80
))
@@ -627,8 +541,6 @@ class ModuleRegistry:
blueprint_name="time_approvals",
default_enabled=True,
dependencies=["timer"],
settings_flag="ui_allow_time_approvals",
user_flag="ui_show_time_approvals",
icon="fa-check-double",
order=81
))
@@ -640,8 +552,6 @@ class ModuleRegistry:
category=ModuleCategory.ADVANCED,
blueprint_name="activity_feed",
default_enabled=True,
settings_flag="ui_allow_activity_feed",
user_flag="ui_show_activity_feed",
icon="fa-stream",
order=82
))
@@ -654,8 +564,6 @@ class ModuleRegistry:
blueprint_name="recurring_tasks",
default_enabled=True,
dependencies=["tasks"],
settings_flag="ui_allow_recurring_tasks",
user_flag="ui_show_recurring_tasks",
icon="fa-redo",
order=83
))
@@ -667,8 +575,6 @@ class ModuleRegistry:
category=ModuleCategory.ADVANCED,
blueprint_name="team_chat",
default_enabled=True,
settings_flag="ui_allow_team_chat",
user_flag="ui_show_team_chat",
icon="fa-comments",
order=84
))
@@ -681,8 +587,6 @@ class ModuleRegistry:
blueprint_name="client_portal",
default_enabled=True,
dependencies=["clients"],
settings_flag="ui_allow_client_portal",
user_flag="ui_show_client_portal",
icon="fa-door-open",
order=85
))
@@ -694,8 +598,6 @@ class ModuleRegistry:
category=ModuleCategory.ADVANCED,
blueprint_name="kiosk",
default_enabled=True,
settings_flag="ui_allow_kiosk",
user_flag="ui_show_kiosk",
icon="fa-desktop",
order=86
))

View File

@@ -0,0 +1,168 @@
"""Remove ui_allow_ module visibility flags from settings
Revision ID: 093_remove_ui_allow_flags
Revises: 092_missing_module_flags
Create Date: 2025-01-27 12:00:00
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = "093_remove_ui_allow_flags"
down_revision = "092_missing_module_flags"
branch_labels = None
depends_on = None
def upgrade():
"""Remove ui_allow_* columns from settings table.
These columns are no longer needed as modules are now enabled by default
and controlled via the ModuleRegistry system and user preferences.
"""
bind = op.get_bind()
inspector = sa.inspect(bind)
# Check if settings table exists
table_names = set(inspector.get_table_names())
if 'settings' not in table_names:
print("⚠ Settings table does not exist, skipping ui_allow_ column removal")
return
# List of all ui_allow_ columns to remove
ui_allow_columns = [
"ui_allow_calendar",
"ui_allow_project_templates",
"ui_allow_gantt_chart",
"ui_allow_kanban_board",
"ui_allow_weekly_goals",
"ui_allow_issues",
"ui_allow_time_entry_templates",
"ui_allow_quotes",
"ui_allow_contacts",
"ui_allow_deals",
"ui_allow_leads",
"ui_allow_reports",
"ui_allow_report_builder",
"ui_allow_scheduled_reports",
"ui_allow_invoices",
"ui_allow_invoice_approvals",
"ui_allow_recurring_invoices",
"ui_allow_payments",
"ui_allow_payment_gateways",
"ui_allow_expenses",
"ui_allow_mileage",
"ui_allow_per_diem",
"ui_allow_budget_alerts",
"ui_allow_inventory",
"ui_allow_analytics",
"ui_allow_tools",
"ui_allow_integrations",
"ui_allow_import_export",
"ui_allow_saved_filters",
"ui_allow_workflows",
"ui_allow_time_approvals",
"ui_allow_activity_feed",
"ui_allow_recurring_tasks",
"ui_allow_team_chat",
"ui_allow_client_portal",
"ui_allow_kiosk",
]
# Helper to drop a column if it exists
def _drop_column_if_exists(table_name: str, column_name: str):
try:
current_cols = {c['name'] for c in inspector.get_columns(table_name)}
if column_name in current_cols:
op.drop_column(table_name, column_name)
print(f"✓ Dropped {column_name} column from {table_name} table")
else:
print(f"⊘ Column {column_name} does not exist in {table_name} table, skipping")
except Exception as e:
error_msg = str(e)
# Column might already be dropped or not exist
if 'does not exist' in error_msg.lower() or 'no such column' in error_msg.lower():
print(f"⊘ Column {column_name} does not exist in {table_name} table (detected via error)")
else:
print(f"⚠ Warning: Could not drop {column_name} column: {e}")
# Drop all ui_allow_ columns
for column_name in ui_allow_columns:
_drop_column_if_exists("settings", column_name)
def downgrade():
"""Re-add ui_allow_* columns to settings table.
Note: This will restore the columns with default value True.
"""
bind = op.get_bind()
# Determine database dialect for proper default values
dialect_name = bind.dialect.name if bind else "generic"
# Set appropriate boolean defaults based on database
if dialect_name == 'sqlite':
bool_true_default = '1'
elif dialect_name == 'postgresql':
bool_true_default = 'true'
else: # MySQL/MariaDB and others
bool_true_default = '1'
# List of all ui_allow_ columns to re-add
ui_allow_columns = [
"ui_allow_calendar",
"ui_allow_project_templates",
"ui_allow_gantt_chart",
"ui_allow_kanban_board",
"ui_allow_weekly_goals",
"ui_allow_issues",
"ui_allow_time_entry_templates",
"ui_allow_quotes",
"ui_allow_contacts",
"ui_allow_deals",
"ui_allow_leads",
"ui_allow_reports",
"ui_allow_report_builder",
"ui_allow_scheduled_reports",
"ui_allow_invoices",
"ui_allow_invoice_approvals",
"ui_allow_recurring_invoices",
"ui_allow_payments",
"ui_allow_payment_gateways",
"ui_allow_expenses",
"ui_allow_mileage",
"ui_allow_per_diem",
"ui_allow_budget_alerts",
"ui_allow_inventory",
"ui_allow_analytics",
"ui_allow_tools",
"ui_allow_integrations",
"ui_allow_import_export",
"ui_allow_saved_filters",
"ui_allow_workflows",
"ui_allow_time_approvals",
"ui_allow_activity_feed",
"ui_allow_recurring_tasks",
"ui_allow_team_chat",
"ui_allow_client_portal",
"ui_allow_kiosk",
]
# Re-add all columns with default True
for column_name in ui_allow_columns:
try:
op.add_column(
"settings",
sa.Column(column_name, sa.Boolean(), nullable=False, server_default=sa.text(bool_true_default)),
)
print(f"✓ Re-added {column_name} column to settings table")
except Exception as e:
error_msg = str(e)
if 'already exists' in error_msg.lower() or 'duplicate' in error_msg.lower():
print(f"⊘ Column {column_name} already exists in settings table")
else:
print(f"⚠ Warning: Could not re-add {column_name} column: {e}")

View File

@@ -7,7 +7,7 @@ from setuptools import setup, find_packages
setup(
name='timetracker',
version='4.8.0',
version='4.8.1',
packages=find_packages(),
include_package_data=True,
install_requires=[