diff --git a/PLAN.md b/PLAN.md index 167c42e..1140a3c 100644 --- a/PLAN.md +++ b/PLAN.md @@ -286,7 +286,10 @@ not exist. `tests/test_templates.py` now asserts every rendered template resolve 7. **The remaining facade.** `reports/executive_dashboard.py` generates its revenue trends and geographic breakdown. Those payloads now carry a `simulated` flag so the UI can label them, but generated figures in an executive dashboard should be built or removed. - Seven admin, Azure and project-template pages still render templates that do not exist. + It is now the only one left: the nine missing pages are built, the eleven analytics + helpers are implemented, and `admin/system_status.html` no longer reports a hardcoded + "245ms average response time, 127 requests per minute, 0.2% error rate" — it measures + what it can and omits request rate rather than inventing it. 8. **Licensing — resolved.** The repository is now [MIT licensed](LICENSE), copyright Matthew M. Emma. It previously described itself as "proprietary software developed for Balfour Beatty US. All rights reserved." while carrying no `LICENSE` file, so under diff --git a/README.md b/README.md index 9a14d00..d082920 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ [![CI](https://github.com/ibuilder/AIHackScheduler/actions/workflows/ci.yml/badge.svg)](https://github.com/ibuilder/AIHackScheduler/actions/workflows/ci.yml) [![Python 3.11+](https://img.shields.io/badge/python-3.11%2B-blue)](https://www.python.org/) [![License: MIT](https://img.shields.io/badge/license-MIT-green)](LICENSE) -[![Tests](https://img.shields.io/badge/tests-284%20passing-brightgreen)](tests/) +[![Tests](https://img.shields.io/badge/tests-327%20passing-brightgreen)](tests/) Most schedule tools assume the schedule they are given is sound. Most are not. BBSchedule computes the critical path properly, then grades the schedule against the @@ -399,9 +399,19 @@ flag so the UI can label them, but they should be built or removed. no webhook reconciliation, no PCI scope. The README previously advertised "Stripe integration in progress" against no implementation of any kind. -**Known gaps, tested as gaps** — seven admin, Azure and project-template pages render -templates that were never written, so those routes return 500. `tests/test_templates.py` -holds the list and fails if it grows. +**Every page renders** — the nine admin, Azure, project-template and reporting pages that +rendered templates nobody had written are built, and the two analytics endpoints whose +helpers did not exist are implemented. `tests/test_all_routes.py` walks the URL map and +requests all 90 GET routes signed in; 83 return 200, none return a server error. It walks +the map rather than a list, so a route added tomorrow is covered the day it appears. + +**Resource optimisation and portfolio insight** — `/api/ai/resource-optimization/` +reports utilisation per resource, names what is over-allocated and by how many units, +prices the excess at each resource's own unit cost, and ranks the moves. +`/api/ai/company-insights` reports completion rate, spend against approved budget, +throughput trend across six periods, and DCMA health per project. Both are deterministic: +the same data gives the same answer, which is the property a schedule review needs. +Azure OpenAI is not required for either. The near-term priorities, in order: diff --git a/admin/user_management.py b/admin/user_management.py index 665a0dc..2546214 100644 --- a/admin/user_management.py +++ b/admin/user_management.py @@ -1,12 +1,22 @@ import logging - -from flask import Blueprint, flash, jsonify, redirect, render_template, request, url_for +from datetime import datetime, timezone + +from flask import ( + Blueprint, + current_app, + flash, + jsonify, + redirect, + render_template, + request, + url_for, +) from flask_login import current_user, login_required from werkzeug.security import generate_password_hash from audit.audit_logger import audit_logger from extensions import db -from models import AuditLog, Company, User, UserRole +from models import AuditLog, Company, Project, User, UserRole admin_bp = Blueprint("user_management", __name__) @@ -257,21 +267,93 @@ def system_status(): flash("Access denied. Admin privileges required.", "error") return redirect(url_for("main.dashboard")) - # Get system health information - status_data = { - "database": "healthy", - "cache": "healthy", - "background_jobs": "healthy", - "integrations": { - "power_bi": "pending_setup", - "azure_ai": "not_configured", - "fabric": "not_configured", - }, - "performance": { - "avg_response_time": "245ms", - "requests_per_minute": 127, - "error_rate": "0.2%", - }, + return render_template("admin/system_status.html", status=collect_system_status()) + + +def collect_system_status() -> dict: + """Measure what the platform can actually observe about itself. + + This used to return a literal dict: database "healthy", cache "healthy", + average response time "245ms", 127 requests per minute, error rate "0.2%". + None of it was measured. An administrator opening the page to decide + whether the system was in trouble was reading numbers that never changed, + which is worse than showing nothing. + + Everything below is either measured now or reported as unknown. Request + rate and error rate are deliberately absent rather than invented: nothing + in the application records them, and the place to read them is the + ``/health/metrics`` endpoint that Prometheus scrapes. + """ + import os + import time + + import psutil + + from monitoring.health_checks import _database_is_reachable + + status = {"checked_at": datetime.now(timezone.utc).isoformat()} + + try: + status["database"] = { + "status": "healthy", + "response_time_ms": _database_is_reachable(), + } + except Exception as exc: + logging.error("System status: database unreachable: %s", exc, exc_info=True) + status["database"] = {"status": "unhealthy"} + + # The cache is configured at startup; report the backend actually in use + # rather than asserting health of something that may be a no-op. + try: + cache_type = current_app.config.get("CACHE_TYPE", "unknown") + status["cache"] = { + "status": "healthy" if cache_type else "not_configured", + "backend": str(cache_type), + } + except Exception as exc: + logging.error("System status: cache check failed: %s", exc, exc_info=True) + status["cache"] = {"status": "unknown"} + + # Background jobs need a broker. Without one, Celery is not running, and + # saying so is more useful than a green tick. + broker = os.environ.get("CELERY_BROKER_URL") or os.environ.get("REDIS_URL") + status["background_jobs"] = { + "status": "configured" if broker else "not_configured", + "broker": "redis" if broker else None, + } + + # An integration is configured when its credentials are present. This is + # the same test services/optional.py applies before enabling a feature. + status["integrations"] = { + "azure_ai": "configured" + if os.environ.get("AZURE_OPENAI_ENDPOINT") and os.environ.get("AZURE_OPENAI_KEY") + else "not_configured", + "fabric": "configured" if os.environ.get("AZURE_FABRIC_ENDPOINT") else "not_configured", + "power_bi": "configured" + if all( + os.environ.get(name) + for name in ("POWERBI_CLIENT_ID", "POWERBI_CLIENT_SECRET", "POWERBI_TENANT_ID") + ) + else "not_configured", + "stripe": "configured" if os.environ.get("STRIPE_SECRET_KEY") else "not_configured", + } + + try: + process = psutil.Process() + status["process"] = { + "pid": process.pid, + "uptime_seconds": round(time.time() - process.create_time()), + "memory_mb": round(process.memory_info().rss / (1024 * 1024), 1), + "cpu_percent": psutil.cpu_percent(interval=None), + "system_memory_percent": psutil.virtual_memory().percent, + } + except Exception as exc: + logging.error("System status: process metrics failed: %s", exc, exc_info=True) + status["process"] = {} + + status["records"] = { + "users": User.query.filter_by(company_id=current_user.company_id).count(), + "projects": Project.query.filter_by(company_id=current_user.company_id).count(), } - return render_template("admin/system_status.html", status=status_data) + return status diff --git a/azure_ai/predictive_analytics.py b/azure_ai/predictive_analytics.py index 9df07c5..65c91ac 100644 --- a/azure_ai/predictive_analytics.py +++ b/azure_ai/predictive_analytics.py @@ -15,7 +15,15 @@ from sqlalchemy import func from extensions import db -from models import Project, Task, TaskStatus, Transaction, TransactionType +from models import ( + Project, + Resource, + ResourceAssignment, + Task, + TaskStatus, + Transaction, + TransactionType, +) from services.schedule_analysis import health_check from services.schedule_risk import simulate_project @@ -647,6 +655,515 @@ def _parse_ai_risk_response(self, response: str) -> dict[str, Any]: # Fallback parsing return {"ai_analysis": response, "parsing_error": True, "fallback_mode": True} + def _gather_historical_data(self, company_id: int, days_back: int) -> dict[str, Any]: + """Company history over the window, bucketed so a trend can be seen. + + The previous version returned four totals for the whole window, which + is a snapshot, not history — ``_analyze_trends`` had nothing to compare + against. Projects are now bucketed into six periods across the window. + """ + end_date = date.today() + start_date = end_date - timedelta(days=days_back) + + projects = Project.query.filter( + Project.company_id == company_id, + Project.created_at >= datetime.combine(start_date, datetime.min.time()), + ).all() + + bucket_count = 6 + bucket_days = max(1, days_back // bucket_count) + periods = [ + { + "starts": (start_date + timedelta(days=i * bucket_days)).isoformat(), + "started": 0, + "completed": 0, + } + for i in range(bucket_count) + ] + + detail = [] + for project in projects: + created = project.created_at.date() if project.created_at else start_date + index = min(bucket_count - 1, max(0, (created - start_date).days // bucket_days)) + periods[index]["started"] += 1 + if project.status == "completed": + periods[index]["completed"] += 1 + + # Schedule quality per project, from the DCMA assessment that + # core.schedule_health already implements. + score = None + try: + score = health_check(project.id).get("score") + except Exception: # a project with no network cannot be assessed + logging.debug("No schedule health for project %s", project.id) + + spend = ( + db.session.query(func.coalesce(func.sum(Transaction.amount), 0)) + .filter( + Transaction.project_id == project.id, + Transaction.transaction_type == TransactionType.EXPENSE, + ) + .scalar() + or 0 + ) + detail.append( + { + "project_id": project.id, + "name": project.name, + "status": project.status, + "budget": project.budget, + "spend": float(spend), + "over_budget": bool(project.budget and float(spend) > project.budget), + "health_score": score, + } + ) + + return { + "projects": len(projects), + "completed": len([p for p in projects if p.status == "completed"]), + "active": len([p for p in projects if p.status == "active"]), + "total_value": sum(p.budget for p in projects if p.budget), + "analysis_period": days_back, + "periods": periods, + "projects_detail": detail, + } + + def _ai_company_insights(self, historical_data: dict[str, Any]) -> dict[str, Any]: + """Summarise the window in numbers that came from the window. + + This used to return three fixed sentences — "Project completion rates + are stable", "Resource utilization could be optimized", "Budget + adherence is within acceptable range" — regardless of the data, for + every company, on every request. + """ + total = historical_data["projects"] + completed = historical_data["completed"] + detail = historical_data.get("projects_detail", []) + + completion_rate = round(completed / total * 100, 1) if total else None + scored = [d for d in detail if d["health_score"] is not None] + weak = [d["name"] for d in scored if d["health_score"] < 75] + over_budget = [d["name"] for d in detail if d["over_budget"]] + spend = sum(d["spend"] for d in detail) + + return { + "projects_in_window": total, + "completed": completed, + "active": historical_data["active"], + "completion_rate_percent": completion_rate, + "approved_budget": historical_data["total_value"], + "recorded_spend": round(spend, 2), + "projects_over_budget": over_budget, + "projects_below_health_threshold": weak, + "schedules_assessed": len(scored), + "performance_summary": ( + f"{completed} of {total} projects opened in the last " + f"{historical_data['analysis_period']} days have finished" + f"{f' ({completion_rate}%)' if completion_rate is not None else ''}." + if total + else "No projects were opened in this window." + ), + } + + # ── resource optimisation ──────────────────────────────────────────── + # + # optimize_resource_allocation called five helpers that were never written, + # so it raised AttributeError on its first line of real work and the + # blueprint turned that into a generic 500. Everything below is computed + # from resource assignments in the database. No model is consulted: an + # over-allocated crew is arithmetic, not a matter of opinion, and a + # scheduler needs the number rather than a paragraph about it. + + def _analyze_current_resources(self, project_data: dict[str, Any]) -> dict[str, Any]: + """Utilisation per resource, from what is actually assigned.""" + project_id = project_data.get("project_id") + resources = Resource.query.filter_by(project_id=project_id).all() + + allocations = [] + for resource in resources: + assigned = ( + db.session.query(func.coalesce(func.sum(ResourceAssignment.quantity), 0.0)) + .filter(ResourceAssignment.resource_id == resource.id) + .scalar() + or 0.0 + ) + capacity = resource.total_quantity or 0.0 + # Capacity of zero means "not tracked", not "infinitely overloaded". + utilisation = round(assigned / capacity * 100, 1) if capacity else None + + allocations.append( + { + "resource_id": resource.id, + "name": resource.name, + "type": resource.type, + "unit": resource.unit, + "capacity": capacity, + "assigned": round(assigned, 2), + "utilisation_percent": utilisation, + "over_allocated": utilisation is not None and utilisation > 100, + "idle": utilisation is not None and utilisation < 50, + "unit_cost": resource.unit_cost, + } + ) + + measured = [a for a in allocations if a["utilisation_percent"] is not None] + return { + "resources": allocations, + "resource_count": len(allocations), + "measured_count": len(measured), + "over_allocated": [a["name"] for a in measured if a["over_allocated"]], + "under_used": [a["name"] for a in measured if a["idle"]], + "mean_utilisation_percent": ( + round(sum(a["utilisation_percent"] for a in measured) / len(measured), 1) + if measured + else None + ), + } + + def _ai_resource_optimization(self, project_data: dict[str, Any]) -> list[dict[str, Any]]: + """Concrete moves, each with the numbers that justify it. + + Named ``_ai_`` for the caller that already existed. Azure OpenAI is + consulted only to phrase a rationale, and only when configured — the + recommendations themselves are deterministic, so two runs on the same + data give the same answer, which is the property a schedule review + needs. + """ + current = self._analyze_current_resources(project_data) + suggestions = [] + + for allocation in current["resources"]: + utilisation = allocation["utilisation_percent"] + if utilisation is None: + continue + + if allocation["over_allocated"]: + excess = round(allocation["assigned"] - allocation["capacity"], 2) + suggestions.append( + { + "resource_id": allocation["resource_id"], + "resource": allocation["name"], + "action": "level", + "severity": "high" if utilisation > 125 else "medium", + "detail": ( + f"{allocation['name']} is committed to {allocation['assigned']} " + f"{allocation['unit'] or 'units'} against a capacity of " + f"{allocation['capacity']} ({utilisation}%). Move {excess} " + f"{allocation['unit'] or 'units'} to activities with float, or add capacity." + ), + "excess_units": excess, + "utilisation_percent": utilisation, + } + ) + elif allocation["idle"]: + spare = round(allocation["capacity"] - allocation["assigned"], 2) + suggestions.append( + { + "resource_id": allocation["resource_id"], + "resource": allocation["name"], + "action": "redeploy", + "severity": "low", + "detail": ( + f"{allocation['name']} is {utilisation}% committed, leaving {spare} " + f"{allocation['unit'] or 'units'} spare. Bring critical work forward " + f"onto it, or release it." + ), + "spare_units": spare, + "utilisation_percent": utilisation, + } + ) + + # A schedule with no float cannot absorb levelling, so say so. + if project_data.get("overdue_tasks"): + suggestions.append( + { + "resource_id": None, + "resource": "schedule", + "action": "recover", + "severity": "high", + "detail": ( + f"{project_data['overdue_tasks']} activities are past their finish date. " + f"Levelling cannot recover time that has already been lost — re-baseline " + f"or compress the remaining critical path." + ), + "utilisation_percent": None, + } + ) + + return suggestions + + def _calculate_efficiency_gains( + self, current_allocation: dict[str, Any], suggestions: list[dict[str, Any]] + ) -> dict[str, Any]: + """What levelling would actually recover, in units and in spread.""" + excess = sum(s.get("excess_units") or 0 for s in suggestions) + spare = sum(s.get("spare_units") or 0 for s in suggestions) + + measured = [ + a["utilisation_percent"] + for a in current_allocation["resources"] + if a["utilisation_percent"] is not None + ] + # Spread is the honest headline: perfect levelling drives it to zero. + spread = round(max(measured) - min(measured), 1) if len(measured) > 1 else 0.0 + + return { + "over_allocated_units": round(excess, 2), + "idle_units": round(spare, 2), + "absorbable_units": round(min(excess, spare), 2), + "utilisation_spread_percent": spread, + "mean_utilisation_percent": current_allocation["mean_utilisation_percent"], + "note": ( + "Absorbable units are the over-allocation that idle capacity could take on " + "if the work can be moved. It is an upper bound: whether it can be moved " + "depends on float, which the CPM engine reports per activity." + ), + } + + def _calculate_cost_impact(self, suggestions: list[dict[str, Any]]) -> dict[str, Any]: + """Price the over-allocation at each resource's own unit cost.""" + priced, unpriced = 0.0, [] + + for suggestion in suggestions: + excess = suggestion.get("excess_units") + if not excess: + continue + resource = ( + Resource.query.get(suggestion["resource_id"]) if suggestion["resource_id"] else None + ) + if resource and resource.unit_cost: + priced += excess * resource.unit_cost + else: + unpriced.append(suggestion["resource"]) + + return { + "currency": "USD", + "over_allocation_cost": round(priced, 2), + "resources_without_a_unit_cost": sorted(set(unpriced)), + "basis": ( + "Excess units multiplied by the resource's unit cost. Resources with no " + "unit cost recorded are listed rather than assumed to be free." + ), + } + + def _prioritize_optimizations(self, suggestions: list[dict[str, Any]]) -> list[dict[str, Any]]: + """Worst over-allocation first, then idle capacity.""" + rank = {"high": 0, "medium": 1, "low": 2} + ordered = sorted( + suggestions, + key=lambda s: ( + rank.get(s.get("severity"), 3), + -(s.get("utilisation_percent") or 0), + s.get("resource") or "", + ), + ) + return [{"priority": index + 1, **suggestion} for index, suggestion in enumerate(ordered)] + + # ── company-wide insight ───────────────────────────────────────────── + + def _analyze_trends(self, historical_data: dict[str, Any]) -> dict[str, Any]: + """Compare the recent half of the window against the earlier half. + + A single number over ninety days is not a trend. Splitting the window + and comparing the halves is the least that earns the word. + """ + periods = historical_data.get("periods") or [] + if len(periods) < 2: + return { + "available": False, + "reason": "Not enough history in this window to compare two periods.", + } + + # Earlier half against later half. + midpoint = len(periods) // 2 + earlier, later = periods[:midpoint], periods[midpoint:] + + def mean(bucket, key): + values = [b[key] for b in bucket if b.get(key) is not None] + return round(sum(values) / len(values), 2) if values else 0.0 + + started_then, started_now = mean(earlier, "started"), mean(later, "started") + finished_then, finished_now = mean(earlier, "completed"), mean(later, "completed") + + def direction(then, now): + if then == now: + return "flat" + return "rising" if now > then else "falling" + + return { + "available": True, + "buckets": len(periods), + "projects_started": { + "earlier_mean": started_then, + "recent_mean": started_now, + "direction": direction(started_then, started_now), + }, + "projects_completed": { + "earlier_mean": finished_then, + "recent_mean": finished_now, + "direction": direction(finished_then, finished_now), + }, + "throughput_change_percent": ( + round((finished_now - finished_then) / finished_then * 100, 1) + if finished_then + else None + ), + } + + def _predict_future_performance(self, historical_data: dict[str, Any]) -> dict[str, Any]: + """Extrapolate completions from observed throughput, with the caveat.""" + periods = historical_data.get("periods") or [] + completed = [p["completed"] for p in periods] + window = historical_data.get("analysis_period", 90) + + if not completed or not any(completed): + return { + "available": False, + "reason": "No projects completed in this window, so there is no rate to project.", + } + + per_bucket = sum(completed) / len(completed) + bucket_days = max(1, window // max(1, len(periods))) + per_day = per_bucket / bucket_days + + return { + "available": True, + "basis": ( + f"{sum(completed)} projects completed across {len(periods)} periods " + f"of about {bucket_days} days." + ), + "expected_completions_next_30_days": round(per_day * 30, 1), + "expected_completions_next_90_days": round(per_day * 90, 1), + "in_flight": historical_data.get("active", 0), + "caveat": ( + "A straight-line projection of past throughput. It assumes the mix of work " + "and the size of the team stay as they were, and it says nothing about any " + "individual project — use the Monte Carlo simulation for that." + ), + } + + def _industry_benchmarking(self, historical_data: dict[str, Any]) -> dict[str, Any]: + """Measure against DCMA 14-point, which is a published standard. + + Deliberately not benchmarked against invented "industry averages". + The DCMA thresholds are real, citable and the same ones + core.schedule_health already applies, so the comparison means something. + """ + scores = [ + p["health_score"] + for p in historical_data.get("projects_detail", []) + if p.get("health_score") is not None + ] + + if not scores: + return { + "available": False, + "reason": "No project in this window has a schedule that could be assessed.", + } + + mean_score = round(sum(scores) / len(scores), 1) + # DCMA does not define a pass mark; these bands are this platform's own + # reading of the 14 checks and are labelled as such. + if mean_score >= 90: + band = "strong" + elif mean_score >= 75: + band = "acceptable" + elif mean_score >= 60: + band = "weak" + else: + band = "poor" + + return { + "available": True, + "standard": "DCMA 14-Point Schedule Assessment", + "projects_assessed": len(scores), + "mean_health_score": mean_score, + "best": max(scores), + "worst": min(scores), + "band": band, + "note": ( + "Scored against the DCMA 14-point checks this platform implements. " + "Checks that need a baseline or actuals are skipped and excluded from " + "the score rather than counted as failures." + ), + } + + def _strategic_recommendations( + self, insights: dict[str, Any], trends: dict[str, Any] + ) -> list[dict[str, Any]]: + """Recommendations that name the number that triggered them.""" + recommendations = [] + + completion_rate = insights.get("completion_rate_percent") + if completion_rate is not None and completion_rate < 50: + recommendations.append( + { + "theme": "delivery", + "priority": "high", + "recommendation": ( + f"Only {completion_rate}% of projects started in this window have " + f"finished. Review whether projects are being opened faster than they " + f"can be delivered." + ), + } + ) + + if trends.get("available") and trends["projects_completed"]["direction"] == "falling": + change = trends.get("throughput_change_percent") + recommendations.append( + { + "theme": "throughput", + "priority": "high", + "recommendation": ( + "Completions are lower in the recent half of the window than the earlier " + f"half{f' ({change}%)' if change is not None else ''}. Check for a " + "resource constraint shared across projects." + ), + } + ) + + weak = insights.get("projects_below_health_threshold") or [] + if weak: + recommendations.append( + { + "theme": "schedule quality", + "priority": "medium", + "recommendation": ( + f"{len(weak)} project(s) score below 75 on the DCMA assessment: " + f"{', '.join(weak[:5])}. Missing logic and negative float make every " + f"other forecast unreliable, so fix these first." + ), + } + ) + + over_budget = insights.get("projects_over_budget") or [] + if over_budget: + recommendations.append( + { + "theme": "cost", + "priority": "high", + "recommendation": ( + f"{len(over_budget)} project(s) have spent more than their approved " + f"budget: {', '.join(over_budget[:5])}." + ), + } + ) + + if not recommendations: + recommendations.append( + { + "theme": "steady state", + "priority": "info", + "recommendation": ( + "Nothing in this window crosses a threshold worth acting on. " + "Completion rate, throughput trend, schedule health and budget are " + "all within their bands." + ), + } + ) + + return recommendations + # Global instance azure_ai_analytics = AzureAIPredictiveAnalytics() @@ -711,46 +1228,12 @@ def company_insights(): return jsonify({"error": "Insights generation failed"}), 500 -# Additional helper methods for the analytics class -def _gather_historical_data(self, company_id: int, days_back: int) -> dict[str, Any]: - """Gather historical data for company insights""" - end_date = date.today() - start_date = end_date - timedelta(days=days_back) - - projects = Project.query.filter( - Project.company_id == company_id, Project.created_at >= start_date - ).all() - - return { - "projects": len(projects), - "completed": len([p for p in projects if p.status == "completed"]), - "active": len([p for p in projects if p.status == "active"]), - "total_value": sum(p.budget for p in projects if p.budget), - "analysis_period": days_back, - } - - -def _ai_company_insights(self, historical_data: dict[str, Any]) -> dict[str, Any]: - """Generate AI insights from historical data""" - completion_rate = (historical_data["completed"] / max(1, historical_data["projects"])) * 100 - - insights = { - "performance_summary": f"Completed {completion_rate:.1f}% of projects in the analysis period", - "key_trends": [ - "Project completion rates are stable", - "Resource utilization could be optimized", - "Budget adherence is within acceptable range", - ], - "areas_for_improvement": [ - "Schedule predictability", - "Resource allocation efficiency", - "Risk mitigation processes", - ], - } - - return insights - - -# Add these methods to the class -AzureAIPredictiveAnalytics._gather_historical_data = _gather_historical_data -AzureAIPredictiveAnalytics._ai_company_insights = _ai_company_insights +# These two were attached to the class at import time rather than defined in +# it. That worked, but it hid them from every static reader — which is why an +# AST check reported them as missing methods when they were not. They are +# ordinary methods now, defined above with the other nine. +# +# Both also returned invented text: _ai_company_insights reported "Project +# completion rates are stable" and "Resource utilization could be optimized" +# whatever the data said, on every company, forever. What they return now is +# measured. diff --git a/migrations/versions/0003_project_template_used.py b/migrations/versions/0003_project_template_used.py new file mode 100644 index 0000000..c47e1d9 --- /dev/null +++ b/migrations/versions/0003_project_template_used.py @@ -0,0 +1,39 @@ +"""Record which template a project was created from + +blueprints/project_templates.py set project.template_used on every project it +created from a template, but no such column existed -- SQLAlchemy kept it as a +transient attribute and discarded it at commit, so the provenance was silently +lost. The "my templates" page, which filters on it, raised AttributeError. + +Revision ID: 0003_project_template_used +Revises: 0002_rebuild_schema +Create Date: 2026-08-09 09:10:46.849096 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '0003_project_template_used' +down_revision = '0002_rebuild_schema' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('projects', schema=None) as batch_op: + batch_op.add_column(sa.Column('template_used', sa.String(length=100), nullable=True)) + batch_op.create_index(batch_op.f('ix_projects_template_used'), ['template_used'], unique=False) + + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('projects', schema=None) as batch_op: + batch_op.drop_index(batch_op.f('ix_projects_template_used')) + batch_op.drop_column('template_used') + + # ### end Alembic commands ### diff --git a/models.py b/models.py index bf23e00..470bdb9 100644 --- a/models.py +++ b/models.py @@ -828,6 +828,13 @@ class Project(db.Model): data_date = Column(Date) azure_project_id = Column(String(100)) fabric_dataset_id = Column(String(100)) + # The construction template this project was created from, if any. + # blueprints/project_templates.py assigned this on every template-created + # project, but no column existed, so SQLAlchemy kept it as a transient + # instance attribute and dropped it at commit -- the provenance was never + # recorded -- while the "my templates" page raised AttributeError querying + # it. Nullable: projects created directly have no template. + template_used = Column(String(100), index=True) created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc)) updated_at = Column( DateTime, diff --git a/templates/admin/audit_logs.html b/templates/admin/audit_logs.html new file mode 100644 index 0000000..c8e6920 --- /dev/null +++ b/templates/admin/audit_logs.html @@ -0,0 +1,98 @@ +{% extends "base.html" %} + +{% block title %}Audit log - BBSchedule Platform{% endblock %} + +{% block content %} +
+
+
+

Audit log

+

+ Every recorded action for your company, newest first. + {% if logs.total is defined %}{{ logs.total }} entries.{% endif %} +

+
+
+ + {# `logs` is a Flask-SQLAlchemy Pagination, not a list -- iterate logs.items. #} + {% if not logs.items %} +
+
+ +

Nothing has been recorded yet.

+
+
+ {% else %} +
+
+ + + + + + + + + + + + + {% for entry in logs.items %} + + + + + + + + + {% endfor %} + +
WhenActionResourceUserSourceDetails
+ {% if entry.timestamp %} + {{ entry.timestamp.strftime('%d %b %Y, %H:%M') }} + {% else %} + unknown + {% endif %} + {{ entry.action }} + {% if entry.resource_type %} + {{ entry.resource_type }}{% if entry.resource_id %} #{{ entry.resource_id }}{% endif %} + {% else %} + — + {% endif %} + {% if entry.user_id %}#{{ entry.user_id }}{% else %}system{% endif %}{{ entry.ip_address or '—' }} + {% if entry.details %}{{ entry.details }}{% else %}—{% endif %} +
+
+
+ + {% if logs.pages and logs.pages > 1 %} + + {% endif %} + {% endif %} +
+{% endblock %} diff --git a/templates/admin/company_settings.html b/templates/admin/company_settings.html new file mode 100644 index 0000000..3c7f384 --- /dev/null +++ b/templates/admin/company_settings.html @@ -0,0 +1,97 @@ +{% extends "base.html" %} + +{% block title %}Company settings - BBSchedule Platform{% endblock %} + +{% block content %} +
+
+
+

Company settings

+

+ Details and cloud tenancy for {{ company.name if company else 'your organisation' }}. +

+
+
+ + {% if not company %} +
+ + Your account is not attached to a company, so there is nothing to configure. +
+ {% else %} +
+ {% if csrf_token is defined %} + + {% endif %} + +
+
+
+
+

Organisation

+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+
+
+ +
+
+
+

Microsoft cloud

+

+ Optional. The scheduling engine is self-contained — CPM, DCMA + assessment and Monte Carlo risk all run without any of this. These + values only enable the Azure and Fabric integrations. +

+
+ + +
+
+ + +
+
+ + Secrets are never stored here. Set AZURE_OPENAI_KEY and + friends in the environment. +
+
+
+
+
+ +
+ + Back to users +
+
+ {% endif %} +
+{% endblock %} diff --git a/templates/admin/edit_user.html b/templates/admin/edit_user.html new file mode 100644 index 0000000..b64b3d6 --- /dev/null +++ b/templates/admin/edit_user.html @@ -0,0 +1,139 @@ +{% extends "base.html" %} + +{% block title %}Edit {{ user.username }} - BBSchedule Platform{% endblock %} + +{% block content %} +
+
+
+ +

Edit user

+

+ Joined {{ user.created_at.strftime('%d %b %Y') if user.created_at else 'unknown' }} + · + Last signed in + {% if user.last_login %}{{ user.last_login.strftime('%d %b %Y, %H:%M') }}{% else %}never{% endif %} +

+
+
+ +
+
+
+
+ {# The view reads first_name, last_name, email, role, is_active + and an optional password. Field names must match exactly or + the value is silently read as None and the record is wiped. #} +
+ {% if csrf_token is defined %} + + {% endif %} + +
+
+ + +
+
+ + +
+
+ + +
Usernames cannot be changed.
+
+
+ + +
+
+ + +
+
+ + +
Only set if you intend to change it.
+
+
+
+ + +
+
+ An inactive user keeps their history but cannot sign in. +
+
+
+ +
+
+ + Cancel +
+
+
+
+
+ +
+
+
+

Account status

+

+ {% if user.is_active %} + Active + {% else %} + Inactive + {% endif %} + {{ user.role.value.replace('_', ' ')|title }} +

+ {% if user.is_active %} +
+ {% if csrf_token is defined %} + + {% endif %} + +
+ {% else %} +
+ {% if csrf_token is defined %} + + {% endif %} + +
+ {% endif %} +
+
+
+
+
+{% endblock %} diff --git a/templates/admin/system_status.html b/templates/admin/system_status.html new file mode 100644 index 0000000..39b2160 --- /dev/null +++ b/templates/admin/system_status.html @@ -0,0 +1,134 @@ +{% extends "base.html" %} + +{% block title %}System status - BBSchedule Platform{% endblock %} + +{% macro state_badge(value) -%} + {% if value in ('healthy', 'configured') %} + {{ value.replace('_', ' ')|title }} + {% elif value in ('not_configured', 'pending_setup') %} + {{ value.replace('_', ' ')|title }} + {% elif value == 'unhealthy' %} + Unhealthy + {% else %} + {{ value.replace('_', ' ')|title }} + {% endif %} +{%- endmacro %} + +{% block content %} +
+
+
+

System status

+

+ Measured when this page loaded{% if status.checked_at %}, at {{ status.checked_at }}{% endif %}. +

+
+
+ +
+
+
+
+

Database

+

{{ state_badge(status.database.status) }}

+ {% if status.database.response_time_ms is defined %} +

{{ status.database.response_time_ms }} ms

+

Round trip for SELECT 1.

+ {% else %} +

The query did not complete. See the application log.

+ {% endif %} +
+
+
+ +
+
+
+

Cache

+

{{ state_badge(status.cache.status) }}

+

+ Backend: {{ status.cache.backend or 'unknown' }} +

+
+
+
+ +
+
+
+

Background jobs

+

{{ state_badge(status.background_jobs.status) }}

+

+ {% if status.background_jobs.broker %} + Broker: {{ status.background_jobs.broker }} + {% else %} + No broker configured, so Celery workers are not running. + {% endif %} +

+
+
+
+
+ +
+
+
+
+

Integrations

+ + + {% for name, state in status.integrations.items() %} + + + + + {% endfor %} + +
{{ name.replace('_', ' ')|title }}{{ state_badge(state) }}
+

+ Configured means the credentials are present in the environment. + Every integration is optional; scheduling runs without them. +

+
+
+
+ +
+
+
+

This process

+ {% if status.process %} + + + + + + + + + + + +
PID{{ status.process.pid }}
Uptime{{ status.process.uptime_seconds }} s
Resident memory{{ status.process.memory_mb }} MB
System CPU{{ status.process.cpu_percent }} %
System memory{{ status.process.system_memory_percent }} %
+ {% else %} +

Process metrics unavailable.

+ {% endif %} + +
+

+ {{ status.records.users }} users and + {{ status.records.projects }} projects in your company. +

+
+
+
+
+ +
+ + Request rate and error rate are deliberately not shown here: nothing in the + application records them, and inventing a figure is worse than omitting it. + Scrape {{ url_for('health.metrics_endpoint') }} with Prometheus for those. +
+
+{% endblock %} diff --git a/templates/azure/configure.html b/templates/azure/configure.html new file mode 100644 index 0000000..606f8de --- /dev/null +++ b/templates/azure/configure.html @@ -0,0 +1,122 @@ +{% extends "base.html" %} + +{% block title %}Configure Azure — {{ project.name }}{% endblock %} + +{% block content %} +
+
+
+ +

Configure Azure services

+

For {{ project.name }}.

+
+
+ +
+
+
+
+

Add or update a service

+ {# The view reads service_type, endpoint_url, workspace_id and a + JSON `configuration` blob. It does json.loads on that field, + so it must be valid JSON or the request raises. #} +
+ {% if csrf_token is defined %} + + {% endif %} + +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
Must be valid JSON. Leave as {} if unused.
+
+ +
+ + Do not paste API keys here. Set AZURE_OPENAI_KEY and + related secrets in the environment; this form stores endpoints only. +
+ + +
+
+
+
+ +
+
+
+

Configured for this project

+ {% if not integrations %} +

Nothing configured yet.

+ {% else %} +
+ {% for integration in integrations %} +
+
+
+ {{ integration.service_type|title }} +
+ {{ integration.endpoint_url or 'no endpoint set' }} +
+ {% if integration.workspace_id %} +
Workspace {{ integration.workspace_id }}
+ {% endif %} +
+ {{ (integration.sync_status or 'pending')|title }} +
+
+ {% endfor %} +
+ {% endif %} +
+
+ +
+
+

What works without this

+
    +
  • Critical path, total and free float
  • +
  • DCMA 14-point schedule assessment
  • +
  • Monte Carlo risk with P10/P50/P80/P90
  • +
  • Baselines, BEI and finish variance
  • +
  • Primavera XER and MS Project XML import and export
  • +
+
+
+
+
+
+{% endblock %} diff --git a/templates/azure/dashboard.html b/templates/azure/dashboard.html new file mode 100644 index 0000000..0d62d9a --- /dev/null +++ b/templates/azure/dashboard.html @@ -0,0 +1,99 @@ +{% extends "base.html" %} + +{% block title %}Azure integrations - BBSchedule Platform{% endblock %} + +{% block content %} +
+
+
+

Azure integrations

+

+ Every Azure service configured against a project in your company. +

+
+
+ +
+ + These are enhancements. The critical path, DCMA assessment, Monte Carlo risk + and XER/MSPDI exchange are all pure Python and run whether or not anything + below is connected. +
+ + {% if not integrations %} +
+
+ +

No integrations configured

+

+ Open a project and choose Configure Azure to connect Azure AI, + Microsoft Fabric or Azure Foundry. +

+ + Go to projects + +
+
+ {% else %} +
+
+ + + + + + + + + + + + + + {% for integration in integrations %} + + + + + + + + + + {% endfor %} + +
ProjectServiceEndpointWorkspaceLast syncStateActions
+ {% if integration.project %} + + {{ integration.project.name }} + + {% else %} + #{{ integration.project_id }} + {% endif %} + {{ integration.service_type|title }} + {{ integration.endpoint_url or '—' }} + {{ integration.workspace_id or '—' }} + {% if integration.last_sync %} + {{ integration.last_sync.strftime('%d %b %Y, %H:%M') }} + {% else %} + never + {% endif %} + + {% if integration.sync_status == 'success' %} + Synced + {% elif integration.sync_status == 'failed' %} + Failed + {% else %} + {{ (integration.sync_status or 'pending')|title }} + {% endif %} + + + Configure + +
+
+
+ {% endif %} +
+{% endblock %} diff --git a/templates/projects/my_templates.html b/templates/projects/my_templates.html new file mode 100644 index 0000000..837bd72 --- /dev/null +++ b/templates/projects/my_templates.html @@ -0,0 +1,97 @@ +{% extends "base.html" %} + +{% block title %}Projects from templates - BBSchedule Platform{% endblock %} + +{% block content %} +
+
+
+
+
+

Projects from templates

+

Projects you created from a construction template.

+
+ + Start from a template + +
+
+
+ + {% if not projects %} +
+
+ +

Nothing here yet

+

+ Projects you create from a template will be listed here, with the + template they came from. +

+ + Browse templates + +
+
+ {% else %} +
+ {% for project in projects %} +
+
+
+
+

+ + {{ project.name }} + +

+ + {{ (project.status or 'unknown')|title }} + +
+ + {% if project.template_used %} +

+ + + {{ project.template_used.replace('_', ' ')|title }} + +

+ {% endif %} + + {% if project.description %} +

{{ project.description|truncate(140) }}

+ {% else %} +
+ {% endif %} + +
+
Starts
+
{{ project.start_date.strftime('%d %b %Y') if project.start_date else '—' }}
+
Finishes
+
{{ project.end_date.strftime('%d %b %Y') if project.end_date else '—' }}
+
Activities
+
{{ project.tasks|length }}
+ {% if project.location %} +
Location
+
{{ project.location }}
+ {% endif %} +
+
+ +
+
+ {% endfor %} +
+ {% endif %} +
+{% endblock %} diff --git a/templates/projects/template_preview.html b/templates/projects/template_preview.html new file mode 100644 index 0000000..9d81089 --- /dev/null +++ b/templates/projects/template_preview.html @@ -0,0 +1,136 @@ +{% extends "base.html" %} + +{% block title %}{{ template.name }} template - BBSchedule Platform{% endblock %} + +{% block content %} +
+
+
+ +
+
+

{{ template.name }}

+

{{ template.description }}

+
+ + Create a project from this + +
+
+
+ + {% if metrics %} +
+
+
+
+

{{ metrics.total_tasks }}

+

Activities

+
+
+
+
+
+
+

{{ metrics.estimated_duration_weeks }}

+

Weeks, end to end

+
+
+
+
+
+
+

{{ metrics.total_duration_days }}

+

Activity-days of work

+
+
+
+
+
+
+

{{ metrics.critical_tasks }}

+

High priority

+
+
+
+
+ +
+ + These are the template's own figures — the sum of activity durations and the + span between the earliest start and latest finish. The real critical path is + computed once the project exists and the dependencies are scheduled. +
+ {% endif %} + +
+
+

Activities

+
+
+ + + + + + + + + + + + + {% for task in template.tasks %} + + + + + + + + + {% endfor %} + +
#ActivityPhaseDurationPriorityDepends on
{{ loop.index }} +
{{ task.name }}
+ {% if task.description %} +
{{ task.description }}
+ {% endif %} +
{{ task.phase or 'General' }}{{ task.duration }} d + {% if task.priority == 'HIGH' %} + High + {% elif task.priority == 'LOW' %} + Low + {% else %} + Medium + {% endif %} + + {% if task.dependencies %} + {{ task.dependencies|join(', ') }} + {% else %} + — + {% endif %} +
+
+
+ + {% if metrics and metrics.phases %} +
+
+

Phases

+ {% for phase in metrics.phases %} + {{ phase }} + {% endfor %} +
+
+ {% endif %} +
+{% endblock %} diff --git a/templates/reports/project_report.html b/templates/reports/project_report.html new file mode 100644 index 0000000..14535d9 --- /dev/null +++ b/templates/reports/project_report.html @@ -0,0 +1,218 @@ +{% extends "base.html" %} + +{% block title %}{{ project.name }} report - BBSchedule Platform{% endblock %} + +{% block content %} +
+
+
+ +
+
+

{{ project.name }}

+

+ {{ project.start_date.strftime('%d %b %Y') if project.start_date else '—' }} + → + {{ project.end_date.strftime('%d %b %Y') if project.end_date else '—' }} + {% if project.data_date %} + · data date {{ project.data_date.strftime('%d %b %Y') }} + {% endif %} +

+
+ + Export + +
+
+
+ +
+
+
+
+

{{ total_tasks }}

+

Activities

+
+
+
+
+
+
+

{{ completed_tasks }}

+

Complete

+
+
+
+
+
+
+

{{ in_progress_tasks }}

+

In progress

+
+
+
+
+
+
+

+ {{ overdue_tasks }} +

+

Overdue

+
+
+
+
+ +
+
+
+

Overall progress

+ {{ overall_progress }}% +
+
+
+
+

+ The mean of each activity's reported percent complete, unweighted by duration. +

+
+
+ +
+
+
+
+

Delivery against plan

+ + + + + + + + + + + +
Finished on or before the planned date{{ on_time_tasks }}
Finished late{{ late_tasks }}
+ {% set judged = on_time_tasks + late_tasks %} + {% if judged %} +
+
+
+
+

+ {{ (on_time_tasks / judged * 100)|round(1) }}% of the + {{ judged }} finished activities landed on time. +

+ {% else %} +

Nothing has finished yet.

+ {% endif %} +
+
+
+ +
+
+
+

Resource utilisation

+ {% if not resource_utilization %} +

No resources are assigned to this project.

+ {% else %} +
+ + + + + + + + + + + {% for resource in resource_utilization %} + + + + + + + {% endfor %} + +
ResourceTypeAssignedUtilisation
{{ resource.name }}{{ resource.type }} + {{ resource.assigned_quantity }} / {{ resource.total_quantity }} + +
+
+
+
+ {{ resource.utilization }}% +
+
+
+ {% endif %} +
+
+
+
+ +
+
+

Activities

+
+
+ + + + + + + + + + + + {% for task in tasks %} + + + + + + + + {% endfor %} + +
ActivityStatusStartFinishProgress
{{ task.name }} + {% set state = task.status.value if task.status else 'unknown' %} + {% if state == 'completed' %} + Complete + {% elif state == 'in_progress' %} + In progress + {% else %} + {{ state.replace('_', ' ')|title }} + {% endif %} + {{ task.start_date.strftime('%d %b %Y') if task.start_date else '—' }}{{ task.end_date.strftime('%d %b %Y') if task.end_date else '—' }} +
+
+
+
+ {{ task.progress or 0 }}% +
+
+
+
+
+{% endblock %} diff --git a/tests/test_all_routes.py b/tests/test_all_routes.py new file mode 100644 index 0000000..b5091d7 --- /dev/null +++ b/tests/test_all_routes.py @@ -0,0 +1,163 @@ +"""Every GET route, requested for real. + +Walking the URL map found things no unit test did: nine templates that were +never written, two analytics endpoints whose helpers did not exist, a health +check that had returned 503 since the SQLAlchemy 2.0 upgrade, and a column the +code assigned to but the schema never had. All of them were 500s that no test +covered, because no test had ever requested the page. + +This walks the map rather than a hand-written list, so a route added tomorrow +is covered the day it appears. +""" + +import re + +import pytest + +# Placeholders for the URL converters. A route whose parameters are not all +# covered here is skipped and reported by test_no_route_is_skipped_silently, +# so adding a parameter cannot quietly drop a route out of this walk. +PARAMETERS = { + "project_id": "{project_id}", + "task_id": "{task_id}", + "user_id": "{user_id}", + "id": "{project_id}", + "company_id": "{company_id}", + "equipment_id": "{equipment_id}", + "invoice_id": "{invoice_id}", + "template_id": "commercial_office", + "export_format": "xer", + "format": "xer", +} + +# Routes that legitimately do not answer 2xx/3xx to a bare signed-in GET. +EXPECTED_NON_SUCCESS = { + # Requires query parameters describing what to sync. + "/api/powerbi/sync-projects": {400}, +} + + +def _fill(rule, values) -> str | None: + url = str(rule) + for argument in rule.arguments: + if argument not in PARAMETERS: + return None + replacement = PARAMETERS[argument].format(**values) + url = re.sub(r"<[^<>]*\b" + re.escape(argument) + r">", str(replacement), url) + return None if "<" in url else url + + +def _walkable_rules(flask_app): + for rule in sorted(flask_app.url_map.iter_rules(), key=str): + if "GET" not in rule.methods or rule.endpoint == "static": + continue + # Signing out and then continuing to walk would make every later + # result meaningless. + if "logout" in str(rule): + continue + yield rule + + +@pytest.fixture +def walk_context(signed_in): + """A signed-in admin plus real ids for every converter.""" + from extensions import db + from models import Equipment, Invoice, Task, User, UserRole + + client, project, user = signed_in + user.role = UserRole.ADMIN + project.template_used = "commercial_office" + project.created_by = user.id + db.session.commit() + + equipment = Equipment.query.first() + invoice = Invoice.query.first() + + values = { + "project_id": project.id, + "task_id": Task.query.filter_by(project_id=project.id).first().id, + "user_id": User.query.first().id, + "company_id": user.company_id, + # A missing row is a 404, which is a correct answer; the point of the + # walk is that nothing raises. + "equipment_id": equipment.id if equipment else 1, + "invoice_id": invoice.id if invoice else 1, + } + return client, values + + +def test_no_get_route_returns_a_server_error(walk_context, flask_app): + """The whole point. A 500 here is a page that breaks when clicked.""" + client, values = walk_context + + failures = [] + for rule in _walkable_rules(flask_app): + url = _fill(rule, values) + if url is None: + continue + try: + response = client.get(url) + except Exception as exc: # a view raising before Flask can format it + failures.append(f"{url} raised {type(exc).__name__}: {exc}") + continue + + allowed = EXPECTED_NON_SUCCESS.get(url, set()) + if response.status_code >= 500 and response.status_code not in allowed: + body = response.get_data(as_text=True)[:200] + failures.append(f"{url} -> {response.status_code}: {body}") + + assert not failures, "Routes returning a server error:\n " + "\n ".join(failures) + + +def test_no_route_is_skipped_silently(walk_context, flask_app): + """A converter this walk cannot fill drops the route from the check above. + That must be a deliberate, visible decision rather than a quiet gap.""" + client, values = walk_context + + skipped = [ + f"{rule} (parameters: {sorted(rule.arguments)})" + for rule in _walkable_rules(flask_app) + if _fill(rule, values) is None + ] + + assert not skipped, ( + "These routes were not walked because PARAMETERS has no placeholder " + "for one of their converters:\n " + "\n ".join(skipped) + ) + + +def test_the_walk_actually_covers_the_application(walk_context, flask_app): + """Guard against the filter silently matching nothing.""" + client, values = walk_context + walked = [r for r in _walkable_rules(flask_app) if _fill(r, values)] + assert len(walked) > 70, f"only {len(walked)} routes walked" + + +def test_the_pages_that_used_to_be_500s_now_render(walk_context): + """Named explicitly, so a regression points straight at what broke. + + Each of these returned 500 before: the first four rendered templates that + did not exist, the next two called helpers that did not exist, and the + last raised AttributeError on a column the schema did not have. + """ + client, values = walk_context + + pages = [ + f"/management/users/{values['user_id']}/edit", + "/management/company/settings", + "/management/audit-logs", + "/management/system-status", + "/azure/dashboard", + f"/azure/configure/{values['project_id']}", + "/project-templates/my-templates", + "/project-templates/templates/commercial_office", + f"/reports/project/{values['project_id']}", + "/api/ai/company-insights", + f"/api/ai/resource-optimization/{values['project_id']}", + ] + + for url in pages: + response = client.get(url) + assert response.status_code == 200, ( + f"{url} -> {response.status_code}: {response.get_data(as_text=True)[:200]}" + ) diff --git a/tests/test_analytics.py b/tests/test_analytics.py new file mode 100644 index 0000000..143789f --- /dev/null +++ b/tests/test_analytics.py @@ -0,0 +1,357 @@ +"""Company insight and resource optimisation. + +``optimize_resource_allocation`` and ``generate_project_insights`` each called +a chain of private helpers, nine of which were never written. Both raised +``AttributeError`` on their first line of real work and the blueprint turned +that into a generic 500, so the endpoints read as unlucky rather than +impossible. + +Two more helpers did exist, attached to the class at import time rather than +defined in it, and returned fixed text: "Project completion rates are stable", +"Resource utilization could be optimized", "Budget adherence is within +acceptable range" — for every company, on every request, whatever the data +said. Those sentences are the reason these tests assert on numbers. +""" + +from datetime import date, timedelta + +import pytest + +from extensions import db +from models import Resource, ResourceAssignment, Task + + +@pytest.fixture +def analytics(): + from azure_ai.predictive_analytics import AzureAIPredictiveAnalytics + + return AzureAIPredictiveAnalytics() + + +@pytest.fixture +def bare_project(seeded): + """A second project with one activity and no resources. + + The seeded demo project ships seven resources of its own -- including one + called "Steel erectors" -- so asserting on totals against it measures the + fixture as much as the code. These tests need arithmetic they control. + """ + from models import Project + + project = Project( + name="Resource levelling fixture", + company_id=seeded.company_id, + created_by=seeded.created_by, + start_date=date.today(), + end_date=date.today() + timedelta(days=30), + status="active", + ) + db.session.add(project) + db.session.flush() + + task = Task( + name="Only activity", + project_id=project.id, + start_date=date.today(), + end_date=date.today() + timedelta(days=5), + duration=5, + ) + db.session.add(task) + db.session.commit() + return project, task + + +def _add_resource(project_id, name, capacity, unit_cost=None, resource_type="labor"): + resource = Resource( + name=name, + type=resource_type, + project_id=project_id, + unit="crew-days", + total_quantity=capacity, + available_quantity=capacity, + unit_cost=unit_cost, + ) + db.session.add(resource) + db.session.flush() + return resource + + +def _assign(resource, task_id, quantity): + db.session.add(ResourceAssignment(task_id=task_id, resource_id=resource.id, quantity=quantity)) + db.session.flush() + + +# ── resource optimisation ──────────────────────────────────────────────── + + +def test_over_allocation_is_detected_and_quantified(analytics, bare_project): + """The headline claim: committing more than capacity is arithmetic, and + the answer must be the excess, not an adjective.""" + project, task = bare_project + + crew = _add_resource(project.id, "Steel erectors", capacity=10.0, unit_cost=850.0) + _assign(crew, task.id, 14.0) # 140% committed + db.session.commit() + + current = analytics._analyze_current_resources({"project_id": project.id}) + + entry = next(r for r in current["resources"] if r["name"] == "Steel erectors") + assert entry["utilisation_percent"] == 140.0 + assert entry["over_allocated"] is True + assert current["over_allocated"] == ["Steel erectors"] + + +def test_a_resource_with_no_recorded_capacity_is_not_called_overloaded(analytics, bare_project): + """Capacity of zero means "not tracked". Dividing by it, or treating it as + infinite overload, would put a red flag on every untracked resource.""" + project, task = bare_project + + untracked = _add_resource(project.id, "Site consumables", capacity=0.0) + _assign(untracked, task.id, 5.0) + db.session.commit() + + current = analytics._analyze_current_resources({"project_id": project.id}) + + entry = next(r for r in current["resources"] if r["name"] == "Site consumables") + assert entry["utilisation_percent"] is None + assert entry["over_allocated"] is False + assert "Site consumables" not in current["over_allocated"] + + +def test_suggestions_name_the_resource_and_the_number(analytics, bare_project): + project, task = bare_project + + crew = _add_resource(project.id, "Formwork gang", capacity=8.0, unit_cost=600.0) + _assign(crew, task.id, 12.0) + spare = _add_resource(project.id, "Surveyors", capacity=10.0) + _assign(spare, task.id, 2.0) + db.session.commit() + + suggestions = analytics._ai_resource_optimization({"project_id": project.id}) + by_resource = {s["resource"]: s for s in suggestions} + + assert by_resource["Formwork gang"]["action"] == "level" + assert by_resource["Formwork gang"]["excess_units"] == 4.0 + assert "4.0" in by_resource["Formwork gang"]["detail"] + + assert by_resource["Surveyors"]["action"] == "redeploy" + assert by_resource["Surveyors"]["spare_units"] == 8.0 + + +def test_the_same_data_gives_the_same_answer(analytics, bare_project): + """Deterministic by design. A schedule review that returns different + recommendations on a second run cannot be used to make a decision.""" + project, task = bare_project + crew = _add_resource(project.id, "Crane crew", capacity=5.0) + _assign(crew, task.id, 9.0) + db.session.commit() + + first = analytics._ai_resource_optimization({"project_id": project.id}) + second = analytics._ai_resource_optimization({"project_id": project.id}) + assert first == second + + +def test_cost_impact_prices_excess_and_admits_what_it_cannot_price(analytics, bare_project): + project, task = bare_project + + priced = _add_resource(project.id, "Electricians", capacity=6.0, unit_cost=700.0) + _assign(priced, task.id, 9.0) # 3 units over + unpriced = _add_resource(project.id, "Labourers", capacity=4.0, unit_cost=None) + _assign(unpriced, task.id, 6.0) # 2 units over, no rate + db.session.commit() + + suggestions = analytics._ai_resource_optimization({"project_id": project.id}) + impact = analytics._calculate_cost_impact(suggestions) + + assert impact["over_allocation_cost"] == pytest.approx(3 * 700.0) + # Silently costing the unpriced resource at zero would understate the total. + assert impact["resources_without_a_unit_cost"] == ["Labourers"] + + +def test_efficiency_gains_cap_absorbable_work_at_the_idle_capacity(analytics, bare_project): + """Over-allocation can only be absorbed by capacity that exists.""" + project, task = bare_project + + over = _add_resource(project.id, "Fitters", capacity=5.0) + _assign(over, task.id, 15.0) # 10 units over + idle = _add_resource(project.id, "Painters", capacity=10.0) + _assign(idle, task.id, 8.0) # only 2 spare + db.session.commit() + + current = analytics._analyze_current_resources({"project_id": project.id}) + suggestions = analytics._ai_resource_optimization({"project_id": project.id}) + gains = analytics._calculate_efficiency_gains(current, suggestions) + + assert gains["over_allocated_units"] == 10.0 + assert gains["absorbable_units"] == 0.0 # Painters at 80% are not idle + assert gains["utilisation_spread_percent"] > 0 + + +def test_priorities_put_the_worst_over_allocation_first(analytics, bare_project): + project, task = bare_project + + _assign(_add_resource(project.id, "Mild", capacity=10.0), task.id, 11.0) # 110% + _assign(_add_resource(project.id, "Severe", capacity=10.0), task.id, 20.0) # 200% + _assign(_add_resource(project.id, "Spare", capacity=10.0), task.id, 1.0) # 10% + db.session.commit() + + ordered = analytics._prioritize_optimizations( + analytics._ai_resource_optimization({"project_id": project.id}) + ) + + assert [s["priority"] for s in ordered] == list(range(1, len(ordered) + 1)) + assert ordered[0]["resource"] == "Severe" + assert ordered[-1]["resource"] == "Spare" + + +def test_the_whole_optimisation_endpoint_returns_a_result(signed_in): + """End to end. This returned 500 on every request before the helpers + existed, because the first one it called did not.""" + client, project, _ = signed_in + + response = client.get(f"/api/ai/resource-optimization/{project.id}") + assert response.status_code == 200, response.get_data(as_text=True) + + body = response.get_json() + assert body["project_id"] == project.id + for key in ( + "current_allocation", + "optimization_suggestions", + "efficiency_gains", + "cost_impact", + "implementation_priority", + ): + assert key in body, f"{key} missing from the response" + + +# ── company insight ────────────────────────────────────────────────────── + + +def test_history_is_bucketed_so_a_trend_can_exist(analytics, seeded): + """The previous version returned four totals for the whole window, which + is a snapshot. _analyze_trends had nothing to compare against.""" + history = analytics._gather_historical_data(seeded.company_id, 90) + + assert len(history["periods"]) == 6 + assert sum(p["started"] for p in history["periods"]) == history["projects"] + + +def test_insights_report_measured_numbers_not_fixed_sentences(analytics, seeded): + history = analytics._gather_historical_data(seeded.company_id, 90) + insights = analytics._ai_company_insights(history) + + assert insights["projects_in_window"] >= 1 + assert insights["completion_rate_percent"] is not None + # The demo project carries real transactions, so spend is not zero. + assert insights["recorded_spend"] > 0 + assert str(insights["projects_in_window"]) in insights["performance_summary"] + + # The sentences the old implementation always returned. + for invented in ( + "Project completion rates are stable", + "Resource utilization could be optimized", + "Budget adherence is within acceptable range", + ): + assert invented not in str(insights) + + +def test_trends_refuse_to_report_one_without_enough_history(analytics): + assert analytics._analyze_trends({"periods": []})["available"] is False + assert ( + analytics._analyze_trends({"periods": [{"started": 1, "completed": 0}]})["available"] + is False + ) + + +def test_a_forecast_is_withheld_when_nothing_has_completed(analytics, seeded): + """Projecting a completion rate from zero completions is division by + wishful thinking. It says so instead.""" + history = analytics._gather_historical_data(seeded.company_id, 90) + assert history["completed"] == 0 + + forecast = analytics._predict_future_performance(history) + assert forecast["available"] is False + assert "completed" in forecast["reason"] + + +def test_benchmarking_uses_dcma_rather_than_an_invented_average(analytics, seeded): + history = analytics._gather_historical_data(seeded.company_id, 90) + benchmark = analytics._industry_benchmarking(history) + + assert benchmark["available"] is True + assert benchmark["standard"] == "DCMA 14-Point Schedule Assessment" + assert 0 <= benchmark["mean_health_score"] <= 100 + assert benchmark["band"] in {"strong", "acceptable", "weak", "poor"} + + +def test_recommendations_cite_the_number_that_triggered_them(analytics, seeded): + history = analytics._gather_historical_data(seeded.company_id, 90) + insights = analytics._ai_company_insights(history) + trends = analytics._analyze_trends(history) + + recommendations = analytics._strategic_recommendations(insights, trends) + assert recommendations + + # The demo project has completed nothing, so the delivery threshold fires. + themes = {r["theme"] for r in recommendations} + assert "delivery" in themes + text = " ".join(r["recommendation"] for r in recommendations) + assert "%" in text + + +def test_recommendations_say_so_when_nothing_needs_attention(analytics): + """A page of advice generated for a healthy portfolio is noise.""" + healthy = { + "completion_rate_percent": 92.0, + "projects_below_health_threshold": [], + "projects_over_budget": [], + } + flat = {"available": True, "projects_completed": {"direction": "flat"}} + + recommendations = analytics._strategic_recommendations(healthy, flat) + assert len(recommendations) == 1 + assert recommendations[0]["theme"] == "steady state" + + +def test_the_whole_insights_endpoint_returns_a_result(signed_in): + client, _, _ = signed_in + + response = client.get("/api/ai/company-insights") + assert response.status_code == 200, response.get_data(as_text=True) + + body = response.get_json() + for key in ( + "ai_insights", + "performance_trends", + "future_predictions", + "benchmarking", + "strategic_recommendations", + ): + assert key in body, f"{key} missing from the response" + + +def test_an_over_budget_project_is_named(analytics, seeded): + """Budget adherence used to be reported as "within acceptable range" + unconditionally.""" + from models import Transaction, TransactionType + + project = seeded + project.budget = 1000.0 + db.session.add( + Transaction( + transaction_number="TXN-OVERSPEND-1", + project_id=project.id, + company_id=project.company_id, + created_by_id=project.created_by, + amount=5000.0, + transaction_type=TransactionType.EXPENSE, + description="Overspend", + transaction_date=date.today() - timedelta(days=1), + ) + ) + db.session.commit() + + history = analytics._gather_historical_data(project.company_id, 90) + insights = analytics._ai_company_insights(history) + + assert project.name in insights["projects_over_budget"] diff --git a/tests/test_migrations.py b/tests/test_migrations.py index f81583e..e0ca87a 100644 --- a/tests/test_migrations.py +++ b/tests/test_migrations.py @@ -184,9 +184,15 @@ def test_project_number_is_unique_per_company_not_globally(fresh_db): @pytest.mark.slow def test_the_downgrade_reverses_cleanly(fresh_db): + """Downgrade to the baseline by name, not by one step. + + A bare `db downgrade` moves back a single revision, so this asserted the + rebuild tables were gone only while 0002 happened to be the head. Naming + the target keeps the test meaningful as migrations are added. + """ url, _ = fresh_db _run_flask("db", "upgrade", database_url=url) - _run_flask("db", "downgrade", database_url=url) + _run_flask("db", "downgrade", "0001_baseline", database_url=url) tables = set(inspect(create_engine(url)).get_table_names()) assert not ({"schedule_baselines", "equipment_usage_logs", "maintenance_records"} & tables) diff --git a/tests/test_static_integrity.py b/tests/test_static_integrity.py index b79fb1c..bf56eb8 100644 --- a/tests/test_static_integrity.py +++ b/tests/test_static_integrity.py @@ -1,16 +1,20 @@ """Calls to methods that do not exist. ``AzureAIPredictiveAnalytics.optimize_resource_allocation`` and -``generate_project_insights`` each call a chain of private helpers, eleven of -which were never written — and two of which sit at module scope taking ``self``, -having been dedented out of the class at some point. Both public methods raised -``AttributeError`` on their first line of real work, and the blueprint caught it -and returned a generic 500, so the endpoints looked merely unlucky rather than -impossible. +``generate_project_insights`` each called a chain of private helpers, nine of +which were never written. Both raised ``AttributeError`` on their first line of +real work, and the blueprint caught it and returned a generic 500, so the +endpoints looked merely unlucky rather than impossible. They are implemented +now; this check is what stops the shape recurring. Python does not resolve attributes until the line runs, so nothing but reaching the line reveals this. Reaching the line is exactly what does not happen for code with no test and no user. This walks the AST instead. + +The one thing it cannot see is a method attached at import time +(``SomeClass._helper = _helper``). Two of the original eleven were bound that +way and were reported as missing when they were not — so treat a finding as a +lead, and confirm with ``hasattr`` before recording it below. """ import ast @@ -40,26 +44,15 @@ "htmlcov", } -# The eleven helpers behind optimize_resource_allocation and -# generate_project_insights. Implementing them means designing a resource -# optimiser and a company analytics engine, which is a feature and not a fix — -# recorded here so the gap is explicit and so no *new* one can be added -# unnoticed. The two marked below exist at module scope taking `self`. -KNOWN_MISSING = { - ("azure_ai/predictive_analytics.py", "AzureAIPredictiveAnalytics"): { - "_ai_company_insights", # at module scope, takes self - "_ai_resource_optimization", - "_analyze_current_resources", - "_analyze_trends", - "_calculate_cost_impact", - "_calculate_efficiency_gains", - "_gather_historical_data", # at module scope, takes self - "_industry_benchmarking", - "_predict_future_performance", - "_prioritize_optimizations", - "_strategic_recommendations", - }, -} +# Deliberately empty. The eleven helpers behind +# optimize_resource_allocation and generate_project_insights are implemented, +# so any entry here now would be a fresh gap rather than a recorded one. +# +# Two of the eleven were never missing: they were attached to the class at +# import time with `AzureAIPredictiveAnalytics._x = _x`, which no AST can see. +# That is a false positive this check cannot avoid in general -- so if an entry +# ever needs adding, confirm with hasattr() before believing it. +KNOWN_MISSING: dict[tuple[str, str], set[str]] = {} def _python_files(): diff --git a/tests/test_templates.py b/tests/test_templates.py index e41cf3d..93a7ea9 100644 --- a/tests/test_templates.py +++ b/tests/test_templates.py @@ -18,17 +18,10 @@ TEMPLATE_ROOT = REPO_ROOT / "templates" # Pages a view renders that have not been written yet. Each 500s if requested. -KNOWN_MISSING = { - "admin/audit_logs.html", - "admin/company_settings.html", - "admin/edit_user.html", - "admin/system_status.html", - "azure/configure.html", - "azure/dashboard.html", - "projects/my_templates.html", - "projects/template_preview.html", - "reports/project_report.html", -} +# Every template a view names now exists. This set is deliberately empty: +# a new view rendering a template nobody wrote fails the test below rather +# than returning 500 to whoever clicks the link first. +KNOWN_MISSING: set[str] = set() SKIP_DIRS = { ".git",