mirror of
https://github.com/DRYTRIX/TimeTracker.git
synced 2026-02-17 19:18:38 -06:00
261
ADVANCED_REPORT_BUILDER_IMPLEMENTATION.md
Normal file
261
ADVANCED_REPORT_BUILDER_IMPLEMENTATION.md
Normal file
@@ -0,0 +1,261 @@
|
||||
# Advanced Report Builder Implementation Summary
|
||||
|
||||
## Overview
|
||||
|
||||
This document summarizes the implementation of the Advanced Report Builder enhancements, including iterative report generation, custom field filtering, and improved scheduled report distribution.
|
||||
|
||||
## Features Implemented
|
||||
|
||||
### 1. Iterative Report Generation
|
||||
|
||||
**What it does:**
|
||||
- Generates one report per unique value of a specified custom field (e.g., one report per salesman)
|
||||
- Automatically extracts all unique values from clients and generates separate reports
|
||||
- Perfect for scenarios where you need separate reports for each salesman, region, or other custom field value
|
||||
|
||||
**How to use:**
|
||||
1. In Report Builder, create or edit a report
|
||||
2. In the Save modal, enable "Iterative Report Generation"
|
||||
3. Select the custom field to iterate over (e.g., "salesman")
|
||||
4. When viewing the report, you'll see separate sections for each unique value
|
||||
|
||||
**Technical Details:**
|
||||
- New fields in `SavedReportView` model:
|
||||
- `iterative_report_generation` (Boolean)
|
||||
- `iterative_custom_field_name` (String)
|
||||
- Route: `/reports/builder/<view_id>` automatically detects iterative mode
|
||||
- Template: `reports/iterative_view.html` displays all reports grouped by field value
|
||||
|
||||
### 2. Custom Field-Based Filtering
|
||||
|
||||
**What it does:**
|
||||
- Filter reports by any custom field on clients
|
||||
- Works in both Report Builder and Scheduled Reports
|
||||
- Supports filtering for unpaid hours reports
|
||||
|
||||
**How to use:**
|
||||
1. In Report Builder filters, select a custom field (e.g., "salesman")
|
||||
2. Enter the value to filter by (e.g., "MM")
|
||||
3. The report will only show data for clients matching that custom field value
|
||||
|
||||
**Technical Details:**
|
||||
- Enhanced `generate_report_data()` function in `custom_reports.py`
|
||||
- Supports filtering in `UnpaidHoursService`
|
||||
- Works with both direct client custom fields and project->client relationships
|
||||
|
||||
### 3. Enhanced Scheduled Report Distribution
|
||||
|
||||
**What it does:**
|
||||
- Supports three email distribution modes:
|
||||
- **Mapping**: Uses `SalesmanEmailMapping` table to map custom field values to email addresses
|
||||
- **Template**: Uses dynamic email templates (e.g., `{value}@test.de`)
|
||||
- **Single**: Sends all reports to the same recipients (fallback)
|
||||
|
||||
**How to use:**
|
||||
1. Create a scheduled report with "Split by Custom Field" enabled
|
||||
2. Choose email distribution mode:
|
||||
- **Mapping**: Set up mappings in Salesman Email Mapping (if available)
|
||||
- **Template**: Enter template like `{value}@test.de`
|
||||
- **Single**: Use default recipients field
|
||||
|
||||
**Technical Details:**
|
||||
- New fields in `ReportEmailSchedule` model:
|
||||
- `email_distribution_mode` (String: 'mapping', 'template', 'single')
|
||||
- `recipient_email_template` (String: e.g., '{value}@test.de')
|
||||
- Enhanced `_get_recipients_for_field_value()` method in `ScheduledReportService`
|
||||
- Automatically resolves email addresses based on distribution mode
|
||||
|
||||
### 4. Improved Unpaid Hours Workflow
|
||||
|
||||
**What it does:**
|
||||
- Clear checkbox option for "Unpaid Hours Only" in Report Builder
|
||||
- Better integration with custom field filtering
|
||||
- Clearer UI with helpful tooltips
|
||||
|
||||
**How to use:**
|
||||
1. In Report Builder, select "Time Entries" as data source
|
||||
2. Check "Unpaid Hours Only" checkbox
|
||||
3. Optionally add custom field filter to segment by salesman
|
||||
4. Preview or save the report
|
||||
|
||||
**Technical Details:**
|
||||
- Uses `UnpaidHoursService` for accurate unpaid hours calculation
|
||||
- Filters out entries that are:
|
||||
- Already in invoices (via `InvoiceItem.time_entry_ids`)
|
||||
- Marked as paid
|
||||
- Not billable
|
||||
|
||||
### 5. Enhanced Management Views
|
||||
|
||||
**What it does:**
|
||||
- Comprehensive list of saved report views with edit/delete options
|
||||
- Shows iterative generation status
|
||||
- Better error handling in scheduled reports view
|
||||
- Ability to fix or remove invalid scheduled reports
|
||||
|
||||
**How to use:**
|
||||
1. Navigate to "Saved Views" from Report Builder
|
||||
2. View all your saved reports with their features
|
||||
3. Edit, view, or delete reports as needed
|
||||
4. In Scheduled Reports, use the "Fix" button to resolve invalid schedules
|
||||
|
||||
**Technical Details:**
|
||||
- Enhanced `list_saved_views()` route
|
||||
- New `fix_scheduled()` route to handle invalid schedules
|
||||
- Improved error handling in `list_scheduled()` route
|
||||
- Validates saved views and filters out invalid ones
|
||||
|
||||
### 6. Better Error Handling
|
||||
|
||||
**What it does:**
|
||||
- Prevents errors from breaking the Scheduled Reports view
|
||||
- Validates report configurations before displaying
|
||||
- Provides clear error messages and fix options
|
||||
|
||||
**Technical Details:**
|
||||
- Enhanced error handling in `scheduled_reports.py`
|
||||
- Validates JSON configs before processing
|
||||
- Gracefully handles missing saved views
|
||||
- Provides fix/remove options for invalid schedules
|
||||
|
||||
## Database Changes
|
||||
|
||||
### Migration: `090_enhance_report_builder_iteration`
|
||||
|
||||
**New Columns:**
|
||||
|
||||
1. **saved_report_views table:**
|
||||
- `iterative_report_generation` (Boolean, default: false)
|
||||
- `iterative_custom_field_name` (String, nullable)
|
||||
|
||||
2. **report_email_schedules table:**
|
||||
- `email_distribution_mode` (String, nullable) - Values: 'mapping', 'template', 'single'
|
||||
- `recipient_email_template` (String, nullable) - Template like '{value}@test.de'
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### New/Enhanced Routes
|
||||
|
||||
1. **GET `/reports/builder/<view_id>`**
|
||||
- Now supports iterative report generation
|
||||
- Automatically detects if iterative mode is enabled
|
||||
|
||||
2. **GET `/api/reports/builder/custom-field-values`**
|
||||
- Returns unique values for a custom field
|
||||
- Query parameter: `field_name`
|
||||
|
||||
3. **POST `/reports/scheduled/<schedule_id>/fix`**
|
||||
- Fixes or removes invalid scheduled reports
|
||||
- Validates saved view and config
|
||||
|
||||
4. **POST `/reports/builder/save`**
|
||||
- Enhanced to accept iterative report generation settings
|
||||
- New fields: `iterative_report_generation`, `iterative_custom_field_name`
|
||||
|
||||
## Files Modified
|
||||
|
||||
### Backend
|
||||
- `app/models/reporting.py` - Added new model fields
|
||||
- `app/routes/custom_reports.py` - Enhanced with iterative generation
|
||||
- `app/routes/scheduled_reports.py` - Improved error handling, added fix route
|
||||
- `app/services/scheduled_report_service.py` - Enhanced email distribution
|
||||
- `migrations/versions/090_enhance_report_builder_iteration.py` - New migration
|
||||
|
||||
### Frontend
|
||||
- `app/templates/reports/builder.html` - Added iterative generation UI
|
||||
- `app/templates/reports/saved_views_list.html` - Shows iterative status
|
||||
- `app/templates/reports/scheduled.html` - Enhanced with distribution info and fix button
|
||||
- `app/templates/reports/iterative_view.html` - New template for iterative reports
|
||||
|
||||
## Usage Examples
|
||||
|
||||
### Example 1: Unpaid Hours Report by Salesman
|
||||
|
||||
1. Create a new report in Report Builder
|
||||
2. Select "Time Entries" as data source
|
||||
3. Enable "Unpaid Hours Only"
|
||||
4. Enable "Iterative Report Generation"
|
||||
5. Select "salesman" as the custom field
|
||||
6. Save the report
|
||||
7. View the report to see separate sections for each salesman
|
||||
|
||||
### Example 2: Scheduled Reports with Email Mapping
|
||||
|
||||
1. Create a scheduled report
|
||||
2. Enable "Split by Custom Field" and select "salesman"
|
||||
3. Set email distribution mode to "mapping"
|
||||
4. Ensure `SalesmanEmailMapping` entries exist (MM -> mm@test.de, PB -> pb@test.de)
|
||||
5. Schedule will automatically send reports to the correct email for each salesman
|
||||
|
||||
### Example 3: Scheduled Reports with Email Template
|
||||
|
||||
1. Create a scheduled report
|
||||
2. Enable "Split by Custom Field" and select "salesman"
|
||||
3. Set email distribution mode to "template"
|
||||
4. Enter template: `{value}@test.de`
|
||||
5. Reports will be sent to MM@test.de, PB@test.de, etc.
|
||||
|
||||
## Testing Recommendations
|
||||
|
||||
1. **Unit Tests:**
|
||||
- Test iterative report generation logic
|
||||
- Test email distribution modes
|
||||
- Test custom field filtering
|
||||
|
||||
2. **Integration Tests:**
|
||||
- Test full workflow: create report → enable iterative → view report
|
||||
- Test scheduled reports with different distribution modes
|
||||
- Test error handling for invalid schedules
|
||||
|
||||
3. **Smoke Tests:**
|
||||
- Create unpaid hours report with custom field filter
|
||||
- Create iterative report and verify all values are shown
|
||||
- Create scheduled report and verify email distribution
|
||||
|
||||
## Known Limitations
|
||||
|
||||
1. **Email Mapping:**
|
||||
- Requires `SalesmanEmailMapping` entries to be set up manually
|
||||
- Falls back to default recipients if mapping not found
|
||||
|
||||
2. **Custom Field Values:**
|
||||
- Only extracts values from active clients
|
||||
- Values must be present in client custom_fields JSON
|
||||
|
||||
3. **Iterative Reports:**
|
||||
- Currently only works for time entries data source
|
||||
- Other data sources will need similar implementation
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
1. Support iterative generation for other data sources (projects, invoices, etc.)
|
||||
2. Add UI for managing email mappings directly in scheduled reports
|
||||
3. Add preview for iterative reports before saving
|
||||
4. Support multiple custom fields for iteration
|
||||
5. Add export functionality for iterative reports
|
||||
|
||||
## Migration Instructions
|
||||
|
||||
1. Run the migration:
|
||||
```bash
|
||||
flask db upgrade
|
||||
```
|
||||
|
||||
2. Verify the migration:
|
||||
```bash
|
||||
flask db current
|
||||
```
|
||||
|
||||
3. Test the new features:
|
||||
- Create a test report with iterative generation
|
||||
- Create a test scheduled report with email distribution
|
||||
- Verify error handling works correctly
|
||||
|
||||
## Support
|
||||
|
||||
For issues or questions:
|
||||
1. Check the error logs in `logs/timetracker.log`
|
||||
2. Verify custom field values exist in client records
|
||||
3. Check that email mappings are set up correctly (if using mapping mode)
|
||||
4. Ensure saved report views have valid JSON configurations
|
||||
|
||||
@@ -61,9 +61,10 @@ class CustomFieldDefinition(db.Model):
|
||||
)
|
||||
except RuntimeError:
|
||||
pass # No application context
|
||||
# Rollback the failed transaction
|
||||
# Rollback the failed transaction and clear session state
|
||||
try:
|
||||
db.session.rollback()
|
||||
db.session.expunge_all() # Clear all objects from session
|
||||
except Exception:
|
||||
pass
|
||||
return []
|
||||
@@ -104,9 +105,10 @@ class CustomFieldDefinition(db.Model):
|
||||
)
|
||||
except RuntimeError:
|
||||
pass # No application context
|
||||
# Rollback the failed transaction
|
||||
# Rollback the failed transaction and clear session state
|
||||
try:
|
||||
db.session.rollback()
|
||||
db.session.expunge_all() # Clear all objects from session
|
||||
except Exception:
|
||||
pass
|
||||
return []
|
||||
@@ -147,9 +149,10 @@ class CustomFieldDefinition(db.Model):
|
||||
)
|
||||
except RuntimeError:
|
||||
pass # No application context
|
||||
# Rollback the failed transaction
|
||||
# Rollback the failed transaction and clear session state
|
||||
try:
|
||||
db.session.rollback()
|
||||
db.session.expunge_all() # Clear all objects from session
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from datetime import datetime
|
||||
from app import db
|
||||
from sqlalchemy.exc import ProgrammingError
|
||||
|
||||
|
||||
class LinkTemplate(db.Model):
|
||||
@@ -65,8 +66,47 @@ class LinkTemplate(db.Model):
|
||||
|
||||
@classmethod
|
||||
def get_active_templates(cls, field_key=None):
|
||||
"""Get active link templates, optionally filtered by field_key"""
|
||||
query = cls.query.filter_by(is_active=True)
|
||||
if field_key:
|
||||
query = query.filter_by(field_key=field_key)
|
||||
return query.order_by(cls.order, cls.name).all()
|
||||
"""Get active link templates, optionally filtered by field_key.
|
||||
|
||||
Returns empty list if table doesn't exist (migration not run yet).
|
||||
"""
|
||||
try:
|
||||
query = cls.query.filter_by(is_active=True)
|
||||
if field_key:
|
||||
query = query.filter_by(field_key=field_key)
|
||||
return query.order_by(cls.order, cls.name).all()
|
||||
except ProgrammingError as e:
|
||||
# Handle case where link_templates table doesn't exist (migration not run)
|
||||
if "does not exist" in str(e.orig) or "relation" in str(e.orig).lower():
|
||||
try:
|
||||
from flask import current_app
|
||||
if current_app:
|
||||
current_app.logger.warning(
|
||||
"link_templates table does not exist. Run migration: flask db upgrade"
|
||||
)
|
||||
except RuntimeError:
|
||||
pass # No application context
|
||||
# Rollback the failed transaction and clear session state
|
||||
try:
|
||||
db.session.rollback()
|
||||
db.session.expunge_all() # Clear all objects from session
|
||||
except Exception:
|
||||
pass
|
||||
return []
|
||||
raise
|
||||
except Exception:
|
||||
# For other database errors, return empty list to prevent breaking the app
|
||||
try:
|
||||
from flask import current_app
|
||||
if current_app:
|
||||
current_app.logger.warning(
|
||||
"Could not query link_templates. Returning empty list."
|
||||
)
|
||||
except RuntimeError:
|
||||
pass # No application context
|
||||
# Rollback the failed transaction
|
||||
try:
|
||||
db.session.rollback()
|
||||
except Exception:
|
||||
pass
|
||||
return []
|
||||
|
||||
@@ -12,6 +12,8 @@ class SavedReportView(db.Model):
|
||||
owner_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False, index=True)
|
||||
scope = db.Column(db.String(20), default="private", nullable=False) # private, team, public
|
||||
config_json = db.Column(db.Text, nullable=False) # JSON for filters, columns, groupings
|
||||
iterative_report_generation = db.Column(db.Boolean, default=False, nullable=False) # Generate one report per custom field value
|
||||
iterative_custom_field_name = db.Column(db.String(50), nullable=True) # Custom field name for iteration (e.g., 'salesman')
|
||||
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)
|
||||
|
||||
@@ -35,6 +37,8 @@ class ReportEmailSchedule(db.Model):
|
||||
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')
|
||||
email_distribution_mode = db.Column(db.String(20), nullable=True) # 'mapping', 'template', 'single'
|
||||
recipient_email_template = db.Column(db.String(255), nullable=True) # e.g., '{value}@test.de' for template mode
|
||||
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)
|
||||
|
||||
@@ -148,6 +148,12 @@ class User(UserMixin, db.Model):
|
||||
# Check if user has any admin role
|
||||
return any(role.name in ["admin", "super_admin"] for role in self.roles)
|
||||
|
||||
@property
|
||||
def is_super_admin(self):
|
||||
"""Check if user is a super admin"""
|
||||
# Check if user has super_admin role
|
||||
return any(role.name == "super_admin" for role in self.roles)
|
||||
|
||||
@property
|
||||
def active_timer(self):
|
||||
"""Get the user's currently active timer"""
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from flask import Blueprint, jsonify, request, current_app, send_from_directory, make_response
|
||||
from flask_login import login_required, current_user
|
||||
from flask_babel import gettext as _
|
||||
from app import db, socketio
|
||||
from app.models import (
|
||||
User,
|
||||
@@ -1220,6 +1221,118 @@ def get_project_tasks(project_id):
|
||||
)
|
||||
|
||||
|
||||
@api_bp.route("/api/tasks/create", methods=["POST"])
|
||||
@login_required
|
||||
def create_task_inline():
|
||||
"""Create a new task via AJAX with default values"""
|
||||
# Detect AJAX/JSON request
|
||||
try:
|
||||
is_classic_form = request.mimetype in ("application/x-www-form-urlencoded", "multipart/form-data")
|
||||
except Exception:
|
||||
is_classic_form = False
|
||||
|
||||
try:
|
||||
wants_json = (
|
||||
request.headers.get("X-Requested-With") == "XMLHttpRequest"
|
||||
or request.is_json
|
||||
or (
|
||||
not is_classic_form
|
||||
and (request.accept_mimetypes["application/json"] > request.accept_mimetypes["text/html"])
|
||||
)
|
||||
)
|
||||
except Exception:
|
||||
wants_json = False
|
||||
|
||||
if request.method == "POST":
|
||||
# Get data from JSON or form
|
||||
if request.is_json:
|
||||
data = request.get_json()
|
||||
name = data.get("name", "").strip()
|
||||
project_id = data.get("project_id")
|
||||
if project_id is not None:
|
||||
project_id = int(project_id)
|
||||
else:
|
||||
name = request.form.get("name", "").strip()
|
||||
project_id = request.form.get("project_id", type=int)
|
||||
|
||||
# Validate required fields
|
||||
if not name or not project_id:
|
||||
if wants_json:
|
||||
return jsonify({"error": "name and project_id are required"}), 400
|
||||
from flask import flash, redirect, url_for
|
||||
flash(_("Task name and project are required"), "error")
|
||||
return redirect(url_for("tasks.list_tasks"))
|
||||
|
||||
# Validate project exists and is active
|
||||
project = Project.query.filter_by(id=project_id, status="active").first()
|
||||
if not project:
|
||||
if wants_json:
|
||||
return jsonify({"error": "Project not found or inactive"}), 404
|
||||
from flask import flash, redirect, url_for
|
||||
flash(_("Selected project does not exist or is inactive"), "error")
|
||||
return redirect(url_for("tasks.list_tasks"))
|
||||
|
||||
# Create task with defaults using TaskService
|
||||
from app.services import TaskService
|
||||
|
||||
task_service = TaskService()
|
||||
result = task_service.create_task(
|
||||
name=name,
|
||||
project_id=project_id,
|
||||
created_by=current_user.id,
|
||||
assignee_id=current_user.id, # Assign to current user
|
||||
priority="medium", # Default priority
|
||||
due_date=None, # No due date
|
||||
description=None,
|
||||
estimated_hours=None,
|
||||
)
|
||||
|
||||
if not result["success"]:
|
||||
if wants_json:
|
||||
return jsonify({"error": result.get("message", "Failed to create task")}), 400
|
||||
from flask import flash, redirect, url_for
|
||||
flash(_(result["message"]), "error")
|
||||
return redirect(url_for("tasks.list_tasks"))
|
||||
|
||||
task = result["task"]
|
||||
|
||||
# Log task creation
|
||||
from app.models import Activity
|
||||
from app import log_event, track_event
|
||||
|
||||
log_event(
|
||||
"task.created",
|
||||
user_id=current_user.id,
|
||||
task_id=task.id,
|
||||
project_id=project_id,
|
||||
priority="medium",
|
||||
)
|
||||
track_event(
|
||||
current_user.id, "task.created", {"task_id": task.id, "project_id": project_id, "priority": "medium"}
|
||||
)
|
||||
|
||||
Activity.log(
|
||||
user_id=current_user.id,
|
||||
action="created",
|
||||
entity_type="task",
|
||||
entity_id=task.id,
|
||||
entity_name=task.name,
|
||||
description=f'Created task "{task.name}" in project "{project.name}"',
|
||||
extra_data={"project_id": project_id, "priority": "medium"},
|
||||
ip_address=request.remote_addr,
|
||||
user_agent=request.headers.get("User-Agent"),
|
||||
)
|
||||
|
||||
if wants_json:
|
||||
return jsonify({"success": True, "id": task.id, "name": task.name, "task": task.to_dict()}), 201
|
||||
from flask import flash, redirect, url_for
|
||||
flash(_('Task "%(name)s" created successfully', name=name), "success")
|
||||
return redirect(url_for("tasks.view_task", task_id=task.id))
|
||||
|
||||
# GET request - redirect to task list
|
||||
return redirect(url_for("tasks.list_tasks"))
|
||||
|
||||
|
||||
# Fetch a single time entry (details for edit modal)
|
||||
@api_bp.route("/api/entry/<int:entry_id>", methods=["GET"])
|
||||
@login_required
|
||||
|
||||
@@ -94,6 +94,10 @@ def save_report_view():
|
||||
if not isinstance(config, dict):
|
||||
return jsonify({"success": False, "message": "Config must be a dictionary"}), 400
|
||||
|
||||
# Extract iterative report generation settings
|
||||
iterative_report_generation = data.get("iterative_report_generation", False)
|
||||
iterative_custom_field_name = data.get("iterative_custom_field_name", "").strip() or None
|
||||
|
||||
# If view_id is provided, update existing report
|
||||
if view_id:
|
||||
existing = SavedReportView.query.get(view_id)
|
||||
@@ -108,6 +112,8 @@ def save_report_view():
|
||||
existing.name = name
|
||||
existing.config_json = json.dumps(config)
|
||||
existing.scope = scope
|
||||
existing.iterative_report_generation = iterative_report_generation
|
||||
existing.iterative_custom_field_name = iterative_custom_field_name
|
||||
existing.updated_at = datetime.utcnow()
|
||||
saved_view = existing
|
||||
action = "updated"
|
||||
@@ -124,7 +130,12 @@ def save_report_view():
|
||||
else:
|
||||
# Create new
|
||||
saved_view = SavedReportView(
|
||||
name=name, owner_id=current_user.id, scope=scope, config_json=json.dumps(config)
|
||||
name=name,
|
||||
owner_id=current_user.id,
|
||||
scope=scope,
|
||||
config_json=json.dumps(config),
|
||||
iterative_report_generation=iterative_report_generation,
|
||||
iterative_custom_field_name=iterative_custom_field_name,
|
||||
)
|
||||
db.session.add(saved_view)
|
||||
action = "created"
|
||||
@@ -152,7 +163,7 @@ def save_report_view():
|
||||
@custom_reports_bp.route("/reports/builder/<int:view_id>")
|
||||
@login_required
|
||||
def view_custom_report(view_id):
|
||||
"""View a custom report."""
|
||||
"""View a custom report. Supports iterative generation if enabled."""
|
||||
saved_view = SavedReportView.query.get_or_404(view_id)
|
||||
|
||||
# Check access
|
||||
@@ -166,7 +177,12 @@ def view_custom_report(view_id):
|
||||
except:
|
||||
config = {}
|
||||
|
||||
# Generate report data based on config
|
||||
# Check if iterative report generation is enabled
|
||||
if saved_view.iterative_report_generation and saved_view.iterative_custom_field_name:
|
||||
# Generate reports for each custom field value
|
||||
return _generate_iterative_reports(saved_view, config, current_user.id)
|
||||
|
||||
# Generate single report data based on config
|
||||
report_data = generate_report_data(config, current_user.id)
|
||||
|
||||
return render_template("reports/custom_view.html", saved_view=saved_view, config=config, report_data=report_data)
|
||||
@@ -403,6 +419,46 @@ def list_saved_views():
|
||||
return render_template("reports/saved_views_list.html", saved_views=saved_views, convert_app_datetime_to_user=convert_app_datetime_to_user)
|
||||
|
||||
|
||||
@custom_reports_bp.route("/reports/builder/<int:view_id>/edit", methods=["GET"])
|
||||
@login_required
|
||||
def edit_saved_view(view_id):
|
||||
"""Edit a saved report view - redirects to builder with view_id."""
|
||||
saved_view = SavedReportView.query.get_or_404(view_id)
|
||||
|
||||
# Check permission
|
||||
if saved_view.owner_id != current_user.id and saved_view.scope == "private":
|
||||
flash(_("You do not have permission to edit this report."), "error")
|
||||
return redirect(url_for("custom_reports.list_saved_views"))
|
||||
|
||||
# Redirect to builder with edit mode
|
||||
return redirect(url_for("custom_reports.report_builder", view_id=view_id))
|
||||
|
||||
|
||||
@custom_reports_bp.route("/api/reports/builder/custom-field-values", methods=["GET"])
|
||||
@login_required
|
||||
def get_custom_field_values():
|
||||
"""Get unique values for a custom field from clients."""
|
||||
custom_field_name = request.args.get("field_name")
|
||||
if not custom_field_name:
|
||||
return jsonify({"success": False, "message": "field_name parameter is required"}), 400
|
||||
|
||||
# Get all active clients
|
||||
clients = Client.query.filter_by(status="active").all()
|
||||
unique_values = set()
|
||||
|
||||
for client in clients:
|
||||
if client.custom_fields and custom_field_name in client.custom_fields:
|
||||
value = client.custom_fields[custom_field_name]
|
||||
if value:
|
||||
unique_values.add(str(value).strip())
|
||||
|
||||
return jsonify({
|
||||
"success": True,
|
||||
"field_name": custom_field_name,
|
||||
"values": sorted(list(unique_values))
|
||||
})
|
||||
|
||||
|
||||
@custom_reports_bp.route("/reports/builder/<int:view_id>/delete", methods=["POST"])
|
||||
@login_required
|
||||
def delete_saved_view(view_id):
|
||||
@@ -430,3 +486,87 @@ def delete_saved_view(view_id):
|
||||
flash(_("Could not delete report view due to a database error"), "error")
|
||||
|
||||
return redirect(url_for("custom_reports.list_saved_views"))
|
||||
|
||||
|
||||
def _generate_iterative_reports(saved_view: SavedReportView, config: dict, user_id: int):
|
||||
"""
|
||||
Generate multiple reports, one per custom field value.
|
||||
|
||||
Returns a template with all reports grouped by custom field value.
|
||||
"""
|
||||
from app.models import Client, TimeEntry
|
||||
from flask import render_template
|
||||
|
||||
custom_field_name = saved_view.iterative_custom_field_name
|
||||
|
||||
# Get date range from config
|
||||
filters = config.get("filters", {})
|
||||
start_date = filters.get("start_date")
|
||||
end_date = filters.get("end_date")
|
||||
|
||||
try:
|
||||
if start_date and isinstance(start_date, str) and start_date.strip():
|
||||
start_dt = datetime.strptime(start_date.strip(), "%Y-%m-%d")
|
||||
else:
|
||||
start_dt = datetime.utcnow() - timedelta(days=30)
|
||||
except (ValueError, AttributeError):
|
||||
start_dt = datetime.utcnow() - timedelta(days=30)
|
||||
|
||||
try:
|
||||
if end_date and isinstance(end_date, str) and end_date.strip():
|
||||
end_dt = datetime.strptime(end_date.strip(), "%Y-%m-%d") + timedelta(days=1) - timedelta(seconds=1)
|
||||
else:
|
||||
end_dt = datetime.utcnow()
|
||||
except (ValueError, AttributeError):
|
||||
end_dt = datetime.utcnow()
|
||||
|
||||
# Get all unique values for the custom field
|
||||
clients = Client.query.filter_by(status="active").all()
|
||||
unique_values = set()
|
||||
|
||||
# Collect unique values from clients
|
||||
for client in clients:
|
||||
if client.custom_fields and custom_field_name in client.custom_fields:
|
||||
value = client.custom_fields[custom_field_name]
|
||||
if value:
|
||||
unique_values.add(str(value).strip())
|
||||
|
||||
# Also check from time entries in the date range
|
||||
time_entries = TimeEntry.query.filter(
|
||||
TimeEntry.end_time.isnot(None),
|
||||
TimeEntry.start_time >= start_dt,
|
||||
TimeEntry.start_time <= end_dt
|
||||
).all()
|
||||
|
||||
for entry in time_entries:
|
||||
client = None
|
||||
if entry.project and entry.project.client_obj:
|
||||
client = entry.project.client_obj
|
||||
elif entry.client:
|
||||
client = entry.client
|
||||
|
||||
if client and client.custom_fields and custom_field_name in client.custom_fields:
|
||||
value = client.custom_fields[custom_field_name]
|
||||
if value:
|
||||
unique_values.add(str(value).strip())
|
||||
|
||||
# Generate report for each value
|
||||
iterative_reports = {}
|
||||
for field_value in sorted(unique_values):
|
||||
# Create modified config with custom field filter
|
||||
modified_config = config.copy()
|
||||
if "filters" not in modified_config:
|
||||
modified_config["filters"] = {}
|
||||
modified_config["filters"]["custom_field_filter"] = {custom_field_name: field_value}
|
||||
|
||||
# Generate report data
|
||||
report_data = generate_report_data(modified_config, user_id)
|
||||
iterative_reports[field_value] = report_data
|
||||
|
||||
return render_template(
|
||||
"reports/iterative_view.html",
|
||||
saved_view=saved_view,
|
||||
config=config,
|
||||
iterative_reports=iterative_reports,
|
||||
custom_field_name=custom_field_name,
|
||||
)
|
||||
|
||||
@@ -47,7 +47,11 @@ def gantt_data():
|
||||
if project_id:
|
||||
query = query.filter_by(id=project_id)
|
||||
|
||||
if not current_user.is_admin:
|
||||
# Check if user has permission to view all projects
|
||||
# Users with view_projects permission can see all projects, otherwise filter by their own
|
||||
has_view_all_projects = current_user.is_admin or current_user.has_permission("view_projects")
|
||||
|
||||
if not has_view_all_projects:
|
||||
# Filter by user's projects or projects they have time entries for
|
||||
query = query.filter(
|
||||
db.or_(
|
||||
|
||||
@@ -12,11 +12,14 @@ kanban_bp = Blueprint("kanban", __name__)
|
||||
@kanban_bp.route("/kanban")
|
||||
@login_required
|
||||
def board():
|
||||
"""Kanban board page with optional project filter"""
|
||||
"""Kanban board page with optional project and user filters"""
|
||||
project_id = request.args.get("project_id", type=int)
|
||||
user_id = request.args.get("user_id", type=int)
|
||||
query = Task.query
|
||||
if project_id:
|
||||
query = query.filter_by(project_id=project_id)
|
||||
if user_id:
|
||||
query = query.filter_by(assigned_to=user_id)
|
||||
# Order tasks for stable rendering
|
||||
tasks = query.order_by(Task.priority.desc(), Task.due_date.asc(), Task.created_at.asc()).all()
|
||||
# Fresh columns - use project-specific columns if project_id is provided
|
||||
@@ -34,12 +37,14 @@ def board():
|
||||
else:
|
||||
columns = []
|
||||
# Provide projects for filter dropdown
|
||||
from app.models import Project
|
||||
from app.models import Project, User
|
||||
|
||||
projects = Project.query.filter_by(status="active").order_by(Project.name).all()
|
||||
# Provide users for filter dropdown (active users only)
|
||||
users = User.query.filter_by(is_active=True).order_by(User.full_name, User.username).all()
|
||||
# No-cache
|
||||
response = render_template(
|
||||
"kanban/board.html", tasks=tasks, kanban_columns=columns, projects=projects, project_id=project_id
|
||||
"kanban/board.html", tasks=tasks, kanban_columns=columns, projects=projects, users=users, project_id=project_id, user_id=user_id
|
||||
)
|
||||
resp = make_response(response)
|
||||
resp.headers["Cache-Control"] = "no-cache, no-store, must-revalidate, max-age=0"
|
||||
|
||||
@@ -218,18 +218,43 @@ def manage_user_roles(user_id):
|
||||
# Get selected role IDs
|
||||
role_ids = request.form.getlist("roles")
|
||||
|
||||
# Validate role assignments - only super_admins can assign super_admin roles
|
||||
# and only super_admins can remove admin roles
|
||||
is_super_admin = current_user.is_super_admin
|
||||
selected_roles = [Role.query.get(int(role_id)) for role_id in role_ids if role_id]
|
||||
selected_roles = [r for r in selected_roles if r] # Remove None values
|
||||
|
||||
# Check if trying to assign super_admin role
|
||||
has_super_admin = any(r.name == "super_admin" for r in selected_roles)
|
||||
if has_super_admin and not is_super_admin:
|
||||
flash(_("Only Super Admins can assign the super_admin role"), "error")
|
||||
all_roles = Role.query.order_by(Role.name).all()
|
||||
return render_template("admin/users/roles.html", user=user, all_roles=all_roles)
|
||||
|
||||
# Check if trying to remove admin role from self
|
||||
current_has_admin = any(r.name == "admin" for r in user.roles)
|
||||
new_has_admin = any(r.name == "admin" for r in selected_roles)
|
||||
if current_has_admin and not new_has_admin and user.id == current_user.id and not is_super_admin:
|
||||
flash(_("Only Super Admins can remove the admin role from themselves"), "error")
|
||||
all_roles = Role.query.order_by(Role.name).all()
|
||||
return render_template("admin/users/roles.html", user=user, all_roles=all_roles)
|
||||
|
||||
# Check if trying to remove admin role from another user
|
||||
if current_has_admin and not new_has_admin and user.id != current_user.id and not is_super_admin:
|
||||
flash(_("Only Super Admins can remove the admin role from other users"), "error")
|
||||
all_roles = Role.query.order_by(Role.name).all()
|
||||
return render_template("admin/users/roles.html", user=user, all_roles=all_roles)
|
||||
|
||||
# Clear current roles
|
||||
user.roles = []
|
||||
|
||||
# Assign selected roles
|
||||
primary_role_name = None
|
||||
for role_id in role_ids:
|
||||
role = Role.query.get(int(role_id))
|
||||
if role:
|
||||
user.add_role(role)
|
||||
# Use the first role as the primary role for backward compatibility
|
||||
if primary_role_name is None:
|
||||
primary_role_name = role.name
|
||||
for role in selected_roles:
|
||||
user.add_role(role)
|
||||
# Use the first role as the primary role for backward compatibility
|
||||
if primary_role_name is None:
|
||||
primary_role_name = role.name
|
||||
|
||||
# Update legacy role field for backward compatibility
|
||||
# This ensures the old role field stays in sync with the new role system
|
||||
|
||||
@@ -7,6 +7,9 @@ from flask_babel import gettext as _
|
||||
from flask_login import login_required, current_user
|
||||
from app.models import SavedReportView, ReportEmailSchedule
|
||||
from app.services.scheduled_report_service import ScheduledReportService
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
scheduled_reports_bp = Blueprint("scheduled_reports", __name__)
|
||||
|
||||
@@ -50,11 +53,43 @@ def api_list_scheduled():
|
||||
@scheduled_reports_bp.route("/reports/scheduled")
|
||||
@login_required
|
||||
def list_scheduled():
|
||||
"""List scheduled reports"""
|
||||
service = ScheduledReportService()
|
||||
schedules = service.list_schedules(user_id=current_user.id)
|
||||
|
||||
return render_template("reports/scheduled.html", schedules=schedules)
|
||||
"""List scheduled reports with error handling"""
|
||||
try:
|
||||
service = ScheduledReportService()
|
||||
schedules = service.list_schedules(user_id=current_user.id)
|
||||
|
||||
# Validate schedules and filter out invalid ones
|
||||
valid_schedules = []
|
||||
for schedule in schedules:
|
||||
try:
|
||||
# Check if saved_view exists and is valid
|
||||
if schedule.saved_view:
|
||||
# Try to parse config to validate
|
||||
import json
|
||||
try:
|
||||
config = json.loads(schedule.saved_view.config_json) if isinstance(schedule.saved_view.config_json, str) else schedule.saved_view.config_json
|
||||
if not isinstance(config, dict):
|
||||
logger.warning(f"Invalid config for schedule {schedule.id}, skipping")
|
||||
continue
|
||||
except:
|
||||
logger.warning(f"Could not parse config for schedule {schedule.id}, skipping")
|
||||
continue
|
||||
else:
|
||||
logger.warning(f"Schedule {schedule.id} has no saved_view, skipping")
|
||||
continue
|
||||
|
||||
valid_schedules.append(schedule)
|
||||
except Exception as e:
|
||||
from flask import current_app
|
||||
current_app.logger.error(f"Error validating schedule {schedule.id}: {e}", exc_info=True)
|
||||
continue
|
||||
|
||||
return render_template("reports/scheduled.html", schedules=valid_schedules)
|
||||
except Exception as e:
|
||||
from flask import current_app
|
||||
current_app.logger.error(f"Error loading scheduled reports: {e}", exc_info=True)
|
||||
flash(_("Error loading scheduled reports. Please check the logs."), "error")
|
||||
return render_template("reports/scheduled.html", schedules=[])
|
||||
|
||||
|
||||
@scheduled_reports_bp.route("/reports/scheduled/create", methods=["GET", "POST"])
|
||||
@@ -72,6 +107,8 @@ def create_scheduled():
|
||||
timezone = request.form.get("timezone", "").strip() or None
|
||||
split_by_custom_field = request.form.get("split_by_custom_field") == "1"
|
||||
custom_field_name = request.form.get("custom_field_name", "").strip() or None
|
||||
email_distribution_mode = request.form.get("email_distribution_mode", "").strip() or None
|
||||
recipient_email_template = request.form.get("recipient_email_template", "").strip() or None
|
||||
|
||||
if not saved_view_id or not recipients or not cadence:
|
||||
flash(_("Please fill in all required fields."), "error")
|
||||
@@ -90,6 +127,8 @@ def create_scheduled():
|
||||
timezone=timezone,
|
||||
split_by_custom_field=split_by_custom_field,
|
||||
custom_field_name=custom_field_name,
|
||||
email_distribution_mode=email_distribution_mode,
|
||||
recipient_email_template=recipient_email_template,
|
||||
)
|
||||
|
||||
if result["success"]:
|
||||
@@ -130,6 +169,8 @@ def api_create_scheduled():
|
||||
timezone = data.get("timezone", "").strip() or None
|
||||
split_by_custom_field = data.get("split_by_custom_field", False)
|
||||
custom_field_name = data.get("custom_field_name", "").strip() or None
|
||||
email_distribution_mode = data.get("email_distribution_mode", "").strip() or None
|
||||
recipient_email_template = data.get("recipient_email_template", "").strip() or None
|
||||
|
||||
if not saved_view_id or not recipients or not cadence:
|
||||
return jsonify({"success": False, "error": _("Please fill in all required fields.")}), 400
|
||||
@@ -146,6 +187,8 @@ def api_create_scheduled():
|
||||
timezone=timezone,
|
||||
split_by_custom_field=split_by_custom_field,
|
||||
custom_field_name=custom_field_name,
|
||||
email_distribution_mode=email_distribution_mode,
|
||||
recipient_email_template=recipient_email_template,
|
||||
)
|
||||
|
||||
if result["success"]:
|
||||
@@ -216,3 +259,48 @@ def api_saved_views():
|
||||
]
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@scheduled_reports_bp.route("/reports/scheduled/<int:schedule_id>/fix", methods=["POST"])
|
||||
@login_required
|
||||
def fix_scheduled(schedule_id):
|
||||
"""Fix or remove an invalid scheduled report"""
|
||||
from app import db
|
||||
import json
|
||||
|
||||
schedule = ReportEmailSchedule.query.get_or_404(schedule_id)
|
||||
|
||||
# Check permission
|
||||
if schedule.created_by != current_user.id and not current_user.is_admin:
|
||||
flash(_("You do not have permission to fix this schedule."), "error")
|
||||
return redirect(url_for("scheduled_reports.list_scheduled"))
|
||||
|
||||
# Try to validate the saved view
|
||||
if not schedule.saved_view:
|
||||
# Saved view doesn't exist - delete the schedule
|
||||
db.session.delete(schedule)
|
||||
db.session.commit()
|
||||
flash(_("Scheduled report deleted: saved view no longer exists."), "success")
|
||||
return redirect(url_for("scheduled_reports.list_scheduled"))
|
||||
|
||||
# Try to parse config
|
||||
try:
|
||||
config = json.loads(schedule.saved_view.config_json) if isinstance(schedule.saved_view.config_json, str) else schedule.saved_view.config_json
|
||||
if not isinstance(config, dict):
|
||||
# Invalid config - deactivate the schedule
|
||||
schedule.active = False
|
||||
db.session.commit()
|
||||
flash(_("Scheduled report deactivated: invalid configuration."), "warning")
|
||||
return redirect(url_for("scheduled_reports.list_scheduled"))
|
||||
except:
|
||||
# Could not parse config - deactivate
|
||||
schedule.active = False
|
||||
db.session.commit()
|
||||
flash(_("Scheduled report deactivated: could not parse configuration."), "warning")
|
||||
return redirect(url_for("scheduled_reports.list_scheduled"))
|
||||
|
||||
# If we get here, the schedule is valid - reactivate it
|
||||
schedule.active = True
|
||||
db.session.commit()
|
||||
flash(_("Scheduled report validated and reactivated."), "success")
|
||||
return redirect(url_for("scheduled_reports.list_scheduled"))
|
||||
|
||||
@@ -43,6 +43,7 @@ def list_tasks():
|
||||
overdue=overdue,
|
||||
user_id=current_user.id,
|
||||
is_admin=current_user.is_admin,
|
||||
has_view_all_tasks=current_user.is_admin or current_user.has_permission("view_all_tasks"),
|
||||
page=page,
|
||||
per_page=per_page,
|
||||
)
|
||||
@@ -1033,8 +1034,9 @@ def export_tasks():
|
||||
today_local = now_in_app_timezone().date()
|
||||
query = query.filter(Task.due_date < today_local, Task.status.in_(["todo", "in_progress", "review"]))
|
||||
|
||||
# Show user's tasks first, then others
|
||||
if not current_user.is_admin:
|
||||
# Permission filter - users without view_all_tasks permission only see their tasks
|
||||
has_view_all_tasks = current_user.is_admin or current_user.has_permission("view_all_tasks")
|
||||
if not has_view_all_tasks:
|
||||
query = query.filter(db.or_(Task.assigned_to == current_user.id, Task.created_by == current_user.id))
|
||||
|
||||
tasks = query.order_by(Task.priority.desc(), Task.due_date.asc(), Task.created_at.asc()).all()
|
||||
|
||||
@@ -36,6 +36,8 @@ class ScheduledReportService:
|
||||
timezone: Optional[str] = None,
|
||||
split_by_custom_field: bool = False,
|
||||
custom_field_name: Optional[str] = None,
|
||||
email_distribution_mode: Optional[str] = None,
|
||||
recipient_email_template: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Create a scheduled report.
|
||||
@@ -71,6 +73,8 @@ class ScheduledReportService:
|
||||
created_by=created_by,
|
||||
split_by_salesman=split_by_custom_field, # Reuse existing field
|
||||
salesman_field_name=custom_field_name, # Reuse existing field
|
||||
email_distribution_mode=email_distribution_mode or ("single" if not split_by_custom_field else None),
|
||||
recipient_email_template=recipient_email_template,
|
||||
)
|
||||
|
||||
db.session.add(schedule)
|
||||
@@ -115,6 +119,11 @@ class ScheduledReportService:
|
||||
if schedule.split_by_salesman and schedule.salesman_field_name:
|
||||
return self._generate_and_send_custom_field_reports(schedule, saved_view, config)
|
||||
|
||||
# Validate config before proceeding
|
||||
if not isinstance(config, dict):
|
||||
logger.error(f"Invalid config for schedule {schedule_id}: config is not a dict")
|
||||
return {"success": False, "message": "Invalid report configuration. Please check the saved report view."}
|
||||
|
||||
# Generate report data based on config
|
||||
report_data = self._generate_report_data(saved_view, config)
|
||||
|
||||
@@ -434,8 +443,13 @@ class ScheduledReportService:
|
||||
html_body = None
|
||||
text_body = f"Scheduled Report: {saved_view.name} - {custom_field_name}={field_value}\n\nGenerated at: {now_in_app_timezone()}\n\nReport data available in HTML version."
|
||||
|
||||
# Send email to all recipients
|
||||
for recipient in recipients:
|
||||
# Determine recipient(s) based on distribution mode
|
||||
report_recipients = self._get_recipients_for_field_value(
|
||||
schedule, field_value, recipients
|
||||
)
|
||||
|
||||
# Send email to determined recipients
|
||||
for recipient in report_recipients:
|
||||
try:
|
||||
send_email(
|
||||
subject=f"Scheduled Report: {saved_view.name} - {custom_field_name}={field_value}",
|
||||
@@ -467,5 +481,50 @@ class ScheduledReportService:
|
||||
}
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
logger.error(f"Error generating and sending custom field reports: {e}")
|
||||
logger.error(f"Error generating and sending custom field reports: {e}", exc_info=True)
|
||||
return {"success": False, "message": f"Error generating reports: {str(e)}"}
|
||||
|
||||
def _get_recipients_for_field_value(
|
||||
self, schedule: ReportEmailSchedule, field_value: str, default_recipients: List[str]
|
||||
) -> List[str]:
|
||||
"""
|
||||
Get recipient email addresses for a specific custom field value.
|
||||
|
||||
Supports three modes:
|
||||
- 'mapping': Use SalesmanEmailMapping table
|
||||
- 'template': Use recipient_email_template with {value} placeholder
|
||||
- 'single': Use default recipients (fallback)
|
||||
|
||||
Args:
|
||||
schedule: ReportEmailSchedule object
|
||||
field_value: The custom field value (e.g., 'MM', 'PB')
|
||||
default_recipients: Fallback recipients if mapping/template fails
|
||||
|
||||
Returns:
|
||||
List of email addresses
|
||||
"""
|
||||
distribution_mode = schedule.email_distribution_mode or "single"
|
||||
|
||||
if distribution_mode == "mapping":
|
||||
# Use SalesmanEmailMapping
|
||||
from app.models import SalesmanEmailMapping
|
||||
email = SalesmanEmailMapping.get_email_for_initial(field_value)
|
||||
if email:
|
||||
return [email]
|
||||
else:
|
||||
logger.warning(f"No email mapping found for {field_value}, using default recipients")
|
||||
return default_recipients
|
||||
|
||||
elif distribution_mode == "template":
|
||||
# Use email template
|
||||
template = schedule.recipient_email_template
|
||||
if template and "{value}" in template:
|
||||
email = template.replace("{value}", field_value)
|
||||
return [email]
|
||||
else:
|
||||
logger.warning(f"Invalid email template '{template}', using default recipients")
|
||||
return default_recipients
|
||||
|
||||
else:
|
||||
# Single mode: use default recipients
|
||||
return default_recipients
|
||||
|
||||
@@ -179,6 +179,7 @@ class TaskService:
|
||||
overdue: bool = False,
|
||||
user_id: Optional[int] = None,
|
||||
is_admin: bool = False,
|
||||
has_view_all_tasks: bool = False,
|
||||
page: int = 1,
|
||||
per_page: int = 20,
|
||||
) -> Dict[str, Any]:
|
||||
@@ -189,14 +190,24 @@ class TaskService:
|
||||
Returns:
|
||||
dict with 'tasks', 'pagination', and 'total' keys
|
||||
"""
|
||||
import time
|
||||
import logging
|
||||
from sqlalchemy.orm import joinedload
|
||||
from app.utils.timezone import now_in_app_timezone
|
||||
|
||||
query = self.task_repo.query()
|
||||
logger = logging.getLogger(__name__)
|
||||
start_time = time.time()
|
||||
step_start = time.time()
|
||||
|
||||
query = self.task_repo.query()
|
||||
logger.debug(f"[TaskService.list_tasks] Step 1: Initial query creation took {(time.time() - step_start) * 1000:.2f}ms")
|
||||
|
||||
step_start = time.time()
|
||||
# Eagerly load relations to prevent N+1
|
||||
query = query.options(joinedload(Task.project), joinedload(Task.assigned_user), joinedload(Task.creator))
|
||||
logger.debug(f"[TaskService.list_tasks] Step 2: Eager loading setup took {(time.time() - step_start) * 1000:.2f}ms")
|
||||
|
||||
step_start = time.time()
|
||||
# Apply filters
|
||||
if status:
|
||||
query = query.filter(Task.status == status)
|
||||
@@ -219,23 +230,34 @@ class TaskService:
|
||||
today_local = now_in_app_timezone().date()
|
||||
query = query.filter(Task.due_date < today_local, Task.status.in_(["todo", "in_progress", "review"]))
|
||||
|
||||
# Permission filter - non-admins only see their tasks
|
||||
if not is_admin and user_id:
|
||||
# Permission filter - users without view_all_tasks permission only see their tasks
|
||||
if not has_view_all_tasks and user_id:
|
||||
query = query.filter(db.or_(Task.assigned_to == user_id, Task.created_by == user_id))
|
||||
logger.debug(f"[TaskService.list_tasks] Step 3: Applying filters took {(time.time() - step_start) * 1000:.2f}ms")
|
||||
|
||||
step_start = time.time()
|
||||
# Order by priority, due date, created date
|
||||
query = query.order_by(Task.priority.desc(), Task.due_date.asc(), Task.created_at.asc())
|
||||
logger.debug(f"[TaskService.list_tasks] Step 4: Ordering query took {(time.time() - step_start) * 1000:.2f}ms")
|
||||
|
||||
step_start = time.time()
|
||||
# Paginate (always use pagination for performance)
|
||||
pagination = query.paginate(page=page, per_page=per_page, error_out=False)
|
||||
logger.debug(f"[TaskService.list_tasks] Step 5: Pagination query execution took {(time.time() - step_start) * 1000:.2f}ms (total: {pagination.total} tasks, page: {page}, per_page: {per_page})")
|
||||
|
||||
step_start = time.time()
|
||||
# Pre-calculate total_hours for all tasks in a single query to avoid N+1
|
||||
# This prevents the template from triggering individual queries for each task
|
||||
tasks = pagination.items
|
||||
logger.debug(f"[TaskService.list_tasks] Step 6: Getting pagination items took {(time.time() - step_start) * 1000:.2f}ms ({len(tasks)} tasks)")
|
||||
|
||||
if tasks:
|
||||
from app.models import TimeEntry, KanbanColumn
|
||||
step_start = time.time()
|
||||
task_ids = [task.id for task in tasks]
|
||||
logger.debug(f"[TaskService.list_tasks] Step 7: Extracting task IDs took {(time.time() - step_start) * 1000:.2f}ms")
|
||||
|
||||
step_start = time.time()
|
||||
# Calculate total hours for all tasks in one query
|
||||
results = (
|
||||
db.session.query(
|
||||
@@ -250,13 +272,16 @@ class TaskService:
|
||||
.all()
|
||||
)
|
||||
total_hours_map = {task_id: total_seconds for task_id, total_seconds in results}
|
||||
logger.debug(f"[TaskService.list_tasks] Step 8: Calculating total hours query took {(time.time() - step_start) * 1000:.2f}ms ({len(results)} results)")
|
||||
|
||||
step_start = time.time()
|
||||
# Pre-load kanban columns to avoid N+1 queries in status_display property
|
||||
# Load global columns (project_id is None) since tasks don't have project-specific columns
|
||||
kanban_columns = KanbanColumn.get_active_columns(project_id=None)
|
||||
status_display_map = {}
|
||||
for col in kanban_columns:
|
||||
status_display_map[col.key] = col.label
|
||||
logger.debug(f"[TaskService.list_tasks] Step 9: Loading kanban columns took {(time.time() - step_start) * 1000:.2f}ms ({len(kanban_columns)} columns)")
|
||||
|
||||
# Fallback status map if no columns found
|
||||
fallback_status_map = {
|
||||
@@ -267,6 +292,7 @@ class TaskService:
|
||||
"cancelled": "Cancelled",
|
||||
}
|
||||
|
||||
step_start = time.time()
|
||||
# Cache the calculated values on task objects to avoid property queries
|
||||
for task in tasks:
|
||||
total_seconds = total_hours_map.get(task.id, 0) or 0
|
||||
@@ -277,5 +303,9 @@ class TaskService:
|
||||
task.status,
|
||||
fallback_status_map.get(task.status, task.status.replace("_", " ").title())
|
||||
)
|
||||
logger.debug(f"[TaskService.list_tasks] Step 10: Caching task properties took {(time.time() - step_start) * 1000:.2f}ms")
|
||||
|
||||
total_time = (time.time() - start_time) * 1000
|
||||
logger.info(f"[TaskService.list_tasks] Total time: {total_time:.2f}ms (tasks: {len(tasks) if tasks else 0}, page: {page}, per_page: {per_page})")
|
||||
|
||||
return {"tasks": tasks, "pagination": pagination, "total": pagination.total}
|
||||
|
||||
@@ -1493,37 +1493,75 @@
|
||||
deferredPrompt = e;
|
||||
|
||||
// Show install button in UI
|
||||
if (window.toastManager) {
|
||||
const toast = window.toastManager.info('Install TimeTracker as an app!', 0);
|
||||
const btn = document.createElement('button');
|
||||
btn.textContent = 'Install';
|
||||
btn.className = 'ml-2 px-3 py-1 bg-primary text-white rounded hover:bg-primary/90';
|
||||
btn.onclick = async () => {
|
||||
if (!deferredPrompt) return;
|
||||
deferredPrompt.prompt();
|
||||
const { outcome } = await deferredPrompt.userChoice;
|
||||
if (outcome === 'accepted') {
|
||||
window.toastManager.success('App installed successfully!');
|
||||
if (window.toastManager && typeof window.toastManager.show === 'function') {
|
||||
// Create a non-dismissible toast so we can add custom buttons
|
||||
const toastId = window.toastManager.show({
|
||||
message: 'Install TimeTracker as an app!',
|
||||
type: 'info',
|
||||
duration: 0,
|
||||
dismissible: false
|
||||
});
|
||||
|
||||
// Check if toast was created successfully
|
||||
if (!toastId) {
|
||||
console.warn('Failed to create PWA install toast');
|
||||
return;
|
||||
}
|
||||
|
||||
// Get the toast element after it's added to DOM
|
||||
// Use setTimeout to ensure DOM is updated after requestAnimationFrame in show()
|
||||
setTimeout(() => {
|
||||
const toastElement = document.querySelector(`[data-toast-id="${toastId}"]`);
|
||||
if (!toastElement) {
|
||||
console.warn('PWA install toast element not found');
|
||||
return;
|
||||
}
|
||||
// Always mark as dismissed after user interaction
|
||||
localStorage.setItem('pwa-install-dismissed', 'true');
|
||||
deferredPrompt = null;
|
||||
toast.remove();
|
||||
};
|
||||
|
||||
// Add a dismiss button
|
||||
const dismissBtn = document.createElement('button');
|
||||
dismissBtn.textContent = '×';
|
||||
dismissBtn.className = 'ml-2 px-2 py-1 text-white hover:bg-white/20 rounded';
|
||||
dismissBtn.title = 'Dismiss permanently';
|
||||
dismissBtn.onclick = () => {
|
||||
localStorage.setItem('pwa-install-dismissed', 'true');
|
||||
deferredPrompt = null;
|
||||
toast.remove();
|
||||
};
|
||||
|
||||
toast.appendChild(btn);
|
||||
toast.appendChild(dismissBtn);
|
||||
|
||||
const btn = document.createElement('button');
|
||||
btn.textContent = 'Install';
|
||||
btn.className = 'ml-2 px-3 py-1 bg-primary text-white rounded hover:bg-primary/90';
|
||||
btn.onclick = async () => {
|
||||
if (!deferredPrompt) return;
|
||||
deferredPrompt.prompt();
|
||||
const { outcome } = await deferredPrompt.userChoice;
|
||||
if (outcome === 'accepted') {
|
||||
if (window.toastManager && typeof window.toastManager.success === 'function') {
|
||||
window.toastManager.success('App installed successfully!');
|
||||
}
|
||||
}
|
||||
// Always mark as dismissed after user interaction
|
||||
localStorage.setItem('pwa-install-dismissed', 'true');
|
||||
deferredPrompt = null;
|
||||
if (window.toastManager && typeof window.toastManager.dismiss === 'function') {
|
||||
window.toastManager.dismiss(toastId);
|
||||
}
|
||||
};
|
||||
|
||||
// Add a dismiss button
|
||||
const dismissBtn = document.createElement('button');
|
||||
dismissBtn.textContent = '×';
|
||||
dismissBtn.className = 'ml-2 px-2 py-1 text-gray-600 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-700 rounded';
|
||||
dismissBtn.title = 'Dismiss permanently';
|
||||
dismissBtn.onclick = () => {
|
||||
localStorage.setItem('pwa-install-dismissed', 'true');
|
||||
deferredPrompt = null;
|
||||
if (window.toastManager && typeof window.toastManager.dismiss === 'function') {
|
||||
window.toastManager.dismiss(toastId);
|
||||
}
|
||||
};
|
||||
|
||||
// Insert buttons in the toast content area
|
||||
const content = toastElement.querySelector('.toast-content');
|
||||
if (content) {
|
||||
content.appendChild(btn);
|
||||
content.appendChild(dismissBtn);
|
||||
} else {
|
||||
// Fallback: append to toast element directly if content not found
|
||||
console.warn('Toast content element not found, appending buttons to toast element');
|
||||
toastElement.appendChild(btn);
|
||||
toastElement.appendChild(dismissBtn);
|
||||
}
|
||||
}, 100); // Small delay to ensure DOM is ready
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -18,6 +18,13 @@
|
||||
<option value="{{ p.id }}" {% if project_id == p.id %}selected{% endif %}>{{ p.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<label for="user_id" class="text-sm text-text-muted-light dark:text-text-muted-dark ml-2">{{ _('Assigned To') }}</label>
|
||||
<select id="user_id" name="user_id" class="bg-background-light dark:bg-gray-700 border border-transparent dark:border-transparent rounded-lg py-2 px-3 text-sm text-text-light dark:text-text-dark" onchange="this.form.submit()">
|
||||
<option value="">{{ _('All') }}</option>
|
||||
{% for u in users %}
|
||||
<option value="{{ u.id }}" {% if user_id == u.id %}selected{% endif %}>{{ u.display_name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</form>
|
||||
{% if current_user.is_admin %}
|
||||
<a href="{{ url_for('kanban.list_columns') }}" class="inline-flex items-center gap-2 bg-primary text-white text-sm px-3 py-2 rounded hover:bg-primary/90 transition-colors">
|
||||
|
||||
@@ -337,9 +337,24 @@
|
||||
</div>
|
||||
<div>
|
||||
<label for="startTimerTask" class="block text-sm font-medium text-gray-700 dark:text-gray-300">{{ _('Task (optional)') }}</label>
|
||||
<select id="startTimerTask" name="task_id" class="form-input">
|
||||
<option value="">—</option>
|
||||
</select>
|
||||
<div class="relative">
|
||||
<input
|
||||
type="text"
|
||||
id="startTimerTask"
|
||||
name="task_name"
|
||||
list="startTimerTaskList"
|
||||
autocomplete="off"
|
||||
class="form-input w-full"
|
||||
placeholder="{{ _('Select existing task or type new task name') }}"
|
||||
/>
|
||||
<input type="hidden" id="startTimerTaskId" name="task_id" value="" />
|
||||
<datalist id="startTimerTaskList">
|
||||
<!-- Options will be populated dynamically via JavaScript -->
|
||||
</datalist>
|
||||
</div>
|
||||
<p class="text-xs text-text-muted-light dark:text-text-muted-dark mt-1">
|
||||
{{ _('Select an existing task or type a new task name to create it') }}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label for="startTimerNotes" class="block text-sm font-medium text-gray-700 dark:text-gray-300">{{ _('Notes (optional)') }}</label>
|
||||
@@ -415,32 +430,84 @@
|
||||
|
||||
const projectSelect = document.getElementById('startTimerProject');
|
||||
const clientSelect = document.getElementById('startTimerClient');
|
||||
const taskSelect = document.getElementById('startTimerTask');
|
||||
const taskInput = document.getElementById('startTimerTask');
|
||||
const taskInputId = document.getElementById('startTimerTaskId');
|
||||
const taskDatalist = document.getElementById('startTimerTaskList');
|
||||
let availableTasks = []; // Store tasks for matching
|
||||
|
||||
async function loadTasksForProject(pid) {
|
||||
if (!taskSelect) return;
|
||||
taskSelect.innerHTML = '<option value="">—</option>';
|
||||
async function loadTasksForProject(pid, preserveTaskId = false) {
|
||||
if (!taskInput || !taskDatalist) return;
|
||||
|
||||
// Preserve current task_id if requested
|
||||
const preservedTaskId = preserveTaskId && taskInputId ? taskInputId.value : null;
|
||||
const preservedTaskName = preserveTaskId && taskInput ? taskInput.value : null;
|
||||
|
||||
// Clear datalist and task input
|
||||
taskDatalist.innerHTML = '';
|
||||
taskInput.value = '';
|
||||
taskInputId.value = '';
|
||||
availableTasks = [];
|
||||
|
||||
if (!pid) {
|
||||
taskSelect.disabled = false;
|
||||
taskInput.disabled = false;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/projects/${pid}/tasks`, { credentials: 'same-origin' });
|
||||
const data = await res.json();
|
||||
if (data && data.tasks) {
|
||||
availableTasks = data.tasks;
|
||||
data.tasks.forEach(t => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = t.id; opt.textContent = t.name; taskSelect.appendChild(opt);
|
||||
opt.value = t.name;
|
||||
opt.dataset.taskId = t.id;
|
||||
taskDatalist.appendChild(opt);
|
||||
});
|
||||
}
|
||||
taskSelect.disabled = false;
|
||||
|
||||
// Restore preserved values if requested
|
||||
if (preserveTaskId && preservedTaskId) {
|
||||
taskInputId.value = preservedTaskId;
|
||||
if (preservedTaskName) {
|
||||
taskInput.value = preservedTaskName;
|
||||
}
|
||||
}
|
||||
|
||||
taskInput.disabled = false;
|
||||
} catch (err) {
|
||||
console.error('Failed to load tasks', err);
|
||||
taskSelect.disabled = true;
|
||||
taskInput.disabled = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (projectSelect && clientSelect && taskSelect) {
|
||||
// Handle task input change to detect selection vs typing
|
||||
if (taskInput) {
|
||||
taskInput.addEventListener('input', () => {
|
||||
const inputValue = taskInput.value.trim();
|
||||
// Try to find matching task
|
||||
const matchedTask = availableTasks.find(t => t.name === inputValue);
|
||||
if (matchedTask) {
|
||||
taskInputId.value = matchedTask.id;
|
||||
} else {
|
||||
// Clear task_id if no match (new task name)
|
||||
taskInputId.value = '';
|
||||
}
|
||||
});
|
||||
|
||||
// Also handle selection from datalist
|
||||
taskInput.addEventListener('change', () => {
|
||||
const inputValue = taskInput.value.trim();
|
||||
const matchedTask = availableTasks.find(t => t.name === inputValue);
|
||||
if (matchedTask) {
|
||||
taskInputId.value = matchedTask.id;
|
||||
} else {
|
||||
taskInputId.value = '';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (projectSelect && clientSelect && taskInput) {
|
||||
projectSelect.addEventListener('change', () => {
|
||||
const pid = projectSelect.value;
|
||||
if (pid) {
|
||||
@@ -453,10 +520,13 @@
|
||||
const cid = clientSelect.value;
|
||||
if (cid) {
|
||||
projectSelect.value = '';
|
||||
taskSelect.innerHTML = '<option value="">—</option>';
|
||||
taskSelect.disabled = true;
|
||||
taskInput.value = '';
|
||||
taskInputId.value = '';
|
||||
taskDatalist.innerHTML = '';
|
||||
availableTasks = [];
|
||||
taskInput.disabled = true;
|
||||
} else {
|
||||
taskSelect.disabled = false;
|
||||
taskInput.disabled = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -468,34 +538,109 @@
|
||||
const submitBtn = startTimerForm.querySelector('button[type="submit"]');
|
||||
let originalButtonHTML = submitBtn ? submitBtn.innerHTML : null;
|
||||
|
||||
startTimerForm.addEventListener('submit', function(e) {
|
||||
startTimerForm.addEventListener('submit', async function(e) {
|
||||
e.preventDefault();
|
||||
|
||||
// Validate project or client selection
|
||||
const projectVal = projectSelect ? projectSelect.value : '';
|
||||
const clientVal = clientSelect ? clientSelect.value : '';
|
||||
if (!projectVal && !clientVal) {
|
||||
e.preventDefault();
|
||||
e.stopImmediatePropagation(); // Stop other handlers from running
|
||||
|
||||
// Ensure button state is preserved
|
||||
if (submitBtn && originalButtonHTML) {
|
||||
submitBtn.innerHTML = originalButtonHTML;
|
||||
submitBtn.disabled = false;
|
||||
}
|
||||
|
||||
// Show error message using toast notification
|
||||
const errorMsg = '{{ _("Please select either a project or a client") }}';
|
||||
if (window.toastManager && typeof window.toastManager.error === 'function') {
|
||||
window.toastManager.error(errorMsg, '{{ _("Error") }}', 5000);
|
||||
} else {
|
||||
alert(errorMsg);
|
||||
}
|
||||
// After showing error, ensure button is still in correct state
|
||||
if (submitBtn && originalButtonHTML) {
|
||||
submitBtn.innerHTML = originalButtonHTML;
|
||||
submitBtn.disabled = false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Handle new task creation if needed
|
||||
const taskName = taskInput ? taskInput.value.trim() : '';
|
||||
const taskId = taskInputId ? taskInputId.value : '';
|
||||
|
||||
// If task name is provided but no task_id, create the task first
|
||||
if (taskName && !taskId && projectVal) {
|
||||
try {
|
||||
// Disable submit button
|
||||
if (submitBtn) {
|
||||
submitBtn.disabled = true;
|
||||
if (originalButtonHTML) {
|
||||
submitBtn.innerHTML = '<i class="fas fa-spinner fa-spin mr-2"></i>{{ _("Creating task...") }}';
|
||||
}
|
||||
}
|
||||
|
||||
// Create task via AJAX
|
||||
const csrfToken = startTimerForm.querySelector('input[name="csrf_token"]')?.value;
|
||||
const createTaskRes = await fetch('/api/tasks/create', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
'X-CSRFToken': csrfToken || ''
|
||||
},
|
||||
credentials: 'same-origin',
|
||||
body: JSON.stringify({
|
||||
name: taskName,
|
||||
project_id: parseInt(projectVal)
|
||||
})
|
||||
});
|
||||
|
||||
if (!createTaskRes.ok) {
|
||||
const errorData = await createTaskRes.json().catch(() => ({}));
|
||||
throw new Error(errorData.error || 'Failed to create task');
|
||||
}
|
||||
|
||||
const taskData = await createTaskRes.json();
|
||||
// Set the task_id in the hidden field BEFORE reloading
|
||||
if (taskInputId && taskData.id) {
|
||||
taskInputId.value = taskData.id;
|
||||
// Also update the input to show the created task name
|
||||
if (taskInput) {
|
||||
taskInput.value = taskData.name;
|
||||
}
|
||||
}
|
||||
|
||||
// Reload tasks list to include the new task, preserving the task_id
|
||||
await loadTasksForProject(projectVal, preserveTaskId=true);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to create task:', error);
|
||||
const errorMsg = '{{ _("Failed to create task: ") }}' + (error.message || error);
|
||||
if (window.toastManager && typeof window.toastManager.error === 'function') {
|
||||
window.toastManager.error(errorMsg, '{{ _("Error") }}', 5000);
|
||||
} else {
|
||||
alert(errorMsg);
|
||||
}
|
||||
// Restore button state
|
||||
if (submitBtn && originalButtonHTML) {
|
||||
submitBtn.innerHTML = originalButtonHTML;
|
||||
submitBtn.disabled = false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure task_id is set if we have a task name that matches an existing task
|
||||
if (taskName && !taskInputId.value) {
|
||||
const matchedTask = availableTasks.find(t => t.name === taskName);
|
||||
if (matchedTask && taskInputId) {
|
||||
taskInputId.value = matchedTask.id;
|
||||
}
|
||||
}
|
||||
|
||||
// Restore button state
|
||||
if (submitBtn && originalButtonHTML) {
|
||||
submitBtn.innerHTML = originalButtonHTML;
|
||||
submitBtn.disabled = false;
|
||||
}
|
||||
|
||||
// Debug: Log form data before submission
|
||||
if (taskInputId && taskInputId.value) {
|
||||
console.log('Submitting form with task_id:', taskInputId.value);
|
||||
}
|
||||
|
||||
// Now submit the form normally
|
||||
startTimerForm.submit();
|
||||
}, true); // Use capture phase to run before other handlers
|
||||
}
|
||||
}
|
||||
@@ -511,10 +656,11 @@
|
||||
|
||||
// Get form elements (re-select to avoid scope issues)
|
||||
const projectSelect = document.getElementById('startTimerProject');
|
||||
const taskSelect = document.getElementById('startTimerTask');
|
||||
const taskInput = document.getElementById('startTimerTask');
|
||||
const taskInputId = document.getElementById('startTimerTaskId');
|
||||
const notesField = document.getElementById('startTimerNotes');
|
||||
|
||||
if (!projectSelect || !taskSelect || !notesField) {
|
||||
if (!projectSelect || !taskInput || !notesField) {
|
||||
throw new Error('Form elements not found');
|
||||
}
|
||||
|
||||
@@ -526,8 +672,18 @@
|
||||
|
||||
// Wait a bit for tasks to load, then select task
|
||||
setTimeout(() => {
|
||||
if (template.task_id) {
|
||||
taskSelect.value = template.task_id;
|
||||
if (template.task_id && taskInputId) {
|
||||
taskInputId.value = template.task_id;
|
||||
// Find task name from loaded tasks
|
||||
const taskDatalist = document.getElementById('startTimerTaskList');
|
||||
if (taskDatalist) {
|
||||
const matchingOption = Array.from(taskDatalist.options).find(
|
||||
opt => opt.dataset.taskId === String(template.task_id)
|
||||
);
|
||||
if (matchingOption) {
|
||||
taskInput.value = matchingOption.value;
|
||||
}
|
||||
}
|
||||
}
|
||||
}, 300);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
{# Reusable Kanban board for tasks. Expects `tasks` and `kanban_columns` in context. #}
|
||||
|
||||
<div class="kanban-board-wrapper p-4">
|
||||
<div id="kanbanBoard" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 xl:grid-cols-5 2xl:grid-cols-6 gap-6" role="list" aria-label="{{ _('Kanban board columns') }}">
|
||||
<div id="kanbanBoard" class="flex flex-row gap-6 overflow-x-auto" role="list" aria-label="{{ _('Kanban board columns') }}">
|
||||
{% for col in kanban_columns %}
|
||||
<div class="bg-card-light dark:bg-card-dark rounded-xl shadow-sm ring-1 ring-border-light/60 dark:ring-border-dark/60 flex flex-col" role="region" aria-labelledby="col-{{ col.key }}-title">
|
||||
<div class="bg-card-light dark:bg-card-dark rounded-xl shadow-sm ring-1 ring-border-light/60 dark:ring-border-dark/60 flex flex-col flex-1 min-w-[280px] max-w-full" role="region" aria-labelledby="col-{{ col.key }}-title">
|
||||
<div class="p-4 border-b border-border-light dark:border-border-dark flex justify-between items-center bg-gradient-to-b from-background-light/60 dark:from-background-dark/40 to-transparent rounded-t-xl">
|
||||
<h3 id="col-{{ col.key }}-title" class="text-base font-semibold flex items-center gap-2">
|
||||
<span class="w-2.5 h-2.5 rounded-full" style="background-color: {{ col.color or '#4A90E2' }}"></span>
|
||||
|
||||
@@ -178,6 +178,33 @@
|
||||
<option value="public" {% if saved_view and saved_view.scope == 'public' %}selected{% endif %}>{{ _('Public') }}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Iterative Report Generation -->
|
||||
<div class="mb-4 pt-4 border-t border-border-light dark:border-border-dark">
|
||||
<label class="flex items-center cursor-pointer mb-3">
|
||||
<input type="checkbox" id="iterativeReportGeneration" class="form-checkbox mr-2" {% if saved_view and saved_view.iterative_report_generation %}checked{% endif %}>
|
||||
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
<i class="fas fa-sync-alt text-blue-500 mr-1"></i>
|
||||
{{ _('Iterative Report Generation') }}
|
||||
</span>
|
||||
</label>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mb-3 ml-6">
|
||||
{{ _('Generate one report per custom field value (e.g., one report per salesman)') }}
|
||||
</p>
|
||||
<div id="iterativeFieldContainer" class="ml-6 {% if not saved_view or not saved_view.iterative_report_generation %}hidden{% endif %}">
|
||||
<label for="iterativeCustomFieldName" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">{{ _('Custom Field Name') }}</label>
|
||||
<select id="iterativeCustomFieldName" class="form-input">
|
||||
<option value="">{{ _('Select Field') }}</option>
|
||||
{% for field_key in custom_field_keys %}
|
||||
<option value="{{ field_key }}" {% if saved_view and saved_view.iterative_custom_field_name == field_key %}selected{% endif %}>{{ field_key|replace('_', ' ')|title }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
{{ _('Select the custom field to iterate over (e.g., "salesman")') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-4">
|
||||
<button type="button" onclick="closeSaveModal()" class="px-4 py-2 border border-border-light dark:border-border-dark rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700">{{ _('Cancel') }}</button>
|
||||
<button type="submit" class="bg-primary text-white px-4 py-2 rounded-lg hover:bg-primary/90">{{ _('Save') }}</button>
|
||||
@@ -193,10 +220,10 @@ let reportConfig = {
|
||||
filters: {}
|
||||
};
|
||||
|
||||
{% if saved_view and config %}
|
||||
{% if saved_view %}
|
||||
// Load saved configuration when editing
|
||||
(function() {
|
||||
const savedConfig = {{ config|tojson }};
|
||||
const savedConfig = {{ (config or {})|tojson }};
|
||||
reportConfig = {
|
||||
data_source: savedConfig.data_source || null,
|
||||
components: savedConfig.components ? [...savedConfig.components] : [],
|
||||
@@ -586,6 +613,16 @@ function closeSaveModal() {
|
||||
document.getElementById('saveModal').classList.add('hidden');
|
||||
}
|
||||
|
||||
// Toggle iterative field container based on checkbox
|
||||
document.getElementById('iterativeReportGeneration')?.addEventListener('change', function(e) {
|
||||
const container = document.getElementById('iterativeFieldContainer');
|
||||
if (e.target.checked) {
|
||||
container.classList.remove('hidden');
|
||||
} else {
|
||||
container.classList.add('hidden');
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('saveForm').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
@@ -656,7 +693,9 @@ document.getElementById('saveForm').addEventListener('submit', async (e) => {
|
||||
name: name,
|
||||
scope: scope,
|
||||
config: reportConfig,
|
||||
view_id: {% if saved_view %}{{ saved_view.id }}{% else %}null{% endif %}
|
||||
view_id: {% if saved_view %}{{ saved_view.id }}{% else %}null{% endif %},
|
||||
iterative_report_generation: document.getElementById('iterativeReportGeneration')?.checked || false,
|
||||
iterative_custom_field_name: document.getElementById('iterativeCustomFieldName')?.value || null
|
||||
})
|
||||
});
|
||||
|
||||
|
||||
105
app/templates/reports/iterative_view.html
Normal file
105
app/templates/reports/iterative_view.html
Normal file
@@ -0,0 +1,105 @@
|
||||
{% extends "base.html" %}
|
||||
{% from "components/ui.html" import page_header %}
|
||||
|
||||
{% block title %}{{ saved_view.name }} - {{ _('Iterative Report') }} - {{ app_name }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% set breadcrumbs = [
|
||||
{'text': 'Reports', 'url': url_for('reports.reports')},
|
||||
{'text': 'Report Builder', 'url': url_for('custom_reports.report_builder')},
|
||||
{'text': 'Saved Views', 'url': url_for('custom_reports.list_saved_views')},
|
||||
{'text': saved_view.name}
|
||||
] %}
|
||||
|
||||
{{ page_header(
|
||||
icon_class='fas fa-sync-alt',
|
||||
title_text=saved_view.name,
|
||||
subtitle_text=_('Iterative Report - One report per %(field_name)s value', field_name=custom_field_name),
|
||||
breadcrumbs=breadcrumbs,
|
||||
actions_html='<a href="' + url_for("custom_reports.report_builder", view_id=saved_view.id) + '" class="bg-gray-200 dark:bg-gray-700 text-gray-800 dark:text-gray-200 px-4 py-2 rounded-lg hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors"><i class="fas fa-edit mr-2"></i>' + _('Edit') + '</a>'
|
||||
) }}
|
||||
|
||||
<div class="space-y-6">
|
||||
{% if iterative_reports %}
|
||||
{% for field_value, report_data in iterative_reports.items() %}
|
||||
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
|
||||
<div class="flex justify-between items-center mb-4 pb-4 border-b border-border-light dark:border-border-dark">
|
||||
<h3 class="text-lg font-semibold">
|
||||
<i class="fas fa-tag text-primary mr-2"></i>
|
||||
{{ custom_field_name|replace('_', ' ')|title }}: <span class="text-primary">{{ field_value }}</span>
|
||||
</h3>
|
||||
<div class="text-sm text-text-muted-light dark:text-text-muted-dark">
|
||||
{{ report_data.summary.get('total_entries', 0) }} {{ _('entries') }} •
|
||||
{{ report_data.summary.get('total_hours', 0) }} {{ _('hours') }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if report_data.data and report_data.data|length > 0 %}
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-border-light dark:divide-border-dark">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-text-muted-light dark:text-text-muted-dark uppercase">{{ _('Date') }}</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-text-muted-light dark:text-text-muted-dark uppercase">{{ _('Client') }}</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-text-muted-light dark:text-text-muted-dark uppercase">{{ _('Project') }}</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-text-muted-light dark:text-text-muted-dark uppercase">{{ _('User') }}</th>
|
||||
<th class="px-4 py-3 text-right text-xs font-medium text-text-muted-light dark:text-text-muted-dark uppercase">{{ _('Hours') }}</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-text-muted-light dark:text-text-muted-dark uppercase">{{ _('Notes') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-border-light dark:divide-border-dark">
|
||||
{% for entry in report_data.data %}
|
||||
<tr class="hover:bg-gray-50 dark:hover:bg-gray-800">
|
||||
<td class="px-4 py-3 whitespace-nowrap text-sm">{{ entry.get('date', '') }}</td>
|
||||
<td class="px-4 py-3 whitespace-nowrap text-sm">{{ entry.get('client', '') }}</td>
|
||||
<td class="px-4 py-3 whitespace-nowrap text-sm">{{ entry.get('project', '') }}</td>
|
||||
<td class="px-4 py-3 whitespace-nowrap text-sm">{{ entry.get('user', '') }}</td>
|
||||
<td class="px-4 py-3 whitespace-nowrap text-sm text-right">{{ "%.2f"|format(entry.get('duration', 0) or 0) }}</td>
|
||||
<td class="px-4 py-3 text-sm text-text-muted-light dark:text-text-muted-dark">{{ (entry.get('notes', '') or '')[:50] }}{% if (entry.get('notes', '') or '')|length > 50 %}...{% endif %}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr class="bg-gray-50 dark:bg-gray-800 font-semibold">
|
||||
<td colspan="4" class="px-4 py-3 text-right">{{ _('Total') }}:</td>
|
||||
<td class="px-4 py-3 text-right">{{ "%.2f"|format(report_data.summary.get('total_hours', 0) or 0) }} {{ _('hours') }}</td>
|
||||
<td></td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{% if report_data.summary.get('by_client') %}
|
||||
<div class="mt-6 pt-6 border-t border-border-light dark:border-border-dark">
|
||||
<h4 class="text-sm font-semibold mb-3">{{ _('Summary by Client') }}</h4>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{% for client_name, client_data in report_data.summary.get('by_client', {}).items() %}
|
||||
<div class="bg-gray-50 dark:bg-gray-800 p-3 rounded">
|
||||
<div class="font-medium">{{ client_name }}</div>
|
||||
<div class="text-sm text-text-muted-light dark:text-text-muted-dark">
|
||||
{{ "%.2f"|format(client_data.get('hours', 0) or 0) }} {{ _('hours') }}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<div class="text-center py-8 text-text-muted-light dark:text-text-muted-dark">
|
||||
<i class="fas fa-inbox text-3xl mb-2"></i>
|
||||
<p>{{ _('No data found for this %(field_name)s value', field_name=custom_field_name) }}</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow text-center">
|
||||
<i class="fas fa-exclamation-triangle text-3xl text-yellow-500 mb-4"></i>
|
||||
<p class="text-text-muted-light dark:text-text-muted-dark">
|
||||
{{ _('No unique values found for custom field "%(field_name)s"', field_name=custom_field_name) }}
|
||||
</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
@@ -26,6 +26,7 @@
|
||||
<tr>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-text-muted-light dark:text-text-muted-dark uppercase tracking-wider">{{ _('Name') }}</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-text-muted-light dark:text-text-muted-dark uppercase tracking-wider">{{ _('Scope') }}</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-text-muted-light dark:text-text-muted-dark uppercase tracking-wider">{{ _('Features') }}</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-text-muted-light dark:text-text-muted-dark uppercase tracking-wider">{{ _('Created') }}</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-text-muted-light dark:text-text-muted-dark uppercase tracking-wider">{{ _('Updated') }}</th>
|
||||
<th class="px-6 py-3 text-right text-xs font-medium text-text-muted-light dark:text-text-muted-dark uppercase tracking-wider">{{ _('Actions') }}</th>
|
||||
@@ -40,6 +41,15 @@
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<span class="px-2 py-1 text-xs rounded bg-primary/10 text-primary">{{ view.scope|title }}</span>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<div class="flex flex-wrap gap-1">
|
||||
{% if view.iterative_report_generation %}
|
||||
<span class="px-2 py-1 text-xs rounded bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-200" title="{{ _('Iterative Report Generation') }}">
|
||||
<i class="fas fa-sync-alt mr-1"></i>{{ _('Iterative') }}
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<div class="text-sm">{% if view.created_at %}{{ convert_app_datetime_to_user(view.created_at, user=current_user).strftime('%Y-%m-%d %H:%M') }}{% endif %}</div>
|
||||
</td>
|
||||
|
||||
@@ -36,9 +36,24 @@
|
||||
<tr class="hover:bg-gray-50 dark:hover:bg-gray-800">
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<div class="font-medium">{{ schedule.saved_view.name if schedule.saved_view else _('Unknown') }}</div>
|
||||
{% if schedule.split_by_salesman and schedule.salesman_field_name %}
|
||||
<div class="text-xs text-text-muted-light dark:text-text-muted-dark mt-1">
|
||||
<i class="fas fa-sync-alt mr-1"></i>{{ _('Split by') }}: {{ schedule.salesman_field_name }}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if schedule.email_distribution_mode and schedule.email_distribution_mode != 'single' %}
|
||||
<div class="text-xs text-text-muted-light dark:text-text-muted-dark mt-1">
|
||||
<i class="fas fa-envelope mr-1"></i>{{ _('Distribution') }}: {{ schedule.email_distribution_mode|title }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="px-6 py-4">
|
||||
<div class="text-sm">{{ schedule.recipients }}</div>
|
||||
{% if schedule.recipient_email_template %}
|
||||
<div class="text-xs text-text-muted-light dark:text-text-muted-dark mt-1">
|
||||
{{ _('Template') }}: {{ schedule.recipient_email_template }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<span class="px-2 py-1 text-xs rounded bg-primary/10 text-primary">{{ schedule.cadence|title }}</span>
|
||||
@@ -51,19 +66,33 @@
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
{% if schedule.active %}
|
||||
{% if not schedule.saved_view %}
|
||||
<span class="px-2 py-1 text-xs rounded bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200" title="{{ _('Invalid: saved view not found') }}">
|
||||
<i class="fas fa-exclamation-triangle mr-1"></i>{{ _('Error') }}
|
||||
</span>
|
||||
{% elif schedule.active %}
|
||||
<span class="px-2 py-1 text-xs rounded bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200">{{ _('Active') }}</span>
|
||||
{% else %}
|
||||
<span class="px-2 py-1 text-xs rounded bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-200">{{ _('Inactive') }}</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||
<form method="POST" action="{{ url_for('scheduled_reports.delete_scheduled', schedule_id=schedule.id) }}" class="inline" onsubmit="return confirm('{{ _('Are you sure you want to delete this scheduled report?') }}')">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<button type="submit" class="text-red-600 hover:text-red-800">
|
||||
<i class="fas fa-trash"></i>
|
||||
</button>
|
||||
</form>
|
||||
<div class="flex justify-end gap-2">
|
||||
{% if not schedule.saved_view %}
|
||||
<form method="POST" action="{{ url_for('scheduled_reports.fix_scheduled', schedule_id=schedule.id) }}" class="inline">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<button type="submit" class="text-yellow-600 hover:text-yellow-800" title="{{ _('Fix or remove invalid schedule') }}">
|
||||
<i class="fas fa-wrench"></i>
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
<form method="POST" action="{{ url_for('scheduled_reports.delete_scheduled', schedule_id=schedule.id) }}" class="inline" onsubmit="return confirm('{{ _('Are you sure you want to delete this scheduled report?') }}')">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<button type="submit" class="text-red-600 hover:text-red-800" title="{{ _('Delete') }}">
|
||||
<i class="fas fa-trash"></i>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
|
||||
@@ -12,6 +12,8 @@ services:
|
||||
- ADMIN_USERNAMES=${ADMIN_USERNAMES:-admin}
|
||||
# Security (required in production)
|
||||
- SECRET_KEY=${SECRET_KEY}
|
||||
# Version (inherited from image, but can be overridden)
|
||||
- APP_VERSION=${APP_VERSION:-}
|
||||
# Database (bundled Postgres)
|
||||
- DATABASE_URL=postgresql+psycopg2://timetracker:timetracker@db:5432/timetracker
|
||||
# CSRF & cookies (safe for HTTP local; tighten for HTTPS)
|
||||
|
||||
73
migrations/versions/090_enhance_report_builder_iteration.py
Normal file
73
migrations/versions/090_enhance_report_builder_iteration.py
Normal file
@@ -0,0 +1,73 @@
|
||||
"""Enhance Report Builder with iterative generation and email distribution
|
||||
|
||||
Revision ID: 090_report_builder_iteration
|
||||
Revises: 089_fix_role_perm_sequences
|
||||
Create Date: 2025-01-30
|
||||
|
||||
This migration adds:
|
||||
- iterative_report_generation field to saved_report_views (enable one report per custom field value)
|
||||
- email_distribution_mode field to report_email_schedules (mapping, template, or single)
|
||||
- recipient_email_template field to report_email_schedules (for dynamic email generation)
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '090_report_builder_iteration'
|
||||
down_revision = '089_fix_role_perm_sequences'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _has_column(inspector, table_name: str, column_name: str) -> bool:
|
||||
"""Check if a column exists in a table"""
|
||||
try:
|
||||
return column_name in [col['name'] for col in inspector.get_columns(table_name)]
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def upgrade():
|
||||
"""Add iterative report generation and email distribution fields"""
|
||||
bind = op.get_bind()
|
||||
inspector = sa.inspect(bind)
|
||||
|
||||
# Add iterative report generation to saved_report_views
|
||||
if 'saved_report_views' in inspector.get_table_names():
|
||||
if not _has_column(inspector, 'saved_report_views', 'iterative_report_generation'):
|
||||
op.add_column('saved_report_views',
|
||||
sa.Column('iterative_report_generation', sa.Boolean(), nullable=False, server_default='false'))
|
||||
|
||||
if not _has_column(inspector, 'saved_report_views', 'iterative_custom_field_name'):
|
||||
op.add_column('saved_report_views',
|
||||
sa.Column('iterative_custom_field_name', sa.String(length=50), nullable=True))
|
||||
|
||||
# Add email distribution options to report_email_schedules
|
||||
if 'report_email_schedules' in inspector.get_table_names():
|
||||
if not _has_column(inspector, 'report_email_schedules', 'email_distribution_mode'):
|
||||
op.add_column('report_email_schedules',
|
||||
sa.Column('email_distribution_mode', sa.String(length=20), nullable=True)) # 'mapping', 'template', 'single'
|
||||
|
||||
if not _has_column(inspector, 'report_email_schedules', 'recipient_email_template'):
|
||||
op.add_column('report_email_schedules',
|
||||
sa.Column('recipient_email_template', sa.String(length=255), nullable=True)) # e.g., '{value}@test.de'
|
||||
|
||||
|
||||
def downgrade():
|
||||
"""Remove iterative report generation and email distribution fields"""
|
||||
bind = op.get_bind()
|
||||
inspector = sa.inspect(bind)
|
||||
|
||||
if 'report_email_schedules' in inspector.get_table_names():
|
||||
if _has_column(inspector, 'report_email_schedules', 'recipient_email_template'):
|
||||
op.drop_column('report_email_schedules', 'recipient_email_template')
|
||||
if _has_column(inspector, 'report_email_schedules', 'email_distribution_mode'):
|
||||
op.drop_column('report_email_schedules', 'email_distribution_mode')
|
||||
|
||||
if 'saved_report_views' in inspector.get_table_names():
|
||||
if _has_column(inspector, 'saved_report_views', 'iterative_custom_field_name'):
|
||||
op.drop_column('saved_report_views', 'iterative_custom_field_name')
|
||||
if _has_column(inspector, 'saved_report_views', 'iterative_report_generation'):
|
||||
op.drop_column('saved_report_views', 'iterative_report_generation')
|
||||
|
||||
2
setup.py
2
setup.py
@@ -7,7 +7,7 @@ from setuptools import setup, find_packages
|
||||
|
||||
setup(
|
||||
name='timetracker',
|
||||
version='4.4.1',
|
||||
version='4.5.0',
|
||||
packages=find_packages(),
|
||||
include_package_data=True,
|
||||
install_requires=[
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import pytest
|
||||
from app import db
|
||||
from app.models import Project, User, SavedFilter
|
||||
from app.models import Project, User, SavedFilter, Task
|
||||
|
||||
|
||||
@pytest.mark.smoke
|
||||
@@ -47,3 +47,76 @@ def test_inline_client_creation_json_flow(admin_authenticated_client):
|
||||
data = resp.get_json()
|
||||
assert data["name"] == "Inline Modal Client"
|
||||
assert data["id"] > 0
|
||||
|
||||
|
||||
@pytest.mark.api
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.models
|
||||
def test_inline_task_creation_json_flow(authenticated_client, project, user, app):
|
||||
"""Creating a task via AJAX JSON should return 201 and task payload with defaults."""
|
||||
with app.app_context():
|
||||
from app.models import Task
|
||||
|
||||
resp = authenticated_client.post(
|
||||
"/api/tasks/create",
|
||||
json={"name": "Inline Timer Task", "project_id": project.id},
|
||||
headers={"X-Requested-With": "XMLHttpRequest", "Content-Type": "application/json"},
|
||||
)
|
||||
assert resp.status_code in (201, 400, 404)
|
||||
if resp.status_code == 201:
|
||||
data = resp.get_json()
|
||||
assert data["success"] is True
|
||||
assert data["name"] == "Inline Timer Task"
|
||||
assert data["id"] > 0
|
||||
assert "task" in data
|
||||
|
||||
# Verify task was created with defaults
|
||||
task = Task.query.get(data["id"])
|
||||
assert task is not None
|
||||
assert task.name == "Inline Timer Task"
|
||||
assert task.project_id == project.id
|
||||
assert task.assigned_to == user.id # Assigned to current user
|
||||
assert task.priority == "medium" # Default priority
|
||||
assert task.due_date is None # No due date
|
||||
assert task.created_by == user.id
|
||||
|
||||
|
||||
@pytest.mark.smoke
|
||||
@pytest.mark.api
|
||||
@pytest.mark.integration
|
||||
def test_start_timer_with_new_task_creation(authenticated_client, project, user, app):
|
||||
"""Smoke test: Start timer with new task creation flow."""
|
||||
with app.app_context():
|
||||
from app.models import TimeEntry
|
||||
|
||||
# Simulate the flow: create task inline, then start timer
|
||||
# Step 1: Create task via AJAX
|
||||
task_resp = authenticated_client.post(
|
||||
"/api/tasks/create",
|
||||
json={"name": "Quick Task for Timer", "project_id": project.id},
|
||||
headers={"X-Requested-With": "XMLHttpRequest", "Content-Type": "application/json"},
|
||||
)
|
||||
|
||||
if task_resp.status_code == 201:
|
||||
task_data = task_resp.get_json()
|
||||
task_id = task_data["id"]
|
||||
|
||||
# Step 2: Start timer with the created task
|
||||
timer_resp = authenticated_client.post(
|
||||
"/timer/start",
|
||||
data={"project_id": project.id, "task_id": task_id},
|
||||
follow_redirects=False,
|
||||
)
|
||||
|
||||
# Timer start should redirect or succeed
|
||||
assert timer_resp.status_code in (200, 302, 400, 404)
|
||||
|
||||
# Verify timer was created
|
||||
if timer_resp.status_code in (200, 302):
|
||||
timer = TimeEntry.query.filter_by(
|
||||
user_id=user.id,
|
||||
project_id=project.id,
|
||||
task_id=task_id,
|
||||
end_time=None # Active timer
|
||||
).first()
|
||||
assert timer is not None, "Timer should be created"
|
||||
|
||||
Reference in New Issue
Block a user