From f696c649c2f20f442f5392eca9ed9439a7230a9a Mon Sep 17 00:00:00 2001 From: Matt Davis Date: Fri, 7 Nov 2025 11:06:41 -0500 Subject: [PATCH] Additional LDAP groups improvements --- .gitignore | 1 + CHANGELOG.md | 74 ++++++ django_forms_workflows/__init__.py | 2 +- django_forms_workflows/apps.py | 5 +- .../data_sources/database_source.py | 167 ++++++++++++ .../management/commands/sync_ldap_profiles.py | 135 ++++++++++ .../management/commands/test_db_connection.py | 138 ++++++++++ .../0007_add_userprofile_ldap_enhancements.py | 43 +++ django_forms_workflows/models.py | 31 ++- django_forms_workflows/signals.py | 152 +++++++++++ django_forms_workflows/utils.py | 244 ++++++++++++++++++ pyproject.toml | 2 +- 12 files changed, 988 insertions(+), 6 deletions(-) create mode 100644 django_forms_workflows/management/commands/sync_ldap_profiles.py create mode 100644 django_forms_workflows/management/commands/test_db_connection.py create mode 100644 django_forms_workflows/migrations/0007_add_userprofile_ldap_enhancements.py create mode 100644 django_forms_workflows/signals.py diff --git a/.gitignore b/.gitignore index 403c223..f17effe 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ dist *.pyc$ db.sqlite3 +form-workflows/ __pycache__/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 49b6f69..c9224ab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,80 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Advanced reporting and analytics - Multi-tenancy support +## [0.4.0] - 2025-11-06 + +### Added - SJCME Migration Support +- **Enhanced UserProfile Model** + - Added `ldap_last_sync` timestamp field for tracking LDAP synchronization + - Added database indexes to `employee_id` and `external_id` fields for better performance + - Added `id_number` property as backward-compatible alias for `employee_id` + - Added `full_name` and `display_name` properties for convenient user display + - Enhanced help text for LDAP attribute fields + +- **LDAP Integration Enhancements** + - New `signals.py` module with automatic LDAP attribute synchronization + - `sync_ldap_attributes()` function for syncing LDAP data to UserProfile + - `get_ldap_attribute()` helper function for retrieving LDAP attributes + - Auto-sync on user login (configurable via `FORMS_WORKFLOWS['LDAP_SYNC']`) + - Signal handlers for automatic UserProfile creation on user creation + - Configurable LDAP attribute mappings in settings + +- **Database Introspection Utilities** + - `DatabaseDataSource.test_connection()` - Test external database connections + - `DatabaseDataSource.get_available_tables()` - List tables in a schema + - `DatabaseDataSource.get_table_columns()` - Get column information for a table + - Support for SQL Server, PostgreSQL, MySQL, and SQLite introspection + +- **Utility Functions** + - `get_user_manager()` - Get user's manager from LDAP or UserProfile + - `user_can_view_form()` - Check if user can view a form + - `user_can_view_submission()` - Check if user can view a submission + - `check_escalation_needed()` - Check if submission needs escalation + - `sync_ldap_groups()` - Synchronize LDAP groups to Django groups + +- **Management Commands** + - `sync_ldap_profiles` - Bulk sync LDAP attributes for all users + - Supports `--username` for single user sync + - Supports `--dry-run` for testing without changes + - Supports `--verbose` for detailed output + - `test_db_connection` - Test external database connections + - Supports `--database` to specify database alias + - Supports `--verbose` for detailed connection information + - Works with SQL Server, PostgreSQL, MySQL, and SQLite + +- **Documentation** + - `PORTING_ANALYSIS.md` - Detailed analysis of SJCME to package migration + - `FEATURE_COMPARISON.md` - Comprehensive feature comparison matrix + - `SJCME_SIMPLIFICATION_PLAN.md` - Code reduction and migration strategy + - `EXECUTIVE_SUMMARY.md` - High-level overview for stakeholders + - `NEXT_STEPS.md` - Actionable implementation guide + +### Changed +- Updated version to 0.4.0 to reflect significant new features +- Enhanced UserProfile model with LDAP-specific fields and properties +- Improved database source with introspection capabilities +- Expanded utility functions for better LDAP and permission handling + +### Migration Notes +- Run `python manage.py migrate django_forms_workflows` to apply UserProfile enhancements +- Configure LDAP sync in settings: + ```python + FORMS_WORKFLOWS = { + 'LDAP_SYNC': { + 'enabled': True, + 'sync_on_login': True, + 'attributes': { + 'employee_id': 'extensionAttribute1', + 'department': 'department', + 'title': 'title', + 'phone': 'telephoneNumber', + 'manager_dn': 'manager', + } + } + } + ``` +- Use `python manage.py sync_ldap_profiles` to bulk sync existing users + ## [0.2.2] - 2025-10-31 ### Changed diff --git a/django_forms_workflows/__init__.py b/django_forms_workflows/__init__.py index d227fd8..1a782d2 100644 --- a/django_forms_workflows/__init__.py +++ b/django_forms_workflows/__init__.py @@ -3,7 +3,7 @@ Enterprise-grade, database-driven form builder with approval workflows """ -__version__ = "0.3.0" +__version__ = "0.4.0" __author__ = "Django Forms Workflows Contributors" __license__ = "LGPL-3.0-only" diff --git a/django_forms_workflows/apps.py b/django_forms_workflows/apps.py index 582290f..5019d61 100644 --- a/django_forms_workflows/apps.py +++ b/django_forms_workflows/apps.py @@ -16,6 +16,5 @@ def ready(self): """ Import signal handlers and perform app initialization. """ - # Import signals if you have any - # from . import signals - pass + # Import signals to register handlers + from . import signals # noqa: F401 diff --git a/django_forms_workflows/data_sources/database_source.py b/django_forms_workflows/data_sources/database_source.py index 3f2b3dc..ca74302 100644 --- a/django_forms_workflows/data_sources/database_source.py +++ b/django_forms_workflows/data_sources/database_source.py @@ -256,3 +256,170 @@ def is_available(self) -> bool: def get_display_name(self) -> str: return "External Database" + + def test_connection(self, database_alias: str = None) -> bool: + """ + Test the database connection. + + Args: + database_alias: Database alias to test (uses config default if not provided) + + Returns: + True if connection successful, False otherwise + """ + if database_alias is None: + config = self._get_config() + database_alias = config.get("database_alias") + + if not database_alias: + logger.error("No database alias configured") + return False + + try: + with connections[database_alias].cursor() as cursor: + # Try to get database version + engine = connections[database_alias].settings_dict.get("ENGINE", "") + + if "mssql" in engine or "sql_server" in engine: + cursor.execute("SELECT @@VERSION") + elif "postgresql" in engine or "postgis" in engine: + cursor.execute("SELECT version()") + elif "mysql" in engine: + cursor.execute("SELECT VERSION()") + elif "sqlite" in engine: + cursor.execute("SELECT sqlite_version()") + else: + cursor.execute("SELECT 1") + + result = cursor.fetchone() + if result: + logger.info( + f"Database connection successful for '{database_alias}'" + ) + return True + + return False + + except Exception as e: + logger.error(f"Database connection test failed for '{database_alias}': {e}") + return False + + def get_available_tables( + self, schema: str = None, database_alias: str = None + ) -> list: + """ + Get list of available tables in a schema. + + Args: + schema: Schema name (uses config default if not provided) + database_alias: Database alias (uses config default if not provided) + + Returns: + List of table names + """ + config = self._get_config() + + if schema is None: + schema = config.get("default_schema", "dbo") + + if database_alias is None: + database_alias = config.get("database_alias") + + if not database_alias: + logger.error("No database alias configured") + return [] + + if not self._is_safe_identifier(schema): + logger.error(f"Invalid schema name: {schema}") + return [] + + try: + query = """ + SELECT TABLE_NAME + FROM INFORMATION_SCHEMA.TABLES + WHERE TABLE_SCHEMA = %s + AND TABLE_TYPE = 'BASE TABLE' + ORDER BY TABLE_NAME + """ + + with connections[database_alias].cursor() as cursor: + cursor.execute(query, [schema]) + rows = cursor.fetchall() + return [row[0] for row in rows] + + except Exception as e: + logger.error(f"Error getting tables from {schema}: {e}") + return [] + + def get_table_columns( + self, table: str, schema: str = None, database_alias: str = None + ) -> list: + """ + Get list of columns in a table. + + Args: + table: Table name + schema: Schema name (uses config default if not provided) + database_alias: Database alias (uses config default if not provided) + + Returns: + List of dictionaries with column information: + [ + { + 'name': 'COLUMN_NAME', + 'type': 'DATA_TYPE', + 'max_length': 100, + 'nullable': True + }, + ... + ] + """ + config = self._get_config() + + if schema is None: + schema = config.get("default_schema", "dbo") + + if database_alias is None: + database_alias = config.get("database_alias") + + if not database_alias: + logger.error("No database alias configured") + return [] + + if not self._is_safe_identifier(schema) or not self._is_safe_identifier(table): + logger.error(f"Invalid schema or table name: {schema}.{table}") + return [] + + try: + query = """ + SELECT + COLUMN_NAME, + DATA_TYPE, + CHARACTER_MAXIMUM_LENGTH, + IS_NULLABLE + FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = %s + AND TABLE_NAME = %s + ORDER BY ORDINAL_POSITION + """ + + with connections[database_alias].cursor() as cursor: + cursor.execute(query, [schema, table]) + rows = cursor.fetchall() + + columns = [] + for row in rows: + columns.append( + { + "name": row[0], + "type": row[1], + "max_length": row[2], + "nullable": row[3] == "YES", + } + ) + + return columns + + except Exception as e: + logger.error(f"Error getting columns from {schema}.{table}: {e}") + return [] diff --git a/django_forms_workflows/management/commands/sync_ldap_profiles.py b/django_forms_workflows/management/commands/sync_ldap_profiles.py new file mode 100644 index 0000000..1a2331e --- /dev/null +++ b/django_forms_workflows/management/commands/sync_ldap_profiles.py @@ -0,0 +1,135 @@ +""" +Management command to sync LDAP attributes to UserProfile for all users. +""" + +from django.contrib.auth.models import User +from django.core.management.base import BaseCommand + +from django_forms_workflows.models import UserProfile +from django_forms_workflows.signals import sync_ldap_attributes + + +class Command(BaseCommand): + """Sync LDAP attributes to UserProfile for all users.""" + + help = "Sync LDAP attributes to UserProfile for all users" + + def add_arguments(self, parser): + parser.add_argument( + "--username", + type=str, + help="Sync only for specific username", + ) + parser.add_argument( + "--dry-run", + action="store_true", + help="Show what would be synced without making changes", + ) + parser.add_argument( + "--verbose", + action="store_true", + help="Show detailed output", + ) + + def handle(self, *args, **options): + username = options.get("username") + dry_run = options.get("dry_run", False) + verbose = options.get("verbose", False) + + # Get users to sync + if username: + users = User.objects.filter(username=username) + if not users.exists(): + self.stdout.write( + self.style.ERROR(f"User '{username}' not found") + ) + return + else: + users = User.objects.all() + + total_users = users.count() + self.stdout.write(f"Syncing LDAP attributes for {total_users} user(s)...") + + if dry_run: + self.stdout.write( + self.style.WARNING("DRY RUN MODE - No changes will be made") + ) + + synced_count = 0 + error_count = 0 + + for user in users: + try: + if verbose: + self.stdout.write(f"Processing user: {user.username}") + + if not dry_run: + # Get or create profile + profile, created = UserProfile.objects.get_or_create(user=user) + + # Sync LDAP attributes + sync_ldap_attributes(user, profile) + + if created: + if verbose: + self.stdout.write( + self.style.SUCCESS( + f" Created profile for {user.username}" + ) + ) + else: + if verbose: + self.stdout.write( + self.style.SUCCESS( + f" Updated profile for {user.username}" + ) + ) + + synced_count += 1 + else: + # Dry run - just check if user has LDAP attributes + ldap_user = getattr(user, "ldap_user", None) + if ldap_user: + if verbose: + self.stdout.write( + f" Would sync LDAP attributes for {user.username}" + ) + synced_count += 1 + else: + if verbose: + self.stdout.write( + self.style.WARNING( + f" No LDAP attributes found for {user.username}" + ) + ) + + except Exception as e: + error_count += 1 + self.stdout.write( + self.style.ERROR( + f"Error syncing user {user.username}: {e}" + ) + ) + + # Summary + self.stdout.write("\n" + "=" * 50) + if dry_run: + self.stdout.write( + self.style.SUCCESS( + f"DRY RUN: Would sync {synced_count} user(s)" + ) + ) + else: + self.stdout.write( + self.style.SUCCESS( + f"Successfully synced {synced_count} user(s)" + ) + ) + + if error_count > 0: + self.stdout.write( + self.style.ERROR(f"Errors: {error_count}") + ) + + self.stdout.write("=" * 50) + diff --git a/django_forms_workflows/management/commands/test_db_connection.py b/django_forms_workflows/management/commands/test_db_connection.py new file mode 100644 index 0000000..1278096 --- /dev/null +++ b/django_forms_workflows/management/commands/test_db_connection.py @@ -0,0 +1,138 @@ +""" +Management command to test external database connections. +""" + +from django.core.management.base import BaseCommand +from django.db import connections + + +class Command(BaseCommand): + """Test external database connection.""" + + help = "Test external database connection" + + def add_arguments(self, parser): + parser.add_argument( + "--database", + type=str, + default="default", + help="Database alias to test (default: 'default')", + ) + parser.add_argument( + "--verbose", + action="store_true", + help="Show detailed output", + ) + + def handle(self, *args, **options): + database_alias = options.get("database", "default") + verbose = options.get("verbose", False) + + self.stdout.write(f"Testing connection to database: {database_alias}") + self.stdout.write("=" * 50) + + # Check if database exists in settings + if database_alias not in connections: + self.stdout.write( + self.style.ERROR( + f"Database '{database_alias}' not found in settings.DATABASES" + ) + ) + return + + try: + # Get database connection + connection = connections[database_alias] + + if verbose: + self.stdout.write(f"Database engine: {connection.settings_dict['ENGINE']}") + self.stdout.write(f"Database name: {connection.settings_dict['NAME']}") + self.stdout.write(f"Database host: {connection.settings_dict.get('HOST', 'N/A')}") + self.stdout.write(f"Database port: {connection.settings_dict.get('PORT', 'N/A')}") + self.stdout.write("") + + # Test connection + with connection.cursor() as cursor: + # Try to get database version + engine = connection.settings_dict["ENGINE"] + + if "mssql" in engine or "sql_server" in engine: + # SQL Server + cursor.execute("SELECT @@VERSION") + version = cursor.fetchone() + self.stdout.write( + self.style.SUCCESS("✓ Connection successful!") + ) + if verbose and version: + self.stdout.write(f"Version: {version[0][:100]}...") + + elif "postgresql" in engine or "postgis" in engine: + # PostgreSQL + cursor.execute("SELECT version()") + version = cursor.fetchone() + self.stdout.write( + self.style.SUCCESS("✓ Connection successful!") + ) + if verbose and version: + self.stdout.write(f"Version: {version[0][:100]}...") + + elif "mysql" in engine: + # MySQL + cursor.execute("SELECT VERSION()") + version = cursor.fetchone() + self.stdout.write( + self.style.SUCCESS("✓ Connection successful!") + ) + if verbose and version: + self.stdout.write(f"Version: {version[0]}") + + elif "sqlite" in engine: + # SQLite + cursor.execute("SELECT sqlite_version()") + version = cursor.fetchone() + self.stdout.write( + self.style.SUCCESS("✓ Connection successful!") + ) + if verbose and version: + self.stdout.write(f"Version: {version[0]}") + + else: + # Generic test + cursor.execute("SELECT 1") + result = cursor.fetchone() + if result and result[0] == 1: + self.stdout.write( + self.style.SUCCESS("✓ Connection successful!") + ) + else: + self.stdout.write( + self.style.WARNING("⚠ Connection test returned unexpected result") + ) + + # Test a simple query + if verbose: + self.stdout.write("\nTesting query execution...") + cursor.execute("SELECT 1 AS test") + result = cursor.fetchone() + if result and result[0] == 1: + self.stdout.write( + self.style.SUCCESS("✓ Query execution successful!") + ) + + except Exception as e: + self.stdout.write( + self.style.ERROR(f"✗ Connection test failed: {e}") + ) + if verbose: + import traceback + self.stdout.write("\nFull error:") + self.stdout.write(traceback.format_exc()) + return + + self.stdout.write("=" * 50) + self.stdout.write( + self.style.SUCCESS( + f"Database '{database_alias}' is accessible and working correctly." + ) + ) + diff --git a/django_forms_workflows/migrations/0007_add_userprofile_ldap_enhancements.py b/django_forms_workflows/migrations/0007_add_userprofile_ldap_enhancements.py new file mode 100644 index 0000000..416a47d --- /dev/null +++ b/django_forms_workflows/migrations/0007_add_userprofile_ldap_enhancements.py @@ -0,0 +1,43 @@ +# Generated by Django 5.2.7 on 2025-11-06 02:54 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ( + "django_forms_workflows", + "0006_workflowdefinition_visual_workflow_data_and_more", + ), + ] + + operations = [ + migrations.AddField( + model_name="userprofile", + name="ldap_last_sync", + field=models.DateTimeField( + blank=True, help_text="Last time LDAP attributes were synced", null=True + ), + ), + migrations.AlterField( + model_name="userprofile", + name="employee_id", + field=models.CharField( + blank=True, + db_index=True, + help_text="Employee ID from LDAP or HR system (e.g., extensionAttribute1)", + max_length=50, + ), + ), + migrations.AlterField( + model_name="userprofile", + name="external_id", + field=models.CharField( + blank=True, + db_index=True, + help_text="ID from external system (for database lookups)", + max_length=100, + ), + ), + ] diff --git a/django_forms_workflows/models.py b/django_forms_workflows/models.py index fc67076..c4fa932 100644 --- a/django_forms_workflows/models.py +++ b/django_forms_workflows/models.py @@ -909,11 +909,15 @@ class UserProfile(models.Model): # External System IDs employee_id = models.CharField( - max_length=50, blank=True, help_text="Employee ID from LDAP or HR system" + max_length=50, + blank=True, + db_index=True, + help_text="Employee ID from LDAP or HR system (e.g., extensionAttribute1)", ) external_id = models.CharField( max_length=100, blank=True, + db_index=True, help_text="ID from external system (for database lookups)", ) @@ -936,6 +940,9 @@ class UserProfile(models.Model): ) # Metadata + ldap_last_sync = models.DateTimeField( + null=True, blank=True, help_text="Last time LDAP attributes were synced" + ) created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) @@ -946,6 +953,28 @@ class Meta: def __str__(self): return f"Profile for {self.user.username}" + @property + def full_name(self): + """Get user's full name.""" + return self.user.get_full_name() or self.user.username + + @property + def display_name(self): + """Get display name with title if available.""" + if self.title: + return f"{self.full_name} ({self.title})" + return self.full_name + + @property + def id_number(self): + """Alias for employee_id for backward compatibility.""" + return self.employee_id + + @id_number.setter + def id_number(self, value): + """Set employee_id via id_number alias.""" + self.employee_id = value + class FormTemplate(models.Model): """ diff --git a/django_forms_workflows/signals.py b/django_forms_workflows/signals.py new file mode 100644 index 0000000..dc5c035 --- /dev/null +++ b/django_forms_workflows/signals.py @@ -0,0 +1,152 @@ +""" +Signal handlers for django-forms-workflows. + +Handles automatic UserProfile creation and LDAP attribute synchronization. +""" + +import logging +from datetime import datetime + +from django.conf import settings +from django.contrib.auth.models import User +from django.contrib.auth.signals import user_logged_in +from django.db.models.signals import post_save +from django.dispatch import receiver +from django.utils import timezone + +logger = logging.getLogger(__name__) + + +def get_ldap_attribute(user, attr_name, ldap_attr_name=None): + """ + Get LDAP attribute for a user. + + Args: + user: Django User object + attr_name: Name of the attribute to retrieve (for logging) + ldap_attr_name: Actual LDAP attribute name (defaults to attr_name) + + Returns: + String value of the attribute or empty string if not found + """ + if not user: + return "" + + ldap_attr_name = ldap_attr_name or attr_name + + # Try to get LDAP attributes from user object + # These would be populated by django-auth-ldap + ldap_user = getattr(user, "ldap_user", None) + if ldap_user: + try: + attrs = ldap_user.attrs + if ldap_attr_name in attrs: + value = attrs[ldap_attr_name] + # LDAP attributes are often lists + if isinstance(value, list) and value: + return ( + value[0].decode("utf-8") + if isinstance(value[0], bytes) + else str(value[0]) + ) + return value.decode("utf-8") if isinstance(value, bytes) else str(value) + except Exception as e: + logger.warning( + f"Error getting LDAP attribute {ldap_attr_name} for user {user.username}: {e}" + ) + + return "" + + +def sync_ldap_attributes(user, profile=None): + """ + Sync LDAP attributes to UserProfile. + + Args: + user: Django User object + profile: UserProfile object (optional, will be fetched if not provided) + + Returns: + UserProfile object or None if sync failed + """ + from django_forms_workflows.models import UserProfile + + # Get or create profile + if profile is None: + profile, created = UserProfile.objects.get_or_create(user=user) + else: + created = False + + # Check if LDAP sync is enabled + forms_workflows_settings = getattr(settings, "FORMS_WORKFLOWS", {}) + ldap_sync_settings = forms_workflows_settings.get("LDAP_SYNC", {}) + + if not ldap_sync_settings.get("enabled", False): + return profile + + # Get LDAP attribute mappings + attribute_mappings = ldap_sync_settings.get( + "attributes", + { + "employee_id": "extensionAttribute1", + "department": "department", + "title": "title", + "phone": "telephoneNumber", + "manager_dn": "manager", + }, + ) + + # Sync each attribute + updated = False + for profile_field, ldap_attr in attribute_mappings.items(): + if hasattr(profile, profile_field): + value = get_ldap_attribute(user, profile_field, ldap_attr) + if value: + current_value = getattr(profile, profile_field, "") + if current_value != value: + setattr(profile, profile_field, value) + updated = True + logger.debug( + f"Updated {profile_field} for {user.username}: {value}" + ) + + # Update sync timestamp if any changes were made + if updated or created: + profile.ldap_last_sync = timezone.now() + profile.save() + logger.info(f"LDAP attributes synced for user {user.username}") + + return profile + + +@receiver(post_save, sender=User) +def create_user_profile(sender, instance, created, **kwargs): + """ + Create UserProfile when User is created. + + This ensures every user has a profile. + """ + from django_forms_workflows.models import UserProfile + + if created: + UserProfile.objects.get_or_create(user=instance) + logger.debug(f"Created UserProfile for {instance.username}") + + +@receiver(user_logged_in) +def sync_ldap_on_login(sender, user, request, **kwargs): + """ + Sync LDAP attributes to UserProfile when user logs in. + + This keeps the profile up-to-date with LDAP data. + Only runs if LDAP_SYNC.sync_on_login is True in settings. + """ + forms_workflows_settings = getattr(settings, "FORMS_WORKFLOWS", {}) + ldap_sync_settings = forms_workflows_settings.get("LDAP_SYNC", {}) + + if ldap_sync_settings.get("sync_on_login", False): + try: + sync_ldap_attributes(user) + except Exception as e: + logger.error(f"Error syncing LDAP attributes for {user.username}: {e}") + diff --git a/django_forms_workflows/utils.py b/django_forms_workflows/utils.py index 02424ca..d3ba958 100644 --- a/django_forms_workflows/utils.py +++ b/django_forms_workflows/utils.py @@ -1,8 +1,21 @@ +""" +Utility functions for django-forms-workflows. + +Provides helper functions for permissions, LDAP integration, and workflow logic. +""" + +import logging +import re +from decimal import Decimal +from typing import Optional + from django.contrib.auth.models import User from django.db import models from .models import FormDefinition, FormSubmission +logger = logging.getLogger(__name__) + def user_can_submit_form(user: User, form_def: FormDefinition) -> bool: """Return True if the user is allowed to submit the given form. @@ -43,3 +56,234 @@ def user_can_approve(user: User, submission: FormSubmission) -> bool: return submission.approval_tasks.filter( models.Q(assigned_to=user) | models.Q(assigned_group__in=user_groups) ).exists() + + +def get_ldap_attribute(user, attr_name: str, ldap_attr_name: str = None) -> str: + """ + Get LDAP attribute for a user. + + Args: + user: Django User object + attr_name: Name of the attribute to retrieve (for logging) + ldap_attr_name: Actual LDAP attribute name (defaults to attr_name) + + Returns: + String value of the attribute or empty string if not found + """ + if not user: + return "" + + ldap_attr_name = ldap_attr_name or attr_name + + # Try to get LDAP attributes from user object + # These would be populated by django-auth-ldap + ldap_user = getattr(user, "ldap_user", None) + if ldap_user: + try: + attrs = ldap_user.attrs + + # Common LDAP attribute mappings + attr_map = { + "department": "department", + "title": "title", + "manager": "manager", + "phone": "telephoneNumber", + "employee_id": "employeeNumber", + "email": "mail", + "first_name": "givenName", + "last_name": "sn", + "display_name": "displayName", + } + + # Use mapping if available, otherwise use provided name + ldap_attr = attr_map.get(ldap_attr_name, ldap_attr_name) + + if ldap_attr in attrs: + value = attrs[ldap_attr] + # LDAP attributes are often lists + if isinstance(value, list) and value: + return ( + value[0].decode("utf-8") + if isinstance(value[0], bytes) + else str(value[0]) + ) + return value.decode("utf-8") if isinstance(value, bytes) else str(value) + + except Exception as e: + logger.warning( + f"Error getting LDAP attribute {ldap_attr_name} for user {user.username}: {e}" + ) + + return "" + + +def get_user_manager(user) -> Optional[User]: + """ + Get the manager of a user from LDAP or UserProfile. + + Args: + user: Django User object + + Returns: + User object of the manager or None + """ + if not user: + return None + + # First try to get manager from UserProfile + try: + from django_forms_workflows.models import UserProfile + + profile = UserProfile.objects.filter(user=user).first() + if profile and profile.manager: + return profile.manager + except Exception as e: + logger.debug(f"Could not get manager from UserProfile: {e}") + + # Try to get manager DN from LDAP + manager_dn = get_ldap_attribute(user, "manager") + if not manager_dn: + return None + + # Try to find user by manager DN + # This is a simplified version - actual implementation depends on LDAP structure + try: + # Extract CN from DN (e.g., "CN=John Doe,OU=Users,DC=example,DC=com") + cn_match = re.search(r"CN=([^,]+)", manager_dn) + if cn_match: + manager_cn = cn_match.group(1) + + # Try to find user by full name or username + # This is a best-effort approach + manager = User.objects.filter( + first_name__icontains=manager_cn.split()[0] + ).first() + if manager: + return manager + + except Exception as e: + logger.warning(f"Error getting manager for user {user.username}: {e}") + + return None + + +def user_can_view_form(user, form_definition: FormDefinition) -> bool: + """ + Check if a user can view a specific form. + + Args: + user: Django User object + form_definition: FormDefinition object + + Returns: + Boolean indicating if user can view + """ + # Public forms can be viewed by anyone + if not form_definition.requires_login: + return True + + # Must be authenticated + if not user or not user.is_authenticated: + return False + + # Superusers can view any form + if user.is_superuser: + return True + + # Check if user is in any of the view groups + view_groups = form_definition.view_groups.all() + if not view_groups.exists(): + # No groups specified means any authenticated user can view + return True + + return user.groups.filter(id__in=view_groups).exists() + + +def user_can_view_submission(user, submission: FormSubmission) -> bool: + """ + Check if a user can view a specific submission. + + Args: + user: Django User object + submission: FormSubmission object + + Returns: + Boolean indicating if user can view the submission + """ + if not user or not user.is_authenticated: + return False + + # Superusers can view anything + if user.is_superuser: + return True + + # Submitter can view their own submission + if submission.submitter == user: + return True + + # Approvers can view submissions they need to approve + if user_can_approve(user, submission): + return True + + # Form admins can view submissions + if user.groups.filter( + id__in=submission.form_definition.admin_groups.all() + ).exists(): + return True + + return False + + +def check_escalation_needed(submission: FormSubmission) -> bool: + """ + Check if a submission needs escalation based on workflow rules. + + Args: + submission: FormSubmission object + + Returns: + Boolean indicating if escalation is needed + """ + workflow = getattr(submission.form_definition, "workflow", None) + if not workflow: + return False + + if not workflow.escalation_field or not workflow.escalation_threshold: + return False + + # Check if the escalation field value exceeds threshold + field_value = submission.form_data.get(workflow.escalation_field) + if field_value is None: + return False + + try: + value = Decimal(str(field_value)) + threshold = workflow.escalation_threshold + return value > threshold + except (ValueError, TypeError): + logger.warning( + f"Could not compare escalation field value: {field_value} for submission {submission.id}" + ) + return False + + +def sync_ldap_groups(): + """ + Synchronize LDAP groups to Django groups. + + This should be run periodically to keep groups in sync. + Note: This is a placeholder - actual implementation depends on LDAP structure. + """ + try: + from django_auth_ldap.backend import LDAPBackend + + backend = LDAPBackend() + + # This would need to be implemented based on your LDAP structure + # and how you want to sync groups + logger.info("LDAP group sync completed") + + except ImportError: + logger.warning("django-auth-ldap not installed, skipping group sync") + except Exception as e: + logger.error(f"Error syncing LDAP groups: {e}") diff --git a/pyproject.toml b/pyproject.toml index f2eb56a..63ba040 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "django-forms-workflows" -version = "0.3.0" +version = "0.4.0" description = "Enterprise-grade, database-driven form builder with approval workflows and external data integration" license = "LGPL-3.0-only" readme = "README.md"