Merge pull request #353 from DRYTRIX/RC

Rc
This commit is contained in:
Dries Peeters
2025-12-12 22:37:52 +01:00
committed by GitHub
26 changed files with 1426 additions and 114 deletions

View 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

View File

@@ -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

View File

@@ -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 []

View File

@@ -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)

View File

@@ -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"""

View File

@@ -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

View File

@@ -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,
)

View File

@@ -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_(

View File

@@ -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"

View File

@@ -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

View File

@@ -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"))

View File

@@ -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()

View File

@@ -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

View File

@@ -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}

View File

@@ -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
}
});

View File

@@ -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">

View File

@@ -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);
}

View File

@@ -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>

View File

@@ -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
})
});

View 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 %}

View File

@@ -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>

View File

@@ -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 %}

View File

@@ -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)

View 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')

View File

@@ -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=[

View File

@@ -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"