Files
TimeTracker/app/routes/projects.py
Dries Peeters 18d9808d5e feat: add user favorite projects functionality with CSV export enhancements
Features:
Add favorite projects feature allowing users to star/bookmark frequently used projects
New UserFavoriteProject association model with user-project relationships
Star icons in project list for one-click favorite toggling via AJAX
Filter to display only favorite projects
Per-user favorites with proper isolation and cascade delete behavior
Activity logging for favorite/unfavorite actions
Database:
Add user_favorite_projects table with migration (023_add_user_favorite_projects.py)
Foreign keys to users and projects with CASCADE delete
Unique constraint preventing duplicate favorites
Indexes on user_id and project_id for query optimization
Models:
User model: Add favorite_projects relationship with helper methods
add_favorite_project() - add project to favorites
remove_favorite_project() - remove from favorites
is_project_favorite() - check favorite status
get_favorite_projects() - retrieve favorites with status filter
Project model: Add is_favorited_by() method and include favorite status in to_dict()
Export UserFavoriteProject model in app/models/__init__.py
Routes:
Add /projects/<id>/favorite POST endpoint to favorite a project
Add /projects/<id>/unfavorite POST endpoint to unfavorite a project
Update /projects GET route to support favorites=true query parameter
Fix status filtering to work correctly with favorites JOIN query
Add /reports/export/form GET endpoint for enhanced CSV export form
Templates:
Update projects/list.html:
Add favorites filter dropdown to filter form (5-column grid)
Add star icon column with Font Awesome icons (filled/unfilled)
Add JavaScript toggleFavorite() function for AJAX favorite toggling
Improve hover states and transitions for better UX
Pass favorite_project_ids and favorites_only to template
Update reports/index.html:
Update CSV export link to point to new export form
Add icon and improve hover styling
Reports:
Enhance CSV export functionality with dedicated form page
Add filter options for users, projects, clients, and date ranges
Set default date range to last 30 days
Import Client model and or_ operator for advanced filtering
Testing:
Comprehensive test suite in tests/test_favorite_projects.py (550+ lines)
Model tests for UserFavoriteProject creation and validation
User/Project method tests for favorite operations
Route tests for favorite/unfavorite endpoints
Filtering tests for favorites-only view
Relationship tests for cascade delete behavior
Smoke tests for complete workflows
Coverage for edge cases and error handling
Documentation:
Add comprehensive feature documentation in docs/FAVORITE_PROJECTS_FEATURE.md
User guide with step-by-step instructions
Technical implementation details
API documentation for new endpoints
Migration guide and troubleshooting
Performance and security considerations
Template Cleanup:
Remove duplicate templates from root templates/ directory
Admin templates (dashboard, users, settings, OIDC debug, etc.)
Client CRUD templates
Error page templates
Invoice templates
Project templates
Report templates
Timer templates
All templates now properly located in app/templates/
Breaking Changes:
None - fully backward compatible
Migration Required:
Run alembic upgrade head to create user_favorite_projects table
2025-10-23 21:15:16 +02:00

1220 lines
49 KiB
Python

