From 4e57f08c03e63afbab85869b1a1f3295373b18fe Mon Sep 17 00:00:00 2001 From: Dries Peeters Date: Wed, 3 Dec 2025 08:59:48 +0100 Subject: [PATCH] Add salesman-based report splitting and email distribution - Add SalesmanEmailMapping model to map salesman initials to email addresses - Support for direct email addresses, email patterns, and domain-based patterns - Admin interface for managing email mappings - Add UnpaidHoursService for querying and grouping unpaid time entries - Filter by client custom fields (e.g., salesman) - Group unpaid hours by salesman for report generation - Add salesman report routes and API endpoints - CRUD operations for email mappings - Generate and send reports split by salesman - Preview email addresses for salesman initials - Enhance scheduled reports with salesman splitting - Add split_by_salesman and salesman_field_name to report schedules - Automatically split reports by salesman and send to mapped emails - Add UI components for salesman report management - Admin dashboard integration - Report builder with salesman splitting options - Email mapping management interface - Add email template for unpaid hours reports - Add database migrations: - 087: Create salesman_email_mappings table - 088: Add salesman splitting fields to report_email_schedules --- app/__init__.py | 11 +- app/models/__init__.py | 2 + app/models/reporting.py | 2 + app/models/salesman_email_mapping.py | 80 ++++ app/routes/custom_reports.py | 155 +++++-- app/routes/salesman_reports.py | 339 +++++++++++++++ app/services/scheduled_report_service.py | 122 +++++- app/services/unpaid_hours_service.py | 283 ++++++++++++ app/templates/admin/dashboard.html | 5 + .../admin/salesman_email_mappings.html | 403 ++++++++++++++++++ app/templates/email/unpaid_hours_report.html | 189 ++++++++ app/templates/reports/builder.html | 102 ++++- app/utils/scheduled_tasks.py | 112 +++++ .../087_add_salesman_email_mapping.py | 44 ++ .../088_add_salesman_splitting_to_reports.py | 36 ++ 15 files changed, 1842 insertions(+), 43 deletions(-) create mode 100644 app/models/salesman_email_mapping.py create mode 100644 app/routes/salesman_reports.py create mode 100644 app/services/unpaid_hours_service.py create mode 100644 app/templates/admin/salesman_email_mappings.html create mode 100644 app/templates/email/unpaid_hours_report.html create mode 100644 migrations/versions/087_add_salesman_email_mapping.py create mode 100644 migrations/versions/088_add_salesman_splitting_to_reports.py diff --git a/app/__init__.py b/app/__init__.py index d996b77..2bd763d 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -987,6 +987,8 @@ def create_app(config=None): from app.routes.kiosk import kiosk_bp from app.routes.link_templates import link_templates_bp from app.routes.custom_field_definitions import custom_field_definitions_bp + from app.routes.custom_reports import custom_reports_bp + from app.routes.salesman_reports import salesman_reports_bp try: from app.routes.audit_logs import audit_logs_bp @@ -1040,6 +1042,8 @@ def create_app(config=None): app.register_blueprint(leads_bp) app.register_blueprint(link_templates_bp) app.register_blueprint(custom_field_definitions_bp) + app.register_blueprint(custom_reports_bp) + app.register_blueprint(salesman_reports_bp) # audit_logs_bp is registered above with error handling # Register integration connectors @@ -1094,12 +1098,7 @@ def create_app(config=None): except Exception as e: logger.warning(f"Could not register push_notifications blueprint: {e}") - try: - from app.routes.custom_reports import custom_reports_bp - - app.register_blueprint(custom_reports_bp) - except Exception as e: - logger.warning(f"Could not register custom_reports blueprint: {e}") + # custom_reports_bp is already registered above (line 1045) try: from app.routes.gantt import gantt_bp diff --git a/app/models/__init__.py b/app/models/__init__.py index c949c8c..21acac1 100644 --- a/app/models/__init__.py +++ b/app/models/__init__.py @@ -76,6 +76,7 @@ from .gamification import Badge, UserBadge, Leaderboard, LeaderboardEntry from .expense_gps import MileageTrack from .link_template import LinkTemplate from .custom_field_definition import CustomFieldDefinition +from .salesman_email_mapping import SalesmanEmailMapping __all__ = [ "User", @@ -178,4 +179,5 @@ __all__ = [ "MileageTrack", "LinkTemplate", "CustomFieldDefinition", + "SalesmanEmailMapping", ] diff --git a/app/models/reporting.py b/app/models/reporting.py index 62af845..f64fa9b 100644 --- a/app/models/reporting.py +++ b/app/models/reporting.py @@ -33,6 +33,8 @@ class ReportEmailSchedule(db.Model): next_run_at = db.Column(db.DateTime, nullable=True) last_run_at = db.Column(db.DateTime, nullable=True) active = db.Column(db.Boolean, default=True, nullable=False) + split_by_salesman = db.Column(db.Boolean, default=False, nullable=False) # Split report by salesman + salesman_field_name = db.Column(db.String(50), nullable=True) # Custom field name for salesman (default: 'salesman') created_by = db.Column(db.Integer, db.ForeignKey("users.id"), 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) diff --git a/app/models/salesman_email_mapping.py b/app/models/salesman_email_mapping.py new file mode 100644 index 0000000..1f47ecf --- /dev/null +++ b/app/models/salesman_email_mapping.py @@ -0,0 +1,80 @@ +""" +Salesman Email Mapping Model + +Maps salesman initials (from client custom fields) to email addresses +for automated report distribution. +""" +from datetime import datetime +from app import db + + +class SalesmanEmailMapping(db.Model): + """Maps salesman initials to email addresses for report distribution""" + + __tablename__ = "salesman_email_mappings" + + id = db.Column(db.Integer, primary_key=True) + salesman_initial = db.Column(db.String(20), nullable=False, unique=True, index=True) + email_address = db.Column(db.String(255), nullable=True) # Direct email address + email_pattern = db.Column(db.String(255), nullable=True) # Pattern like '{value}@test.de' + domain = db.Column(db.String(255), nullable=True) # Domain for pattern-based emails + is_active = db.Column(db.Boolean, default=True, nullable=False, index=True) + 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) + + def __init__(self, salesman_initial, email_address=None, email_pattern=None, domain=None, notes=None): + """Create a salesman email mapping""" + self.salesman_initial = salesman_initial.strip().upper() + self.email_address = email_address.strip() if email_address else None + self.email_pattern = email_pattern.strip() if email_pattern else None + self.domain = domain.strip() if domain else None + self.notes = notes.strip() if notes else None + + def __repr__(self): + return f" {self.get_email()}>" + + def get_email(self): + """Get the email address for this salesman initial""" + if self.email_address: + return self.email_address + elif self.email_pattern and self.salesman_initial: + # Replace {value} with the salesman initial + return self.email_pattern.replace("{value}", self.salesman_initial) + elif self.domain and self.salesman_initial: + # Default pattern: {initial}@domain + return f"{self.salesman_initial}@{self.domain}" + return None + + def to_dict(self): + """Convert to dictionary""" + return { + "id": self.id, + "salesman_initial": self.salesman_initial, + "email_address": self.email_address, + "email_pattern": self.email_pattern, + "domain": self.domain, + "is_active": self.is_active, + "notes": self.notes, + "resolved_email": self.get_email(), + "created_at": self.created_at.isoformat() if self.created_at else None, + "updated_at": self.updated_at.isoformat() if self.updated_at else None, + } + + @classmethod + def get_email_for_initial(cls, initial): + """Get email address for a salesman initial""" + if not initial: + return None + + initial = initial.strip().upper() + mapping = cls.query.filter_by(salesman_initial=initial, is_active=True).first() + if mapping: + return mapping.get_email() + return None + + @classmethod + def get_all_active(cls): + """Get all active mappings""" + return cls.query.filter_by(is_active=True).order_by(cls.salesman_initial).all() + diff --git a/app/routes/custom_reports.py b/app/routes/custom_reports.py index d4254df..5cc4a84 100644 --- a/app/routes/custom_reports.py +++ b/app/routes/custom_reports.py @@ -6,8 +6,9 @@ from flask import Blueprint, render_template, request, redirect, url_for, flash, from flask_babel import gettext as _ from flask_login import login_required, current_user from app import db -from app.models import SavedReportView, TimeEntry, Project, Task, User +from app.models import SavedReportView, TimeEntry, Project, Task, User, Client from app.utils.db import safe_commit +from app.services.unpaid_hours_service import UnpaidHoursService import json from datetime import datetime, timedelta @@ -29,7 +30,21 @@ def report_builder(): {"id": "expenses", "name": "Expenses", "icon": "receipt"}, ] - return render_template("reports/builder.html", saved_views=saved_views, data_sources=data_sources) + # Get available clients for custom field filtering + clients = Client.query.filter_by(status="active").order_by(Client.name).all() + + # Extract unique custom field keys from clients + custom_field_keys = set() + for client in clients: + if client.custom_fields: + custom_field_keys.update(client.custom_fields.keys()) + + return render_template( + "reports/builder.html", + saved_views=saved_views, + data_sources=data_sources, + custom_field_keys=sorted(list(custom_field_keys)), + ) @custom_reports_bp.route("/reports/builder/save", methods=["POST"]) @@ -177,43 +192,115 @@ def generate_report_data(config, user_id=None): # Generate data based on source if data_source == "time_entries": - query = TimeEntry.query.filter( - TimeEntry.end_time.isnot(None), TimeEntry.start_time >= start_dt, TimeEntry.start_time <= end_dt - ) + # Check if unpaid hours filter is enabled + unpaid_only = filters.get("unpaid_only", False) + custom_field_filter = filters.get("custom_field_filter") # e.g., {"salesman": "MM"} - # Filter by user if not admin or if user_id is specified - if user_id: - user = User.query.get(user_id) - if not user or not user.is_admin: - query = query.filter(TimeEntry.user_id == user_id) + if unpaid_only: + # Use unpaid hours service + unpaid_service = UnpaidHoursService() + entries = unpaid_service.get_unpaid_time_entries( + start_date=start_dt, + end_date=end_dt, + project_id=filters.get("project_id"), + client_id=filters.get("client_id"), + user_id=filters.get("user_id") or user_id, + custom_field_filter=custom_field_filter, + ) + else: + # Standard query + query = TimeEntry.query.filter( + TimeEntry.end_time.isnot(None), TimeEntry.start_time >= start_dt, TimeEntry.start_time <= end_dt + ) - project_id = filters.get("project_id") - if project_id: - # Convert to int if it's a string - try: - project_id = int(project_id) if isinstance(project_id, str) else project_id - query = query.filter(TimeEntry.project_id == project_id) - except (ValueError, TypeError): - from flask import current_app - current_app.logger.warning(f"Invalid project_id: {project_id}, ignoring filter") - if filters.get("user_id"): - query = query.filter(TimeEntry.user_id == filters["user_id"]) + # Filter by user if not admin or if user_id is specified + if user_id: + user = User.query.get(user_id) + if not user or not user.is_admin: + query = query.filter(TimeEntry.user_id == user_id) - entries = query.all() + project_id = filters.get("project_id") + if project_id: + # Convert to int if it's a string + try: + project_id = int(project_id) if isinstance(project_id, str) else project_id + query = query.filter(TimeEntry.project_id == project_id) + except (ValueError, TypeError): + from flask import current_app + current_app.logger.warning(f"Invalid project_id: {project_id}, ignoring filter") + + if filters.get("user_id"): + query = query.filter(TimeEntry.user_id == filters["user_id"]) + + # Apply custom field filter if provided + if custom_field_filter: + # Get all entries first, then filter by custom fields + all_entries = query.all() + entries = [] + for entry in all_entries: + client = None + if entry.project and entry.project.client: + client = entry.project.client + elif entry.client: + client = entry.client + + if client and client.custom_fields: + matches = True + for field_name, field_value in custom_field_filter.items(): + client_value = client.custom_fields.get(field_name) + if str(client_value).upper().strip() != str(field_value).upper().strip(): + matches = False + break + if matches: + entries.append(entry) + else: + entries = query.all() + + # Build response data + client_data = {} + data_list = [] + + for e in entries: + client = None + if e.project and e.project.client: + client = e.project.client + elif e.client: + client = e.client + + client_name = client.name if client else "Unknown" + salesman = None + if client and client.custom_fields: + salesman = client.custom_fields.get("salesman") + + entry_data = { + "id": e.id, + "date": e.start_time.strftime("%Y-%m-%d") if e.start_time else "", + "project": e.project.name if e.project else "", + "client": client_name, + "salesman": salesman or "", + "user": e.user.username if e.user else "", + "duration": e.duration_hours, + "notes": e.notes or "", + "billable": e.billable, + "paid": e.paid, + } + + data_list.append(entry_data) + + # Group by client for summary + if client_name not in client_data: + client_data[client_name] = {"hours": 0, "entries": []} + client_data[client_name]["hours"] += e.duration_hours or 0 + client_data[client_name]["entries"].append(entry_data) return { - "data": [ - { - "id": e.id, - "date": e.start_time.strftime("%Y-%m-%d") if e.start_time else "", - "project": e.project.name if e.project else "", - "user": e.user.username if e.user else "", - "duration": e.duration_hours, - "notes": e.notes or "", - } - for e in entries - ], - "summary": {"total_entries": len(entries), "total_hours": sum(e.duration_hours or 0 for e in entries)}, + "data": data_list, + "summary": { + "total_entries": len(entries), + "total_hours": round(sum(e.duration_hours or 0 for e in entries), 2), + "unpaid_only": unpaid_only, + "by_client": client_data, + }, } elif data_source == "projects": diff --git a/app/routes/salesman_reports.py b/app/routes/salesman_reports.py new file mode 100644 index 0000000..f1b82ff --- /dev/null +++ b/app/routes/salesman_reports.py @@ -0,0 +1,339 @@ +""" +Routes for salesman-based report generation and email mapping management. +""" +from flask import Blueprint, render_template, request, jsonify, flash, redirect, url_for +from flask_babel import gettext as _ +from flask_login import login_required, current_user +from app import db +from app.models import SalesmanEmailMapping, Client +from app.services.unpaid_hours_service import UnpaidHoursService +from app.services.scheduled_report_service import ScheduledReportService +from app.utils.db import safe_commit +from app.utils.email import send_email +from datetime import datetime, timedelta +import json + + +salesman_reports_bp = Blueprint("salesman_reports", __name__) + + +@salesman_reports_bp.route("/admin/salesman-email-mappings") +@login_required +def list_email_mappings(): + """List all salesman email mappings (Admin only)""" + if not current_user.is_admin: + flash(_("You do not have permission to access this page."), "error") + return redirect(url_for("reports.reports")) + + mappings = SalesmanEmailMapping.query.order_by(SalesmanEmailMapping.salesman_initial).all() + return render_template("admin/salesman_email_mappings.html", mappings=mappings) + + +@salesman_reports_bp.route("/api/salesman-email-mappings", methods=["GET"]) +@login_required +def get_email_mappings_api(): + """Get all salesman email mappings (API)""" + if not current_user.is_admin: + return jsonify({"error": "Permission denied"}), 403 + + mappings = SalesmanEmailMapping.query.order_by(SalesmanEmailMapping.salesman_initial).all() + return jsonify({"success": True, "mappings": [m.to_dict() for m in mappings]}) + + +@salesman_reports_bp.route("/api/salesman-email-mappings", methods=["POST"]) +@login_required +def create_email_mapping(): + """Create a new salesman email mapping""" + if not current_user.is_admin: + return jsonify({"success": False, "message": "Permission denied"}), 403 + + try: + data = request.json + salesman_initial = data.get("salesman_initial") + email_address = data.get("email_address") + email_pattern = data.get("email_pattern") + domain = data.get("domain") + notes = data.get("notes") + + if not salesman_initial: + return jsonify({"success": False, "message": "Salesman initial is required"}), 400 + + # Check if mapping already exists + existing = SalesmanEmailMapping.query.filter_by(salesman_initial=salesman_initial.upper().strip()).first() + if existing: + return jsonify({"success": False, "message": "Mapping for this initial already exists"}), 400 + + mapping = SalesmanEmailMapping( + salesman_initial=salesman_initial, + email_address=email_address, + email_pattern=email_pattern, + domain=domain, + notes=notes, + ) + + db.session.add(mapping) + if safe_commit("create_salesman_email_mapping", {"user_id": current_user.id}): + return jsonify({"success": True, "message": "Mapping created successfully", "mapping": mapping.to_dict()}) + else: + return jsonify({"success": False, "message": "Failed to create mapping"}), 500 + + except Exception as e: + return jsonify({"success": False, "message": str(e)}), 500 + + +@salesman_reports_bp.route("/api/salesman-email-mappings/", methods=["PUT"]) +@login_required +def update_email_mapping(mapping_id): + """Update a salesman email mapping""" + if not current_user.is_admin: + return jsonify({"success": False, "message": "Permission denied"}), 403 + + try: + mapping = SalesmanEmailMapping.query.get_or_404(mapping_id) + data = request.json + + if "email_address" in data: + mapping.email_address = data["email_address"] + if "email_pattern" in data: + mapping.email_pattern = data["email_pattern"] + if "domain" in data: + mapping.domain = data["domain"] + if "notes" in data: + mapping.notes = data["notes"] + if "is_active" in data: + mapping.is_active = bool(data["is_active"]) + + mapping.updated_at = datetime.utcnow() + + if safe_commit("update_salesman_email_mapping", {"user_id": current_user.id, "mapping_id": mapping_id}): + return jsonify({"success": True, "message": "Mapping updated successfully", "mapping": mapping.to_dict()}) + else: + return jsonify({"success": False, "message": "Failed to update mapping"}), 500 + + except Exception as e: + return jsonify({"success": False, "message": str(e)}), 500 + + +@salesman_reports_bp.route("/api/salesman-email-mappings/", methods=["DELETE"]) +@login_required +def delete_email_mapping(mapping_id): + """Delete a salesman email mapping""" + if not current_user.is_admin: + return jsonify({"success": False, "message": "Permission denied"}), 403 + + try: + mapping = SalesmanEmailMapping.query.get_or_404(mapping_id) + db.session.delete(mapping) + + if safe_commit("delete_salesman_email_mapping", {"user_id": current_user.id, "mapping_id": mapping_id}): + return jsonify({"success": True, "message": "Mapping deleted successfully"}) + else: + return jsonify({"success": False, "message": "Failed to delete mapping"}), 500 + + except Exception as e: + return jsonify({"success": False, "message": str(e)}), 500 + + +@salesman_reports_bp.route("/api/unpaid-hours/by-salesman", methods=["GET"]) +@login_required +def get_unpaid_hours_by_salesman(): + """Get unpaid hours grouped by salesman""" + if not current_user.is_admin: + return jsonify({"error": "Permission denied"}), 403 + + try: + start_date = request.args.get("start_date") + end_date = request.args.get("end_date") + salesman_field_name = request.args.get("salesman_field_name", "salesman") + + # Parse dates + start_dt = None + end_dt = None + if start_date: + try: + start_dt = datetime.strptime(start_date, "%Y-%m-%d") + except ValueError: + return jsonify({"error": "Invalid start_date format. Use YYYY-MM-DD"}), 400 + + if end_date: + try: + end_dt = datetime.strptime(end_date, "%Y-%m-%d") + timedelta(days=1) - timedelta(seconds=1) + except ValueError: + return jsonify({"error": "Invalid end_date format. Use YYYY-MM-DD"}), 400 + + unpaid_service = UnpaidHoursService() + result = unpaid_service.get_unpaid_hours_by_salesman( + start_date=start_dt, + end_date=end_dt, + salesman_field_name=salesman_field_name, + ) + + # Convert entries to dict format for JSON serialization + formatted_result = {} + for salesman_initial, data in result.items(): + formatted_result[salesman_initial] = { + "total_hours": data["total_hours"], + "total_entries": data["total_entries"], + "clients": data["clients"], + "projects": data["projects"], + "entries": [ + { + "id": e.id, + "date": e.start_time.strftime("%Y-%m-%d") if e.start_time else "", + "project": e.project.name if e.project else "", + "client": (e.project.client.name if e.project and e.project.client else (e.client.name if e.client else "Unknown")), + "user": e.user.username if e.user else "", + "duration": e.duration_hours, + "notes": e.notes or "", + } + for e in data["entries"] + ], + } + + return jsonify({"success": True, "data": formatted_result}) + + except Exception as e: + return jsonify({"success": False, "error": str(e)}), 500 + + +@salesman_reports_bp.route("/api/salesman-reports/preview-email", methods=["POST"]) +@login_required +def preview_salesman_email(): + """Preview email address for a salesman initial""" + if not current_user.is_admin: + return jsonify({"error": "Permission denied"}), 403 + + try: + data = request.json + salesman_initial = data.get("salesman_initial") + email_pattern = data.get("email_pattern") + domain = data.get("domain") + + if not salesman_initial: + return jsonify({"error": "Salesman initial is required"}), 400 + + salesman_initial = salesman_initial.strip().upper() + + # Try to get existing mapping + mapping = SalesmanEmailMapping.query.filter_by(salesman_initial=salesman_initial).first() + if mapping: + email = mapping.get_email() + else: + # Preview with provided pattern/domain + if email_pattern: + email = email_pattern.replace("{value}", salesman_initial) + elif domain: + email = f"{salesman_initial}@{domain}" + else: + email = None + + return jsonify({"success": True, "email": email, "salesman_initial": salesman_initial}) + + except Exception as e: + return jsonify({"success": False, "error": str(e)}), 500 + + +@salesman_reports_bp.route("/api/salesman-reports/generate", methods=["POST"]) +@login_required +def generate_salesman_reports(): + """Generate and optionally send reports for each salesman""" + if not current_user.is_admin: + return jsonify({"error": "Permission denied"}), 403 + + try: + data = request.json + start_date = data.get("start_date") + end_date = data.get("end_date") + salesman_field_name = data.get("salesman_field_name", "salesman") + send_emails = data.get("send_emails", False) + + # Parse dates + start_dt = None + end_dt = None + if start_date: + start_dt = datetime.strptime(start_date, "%Y-%m-%d") + if end_date: + end_dt = datetime.strptime(end_date, "%Y-%m-%d") + timedelta(days=1) - timedelta(seconds=1) + + unpaid_service = UnpaidHoursService() + result = unpaid_service.get_unpaid_hours_by_salesman( + start_date=start_dt, + end_date=end_dt, + salesman_field_name=salesman_field_name, + ) + + reports = {} + emails_sent = [] + + for salesman_initial, data in result.items(): + if salesman_initial == "_UNASSIGNED_": + continue + + # Get email for this salesman + email = SalesmanEmailMapping.get_email_for_initial(salesman_initial) + if not email and send_emails: + continue # Skip if no email mapping and we're sending emails + + # Format report data + report_data = { + "salesman_initial": salesman_initial, + "total_hours": data["total_hours"], + "total_entries": data["total_entries"], + "clients": data["clients"], + "projects": data["projects"], + "entries": [ + { + "id": e.id, + "date": e.start_time.strftime("%Y-%m-%d") if e.start_time else "", + "project": e.project.name if e.project else "", + "client": (e.project.client.name if e.project and e.project.client else (e.client.name if e.client else "Unknown")), + "user": e.user.username if e.user else "", + "duration": e.duration_hours, + "notes": e.notes or "", + } + for e in data["entries"] + ], + } + + reports[salesman_initial] = report_data + + # Send email if requested + if send_emails and email: + try: + subject = f"Unpaid Hours Report - {salesman_initial}" + text_body = f""" +Unpaid Hours Report for {salesman_initial} + +Total Hours: {data['total_hours']} +Total Entries: {data['total_entries']} + +Clients: {', '.join(data['clients'])} + +Projects: {', '.join(data['projects'])} + +Please review the attached report for details. +""" + send_email( + to=email, + subject=subject, + text_body=text_body, + template="email/unpaid_hours_report.html", + salesman_initial=salesman_initial, + report_data=report_data, + start_date=start_date, + end_date=end_date, + ) + emails_sent.append({"salesman": salesman_initial, "email": email, "status": "sent"}) + except Exception as e: + emails_sent.append({"salesman": salesman_initial, "email": email, "status": "error", "error": str(e)}) + + return jsonify({ + "success": True, + "reports": reports, + "emails_sent": emails_sent, + "total_salesmen": len(reports), + }) + + except Exception as e: + return jsonify({"success": False, "error": str(e)}), 500 + diff --git a/app/services/scheduled_report_service.py b/app/services/scheduled_report_service.py index cf2e070..e6f61f9 100644 --- a/app/services/scheduled_report_service.py +++ b/app/services/scheduled_report_service.py @@ -6,8 +6,9 @@ from typing import Optional, List, Dict, Any from datetime import datetime, timedelta from flask import current_app from app import db -from app.models import ReportEmailSchedule, SavedReportView, User +from app.models import ReportEmailSchedule, SavedReportView, User, SalesmanEmailMapping from app.services.reporting_service import ReportingService +from app.services.unpaid_hours_service import UnpaidHoursService from app.utils.email import send_email from app.utils.timezone import now_in_app_timezone import logging @@ -106,6 +107,10 @@ class ScheduledReportService: except: config = {} + # Check if we should split by salesman + if schedule.split_by_salesman: + return self._generate_and_send_salesman_reports(schedule, saved_view, config) + # Generate report data based on config report_data = self._generate_report_data(saved_view, config) @@ -275,3 +280,118 @@ class ScheduledReportService: db.session.rollback() logger.error(f"Error deleting schedule: {e}") return {"success": False, "message": f"Error deleting schedule: {str(e)}"} + + def _generate_and_send_salesman_reports( + self, schedule: ReportEmailSchedule, saved_view: SavedReportView, config: Dict[str, Any] + ) -> Dict[str, Any]: + """ + Generate and send reports split by salesman. + + This generates individual reports for each salesman and sends them + to their configured email addresses. + + Returns: + dict with 'success', 'message', and 'sent_count' keys + """ + try: + from datetime import timedelta + + # Get date range from config or use defaults + end_date = now_in_app_timezone() + if config.get("end_date"): + if isinstance(config["end_date"], str): + end_date = datetime.fromisoformat(config["end_date"]) + else: + end_date = config["end_date"] + + # Default to last month if no start date + start_date = end_date - timedelta(days=30) + if config.get("start_date"): + if isinstance(config["start_date"], str): + start_date = datetime.fromisoformat(config["start_date"]) + else: + start_date = config["start_date"] + + # Get salesman field name (default: 'salesman') + salesman_field_name = schedule.salesman_field_name or "salesman" + + # Get unpaid hours grouped by salesman + unpaid_service = UnpaidHoursService() + salesman_reports = unpaid_service.get_unpaid_hours_by_salesman( + start_date=start_date, + end_date=end_date, + salesman_field_name=salesman_field_name, + ) + + sent_count = 0 + errors = [] + + for salesman_initial, report_data in salesman_reports.items(): + # Skip unassigned entries + if salesman_initial == "_UNASSIGNED_": + continue + + # Get email for this salesman + email = SalesmanEmailMapping.get_email_for_initial(salesman_initial) + if not email: + logger.warning(f"No email mapping found for salesman {salesman_initial}, skipping") + errors.append(f"No email for {salesman_initial}") + continue + + # Format report data for email + formatted_data = { + "salesman_initial": salesman_initial, + "total_hours": report_data["total_hours"], + "total_entries": report_data["total_entries"], + "clients": report_data["clients"], + "projects": report_data["projects"], + "entries": [ + { + "id": e.id, + "date": e.start_time.strftime("%Y-%m-%d") if e.start_time else "", + "project": e.project.name if e.project else "", + "client": (e.project.client.name if e.project and e.project.client else (e.client.name if e.client else "Unknown")), + "user": e.user.username if e.user else "", + "duration": e.duration_hours, + "notes": e.notes or "", + } + for e in report_data["entries"] + ], + } + + try: + send_email( + to=email, + subject=f"Unpaid Hours Report - {salesman_initial} ({start_date.strftime('%Y-%m-%d')} to {end_date.strftime('%Y-%m-%d')})", + template="email/unpaid_hours_report.html", + salesman_initial=salesman_initial, + report_data=formatted_data, + start_date=start_date.strftime("%Y-%m-%d"), + end_date=end_date.strftime("%Y-%m-%d"), + ) + sent_count += 1 + logger.info(f"Sent unpaid hours report to {email} for salesman {salesman_initial}") + except Exception as e: + error_msg = f"Error sending to {email} ({salesman_initial}): {str(e)}" + logger.error(error_msg) + errors.append(error_msg) + + # Update schedule + schedule.last_run_at = now_in_app_timezone() + schedule.next_run_at = self._calculate_next_run(schedule.cadence, schedule.cron, schedule.timezone) + db.session.commit() + + message = f"Reports sent to {sent_count} salesmen." + if errors: + message += f" Errors: {len(errors)}" + + return { + "success": True, + "message": message, + "sent_count": sent_count, + "errors": errors, + } + except Exception as e: + db.session.rollback() + logger.error(f"Error generating and sending salesman reports: {e}") + return {"success": False, "message": f"Error generating reports: {str(e)}"} diff --git a/app/services/unpaid_hours_service.py b/app/services/unpaid_hours_service.py new file mode 100644 index 0000000..626ecb6 --- /dev/null +++ b/app/services/unpaid_hours_service.py @@ -0,0 +1,283 @@ +""" +Service for querying unpaid hours with custom field filtering. + +This service provides methods to: +- Query unpaid (unbilled) time entries +- Filter by client custom fields (e.g., salesman) +- Group by salesman for report generation +""" +from typing import Optional, Dict, Any, List +from datetime import datetime +from decimal import Decimal +from sqlalchemy import func, or_, and_ +from sqlalchemy.orm import joinedload +from app import db +from app.models import TimeEntry, InvoiceItem, Client, Project + + +class UnpaidHoursService: + """Service for unpaid hours queries and reporting""" + + def get_unpaid_time_entries( + self, + start_date: Optional[datetime] = None, + end_date: Optional[datetime] = None, + project_id: Optional[int] = None, + client_id: Optional[int] = None, + user_id: Optional[int] = None, + custom_field_filter: Optional[Dict[str, Any]] = None, + ) -> List[TimeEntry]: + """ + Get unpaid (unbilled) time entries. + + Unpaid means: + - billable = True + - paid = False + - Not referenced in any InvoiceItem.time_entry_ids + + Args: + start_date: Filter entries from this date + end_date: Filter entries until this date + project_id: Filter by project + client_id: Filter by client + user_id: Filter by user + custom_field_filter: Dict with field name and value to filter by client custom fields + e.g., {"salesman": "MM"} or {"field_name": "value"} + + Returns: + List of TimeEntry objects that are unpaid + """ + # Start with base query for billable, unpaid entries + query = TimeEntry.query.filter( + TimeEntry.billable == True, + TimeEntry.paid == False, + TimeEntry.end_time.isnot(None), + ) + + # Date filters + if start_date: + query = query.filter(TimeEntry.start_time >= start_date) + if end_date: + query = query.filter(TimeEntry.start_time <= end_date) + + # Project/Client/User filters + if project_id: + query = query.filter(TimeEntry.project_id == project_id) + if client_id: + query = query.filter(TimeEntry.client_id == client_id) + if user_id: + query = query.filter(TimeEntry.user_id == user_id) + + # Get all entries first + all_entries = query.options(joinedload(TimeEntry.project), joinedload(TimeEntry.client)).all() + + # Get all billed time entry IDs from invoice items + billed_entry_ids = set() + invoice_items = InvoiceItem.query.filter(InvoiceItem.time_entry_ids.isnot(None)).all() + for item in invoice_items: + if item.time_entry_ids: + try: + entry_ids = [int(id_str.strip()) for id_str in item.time_entry_ids.split(",") if id_str.strip()] + billed_entry_ids.update(entry_ids) + except (ValueError, AttributeError): + continue + + # Filter out billed entries + unpaid_entries = [entry for entry in all_entries if entry.id not in billed_entry_ids] + + # Apply custom field filter if provided + if custom_field_filter: + unpaid_entries = self._filter_by_custom_fields(unpaid_entries, custom_field_filter) + + return unpaid_entries + + def _filter_by_custom_fields(self, entries: List[TimeEntry], custom_field_filter: Dict[str, Any]) -> List[TimeEntry]: + """ + Filter entries by client custom fields. + + Args: + entries: List of TimeEntry objects + custom_field_filter: Dict with field name and value + e.g., {"salesman": "MM"} + + Returns: + Filtered list of TimeEntry objects + """ + if not custom_field_filter: + return entries + + filtered = [] + for entry in entries: + # Get client from entry (via project or direct) + client = None + if entry.project and entry.project.client: + client = entry.project.client + elif entry.client: + client = entry.client + + if not client or not client.custom_fields: + continue + + # Check if any custom field matches + matches = True + for field_name, field_value in custom_field_filter.items(): + client_value = client.custom_fields.get(field_name) + # Case-insensitive comparison + if str(client_value).upper().strip() != str(field_value).upper().strip(): + matches = False + break + + if matches: + filtered.append(entry) + + return filtered + + def get_unpaid_hours_summary( + self, + start_date: Optional[datetime] = None, + end_date: Optional[datetime] = None, + custom_field_filter: Optional[Dict[str, Any]] = None, + ) -> Dict[str, Any]: + """ + Get summary of unpaid hours. + + Returns: + Dict with total_hours, total_entries, and breakdown by client/project + """ + entries = self.get_unpaid_time_entries( + start_date=start_date, + end_date=end_date, + custom_field_filter=custom_field_filter, + ) + + total_hours = sum(entry.duration_hours or 0 for entry in entries) + + # Group by client + by_client = {} + by_project = {} + + for entry in entries: + client = None + if entry.project and entry.project.client: + client = entry.project.client + elif entry.client: + client = entry.client + + if client: + client_name = client.name + if client_name not in by_client: + by_client[client_name] = {"hours": 0, "entries": []} + by_client[client_name]["hours"] += entry.duration_hours or 0 + by_client[client_name]["entries"].append(entry) + + if entry.project: + project_name = entry.project.name + if project_name not in by_project: + by_project[project_name] = {"hours": 0, "entries": []} + by_project[project_name]["hours"] += entry.duration_hours or 0 + by_project[project_name]["entries"].append(entry) + + return { + "total_hours": round(total_hours, 2), + "total_entries": len(entries), + "by_client": by_client, + "by_project": by_project, + } + + def group_by_salesman( + self, + entries: List[TimeEntry], + salesman_field_name: str = "salesman", + ) -> Dict[str, List[TimeEntry]]: + """ + Group unpaid hours by salesman initial from client custom fields. + + Args: + entries: List of TimeEntry objects + salesman_field_name: Name of the custom field containing salesman info + + Returns: + Dict mapping salesman initial to list of TimeEntry objects + """ + grouped = {} + unassigned = [] + + for entry in entries: + # Get client from entry + client = None + if entry.project and entry.project.client: + client = entry.project.client + elif entry.client: + client = entry.client + + if not client or not client.custom_fields: + unassigned.append(entry) + continue + + salesman_value = client.custom_fields.get(salesman_field_name) + if not salesman_value: + unassigned.append(entry) + continue + + # Normalize salesman initial (uppercase, strip) + salesman_initial = str(salesman_value).upper().strip() + + if salesman_initial not in grouped: + grouped[salesman_initial] = [] + + grouped[salesman_initial].append(entry) + + # Add unassigned entries to a special key + if unassigned: + grouped["_UNASSIGNED_"] = unassigned + + return grouped + + def get_unpaid_hours_by_salesman( + self, + start_date: Optional[datetime] = None, + end_date: Optional[datetime] = None, + salesman_field_name: str = "salesman", + ) -> Dict[str, Dict[str, Any]]: + """ + Get unpaid hours grouped by salesman. + + Returns: + Dict mapping salesman initial to summary dict with: + - entries: List of TimeEntry objects + - total_hours: Total hours for this salesman + - clients: List of unique clients + - projects: List of unique projects + """ + entries = self.get_unpaid_time_entries( + start_date=start_date, + end_date=end_date, + ) + + grouped_entries = self.group_by_salesman(entries, salesman_field_name) + + result = {} + for salesman_initial, salesman_entries in grouped_entries.items(): + total_hours = sum(entry.duration_hours or 0 for entry in salesman_entries) + + # Get unique clients and projects + clients = set() + projects = set() + for entry in salesman_entries: + if entry.project and entry.project.client: + clients.add(entry.project.client.name) + elif entry.client: + clients.add(entry.client.name) + if entry.project: + projects.add(entry.project.name) + + result[salesman_initial] = { + "entries": salesman_entries, + "total_hours": round(total_hours, 2), + "total_entries": len(salesman_entries), + "clients": sorted(list(clients)), + "projects": sorted(list(projects)), + } + + return result + diff --git a/app/templates/admin/dashboard.html b/app/templates/admin/dashboard.html index c2012b7..209643e 100644 --- a/app/templates/admin/dashboard.html +++ b/app/templates/admin/dashboard.html @@ -76,6 +76,11 @@
Link Templates
URL Templates for Fields
+ + +
Salesman Email Mappings
+
Report Distribution
+
diff --git a/app/templates/admin/salesman_email_mappings.html b/app/templates/admin/salesman_email_mappings.html new file mode 100644 index 0000000..a5f7021 --- /dev/null +++ b/app/templates/admin/salesman_email_mappings.html @@ -0,0 +1,403 @@ +{% extends "base.html" %} +{% from "components/ui.html" import page_header %} + +{% block title %}{{ _('Salesman Email Mappings') }} - {{ _('Admin') }} - {{ app_name }}{% endblock %} + +{% block content %} +{% set breadcrumbs = [ + {'text': 'Admin', 'url': url_for('admin.admin_dashboard')}, + {'text': 'Salesman Email Mappings'} +] %} + +{{ page_header( + icon_class='fas fa-envelope', + title_text='Salesman Email Mappings', + subtitle_text='Configure email addresses for salesman-based report distribution', + breadcrumbs=breadcrumbs +) }} + +
+
+

