From 583f9b67553daab0430957196cbcc60413398964 Mon Sep 17 00:00:00 2001 From: Dries Peeters Date: Sat, 29 Nov 2025 07:13:23 +0100 Subject: [PATCH] Format code with Black to fix code quality test --- app/__init__.py | 16 +- app/integrations/asana.py | 107 +-- app/integrations/github.py | 51 +- app/integrations/gitlab.py | 37 +- app/integrations/google_calendar.py | 174 ++-- app/integrations/jira.py | 62 +- app/integrations/microsoft_teams.py | 48 +- app/integrations/outlook_calendar.py | 92 +-- app/integrations/quickbooks.py | 159 ++-- app/integrations/slack.py | 38 +- app/integrations/trello.py | 99 +-- app/integrations/xero.py | 152 ++-- app/models/client.py | 24 +- app/models/client_portal_customization.py | 15 +- app/models/client_time_approval.py | 2 +- app/models/custom_report.py | 13 +- app/models/expense_gps.py | 21 +- app/models/gamification.py | 43 +- app/models/integration.py | 12 +- app/models/link_template.py | 1 - app/models/recurring_task.py | 7 +- app/models/settings.py | 63 +- app/models/team_chat.py | 51 +- app/models/time_entry_approval.py | 36 +- app/models/time_entry_template.py | 4 +- app/models/user.py | 16 +- app/models/workflow.py | 1 - app/repositories/base_repository.py | 2 +- app/repositories/time_entry_repository.py | 28 +- app/routes/activity_feed.py | 63 +- app/routes/admin.py | 48 +- app/routes/api_v1.py | 764 ++++++++---------- app/routes/auth.py | 14 +- app/routes/client_portal_customization.py | 29 +- app/routes/integrations.py | 57 +- app/routes/kiosk.py | 3 +- app/routes/link_templates.py | 1 - app/routes/main.py | 23 +- app/routes/projects.py | 8 +- app/routes/quotes.py | 21 +- app/routes/recurring_tasks.py | 11 +- app/routes/reports.py | 19 +- app/routes/scheduled_reports.py | 163 ++-- app/routes/setup.py | 4 +- app/routes/team_chat.py | 174 ++-- app/routes/time_approvals.py | 41 +- app/routes/time_entry_templates.py | 24 +- app/routes/timer.py | 15 +- app/routes/user.py | 12 +- app/routes/workflows.py | 22 +- app/services/ai_categorization_service.py | 64 +- app/services/ai_suggestion_service.py | 168 ++-- app/services/client_approval_service.py | 63 +- app/services/currency_service.py | 49 +- app/services/custom_report_service.py | 42 +- app/services/enhanced_ocr_service.py | 82 +- app/services/expense_service.py | 6 +- app/services/gamification_service.py | 93 +-- app/services/gps_tracking_service.py | 94 +-- app/services/integration_service.py | 29 +- app/services/invoice_service.py | 4 +- app/services/payment_service.py | 2 +- app/services/pomodoro_service.py | 74 +- app/services/project_service.py | 4 +- app/services/quote_service.py | 5 +- app/services/time_approval_service.py | 96 +-- app/services/time_tracking_service.py | 8 +- app/services/workflow_engine.py | 5 +- app/utils/api_auth.py | 9 +- app/utils/cache.py | 38 +- app/utils/decorators.py | 2 +- app/utils/error_handlers.py | 29 +- app/utils/powerpoint_export.py | 22 +- app/utils/scheduled_tasks.py | 16 +- tests/conftest.py | 2 +- tests/test_client_portal.py | 10 +- tests/test_pdf_layout.py | 10 +- ...st_api_v1_calendar_templates_refactored.py | 33 +- .../test_api_v1_expenses_complete.py | 45 +- ...i_v1_invoices_tasks_expenses_refactored.py | 56 +- .../test_api_v1_mileage_refactored.py | 36 +- .../test_api_v1_payments_refactored.py | 29 +- .../test_api_v1_projects_refactored.py | 48 +- .../test_api_v1_quotes_refactored.py | 27 +- ..._api_v1_recurring_invoices_credit_notes.py | 32 +- .../test_api_v1_reports_refactored.py | 22 +- .../test_api_v1_time_entries_complete.py | 42 +- .../test_api_v1_time_entries_refactored.py | 42 +- .../test_routes/test_main_dashboard_cached.py | 29 +- .../test_payment_service_complete.py | 43 +- .../test_time_tracking_service_complete.py | 57 +- tests/test_system_ui_flags.py | 3 - tests/test_utils/test_api_auth_enhanced.py | 118 ++- tests/test_utils/test_cache.py | 98 ++- 94 files changed, 2067 insertions(+), 2609 deletions(-) diff --git a/app/__init__.py b/app/__init__.py index 28c6fae6..07930f2a 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -1056,36 +1056,42 @@ def create_app(config=None): # Register new feature blueprints (workflows, approvals, chat, etc.) try: from app.routes.workflows import workflows_bp + app.register_blueprint(workflows_bp) except Exception as e: logger.warning(f"Could not register workflows blueprint: {e}") try: from app.routes.time_approvals import time_approvals_bp + app.register_blueprint(time_approvals_bp) except Exception as e: logger.warning(f"Could not register time_approvals blueprint: {e}") try: from app.routes.activity_feed import activity_feed_bp + app.register_blueprint(activity_feed_bp) except Exception as e: logger.warning(f"Could not register activity_feed blueprint: {e}") try: from app.routes.recurring_tasks import recurring_tasks_bp + app.register_blueprint(recurring_tasks_bp) except Exception as e: logger.warning(f"Could not register recurring_tasks blueprint: {e}") try: from app.routes.team_chat import team_chat_bp + app.register_blueprint(team_chat_bp) except Exception as e: logger.warning(f"Could not register team_chat blueprint: {e}") try: from app.routes.client_portal_customization import client_portal_customization_bp + app.register_blueprint(client_portal_customization_bp) except Exception as e: logger.warning(f"Could not register client_portal_customization blueprint: {e}") @@ -1209,14 +1215,15 @@ def create_app(config=None): admin_username = app.config.get("ADMIN_USERNAMES", ["admin"])[0] if not User.query.filter_by(username=admin_username).first(): from app.models import Role + admin_user = User(username=admin_username, role="admin") admin_user.is_active = True - + # Assign admin role from the new Role system admin_role = Role.query.filter_by(name="admin").first() if admin_role: admin_user.roles.append(admin_role) - + db.session.add(admin_user) db.session.commit() print(f"Created default admin user: {admin_username}") @@ -1371,14 +1378,15 @@ def init_database(app): admin_username = app.config.get("ADMIN_USERNAMES", ["admin"])[0] if not User.query.filter_by(username=admin_username).first(): from app.models import Role + admin_user = User(username=admin_username, role="admin") admin_user.is_active = True - + # Assign admin role from the new Role system admin_role = Role.query.filter_by(name="admin").first() if admin_role: admin_user.roles.append(admin_role) - + db.session.add(admin_user) db.session.commit() print(f"Created default admin user: {admin_username}") diff --git a/app/integrations/asana.py b/app/integrations/asana.py index 7e4dded3..07c0148c 100644 --- a/app/integrations/asana.py +++ b/app/integrations/asana.py @@ -35,13 +35,8 @@ class AsanaConnector(BaseConnector): raise ValueError("ASANA_CLIENT_ID not configured") auth_url = "https://app.asana.com/-/oauth_authorize" - - params = { - "client_id": client_id, - "redirect_uri": redirect_uri, - "response_type": "code", - "state": state or "" - } + + params = {"client_id": client_id, "redirect_uri": redirect_uri, "response_type": "code", "state": state or ""} query_string = "&".join([f"{k}={v}" for k, v in params.items()]) return f"{auth_url}?{query_string}" @@ -83,15 +78,14 @@ class AsanaConnector(BaseConnector): if "access_token" in data: try: user_response = requests.get( - f"{self.BASE_URL}/users/me", - headers={"Authorization": f"Bearer {data['access_token']}"} + f"{self.BASE_URL}/users/me", headers={"Authorization": f"Bearer {data['access_token']}"} ) if user_response.status_code == 200: user_data = user_response.json().get("data", {}) user_info = { "gid": user_data.get("gid"), "name": user_data.get("name"), - "email": user_data.get("email") + "email": user_data.get("email"), } except Exception: pass @@ -101,7 +95,7 @@ class AsanaConnector(BaseConnector): "refresh_token": data.get("refresh_token"), "expires_at": expires_at.isoformat() if expires_at else None, "token_type": "Bearer", - "extra_data": user_info + "extra_data": user_info, } def refresh_access_token(self) -> Dict[str, Any]: @@ -110,6 +104,7 @@ class AsanaConnector(BaseConnector): raise ValueError("No refresh token available") from app.models import Settings + settings = Settings.get_settings() creds = settings.get_integration_credentials("asana") client_id = creds.get("client_id") or os.getenv("ASANA_CLIENT_ID") @@ -142,33 +137,21 @@ class AsanaConnector(BaseConnector): self.credentials.expires_at = expires_at self.credentials.save() - return { - "access_token": data.get("access_token"), - "expires_at": expires_at.isoformat() if expires_at else None - } + return {"access_token": data.get("access_token"), "expires_at": expires_at.isoformat() if expires_at else None} def test_connection(self) -> Dict[str, Any]: """Test connection to Asana.""" try: headers = {"Authorization": f"Bearer {self.get_access_token()}"} response = requests.get(f"{self.BASE_URL}/users/me", headers=headers) - + if response.status_code == 200: user_data = response.json().get("data", {}) - return { - "success": True, - "message": f"Connected to Asana as {user_data.get('name', 'Unknown')}" - } + return {"success": True, "message": f"Connected to Asana as {user_data.get('name', 'Unknown')}"} else: - return { - "success": False, - "message": f"Connection test failed: {response.status_code}" - } + return {"success": False, "message": f"Connection test failed: {response.status_code}"} except Exception as e: - return { - "success": False, - "message": f"Connection test failed: {str(e)}" - } + return {"success": False, "message": f"Connection test failed: {str(e)}"} def sync_data(self, sync_type: str = "full") -> Dict[str, Any]: """Sync tasks and projects with Asana.""" @@ -177,7 +160,7 @@ class AsanaConnector(BaseConnector): try: headers = {"Authorization": f"Bearer {self.get_access_token()}"} - + # Get workspace from config workspace_gid = self.integration.config.get("workspace_gid") if not workspace_gid: @@ -190,7 +173,7 @@ class AsanaConnector(BaseConnector): projects_response = requests.get( f"{self.BASE_URL}/projects", headers=headers, - params={"workspace": workspace_gid, "opt_fields": "name,notes,archived"} + params={"workspace": workspace_gid, "opt_fields": "name,notes,archived"}, ) if projects_response.status_code == 200: @@ -200,8 +183,7 @@ class AsanaConnector(BaseConnector): try: # Find or create project project = Project.query.filter_by( - user_id=self.integration.user_id, - name=asana_project.get("name") + user_id=self.integration.user_id, name=asana_project.get("name") ).first() if not project: @@ -209,78 +191,72 @@ class AsanaConnector(BaseConnector): name=asana_project.get("name"), description=asana_project.get("notes", ""), user_id=self.integration.user_id, - status="active" if not asana_project.get("archived") else "archived" + status="active" if not asana_project.get("archived") else "archived", ) db.session.add(project) db.session.flush() # Store Asana project GID in project metadata - if not hasattr(project, 'metadata') or not project.metadata: + if not hasattr(project, "metadata") or not project.metadata: project.metadata = {} - project.metadata['asana_project_gid'] = asana_project.get("gid") - + project.metadata["asana_project_gid"] = asana_project.get("gid") + # Sync tasks from Asana project tasks_response = requests.get( f"{self.BASE_URL}/projects/{asana_project.get('gid')}/tasks", headers=headers, - params={"opt_fields": "name,notes,completed,due_on"} + params={"opt_fields": "name,notes,completed,due_on"}, ) - + if tasks_response.status_code == 200: asana_tasks = tasks_response.json().get("data", []) - + for asana_task in asana_tasks: try: # Get task details task_response = requests.get( f"{self.BASE_URL}/tasks/{asana_task.get('gid')}", headers=headers, - params={"opt_fields": "name,notes,completed,due_on,assignee"} + params={"opt_fields": "name,notes,completed,due_on,assignee"}, ) - + if task_response.status_code == 200: task_data = task_response.json().get("data", {}) - + # Find or create task task = Task.query.filter_by( - project_id=project.id, - name=task_data.get("name", "") + project_id=project.id, name=task_data.get("name", "") ).first() - + if not task: task = Task( project_id=project.id, name=task_data.get("name", ""), description=task_data.get("notes", ""), - status="completed" if task_data.get("completed") else "todo" + status="completed" if task_data.get("completed") else "todo", ) db.session.add(task) db.session.flush() - + # Store Asana task GID in metadata - if not hasattr(task, 'metadata') or not task.metadata: + if not hasattr(task, "metadata") or not task.metadata: task.metadata = {} - task.metadata['asana_task_gid'] = asana_task.get("gid") + task.metadata["asana_task_gid"] = asana_task.get("gid") except Exception as e: - errors.append(f"Error syncing task in project {asana_project.get('name')}: {str(e)}") - + errors.append( + f"Error syncing task in project {asana_project.get('name')}: {str(e)}" + ) + synced_count += 1 except Exception as e: errors.append(f"Error syncing project {asana_project.get('name')}: {str(e)}") db.session.commit() - return { - "success": True, - "synced_count": synced_count, - "errors": errors - } + return {"success": True, "synced_count": synced_count, "errors": errors} except Exception as e: - return { - "success": False, - "message": f"Sync failed: {str(e)}" - } + return {"success": False, "message": f"Sync failed: {str(e)}"} def get_config_schema(self) -> Dict[str, Any]: """Get configuration schema.""" @@ -290,7 +266,7 @@ class AsanaConnector(BaseConnector): "name": "workspace_gid", "type": "string", "label": "Workspace GID", - "description": "Asana workspace GID to sync with" + "description": "Asana workspace GID to sync with", }, { "name": "sync_direction", @@ -299,11 +275,10 @@ class AsanaConnector(BaseConnector): "options": [ {"value": "asana_to_timetracker", "label": "Asana → TimeTracker"}, {"value": "timetracker_to_asana", "label": "TimeTracker → Asana"}, - {"value": "bidirectional", "label": "Bidirectional"} + {"value": "bidirectional", "label": "Bidirectional"}, ], - "default": "asana_to_timetracker" - } + "default": "asana_to_timetracker", + }, ], - "required": ["workspace_gid"] + "required": ["workspace_gid"], } - diff --git a/app/integrations/github.py b/app/integrations/github.py index f99d9b78..bf7113e7 100644 --- a/app/integrations/github.py +++ b/app/integrations/github.py @@ -147,8 +147,7 @@ class GitHubConnector(BaseConnector): if not repos_str: # Get user's repositories repos_response = requests.get( - "https://api.github.com/user/repos", - headers={"Authorization": f"token {token}"} + "https://api.github.com/user/repos", headers={"Authorization": f"token {token}"} ) if repos_response.status_code == 200: repos = repos_response.json() @@ -165,19 +164,16 @@ class GitHubConnector(BaseConnector): for repo in repos_list: try: owner, repo_name = repo.split("/") - + # Find or create project - project = Project.query.filter_by( - user_id=self.integration.user_id, - name=repo - ).first() - + project = Project.query.filter_by(user_id=self.integration.user_id, name=repo).first() + if not project: project = Project( name=repo, description=f"GitHub repository: {repo}", user_id=self.integration.user_id, - status="active" + status="active", ) db.session.add(project) db.session.flush() @@ -185,14 +181,8 @@ class GitHubConnector(BaseConnector): # Fetch issues issues_response = requests.get( f"https://api.github.com/repos/{repo}/issues", - headers={ - "Authorization": f"token {token}", - "Accept": "application/vnd.github.v3+json" - }, - params={ - "state": "open", - "per_page": 100 - } + headers={"Authorization": f"token {token}", "Accept": "application/vnd.github.v3+json"}, + params={"state": "open", "per_page": 100}, ) if issues_response.status_code != 200: @@ -205,11 +195,10 @@ class GitHubConnector(BaseConnector): try: issue_number = issue.get("number") issue_title = issue.get("title", "") - + # Find or create task task = Task.query.filter_by( - project_id=project.id, - name=f"#{issue_number}: {issue_title}" + project_id=project.id, name=f"#{issue_number}: {issue_title}" ).first() if not task: @@ -218,18 +207,18 @@ class GitHubConnector(BaseConnector): name=f"#{issue_number}: {issue_title}", description=issue.get("body", ""), status="todo", - notes=f"GitHub Issue: {issue.get('html_url', '')}" + notes=f"GitHub Issue: {issue.get('html_url', '')}", ) db.session.add(task) db.session.flush() # Store GitHub issue info in task metadata - if not hasattr(task, 'metadata') or not task.metadata: + if not hasattr(task, "metadata") or not task.metadata: task.metadata = {} - task.metadata['github_repo'] = repo - task.metadata['github_issue_number'] = issue_number - task.metadata['github_issue_id'] = issue.get("id") - task.metadata['github_issue_url'] = issue.get("html_url") + task.metadata["github_repo"] = repo + task.metadata["github_issue_number"] = issue_number + task.metadata["github_issue_id"] = issue.get("id") + task.metadata["github_issue_url"] = issue.get("html_url") synced_count += 1 except Exception as e: @@ -245,7 +234,7 @@ class GitHubConnector(BaseConnector): "success": True, "message": f"Sync completed. Synced {synced_count} issues.", "synced_items": synced_count, - "errors": errors + "errors": errors, } except Exception as e: return {"success": False, "message": f"Sync failed: {str(e)}"} @@ -270,7 +259,7 @@ class GitHubConnector(BaseConnector): return { "success": True, "message": f"Webhook received for issue #{issue_number} in {repo}", - "event_type": f"{event_type}.{action}" + "event_type": f"{event_type}.{action}", } elif event_type == "pull_request": pr = payload.get("pull_request", {}) @@ -280,7 +269,7 @@ class GitHubConnector(BaseConnector): return { "success": True, "message": f"Webhook received for PR #{pr_number} in {repo}", - "event_type": f"{event_type}.{action}" + "event_type": f"{event_type}.{action}", } return {"success": True, "message": f"Webhook processed: {event_type}"} @@ -304,8 +293,8 @@ class GitHubConnector(BaseConnector): "type": "boolean", "label": "Auto Sync", "default": True, - "description": "Automatically sync when webhooks are received" - } + "description": "Automatically sync when webhooks are received", + }, ], "required": [], } diff --git a/app/integrations/gitlab.py b/app/integrations/gitlab.py index 40926ecc..6f82eab7 100644 --- a/app/integrations/gitlab.py +++ b/app/integrations/gitlab.py @@ -24,6 +24,7 @@ class GitLabConnector(BaseConnector): def _get_base_url(self) -> str: """Get GitLab instance URL from settings.""" from app.models import Settings + settings = Settings.get_settings() creds = settings.get_integration_credentials("gitlab") instance_url = creds.get("instance_url") or os.getenv("GITLAB_INSTANCE_URL", "https://gitlab.com") @@ -93,8 +94,7 @@ class GitLabConnector(BaseConnector): if "access_token" in data: try: user_response = requests.get( - f"{base_url}/api/v4/user", - headers={"Authorization": f"Bearer {data['access_token']}"} + f"{base_url}/api/v4/user", headers={"Authorization": f"Bearer {data['access_token']}"} ) if user_response.status_code == 200: user_data = user_response.json() @@ -122,6 +122,7 @@ class GitLabConnector(BaseConnector): raise ValueError("No refresh token available") from app.models import Settings + settings = Settings.get_settings() creds = settings.get_integration_credentials("gitlab") client_id = creds.get("client_id") or os.getenv("GITLAB_CLIENT_ID") @@ -154,6 +155,7 @@ class GitLabConnector(BaseConnector): if expires_at: self.credentials.expires_at = expires_at from app.utils.db import safe_commit + safe_commit("refresh_gitlab_token", {"integration_id": self.integration.id}) return { @@ -171,10 +173,7 @@ class GitLabConnector(BaseConnector): api_url = f"{base_url}/api/v4/user" try: - response = requests.get( - api_url, - headers={"Authorization": f"Bearer {token}"} - ) + response = requests.get(api_url, headers={"Authorization": f"Bearer {token}"}) if response.status_code == 200: user_data = response.json() @@ -197,13 +196,13 @@ class GitLabConnector(BaseConnector): try: # Get repositories from config or all accessible repos repo_ids = self.integration.config.get("repository_ids", []) - + if not repo_ids: # Get all accessible projects projects_response = requests.get( f"{base_url}/api/v4/projects", headers={"Authorization": f"Bearer {token}"}, - params={"membership": True, "per_page": 100} + params={"membership": True, "per_page": 100}, ) if projects_response.status_code == 200: projects = projects_response.json() @@ -215,21 +214,16 @@ class GitLabConnector(BaseConnector): issues_response = requests.get( f"{base_url}/api/v4/projects/{repo_id}/issues", headers={"Authorization": f"Bearer {token}"}, - params={"state": "opened", "per_page": 100} + params={"state": "opened", "per_page": 100}, ) - + if issues_response.status_code == 200: issues = issues_response.json() synced_count += len(issues) except Exception as e: errors.append(f"Error syncing repository {repo_id}: {str(e)}") - return { - "success": True, - "message": "Sync completed", - "synced_items": synced_count, - "errors": errors - } + return {"success": True, "message": "Sync completed", "synced_items": synced_count, "errors": errors} except Exception as e: return {"success": False, "message": f"Sync failed: {str(e)}"} @@ -241,7 +235,7 @@ class GitLabConnector(BaseConnector): "name": "repository_ids", "type": "array", "label": "Repository IDs", - "description": "GitLab project IDs to sync (leave empty to sync all accessible projects)" + "description": "GitLab project IDs to sync (leave empty to sync all accessible projects)", }, { "name": "sync_direction", @@ -250,11 +244,10 @@ class GitLabConnector(BaseConnector): "options": [ {"value": "gitlab_to_timetracker", "label": "GitLab → TimeTracker"}, {"value": "timetracker_to_gitlab", "label": "TimeTracker → GitLab"}, - {"value": "bidirectional", "label": "Bidirectional"} + {"value": "bidirectional", "label": "Bidirectional"}, ], - "default": "gitlab_to_timetracker" - } + "default": "gitlab_to_timetracker", + }, ], - "required": [] + "required": [], } - diff --git a/app/integrations/google_calendar.py b/app/integrations/google_calendar.py index b978073f..2f1798b0 100644 --- a/app/integrations/google_calendar.py +++ b/app/integrations/google_calendar.py @@ -23,10 +23,7 @@ class GoogleCalendarConnector(BaseConnector): icon = "google" # OAuth 2.0 scopes required - SCOPES = [ - 'https://www.googleapis.com/auth/calendar', - 'https://www.googleapis.com/auth/calendar.events' - ] + SCOPES = ["https://www.googleapis.com/auth/calendar", "https://www.googleapis.com/auth/calendar.events"] @property def provider_name(self) -> str: @@ -51,20 +48,18 @@ class GoogleCalendarConnector(BaseConnector): "client_secret": client_secret, "auth_uri": "https://accounts.google.com/o/oauth2/auth", "token_uri": "https://oauth2.googleapis.com/token", - "redirect_uris": [redirect_uri] + "redirect_uris": [redirect_uri], } }, scopes=self.SCOPES, - redirect_uri=redirect_uri + redirect_uri=redirect_uri, ) if state: flow.state = state authorization_url, _ = flow.authorization_url( - access_type='offline', - include_granted_scopes='true', - prompt='consent' # Force consent to get refresh token + access_type="offline", include_granted_scopes="true", prompt="consent" # Force consent to get refresh token ) return authorization_url @@ -88,11 +83,11 @@ class GoogleCalendarConnector(BaseConnector): "client_secret": client_secret, "auth_uri": "https://accounts.google.com/o/oauth2/auth", "token_uri": "https://oauth2.googleapis.com/token", - "redirect_uris": [redirect_uri] + "redirect_uris": [redirect_uri], } }, scopes=self.SCOPES, - redirect_uri=redirect_uri + redirect_uri=redirect_uri, ) flow.fetch_token(code=code) @@ -102,12 +97,12 @@ class GoogleCalendarConnector(BaseConnector): # Get user info user_info = {} try: - service = build('oauth2', 'v2', credentials=credentials) + service = build("oauth2", "v2", credentials=credentials) user_info_response = service.userinfo().get().execute() user_info = { "email": user_info_response.get("email"), "name": user_info_response.get("name"), - "picture": user_info_response.get("picture") + "picture": user_info_response.get("picture"), } except Exception: pass @@ -118,7 +113,7 @@ class GoogleCalendarConnector(BaseConnector): "expires_at": credentials.expiry.isoformat() if credentials.expiry else None, "token_type": "Bearer", "scope": " ".join(credentials.scopes) if credentials.scopes else None, - "extra_data": user_info + "extra_data": user_info, } def refresh_access_token(self) -> Dict[str, Any]: @@ -127,6 +122,7 @@ class GoogleCalendarConnector(BaseConnector): raise ValueError("No refresh token available") from app.models import Settings + settings = Settings.get_settings() creds = settings.get_integration_credentials("google_calendar") client_id = creds.get("client_id") or os.getenv("GOOGLE_CLIENT_ID") @@ -140,13 +136,14 @@ class GoogleCalendarConnector(BaseConnector): refresh_token=self.credentials.refresh_token, token_uri="https://oauth2.googleapis.com/token", client_id=client_id, - client_secret=client_secret + client_secret=client_secret, ) credentials.refresh(Request()) # Update credentials from app.utils.db import safe_commit + self.credentials.access_token = credentials.token if credentials.expiry: self.credentials.expires_at = credentials.expiry @@ -154,7 +151,7 @@ class GoogleCalendarConnector(BaseConnector): return { "access_token": credentials.token, - "expires_at": credentials.expiry.isoformat() if credentials.expiry else None + "expires_at": credentials.expiry.isoformat() if credentials.expiry else None, } def test_connection(self) -> Dict[str, Any]: @@ -162,34 +159,31 @@ class GoogleCalendarConnector(BaseConnector): try: service = self._get_calendar_service() calendar_list = service.calendarList().list().execute() - calendars = calendar_list.get('items', []) - + calendars = calendar_list.get("items", []) + # Return calendar list for selection calendar_options = [ { - "id": cal.get('id', 'primary'), - "name": cal.get('summary', 'Primary Calendar'), - "primary": cal.get('primary', False) + "id": cal.get("id", "primary"), + "name": cal.get("summary", "Primary Calendar"), + "primary": cal.get("primary", False), } for cal in calendars ] - + return { "success": True, "message": f"Connected to Google Calendar. Found {len(calendars)} calendars.", - "calendars": calendar_options + "calendars": calendar_options, } except Exception as e: - return { - "success": False, - "message": f"Connection test failed: {str(e)}" - } + return {"success": False, "message": f"Connection test failed: {str(e)}"} def _get_calendar_service(self): """Get Google Calendar API service.""" from app.models import Settings from app.utils.db import safe_commit - + settings = Settings.get_settings() creds = settings.get_integration_credentials("google_calendar") client_id = creds.get("client_id") or os.getenv("GOOGLE_CLIENT_ID") @@ -203,7 +197,7 @@ class GoogleCalendarConnector(BaseConnector): refresh_token=self.credentials.refresh_token, token_uri="https://oauth2.googleapis.com/token", client_id=client_id, - client_secret=client_secret + client_secret=client_secret, ) # Refresh if needed @@ -214,7 +208,7 @@ class GoogleCalendarConnector(BaseConnector): self.credentials.expires_at = credentials.expiry safe_commit("refresh_google_calendar_token", {"integration_id": self.integration.id}) - return build('calendar', 'v3', credentials=credentials) + return build("calendar", "v3", credentials=credentials) def sync_data(self, sync_type: str = "full") -> Dict[str, Any]: """Sync time entries with Google Calendar (bidirectional).""" @@ -237,7 +231,11 @@ class GoogleCalendarConnector(BaseConnector): if sync_direction in ["time_tracker_to_calendar", "bidirectional"]: # Get time entries to sync if sync_type == "incremental": - start_date = self.integration.last_sync_at if self.integration.last_sync_at else datetime.utcnow() - timedelta(days=30) + start_date = ( + self.integration.last_sync_at + if self.integration.last_sync_at + else datetime.utcnow() - timedelta(days=30) + ) else: start_date = datetime.utcnow() - timedelta(days=90) @@ -245,7 +243,7 @@ class GoogleCalendarConnector(BaseConnector): time_entries = TimeEntry.query.filter( TimeEntry.user_id == self.integration.user_id, TimeEntry.start_time >= start_date, - TimeEntry.end_time.isnot(None) + TimeEntry.end_time.isnot(None), ).all() for entry in time_entries: @@ -279,44 +277,49 @@ class GoogleCalendarConnector(BaseConnector): if sync_type == "incremental" and self.integration.last_sync_at: time_min = self.integration.last_sync_at - events_result = service.events().list( - calendarId=calendar_id, - timeMin=time_min.isoformat() + 'Z', - maxResults=250, - singleEvents=True, - orderBy='startTime' - ).execute() + events_result = ( + service.events() + .list( + calendarId=calendar_id, + timeMin=time_min.isoformat() + "Z", + maxResults=250, + singleEvents=True, + orderBy="startTime", + ) + .execute() + ) - events = events_result.get('items', []) + events = events_result.get("items", []) for event in events: try: # Skip events we created (check description for marker) - if event.get('description', '').startswith('TimeTracker:'): + if event.get("description", "").startswith("TimeTracker:"): continue # Check if we already have this event - event_id = event.get('id') + event_id = event.get("id") existing_entry = TimeEntry.query.filter( TimeEntry.user_id == self.integration.user_id, - TimeEntry.metadata.contains({'google_calendar_event_id': event_id}) + TimeEntry.metadata.contains({"google_calendar_event_id": event_id}), ).first() if not existing_entry: # Create time entry from calendar event - start_str = event['start'].get('dateTime', event['start'].get('date')) - end_str = event['end'].get('dateTime', event['end'].get('date')) + start_str = event["start"].get("dateTime", event["start"].get("date")) + end_str = event["end"].get("dateTime", event["end"].get("date")) - start_time = datetime.fromisoformat(start_str.replace('Z', '+00:00')) - end_time = datetime.fromisoformat(end_str.replace('Z', '+00:00')) + start_time = datetime.fromisoformat(start_str.replace("Z", "+00:00")) + end_time = datetime.fromisoformat(end_str.replace("Z", "+00:00")) # Try to match project/task from event title project = None task = None - title = event.get('summary', '') - + title = event.get("summary", "") + # Simple matching: look for project name in title from app.models import Project, Task + projects = Project.query.filter_by(user_id=self.integration.user_id, status="active").all() for p in projects: if p.name in title: @@ -329,8 +332,8 @@ class GoogleCalendarConnector(BaseConnector): task_id=task.id if task else None, start_time=start_time, end_time=end_time, - notes=event.get('description', ''), - billable=False + notes=event.get("description", ""), + billable=False, ) # Store Google Calendar event ID @@ -353,17 +356,14 @@ class GoogleCalendarConnector(BaseConnector): "success": True, "synced_count": synced_count, "errors": errors, - "message": f"Synced {synced_count} items" + "message": f"Synced {synced_count} items", } except Exception as e: self.integration.last_sync_status = "error" self.integration.last_error = str(e) db.session.commit() - return { - "success": False, - "message": f"Sync failed: {str(e)}" - } + return {"success": False, "message": f"Sync failed: {str(e)}"} def _create_calendar_event(self, service, calendar_id: str, time_entry) -> str: """Create a calendar event from a time entry.""" @@ -399,25 +399,22 @@ class GoogleCalendarConnector(BaseConnector): description = "\n\n".join(description_parts) if description_parts else "TimeTracker: Created from time entry" event = { - 'summary': title, - 'description': description, - 'start': { - 'dateTime': time_entry.start_time.isoformat(), - 'timeZone': 'UTC', + "summary": title, + "description": description, + "start": { + "dateTime": time_entry.start_time.isoformat(), + "timeZone": "UTC", }, - 'end': { - 'dateTime': time_entry.end_time.isoformat(), - 'timeZone': 'UTC', + "end": { + "dateTime": time_entry.end_time.isoformat(), + "timeZone": "UTC", }, - 'colorId': '9' if time_entry.billable else '11', # Blue for billable, red for non-billable + "colorId": "9" if time_entry.billable else "11", # Blue for billable, red for non-billable } - created_event = service.events().insert( - calendarId=calendar_id, - body=event - ).execute() + created_event = service.events().insert(calendarId=calendar_id, body=event).execute() - return created_event['id'] + return created_event["id"] def _update_calendar_event(self, service, calendar_id: str, event_id: str, time_entry): """Update an existing calendar event.""" @@ -451,23 +448,19 @@ class GoogleCalendarConnector(BaseConnector): event = service.events().get(calendarId=calendar_id, eventId=event_id).execute() # Update event - event['summary'] = title - event['description'] = description - event['start'] = { - 'dateTime': time_entry.start_time.isoformat(), - 'timeZone': 'UTC', + event["summary"] = title + event["description"] = description + event["start"] = { + "dateTime": time_entry.start_time.isoformat(), + "timeZone": "UTC", } - event['end'] = { - 'dateTime': time_entry.end_time.isoformat(), - 'timeZone': 'UTC', + event["end"] = { + "dateTime": time_entry.end_time.isoformat(), + "timeZone": "UTC", } - event['colorId'] = '9' if time_entry.billable else '11' + event["colorId"] = "9" if time_entry.billable else "11" - service.events().update( - calendarId=calendar_id, - eventId=event_id, - body=event - ).execute() + service.events().update(calendarId=calendar_id, eventId=event_id, body=event).execute() def get_config_schema(self) -> Dict[str, Any]: """Get configuration schema.""" @@ -478,7 +471,7 @@ class GoogleCalendarConnector(BaseConnector): "type": "string", "label": "Calendar ID", "default": "primary", - "description": "Google Calendar ID to sync with (default: primary)" + "description": "Google Calendar ID to sync with (default: primary)", }, { "name": "sync_direction", @@ -487,18 +480,17 @@ class GoogleCalendarConnector(BaseConnector): "options": [ {"value": "time_tracker_to_calendar", "label": "TimeTracker → Calendar"}, {"value": "calendar_to_time_tracker", "label": "Calendar → TimeTracker"}, - {"value": "bidirectional", "label": "Bidirectional"} + {"value": "bidirectional", "label": "Bidirectional"}, ], - "default": "time_tracker_to_calendar" + "default": "time_tracker_to_calendar", }, { "name": "auto_sync", "type": "boolean", "label": "Auto Sync", "default": True, - "description": "Automatically sync when time entries are created/updated" - } + "description": "Automatically sync when time entries are created/updated", + }, ], - "required": [] + "required": [], } - diff --git a/app/integrations/jira.py b/app/integrations/jira.py index 66c362c2..31de548d 100644 --- a/app/integrations/jira.py +++ b/app/integrations/jira.py @@ -157,31 +157,30 @@ class JiraConnector(BaseConnector): base_url = self.integration.config.get("jira_url", "https://your-domain.atlassian.net") api_url = f"{base_url}/rest/api/3/search" - + synced_count = 0 errors = [] try: # Get JQL query from config or use default - jql = self.integration.config.get("jql", "assignee = currentUser() AND status != Done ORDER BY updated DESC") - + jql = self.integration.config.get( + "jql", "assignee = currentUser() AND status != Done ORDER BY updated DESC" + ) + # Determine date range if sync_type == "incremental": # Get issues updated in last 7 days jql = f"{jql} AND updated >= -7d" - + # Fetch issues from Jira response = requests.get( api_url, - headers={ - "Authorization": f"Bearer {token}", - "Accept": "application/json" - }, + headers={"Authorization": f"Bearer {token}", "Accept": "application/json"}, params={ "jql": jql, "maxResults": 100, - "fields": "summary,description,status,assignee,project,created,updated" - } + "fields": "summary,description,status,assignee,project,created,updated", + }, ) if response.status_code != 200: @@ -194,28 +193,24 @@ class JiraConnector(BaseConnector): issue_key = issue.get("key") issue_fields = issue.get("fields", {}) project_key = issue.get("fields", {}).get("project", {}).get("key", "") - + # Find or create project project = Project.query.filter_by( - user_id=self.integration.user_id, - name=project_key or "Jira" + user_id=self.integration.user_id, name=project_key or "Jira" ).first() - + if not project: project = Project( name=project_key or "Jira", description=f"Synced from Jira project {project_key}", user_id=self.integration.user_id, - status="active" + status="active", ) db.session.add(project) db.session.flush() # Find or create task - task = Task.query.filter_by( - project_id=project.id, - name=issue_key - ).first() + task = Task.query.filter_by(project_id=project.id, name=issue_key).first() if not task: task = Task( @@ -223,16 +218,23 @@ class JiraConnector(BaseConnector): name=issue_key, description=issue_fields.get("summary", ""), status=self._map_jira_status(issue_fields.get("status", {}).get("name", "To Do")), - notes=issue_fields.get("description", {}).get("content", [{}])[0].get("content", [{}])[0].get("text", "") if issue_fields.get("description") else None + notes=( + issue_fields.get("description", {}) + .get("content", [{}])[0] + .get("content", [{}])[0] + .get("text", "") + if issue_fields.get("description") + else None + ), ) db.session.add(task) db.session.flush() # Store Jira issue key in task metadata - if not hasattr(task, 'metadata') or not task.metadata: + if not hasattr(task, "metadata") or not task.metadata: task.metadata = {} - task.metadata['jira_issue_key'] = issue_key - task.metadata['jira_issue_id'] = issue.get("id") + task.metadata["jira_issue_key"] = issue_key + task.metadata["jira_issue_id"] = issue.get("id") synced_count += 1 except Exception as e: @@ -244,7 +246,7 @@ class JiraConnector(BaseConnector): "success": True, "message": f"Sync completed. Synced {synced_count} issues.", "synced_items": synced_count, - "errors": errors + "errors": errors, } except Exception as e: return {"success": False, "message": f"Sync failed: {str(e)}"} @@ -273,11 +275,7 @@ class JiraConnector(BaseConnector): if event_type in ["jira:issue_updated", "jira:issue_created"]: # Trigger a sync for this specific issue # This would be handled by the sync_data method - return { - "success": True, - "message": f"Webhook received for issue {issue_key}", - "event_type": event_type - } + return {"success": True, "message": f"Webhook received for issue {issue_key}", "event_type": event_type} return {"success": True, "message": f"Webhook processed: {event_type}"} except Exception as e: @@ -300,15 +298,15 @@ class JiraConnector(BaseConnector): "type": "text", "required": False, "placeholder": "assignee = currentUser() AND status != Done", - "help": "Jira Query Language query to filter issues to sync" + "help": "Jira Query Language query to filter issues to sync", }, { "name": "auto_sync", "type": "boolean", "label": "Auto Sync", "default": True, - "description": "Automatically sync when webhooks are received" - } + "description": "Automatically sync when webhooks are received", + }, ], "required": ["jira_url"], } diff --git a/app/integrations/microsoft_teams.py b/app/integrations/microsoft_teams.py index 3c2acc8e..9b16549a 100644 --- a/app/integrations/microsoft_teams.py +++ b/app/integrations/microsoft_teams.py @@ -22,12 +22,7 @@ class MicrosoftTeamsConnector(BaseConnector): AUTH_BASE_URL = "https://login.microsoftonline.com" # OAuth 2.0 scopes required - SCOPES = [ - "ChannelMessage.Send", - "Chat.ReadWrite", - "offline_access", - "User.Read" - ] + SCOPES = ["ChannelMessage.Send", "Chat.ReadWrite", "offline_access", "User.Read"] @property def provider_name(self) -> str: @@ -36,6 +31,7 @@ class MicrosoftTeamsConnector(BaseConnector): def _get_tenant_id(self) -> str: """Get tenant ID from settings or use 'common' for multi-tenant.""" from app.models import Settings + settings = Settings.get_settings() creds = settings.get_integration_credentials("microsoft_teams") tenant_id = creds.get("tenant_id") or os.getenv("MICROSOFT_TEAMS_TENANT_ID", "common") @@ -107,8 +103,7 @@ class MicrosoftTeamsConnector(BaseConnector): if "access_token" in data: try: user_response = requests.get( - f"{self.GRAPH_BASE_URL}/me", - headers={"Authorization": f"Bearer {data['access_token']}"} + f"{self.GRAPH_BASE_URL}/me", headers={"Authorization": f"Bearer {data['access_token']}"} ) if user_response.status_code == 200: user_data = user_response.json() @@ -135,6 +130,7 @@ class MicrosoftTeamsConnector(BaseConnector): raise ValueError("No refresh token available") from app.models import Settings + settings = Settings.get_settings() creds = settings.get_integration_credentials("microsoft_teams") client_id = creds.get("client_id") or os.getenv("MICROSOFT_TEAMS_CLIENT_ID") @@ -171,6 +167,7 @@ class MicrosoftTeamsConnector(BaseConnector): if expires_at: self.credentials.expires_at = expires_at from app.utils.db import safe_commit + safe_commit("refresh_microsoft_teams_token", {"integration_id": self.integration.id}) return { @@ -186,16 +183,13 @@ class MicrosoftTeamsConnector(BaseConnector): try: # Get user info - response = requests.get( - f"{self.GRAPH_BASE_URL}/me", - headers={"Authorization": f"Bearer {token}"} - ) + response = requests.get(f"{self.GRAPH_BASE_URL}/me", headers={"Authorization": f"Bearer {token}"}) if response.status_code == 200: user_data = response.json() return { "success": True, - "message": f"Connected to Microsoft Teams as {user_data.get('displayName', 'Unknown')}" + "message": f"Connected to Microsoft Teams as {user_data.get('displayName', 'Unknown')}", } else: return {"success": False, "message": f"API returned status {response.status_code}"} @@ -212,16 +206,8 @@ class MicrosoftTeamsConnector(BaseConnector): # Send message to channel response = requests.post( f"{self.GRAPH_BASE_URL}/teams/{channel_id}/channels/{channel_id}/messages", - headers={ - "Authorization": f"Bearer {token}", - "Content-Type": "application/json" - }, - json={ - "body": { - "contentType": "text", - "content": message - } - } + headers={"Authorization": f"Bearer {token}", "Content-Type": "application/json"}, + json={"body": {"contentType": "text", "content": message}}, ) if response.status_code in [200, 201]: @@ -240,8 +226,7 @@ class MicrosoftTeamsConnector(BaseConnector): try: # Get teams response = requests.get( - f"{self.GRAPH_BASE_URL}/me/joinedTeams", - headers={"Authorization": f"Bearer {token}"} + f"{self.GRAPH_BASE_URL}/me/joinedTeams", headers={"Authorization": f"Bearer {token}"} ) if response.status_code == 200: @@ -249,7 +234,7 @@ class MicrosoftTeamsConnector(BaseConnector): return { "success": True, "message": f"Sync completed. Found {len(teams)} teams.", - "synced_items": len(teams) + "synced_items": len(teams), } else: return {"success": False, "message": f"API returned status {response.status_code}"} @@ -264,21 +249,20 @@ class MicrosoftTeamsConnector(BaseConnector): "name": "default_channel_id", "type": "string", "label": "Default Channel ID", - "description": "Default Teams channel ID for notifications" + "description": "Default Teams channel ID for notifications", }, { "name": "notify_on_time_entry_start", "type": "boolean", "label": "Notify on Time Entry Start", - "default": False + "default": False, }, { "name": "notify_on_invoice_sent", "type": "boolean", "label": "Notify on Invoice Sent", - "default": True - } + "default": True, + }, ], - "required": [] + "required": [], } - diff --git a/app/integrations/outlook_calendar.py b/app/integrations/outlook_calendar.py index 9dbe92af..fac61494 100644 --- a/app/integrations/outlook_calendar.py +++ b/app/integrations/outlook_calendar.py @@ -22,11 +22,7 @@ class OutlookCalendarConnector(BaseConnector): AUTH_BASE_URL = "https://login.microsoftonline.com" # OAuth 2.0 scopes required - SCOPES = [ - "Calendars.ReadWrite", - "offline_access", - "User.Read" - ] + SCOPES = ["Calendars.ReadWrite", "offline_access", "User.Read"] @property def provider_name(self) -> str: @@ -35,6 +31,7 @@ class OutlookCalendarConnector(BaseConnector): def _get_tenant_id(self) -> str: """Get tenant ID from settings or use 'common' for multi-tenant.""" from app.models import Settings + settings = Settings.get_settings() creds = settings.get_integration_credentials("outlook_calendar") tenant_id = creds.get("tenant_id") or os.getenv("OUTLOOK_TENANT_ID", "common") @@ -106,8 +103,7 @@ class OutlookCalendarConnector(BaseConnector): if "access_token" in data: try: user_response = requests.get( - f"{self.GRAPH_BASE_URL}/me", - headers={"Authorization": f"Bearer {data['access_token']}"} + f"{self.GRAPH_BASE_URL}/me", headers={"Authorization": f"Bearer {data['access_token']}"} ) if user_response.status_code == 200: user_data = user_response.json() @@ -135,6 +131,7 @@ class OutlookCalendarConnector(BaseConnector): raise ValueError("No refresh token available") from app.models import Settings + settings = Settings.get_settings() creds = settings.get_integration_credentials("outlook_calendar") client_id = creds.get("client_id") or os.getenv("OUTLOOK_CLIENT_ID") @@ -171,6 +168,7 @@ class OutlookCalendarConnector(BaseConnector): if expires_at: self.credentials.expires_at = expires_at from app.utils.db import safe_commit + safe_commit("refresh_outlook_calendar_token", {"integration_id": self.integration.id}) return { @@ -186,17 +184,11 @@ class OutlookCalendarConnector(BaseConnector): try: # Get user info and calendars - response = requests.get( - f"{self.GRAPH_BASE_URL}/me/calendars", - headers={"Authorization": f"Bearer {token}"} - ) + response = requests.get(f"{self.GRAPH_BASE_URL}/me/calendars", headers={"Authorization": f"Bearer {token}"}) if response.status_code == 200: calendars = response.json().get("value", []) - return { - "success": True, - "message": f"Connected to Outlook Calendar. Found {len(calendars)} calendars." - } + return {"success": True, "message": f"Connected to Outlook Calendar. Found {len(calendars)} calendars."} else: return {"success": False, "message": f"API returned status {response.status_code}"} except Exception as e: @@ -226,7 +218,7 @@ class OutlookCalendarConnector(BaseConnector): time_entries = TimeEntry.query.filter( TimeEntry.user_id == self.integration.user_id, TimeEntry.start_time >= start_date, - TimeEntry.end_time.isnot(None) + TimeEntry.end_time.isnot(None), ).all() synced_count = 0 @@ -258,17 +250,10 @@ class OutlookCalendarConnector(BaseConnector): db.session.commit() - return { - "success": True, - "synced_count": synced_count, - "errors": errors - } + return {"success": True, "synced_count": synced_count, "errors": errors} except Exception as e: - return { - "success": False, - "message": f"Sync failed: {str(e)}" - } + return {"success": False, "message": f"Sync failed: {str(e)}"} def _create_calendar_event(self, token: str, calendar_id: str, time_entry) -> str: """Create a calendar event from a time entry.""" @@ -298,28 +283,16 @@ class OutlookCalendarConnector(BaseConnector): event = { "subject": title, - "body": { - "contentType": "text", - "content": description or "" - }, - "start": { - "dateTime": time_entry.start_time.isoformat(), - "timeZone": "UTC" - }, - "end": { - "dateTime": time_entry.end_time.isoformat(), - "timeZone": "UTC" - }, + "body": {"contentType": "text", "content": description or ""}, + "start": {"dateTime": time_entry.start_time.isoformat(), "timeZone": "UTC"}, + "end": {"dateTime": time_entry.end_time.isoformat(), "timeZone": "UTC"}, "isAllDay": False, } response = requests.post( f"{self.GRAPH_BASE_URL}/me/calendars/{calendar_id}/events", - headers={ - "Authorization": f"Bearer {token}", - "Content-Type": "application/json" - }, - json=event + headers={"Authorization": f"Bearer {token}", "Content-Type": "application/json"}, + json=event, ) response.raise_for_status() @@ -354,27 +327,15 @@ class OutlookCalendarConnector(BaseConnector): event = { "subject": title, - "body": { - "contentType": "text", - "content": description or "" - }, - "start": { - "dateTime": time_entry.start_time.isoformat(), - "timeZone": "UTC" - }, - "end": { - "dateTime": time_entry.end_time.isoformat(), - "timeZone": "UTC" - }, + "body": {"contentType": "text", "content": description or ""}, + "start": {"dateTime": time_entry.start_time.isoformat(), "timeZone": "UTC"}, + "end": {"dateTime": time_entry.end_time.isoformat(), "timeZone": "UTC"}, } response = requests.patch( f"{self.GRAPH_BASE_URL}/me/calendars/{calendar_id}/events/{event_id}", - headers={ - "Authorization": f"Bearer {token}", - "Content-Type": "application/json" - }, - json=event + headers={"Authorization": f"Bearer {token}", "Content-Type": "application/json"}, + json=event, ) response.raise_for_status() @@ -388,7 +349,7 @@ class OutlookCalendarConnector(BaseConnector): "type": "string", "label": "Calendar ID", "default": "calendar", - "description": "Outlook Calendar ID to sync with (default: 'calendar' for primary calendar)" + "description": "Outlook Calendar ID to sync with (default: 'calendar' for primary calendar)", }, { "name": "sync_direction", @@ -397,18 +358,17 @@ class OutlookCalendarConnector(BaseConnector): "options": [ {"value": "time_tracker_to_calendar", "label": "TimeTracker → Calendar"}, {"value": "calendar_to_time_tracker", "label": "Calendar → TimeTracker"}, - {"value": "bidirectional", "label": "Bidirectional"} + {"value": "bidirectional", "label": "Bidirectional"}, ], - "default": "time_tracker_to_calendar" + "default": "time_tracker_to_calendar", }, { "name": "auto_sync", "type": "boolean", "label": "Auto Sync", "default": True, - "description": "Automatically sync when time entries are created/updated" - } + "description": "Automatically sync when time entries are created/updated", + }, ], - "required": [] + "required": [], } - diff --git a/app/integrations/quickbooks.py b/app/integrations/quickbooks.py index ee80b142..4fcbda24 100644 --- a/app/integrations/quickbooks.py +++ b/app/integrations/quickbooks.py @@ -47,10 +47,7 @@ class QuickBooksConnector(BaseConnector): auth_url = "https://appcenter.intuit.com/connect/oauth2" - scopes = [ - "com.intuit.quickbooks.accounting", - "com.intuit.quickbooks.payment" - ] + scopes = ["com.intuit.quickbooks.accounting", "com.intuit.quickbooks.payment"] params = { "client_id": client_id, @@ -58,7 +55,7 @@ class QuickBooksConnector(BaseConnector): "redirect_uri": redirect_uri, "response_type": "code", "access_type": "offline", - "state": state or "" + "state": state or "", } query_string = "&".join([f"{k}={v}" for k, v in params.items()]) @@ -80,21 +77,17 @@ class QuickBooksConnector(BaseConnector): # QuickBooks requires Basic Auth for token exchange auth_string = f"{client_id}:{client_secret}" - auth_bytes = auth_string.encode('ascii') - auth_b64 = base64.b64encode(auth_bytes).decode('ascii') + auth_bytes = auth_string.encode("ascii") + auth_b64 = base64.b64encode(auth_bytes).decode("ascii") response = requests.post( token_url, headers={ "Authorization": f"Basic {auth_b64}", "Accept": "application/json", - "Content-Type": "application/x-www-form-urlencoded" + "Content-Type": "application/x-www-form-urlencoded", }, - data={ - "grant_type": "authorization_code", - "code": code, - "redirect_uri": redirect_uri - } + data={"grant_type": "authorization_code", "code": code, "redirect_uri": redirect_uri}, ) response.raise_for_status() @@ -110,10 +103,7 @@ class QuickBooksConnector(BaseConnector): try: realm_id = data["realmId"] company_response = self._api_request( - "GET", - f"/v3/company/{realm_id}/companyinfo/{realm_id}", - data.get("access_token"), - realm_id + "GET", f"/v3/company/{realm_id}/companyinfo/{realm_id}", data.get("access_token"), realm_id ) if company_response: company_info = company_response.get("CompanyInfo", {}) @@ -126,10 +116,7 @@ class QuickBooksConnector(BaseConnector): "expires_at": expires_at.isoformat() if expires_at else None, "token_type": "Bearer", "realm_id": data.get("realmId"), # QuickBooks company ID - "extra_data": { - "company_name": company_info.get("CompanyName", ""), - "company_id": data.get("realmId") - } + "extra_data": {"company_name": company_info.get("CompanyName", ""), "company_id": data.get("realmId")}, } def refresh_access_token(self) -> Dict[str, Any]: @@ -138,6 +125,7 @@ class QuickBooksConnector(BaseConnector): raise ValueError("No refresh token available") from app.models import Settings + settings = Settings.get_settings() creds = settings.get_integration_credentials("quickbooks") client_id = creds.get("client_id") or os.getenv("QUICKBOOKS_CLIENT_ID") @@ -146,20 +134,17 @@ class QuickBooksConnector(BaseConnector): token_url = "https://oauth.platform.intuit.com/oauth2/v1/tokens/bearer" auth_string = f"{client_id}:{client_secret}" - auth_bytes = auth_string.encode('ascii') - auth_b64 = base64.b64encode(auth_bytes).decode('ascii') + auth_bytes = auth_string.encode("ascii") + auth_b64 = base64.b64encode(auth_bytes).decode("ascii") response = requests.post( token_url, headers={ "Authorization": f"Basic {auth_b64}", "Accept": "application/json", - "Content-Type": "application/x-www-form-urlencoded" + "Content-Type": "application/x-www-form-urlencoded", }, - data={ - "grant_type": "refresh_token", - "refresh_token": self.credentials.refresh_token - } + data={"grant_type": "refresh_token", "refresh_token": self.credentials.refresh_token}, ) response.raise_for_status() @@ -177,10 +162,7 @@ class QuickBooksConnector(BaseConnector): self.credentials.expires_at = expires_at self.credentials.save() - return { - "access_token": data.get("access_token"), - "expires_at": expires_at.isoformat() if expires_at else None - } + return {"access_token": data.get("access_token"), "expires_at": expires_at.isoformat() if expires_at else None} def test_connection(self) -> Dict[str, Any]: """Test connection to QuickBooks.""" @@ -190,28 +172,16 @@ class QuickBooksConnector(BaseConnector): return {"success": False, "message": "QuickBooks company not configured"} company_info = self._api_request( - "GET", - f"/v3/company/{realm_id}/companyinfo/{realm_id}", - self.get_access_token(), - realm_id + "GET", f"/v3/company/{realm_id}/companyinfo/{realm_id}", self.get_access_token(), realm_id ) if company_info: company_name = company_info.get("CompanyInfo", {}).get("CompanyName", "Unknown") - return { - "success": True, - "message": f"Connected to QuickBooks company: {company_name}" - } + return {"success": True, "message": f"Connected to QuickBooks company: {company_name}"} else: - return { - "success": False, - "message": "Failed to retrieve company information" - } + return {"success": False, "message": "Failed to retrieve company information"} except Exception as e: - return { - "success": False, - "message": f"Connection test failed: {str(e)}" - } + return {"success": False, "message": f"Connection test failed: {str(e)}"} def _api_request(self, method: str, endpoint: str, access_token: str, realm_id: str) -> Optional[Dict]: """Make API request to QuickBooks""" @@ -221,7 +191,7 @@ class QuickBooksConnector(BaseConnector): headers = { "Authorization": f"Bearer {access_token}", "Accept": "application/json", - "Content-Type": "application/json" + "Content-Type": "application/json", } if realm_id: @@ -258,8 +228,7 @@ class QuickBooksConnector(BaseConnector): # Sync invoices (create as invoices in QuickBooks) if sync_type == "full" or sync_type == "invoices": invoices = Invoice.query.filter( - Invoice.status.in_(["sent", "paid"]), - Invoice.created_at >= datetime.utcnow() - timedelta(days=90) + Invoice.status.in_(["sent", "paid"]), Invoice.created_at >= datetime.utcnow() - timedelta(days=90) ).all() for invoice in invoices: @@ -267,65 +236,56 @@ class QuickBooksConnector(BaseConnector): qb_invoice = self._create_quickbooks_invoice(invoice, access_token, realm_id) if qb_invoice: # Store QuickBooks ID in invoice metadata - if not hasattr(invoice, 'metadata') or not invoice.metadata: + if not hasattr(invoice, "metadata") or not invoice.metadata: invoice.metadata = {} - invoice.metadata['quickbooks_id'] = qb_invoice.get("Id") + invoice.metadata["quickbooks_id"] = qb_invoice.get("Id") synced_count += 1 except Exception as e: errors.append(f"Error syncing invoice {invoice.id}: {str(e)}") # Sync expenses (create as expenses in QuickBooks) if sync_type == "full" or sync_type == "expenses": - expenses = Expense.query.filter( - Expense.date >= datetime.utcnow().date() - timedelta(days=90) - ).all() + expenses = Expense.query.filter(Expense.date >= datetime.utcnow().date() - timedelta(days=90)).all() for expense in expenses: try: qb_expense = self._create_quickbooks_expense(expense, access_token, realm_id) if qb_expense: - if not hasattr(expense, 'metadata') or not expense.metadata: + if not hasattr(expense, "metadata") or not expense.metadata: expense.metadata = {} - expense.metadata['quickbooks_id'] = qb_expense.get("Id") + expense.metadata["quickbooks_id"] = qb_expense.get("Id") synced_count += 1 except Exception as e: errors.append(f"Error syncing expense {expense.id}: {str(e)}") db.session.commit() - return { - "success": True, - "synced_count": synced_count, - "errors": errors - } + return {"success": True, "synced_count": synced_count, "errors": errors} except Exception as e: - return { - "success": False, - "message": f"Sync failed: {str(e)}" - } + return {"success": False, "message": f"Sync failed: {str(e)}"} def _create_quickbooks_invoice(self, invoice, access_token: str, realm_id: str) -> Optional[Dict]: """Create invoice in QuickBooks""" # Build QuickBooks invoice structure - qb_invoice = { - "Line": [] - } + qb_invoice = {"Line": []} # Add invoice items for item in invoice.items: - qb_invoice["Line"].append({ - "Amount": float(item.quantity * item.unit_price), - "DetailType": "SalesItemLineDetail", - "SalesItemLineDetail": { - "ItemRef": { - "value": "1", # Would need to map to actual QuickBooks item - "name": item.description + qb_invoice["Line"].append( + { + "Amount": float(item.quantity * item.unit_price), + "DetailType": "SalesItemLineDetail", + "SalesItemLineDetail": { + "ItemRef": { + "value": "1", # Would need to map to actual QuickBooks item + "name": item.description, + }, + "Qty": float(item.quantity), + "UnitPrice": float(item.unit_price), }, - "Qty": float(item.quantity), - "UnitPrice": float(item.unit_price) } - }) + ) # Add customer reference (would need customer mapping) # qb_invoice["CustomerRef"] = {"value": customer_qb_id} @@ -338,18 +298,14 @@ class QuickBooksConnector(BaseConnector): # Build QuickBooks expense structure qb_expense = { "PaymentType": "Cash", - "AccountRef": { - "value": "1" # Would need account mapping - }, - "Line": [{ - "Amount": float(expense.amount), - "DetailType": "AccountBasedExpenseLineDetail", - "AccountBasedExpenseLineDetail": { - "AccountRef": { - "value": "1" # Expense account - } + "AccountRef": {"value": "1"}, # Would need account mapping + "Line": [ + { + "Amount": float(expense.amount), + "DetailType": "AccountBasedExpenseLineDetail", + "AccountBasedExpenseLineDetail": {"AccountRef": {"value": "1"}}, # Expense account } - }] + ], } endpoint = f"/v3/company/{realm_id}/purchase" @@ -363,28 +319,17 @@ class QuickBooksConnector(BaseConnector): "name": "realm_id", "type": "string", "label": "Company ID (Realm ID)", - "description": "QuickBooks company ID (realm ID)" + "description": "QuickBooks company ID (realm ID)", }, { "name": "use_sandbox", "type": "boolean", "label": "Use Sandbox", "default": True, - "description": "Use QuickBooks sandbox environment for testing" + "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_invoices", "type": "boolean", "label": "Sync Invoices", "default": True}, + {"name": "sync_expenses", "type": "boolean", "label": "Sync Expenses", "default": True}, ], - "required": ["realm_id"] + "required": ["realm_id"], } - diff --git a/app/integrations/slack.py b/app/integrations/slack.py index 3085e935..b48f118e 100644 --- a/app/integrations/slack.py +++ b/app/integrations/slack.py @@ -157,7 +157,7 @@ class SlackConnector(BaseConnector): channels_response = requests.get( "https://slack.com/api/conversations.list", headers={"Authorization": f"Bearer {token}"}, - params={"types": "public_channel,private_channel", "exclude_archived": True} + params={"types": "public_channel,private_channel", "exclude_archived": True}, ) if channels_response.status_code == 200: @@ -165,16 +165,12 @@ class SlackConnector(BaseConnector): if channels_data.get("ok"): channels = channels_data.get("channels", []) synced_count += len(channels) - + # Store channels in integration config if not self.integration.config: self.integration.config = {} - self.integration.config['channels'] = [ - { - "id": ch.get("id"), - "name": ch.get("name"), - "is_private": ch.get("is_private", False) - } + self.integration.config["channels"] = [ + {"id": ch.get("id"), "name": ch.get("name"), "is_private": ch.get("is_private", False)} for ch in channels ] else: @@ -182,8 +178,7 @@ class SlackConnector(BaseConnector): # Get users users_response = requests.get( - "https://slack.com/api/users.list", - headers={"Authorization": f"Bearer {token}"} + "https://slack.com/api/users.list", headers={"Authorization": f"Bearer {token}"} ) if users_response.status_code == 200: @@ -191,31 +186,33 @@ class SlackConnector(BaseConnector): if users_data.get("ok"): users = users_data.get("members", []) synced_count += len(users) - + # Store users in integration config if not self.integration.config: self.integration.config = {} - self.integration.config['users'] = [ + self.integration.config["users"] = [ { "id": u.get("id"), "name": u.get("name"), "real_name": u.get("real_name", ""), - "email": u.get("profile", {}).get("email", "") + "email": u.get("profile", {}).get("email", ""), } - for u in users if not u.get("deleted", False) + for u in users + if not u.get("deleted", False) ] else: errors.append(f"Slack API error: {users_data.get('error', 'Unknown error')}") from app import db from app.utils.db import safe_commit + safe_commit("sync_slack_data", {"integration_id": self.integration.id}) return { "success": True, "message": f"Sync completed. Found {synced_count} items.", "synced_items": synced_count, - "errors": errors + "errors": errors, } except Exception as e: return {"success": False, "message": f"Sync failed: {str(e)}"} @@ -225,21 +222,14 @@ class SlackConnector(BaseConnector): try: # Slack webhooks typically use challenge-response for URL verification if payload.get("type") == "url_verification": - return { - "success": True, - "challenge": payload.get("challenge") - } + return {"success": True, "challenge": payload.get("challenge")} event = payload.get("event", {}) event_type = event.get("type", "") # Handle various Slack events if event_type == "message": - return { - "success": True, - "message": "Message event received", - "event_type": event_type - } + return {"success": True, "message": "Message event received", "event_type": event_type} return {"success": True, "message": f"Webhook processed: {event_type}"} except Exception as e: diff --git a/app/integrations/trello.py b/app/integrations/trello.py index ac937868..39771d48 100644 --- a/app/integrations/trello.py +++ b/app/integrations/trello.py @@ -45,7 +45,7 @@ class TrelloConnector(BaseConnector): "response_type": "token", "scope": "read,write", "expiration": "never", - "redirect_uri": redirect_uri + "redirect_uri": redirect_uri, } query_string = "&".join([f"{k}={v}" for k, v in params.items()]) @@ -70,17 +70,14 @@ class TrelloConnector(BaseConnector): # Verify token by getting user info user_info = {} try: - response = requests.get( - f"{self.BASE_URL}/members/me", - params={"key": api_key, "token": token} - ) + response = requests.get(f"{self.BASE_URL}/members/me", params={"key": api_key, "token": token}) if response.status_code == 200: user_data = response.json() user_info = { "id": user_data.get("id"), "username": user_data.get("username"), "fullName": user_data.get("fullName"), - "email": user_data.get("email") + "email": user_data.get("email"), } except Exception: pass @@ -90,47 +87,35 @@ class TrelloConnector(BaseConnector): "refresh_token": None, # Trello tokens don't expire "expires_at": None, "token_type": "Bearer", - "extra_data": user_info + "extra_data": user_info, } def refresh_access_token(self) -> Dict[str, Any]: """Refresh access token (Trello tokens don't expire).""" # Trello tokens don't expire, so just return current token - return { - "access_token": self.credentials.access_token if self.credentials else None, - "expires_at": None - } + return {"access_token": self.credentials.access_token if self.credentials else None, "expires_at": None} def test_connection(self) -> Dict[str, Any]: """Test connection to Trello.""" try: from app.models import Settings + settings = Settings.get_settings() creds = settings.get_integration_credentials("trello") api_key = creds.get("api_key") or os.getenv("TRELLO_API_KEY") headers = {"Authorization": f"Bearer {self.get_access_token()}"} response = requests.get( - f"{self.BASE_URL}/members/me", - params={"key": api_key, "token": self.get_access_token()} + f"{self.BASE_URL}/members/me", params={"key": api_key, "token": self.get_access_token()} ) if response.status_code == 200: user_data = response.json() - return { - "success": True, - "message": f"Connected to Trello as {user_data.get('fullName', 'Unknown')}" - } + return {"success": True, "message": f"Connected to Trello as {user_data.get('fullName', 'Unknown')}"} else: - return { - "success": False, - "message": f"Connection test failed: {response.status_code}" - } + return {"success": False, "message": f"Connection test failed: {response.status_code}"} except Exception as e: - return { - "success": False, - "message": f"Connection test failed: {str(e)}" - } + return {"success": False, "message": f"Connection test failed: {str(e)}"} def sync_data(self, sync_type: str = "full") -> Dict[str, Any]: """Sync boards and cards with Trello.""" @@ -139,6 +124,7 @@ class TrelloConnector(BaseConnector): try: from app.models import Settings + settings = Settings.get_settings() creds = settings.get_integration_credentials("trello") api_key = creds.get("api_key") or os.getenv("TRELLO_API_KEY") @@ -152,8 +138,7 @@ class TrelloConnector(BaseConnector): # Get boards boards_response = requests.get( - f"{self.BASE_URL}/members/me/boards", - params={"key": api_key, "token": token, "filter": "open"} + f"{self.BASE_URL}/members/me/boards", params={"key": api_key, "token": token, "filter": "open"} ) if boards_response.status_code == 200: @@ -163,8 +148,7 @@ class TrelloConnector(BaseConnector): try: # Create or update project from board project = Project.query.filter_by( - user_id=self.integration.user_id, - name=board.get("name") + user_id=self.integration.user_id, name=board.get("name") ).first() if not project: @@ -172,20 +156,20 @@ class TrelloConnector(BaseConnector): name=board.get("name"), description=board.get("desc", ""), user_id=self.integration.user_id, - status="active" + status="active", ) db.session.add(project) db.session.flush() # Store Trello board ID in metadata - if not hasattr(project, 'metadata') or not project.metadata: + if not hasattr(project, "metadata") or not project.metadata: project.metadata = {} - project.metadata['trello_board_id'] = board.get("id") + project.metadata["trello_board_id"] = board.get("id") # Sync cards as tasks cards_response = requests.get( f"{self.BASE_URL}/boards/{board.get('id')}/cards", - params={"key": api_key, "token": token, "filter": "open"} + params={"key": api_key, "token": token, "filter": "open"}, ) if cards_response.status_code == 200: @@ -193,25 +177,22 @@ class TrelloConnector(BaseConnector): for card in cards: # Find or create task - task = Task.query.filter_by( - project_id=project.id, - name=card.get("name") - ).first() + task = Task.query.filter_by(project_id=project.id, name=card.get("name")).first() if not task: task = Task( project_id=project.id, name=card.get("name"), description=card.get("desc", ""), - status=self._map_trello_list_to_status(card.get("idList")) + status=self._map_trello_list_to_status(card.get("idList")), ) db.session.add(task) db.session.flush() # Store Trello card ID in metadata - if not hasattr(task, 'metadata') or not task.metadata: + if not hasattr(task, "metadata") or not task.metadata: task.metadata = {} - task.metadata['trello_card_id'] = card.get("id") + task.metadata["trello_card_id"] = card.get("id") synced_count += 1 except Exception as e: @@ -219,40 +200,31 @@ class TrelloConnector(BaseConnector): db.session.commit() - return { - "success": True, - "synced_count": synced_count, - "errors": errors - } + return {"success": True, "synced_count": synced_count, "errors": errors} except Exception as e: - return { - "success": False, - "message": f"Sync failed: {str(e)}" - } + return {"success": False, "message": f"Sync failed: {str(e)}"} def _map_trello_list_to_status(self, list_id: str) -> str: """Map Trello list to task status.""" from app.models import Settings + settings = Settings.get_settings() creds = settings.get_integration_credentials("trello") api_key = creds.get("api_key") or os.getenv("TRELLO_API_KEY") token = self.get_access_token() - + if not token or not api_key: return "todo" - + try: # Fetch list name - list_response = requests.get( - f"{self.BASE_URL}/lists/{list_id}", - params={"key": api_key, "token": token} - ) - + list_response = requests.get(f"{self.BASE_URL}/lists/{list_id}", params={"key": api_key, "token": token}) + if list_response.status_code == 200: list_data = list_response.json() list_name = list_data.get("name", "").lower() - + # Map common list names to statuses if "done" in list_name or "completed" in list_name or "closed" in list_name: return "completed" @@ -262,7 +234,7 @@ class TrelloConnector(BaseConnector): return "todo" except Exception: pass - + return "todo" def get_config_schema(self) -> Dict[str, Any]: @@ -273,7 +245,7 @@ class TrelloConnector(BaseConnector): "name": "board_ids", "type": "array", "label": "Board IDs", - "description": "Trello board IDs to sync (leave empty to sync all)" + "description": "Trello board IDs to sync (leave empty to sync all)", }, { "name": "sync_direction", @@ -282,11 +254,10 @@ class TrelloConnector(BaseConnector): "options": [ {"value": "trello_to_timetracker", "label": "Trello → TimeTracker"}, {"value": "timetracker_to_trello", "label": "TimeTracker → Trello"}, - {"value": "bidirectional", "label": "Bidirectional"} + {"value": "bidirectional", "label": "Bidirectional"}, ], - "default": "trello_to_timetracker" - } + "default": "trello_to_timetracker", + }, ], - "required": [] + "required": [], } - diff --git a/app/integrations/xero.py b/app/integrations/xero.py index 86ac765b..0f1dc821 100644 --- a/app/integrations/xero.py +++ b/app/integrations/xero.py @@ -38,12 +38,7 @@ class XeroConnector(BaseConnector): if not client_id: raise ValueError("XERO_CLIENT_ID not configured") - scopes = [ - "accounting.transactions", - "accounting.contacts", - "accounting.settings", - "offline_access" - ] + scopes = ["accounting.transactions", "accounting.contacts", "accounting.settings", "offline_access"] auth_url = "https://login.xero.com/identity/connect/authorize" params = { @@ -73,20 +68,13 @@ class XeroConnector(BaseConnector): # Xero requires Basic Auth for token exchange auth_string = f"{client_id}:{client_secret}" - auth_bytes = auth_string.encode('ascii') - auth_b64 = base64.b64encode(auth_bytes).decode('ascii') + auth_bytes = auth_string.encode("ascii") + auth_b64 = base64.b64encode(auth_bytes).decode("ascii") response = requests.post( token_url, - headers={ - "Authorization": f"Basic {auth_b64}", - "Content-Type": "application/x-www-form-urlencoded" - }, - data={ - "grant_type": "authorization_code", - "code": code, - "redirect_uri": redirect_uri - } + headers={"Authorization": f"Basic {auth_b64}", "Content-Type": "application/x-www-form-urlencoded"}, + data={"grant_type": "authorization_code", "code": code, "redirect_uri": redirect_uri}, ) response.raise_for_status() @@ -101,8 +89,7 @@ class XeroConnector(BaseConnector): if "access_token" in data: try: tenants_response = requests.get( - f"{self.BASE_URL}/connections", - headers={"Authorization": f"Bearer {data['access_token']}"} + f"{self.BASE_URL}/connections", headers={"Authorization": f"Bearer {data['access_token']}"} ) if tenants_response.status_code == 200: tenants = tenants_response.json() @@ -129,6 +116,7 @@ class XeroConnector(BaseConnector): raise ValueError("No refresh token available") from app.models import Settings + settings = Settings.get_settings() creds = settings.get_integration_credentials("xero") client_id = creds.get("client_id") or os.getenv("XERO_CLIENT_ID") @@ -137,19 +125,13 @@ class XeroConnector(BaseConnector): token_url = "https://identity.xero.com/connect/token" auth_string = f"{client_id}:{client_secret}" - auth_bytes = auth_string.encode('ascii') - auth_b64 = base64.b64encode(auth_bytes).decode('ascii') + auth_bytes = auth_string.encode("ascii") + auth_b64 = base64.b64encode(auth_bytes).decode("ascii") response = requests.post( token_url, - headers={ - "Authorization": f"Basic {auth_b64}", - "Content-Type": "application/x-www-form-urlencoded" - }, - data={ - "grant_type": "refresh_token", - "refresh_token": self.credentials.refresh_token - } + headers={"Authorization": f"Basic {auth_b64}", "Content-Type": "application/x-www-form-urlencoded"}, + data={"grant_type": "refresh_token", "refresh_token": self.credentials.refresh_token}, ) response.raise_for_status() @@ -166,6 +148,7 @@ class XeroConnector(BaseConnector): if expires_at: self.credentials.expires_at = expires_at from app.utils.db import safe_commit + safe_commit("refresh_xero_token", {"integration_id": self.integration.id}) return { @@ -186,28 +169,16 @@ class XeroConnector(BaseConnector): return {"success": False, "message": "Xero tenant not configured"} organisation_info = self._api_request( - "GET", - f"/api.xro/2.0/Organisation", - self.get_access_token(), - tenant_id + "GET", f"/api.xro/2.0/Organisation", self.get_access_token(), tenant_id ) if organisation_info: org_name = organisation_info.get("Organisations", [{}])[0].get("Name", "Unknown") - return { - "success": True, - "message": f"Connected to Xero organisation: {org_name}" - } + return {"success": True, "message": f"Connected to Xero organisation: {org_name}"} else: - return { - "success": False, - "message": "Failed to retrieve organisation information" - } + return {"success": False, "message": "Failed to retrieve organisation information"} except Exception as e: - return { - "success": False, - "message": f"Connection test failed: {str(e)}" - } + return {"success": False, "message": f"Connection test failed: {str(e)}"} def _api_request(self, method: str, endpoint: str, access_token: str, tenant_id: str) -> Optional[Dict]: """Make API request to Xero""" @@ -217,7 +188,7 @@ class XeroConnector(BaseConnector): "Authorization": f"Bearer {access_token}", "Accept": "application/json", "Content-Type": "application/json", - "Xero-tenant-id": tenant_id + "Xero-tenant-id": tenant_id, } try: @@ -244,7 +215,7 @@ class XeroConnector(BaseConnector): if not tenant_id: if self.credentials and self.credentials.extra_data: tenant_id = self.credentials.extra_data.get("tenantId") - + if not tenant_id: return {"success": False, "message": "Xero tenant not configured"} @@ -255,8 +226,7 @@ class XeroConnector(BaseConnector): # Sync invoices (create as invoices in Xero) if sync_type == "full" or sync_type == "invoices": invoices = Invoice.query.filter( - Invoice.status.in_(["sent", "paid"]), - Invoice.created_at >= datetime.utcnow() - timedelta(days=90) + Invoice.status.in_(["sent", "paid"]), Invoice.created_at >= datetime.utcnow() - timedelta(days=90) ).all() for invoice in invoices: @@ -264,65 +234,58 @@ class XeroConnector(BaseConnector): xero_invoice = self._create_xero_invoice(invoice, access_token, tenant_id) if xero_invoice: # Store Xero ID in invoice metadata - if not hasattr(invoice, 'metadata') or not invoice.metadata: + if not hasattr(invoice, "metadata") or not invoice.metadata: invoice.metadata = {} - invoice.metadata['xero_invoice_id'] = xero_invoice.get("Invoices", [{}])[0].get("InvoiceID") + invoice.metadata["xero_invoice_id"] = xero_invoice.get("Invoices", [{}])[0].get("InvoiceID") synced_count += 1 except Exception as e: errors.append(f"Error syncing invoice {invoice.id}: {str(e)}") # Sync expenses (create as expenses in Xero) if sync_type == "full" or sync_type == "expenses": - expenses = Expense.query.filter( - Expense.date >= datetime.utcnow().date() - timedelta(days=90) - ).all() + expenses = Expense.query.filter(Expense.date >= datetime.utcnow().date() - timedelta(days=90)).all() for expense in expenses: try: xero_expense = self._create_xero_expense(expense, access_token, tenant_id) if xero_expense: - if not hasattr(expense, 'metadata') or not expense.metadata: + if not hasattr(expense, "metadata") or not expense.metadata: expense.metadata = {} - expense.metadata['xero_expense_id'] = xero_expense.get("Expenses", [{}])[0].get("ExpenseID") + expense.metadata["xero_expense_id"] = xero_expense.get("Expenses", [{}])[0].get("ExpenseID") synced_count += 1 except Exception as e: errors.append(f"Error syncing expense {expense.id}: {str(e)}") db.session.commit() - return { - "success": True, - "synced_count": synced_count, - "errors": errors - } + return {"success": True, "synced_count": synced_count, "errors": errors} except Exception as e: - return { - "success": False, - "message": f"Sync failed: {str(e)}" - } + return {"success": False, "message": f"Sync failed: {str(e)}"} def _create_xero_invoice(self, invoice, access_token: str, tenant_id: str) -> Optional[Dict]: """Create invoice in Xero""" # Build Xero invoice structure xero_invoice = { "Type": "ACCREC", - "Contact": { - "Name": invoice.client.name if invoice.client else "Unknown" - }, + "Contact": {"Name": invoice.client.name if invoice.client else "Unknown"}, "Date": invoice.date.strftime("%Y-%m-%d") if invoice.date else datetime.utcnow().strftime("%Y-%m-%d"), - "DueDate": invoice.due_date.strftime("%Y-%m-%d") if invoice.due_date else datetime.utcnow().strftime("%Y-%m-%d"), - "LineItems": [] + "DueDate": ( + invoice.due_date.strftime("%Y-%m-%d") if invoice.due_date else datetime.utcnow().strftime("%Y-%m-%d") + ), + "LineItems": [], } # Add invoice items for item in invoice.items: - xero_invoice["LineItems"].append({ - "Description": item.description, - "Quantity": float(item.quantity), - "UnitAmount": float(item.unit_price), - "LineAmount": float(item.quantity * item.unit_price), - }) + xero_invoice["LineItems"].append( + { + "Description": item.description, + "Quantity": float(item.quantity), + "UnitAmount": float(item.unit_price), + "LineAmount": float(item.quantity * item.unit_price), + } + ) endpoint = "/api.xro/2.0/Invoices" return self._api_request("POST", endpoint, access_token, tenant_id) @@ -332,15 +295,15 @@ class XeroConnector(BaseConnector): # Build Xero expense structure xero_expense = { "Date": expense.date.strftime("%Y-%m-%d") if expense.date else datetime.utcnow().strftime("%Y-%m-%d"), - "Contact": { - "Name": expense.vendor or "Unknown" - }, - "LineItems": [{ - "Description": expense.description or "Expense", - "Quantity": 1.0, - "UnitAmount": float(expense.amount), - "LineAmount": float(expense.amount), - }] + "Contact": {"Name": expense.vendor or "Unknown"}, + "LineItems": [ + { + "Description": expense.description or "Expense", + "Quantity": 1.0, + "UnitAmount": float(expense.amount), + "LineAmount": float(expense.amount), + } + ], } endpoint = "/api.xro/2.0/Expenses" @@ -354,21 +317,10 @@ class XeroConnector(BaseConnector): "name": "tenant_id", "type": "string", "label": "Tenant ID", - "description": "Xero organisation tenant ID" + "description": "Xero organisation tenant ID", }, - { - "name": "sync_invoices", - "type": "boolean", - "label": "Sync Invoices", - "default": True - }, - { - "name": "sync_expenses", - "type": "boolean", - "label": "Sync Expenses", - "default": True - } + {"name": "sync_invoices", "type": "boolean", "label": "Sync Invoices", "default": True}, + {"name": "sync_expenses", "type": "boolean", "label": "Sync Expenses", "default": True}, ], - "required": ["tenant_id"] + "required": ["tenant_id"], } - diff --git a/app/models/client.py b/app/models/client.py index 5d8db33f..a529c557 100644 --- a/app/models/client.py +++ b/app/models/client.py @@ -224,26 +224,28 @@ class Client(db.Model): def get_rendered_links(self): """Get all rendered links from active link templates that match this client's custom fields""" from .link_template import LinkTemplate - + if not self.custom_fields: return [] - + links = [] templates = LinkTemplate.get_active_templates() - + for template in templates: field_value = self.get_custom_field(template.field_key) if field_value: url = template.render_url(field_value) if url: - links.append({ - "id": template.id, - "name": template.name, - "url": url, - "icon": template.icon, - "description": template.description, - }) - + links.append( + { + "id": template.id, + "name": template.name, + "url": url, + "icon": template.icon, + "description": template.description, + } + ) + return links def to_dict(self): diff --git a/app/models/client_portal_customization.py b/app/models/client_portal_customization.py index f0bb5d85..e63fa04d 100644 --- a/app/models/client_portal_customization.py +++ b/app/models/client_portal_customization.py @@ -19,35 +19,35 @@ class ClientPortalCustomization(db.Model): logo_url = db.Column(db.String(500), nullable=True) # URL to custom logo logo_upload_path = db.Column(db.String(500), nullable=True) # Path to uploaded logo file favicon_url = db.Column(db.String(500), nullable=True) - + # Colors primary_color = db.Column(db.String(7), nullable=True) # Hex color code secondary_color = db.Column(db.String(7), nullable=True) accent_color = db.Column(db.String(7), nullable=True) - + # Typography font_family = db.Column(db.String(100), nullable=True) # Custom font family heading_font = db.Column(db.String(100), nullable=True) - + # Custom CSS custom_css = db.Column(db.Text, nullable=True) # Custom CSS rules custom_header_html = db.Column(db.Text, nullable=True) # Custom header HTML custom_footer_html = db.Column(db.Text, nullable=True) # Custom footer HTML - + # Portal title and description portal_title = db.Column(db.String(200), nullable=True) # Custom portal title portal_description = db.Column(db.Text, nullable=True) welcome_message = db.Column(db.Text, nullable=True) - + # Features show_projects = db.Column(db.Boolean, default=True, nullable=False) show_invoices = db.Column(db.Boolean, default=True, nullable=False) show_time_entries = db.Column(db.Boolean, default=True, nullable=False) show_quotes = db.Column(db.Boolean, default=True, nullable=False) - + # Navigation custom_navigation_items = db.Column(db.JSON, nullable=True) # Custom menu items - + # Metadata created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False) @@ -97,4 +97,3 @@ class ClientPortalCustomization(db.Model): if self.heading_font: variables.append(f"--portal-heading-font: {self.heading_font};") return "\n".join(variables) - diff --git a/app/models/client_time_approval.py b/app/models/client_time_approval.py index d8d74880..56a10cc8 100644 --- a/app/models/client_time_approval.py +++ b/app/models/client_time_approval.py @@ -11,6 +11,7 @@ import enum class ClientApprovalStatus(enum.Enum): """Client approval status""" + PENDING = "pending" APPROVED = "approved" REJECTED = "rejected" @@ -161,4 +162,3 @@ class ClientApprovalPolicy(db.Model): return False return True - diff --git a/app/models/custom_report.py b/app/models/custom_report.py index f102855d..ffd2cf07 100644 --- a/app/models/custom_report.py +++ b/app/models/custom_report.py @@ -14,23 +14,23 @@ class CustomReportConfig(db.Model): id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(200), nullable=False) owner_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False, index=True) - + # Report type report_type = db.Column(db.String(50), nullable=False) # 'time', 'project', 'invoice', 'expense', 'combined' - + # Builder configuration (JSON) builder_config = db.Column(db.JSON, nullable=False) # Columns, filters, groupings, charts - + # Layout layout_config = db.Column(db.JSON, nullable=True) # Drag-and-drop layout positions - + # Sharing scope = db.Column(db.String(20), default="private", nullable=False) # 'private', 'team', 'public' shared_with = db.Column(db.JSON, nullable=True) # List of user IDs - + # Status is_active = db.Column(db.Boolean, default=True, nullable=False) - + # Metadata created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False) @@ -55,4 +55,3 @@ class CustomReportConfig(db.Model): "created_at": self.created_at.isoformat() if self.created_at else None, "updated_at": self.updated_at.isoformat() if self.updated_at else None, } - diff --git a/app/models/expense_gps.py b/app/models/expense_gps.py index e74e51f8..b7b184b1 100644 --- a/app/models/expense_gps.py +++ b/app/models/expense_gps.py @@ -16,7 +16,7 @@ class MileageTrack(db.Model): id = db.Column(db.Integer, primary_key=True) expense_id = db.Column(db.Integer, db.ForeignKey("expenses.id"), nullable=True, index=True) user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False, index=True) - + # Track metadata start_location = db.Column(db.String(200), nullable=True) # Address or coordinates end_location = db.Column(db.String(200), nullable=True) @@ -24,23 +24,23 @@ class MileageTrack(db.Model): start_longitude = db.Column(db.Numeric(11, 8), nullable=True) end_latitude = db.Column(db.Numeric(10, 8), nullable=True) end_longitude = db.Column(db.Numeric(11, 8), nullable=True) - + # Calculated distance distance_km = db.Column(db.Numeric(10, 2), nullable=True) distance_miles = db.Column(db.Numeric(10, 2), nullable=True) - + # Track points (JSON array of {lat, lng, timestamp}) track_points = db.Column(db.JSON, nullable=True) - + # Timing started_at = db.Column(db.DateTime, nullable=False, default=datetime.utcnow) ended_at = db.Column(db.DateTime, nullable=True) duration_seconds = db.Column(db.Integer, nullable=True) - + # Metadata method = db.Column(db.String(50), default="gps", nullable=False) # 'gps', 'manual', 'route_calculation' notes = db.Column(db.Text, nullable=True) - + created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False) @@ -48,9 +48,7 @@ class MileageTrack(db.Model): expense = db.relationship("Expense", backref=db.backref("gps_tracks", lazy="dynamic")) user = db.relationship("User", backref=db.backref("mileage_tracks", lazy="dynamic")) - __table_args__ = ( - Index("ix_mileage_tracks_user_started", "user_id", "started_at"), - ) + __table_args__ = (Index("ix_mileage_tracks_user_started", "user_id", "started_at"),) def __repr__(self): return f"" @@ -94,7 +92,7 @@ class MileageTrack(db.Model): dlat = lat2 - lat1 dlon = lon2 - lon1 - a = sin(dlat / 2)**2 + cos(lat1) * cos(lat2) * sin(dlon / 2)**2 + a = sin(dlat / 2) ** 2 + cos(lat1) * cos(lat2) * sin(dlon / 2) ** 2 c = 2 * atan2(sqrt(a), sqrt(1 - a)) distance_km = R * c @@ -127,7 +125,7 @@ class MileageTrack(db.Model): dlat = lat2 - lat1 dlon = lon2 - lon1 - a = sin(dlat / 2)**2 + cos(lat1) * cos(lat2) * sin(dlon / 2)**2 + a = sin(dlat / 2) ** 2 + cos(lat1) * cos(lat2) * sin(dlon / 2) ** 2 c = 2 * atan2(sqrt(a), sqrt(1 - a)) segment_distance = R * c @@ -137,4 +135,3 @@ class MileageTrack(db.Model): self.distance_miles = total_distance * 0.621371 return total_distance - diff --git a/app/models/gamification.py b/app/models/gamification.py index 2257e0fb..2bdb25dc 100644 --- a/app/models/gamification.py +++ b/app/models/gamification.py @@ -18,15 +18,15 @@ class Badge(db.Model): description = db.Column(db.Text, nullable=True) icon = db.Column(db.String(100), nullable=True) # Icon class or URL badge_type = db.Column(db.String(50), nullable=False) # 'achievement', 'milestone', 'streak', 'special' - + # Criteria (JSON) - conditions to earn badge criteria = db.Column(db.JSON, nullable=False) - + # Metadata points = db.Column(db.Integer, default=0, nullable=False) rarity = db.Column(db.String(20), default="common", nullable=False) # 'common', 'rare', 'epic', 'legendary' is_active = db.Column(db.Boolean, default=True, nullable=False) - + created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False) @@ -55,7 +55,7 @@ class UserBadge(db.Model): id = db.Column(db.Integer, primary_key=True) user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False, index=True) badge_id = db.Column(db.Integer, db.ForeignKey("badges.id"), nullable=False, index=True) - + # Achievement metadata earned_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) progress = db.Column(db.Integer, default=100, nullable=False) # Progress percentage @@ -95,22 +95,24 @@ class Leaderboard(db.Model): id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(200), nullable=False) description = db.Column(db.Text, nullable=True) - + # Leaderboard type - leaderboard_type = db.Column(db.String(50), nullable=False) # 'time_tracked', 'tasks_completed', 'projects_completed', 'streak', 'points' - + leaderboard_type = db.Column( + db.String(50), nullable=False + ) # 'time_tracked', 'tasks_completed', 'projects_completed', 'streak', 'points' + # Time period period = db.Column(db.String(20), default="all_time", nullable=False) # 'daily', 'weekly', 'monthly', 'all_time' - + # Scope scope = db.Column(db.String(50), nullable=True) # 'global', 'team', 'project_{id}' - + # Configuration config = db.Column(db.JSON, nullable=True) # Additional configuration - + # Status is_active = db.Column(db.Boolean, default=True, nullable=False) - + created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False) @@ -138,18 +140,18 @@ class LeaderboardEntry(db.Model): id = db.Column(db.Integer, primary_key=True) leaderboard_id = db.Column(db.Integer, db.ForeignKey("leaderboards.id"), nullable=False, index=True) user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False, index=True) - + # Ranking data rank = db.Column(db.Integer, nullable=False) score = db.Column(db.Numeric(10, 2), nullable=False) - + # Period tracking period_start = db.Column(db.DateTime, nullable=False, index=True) period_end = db.Column(db.DateTime, nullable=False) - + # Metadata entry_metadata = db.Column(db.JSON, nullable=True) - + calculated_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) # Relationships @@ -174,10 +176,9 @@ class LeaderboardEntry(db.Model): "score": float(self.score), "period_start": self.period_start.isoformat() if self.period_start else None, "period_end": self.period_end.isoformat() if self.period_end else None, - "user": { - "id": self.user.id, - "username": self.user.username, - "display_name": self.user.display_name - } if self.user else None, + "user": ( + {"id": self.user.id, "username": self.user.username, "display_name": self.user.display_name} + if self.user + else None + ), } - diff --git a/app/models/integration.py b/app/models/integration.py index a482643a..d4f8ab0f 100644 --- a/app/models/integration.py +++ b/app/models/integration.py @@ -16,8 +16,12 @@ class Integration(db.Model): id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(100), nullable=False) # e.g., 'Jira', 'Slack', 'GitHub' provider = db.Column(db.String(50), nullable=False, index=True) # e.g., 'jira', 'slack', 'github' - user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=True, index=True) # Nullable for global integrations - is_global = db.Column(db.Boolean, default=False, nullable=False, index=True) # True for global (shared) integrations + user_id = db.Column( + db.Integer, db.ForeignKey("users.id"), nullable=True, index=True + ) # Nullable for global integrations + is_global = db.Column( + db.Boolean, default=False, nullable=False, index=True + ) # True for global (shared) integrations is_active = db.Column(db.Boolean, default=False, nullable=False) # Only True when credentials are set up config = db.Column(JSON, nullable=True) # Provider-specific configuration last_sync_at = db.Column(db.DateTime, nullable=True) @@ -25,10 +29,10 @@ class Integration(db.Model): last_error = db.Column(db.Text, nullable=True) created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False) - + __table_args__ = ( # Ensure only one global integration per provider - db.CheckConstraint('(is_global = 0) OR (is_global = 1 AND user_id IS NULL)', name='check_global_integration'), + db.CheckConstraint("(is_global = 0) OR (is_global = 1 AND user_id IS NULL)", name="check_global_integration"), ) user = db.relationship("User", backref="integrations") diff --git a/app/models/link_template.py b/app/models/link_template.py index 4759c108..1673cf4d 100644 --- a/app/models/link_template.py +++ b/app/models/link_template.py @@ -59,4 +59,3 @@ class LinkTemplate(db.Model): if field_key: query = query.filter_by(field_key=field_key) return query.order_by(cls.order, cls.name).all() - diff --git a/app/models/recurring_task.py b/app/models/recurring_task.py index 50b8e41d..159e6992 100644 --- a/app/models/recurring_task.py +++ b/app/models/recurring_task.py @@ -43,7 +43,9 @@ class RecurringTask(db.Model): # Relationships project = db.relationship("Project", backref=db.backref("recurring_tasks", lazy="dynamic")) - creator = db.relationship("User", foreign_keys=[created_by], backref=db.backref("created_recurring_tasks", lazy="dynamic")) + creator = db.relationship( + "User", foreign_keys=[created_by], backref=db.backref("created_recurring_tasks", lazy="dynamic") + ) assignee = db.relationship("User", foreign_keys=[assigned_to]) def __init__(self, name, project_id, frequency, next_run_date, created_by, **kwargs): @@ -100,7 +102,7 @@ class RecurringTask(db.Model): priority=self.priority, estimated_hours=float(self.estimated_hours) if self.estimated_hours else None, assigned_to=self.assigned_to if not self.auto_assign else self.created_by, - status="todo" + status="todo", ) db.session.add(task) @@ -139,4 +141,3 @@ class RecurringTask(db.Model): "last_created_at": self.last_created_at.isoformat() if self.last_created_at else None, "tasks_created_count": self.tasks_created_count, } - diff --git a/app/models/settings.py b/app/models/settings.py index 2b245389..5e4772d1 100644 --- a/app/models/settings.py +++ b/app/models/settings.py @@ -107,14 +107,20 @@ class Settings(db.Model): github_client_secret = db.Column(db.String(255), default="", nullable=True) # Store encrypted in production # Google Calendar google_calendar_client_id = db.Column(db.String(255), default="", nullable=True) - google_calendar_client_secret = db.Column(db.String(255), default="", nullable=True) # Store encrypted in production + google_calendar_client_secret = db.Column( + db.String(255), default="", nullable=True + ) # Store encrypted in production # Outlook Calendar outlook_calendar_client_id = db.Column(db.String(255), default="", nullable=True) - outlook_calendar_client_secret = db.Column(db.String(255), default="", nullable=True) # Store encrypted in production + outlook_calendar_client_secret = db.Column( + db.String(255), default="", nullable=True + ) # Store encrypted in production outlook_calendar_tenant_id = db.Column(db.String(255), default="", nullable=True) # Microsoft Teams microsoft_teams_client_id = db.Column(db.String(255), default="", nullable=True) - microsoft_teams_client_secret = db.Column(db.String(255), default="", nullable=True) # Store encrypted in production + microsoft_teams_client_secret = db.Column( + db.String(255), default="", nullable=True + ) # Store encrypted in production microsoft_teams_tenant_id = db.Column(db.String(255), default="", nullable=True) # Asana asana_client_id = db.Column(db.String(255), default="", nullable=True) @@ -279,60 +285,66 @@ class Settings(db.Model): client_id = self.jira_client_id or os.getenv("JIRA_CLIENT_ID", "") client_secret = self.jira_client_secret or os.getenv("JIRA_CLIENT_SECRET", "") return {"client_id": client_id, "client_secret": client_secret} - + elif provider == "slack": client_id = self.slack_client_id or os.getenv("SLACK_CLIENT_ID", "") client_secret = self.slack_client_secret or os.getenv("SLACK_CLIENT_SECRET", "") return {"client_id": client_id, "client_secret": client_secret} - + elif provider == "github": client_id = self.github_client_id or os.getenv("GITHUB_CLIENT_ID", "") client_secret = self.github_client_secret or os.getenv("GITHUB_CLIENT_SECRET", "") return {"client_id": client_id, "client_secret": client_secret} - + elif provider == "google_calendar": client_id = getattr(self, "google_calendar_client_id", "") or os.getenv("GOOGLE_CLIENT_ID", "") client_secret = getattr(self, "google_calendar_client_secret", "") or os.getenv("GOOGLE_CLIENT_SECRET", "") return {"client_id": client_id, "client_secret": client_secret} - + elif provider == "outlook_calendar": client_id = getattr(self, "outlook_calendar_client_id", "") or os.getenv("OUTLOOK_CLIENT_ID", "") - client_secret = getattr(self, "outlook_calendar_client_secret", "") or os.getenv("OUTLOOK_CLIENT_SECRET", "") + client_secret = getattr(self, "outlook_calendar_client_secret", "") or os.getenv( + "OUTLOOK_CLIENT_SECRET", "" + ) tenant_id = getattr(self, "outlook_calendar_tenant_id", "") or os.getenv("OUTLOOK_TENANT_ID", "") return {"client_id": client_id, "client_secret": client_secret, "tenant_id": tenant_id} - + elif provider == "microsoft_teams": client_id = getattr(self, "microsoft_teams_client_id", "") or os.getenv("MICROSOFT_TEAMS_CLIENT_ID", "") - client_secret = getattr(self, "microsoft_teams_client_secret", "") or os.getenv("MICROSOFT_TEAMS_CLIENT_SECRET", "") + client_secret = getattr(self, "microsoft_teams_client_secret", "") or os.getenv( + "MICROSOFT_TEAMS_CLIENT_SECRET", "" + ) tenant_id = getattr(self, "microsoft_teams_tenant_id", "") or os.getenv("MICROSOFT_TEAMS_TENANT_ID", "") return {"client_id": client_id, "client_secret": client_secret, "tenant_id": tenant_id} - + elif provider == "asana": client_id = getattr(self, "asana_client_id", "") or os.getenv("ASANA_CLIENT_ID", "") client_secret = getattr(self, "asana_client_secret", "") or os.getenv("ASANA_CLIENT_SECRET", "") return {"client_id": client_id, "client_secret": client_secret} - + elif provider == "trello": api_key = getattr(self, "trello_api_key", "") or os.getenv("TRELLO_API_KEY", "") api_secret = getattr(self, "trello_api_secret", "") or os.getenv("TRELLO_API_SECRET", "") return {"api_key": api_key, "api_secret": api_secret} - + elif provider == "gitlab": client_id = getattr(self, "gitlab_client_id", "") or os.getenv("GITLAB_CLIENT_ID", "") client_secret = getattr(self, "gitlab_client_secret", "") or os.getenv("GITLAB_CLIENT_SECRET", "") - instance_url = getattr(self, "gitlab_instance_url", "") or os.getenv("GITLAB_INSTANCE_URL", "https://gitlab.com") + instance_url = getattr(self, "gitlab_instance_url", "") or os.getenv( + "GITLAB_INSTANCE_URL", "https://gitlab.com" + ) return {"client_id": client_id, "client_secret": client_secret, "instance_url": instance_url} - + elif provider == "quickbooks": client_id = getattr(self, "quickbooks_client_id", "") or os.getenv("QUICKBOOKS_CLIENT_ID", "") client_secret = getattr(self, "quickbooks_client_secret", "") or os.getenv("QUICKBOOKS_CLIENT_SECRET", "") return {"client_id": client_id, "client_secret": client_secret} - + elif provider == "xero": client_id = getattr(self, "xero_client_id", "") or os.getenv("XERO_CLIENT_ID", "") client_secret = getattr(self, "xero_client_secret", "") or os.getenv("XERO_CLIENT_SECRET", "") return {"client_id": client_id, "client_secret": client_secret} - + else: return {} @@ -440,22 +452,25 @@ class Settings(db.Model): # Check if it's a column error - if so, it's expected during migrations error_str = str(e) is_column_error = ( - "UndefinedColumn" in error_str or - "does not exist" in error_str.lower() or - "no such column" in error_str.lower() + "UndefinedColumn" in error_str + or "does not exist" in error_str.lower() + or "no such column" in error_str.lower() ) - + import logging + logger = logging.getLogger(__name__) - + if is_column_error: # This is expected during migrations when schema is incomplete # Only log at debug level to avoid cluttering logs - logger.debug(f"Settings table schema incomplete (migration may be pending): {error_str.split('LINE')[0] if 'LINE' in error_str else error_str}") + logger.debug( + f"Settings table schema incomplete (migration may be pending): {error_str.split('LINE')[0] if 'LINE' in error_str else error_str}" + ) else: # Other errors should be logged as warnings logger.warning(f"Could not query settings: {e}") - + # Rollback the failed transaction try: db.session.rollback() diff --git a/app/models/team_chat.py b/app/models/team_chat.py index 4354baae..1d0d90e7 100644 --- a/app/models/team_chat.py +++ b/app/models/team_chat.py @@ -16,11 +16,13 @@ class ChatChannel(db.Model): name = db.Column(db.String(200), nullable=False) description = db.Column(db.Text, nullable=True) channel_type = db.Column(db.String(20), default="public", nullable=False) # 'public', 'private', 'direct' - + # Channel settings created_by = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False) - project_id = db.Column(db.Integer, db.ForeignKey("projects.id"), nullable=True, index=True) # Project-specific channel - + project_id = db.Column( + db.Integer, db.ForeignKey("projects.id"), nullable=True, index=True + ) # Project-specific channel + # Metadata is_archived = db.Column(db.Boolean, default=False, nullable=False) created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) @@ -32,9 +34,7 @@ class ChatChannel(db.Model): messages = db.relationship("ChatMessage", backref="channel", lazy="dynamic", cascade="all, delete-orphan") members = db.relationship("ChatChannelMember", backref="channel", lazy="dynamic", cascade="all, delete-orphan") - __table_args__ = ( - Index("ix_chat_channels_type", "channel_type"), - ) + __table_args__ = (Index("ix_chat_channels_type", "channel_type"),) def __repr__(self): return f"" @@ -50,7 +50,7 @@ class ChatChannel(db.Model): "is_archived": self.is_archived, "created_at": self.created_at.isoformat() if self.created_at else None, "message_count": self.messages.count(), - "member_count": self.members.count() + "member_count": self.members.count(), } @@ -62,14 +62,14 @@ class ChatChannelMember(db.Model): id = db.Column(db.Integer, primary_key=True) channel_id = db.Column(db.Integer, db.ForeignKey("chat_channels.id"), nullable=False, index=True) user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False, index=True) - + # Permissions is_admin = db.Column(db.Boolean, default=False, nullable=False) - + # Notification settings notifications_enabled = db.Column(db.Boolean, default=True, nullable=False) muted_until = db.Column(db.DateTime, nullable=True) # Mute until this time - + # Metadata joined_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) last_read_at = db.Column(db.DateTime, nullable=True) @@ -94,30 +94,30 @@ class ChatMessage(db.Model): id = db.Column(db.Integer, primary_key=True) channel_id = db.Column(db.Integer, db.ForeignKey("chat_channels.id"), nullable=False, index=True) user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False, index=True) - + # Message content message = db.Column(db.Text, nullable=False) message_type = db.Column(db.String(20), default="text", nullable=False) # 'text', 'file', 'system' - + # File attachment attachment_url = db.Column(db.String(500), nullable=True) attachment_filename = db.Column(db.String(255), nullable=True) attachment_size = db.Column(db.Integer, nullable=True) - + # Reply/thread reply_to_id = db.Column(db.Integer, db.ForeignKey("chat_messages.id"), nullable=True) - + # Mentions mentions = db.Column(db.JSON, nullable=True) # List of mentioned user IDs - + # Reactions reactions = db.Column(db.JSON, nullable=True) # {emoji: [user_ids]} - + # Status is_edited = db.Column(db.Boolean, default=False, nullable=False) is_deleted = db.Column(db.Boolean, default=False, nullable=False) edited_at = db.Column(db.DateTime, nullable=True) - + # Metadata created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False, index=True) updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False) @@ -126,9 +126,7 @@ class ChatMessage(db.Model): user = db.relationship("User", backref=db.backref("chat_messages", lazy="dynamic")) reply_to = db.relationship("ChatMessage", remote_side=[id], backref=db.backref("replies", lazy="dynamic")) - __table_args__ = ( - Index("ix_chat_messages_channel_created", "channel_id", "created_at"), - ) + __table_args__ = (Index("ix_chat_messages_channel_created", "channel_id", "created_at"),) def __repr__(self): return f"" @@ -156,16 +154,18 @@ class ChatMessage(db.Model): def parse_mentions(self): """Parse @mentions from message and extract user IDs""" import re + mentions = [] - pattern = r'@(\w+)' + pattern = r"@(\w+)" matches = re.findall(pattern, self.message) - + from app.models import User + for username in matches: user = User.query.filter_by(username=username).first() if user: mentions.append(user.id) - + self.mentions = mentions if mentions else None return mentions @@ -184,10 +184,7 @@ class ChatReadReceipt(db.Model): message = db.relationship("ChatMessage", backref=db.backref("read_receipts", lazy="dynamic")) user = db.relationship("User", backref=db.backref("chat_read_receipts", lazy="dynamic")) - __table_args__ = ( - db.UniqueConstraint("message_id", "user_id", name="uq_read_receipt"), - ) + __table_args__ = (db.UniqueConstraint("message_id", "user_id", name="uq_read_receipt"),) def __repr__(self): return f"" - diff --git a/app/models/time_entry_approval.py b/app/models/time_entry_approval.py index 1e93f756..7bc59136 100644 --- a/app/models/time_entry_approval.py +++ b/app/models/time_entry_approval.py @@ -10,6 +10,7 @@ import enum class ApprovalStatus(enum.Enum): """Time entry approval status""" + PENDING = "pending" APPROVED = "approved" REJECTED = "rejected" @@ -23,35 +24,41 @@ class TimeEntryApproval(db.Model): id = db.Column(db.Integer, primary_key=True) time_entry_id = db.Column(db.Integer, db.ForeignKey("time_entries.id"), nullable=False, index=True) - + # Approval workflow status = db.Column(SQLEnum(ApprovalStatus), default=ApprovalStatus.PENDING, nullable=False, index=True) requested_by = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False, index=True) approved_by = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=True, index=True) - + # Timestamps requested_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) approved_at = db.Column(db.DateTime, nullable=True) rejected_at = db.Column(db.DateTime, nullable=True) - + # Comments request_comment = db.Column(db.Text, nullable=True) approval_comment = db.Column(db.Text, nullable=True) rejection_reason = db.Column(db.Text, nullable=True) - + # Approval chain (for multi-level approvals) parent_approval_id = db.Column(db.Integer, db.ForeignKey("time_entry_approvals.id"), nullable=True) approval_level = db.Column(db.Integer, default=1, nullable=False) - + # Metadata created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False) # Relationships time_entry = db.relationship("TimeEntry", backref=db.backref("approvals", lazy="dynamic")) - requester = db.relationship("User", foreign_keys=[requested_by], backref=db.backref("approval_requests", lazy="dynamic")) - approver = db.relationship("User", foreign_keys=[approved_by], backref=db.backref("approvals_given", lazy="dynamic")) - parent_approval = db.relationship("TimeEntryApproval", remote_side=[id], backref=db.backref("child_approvals", lazy="dynamic")) + requester = db.relationship( + "User", foreign_keys=[requested_by], backref=db.backref("approval_requests", lazy="dynamic") + ) + approver = db.relationship( + "User", foreign_keys=[approved_by], backref=db.backref("approvals_given", lazy="dynamic") + ) + parent_approval = db.relationship( + "TimeEntryApproval", remote_side=[id], backref=db.backref("child_approvals", lazy="dynamic") + ) def __repr__(self): return f"" @@ -101,28 +108,28 @@ class ApprovalPolicy(db.Model): __tablename__ = "approval_policies" id = db.Column(db.Integer, primary_key=True) - + # Policy scope project_id = db.Column(db.Integer, db.ForeignKey("projects.id"), nullable=True, index=True) user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=True, index=True) applies_to_all = db.Column(db.Boolean, default=False, nullable=False) - + # Approval requirements requires_approval = db.Column(db.Boolean, default=True, nullable=False) approval_levels = db.Column(db.Integer, default=1, nullable=False) # Multi-level approvals approver_user_ids = db.Column(db.String(500), nullable=True) # Comma-separated user IDs - + # Conditions min_hours = db.Column(db.Numeric(10, 2), nullable=True) # Require approval if >= this many hours billable_only = db.Column(db.Boolean, default=False, nullable=False) # Only require approval for billable time - + # Auto-approval rules auto_approve_after_hours = db.Column(db.Integer, nullable=True) # Auto-approve after X hours if no response auto_approve_for_admins = db.Column(db.Boolean, default=False, nullable=False) - + # Status enabled = db.Column(db.Boolean, default=True, nullable=False) - + # Metadata created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False) @@ -181,4 +188,3 @@ class ApprovalPolicy(db.Model): return False return True - diff --git a/app/models/time_entry_template.py b/app/models/time_entry_template.py index 8716e29c..994cbbce 100644 --- a/app/models/time_entry_template.py +++ b/app/models/time_entry_template.py @@ -75,7 +75,7 @@ class TimeEntryTemplate(db.Model): except Exception: # If accessing project fails (e.g., detached instance), just use None project_name = None - + task_name = None if self.task_id: try: @@ -83,7 +83,7 @@ class TimeEntryTemplate(db.Model): except Exception: # If accessing task fails (e.g., detached instance), just use None task_name = None - + return { "id": self.id, "user_id": self.user_id, diff --git a/app/models/user.py b/app/models/user.py index 3c7bbb18..b842bc50 100644 --- a/app/models/user.py +++ b/app/models/user.py @@ -25,7 +25,9 @@ class User(UserMixin, db.Model): oidc_issuer = db.Column(db.String(255), nullable=True) avatar_filename = db.Column(db.String(255), nullable=True) password_hash = db.Column(db.String(255), nullable=True) - password_change_required = db.Column(db.Boolean, default=False, nullable=False) # Force password change on first login + password_change_required = db.Column( + db.Boolean, default=False, nullable=False + ) # Force password change on first login # User preferences and settings email_notifications = db.Column(db.Boolean, default=True, nullable=False) # Enable/disable email notifications @@ -58,16 +60,16 @@ class User(UserMixin, db.Model): # All default to True (enabled) for backward compatibility # Calendar section ui_show_calendar = db.Column(db.Boolean, default=True, nullable=False) # Show/hide Calendar section - + # Time Tracking section items ui_show_project_templates = db.Column(db.Boolean, default=True, nullable=False) # Show/hide Project Templates ui_show_gantt_chart = db.Column(db.Boolean, default=True, nullable=False) # Show/hide Gantt Chart ui_show_kanban_board = db.Column(db.Boolean, default=True, nullable=False) # Show/hide Kanban Board ui_show_weekly_goals = db.Column(db.Boolean, default=True, nullable=False) # Show/hide Weekly Goals - + # CRM section ui_show_quotes = db.Column(db.Boolean, default=True, nullable=False) # Show/hide Quotes - + # Finance & Expenses section items ui_show_reports = db.Column(db.Boolean, default=True, nullable=False) # Show/hide Reports ui_show_report_builder = db.Column(db.Boolean, default=True, nullable=False) # Show/hide Report Builder @@ -79,13 +81,13 @@ class User(UserMixin, db.Model): ui_show_mileage = db.Column(db.Boolean, default=True, nullable=False) # Show/hide Mileage ui_show_per_diem = db.Column(db.Boolean, default=True, nullable=False) # Show/hide Per Diem ui_show_budget_alerts = db.Column(db.Boolean, default=True, nullable=False) # Show/hide Budget Alerts - + # Inventory section ui_show_inventory = db.Column(db.Boolean, default=True, nullable=False) # Show/hide Inventory section - + # Analytics ui_show_analytics = db.Column(db.Boolean, default=True, nullable=False) # Show/hide Analytics - + # Tools & Data section ui_show_tools = db.Column(db.Boolean, default=True, nullable=False) # Show/hide Tools & Data section diff --git a/app/models/workflow.py b/app/models/workflow.py index b8a23699..89ab1db0 100644 --- a/app/models/workflow.py +++ b/app/models/workflow.py @@ -98,4 +98,3 @@ class WorkflowExecution(db.Model): "trigger_event": self.trigger_event, "execution_time_ms": self.execution_time_ms, } - diff --git a/app/repositories/base_repository.py b/app/repositories/base_repository.py index b5234277..df59a732 100644 --- a/app/repositories/base_repository.py +++ b/app/repositories/base_repository.py @@ -8,7 +8,7 @@ Example: class ProjectRepository(BaseRepository[Project]): def __init__(self): super().__init__(Project) - + def get_active_projects(self): return self.model.query.filter_by(status='active').all() """ diff --git a/app/repositories/time_entry_repository.py b/app/repositories/time_entry_repository.py index a82c7cc2..39b642ae 100644 --- a/app/repositories/time_entry_repository.py +++ b/app/repositories/time_entry_repository.py @@ -30,10 +30,10 @@ class TimeEntryRepository(BaseRepository[TimeEntry]): if include_relations: query = query.options( - joinedload(TimeEntry.project), + joinedload(TimeEntry.project), joinedload(TimeEntry.client), - joinedload(TimeEntry.task), - joinedload(TimeEntry.user) + joinedload(TimeEntry.task), + joinedload(TimeEntry.user), ) query = query.order_by(TimeEntry.start_time.desc()) @@ -51,10 +51,10 @@ class TimeEntryRepository(BaseRepository[TimeEntry]): if include_relations: query = query.options( - joinedload(TimeEntry.user), + joinedload(TimeEntry.user), joinedload(TimeEntry.project), joinedload(TimeEntry.client), - joinedload(TimeEntry.task) + joinedload(TimeEntry.task), ) query = query.order_by(TimeEntry.start_time.desc()) @@ -87,10 +87,10 @@ class TimeEntryRepository(BaseRepository[TimeEntry]): if include_relations: query = query.options( - joinedload(TimeEntry.user), - joinedload(TimeEntry.project), + joinedload(TimeEntry.user), + joinedload(TimeEntry.project), joinedload(TimeEntry.client), - joinedload(TimeEntry.task) + joinedload(TimeEntry.task), ) return query.order_by(TimeEntry.start_time.desc()).all() @@ -145,13 +145,13 @@ class TimeEntryRepository(BaseRepository[TimeEntry]): from app.models.time_entry import local_now entry = self.model( - user_id=user_id, - project_id=project_id, + user_id=user_id, + project_id=project_id, client_id=client_id, - task_id=task_id, - start_time=local_now(), - notes=notes, - source=source + task_id=task_id, + start_time=local_now(), + notes=notes, + source=source, ) db.session.add(entry) return entry diff --git a/app/routes/activity_feed.py b/app/routes/activity_feed.py index c889ca1f..f783c59a 100644 --- a/app/routes/activity_feed.py +++ b/app/routes/activity_feed.py @@ -23,47 +23,45 @@ def activity_feed(): user_id = request.args.get("user_id", type=int) entity_type = request.args.get("entity_type", "").strip() action = request.args.get("action", "").strip() - + # Build query query = Activity.query # Apply filters if user_id: query = query.filter_by(user_id=user_id) - + if entity_type: query = query.filter_by(entity_type=entity_type) - + if action: query = query.filter_by(action=action) # Date filters start_date = request.args.get("start_date", "").strip() end_date = request.args.get("end_date", "").strip() - + if start_date: try: - start_dt = datetime.fromisoformat(start_date.replace('Z', '+00:00')) + start_dt = datetime.fromisoformat(start_date.replace("Z", "+00:00")) query = query.filter(Activity.created_at >= start_dt) except Exception: pass - + if end_date: try: - end_dt = datetime.fromisoformat(end_date.replace('Z', '+00:00')) + end_dt = datetime.fromisoformat(end_date.replace("Z", "+00:00")) query = query.filter(Activity.created_at <= end_dt) except Exception: pass # Paginate per_page = min(limit, 100) # Max 100 per page - paginated = query.order_by(Activity.created_at.desc()).paginate( - page=page, per_page=per_page, error_out=False - ) + paginated = query.order_by(Activity.created_at.desc()).paginate(page=page, per_page=per_page, error_out=False) # Get filter options - entity_types = db.session.query(Activity.entity_type).distinct().all() if hasattr(db, 'session') else [] - actions = db.session.query(Activity.action).distinct().all() if hasattr(db, 'session') else [] + entity_types = db.session.query(Activity.entity_type).distinct().all() if hasattr(db, "session") else [] + actions = db.session.query(Activity.action).distinct().all() if hasattr(db, "session") else [] return render_template( "activity/feed.html", @@ -77,7 +75,7 @@ def activity_feed(): "action": action, "start_date": start_date, "end_date": end_date, - } + }, ) @@ -100,42 +98,41 @@ def api_activity_feed(): # Apply filters if user_id: query = query.filter_by(user_id=user_id) - + if entity_type: query = query.filter_by(entity_type=entity_type) - + if action: query = query.filter_by(action=action) - + if start_date: try: - start_dt = datetime.fromisoformat(start_date.replace('Z', '+00:00')) + start_dt = datetime.fromisoformat(start_date.replace("Z", "+00:00")) query = query.filter(Activity.created_at >= start_dt) except Exception: pass - + if end_date: try: - end_dt = datetime.fromisoformat(end_date.replace('Z', '+00:00')) + end_dt = datetime.fromisoformat(end_date.replace("Z", "+00:00")) query = query.filter(Activity.created_at <= end_dt) except Exception: pass # Paginate per_page = min(limit, 100) - paginated = query.order_by(Activity.created_at.desc()).paginate( - page=page, per_page=per_page, error_out=False - ) + paginated = query.order_by(Activity.created_at.desc()).paginate(page=page, per_page=per_page, error_out=False) - return jsonify({ - "activities": [a.to_dict() for a in paginated.items], - "pagination": { - "page": paginated.page, - "per_page": paginated.per_page, - "total": paginated.total, - "pages": paginated.pages, - "has_next": paginated.has_next, - "has_prev": paginated.has_prev, + return jsonify( + { + "activities": [a.to_dict() for a in paginated.items], + "pagination": { + "page": paginated.page, + "per_page": paginated.per_page, + "total": paginated.total, + "pages": paginated.pages, + "has_next": paginated.has_next, + "has_prev": paginated.has_prev, + }, } - }) - + ) diff --git a/app/routes/admin.py b/app/routes/admin.py index 09f6239a..136e99fb 100644 --- a/app/routes/admin.py +++ b/app/routes/admin.py @@ -203,16 +203,16 @@ def create_user(): # Create user with legacy role field for backward compatibility user = User(username=username, role=role_name) - + # Assign the role from the new Role system user.roles.append(role_obj) - + # Set default password if provided if default_password: user.set_password(default_password) if force_password_change: user.password_change_required = True - + db.session.add(user) if not safe_commit("admin_create_user", {"username": username}): flash(_("Could not create user due to a database error. Please check server logs."), "error") @@ -504,6 +504,7 @@ def settings(): 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 @@ -2436,18 +2437,21 @@ def delete_email_template(template_id): # ==================== Integration Setup Routes ==================== + @admin_bp.route("/admin/integrations") @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) + + return render_template( + "admin/integrations/list.html", integrations=integrations, available_providers=available_providers + ) @admin_bp.route("/admin/integrations//setup", methods=["GET", "POST"]) @@ -2457,17 +2461,17 @@ 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": @@ -2480,7 +2484,7 @@ def integration_setup(provider): 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": @@ -2493,6 +2497,7 @@ def integration_setup(provider): # 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, @@ -2501,13 +2506,13 @@ def integration_setup(provider): expires_at=None, token_type="Bearer", scope="read,write", - extra_data={"api_key": api_key} + 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"), @@ -2521,14 +2526,14 @@ def integration_setup(provider): "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() @@ -2542,19 +2547,24 @@ def integration_setup(provider): 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") + 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, diff --git a/app/routes/api_v1.py b/app/routes/api_v1.py index dba0362a..53b92ef6 100644 --- a/app/routes/api_v1.py +++ b/app/routes/api_v1.py @@ -220,7 +220,7 @@ def list_projects(): description: List of projects """ from app.services import ProjectService - + # Filter by status status = request.args.get("status", "active") client_id = request.args.get("client_id", type=int) @@ -259,13 +259,13 @@ def get_project(project_id): description: Project not found """ from app.services import ProjectService - + project_service = ProjectService() result = project_service.get_project_with_details(project_id=project_id, include_time_entries=False) - + if not result: return jsonify({"error": "Project not found"}), 404 - + return jsonify({"project": result.to_dict()}) @@ -307,7 +307,7 @@ def create_project(): description: Invalid input """ from app.services import ProjectService - + data = request.get_json() or {} # Validate required fields @@ -360,12 +360,12 @@ def update_project(project_id): description: Project not found """ from app.services import ProjectService - + data = request.get_json() or {} # Use service layer to update project project_service = ProjectService() - + # Prepare update kwargs update_kwargs = {} if "name" in data: @@ -387,11 +387,7 @@ def update_project(project_id): if "billing_ref" in data: update_kwargs["billing_ref"] = data["billing_ref"] - result = project_service.update_project( - project_id=project_id, - user_id=g.api_user.id, - **update_kwargs - ) + result = project_service.update_project(project_id=project_id, user_id=g.api_user.id, **update_kwargs) if not result.get("success"): return jsonify({"error": result.get("message", "Could not update project")}), 400 @@ -420,13 +416,9 @@ def delete_project(project_id): description: Project not found """ from app.services import ProjectService - + project_service = ProjectService() - result = project_service.archive_project( - project_id=project_id, - user_id=g.api_user.id, - reason="Archived via API" - ) + result = project_service.archive_project(project_id=project_id, user_id=g.api_user.id, reason="Archived via API") if not result.get("success"): return jsonify({"error": result.get("message", "Could not archive project")}), 404 @@ -476,10 +468,10 @@ def list_time_entries(): """ from app.services import TimeTrackingService from sqlalchemy.orm import joinedload - + # Filter by project project_id = request.args.get("project_id", type=int) - + # Filter by user (non-admin can only see their own) user_id = request.args.get("user_id", type=int) if user_id: @@ -504,22 +496,22 @@ def list_time_entries(): # Only completed entries by default include_active = request.args.get("include_active") == "true" - + page = request.args.get("page", 1, type=int) per_page = request.args.get("per_page", 50, type=int) # Use repository with eager loading to avoid N+1 queries from app.repositories import TimeEntryRepository + time_entry_repo = TimeEntryRepository() - + # Build query with eager loading (use model.query for base query) from app.models import TimeEntry + query = TimeEntry.query.options( - joinedload(TimeEntry.project), - joinedload(TimeEntry.user), - joinedload(TimeEntry.task) + joinedload(TimeEntry.project), joinedload(TimeEntry.user), joinedload(TimeEntry.task) ) - + # Apply filters if project_id: query = query.filter(TimeEntry.project_id == project_id) @@ -533,7 +525,7 @@ def list_time_entries(): query = query.filter(TimeEntry.billable == billable_filter) if not include_active: query = query.filter(TimeEntry.end_time.isnot(None)) - + # Order and paginate query = query.order_by(TimeEntry.start_time.desc()) result = paginate_query(query, page, per_page) @@ -563,12 +555,12 @@ def get_time_entry(entry_id): """ from sqlalchemy.orm import joinedload from app.models import TimeEntry - - entry = TimeEntry.query.options( - joinedload(TimeEntry.project), - joinedload(TimeEntry.user), - joinedload(TimeEntry.task) - ).filter_by(id=entry_id).first_or_404() + + entry = ( + TimeEntry.query.options(joinedload(TimeEntry.project), joinedload(TimeEntry.user), joinedload(TimeEntry.task)) + .filter_by(id=entry_id) + .first_or_404() + ) # Check permissions if not g.api_user.is_admin and entry.user_id != g.api_user.id: @@ -619,7 +611,7 @@ def create_time_entry(): description: Invalid input """ from app.services import TimeTrackingService - + data = request.get_json() or {} # Validate required fields @@ -684,14 +676,14 @@ def update_time_entry(entry_id): description: Time entry not found """ from app.services import TimeTrackingService - + data = request.get_json() or {} # Parse times start_time = None if "start_time" in data: start_time = parse_datetime(data["start_time"]) - + end_time = None if "end_time" in data: if data["end_time"] is None: @@ -741,13 +733,9 @@ def delete_time_entry(entry_id): description: Time entry not found """ from app.services import TimeTrackingService - + time_tracking_service = TimeTrackingService() - result = time_tracking_service.delete_entry( - entry_id=entry_id, - user_id=g.api_user.id, - is_admin=g.api_user.is_admin - ) + result = time_tracking_service.delete_entry(entry_id=entry_id, user_id=g.api_user.id, is_admin=g.api_user.is_admin) if not result.get("success"): return jsonify({"error": result.get("message", "Could not delete time entry")}), 400 @@ -808,7 +796,7 @@ def start_timer(): description: Invalid input or timer already running """ from app.services import TimeTrackingService - + data = request.get_json() or {} # Validate project_id @@ -848,7 +836,7 @@ def stop_timer(): description: No active timer """ from app.services import TimeTrackingService - + time_tracking_service = TimeTrackingService() result = time_tracking_service.stop_timer(user_id=g.api_user.id) @@ -888,7 +876,7 @@ def list_tasks(): description: List of tasks """ from app.services import TaskService - + # Filter by project project_id = request.args.get("project_id", type=int) status = request.args.get("status") @@ -916,7 +904,7 @@ def list_tasks(): "next_page": pagination.page + 1 if pagination.has_next else None, "prev_page": pagination.page - 1 if pagination.has_prev else None, } - + return jsonify({"tasks": [t.to_dict() for t in result["tasks"]], "pagination": pagination_dict}) @@ -942,13 +930,13 @@ def get_task(task_id): """ from sqlalchemy.orm import joinedload from app.models import Task - - task = Task.query.options( - joinedload(Task.project), - joinedload(Task.assignee), - joinedload(Task.created_by_user) - ).filter_by(id=task_id).first_or_404() - + + task = ( + Task.query.options(joinedload(Task.project), joinedload(Task.assignee), joinedload(Task.created_by_user)) + .filter_by(id=task_id) + .first_or_404() + ) + return jsonify({"task": task.to_dict()}) @@ -988,7 +976,7 @@ def create_task(): description: Invalid input """ from app.services import TaskService - + data = request.get_json() or {} # Validate required fields @@ -1041,12 +1029,12 @@ def update_task(task_id): description: Task not found """ from app.services import TaskService - + data = request.get_json() or {} # Use service layer to update task task_service = TaskService() - + # Prepare update kwargs update_kwargs = {} if "name" in data: @@ -1064,11 +1052,7 @@ def update_task(task_id): if "estimated_hours" in data: update_kwargs["estimated_hours"] = data["estimated_hours"] - result = task_service.update_task( - task_id=task_id, - user_id=g.api_user.id, - **update_kwargs - ) + result = task_service.update_task(task_id=task_id, user_id=g.api_user.id, **update_kwargs) if not result.get("success"): return jsonify({"error": result.get("message", "Could not update task")}), 400 @@ -1097,13 +1081,14 @@ def delete_task(task_id): description: Task not found """ from app.services import TaskService - + task_service = TaskService() # For now, use repository directly (can add delete_task method to service later) from app.repositories import TaskRepository + task_repo = TaskRepository() task = task_repo.get_by_id(task_id) - + if not task: return jsonify({"error": "Task not found"}), 404 @@ -1137,15 +1122,16 @@ def list_clients(): description: List of clients """ from app.services import ClientService - + page = request.args.get("page", 1, type=int) per_page = request.args.get("per_page", 50, type=int) # Use repository with eager loading (clients don't have many relations, but good practice) from app.repositories import ClientRepository + client_repo = ClientRepository() query = client_repo.query().order_by(Client.name) - + # Paginate pagination = query.paginate(page=page, per_page=per_page, error_out=False) pagination_dict = { @@ -1183,11 +1169,9 @@ def get_client(client_id): description: Client not found """ from sqlalchemy.orm import joinedload - - client = Client.query.options( - joinedload(Client.projects) - ).filter_by(id=client_id).first_or_404() - + + client = Client.query.options(joinedload(Client.projects)).filter_by(id=client_id).first_or_404() + return jsonify({"client": client.to_dict()}) @@ -1284,7 +1268,7 @@ def list_invoices(): description: List of invoices """ from app.services import InvoiceService - + status = request.args.get("status") client_id = request.args.get("client_id", type=int) project_id = request.args.get("project_id", type=int) @@ -1313,7 +1297,7 @@ def list_invoices(): "next_page": pagination.page + 1 if pagination.has_next else None, "prev_page": pagination.page - 1 if pagination.has_prev else None, } - + return jsonify({"invoices": [inv.to_dict() for inv in result["invoices"]], "pagination": pagination_dict}) @@ -1334,12 +1318,13 @@ def get_invoice(invoice_id): """ from sqlalchemy.orm import joinedload from app.models import Invoice - - invoice = Invoice.query.options( - joinedload(Invoice.project), - joinedload(Invoice.client) - ).filter_by(id=invoice_id).first_or_404() - + + invoice = ( + Invoice.query.options(joinedload(Invoice.project), joinedload(Invoice.client)) + .filter_by(id=invoice_id) + .first_or_404() + ) + return jsonify({"invoice": invoice.to_dict()}) @@ -1383,27 +1368,27 @@ def create_invoice(): """ from app.services import InvoiceService from datetime import date - + data = request.get_json() or {} - + # Validate required fields required = ["project_id", "client_id", "client_name", "due_date"] missing = [f for f in required if not data.get(f)] if missing: return jsonify({"error": f"Missing required fields: {', '.join(missing)}"}), 400 - + # Parse due date due_dt = _parse_date(data.get("due_date")) if not due_dt: return jsonify({"error": "Invalid due_date format, expected YYYY-MM-DD"}), 400 - + # Parse issue date if provided issue_dt = None if data.get("issue_date"): issue_dt = _parse_date(data.get("issue_date")) if not issue_dt: return jsonify({"error": "Invalid issue_date format, expected YYYY-MM-DD"}), 400 - + # Use service layer to create invoice invoice_service = InvoiceService() result = invoice_service.create_invoice( @@ -1444,9 +1429,9 @@ def update_invoice(invoice_id): description: Not found """ from app.services import InvoiceService - + data = request.get_json() or {} - + # Prepare update kwargs update_kwargs = {} for field in ("client_name", "client_email", "client_address", "notes", "terms", "status", "currency_code"): @@ -1464,17 +1449,14 @@ def update_invoice(invoice_id): if "amount_paid" in data: try: from decimal import Decimal + update_kwargs["amount_paid"] = Decimal(str(data["amount_paid"])) except Exception: pass # Use service layer to update invoice invoice_service = InvoiceService() - result = invoice_service.update_invoice( - invoice_id=invoice_id, - user_id=g.api_user.id, - **update_kwargs - ) + result = invoice_service.update_invoice(invoice_id=invoice_id, user_id=g.api_user.id, **update_kwargs) if not result.get("success"): return jsonify({"error": result.get("message", "Could not update invoice")}), 400 @@ -1504,13 +1486,9 @@ def delete_invoice(invoice_id): description: Not found """ from app.services import InvoiceService - + invoice_service = InvoiceService() - result = invoice_service.update_invoice( - invoice_id=invoice_id, - user_id=g.api_user.id, - status="cancelled" - ) + result = invoice_service.update_invoice(invoice_id=invoice_id, user_id=g.api_user.id, status="cancelled") if not result.get("success"): return jsonify({"error": result.get("message", "Could not cancel invoice")}), 400 @@ -1566,7 +1544,7 @@ def list_expenses(): """ from app.services import ExpenseService from datetime import date - + # Restrict by user if not admin user_id = request.args.get("user_id", type=int) if user_id: @@ -1575,7 +1553,7 @@ def list_expenses(): else: if not g.api_user.is_admin: user_id = g.api_user.id - + # Other filters project_id = request.args.get("project_id", type=int) client_id = request.args.get("client_id", type=int) @@ -1613,7 +1591,7 @@ def list_expenses(): "next_page": pagination.page + 1 if pagination.has_next else None, "prev_page": pagination.page - 1 if pagination.has_prev else None, } - + return jsonify({"expenses": [e.to_dict() for e in result["expenses"]], "pagination": pagination_dict}) @@ -1634,16 +1612,16 @@ def get_expense(expense_id): """ from sqlalchemy.orm import joinedload from app.models import Expense - - expense = Expense.query.options( - joinedload(Expense.project), - joinedload(Expense.user), - joinedload(Expense.category) - ).filter_by(id=expense_id).first_or_404() - + + expense = ( + Expense.query.options(joinedload(Expense.project), joinedload(Expense.user), joinedload(Expense.category)) + .filter_by(id=expense_id) + .first_or_404() + ) + if not g.api_user.is_admin and expense.user_id != g.api_user.id: return jsonify({"error": "Access denied"}), 403 - + return jsonify({"expense": expense.to_dict()}) @@ -1689,17 +1667,17 @@ def create_expense(): """ from app.services import ExpenseService from decimal import Decimal - + data = request.get_json() or {} required = ["title", "category", "amount", "expense_date"] missing = [f for f in required if not data.get(f)] if missing: return jsonify({"error": f"Missing required fields: {', '.join(missing)}"}), 400 - + exp_date = _parse_date(data.get("expense_date")) if not exp_date: return jsonify({"error": "Invalid expense_date format, expected YYYY-MM-DD"}), 400 - + pay_date = _parse_date(data.get("payment_date")) if data.get("payment_date") else None try: @@ -1751,9 +1729,9 @@ def update_expense(expense_id): """ from app.services import ExpenseService from decimal import Decimal - + data = request.get_json() or {} - + # Prepare update kwargs update_kwargs = {} for field in ("title", "description", "category", "currency_code", "payment_method", "status", "tags"): @@ -1778,10 +1756,7 @@ def update_expense(expense_id): # Use service layer to update expense expense_service = ExpenseService() result = expense_service.update_expense( - expense_id=expense_id, - user_id=g.api_user.id, - is_admin=g.api_user.is_admin, - **update_kwargs + expense_id=expense_id, user_id=g.api_user.id, is_admin=g.api_user.is_admin, **update_kwargs ) if not result.get("success"): @@ -1806,13 +1781,9 @@ def delete_expense(expense_id): description: Not found """ from app.services import ExpenseService - + expense_service = ExpenseService() - result = expense_service.delete_expense( - expense_id=expense_id, - user_id=g.api_user.id, - is_admin=g.api_user.is_admin - ) + result = expense_service.delete_expense(expense_id=expense_id, user_id=g.api_user.id, is_admin=g.api_user.is_admin) if not result.get("success"): return jsonify({"error": result.get("message", "Could not reject expense")}), 400 @@ -1849,21 +1820,19 @@ def list_payments(): from app.services import PaymentService from sqlalchemy.orm import joinedload from app.models import Payment - + invoice_id = request.args.get("invoice_id", type=int) page = request.args.get("page", 1, type=int) per_page = request.args.get("per_page", 50, type=int) # Use repository with eager loading to avoid N+1 queries - query = Payment.query.options( - joinedload(Payment.invoice) - ) - + query = Payment.query.options(joinedload(Payment.invoice)) + if invoice_id: query = query.filter(Payment.invoice_id == invoice_id) - + query = query.order_by(Payment.created_at.desc()) - + # Paginate pagination = query.paginate(page=page, per_page=per_page, error_out=False) pagination_dict = { @@ -1876,7 +1845,7 @@ def list_payments(): "next_page": pagination.page + 1 if pagination.has_next else None, "prev_page": pagination.page - 1 if pagination.has_prev else None, } - + return jsonify({"payments": [p.to_dict() for p in pagination.items], "pagination": pagination_dict}) @@ -1895,11 +1864,9 @@ def get_payment(payment_id): """ from sqlalchemy.orm import joinedload from app.models import Payment - - payment = Payment.query.options( - joinedload(Payment.invoice) - ).filter_by(id=payment_id).first_or_404() - + + payment = Payment.query.options(joinedload(Payment.invoice)).filter_by(id=payment_id).first_or_404() + return jsonify({"payment": payment.to_dict()}) @@ -1933,7 +1900,7 @@ def create_payment(): """ from app.services import PaymentService from decimal import Decimal - + data = request.get_json() or {} required = ["invoice_id", "amount"] missing = [f for f in required if not data.get(f)] @@ -1944,10 +1911,11 @@ def create_payment(): amount = Decimal(str(data["amount"])) except Exception: return jsonify({"error": "Invalid amount"}), 400 - + pay_date = _parse_date(data.get("payment_date")) if data.get("payment_date") else None if not pay_date: from datetime import date + pay_date = date.today() # Use service layer to create payment @@ -1985,9 +1953,9 @@ def update_payment(payment_id): """ from app.services import PaymentService from decimal import Decimal - + data = request.get_json() or {} - + # Prepare update kwargs update_kwargs = {} for field in ("currency", "method", "reference", "notes", "status"): @@ -2005,11 +1973,7 @@ def update_payment(payment_id): # Use service layer to update payment payment_service = PaymentService() - result = payment_service.update_payment( - payment_id=payment_id, - user_id=g.api_user.id, - **update_kwargs - ) + result = payment_service.update_payment(payment_id=payment_id, user_id=g.api_user.id, **update_kwargs) if not result.get("success"): return jsonify({"error": result.get("message", "Could not update payment")}), 400 @@ -2031,12 +1995,9 @@ def delete_payment(payment_id): description: Payment deleted """ from app.services import PaymentService - + payment_service = PaymentService() - result = payment_service.delete_payment( - payment_id=payment_id, - user_id=g.api_user.id - ) + result = payment_service.delete_payment(payment_id=payment_id, user_id=g.api_user.id) if not result.get("success"): return jsonify({"error": result.get("message", "Could not delete payment")}), 400 @@ -2082,7 +2043,7 @@ def list_mileage(): description: List of mileage entries """ from sqlalchemy.orm import joinedload - + # Restrict by user if not admin user_id = request.args.get("user_id", type=int) if user_id: @@ -2091,7 +2052,7 @@ def list_mileage(): else: if not g.api_user.is_admin: user_id = g.api_user.id - + project_id = request.args.get("project_id", type=int) start_date = _parse_date(request.args.get("start_date")) end_date = _parse_date(request.args.get("end_date")) @@ -2099,12 +2060,8 @@ def list_mileage(): per_page = request.args.get("per_page", 50, type=int) # Use eager loading to avoid N+1 queries - query = Mileage.query.options( - joinedload(Mileage.user), - joinedload(Mileage.project), - joinedload(Mileage.client) - ) - + query = Mileage.query.options(joinedload(Mileage.user), joinedload(Mileage.project), joinedload(Mileage.client)) + # Apply filters if user_id: query = query.filter(Mileage.user_id == user_id) @@ -2114,9 +2071,9 @@ def list_mileage(): query = query.filter(Mileage.trip_date >= start_date) if end_date: query = query.filter(Mileage.trip_date <= end_date) - + query = query.order_by(Mileage.trip_date.desc(), Mileage.created_at.desc()) - + # Paginate pagination = query.paginate(page=page, per_page=per_page, error_out=False) pagination_dict = { @@ -2129,7 +2086,7 @@ def list_mileage(): "next_page": pagination.page + 1 if pagination.has_next else None, "prev_page": pagination.page - 1 if pagination.has_prev else None, } - + return jsonify({"mileage": [m.to_dict() for m in pagination.items], "pagination": pagination_dict}) @@ -2144,16 +2101,16 @@ def get_mileage(entry_id): - Bearer: [] """ from sqlalchemy.orm import joinedload - - entry = Mileage.query.options( - joinedload(Mileage.user), - joinedload(Mileage.project), - joinedload(Mileage.client) - ).filter_by(id=entry_id).first_or_404() - + + entry = ( + Mileage.query.options(joinedload(Mileage.user), joinedload(Mileage.project), joinedload(Mileage.client)) + .filter_by(id=entry_id) + .first_or_404() + ) + if not g.api_user.is_admin and entry.user_id != g.api_user.id: return jsonify({"error": "Access denied"}), 403 - + return jsonify({"mileage": entry.to_dict()}) @@ -2225,18 +2182,18 @@ def update_mileage(entry_id): """ from sqlalchemy.orm import joinedload from decimal import Decimal - - entry = Mileage.query.options( - joinedload(Mileage.user), - joinedload(Mileage.project), - joinedload(Mileage.client) - ).filter_by(id=entry_id).first_or_404() - + + entry = ( + Mileage.query.options(joinedload(Mileage.user), joinedload(Mileage.project), joinedload(Mileage.client)) + .filter_by(id=entry_id) + .first_or_404() + ) + if not g.api_user.is_admin and entry.user_id != g.api_user.id: return jsonify({"error": "Access denied"}), 403 - + data = request.get_json() or {} - + # Update fields for field in ( "purpose", @@ -2264,13 +2221,13 @@ def update_mileage(entry_id): pass if "is_round_trip" in data: entry.is_round_trip = bool(data["is_round_trip"]) - + # Recalculate amount if distance or rate changed if "distance_km" in data or "rate_per_km" in data: entry.calculated_amount = entry.distance_km * entry.rate_per_km if entry.is_round_trip: entry.calculated_amount *= Decimal("2") - + db.session.commit() return jsonify({"message": "Mileage entry updated successfully", "mileage": entry.to_dict()}) @@ -2284,16 +2241,16 @@ def delete_mileage(entry_id): - Mileage """ from sqlalchemy.orm import joinedload - - entry = Mileage.query.options( - joinedload(Mileage.user), - joinedload(Mileage.project), - joinedload(Mileage.client) - ).filter_by(id=entry_id).first_or_404() - + + entry = ( + Mileage.query.options(joinedload(Mileage.user), joinedload(Mileage.project), joinedload(Mileage.client)) + .filter_by(id=entry_id) + .first_or_404() + ) + if not g.api_user.is_admin and entry.user_id != g.api_user.id: return jsonify({"error": "Access denied"}), 403 - + entry.status = "rejected" db.session.commit() return jsonify({"message": "Mileage entry rejected successfully"}) @@ -2311,18 +2268,18 @@ def list_per_diems(): - PerDiem """ from sqlalchemy.orm import joinedload - + page = request.args.get("page", 1, type=int) per_page = request.args.get("per_page", 50, type=int) # Use eager loading to avoid N+1 queries query = PerDiem.query.options(joinedload(PerDiem.user)) - + if not g.api_user.is_admin: query = query.filter(PerDiem.user_id == g.api_user.id) - + query = query.order_by(PerDiem.start_date.desc()) - + pagination = query.paginate(page=page, per_page=per_page, error_out=False) pagination_dict = { "page": pagination.page, @@ -2334,7 +2291,7 @@ def list_per_diems(): "next_page": pagination.page + 1 if pagination.has_next else None, "prev_page": pagination.page - 1 if pagination.has_prev else None, } - + return jsonify({"per_diems": [p.to_dict() for p in pagination.items], "pagination": pagination_dict}) @@ -2347,12 +2304,12 @@ def get_per_diem(pd_id): - PerDiem """ from sqlalchemy.orm import joinedload - + pd = PerDiem.query.options(joinedload(PerDiem.user)).filter_by(id=pd_id).first_or_404() - + if not g.api_user.is_admin and pd.user_id != g.api_user.id: return jsonify({"error": "Access denied"}), 403 - + return jsonify({"per_diem": pd.to_dict()}) @@ -2412,12 +2369,12 @@ def update_per_diem(pd_id): - PerDiem """ from sqlalchemy.orm import joinedload - + pd = PerDiem.query.options(joinedload(PerDiem.user)).filter_by(id=pd_id).first_or_404() - + if not g.api_user.is_admin and pd.user_id != g.api_user.id: return jsonify({"error": "Access denied"}), 403 - + data = request.get_json() or {} for field in ("trip_purpose", "description", "country", "city", "currency_code", "status", "notes"): if field in data: @@ -2458,12 +2415,12 @@ def delete_per_diem(pd_id): - PerDiem """ from sqlalchemy.orm import joinedload - + pd = PerDiem.query.options(joinedload(PerDiem.user)).filter_by(id=pd_id).first_or_404() - + if not g.api_user.is_admin and pd.user_id != g.api_user.id: return jsonify({"error": "Access denied"}), 403 - + pd.status = "rejected" db.session.commit() return jsonify({"message": "Per diem rejected successfully"}) @@ -2482,7 +2439,7 @@ def list_per_diem_rates(): query = PerDiemRate.query.filter(PerDiemRate.is_active == True) query = query.order_by(PerDiemRate.country.asc(), PerDiemRate.city.asc()) - + pagination = query.paginate(page=page, per_page=per_page, error_out=False) pagination_dict = { "page": pagination.page, @@ -2494,7 +2451,7 @@ def list_per_diem_rates(): "next_page": pagination.page + 1 if pagination.has_next else None, "prev_page": pagination.page - 1 if pagination.has_prev else None, } - + return jsonify({"rates": [r.to_dict() for r in pagination.items], "pagination": pagination_dict}) @@ -2552,21 +2509,19 @@ def list_budget_alerts(): - BudgetAlerts """ from sqlalchemy.orm import joinedload - + project_id = request.args.get("project_id", type=int) page = request.args.get("page", 1, type=int) per_page = request.args.get("per_page", 50, type=int) # Use eager loading to avoid N+1 queries - query = BudgetAlert.query.options( - joinedload(BudgetAlert.project) - ) - + query = BudgetAlert.query.options(joinedload(BudgetAlert.project)) + if project_id: query = query.filter(BudgetAlert.project_id == project_id) - + query = query.order_by(BudgetAlert.created_at.desc()) - + pagination = query.paginate(page=page, per_page=per_page, error_out=False) pagination_dict = { "page": pagination.page, @@ -2578,7 +2533,7 @@ def list_budget_alerts(): "next_page": pagination.page + 1 if pagination.has_next else None, "prev_page": pagination.page - 1 if pagination.has_prev else None, } - + return jsonify({"alerts": [a.to_dict() for a in pagination.items], "pagination": pagination_dict}) @@ -2618,9 +2573,9 @@ def acknowledge_budget_alert(alert_id): - BudgetAlerts """ from sqlalchemy.orm import joinedload - + alert = BudgetAlert.query.options(joinedload(BudgetAlert.project)).filter_by(id=alert_id).first_or_404() - + alert.acknowledge(g.api_user.id) return jsonify({"message": "Alert acknowledged"}) @@ -2648,15 +2603,15 @@ def list_calendar_events(): start_dt = parse_datetime(start) if start else None end_dt = parse_datetime(end) if end else None from sqlalchemy.orm import joinedload - + query = CalendarEvent.query.options(joinedload(CalendarEvent.user)) query = query.filter(CalendarEvent.user_id == g.api_user.id) - + if start_dt: query = query.filter(CalendarEvent.start_time >= start_dt) if end_dt: query = query.filter(CalendarEvent.start_time <= end_dt) - + events = query.order_by(CalendarEvent.start_time.asc()).all() return jsonify({"events": [e.to_dict() for e in events]}) @@ -2670,12 +2625,12 @@ def get_calendar_event(event_id): - Calendar """ from sqlalchemy.orm import joinedload - + ev = CalendarEvent.query.options(joinedload(CalendarEvent.user)).filter_by(id=event_id).first_or_404() - + if not g.api_user.is_admin and ev.user_id != g.api_user.id: return jsonify({"error": "Access denied"}), 403 - + return jsonify({"event": ev.to_dict()}) @@ -2726,12 +2681,12 @@ def update_calendar_event(event_id): - Calendar """ from sqlalchemy.orm import joinedload - + ev = CalendarEvent.query.options(joinedload(CalendarEvent.user)).filter_by(id=event_id).first_or_404() - + if not g.api_user.is_admin and ev.user_id != g.api_user.id: return jsonify({"error": "Access denied"}), 403 - + data = request.get_json() or {} for field in ("title", "description", "location", "event_type", "color", "is_private", "reminder_minutes"): if field in data: @@ -2757,12 +2712,12 @@ def delete_calendar_event(event_id): - Calendar """ from sqlalchemy.orm import joinedload - + ev = CalendarEvent.query.options(joinedload(CalendarEvent.user)).filter_by(id=event_id).first_or_404() - + if not g.api_user.is_admin and ev.user_id != g.api_user.id: return jsonify({"error": "Access denied"}), 403 - + db.session.delete(ev) db.session.commit() return jsonify({"message": "Event deleted successfully"}) @@ -2826,9 +2781,9 @@ def update_kanban_column(col_id): - Kanban """ from sqlalchemy.orm import joinedload - + col = KanbanColumn.query.options(joinedload(KanbanColumn.project)).filter_by(id=col_id).first_or_404() - + data = request.get_json() or {} for field in ("key", "label", "icon", "color", "position", "is_active", "is_complete_state"): if field in data: @@ -2846,12 +2801,12 @@ def delete_kanban_column(col_id): - Kanban """ from sqlalchemy.orm import joinedload - + col = KanbanColumn.query.options(joinedload(KanbanColumn.project)).filter_by(id=col_id).first_or_404() - + if col.is_system: return jsonify({"error": "Cannot delete system column"}), 400 - + db.session.delete(col) db.session.commit() return jsonify({"message": "Column deleted successfully"}) @@ -2886,14 +2841,14 @@ def list_saved_filters(): - SavedFilters """ from sqlalchemy.orm import joinedload - + page = request.args.get("page", 1, type=int) per_page = request.args.get("per_page", 50, type=int) query = SavedFilter.query.options(joinedload(SavedFilter.user)) query = query.filter(SavedFilter.user_id == g.api_user.id) query = query.order_by(SavedFilter.created_at.desc()) - + pagination = query.paginate(page=page, per_page=per_page, error_out=False) pagination_dict = { "page": pagination.page, @@ -2905,7 +2860,7 @@ def list_saved_filters(): "next_page": pagination.page + 1 if pagination.has_next else None, "prev_page": pagination.page - 1 if pagination.has_prev else None, } - + return jsonify({"filters": [f.to_dict() for f in pagination.items], "pagination": pagination_dict}) @@ -2918,12 +2873,12 @@ def get_saved_filter(filter_id): - SavedFilters """ from sqlalchemy.orm import joinedload - + sf = SavedFilter.query.options(joinedload(SavedFilter.user)).filter_by(id=filter_id).first_or_404() - + if sf.user_id != g.api_user.id and not (sf.is_shared or g.api_user.is_admin): return jsonify({"error": "Access denied"}), 403 - + return jsonify({"filter": sf.to_dict()}) @@ -2961,12 +2916,12 @@ def update_saved_filter(filter_id): - SavedFilters """ from sqlalchemy.orm import joinedload - + sf = SavedFilter.query.options(joinedload(SavedFilter.user)).filter_by(id=filter_id).first_or_404() - + if sf.user_id != g.api_user.id and not g.api_user.is_admin: return jsonify({"error": "Access denied"}), 403 - + data = request.get_json() or {} for field in ("name", "scope", "payload", "is_shared"): if field in data: @@ -2984,12 +2939,12 @@ def delete_saved_filter(filter_id): - SavedFilters """ from sqlalchemy.orm import joinedload - + sf = SavedFilter.query.options(joinedload(SavedFilter.user)).filter_by(id=filter_id).first_or_404() - + if sf.user_id != g.api_user.id and not g.api_user.is_admin: return jsonify({"error": "Access denied"}), 403 - + db.session.delete(sf) db.session.commit() return jsonify({"message": "Saved filter deleted successfully"}) @@ -3007,17 +2962,14 @@ def list_time_entry_templates(): - TimeEntryTemplates """ from sqlalchemy.orm import joinedload - + page = request.args.get("page", 1, type=int) per_page = request.args.get("per_page", 50, type=int) - query = TimeEntryTemplate.query.options( - joinedload(TimeEntryTemplate.user), - joinedload(TimeEntryTemplate.project) - ) + query = TimeEntryTemplate.query.options(joinedload(TimeEntryTemplate.user), joinedload(TimeEntryTemplate.project)) query = query.filter(TimeEntryTemplate.user_id == g.api_user.id) query = query.order_by(TimeEntryTemplate.created_at.desc()) - + pagination = query.paginate(page=page, per_page=per_page, error_out=False) pagination_dict = { "page": pagination.page, @@ -3029,7 +2981,7 @@ def list_time_entry_templates(): "next_page": pagination.page + 1 if pagination.has_next else None, "prev_page": pagination.page - 1 if pagination.has_prev else None, } - + return jsonify({"templates": [t.to_dict() for t in pagination.items], "pagination": pagination_dict}) @@ -3042,15 +2994,16 @@ def get_time_entry_template(tpl_id): - TimeEntryTemplates """ from sqlalchemy.orm import joinedload - - tpl = TimeEntryTemplate.query.options( - joinedload(TimeEntryTemplate.user), - joinedload(TimeEntryTemplate.project) - ).filter_by(id=tpl_id).first_or_404() - + + tpl = ( + TimeEntryTemplate.query.options(joinedload(TimeEntryTemplate.user), joinedload(TimeEntryTemplate.project)) + .filter_by(id=tpl_id) + .first_or_404() + ) + if tpl.user_id != g.api_user.id and not g.api_user.is_admin: return jsonify({"error": "Access denied"}), 403 - + return jsonify({"template": tpl.to_dict()}) @@ -3092,15 +3045,16 @@ def update_time_entry_template(tpl_id): - TimeEntryTemplates """ from sqlalchemy.orm import joinedload - - tpl = TimeEntryTemplate.query.options( - joinedload(TimeEntryTemplate.user), - joinedload(TimeEntryTemplate.project) - ).filter_by(id=tpl_id).first_or_404() - + + tpl = ( + TimeEntryTemplate.query.options(joinedload(TimeEntryTemplate.user), joinedload(TimeEntryTemplate.project)) + .filter_by(id=tpl_id) + .first_or_404() + ) + if tpl.user_id != g.api_user.id and not g.api_user.is_admin: return jsonify({"error": "Access denied"}), 403 - + data = request.get_json() or {} for field in ( "name", @@ -3127,15 +3081,16 @@ def delete_time_entry_template(tpl_id): - TimeEntryTemplates """ from sqlalchemy.orm import joinedload - - tpl = TimeEntryTemplate.query.options( - joinedload(TimeEntryTemplate.user), - joinedload(TimeEntryTemplate.project) - ).filter_by(id=tpl_id).first_or_404() - + + tpl = ( + TimeEntryTemplate.query.options(joinedload(TimeEntryTemplate.user), joinedload(TimeEntryTemplate.project)) + .filter_by(id=tpl_id) + .first_or_404() + ) + if tpl.user_id != g.api_user.id and not g.api_user.is_admin: return jsonify({"error": "Access denied"}), 403 - + db.session.delete(tpl) db.session.commit() return jsonify({"message": "Template deleted successfully"}) @@ -3206,7 +3161,7 @@ def list_quotes(): from app.services import QuoteService from sqlalchemy.orm import joinedload - + status = request.args.get("status") client_id = request.args.get("client_id", type=int) page = request.args.get("page", 1, type=int) @@ -3221,18 +3176,18 @@ def list_quotes(): search=None, include_analytics=False, ) - + quotes = result["quotes"] - + # Apply client filter if needed if client_id: quotes = [q for q in quotes if q.client_id == client_id] - + # Paginate manually (service doesn't paginate yet) start = (page - 1) * per_page end = start + per_page paginated_quotes = quotes[start:end] - + pagination_dict = { "page": page, "per_page": per_page, @@ -3243,7 +3198,7 @@ def list_quotes(): "next_page": page + 1 if end < len(quotes) else None, "prev_page": page - 1 if page > 1 else None, } - + return jsonify({"quotes": [q.to_dict() for q in paginated_quotes], "pagination": pagination_dict}), 200 @@ -3258,17 +3213,15 @@ def get_quote(quote_id): from app.models import Quote from app.services import QuoteService - + quote_service = QuoteService() quote = quote_service.get_quote_with_details( - quote_id=quote_id, - user_id=g.api_user.id if not g.api_user.is_admin else None, - is_admin=g.api_user.is_admin + quote_id=quote_id, user_id=g.api_user.id if not g.api_user.is_admin else None, is_admin=g.api_user.is_admin ) - + if not quote: return jsonify({"error": "Quote not found"}), 404 - + return jsonify({"quote": quote.to_dict()}), 200 @@ -3354,7 +3307,7 @@ def update_quote(quote_id): # Use service layer to update quote quote_service = QuoteService() - + # Prepare update kwargs update_kwargs = {} if "title" in data: @@ -3375,10 +3328,7 @@ def update_quote(quote_id): update_kwargs["valid_until"] = valid_until result = quote_service.update_quote( - quote_id=quote_id, - user_id=g.api_user.id, - is_admin=g.api_user.is_admin, - **update_kwargs + quote_id=quote_id, user_id=g.api_user.id, is_admin=g.api_user.is_admin, **update_kwargs ) if not result.get("success"): @@ -3421,22 +3371,20 @@ def delete_quote(quote_id): from app.services import QuoteService from sqlalchemy.orm import joinedload - + # Use service layer with eager loading quote_service = QuoteService() quote = quote_service.get_quote_with_details( - quote_id=quote_id, - user_id=g.api_user.id if not g.api_user.is_admin else None, - is_admin=g.api_user.is_admin + quote_id=quote_id, user_id=g.api_user.id if not g.api_user.is_admin else None, is_admin=g.api_user.is_admin ) - + if not quote: return jsonify({"error": "Quote not found"}), 404 - + # Check permissions if not g.api_user.is_admin and quote.created_by != g.api_user.id: return jsonify({"error": "Access denied"}), 403 - + db.session.delete(quote) db.session.commit() return jsonify({"message": "Quote deleted successfully"}), 200 @@ -3451,16 +3399,16 @@ def update_comment(comment_id): - Comments """ from sqlalchemy.orm import joinedload - - cmt = Comment.query.options( - joinedload(Comment.user), - joinedload(Comment.project), - joinedload(Comment.task) - ).filter_by(id=comment_id).first_or_404() - + + cmt = ( + Comment.query.options(joinedload(Comment.user), joinedload(Comment.project), joinedload(Comment.task)) + .filter_by(id=comment_id) + .first_or_404() + ) + if cmt.user_id != g.api_user.id and not g.api_user.is_admin: return jsonify({"error": "Access denied"}), 403 - + data = request.get_json() or {} new_content = (data.get("content") or "").strip() if not new_content: @@ -3481,13 +3429,13 @@ def delete_comment(comment_id): - Comments """ from sqlalchemy.orm import joinedload - - cmt = Comment.query.options( - joinedload(Comment.user), - joinedload(Comment.project), - joinedload(Comment.task) - ).filter_by(id=comment_id).first_or_404() - + + cmt = ( + Comment.query.options(joinedload(Comment.user), joinedload(Comment.project), joinedload(Comment.task)) + .filter_by(id=comment_id) + .first_or_404() + ) + try: cmt.delete_comment(g.api_user) except PermissionError: @@ -3503,17 +3451,14 @@ def delete_comment(comment_id): def list_client_notes(client_id): """List client notes (paginated, important first)""" from sqlalchemy.orm import joinedload - + page = request.args.get("page", 1, type=int) per_page = request.args.get("per_page", 50, type=int) - query = ClientNote.query.options( - joinedload(ClientNote.client), - joinedload(ClientNote.created_by_user) - ) + query = ClientNote.query.options(joinedload(ClientNote.client), joinedload(ClientNote.created_by_user)) query = query.filter(ClientNote.client_id == client_id) query = query.order_by(ClientNote.is_important.desc(), ClientNote.created_at.desc()) - + pagination = query.paginate(page=page, per_page=per_page, error_out=False) pagination_dict = { "page": pagination.page, @@ -3525,7 +3470,7 @@ def list_client_notes(client_id): "next_page": pagination.page + 1 if pagination.has_next else None, "prev_page": pagination.page - 1 if pagination.has_prev else None, } - + return jsonify({"notes": [n.to_dict() for n in pagination.items], "pagination": pagination_dict}) @@ -3549,12 +3494,13 @@ def create_client_note(client_id): @require_api_token("read:clients") def get_client_note(note_id): from sqlalchemy.orm import joinedload - - note = ClientNote.query.options( - joinedload(ClientNote.client), - joinedload(ClientNote.created_by_user) - ).filter_by(id=note_id).first_or_404() - + + note = ( + ClientNote.query.options(joinedload(ClientNote.client), joinedload(ClientNote.created_by_user)) + .filter_by(id=note_id) + .first_or_404() + ) + return jsonify({"note": note.to_dict()}) @@ -3562,12 +3508,13 @@ def get_client_note(note_id): @require_api_token("write:clients") def update_client_note(note_id): from sqlalchemy.orm import joinedload - - note = ClientNote.query.options( - joinedload(ClientNote.client), - joinedload(ClientNote.created_by_user) - ).filter_by(id=note_id).first_or_404() - + + note = ( + ClientNote.query.options(joinedload(ClientNote.client), joinedload(ClientNote.created_by_user)) + .filter_by(id=note_id) + .first_or_404() + ) + data = request.get_json() or {} new_content = (data.get("content") or "").strip() if not new_content: @@ -3585,15 +3532,16 @@ def update_client_note(note_id): @require_api_token("write:clients") def delete_client_note(note_id): from sqlalchemy.orm import joinedload - - note = ClientNote.query.options( - joinedload(ClientNote.client), - joinedload(ClientNote.created_by_user) - ).filter_by(id=note_id).first_or_404() - + + note = ( + ClientNote.query.options(joinedload(ClientNote.client), joinedload(ClientNote.created_by_user)) + .filter_by(id=note_id) + .first_or_404() + ) + if not (g.api_user.is_admin or note.user_id == g.api_user.id): return jsonify({"error": "Access denied"}), 403 - + db.session.delete(note) db.session.commit() return jsonify({"message": "Client note deleted successfully"}) @@ -3611,16 +3559,13 @@ def list_project_costs(project_id): user_id = request.args.get("user_id", type=int) billable_only = request.args.get("billable_only", "false").lower() == "true" from sqlalchemy.orm import joinedload - + page = request.args.get("page", 1, type=int) per_page = request.args.get("per_page", 50, type=int) - query = ProjectCost.query.options( - joinedload(ProjectCost.project), - joinedload(ProjectCost.user) - ) + query = ProjectCost.query.options(joinedload(ProjectCost.project), joinedload(ProjectCost.user)) query = query.filter(ProjectCost.project_id == project_id) - + if start_date: query = query.filter(ProjectCost.cost_date >= start_date) if end_date: @@ -3629,9 +3574,9 @@ def list_project_costs(project_id): query = query.filter(ProjectCost.user_id == user_id) if billable_only: query = query.filter(ProjectCost.billable == True) - + query = query.order_by(ProjectCost.cost_date.desc(), ProjectCost.created_at.desc()) - + pagination = query.paginate(page=page, per_page=per_page, error_out=False) pagination_dict = { "page": pagination.page, @@ -3643,7 +3588,7 @@ def list_project_costs(project_id): "next_page": pagination.page + 1 if pagination.has_next else None, "prev_page": pagination.page - 1 if pagination.has_prev else None, } - + return jsonify({"costs": [c.to_dict() for c in pagination.items], "pagination": pagination_dict}) @@ -3685,12 +3630,13 @@ def create_project_cost(project_id): @require_api_token("read:projects") def get_project_cost(cost_id): from sqlalchemy.orm import joinedload - - cost = ProjectCost.query.options( - joinedload(ProjectCost.project), - joinedload(ProjectCost.user) - ).filter_by(id=cost_id).first_or_404() - + + cost = ( + ProjectCost.query.options(joinedload(ProjectCost.project), joinedload(ProjectCost.user)) + .filter_by(id=cost_id) + .first_or_404() + ) + return jsonify({"cost": cost.to_dict()}) @@ -3698,11 +3644,12 @@ def get_project_cost(cost_id): @require_api_token("write:projects") def update_project_cost(cost_id): from sqlalchemy.orm import joinedload - - cost = ProjectCost.query.options( - joinedload(ProjectCost.project), - joinedload(ProjectCost.user) - ).filter_by(id=cost_id).first_or_404() + + cost = ( + ProjectCost.query.options(joinedload(ProjectCost.project), joinedload(ProjectCost.user)) + .filter_by(id=cost_id) + .first_or_404() + ) data = request.get_json() or {} for field in ("description", "category", "currency_code", "notes", "billable"): if field in data: @@ -3726,12 +3673,13 @@ def update_project_cost(cost_id): @require_api_token("write:projects") def delete_project_cost(cost_id): from sqlalchemy.orm import joinedload - - cost = ProjectCost.query.options( - joinedload(ProjectCost.project), - joinedload(ProjectCost.user) - ).filter_by(id=cost_id).first_or_404() - + + cost = ( + ProjectCost.query.options(joinedload(ProjectCost.project), joinedload(ProjectCost.user)) + .filter_by(id=cost_id) + .first_or_404() + ) + db.session.delete(cost) db.session.commit() return jsonify({"message": "Project cost deleted successfully"}) @@ -3807,12 +3755,13 @@ def create_tax_rule(): @require_api_token("admin:all") def update_tax_rule(rule_id): from sqlalchemy.orm import joinedload - - rule = TaxRule.query.options( - joinedload(TaxRule.client), - joinedload(TaxRule.project) - ).filter_by(id=rule_id).first_or_404() - + + rule = ( + TaxRule.query.options(joinedload(TaxRule.client), joinedload(TaxRule.project)) + .filter_by(id=rule_id) + .first_or_404() + ) + data = request.get_json() or {} for field in ( "name", @@ -4201,15 +4150,12 @@ def list_recurring_invoices(): - RecurringInvoices """ from sqlalchemy.orm import joinedload - + page = request.args.get("page", 1, type=int) per_page = request.args.get("per_page", 50, type=int) - query = RecurringInvoice.query.options( - joinedload(RecurringInvoice.project), - joinedload(RecurringInvoice.client) - ) - + query = RecurringInvoice.query.options(joinedload(RecurringInvoice.project), joinedload(RecurringInvoice.client)) + is_active = request.args.get("is_active") if is_active is not None: query = query.filter(RecurringInvoice.is_active == (is_active.lower() == "true")) @@ -4219,9 +4165,9 @@ def list_recurring_invoices(): project_id = request.args.get("project_id", type=int) if project_id: query = query.filter(RecurringInvoice.project_id == project_id) - + query = query.order_by(RecurringInvoice.created_at.desc()) - + pagination = query.paginate(page=page, per_page=per_page, error_out=False) pagination_dict = { "page": pagination.page, @@ -4233,7 +4179,7 @@ def list_recurring_invoices(): "next_page": pagination.page + 1 if pagination.has_next else None, "prev_page": pagination.page - 1 if pagination.has_prev else None, } - + return jsonify({"recurring_invoices": [ri.to_dict() for ri in pagination.items], "pagination": pagination_dict}) @@ -4242,12 +4188,13 @@ def list_recurring_invoices(): def get_recurring_invoice(ri_id): """Get a recurring invoice template""" from sqlalchemy.orm import joinedload - - ri = RecurringInvoice.query.options( - joinedload(RecurringInvoice.project), - joinedload(RecurringInvoice.client) - ).filter_by(id=ri_id).first_or_404() - + + ri = ( + RecurringInvoice.query.options(joinedload(RecurringInvoice.project), joinedload(RecurringInvoice.client)) + .filter_by(id=ri_id) + .first_or_404() + ) + return jsonify({"recurring_invoice": ri.to_dict()}) @@ -4302,12 +4249,13 @@ def create_recurring_invoice(): def update_recurring_invoice(ri_id): """Update a recurring invoice template""" from sqlalchemy.orm import joinedload - - ri = RecurringInvoice.query.options( - joinedload(RecurringInvoice.project), - joinedload(RecurringInvoice.client) - ).filter_by(id=ri_id).first_or_404() - + + ri = ( + RecurringInvoice.query.options(joinedload(RecurringInvoice.project), joinedload(RecurringInvoice.client)) + .filter_by(id=ri_id) + .first_or_404() + ) + data = request.get_json() or {} for field in ("name", "client_name", "client_email", "client_address", "notes", "terms", "currency_code"): if field in data: @@ -4378,18 +4326,18 @@ def list_credit_notes(): - CreditNotes """ from sqlalchemy.orm import joinedload - + page = request.args.get("page", 1, type=int) per_page = request.args.get("per_page", 50, type=int) query = CreditNote.query.options(joinedload(CreditNote.invoice)) - + invoice_id = request.args.get("invoice_id", type=int) if invoice_id: query = query.filter(CreditNote.invoice_id == invoice_id) - + query = query.order_by(CreditNote.created_at.desc()) - + pagination = query.paginate(page=page, per_page=per_page, error_out=False) pagination_dict = { "page": pagination.page, @@ -4401,7 +4349,7 @@ def list_credit_notes(): "next_page": pagination.page + 1 if pagination.has_next else None, "prev_page": pagination.page - 1 if pagination.has_prev else None, } - + return jsonify( { "credit_notes": [ @@ -4426,9 +4374,9 @@ def list_credit_notes(): def get_credit_note(cn_id): """Get credit note""" from sqlalchemy.orm import joinedload - + cn = CreditNote.query.options(joinedload(CreditNote.invoice)).filter_by(id=cn_id).first_or_404() - + return jsonify( { "credit_note": { @@ -4497,9 +4445,9 @@ def create_credit_note(): def update_credit_note(cn_id): """Update credit note""" from sqlalchemy.orm import joinedload - + cn = CreditNote.query.options(joinedload(CreditNote.invoice)).filter_by(id=cn_id).first_or_404() - + data = request.get_json() or {} if "reason" in data: cn.reason = data["reason"] @@ -4519,9 +4467,9 @@ def update_credit_note(cn_id): def delete_credit_note(cn_id): """Delete credit note""" from sqlalchemy.orm import joinedload - + cn = CreditNote.query.options(joinedload(CreditNote.invoice)).filter_by(id=cn_id).first_or_404() - + db.session.delete(cn) db.session.commit() return jsonify({"message": "Credit note deleted successfully"}) @@ -4574,14 +4522,10 @@ def report_summary(): # Build query with eager loading from sqlalchemy.orm import joinedload - + query = TimeEntry.query.options( - joinedload(TimeEntry.project), - joinedload(TimeEntry.user), - joinedload(TimeEntry.task) - ).filter( - TimeEntry.end_time.isnot(None), TimeEntry.start_time >= start_dt, TimeEntry.start_time <= end_dt - ) + joinedload(TimeEntry.project), joinedload(TimeEntry.user), joinedload(TimeEntry.task) + ).filter(TimeEntry.end_time.isnot(None), TimeEntry.start_time >= start_dt, TimeEntry.start_time <= end_dt) # Filter by user user_id = request.args.get("user_id", type=int) @@ -4817,7 +4761,7 @@ def get_webhook(webhook_id): description: Webhook not found """ from sqlalchemy.orm import joinedload - + webhook = Webhook.query.options(joinedload(Webhook.user)).filter_by(id=webhook_id).first_or_404() # Check permissions @@ -4848,7 +4792,7 @@ def update_webhook(webhook_id): description: Webhook not found """ from sqlalchemy.orm import joinedload - + webhook = Webhook.query.options(joinedload(Webhook.user)).filter_by(id=webhook_id).first_or_404() # Check permissions @@ -4931,7 +4875,7 @@ def delete_webhook(webhook_id): description: Webhook not found """ from sqlalchemy.orm import joinedload - + webhook = Webhook.query.options(joinedload(Webhook.user)).filter_by(id=webhook_id).first_or_404() # Check permissions @@ -4973,7 +4917,7 @@ def list_webhook_deliveries(webhook_id): description: List of deliveries """ from sqlalchemy.orm import joinedload - + webhook = Webhook.query.options(joinedload(Webhook.user)).filter_by(id=webhook_id).first_or_404() # Check permissions diff --git a/app/routes/auth.py b/app/routes/auth.py index 30bf7033..75169d8f 100644 --- a/app/routes/auth.py +++ b/app/routes/auth.py @@ -122,13 +122,14 @@ def login(): # Create new user, promote to admin if username is configured as admin role_name = "admin" if username in admin_usernames else "user" user = User(username=username, role=role_name) - + # Assign role from the new Role system from app.models import Role + role_obj = Role.query.filter_by(name=role_name).first() if role_obj: user.roles.append(role_obj) - + # Set password if password auth is required if requires_password and password: user.set_password(password) @@ -462,7 +463,7 @@ def change_password(): if not current_password: flash(_("Current password is required"), "error") return render_template("auth/change_password.html") - + if not current_user.check_password(current_password): flash(_("Current password is incorrect"), "error") return render_template("auth/change_password.html") @@ -470,7 +471,7 @@ def change_password(): # Set new password current_user.set_password(new_password) current_user.password_change_required = False - + try: db.session.commit() current_app.logger.info("User '%s' changed password", current_user.username) @@ -736,13 +737,14 @@ def oidc_callback(): user.is_active = True user.oidc_issuer = issuer user.oidc_sub = sub - + # Assign role from the new Role system from app.models import Role + role_obj = Role.query.filter_by(name=role_name).first() if role_obj: user.roles.append(role_obj) - + db.session.add(user) if not safe_commit("oidc_create_user", {"username": username, "email": email}): raise RuntimeError("db commit failed on user create") diff --git a/app/routes/client_portal_customization.py b/app/routes/client_portal_customization.py index f9fa3250..54928ee7 100644 --- a/app/routes/client_portal_customization.py +++ b/app/routes/client_portal_customization.py @@ -2,7 +2,17 @@ Client Portal Customization routes """ -from flask import Blueprint, render_template, request, jsonify, redirect, url_for, flash, send_from_directory, current_app +from flask import ( + Blueprint, + render_template, + request, + jsonify, + redirect, + url_for, + flash, + send_from_directory, + current_app, +) from flask_login import login_required, current_user from werkzeug.utils import secure_filename from app import db @@ -15,17 +25,17 @@ from PIL import Image client_portal_customization_bp = Blueprint("client_portal_customization", __name__) -ALLOWED_EXTENSIONS = {'png', 'jpg', 'jpeg', 'gif', 'svg', 'webp'} +ALLOWED_EXTENSIONS = {"png", "jpg", "jpeg", "gif", "svg", "webp"} MAX_FILE_SIZE = 5 * 1024 * 1024 # 5MB def allowed_file(filename): - return '.' in filename and filename.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS + return "." in filename and filename.rsplit(".", 1)[1].lower() in ALLOWED_EXTENSIONS def get_upload_folder(): """Get folder for portal customization uploads""" - folder = os.path.join(current_app.root_path, 'static', 'uploads', 'portal_customization') + folder = os.path.join(current_app.root_path, "static", "uploads", "portal_customization") os.makedirs(folder, exist_ok=True) return folder @@ -40,7 +50,7 @@ def edit_customization(client_id): client = Client.query.get_or_404(client_id) customization = ClientPortalCustomization.query.filter_by(client_id=client_id).first() - + if not customization: customization = ClientPortalCustomization(client_id=client_id) db.session.add(customization) @@ -58,7 +68,7 @@ def update_customization(client_id): client = Client.query.get_or_404(client_id) customization = ClientPortalCustomization.query.filter_by(client_id=client_id).first() - + if not customization: customization = ClientPortalCustomization(client_id=client_id) db.session.add(customization) @@ -84,15 +94,15 @@ def update_customization(client_id): customization.logo_url = data.get("logo_url") or None # Handle logo upload - if 'logo' in request.files: - file = request.files['logo'] + if "logo" in request.files: + file = request.files["logo"] if file and file.filename and allowed_file(file.filename): try: # Validate file size file.seek(0, os.SEEK_END) file_size = file.tell() file.seek(0) - + if file_size > MAX_FILE_SIZE: if request.is_json: return jsonify({"error": "File too large. Maximum 5MB."}), 400 @@ -133,4 +143,3 @@ def serve_portal_upload(filename): """Serve uploaded portal customization files""" folder = get_upload_folder() return send_from_directory(folder, filename) - diff --git a/app/routes/integrations.py b/app/routes/integrations.py index 535af444..61cd5530 100644 --- a/app/routes/integrations.py +++ b/app/routes/integrations.py @@ -25,7 +25,12 @@ def list_integrations(): integrations = service.list_integrations(current_user.id) available_providers = service.get_available_providers() - return render_template("integrations/list.html", integrations=integrations, available_providers=available_providers, current_user=current_user) + return render_template( + "integrations/list.html", + integrations=integrations, + available_providers=available_providers, + current_user=current_user, + ) @integrations_bp.route("/integrations//connect", methods=["GET", "POST"]) @@ -46,10 +51,10 @@ def connect_integration(provider): 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)) - + # Google Calendar is per-user, all others are global - is_global = (provider != "google_calendar") - + is_global = provider != "google_calendar" + if is_global: # For global integrations, check if one exists integration = service.get_global_integration(provider) @@ -95,7 +100,9 @@ def connect_integration(provider): # OAuth credentials not configured yet if provider == "google_calendar": if current_user.is_admin: - flash(_("Google Calendar OAuth credentials need to be configured first. Redirecting to setup..."), "info") + flash( + _("Google Calendar OAuth credentials need to be configured first. Redirecting to setup..."), "info" + ) return redirect(url_for("admin.integration_setup", provider=provider)) else: flash(_("Google Calendar integration needs to be configured by an administrator first."), "warning") @@ -126,7 +133,7 @@ def oauth_callback(provider): return redirect(url_for("integrations.list_integrations")) # Find integration (global or per-user) - is_global = (provider != "google_calendar") + is_global = provider != "google_calendar" if is_global: integration = service.get_global_integration(provider) else: @@ -205,17 +212,23 @@ def view_integration(integration_id): connector = service.get_connector(integration) credentials = IntegrationCredential.query.filter_by(integration_id=integration_id).first() - + # Get recent sync events from app.models import IntegrationEvent - recent_events = IntegrationEvent.query.filter_by(integration_id=integration_id).order_by(IntegrationEvent.created_at.desc()).limit(20).all() + + recent_events = ( + IntegrationEvent.query.filter_by(integration_id=integration_id) + .order_by(IntegrationEvent.created_at.desc()) + .limit(20) + .all() + ) return render_template( "integrations/view.html", integration=integration, connector=connector, credentials=credentials, - recent_events=recent_events + recent_events=recent_events, ) @@ -229,7 +242,7 @@ def test_integration(integration_id): if not integration: flash(_("Integration not found."), "error") return redirect(url_for("integrations.list_integrations")) - + result = service.test_connection(integration_id, current_user.id if not integration.is_global else None) if result.get("success"): @@ -249,7 +262,7 @@ def delete_integration(integration_id): if not integration: flash(_("Integration not found."), "error") return redirect(url_for("integrations.list_integrations")) - + result = service.delete_integration(integration_id, current_user.id) if result["success"]: @@ -320,11 +333,13 @@ def integration_webhook(provider): # Handle webhook result = connector.handle_webhook(payload, headers) - results.append({ - "integration_id": integration.id, - "success": result.get("success", False), - "message": result.get("message", "") - }) + results.append( + { + "integration_id": integration.id, + "success": result.get("success", False), + "message": result.get("message", ""), + } + ) # Log event if result.get("success"): @@ -333,18 +348,14 @@ def integration_webhook(provider): "webhook_received", True, f"Webhook processed successfully", - {"provider": provider, "event_type": payload.get("event_type", "unknown")} + {"provider": provider, "event_type": payload.get("event_type", "unknown")}, ) except Exception as e: logger.error(f"Error handling webhook for integration {integration.id}: {e}", exc_info=True) - results.append({ - "integration_id": integration.id, - "success": False, - "message": str(e) - }) + results.append({"integration_id": integration.id, "success": False, "message": str(e)}) # Return success if at least one integration processed the webhook if any(r["success"] for r in results): return jsonify({"success": True, "results": results}), 200 else: - return jsonify({"success": False, "results": results}), 500 \ No newline at end of file + return jsonify({"success": False, "results": results}), 500 diff --git a/app/routes/kiosk.py b/app/routes/kiosk.py index 98ebfdff..0b1372c0 100644 --- a/app/routes/kiosk.py +++ b/app/routes/kiosk.py @@ -35,7 +35,7 @@ def kiosk_dashboard(): # Use services/repositories for data access where available from app.services import ProjectService - + # Get default warehouse (from session or first active) # Note: WarehouseRepository doesn't exist yet, using direct query for now default_warehouse = None @@ -141,6 +141,7 @@ def kiosk_login(): # Get list of active users for quick selection (use repository if available) from app.repositories import UserRepository + user_repo = UserRepository() users = user_repo.query().filter_by(is_active=True).order_by(User.username).all() return render_template("kiosk/login.html", users=users, requires_password=requires_password) diff --git a/app/routes/link_templates.py b/app/routes/link_templates.py index d61cc91f..d095d796 100644 --- a/app/routes/link_templates.py +++ b/app/routes/link_templates.py @@ -142,4 +142,3 @@ def delete_link_template(template_id): flash(_("Link template deleted successfully"), "success") return redirect(url_for("link_templates.list_link_templates")) - diff --git a/app/routes/main.py b/app/routes/main.py index c628cf04..09cd1bdb 100644 --- a/app/routes/main.py +++ b/app/routes/main.py @@ -28,9 +28,10 @@ def dashboard(): # Use caching for dashboard data (5 minute TTL) from app.utils.cache import get_cache, cached + cache = get_cache() cache_key = f"dashboard:{current_user.id}" - + # Try to get from cache cached_data = cache.get(cache_key) if cached_data: @@ -41,11 +42,13 @@ def dashboard(): # Get recent entries for the user (using repository to avoid N+1) from app.repositories import TimeEntryRepository + time_entry_repo = TimeEntryRepository() recent_entries = time_entry_repo.get_by_user(user_id=current_user.id, limit=10, include_relations=True) # Get active projects for timer dropdown (using repository) from app.repositories import ProjectRepository, ClientRepository + project_repo = ProjectRepository() client_repo = ClientRepository() active_projects = project_repo.get_active_projects() @@ -53,22 +56,22 @@ def dashboard(): # Get user statistics using analytics service from app.services import AnalyticsService + analytics_service = AnalyticsService() stats = analytics_service.get_dashboard_stats(user_id=current_user.id) - + today_hours = stats["time_tracking"]["today_hours"] week_hours = stats["time_tracking"]["week_hours"] month_hours = stats["time_tracking"]["month_hours"] # Build Top Projects (last 30 days) - using optimized query with eager loading from sqlalchemy.orm import joinedload + period_start = datetime.utcnow().date() - timedelta(days=30) entries_30 = ( TimeEntry.query.options(joinedload(TimeEntry.project)) # Eager load projects to avoid N+1 .filter( - TimeEntry.end_time.isnot(None), - TimeEntry.start_time >= period_start, - TimeEntry.user_id == current_user.id + TimeEntry.end_time.isnot(None), TimeEntry.start_time >= period_start, TimeEntry.user_id == current_user.id ) .all() ) @@ -90,11 +93,9 @@ def dashboard(): # Get user's time entry templates (most recently used first) from sqlalchemy import desc from sqlalchemy.orm import joinedload + templates = ( - TimeEntryTemplate.query.options( - joinedload(TimeEntryTemplate.project), - joinedload(TimeEntryTemplate.task) - ) + TimeEntryTemplate.query.options(joinedload(TimeEntryTemplate.project), joinedload(TimeEntryTemplate.task)) .filter_by(user_id=current_user.id) .order_by(desc(TimeEntryTemplate.last_used_at)) .limit(5) @@ -118,10 +119,10 @@ def dashboard(): "templates": templates, "recent_activities": recent_activities, } - + # Cache for 5 minutes cache.set(cache_key, template_data, ttl=300) - + return render_template("main/dashboard.html", **template_data) diff --git a/app/routes/projects.py b/app/routes/projects.py index eb4c3d0f..c820fd33 100644 --- a/app/routes/projects.py +++ b/app/routes/projects.py @@ -236,7 +236,7 @@ def create_project(): except ValueError: flash(_("Invalid hourly rate format"), "error") return render_template("projects/create.html", clients=Client.get_active_clients()) - + # Validate budgets budget_amount = None budget_threshold_percent = None @@ -262,8 +262,9 @@ def create_project(): # Use service layer to create project from app.services import ProjectService + project_service = ProjectService() - + result = project_service.create_project( name=name, client_id=int(client_id), @@ -636,8 +637,9 @@ def edit_project(project_id): # Use service layer to update project from app.services import ProjectService + project_service = ProjectService() - + result = project_service.update_project( project_id=project.id, user_id=current_user.id, diff --git a/app/routes/quotes.py b/app/routes/quotes.py index 7e545719..ec4bf147 100644 --- a/app/routes/quotes.py +++ b/app/routes/quotes.py @@ -21,7 +21,7 @@ def list_quotes(): # Use service layer for quote listing with analytics from app.services import QuoteService - + quote_service = QuoteService() result = quote_service.list_quotes( user_id=current_user.id if not current_user.is_admin else None, @@ -30,7 +30,7 @@ def list_quotes(): search=search if search else None, include_analytics=show_analytics, ) - + quotes = result["quotes"] analytics = result.get("analytics") @@ -221,21 +221,21 @@ def view_quote(quote_id): from app.services import QuoteService from sqlalchemy.orm import joinedload from app.models import Comment - + # Use service layer with eager loading quote_service = QuoteService() quote = quote_service.get_quote_with_details( quote_id=quote_id, user_id=current_user.id if not current_user.is_admin else None, - is_admin=current_user.is_admin + is_admin=current_user.is_admin, ) - + if not quote: flash(_("Quote not found"), "error") return redirect(url_for("quotes.list_quotes")) - + quote.calculate_totals() # Ensure totals are up to date - + # Get all comments (both internal and client-facing) comments = Comment.get_quote_comments(quote_id, include_replies=True, include_internal=True) @@ -248,11 +248,8 @@ def view_quote(quote_id): def edit_quote(quote_id): """Edit an quote""" from sqlalchemy.orm import joinedload - - quote = Quote.query.options( - joinedload(Quote.client), - joinedload(Quote.items) - ).filter_by(id=quote_id).first_or_404() + + quote = Quote.query.options(joinedload(Quote.client), joinedload(Quote.items)).filter_by(id=quote_id).first_or_404() # Only allow editing draft quotes if quote.status != "draft": diff --git a/app/routes/recurring_tasks.py b/app/routes/recurring_tasks.py index 80d787b6..cf1d5777 100644 --- a/app/routes/recurring_tasks.py +++ b/app/routes/recurring_tasks.py @@ -20,9 +20,9 @@ def list_recurring_tasks(): if current_user.is_admin: recurring_tasks = RecurringTask.query.order_by(RecurringTask.next_run_date.asc()).all() else: - recurring_tasks = RecurringTask.query.filter_by(created_by=current_user.id).order_by( - RecurringTask.next_run_date.asc() - ).all() + recurring_tasks = ( + RecurringTask.query.filter_by(created_by=current_user.id).order_by(RecurringTask.next_run_date.asc()).all() + ) return render_template("recurring_tasks/list.html", recurring_tasks=recurring_tasks) @@ -47,7 +47,7 @@ def create_recurring_task(): priority=data.get("priority", "medium"), estimated_hours=float(data.get("estimated_hours")) if data.get("estimated_hours") else None, assigned_to=int(data.get("assigned_to")) if data.get("assigned_to") else None, - auto_assign=bool(data.get("auto_assign", False)) + auto_assign=bool(data.get("auto_assign", False)), ) db.session.add(recurring_task) @@ -61,7 +61,7 @@ def create_recurring_task(): # GET - Show form projects = Project.query.filter_by(status="active").order_by(Project.name).all() - + return render_template("recurring_tasks/create.html", projects=projects) @@ -91,4 +91,3 @@ def toggle_recurring_task(task_id): db.session.commit() return jsonify({"success": True, "is_active": recurring_task.is_active}) - diff --git a/app/routes/reports.py b/app/routes/reports.py index 309be863..5a5aa2f7 100644 --- a/app/routes/reports.py +++ b/app/routes/reports.py @@ -28,6 +28,7 @@ from app.utils.posthog_monitoring import track_error, track_export_performance, # Optional PowerPoint export - only import if available try: from app.utils.powerpoint_export import create_report_powerpoint + PPTX_EXPORT_AVAILABLE = True except ImportError: PPTX_EXPORT_AVAILABLE = False @@ -1000,12 +1001,14 @@ def export_task_excel(): entries = te_query.all() hours = sum(e.duration_hours for e in entries) - task_rows.append({ - "task": task, - "project": task.project, - "completed_at": task.completed_at, - "hours": round(hours, 2), - }) + task_rows.append( + { + "task": task, + "project": task.project, + "completed_at": task.completed_at, + "hours": round(hours, 2), + } + ) # Create Excel file from openpyxl import Workbook @@ -1045,7 +1048,9 @@ def export_task_excel(): for row_data in task_rows: ws.cell(row=row_num, column=1).value = row_data["task"].name ws.cell(row=row_num, column=2).value = row_data["project"].name if row_data["project"] else "N/A" - ws.cell(row=row_num, column=3).value = row_data["completed_at"].strftime('%Y-%m-%d') if row_data["completed_at"] else "N/A" + ws.cell(row=row_num, column=3).value = ( + row_data["completed_at"].strftime("%Y-%m-%d") if row_data["completed_at"] else "N/A" + ) ws.cell(row=row_num, column=4).value = row_data["hours"] for col_num in range(1, len(headers) + 1): diff --git a/app/routes/scheduled_reports.py b/app/routes/scheduled_reports.py index 1b27fc7f..9136a90f 100644 --- a/app/routes/scheduled_reports.py +++ b/app/routes/scheduled_reports.py @@ -18,30 +18,33 @@ def api_list_scheduled(): from sqlalchemy.orm import joinedload from app.models import ReportEmailSchedule from app import db - + # Query with eager loading - query = db.session.query(ReportEmailSchedule).options( - joinedload(ReportEmailSchedule.saved_view) - ) - + query = db.session.query(ReportEmailSchedule).options(joinedload(ReportEmailSchedule.saved_view)) + if not current_user.is_admin: query = query.filter_by(created_by=current_user.id) - + schedules = query.order_by(ReportEmailSchedule.next_run_at.asc()).all() - - return jsonify({ - "schedules": [{ - "id": s.id, - "saved_view_id": s.saved_view_id, - "saved_view_name": s.saved_view.name if s.saved_view else "Unknown", - "recipients": s.recipients, - "cadence": s.cadence, - "next_run_at": s.next_run_at.isoformat() if s.next_run_at else None, - "last_run_at": s.last_run_at.isoformat() if s.last_run_at else None, - "active": s.active, - "created_at": s.created_at.isoformat() if s.created_at else None, - } for s in schedules] - }) + + return jsonify( + { + "schedules": [ + { + "id": s.id, + "saved_view_id": s.saved_view_id, + "saved_view_name": s.saved_view.name if s.saved_view else "Unknown", + "recipients": s.recipients, + "cadence": s.cadence, + "next_run_at": s.next_run_at.isoformat() if s.next_run_at else None, + "last_run_at": s.last_run_at.isoformat() if s.last_run_at else None, + "active": s.active, + "created_at": s.created_at.isoformat() if s.created_at else None, + } + for s in schedules + ] + } + ) @scheduled_reports_bp.route("/reports/scheduled") @@ -111,16 +114,16 @@ def api_create_scheduled(): """Create scheduled report via API""" service = ScheduledReportService() data = request.get_json() - + saved_view_id = data.get("saved_view_id", type=int) recipients = data.get("recipients", "").strip() cadence = data.get("cadence", "").strip() cron = data.get("cron", "").strip() or None timezone = data.get("timezone", "").strip() or None - + if not saved_view_id or not recipients or not cadence: return jsonify({"success": False, "error": _("Please fill in all required fields.")}), 400 - + result = service.create_schedule( saved_view_id=saved_view_id, recipients=recipients, @@ -129,18 +132,24 @@ def api_create_scheduled(): cron=cron, timezone=timezone, ) - + if result["success"]: - return jsonify({ - "success": True, - "schedule": { - "id": result["schedule"].id, - "saved_view_name": result["schedule"].saved_view.name if result["schedule"].saved_view else "Unknown", - "recipients": result["schedule"].recipients, - "cadence": result["schedule"].cadence, - "next_run_at": result["schedule"].next_run_at.isoformat() if result["schedule"].next_run_at else None, + return jsonify( + { + "success": True, + "schedule": { + "id": result["schedule"].id, + "saved_view_name": ( + result["schedule"].saved_view.name if result["schedule"].saved_view else "Unknown" + ), + "recipients": result["schedule"].recipients, + "cadence": result["schedule"].cadence, + "next_run_at": ( + result["schedule"].next_run_at.isoformat() if result["schedule"].next_run_at else None + ), + }, } - }) + ) else: return jsonify({"success": False, "error": result["message"]}), 400 @@ -150,14 +159,15 @@ def api_create_scheduled(): def api_toggle_scheduled(schedule_id): """Toggle active status of scheduled report""" from app import db + schedule = ReportEmailSchedule.query.get_or_404(schedule_id) - + if schedule.created_by != current_user.id and not current_user.is_admin: return jsonify({"success": False, "error": _("Permission denied")}), 403 - + schedule.active = not schedule.active db.session.commit() - + return jsonify({"success": True, "active": schedule.active}) @@ -167,7 +177,7 @@ def api_delete_scheduled(schedule_id): """Delete scheduled report via API""" service = ScheduledReportService() result = service.delete_schedule(schedule_id, current_user.id) - + if result["success"]: return jsonify({"success": True}) else: @@ -179,13 +189,18 @@ def api_delete_scheduled(schedule_id): def api_saved_views(): """Get saved report views for current user""" saved_views = SavedReportView.query.filter_by(owner_id=current_user.id).all() - return jsonify({ - "saved_views": [{ - "id": sv.id, - "name": sv.name, - "scope": sv.scope, - } for sv in saved_views] - }) + return jsonify( + { + "saved_views": [ + { + "id": sv.id, + "name": sv.name, + "scope": sv.scope, + } + for sv in saved_views + ] + } + ) @scheduled_reports_bp.route("/api/reports/scheduled", methods=["POST"]) @@ -194,16 +209,16 @@ def api_create_scheduled(): """Create scheduled report via API""" service = ScheduledReportService() data = request.get_json() - + saved_view_id = data.get("saved_view_id", type=int) recipients = data.get("recipients", "").strip() cadence = data.get("cadence", "").strip() cron = data.get("cron", "").strip() or None timezone = data.get("timezone", "").strip() or None - + if not saved_view_id or not recipients or not cadence: return jsonify({"success": False, "error": _("Please fill in all required fields.")}), 400 - + result = service.create_schedule( saved_view_id=saved_view_id, recipients=recipients, @@ -212,18 +227,24 @@ def api_create_scheduled(): cron=cron, timezone=timezone, ) - + if result["success"]: - return jsonify({ - "success": True, - "schedule": { - "id": result["schedule"].id, - "saved_view_name": result["schedule"].saved_view.name if result["schedule"].saved_view else "Unknown", - "recipients": result["schedule"].recipients, - "cadence": result["schedule"].cadence, - "next_run_at": result["schedule"].next_run_at.isoformat() if result["schedule"].next_run_at else None, + return jsonify( + { + "success": True, + "schedule": { + "id": result["schedule"].id, + "saved_view_name": ( + result["schedule"].saved_view.name if result["schedule"].saved_view else "Unknown" + ), + "recipients": result["schedule"].recipients, + "cadence": result["schedule"].cadence, + "next_run_at": ( + result["schedule"].next_run_at.isoformat() if result["schedule"].next_run_at else None + ), + }, } - }) + ) else: return jsonify({"success": False, "error": result["message"]}), 400 @@ -233,14 +254,15 @@ def api_create_scheduled(): def api_toggle_scheduled(schedule_id): """Toggle active status of scheduled report""" from app import db + schedule = ReportEmailSchedule.query.get_or_404(schedule_id) - + if schedule.created_by != current_user.id and not current_user.is_admin: return jsonify({"success": False, "error": _("Permission denied")}), 403 - + schedule.active = not schedule.active db.session.commit() - + return jsonify({"success": True, "active": schedule.active}) @@ -250,7 +272,7 @@ def api_delete_scheduled(schedule_id): """Delete scheduled report via API""" service = ScheduledReportService() result = service.delete_schedule(schedule_id, current_user.id) - + if result["success"]: return jsonify({"success": True}) else: @@ -262,10 +284,15 @@ def api_delete_scheduled(schedule_id): def api_saved_views(): """Get saved report views for current user""" saved_views = SavedReportView.query.filter_by(owner_id=current_user.id).all() - return jsonify({ - "saved_views": [{ - "id": sv.id, - "name": sv.name, - "scope": sv.scope, - } for sv in saved_views] - }) + return jsonify( + { + "saved_views": [ + { + "id": sv.id, + "name": sv.name, + "scope": sv.scope, + } + for sv in saved_views + ] + } + ) diff --git a/app/routes/setup.py b/app/routes/setup.py index 60bb8418..ca530e34 100644 --- a/app/routes/setup.py +++ b/app/routes/setup.py @@ -30,7 +30,7 @@ def initial_setup(): # Save OAuth credentials if provided settings = Settings.get_settings() - + # Google Calendar OAuth credentials google_client_id = request.form.get("google_calendar_client_id", "").strip() google_client_secret = request.form.get("google_calendar_client_secret", "").strip() @@ -54,7 +54,7 @@ def initial_setup(): flash(_("Setup complete! Thank you for helping us improve TimeTracker."), "success") else: flash(_("Setup complete! Telemetry is disabled."), "success") - + if google_client_id: flash(_("Google Calendar OAuth credentials have been configured."), "success") diff --git a/app/routes/team_chat.py b/app/routes/team_chat.py index 260d3af5..eee70a86 100644 --- a/app/routes/team_chat.py +++ b/app/routes/team_chat.py @@ -19,17 +19,23 @@ team_chat_bp = Blueprint("team_chat", __name__) def chat_index(): """Main chat interface""" # Get all channels user is member of - channels = ChatChannel.query.join(ChatChannelMember).filter( - ChatChannelMember.user_id == current_user.id, - ChatChannel.is_archived == False - ).order_by(ChatChannel.updated_at.desc()).all() + channels = ( + ChatChannel.query.join(ChatChannelMember) + .filter(ChatChannelMember.user_id == current_user.id, ChatChannel.is_archived == False) + .order_by(ChatChannel.updated_at.desc()) + .all() + ) # Get direct messages (channels with type='direct' and 2 members) - direct_channels = ChatChannel.query.join(ChatChannelMember).filter( - ChatChannelMember.user_id == current_user.id, - ChatChannel.channel_type == "direct", - ChatChannel.is_archived == False - ).all() + direct_channels = ( + ChatChannel.query.join(ChatChannelMember) + .filter( + ChatChannelMember.user_id == current_user.id, + ChatChannel.channel_type == "direct", + ChatChannel.is_archived == False, + ) + .all() + ) return render_template("chat/index.html", channels=channels, direct_channels=direct_channels) @@ -41,30 +47,26 @@ def chat_channel(channel_id): channel = ChatChannel.query.get_or_404(channel_id) # Check membership - membership = ChatChannelMember.query.filter_by( - channel_id=channel_id, - user_id=current_user.id - ).first() + membership = ChatChannelMember.query.filter_by(channel_id=channel_id, user_id=current_user.id).first() if not membership and not current_user.is_admin: flash(_("You don't have access to this channel"), "error") return redirect(url_for("team_chat.chat_index")) # Get messages - messages = ChatMessage.query.filter_by( - channel_id=channel_id, - is_deleted=False - ).order_by(ChatMessage.created_at.asc()).limit(100).all() + messages = ( + ChatMessage.query.filter_by(channel_id=channel_id, is_deleted=False) + .order_by(ChatMessage.created_at.asc()) + .limit(100) + .all() + ) # Get channel members members = ChatChannelMember.query.filter_by(channel_id=channel_id).all() # Mark messages as read for message in messages: - receipt = ChatReadReceipt.query.filter_by( - message_id=message.id, - user_id=current_user.id - ).first() + receipt = ChatReadReceipt.query.filter_by(message_id=message.id, user_id=current_user.id).first() if not receipt: receipt = ChatReadReceipt(message_id=message.id, user_id=current_user.id) db.session.add(receipt) @@ -80,14 +82,11 @@ def send_message(channel_id): """Send a message via form submission (supports attachments)""" import json import os - + channel = ChatChannel.query.get_or_404(channel_id) # Check membership - membership = ChatChannelMember.query.filter_by( - channel_id=channel_id, - user_id=current_user.id - ).first() + membership = ChatChannelMember.query.filter_by(channel_id=channel_id, user_id=current_user.id).first() if not membership and not current_user.is_admin: flash(_("You don't have access to this channel"), "error") @@ -95,7 +94,7 @@ def send_message(channel_id): content = request.form.get("content", "").strip() attachment_data = request.form.get("attachment_data") - + if not content and not attachment_data: flash(_("Message cannot be empty"), "error") return redirect(url_for("team_chat.chat_channel", channel_id=channel_id)) @@ -105,7 +104,7 @@ def send_message(channel_id): attachment_filename = None attachment_size = None message_type = "text" - + if attachment_data: try: attachment_info = json.loads(attachment_data) @@ -124,7 +123,7 @@ def send_message(channel_id): message_type=message_type, attachment_url=attachment_url, attachment_filename=attachment_filename, - attachment_size=attachment_size + attachment_size=attachment_size, ) # Parse mentions @@ -133,15 +132,16 @@ def send_message(channel_id): message.mentions = mentions db.session.add(message) - + # Update channel updated_at channel.updated_at = datetime.utcnow() - + db.session.commit() # Notify mentioned users if mentions: from app.utils.notification_service import NotificationService + service = NotificationService() for user_id in mentions: service.send_notification( @@ -149,12 +149,12 @@ def send_message(channel_id): title="You were mentioned", message=f"{current_user.display_name} mentioned you in {channel.name}", type="info", - priority="high" + priority="high", ) - if request.is_json or request.headers.get('X-Requested-With') == 'XMLHttpRequest': + if request.is_json or request.headers.get("X-Requested-With") == "XMLHttpRequest": return jsonify({"success": True, "message": message.to_dict()}) - + return redirect(url_for("team_chat.chat_channel", channel_id=channel_id)) @@ -165,33 +165,26 @@ def api_channels(): if request.method == "POST": # Create new channel data = request.get_json() - + channel = ChatChannel( name=data.get("name"), description=data.get("description"), channel_type=data.get("channel_type", "public"), created_by=current_user.id, - project_id=data.get("project_id") + project_id=data.get("project_id"), ) db.session.add(channel) db.session.flush() # Add creator as member - member = ChatChannelMember( - channel_id=channel.id, - user_id=current_user.id, - is_admin=True - ) + member = ChatChannelMember(channel_id=channel.id, user_id=current_user.id, is_admin=True) db.session.add(member) # Add other members if specified if data.get("member_ids"): for user_id in data.get("member_ids", []): if user_id != current_user.id: - member = ChatChannelMember( - channel_id=channel.id, - user_id=user_id - ) + member = ChatChannelMember(channel_id=channel.id, user_id=user_id) db.session.add(member) db.session.commit() @@ -199,14 +192,14 @@ def api_channels(): return jsonify({"success": True, "channel": channel.to_dict()}) # GET - List channels - channels = ChatChannel.query.join(ChatChannelMember).filter( - ChatChannelMember.user_id == current_user.id, - ChatChannel.is_archived == False - ).order_by(ChatChannel.updated_at.desc()).all() + channels = ( + ChatChannel.query.join(ChatChannelMember) + .filter(ChatChannelMember.user_id == current_user.id, ChatChannel.is_archived == False) + .order_by(ChatChannel.updated_at.desc()) + .all() + ) - return jsonify({ - "channels": [c.to_dict() for c in channels] - }) + return jsonify({"channels": [c.to_dict() for c in channels]}) @team_chat_bp.route("/api/chat/channels//messages", methods=["GET", "POST"]) @@ -216,10 +209,7 @@ def api_messages(channel_id): channel = ChatChannel.query.get_or_404(channel_id) # Check membership - membership = ChatChannelMember.query.filter_by( - channel_id=channel_id, - user_id=current_user.id - ).first() + membership = ChatChannelMember.query.filter_by(channel_id=channel_id, user_id=current_user.id).first() if not membership and not current_user.is_admin: return jsonify({"error": "Access denied"}), 403 @@ -227,7 +217,7 @@ def api_messages(channel_id): if request.method == "POST": # Create new message data = request.get_json() - + message = ChatMessage( channel_id=channel_id, user_id=current_user.id, @@ -236,7 +226,7 @@ def api_messages(channel_id): reply_to_id=data.get("reply_to_id"), attachment_url=data.get("attachment_url"), attachment_filename=data.get("attachment_filename"), - attachment_size=data.get("attachment_size") + attachment_size=data.get("attachment_size"), ) # Parse mentions @@ -254,6 +244,7 @@ def api_messages(channel_id): # Notify mentioned users if mentions: from app.utils.notification_service import NotificationService + service = NotificationService() for user_id in mentions: service.send_notification( @@ -261,7 +252,7 @@ def api_messages(channel_id): title="You were mentioned", message=f"{current_user.display_name} mentioned you in {channel.name}", type="info", - priority="high" + priority="high", ) return jsonify({"success": True, "message": message.to_dict()}) @@ -270,10 +261,7 @@ def api_messages(channel_id): before_id = request.args.get("before_id", type=int) limit = request.args.get("limit", 50, type=int) - query = ChatMessage.query.filter_by( - channel_id=channel_id, - is_deleted=False - ) + query = ChatMessage.query.filter_by(channel_id=channel_id, is_deleted=False) if before_id: query = query.filter(ChatMessage.id < before_id) @@ -283,19 +271,14 @@ def api_messages(channel_id): # Mark as read for message in messages: - receipt = ChatReadReceipt.query.filter_by( - message_id=message.id, - user_id=current_user.id - ).first() + receipt = ChatReadReceipt.query.filter_by(message_id=message.id, user_id=current_user.id).first() if not receipt: receipt = ChatReadReceipt(message_id=message.id, user_id=current_user.id) db.session.add(receipt) db.session.commit() - return jsonify({ - "messages": [m.to_dict() for m in messages] - }) + return jsonify({"messages": [m.to_dict() for m in messages]}) @team_chat_bp.route("/api/chat/messages/", methods=["PUT", "DELETE"]) @@ -313,7 +296,7 @@ def api_message(message_id): message.message = data.get("message", message.message) message.is_edited = True message.edited_at = datetime.utcnow() - + # Re-parse mentions message.parse_mentions() @@ -333,7 +316,7 @@ def api_react(message_id): """Add or remove reaction to message""" message = ChatMessage.query.get_or_404(message_id) data = request.get_json() - + emoji = data.get("emoji") if not emoji: return jsonify({"error": "Emoji required"}), 400 @@ -341,7 +324,7 @@ def api_react(message_id): reactions = message.reactions or {} if emoji not in reactions: reactions[emoji] = [] - + if current_user.id in reactions[emoji]: reactions[emoji].remove(current_user.id) if not reactions[emoji]: @@ -363,17 +346,14 @@ def download_attachment(channel_id, message_id): import os message = ChatMessage.query.get_or_404(message_id) - + # Verify message belongs to channel if message.channel_id != channel_id: flash(_("Invalid message"), "error") return redirect(url_for("team_chat.chat_channel", channel_id=channel_id)) # Check membership - membership = ChatChannelMember.query.filter_by( - channel_id=channel_id, - user_id=current_user.id - ).first() + membership = ChatChannelMember.query.filter_by(channel_id=channel_id, user_id=current_user.id).first() if not membership and not current_user.is_admin: flash(_("You don't have access to this channel"), "error") @@ -409,16 +389,28 @@ def upload_attachment(channel_id): channel = ChatChannel.query.get_or_404(channel_id) # Check membership - membership = ChatChannelMember.query.filter_by( - channel_id=channel_id, - user_id=current_user.id - ).first() + membership = ChatChannelMember.query.filter_by(channel_id=channel_id, user_id=current_user.id).first() if not membership and not current_user.is_admin: return jsonify({"error": _("You don't have access to this channel")}), 403 # File upload configuration - ALLOWED_EXTENSIONS = {"png", "jpg", "jpeg", "gif", "pdf", "doc", "docx", "txt", "xls", "xlsx", "zip", "rar", "csv", "json"} + ALLOWED_EXTENSIONS = { + "png", + "jpg", + "jpeg", + "gif", + "pdf", + "doc", + "docx", + "txt", + "xls", + "xlsx", + "zip", + "rar", + "csv", + "json", + } UPLOAD_FOLDER = "uploads/chat_attachments" MAX_FILE_SIZE = 10 * 1024 * 1024 # 10 MB @@ -456,11 +448,13 @@ def upload_attachment(channel_id): file.save(file_path) # Return file info for message creation - return jsonify({ - "success": True, - "attachment": { - "url": os.path.join(UPLOAD_FOLDER, filename), - "filename": original_filename, - "size": file_size + return jsonify( + { + "success": True, + "attachment": { + "url": os.path.join(UPLOAD_FOLDER, filename), + "filename": original_filename, + "size": file_size, + }, } - }) + ) diff --git a/app/routes/time_approvals.py b/app/routes/time_approvals.py index 51ee6467..8ba95335 100644 --- a/app/routes/time_approvals.py +++ b/app/routes/time_approvals.py @@ -21,10 +21,11 @@ def list_approvals(): pending = service.get_pending_approvals(current_user.id) # Get user's pending requests - my_requests = TimeEntryApproval.query.filter_by( - requested_by=current_user.id, - status=ApprovalStatus.PENDING - ).order_by(TimeEntryApproval.requested_at.desc()).all() + my_requests = ( + TimeEntryApproval.query.filter_by(requested_by=current_user.id, status=ApprovalStatus.PENDING) + .order_by(TimeEntryApproval.requested_at.desc()) + .all() + ) return render_template("approvals/list.html", pending_approvals=pending, my_requests=my_requests) @@ -34,7 +35,7 @@ def list_approvals(): def view_approval(approval_id): """View approval details""" approval = TimeEntryApproval.query.get_or_404(approval_id) - + # Check permissions if approval.requested_by != current_user.id and approval.approved_by != current_user.id: service = TimeApprovalService() @@ -53,11 +54,7 @@ def approve_entry(approval_id): service = TimeApprovalService() data = request.get_json() if request.is_json else request.form - result = service.approve( - approval_id=approval_id, - approver_id=current_user.id, - comment=data.get("comment") - ) + result = service.approve(approval_id=approval_id, approver_id=current_user.id, comment=data.get("comment")) if request.is_json: return jsonify(result) @@ -84,11 +81,7 @@ def reject_entry(approval_id): flash(_("Rejection reason is required"), "error") return redirect(url_for("time_approvals.view_approval", approval_id=approval_id)) - result = service.reject( - approval_id=approval_id, - approver_id=current_user.id, - reason=reason - ) + result = service.reject(approval_id=approval_id, approver_id=current_user.id, reason=reason) if request.is_json: return jsonify(result) @@ -112,7 +105,7 @@ def request_approval(entry_id): time_entry_id=entry_id, requested_by=current_user.id, comment=data.get("comment"), - approver_ids=data.get("approver_ids") + approver_ids=data.get("approver_ids"), ) if request.is_json: @@ -132,10 +125,7 @@ def cancel_approval(approval_id): """Cancel an approval request""" service = TimeApprovalService() - result = service.cancel_approval( - approval_id=approval_id, - user_id=current_user.id - ) + result = service.cancel_approval(approval_id=approval_id, user_id=current_user.id) if request.is_json: return jsonify(result) @@ -159,11 +149,7 @@ def bulk_approve(): if not approval_ids: return jsonify({"success": False, "message": "No approval IDs provided"}), 400 - result = service.bulk_approve( - approval_ids=approval_ids, - approver_id=current_user.id, - comment=data.get("comment") - ) + result = service.bulk_approve(approval_ids=approval_ids, approver_id=current_user.id, comment=data.get("comment")) return jsonify(result) @@ -175,7 +161,4 @@ def api_pending_approvals(): service = TimeApprovalService() approvals = service.get_pending_approvals(current_user.id) - return jsonify({ - "approvals": [a.to_dict() for a in approvals] - }) - + return jsonify({"approvals": [a.to_dict() for a in approvals]}) diff --git a/app/routes/time_entry_templates.py b/app/routes/time_entry_templates.py index cf2cff25..44109cbb 100644 --- a/app/routes/time_entry_templates.py +++ b/app/routes/time_entry_templates.py @@ -26,11 +26,9 @@ time_entry_templates_bp = Blueprint("time_entry_templates", __name__) def list_templates(): """List all time entry templates for the current user.""" from sqlalchemy.orm import joinedload + templates = ( - TimeEntryTemplate.query.options( - joinedload(TimeEntryTemplate.project), - joinedload(TimeEntryTemplate.task) - ) + TimeEntryTemplate.query.options(joinedload(TimeEntryTemplate.project), joinedload(TimeEntryTemplate.task)) .filter_by(user_id=current_user.id) .order_by(desc(TimeEntryTemplate.last_used_at)) .all() @@ -135,11 +133,9 @@ def create_template(): def view_template(template_id): """View a specific template.""" from sqlalchemy.orm import joinedload + template = ( - TimeEntryTemplate.query.options( - joinedload(TimeEntryTemplate.project), - joinedload(TimeEntryTemplate.task) - ) + TimeEntryTemplate.query.options(joinedload(TimeEntryTemplate.project), joinedload(TimeEntryTemplate.task)) .filter_by(id=template_id, user_id=current_user.id) .first_or_404() ) @@ -275,11 +271,9 @@ def delete_template(template_id): def get_templates_api(): """Get templates as JSON (for AJAX requests).""" from sqlalchemy.orm import joinedload + templates = ( - TimeEntryTemplate.query.options( - joinedload(TimeEntryTemplate.project), - joinedload(TimeEntryTemplate.task) - ) + TimeEntryTemplate.query.options(joinedload(TimeEntryTemplate.project), joinedload(TimeEntryTemplate.task)) .filter_by(user_id=current_user.id) .order_by(desc(TimeEntryTemplate.last_used_at)) .all() @@ -293,11 +287,9 @@ def get_templates_api(): def get_template_api(template_id): """Get a specific template as JSON.""" from sqlalchemy.orm import joinedload + template = ( - TimeEntryTemplate.query.options( - joinedload(TimeEntryTemplate.project), - joinedload(TimeEntryTemplate.task) - ) + TimeEntryTemplate.query.options(joinedload(TimeEntryTemplate.project), joinedload(TimeEntryTemplate.task)) .filter_by(id=template_id, user_id=current_user.id) .first_or_404() ) diff --git a/app/routes/timer.py b/app/routes/timer.py index 2250a6d3..544d9106 100644 --- a/app/routes/timer.py +++ b/app/routes/timer.py @@ -170,9 +170,7 @@ def start_timer(): action="started", entity_type="time_entry", entity_id=new_timer.id, - entity_name=( - f"{project.name}" if project else f"{client.name if client else _('Unknown')}" - ) + entity_name=(f"{project.name}" if project else f"{client.name if client else _('Unknown')}") + (f" - {task.name}" if task else ""), description=( f"Started timer for {project.name}" @@ -781,11 +779,13 @@ def manual_entry(): target_name = entry.client.name else: target_name = "Unknown" - + if task_id and entry.project: task = Task.query.get(task_id) task_name = task.name if task else "Unknown Task" - flash(_("Manual entry created for %(project)s - %(task)s", project=target_name, task=task_name), "success") + flash( + _("Manual entry created for %(project)s - %(task)s", project=target_name, task=task_name), "success" + ) else: flash(_("Manual entry created for %(target)s", target=target_name), "success") @@ -1118,10 +1118,7 @@ def timer_page(): from sqlalchemy.orm import joinedload templates = ( - TimeEntryTemplate.query.options( - joinedload(TimeEntryTemplate.project), - joinedload(TimeEntryTemplate.task) - ) + TimeEntryTemplate.query.options(joinedload(TimeEntryTemplate.project), joinedload(TimeEntryTemplate.task)) .filter_by(user_id=current_user.id) .order_by(desc(TimeEntryTemplate.last_used_at)) .limit(5) diff --git a/app/routes/user.py b/app/routes/user.py index 7e7a8952..e2147bb8 100644 --- a/app/routes/user.py +++ b/app/routes/user.py @@ -116,16 +116,16 @@ def 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 - + # 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 @@ -137,13 +137,13 @@ def settings(): 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 diff --git a/app/routes/workflows.py b/app/routes/workflows.py index 87adb2c5..66603d9a 100644 --- a/app/routes/workflows.py +++ b/app/routes/workflows.py @@ -17,9 +17,11 @@ workflows_bp = Blueprint("workflows", __name__) @login_required def list_workflows(): """List all workflows""" - workflows = WorkflowRule.query.filter(WorkflowRule.user_id == current_user.id).order_by( - WorkflowRule.priority.desc(), WorkflowRule.created_at.desc() - ).all() + workflows = ( + WorkflowRule.query.filter(WorkflowRule.user_id == current_user.id) + .order_by(WorkflowRule.priority.desc(), WorkflowRule.created_at.desc()) + .all() + ) return render_template("workflows/list.html", workflows=workflows) @@ -88,9 +90,12 @@ def view_workflow(workflow_id): flash(_("Access denied"), "error") return redirect(url_for("workflows.list_workflows")) - executions = WorkflowExecution.query.filter_by(rule_id=workflow_id).order_by( - WorkflowExecution.executed_at.desc() - ).limit(50).all() + executions = ( + WorkflowExecution.query.filter_by(rule_id=workflow_id) + .order_by(WorkflowExecution.executed_at.desc()) + .limit(50) + .all() + ) return render_template("workflows/view.html", workflow=workflow, executions=executions) @@ -140,7 +145,9 @@ def edit_workflow(workflow_id): {"value": "assign_task", "label": _("Assign Task")}, ] - return render_template("workflows/edit.html", workflow=workflow, trigger_types=trigger_types, action_types=action_types) + return render_template( + "workflows/edit.html", workflow=workflow, trigger_types=trigger_types, action_types=action_types + ) @workflows_bp.route("/workflows//delete", methods=["POST"]) @@ -275,4 +282,3 @@ def test_workflow(workflow_id): result = WorkflowEngine.execute_rule(workflow, test_event) return jsonify(result) - diff --git a/app/services/ai_categorization_service.py b/app/services/ai_categorization_service.py index 56a68ee3..4dd2d12f 100644 --- a/app/services/ai_categorization_service.py +++ b/app/services/ai_categorization_service.py @@ -20,33 +20,41 @@ class AICategorizationService: # Category patterns (can be extended with ML models) CATEGORY_PATTERNS = { "development": { - "keywords": ["code", "develop", "programming", "debug", "fix", "bug", "feature", "api", "backend", "frontend"], - "projects": ["software", "app", "website", "system"] + "keywords": [ + "code", + "develop", + "programming", + "debug", + "fix", + "bug", + "feature", + "api", + "backend", + "frontend", + ], + "projects": ["software", "app", "website", "system"], }, "design": { "keywords": ["design", "ui", "ux", "mockup", "wireframe", "prototype", "figma", "sketch"], - "projects": ["design", "ui", "ux", "branding"] - }, - "meeting": { - "keywords": ["meeting", "call", "discuss", "review", "standup", "sync"], - "projects": [] + "projects": ["design", "ui", "ux", "branding"], }, + "meeting": {"keywords": ["meeting", "call", "discuss", "review", "standup", "sync"], "projects": []}, "documentation": { "keywords": ["document", "write", "docs", "readme", "spec", "requirements"], - "projects": ["documentation", "wiki"] + "projects": ["documentation", "wiki"], }, "testing": { "keywords": ["test", "qa", "quality", "verify", "validate", "check"], - "projects": ["testing", "qa"] + "projects": ["testing", "qa"], }, "support": { "keywords": ["support", "help", "ticket", "issue", "customer", "client"], - "projects": ["support", "helpdesk"] + "projects": ["support", "helpdesk"], }, "research": { "keywords": ["research", "investigate", "analyze", "study", "explore"], - "projects": ["research", "analysis"] - } + "projects": ["research", "analysis"], + }, } def categorize_time_entry(self, time_entry: TimeEntry) -> Dict[str, Any]: @@ -78,24 +86,22 @@ class AICategorizationService: return { "category": best_category[0], "confidence": min(best_category[1] / 3.0, 1.0), # Normalize - "all_matches": category_scores + "all_matches": category_scores, } - return { - "category": "uncategorized", - "confidence": 0.0, - "all_matches": {} - } + return {"category": "uncategorized", "confidence": 0.0, "all_matches": {}} def suggest_project_for_entry(self, description: str, user_id: int) -> Optional[Dict]: """Suggest project based on entry description""" description_lower = description.lower() # Get user's recent projects - recent_projects = Project.query.join(TimeEntry).filter( - TimeEntry.user_id == user_id, - TimeEntry.start_time >= datetime.utcnow() - timedelta(days=90) - ).distinct().all() + recent_projects = ( + Project.query.join(TimeEntry) + .filter(TimeEntry.user_id == user_id, TimeEntry.start_time >= datetime.utcnow() - timedelta(days=90)) + .distinct() + .all() + ) best_match = None best_score = 0 @@ -111,7 +117,7 @@ class AICategorizationService: "project_id": best_match.id, "project_name": best_match.name, "confidence": min(best_score, 1.0), - "reason": "Pattern match with project" + "reason": "Pattern match with project", } return None @@ -137,7 +143,7 @@ class AICategorizationService: "task_id": best_match.id, "task_name": best_match.name, "confidence": min(best_score, 1.0), - "reason": "Pattern match with task" + "reason": "Pattern match with task", } return None @@ -184,8 +190,8 @@ class AICategorizationService: entity_text = f"{entity.name} {getattr(entity, 'description', '')}".lower() # Word overlap - desc_words = set(re.findall(r'\b\w+\b', description)) - entity_words = set(re.findall(r'\b\w+\b', entity_text)) + desc_words = set(re.findall(r"\b\w+\b", description)) + entity_words = set(re.findall(r"\b\w+\b", entity_text)) common_words = desc_words.intersection(entity_words) if desc_words: @@ -216,14 +222,14 @@ class AICategorizationService: if entry.project_id: if entry.project_id not in project_category_map: project_category_map[entry.project_id] = {} - project_category_map[entry.project_id][category] = \ + project_category_map[entry.project_id][category] = ( project_category_map[entry.project_id].get(category, 0) + 1 + ) return { "category_distribution": category_distribution, "project_categories": { pid: max(cats.items(), key=lambda x: x[1])[0] if cats else "uncategorized" for pid, cats in project_category_map.items() - } + }, } - diff --git a/app/services/ai_suggestion_service.py b/app/services/ai_suggestion_service.py index 03d913a9..7c533b24 100644 --- a/app/services/ai_suggestion_service.py +++ b/app/services/ai_suggestion_service.py @@ -16,12 +16,7 @@ logger = logging.getLogger(__name__) class AISuggestionService: """Service for AI-powered time entry suggestions""" - def get_time_entry_suggestions( - self, - user_id: int, - context: str = None, - limit: int = 5 - ) -> List[Dict[str, Any]]: + def get_time_entry_suggestions(self, user_id: int, context: str = None, limit: int = 5) -> List[Dict[str, Any]]: """Get AI-powered suggestions for time entries""" suggestions = [] @@ -53,11 +48,14 @@ class AISuggestionService: # Get recent entries (last 30 days) cutoff = datetime.utcnow() - timedelta(days=30) - recent_entries = TimeEntry.query.filter( - TimeEntry.user_id == user_id, - TimeEntry.start_time >= cutoff, - TimeEntry.end_time.isnot(None) - ).order_by(TimeEntry.start_time.desc()).limit(100).all() + recent_entries = ( + TimeEntry.query.filter( + TimeEntry.user_id == user_id, TimeEntry.start_time >= cutoff, TimeEntry.end_time.isnot(None) + ) + .order_by(TimeEntry.start_time.desc()) + .limit(100) + .all() + ) if not recent_entries: return suggestions @@ -70,22 +68,24 @@ class AISuggestionService: # Suggest top patterns sorted_patterns = sorted(project_task_counts.items(), key=lambda x: x[1], reverse=True) - + for (project_id, task_id), count in sorted_patterns[:3]: project = Project.query.get(project_id) task = Task.query.get(task_id) if task_id else None if project: - suggestions.append({ - "type": "pattern", - "confidence": min(count / 10.0, 1.0), # Normalize to 0-1 - "project_id": project_id, - "project_name": project.name, - "task_id": task_id, - "task_name": task.name if task else None, - "reason": f"You've logged time here {count} times recently", - "suggested_duration": self._estimate_duration(recent_entries, project_id, task_id) - }) + suggestions.append( + { + "type": "pattern", + "confidence": min(count / 10.0, 1.0), # Normalize to 0-1 + "project_id": project_id, + "project_name": project.name, + "task_id": task_id, + "task_name": task.name if task else None, + "reason": f"You've logged time here {count} times recently", + "suggested_duration": self._estimate_duration(recent_entries, project_id, task_id), + } + ) return suggestions @@ -94,32 +94,34 @@ class AISuggestionService: suggestions = [] # Get active tasks assigned to user - active_tasks = Task.query.filter( - Task.assigned_to == user_id, - Task.status.in_(["todo", "in_progress"]) - ).order_by(Task.priority.desc(), Task.created_at.desc()).limit(5).all() + active_tasks = ( + Task.query.filter(Task.assigned_to == user_id, Task.status.in_(["todo", "in_progress"])) + .order_by(Task.priority.desc(), Task.created_at.desc()) + .limit(5) + .all() + ) for task in active_tasks: # Check if already logged today today = datetime.utcnow().date() today_entry = TimeEntry.query.filter( - TimeEntry.user_id == user_id, - TimeEntry.task_id == task.id, - func.date(TimeEntry.start_time) == today + TimeEntry.user_id == user_id, TimeEntry.task_id == task.id, func.date(TimeEntry.start_time) == today ).first() if not today_entry: - suggestions.append({ - "type": "active_task", - "confidence": 0.8, - "project_id": task.project_id, - "project_name": task.project.name if task.project else None, - "task_id": task.id, - "task_name": task.name, - "reason": f"Active task: {task.name}", - "priority": task.priority, - "suggested_duration": task.estimated_hours or 2.0 - }) + suggestions.append( + { + "type": "active_task", + "confidence": 0.8, + "project_id": task.project_id, + "project_name": task.project.name if task.project else None, + "task_id": task.id, + "task_name": task.name, + "reason": f"Active task: {task.name}", + "priority": task.priority, + "suggested_duration": task.estimated_hours or 2.0, + } + ) return suggestions @@ -132,7 +134,7 @@ class AISuggestionService: recent_entries = TimeEntry.query.filter( TimeEntry.user_id == user_id, TimeEntry.start_time >= datetime.utcnow() - timedelta(days=30), - TimeEntry.end_time.isnot(None) + TimeEntry.end_time.isnot(None), ).all() if not recent_entries: @@ -140,7 +142,7 @@ class AISuggestionService: # Find most common project for this hour hour_entries = [e for e in recent_entries if e.start_time.hour == current_hour] - + if hour_entries: project_counts = {} for entry in hour_entries: @@ -151,15 +153,17 @@ class AISuggestionService: project = Project.query.get(most_common_project_id) if project: - suggestions.append({ - "type": "time_pattern", - "confidence": 0.6, - "project_id": project.id, - "project_name": project.name, - "task_id": None, - "reason": f"You usually work on {project.name} around this time", - "suggested_duration": 2.0 - }) + suggestions.append( + { + "type": "time_pattern", + "confidence": 0.6, + "project_id": project.id, + "project_name": project.name, + "task_id": None, + "reason": f"You usually work on {project.name} around this time", + "suggested_duration": 2.0, + } + ) return suggestions @@ -169,35 +173,41 @@ class AISuggestionService: # Get tasks with upcoming deadlines upcoming_deadline = datetime.utcnow() + timedelta(days=7) - urgent_tasks = Task.query.filter( - Task.assigned_to == user_id, - Task.status.in_(["todo", "in_progress"]), - Task.due_date.isnot(None), - Task.due_date <= upcoming_deadline - ).order_by(Task.due_date.asc()).limit(3).all() + urgent_tasks = ( + Task.query.filter( + Task.assigned_to == user_id, + Task.status.in_(["todo", "in_progress"]), + Task.due_date.isnot(None), + Task.due_date <= upcoming_deadline, + ) + .order_by(Task.due_date.asc()) + .limit(3) + .all() + ) for task in urgent_tasks: days_until_deadline = (task.due_date.date() - datetime.utcnow().date()).days - - suggestions.append({ - "type": "deadline", - "confidence": 0.9 if days_until_deadline <= 2 else 0.7, - "project_id": task.project_id, - "project_name": task.project.name if task.project else None, - "task_id": task.id, - "task_name": task.name, - "reason": f"Deadline in {days_until_deadline} days", - "urgency": "high" if days_until_deadline <= 2 else "medium", - "suggested_duration": task.estimated_hours or 4.0 - }) + + suggestions.append( + { + "type": "deadline", + "confidence": 0.9 if days_until_deadline <= 2 else 0.7, + "project_id": task.project_id, + "project_name": task.project.name if task.project else None, + "task_id": task.id, + "task_name": task.name, + "reason": f"Deadline in {days_until_deadline} days", + "urgency": "high" if days_until_deadline <= 2 else "medium", + "suggested_duration": task.estimated_hours or 4.0, + } + ) return suggestions def _estimate_duration(self, entries: List[TimeEntry], project_id: int, task_id: int = None) -> float: """Estimate duration based on historical data""" relevant_entries = [ - e for e in entries - if e.project_id == project_id and (task_id is None or e.task_id == task_id) + e for e in entries if e.project_id == project_id and (task_id is None or e.task_id == task_id) ] if not relevant_entries: @@ -225,18 +235,13 @@ class AISuggestionService: def _rank_suggestions(self, suggestions: List[Dict], user_id: int) -> List[Dict]: """Rank suggestions by relevance""" # Sort by confidence, then by type priority - type_priority = { - "deadline": 4, - "active_task": 3, - "pattern": 2, - "time_pattern": 1 - } + type_priority = {"deadline": 4, "active_task": 3, "pattern": 2, "time_pattern": 1} def rank_key(s): return ( s.get("confidence", 0), type_priority.get(s.get("type", ""), 0), - s.get("urgency") == "high" if s.get("urgency") else False + s.get("urgency") == "high" if s.get("urgency") else False, ) return sorted(suggestions, key=rank_key, reverse=True) @@ -247,9 +252,7 @@ class AISuggestionService: description_lower = description.lower() # Get user's projects - user_projects = Project.query.join(TimeEntry).filter( - TimeEntry.user_id == user_id - ).distinct().all() + user_projects = Project.query.join(TimeEntry).filter(TimeEntry.user_id == user_id).distinct().all() # Match keywords best_match = None @@ -278,8 +281,7 @@ class AISuggestionService: "project_id": best_match.id, "project_name": best_match.name, "confidence": min(best_score / 5.0, 1.0), - "reason": "Keyword match with project name/description" + "reason": "Keyword match with project name/description", } return None - diff --git a/app/services/client_approval_service.py b/app/services/client_approval_service.py index 6b00e15b..ecaa779b 100644 --- a/app/services/client_approval_service.py +++ b/app/services/client_approval_service.py @@ -16,12 +16,7 @@ logger = logging.getLogger(__name__) class ClientApprovalService: """Service for managing client-side time entry approvals""" - def request_approval( - self, - time_entry_id: int, - requested_by: int, - comment: str = None - ) -> Dict[str, Any]: + def request_approval(self, time_entry_id: int, requested_by: int, comment: str = None) -> Dict[str, Any]: """Request client approval for a time entry""" time_entry = TimeEntry.query.get(time_entry_id) if not time_entry: @@ -37,8 +32,7 @@ class ClientApprovalService: # Check if already pending existing = ClientTimeApproval.query.filter_by( - time_entry_id=time_entry_id, - status=ClientApprovalStatus.PENDING + time_entry_id=time_entry_id, status=ClientApprovalStatus.PENDING ).first() if existing: @@ -51,7 +45,7 @@ class ClientApprovalService: client_id=client.id, requested_by=requested_by, status=ClientApprovalStatus.PENDING, - request_comment=comment + request_comment=comment, ) db.session.add(approval) db.session.commit() @@ -59,18 +53,9 @@ class ClientApprovalService: # Notify client contacts self._notify_client_contacts(client, approval) - return { - "success": True, - "message": "Approval requested", - "approval": approval.to_dict() - } + return {"success": True, "message": "Approval requested", "approval": approval.to_dict()} - def approve( - self, - approval_id: int, - contact_id: int, - comment: str = None - ) -> Dict[str, Any]: + def approve(self, approval_id: int, contact_id: int, comment: str = None) -> Dict[str, Any]: """Approve a time entry (client-side)""" approval = ClientTimeApproval.query.get(approval_id) if not approval: @@ -82,18 +67,9 @@ class ClientApprovalService: approval.approve(contact_id, comment) self._notify_requester(approval, "approved", comment) - return { - "success": True, - "message": "Time entry approved", - "approval": approval.to_dict() - } + return {"success": True, "message": "Time entry approved", "approval": approval.to_dict()} - def reject( - self, - approval_id: int, - contact_id: int, - reason: str - ) -> Dict[str, Any]: + def reject(self, approval_id: int, contact_id: int, reason: str) -> Dict[str, Any]: """Reject a time entry (client-side)""" approval = ClientTimeApproval.query.get(approval_id) if not approval: @@ -105,18 +81,15 @@ class ClientApprovalService: approval.reject(contact_id, reason) self._notify_requester(approval, "rejected", reason) - return { - "success": True, - "message": "Time entry rejected", - "approval": approval.to_dict() - } + return {"success": True, "message": "Time entry rejected", "approval": approval.to_dict()} def get_pending_approvals_for_client(self, client_id: int) -> List[ClientTimeApproval]: """Get pending approvals for a client""" - return ClientTimeApproval.query.filter_by( - client_id=client_id, - status=ClientApprovalStatus.PENDING - ).order_by(ClientTimeApproval.requested_at.desc()).all() + return ( + ClientTimeApproval.query.filter_by(client_id=client_id, status=ClientApprovalStatus.PENDING) + .order_by(ClientTimeApproval.requested_at.desc()) + .all() + ) def _notify_client_contacts(self, client: Client, approval: ClientTimeApproval): """Send notifications to client contacts""" @@ -124,21 +97,22 @@ class ClientApprovalService: from app.utils.notification_service import NotificationService service = NotificationService() - + # Get client contacts contacts = Contact.query.filter_by(client_id=client.id, is_active=True).all() - + for contact in contacts: if contact.email: # Send email notification from app.utils.email import send_email + try: send_email( to=contact.email, subject=f"Time Entry Approval Requested - {approval.time_entry.project.name}", template="email/client_approval_request.html", approval=approval, - contact=contact + contact=contact, ) except Exception as e: logger.error(f"Error sending approval email to {contact.email}: {e}") @@ -157,6 +131,5 @@ class ClientApprovalService: title=f"Time Entry {status.title()}", message=message, type="success" if status == "approved" else "error", - priority="normal" + priority="normal", ) - diff --git a/app/services/currency_service.py b/app/services/currency_service.py index 3678a011..858cd752 100644 --- a/app/services/currency_service.py +++ b/app/services/currency_service.py @@ -43,25 +43,17 @@ class CurrencyService: rate_date = date.today() # Try database first - rate = ExchangeRate.query.filter_by( - base_code=base_currency, - quote_code=quote_currency, - date=rate_date - ).first() + rate = ExchangeRate.query.filter_by(base_code=base_currency, quote_code=quote_currency, date=rate_date).first() if rate: return D(str(rate.rate)) # Try reverse rate - rate = ExchangeRate.query.filter_by( - base_code=quote_currency, - quote_code=base_currency, - date=rate_date - ).first() + rate = ExchangeRate.query.filter_by(base_code=quote_currency, quote_code=base_currency, date=rate_date).first() if rate: # Calculate inverse rate - return D('1') / D(str(rate.rate)) + return D("1") / D(str(rate.rate)) # Fetch from API fetched_rate = CurrencyService.fetch_exchange_rate(base_currency, quote_currency, rate_date) @@ -81,10 +73,7 @@ class CurrencyService: try: # Try primary API (exchangerate.host) url = f"{CurrencyService.EXCHANGE_API_URL}/{rate_date}" - params = { - "base": base_currency, - "symbols": quote_currency - } + params = {"base": base_currency, "symbols": quote_currency} response = requests.get(url, params=params, timeout=10) if response.status_code == 200: @@ -118,7 +107,7 @@ class CurrencyService: quote_code=quote_currency, rate=rate, date=rate_date, - source="exchangerate.host" + source="exchangerate.host", ) db.session.add(exchange_rate) db.session.commit() @@ -153,26 +142,23 @@ class CurrencyService: @staticmethod def get_historical_rates(base_currency: str, quote_currency: str, start_date: date, end_date: date) -> list: """Get historical exchange rates for a date range""" - rates = ExchangeRate.query.filter( - ExchangeRate.base_code == base_currency, - ExchangeRate.quote_code == quote_currency, - ExchangeRate.date >= start_date, - ExchangeRate.date <= end_date - ).order_by(ExchangeRate.date.asc()).all() + rates = ( + ExchangeRate.query.filter( + ExchangeRate.base_code == base_currency, + ExchangeRate.quote_code == quote_currency, + ExchangeRate.date >= start_date, + ExchangeRate.date <= end_date, + ) + .order_by(ExchangeRate.date.asc()) + .all() + ) - return [ - { - "date": rate.date.isoformat(), - "rate": float(rate.rate), - "source": rate.source - } - for rate in rates - ] + return [{"date": rate.date.isoformat(), "rate": float(rate.rate), "source": rate.source} for rate in rates] @staticmethod def auto_convert_invoice(invoice) -> Dict[str, Decimal]: """Automatically convert invoice amounts to different currencies""" - if not hasattr(invoice, 'currency_code') or not invoice.currency_code: + if not hasattr(invoice, "currency_code") or not invoice.currency_code: return {} conversions = {} @@ -188,4 +174,3 @@ class CurrencyService: conversions[currency.code] = converted return conversions - diff --git a/app/services/custom_report_service.py b/app/services/custom_report_service.py index 3484aee7..6439d952 100644 --- a/app/services/custom_report_service.py +++ b/app/services/custom_report_service.py @@ -19,7 +19,7 @@ class CustomReportService: def build_report(self, config_id: int, filters: Dict = None) -> Dict[str, Any]: """Build a report from a custom configuration""" config = CustomReportConfig.query.get_or_404(config_id) - + if not config.is_active: return {"error": "Report configuration is inactive"} @@ -42,7 +42,7 @@ class CustomReportService: builder_config = config.builder_config or {} columns = builder_config.get("columns", []) groupings = builder_config.get("groupings", []) - + # Base query query = TimeEntry.query.filter(TimeEntry.end_time.isnot(None)) @@ -69,27 +69,24 @@ class CustomReportService: "data": formatted_data, "summary": self._calculate_summary(entries), "columns": columns, - "groupings": groupings + "groupings": groupings, } def _build_project_report(self, config: CustomReportConfig, filters: Dict) -> Dict[str, Any]: """Build projects report""" query = Project.query.filter_by(status="active") - + if filters.get("client_id"): query = query.filter(Project.client_id == filters["client_id"]) projects = query.all() - return { - "data": [p.to_dict() for p in projects], - "summary": {"total_projects": len(projects)} - } + return {"data": [p.to_dict() for p in projects], "summary": {"total_projects": len(projects)}} def _build_invoice_report(self, config: CustomReportConfig, filters: Dict) -> Dict[str, Any]: """Build invoices report""" query = Invoice.query - + if filters.get("start_date"): query = query.filter(Invoice.issue_date >= filters["start_date"]) if filters.get("end_date"): @@ -99,16 +96,13 @@ class CustomReportService: return { "data": [i.to_dict() for i in invoices], - "summary": { - "total_invoices": len(invoices), - "total_amount": sum(float(i.total_amount) for i in invoices) - } + "summary": {"total_invoices": len(invoices), "total_amount": sum(float(i.total_amount) for i in invoices)}, } def _build_expense_report(self, config: CustomReportConfig, filters: Dict) -> Dict[str, Any]: """Build expenses report""" query = Expense.query - + if filters.get("start_date"): query = query.filter(Expense.date >= filters["start_date"]) if filters.get("end_date"): @@ -118,10 +112,7 @@ class CustomReportService: return { "data": [e.to_dict() for e in expenses], - "summary": { - "total_expenses": len(expenses), - "total_amount": sum(float(e.amount) for e in expenses) - } + "summary": {"total_expenses": len(expenses), "total_amount": sum(float(e.amount) for e in expenses)}, } def _build_combined_report(self, config: CustomReportConfig, filters: Dict) -> Dict[str, Any]: @@ -130,11 +121,7 @@ class CustomReportService: invoice_report = self._build_invoice_report(config, filters) expense_report = self._build_expense_report(config, filters) - return { - "time": time_report, - "invoices": invoice_report, - "expenses": expense_report - } + return {"time": time_report, "invoices": invoice_report, "expenses": expense_report} def _apply_groupings(self, entries: List, groupings: List[str]) -> Dict: """Apply grouping to entries""" @@ -151,7 +138,7 @@ class CustomReportService: key_parts.append(str(entry.user_id)) elif group_by == "date": key_parts.append(entry.start_time.strftime("%Y-%m-%d") if entry.start_time else "") - + key = "|".join(key_parts) if key_parts else "ungrouped" if key not in grouped: grouped[key] = [] @@ -162,7 +149,7 @@ class CustomReportService: def _format_columns(self, data: Dict, columns: List[str]) -> List[Dict]: """Format data with selected columns""" formatted = [] - + if isinstance(data, dict): for group_key, entries in data.items(): for entry in entries: @@ -189,11 +176,10 @@ class CustomReportService: """Calculate summary statistics""" total_hours = sum(e.duration_hours for e in entries if e.end_time) billable_hours = sum(e.duration_hours for e in entries if e.billable and e.end_time) - + return { "total_entries": len(entries), "total_hours": round(total_hours, 2), "billable_hours": round(billable_hours, 2), - "non_billable_hours": round(total_hours - billable_hours, 2) + "non_billable_hours": round(total_hours - billable_hours, 2), } - diff --git a/app/services/enhanced_ocr_service.py b/app/services/enhanced_ocr_service.py index bbe747a1..cc67284b 100644 --- a/app/services/enhanced_ocr_service.py +++ b/app/services/enhanced_ocr_service.py @@ -23,7 +23,7 @@ class EnhancedOCRService: try: # Extract text text = extract_text_from_image(image_path, lang=lang) - + if not text: return {"error": "No text extracted from image"} @@ -37,7 +37,7 @@ class EnhancedOCRService: "items": self._extract_items(text), "currency": self._extract_currency(text), "receipt_number": self._extract_receipt_number(text), - "confidence": self._calculate_confidence(text) + "confidence": self._calculate_confidence(text), } return data @@ -48,16 +48,16 @@ class EnhancedOCRService: def _extract_merchant(self, text: str) -> Optional[str]: """Extract merchant name (usually first line)""" - lines = [line.strip() for line in text.split('\n') if line.strip()] - + lines = [line.strip() for line in text.split("\n") if line.strip()] + if not lines: return None # First non-empty line is often merchant name merchant = lines[0] - + # Clean up common OCR artifacts - merchant = re.sub(r'[^\w\s&.-]', '', merchant) + merchant = re.sub(r"[^\w\s&.-]", "", merchant) merchant = merchant.strip() return merchant if len(merchant) > 2 else None @@ -66,10 +66,10 @@ class EnhancedOCRService: """Extract date from receipt""" # Common date patterns patterns = [ - r'\d{1,2}[/-]\d{1,2}[/-]\d{2,4}', - r'\d{4}[/-]\d{1,2}[/-]\d{1,2}', - r'\d{1,2}\s+\w{3,9}\s+\d{2,4}', - r'\w{3,9}\s+\d{1,2},?\s+\d{4}', + r"\d{1,2}[/-]\d{1,2}[/-]\d{2,4}", + r"\d{4}[/-]\d{1,2}[/-]\d{1,2}", + r"\d{1,2}\s+\w{3,9}\s+\d{2,4}", + r"\w{3,9}\s+\d{1,2},?\s+\d{4}", ] for pattern in patterns: @@ -88,10 +88,10 @@ class EnhancedOCRService: """Extract total amount""" # Look for "TOTAL", "TOTAL DUE", "AMOUNT", etc. patterns = [ - r'TOTAL[:\s]+[\$€£¥]?([\d,]+\.?\d*)', - r'AMOUNT[:\s]+[\$€£¥]?([\d,]+\.?\d*)', - r'DUE[:\s]+[\$€£¥]?([\d,]+\.?\d*)', - r'[\$€£¥]([\d,]+\.?\d{2})\s*$', # Amount at end of line + r"TOTAL[:\s]+[\$€£¥]?([\d,]+\.?\d*)", + r"AMOUNT[:\s]+[\$€£¥]?([\d,]+\.?\d*)", + r"DUE[:\s]+[\$€£¥]?([\d,]+\.?\d*)", + r"[\$€£¥]([\d,]+\.?\d{2})\s*$", # Amount at end of line ] amounts = [] @@ -99,7 +99,7 @@ class EnhancedOCRService: matches = re.finditer(pattern, text, re.IGNORECASE | re.MULTILINE) for match in matches: try: - amount_str = match.group(1).replace(',', '') + amount_str = match.group(1).replace(",", "") amount = Decimal(amount_str) amounts.append(amount) except Exception: @@ -114,16 +114,16 @@ class EnhancedOCRService: def _extract_tax(self, text: str) -> Optional[Decimal]: """Extract tax amount""" patterns = [ - r'TAX[:\s]+[\$€£¥]?([\d,]+\.?\d*)', - r'VAT[:\s]+[\$€£¥]?([\d,]+\.?\d*)', - r'SALES\s+TAX[:\s]+[\$€£¥]?([\d,]+\.?\d*)', + r"TAX[:\s]+[\$€£¥]?([\d,]+\.?\d*)", + r"VAT[:\s]+[\$€£¥]?([\d,]+\.?\d*)", + r"SALES\s+TAX[:\s]+[\$€£¥]?([\d,]+\.?\d*)", ] for pattern in patterns: match = re.search(pattern, text, re.IGNORECASE) if match: try: - tax_str = match.group(1).replace(',', '') + tax_str = match.group(1).replace(",", "") return Decimal(tax_str) except Exception: continue @@ -133,10 +133,10 @@ class EnhancedOCRService: def _extract_items(self, text: str) -> List[Dict[str, Any]]: """Extract line items from receipt""" items = [] - lines = text.split('\n') + lines = text.split("\n") # Pattern: description followed by amount - item_pattern = re.compile(r'^(.+?)\s+[\$€£¥]?([\d,]+\.?\d{2})$') + item_pattern = re.compile(r"^(.+?)\s+[\$€£¥]?([\d,]+\.?\d{2})$") for line in lines: line = line.strip() @@ -146,18 +146,15 @@ class EnhancedOCRService: match = item_pattern.match(line) if match: description = match.group(1).strip() - amount_str = match.group(2).replace(',', '') - + amount_str = match.group(2).replace(",", "") + # Skip totals and tax lines - if any(keyword in description.upper() for keyword in ['TOTAL', 'TAX', 'SUB', 'AMOUNT', 'DUE']): + if any(keyword in description.upper() for keyword in ["TOTAL", "TAX", "SUB", "AMOUNT", "DUE"]): continue try: amount = Decimal(amount_str) - items.append({ - "description": description, - "amount": float(amount) - }) + items.append({"description": description, "amount": float(amount)}) except Exception: continue @@ -166,11 +163,11 @@ class EnhancedOCRService: def _extract_currency(self, text: str) -> Optional[str]: """Extract currency symbol""" currency_symbols = { - '$': 'USD', - '€': 'EUR', - '£': 'GBP', - '¥': 'JPY', - '₹': 'INR', + "$": "USD", + "€": "EUR", + "£": "GBP", + "¥": "JPY", + "₹": "INR", } for symbol, code in currency_symbols.items(): @@ -178,7 +175,7 @@ class EnhancedOCRService: return code # Check for currency codes - currency_code_pattern = r'\b(USD|EUR|GBP|JPY|INR|CAD|AUD)\b' + currency_code_pattern = r"\b(USD|EUR|GBP|JPY|INR|CAD|AUD)\b" match = re.search(currency_code_pattern, text, re.IGNORECASE) if match: return match.group(1).upper() @@ -188,10 +185,10 @@ class EnhancedOCRService: def _extract_receipt_number(self, text: str) -> Optional[str]: """Extract receipt/invoice number""" patterns = [ - r'RECEIPT[#:\s]+(\w+)', - r'INVOICE[#:\s]+(\w+)', - r'#\s*(\d{4,})', - r'NO[.:\s]+(\d+)', + r"RECEIPT[#:\s]+(\w+)", + r"INVOICE[#:\s]+(\w+)", + r"#\s*(\d{4,})", + r"NO[.:\s]+(\d+)", ] for pattern in patterns: @@ -208,14 +205,13 @@ class EnhancedOCRService: # Check for key indicators if len(text) > 50: confidence += 0.2 - if re.search(r'[\$€£¥]', text): + if re.search(r"[\$€£¥]", text): confidence += 0.2 - if re.search(r'TOTAL|AMOUNT|DUE', text, re.IGNORECASE): + if re.search(r"TOTAL|AMOUNT|DUE", text, re.IGNORECASE): confidence += 0.2 - if re.search(r'\d{1,2}[/-]\d{1,2}', text): + if re.search(r"\d{1,2}[/-]\d{1,2}", text): confidence += 0.2 - if re.search(r'\d+\.\d{2}', text): + if re.search(r"\d+\.\d{2}", text): confidence += 0.2 return min(confidence, 1.0) - diff --git a/app/services/expense_service.py b/app/services/expense_service.py index dba9b367..a637c8cd 100644 --- a/app/services/expense_service.py +++ b/app/services/expense_service.py @@ -57,7 +57,7 @@ class ExpenseService: # Use model directly for full field support from app.models import Expense - + expense = Expense( user_id=created_by, title=title or description or "Expense", @@ -77,7 +77,7 @@ class ExpenseService: tags=tags, receipt_path=receipt_path, ) - + db.session.add(expense) if not safe_commit("create_expense", {"project_id": project_id, "created_by": created_by}): @@ -223,4 +223,4 @@ class ExpenseService: "error": "database_error", } - return {"success": True, "message": "Expense rejected successfully"} \ No newline at end of file + return {"success": True, "message": "Expense rejected successfully"} diff --git a/app/services/gamification_service.py b/app/services/gamification_service.py index 2ff512e3..1812f3d1 100644 --- a/app/services/gamification_service.py +++ b/app/services/gamification_service.py @@ -19,7 +19,7 @@ class GamificationService: def check_and_award_badges(self, user_id: int, event_type: str, event_data: Dict = None) -> List[Dict]: """Check if user qualifies for any badges and award them""" awarded = [] - + # Get all active badges badges = Badge.query.filter_by(is_active=True).all() @@ -32,11 +32,7 @@ class GamificationService: # Check criteria if self._check_badge_criteria(user_id, badge, event_type, event_data or {}): # Award badge - user_badge = UserBadge( - user_id=user_id, - badge_id=badge.id, - progress=100 - ) + user_badge = UserBadge(user_id=user_id, badge_id=badge.id, progress=100) db.session.add(user_badge) awarded.append(badge.to_dict()) @@ -85,10 +81,7 @@ class GamificationService: def _get_total_hours(self, user_id: int, criteria: Dict) -> float: """Get total hours tracked for user""" - query = TimeEntry.query.filter_by( - user_id=user_id, - billable=True - ).filter(TimeEntry.end_time.isnot(None)) + query = TimeEntry.query.filter_by(user_id=user_id, billable=True).filter(TimeEntry.end_time.isnot(None)) if criteria.get("date_from"): query = query.filter(TimeEntry.start_time >= criteria["date_from"]) @@ -100,10 +93,7 @@ class GamificationService: def _get_completed_tasks(self, user_id: int, criteria: Dict) -> int: """Get completed tasks count""" - query = Task.query.join(TimeEntry).filter( - Task.status == "completed", - TimeEntry.user_id == user_id - ) + query = Task.query.join(TimeEntry).filter(Task.status == "completed", TimeEntry.user_id == user_id) if criteria.get("date_from"): query = query.filter(Task.updated_at >= criteria["date_from"]) @@ -118,8 +108,7 @@ class GamificationService: for i in range(365): # Check up to 1 year check_date = today - timedelta(days=i) has_entry = TimeEntry.query.filter( - TimeEntry.user_id == user_id, - func.date(TimeEntry.start_time) == check_date + TimeEntry.user_id == user_id, func.date(TimeEntry.start_time) == check_date ).first() if has_entry: @@ -141,21 +130,19 @@ class GamificationService: def get_user_badges(self, user_id: int) -> List[Dict]: """Get all badges earned by user""" - user_badges = UserBadge.query.filter_by(user_id=user_id).order_by( - UserBadge.earned_at.desc() - ).all() + user_badges = UserBadge.query.filter_by(user_id=user_id).order_by(UserBadge.earned_at.desc()).all() return [ub.to_dict() for ub in user_badges] def get_user_points(self, user_id: int) -> int: """Get total points for user from badges""" - user_badges = UserBadge.query.join(Badge).filter( - UserBadge.user_id == user_id - ).all() + user_badges = UserBadge.query.join(Badge).filter(UserBadge.user_id == user_id).all() return sum(ub.badge.points for ub in user_badges) - def calculate_leaderboard(self, leaderboard_id: int, period_start: datetime = None, period_end: datetime = None) -> List[Dict]: + def calculate_leaderboard( + self, leaderboard_id: int, period_start: datetime = None, period_end: datetime = None + ) -> List[Dict]: """Calculate and update leaderboard rankings""" leaderboard = Leaderboard.query.get_or_404(leaderboard_id) @@ -169,9 +156,7 @@ class GamificationService: sorted_scores = sorted(scores.items(), key=lambda x: x[1], reverse=True) # Clear old entries for this period - LeaderboardEntry.query.filter_by( - leaderboard_id=leaderboard_id - ).filter( + LeaderboardEntry.query.filter_by(leaderboard_id=leaderboard_id).filter( LeaderboardEntry.period_start == period_start ).delete() @@ -184,7 +169,7 @@ class GamificationService: rank=rank, score=score, period_start=period_start, - period_end=period_end + period_end=period_end, ) db.session.add(entry) entries.append(entry) @@ -196,7 +181,7 @@ class GamificationService: def _get_period_dates(self, period: str) -> tuple: """Get period start and end dates""" today = datetime.now().date() - + if period == "daily": start = datetime.combine(today, datetime.min.time()) end = datetime.combine(today, datetime.max.time()) @@ -223,28 +208,22 @@ class GamificationService: if leaderboard_type == "time_tracked": # Total hours tracked - query = db.session.query( - TimeEntry.user_id, - func.sum(TimeEntry.duration_seconds).label("total_seconds") - ).filter( - TimeEntry.start_time >= start, - TimeEntry.start_time <= end, - TimeEntry.end_time.isnot(None) - ).group_by(TimeEntry.user_id) + query = ( + db.session.query(TimeEntry.user_id, func.sum(TimeEntry.duration_seconds).label("total_seconds")) + .filter(TimeEntry.start_time >= start, TimeEntry.start_time <= end, TimeEntry.end_time.isnot(None)) + .group_by(TimeEntry.user_id) + ) for user_id, total_seconds in query.all(): scores[user_id] = (total_seconds or 0) / 3600 # Convert to hours elif leaderboard_type == "tasks_completed": # Tasks completed - query = db.session.query( - Task.assigned_to.label("user_id"), - func.count(Task.id).label("count") - ).filter( - Task.status == "completed", - Task.updated_at >= start, - Task.updated_at <= end - ).group_by(Task.assigned_to) + query = ( + db.session.query(Task.assigned_to.label("user_id"), func.count(Task.id).label("count")) + .filter(Task.status == "completed", Task.updated_at >= start, Task.updated_at <= end) + .group_by(Task.assigned_to) + ) for user_id, count in query.all(): if user_id: @@ -252,13 +231,12 @@ class GamificationService: elif leaderboard_type == "points": # Badge points - query = db.session.query( - UserBadge.user_id, - func.sum(Badge.points).label("total_points") - ).join(Badge).filter( - UserBadge.earned_at >= start, - UserBadge.earned_at <= end - ).group_by(UserBadge.user_id) + query = ( + db.session.query(UserBadge.user_id, func.sum(Badge.points).label("total_points")) + .join(Badge) + .filter(UserBadge.earned_at >= start, UserBadge.earned_at <= end) + .group_by(UserBadge.user_id) + ) for user_id, total_points in query.all(): scores[user_id] = total_points or 0 @@ -270,11 +248,12 @@ class GamificationService: leaderboard = Leaderboard.query.get_or_404(leaderboard_id) period_start, period_end = self._get_period_dates(leaderboard.period) - entries = LeaderboardEntry.query.filter_by( - leaderboard_id=leaderboard_id - ).filter( - LeaderboardEntry.period_start == period_start - ).order_by(LeaderboardEntry.rank.asc()).limit(limit).all() + entries = ( + LeaderboardEntry.query.filter_by(leaderboard_id=leaderboard_id) + .filter(LeaderboardEntry.period_start == period_start) + .order_by(LeaderboardEntry.rank.asc()) + .limit(limit) + .all() + ) return [e.to_dict() for e in entries] - diff --git a/app/services/gps_tracking_service.py b/app/services/gps_tracking_service.py index 3d293083..515f6ac7 100644 --- a/app/services/gps_tracking_service.py +++ b/app/services/gps_tracking_service.py @@ -16,36 +16,20 @@ class GPSTrackingService: """Service for GPS tracking and mileage calculation""" def start_tracking( - self, - user_id: int, - latitude: float = None, - longitude: float = None, - location: str = None + self, user_id: int, latitude: float = None, longitude: float = None, location: str = None ) -> Dict[str, Any]: """Start GPS tracking for mileage""" track = MileageTrack( - user_id=user_id, - start_latitude=latitude, - start_longitude=longitude, - start_location=location, - method="gps" + user_id=user_id, start_latitude=latitude, start_longitude=longitude, start_location=location, method="gps" ) - + db.session.add(track) db.session.commit() - return { - "success": True, - "track_id": track.id, - "track": track.to_dict() - } + return {"success": True, "track_id": track.id, "track": track.to_dict()} def add_track_point( - self, - track_id: int, - latitude: float, - longitude: float, - timestamp: datetime = None + self, track_id: int, latitude: float, longitude: float, timestamp: datetime = None ) -> Dict[str, Any]: """Add a GPS point to the track""" track = MileageTrack.query.get_or_404(track_id) @@ -53,37 +37,23 @@ class GPSTrackingService: if not track.track_points: track.track_points = [] - point = { - "lat": latitude, - "lng": longitude, - "timestamp": (timestamp or datetime.utcnow()).isoformat() - } + point = {"lat": latitude, "lng": longitude, "timestamp": (timestamp or datetime.utcnow()).isoformat()} track.track_points.append(point) track.updated_at = datetime.utcnow() - + db.session.commit() - return { - "success": True, - "track": track.to_dict() - } + return {"success": True, "track": track.to_dict()} def stop_tracking( - self, - track_id: int, - latitude: float = None, - longitude: float = None, - location: str = None + self, track_id: int, latitude: float = None, longitude: float = None, location: str = None ) -> Dict[str, Any]: """Stop GPS tracking and calculate distance""" track = MileageTrack.query.get_or_404(track_id) if track.ended_at: - return { - "success": False, - "message": "Tracking already stopped" - } + return {"success": False, "message": "Tracking already stopped"} track.end_latitude = latitude track.end_longitude = longitude @@ -107,29 +77,20 @@ class GPSTrackingService: "success": True, "track": track.to_dict(), "distance_km": float(distance) if distance else None, - "distance_miles": float(track.distance_miles) if track.distance_miles else None + "distance_miles": float(track.distance_miles) if track.distance_miles else None, } def create_expense_from_track( - self, - track_id: int, - project_id: int = None, - rate_per_km: float = None + self, track_id: int, project_id: int = None, rate_per_km: float = None ) -> Dict[str, Any]: """Create expense from GPS track""" track = MileageTrack.query.get_or_404(track_id) if not track.ended_at: - return { - "success": False, - "message": "Tracking must be stopped before creating expense" - } + return {"success": False, "message": "Tracking must be stopped before creating expense"} if not track.distance_km: - return { - "success": False, - "message": "Distance not calculated" - } + return {"success": False, "message": "Distance not calculated"} # Calculate amount rate = rate_per_km or 0.5 # Default rate @@ -143,7 +104,7 @@ class GPSTrackingService: amount=amount, category="mileage", description=f"Mileage: {track.start_location or 'Start'} to {track.end_location or 'End'}", - notes=f"GPS tracked: {track.distance_km}km ({track.distance_miles} miles)" + notes=f"GPS tracked: {track.distance_km}km ({track.distance_miles} miles)", ) db.session.add(expense) @@ -153,23 +114,15 @@ class GPSTrackingService: track.expense_id = expense.id db.session.commit() - return { - "success": True, - "expense": expense.to_dict(), - "track": track.to_dict() - } + return {"success": True, "expense": expense.to_dict(), "track": track.to_dict()} def calculate_route_distance( - self, - start_lat: float, - start_lng: float, - end_lat: float, - end_lng: float + self, start_lat: float, start_lng: float, end_lat: float, end_lng: float ) -> Dict[str, Any]: """Calculate route distance between two points (can use routing API)""" # Simple Haversine calculation (straight line) # In production, use Google Maps API or similar for actual route distance - + from math import radians, sin, cos, sqrt, atan2 R = 6371 # Earth radius in km @@ -182,7 +135,7 @@ class GPSTrackingService: dlat = lat2 - lat1 dlon = lon2 - lon1 - a = sin(dlat / 2)**2 + cos(lat1) * cos(lat2) * sin(dlon / 2)**2 + a = sin(dlat / 2) ** 2 + cos(lat1) * cos(lat2) * sin(dlon / 2) ** 2 c = 2 * atan2(sqrt(a), sqrt(1 - a)) distance_km = R * c @@ -191,15 +144,11 @@ class GPSTrackingService: return { "distance_km": round(distance_km, 2), "distance_miles": round(distance_miles, 2), - "method": "haversine" # Straight line, not actual route + "method": "haversine", # Straight line, not actual route } def get_user_tracks( - self, - user_id: int, - start_date: datetime = None, - end_date: datetime = None, - limit: int = 50 + self, user_id: int, start_date: datetime = None, end_date: datetime = None, limit: int = 50 ) -> List[Dict]: """Get GPS tracks for a user""" query = MileageTrack.query.filter_by(user_id=user_id) @@ -212,4 +161,3 @@ class GPSTrackingService: tracks = query.order_by(MileageTrack.started_at.desc()).limit(limit).all() return [t.to_dict() for t in tracks] - diff --git a/app/services/integration_service.py b/app/services/integration_service.py index badcd557..0b16122e 100644 --- a/app/services/integration_service.py +++ b/app/services/integration_service.py @@ -53,7 +53,12 @@ class IntegrationService: return connector_class(integration, credentials) def create_integration( - self, provider: str, user_id: Optional[int] = None, name: Optional[str] = None, config: Optional[Dict] = None, is_global: bool = False + self, + provider: str, + user_id: Optional[int] = None, + name: Optional[str] = None, + config: Optional[Dict] = None, + is_global: bool = False, ) -> Dict[str, Any]: """ Create a new integration. @@ -118,33 +123,28 @@ class IntegrationService: integration = Integration.query.get(integration_id) if not integration: return None - + # Global integrations are accessible to all users if integration.is_global: return integration - + # Per-user integrations require user_id match if user_id and integration.user_id == user_id: return integration - + return None def list_integrations(self, user_id: Optional[int] = None) -> List[Integration]: """List all integrations accessible to a user (global + their per-user).""" from sqlalchemy import or_ - + # Get global integrations + user's per-user integrations if user_id: - query = Integration.query.filter( - or_( - Integration.is_global == True, - Integration.user_id == user_id - ) - ) + query = Integration.query.filter(or_(Integration.is_global == True, Integration.user_id == user_id)) else: # Admin view: show all query = Integration.query - + integrations = query.order_by(Integration.is_global.desc(), Integration.created_at.desc()).all() # Sync is_active status with credentials existence @@ -157,7 +157,7 @@ class IntegrationService: safe_commit("sync_integration_active_status", {"integration_id": integration.id}) return integrations - + def get_global_integration(self, provider: str) -> Optional[Integration]: """Get global integration for a provider.""" return Integration.query.filter_by(provider=provider, is_global=True).first() @@ -167,10 +167,11 @@ class IntegrationService: integration = self.get_integration(integration_id, user_id) if not integration: return {"success": False, "message": "Integration not found."} - + # Only admins can delete global integrations if integration.is_global: from app.models import User + user = User.query.get(user_id) if user_id else None if not user or not user.is_admin: return {"success": False, "message": "Only administrators can delete global integrations."} diff --git a/app/services/invoice_service.py b/app/services/invoice_service.py index 51d8ee53..20b4fa68 100644 --- a/app/services/invoice_service.py +++ b/app/services/invoice_service.py @@ -341,9 +341,7 @@ class InvoiceService: query = query.filter(db.or_(Invoice.invoice_number.ilike(like), Invoice.client_name.ilike(like))) # Order by creation date and paginate - pagination = query.order_by(Invoice.created_at.desc()).paginate( - page=page, per_page=per_page, error_out=False - ) + pagination = query.order_by(Invoice.created_at.desc()).paginate(page=page, per_page=per_page, error_out=False) invoices = pagination.items # Calculate overdue status diff --git a/app/services/payment_service.py b/app/services/payment_service.py index d0490981..b2d904c6 100644 --- a/app/services/payment_service.py +++ b/app/services/payment_service.py @@ -185,4 +185,4 @@ class PaymentService: "error": "database_error", } - return {"success": True, "message": "Payment deleted successfully"} \ No newline at end of file + return {"success": True, "message": "Payment deleted successfully"} diff --git a/app/services/pomodoro_service.py b/app/services/pomodoro_service.py index 1e12880c..d7547cab 100644 --- a/app/services/pomodoro_service.py +++ b/app/services/pomodoro_service.py @@ -24,22 +24,15 @@ class PomodoroService: pomodoro_length: int = 25, short_break_length: int = 5, long_break_length: int = 15, - long_break_interval: int = 4 + long_break_interval: int = 4, ) -> Dict[str, Any]: """Start a new Pomodoro focus session""" - + # Check for active session - active = FocusSession.query.filter_by( - user_id=user_id, - ended_at=None - ).first() + active = FocusSession.query.filter_by(user_id=user_id, ended_at=None).first() if active: - return { - "success": False, - "message": "Active session already exists", - "session": active.to_dict() - } + return {"success": False, "message": "Active session already exists", "session": active.to_dict()} # Create new session session = FocusSession( @@ -49,9 +42,9 @@ class PomodoroService: pomodoro_length=pomodoro_length, short_break_length=short_break_length, long_break_length=long_break_length, - long_break_interval=long_break_interval + long_break_interval=long_break_interval, ) - + db.session.add(session) db.session.commit() @@ -64,58 +57,58 @@ class PomodoroService: task_id=task_id, start_time=datetime.utcnow(), source="pomodoro", - billable=True + billable=True, ) db.session.add(time_entry) db.session.flush() - + session.time_entry_id = time_entry.id db.session.commit() return { "success": True, "session": session.to_dict(), - "time_entry": time_entry.to_dict() if time_entry else None + "time_entry": time_entry.to_dict() if time_entry else None, } def complete_cycle(self, session_id: int) -> Dict[str, Any]: """Complete a Pomodoro cycle""" session = FocusSession.query.get_or_404(session_id) - + session.cycles_completed += 1 session.updated_at = datetime.utcnow() - + # Check if long break is due needs_long_break = session.cycles_completed % session.long_break_interval == 0 - + db.session.commit() return { "success": True, "session": session.to_dict(), "needs_long_break": needs_long_break, - "next_break_length": session.long_break_length if needs_long_break else session.short_break_length + "next_break_length": session.long_break_length if needs_long_break else session.short_break_length, } def end_session(self, session_id: int, notes: str = None) -> Dict[str, Any]: """End a Pomodoro focus session""" session = FocusSession.query.get_or_404(session_id) - + session.ended_at = datetime.utcnow() session.notes = notes - + # Update linked time entry if exists if session.time_entry_id: time_entry = TimeEntry.query.get(session.time_entry_id) if time_entry and not time_entry.end_time: time_entry.end_time = datetime.utcnow() time_entry.duration_seconds = int((time_entry.end_time - time_entry.start_time).total_seconds()) - + # Add note about Pomodoro session if notes: existing_notes = time_entry.notes or "" time_entry.notes = f"{existing_notes}\n[Pomodoro: {session.cycles_completed} cycles]".strip() - + db.session.commit() return { @@ -124,45 +117,38 @@ class PomodoroService: "summary": { "duration_minutes": int((session.ended_at - session.started_at).total_seconds() / 60), "cycles_completed": session.cycles_completed, - "interruptions": session.interruptions - } + "interruptions": session.interruptions, + }, } def log_interruption(self, session_id: int, reason: str = None) -> Dict[str, Any]: """Log an interruption during a Pomodoro session""" session = FocusSession.query.get_or_404(session_id) - + session.interruptions += 1 - + # Add to notes if reason: existing_notes = session.notes or "" timestamp = datetime.utcnow().strftime("%H:%M:%S") session.notes = f"{existing_notes}\n[Interruption {session.interruptions} at {timestamp}: {reason}]".strip() - + db.session.commit() - return { - "success": True, - "session": session.to_dict() - } + return {"success": True, "session": session.to_dict()} def get_session_stats(self, user_id: int, days: int = 30) -> Dict[str, Any]: """Get Pomodoro session statistics for a user""" cutoff_date = datetime.utcnow() - timedelta(days=days) - + sessions = FocusSession.query.filter( - FocusSession.user_id == user_id, - FocusSession.ended_at.isnot(None), - FocusSession.ended_at >= cutoff_date + FocusSession.user_id == user_id, FocusSession.ended_at.isnot(None), FocusSession.ended_at >= cutoff_date ).all() total_sessions = len(sessions) total_cycles = sum(s.cycles_completed for s in sessions) total_interruptions = sum(s.interruptions for s in sessions) - total_minutes = sum( - int((s.ended_at - s.started_at).total_seconds() / 60) for s in sessions if s.ended_at - ) + total_minutes = sum(int((s.ended_at - s.started_at).total_seconds() / 60) for s in sessions if s.ended_at) return { "total_sessions": total_sessions, @@ -170,13 +156,9 @@ class PomodoroService: "total_interruptions": total_interruptions, "total_minutes": total_minutes, "average_cycles_per_session": round(total_cycles / total_sessions, 2) if total_sessions > 0 else 0, - "average_minutes_per_session": round(total_minutes / total_sessions, 2) if total_sessions > 0 else 0 + "average_minutes_per_session": round(total_minutes / total_sessions, 2) if total_sessions > 0 else 0, } def get_active_session(self, user_id: int) -> Optional[FocusSession]: """Get active Pomodoro session for a user""" - return FocusSession.query.filter_by( - user_id=user_id, - ended_at=None - ).first() - + return FocusSession.query.filter_by(user_id=user_id, ended_at=None).first() diff --git a/app/services/project_service.py b/app/services/project_service.py index e133cba1..ac12cda6 100644 --- a/app/services/project_service.py +++ b/app/services/project_service.py @@ -92,7 +92,7 @@ class ProjectService: # Create project using model directly (repository doesn't support all fields yet) from app.models import Project from decimal import Decimal - + project = Project( name=name, client_id=client_id, @@ -105,7 +105,7 @@ class ProjectService: billing_ref=billing_ref, status=ProjectStatus.ACTIVE.value, ) - + db.session.add(project) if not safe_commit("create_project", {"client_id": client_id, "name": name}): diff --git a/app/services/quote_service.py b/app/services/quote_service.py index 96121527..801d881e 100644 --- a/app/services/quote_service.py +++ b/app/services/quote_service.py @@ -126,7 +126,9 @@ class QuoteService: ], } - def get_quote_with_details(self, quote_id: int, user_id: Optional[int] = None, is_admin: bool = False) -> Optional[Quote]: + def get_quote_with_details( + self, quote_id: int, user_id: Optional[int] = None, is_admin: bool = False + ) -> Optional[Quote]: """ Get quote with all related data using eager loading. @@ -244,4 +246,3 @@ class QuoteService: } return {"success": True, "message": "Quote updated successfully", "quote": quote} - diff --git a/app/services/time_approval_service.py b/app/services/time_approval_service.py index e780850a..a0765438 100644 --- a/app/services/time_approval_service.py +++ b/app/services/time_approval_service.py @@ -17,11 +17,7 @@ class TimeApprovalService: """Service for managing time entry approvals""" def request_approval( - self, - time_entry_id: int, - requested_by: int, - comment: str = None, - approver_ids: List[int] = None + self, time_entry_id: int, requested_by: int, comment: str = None, approver_ids: List[int] = None ) -> Dict[str, Any]: """Request approval for a time entry""" time_entry = TimeEntry.query.get(time_entry_id) @@ -29,10 +25,7 @@ class TimeApprovalService: return {"success": False, "message": "Time entry not found", "error": "not_found"} # Check if already pending - existing = TimeEntryApproval.query.filter_by( - time_entry_id=time_entry_id, - status=ApprovalStatus.PENDING - ).first() + existing = TimeEntryApproval.query.filter_by(time_entry_id=time_entry_id, status=ApprovalStatus.PENDING).first() if existing: return {"success": False, "message": "Approval already pending", "error": "already_pending"} @@ -55,7 +48,7 @@ class TimeApprovalService: status=ApprovalStatus.PENDING, request_comment=comment, parent_approval_id=parent_approval.id if parent_approval else None, - approval_level=level + approval_level=level, ) db.session.add(approval) approvals.append(approval) @@ -66,18 +59,9 @@ class TimeApprovalService: # Send notifications to approvers self._notify_approvers(approvals[0], approver_ids) - return { - "success": True, - "message": "Approval requested", - "approval": approvals[0].to_dict() - } + return {"success": True, "message": "Approval requested", "approval": approvals[0].to_dict()} - def approve( - self, - approval_id: int, - approver_id: int, - comment: str = None - ) -> Dict[str, Any]: + def approve(self, approval_id: int, approver_id: int, comment: str = None) -> Dict[str, Any]: """Approve a time entry""" approval = TimeEntryApproval.query.get(approval_id) if not approval: @@ -96,8 +80,7 @@ class TimeApprovalService: # Check for next level approval child_approval = TimeEntryApproval.query.filter_by( - parent_approval_id=approval.id, - status=ApprovalStatus.PENDING + parent_approval_id=approval.id, status=ApprovalStatus.PENDING ).first() if child_approval: @@ -106,24 +89,15 @@ class TimeApprovalService: return { "success": True, "message": "Approved, awaiting next level approval", - "approval": approval.to_dict() + "approval": approval.to_dict(), } # All levels approved self._mark_entry_approved(approval.time_entry) - return { - "success": True, - "message": "Time entry approved", - "approval": approval.to_dict() - } + return {"success": True, "message": "Time entry approved", "approval": approval.to_dict()} - def reject( - self, - approval_id: int, - approver_id: int, - reason: str - ) -> Dict[str, Any]: + def reject(self, approval_id: int, approver_id: int, reason: str) -> Dict[str, Any]: """Reject a time entry approval""" approval = TimeEntryApproval.query.get(approval_id) if not approval: @@ -136,8 +110,7 @@ class TimeApprovalService: # Cancel any child approvals child_approvals = TimeEntryApproval.query.filter_by( - parent_approval_id=approval.id, - status=ApprovalStatus.PENDING + parent_approval_id=approval.id, status=ApprovalStatus.PENDING ).all() for child in child_approvals: @@ -146,11 +119,7 @@ class TimeApprovalService: # Notify requester self._notify_requester(approval, "rejected", reason) - return { - "success": True, - "message": "Time entry rejected", - "approval": approval.to_dict() - } + return {"success": True, "message": "Time entry rejected", "approval": approval.to_dict()} def cancel_approval(self, approval_id: int, user_id: int) -> Dict[str, Any]: """Cancel an approval request""" @@ -168,18 +137,13 @@ class TimeApprovalService: # Cancel child approvals child_approvals = TimeEntryApproval.query.filter_by( - parent_approval_id=approval.id, - status=ApprovalStatus.PENDING + parent_approval_id=approval.id, status=ApprovalStatus.PENDING ).all() for child in child_approvals: child.cancel() - return { - "success": True, - "message": "Approval cancelled", - "approval": approval.to_dict() - } + return {"success": True, "message": "Approval cancelled", "approval": approval.to_dict()} def get_pending_approvals(self, approver_id: int = None) -> List[TimeEntryApproval]: """Get pending approvals for an approver""" @@ -205,41 +169,32 @@ class TimeApprovalService: return { "success": True, "message": f"Approved {success_count} of {len(approval_ids)} entries", - "results": results + "results": results, } def _get_approvers_for_entry(self, time_entry: TimeEntry) -> List[int]: """Get list of approver user IDs for a time entry""" # Check project-specific policy - policy = ApprovalPolicy.query.filter_by( - project_id=time_entry.project_id, - enabled=True - ).first() + policy = ApprovalPolicy.query.filter_by(project_id=time_entry.project_id, enabled=True).first() if policy and policy.applies_to_entry(time_entry): return policy.get_approvers() # Check user-specific policy - policy = ApprovalPolicy.query.filter_by( - user_id=time_entry.user_id, - enabled=True - ).first() + policy = ApprovalPolicy.query.filter_by(user_id=time_entry.user_id, enabled=True).first() if policy and policy.applies_to_entry(time_entry): return policy.get_approvers() # Check global policy - policy = ApprovalPolicy.query.filter_by( - applies_to_all=True, - enabled=True - ).first() + policy = ApprovalPolicy.query.filter_by(applies_to_all=True, enabled=True).first() if policy and policy.applies_to_entry(time_entry): return policy.get_approvers() # Default: return project manager or admin project = time_entry.project - if project and hasattr(project, 'manager_id') and project.manager_id: + if project and hasattr(project, "manager_id") and project.manager_id: return [project.manager_id] # Fallback to admins @@ -248,9 +203,7 @@ class TimeApprovalService: def _get_all_approver_ids(self, user_id: int) -> List[int]: """Get all policies where user is an approver""" - policies = ApprovalPolicy.query.filter( - ApprovalPolicy.enabled == True - ).all() + policies = ApprovalPolicy.query.filter(ApprovalPolicy.enabled == True).all() approver_ids = [] for policy in policies: @@ -262,10 +215,10 @@ class TimeApprovalService: def _mark_entry_approved(self, time_entry: TimeEntry): """Mark time entry as approved""" # Add metadata to indicate approval - if not hasattr(time_entry, 'metadata') or not time_entry.metadata: + if not hasattr(time_entry, "metadata") or not time_entry.metadata: time_entry.metadata = {} - time_entry.metadata['approved'] = True - time_entry.metadata['approved_at'] = datetime.utcnow().isoformat() + time_entry.metadata["approved"] = True + time_entry.metadata["approved_at"] = datetime.utcnow().isoformat() db.session.commit() def _notify_approvers(self, approval: TimeEntryApproval, approver_ids: List[int]): @@ -279,7 +232,7 @@ class TimeApprovalService: title="Time Entry Approval Requested", message=f"Time entry {approval.time_entry_id} requires your approval", type="info", - priority="normal" + priority="normal", ) def _notify_requester(self, approval: TimeEntryApproval, status: str, reason: str = None): @@ -296,6 +249,5 @@ class TimeApprovalService: title=f"Time Entry {status.title()}", message=message, type="success" if status == "approved" else "error", - priority="normal" + priority="normal", ) - diff --git a/app/services/time_tracking_service.py b/app/services/time_tracking_service.py index 3d9819b0..66b62950 100644 --- a/app/services/time_tracking_service.py +++ b/app/services/time_tracking_service.py @@ -264,12 +264,12 @@ class TimeTrackingService: """Get time entries for a user with optional filters""" if start_date and end_date: return self.time_entry_repo.get_by_date_range( - start_date=start_date, - end_date=end_date, - user_id=user_id, + start_date=start_date, + end_date=end_date, + user_id=user_id, project_id=project_id, client_id=client_id, - include_relations=True + include_relations=True, ) elif project_id: return self.time_entry_repo.get_by_project( diff --git a/app/services/workflow_engine.py b/app/services/workflow_engine.py index 9c616521..f3d94532 100644 --- a/app/services/workflow_engine.py +++ b/app/services/workflow_engine.py @@ -372,9 +372,7 @@ class WorkflowEngine: """Trigger workflow evaluation for an event""" # Get all enabled rules for this trigger type, ordered by priority rules = ( - WorkflowRule.query.filter( - WorkflowRule.trigger_type == event_type, WorkflowRule.enabled == True - ) + WorkflowRule.query.filter(WorkflowRule.trigger_type == event_type, WorkflowRule.enabled == True) .order_by(WorkflowRule.priority.desc()) .all() ) @@ -391,4 +389,3 @@ class WorkflowEngine: results.append({"rule_id": rule.id, "rule_name": rule.name, "success": False, "error": str(e)}) return results - diff --git a/app/utils/api_auth.py b/app/utils/api_auth.py index 1c1eba18..01154490 100644 --- a/app/utils/api_auth.py +++ b/app/utils/api_auth.py @@ -68,11 +68,12 @@ def authenticate_token(token_string): if api_token.ip_whitelist: client_ip = request.remote_addr allowed_ips = [ip.strip() for ip in api_token.ip_whitelist.split(",") if ip.strip()] - + # Simple IP matching (can be enhanced with CIDR support) if client_ip not in allowed_ips: # Check CIDR blocks if any from ipaddress import ip_address, ip_network + ip_allowed = False for allowed in allowed_ips: try: @@ -87,11 +88,9 @@ def authenticate_token(token_string): except ValueError: # Invalid IP format, skip continue - + if not ip_allowed: - current_app.logger.warning( - f"API token {api_token.token_prefix}... access denied from IP {client_ip}" - ) + current_app.logger.warning(f"API token {api_token.token_prefix}... access denied from IP {client_ip}") return None, None, "Access denied from this IP address" # Get associated user diff --git a/app/utils/cache.py b/app/utils/cache.py index 3aeda832..e9253d8b 100644 --- a/app/utils/cache.py +++ b/app/utils/cache.py @@ -14,6 +14,7 @@ from flask import current_app # Try to import Redis try: import redis + REDIS_AVAILABLE = True except ImportError: REDIS_AVAILABLE = False @@ -76,16 +77,17 @@ class RedisCache: try: # Parse Redis URL from urllib.parse import urlparse + parsed = urlparse(redis_url) - + # Extract password from URL if present password = parsed.password or None - + self._client = redis.Redis( - host=parsed.hostname or 'localhost', + host=parsed.hostname or "localhost", port=parsed.port or 6379, password=password, - db=int(parsed.path.lstrip('/')) if parsed.path else 0, + db=int(parsed.path.lstrip("/")) if parsed.path else 0, decode_responses=False, # We'll handle serialization ourselves socket_connect_timeout=5, socket_timeout=5, @@ -105,7 +107,7 @@ class RedisCache: """Get a value from cache""" if not self._connected: return self._fallback.get(key) - + try: data = self._client.get(key) if data is None: @@ -121,7 +123,7 @@ class RedisCache: if not self._connected: self._fallback.set(key, value, ttl) return - + try: ttl = ttl or self._default_ttl data = pickle.dumps(value) @@ -135,7 +137,7 @@ class RedisCache: if not self._connected: self._fallback.delete(key) return - + try: self._client.delete(key) except Exception as e: @@ -147,7 +149,7 @@ class RedisCache: if not self._connected: self._fallback.clear() return - + try: self._client.flushdb() except Exception as e: @@ -158,7 +160,7 @@ class RedisCache: """Check if a key exists in cache""" if not self._connected: return self._fallback.exists(key) - + try: return bool(self._client.exists(key)) except Exception as e: @@ -174,15 +176,15 @@ _cache: Optional[Any] = None def get_cache(): """Get the global cache instance (Redis if available, otherwise in-memory)""" global _cache - + if _cache is not None: return _cache - + # Try to initialize Redis if enabled try: - if current_app and current_app.config.get('REDIS_ENABLED', True) and REDIS_AVAILABLE: - redis_url = current_app.config.get('REDIS_URL', 'redis://localhost:6379/0') - default_ttl = current_app.config.get('REDIS_DEFAULT_TTL', 3600) + if current_app and current_app.config.get("REDIS_ENABLED", True) and REDIS_AVAILABLE: + redis_url = current_app.config.get("REDIS_URL", "redis://localhost:6379/0") + default_ttl = current_app.config.get("REDIS_DEFAULT_TTL", 3600) _cache = RedisCache(redis_url, default_ttl) if _cache._connected: return _cache @@ -192,11 +194,11 @@ def get_cache(): except Exception as e: if current_app: current_app.logger.warning(f"Failed to initialize Redis cache: {e}") - + # Fallback to in-memory cache default_ttl = 3600 if current_app: - default_ttl = current_app.config.get('REDIS_DEFAULT_TTL', 3600) + default_ttl = current_app.config.get("REDIS_DEFAULT_TTL", 3600) _cache = InMemoryCache(default_ttl) return _cache @@ -252,12 +254,12 @@ def invalidate_cache(pattern: str) -> None: def invalidate_pattern(pattern: str) -> None: """ Invalidate cache entries matching a pattern. - + Args: pattern: Pattern to match (supports * wildcard) """ cache = get_cache() - if hasattr(cache, '_client') and cache._connected: + if hasattr(cache, "_client") and cache._connected: # Redis pattern matching try: keys = cache._client.keys(pattern) diff --git a/app/utils/decorators.py b/app/utils/decorators.py index 93f951a3..28d86ade 100644 --- a/app/utils/decorators.py +++ b/app/utils/decorators.py @@ -12,6 +12,7 @@ def admin_required(f): DEPRECATED: Use @admin_or_permission_required() with specific permissions instead. This decorator is kept for backward compatibility. """ + @wraps(f) def decorated_function(*args, **kwargs): if not current_user.is_authenticated or not current_user.is_admin: @@ -20,4 +21,3 @@ def admin_required(f): return f(*args, **kwargs) return decorated_function - diff --git a/app/utils/error_handlers.py b/app/utils/error_handlers.py index 6ba22be0..96edaf51 100644 --- a/app/utils/error_handlers.py +++ b/app/utils/error_handlers.py @@ -116,12 +116,15 @@ def register_error_handlers(app): message=error.description or "An error occurred", error_code=error.code, status_code=error.code ) from flask import render_template - - return render_template( - "errors/generic.html", - error=error, - error_info={"title": error.name, "message": error.description or "An error occurred"} - ), error.code + + return ( + render_template( + "errors/generic.html", + error=error, + error_info={"title": error.name, "message": error.description or "An error occurred"}, + ), + error.code, + ) @app.errorhandler(Exception) def handle_generic_exception(error): @@ -145,10 +148,16 @@ def register_error_handlers(app): from flask import render_template, flash flash("An error occurred. Please try again.", "error") - return render_template( - "errors/500.html", - error_info={"title": "Server Error", "message": "Something went wrong on our end. Please try again later."} - ), 500 + return ( + render_template( + "errors/500.html", + error_info={ + "title": "Server Error", + "message": "Something went wrong on our end. Please try again later.", + }, + ), + 500, + ) def create_error_response( diff --git a/app/utils/powerpoint_export.py b/app/utils/powerpoint_export.py index 33e6f489..c8d5622a 100644 --- a/app/utils/powerpoint_export.py +++ b/app/utils/powerpoint_export.py @@ -13,6 +13,7 @@ try: from pptx.util import Inches, Pt from pptx.enum.text import PP_ALIGN from pptx.dml.color import RGBColor + PPTX_AVAILABLE = True except ImportError: PPTX_AVAILABLE = False @@ -34,14 +35,12 @@ def create_report_powerpoint(entries, title="TimeTracker Report", filename_prefi Returns: tuple: (BytesIO object with PPTX file, filename) - + Raises: ImportError: If python-pptx is not installed """ if not PPTX_AVAILABLE: - raise ImportError( - "PowerPoint export requires python-pptx. Install it with: pip install python-pptx" - ) + raise ImportError("PowerPoint export requires python-pptx. Install it with: pip install python-pptx") prs = Presentation() prs.slide_width = Inches(10) prs.slide_height = Inches(7.5) @@ -64,14 +63,14 @@ def create_report_powerpoint(entries, title="TimeTracker Report", filename_prefi # Calculate summary total_hours = sum(entry.duration_hours for entry in entries if entry.end_time) billable_hours = sum(entry.duration_hours for entry in entries if entry.billable and entry.end_time) - + projects_count = len(set(entry.project_id for entry in entries)) users_count = len(set(entry.user_id for entry in entries)) content = slide.placeholders[1] tf = content.text_frame tf.text = f"Total Hours: {total_hours:.2f}" - + p = tf.add_paragraph() p.text = f"Billable Hours: {billable_hours:.2f}" p.level = 0 @@ -137,7 +136,7 @@ def create_report_powerpoint(entries, title="TimeTracker Report", filename_prefi entry.project.name if entry.project else "N/A", entry.start_time.strftime("%Y-%m-%d") if entry.start_time else "", f"{entry.duration_hours:.2f}" if entry.end_time else "In Progress", - (entry.notes or "")[:50] # Truncate long notes + (entry.notes or "")[:50], # Truncate long notes ] for col_idx, value in enumerate(data): @@ -151,7 +150,7 @@ def create_report_powerpoint(entries, title="TimeTracker Report", filename_prefi if len(entries) > 20: for i in range(20, len(entries), 20): slide = prs.slides.add_slide(blank_slide_layout) - + # Add title txBox = slide.shapes.add_textbox(left, top - Inches(1), width, height) tf = txBox.text_frame @@ -160,9 +159,9 @@ def create_report_powerpoint(entries, title="TimeTracker Report", filename_prefi tf.paragraphs[0].font.bold = True # Create table for this batch - batch_entries = entries[i:i+20] + batch_entries = entries[i : i + 20] rows = len(batch_entries) + 1 - + table = slide.shapes.add_table(rows, cols, left, top, width, height).table # Set column widths @@ -187,7 +186,7 @@ def create_report_powerpoint(entries, title="TimeTracker Report", filename_prefi entry.project.name if entry.project else "N/A", entry.start_time.strftime("%Y-%m-%d") if entry.start_time else "", f"{entry.duration_hours:.2f}" if entry.end_time else "In Progress", - (entry.notes or "")[:50] + (entry.notes or "")[:50], ] for col_idx, value in enumerate(data): @@ -205,4 +204,3 @@ def create_report_powerpoint(entries, title="TimeTracker Report", filename_prefi filename = f"{filename_prefix}_{datetime.now().strftime('%Y%m%d')}.pptx" return output, filename - diff --git a/app/utils/scheduled_tasks.py b/app/utils/scheduled_tasks.py index 81d23af0..a298649c 100644 --- a/app/utils/scheduled_tasks.py +++ b/app/utils/scheduled_tasks.py @@ -4,7 +4,17 @@ import logging from datetime import datetime, timedelta from flask import current_app from app import db -from app.models import Invoice, User, TimeEntry, Project, BudgetAlert, RecurringInvoice, Quote, ReportEmailSchedule, Integration +from app.models import ( + Invoice, + User, + TimeEntry, + Project, + BudgetAlert, + RecurringInvoice, + Quote, + ReportEmailSchedule, + Integration, +) from app.utils.email import send_overdue_invoice_notification, send_weekly_summary, send_quote_expired_notification from app.utils.budget_forecasting import check_budget_alerts from app.services.scheduled_report_service import ScheduledReportService @@ -595,7 +605,9 @@ def sync_integrations(): errors.append(f"{integration.provider}: {result.get('message', 'Unknown error')}") integration.last_sync_status = "error" integration.last_error = result.get("message", "Unknown error") - logger.error(f"Failed to sync integration {integration.id} ({integration.provider}): {result.get('message')}") + logger.error( + f"Failed to sync integration {integration.id} ({integration.provider}): {result.get('message')}" + ) db.session.commit() diff --git a/tests/conftest.py b/tests/conftest.py index 54a22701..64125f13 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -762,7 +762,7 @@ def authenticated_client(client, user): def admin_authenticated_client(client, admin_user): """Create an authenticated admin test client.""" from flask_login import login_user - + with client.session_transaction() as sess: # Use Flask-Login's login_user directly for tests login_user(admin_user) diff --git a/tests/test_client_portal.py b/tests/test_client_portal.py index efeb46e9..fd5e8c9d 100644 --- a/tests/test_client_portal.py +++ b/tests/test_client_portal.py @@ -350,13 +350,14 @@ class TestAdminClientPortalManagement: # Get the edit form page first to get CSRF token get_response = admin_authenticated_client.get(f"/admin/users/{user.id}/edit") assert get_response.status_code == 200 - + # Extract CSRF token from the form if available html = get_response.get_data(as_text=True) import re + csrf_match = re.search(r'name="csrf_token"\s+value="([^"]+)"', html) csrf_token = csrf_match.group(1) if csrf_match else "" - + response = admin_authenticated_client.post( f"/admin/users/{user.id}/edit", data={ @@ -397,13 +398,14 @@ class TestAdminClientPortalManagement: # Get the edit form page first to get CSRF token get_response = admin_authenticated_client.get(f"/admin/users/{user.id}/edit") assert get_response.status_code == 200 - + # Extract CSRF token from the form if available html = get_response.get_data(as_text=True) import re + csrf_match = re.search(r'name="csrf_token"\s+value="([^"]+)"', html) csrf_token = csrf_match.group(1) if csrf_match else "" - + response = admin_authenticated_client.post( f"/admin/users/{user.id}/edit", data={ diff --git a/tests/test_pdf_layout.py b/tests/test_pdf_layout.py index 8f69a5eb..2377396c 100644 --- a/tests/test_pdf_layout.py +++ b/tests/test_pdf_layout.py @@ -105,18 +105,14 @@ def test_pdf_layout_page_accessible_to_admin(admin_authenticated_client): def test_pdf_layout_save_custom_template(admin_authenticated_client, app): """Test saving custom PDF layout templates.""" from app.models import InvoicePDFTemplate - + custom_html = '

{{ invoice.invoice_number }}

' custom_css = ".custom-invoice { color: red; }" # Save custom template (A4 is default) response = admin_authenticated_client.post( "/admin/pdf-layout", - data={ - "invoice_pdf_template_html": custom_html, - "invoice_pdf_template_css": custom_css, - "page_size": "A4" - }, + data={"invoice_pdf_template_html": custom_html, "invoice_pdf_template_css": custom_css, "page_size": "A4"}, follow_redirects=True, ) @@ -127,7 +123,7 @@ def test_pdf_layout_save_custom_template(admin_authenticated_client, app): settings = Settings.get_settings() assert settings.invoice_pdf_template_html == custom_html assert settings.invoice_pdf_template_css == custom_css - + # Also check InvoicePDFTemplate template = InvoicePDFTemplate.get_template("A4") assert template.template_html == custom_html diff --git a/tests/test_routes/test_api_v1_calendar_templates_refactored.py b/tests/test_routes/test_api_v1_calendar_templates_refactored.py index 4d5f4069..3932e99e 100644 --- a/tests/test_routes/test_api_v1_calendar_templates_refactored.py +++ b/tests/test_routes/test_api_v1_calendar_templates_refactored.py @@ -16,9 +16,10 @@ class TestAPICalendarTemplatesRefactored: token, plain_token = ApiToken.create_token( user_id=user.id, name="Test API Token", - scopes="read:calendar,write:calendar,read:time_entries,write:time_entries" + scopes="read:calendar,write:calendar,read:time_entries,write:time_entries", ) from app import db + db.session.add(token) db.session.commit() return token, plain_token @@ -28,24 +29,25 @@ class TestAPICalendarTemplatesRefactored: """Create a test client with API token""" token, plain_token = api_token test_client = app.test_client() - test_client.environ_base['HTTP_AUTHORIZATION'] = f'Bearer {plain_token}' + test_client.environ_base["HTTP_AUTHORIZATION"] = f"Bearer {plain_token}" return test_client def test_list_calendar_events_uses_eager_loading(self, app, client_with_token, user): """Test that list_calendar_events uses eager loading""" # Create a test event from app import db + event = CalendarEvent( user_id=user.id, title="Test Event", start_time=datetime.utcnow(), - end_time=datetime.utcnow() + timedelta(hours=1) + end_time=datetime.utcnow() + timedelta(hours=1), ) db.session.add(event) db.session.commit() - + response = client_with_token.get("/api/v1/calendar/events") - + assert response.status_code == 200 data = response.get_json() assert "events" in data @@ -53,17 +55,18 @@ class TestAPICalendarTemplatesRefactored: def test_get_calendar_event_uses_eager_loading(self, app, client_with_token, user): """Test that get_calendar_event uses eager loading""" from app import db + event = CalendarEvent( user_id=user.id, title="Test Event", start_time=datetime.utcnow(), - end_time=datetime.utcnow() + timedelta(hours=1) + end_time=datetime.utcnow() + timedelta(hours=1), ) db.session.add(event) db.session.commit() - + response = client_with_token.get(f"/api/v1/calendar/events/{event.id}") - + assert response.status_code == 200 data = response.get_json() assert "event" in data @@ -72,7 +75,7 @@ class TestAPICalendarTemplatesRefactored: def test_list_time_entry_templates_uses_eager_loading(self, app, client_with_token, user): """Test that list_time_entry_templates uses eager loading""" response = client_with_token.get("/api/v1/time-entry-templates") - + assert response.status_code == 200 data = response.get_json() assert "templates" in data @@ -81,18 +84,14 @@ class TestAPICalendarTemplatesRefactored: def test_get_time_entry_template_uses_eager_loading(self, app, client_with_token, user): """Test that get_time_entry_template uses eager loading""" from app import db - template = TimeEntryTemplate( - user_id=user.id, - name="Test Template", - default_notes="Test notes" - ) + + template = TimeEntryTemplate(user_id=user.id, name="Test Template", default_notes="Test notes") db.session.add(template) db.session.commit() - + response = client_with_token.get(f"/api/v1/time-entry-templates/{template.id}") - + assert response.status_code == 200 data = response.get_json() assert "template" in data assert data["template"]["name"] == "Test Template" - diff --git a/tests/test_routes/test_api_v1_expenses_complete.py b/tests/test_routes/test_api_v1_expenses_complete.py index 9b93969a..9c12c99b 100644 --- a/tests/test_routes/test_api_v1_expenses_complete.py +++ b/tests/test_routes/test_api_v1_expenses_complete.py @@ -15,11 +15,10 @@ class TestAPIExpensesComplete: def api_token(self, app, user): """Create an API token for testing""" token, plain_token = ApiToken.create_token( - user_id=user.id, - name="Test API Token", - scopes="read:expenses,write:expenses" + user_id=user.id, name="Test API Token", scopes="read:expenses,write:expenses" ) from app import db + db.session.add(token) db.session.commit() return token, plain_token @@ -29,7 +28,7 @@ class TestAPIExpensesComplete: """Create a test client with API token""" token, plain_token = api_token test_client = app.test_client() - test_client.environ_base['HTTP_AUTHORIZATION'] = f'Bearer {plain_token}' + test_client.environ_base["HTTP_AUTHORIZATION"] = f"Bearer {plain_token}" return test_client def test_list_expenses_with_filters(self, app, client_with_token, user, project, expense): @@ -67,11 +66,11 @@ class TestAPIExpensesComplete: "payment_date": date.today().isoformat(), "billable": True, "reimbursable": True, - "tags": "test,travel" + "tags": "test,travel", }, - content_type="application/json" + content_type="application/json", ) - + assert response.status_code == 201 data = response.get_json() assert "expense" in data @@ -82,14 +81,10 @@ class TestAPIExpensesComplete: """Test that update_expense uses service layer""" response = client_with_token.put( f"/api/v1/expenses/{expense.id}", - json={ - "title": "Updated Expense", - "amount": 300.00, - "status": "approved" - }, - content_type="application/json" + json={"title": "Updated Expense", "amount": 300.00, "status": "approved"}, + content_type="application/json", ) - + assert response.status_code == 200 data = response.get_json() assert "expense" in data @@ -98,13 +93,14 @@ class TestAPIExpensesComplete: def test_delete_expense_uses_service_layer(self, app, client_with_token, expense): """Test that delete_expense uses service layer""" response = client_with_token.delete(f"/api/v1/expenses/{expense.id}") - + assert response.status_code == 200 data = response.get_json() assert "message" in data - + # Verify expense was rejected from app import db + db.session.refresh(expense) assert expense.status == "rejected" @@ -112,7 +108,7 @@ class TestAPIExpensesComplete: """Test expense access permissions""" from app.models import Expense, ApiToken from app import db - + # Create expense for another user other_user = User.query.filter(User.id != user.id).first() if other_user: @@ -121,24 +117,21 @@ class TestAPIExpensesComplete: title="Other User Expense", category="travel", amount=Decimal("100.00"), - expense_date=date.today() + expense_date=date.today(), ) db.session.add(expense) db.session.commit() - + # Create token for first user token, plain_token = ApiToken.create_token( - user_id=user.id, - name="Test Token", - scopes="read:expenses,write:expenses" + user_id=user.id, name="Test Token", scopes="read:expenses,write:expenses" ) db.session.add(token) db.session.commit() - + test_client = app.test_client() - test_client.environ_base['HTTP_AUTHORIZATION'] = f'Bearer {plain_token}' - + test_client.environ_base["HTTP_AUTHORIZATION"] = f"Bearer {plain_token}" + # Non-admin should not access other user's expense response = test_client.get(f"/api/v1/expenses/{expense.id}") assert response.status_code == 403 - diff --git a/tests/test_routes/test_api_v1_invoices_tasks_expenses_refactored.py b/tests/test_routes/test_api_v1_invoices_tasks_expenses_refactored.py index af689b01..30bc5309 100644 --- a/tests/test_routes/test_api_v1_invoices_tasks_expenses_refactored.py +++ b/tests/test_routes/test_api_v1_invoices_tasks_expenses_refactored.py @@ -15,11 +15,10 @@ class TestAPIInvoicesRefactored: def api_token(self, app, user): """Create an API token for testing""" token, plain_token = ApiToken.create_token( - user_id=user.id, - name="Test API Token", - scopes="read:invoices,write:invoices" + user_id=user.id, name="Test API Token", scopes="read:invoices,write:invoices" ) from app import db + db.session.add(token) db.session.commit() return token, plain_token @@ -29,13 +28,13 @@ class TestAPIInvoicesRefactored: """Create a test client with API token""" token, plain_token = api_token test_client = app.test_client() - test_client.environ_base['HTTP_AUTHORIZATION'] = f'Bearer {plain_token}' + test_client.environ_base["HTTP_AUTHORIZATION"] = f"Bearer {plain_token}" return test_client def test_list_invoices_uses_service_layer(self, app, client_with_token, invoice): """Test that list_invoices route uses service layer""" response = client_with_token.get("/api/v1/invoices") - + assert response.status_code == 200 data = response.get_json() assert "invoices" in data @@ -44,7 +43,7 @@ class TestAPIInvoicesRefactored: def test_get_invoice_uses_eager_loading(self, app, client_with_token, invoice): """Test that get_invoice route uses eager loading""" response = client_with_token.get(f"/api/v1/invoices/{invoice.id}") - + assert response.status_code == 200 data = response.get_json() assert "invoice" in data @@ -59,11 +58,11 @@ class TestAPIInvoicesRefactored: "client_id": client.id, "client_name": client.name, "due_date": (date.today() + timedelta(days=30)).isoformat(), - "notes": "Test invoice" + "notes": "Test invoice", }, - content_type="application/json" + content_type="application/json", ) - + assert response.status_code == 201 data = response.get_json() assert "invoice" in data @@ -77,11 +76,10 @@ class TestAPITasksRefactored: def api_token(self, app, user): """Create an API token for testing""" token, plain_token = ApiToken.create_token( - user_id=user.id, - name="Test API Token", - scopes="read:tasks,write:tasks" + user_id=user.id, name="Test API Token", scopes="read:tasks,write:tasks" ) from app import db + db.session.add(token) db.session.commit() return token, plain_token @@ -91,13 +89,13 @@ class TestAPITasksRefactored: """Create a test client with API token""" token, plain_token = api_token test_client = app.test_client() - test_client.environ_base['HTTP_AUTHORIZATION'] = f'Bearer {plain_token}' + test_client.environ_base["HTTP_AUTHORIZATION"] = f"Bearer {plain_token}" return test_client def test_list_tasks_uses_service_layer(self, app, client_with_token, task): """Test that list_tasks route uses service layer""" response = client_with_token.get("/api/v1/tasks") - + assert response.status_code == 200 data = response.get_json() assert "tasks" in data @@ -106,7 +104,7 @@ class TestAPITasksRefactored: def test_get_task_uses_eager_loading(self, app, client_with_token, task): """Test that get_task route uses eager loading""" response = client_with_token.get(f"/api/v1/tasks/{task.id}") - + assert response.status_code == 200 data = response.get_json() assert "task" in data @@ -116,14 +114,10 @@ class TestAPITasksRefactored: """Test that create_task route uses service layer""" response = client_with_token.post( "/api/v1/tasks", - json={ - "name": "API Test Task", - "project_id": project.id, - "description": "Test task description" - }, - content_type="application/json" + json={"name": "API Test Task", "project_id": project.id, "description": "Test task description"}, + content_type="application/json", ) - + assert response.status_code == 201 data = response.get_json() assert "task" in data @@ -137,11 +131,10 @@ class TestAPIExpensesRefactored: def api_token(self, app, user): """Create an API token for testing""" token, plain_token = ApiToken.create_token( - user_id=user.id, - name="Test API Token", - scopes="read:expenses,write:expenses" + user_id=user.id, name="Test API Token", scopes="read:expenses,write:expenses" ) from app import db + db.session.add(token) db.session.commit() return token, plain_token @@ -151,13 +144,13 @@ class TestAPIExpensesRefactored: """Create a test client with API token""" token, plain_token = api_token test_client = app.test_client() - test_client.environ_base['HTTP_AUTHORIZATION'] = f'Bearer {plain_token}' + test_client.environ_base["HTTP_AUTHORIZATION"] = f"Bearer {plain_token}" return test_client def test_list_expenses_uses_service_layer(self, app, client_with_token, expense): """Test that list_expenses route uses service layer""" response = client_with_token.get("/api/v1/expenses") - + assert response.status_code == 200 data = response.get_json() assert "expenses" in data @@ -166,7 +159,7 @@ class TestAPIExpensesRefactored: def test_get_expense_uses_eager_loading(self, app, client_with_token, expense): """Test that get_expense route uses eager loading""" response = client_with_token.get(f"/api/v1/expenses/{expense.id}") - + assert response.status_code == 200 data = response.get_json() assert "expense" in data @@ -182,13 +175,12 @@ class TestAPIExpensesRefactored: "amount": 100.50, "expense_date": date.today().isoformat(), "project_id": project.id, - "description": "Test expense" + "description": "Test expense", }, - content_type="application/json" + content_type="application/json", ) - + assert response.status_code == 201 data = response.get_json() assert "expense" in data assert data["expense"]["title"] == "API Test Expense" - diff --git a/tests/test_routes/test_api_v1_mileage_refactored.py b/tests/test_routes/test_api_v1_mileage_refactored.py index 55748778..bd4a70ef 100644 --- a/tests/test_routes/test_api_v1_mileage_refactored.py +++ b/tests/test_routes/test_api_v1_mileage_refactored.py @@ -15,11 +15,10 @@ class TestAPIMileageRefactored: def api_token(self, app, user): """Create an API token for testing""" token, plain_token = ApiToken.create_token( - user_id=user.id, - name="Test API Token", - scopes="read:mileage,write:mileage" + user_id=user.id, name="Test API Token", scopes="read:mileage,write:mileage" ) from app import db + db.session.add(token) db.session.commit() return token, plain_token @@ -29,13 +28,13 @@ class TestAPIMileageRefactored: """Create a test client with API token""" token, plain_token = api_token test_client = app.test_client() - test_client.environ_base['HTTP_AUTHORIZATION'] = f'Bearer {plain_token}' + test_client.environ_base["HTTP_AUTHORIZATION"] = f"Bearer {plain_token}" return test_client def test_list_mileage_uses_eager_loading(self, app, client_with_token, user, mileage): """Test that list_mileage route uses eager loading to avoid N+1""" response = client_with_token.get("/api/v1/mileage") - + assert response.status_code == 200 data = response.get_json() assert "mileage" in data @@ -44,7 +43,7 @@ class TestAPIMileageRefactored: def test_get_mileage_uses_eager_loading(self, app, client_with_token, mileage): """Test that get_mileage route uses eager loading""" response = client_with_token.get(f"/api/v1/mileage/{mileage.id}") - + assert response.status_code == 200 data = response.get_json() assert "mileage" in data @@ -62,11 +61,11 @@ class TestAPIMileageRefactored: "distance_km": 50.5, "rate_per_km": 0.50, "project_id": project.id, - "is_round_trip": False + "is_round_trip": False, }, - content_type="application/json" + content_type="application/json", ) - + assert response.status_code == 201 data = response.get_json() assert "mileage" in data @@ -76,13 +75,10 @@ class TestAPIMileageRefactored: """Test update_mileage route""" response = client_with_token.put( f"/api/v1/mileage/{mileage.id}", - json={ - "purpose": "Updated purpose", - "distance_km": 75.0 - }, - content_type="application/json" + json={"purpose": "Updated purpose", "distance_km": 75.0}, + content_type="application/json", ) - + assert response.status_code == 200 data = response.get_json() assert "mileage" in data @@ -91,13 +87,14 @@ class TestAPIMileageRefactored: def test_delete_mileage(self, app, client_with_token, mileage): """Test delete_mileage route""" response = client_with_token.delete(f"/api/v1/mileage/{mileage.id}") - + assert response.status_code == 200 data = response.get_json() assert "message" in data - + # Verify mileage was rejected from app import db + db.session.refresh(mileage) assert mileage.status == "rejected" @@ -105,12 +102,11 @@ class TestAPIMileageRefactored: """Test list_mileage with various filters""" # Filter by project response = client_with_token.get(f"/api/v1/mileage?project_id={project.id}") - + assert response.status_code == 200 data = response.get_json() assert "mileage" in data - + # All entries should belong to the project for entry in data["mileage"]: assert entry["project_id"] == project.id - diff --git a/tests/test_routes/test_api_v1_payments_refactored.py b/tests/test_routes/test_api_v1_payments_refactored.py index 55cf60ba..93e45548 100644 --- a/tests/test_routes/test_api_v1_payments_refactored.py +++ b/tests/test_routes/test_api_v1_payments_refactored.py @@ -15,11 +15,10 @@ class TestAPIPaymentsRefactored: def api_token(self, app, user): """Create an API token for testing""" token, plain_token = ApiToken.create_token( - user_id=user.id, - name="Test API Token", - scopes="read:payments,write:payments" + user_id=user.id, name="Test API Token", scopes="read:payments,write:payments" ) from app import db + db.session.add(token) db.session.commit() return token, plain_token @@ -29,13 +28,13 @@ class TestAPIPaymentsRefactored: """Create a test client with API token""" token, plain_token = api_token test_client = app.test_client() - test_client.environ_base['HTTP_AUTHORIZATION'] = f'Bearer {plain_token}' + test_client.environ_base["HTTP_AUTHORIZATION"] = f"Bearer {plain_token}" return test_client def test_list_payments_uses_eager_loading(self, app, client_with_token, payment): """Test that list_payments route uses eager loading""" response = client_with_token.get("/api/v1/payments") - + assert response.status_code == 200 data = response.get_json() assert "payments" in data @@ -44,7 +43,7 @@ class TestAPIPaymentsRefactored: def test_get_payment_uses_eager_loading(self, app, client_with_token, payment): """Test that get_payment route uses eager loading""" response = client_with_token.get(f"/api/v1/payments/{payment.id}") - + assert response.status_code == 200 data = response.get_json() assert "payment" in data @@ -60,11 +59,11 @@ class TestAPIPaymentsRefactored: "currency": "EUR", "payment_date": date.today().isoformat(), "method": "bank_transfer", - "notes": "Test payment" + "notes": "Test payment", }, - content_type="application/json" + content_type="application/json", ) - + assert response.status_code == 201 data = response.get_json() assert "payment" in data @@ -74,13 +73,10 @@ class TestAPIPaymentsRefactored: """Test that update_payment route uses service layer""" response = client_with_token.put( f"/api/v1/payments/{payment.id}", - json={ - "amount": 1500.00, - "notes": "Updated payment" - }, - content_type="application/json" + json={"amount": 1500.00, "notes": "Updated payment"}, + content_type="application/json", ) - + assert response.status_code == 200 data = response.get_json() assert "payment" in data @@ -89,8 +85,7 @@ class TestAPIPaymentsRefactored: def test_delete_payment_uses_service_layer(self, app, client_with_token, payment): """Test that delete_payment route uses service layer""" response = client_with_token.delete(f"/api/v1/payments/{payment.id}") - + assert response.status_code == 200 data = response.get_json() assert "message" in data - diff --git a/tests/test_routes/test_api_v1_projects_refactored.py b/tests/test_routes/test_api_v1_projects_refactored.py index b42c85ff..20e0185d 100644 --- a/tests/test_routes/test_api_v1_projects_refactored.py +++ b/tests/test_routes/test_api_v1_projects_refactored.py @@ -13,11 +13,10 @@ class TestAPIProjectsRefactored: def api_token(self, app, user): """Create an API token for testing""" token, plain_token = ApiToken.create_token( - user_id=user.id, - name="Test API Token", - scopes="read:projects,write:projects" + user_id=user.id, name="Test API Token", scopes="read:projects,write:projects" ) from app import db + db.session.add(token) db.session.commit() return token, plain_token @@ -27,13 +26,13 @@ class TestAPIProjectsRefactored: """Create a test client with API token""" token, plain_token = api_token test_client = app.test_client() - test_client.environ_base['HTTP_AUTHORIZATION'] = f'Bearer {plain_token}' + test_client.environ_base["HTTP_AUTHORIZATION"] = f"Bearer {plain_token}" return test_client def test_list_projects_uses_service_layer(self, app, client_with_token, project): """Test that list_projects route uses service layer""" response = client_with_token.get("/api/v1/projects") - + assert response.status_code == 200 data = response.get_json() assert "projects" in data @@ -43,7 +42,7 @@ class TestAPIProjectsRefactored: def test_get_project_uses_eager_loading(self, app, client_with_token, project): """Test that get_project route uses eager loading to avoid N+1""" response = client_with_token.get(f"/api/v1/projects/{project.id}") - + assert response.status_code == 200 data = response.get_json() assert "project" in data @@ -53,21 +52,18 @@ class TestAPIProjectsRefactored: """Test that create_project route uses service layer""" response = client_with_token.post( "/api/v1/projects", - json={ - "name": "API Test Project", - "client_id": client.id, - "billable": True - }, - content_type="application/json" + json={"name": "API Test Project", "client_id": client.id, "billable": True}, + content_type="application/json", ) - + assert response.status_code == 201 data = response.get_json() assert "project" in data assert data["project"]["name"] == "API Test Project" - + # Verify project was created from app import db + project = Project.query.filter_by(name="API Test Project").first() assert project is not None @@ -75,42 +71,39 @@ class TestAPIProjectsRefactored: """Test that update_project route uses service layer""" response = client_with_token.put( f"/api/v1/projects/{project.id}", - json={ - "name": "Updated Project Name", - "description": "Updated description" - }, - content_type="application/json" + json={"name": "Updated Project Name", "description": "Updated description"}, + content_type="application/json", ) - + assert response.status_code == 200 data = response.get_json() assert "project" in data assert data["project"]["name"] == "Updated Project Name" - + # Verify project was updated from app import db + db.session.refresh(project) assert project.name == "Updated Project Name" def test_delete_project_uses_service_layer(self, app, client_with_token, project): """Test that delete_project route uses service layer""" response = client_with_token.delete(f"/api/v1/projects/{project.id}") - + assert response.status_code == 200 data = response.get_json() assert "message" in data - + # Verify project was archived from app import db + db.session.refresh(project) assert project.status == "archived" def test_list_projects_with_filters(self, app, client_with_token, project, client): """Test list_projects with status and client filters""" - response = client_with_token.get( - f"/api/v1/projects?status=active&client_id={client.id}" - ) - + response = client_with_token.get(f"/api/v1/projects?status=active&client_id={client.id}") + assert response.status_code == 200 data = response.get_json() assert "projects" in data @@ -118,4 +111,3 @@ class TestAPIProjectsRefactored: for p in data["projects"]: assert p["status"] == "active" assert p["client_id"] == client.id - diff --git a/tests/test_routes/test_api_v1_quotes_refactored.py b/tests/test_routes/test_api_v1_quotes_refactored.py index 4792d8ec..184b890d 100644 --- a/tests/test_routes/test_api_v1_quotes_refactored.py +++ b/tests/test_routes/test_api_v1_quotes_refactored.py @@ -13,11 +13,10 @@ class TestAPIQuotesRefactored: def api_token(self, app, user): """Create an API token for testing""" token, plain_token = ApiToken.create_token( - user_id=user.id, - name="Test API Token", - scopes="read:quotes,write:quotes" + user_id=user.id, name="Test API Token", scopes="read:quotes,write:quotes" ) from app import db + db.session.add(token) db.session.commit() return token, plain_token @@ -27,13 +26,13 @@ class TestAPIQuotesRefactored: """Create a test client with API token""" token, plain_token = api_token test_client = app.test_client() - test_client.environ_base['HTTP_AUTHORIZATION'] = f'Bearer {plain_token}' + test_client.environ_base["HTTP_AUTHORIZATION"] = f"Bearer {plain_token}" return test_client def test_list_quotes_uses_service_layer(self, app, client_with_token, quote): """Test that list_quotes route uses service layer""" response = client_with_token.get("/api/v1/quotes") - + assert response.status_code == 200 data = response.get_json() assert "quotes" in data @@ -42,7 +41,7 @@ class TestAPIQuotesRefactored: def test_get_quote_uses_service_layer(self, app, client_with_token, quote): """Test that get_quote route uses service layer""" response = client_with_token.get(f"/api/v1/quotes/{quote.id}") - + assert response.status_code == 200 data = response.get_json() assert "quote" in data @@ -57,11 +56,11 @@ class TestAPIQuotesRefactored: "title": "Test Quote", "description": "Test description", "tax_rate": 21.0, - "currency_code": "EUR" + "currency_code": "EUR", }, - content_type="application/json" + content_type="application/json", ) - + assert response.status_code == 201 data = response.get_json() assert "quote" in data @@ -71,15 +70,11 @@ class TestAPIQuotesRefactored: """Test that update_quote route uses service layer""" response = client_with_token.put( f"/api/v1/quotes/{quote.id}", - json={ - "title": "Updated Quote Title", - "status": "sent" - }, - content_type="application/json" + json={"title": "Updated Quote Title", "status": "sent"}, + content_type="application/json", ) - + assert response.status_code == 200 data = response.get_json() assert "quote" in data assert data["quote"]["title"] == "Updated Quote Title" - diff --git a/tests/test_routes/test_api_v1_recurring_invoices_credit_notes.py b/tests/test_routes/test_api_v1_recurring_invoices_credit_notes.py index 142d13b5..9710337b 100644 --- a/tests/test_routes/test_api_v1_recurring_invoices_credit_notes.py +++ b/tests/test_routes/test_api_v1_recurring_invoices_credit_notes.py @@ -16,9 +16,10 @@ class TestAPIRecurringInvoicesCreditNotes: token, plain_token = ApiToken.create_token( user_id=user.id, name="Test API Token", - scopes="read:recurring_invoices,write:recurring_invoices,read:invoices,write:invoices" + scopes="read:recurring_invoices,write:recurring_invoices,read:invoices,write:invoices", ) from app import db + db.session.add(token) db.session.commit() return token, plain_token @@ -28,13 +29,13 @@ class TestAPIRecurringInvoicesCreditNotes: """Create a test client with API token""" token, plain_token = api_token test_client = app.test_client() - test_client.environ_base['HTTP_AUTHORIZATION'] = f'Bearer {plain_token}' + test_client.environ_base["HTTP_AUTHORIZATION"] = f"Bearer {plain_token}" return test_client def test_list_recurring_invoices_uses_eager_loading(self, app, client_with_token, recurring_invoice): """Test that list_recurring_invoices uses eager loading""" response = client_with_token.get("/api/v1/recurring-invoices") - + assert response.status_code == 200 data = response.get_json() assert "recurring_invoices" in data @@ -43,7 +44,7 @@ class TestAPIRecurringInvoicesCreditNotes: def test_get_recurring_invoice_uses_eager_loading(self, app, client_with_token, recurring_invoice): """Test that get_recurring_invoice uses eager loading""" response = client_with_token.get(f"/api/v1/recurring-invoices/{recurring_invoice.id}") - + assert response.status_code == 200 data = response.get_json() assert "recurring_invoice" in data @@ -52,18 +53,15 @@ class TestAPIRecurringInvoicesCreditNotes: def test_list_credit_notes_uses_eager_loading(self, app, client_with_token, invoice): """Test that list_credit_notes uses eager loading""" from app import db + credit_note = CreditNote( - invoice_id=invoice.id, - credit_number="CN-TEST-001", - amount=100.00, - reason="Test credit", - created_by=1 + invoice_id=invoice.id, credit_number="CN-TEST-001", amount=100.00, reason="Test credit", created_by=1 ) db.session.add(credit_note) db.session.commit() - + response = client_with_token.get("/api/v1/credit-notes") - + assert response.status_code == 200 data = response.get_json() assert "credit_notes" in data @@ -72,20 +70,16 @@ class TestAPIRecurringInvoicesCreditNotes: def test_get_credit_note_uses_eager_loading(self, app, client_with_token, invoice): """Test that get_credit_note uses eager loading""" from app import db + credit_note = CreditNote( - invoice_id=invoice.id, - credit_number="CN-TEST-002", - amount=50.00, - reason="Test credit", - created_by=1 + invoice_id=invoice.id, credit_number="CN-TEST-002", amount=50.00, reason="Test credit", created_by=1 ) db.session.add(credit_note) db.session.commit() - + response = client_with_token.get(f"/api/v1/credit-notes/{credit_note.id}") - + assert response.status_code == 200 data = response.get_json() assert "credit_note" in data assert data["credit_note"]["id"] == credit_note.id - diff --git a/tests/test_routes/test_api_v1_reports_refactored.py b/tests/test_routes/test_api_v1_reports_refactored.py index 000620ff..395a56ec 100644 --- a/tests/test_routes/test_api_v1_reports_refactored.py +++ b/tests/test_routes/test_api_v1_reports_refactored.py @@ -13,12 +13,9 @@ class TestAPIReportsRefactored: @pytest.fixture def api_token(self, app, user): """Create an API token for testing""" - token, plain_token = ApiToken.create_token( - user_id=user.id, - name="Test API Token", - scopes="read:reports" - ) + token, plain_token = ApiToken.create_token(user_id=user.id, name="Test API Token", scopes="read:reports") from app import db + db.session.add(token) db.session.commit() return token, plain_token @@ -28,7 +25,7 @@ class TestAPIReportsRefactored: """Create a test client with API token""" token, plain_token = api_token test_client = app.test_client() - test_client.environ_base['HTTP_AUTHORIZATION'] = f'Bearer {plain_token}' + test_client.environ_base["HTTP_AUTHORIZATION"] = f"Bearer {plain_token}" return test_client def test_report_summary_uses_eager_loading(self, app, client_with_token, user, project, time_entry): @@ -36,10 +33,11 @@ class TestAPIReportsRefactored: # Ensure entry is completed time_entry.end_time = datetime.utcnow() from app import db + db.session.commit() - + response = client_with_token.get("/api/v1/reports/summary") - + assert response.status_code == 200 data = response.get_json() assert "summary" in data or "total_hours" in data @@ -48,14 +46,14 @@ class TestAPIReportsRefactored: """Test report_summary with date and project filters""" time_entry.end_time = datetime.utcnow() from app import db + db.session.commit() - + start_date = (datetime.utcnow() - timedelta(days=7)).strftime("%Y-%m-%d") end_date = datetime.utcnow().strftime("%Y-%m-%d") - + response = client_with_token.get( f"/api/v1/reports/summary?start_date={start_date}&end_date={end_date}&project_id={project.id}" ) - - assert response.status_code == 200 + assert response.status_code == 200 diff --git a/tests/test_routes/test_api_v1_time_entries_complete.py b/tests/test_routes/test_api_v1_time_entries_complete.py index 7ba56c08..ab4f34e9 100644 --- a/tests/test_routes/test_api_v1_time_entries_complete.py +++ b/tests/test_routes/test_api_v1_time_entries_complete.py @@ -14,11 +14,10 @@ class TestAPITimeEntriesComplete: def api_token(self, app, user): """Create an API token for testing""" token, plain_token = ApiToken.create_token( - user_id=user.id, - name="Test API Token", - scopes="read:time_entries,write:time_entries" + user_id=user.id, name="Test API Token", scopes="read:time_entries,write:time_entries" ) from app import db + db.session.add(token) db.session.commit() return token, plain_token @@ -28,20 +27,17 @@ class TestAPITimeEntriesComplete: """Create a test client with API token""" token, plain_token = api_token test_client = app.test_client() - test_client.environ_base['HTTP_AUTHORIZATION'] = f'Bearer {plain_token}' + test_client.environ_base["HTTP_AUTHORIZATION"] = f"Bearer {plain_token}" return test_client def test_update_time_entry_uses_service_layer(self, app, client_with_token, time_entry): """Test that update_time_entry route uses service layer""" response = client_with_token.put( f"/api/v1/time-entries/{time_entry.id}", - json={ - "notes": "Updated notes", - "billable": False - }, - content_type="application/json" + json={"notes": "Updated notes", "billable": False}, + content_type="application/json", ) - + assert response.status_code == 200 data = response.get_json() assert "time_entry" in data @@ -52,10 +48,11 @@ class TestAPITimeEntriesComplete: # Ensure entry is not active time_entry.end_time = datetime.utcnow() from app import db + db.session.commit() - + response = client_with_token.delete(f"/api/v1/time-entries/{time_entry.id}") - + assert response.status_code == 200 data = response.get_json() assert "message" in data @@ -64,13 +61,10 @@ class TestAPITimeEntriesComplete: """Test that start_timer route uses service layer""" response = client_with_token.post( "/api/v1/timer/start", - json={ - "project_id": project.id, - "notes": "API test timer" - }, - content_type="application/json" + json={"project_id": project.id, "notes": "API test timer"}, + content_type="application/json", ) - + assert response.status_code == 201 data = response.get_json() assert "timer" in data @@ -81,17 +75,13 @@ class TestAPITimeEntriesComplete: # First start a timer from app.models import TimeEntry from app import db - timer = TimeEntry( - user_id=user.id, - project_id=project.id, - start_time=datetime.utcnow() - ) + + timer = TimeEntry(user_id=user.id, project_id=project.id, start_time=datetime.utcnow()) db.session.add(timer) db.session.commit() - + response = client_with_token.post("/api/v1/timer/stop") - + assert response.status_code == 200 data = response.get_json() assert "time_entry" in data - diff --git a/tests/test_routes/test_api_v1_time_entries_refactored.py b/tests/test_routes/test_api_v1_time_entries_refactored.py index 7d249998..f72d330d 100644 --- a/tests/test_routes/test_api_v1_time_entries_refactored.py +++ b/tests/test_routes/test_api_v1_time_entries_refactored.py @@ -14,11 +14,10 @@ class TestAPITimeEntriesRefactored: def api_token(self, app, user): """Create an API token for testing""" token, plain_token = ApiToken.create_token( - user_id=user.id, - name="Test API Token", - scopes="read:time_entries,write:time_entries" + user_id=user.id, name="Test API Token", scopes="read:time_entries,write:time_entries" ) from app import db + db.session.add(token) db.session.commit() return token, plain_token @@ -28,18 +27,18 @@ class TestAPITimeEntriesRefactored: """Create a test client with API token""" token, plain_token = api_token test_client = app.test_client() - test_client.environ_base['HTTP_AUTHORIZATION'] = f'Bearer {plain_token}' + test_client.environ_base["HTTP_AUTHORIZATION"] = f"Bearer {plain_token}" return test_client def test_list_time_entries_uses_eager_loading(self, app, client_with_token, user, project, time_entry): """Test that list_time_entries route uses eager loading to avoid N+1""" response = client_with_token.get("/api/v1/time-entries") - + assert response.status_code == 200 data = response.get_json() assert "time_entries" in data assert "pagination" in data - + # Verify entries have project data loaded (no N+1) if len(data["time_entries"]) > 0: entry = data["time_entries"][0] @@ -48,7 +47,7 @@ class TestAPITimeEntriesRefactored: def test_get_time_entry_uses_eager_loading(self, app, client_with_token, time_entry): """Test that get_time_entry route uses eager loading""" response = client_with_token.get(f"/api/v1/time-entries/{time_entry.id}") - + assert response.status_code == 200 data = response.get_json() assert "time_entry" in data @@ -58,7 +57,7 @@ class TestAPITimeEntriesRefactored: """Test that create_time_entry route uses service layer""" start_time = datetime.utcnow() - timedelta(hours=2) end_time = datetime.utcnow() - + response = client_with_token.post( "/api/v1/time-entries", json={ @@ -66,32 +65,31 @@ class TestAPITimeEntriesRefactored: "start_time": start_time.isoformat(), "end_time": end_time.isoformat(), "notes": "API test entry", - "billable": True + "billable": True, }, - content_type="application/json" + content_type="application/json", ) - + assert response.status_code == 201 data = response.get_json() assert "time_entry" in data assert data["time_entry"]["project_id"] == project.id - + # Verify entry was created from app import db + entry = TimeEntry.query.filter_by(notes="API test entry").first() assert entry is not None def test_list_time_entries_with_filters(self, app, client_with_token, user, project, time_entry): """Test list_time_entries with various filters""" # Filter by project - response = client_with_token.get( - f"/api/v1/time-entries?project_id={project.id}" - ) - + response = client_with_token.get(f"/api/v1/time-entries?project_id={project.id}") + assert response.status_code == 200 data = response.get_json() assert "time_entries" in data - + # All entries should belong to the project for entry in data["time_entries"]: assert entry["project_id"] == project.id @@ -100,24 +98,24 @@ class TestAPITimeEntriesRefactored: """Test that list_time_entries supports pagination""" # Create multiple entries from app import db + for i in range(5): entry = TimeEntry( user_id=user.id, project_id=project.id, - start_time=datetime.utcnow() - timedelta(hours=i+1), + start_time=datetime.utcnow() - timedelta(hours=i + 1), end_time=datetime.utcnow() - timedelta(hours=i), - notes=f"Test entry {i}" + notes=f"Test entry {i}", ) db.session.add(entry) db.session.commit() - + # Request first page response = client_with_token.get("/api/v1/time-entries?page=1&per_page=2") - + assert response.status_code == 200 data = response.get_json() assert "pagination" in data assert data["pagination"]["page"] == 1 assert data["pagination"]["per_page"] == 2 assert len(data["time_entries"]) <= 2 - diff --git a/tests/test_routes/test_main_dashboard_cached.py b/tests/test_routes/test_main_dashboard_cached.py index c98c974b..10bdf120 100644 --- a/tests/test_routes/test_main_dashboard_cached.py +++ b/tests/test_routes/test_main_dashboard_cached.py @@ -13,15 +13,15 @@ class TestDashboardCaching: def test_dashboard_uses_cache(self, authenticated_client, app, user, project): """Test that dashboard data is cached""" from app.utils.cache import get_cache - + cache = get_cache() cache.clear() # Clear cache before test - + # First request should populate cache - with patch('app.routes.main.track_page_view'): + with patch("app.routes.main.track_page_view"): response1 = authenticated_client.get("/dashboard") assert response1.status_code == 200 - + # Check cache was set cache_key = f"dashboard:{user.id}" cached_data = cache.get(cache_key) @@ -33,13 +33,13 @@ class TestDashboardCaching: """Test that dashboard cache has appropriate TTL""" from app.utils.cache import get_cache import time - + cache = get_cache() cache.clear() - - with patch('app.routes.main.track_page_view'): + + with patch("app.routes.main.track_page_view"): authenticated_client.get("/dashboard") - + cache_key = f"dashboard:{user.id}" # Cache should exist assert cache.exists(cache_key) is True @@ -47,22 +47,21 @@ class TestDashboardCaching: def test_dashboard_cache_invalidation(self, authenticated_client, app, user): """Test that dashboard cache can be invalidated""" from app.utils.cache import get_cache - + cache = get_cache() cache.clear() - - with patch('app.routes.main.track_page_view'): + + with patch("app.routes.main.track_page_view"): # First request authenticated_client.get("/dashboard") - + cache_key = f"dashboard:{user.id}" assert cache.exists(cache_key) is True - + # Invalidate cache cache.delete(cache_key) assert cache.exists(cache_key) is False - + # Next request should repopulate cache authenticated_client.get("/dashboard") assert cache.exists(cache_key) is True - diff --git a/tests/test_services/test_payment_service_complete.py b/tests/test_services/test_payment_service_complete.py index e922b2e8..46804687 100644 --- a/tests/test_services/test_payment_service_complete.py +++ b/tests/test_services/test_payment_service_complete.py @@ -15,14 +15,11 @@ class TestPaymentServiceComplete: def test_update_payment_success(self, app, invoice, payment): """Test successful payment update""" service = PaymentService() - + result = service.update_payment( - payment_id=payment.id, - user_id=1, - amount=Decimal("1500.00"), - notes="Updated payment" + payment_id=payment.id, user_id=1, amount=Decimal("1500.00"), notes="Updated payment" ) - + assert result["success"] is True assert result["payment"].amount == Decimal("1500.00") assert result["payment"].notes == "Updated payment" @@ -30,45 +27,37 @@ class TestPaymentServiceComplete: def test_update_payment_not_found(self, app): """Test update with non-existent payment""" service = PaymentService() - - result = service.update_payment( - payment_id=99999, - user_id=1 - ) - + + result = service.update_payment(payment_id=99999, user_id=1) + assert result["success"] is False assert result["error"] == "not_found" def test_delete_payment_success(self, app, invoice, payment): """Test successful payment deletion""" service = PaymentService() - - result = service.delete_payment( - payment_id=payment.id, - user_id=1 - ) - + + result = service.delete_payment(payment_id=payment.id, user_id=1) + assert result["success"] is True def test_delete_payment_updates_invoice_status(self, app, invoice, payment): """Test that deleting payment updates invoice payment status""" service = PaymentService() - + # Set invoice amount_paid invoice.amount_paid = payment.amount invoice.payment_status = "fully_paid" from app import db + db.session.commit() - - result = service.delete_payment( - payment_id=payment.id, - user_id=1 - ) - + + result = service.delete_payment(payment_id=payment.id, user_id=1) + assert result["success"] is True - + # Verify invoice status updated from app import db + db.session.refresh(invoice) assert invoice.payment_status == "unpaid" - diff --git a/tests/test_services/test_time_tracking_service_complete.py b/tests/test_services/test_time_tracking_service_complete.py index e8c7ece7..663d928c 100644 --- a/tests/test_services/test_time_tracking_service_complete.py +++ b/tests/test_services/test_time_tracking_service_complete.py @@ -14,15 +14,11 @@ class TestTimeTrackingServiceComplete: def test_update_entry_success(self, app, user, project, time_entry): """Test successful time entry update""" service = TimeTrackingService() - + result = service.update_entry( - entry_id=time_entry.id, - user_id=user.id, - is_admin=False, - notes="Updated notes", - billable=False + entry_id=time_entry.id, user_id=user.id, is_admin=False, notes="Updated notes", billable=False ) - + assert result["success"] is True assert result["entry"].notes == "Updated notes" assert result["entry"].billable is False @@ -30,26 +26,18 @@ class TestTimeTrackingServiceComplete: def test_update_entry_not_found(self, app, user): """Test update with non-existent entry""" service = TimeTrackingService() - - result = service.update_entry( - entry_id=99999, - user_id=user.id, - is_admin=False - ) - + + result = service.update_entry(entry_id=99999, user_id=user.id, is_admin=False) + assert result["success"] is False assert result["error"] == "not_found" def test_update_entry_access_denied(self, app, user, other_user, time_entry): """Test update with access denied""" service = TimeTrackingService() - - result = service.update_entry( - entry_id=time_entry.id, - user_id=other_user.id, - is_admin=False - ) - + + result = service.update_entry(entry_id=time_entry.id, user_id=other_user.id, is_admin=False) + assert result["success"] is False assert result["error"] == "access_denied" @@ -58,16 +46,13 @@ class TestTimeTrackingServiceComplete: # Ensure entry is not active time_entry.end_time = datetime.utcnow() from app import db + db.session.commit() - + service = TimeTrackingService() - - result = service.delete_entry( - entry_id=time_entry.id, - user_id=user.id, - is_admin=False - ) - + + result = service.delete_entry(entry_id=time_entry.id, user_id=user.id, is_admin=False) + assert result["success"] is True def test_delete_entry_active_timer(self, app, user, time_entry): @@ -75,16 +60,12 @@ class TestTimeTrackingServiceComplete: # Ensure entry is active time_entry.end_time = None from app import db + db.session.commit() - + service = TimeTrackingService() - - result = service.delete_entry( - entry_id=time_entry.id, - user_id=user.id, - is_admin=False - ) - + + result = service.delete_entry(entry_id=time_entry.id, user_id=user.id, is_admin=False) + assert result["success"] is False assert result["error"] == "timer_active" - diff --git a/tests/test_system_ui_flags.py b/tests/test_system_ui_flags.py index e66bc07b..65628931 100644 --- a/tests/test_system_ui_flags.py +++ b/tests/test_system_ui_flags.py @@ -26,6 +26,3 @@ class TestSystemUiFlags: resp = client.get(url_for("main.dashboard")) nav = resp.data.decode("utf-8") assert "Calendar" not in nav - - - diff --git a/tests/test_utils/test_api_auth_enhanced.py b/tests/test_utils/test_api_auth_enhanced.py index 8a368a1c..f0befb2b 100644 --- a/tests/test_utils/test_api_auth_enhanced.py +++ b/tests/test_utils/test_api_auth_enhanced.py @@ -17,35 +17,39 @@ class TestExtractToken: def test_extract_from_bearer_header(self): """Test extracting token from Bearer Authorization header""" from flask import Flask, request + app = Flask(__name__) - + with app.test_request_context(headers={"Authorization": "Bearer tt_testtoken123"}): token = extract_token_from_request() assert token == "tt_testtoken123" - + def test_extract_from_token_header(self): """Test extracting token from Token Authorization header""" from flask import Flask, request + app = Flask(__name__) - + with app.test_request_context(headers={"Authorization": "Token tt_testtoken123"}): token = extract_token_from_request() assert token == "tt_testtoken123" - + def test_extract_from_api_key_header(self): """Test extracting token from X-API-Key header""" from flask import Flask, request + app = Flask(__name__) - + with app.test_request_context(headers={"X-API-Key": "tt_testtoken123"}): token = extract_token_from_request() assert token == "tt_testtoken123" - + def test_extract_none_when_missing(self): """Test that None is returned when no token is present""" from flask import Flask, request + app = Flask(__name__) - + with app.test_request_context(): token = extract_token_from_request() assert token is None @@ -66,10 +70,7 @@ class TestAuthenticateToken: def sample_token(self, app, sample_user): """Create a sample API token""" token, plain_token = ApiToken.create_token( - user_id=sample_user.id, - name="Test Token", - scopes="read:projects", - expires_days=30 + user_id=sample_user.id, name="Test Token", scopes="read:projects", expires_days=30 ) db.session.add(token) db.session.commit() @@ -78,10 +79,10 @@ class TestAuthenticateToken: def test_authenticate_valid_token(self, app, sample_user, sample_token): """Test authentication with valid token""" token, plain_token = sample_token - + with app.test_request_context(remote_addr="127.0.0.1"): user, api_token, error = authenticate_token(plain_token) - + assert user is not None assert api_token is not None assert error is None @@ -91,17 +92,15 @@ class TestAuthenticateToken: def test_authenticate_expired_token(self, app, sample_user): """Test authentication with expired token""" token, plain_token = ApiToken.create_token( - user_id=sample_user.id, - name="Expired Token", - expires_days=-1 # Expired + user_id=sample_user.id, name="Expired Token", expires_days=-1 # Expired ) token.expires_at = datetime.utcnow() - timedelta(days=1) db.session.add(token) db.session.commit() - + with app.test_request_context(remote_addr="127.0.0.1"): user, api_token, error = authenticate_token(plain_token) - + assert user is None assert api_token is None assert error == "Token has expired" @@ -111,10 +110,10 @@ class TestAuthenticateToken: token, plain_token = sample_token token.is_active = False db.session.commit() - + with app.test_request_context(remote_addr="127.0.0.1"): user, api_token, error = authenticate_token(plain_token) - + assert user is None assert api_token is None assert error == "Token has been revoked" @@ -122,17 +121,15 @@ class TestAuthenticateToken: def test_authenticate_with_ip_whitelist_allowed(self, app, sample_user): """Test authentication with IP whitelist - allowed IP""" token, plain_token = ApiToken.create_token( - user_id=sample_user.id, - name="Whitelisted Token", - scopes="read:projects" + user_id=sample_user.id, name="Whitelisted Token", scopes="read:projects" ) token.ip_whitelist = "127.0.0.1,192.168.1.0/24" db.session.add(token) db.session.commit() - + with app.test_request_context(remote_addr="127.0.0.1"): user, api_token, error = authenticate_token(plain_token) - + assert user is not None assert api_token is not None assert error is None @@ -140,35 +137,29 @@ class TestAuthenticateToken: def test_authenticate_with_ip_whitelist_denied(self, app, sample_user): """Test authentication with IP whitelist - denied IP""" token, plain_token = ApiToken.create_token( - user_id=sample_user.id, - name="Whitelisted Token", - scopes="read:projects" + user_id=sample_user.id, name="Whitelisted Token", scopes="read:projects" ) token.ip_whitelist = "192.168.1.0/24" db.session.add(token) db.session.commit() - + with app.test_request_context(remote_addr="10.0.0.1"): user, api_token, error = authenticate_token(plain_token) - + assert user is None assert api_token is None assert error == "Access denied from this IP address" def test_authenticate_with_cidr_block(self, app, sample_user): """Test authentication with CIDR block in whitelist""" - token, plain_token = ApiToken.create_token( - user_id=sample_user.id, - name="CIDR Token", - scopes="read:projects" - ) + token, plain_token = ApiToken.create_token(user_id=sample_user.id, name="CIDR Token", scopes="read:projects") token.ip_whitelist = "192.168.1.0/24" db.session.add(token) db.session.commit() - + with app.test_request_context(remote_addr="192.168.1.100"): user, api_token, error = authenticate_token(plain_token) - + assert user is not None assert api_token is not None assert error is None @@ -177,7 +168,7 @@ class TestAuthenticateToken: """Test authentication with invalid token format""" with app.test_request_context(remote_addr="127.0.0.1"): user, api_token, error = authenticate_token("invalid_token") - + assert user is None assert api_token is None assert error == "Invalid token format" @@ -185,10 +176,10 @@ class TestAuthenticateToken: def test_authenticate_nonexistent_token(self, app): """Test authentication with non-existent token""" fake_token = "tt_" + "x" * 32 - + with app.test_request_context(remote_addr="127.0.0.1"): user, api_token, error = authenticate_token(fake_token) - + assert user is None assert api_token is None assert error == "Token not found" @@ -198,10 +189,10 @@ class TestAuthenticateToken: token, plain_token = sample_token token.user.is_active = False db.session.commit() - + with app.test_request_context(remote_addr="127.0.0.1"): user, api_token, error = authenticate_token(plain_token) - + assert user is None assert api_token is None assert error == "User account is inactive" @@ -213,28 +204,26 @@ class TestRequireApiToken: @pytest.fixture def app_with_routes(self, app): """Create Flask app with test routes""" + @app.route("/test/protected") @require_api_token("read:projects") def protected_route(): return {"message": "success", "user_id": g.api_user.id} - + @app.route("/test/protected_no_scope") @require_api_token() def protected_route_no_scope(): return {"message": "success"} - + return app def test_protected_route_with_valid_token(self, app_with_routes, sample_user, sample_token): """Test accessing protected route with valid token""" token, plain_token = sample_token - + with app_with_routes.test_client() as client: - response = client.get( - "/test/protected", - headers={"Authorization": f"Bearer {plain_token}"} - ) - + response = client.get("/test/protected", headers={"Authorization": f"Bearer {plain_token}"}) + assert response.status_code == 200 data = response.get_json() assert data["message"] == "success" @@ -244,7 +233,7 @@ class TestRequireApiToken: """Test accessing protected route without token""" with app_with_routes.test_client() as client: response = client.get("/test/protected") - + assert response.status_code == 401 data = response.get_json() assert "error" in data @@ -253,19 +242,14 @@ class TestRequireApiToken: def test_protected_route_with_insufficient_scope(self, app_with_routes, sample_user): """Test accessing protected route with insufficient scope""" token, plain_token = ApiToken.create_token( - user_id=sample_user.id, - name="Limited Token", - scopes="read:time_entries" # Different scope + user_id=sample_user.id, name="Limited Token", scopes="read:time_entries" # Different scope ) db.session.add(token) db.session.commit() - + with app_with_routes.test_client() as client: - response = client.get( - "/test/protected", - headers={"Authorization": f"Bearer {plain_token}"} - ) - + response = client.get("/test/protected", headers={"Authorization": f"Bearer {plain_token}"}) + assert response.status_code == 403 data = response.get_json() assert "error" in data @@ -274,20 +258,14 @@ class TestRequireApiToken: def test_protected_route_with_wildcard_scope(self, app_with_routes, sample_user): """Test accessing protected route with wildcard scope""" token, plain_token = ApiToken.create_token( - user_id=sample_user.id, - name="Admin Token", - scopes="read:*" # Wildcard scope + user_id=sample_user.id, name="Admin Token", scopes="read:*" # Wildcard scope ) db.session.add(token) db.session.commit() - + with app_with_routes.test_client() as client: - response = client.get( - "/test/protected", - headers={"Authorization": f"Bearer {plain_token}"} - ) - + response = client.get("/test/protected", headers={"Authorization": f"Bearer {plain_token}"}) + assert response.status_code == 200 data = response.get_json() assert data["message"] == "success" - diff --git a/tests/test_utils/test_cache.py b/tests/test_utils/test_cache.py index 1fd9017d..d820ff45 100644 --- a/tests/test_utils/test_cache.py +++ b/tests/test_utils/test_cache.py @@ -13,42 +13,43 @@ class TestInMemoryCache: def test_get_set_delete(self): """Test basic cache operations""" cache = InMemoryCache() - + # Test set and get cache.set("test_key", "test_value", ttl=3600) assert cache.get("test_key") == "test_value" - + # Test delete cache.delete("test_key") assert cache.get("test_key") is None - + def test_expiration(self): """Test that expired entries are not returned""" cache = InMemoryCache(default_ttl=1) - + cache.set("expired_key", "value", ttl=0.1) # Very short TTL assert cache.get("expired_key") == "value" - + import time + time.sleep(0.2) assert cache.get("expired_key") is None - + def test_exists(self): """Test exists method""" cache = InMemoryCache() - + assert cache.exists("nonexistent") is False cache.set("existing", "value") assert cache.exists("existing") is True - + def test_clear(self): """Test clearing all cache""" cache = InMemoryCache() - + cache.set("key1", "value1") cache.set("key2", "value2") cache.clear() - + assert cache.get("key1") is None assert cache.get("key2") is None @@ -56,39 +57,39 @@ class TestInMemoryCache: class TestRedisCache: """Tests for Redis cache implementation""" - @patch('app.utils.cache.redis') + @patch("app.utils.cache.redis") def test_redis_connection_success(self, mock_redis): """Test successful Redis connection""" mock_client = MagicMock() mock_client.ping.return_value = True mock_redis.Redis.return_value = mock_client - + cache = RedisCache("redis://localhost:6379/0") - + assert cache._connected is True mock_client.ping.assert_called_once() - - @patch('app.utils.cache.redis') + + @patch("app.utils.cache.redis") def test_redis_connection_failure(self, mock_redis): """Test Redis connection failure falls back to in-memory""" mock_redis.Redis.side_effect = Exception("Connection failed") - + cache = RedisCache("redis://localhost:6379/0") - + assert cache._connected is False - assert hasattr(cache, '_fallback') - - @patch('app.utils.cache.redis') + assert hasattr(cache, "_fallback") + + @patch("app.utils.cache.redis") def test_redis_get_set(self, mock_redis): """Test Redis get and set operations""" import pickle - + mock_client = MagicMock() mock_client.ping.return_value = True mock_redis.Redis.return_value = mock_client - + cache = RedisCache("redis://localhost:6379/0") - + # Test set cache.set("test_key", "test_value", ttl=3600) mock_client.setex.assert_called_once() @@ -96,23 +97,23 @@ class TestRedisCache: assert args[0] == "test_key" assert args[1] == 3600 assert pickle.loads(args[2]) == "test_value" - + # Test get mock_client.get.return_value = pickle.dumps("test_value") result = cache.get("test_key") assert result == "test_value" mock_client.get.assert_called_with("test_key") - - @patch('app.utils.cache.redis') + + @patch("app.utils.cache.redis") def test_redis_fallback_on_error(self, mock_redis): """Test that Redis errors fall back to in-memory cache""" mock_client = MagicMock() mock_client.ping.return_value = True mock_client.get.side_effect = Exception("Redis error") mock_redis.Redis.return_value = mock_client - + cache = RedisCache("redis://localhost:6379/0") - + # Should not raise, but return None result = cache.get("test_key") assert result is None @@ -121,63 +122,56 @@ class TestRedisCache: class TestCacheIntegration: """Integration tests for cache utilities""" - @patch('app.utils.cache.current_app') + @patch("app.utils.cache.current_app") def test_get_cache_with_redis_enabled(self, mock_app): """Test get_cache when Redis is enabled""" - mock_config = { - 'REDIS_ENABLED': True, - 'REDIS_URL': 'redis://localhost:6379/0', - 'REDIS_DEFAULT_TTL': 3600 - } + mock_config = {"REDIS_ENABLED": True, "REDIS_URL": "redis://localhost:6379/0", "REDIS_DEFAULT_TTL": 3600} mock_app.config = mock_config - - with patch('app.utils.cache.RedisCache') as mock_redis_cache: + + with patch("app.utils.cache.RedisCache") as mock_redis_cache: mock_instance = MagicMock() mock_instance._connected = True mock_redis_cache.return_value = mock_instance - + cache = get_cache() assert cache is not None - - @patch('app.utils.cache.current_app') + + @patch("app.utils.cache.current_app") def test_get_cache_fallback_to_memory(self, mock_app): """Test get_cache falls back to in-memory when Redis unavailable""" - mock_config = { - 'REDIS_ENABLED': False, - 'REDIS_DEFAULT_TTL': 3600 - } + mock_config = {"REDIS_ENABLED": False, "REDIS_DEFAULT_TTL": 3600} mock_app.config = mock_config - + # Reset global cache import app.utils.cache + app.utils.cache._cache = None - + cache = get_cache() assert isinstance(cache, InMemoryCache) - + def test_cache_decorator(self): """Test the @cached decorator""" from app.utils.cache import cached - + call_count = [0] - + @cached(ttl=60, key_prefix="test") def expensive_function(x, y): call_count[0] += 1 return x + y - + # First call should execute function result1 = expensive_function(1, 2) assert result1 == 3 assert call_count[0] == 1 - + # Second call should use cache result2 = expensive_function(1, 2) assert result2 == 3 assert call_count[0] == 1 # Should not increment - + # Different args should execute again result3 = expensive_function(2, 3) assert result3 == 5 assert call_count[0] == 2 -