diff --git a/app/routes/clients.py b/app/routes/clients.py index e42e6cf..58c2e38 100644 --- a/app/routes/clients.py +++ b/app/routes/clients.py @@ -1,4 +1,4 @@ -from flask import Blueprint, render_template, request, redirect, url_for, flash, current_app, jsonify +from flask import Blueprint, render_template, request, redirect, url_for, flash, current_app, jsonify, Response from flask_babel import gettext as _ from flask_login import login_required, current_user import app as app_module @@ -8,6 +8,8 @@ from datetime import datetime from decimal import Decimal from app.utils.db import safe_commit from app.utils.permissions import admin_or_permission_required +import csv +import io clients_bp = Blueprint('clients', __name__) @@ -431,6 +433,82 @@ def bulk_status_change(): return redirect(url_for('clients.list_clients')) +@clients_bp.route('/clients/export') +@login_required +def export_clients(): + """Export clients to CSV""" + status = request.args.get('status', 'active') + search = request.args.get('search', '').strip() + + query = Client.query + if status == 'active': + query = query.filter_by(status='active') + elif status == 'inactive': + query = query.filter_by(status='inactive') + + if search: + like = f"%{search}%" + query = query.filter( + db.or_( + Client.name.ilike(like), + Client.description.ilike(like), + Client.contact_person.ilike(like), + Client.email.ilike(like) + ) + ) + + clients = query.order_by(Client.name).all() + + # Create CSV in memory + output = io.StringIO() + writer = csv.writer(output) + + # Write header + writer.writerow([ + 'ID', + 'Name', + 'Description', + 'Contact Person', + 'Email', + 'Phone', + 'Address', + 'Default Hourly Rate', + 'Status', + 'Active Projects', + 'Total Projects', + 'Created At', + 'Updated At' + ]) + + # Write client data + for client in clients: + writer.writerow([ + client.id, + client.name, + client.description or '', + client.contact_person or '', + client.email or '', + client.phone or '', + client.address or '', + client.default_hourly_rate or '', + client.status, + client.active_projects, + client.total_projects, + client.created_at.strftime('%Y-%m-%d %H:%M:%S') if client.created_at else '', + client.updated_at.strftime('%Y-%m-%d %H:%M:%S') if client.updated_at else '' + ]) + + # Create response + output.seek(0) + return Response( + output.getvalue(), + mimetype='text/csv', + headers={ + 'Content-Disposition': f'attachment; filename=clients_export_{datetime.now().strftime("%Y%m%d_%H%M%S")}.csv' + } + ) + + @clients_bp.route('/api/clients') @login_required def api_clients(): diff --git a/app/routes/projects.py b/app/routes/projects.py index a154c32..8d2ad71 100644 --- a/app/routes/projects.py +++ b/app/routes/projects.py @@ -1,4 +1,4 @@ -from flask import Blueprint, render_template, request, redirect, url_for, flash, current_app, jsonify, make_response +from flask import Blueprint, render_template, request, redirect, url_for, flash, current_app, jsonify, make_response, Response from flask_babel import gettext as _ from flask_login import login_required, current_user from app import db, log_event, track_event @@ -7,6 +7,8 @@ from datetime import datetime from decimal import Decimal from app.utils.db import safe_commit from app.utils.permissions import admin_or_permission_required, permission_required +import csv +import io from app.utils.posthog_funnels import ( track_onboarding_first_project, track_project_setup_started, @@ -87,6 +89,100 @@ def list_projects(): favorites_only=favorites_only ) +@projects_bp.route('/projects/export') +@login_required +def export_projects(): + """Export projects to CSV""" + status = request.args.get('status', 'active') + client_name = request.args.get('client', '').strip() + search = request.args.get('search', '').strip() + favorites_only = request.args.get('favorites', '').lower() == 'true' + + query = Project.query + + # Filter by favorites if requested + if favorites_only: + query = query.join( + UserFavoriteProject, + db.and_( + UserFavoriteProject.project_id == Project.id, + UserFavoriteProject.user_id == current_user.id + ) + ) + + # Filter by status + if status == 'active': + query = query.filter(Project.status == 'active') + elif status == 'archived': + query = query.filter(Project.status == 'archived') + elif status == 'inactive': + query = query.filter(Project.status == 'inactive') + + if client_name: + query = query.join(Client).filter(Client.name == client_name) + + if search: + like = f"%{search}%" + query = query.filter( + db.or_( + Project.name.ilike(like), + Project.description.ilike(like) + ) + ) + + projects = query.order_by(Project.name).all() + + # Create CSV in memory + output = io.StringIO() + writer = csv.writer(output) + + # Write header + writer.writerow([ + 'ID', + 'Name', + 'Code', + 'Client', + 'Description', + 'Status', + 'Billable', + 'Hourly Rate', + 'Budget Amount', + 'Budget Threshold %', + 'Estimated Hours', + 'Billing Reference', + 'Created At', + 'Updated At' + ]) + + # Write project data + for project in projects: + writer.writerow([ + project.id, + project.name, + project.code or '', + project.client if project.client else '', + project.description or '', + project.status, + 'Yes' if project.billable else 'No', + project.hourly_rate or '', + project.budget_amount or '', + project.budget_threshold_percent or '', + project.estimated_hours or '', + project.billing_ref or '', + project.created_at.strftime('%Y-%m-%d %H:%M:%S') if project.created_at else '', + project.updated_at.strftime('%Y-%m-%d %H:%M:%S') if hasattr(project, 'updated_at') and project.updated_at else '' + ]) + + # Create response + output.seek(0) + return Response( + output.getvalue(), + mimetype='text/csv', + headers={ + 'Content-Disposition': f'attachment; filename=projects_export_{datetime.now().strftime("%Y%m%d_%H%M%S")}.csv' + } + ) + @projects_bp.route('/projects/create', methods=['GET', 'POST']) @login_required @admin_or_permission_required('create_projects') diff --git a/app/routes/tasks.py b/app/routes/tasks.py index 4227118..ef5a08a 100644 --- a/app/routes/tasks.py +++ b/app/routes/tasks.py @@ -1,4 +1,4 @@ -from flask import Blueprint, render_template, request, redirect, url_for, flash, jsonify, make_response +from flask import Blueprint, render_template, request, redirect, url_for, flash, jsonify, make_response, Response from flask_babel import gettext as _ from flask_login import login_required, current_user import app as app_module @@ -8,6 +8,8 @@ from datetime import datetime, date from decimal import Decimal from app.utils.db import safe_commit from app.utils.timezone import now_in_app_timezone +import csv +import io tasks_bp = Blueprint('tasks', __name__) @@ -772,6 +774,181 @@ def bulk_assign_tasks(): return redirect(url_for('tasks.list_tasks')) +@tasks_bp.route('/tasks/bulk-move-project', methods=['POST']) +@login_required +def bulk_move_project(): + """Move multiple tasks to a different project""" + task_ids = request.form.getlist('task_ids[]') + new_project_id = request.form.get('project_id', type=int) + + if not task_ids: + flash('No tasks selected', 'warning') + return redirect(url_for('tasks.list_tasks')) + + if not new_project_id: + flash('No project selected', 'error') + return redirect(url_for('tasks.list_tasks')) + + # Verify project exists and is active + new_project = Project.query.filter_by(id=new_project_id, status='active').first() + if not new_project: + flash('Invalid project selected', 'error') + return redirect(url_for('tasks.list_tasks')) + + updated_count = 0 + skipped_count = 0 + + for task_id_str in task_ids: + try: + task_id = int(task_id_str) + task = Task.query.get(task_id) + + if not task: + continue + + # Check permissions + if not current_user.is_admin and task.created_by != current_user.id: + skipped_count += 1 + continue + + # Update task project + old_project_id = task.project_id + task.project_id = new_project_id + + # Update related time entries to match the new project + for entry in task.time_entries.all(): + entry.project_id = new_project_id + + # Log activity + db.session.add(TaskActivity( + task_id=task.id, + user_id=current_user.id, + event='project_change', + details=f"Project changed from {old_project_id} to {new_project_id}" + )) + + updated_count += 1 + + except Exception: + skipped_count += 1 + + if updated_count > 0: + if not safe_commit('bulk_move_project', {'count': updated_count, 'project_id': new_project_id}): + flash('Could not move tasks due to a database error', 'error') + return redirect(url_for('tasks.list_tasks')) + + flash(f'Successfully moved {updated_count} task{"s" if updated_count != 1 else ""} to {new_project.name}', 'success') + + if skipped_count > 0: + flash(f'Skipped {skipped_count} task{"s" if skipped_count != 1 else ""} (no permission)', 'warning') + + return redirect(url_for('tasks.list_tasks')) + + +@tasks_bp.route('/tasks/export') +@login_required +def export_tasks(): + """Export tasks to CSV""" + # Get the same filters as the list view + status = request.args.get('status', '') + priority = request.args.get('priority', '') + project_id = request.args.get('project_id', type=int) + assigned_to = request.args.get('assigned_to', type=int) + search = request.args.get('search', '').strip() + overdue_param = request.args.get('overdue', '').strip().lower() + overdue = overdue_param in ['1', 'true', 'on', 'yes'] + + query = Task.query + + # Apply filters (same as list_tasks) + if status: + query = query.filter_by(status=status) + + if priority: + query = query.filter_by(priority=priority) + + if project_id: + query = query.filter_by(project_id=project_id) + + if assigned_to: + query = query.filter_by(assigned_to=assigned_to) + + if search: + like = f"%{search}%" + query = query.filter( + db.or_( + Task.name.ilike(like), + Task.description.ilike(like) + ) + ) + + # Overdue filter + if overdue: + today_local = now_in_app_timezone().date() + query = query.filter( + Task.due_date < today_local, + Task.status.in_(['todo', 'in_progress', 'review']) + ) + + # Show user's tasks first, then others + if not current_user.is_admin: + query = query.filter( + db.or_( + Task.assigned_to == current_user.id, + Task.created_by == current_user.id + ) + ) + + tasks = query.order_by(Task.priority.desc(), Task.due_date.asc(), Task.created_at.asc()).all() + + # Create CSV in memory + output = io.StringIO() + writer = csv.writer(output) + + # Write header + writer.writerow([ + 'ID', + 'Name', + 'Description', + 'Project', + 'Status', + 'Priority', + 'Assigned To', + 'Created By', + 'Due Date', + 'Estimated Hours', + 'Created At', + 'Updated At' + ]) + + # Write task data + for task in tasks: + writer.writerow([ + task.id, + task.name, + task.description or '', + task.project.name if task.project else '', + task.status, + task.priority, + task.assigned_user.display_name if task.assigned_user else '', + task.creator.display_name if task.creator else '', + task.due_date.strftime('%Y-%m-%d') if task.due_date else '', + task.estimated_hours or '', + task.created_at.strftime('%Y-%m-%d %H:%M:%S') if task.created_at else '', + task.updated_at.strftime('%Y-%m-%d %H:%M:%S') if task.updated_at else '' + ]) + + # Create response + output.seek(0) + return Response( + output.getvalue(), + mimetype='text/csv', + headers={ + 'Content-Disposition': f'attachment; filename=tasks_export_{datetime.now().strftime("%Y%m%d_%H%M%S")}.csv' + } + ) + + @tasks_bp.route('/tasks/my-tasks') @login_required def my_tasks(): diff --git a/app/templates/clients/list.html b/app/templates/clients/list.html index 95db611..63fd452 100644 --- a/app/templates/clients/list.html +++ b/app/templates/clients/list.html @@ -32,24 +32,29 @@ -