{{ _('Email Mappings') }}

+ +
+ +
+ + + + + + + + + + + + + + + +
+ {{ _('Salesman Initial') }} + + {{ _('Email Address') }} + + {{ _('Pattern/Domain') }} + + {{ _('Status') }} + + {{ _('Actions') }} +
+ {{ _('Loading...') }} +
+
+
+ + + + + +{% endblock %} + diff --git a/app/templates/email/unpaid_hours_report.html b/app/templates/email/unpaid_hours_report.html new file mode 100644 index 0000000..267dd51 --- /dev/null +++ b/app/templates/email/unpaid_hours_report.html @@ -0,0 +1,189 @@ + + + + + + + +
+

⚠️ Unpaid Hours Report

+

Salesman: {{ salesman_initial }}

+

{{ start_date }} to {{ end_date }}

+
+ +
+

Hello,

+ +

This report contains all unpaid (unbilled) hours for clients assigned to you ({{ salesman_initial }}).

+ +
+

Total Unpaid Hours

+

{{ "%.2f"|format(report_data.total_hours) }}

+

{{ report_data.total_entries }} time entries

+
+ +
+ ⚠️ Action Required: These hours need to be reviewed and billed to clients. +
+ +
+

Clients with Unpaid Hours:

+

{{ report_data.clients|join(', ') }}

