Case Study 37-1: Priya Builds the Acme Corp Internal Dashboard
The Setup
The trigger was a Monday morning meeting that could have been a browser tab.
Sandra Chen had opened a Q3 business review with the Northeast sales team. Fifteen minutes in, she needed to know whether the Northeast was tracking ahead of or behind the Southeast for the same period last year. It was the kind of question that comes up in every sales meeting and requires a specific comparison that nobody had prepared.
"Priya, can you pull that?" Sandra said.
Priya had the data. She always had the data. But pulling it meant opening the quarterly CSV, running a pandas query she had written but not saved as a script, and formatting the result into something she could read aloud in a Zoom call. It took eight minutes. Everyone else waited. The meeting ran over.
That afternoon, Priya opened a new Python file and typed:
from flask import Flask
app = Flask(__name__)
@app.route("/")
def index():
return "Coming soon."
if __name__ == "__main__":
app.run(debug=True)
The planning took longer than the code. The code was the easy part.
The Plan
Priya had been using pandas for months. She knew exactly which metrics Sandra needed in meetings:
- Total revenue (year-to-date, by quarter, by region)
- Quota attainment percentage
- Deals closed (volume)
- Average deal size
- Top-performing reps
- Month-over-month trend
Sandra looked at these numbers daily. She refreshed a spreadsheet Priya emailed every Thursday. But between Thursdays, she was flying blind unless she asked Priya directly.
The solution Priya designed:
- A Flask application running on her laptop during development, then on the company intranet server
- A single dashboard page that reads
acme_sales.csvon each load and computes the metrics fresh - A simple password so not every employee could casually stumble onto sales data
- No database required — the CSV was already maintained by the existing data pipeline
She estimated two days of work. It took three — one day setting up the Flask application and routes, one day on the templates, and one unexpected afternoon chasing a bug in the Jinja2 filter syntax for currency formatting.
The Build
Priya started with the data layer. She extracted the metrics logic she had been writing in ad-hoc pandas scripts and formalized it into a load_sales_metrics() function:
def load_sales_metrics() -> dict:
"""Load and aggregate sales data from CSV."""
sales_df = pd.read_csv("data/acme_sales.csv", parse_dates=["sale_date"])
total_revenue = float(sales_df["revenue"].sum())
deals_closed = int(sales_df["deal_id"].nunique())
avg_deal_size = float(sales_df["revenue"].mean()) if deals_closed > 0 else 0.0
annual_quota = 1_500_000.0
quota_attainment = round((total_revenue / annual_quota) * 100, 1)
by_region = (
sales_df.groupby("region")["revenue"]
.sum()
.reset_index()
.sort_values("revenue", ascending=False)
.to_dict("records")
)
return {
"total_revenue": total_revenue,
"deals_closed": deals_closed,
"avg_deal_size": avg_deal_size,
"quota_attainment": quota_attainment,
"by_region": by_region,
}
She tested this function in isolation first — running it from the Python REPL and verifying the numbers against her existing spreadsheet. Only once it was correct did she wire it into the Flask route.
The route itself was deliberately simple:
@app.route("/dashboard")
@login_required
def dashboard():
metrics = load_sales_metrics()
return render_template("dashboard.html", metrics=metrics)
The complexity lived in the template, not the route. This was an intentional design choice: routes should be thin wrappers around business logic. The logic itself should live in plain Python functions that can be tested independently.
The Template Challenge
Priya's biggest learning curve was Jinja2's filter syntax. She wanted to display revenue as $1,245,780 — no decimal places, with comma separators. The first attempt:
<!-- This does not work in Jinja2 -->
{{ metrics.total_revenue | format(",d") }}
Jinja2's built-in filters do not include Python's full number formatting syntax. The correct approach:
<!-- This works — calls Python's format() function directly -->
${{ "{:,.0f}".format(metrics.total_revenue) }}
This is a subtle but important distinction: Jinja2's {{ }} blocks can call Python string methods. "{:,.0f}".format(number) is standard Python string formatting, not Jinja2 syntax.
The Access Control Decision
Priya wrestled with the authentication question more than she expected to.
The secure option — a real database of users with hashed passwords and role-based access — would have been two to three additional days of work, plus integration with Acme Corp's Active Directory or SSO system. Marcus Webb had raised this when she mentioned the project.
"If it's on the intranet, behind the VPN, with a password," Priya argued, "the sales data isn't exposed to the internet. The password is just to prevent accidental access."
Marcus thought about it. "If it's truly internal, behind the VPN — okay. But document it. And when you deploy to the server, make sure it's only accessible on the internal network."
Priya documented it:
# NOTE: This authentication mechanism is suitable for internal intranet tools
# with controlled network access. It is NOT appropriate for:
# - Public-facing applications
# - Applications containing PII or HIPAA-regulated data
# - Applications where audit logging of user access is required
#
# For production-grade auth, use Flask-Login with a proper user database,
# or deploy behind Acme Corp's SSO system.
She set the password in a .env file:
# .env — never commit this file to version control
SECRET_KEY=a-very-long-random-string-change-this
DASHBOARD_PASSWORD=AcmeSales2024!
And loaded it with python-dotenv:
from dotenv import load_dotenv
load_dotenv()
DASHBOARD_PASSWORD = os.environ.get("DASHBOARD_PASSWORD")
The .env file was added to .gitignore immediately. She wrote a .env.example file with the structure but no actual values, so the next person to run the application would know what to configure.
The Demo
Three days after the Monday meeting, Priya sent Sandra a link: http://10.0.1.45:5000/dashboard.
Sandra clicked it, was prompted for a password, entered it, and saw the dashboard. She clicked "Refresh" to verify the numbers matched her Thursday spreadsheet. They did, because they were reading from the same CSV.
"Can I bookmark this?" Sandra asked.
"That's the whole idea," Priya said.
Sandra bookmarked it. Then she said: "Can it show me the Northeast versus Southeast comparison from Monday's meeting?"
Priya added a regional comparison view that afternoon. It took forty-five minutes.
The IT Review
Marcus Webb's review was more thorough than Priya expected. He wanted to understand:
-
What is running the application? The development server, currently —
python app.py. Marcus wanted Gunicorn before it went on the company intranet server. "The dev server isn't made for this," he said. Priya had expected this; she already had the Gunicorn command ready. -
What data does it access? Only
acme_sales.csvand the expense submission log. No customer PII, no financial systems. Marcus was satisfied. -
What happens if the server goes down? The application stops responding. There is no auto-restart. Marcus configured a simple systemd service on the Linux intranet server that restarts the application if it crashes.
-
Where are the logs? Priya had not set up application logging. This was the gap Marcus pushed hardest on. She added structured logging before the intranet deployment:
import logging
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)s %(name)s — %(message)s",
handlers=[
logging.FileHandler("logs/app.log"),
logging.StreamHandler(),
],
)
logger = logging.getLogger(__name__)
# In the dashboard route:
@app.route("/dashboard")
@login_required
def dashboard():
logger.info("Dashboard accessed")
metrics = load_sales_metrics()
logger.info(
"Metrics loaded: revenue=%.2f, deals=%d",
metrics["total_revenue"],
metrics["deals_closed"],
)
return render_template("dashboard.html", metrics=metrics)
With logging in place, Marcus approved the deployment. "Now when something breaks, we'll know when and what triggered it," he said.
The Expense Form Bonus
While building the dashboard, Priya added the expense submission form — an idea Sandra had mentioned offhand. The existing expense process involved filling out a Word document, emailing it to finance, and waiting for a response confirming receipt.
The Flask form replaced the Word document and email step. Submissions went directly to submitted_expenses.csv, which the finance team could open in Excel at any time. The form included validation (required fields, valid amounts, date cannot be in the future) and sent the user to a confirmation page after successful submission.
The finance team's reaction was immediate. "We now have a searchable log instead of a hundred emails with attachments," said Divya Sharma from Finance. She requested one additional feature: a way to mark expenses as approved. That became version 1.1.
Outcomes and Lessons
Four weeks after deployment: - Sandra had not sent Priya a "can you pull the sales numbers" message once - The finance team processed expenses 40% faster with the centralized log - Marcus had received zero support tickets about the application - Priya had shipped two minor updates (the regional comparison and expense approval status) in the time it previously would have taken to respond to one ad-hoc data request
What Priya learned:
Templates are harder than routes. The Flask routing logic took less than an hour to write. The templates — getting the layout right, the number formatting, the responsive design — took most of the three days. This is normal. Business logic is what you already know. Presentation logic is a separate skill.
Separate data functions from route functions. load_sales_metrics() is a pure Python function that takes no arguments and returns a dictionary. It can be tested, debugged, and modified without touching the Flask code at all. This separation made debugging much faster.
.env from day one. Priya almost hardcoded the dashboard password the first night because she was tired and just wanted to get it working. She caught herself. The discipline of never putting secrets in source code is a habit worth building early.
Ship something simple first. Version 1.0 of the dashboard had four metric cards and one table. It went live after three days. It immediately started delivering value. The additional features came later, informed by real feedback from Sandra and the finance team. The version that was perfect on paper but never deployed would have delivered nothing.
Technical Snapshot
| Component | Technology | Notes |
|---|---|---|
| Web framework | Flask 3.0 | Minimal — just routing and templates |
| Templates | Jinja2 (bundled with Flask) | Template inheritance via base.html |
| Data layer | pandas + CSV | Re-reads CSV on every dashboard load |
| Auth | Flask sessions + env-var password | Appropriate for internal intranet use |
| Production server | Gunicorn | Configured by Marcus for intranet server |
| Process management | systemd | Auto-restarts on crash |
| Configuration | python-dotenv | Secrets in .env, never in source |
Lines of code: approximately 280 (app.py) + 6 template files Time to build: 3 days (developer with strong Python, new to Flask) Time to value: Same day as intranet deployment
Next: Case Study 37-2 — Maya builds a client-facing project status portal.