from flask import Blueprint, render_template, request, redirect, url_for, flash, current_app, jsonify, make_response
from flask_babel import gettext as _
from flask_login import login_required, current_user
from app import db, log_event, track_event
from app.models import Project, TimeEntry, Task, Client, ProjectCost, KanbanColumn, ExtraGood, Activity, UserFavoriteProject
from datetime import datetime
from decimal import Decimal
from app.utils.db import safe_commit
from app.utils.posthog_funnels import (
track_onboarding_first_project,
track_project_setup_started,
track_project_setup_basic_info,
track_project_setup_billing_configured,
track_project_setup_completed
)
projects_bp = Blueprint('projects', __name__)
@projects_bp.route('/projects')
@login_required
def list_projects():
"""List all projects"""
# Track page view
from app import track_page_view
track_page_view("projects_list")
page = request.args.get('page', 1, type=int)
status = request.args.get('status', 'active')
client_name = request.args.get('client', '').strip()
search = request.args.get('search', '').strip()
favorites_only = request.args.get('favorites', '').lower() == 'true'
query = Project.query
# Filter by favorites if requested
if favorites_only:
# Join with user_favorite_projects table
query = query.join(
UserFavoriteProject,
db.and_(
UserFavoriteProject.project_id == Project.id,
UserFavoriteProject.user_id == current_user.id
)
)
# Filter by status (use Project.status to be explicit)
if status == 'active':
query = query.filter(Project.status == 'active')
elif status == 'archived':
query = query.filter(Project.status == 'archived')
elif status == 'inactive':
query = query.filter(Project.status == 'inactive')
if client_name:
query = query.join(Client).filter(Client.name == client_name)
if search:
like = f"%{search}%"
query = query.filter(
db.or_(
Project.name.ilike(like),
Project.description.ilike(like)
)
)
# Get all projects for the current page
projects_pagination = query.order_by(Project.name).paginate(
page=page,
per_page=20,
error_out=False
)
# Get user's favorite project IDs for quick lookup in template
favorite_project_ids = {p.id for p in current_user.favorite_projects.all()}
# Get clients for filter dropdown
clients = Client.get_active_clients()
client_list = [c.name for c in clients]
return render_template(
'projects/list.html',
projects=projects_pagination.items,
status=status,
clients=client_list,
favorite_project_ids=favorite_project_ids,
favorites_only=favorites_only
)
@projects_bp.route('/projects/create', methods=['GET', 'POST'])
@login_required
def create_project():
"""Create a new project"""
if not current_user.is_admin:
try:
current_app.logger.warning("Non-admin user attempted to create project: user=%s", current_user.username)
except Exception:
pass
flash('Only administrators can create projects', 'error')
return redirect(url_for('projects.list_projects'))
# Track project setup started when user opens the form
if request.method == 'GET':
track_project_setup_started(current_user.id)
if request.method == 'POST':
name = request.form.get('name', '').strip()
client_id = request.form.get('client_id', '').strip()
description = request.form.get('description', '').strip()
billable = request.form.get('billable') == 'on'
hourly_rate = request.form.get('hourly_rate', '').strip()
billing_ref = request.form.get('billing_ref', '').strip()
# Budgets
budget_amount_raw = request.form.get('budget_amount', '').strip()
budget_threshold_raw = request.form.get('budget_threshold_percent', '').strip()
code = request.form.get('code', '').strip()
try:
current_app.logger.info(
"POST /projects/create user=%s name=%s client_id=%s billable=%s",
current_user.username,
name or '<empty>',
client_id or '<empty>',
billable,
)
except Exception:
pass
# Validate required fields
if not name or not client_id:
flash('Project name and client are required', 'error')
try:
current_app.logger.warning("Validation failed: missing required fields for project creation")
except Exception:
pass
return render_template('projects/create.html', clients=Client.get_active_clients())
# Get client and validate
client = Client.query.get(client_id)
if not client:
flash('Selected client not found', 'error')
try:
current_app.logger.warning("Validation failed: client not found (id=%s)", client_id)
except Exception:
pass
return render_template('projects/create.html', clients=Client.get_active_clients())
# Validate hourly rate
try:
hourly_rate = Decimal(hourly_rate) if hourly_rate else None
except ValueError:
flash('Invalid hourly rate format', 'error')
# Validate budgets
budget_amount = None
budget_threshold_percent = None
if budget_amount_raw:
try:
budget_amount = Decimal(budget_amount_raw)
if budget_amount < 0:
raise ValueError('Budget cannot be negative')
except Exception:
flash('Invalid budget amount', 'error')
return render_template('projects/create.html', clients=Client.get_active_clients())
if budget_threshold_raw:
try:
budget_threshold_percent = int(budget_threshold_raw)
if budget_threshold_percent < 0 or budget_threshold_percent > 100:
raise ValueError('Invalid threshold')
except Exception:
flash('Invalid budget threshold percent (0-100)', 'error')
return render_template('projects/create.html', clients=Client.get_active_clients())
# Check if project name already exists
if Project.query.filter_by(name=name).first():
flash('A project with this name already exists', 'error')
try:
current_app.logger.warning("Validation failed: duplicate project name '%s'", name)
except Exception:
pass
return render_template('projects/create.html', clients=Client.get_active_clients())
# Normalize code
normalized_code = code.upper() if code else None
# Validate code uniqueness if provided
if normalized_code:
existing_code = Project.query.filter(Project.code == normalized_code).first()
if existing_code:
flash(_('Project code already in use'), 'error')
return render_template('projects/create.html', clients=Client.get_active_clients())
# Create project
project = Project(
name=name,
client_id=client_id,
description=description,
billable=billable,
hourly_rate=hourly_rate,
billing_ref=billing_ref,
code=normalized_code,
budget_amount=budget_amount,
budget_threshold_percent=budget_threshold_percent or 80
)
db.session.add(project)
if not safe_commit('create_project', {'name': name, 'client_id': client_id}):
flash('Could not create project due to a database error. Please check server logs.', 'error')
return render_template('projects/create.html', clients=Client.get_active_clients())
# Track project created event
log_event("project.created",
user_id=current_user.id,
project_id=project.id,
project_name=name,
has_client=bool(client_id))
track_event(current_user.id, "project.created", {
"project_id": project.id,
"project_name": name,
"has_client": bool(client_id),
"billable": billable
})
# Track project setup funnel steps
track_project_setup_basic_info(current_user.id, {
"has_description": bool(description),
"has_code": bool(code),
"billable": billable
})
if hourly_rate or billing_ref or budget_amount:
track_project_setup_billing_configured(current_user.id, {
"has_hourly_rate": bool(hourly_rate),
"has_billing_ref": bool(billing_ref),
"has_budget": bool(budget_amount)
})
track_project_setup_completed(current_user.id, {
"project_id": project.id,
"billable": billable,
"has_budget": bool(budget_amount)
})
# Check if this is user's first project (onboarding milestone)
# Count projects this user has created or has time entries for
from sqlalchemy import func, or_
project_count = db.session.query(func.count(Project.id.distinct())).join(
TimeEntry,
TimeEntry.project_id == Project.id,
isouter=True
).filter(
or_(
TimeEntry.user_id == current_user.id,
Project.id == project.id # Include the just-created project
)
).scalar() or 0
if project_count == 1:
track_onboarding_first_project(current_user.id, {
"project_name_length": len(name),
"has_description": bool(description),
"billable": billable,
"has_budget": bool(budget_amount)
})
# Log activity
Activity.log(
user_id=current_user.id,
action='created',
entity_type='project',
entity_id=project.id,
entity_name=project.name,
description=f'Created project "{project.name}" for {client.name}',
ip_address=request.remote_addr,
user_agent=request.headers.get('User-Agent')
)
flash(f'Project "{name}" created successfully', 'success')
return redirect(url_for('projects.view_project', project_id=project.id))
return render_template('projects/create.html', clients=Client.get_active_clients())
@projects_bp.route('/projects/<int:project_id>')
@login_required
def view_project(project_id):
"""View project details and time entries"""
project = Project.query.get_or_404(project_id)
# Get time entries for this project
page = request.args.get('page', 1, type=int)
entries_pagination = project.time_entries.filter(
TimeEntry.end_time.isnot(None)
).order_by(
TimeEntry.start_time.desc()
).paginate(
page=page,
per_page=50,
error_out=False
)
# Get tasks for this project
tasks = project.tasks.order_by(Task.priority.desc(), Task.due_date.asc(), Task.created_at.asc()).all()
# Get user totals
user_totals = project.get_user_totals()
# Get comments for this project
from app.models import Comment
comments = Comment.get_project_comments(project_id, include_replies=True)
# Get recent project costs (latest 5)
recent_costs = ProjectCost.query.filter_by(project_id=project_id).order_by(
ProjectCost.cost_date.desc()
).limit(5).all()
# Get total cost count
total_costs_count = ProjectCost.query.filter_by(project_id=project_id).count()
# Get kanban columns - force fresh data
db.session.expire_all()
kanban_columns = KanbanColumn.get_active_columns() if KanbanColumn else []
# Prevent browser caching of kanban board
response = render_template('projects/view.html',
project=project,
entries=entries_pagination.items,
pagination=entries_pagination,
tasks=tasks,
user_totals=user_totals,
comments=comments,
recent_costs=recent_costs,
total_costs_count=total_costs_count,
kanban_columns=kanban_columns)
resp = make_response(response)
resp.headers['Cache-Control'] = 'no-cache, no-store, must-revalidate, max-age=0'
resp.headers['Pragma'] = 'no-cache'
resp.headers['Expires'] = '0'
return resp
@projects_bp.route('/projects/<int:project_id>/edit', methods=['GET', 'POST'])
@login_required
def edit_project(project_id):
"""Edit project details"""
if not current_user.is_admin:
flash('Only administrators can edit projects', 'error')
return redirect(url_for('projects.view_project', project_id=project_id))
project = Project.query.get_or_404(project_id)
if request.method == 'POST':
name = request.form.get('name', '').strip()
client_id = request.form.get('client_id', '').strip()
description = request.form.get('description', '').strip()
billable = request.form.get('billable') == 'on'
hourly_rate = request.form.get('hourly_rate', '').strip()
billing_ref = request.form.get('billing_ref', '').strip()
code = request.form.get('code', '').strip()
budget_amount_raw = request.form.get('budget_amount', '').strip()
budget_threshold_raw = request.form.get('budget_threshold_percent', '').strip()
# Validate required fields
if not name or not client_id:
flash('Project name and client are required', 'error')
return render_template('projects/edit.html', project=project, clients=Client.get_active_clients())
# Get client and validate
client = Client.query.get(client_id)
if not client:
flash('Selected client not found', 'error')
return render_template('projects/edit.html', project=project, clients=Client.get_active_clients())
# Validate hourly rate
try:
hourly_rate = Decimal(hourly_rate) if hourly_rate else None
except ValueError:
flash('Invalid hourly rate format', 'error')
return render_template('projects/edit.html', project=project, clients=Client.get_active_clients())
# Validate budgets
budget_amount = None
if budget_amount_raw:
try:
budget_amount = Decimal(budget_amount_raw)
if budget_amount < 0:
raise ValueError('Budget cannot be negative')
except Exception:
flash('Invalid budget amount', 'error')
return render_template('projects/edit.html', project=project, clients=Client.get_active_clients())
budget_threshold_percent = project.budget_threshold_percent or 80
if budget_threshold_raw:
try:
budget_threshold_percent = int(budget_threshold_raw)
if budget_threshold_percent < 0 or budget_threshold_percent > 100:
raise ValueError('Invalid threshold')
except Exception:
flash('Invalid budget threshold percent (0-100)', 'error')
return render_template('projects/edit.html', project=project, clients=Client.get_active_clients())
# Check if project name already exists (excluding current project)
existing = Project.query.filter_by(name=name).first()
if existing and existing.id != project.id:
flash('A project with this name already exists', 'error')
return render_template('projects/edit.html', project=project, clients=Client.get_active_clients())
# Validate code uniqueness if provided
normalized_code = code.upper() if code else None
if normalized_code:
existing_code = Project.query.filter(Project.code == normalized_code).first()
if existing_code and existing_code.id != project.id:
flash(_('Project code already in use'), 'error')
return render_template('projects/edit.html', project=project, clients=Client.get_active_clients())
# Update project
project.name = name
project.client_id = client_id
project.description = description
project.billable = billable
project.hourly_rate = hourly_rate
project.billing_ref = billing_ref
project.code = normalized_code
project.budget_amount = budget_amount if budget_amount_raw != '' else None
project.budget_threshold_percent = budget_threshold_percent
project.updated_at = datetime.utcnow()
if not safe_commit('edit_project', {'project_id': project.id}):
flash('Could not update project due to a database error. Please check server logs.', 'error')
return render_template('projects/edit.html', project=project, clients=Client.get_active_clients())
flash(f'Project "{name}" updated successfully', 'success')
return redirect(url_for('projects.view_project', project_id=project.id))
return render_template('projects/edit.html', project=project, clients=Client.get_active_clients())
@projects_bp.route('/projects/<int:project_id>/archive', methods=['POST'])
@login_required
def archive_project(project_id):
"""Archive a project"""
if not current_user.is_admin:
flash('Only administrators can archive projects', 'error')
return redirect(url_for('projects.view_project', project_id=project_id))
project = Project.query.get_or_404(project_id)
if project.status == 'archived':
flash('Project is already archived', 'info')
else:
project.archive()
flash(f'Project "{project.name}" archived successfully', 'success')
return redirect(url_for('projects.list_projects'))
@projects_bp.route('/projects/<int:project_id>/unarchive', methods=['POST'])
@login_required
def unarchive_project(project_id):
"""Unarchive a project"""
if not current_user.is_admin:
flash('Only administrators can unarchive projects', 'error')
return redirect(url_for('projects.view_project', project_id=project_id))
project = Project.query.get_or_404(project_id)
if project.status == 'active':
flash('Project is already active', 'info')
else:
project.unarchive()
flash(f'Project "{project.name}" unarchived successfully', 'success')
return redirect(url_for('projects.list_projects'))
@projects_bp.route('/projects/<int:project_id>/deactivate', methods=['POST'])
@login_required
def deactivate_project(project_id):
"""Mark a project as inactive"""
if not current_user.is_admin:
flash('Only administrators can deactivate projects', 'error')
return redirect(url_for('projects.view_project', project_id=project_id))
project = Project.query.get_or_404(project_id)
if project.status == 'inactive':
flash('Project is already inactive', 'info')
else:
project.deactivate()
# Log project deactivation
log_event("project.deactivated", user_id=current_user.id, project_id=project.id)
track_event(current_user.id, "project.deactivated", {"project_id": project.id})
flash(f'Project "{project.name}" marked as inactive', 'success')
return redirect(url_for('projects.list_projects'))
@projects_bp.route('/projects/<int:project_id>/activate', methods=['POST'])
@login_required
def activate_project(project_id):
"""Activate a project"""
if not current_user.is_admin:
flash('Only administrators can activate projects', 'error')
return redirect(url_for('projects.view_project', project_id=project_id))
project = Project.query.get_or_404(project_id)
if project.status == 'active':
flash('Project is already active', 'info')
else:
project.activate()
# Log project activation
log_event("project.activated", user_id=current_user.id, project_id=project.id)
track_event(current_user.id, "project.activated", {"project_id": project.id})
flash(f'Project "{project.name}" activated successfully', 'success')
return redirect(url_for('projects.list_projects'))
@projects_bp.route('/projects/<int:project_id>/delete', methods=['POST'])
@login_required
def delete_project(project_id):
"""Delete a project (only if no time entries exist)"""
if not current_user.is_admin:
flash('Only administrators can delete projects', 'error')
return redirect(url_for('projects.view_project', project_id=project_id))
project = Project.query.get_or_404(project_id)
# Check if project has time entries
if project.time_entries.count() > 0:
flash('Cannot delete project with existing time entries', 'error')
return redirect(url_for('projects.view_project', project_id=project_id))
project_name = project.name
db.session.delete(project)
if not safe_commit('delete_project', {'project_id': project.id}):
flash('Could not delete project due to a database error. Please check server logs.', 'error')
return redirect(url_for('projects.view_project', project_id=project.id))
flash(f'Project "{project_name}" deleted successfully', 'success')
return redirect(url_for('projects.list_projects'))
@projects_bp.route('/projects/bulk-delete', methods=['POST'])
@login_required
def bulk_delete_projects():
"""Delete multiple projects at once"""
if not current_user.is_admin:
flash('Only administrators can delete projects', 'error')
return redirect(url_for('projects.list_projects'))
project_ids = request.form.getlist('project_ids[]')
if not project_ids:
flash('No projects selected for deletion', 'warning')
return redirect(url_for('projects.list_projects'))
deleted_count = 0
skipped_count = 0
errors = []
for project_id_str in project_ids:
try:
project_id = int(project_id_str)
project = Project.query.get(project_id)
if not project:
continue
# Check for time entries
if project.time_entries.count() > 0:
skipped_count += 1
errors.append(f"'{project.name}': Has time entries")
continue
# Delete the project
project_id_for_log = project.id
project_name = project.name
db.session.delete(project)
deleted_count += 1
# Log the deletion
log_event("project.deleted", user_id=current_user.id, project_id=project_id_for_log)
track_event(current_user.id, "project.deleted", {"project_id": project_id_for_log})
except Exception as e:
skipped_count += 1
errors.append(f"ID {project_id_str}: {str(e)}")
# Commit all deletions
if deleted_count > 0:
if not safe_commit('bulk_delete_projects', {'count': deleted_count}):
flash('Could not delete projects due to a database error. Please check server logs.', 'error')
return redirect(url_for('projects.list_projects'))
# Show appropriate messages
if deleted_count > 0:
flash(f'Successfully deleted {deleted_count} project{"s" if deleted_count != 1 else ""}', 'success')
if skipped_count > 0:
flash(f'Skipped {skipped_count} project{"s" if skipped_count != 1 else ""}: {", ".join(errors[:3])}{"..." if len(errors) > 3 else ""}', 'warning')
if deleted_count == 0 and skipped_count == 0:
flash('No projects were deleted', 'info')
return redirect(url_for('projects.list_projects'))
@projects_bp.route('/projects/bulk-status-change', methods=['POST'])
@login_required
def bulk_status_change():
"""Change status for multiple projects at once"""
if not current_user.is_admin:
flash('Only administrators can change project status', 'error')
return redirect(url_for('projects.list_projects'))
project_ids = request.form.getlist('project_ids[]')
new_status = request.form.get('new_status', '').strip()
if not project_ids:
flash('No projects selected', 'warning')
return redirect(url_for('projects.list_projects'))
if new_status not in ['active', 'inactive', 'archived']:
flash('Invalid status', 'error')
return redirect(url_for('projects.list_projects'))
updated_count = 0
errors = []
for project_id_str in project_ids:
try:
project_id = int(project_id_str)
project = Project.query.get(project_id)
if not project:
continue
# Update status
project.status = new_status
project.updated_at = datetime.utcnow()
updated_count += 1
# Log the status change
log_event(f"project.status_changed_{new_status}", user_id=current_user.id, project_id=project.id)
track_event(current_user.id, "project.status_changed", {"project_id": project.id, "new_status": new_status})
except Exception as e:
errors.append(f"ID {project_id_str}: {str(e)}")
# Commit all changes
if updated_count > 0:
if not safe_commit('bulk_status_change_projects', {'count': updated_count, 'status': new_status}):
flash('Could not update project status due to a database error. Please check server logs.', 'error')
return redirect(url_for('projects.list_projects'))
# Show appropriate messages
status_labels = {'active': 'active', 'inactive': 'inactive', 'archived': 'archived'}
if updated_count > 0:
flash(f'Successfully marked {updated_count} project{"s" if updated_count != 1 else ""} as {status_labels.get(new_status, new_status)}', 'success')
if errors:
flash(f'Some projects could not be updated: {", ".join(errors[:3])}{"..." if len(errors) > 3 else ""}', 'warning')
if updated_count == 0:
flash('No projects were updated', 'info')
return redirect(url_for('projects.list_projects'))
# ===== FAVORITE PROJECTS ROUTES =====
@projects_bp.route('/projects/<int:project_id>/favorite', methods=['POST'])
@login_required
def favorite_project(project_id):
"""Add a project to user's favorites"""
project = Project.query.get_or_404(project_id)
try:
# Check if already favorited
if current_user.is_project_favorite(project):
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
return jsonify({'success': False, 'message': _('Project is already in favorites')}), 200
flash(_('Project is already in favorites'), 'info')
else:
# Add to favorites
current_user.add_favorite_project(project)
# Log activity
Activity.log(
user_id=current_user.id,
action='favorited',
entity_type='project',
entity_id=project.id,
entity_name=project.name,
description=f'Added project "{project.name}" to favorites',
ip_address=request.remote_addr,
user_agent=request.headers.get('User-Agent')
)
# Track event
log_event("project.favorited", user_id=current_user.id, project_id=project.id)
track_event(current_user.id, "project.favorited", {"project_id": project.id})
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
return jsonify({'success': True, 'message': _('Project added to favorites')}), 200
flash(_('Project added to favorites'), 'success')
except Exception as e:
current_app.logger.error(f"Error favoriting project: {e}")
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
return jsonify({'success': False, 'message': _('Failed to add project to favorites')}), 500
flash(_('Failed to add project to favorites'), 'error')
# Redirect back to referrer or project list
return redirect(request.referrer or url_for('projects.list_projects'))
@projects_bp.route('/projects/<int:project_id>/unfavorite', methods=['POST'])
@login_required
def unfavorite_project(project_id):
"""Remove a project from user's favorites"""
project = Project.query.get_or_404(project_id)
try:
# Check if not favorited
if not current_user.is_project_favorite(project):
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
return jsonify({'success': False, 'message': _('Project is not in favorites')}), 200
flash(_('Project is not in favorites'), 'info')
else:
# Remove from favorites
current_user.remove_favorite_project(project)
# Log activity
Activity.log(
user_id=current_user.id,
action='unfavorited',
entity_type='project',
entity_id=project.id,
entity_name=project.name,
description=f'Removed project "{project.name}" from favorites',
ip_address=request.remote_addr,
user_agent=request.headers.get('User-Agent')
)
# Track event
log_event("project.unfavorited", user_id=current_user.id, project_id=project.id)
track_event(current_user.id, "project.unfavorited", {"project_id": project.id})
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
return jsonify({'success': True, 'message': _('Project removed from favorites')}), 200
flash(_('Project removed from favorites'), 'success')
except Exception as e:
current_app.logger.error(f"Error unfavoriting project: {e}")
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
return jsonify({'success': False, 'message': _('Failed to remove project from favorites')}), 500
flash(_('Failed to remove project from favorites'), 'error')
# Redirect back to referrer or project list
return redirect(request.referrer or url_for('projects.list_projects'))
# ===== PROJECT COSTS ROUTES =====
@projects_bp.route('/projects/<int:project_id>/costs')
@login_required
def list_costs(project_id):
"""List all costs for a project"""
project = Project.query.get_or_404(project_id)
# Get filters from query params
start_date_str = request.args.get('start_date', '')
end_date_str = request.args.get('end_date', '')
category = request.args.get('category', '')
start_date = None
end_date = None
if start_date_str:
try:
start_date = datetime.strptime(start_date_str, '%Y-%m-%d').date()
except ValueError:
pass
if end_date_str:
try:
end_date = datetime.strptime(end_date_str, '%Y-%m-%d').date()
except ValueError:
pass
# Get costs
query = project.costs
if start_date:
query = query.filter(ProjectCost.cost_date >= start_date)
if end_date:
query = query.filter(ProjectCost.cost_date <= end_date)
if category:
query = query.filter(ProjectCost.category == category)
costs = query.order_by(ProjectCost.cost_date.desc()).all()
# Get category breakdown
category_breakdown = ProjectCost.get_costs_by_category(
project_id, start_date, end_date
)
return render_template(
'projects/costs.html',
project=project,
costs=costs,
category_breakdown=category_breakdown,
start_date=start_date_str,
end_date=end_date_str,
selected_category=category
)
@projects_bp.route('/projects/<int:project_id>/costs/add', methods=['GET', 'POST'])
@login_required
def add_cost(project_id):
"""Add a new cost to a project"""
project = Project.query.get_or_404(project_id)
if request.method == 'POST':
description = request.form.get('description', '').strip()
category = request.form.get('category', '').strip()
amount = request.form.get('amount', '').strip()
cost_date_str = request.form.get('cost_date', '').strip()
billable = request.form.get('billable') == 'on'
notes = request.form.get('notes', '').strip()
currency_code = request.form.get('currency_code', 'EUR').strip()
# Validate required fields
if not description or not category or not amount or not cost_date_str:
flash(_('Description, category, amount, and date are required'), 'error')
return render_template('projects/add_cost.html', project=project)
# Validate amount
try:
amount = Decimal(amount)
if amount <= 0:
raise ValueError('Amount must be positive')
except (ValueError, Exception):
flash(_('Invalid amount format'), 'error')
return render_template('projects/add_cost.html', project=project)
# Validate date
try:
cost_date = datetime.strptime(cost_date_str, '%Y-%m-%d').date()
except ValueError:
flash(_('Invalid date format'), 'error')
return render_template('projects/add_cost.html', project=project)
# Create cost
cost = ProjectCost(
project_id=project_id,
user_id=current_user.id,
description=description,
category=category,
amount=amount,
cost_date=cost_date,
billable=billable,
notes=notes,
currency_code=currency_code
)
db.session.add(cost)
if not safe_commit('add_project_cost', {'project_id': project_id}):
flash(_('Could not add cost due to a database error. Please check server logs.'), 'error')
return render_template('projects/add_cost.html', project=project)
flash(_('Cost added successfully'), 'success')
return redirect(url_for('projects.view_project', project_id=project.id))
return render_template('projects/add_cost.html', project=project)
@projects_bp.route('/projects/<int:project_id>/costs/<int:cost_id>/edit', methods=['GET', 'POST'])
@login_required
def edit_cost(project_id, cost_id):
"""Edit a project cost"""
project = Project.query.get_or_404(project_id)
cost = ProjectCost.query.get_or_404(cost_id)
# Verify cost belongs to project
if cost.project_id != project_id:
flash(_('Cost not found'), 'error')
return redirect(url_for('projects.view_project', project_id=project_id))
# Only admin or the user who created the cost can edit
if not current_user.is_admin and cost.user_id != current_user.id:
flash(_('You do not have permission to edit this cost'), 'error')
return redirect(url_for('projects.view_project', project_id=project_id))
if request.method == 'POST':
description = request.form.get('description', '').strip()
category = request.form.get('category', '').strip()
amount = request.form.get('amount', '').strip()
cost_date_str = request.form.get('cost_date', '').strip()
billable = request.form.get('billable') == 'on'
notes = request.form.get('notes', '').strip()
currency_code = request.form.get('currency_code', 'EUR').strip()
# Validate required fields
if not description or not category or not amount or not cost_date_str:
flash(_('Description, category, amount, and date are required'), 'error')
return render_template('projects/edit_cost.html', project=project, cost=cost)
# Validate amount
try:
amount = Decimal(amount)
if amount <= 0:
raise ValueError('Amount must be positive')
except (ValueError, Exception):
flash(_('Invalid amount format'), 'error')
return render_template('projects/edit_cost.html', project=project, cost=cost)
# Validate date
try:
cost_date = datetime.strptime(cost_date_str, '%Y-%m-%d').date()
except ValueError:
flash(_('Invalid date format'), 'error')
return render_template('projects/edit_cost.html', project=project, cost=cost)
# Update cost
cost.description = description
cost.category = category
cost.amount = amount
cost.cost_date = cost_date
cost.billable = billable
cost.notes = notes
cost.currency_code = currency_code
cost.updated_at = datetime.utcnow()
if not safe_commit('edit_project_cost', {'cost_id': cost_id}):
flash(_('Could not update cost due to a database error. Please check server logs.'), 'error')
return render_template('projects/edit_cost.html', project=project, cost=cost)
flash(_('Cost updated successfully'), 'success')
return redirect(url_for('projects.view_project', project_id=project.id))
return render_template('projects/edit_cost.html', project=project, cost=cost)
@projects_bp.route('/projects/<int:project_id>/costs/<int:cost_id>/delete', methods=['POST'])
@login_required
def delete_cost(project_id, cost_id):
"""Delete a project cost"""
project = Project.query.get_or_404(project_id)
cost = ProjectCost.query.get_or_404(cost_id)
# Verify cost belongs to project
if cost.project_id != project_id:
flash(_('Cost not found'), 'error')
return redirect(url_for('projects.view_project', project_id=project_id))
# Only admin or the user who created the cost can delete
if not current_user.is_admin and cost.user_id != current_user.id:
flash(_('You do not have permission to delete this cost'), 'error')
return redirect(url_for('projects.view_project', project_id=project_id))
# Check if cost has been invoiced
if cost.is_invoiced:
flash(_('Cannot delete cost that has been invoiced'), 'error')
return redirect(url_for('projects.view_project', project_id=project_id))
cost_description = cost.description
db.session.delete(cost)
if not safe_commit('delete_project_cost', {'cost_id': cost_id}):
flash(_('Could not delete cost due to a database error. Please check server logs.'), 'error')
return redirect(url_for('projects.view_project', project_id=project_id))
flash(_(f'Cost "{cost_description}" deleted successfully'), 'success')
return redirect(url_for('projects.view_project', project_id=project.id))
# API endpoint for getting project costs as JSON
@projects_bp.route('/api/projects/<int:project_id>/costs')
@login_required
def api_project_costs(project_id):
"""API endpoint to get project costs"""
project = Project.query.get_or_404(project_id)
start_date_str = request.args.get('start_date')
end_date_str = request.args.get('end_date')
start_date = None
end_date = None
if start_date_str:
try:
start_date = datetime.strptime(start_date_str, '%Y-%m-%d').date()
except ValueError:
pass
if end_date_str:
try:
end_date = datetime.strptime(end_date_str, '%Y-%m-%d').date()
except ValueError:
pass
costs = ProjectCost.get_project_costs(project_id, start_date, end_date)
total_costs = ProjectCost.get_total_costs(project_id, start_date, end_date)
billable_costs = ProjectCost.get_total_costs(project_id, start_date, end_date, billable_only=True)
return jsonify({
'costs': [cost.to_dict() for cost in costs],
'total_costs': total_costs,
'billable_costs': billable_costs,
'count': len(costs)
})
# ===== PROJECT EXTRA GOODS ROUTES =====
@projects_bp.route('/projects/<int:project_id>/goods')
@login_required
def list_goods(project_id):
"""List all extra goods for a project"""
project = Project.query.get_or_404(project_id)
# Get goods
goods = project.extra_goods.order_by(ExtraGood.created_at.desc()).all()
# Get category breakdown
category_breakdown = ExtraGood.get_goods_by_category(project_id=project_id)
# Calculate totals
total_amount = ExtraGood.get_total_amount(project_id=project_id)
billable_amount = ExtraGood.get_total_amount(project_id=project_id, billable_only=True)
return render_template(
'projects/goods.html',
project=project,
goods=goods,
category_breakdown=category_breakdown,
total_amount=total_amount,
billable_amount=billable_amount
)
@projects_bp.route('/projects/<int:project_id>/goods/add', methods=['GET', 'POST'])
@login_required
def add_good(project_id):
"""Add a new extra good to a project"""
project = Project.query.get_or_404(project_id)
if request.method == 'POST':
name = request.form.get('name', '').strip()
description = request.form.get('description', '').strip()
category = request.form.get('category', 'product').strip()
quantity = request.form.get('quantity', '1').strip()
unit_price = request.form.get('unit_price', '').strip()
sku = request.form.get('sku', '').strip()
billable = request.form.get('billable') == 'on'
currency_code = request.form.get('currency_code', 'EUR').strip()
# Validate required fields
if not name or not unit_price:
flash(_('Name and unit price are required'), 'error')
return render_template('projects/add_good.html', project=project)
# Validate quantity
try:
quantity = Decimal(quantity)
if quantity <= 0:
raise ValueError('Quantity must be positive')
except (ValueError, Exception):
flash(_('Invalid quantity format'), 'error')
return render_template('projects/add_good.html', project=project)
# Validate unit price
try:
unit_price = Decimal(unit_price)
if unit_price < 0:
raise ValueError('Unit price cannot be negative')
except (ValueError, Exception):
flash(_('Invalid unit price format'), 'error')
return render_template('projects/add_good.html', project=project)
# Create extra good
good = ExtraGood(
name=name,
description=description if description else None,
category=category,
quantity=quantity,
unit_price=unit_price,
sku=sku if sku else None,
billable=billable,
currency_code=currency_code,
project_id=project_id,
created_by=current_user.id
)
db.session.add(good)
if not safe_commit('add_project_good', {'project_id': project_id}):
flash(_('Could not add extra good due to a database error. Please check server logs.'), 'error')
return render_template('projects/add_good.html', project=project)
flash(_('Extra good added successfully'), 'success')
return redirect(url_for('projects.view_project', project_id=project.id))
return render_template('projects/add_good.html', project=project)
@projects_bp.route('/projects/<int:project_id>/goods/<int:good_id>/edit', methods=['GET', 'POST'])
@login_required
def edit_good(project_id, good_id):
"""Edit a project extra good"""
project = Project.query.get_or_404(project_id)
good = ExtraGood.query.get_or_404(good_id)
# Verify good belongs to project
if good.project_id != project_id:
flash(_('Extra good not found'), 'error')
return redirect(url_for('projects.view_project', project_id=project_id))
# Only admin or the user who created the good can edit
if not current_user.is_admin and good.created_by != current_user.id:
flash(_('You do not have permission to edit this extra good'), 'error')
return redirect(url_for('projects.view_project', project_id=project_id))
if request.method == 'POST':
name = request.form.get('name', '').strip()
description = request.form.get('description', '').strip()
category = request.form.get('category', 'product').strip()
quantity = request.form.get('quantity', '1').strip()
unit_price = request.form.get('unit_price', '').strip()
sku = request.form.get('sku', '').strip()
billable = request.form.get('billable') == 'on'
currency_code = request.form.get('currency_code', 'EUR').strip()
# Validate required fields
if not name or not unit_price:
flash(_('Name and unit price are required'), 'error')
return render_template('projects/edit_good.html', project=project, good=good)
# Validate quantity
try:
quantity = Decimal(quantity)
if quantity <= 0:
raise ValueError('Quantity must be positive')
except (ValueError, Exception):
flash(_('Invalid quantity format'), 'error')
return render_template('projects/edit_good.html', project=project, good=good)
# Validate unit price
try:
unit_price = Decimal(unit_price)
if unit_price < 0:
raise ValueError('Unit price cannot be negative')
except (ValueError, Exception):
flash(_('Invalid unit price format'), 'error')
return render_template('projects/edit_good.html', project=project, good=good)
# Update good
good.name = name
good.description = description if description else None
good.category = category
good.quantity = quantity
good.unit_price = unit_price
good.sku = sku if sku else None
good.billable = billable
good.currency_code = currency_code
good.update_total()
if not safe_commit('edit_project_good', {'good_id': good_id}):
flash(_('Could not update extra good due to a database error. Please check server logs.'), 'error')
return render_template('projects/edit_good.html', project=project, good=good)
flash(_('Extra good updated successfully'), 'success')
return redirect(url_for('projects.view_project', project_id=project.id))
return render_template('projects/edit_good.html', project=project, good=good)
@projects_bp.route('/projects/<int:project_id>/goods/<int:good_id>/delete', methods=['POST'])
@login_required
def delete_good(project_id, good_id):
"""Delete a project extra good"""
project = Project.query.get_or_404(project_id)
good = ExtraGood.query.get_or_404(good_id)
# Verify good belongs to project
if good.project_id != project_id:
flash(_('Extra good not found'), 'error')
return redirect(url_for('projects.view_project', project_id=project_id))
# Only admin or the user who created the good can delete
if not current_user.is_admin and good.created_by != current_user.id:
flash(_('You do not have permission to delete this extra good'), 'error')
return redirect(url_for('projects.view_project', project_id=project_id))
# Check if good has been added to an invoice
if good.invoice_id:
flash(_('Cannot delete extra good that has been added to an invoice'), 'error')
return redirect(url_for('projects.view_project', project_id=project_id))
good_name = good.name
db.session.delete(good)
if not safe_commit('delete_project_good', {'good_id': good_id}):
flash(_('Could not delete extra good due to a database error. Please check server logs.'), 'error')
return redirect(url_for('projects.view_project', project_id=project_id))
flash(_(f'Extra good "{good_name}" deleted successfully'), 'success')
return redirect(url_for('projects.view_project', project_id=project.id))
# API endpoint for getting project extra goods as JSON
@projects_bp.route('/api/projects/<int:project_id>/goods')
@login_required
def api_project_goods(project_id):
"""API endpoint to get project extra goods"""
project = Project.query.get_or_404(project_id)
goods = ExtraGood.get_project_goods(project_id)
total_amount = ExtraGood.get_total_amount(project_id=project_id)
billable_amount = ExtraGood.get_total_amount(project_id=project_id, billable_only=True)
return jsonify({
'goods': [good.to_dict() for good in goods],
'total_amount': total_amount,
'billable_amount': billable_amount,
'count': len(goods)
})