+
+ +
+

Projects with Unpaid Hours:

+

{{ report_data.projects|join(', ') }}

+
+ + {% if report_data.entries %} +

Time Entry Details

+ + + + + + + + + + + + + + {% for entry in report_data.entries %} + + + + + + + + + {% endfor %} + +
DateClientProjectUserHoursNotes
{{ entry.date }}{{ entry.client }}{{ entry.project }}{{ entry.user }} + {{ "%.2f"|format(entry.duration) }} + {{ entry.notes[:50] }}{% if entry.notes|length > 50 %}...{% endif %}
+ {% endif %} + +

Please review these entries and create invoices as needed.

+ +
+ + View Reports in TimeTracker + +
+
+ + + + + diff --git a/app/templates/reports/builder.html b/app/templates/reports/builder.html index 648202f..cb159db 100644 --- a/app/templates/reports/builder.html +++ b/app/templates/reports/builder.html @@ -85,6 +85,52 @@ + + +
+

{{ _('Advanced Filters') }}

+ + +
+ +

+ {{ _('Show only billable hours that have not been invoiced') }} +

+
+ + + {% if custom_field_keys %} +
+
+ + +
+ +
+ {% endif %} +
@@ -243,12 +289,23 @@ function previewReport() { // Collect current filters const projectSelect = document.getElementById('filterProject'); const projectValue = projectSelect ? projectSelect.value : ''; + reportConfig.filters = { start_date: document.getElementById('filterStartDate').value || null, end_date: document.getElementById('filterEndDate').value || null, - project_id: projectValue || null + project_id: projectValue || null, + unpaid_only: document.getElementById('filterUnpaidOnly')?.checked || false }; + // Add custom field filter if set + const customFieldName = document.getElementById('filterCustomFieldName')?.value; + const customFieldValue = document.getElementById('filterCustomFieldValue')?.value; + if (customFieldName && customFieldValue) { + reportConfig.filters.custom_field_filter = { + [customFieldName]: customFieldValue + }; + } + // Show preview modal const modal = document.getElementById('previewModal'); const loading = document.getElementById('previewLoading'); @@ -424,9 +481,19 @@ document.getElementById('saveForm').addEventListener('submit', async (e) => { reportConfig.filters = { start_date: document.getElementById('filterStartDate').value, end_date: document.getElementById('filterEndDate').value, - project_id: document.getElementById('filterProject').value || null + project_id: document.getElementById('filterProject').value || null, + unpaid_only: document.getElementById('filterUnpaidOnly')?.checked || false }; + // Add custom field filter if set + const customFieldName = document.getElementById('filterCustomFieldName')?.value; + const customFieldValue = document.getElementById('filterCustomFieldValue')?.value; + if (customFieldName && customFieldValue) { + reportConfig.filters.custom_field_filter = { + [customFieldName]: customFieldValue + }; + } + try { // Get CSRF token from meta tag const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content || ''; @@ -458,6 +525,37 @@ document.getElementById('saveForm').addEventListener('submit', async (e) => { alert('{{ _("Error saving report") }}: ' + error.message); } }); + +// Show/hide custom field value input based on field selection +document.getElementById('filterCustomFieldName')?.addEventListener('change', function() { + const valueContainer = document.getElementById('filterCustomFieldValueContainer'); + const valueInput = document.getElementById('filterCustomFieldValue'); + if (this.value) { + valueContainer.classList.remove('hidden'); + valueInput.required = true; + } else { + valueContainer.classList.add('hidden'); + valueInput.required = false; + valueInput.value = ''; + } +}); + +// Show time entries filters only when time_entries is selected +function updateFiltersVisibility() { + const timeEntriesFilters = document.getElementById('timeEntriesFilters'); + if (reportConfig.data_source === 'time_entries') { + timeEntriesFilters?.classList.remove('hidden'); + } else { + timeEntriesFilters?.classList.add('hidden'); + } +} + +// Update filter visibility when data source changes +const originalAddDataSource = addDataSourceToCanvas; +addDataSourceToCanvas = function(sourceId) { + originalAddDataSource(sourceId); + updateFiltersVisibility(); +}; {% endblock %} diff --git a/app/utils/scheduled_tasks.py b/app/utils/scheduled_tasks.py index fbadcc8..e58e0e8 100644 --- a/app/utils/scheduled_tasks.py +++ b/app/utils/scheduled_tasks.py @@ -263,6 +263,93 @@ def generate_recurring_invoices(): return 0 +def send_monthly_unpaid_hours_reports(): + """Send monthly unpaid hours reports split by salesman + + This task runs on the first day of each month and generates + unpaid hours reports for each salesman based on their client assignments. + """ + with current_app.app_context(): + try: + logger.info("Sending monthly unpaid hours reports by salesman...") + + from app.services.unpaid_hours_service import UnpaidHoursService + from app.models import SalesmanEmailMapping + from app.utils.email import send_email + from datetime import datetime, timedelta + + # Get last month's date range + now = datetime.now() + if now.month == 1: + last_month_start = datetime(now.year - 1, 12, 1) + last_month_end = datetime(now.year, 1, 1) - timedelta(seconds=1) + else: + last_month_start = datetime(now.year, now.month - 1, 1) + last_month_end = datetime(now.year, now.month, 1) - timedelta(seconds=1) + + # Get unpaid hours grouped by salesman + unpaid_service = UnpaidHoursService() + salesman_reports = unpaid_service.get_unpaid_hours_by_salesman( + start_date=last_month_start, + end_date=last_month_end, + salesman_field_name="salesman", + ) + + sent_count = 0 + for salesman_initial, report_data in salesman_reports.items(): + if salesman_initial == "_UNASSIGNED_": + continue + + # Get email for this salesman + email = SalesmanEmailMapping.get_email_for_initial(salesman_initial) + if not email: + logger.warning(f"No email mapping for salesman {salesman_initial}, skipping") + continue + + # Format report data + formatted_data = { + "salesman_initial": salesman_initial, + "total_hours": report_data["total_hours"], + "total_entries": report_data["total_entries"], + "clients": report_data["clients"], + "projects": report_data["projects"], + "entries": [ + { + "id": e.id, + "date": e.start_time.strftime("%Y-%m-%d") if e.start_time else "", + "project": e.project.name if e.project else "", + "client": (e.project.client.name if e.project and e.project.client else (e.client.name if e.client else "Unknown")), + "user": e.user.username if e.user else "", + "duration": e.duration_hours, + "notes": e.notes or "", + } + for e in report_data["entries"] + ], + } + + try: + send_email( + to=email, + subject=f"Monthly Unpaid Hours Report - {salesman_initial} ({last_month_start.strftime('%Y-%m-%d')} to {last_month_end.strftime('%Y-%m-%d')})", + template="email/unpaid_hours_report.html", + salesman_initial=salesman_initial, + report_data=formatted_data, + start_date=last_month_start.strftime("%Y-%m-%d"), + end_date=last_month_end.strftime("%Y-%m-%d"), + ) + sent_count += 1 + logger.info(f"Sent monthly unpaid hours report to {email} for {salesman_initial}") + except Exception as e: + logger.error(f"Error sending report to {email} ({salesman_initial}): {e}") + + logger.info(f"Sent {sent_count} monthly unpaid hours reports") + return sent_count + + except Exception as e: + logger.error(f"Error sending monthly unpaid hours reports: {e}") + return 0 + + def register_scheduled_tasks(scheduler, app=None): """Register all scheduled tasks with APScheduler @@ -320,6 +407,31 @@ def register_scheduled_tasks(scheduler, app=None): ) logger.info("Registered recurring invoices generation task") + # Send monthly unpaid hours reports by salesman (first day of month at 9 AM) + def send_monthly_unpaid_hours_reports_with_app(): + """Wrapper that uses the captured app instance""" + app_instance = app + if app_instance is None: + try: + app_instance = current_app._get_current_object() + except RuntimeError: + logger.error("No app instance available for monthly unpaid hours reports") + return + with app_instance.app_context(): + send_monthly_unpaid_hours_reports() + + scheduler.add_job( + func=send_monthly_unpaid_hours_reports_with_app, + trigger="cron", + day=1, + hour=9, + minute=0, + id="send_monthly_unpaid_hours_reports", + name="Send monthly unpaid hours reports by salesman", + replace_existing=True, + ) + logger.info("Registered monthly unpaid hours reports task") + # Retry failed webhook deliveries every 5 minutes # Create a closure that captures the app instance if app is None: diff --git a/migrations/versions/087_add_salesman_email_mapping.py b/migrations/versions/087_add_salesman_email_mapping.py new file mode 100644 index 0000000..6b2db23 --- /dev/null +++ b/migrations/versions/087_add_salesman_email_mapping.py @@ -0,0 +1,44 @@ +"""Add salesman email mapping table + +Revision ID: 087_salesman_email_mapping +Revises: 086_project_client_attachments +Create Date: 2025-01-29 + +This migration adds: +- salesman_email_mappings table for mapping salesman initials to email addresses +""" +from alembic import op +import sqlalchemy as sa + +# revision identifiers, used by Alembic. +revision = '087_salesman_email_mapping' +down_revision = '086_project_client_attachments' +branch_labels = None +depends_on = None + + +def upgrade(): + """Create salesman_email_mappings table""" + op.create_table('salesman_email_mappings', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('salesman_initial', sa.String(length=20), nullable=False), + sa.Column('email_address', sa.String(length=255), nullable=True), + sa.Column('email_pattern', sa.String(length=255), nullable=True), # e.g., '{value}@test.de' + sa.Column('domain', sa.String(length=255), nullable=True), # e.g., 'test.de' for pattern-based emails + sa.Column('is_active', sa.Boolean(), nullable=False, server_default='true'), + sa.Column('notes', sa.Text(), nullable=True), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.Column('updated_at', sa.DateTime(), nullable=False), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('salesman_initial', name='uq_salesman_email_mapping_initial') + ) + op.create_index('ix_salesman_email_mappings_initial', 'salesman_email_mappings', ['salesman_initial'], unique=False) + op.create_index('ix_salesman_email_mappings_active', 'salesman_email_mappings', ['is_active'], unique=False) + + +def downgrade(): + """Drop salesman_email_mappings table""" + op.drop_index('ix_salesman_email_mappings_active', table_name='salesman_email_mappings') + op.drop_index('ix_salesman_email_mappings_initial', table_name='salesman_email_mappings') + op.drop_table('salesman_email_mappings') + diff --git a/migrations/versions/088_add_salesman_splitting_to_reports.py b/migrations/versions/088_add_salesman_splitting_to_reports.py new file mode 100644 index 0000000..7fafd45 --- /dev/null +++ b/migrations/versions/088_add_salesman_splitting_to_reports.py @@ -0,0 +1,36 @@ +"""Add salesman splitting to report email schedules + +Revision ID: 088_salesman_splitting_reports +Revises: 087_salesman_email_mapping +Create Date: 2025-01-29 + +This migration adds: +- split_by_salesman field to report_email_schedules table +- salesman_field_name field to specify which custom field to use +""" +from alembic import op +import sqlalchemy as sa + +# revision identifiers, used by Alembic. +revision = '088_salesman_splitting_reports' +down_revision = '087_salesman_email_mapping' +branch_labels = None +depends_on = None + + +def upgrade(): + """Add salesman splitting fields to report_email_schedules""" + # Add split_by_salesman field + op.add_column('report_email_schedules', + sa.Column('split_by_salesman', sa.Boolean(), nullable=False, server_default='false')) + + # Add salesman_field_name field (defaults to 'salesman') + op.add_column('report_email_schedules', + sa.Column('salesman_field_name', sa.String(length=50), nullable=True)) + + +def downgrade(): + """Remove salesman splitting fields""" + op.drop_column('report_email_schedules', 'salesman_field_name') + op.drop_column('report_email_schedules', 'split_by_salesman') +