diff --git a/API.md b/API.md index abfdeebc..b44003b3 100644 --- a/API.md +++ b/API.md @@ -72,5 +72,6 @@ Replace `your-domain.com` with your TimeTracker host and `YOUR_API_TOKEN` with y ## Full Documentation - **[REST API reference](docs/api/REST_API.md)** — All endpoints, request/response formats, pagination, errors +- **[API Consistency Audit](docs/api/API_CONSISTENCY_AUDIT.md)** — Response contracts, error format, pagination - **[API Token Scopes](docs/api/API_TOKEN_SCOPES.md)** — Scopes and permissions - **[API Versioning](docs/api/API_VERSIONING.md)** — Versioning policy and usage diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index df24fffe..2fe68d3d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -12,6 +12,7 @@ Thank you for your interest in contributing to TimeTracker. This page gives you For development setup, coding standards, testing, pull request process, and commit conventions, see: +- **[Contributor Guide](docs/development/CONTRIBUTOR_GUIDE.md)** — Architecture, local dev, testing, how to add routes/services/templates, versioning - **[Contributing guidelines (full)](docs/development/CONTRIBUTING.md)** — Development setup, coding standards, testing, PR process - **[Code of Conduct](docs/development/CODE_OF_CONDUCT.md)** — Community standards and expected behavior - **[CHANGELOG.md](CHANGELOG.md)** — How we track changes; update the *Unreleased* section for user-facing changes diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 4f968b06..ac35c68b 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -1,6 +1,6 @@ # TimeTracker Development Guide -Quick reference for running the project locally, running tests, and contributing. For full guidelines, see [Contributing](CONTRIBUTING.md) and the [developer documentation](docs/development/CONTRIBUTING.md). +Quick reference for running the project locally, running tests, and contributing. For a single-page contributor overview (workflows, adding routes/services/templates), see [Contributor Guide](docs/development/CONTRIBUTOR_GUIDE.md). For full guidelines, see [Contributing](CONTRIBUTING.md) and the [developer documentation](docs/development/CONTRIBUTING.md). ## Running Locally diff --git a/README.md b/README.md index 482f6703..38c3650a 100644 --- a/README.md +++ b/README.md @@ -76,7 +76,7 @@ TimeTracker is built with modern, reliable technologies: ## 🖥️ UI overview -The web app uses a **single main layout** with a sidebar and top header. Content is centered with a max width for readability. **Getting around:** **Dashboard** — overview, today’s stats, and the main **Timer** widget (start/stop, quick start, repeat last). **Timer** and **Time entries** are first-class in the sidebar for fast access. **Time entries** is the place to filter, review, and export all logged time. **Reports**, **Projects**, **Finance**, and **Settings** are available from the sidebar and navigation. For design and component conventions, see [UI Guidelines](docs/UI_GUIDELINES.md). +The web app uses a **single main layout** with a sidebar and top header. Content is centered with a max width for readability. **Getting around:** **Dashboard** — overview, today’s stats, and the main **Timer** widget (start/stop, quick start, repeat last). **Timer** and **Time entries** are first-class in the sidebar for fast access. **Time entries** is the place to filter, review, and export all logged time. **Reports** (time, project, finance) are available from the sidebar (top-level **Reports** link or **Finance & Expenses → Reports** for Report Builder, Saved Views, Scheduled Reports), and from the bottom bar on mobile. **Projects**, **Finance**, and **Settings** are available from the sidebar and navigation. For design and component conventions, see [UI Guidelines](docs/UI_GUIDELINES.md). --- @@ -86,20 +86,12 @@ TimeTracker has been continuously enhanced with powerful new features! Here's wh > **📋 For complete release history, see [CHANGELOG.md](CHANGELOG.md)** -**Current version** is defined in `setup.py` (single source of truth). See [CHANGELOG.md](CHANGELOG.md) for release history. +**Current version** is defined in `setup.py` (single source of truth). See [CHANGELOG.md](CHANGELOG.md) for versioned release history. - 📱 **Native Mobile & Desktop Apps** — Flutter mobile app (iOS/Android) and Electron desktop app with time tracking, offline support, and API integration ([Build Guide](BUILD.md), [Docs](docs/mobile-desktop-apps/README.md)) - 📋 **Project Analysis & Documentation** — Comprehensive project analysis and documentation updates - 🔧 **Version Consistency** — Fixed version inconsistencies across documentation files -**Previous Releases:** -- **v4.14.0** (January 2025) — Documentation and technology stack updates -- **v4.6.0** (December 2025) — Comprehensive Issue/Bug Tracking System - -**Recent Releases:** -- **v4.5.1** — Performance optimizations and version management improvements -- **v4.5.0** — Advanced Report Builder, quick task creation, Kanban enhancements, and PWA improvements -- **v4.4.1** — Dashboard cache fixes and custom reports enhancements -- **v4.4.0** — Project custom fields, file attachments, and salesman-based report splitting +See [CHANGELOG.md](CHANGELOG.md) for all release notes and version history. ### 🎯 **Major Feature Additions** @@ -437,7 +429,7 @@ docker-compose up -d # Click "Advanced" → "Proceed to localhost" to continue ``` -**First login creates the admin account** — just enter your username! +**First login creates the admin account** — just enter your username! For setup problems, see [INSTALLATION.md](INSTALLATION.md). **📖 See the complete setup guide:** [`docs/admin/configuration/DOCKER_COMPOSE_SETUP.md`](docs/admin/configuration/DOCKER_COMPOSE_SETUP.md) diff --git a/app/__init__.py b/app/__init__.py index 36bc62d0..62d4d5bb 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -672,6 +672,14 @@ def record_metrics_and_log(response): except Exception as e: app.logger.warning(f"Could not enable query logging: {e}") + # Optional performance instrumentation (slow-request log, query-count when PERF_QUERY_PROFILE=1) + try: + from app.utils.performance import init_performance_logging + + init_performance_logging(app) + except Exception as e: + app.logger.warning(f"Could not init performance logging: {e}") + # Load analytics configuration (embedded at build time) from app.config.analytics_defaults import get_analytics_config, has_analytics_configured diff --git a/app/config.py b/app/config.py index 1aa88426..dcf65676 100644 --- a/app/config.py +++ b/app/config.py @@ -182,6 +182,12 @@ class Config: "Referrer-Policy": "strict-origin-when-cross-origin", } + # Performance instrumentation (optional; no production overhead when disabled) + # Log a single line when request duration exceeds this many milliseconds (0 = disabled) + PERF_LOG_SLOW_REQUESTS_MS = int(os.getenv("PERF_LOG_SLOW_REQUESTS_MS", "0")) + # When true, track DB query count per request and include in slow-request logs + PERF_QUERY_PROFILE = os.getenv("PERF_QUERY_PROFILE", "false").lower() == "true" + # Rate limiting RATELIMIT_DEFAULT = os.getenv("RATELIMIT_DEFAULT", "") # e.g., "200 per day;50 per hour" RATELIMIT_STORAGE_URI = os.getenv("RATELIMIT_STORAGE_URI", "memory://") diff --git a/app/models/recurring_invoice.py b/app/models/recurring_invoice.py index 3cd2d4ad..45f2c74c 100644 --- a/app/models/recurring_invoice.py +++ b/app/models/recurring_invoice.py @@ -111,117 +111,10 @@ def should_generate_today(self): return today >= self.next_run_date def generate_invoice(self): - """Generate an invoice from this recurring template""" - from app.models import Invoice, InvoiceItem, TimeEntry, Settings - - if not self.should_generate_today(): - return None - - # Get settings for currency - settings = Settings.get_settings() - currency_code = self.currency_code or (settings.currency if settings else "EUR") - - # Calculate dates - issue_date = datetime.utcnow().date() - due_date = issue_date + timedelta(days=self.due_date_days) - - # Generate invoice number - invoice_number = Invoice.generate_invoice_number() - - # Create invoice - invoice = Invoice( - invoice_number=invoice_number, - project_id=self.project_id, - client_name=self.client_name, - due_date=due_date, - created_by=self.created_by, - client_id=self.client_id, - client_email=self.client_email, - client_address=self.client_address, - tax_rate=self.tax_rate, - notes=self.notes, - terms=self.terms, - currency_code=currency_code, - template_id=self.template_id, - issue_date=issue_date, - ) - - # Link to recurring invoice template - invoice.recurring_invoice_id = self.id - - db.session.add(invoice) - - # Auto-include time entries if enabled - if self.auto_include_time_entries: - # Get unbilled time entries for this project - time_entries = ( - TimeEntry.query.filter( - TimeEntry.project_id == self.project_id, TimeEntry.end_time.isnot(None), TimeEntry.billable == True - ) - .order_by(TimeEntry.start_time.desc()) - .all() - ) - - # Filter out entries already billed - unbilled_entries = [] - for entry in time_entries: - already_billed = False - for other_invoice in self.project.invoices: - if other_invoice.id != invoice.id: - for item in other_invoice.items: - if item.time_entry_ids and str(entry.id) in item.time_entry_ids.split(","): - already_billed = True - break - if already_billed: - break - - if not already_billed: - unbilled_entries.append(entry) - - # Group and create invoice items - if unbilled_entries: - from app.models.rate_override import RateOverride - - grouped_entries = {} - for entry in unbilled_entries: - if entry.task_id: - key = f"task_{entry.task_id}" - description = f"Task: {entry.task.name if entry.task else 'Unknown Task'}" - else: - key = f"project_{entry.project_id}" - description = f"Project: {entry.project.name}" - - if key not in grouped_entries: - grouped_entries[key] = { - "description": description, - "entries": [], - "total_hours": Decimal("0"), - } - - grouped_entries[key]["entries"].append(entry) - grouped_entries[key]["total_hours"] += entry.duration_hours - - # Create invoice items - hourly_rate = RateOverride.resolve_rate(self.project) - for group in grouped_entries.values(): - if group["total_hours"] > 0: - item = InvoiceItem( - invoice_id=invoice.id, - description=group["description"], - quantity=group["total_hours"], - unit_price=hourly_rate, - time_entry_ids=",".join(str(e.id) for e in group["entries"]), - ) - db.session.add(item) - - # Calculate totals - invoice.calculate_totals() - - # Update recurring invoice - self.last_generated_at = datetime.utcnow() - self.next_run_date = self.calculate_next_run_date(issue_date) - - return invoice + """Generate an invoice from this recurring template. Delegates to RecurringInvoiceService.""" + from app.services.recurring_invoice_service import RecurringInvoiceService + + return RecurringInvoiceService().generate_invoice(self) def to_dict(self): """Convert recurring invoice to dictionary""" diff --git a/app/repositories/__init__.py b/app/repositories/__init__.py index 136e5f06..0670bdd1 100644 --- a/app/repositories/__init__.py +++ b/app/repositories/__init__.py @@ -13,6 +13,7 @@ from .expense_repository import ExpenseRepository from .payment_repository import PaymentRepository from .comment_repository import CommentRepository +from .recurring_invoice_repository import RecurringInvoiceRepository __all__ = [ "TimeEntryRepository", @@ -24,4 +25,5 @@ "ExpenseRepository", "PaymentRepository", "CommentRepository", + "RecurringInvoiceRepository", ] diff --git a/app/repositories/recurring_invoice_repository.py b/app/repositories/recurring_invoice_repository.py new file mode 100644 index 00000000..99d16a5f --- /dev/null +++ b/app/repositories/recurring_invoice_repository.py @@ -0,0 +1,31 @@ +""" +Repository for recurring invoice data access. +""" + +from typing import List, Optional + +from app.models import RecurringInvoice +from app.repositories.base_repository import BaseRepository + + +class RecurringInvoiceRepository(BaseRepository[RecurringInvoice]): + """Repository for RecurringInvoice operations.""" + + def __init__(self): + super().__init__(RecurringInvoice) + + def list_for_user( + self, + created_by: Optional[int] = None, + is_admin: bool = False, + is_active: Optional[bool] = None, + ) -> List[RecurringInvoice]: + """List recurring invoices, optionally filtered by creator and active status.""" + query = self.model.query + if not is_admin and created_by is not None: + query = query.filter_by(created_by=created_by) + if is_active is True: + query = query.filter_by(is_active=True) + elif is_active is False: + query = query.filter_by(is_active=False) + return query.order_by(RecurringInvoice.next_run_date.asc()).all() diff --git a/app/repositories/time_entry_repository.py b/app/repositories/time_entry_repository.py index d72daad3..a76e81ee 100644 --- a/app/repositories/time_entry_repository.py +++ b/app/repositories/time_entry_repository.py @@ -95,6 +95,29 @@ def get_by_date_range( return query.order_by(TimeEntry.start_time.desc()).all() + def count_for_date_range( + self, + start_date: datetime, + end_date: datetime, + user_id: Optional[int] = None, + project_id: Optional[int] = None, + client_id: Optional[int] = None, + ) -> int: + """Count time entries in date range with optional filters (avoids loading all rows).""" + from sqlalchemy import func + + query = db.session.query(func.count(TimeEntry.id)).filter( + and_(TimeEntry.start_time >= start_date, TimeEntry.start_time <= end_date) + ) + if user_id: + query = query.filter_by(user_id=user_id) + if project_id: + query = query.filter_by(project_id=project_id) + if client_id: + query = query.filter_by(client_id=client_id) + result = query.scalar() + return int(result) if result else 0 + def get_billable_entries( self, user_id: Optional[int] = None, @@ -194,6 +217,17 @@ def create_manual_entry( db.session.add(entry) return entry + def get_distinct_project_ids_for_user(self, user_id: int) -> List[int]: + """Return distinct project IDs the user has time entries for (excludes None).""" + rows = ( + self.model.query.with_entities(TimeEntry.project_id) + .filter_by(user_id=user_id) + .filter(TimeEntry.project_id.isnot(None)) + .distinct() + .all() + ) + return [r[0] for r in rows] + def get_total_duration( self, user_id: Optional[int] = None, @@ -228,3 +262,41 @@ def get_total_duration( result = query.scalar() return int(result) if result else 0 + + def get_task_aggregates( + self, + task_ids: List[int], + start_date: datetime, + end_date: datetime, + project_id: Optional[int] = None, + user_id: Optional[int] = None, + ) -> List[tuple]: + """ + Return (task_id, total_seconds, entry_count) for each task in task_ids, + filtered by date range and optional project_id/user_id. + Use for task report to avoid N+1 per-task queries. + """ + if not task_ids: + return [] + from sqlalchemy import func + + query = ( + db.session.query( + TimeEntry.task_id, + func.sum(TimeEntry.duration_seconds).label("total_seconds"), + func.count(TimeEntry.id).label("entry_count"), + ) + .filter( + TimeEntry.task_id.in_(task_ids), + TimeEntry.end_time.isnot(None), + TimeEntry.start_time >= start_date, + TimeEntry.start_time <= end_date, + ) + .group_by(TimeEntry.task_id) + ) + if project_id: + query = query.filter(TimeEntry.project_id == project_id) + if user_id: + query = query.filter(TimeEntry.user_id == user_id) + rows = query.all() + return [(r.task_id, int(r.total_seconds or 0), r.entry_count) for r in rows] diff --git a/app/routes/admin.py b/app/routes/admin.py index 9b4c06c7..e1abadcf 100644 --- a/app/routes/admin.py +++ b/app/routes/admin.py @@ -608,72 +608,91 @@ def admin_dashboard(): # Log error but continue - OIDC user count is not critical for dashboard display current_app.logger.warning(f"Failed to count OIDC users: {e}", exc_info=True) - # Calculate chart data for last 30 days - from datetime import time as time_class - end_date = datetime.utcnow() - start_date = end_date - timedelta(days=30) - - # User activity over time (daily user counts who created entries) - user_activity_data = [] - for i in range(30): - date = (end_date - timedelta(days=i)).date() - date_start = datetime.combine(date, time_class(0, 0, 0)) - date_end = datetime.combine(date, time_class(23, 59, 59, 999999)) - - # Count distinct users who logged time on this date - user_count = ( - db.session.query(func.count(func.distinct(TimeEntry.user_id))) + # Chart data for last 30 days (cached 10 min to reduce DB load) + from app.utils.cache import get_cache + _cache = get_cache() + chart_data = _cache.get("admin:dashboard:chart") + if chart_data is None: + from datetime import date as date_type + + end_date = datetime.utcnow() + range_start = (end_date - timedelta(days=29)).replace(hour=0, minute=0, second=0, microsecond=0) + range_end = end_date + all_dates = [(end_date - timedelta(days=i)).date() for i in range(29, -1, -1)] + + def _norm_date(v): + if v is None: + return None + if isinstance(v, date_type): + return v + if hasattr(v, "date") and callable(getattr(v, "date")): + return v.date() + if isinstance(v, str): + try: + return date_type.fromisoformat(v[:10]) + except (ValueError, TypeError): + return v + return v + + user_activity_rows = ( + db.session.query( + func.date(TimeEntry.start_time).label("day"), + func.count(func.distinct(TimeEntry.user_id)).label("cnt"), + ) .filter( TimeEntry.end_time.isnot(None), - TimeEntry.start_time >= date_start, - TimeEntry.start_time <= date_end + TimeEntry.start_time >= range_start, + TimeEntry.start_time <= range_end, ) - .scalar() or 0 + .group_by(func.date(TimeEntry.start_time)) + .all() ) - - user_activity_data.append({ - 'date': date.strftime('%Y-%m-%d'), - 'count': user_count - }) - - user_activity_data.reverse() # Oldest to newest - - # Project status distribution - project_status_data = {} - status_counts = ( - db.session.query(Project.status, func.count(Project.id)) - .group_by(Project.status) - .all() - ) - for status, count in status_counts: - project_status_data[status or 'none'] = count - - # Daily time entry hours for last 30 days - time_entries_daily = [] - for i in range(30): - date = (end_date - timedelta(days=i)).date() - date_start = datetime.combine(date, time_class(0, 0, 0)) - date_end = datetime.combine(date, time_class(23, 59, 59, 999999)) - - # Get total hours for this day - total_seconds = ( - db.session.query(func.sum(TimeEntry.duration_seconds)) + user_activity_by_date = {_norm_date(d.day): d.cnt for d in user_activity_rows} + user_activity_data = [ + {"date": d.strftime("%Y-%m-%d"), "count": user_activity_by_date.get(d, 0)} + for d in all_dates + ] + + project_status_data = {} + status_counts = ( + db.session.query(Project.status, func.count(Project.id)) + .group_by(Project.status) + .all() + ) + for status, count in status_counts: + project_status_data[status or "none"] = count + + time_hours_rows = ( + db.session.query( + func.date(TimeEntry.start_time).label("day"), + func.sum(TimeEntry.duration_seconds).label("total_seconds"), + ) .filter( TimeEntry.end_time.isnot(None), - TimeEntry.start_time >= date_start, - TimeEntry.start_time <= date_end + TimeEntry.start_time >= range_start, + TimeEntry.start_time <= range_end, ) - .scalar() or 0 + .group_by(func.date(TimeEntry.start_time)) + .all() ) - - hours = round(total_seconds / 3600, 2) if total_seconds else 0 - - time_entries_daily.append({ - 'date': date.strftime('%Y-%m-%d'), - 'hours': hours - }) - - time_entries_daily.reverse() # Oldest to newest + time_hours_by_date = {} + for row in time_hours_rows: + day = _norm_date(row.day) + if day is not None: + time_hours_by_date[day] = round((row.total_seconds or 0) / 3600, 2) + time_entries_daily = [ + {"date": d.strftime("%Y-%m-%d"), "hours": time_hours_by_date.get(d, 0)} + for d in all_dates + ] + chart_data = { + "user_activity": user_activity_data, + "project_status": project_status_data, + "time_entries_daily": time_entries_daily, + } + try: + _cache.set("admin:dashboard:chart", chart_data, ttl=600) + except Exception: + pass # Build stats object expected by the template stats = { @@ -688,13 +707,6 @@ def admin_dashboard(): "last_backup": None, } - # Chart data - chart_data = { - 'user_activity': user_activity_data, - 'project_status': project_status_data, - 'time_entries_daily': time_entries_daily, - } - return render_template( "admin/dashboard.html", stats=stats, @@ -901,10 +913,10 @@ def edit_user(user_id): # Subcontractor: sync assigned clients (only when role is subcontractor) assigned_client_ids = [int(x) for x in request.form.getlist("assigned_client_ids") if x and x.isdigit()] UserClient.query.filter_by(user_id=user.id).delete() - if role_name == "subcontractor": + if role_name == "subcontractor" and assigned_client_ids: + valid_client_ids = {c.id for c in Client.query.filter(Client.id.in_(assigned_client_ids)).all()} for cid in assigned_client_ids: - client_obj = Client.query.get(cid) - if client_obj: + if cid in valid_client_ids: db.session.add(UserClient(user_id=user.id, client_id=cid)) if not safe_commit("admin_edit_user", {"user_id": user.id}): diff --git a/app/routes/api.py b/app/routes/api.py index 1e789077..cd18d9a7 100644 --- a/app/routes/api.py +++ b/app/routes/api.py @@ -925,10 +925,8 @@ def parse_iso_local(s: str): # Invalidate dashboard cache for the entry owner so new entry appears immediately try: - from app.utils.cache import get_cache - cache = get_cache() - cache_key = f"dashboard:{entry.user_id}" - cache.delete(cache_key) + from app.utils.cache import invalidate_dashboard_for_user + invalidate_dashboard_for_user(entry.user_id) current_app.logger.debug("Invalidated dashboard cache for user %s after entry creation", entry.user_id) except Exception as e: current_app.logger.warning("Failed to invalidate dashboard cache: %s", e) @@ -1618,10 +1616,8 @@ def parse_dt_local(dt_str): # Invalidate dashboard cache for the entry owner so changes appear immediately try: - from app.utils.cache import get_cache - cache = get_cache() - cache_key = f"dashboard:{entry.user_id}" - cache.delete(cache_key) + from app.utils.cache import invalidate_dashboard_for_user + invalidate_dashboard_for_user(entry.user_id) current_app.logger.debug("Invalidated dashboard cache for user %s after entry update", entry.user_id) except Exception as e: current_app.logger.warning("Failed to invalidate dashboard cache: %s", e) @@ -1654,10 +1650,8 @@ def delete_entry(entry_id): # Invalidate dashboard cache for the entry owner so changes appear immediately try: - from app.utils.cache import get_cache - cache = get_cache() - cache_key = f"dashboard:{current_user.id}" - cache.delete(cache_key) + from app.utils.cache import invalidate_dashboard_for_user + invalidate_dashboard_for_user(current_user.id) current_app.logger.debug("Invalidated dashboard cache for user %s after entry deletion", current_user.id) except Exception as e: current_app.logger.warning("Failed to invalidate dashboard cache: %s", e) diff --git a/app/routes/api_docs.py b/app/routes/api_docs.py index e4ca24e5..2a08c5bb 100644 --- a/app/routes/api_docs.py +++ b/app/routes/api_docs.py @@ -94,10 +94,10 @@ def openapi_spec(): - `page` - Page number (default: 1) - `per_page` - Items per page (default: 50, max: 100) -Responses include pagination metadata: +List responses use a **resource-named key** plus `pagination` (e.g. `time_entries`, `projects`, `clients`). Example: ```json { - "items": [...], + "time_entries": [...], "pagination": { "page": 1, "per_page": 50, @@ -123,11 +123,21 @@ def openapi_spec(): - **404 Not Found** - Resource not found - **500 Internal Server Error** - Server error -Error responses include a JSON body: +Error responses include a JSON body with at least `error` (user-facing message) and `message`; optional `error_code` (e.g. unauthorized, forbidden, not_found, validation_error) and `errors` (field-level validation): ```json { - "error": "Error type", - "message": "Detailed error message" + "error": "Invalid token", + "message": "The provided API token is invalid or expired", + "error_code": "unauthorized" +} +``` +Validation errors (400): +```json +{ + "error": "Validation failed", + "message": "Validation failed", + "error_code": "validation_error", + "errors": { "field_name": ["message1", "message2"] } } ``` @@ -212,7 +222,17 @@ def openapi_spec(): "phone": {"type": "string", "nullable": True}, }, }, - "Error": {"type": "object", "properties": {"error": {"type": "string"}, "message": {"type": "string"}}}, + "Error": { + "type": "object", + "properties": { + "error": {"type": "string", "description": "User-facing error message"}, + "message": {"type": "string", "description": "Detailed error message"}, + "error_code": {"type": "string", "description": "Machine-readable code (e.g. unauthorized, forbidden, not_found, validation_error)"}, + "errors": {"type": "object", "additionalProperties": {"type": "array", "items": {"type": "string"}}, "description": "Field-level validation errors"}, + "required_scope": {"type": "string"}, + "available_scopes": {"type": "array", "items": {"type": "string"}}, + }, + }, "Pagination": { "type": "object", "properties": { diff --git a/app/routes/api_v1.py b/app/routes/api_v1.py index 67c986c2..e32f10d6 100644 --- a/app/routes/api_v1.py +++ b/app/routes/api_v1.py @@ -51,11 +51,12 @@ from app.models.time_entry_approval import TimeEntryApproval, ApprovalStatus from app.utils.api_auth import require_api_token from app.utils.api_responses import ( - success_response, error_response, - paginated_response, forbidden_response, not_found_response, + paginated_response, + success_response, + validation_error_response, ) from datetime import datetime, timedelta, date from sqlalchemy import func, or_ @@ -414,7 +415,7 @@ def get_per_diem(pd_id): pd = PerDiem.query.options(joinedload(PerDiem.user)).filter_by(id=pd_id).first_or_404() if not g.api_user.is_admin and pd.user_id != g.api_user.id: - return jsonify({"error": "Access denied"}), 403 + return forbidden_response("Access denied") return jsonify({"per_diem": pd.to_dict()}) @@ -479,7 +480,7 @@ def update_per_diem(pd_id): pd = PerDiem.query.options(joinedload(PerDiem.user)).filter_by(id=pd_id).first_or_404() if not g.api_user.is_admin and pd.user_id != g.api_user.id: - return jsonify({"error": "Access denied"}), 403 + return forbidden_response("Access denied") data = request.get_json() or {} for field in ("trip_purpose", "description", "country", "city", "currency_code", "status", "notes"): @@ -525,7 +526,7 @@ def delete_per_diem(pd_id): pd = PerDiem.query.options(joinedload(PerDiem.user)).filter_by(id=pd_id).first_or_404() if not g.api_user.is_admin and pd.user_id != g.api_user.id: - return jsonify({"error": "Access denied"}), 403 + return forbidden_response("Access denied") pd.status = "rejected" db.session.commit() @@ -735,7 +736,7 @@ def get_calendar_event(event_id): ev = CalendarEvent.query.options(joinedload(CalendarEvent.user)).filter_by(id=event_id).first_or_404() if not g.api_user.is_admin and ev.user_id != g.api_user.id: - return jsonify({"error": "Access denied"}), 403 + return forbidden_response("Access denied") return jsonify({"event": ev.to_dict()}) @@ -791,7 +792,7 @@ def update_calendar_event(event_id): ev = CalendarEvent.query.options(joinedload(CalendarEvent.user)).filter_by(id=event_id).first_or_404() if not g.api_user.is_admin and ev.user_id != g.api_user.id: - return jsonify({"error": "Access denied"}), 403 + return forbidden_response("Access denied") data = request.get_json() or {} for field in ("title", "description", "location", "event_type", "color", "is_private", "reminder_minutes"): @@ -822,7 +823,7 @@ def delete_calendar_event(event_id): ev = CalendarEvent.query.options(joinedload(CalendarEvent.user)).filter_by(id=event_id).first_or_404() if not g.api_user.is_admin and ev.user_id != g.api_user.id: - return jsonify({"error": "Access denied"}), 403 + return forbidden_response("Access denied") db.session.delete(ev) db.session.commit() @@ -983,7 +984,7 @@ def get_saved_filter(filter_id): sf = SavedFilter.query.options(joinedload(SavedFilter.user)).filter_by(id=filter_id).first_or_404() if sf.user_id != g.api_user.id and not (sf.is_shared or g.api_user.is_admin): - return jsonify({"error": "Access denied"}), 403 + return forbidden_response("Access denied") return jsonify({"filter": sf.to_dict()}) @@ -1026,7 +1027,7 @@ def update_saved_filter(filter_id): sf = SavedFilter.query.options(joinedload(SavedFilter.user)).filter_by(id=filter_id).first_or_404() if sf.user_id != g.api_user.id and not g.api_user.is_admin: - return jsonify({"error": "Access denied"}), 403 + return forbidden_response("Access denied") data = request.get_json() or {} for field in ("name", "scope", "payload", "is_shared"): @@ -1049,7 +1050,7 @@ def delete_saved_filter(filter_id): sf = SavedFilter.query.options(joinedload(SavedFilter.user)).filter_by(id=filter_id).first_or_404() if sf.user_id != g.api_user.id and not g.api_user.is_admin: - return jsonify({"error": "Access denied"}), 403 + return forbidden_response("Access denied") db.session.delete(sf) db.session.commit() @@ -1108,7 +1109,7 @@ def get_time_entry_template(tpl_id): ) if tpl.user_id != g.api_user.id and not g.api_user.is_admin: - return jsonify({"error": "Access denied"}), 403 + return forbidden_response("Access denied") return jsonify({"template": tpl.to_dict()}) @@ -1159,7 +1160,7 @@ def update_time_entry_template(tpl_id): ) if tpl.user_id != g.api_user.id and not g.api_user.is_admin: - return jsonify({"error": "Access denied"}), 403 + return forbidden_response("Access denied") data = request.get_json() or {} for field in ( @@ -1195,7 +1196,7 @@ def delete_time_entry_template(tpl_id): ) if tpl.user_id != g.api_user.id and not g.api_user.is_admin: - return jsonify({"error": "Access denied"}), 403 + return forbidden_response("Access denied") db.session.delete(tpl) db.session.commit() @@ -1489,7 +1490,7 @@ def delete_quote(quote_id): # Check permissions if not g.api_user.is_admin and quote.created_by != g.api_user.id: - return jsonify({"error": "Access denied"}), 403 + return forbidden_response("Access denied") db.session.delete(quote) db.session.commit() @@ -1513,7 +1514,7 @@ def update_comment(comment_id): ) if cmt.user_id != g.api_user.id and not g.api_user.is_admin: - return jsonify({"error": "Access denied"}), 403 + return forbidden_response("Access denied") data = request.get_json() or {} new_content = (data.get("content") or "").strip() @@ -1522,7 +1523,7 @@ def update_comment(comment_id): try: cmt.edit_content(new_content, g.api_user) except PermissionError: - return jsonify({"error": "Access denied"}), 403 + return forbidden_response("Access denied") return jsonify({"message": "Comment updated successfully", "comment": cmt.to_dict()}) @@ -1545,7 +1546,7 @@ def delete_comment(comment_id): try: cmt.delete_comment(g.api_user) except PermissionError: - return jsonify({"error": "Access denied"}), 403 + return forbidden_response("Access denied") return jsonify({"message": "Comment deleted successfully"}) @@ -1638,7 +1639,7 @@ def update_client_note(note_id): if not new_content: return jsonify({"error": "content is required"}), 400 if not (g.api_user.is_admin or note.user_id == g.api_user.id): - return jsonify({"error": "Access denied"}), 403 + return forbidden_response("Access denied") note.content = new_content if "is_important" in data: note.is_important = bool(data["is_important"]) @@ -1661,7 +1662,7 @@ def delete_client_note(note_id): ) if not (g.api_user.is_admin or note.user_id == g.api_user.id): - return jsonify({"error": "Access denied"}), 403 + return forbidden_response("Access denied") db.session.delete(note) db.session.commit() @@ -2654,7 +2655,7 @@ def report_summary(): if g.api_user.is_admin or user_id == g.api_user.id: query = query.filter_by(user_id=user_id) else: - return jsonify({"error": "Access denied"}), 403 + return forbidden_response("Access denied") elif not g.api_user.is_admin: query = query.filter_by(user_id=g.api_user.id) @@ -2914,7 +2915,7 @@ def get_webhook(webhook_id): # Check permissions if not g.api_user.is_admin and webhook.user_id != g.api_user.id: - return jsonify({"error": "Access denied"}), 403 + return forbidden_response("Access denied") return jsonify({"webhook": webhook.to_dict()}) @@ -2945,7 +2946,7 @@ def update_webhook(webhook_id): # Check permissions if not g.api_user.is_admin and webhook.user_id != g.api_user.id: - return jsonify({"error": "Access denied"}), 403 + return forbidden_response("Access denied") data = request.get_json() or {} @@ -3028,7 +3029,7 @@ def delete_webhook(webhook_id): # Check permissions if not g.api_user.is_admin and webhook.user_id != g.api_user.id: - return jsonify({"error": "Access denied"}), 403 + return forbidden_response("Access denied") db.session.delete(webhook) db.session.commit() @@ -3070,7 +3071,7 @@ def list_webhook_deliveries(webhook_id): # Check permissions if not g.api_user.is_admin and webhook.user_id != g.api_user.id: - return jsonify({"error": "Access denied"}), 403 + return forbidden_response("Access denied") query = WebhookDelivery.query.filter_by(webhook_id=webhook_id) @@ -4223,7 +4224,7 @@ def approve_timesheet_period(period_id): from app.services.workforce_governance_service import WorkforceGovernanceService if not _is_api_approver(g.api_user): - return jsonify({"error": "Access denied"}), 403 + return forbidden_response("Access denied") data = request.get_json() or {} result = WorkforceGovernanceService().approve_period(period_id=period_id, approver_id=g.api_user.id, comment=data.get("comment")) @@ -4238,7 +4239,7 @@ def reject_timesheet_period(period_id): from app.services.workforce_governance_service import WorkforceGovernanceService if not _is_api_approver(g.api_user): - return jsonify({"error": "Access denied"}), 403 + return forbidden_response("Access denied") data = request.get_json() or {} reason = (data.get("reason") or "").strip() @@ -4283,7 +4284,7 @@ def get_timesheet_policy(): from app.services.workforce_governance_service import WorkforceGovernanceService if not _is_api_approver(g.api_user): - return jsonify({"error": "Access denied"}), 403 + return forbidden_response("Access denied") policy = WorkforceGovernanceService().get_or_create_default_policy() return jsonify({"timesheet_policy": policy.to_dict()}) @@ -4294,7 +4295,7 @@ def update_timesheet_policy(): from app.services.workforce_governance_service import WorkforceGovernanceService if not g.api_user.is_admin: - return jsonify({"error": "Access denied"}), 403 + return forbidden_response("Access denied") service = WorkforceGovernanceService() policy = service.get_or_create_default_policy() @@ -4338,7 +4339,7 @@ def create_leave_type_api(): from app.models.time_off import LeaveType if not g.api_user.is_admin: - return jsonify({"error": "Access denied"}), 403 + return forbidden_response("Access denied") data = request.get_json() or {} name = (data.get("name") or "").strip() @@ -4365,7 +4366,7 @@ def delete_leave_type_api(leave_type_id): from app.services.workforce_governance_service import WorkforceGovernanceService if not g.api_user.is_admin: - return jsonify({"error": "Access denied"}), 403 + return forbidden_response("Access denied") result = WorkforceGovernanceService().delete_leave_type(leave_type_id) if not result.get("success"): return jsonify({"error": result.get("message", "Could not delete leave type")}), 400 @@ -4441,7 +4442,7 @@ def approve_time_off_request_api(request_id): from app.services.workforce_governance_service import WorkforceGovernanceService if not _is_api_approver(g.api_user): - return jsonify({"error": "Access denied"}), 403 + return forbidden_response("Access denied") data = request.get_json() or {} result = WorkforceGovernanceService().review_leave_request( @@ -4458,7 +4459,7 @@ def reject_time_off_request_api(request_id): from app.services.workforce_governance_service import WorkforceGovernanceService if not _is_api_approver(g.api_user): - return jsonify({"error": "Access denied"}), 403 + return forbidden_response("Access denied") data = request.get_json() or {} result = WorkforceGovernanceService().review_leave_request( @@ -4518,7 +4519,7 @@ def create_holiday_api(): from app.models.time_off import CompanyHoliday if not g.api_user.is_admin: - return jsonify({"error": "Access denied"}), 403 + return forbidden_response("Access denied") data = request.get_json() or {} name = (data.get("name") or "").strip() @@ -4539,7 +4540,7 @@ def delete_holiday_api(holiday_id): from app.services.workforce_governance_service import WorkforceGovernanceService if not g.api_user.is_admin: - return jsonify({"error": "Access denied"}), 403 + return forbidden_response("Access denied") result = WorkforceGovernanceService().delete_holiday(holiday_id) if not result.get("success"): return jsonify({"error": result.get("message", "Could not delete holiday")}), 400 @@ -4652,7 +4653,7 @@ def compliance_locked_periods_api(): from app.services.workforce_governance_service import WorkforceGovernanceService if not _is_api_approver(g.api_user): - return jsonify({"error": "Access denied"}), 403 + return forbidden_response("Access denied") start = _parse_date(request.args.get("start_date")) end = _parse_date(request.args.get("end_date")) @@ -4666,7 +4667,7 @@ def compliance_audit_events_api(): from app.services.workforce_governance_service import WorkforceGovernanceService if not _is_api_approver(g.api_user): - return jsonify({"error": "Access denied"}), 403 + return forbidden_response("Access denied") start = _parse_date(request.args.get("start_date")) end = _parse_date(request.args.get("end_date")) diff --git a/app/routes/api_v1_clients.py b/app/routes/api_v1_clients.py index 38cbab59..f8d14578 100644 --- a/app/routes/api_v1_clients.py +++ b/app/routes/api_v1_clients.py @@ -6,6 +6,7 @@ from flask import Blueprint, jsonify, request, g from app.models import Client from app.utils.api_auth import require_api_token +from app.utils.api_responses import error_response, forbidden_response, validation_error_response from app.routes.api_v1_common import _require_module_enabled_for_api api_v1_clients_bp = Blueprint("api_v1_clients", __name__, url_prefix="/api/v1") @@ -22,7 +23,7 @@ def list_clients(): from app.utils.scope_filter import apply_client_scope_to_model page = request.args.get("page", 1, type=int) - per_page = request.args.get("per_page", 50, type=int) + per_page = min(request.args.get("per_page", 50, type=int), 100) client_repo = ClientRepository() query = client_repo.query().order_by(Client.name) scope = apply_client_scope_to_model(Client, g.api_user) @@ -54,7 +55,7 @@ def get_client(client_id): client = Client.query.options(joinedload(Client.projects)).filter_by(id=client_id).first_or_404() if not user_can_access_client(g.api_user, client_id): - return jsonify({"error": "Access denied", "message": "You do not have access to this client"}), 403 + return forbidden_response("You do not have access to this client") return jsonify({"client": client.to_dict()}) @@ -70,7 +71,10 @@ def create_client(): data = request.get_json() or {} if not data.get("name"): - return jsonify({"error": "Client name is required"}), 400 + return validation_error_response( + errors={"name": ["Client name is required"]}, + message="Client name is required", + ) client_service = ClientService() result = client_service.create_client( name=data["name"], @@ -83,5 +87,5 @@ def create_client(): custom_fields=data.get("custom_fields"), ) if not result.get("success"): - return jsonify({"error": result.get("message", "Could not create client")}), 400 + return error_response(result.get("message", "Could not create client"), status_code=400) return jsonify({"message": "Client created successfully", "client": result["client"].to_dict()}), 201 diff --git a/app/routes/api_v1_common.py b/app/routes/api_v1_common.py index 9f9bf719..c0e9eb4e 100644 --- a/app/routes/api_v1_common.py +++ b/app/routes/api_v1_common.py @@ -86,6 +86,7 @@ def _require_module_enabled_for_api(module_id: str): { "error": "module_disabled", "message": f"{module_id} module is disabled by the administrator.", + "error_code": "forbidden", } ), 403, diff --git a/app/routes/api_v1_contacts.py b/app/routes/api_v1_contacts.py index 03154dc1..e899f172 100644 --- a/app/routes/api_v1_contacts.py +++ b/app/routes/api_v1_contacts.py @@ -7,7 +7,7 @@ from app import db from app.models import Client, Contact from app.utils.api_auth import require_api_token -from app.utils.api_responses import error_response +from app.utils.api_responses import error_response, forbidden_response from app.routes.api_v1_common import _require_module_enabled_for_api api_v1_contacts_bp = Blueprint("api_v1_contacts", __name__, url_prefix="/api/v1") @@ -40,7 +40,7 @@ def create_contact(client_id): client = Client.query.filter_by(id=client_id).first_or_404() if not user_can_access_client(g.api_user, client_id): - return jsonify({"error": "Access denied", "message": "You do not have access to this client"}), 403 + return forbidden_response("You do not have access to this client") data = request.get_json() or {} first_name = (data.get("first_name") or "").strip() last_name = (data.get("last_name") or "").strip() diff --git a/app/routes/api_v1_deals.py b/app/routes/api_v1_deals.py index 8cf88751..4c855bc5 100644 --- a/app/routes/api_v1_deals.py +++ b/app/routes/api_v1_deals.py @@ -8,7 +8,7 @@ from app import db from app.models import Deal from app.utils.api_auth import require_api_token -from app.utils.api_responses import error_response, forbidden_response +from app.utils.api_responses import error_response, forbidden_response, validation_error_response from app.routes.api_v1_common import _parse_date, _require_module_enabled_for_api api_v1_deals_bp = Blueprint("api_v1_deals", __name__, url_prefix="/api/v1") @@ -77,7 +77,10 @@ def create_deal(): data = request.get_json() or {} name = (data.get("name") or "").strip() if not name: - return jsonify({"error": "name is required"}), 400 + return validation_error_response( + errors={"name": ["name is required"]}, + message="name is required", + ) value = None if data.get("value") is not None: try: diff --git a/app/routes/api_v1_expenses.py b/app/routes/api_v1_expenses.py index 5dca5908..cee17b8c 100644 --- a/app/routes/api_v1_expenses.py +++ b/app/routes/api_v1_expenses.py @@ -6,6 +6,7 @@ from flask import Blueprint, jsonify, request, g from decimal import Decimal from app.utils.api_auth import require_api_token +from app.utils.api_responses import error_response, forbidden_response, validation_error_response from app.routes.api_v1_common import _parse_date api_v1_expenses_bp = Blueprint("api_v1_expenses", __name__, url_prefix="/api/v1") @@ -20,7 +21,7 @@ def list_expenses(): user_id = request.args.get("user_id", type=int) if user_id: if not g.api_user.is_admin and user_id != g.api_user.id: - return jsonify({"error": "Access denied"}), 403 + return forbidden_response("Access denied") else: if not g.api_user.is_admin: user_id = g.api_user.id @@ -72,7 +73,7 @@ def get_expense(expense_id): .first_or_404() ) if not g.api_user.is_admin and expense.user_id != g.api_user.id: - return jsonify({"error": "Access denied"}), 403 + return forbidden_response("Access denied") return jsonify({"expense": expense.to_dict()}) @@ -83,18 +84,27 @@ def create_expense(): from app.services import ExpenseService data = request.get_json() or {} + errors = {} required = ["title", "category", "amount", "expense_date"] missing = [f for f in required if not data.get(f)] if missing: - return jsonify({"error": f"Missing required fields: {', '.join(missing)}"}), 400 + for f in missing: + errors[f] = [f"{f} is required"] + return validation_error_response(errors=errors, message=f"Missing required fields: {', '.join(missing)}") exp_date = _parse_date(data.get("expense_date")) if not exp_date: - return jsonify({"error": "Invalid expense_date format, expected YYYY-MM-DD"}), 400 + return validation_error_response( + errors={"expense_date": ["Invalid expense_date format, expected YYYY-MM-DD"]}, + message="Invalid expense_date format, expected YYYY-MM-DD", + ) pay_date = _parse_date(data.get("payment_date")) if data.get("payment_date") else None try: amount = Decimal(str(data["amount"])) except Exception: - return jsonify({"error": "Invalid amount"}), 400 + return validation_error_response( + errors={"amount": ["Invalid amount"]}, + message="Invalid amount", + ) expense_service = ExpenseService() result = expense_service.create_expense( amount=amount, @@ -115,7 +125,7 @@ def create_expense(): tags=data.get("tags"), ) if not result.get("success"): - return jsonify({"error": result.get("message", "Could not create expense")}), 400 + return error_response(result.get("message", "Could not create expense"), status_code=400) return jsonify({"message": "Expense created successfully", "expense": result["expense"].to_dict()}), 201 @@ -149,7 +159,7 @@ def update_expense(expense_id): expense_id=expense_id, user_id=g.api_user.id, is_admin=g.api_user.is_admin, **update_kwargs ) if not result.get("success"): - return jsonify({"error": result.get("message", "Could not update expense")}), 400 + return error_response(result.get("message", "Could not update expense"), status_code=400) return jsonify({"message": "Expense updated successfully", "expense": result["expense"].to_dict()}) @@ -164,5 +174,5 @@ def delete_expense(expense_id): expense_id=expense_id, user_id=g.api_user.id, is_admin=g.api_user.is_admin ) if not result.get("success"): - return jsonify({"error": result.get("message", "Could not reject expense")}), 400 + return error_response(result.get("message", "Could not reject expense"), status_code=400) return jsonify({"message": "Expense rejected successfully"}) diff --git a/app/routes/api_v1_invoices.py b/app/routes/api_v1_invoices.py index b75a75ce..6fa7d1f2 100644 --- a/app/routes/api_v1_invoices.py +++ b/app/routes/api_v1_invoices.py @@ -6,6 +6,7 @@ from flask import Blueprint, jsonify, request, g, current_app from app import db from app.utils.api_auth import require_api_token +from app.utils.api_responses import error_response, validation_error_response from app.routes.api_v1_common import _parse_date api_v1_invoices_bp = Blueprint("api_v1_invoices", __name__, url_prefix="/api/v1") @@ -64,18 +65,27 @@ def create_invoice(): from app.services import InvoiceService data = request.get_json() or {} + errors = {} required = ["project_id", "client_id", "client_name", "due_date"] missing = [f for f in required if not data.get(f)] if missing: - return jsonify({"error": f"Missing required fields: {', '.join(missing)}"}), 400 + for f in missing: + errors[f] = [f"{f} is required"] + return validation_error_response(errors=errors, message=f"Missing required fields: {', '.join(missing)}") due_dt = _parse_date(data.get("due_date")) if not due_dt: - return jsonify({"error": "Invalid due_date format, expected YYYY-MM-DD"}), 400 + return validation_error_response( + errors={"due_date": ["Invalid due_date format, expected YYYY-MM-DD"]}, + message="Invalid due_date format, expected YYYY-MM-DD", + ) issue_dt = None if data.get("issue_date"): issue_dt = _parse_date(data.get("issue_date")) if not issue_dt: - return jsonify({"error": "Invalid issue_date format, expected YYYY-MM-DD"}), 400 + return validation_error_response( + errors={"issue_date": ["Invalid issue_date format, expected YYYY-MM-DD"]}, + message="Invalid issue_date format, expected YYYY-MM-DD", + ) invoice_service = InvoiceService() result = invoice_service.create_invoice( project_id=data["project_id"], @@ -93,7 +103,7 @@ def create_invoice(): issue_date=issue_dt, ) if not result.get("success"): - return jsonify({"error": result.get("message", "Could not create invoice")}), 400 + return error_response(result.get("message", "Could not create invoice"), status_code=400) return jsonify({"message": "Invoice created successfully", "invoice": result["invoice"].to_dict()}), 201 @@ -126,7 +136,7 @@ def update_invoice(invoice_id): invoice_service = InvoiceService() result = invoice_service.update_invoice(invoice_id=invoice_id, user_id=g.api_user.id, **update_kwargs) if not result.get("success"): - return jsonify({"error": result.get("message", "Could not update invoice")}), 400 + return error_response(result.get("message", "Could not update invoice"), status_code=400) if "amount_paid" in data: result["invoice"].update_payment_status() db.session.commit() @@ -142,5 +152,5 @@ def delete_invoice(invoice_id): invoice_service = InvoiceService() result = invoice_service.update_invoice(invoice_id=invoice_id, user_id=g.api_user.id, status="cancelled") if not result.get("success"): - return jsonify({"error": result.get("message", "Could not cancel invoice")}), 400 + return error_response(result.get("message", "Could not cancel invoice"), status_code=400) return jsonify({"message": "Invoice cancelled successfully"}) diff --git a/app/routes/api_v1_mileage.py b/app/routes/api_v1_mileage.py index e8671d77..9cc5587b 100644 --- a/app/routes/api_v1_mileage.py +++ b/app/routes/api_v1_mileage.py @@ -8,6 +8,7 @@ from app import db from app.models import Mileage from app.utils.api_auth import require_api_token +from app.utils.api_responses import error_response, forbidden_response, validation_error_response from app.routes.api_v1_common import _parse_date api_v1_mileage_bp = Blueprint("api_v1_mileage", __name__, url_prefix="/api/v1") @@ -22,7 +23,7 @@ def list_mileage(): user_id = request.args.get("user_id", type=int) if user_id: if not g.api_user.is_admin and user_id != g.api_user.id: - return jsonify({"error": "Access denied"}), 403 + return forbidden_response("Access denied") else: if not g.api_user.is_admin: user_id = g.api_user.id @@ -71,7 +72,7 @@ def get_mileage(entry_id): .first_or_404() ) if not g.api_user.is_admin and entry.user_id != g.api_user.id: - return jsonify({"error": "Access denied"}), 403 + return forbidden_response("Access denied") return jsonify({"mileage": entry.to_dict()}) @@ -80,18 +81,27 @@ def get_mileage(entry_id): def create_mileage(): """Create a mileage entry.""" data = request.get_json() or {} + errors = {} required = ["trip_date", "purpose", "start_location", "end_location", "distance_km", "rate_per_km"] missing = [f for f in required if not data.get(f)] if missing: - return jsonify({"error": f"Missing required fields: {', '.join(missing)}"}), 400 + for f in missing: + errors[f] = [f"{f} is required"] + return validation_error_response(errors=errors, message=f"Missing required fields: {', '.join(missing)}") trip_date = _parse_date(data.get("trip_date")) if not trip_date: - return jsonify({"error": "Invalid trip_date format, expected YYYY-MM-DD"}), 400 + return validation_error_response( + errors={"trip_date": ["Invalid trip_date format, expected YYYY-MM-DD"]}, + message="Invalid trip_date format, expected YYYY-MM-DD", + ) try: distance_km = Decimal(str(data["distance_km"])) rate_per_km = Decimal(str(data["rate_per_km"])) except Exception: - return jsonify({"error": "Invalid distance_km or rate_per_km"}), 400 + return validation_error_response( + errors={"distance_km": ["Invalid distance_km or rate_per_km"], "rate_per_km": ["Invalid distance_km or rate_per_km"]}, + message="Invalid distance_km or rate_per_km", + ) entry = Mileage( user_id=g.api_user.id, trip_date=trip_date, @@ -124,7 +134,7 @@ def update_mileage(entry_id): .first_or_404() ) if not g.api_user.is_admin and entry.user_id != g.api_user.id: - return jsonify({"error": "Access denied"}), 403 + return forbidden_response("Access denied") data = request.get_json() or {} for field in ( "purpose", "start_location", "end_location", "description", @@ -166,7 +176,7 @@ def delete_mileage(entry_id): .first_or_404() ) if not g.api_user.is_admin and entry.user_id != g.api_user.id: - return jsonify({"error": "Access denied"}), 403 + return forbidden_response("Access denied") entry.status = "rejected" db.session.commit() return jsonify({"message": "Mileage entry rejected successfully"}) diff --git a/app/routes/api_v1_payments.py b/app/routes/api_v1_payments.py index 0a0e242d..b3b51cb8 100644 --- a/app/routes/api_v1_payments.py +++ b/app/routes/api_v1_payments.py @@ -6,6 +6,7 @@ from flask import Blueprint, jsonify, request, g from decimal import Decimal from app.utils.api_auth import require_api_token +from app.utils.api_responses import error_response, validation_error_response from app.routes.api_v1_common import _parse_date api_v1_payments_bp = Blueprint("api_v1_payments", __name__, url_prefix="/api/v1") @@ -58,14 +59,20 @@ def create_payment(): from datetime import date data = request.get_json() or {} + errors = {} required = ["invoice_id", "amount"] missing = [f for f in required if not data.get(f)] if missing: - return jsonify({"error": f"Missing required fields: {', '.join(missing)}"}), 400 + for f in missing: + errors[f] = [f"{f} is required"] + return validation_error_response(errors=errors, message=f"Missing required fields: {', '.join(missing)}") try: amount = Decimal(str(data["amount"])) except Exception: - return jsonify({"error": "Invalid amount"}), 400 + return validation_error_response( + errors={"amount": ["Invalid amount"]}, + message="Invalid amount", + ) pay_date = _parse_date(data.get("payment_date")) if data.get("payment_date") else date.today() payment_service = PaymentService() result = payment_service.create_payment( @@ -107,7 +114,7 @@ def update_payment(payment_id): payment_service = PaymentService() result = payment_service.update_payment(payment_id=payment_id, user_id=g.api_user.id, **update_kwargs) if not result.get("success"): - return jsonify({"error": result.get("message", "Could not update payment")}), 400 + return error_response(result.get("message", "Could not update payment"), status_code=400) return jsonify({"message": "Payment updated successfully", "payment": result["payment"].to_dict()}) @@ -120,5 +127,5 @@ def delete_payment(payment_id): payment_service = PaymentService() result = payment_service.delete_payment(payment_id=payment_id, user_id=g.api_user.id) if not result.get("success"): - return jsonify({"error": result.get("message", "Could not delete payment")}), 400 + return error_response(result.get("message", "Could not delete payment"), status_code=400) return jsonify({"message": "Payment deleted successfully"}) diff --git a/app/routes/api_v1_projects.py b/app/routes/api_v1_projects.py index c2044f78..3f0e83e8 100644 --- a/app/routes/api_v1_projects.py +++ b/app/routes/api_v1_projects.py @@ -4,8 +4,15 @@ """ from flask import Blueprint, jsonify, request, g +from marshmallow import ValidationError from app.utils.api_auth import require_api_token -from app.utils.api_responses import error_response, not_found_response +from app.utils.api_responses import ( + error_response, + forbidden_response, + handle_validation_error, + not_found_response, + validation_error_response, +) api_v1_projects_bp = Blueprint("api_v1_projects", __name__, url_prefix="/api/v1") @@ -20,7 +27,7 @@ def list_projects(): status = request.args.get("status", "active") client_id = request.args.get("client_id", type=int) page = request.args.get("page", 1, type=int) - per_page = request.args.get("per_page", 20, type=int) + per_page = min(request.args.get("per_page", 50, type=int), 100) scope_client_ids = get_allowed_client_ids(g.api_user) project_service = ProjectService() @@ -58,7 +65,7 @@ def get_project(project_id): if not result: return not_found_response("Project", project_id) if not user_can_access_project(g.api_user, project_id): - return jsonify({"error": "Access denied", "message": "You do not have access to this project"}), 403 + return forbidden_response("You do not have access to this project") return jsonify({"project": result.to_dict()}) @@ -67,24 +74,32 @@ def get_project(project_id): @require_api_token("write:projects") def create_project(): """Create a new project.""" + from app.schemas import ProjectCreateSchema from app.services import ProjectService data = request.get_json() or {} if not data.get("name"): - return jsonify({"error": "Project name is required"}), 400 + return validation_error_response( + errors={"name": ["Project name is required"]}, + message="Project name is required", + ) + try: + loaded = ProjectCreateSchema(partial=True).load(data) + except ValidationError as err: + return handle_validation_error(err) project_service = ProjectService() result = project_service.create_project( - name=data["name"], - client_id=data.get("client_id"), + name=loaded["name"], + client_id=loaded.get("client_id"), created_by=g.api_user.id, - description=data.get("description"), - billable=data.get("billable", True), - hourly_rate=data.get("hourly_rate"), - code=data.get("code"), - budget_amount=data.get("budget_amount"), - budget_threshold_percent=data.get("budget_threshold_percent"), - billing_ref=data.get("billing_ref"), + description=loaded.get("description"), + billable=loaded.get("billable", True), + hourly_rate=loaded.get("hourly_rate"), + code=loaded.get("code"), + budget_amount=loaded.get("budget_amount"), + budget_threshold_percent=loaded.get("budget_threshold_percent"), + billing_ref=loaded.get("billing_ref"), ) if not result.get("success"): diff --git a/app/routes/api_v1_tasks.py b/app/routes/api_v1_tasks.py index 34cbcbff..79a27427 100644 --- a/app/routes/api_v1_tasks.py +++ b/app/routes/api_v1_tasks.py @@ -6,6 +6,7 @@ from flask import Blueprint, jsonify, request, g from app import db from app.utils.api_auth import require_api_token +from app.utils.api_responses import error_response, not_found_response, validation_error_response api_v1_tasks_bp = Blueprint("api_v1_tasks", __name__, url_prefix="/api/v1") @@ -68,10 +69,13 @@ def create_task(): from app.services import TaskService data = request.get_json() or {} + errors = {} if not data.get("name"): - return jsonify({"error": "Task name is required"}), 400 + errors["name"] = ["Task name is required"] if not data.get("project_id"): - return jsonify({"error": "project_id is required"}), 400 + errors["project_id"] = ["project_id is required"] + if errors: + return validation_error_response(errors=errors, message="Validation failed") task_service = TaskService() result = task_service.create_task( @@ -86,7 +90,7 @@ def create_task(): tags=data.get("tags"), ) if not result.get("success"): - return jsonify({"error": result.get("message", "Could not create task")}), 400 + return error_response(result.get("message", "Could not create task"), status_code=400) return jsonify({"message": "Task created successfully", "task": result["task"].to_dict()}), 201 @@ -118,7 +122,7 @@ def update_task(task_id): result = task_service.update_task(task_id=task_id, user_id=g.api_user.id, **update_kwargs) if not result.get("success"): - return jsonify({"error": result.get("message", "Could not update task")}), 400 + return error_response(result.get("message", "Could not update task"), status_code=400) return jsonify({"message": "Task updated successfully", "task": result["task"].to_dict()}) @@ -131,7 +135,7 @@ def delete_task(task_id): task_repo = TaskRepository() task = task_repo.get_by_id(task_id) if not task: - return jsonify({"error": "Task not found"}), 404 + return not_found_response("Task", task_id) db.session.delete(task) db.session.commit() return jsonify({"message": "Task deleted successfully"}) diff --git a/app/routes/api_v1_time_entries.py b/app/routes/api_v1_time_entries.py index 43c8ff09..8e52b3ce 100644 --- a/app/routes/api_v1_time_entries.py +++ b/app/routes/api_v1_time_entries.py @@ -6,7 +6,12 @@ from flask import Blueprint, jsonify, request, g from marshmallow import ValidationError from app.utils.api_auth import require_api_token -from app.utils.api_responses import validation_error_response, handle_validation_error +from app.utils.api_responses import ( + error_response, + forbidden_response, + handle_validation_error, + validation_error_response, +) from app.routes.api_v1_common import paginate_query, parse_datetime, _parse_date_range from app.schemas.time_entry_schema import TimeEntryCreateSchema, TimeEntryUpdateSchema @@ -24,7 +29,7 @@ def list_time_entries(): user_id = request.args.get("user_id", type=int) if user_id: if not g.api_user.is_admin and user_id != g.api_user.id: - return jsonify({"error": "Access denied"}), 403 + return forbidden_response("Access denied") else: if not g.api_user.is_admin: user_id = g.api_user.id @@ -76,7 +81,7 @@ def get_time_entry(entry_id): .first_or_404() ) if not g.api_user.is_admin and entry.user_id != g.api_user.id: - return jsonify({"error": "Access denied"}), 403 + return forbidden_response("Access denied") return jsonify({"time_entry": entry.to_dict()}) @@ -113,7 +118,10 @@ def create_time_entry(): ) if not result.get("success"): - return jsonify({"error": result.get("message", "Could not create time entry")}), 400 + return error_response( + result.get("message", "Could not create time entry"), + status_code=400, + ) entry = result.get("entry") if entry: @@ -180,7 +188,10 @@ def update_time_entry(entry_id): ) if not result.get("success"): - return jsonify({"error": result.get("message", "Could not update time entry")}), 400 + return error_response( + result.get("message", "Could not update time entry"), + status_code=400, + ) entry = result.get("entry") if entry: @@ -225,7 +236,10 @@ def delete_time_entry(entry_id): reason=reason, ) if not result.get("success"): - return jsonify({"error": result.get("message", "Could not delete time entry")}), 400 + return error_response( + result.get("message", "Could not delete time entry"), + status_code=400, + ) return jsonify({"message": "Time entry deleted successfully"}) @@ -248,12 +262,15 @@ def start_timer(): data = request.get_json() or {} project_id = data.get("project_id") if not project_id: - return jsonify({"error": "project_id is required"}), 400 + return validation_error_response( + errors={"project_id": ["project_id is required"]}, + message="project_id is required", + ) from app.utils.scope_filter import user_can_access_project if not user_can_access_project(g.api_user, project_id): - return jsonify({"error": "You do not have access to this project"}), 403 + return forbidden_response("You do not have access to this project") time_tracking_service = TimeTrackingService() result = time_tracking_service.start_timer( @@ -264,7 +281,10 @@ def start_timer(): template_id=data.get("template_id"), ) if not result.get("success"): - return jsonify({"error": result.get("message", "Could not start timer")}), 400 + return error_response( + result.get("message", "Could not start timer"), + status_code=400, + ) return jsonify({"message": "Timer started successfully", "timer": result["timer"].to_dict()}), 201 @@ -277,10 +297,11 @@ def pause_timer(): time_tracking_service = TimeTrackingService() result = time_tracking_service.pause_timer(user_id=g.api_user.id) if not result.get("success"): - return jsonify({ - "error": result.get("message", "Could not pause timer"), - "error_code": result.get("error", "pause_failed"), - }), 400 + return error_response( + result.get("message", "Could not pause timer"), + error_code=result.get("error", "pause_failed"), + status_code=400, + ) return jsonify({"message": "Timer paused", "time_entry": result["entry"].to_dict()}) @@ -293,10 +314,11 @@ def resume_timer(): time_tracking_service = TimeTrackingService() result = time_tracking_service.resume_timer(user_id=g.api_user.id) if not result.get("success"): - return jsonify({ - "error": result.get("message", "Could not resume timer"), - "error_code": result.get("error", "resume_failed"), - }), 400 + return error_response( + result.get("message", "Could not resume timer"), + error_code=result.get("error", "resume_failed"), + status_code=400, + ) return jsonify({"message": "Timer resumed", "time_entry": result["entry"].to_dict()}) @@ -308,12 +330,17 @@ def stop_timer(): active_timer = g.api_user.active_timer if not active_timer: - return jsonify({"error": "No active timer to stop", "error_code": "no_active_timer"}), 400 + return error_response( + "No active timer to stop", + error_code="no_active_timer", + status_code=400, + ) time_tracking_service = TimeTrackingService() result = time_tracking_service.stop_timer(user_id=g.api_user.id, entry_id=active_timer.id) if not result.get("success"): - return jsonify({ - "error": result.get("message", "Could not stop timer"), - "error_code": result.get("error", "stop_failed"), - }), 400 + return error_response( + result.get("message", "Could not stop timer"), + error_code=result.get("error", "stop_failed"), + status_code=400, + ) return jsonify({"message": "Timer stopped successfully", "time_entry": result["entry"].to_dict()}) diff --git a/app/routes/budget_alerts.py b/app/routes/budget_alerts.py index 1657d26b..a55605d7 100644 --- a/app/routes/budget_alerts.py +++ b/app/routes/budget_alerts.py @@ -9,6 +9,7 @@ from flask_babel import _ from app import db, log_event, track_event from app.models import Project, BudgetAlert, User +from app.repositories import TimeEntryRepository from app.utils.budget_forecasting import ( calculate_burn_rate, estimate_completion_date, @@ -39,13 +40,8 @@ def budget_dashboard(): ) else: # For non-admin users, show only projects they've worked on - from sqlalchemy import distinct - from app.models import TimeEntry - - user_project_ids_result = ( - db.session.query(distinct(TimeEntry.project_id)).filter(TimeEntry.user_id == current_user.id).all() - ) - user_project_ids = [pid[0] for pid in user_project_ids_result] + time_entry_repo = TimeEntryRepository() + user_project_ids = time_entry_repo.get_distinct_project_ids_for_user(current_user.id) projects = ( Project.query.filter( @@ -242,13 +238,8 @@ def get_alerts(): alerts = BudgetAlert.get_active_alerts(project_id=project_id, acknowledged=acknowledged) else: # For non-admin, get alerts for their projects - from sqlalchemy import distinct - from app.models import TimeEntry - - user_project_ids = ( - db.session.query(distinct(TimeEntry.project_id)).filter(TimeEntry.user_id == current_user.id).all() - ) - user_project_ids = [pid[0] for pid in user_project_ids] + time_entry_repo = TimeEntryRepository() + user_project_ids = time_entry_repo.get_distinct_project_ids_for_user(current_user.id) query = BudgetAlert.query.filter( BudgetAlert.is_acknowledged == acknowledged, BudgetAlert.project_id.in_(user_project_ids) @@ -388,13 +379,8 @@ def get_budget_summary(): projects = Project.query.filter(Project.budget_amount.isnot(None), Project.status == "active").all() else: # For non-admin, get projects they've worked on - from sqlalchemy import distinct - from app.models import TimeEntry - - user_project_ids = ( - db.session.query(distinct(TimeEntry.project_id)).filter(TimeEntry.user_id == current_user.id).all() - ) - user_project_ids = [pid[0] for pid in user_project_ids] + time_entry_repo = TimeEntryRepository() + user_project_ids = time_entry_repo.get_distinct_project_ids_for_user(current_user.id) projects = Project.query.filter( Project.id.in_(user_project_ids), Project.budget_amount.isnot(None), Project.status == "active" @@ -423,13 +409,8 @@ def get_budget_summary(): if current_user.is_admin: alert_stats = BudgetAlert.get_alert_summary() else: - from sqlalchemy import distinct - from app.models import TimeEntry - - user_project_ids = ( - db.session.query(distinct(TimeEntry.project_id)).filter(TimeEntry.user_id == current_user.id).all() - ) - user_project_ids = [pid[0] for pid in user_project_ids] + time_entry_repo = TimeEntryRepository() + user_project_ids = time_entry_repo.get_distinct_project_ids_for_user(current_user.id) total_alerts = BudgetAlert.query.filter(BudgetAlert.project_id.in_(user_project_ids)).count() diff --git a/app/routes/clients.py b/app/routes/clients.py index f2b03ccb..fcb3a5d6 100644 --- a/app/routes/clients.py +++ b/app/routes/clients.py @@ -364,10 +364,8 @@ def create_client(): # Invalidate dashboard cache so single-client state updates (Issue #467) try: - from app.utils.cache import get_cache - cache = get_cache() - if cache: - cache.delete(f"dashboard:{current_user.id}") + from app.utils.cache import invalidate_dashboard_for_user + invalidate_dashboard_for_user(current_user.id) except Exception: pass @@ -769,9 +767,8 @@ def archive_client(client_id): flash(f'Client "{client.name}" archived successfully', "success") try: from app.utils.cache import get_cache - c = get_cache() - if c: - c.delete(f"dashboard:{current_user.id}") + from app.utils.cache import invalidate_dashboard_for_user + invalidate_dashboard_for_user(current_user.id) except Exception: pass @@ -797,9 +794,8 @@ def activate_client(client_id): flash(f'Client "{client.name}" activated successfully', "success") try: from app.utils.cache import get_cache - c = get_cache() - if c: - c.delete(f"dashboard:{current_user.id}") + from app.utils.cache import invalidate_dashboard_for_user + invalidate_dashboard_for_user(current_user.id) except Exception: pass @@ -850,10 +846,8 @@ def delete_client(client_id): app_module.track_event(current_user.id, "client.deleted", {"client_id": client_id_for_log}) try: - from app.utils.cache import get_cache - c = get_cache() - if c: - c.delete(f"dashboard:{current_user.id}") + from app.utils.cache import invalidate_dashboard_for_user + invalidate_dashboard_for_user(current_user.id) except Exception: pass @@ -934,9 +928,8 @@ def bulk_delete_clients(): flash(f'Successfully deleted {deleted_count} client{"s" if deleted_count != 1 else ""}', "success") try: from app.utils.cache import get_cache - c = get_cache() - if c: - c.delete(f"dashboard:{current_user.id}") + from app.utils.cache import invalidate_dashboard_for_user + invalidate_dashboard_for_user(current_user.id) except Exception: pass diff --git a/app/routes/gantt.py b/app/routes/gantt.py index eef246cc..9d70532c 100644 --- a/app/routes/gantt.py +++ b/app/routes/gantt.py @@ -2,13 +2,14 @@ Routes for Gantt chart visualization. """ +from datetime import datetime, timedelta + from flask import Blueprint, render_template, request, jsonify from flask_babel import gettext as _ from flask_login import login_required, current_user -from app import db -from app.models import Project, Task, TimeEntry -from datetime import datetime, timedelta -from sqlalchemy import func + +from app.models import Project +from app.services.gantt_service import GanttService from app.utils.module_helpers import module_enabled gantt_bp = Blueprint("gantt", __name__) @@ -21,7 +22,6 @@ def gantt_view(): """Main Gantt chart view.""" project_id = request.args.get("project_id", type=int) projects = Project.query.filter_by(status="active").order_by(Project.name).all() - return render_template("gantt/view.html", projects=projects, selected_project_id=project_id) @@ -34,154 +34,21 @@ def gantt_data(): start_date = request.args.get("start_date") end_date = request.args.get("end_date") - # Parse dates if start_date: start_dt = datetime.strptime(start_date, "%Y-%m-%d") else: start_dt = datetime.utcnow() - timedelta(days=90) - if end_date: end_dt = datetime.strptime(end_date, "%Y-%m-%d") else: end_dt = datetime.utcnow() + timedelta(days=90) - # Get projects - query = Project.query.filter_by(status="active") - if project_id: - query = query.filter_by(id=project_id) - - # 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_( - Project.created_by == current_user.id, - Project.id.in_( - db.session.query(TimeEntry.project_id).filter_by(user_id=current_user.id).distinct().subquery() - ), - ) - ) - - projects = query.all() - - # Build Gantt data - gantt_data = [] - - for project in projects: - # Get project start and end dates from tasks - tasks = Task.query.filter_by(project_id=project.id).all() - - if not tasks: - # If no tasks, use project creation date - project_start = project.created_at or datetime.utcnow() - project_end = project_start + timedelta(days=30) - else: - # Calculate project timeline from tasks - task_dates = [] - for task in tasks: - if task.due_date: - task_dates.append(datetime.combine(task.due_date, datetime.min.time())) - if task.created_at: - task_dates.append(task.created_at) - - if task_dates: - project_start = min(task_dates) - project_end = max(task_dates) + timedelta(days=7) # Add buffer - else: - project_start = project.created_at or datetime.utcnow() - project_end = project_start + timedelta(days=30) - - # Ensure dates are within requested range - if project_start < start_dt: - project_start = start_dt - if project_end > end_dt: - project_end = end_dt - - # Add project as parent task - proj_color = (project.color or "#3b82f6").lstrip("#") - if len(proj_color) != 6 or not all(c in "0123456789aAbBcCdDeEfF" for c in proj_color): - proj_color = "3b82f6" - gantt_data.append( - { - "id": f"project-{project.id}", - "name": project.name, - "start": project_start.strftime("%Y-%m-%d"), - "end": project_end.strftime("%Y-%m-%d"), - "progress": calculate_project_progress(project), - "type": "project", - "project_id": project.id, - "dependencies": [], - "color": proj_color, - } - ) - - # Add tasks as child items - for task in tasks: - # Use due_date if available, otherwise estimate from created_at - if task.due_date: - task_end = datetime.combine(task.due_date, datetime.min.time()) - task_start = task_end - timedelta(days=7) # Default 7-day duration - else: - task_start = task.created_at or project_start - task_end = task_start + timedelta(days=7) - - # Ensure dates are within range - if task_start < start_dt: - task_start = start_dt - if task_end > end_dt: - task_end = end_dt - - dependencies = [] - # Task dependencies would need to be added to Task model if needed - # Use task-level color when set, otherwise project color - raw = (task.color or "").strip().lstrip("#") - if raw and len(raw) == 6 and all(c in "0123456789aAbBcCdDeEfF" for c in raw): - task_color = raw.lower() - else: - task_color = proj_color - - gantt_data.append( - { - "id": f"task-{task.id}", - "name": task.name, - "start": task_start.strftime("%Y-%m-%d"), - "end": task_end.strftime("%Y-%m-%d"), - "progress": calculate_task_progress(task), - "type": "task", - "task_id": task.id, - "project_id": project.id, - "parent": f"project-{project.id}", - "dependencies": dependencies, - "status": task.status, - "color": task_color, - } - ) - - return jsonify( - {"data": gantt_data, "start_date": start_dt.strftime("%Y-%m-%d"), "end_date": end_dt.strftime("%Y-%m-%d")} + result = GanttService().get_gantt_data( + project_id=project_id, + start_dt=start_dt, + end_dt=end_dt, + user_id=current_user.id, + has_view_all_projects=has_view_all_projects, ) - - -def calculate_project_progress(project): - """Calculate project progress percentage.""" - tasks = Task.query.filter_by(project_id=project.id).all() - if not tasks: - return 0 - - completed = sum(1 for t in tasks if t.status == "done") - return int((completed / len(tasks)) * 100) - - -def calculate_task_progress(task): - """Calculate task progress percentage.""" - if task.status == "done": - return 100 - elif task.status == "in_progress": - return 50 - elif task.status == "review": - return 75 - else: - return 0 + return jsonify(result) diff --git a/app/routes/inventory.py b/app/routes/inventory.py index 63c632ab..a623a317 100644 --- a/app/routes/inventory.py +++ b/app/routes/inventory.py @@ -1472,12 +1472,23 @@ def stock_item_history(item_id): def low_stock_alerts(): """View low stock alerts""" items = StockItem.query.filter_by(is_active=True, is_trackable=True).all() + items_with_reorder = [i for i in items if i.reorder_point] + item_ids = [i.id for i in items_with_reorder] low_stock_items = [] - for item in items: - if item.reorder_point: - stock_levels = WarehouseStock.query.filter_by(stock_item_id=item.id).all() - for stock in stock_levels: + if item_ids: + from collections import defaultdict + from sqlalchemy.orm import joinedload + all_stock = ( + WarehouseStock.query.options(joinedload(WarehouseStock.warehouse)) + .filter(WarehouseStock.stock_item_id.in_(item_ids)) + .all() + ) + stock_by_item = defaultdict(list) + for s in all_stock: + stock_by_item[s.stock_item_id].append(s) + for item in items_with_reorder: + for stock in stock_by_item.get(item.id, []): if stock.quantity_on_hand < item.reorder_point: low_stock_items.append( { @@ -2129,13 +2140,23 @@ def reports_dashboard(): items_with_reorder = StockItem.query.filter( StockItem.is_active == True, StockItem.is_trackable == True, StockItem.reorder_point.isnot(None) ).all() - - for item in items_with_reorder: - stock_levels = WarehouseStock.query.filter_by(stock_item_id=item.id).all() - for stock in stock_levels: - if stock.quantity_on_hand < item.reorder_point: - low_stock_count += 1 - break + item_ids = [i.id for i in items_with_reorder] + if item_ids: + from collections import defaultdict + from sqlalchemy.orm import joinedload + all_stock = ( + WarehouseStock.query.options(joinedload(WarehouseStock.warehouse)) + .filter(WarehouseStock.stock_item_id.in_(item_ids)) + .all() + ) + stock_by_item = defaultdict(list) + for s in all_stock: + stock_by_item[s.stock_item_id].append(s) + for item in items_with_reorder: + for stock in stock_by_item.get(item.id, []): + if stock.quantity_on_hand < item.reorder_point: + low_stock_count += 1 + break settings = Settings.get_settings() currency = settings.currency if settings else "EUR" @@ -2346,12 +2367,23 @@ def reports_turnover(): def reports_low_stock(): """Low stock report""" items = StockItem.query.filter_by(is_active=True, is_trackable=True).all() + items_with_reorder = [i for i in items if i.reorder_point] + item_ids = [i.id for i in items_with_reorder] low_stock_items = [] - for item in items: - if item.reorder_point: - stock_levels = WarehouseStock.query.filter_by(stock_item_id=item.id).all() - for stock in stock_levels: + if item_ids: + from collections import defaultdict + from sqlalchemy.orm import joinedload + all_stock = ( + WarehouseStock.query.options(joinedload(WarehouseStock.warehouse)) + .filter(WarehouseStock.stock_item_id.in_(item_ids)) + .all() + ) + stock_by_item = defaultdict(list) + for s in all_stock: + stock_by_item[s.stock_item_id].append(s) + for item in items_with_reorder: + for stock in stock_by_item.get(item.id, []): if stock.quantity_on_hand < item.reorder_point: low_stock_items.append( { diff --git a/app/routes/invoices.py b/app/routes/invoices.py index 2563e6d2..de121965 100644 --- a/app/routes/invoices.py +++ b/app/routes/invoices.py @@ -920,105 +920,25 @@ def generate_from_time(invoice_id): return redirect(url_for("invoices.edit_invoice", invoice_id=invoice.id)) # GET request - show time entry and cost selection - # Get unbilled time entries for this project - time_entries = ( - TimeEntry.query.filter( - TimeEntry.project_id == invoice.project_id, TimeEntry.end_time.isnot(None), TimeEntry.billable == True - ) - .order_by(TimeEntry.start_time.asc()) - .all() - ) - - # Filter out entries already billed in other invoices - unbilled_entries = [] - for entry in time_entries: - # Check if this entry is already billed in another invoice - already_billed = False - for other_invoice in invoice.project.invoices: - if other_invoice.id != invoice.id: - for item in other_invoice.items: - if item.time_entry_ids and str(entry.id) in item.time_entry_ids.split(","): - already_billed = True - break - if already_billed: - break - - if not already_billed: - unbilled_entries.append(entry) - - # Get uninvoiced billable costs for this project - unbilled_costs = ProjectCost.get_uninvoiced_costs(invoice.project_id) - - # Get uninvoiced billable expenses for this project - unbilled_expenses = Expense.get_uninvoiced_expenses(project_id=invoice.project_id) - - # Get billable extra goods for this project (not yet on an invoice) - project_goods = ( - ExtraGood.query.filter( - ExtraGood.project_id == invoice.project_id, ExtraGood.invoice_id.is_(None), ExtraGood.billable == True - ) - .order_by(ExtraGood.created_at.desc()) - .all() - ) - - # Group time entries by day for a clearer selection UI - grouped_time_entries = [] - current_date = None - current_bucket = None - for entry in unbilled_entries: - entry_date = entry.start_time.date() if entry.start_time else None - if entry_date != current_date: - current_date = entry_date - current_bucket = {"date": current_date, "entries": [], "total_hours": 0.0} - grouped_time_entries.append(current_bucket) - current_bucket["entries"].append(entry) - current_bucket["total_hours"] += float(entry.duration_hours or 0) - - # Calculate totals - total_available_hours = sum(entry.duration_hours for entry in unbilled_entries) - total_available_costs = sum(float(cost.amount) for cost in unbilled_costs) - total_available_expenses = sum(float(expense.total_amount) for expense in unbilled_expenses) - total_available_goods = sum(float(good.total_amount) for good in project_goods) - - prepaid_summary = [] - prepaid_plan_hours = None - if invoice.client and invoice.client.prepaid_plan_enabled: - allocator = PrepaidHoursAllocator(client=invoice.client) - summaries = allocator.build_summary(unbilled_entries) - prepaid_summary = [] - for summary in summaries: - allocation_month = summary.allocation_month - prepaid_summary.append( - { - "allocation_month": allocation_month, - "allocation_month_label": allocation_month.strftime("%Y-%m-%d") if allocation_month else "", - "plan_hours": float(summary.plan_hours), - "consumed_hours": float(summary.consumed_hours), - "remaining_hours": float(summary.remaining_hours), - } - ) - prepaid_plan_hours = float(invoice.client.prepaid_hours_decimal) - - # Get currency from settings - settings = Settings.get_settings() - currency = settings.currency if settings else "USD" + from app.services import InvoiceService + data = InvoiceService().get_unbilled_data_for_invoice(invoice) return render_template( "invoices/generate_from_time.html", invoice=invoice, - time_entries=unbilled_entries, - grouped_time_entries=grouped_time_entries, - project_costs=unbilled_costs, - expenses=unbilled_expenses, - extra_goods=project_goods, - total_available_hours=total_available_hours, - total_available_costs=total_available_costs, - total_available_expenses=total_available_expenses, - total_available_goods=total_available_goods, - currency=currency, - prepaid_summary=prepaid_summary, - prepaid_plan_hours=prepaid_plan_hours, - prepaid_reset_day=invoice.client.prepaid_reset_day if invoice.client else None, + time_entries=data["time_entries"], + grouped_time_entries=data["grouped_time_entries"], + project_costs=data["project_costs"], + expenses=data["expenses"], + extra_goods=data["extra_goods"], + total_available_hours=data["total_available_hours"], + total_available_costs=data["total_available_costs"], + total_available_expenses=data["total_available_expenses"], + total_available_goods=data["total_available_goods"], + currency=data["currency"], + prepaid_summary=data["prepaid_summary"], + prepaid_plan_hours=data["prepaid_plan_hours"], + prepaid_reset_day=data.get("prepaid_reset_day"), ) diff --git a/app/routes/issues.py b/app/routes/issues.py index 19bb702b..13945e3f 100644 --- a/app/routes/issues.py +++ b/app/routes/issues.py @@ -11,6 +11,7 @@ from app.models import Issue, Client, Project, Task, User from app.utils.db import safe_commit from app.utils.pagination import get_pagination_params +from app.utils.scope_filter import get_accessible_project_and_client_ids_for_user from sqlalchemy import or_ from app.utils.module_helpers import module_enabled @@ -62,33 +63,14 @@ def list_issues(): has_view_all_issues = current_user.has_permission("view_all_issues") if hasattr(current_user, 'has_permission') else False if not has_view_all_issues: - # Get user's accessible project IDs (projects they created or have time entries for) - from app.models.time_entry import TimeEntry - - # Projects the user has time entries for - user_project_ids = db.session.query(TimeEntry.project_id).filter_by( - user_id=current_user.id - ).distinct().subquery() - - # Get client IDs from accessible projects - accessible_client_ids = db.session.query(Project.client_id).filter( - db.or_( - Project.id.in_(db.session.query(user_project_ids)), - # Also include projects where user is assigned to tasks - Project.id.in_( - db.session.query(Task.project_id).filter_by(assigned_to=current_user.id).distinct().subquery() - ) - ) - ).distinct().subquery() - - # Filter issues by: - # 1. Issues assigned to the user - # 2. Issues for clients/projects the user has access to + accessible_project_ids, accessible_client_ids = get_accessible_project_and_client_ids_for_user( + current_user.id + ) query = query.filter( db.or_( Issue.assigned_to == current_user.id, - Issue.client_id.in_(db.session.query(accessible_client_ids)), - Issue.project_id.in_(db.session.query(user_project_ids)) + Issue.client_id.in_(accessible_client_ids), + Issue.project_id.in_(accessible_project_ids), ) ) @@ -263,32 +245,14 @@ def view_issue(issue_id): if issue.assigned_to == current_user.id: has_access = True else: - # Check if user has access through projects - from app.models.time_entry import TimeEntry - user_project_ids = db.session.query(TimeEntry.project_id).filter_by( - user_id=current_user.id - ).distinct().all() - user_project_ids = [p[0] for p in user_project_ids] - - # Also check projects where user is assigned to tasks - user_task_project_ids = db.session.query(Task.project_id).filter_by( - assigned_to=current_user.id - ).distinct().all() - user_task_project_ids = [p[0] for p in user_task_project_ids] - - all_accessible_project_ids = set(user_project_ids + user_task_project_ids) - - # Check if issue's project or client's projects are accessible - if issue.project_id and issue.project_id in all_accessible_project_ids: - has_access = True - elif issue.client_id: - # Check if any project for this client is accessible - client_project_ids = db.session.query(Project.id).filter_by( - client_id=issue.client_id - ).all() - client_project_ids = [p[0] for p in client_project_ids] - if any(pid in all_accessible_project_ids for pid in client_project_ids): - has_access = True + # Check if user has access through projects or clients + accessible_project_ids, accessible_client_ids = get_accessible_project_and_client_ids_for_user( + current_user.id + ) + has_access = ( + (issue.project_id and issue.project_id in accessible_project_ids) + or (issue.client_id and issue.client_id in accessible_client_ids) + ) if not has_access: flash(_("You do not have permission to view this issue."), "error") @@ -332,26 +296,13 @@ def edit_issue(issue_id): if issue.assigned_to == current_user.id: has_access = True else: - from app.models.time_entry import TimeEntry - user_project_ids = db.session.query(TimeEntry.project_id).filter_by( - user_id=current_user.id - ).distinct().all() - user_project_ids = [p[0] for p in user_project_ids] - user_task_project_ids = db.session.query(Task.project_id).filter_by( - assigned_to=current_user.id - ).distinct().all() - user_task_project_ids = [p[0] for p in user_task_project_ids] - all_accessible_project_ids = set(user_project_ids + user_task_project_ids) - - if issue.project_id and issue.project_id in all_accessible_project_ids: - has_access = True - elif issue.client_id: - client_project_ids = db.session.query(Project.id).filter_by( - client_id=issue.client_id - ).all() - client_project_ids = [p[0] for p in client_project_ids] - if any(pid in all_accessible_project_ids for pid in client_project_ids): - has_access = True + accessible_project_ids, accessible_client_ids = get_accessible_project_and_client_ids_for_user( + current_user.id + ) + has_access = ( + (issue.project_id and issue.project_id in accessible_project_ids) + or (issue.client_id and issue.client_id in accessible_client_ids) + ) if not has_access: flash(_("You do not have permission to edit this issue."), "error") diff --git a/app/routes/main.py b/app/routes/main.py index 10152282..1db2682d 100644 --- a/app/routes/main.py +++ b/app/routes/main.py @@ -1,6 +1,7 @@ from flask import Blueprint, render_template, request, redirect, url_for, flash, session, jsonify from flask_login import login_required, current_user from app.models import User, Project, TimeEntry, Settings, WeeklyTimeGoal, TimeEntryTemplate, Activity, Client +from app.utils.license_utils import is_license_activated from datetime import datetime, timedelta from app import db, track_page_view from sqlalchemy import text @@ -55,12 +56,32 @@ def dashboard(): only_one_client = len(active_clients) == 1 single_client = active_clients[0] if only_one_client else None - # Get user statistics and dashboard aggregations via analytics service + # Get user statistics and dashboard aggregations (cached 90s to reduce DB load) from app.services import AnalyticsService from app.utils.overtime import calculate_period_overtime, get_week_start_for_date, get_overtime_ytd + from app.utils.cache import get_cache + + dashboard_stats_key = f"dashboard:stats:{current_user.id}" + dashboard_chart_key = f"dashboard:chart:{current_user.id}" + cache = get_cache() + stats = cache.get(dashboard_stats_key) + chart_data = cache.get(dashboard_chart_key) + + if stats is None or chart_data is None: + analytics_service = AnalyticsService() + if stats is None: + stats = analytics_service.get_dashboard_stats(user_id=current_user.id) + try: + cache.set(dashboard_stats_key, stats, ttl=90) + except Exception: + pass + if chart_data is None: + chart_data = analytics_service.get_time_by_project_chart(current_user.id, days=7, limit=10) + try: + cache.set(dashboard_chart_key, chart_data, ttl=90) + except Exception: + pass - analytics_service = AnalyticsService() - stats = analytics_service.get_dashboard_stats(user_id=current_user.id) today_hours = stats["time_tracking"]["today_hours"] week_hours = stats["time_tracking"]["week_hours"] month_hours = stats["time_tracking"]["month_hours"] @@ -73,9 +94,9 @@ def dashboard(): overtime_ytd = get_overtime_ytd(current_user) standard_hours_per_day = float(getattr(current_user, "standard_hours_per_day", 8.0) or 8.0) - # Top projects (last 30 days) and time-by-project chart (last 7 days) from service + # Top projects (last 30 days) - not cached (contains ORM refs for template links) + analytics_service = AnalyticsService() top_projects = analytics_service.get_dashboard_top_projects(current_user.id, days=30, limit=5) - chart_data = analytics_service.get_time_by_project_chart(current_user.id, days=7, limit=10) time_by_project_7d = chart_data["series"] chart_labels_7d = chart_data["chart_labels"] chart_hours_7d = chart_data["chart_hours"] @@ -128,7 +149,11 @@ def dashboard(): # Last timer context: most recent completed time entry for "Repeat last" / quick start last_entry = ( - TimeEntry.query.filter( + TimeEntry.query.options( + joinedload(TimeEntry.project), + joinedload(TimeEntry.client), + ) + .filter( TimeEntry.user_id == current_user.id, TimeEntry.end_time.isnot(None), ) @@ -171,7 +196,16 @@ def dashboard(): # Get donation widget stats (separate from user_stats for clarity) time_entries_count = user_stats.get("time_entries_count", 0) total_hours = user_stats.get("total_hours", 0.0) - + + # Optional support reminder: show at most once per session for unlicensed instances + settings_obj = Settings.get_settings() + show_support_reminder = ( + not is_license_activated(settings_obj) + and not session.get("support_reminder_shown", False) + ) + if show_support_reminder: + session["support_reminder_shown"] = True + # Prepare template data template_data = { "active_timer": active_timer, @@ -203,6 +237,7 @@ def dashboard(): "time_entries_count": time_entries_count, # For donation widget "total_hours": total_hours, # For donation widget "timer_stopped_toast": timer_stopped_toast, + "show_support_reminder": show_support_reminder, } return render_template("main/dashboard.html", **template_data) diff --git a/app/routes/projects.py b/app/routes/projects.py index 01e26537..ff2f4944 100644 --- a/app/routes/projects.py +++ b/app/routes/projects.py @@ -569,10 +569,27 @@ def view_project(project_id): current_app.logger.warning(f"Could not load attachments for project {project_id}: {e}") attachments = [] + # Precompute budget status for template (business rule: over/critical/warning/healthy) + project = result["project"] + budget_status = None + if project.budget_amount and float(project.budget_amount) > 0: + consumed = float(project.budget_consumed_amount or 0) + budget_amt = float(project.budget_amount) + pct = (consumed / budget_amt * 100) + threshold = int(project.budget_threshold_percent or 80) + if pct >= 100: + budget_status = "over" + elif pct >= threshold: + budget_status = "critical" + elif pct >= (threshold * 0.8): + budget_status = "warning" + else: + budget_status = "healthy" + # Prevent browser caching of kanban board response = render_template( "projects/view.html", - project=result["project"], + project=project, entries=result["time_entries_pagination"].items, pagination=result["time_entries_pagination"], tasks=result["tasks"], @@ -584,6 +601,7 @@ def view_project(project_id): custom_field_definitions_by_key=custom_field_definitions_by_key, link_templates_by_field=link_templates_by_field, attachments=attachments, + budget_status=budget_status, ) resp = make_response(response) resp.headers["Cache-Control"] = "no-cache, no-store, must-revalidate, max-age=0" diff --git a/app/routes/recurring_invoices.py b/app/routes/recurring_invoices.py index a0ffdeb4..c9b15d7f 100644 --- a/app/routes/recurring_invoices.py +++ b/app/routes/recurring_invoices.py @@ -18,24 +18,20 @@ @module_enabled("recurring_invoices") def list_recurring_invoices(): """List all recurring invoices""" - # Get filter parameters - is_active = request.args.get("is_active", "").strip() - - # Build query - if current_user.is_admin: - query = RecurringInvoice.query - else: - query = RecurringInvoice.query.filter_by(created_by=current_user.id) - - # Apply active filter - if is_active == "true": - query = query.filter_by(is_active=True) - elif is_active == "false": - query = query.filter_by(is_active=False) - - # Get recurring invoices - recurring_invoices = query.order_by(RecurringInvoice.next_run_date.asc()).all() - + from app.services.recurring_invoice_service import RecurringInvoiceService + + is_active_param = request.args.get("is_active", "").strip() + is_active = None + if is_active_param == "true": + is_active = True + elif is_active_param == "false": + is_active = False + + recurring_invoices = RecurringInvoiceService().list_recurring_invoices( + user_id=current_user.id, + is_admin=current_user.is_admin, + is_active=is_active, + ) return render_template("recurring_invoices/list.html", recurring_invoices=recurring_invoices) diff --git a/app/routes/reports.py b/app/routes/reports.py index d056722e..cfdd3e50 100644 --- a/app/routes/reports.py +++ b/app/routes/reports.py @@ -16,6 +16,7 @@ SavedReportView, ) from app.services.scheduled_report_service import ScheduledReportService +from app.repositories import TimeEntryRepository from datetime import datetime, timedelta from sqlalchemy import or_, func, case from sqlalchemy.orm import joinedload @@ -83,46 +84,14 @@ def week_in_review(): @module_enabled("reports") def comparison_view(): """Get comparison data for reports""" + from app.services import ReportingService + period = request.args.get("period", "month") - now = datetime.utcnow() - - if period == "month": - # This month vs last month - this_period_start = datetime(now.year, now.month, 1) - last_period_start = (this_period_start - timedelta(days=1)).replace(day=1) - last_period_end = this_period_start - timedelta(seconds=1) - else: # year - # This year vs last year - this_period_start = datetime(now.year, 1, 1) - last_period_start = datetime(now.year - 1, 1, 1) - last_period_end = datetime(now.year, 1, 1) - timedelta(seconds=1) - - # Get hours for current period can_view_all = current_user.is_admin or current_user.has_permission("view_all_time_entries") - current_query = db.session.query(db.func.sum(TimeEntry.duration_seconds)).filter( - TimeEntry.end_time.isnot(None), TimeEntry.start_time >= this_period_start, TimeEntry.start_time <= now - ) - if not can_view_all: - current_query = current_query.filter(TimeEntry.user_id == current_user.id) - current_seconds = current_query.scalar() or 0 - - # Get hours for previous period - previous_query = db.session.query(db.func.sum(TimeEntry.duration_seconds)).filter( - TimeEntry.end_time.isnot(None), - TimeEntry.start_time >= last_period_start, - TimeEntry.start_time <= last_period_end, - ) - if not can_view_all: - previous_query = previous_query.filter(TimeEntry.user_id == current_user.id) - previous_seconds = previous_query.scalar() or 0 - - current_hours = round(current_seconds / 3600, 2) - previous_hours = round(previous_seconds / 3600, 2) - change = ((current_hours - previous_hours) / previous_hours * 100) if previous_hours > 0 else 0 - - return jsonify( - {"current": {"hours": current_hours}, "previous": {"hours": previous_hours}, "change": round(change, 1)} + data = ReportingService().get_comparison_data( + period=period, user_id=current_user.id, can_view_all=can_view_all ) + return jsonify(data) @reports_bp.route("/reports/project") @@ -130,13 +99,14 @@ def comparison_view(): @module_enabled("reports") def project_report(): """Project-based time report""" + from app.utils.scope_filter import apply_project_scope_to_model + from app.services import ReportingService + project_id = request.args.get("project_id", type=int) start_date = request.args.get("start_date") end_date = request.args.get("end_date") user_id = request.args.get("user_id", type=int) - # Get projects for filter (scoped for subcontractors) - from app.utils.scope_filter import apply_client_scope_to_model, apply_project_scope_to_model projects_query = Project.query.filter_by(status="active").order_by(Project.name) scope_p = apply_project_scope_to_model(Project, current_user) if scope_p is not None: @@ -144,7 +114,6 @@ def project_report(): projects = projects_query.all() users = User.query.filter_by(is_active=True).order_by(User.username).all() - # Parse dates if not start_date: start_date = (datetime.utcnow() - timedelta(days=30)).strftime("%Y-%m-%d") if not end_date: @@ -157,132 +126,26 @@ def project_report(): flash(_("Invalid date format"), "error") return render_template("reports/project_report.html", projects=projects, users=users) - # Get time entries can_view_all = current_user.is_admin or current_user.has_permission("view_all_time_entries") - query = TimeEntry.query.filter( - TimeEntry.end_time.isnot(None), TimeEntry.start_time >= start_dt, TimeEntry.start_time <= end_dt - ) - - # Filter by user if no permission to view all - if not can_view_all: - query = query.filter(TimeEntry.user_id == current_user.id) - - if project_id: - query = query.filter(TimeEntry.project_id == project_id) - - if user_id: - # Only allow filtering by other users if they have permission - if can_view_all: - query = query.filter(TimeEntry.user_id == user_id) - elif user_id != current_user.id: - # User doesn't have permission to view other users' entries - flash(_("You do not have permission to view other users' time entries"), "error") - return render_template("reports/project_report.html", projects=projects, users=users) - - entries = query.options( - joinedload(TimeEntry.project).joinedload(Project.client_obj), - joinedload(TimeEntry.user), - ).order_by(TimeEntry.start_time.desc()).all() - - # Aggregate by project for template expectations - projects_map = {} - for entry in entries: - project = entry.project - if not project: - continue - if project.id not in projects_map: - projects_map[project.id] = { - "id": project.id, - "name": project.name, - "client": project.client, - "description": project.description, - "billable": project.billable, - "hourly_rate": float(project.hourly_rate) if project.hourly_rate else None, - "total_hours": 0.0, - "billable_hours": 0.0, - "billable_amount": 0.0, - "total_costs": 0.0, - "billable_costs": 0.0, - "total_value": 0.0, - "user_totals": {}, - } - agg = projects_map[project.id] - hours = entry.duration_hours - agg["total_hours"] += hours - if entry.billable and project.billable: - agg["billable_hours"] += hours - if project.hourly_rate: - agg["billable_amount"] += hours * float(project.hourly_rate) - # per-user totals - username = entry.user.display_name if entry.user else "Unknown" - agg["user_totals"][username] = agg["user_totals"].get(username, 0.0) + hours - - # Add project costs to the aggregated data - for project_id, agg in projects_map.items(): - # Get costs for this project within the date range - costs_query = ProjectCost.query.filter( - ProjectCost.project_id == project_id, - ProjectCost.cost_date >= start_dt.date(), - ProjectCost.cost_date <= end_dt.date(), - ) - - if user_id: - costs_query = costs_query.filter(ProjectCost.user_id == user_id) - - costs = costs_query.all() - - for cost in costs: - agg["total_costs"] += float(cost.amount) - if cost.billable: - agg["billable_costs"] += float(cost.amount) - - # Calculate total project value (billable hours + billable costs) - agg["total_value"] = agg["billable_amount"] + agg["billable_costs"] - - # Finalize structures - projects_data = [] - total_hours = 0.0 - billable_hours = 0.0 - total_billable_amount = 0.0 - total_costs = 0.0 - total_billable_costs = 0.0 - total_project_value = 0.0 - for agg in projects_map.values(): - total_hours += agg["total_hours"] - billable_hours += agg["billable_hours"] - total_billable_amount += agg["billable_amount"] - total_costs += agg["total_costs"] - total_billable_costs += agg["billable_costs"] - total_project_value += agg["total_value"] - agg["total_hours"] = round(agg["total_hours"], 1) - agg["billable_hours"] = round(agg["billable_hours"], 1) - agg["billable_amount"] = round(agg["billable_amount"], 2) - agg["total_costs"] = round(agg["total_costs"], 2) - agg["billable_costs"] = round(agg["billable_costs"], 2) - agg["total_value"] = round(agg["total_value"], 2) - agg["user_totals"] = [ - {"username": username, "hours": round(hours, 1)} for username, hours in agg["user_totals"].items() - ] - projects_data.append(agg) - - # Summary section expected by template - summary = { - "total_hours": round(total_hours, 1), - "billable_hours": round(billable_hours, 1), - "total_billable_amount": round(total_billable_amount, 2), - "total_costs": round(total_costs, 2), - "total_billable_costs": round(total_billable_costs, 2), - "total_project_value": round(total_project_value, 2), - "projects_count": len(projects_data), - } + if user_id and not can_view_all and user_id != current_user.id: + flash(_("You do not have permission to view other users' time entries"), "error") + return render_template("reports/project_report.html", projects=projects, users=users) + data = ReportingService().get_project_report_data( + start_dt=start_dt, + end_dt=end_dt, + project_id=project_id, + user_id_filter=user_id, + current_user_id=current_user.id, + can_view_all=can_view_all, + ) return render_template( "reports/project_report.html", projects=projects, users=users, - entries=entries, - projects_data=projects_data, - summary=summary, + entries=data["entries"], + projects_data=data["projects_data"], + summary=data["summary"], start_date=start_date, end_date=end_date, selected_project=project_id, @@ -664,10 +527,8 @@ def export_summary_pdf(): if scope_p is not None: projects_query = projects_query.filter(scope_p) elif not current_user.is_admin: - project_ids = ( - db.session.query(TimeEntry.project_id).filter(TimeEntry.user_id == current_user.id).distinct().all() - ) - project_ids = [pid[0] for pid in project_ids] + time_entry_repo = TimeEntryRepository() + project_ids = time_entry_repo.get_distinct_project_ids_for_user(current_user.id) projects_query = projects_query.filter(Project.id.in_(project_ids)) if project_ids else projects_query.filter(Project.id.in_([])) projects = projects_query.all() project_stats = [] @@ -726,10 +587,8 @@ def summary_report(): if scope_p is not None: projects_query = projects_query.filter(scope_p) elif not current_user.is_admin: - project_ids = ( - db.session.query(TimeEntry.project_id).filter(TimeEntry.user_id == current_user.id).distinct().all() - ) - project_ids = [pid[0] for pid in project_ids] + time_entry_repo = TimeEntryRepository() + project_ids = time_entry_repo.get_distinct_project_ids_for_user(current_user.id) projects_query = projects_query.filter(Project.id.in_(project_ids)) if project_ids else projects_query.filter(Project.id.in_([])) projects = projects_query.all() @@ -823,30 +682,29 @@ def task_report(): # Get distinct task IDs (PostgreSQL requires ORDER BY cols in SELECT when using DISTINCT) task_ids_subq = tasks_query.with_entities(Task.id).distinct() - tasks = Task.query.filter(Task.id.in_(task_ids_subq)).order_by( - case((Task.status == "done", 0), else_=1), - Task.name - ).all() + tasks = ( + Task.query.options(joinedload(Task.project), joinedload(Task.assigned_user)) + .filter(Task.id.in_(task_ids_subq)) + .order_by(case((Task.status == "done", 0), else_=1), Task.name) + .all() + ) + task_ids = [t.id for t in tasks] + + # Single aggregation query for hours/entry count per task (avoids N+1) + from app.repositories import TimeEntryRepository + + time_entry_repo = TimeEntryRepository() + aggregates = time_entry_repo.get_task_aggregates( + task_ids, start_dt, end_dt, project_id=project_id, user_id=user_id + ) + agg_by_task = {tid: (total_sec, cnt) for tid, total_sec, cnt in aggregates} - # Compute hours per task (sum of entry durations; respect user/project filters and date range) task_rows = [] total_hours = 0.0 for task in tasks: - te_query = TimeEntry.query.filter( - TimeEntry.task_id == task.id, - TimeEntry.end_time.isnot(None), - TimeEntry.start_time >= start_dt, - TimeEntry.start_time <= end_dt, - ) - if project_id: - te_query = te_query.filter(TimeEntry.project_id == project_id) - if user_id: - te_query = te_query.filter(TimeEntry.user_id == user_id) - - entries = te_query.all() - hours = sum(e.duration_hours for e in entries) + total_seconds, entries_count = agg_by_task.get(task.id, (0, 0)) + hours = round(total_seconds / 3600, 2) total_hours += hours - task_rows.append( { "task": task, @@ -854,8 +712,8 @@ def task_report(): "assignee": task.assigned_user, "status": task.status, "completed_at": task.completed_at, - "hours": round(hours, 2), - "entries_count": len(entries), + "hours": hours, + "entries_count": entries_count, } ) @@ -877,8 +735,10 @@ def task_report(): ) -def _time_entries_report_query(request, require_dates=True): - """Shared query logic for time entries report and its exports. Returns (entries, start_dt, end_dt, start_date, end_date) or (None, None, None, start_date, end_date) on date error.""" +def _time_entries_report_query(request, require_dates=True, return_query=False): + """Shared query logic for time entries report and its exports. + When return_query=False: returns (entries, start_dt, end_dt, start_date, end_date) or (None, None, None, start_date, end_date) on date error. + When return_query=True: returns (query, start_dt, end_dt, start_date, end_date) with query having filters applied (no options/order_by/execution).""" from app.utils.client_lock import enforce_locked_client_id start_date = request.args.get("start_date") @@ -944,6 +804,9 @@ def _time_entries_report_query(request, require_dates=True): else: query = query.filter(TimeEntry.project_id.in_(allowed_project_ids)) + if return_query: + return query, start_dt, end_dt, start_date, end_date + entries = query.options( joinedload(TimeEntry.project).joinedload(Project.client_obj), joinedload(TimeEntry.user), @@ -981,8 +844,10 @@ def time_entries_report(): clients = clients_query.all() tasks = Task.query.order_by(Task.name).all() - entries, start_dt, end_dt, start_date, end_date = _time_entries_report_query(request, require_dates=True) - if entries is None: + base_query, start_dt, end_dt, start_date, end_date = _time_entries_report_query( + request, require_dates=True, return_query=True + ) + if base_query is None: flash(_("Invalid date format"), "error") return render_template( "reports/time_entries_report.html", @@ -999,10 +864,39 @@ def time_entries_report(): selected_client=enforce_locked_client_id(request.args.get("client_id", type=int)), selected_task=request.args.get("task_id", type=int), selected_billed=request.args.get("billed", "all"), + pagination=None, ) - total_hours = sum(e.duration_hours or 0 for e in entries) - summary = {"entries_count": len(entries), "total_hours": round(total_hours, 2)} + from app.constants import DEFAULT_PAGE_SIZE, MAX_PAGE_SIZE + + page = request.args.get("page", 1, type=int) + per_page = min(request.args.get("per_page", DEFAULT_PAGE_SIZE, type=int), MAX_PAGE_SIZE) + per_page = max(1, per_page) + + # Summary from aggregation (same filters, not just current page) + total_count = base_query.with_entities(func.count(TimeEntry.id)).scalar() or 0 + total_seconds = base_query.with_entities(func.sum(TimeEntry.duration_seconds)).scalar() or 0 + summary = {"entries_count": total_count, "total_hours": round((total_seconds or 0) / 3600, 2)} + + entries_query = base_query.options( + joinedload(TimeEntry.project).joinedload(Project.client_obj), + joinedload(TimeEntry.user), + joinedload(TimeEntry.task), + joinedload(TimeEntry.client), + ).order_by(TimeEntry.start_time.desc()) + paginated = entries_query.paginate(page=page, per_page=per_page, error_out=False) + entries = paginated.items + + pagination = { + "page": paginated.page, + "per_page": paginated.per_page, + "total": paginated.total, + "pages": paginated.pages, + "has_next": paginated.has_next, + "has_prev": paginated.has_prev, + "next_page": paginated.page + 1 if paginated.has_next else None, + "prev_page": paginated.page - 1 if paginated.has_prev else None, + } return render_template( "reports/time_entries_report.html", @@ -1019,6 +913,7 @@ def time_entries_report(): selected_client=enforce_locked_client_id(request.args.get("client_id", type=int)), selected_task=request.args.get("task_id", type=int), selected_billed=request.args.get("billed", "all"), + pagination=pagination, ) @@ -1541,7 +1436,7 @@ def export_task_excel(): flash(_("Invalid date format"), "error") return redirect(url_for("reports.task_report")) - # Get tasks: all tasks that have time entries within the date range + # Get tasks: all tasks that have time entries within the date range (eager load to avoid N+1) tasks_query = Task.query.join(TimeEntry, TimeEntry.task_id == Task.id).filter( TimeEntry.end_time.isnot(None), TimeEntry.start_time >= start_dt, @@ -1554,37 +1449,34 @@ def export_task_excel(): if user_id: tasks_query = tasks_query.filter(TimeEntry.user_id == user_id) - # Get distinct task IDs (PostgreSQL requires ORDER BY cols in SELECT when using DISTINCT) task_ids_subq = tasks_query.with_entities(Task.id).distinct() - tasks = Task.query.filter(Task.id.in_(task_ids_subq)).order_by( - case((Task.status == "done", 0), else_=1), - Task.name - ).all() + tasks = ( + Task.query.options(joinedload(Task.project), joinedload(Task.assigned_user)) + .filter(Task.id.in_(task_ids_subq)) + .order_by(case((Task.status == "done", 0), else_=1), Task.name) + .all() + ) + task_ids = [t.id for t in tasks] - # Compute hours per task - task_rows = [] - for task in tasks: - te_query = TimeEntry.query.filter( - TimeEntry.task_id == task.id, - TimeEntry.end_time.isnot(None), - TimeEntry.start_time >= start_dt, - TimeEntry.start_time <= end_dt, - ) - if project_id: - te_query = te_query.filter(TimeEntry.project_id == project_id) - if user_id: - te_query = te_query.filter(TimeEntry.user_id == user_id) + from app.repositories import TimeEntryRepository - entries = te_query.all() - hours = sum(e.duration_hours for e in entries) + time_entry_repo = TimeEntryRepository() + aggregates = time_entry_repo.get_task_aggregates( + task_ids, start_dt, end_dt, project_id=project_id, user_id=user_id + ) + agg_by_task = {tid: (int(total_sec or 0), cnt) for tid, total_sec, cnt in aggregates} + task_rows = [] + for task in tasks: + total_seconds, _ = agg_by_task.get(task.id, (0, 0)) + hours = round(total_seconds / 3600, 2) task_rows.append( { "task": task, "project": task.project, "status": task.status, "completed_at": task.completed_at, - "hours": round(hours, 2), + "hours": hours, } ) @@ -1702,163 +1594,49 @@ def unpaid_hours_report(): single_client=single_client, ) - # Get all billable time entries in the date range can_view_all = current_user.is_admin or current_user.has_permission("view_all_time_entries") - from sqlalchemy.orm import joinedload - - query = TimeEntry.query.options( - joinedload(TimeEntry.user), - joinedload(TimeEntry.project), - joinedload(TimeEntry.task), - joinedload(TimeEntry.client), - ).filter( - TimeEntry.end_time.isnot(None), - TimeEntry.billable == True, - TimeEntry.start_time >= start_dt, - TimeEntry.start_time <= end_dt, - ) - - # Filter by user if no permission to view all - if not can_view_all: - query = query.filter(TimeEntry.user_id == current_user.id) - - if client_id: - query = query.filter(TimeEntry.client_id == client_id) - - all_entries = query.all() - - # Get all invoice items to check which time entries are already invoiced - from app.models.invoice import InvoiceItem - - all_invoice_items = InvoiceItem.query.join(Invoice).filter( - InvoiceItem.time_entry_ids.isnot(None), InvoiceItem.time_entry_ids != "" - ).all() - - # Build a set of time entry IDs that are in fully paid invoices - billed_entry_ids = set() - unpaid_entry_ids = set() # Entries in unpaid/partially paid invoices - - for item in all_invoice_items: - if not item.time_entry_ids: - continue - entry_ids = [int(eid.strip()) for eid in item.time_entry_ids.split(",") if eid.strip().isdigit()] - invoice = item.invoice - if invoice and invoice.payment_status == "fully_paid": - billed_entry_ids.update(entry_ids) - elif invoice and invoice.payment_status in ("unpaid", "partially_paid"): - unpaid_entry_ids.update(entry_ids) - - # Filter entries: only include those that are NOT in fully paid invoices - unpaid_entries = [e for e in all_entries if e.id not in billed_entry_ids] - - # Group by client - client_totals = {} - for entry in unpaid_entries: - # Get client from entry or from project - client = None - if entry.client_id: - client = entry.client - elif entry.project and entry.project.client_id: - client = entry.project.client_obj - - if not client: - continue - - if client.id not in client_totals: - client_totals[client.id] = { - "client": client, - "total_hours": 0.0, - "billable_hours": 0.0, - "estimated_amount": 0.0, - "entries": [], - "projects": {}, - } - - hours = entry.duration_hours - client_totals[client.id]["total_hours"] += hours - client_totals[client.id]["billable_hours"] += hours - client_totals[client.id]["entries"].append(entry) - - # Track by project - if entry.project: - project_id = entry.project.id - if project_id not in client_totals[client.id]["projects"]: - client_totals[client.id]["projects"][project_id] = { - "project": entry.project, - "hours": 0.0, - "rate": float(entry.project.hourly_rate) if entry.project.hourly_rate else 0.0, - } - client_totals[client.id]["projects"][project_id]["hours"] += hours - - # Calculate estimated amount - rate = 0.0 - if entry.project and entry.project.hourly_rate: - rate = float(entry.project.hourly_rate) - elif client.default_hourly_rate: - rate = float(client.default_hourly_rate) - client_totals[client.id]["estimated_amount"] += hours * rate - - # Convert to list and round values - client_data = [] - total_unpaid_hours = 0.0 - total_estimated_amount = 0.0 - - for client_id, data in client_totals.items(): - data["total_hours"] = round(data["total_hours"], 2) - data["billable_hours"] = round(data["billable_hours"], 2) - data["estimated_amount"] = round(data["estimated_amount"], 2) - data["projects"] = list(data["projects"].values()) - for proj in data["projects"]: - proj["hours"] = round(proj["hours"], 2) - client_data.append(data) - total_unpaid_hours += data["total_hours"] - total_estimated_amount += data["estimated_amount"] - - # Sort by total hours descending - client_data.sort(key=lambda x: x["total_hours"], reverse=True) + from app.services import ReportingService - summary = { - "total_unpaid_hours": round(total_unpaid_hours, 2), - "total_estimated_amount": round(total_estimated_amount, 2), - "clients_count": len(client_data), - } + data = ReportingService().get_unpaid_hours_report_data( + start_dt=start_dt, + end_dt=end_dt, + client_id=client_id, + current_user_id=current_user.id, + can_view_all=can_view_all, + ) + client_data = data["client_data"] + summary = data["summary"] - # Check if this is an Ajax request if request.headers.get("X-Requested-With") == "XMLHttpRequest" or request.args.get("format") == "json": return jsonify({ "summary": summary, "client_data": [ { - "client_id": data["client"].id, - "client_name": data["client"].name, - "client_email": data["client"].email, - "total_hours": data["total_hours"], - "billable_hours": data["billable_hours"], - "estimated_amount": data["estimated_amount"], + "client_id": d["client"].id, + "client_name": d["client"].name, + "client_email": getattr(d["client"], "email", None), + "total_hours": d["total_hours"], + "billable_hours": d["billable_hours"], + "estimated_amount": d["estimated_amount"], "projects": [ - { - "project_id": proj["project"].id, - "project_name": proj["project"].name, - "hours": proj["hours"], - "rate": proj["rate"], - } - for proj in data["projects"] + {"project_id": p["project"].id, "project_name": p["project"].name, "hours": p["hours"], "rate": p["rate"]} + for p in d["projects"] ], "entries": [ { - "id": entry.id, - "user": entry.user.display_name if entry.user else "Unknown", - "project": entry.project.name if entry.project else "No Project", - "task": entry.task.name if entry.task else None, - "start_time": entry.start_time.isoformat() if entry.start_time else None, - "end_time": entry.end_time.isoformat() if entry.end_time else None, - "duration_hours": round(entry.duration_hours, 2), - "notes": entry.notes or "", + "id": e.id, + "user": e.user.display_name if e.user else "Unknown", + "project": e.project.name if e.project else "No Project", + "task": e.task.name if e.task else None, + "start_time": e.start_time.isoformat() if e.start_time else None, + "end_time": e.end_time.isoformat() if e.end_time else None, + "duration_hours": round(e.duration_hours, 2), + "notes": e.notes or "", } - for entry in data["entries"] + for e in d["entries"] ], } - for data in client_data + for d in client_data ], }) diff --git a/app/routes/tasks.py b/app/routes/tasks.py index 4e13364c..276e4fe7 100644 --- a/app/routes/tasks.py +++ b/app/routes/tasks.py @@ -1425,6 +1425,12 @@ def my_tasks(): db.session.expire_all() kanban_columns = KanbanColumn.get_active_columns() if KanbanColumn else [] + # Precompute task counts by status for summary cards (avoid template selectattr iteration) + task_counts = {"todo": 0, "in_progress": 0, "review": 0, "done": 0} + for task in tasks.items: + if task.status in task_counts: + task_counts[task.status] += 1 + # Prevent browser caching of kanban board response = render_template( "tasks/my_tasks.html", @@ -1439,6 +1445,7 @@ def my_tasks(): tags=tags, task_type=task_type, overdue=overdue, + task_counts=task_counts, ) resp = make_response(response) resp.headers["Cache-Control"] = "no-cache, no-store, must-revalidate, max-age=0" diff --git a/app/routes/team_chat.py b/app/routes/team_chat.py index 4914910e..277cf0c5 100644 --- a/app/routes/team_chat.py +++ b/app/routes/team_chat.py @@ -67,12 +67,19 @@ def chat_channel(channel_id): # Get channel members members = ChatChannelMember.query.filter_by(channel_id=channel_id).all() - # Mark messages as read + # Mark messages as read (batch load receipts to avoid N+1) + message_ids = [m.id for m in messages] + existing_receipts = ( + ChatReadReceipt.query.filter( + ChatReadReceipt.message_id.in_(message_ids), + ChatReadReceipt.user_id == current_user.id, + ).all() + if message_ids else [] + ) + receipt_by_message = {r.message_id: r for r in existing_receipts} for message in messages: - receipt = ChatReadReceipt.query.filter_by(message_id=message.id, user_id=current_user.id).first() - if not receipt: - receipt = ChatReadReceipt(message_id=message.id, user_id=current_user.id) - db.session.add(receipt) + if message.id not in receipt_by_message: + db.session.add(ChatReadReceipt(message_id=message.id, user_id=current_user.id)) db.session.commit() @@ -277,12 +284,19 @@ def api_messages(channel_id): messages = query.order_by(ChatMessage.created_at.desc()).limit(limit).all() messages.reverse() # Return in chronological order - # Mark as read + # Mark as read (batch load receipts to avoid N+1) + message_ids = [m.id for m in messages] + existing_receipts = ( + ChatReadReceipt.query.filter( + ChatReadReceipt.message_id.in_(message_ids), + ChatReadReceipt.user_id == current_user.id, + ).all() + if message_ids else [] + ) + receipt_by_message = {r.message_id: r for r in existing_receipts} for message in messages: - receipt = ChatReadReceipt.query.filter_by(message_id=message.id, user_id=current_user.id).first() - if not receipt: - receipt = ChatReadReceipt(message_id=message.id, user_id=current_user.id) - db.session.add(receipt) + if message.id not in receipt_by_message: + db.session.add(ChatReadReceipt(message_id=message.id, user_id=current_user.id)) db.session.commit() diff --git a/app/routes/timer.py b/app/routes/timer.py index 48575064..18b7bc98 100644 --- a/app/routes/timer.py +++ b/app/routes/timer.py @@ -266,10 +266,8 @@ def start_timer(): # Invalidate dashboard cache so timer appears immediately try: - from app.utils.cache import get_cache - cache = get_cache() - cache_key = f"dashboard:{current_user.id}" - cache.delete(cache_key) + from app.utils.cache import invalidate_dashboard_for_user + invalidate_dashboard_for_user(current_user.id) current_app.logger.debug("Invalidated dashboard cache for user %s", current_user.id) except Exception as e: current_app.logger.warning("Failed to invalidate dashboard cache: %s", e) @@ -358,10 +356,8 @@ def start_timer_from_template(template_id): # Invalidate dashboard cache so timer appears immediately try: - from app.utils.cache import get_cache - cache = get_cache() - cache_key = f"dashboard:{current_user.id}" - cache.delete(cache_key) + from app.utils.cache import invalidate_dashboard_for_user + invalidate_dashboard_for_user(current_user.id) current_app.logger.debug("Invalidated dashboard cache for user %s", current_user.id) except Exception as e: current_app.logger.warning("Failed to invalidate dashboard cache: %s", e) @@ -444,10 +440,8 @@ def start_timer_for_project(project_id): # Invalidate dashboard cache so timer appears immediately try: - from app.utils.cache import get_cache - cache = get_cache() - cache_key = f"dashboard:{current_user.id}" - cache.delete(cache_key) + from app.utils.cache import invalidate_dashboard_for_user + invalidate_dashboard_for_user(current_user.id) current_app.logger.debug("Invalidated dashboard cache for user %s", current_user.id) except Exception as e: current_app.logger.warning("Failed to invalidate dashboard cache: %s", e) @@ -540,10 +534,8 @@ def stop_timer(): # Invalidate dashboard cache so timer disappears immediately try: - from app.utils.cache import get_cache - cache = get_cache() - cache_key = f"dashboard:{current_user.id}" - cache.delete(cache_key) + from app.utils.cache import invalidate_dashboard_for_user + invalidate_dashboard_for_user(current_user.id) current_app.logger.debug("Invalidated dashboard cache for user %s", current_user.id) except Exception as e: current_app.logger.warning("Failed to invalidate dashboard cache: %s", e) @@ -584,9 +576,8 @@ def pause_timer(): current_app.logger.exception("Error pausing timer: %s", e) flash(_("Could not pause timer. Please try again."), "error") try: - from app.utils.cache import get_cache - cache = get_cache() - cache.delete(f"dashboard:{current_user.id}") + from app.utils.cache import invalidate_dashboard_for_user + invalidate_dashboard_for_user(current_user.id) except Exception: pass return redirect(url_for("main.dashboard")) @@ -609,9 +600,8 @@ def resume_timer(): current_app.logger.exception("Error resuming timer: %s", e) flash(_("Could not resume timer. Please try again."), "error") try: - from app.utils.cache import get_cache - cache = get_cache() - cache.delete(f"dashboard:{current_user.id}") + from app.utils.cache import invalidate_dashboard_for_user + invalidate_dashboard_for_user(current_user.id) except Exception: pass return redirect(url_for("main.dashboard")) @@ -649,9 +639,8 @@ def adjust_timer(): db.session.commit() try: - from app.utils.cache import get_cache - cache = get_cache() - cache.delete(f"dashboard:{current_user.id}") + from app.utils.cache import invalidate_dashboard_for_user + invalidate_dashboard_for_user(current_user.id) except Exception: pass @@ -885,10 +874,8 @@ def edit_timer(timer_id): # Invalidate dashboard cache for the timer owner so changes appear immediately try: - from app.utils.cache import get_cache - cache = get_cache() - cache_key = f"dashboard:{timer.user_id}" - cache.delete(cache_key) + from app.utils.cache import invalidate_dashboard_for_user + invalidate_dashboard_for_user(timer.user_id) current_app.logger.debug("Invalidated dashboard cache for user %s after timer edit", timer.user_id) except Exception as e: current_app.logger.warning("Failed to invalidate dashboard cache: %s", e) @@ -1043,10 +1030,8 @@ def delete_timer(timer_id): # Invalidate dashboard cache for the timer owner so changes appear immediately try: - from app.utils.cache import get_cache - cache = get_cache() - cache_key = f"dashboard:{timer_user_id}" - cache.delete(cache_key) + from app.utils.cache import invalidate_dashboard_for_user + invalidate_dashboard_for_user(timer_user_id) current_app.logger.debug("Invalidated dashboard cache for user %s after timer deletion", timer_user_id) except Exception as e: current_app.logger.warning("Failed to invalidate dashboard cache: %s", e) @@ -1066,10 +1051,8 @@ def delete_timer(timer_id): # Invalidate dashboard cache so deleted entry disappears immediately try: - from app.utils.cache import get_cache - cache = get_cache() - cache_key = f"dashboard:{current_user.id}" - cache.delete(cache_key) + from app.utils.cache import invalidate_dashboard_for_user + invalidate_dashboard_for_user(current_user.id) current_app.logger.debug("Invalidated dashboard cache for user %s after deleting timer", current_user.id) except Exception as e: current_app.logger.warning("Failed to invalidate dashboard cache: %s", e) @@ -1484,10 +1467,8 @@ def _parse_worked_time_minutes(raw: str): # Invalidate dashboard cache so new entry appears immediately try: - from app.utils.cache import get_cache - cache = get_cache() - cache_key = f"dashboard:{current_user.id}" - cache.delete(cache_key) + from app.utils.cache import invalidate_dashboard_for_user + invalidate_dashboard_for_user(current_user.id) current_app.logger.debug("Invalidated dashboard cache for user %s after manual entry creation", current_user.id) except Exception as e: current_app.logger.warning("Failed to invalidate dashboard cache: %s", e) @@ -2134,10 +2115,8 @@ def resume_timer_by_id(timer_id): # Invalidate dashboard cache so timer appears immediately try: - from app.utils.cache import get_cache - cache = get_cache() - cache_key = f"dashboard:{current_user.id}" - cache.delete(cache_key) + from app.utils.cache import invalidate_dashboard_for_user + invalidate_dashboard_for_user(current_user.id) current_app.logger.debug("Invalidated dashboard cache for user %s", current_user.id) except Exception as e: current_app.logger.warning("Failed to invalidate dashboard cache: %s", e) @@ -2372,13 +2351,8 @@ def time_entries_overview(): else: # For non-admin users, only show their projects # Get projects from user's time entries - user_project_ids = ( - db.session.query(TimeEntry.project_id) - .filter(TimeEntry.user_id == current_user.id, TimeEntry.project_id.isnot(None)) - .distinct() - .all() - ) - user_project_ids = [pid[0] for pid in user_project_ids] + time_entry_repo = TimeEntryRepository() + user_project_ids = time_entry_repo.get_distinct_project_ids_for_user(current_user.id) if user_project_ids: projects = Project.query.filter(Project.id.in_(user_project_ids), Project.status == "active").order_by(Project.name).all() # Get clients from user's projects diff --git a/app/routes/user.py b/app/routes/user.py index 05153852..1d539c3e 100644 --- a/app/routes/user.py +++ b/app/routes/user.py @@ -1,12 +1,15 @@ """User profile and settings routes""" +import hmac import re -from flask import Blueprint, render_template, request, redirect, url_for, flash, jsonify +from flask import Blueprint, render_template, request, redirect, url_for, flash, jsonify, current_app from flask_login import login_required, current_user from app import db from app.models import User, Activity, Settings from app.utils.db import safe_commit +from app.utils.donate_hide_code import compute_donate_hide_code, verify_ed25519_signature +from app.utils.license_utils import is_license_activated from flask_babel import gettext as _ from zoneinfo import ZoneInfo, ZoneInfoNotFoundError from app.utils.timezone import get_available_timezones @@ -209,13 +212,48 @@ def settings(): ) +@user_bp.route("/settings/license", methods=["GET", "POST"]) +@login_required +def license(): + """License management page: show status, enter key, validate (sets donate_ui_hidden for instance).""" + settings_obj = Settings.get_settings() + if request.method == "POST": + if is_license_activated(settings_obj): + flash(_("This instance is already licensed."), "info") + return redirect(url_for("user.license")) + code = (request.form.get("license_key") or request.form.get("code") or "").strip() + system_id = Settings.get_system_instance_id() + if not system_id: + flash(_("Invalid code."), "error") + return redirect(url_for("user.license")) + valid = False + public_key_pem = current_app.config.get("DONATE_HIDE_PUBLIC_KEY_PEM") or "" + if public_key_pem: + valid = verify_ed25519_signature(code, system_id, public_key_pem) + if not valid: + secret = current_app.config.get("DONATE_HIDE_UNLOCK_SECRET") or "" + if secret: + expected = compute_donate_hide_code(secret, system_id) + valid = bool(expected and hmac.compare_digest(code, expected)) + if not valid: + flash(_("Invalid code."), "error") + return redirect(url_for("user.license")) + settings_obj.donate_ui_hidden = True + if safe_commit(db.session): + flash(_("License activated. Thank you for supporting TimeTracker!"), "success") + else: + flash(_("Error saving settings."), "error") + return redirect(url_for("user.license")) + return render_template( + "user/license.html", + is_license_activated=is_license_activated(settings_obj), + ) + + @user_bp.route("/settings/verify-donate-hide-code", methods=["POST"]) @login_required def verify_donate_hide_code(): """Verify code (Ed25519 signature or HMAC) and set ui_show_donate=False.""" - import hmac - from flask import current_app - from app.utils.donate_hide_code import compute_donate_hide_code, verify_ed25519_signature if not getattr(current_user, "ui_show_donate", True): return jsonify({"success": True}) diff --git a/app/services/analytics_service.py b/app/services/analytics_service.py index a718893b..984ed26c 100644 --- a/app/services/analytics_service.py +++ b/app/services/analytics_service.py @@ -5,8 +5,10 @@ from typing import Dict, Any, List, Optional from datetime import datetime, timedelta from decimal import Decimal +from sqlalchemy import func, case, and_ from sqlalchemy.orm import joinedload -from app.models import TimeEntry +from app import db +from app.models import TimeEntry, Project from app.repositories import TimeEntryRepository, ProjectRepository, InvoiceRepository, ExpenseRepository @@ -72,65 +74,79 @@ def get_dashboard_top_projects( self, user_id: int, days: int = 30, limit: int = 5 ) -> List[Dict[str, Any]]: """ - Get top projects by hours for the dashboard (single query + in-memory aggregation). - - Returns: - List of dicts with keys: project, hours, billable_hours (sorted by hours desc, limited). + Get top projects by hours for the dashboard (DB GROUP BY to avoid loading all entries). + Returns list of dicts with keys: project, hours, billable_hours (sorted by hours desc, limited). """ period_start = datetime.utcnow().date() - timedelta(days=days) - entries = ( - TimeEntry.query.options(joinedload(TimeEntry.project)) + rows = ( + db.session.query( + TimeEntry.project_id, + func.sum(TimeEntry.duration_seconds).label("total_seconds"), + func.sum( + case( + (and_(TimeEntry.billable == True, Project.billable == True), TimeEntry.duration_seconds), + else_=0, + ) + ).label("billable_seconds"), + ) + .join(Project, TimeEntry.project_id == Project.id) .filter( TimeEntry.end_time.isnot(None), TimeEntry.start_time >= period_start, TimeEntry.user_id == user_id, + TimeEntry.project_id.isnot(None), ) + .group_by(TimeEntry.project_id) + .order_by(func.sum(TimeEntry.duration_seconds).desc()) + .limit(limit) .all() ) - project_hours = {} - for e in entries: - if not e.project: + project_ids = [r.project_id for r in rows] + projects_by_id = {p.id: p for p in Project.query.filter(Project.id.in_(project_ids)).all()} if project_ids else {} + result = [] + for r in rows: + project = projects_by_id.get(r.project_id) + if not project: continue - key = e.project.id - if key not in project_hours: - project_hours[key] = {"project": e.project, "hours": 0.0, "billable_hours": 0.0} - project_hours[key]["hours"] += e.duration_hours - if e.billable and e.project.billable: - project_hours[key]["billable_hours"] += e.duration_hours - return sorted(project_hours.values(), key=lambda x: x["hours"], reverse=True)[:limit] + total_seconds = int(r.total_seconds or 0) + billable_seconds = int(r.billable_seconds or 0) + result.append( + { + "project": project, + "hours": round(total_seconds / 3600, 2), + "billable_hours": round(billable_seconds / 3600, 2), + } + ) + return result[:limit] def get_time_by_project_chart( self, user_id: int, days: int = 7, limit: int = 10 ) -> Dict[str, Any]: """ - Get time-by-project series for dashboard chart (single query + aggregation). - - Returns: - dict with keys: series (list of {label, hours}), chart_labels, chart_hours. + Get time-by-project series for dashboard chart (DB GROUP BY to avoid loading all entries). + Returns dict with keys: series (list of {label, hours}), chart_labels, chart_hours. """ period_start = datetime.utcnow().date() - timedelta(days=days) - entries = ( - TimeEntry.query.options(joinedload(TimeEntry.project)) + rows = ( + db.session.query( + Project.name, + func.sum(TimeEntry.duration_seconds).label("total_seconds"), + ) + .join(TimeEntry, TimeEntry.project_id == Project.id) .filter( TimeEntry.end_time.isnot(None), TimeEntry.start_time >= period_start, TimeEntry.user_id == user_id, ) + .group_by(TimeEntry.project_id, Project.name) + .order_by(func.sum(TimeEntry.duration_seconds).desc()) + .limit(limit) .all() ) - project_hours = {} - for e in entries: - if not e.project: - continue - key = e.project.id - if key not in project_hours: - project_hours[key] = {"name": e.project.name, "hours": 0.0} - project_hours[key]["hours"] += e.duration_hours - series = sorted( - [{"label": v["name"], "hours": round(v["hours"], 2)} for v in project_hours.values()], - key=lambda x: x["hours"], - reverse=True, - )[:limit] + series = [ + {"label": r.name or "", "hours": round((r.total_seconds or 0) / 3600, 2)} + for r in rows + ] return { "series": series, "chart_labels": [x["label"] for x in series], diff --git a/app/services/gantt_service.py b/app/services/gantt_service.py new file mode 100644 index 00000000..bba0e314 --- /dev/null +++ b/app/services/gantt_service.py @@ -0,0 +1,151 @@ +""" +Service for Gantt chart data and progress calculation. +""" + +from collections import defaultdict +from datetime import datetime, timedelta +from typing import Any, Dict, List, Optional + +from app import db +from app.models import Project, Task +from app.repositories import TimeEntryRepository + + +def calculate_project_progress(project: Project, tasks: Optional[List[Task]] = None) -> int: + """Calculate project progress percentage (0-100). Pass tasks to avoid extra query.""" + if tasks is None: + tasks = Task.query.filter_by(project_id=project.id).all() + if not tasks: + return 0 + completed = sum(1 for t in tasks if t.status == "done") + return int((completed / len(tasks)) * 100) + + +def calculate_task_progress(task: Task) -> int: + """Calculate task progress percentage (0-100) from status.""" + if task.status == "done": + return 100 + if task.status == "in_progress": + return 50 + if task.status == "review": + return 75 + return 0 + + +class GanttService: + """Service for Gantt chart data.""" + + def get_gantt_data( + self, + project_id: Optional[int], + start_dt: datetime, + end_dt: datetime, + user_id: int, + has_view_all_projects: bool, + ) -> Dict[str, Any]: + """ + Build Gantt chart data (projects and tasks with dates and progress). + + Returns: + dict with "data" (list of gantt items), "start_date", "end_date" (formatted strings). + """ + from sqlalchemy import false + + query = Project.query.filter_by(status="active") + if project_id: + query = query.filter_by(id=project_id) + if not has_view_all_projects: + time_entry_repo = TimeEntryRepository() + user_project_ids = time_entry_repo.get_distinct_project_ids_for_user(user_id) + query = query.filter( + db.or_( + Project.created_by == user_id, + Project.id.in_(user_project_ids) if user_project_ids else false(), + ) + ) + projects = query.all() + project_ids = [p.id for p in projects] + + all_tasks = ( + Task.query.filter(Task.project_id.in_(project_ids)).order_by(Task.project_id, Task.id).all() + if project_ids else [] + ) + tasks_by_project = defaultdict(list) + for t in all_tasks: + tasks_by_project[t.project_id].append(t) + + gantt_data: List[Dict[str, Any]] = [] + for project in projects: + tasks = tasks_by_project.get(project.id, []) + if not tasks: + project_start = project.created_at or datetime.utcnow() + project_end = project_start + timedelta(days=30) + else: + task_dates = [] + for task in tasks: + if task.due_date: + task_dates.append(datetime.combine(task.due_date, datetime.min.time())) + if task.created_at: + task_dates.append(task.created_at) + if task_dates: + project_start = min(task_dates) + project_end = max(task_dates) + timedelta(days=7) + else: + project_start = project.created_at or datetime.utcnow() + project_end = project_start + timedelta(days=30) + if project_start < start_dt: + project_start = start_dt + if project_end > end_dt: + project_end = end_dt + + proj_color = (project.color or "#3b82f6").lstrip("#") + if len(proj_color) != 6 or not all(c in "0123456789aAbBcCdDeEfF" for c in proj_color): + proj_color = "3b82f6" + gantt_data.append({ + "id": f"project-{project.id}", + "name": project.name, + "start": project_start.strftime("%Y-%m-%d"), + "end": project_end.strftime("%Y-%m-%d"), + "progress": calculate_project_progress(project, tasks), + "type": "project", + "project_id": project.id, + "dependencies": [], + "color": proj_color, + }) + + for task in tasks: + if task.due_date: + task_end = datetime.combine(task.due_date, datetime.min.time()) + task_start = task_end - timedelta(days=7) + else: + task_start = task.created_at or project_start + task_end = task_start + timedelta(days=7) + if task_start < start_dt: + task_start = start_dt + if task_end > end_dt: + task_end = end_dt + raw = (task.color or "").strip().lstrip("#") + if raw and len(raw) == 6 and all(c in "0123456789aAbBcCdDeEfF" for c in raw): + task_color = raw.lower() + else: + task_color = proj_color + gantt_data.append({ + "id": f"task-{task.id}", + "name": task.name, + "start": task_start.strftime("%Y-%m-%d"), + "end": task_end.strftime("%Y-%m-%d"), + "progress": calculate_task_progress(task), + "type": "task", + "task_id": task.id, + "project_id": project.id, + "parent": f"project-{project.id}", + "dependencies": [], + "status": task.status, + "color": task_color, + }) + + return { + "data": gantt_data, + "start_date": start_dt.strftime("%Y-%m-%d"), + "end_date": end_dt.strftime("%Y-%m-%d"), + } diff --git a/app/services/integration_service.py b/app/services/integration_service.py index db8f0155..72a71ce4 100644 --- a/app/services/integration_service.py +++ b/app/services/integration_service.py @@ -161,9 +161,16 @@ def list_integrations(self, user_id: Optional[int] = None) -> List[Integration]: integrations = query.order_by(Integration.is_global.desc(), Integration.created_at.desc()).all() - # Sync is_active status with credentials existence + # Sync is_active status with credentials existence (batch load to avoid N+1) + integration_ids = [i.id for i in integrations] + cred_integration_ids = set() + if integration_ids: + creds = IntegrationCredential.query.filter( + IntegrationCredential.integration_id.in_(integration_ids) + ).all() + cred_integration_ids = {c.integration_id for c in creds} for integration in integrations: - has_credentials = IntegrationCredential.query.filter_by(integration_id=integration.id).first() is not None + has_credentials = integration.id in cred_integration_ids # Update is_active if it doesn't match credentials status if integration.is_active != has_credentials: diff --git a/app/services/invoice_service.py b/app/services/invoice_service.py index 9972c2e8..b8d5e135 100644 --- a/app/services/invoice_service.py +++ b/app/services/invoice_service.py @@ -477,3 +477,103 @@ def get_invoice_with_details(self, invoice_id: int) -> Optional[Invoice]: Invoice with eagerly loaded relations, or None if not found """ return self.invoice_repo.get_with_relations(invoice_id) + + def get_unbilled_data_for_invoice(self, invoice: Invoice) -> Dict[str, Any]: + """ + Get unbilled time entries, costs, expenses, and extra goods for an invoice's project, + plus grouped time entries and totals. Used by the generate-from-time view. + + Returns: + dict with time_entries, grouped_time_entries, project_costs, expenses, extra_goods, + total_available_* totals, prepaid_summary, prepaid_plan_hours, currency. + """ + from app.models import ProjectCost, Expense, ExtraGood, Settings + + time_entries = ( + TimeEntry.query.filter( + TimeEntry.project_id == invoice.project_id, + TimeEntry.end_time.isnot(None), + TimeEntry.billable == True, + ) + .order_by(TimeEntry.start_time.asc()) + .all() + ) + unbilled_entries = [] + for entry in time_entries: + already_billed = False + for other_invoice in invoice.project.invoices: + if other_invoice.id != invoice.id: + for item in other_invoice.items: + if item.time_entry_ids and str(entry.id) in item.time_entry_ids.split(","): + already_billed = True + break + if already_billed: + break + if not already_billed: + unbilled_entries.append(entry) + + unbilled_costs = ProjectCost.get_uninvoiced_costs(invoice.project_id) + unbilled_expenses = Expense.get_uninvoiced_expenses(project_id=invoice.project_id) + project_goods = ( + ExtraGood.query.filter( + ExtraGood.project_id == invoice.project_id, + ExtraGood.invoice_id.is_(None), + ExtraGood.billable == True, + ) + .order_by(ExtraGood.created_at.desc()) + .all() + ) + + grouped_time_entries = [] + current_date = None + current_bucket = None + for entry in unbilled_entries: + entry_date = entry.start_time.date() if entry.start_time else None + if entry_date != current_date: + current_date = entry_date + current_bucket = {"date": current_date, "entries": [], "total_hours": 0.0} + grouped_time_entries.append(current_bucket) + current_bucket["entries"].append(entry) + current_bucket["total_hours"] += float(entry.duration_hours or 0) + + total_available_hours = sum(entry.duration_hours for entry in unbilled_entries) + total_available_costs = sum(float(c.amount) for c in unbilled_costs) + total_available_expenses = sum(float(e.total_amount) for e in unbilled_expenses) + total_available_goods = sum(float(g.total_amount) for g in project_goods) + + prepaid_summary = [] + prepaid_plan_hours = None + if invoice.client and getattr(invoice.client, "prepaid_plan_enabled", False): + from app.utils.prepaid_hours_allocator import PrepaidHoursAllocator + + allocator = PrepaidHoursAllocator(client=invoice.client) + summaries = allocator.build_summary(unbilled_entries) + for summary in summaries: + allocation_month = summary.allocation_month + prepaid_summary.append({ + "allocation_month": allocation_month, + "allocation_month_label": allocation_month.strftime("%Y-%m-%d") if allocation_month else "", + "plan_hours": float(summary.plan_hours), + "consumed_hours": float(summary.consumed_hours), + "remaining_hours": float(summary.remaining_hours), + }) + prepaid_plan_hours = float(getattr(invoice.client, "prepaid_hours_decimal", 0) or 0) + + settings = Settings.get_settings() + currency = settings.currency if settings else "USD" + + return { + "time_entries": unbilled_entries, + "grouped_time_entries": grouped_time_entries, + "project_costs": unbilled_costs, + "expenses": unbilled_expenses, + "extra_goods": project_goods, + "total_available_hours": total_available_hours, + "total_available_costs": total_available_costs, + "total_available_expenses": total_available_expenses, + "total_available_goods": total_available_goods, + "prepaid_summary": prepaid_summary, + "prepaid_plan_hours": prepaid_plan_hours, + "currency": currency, + "prepaid_reset_day": invoice.client.prepaid_reset_day if invoice.client else None, + } diff --git a/app/services/recurring_invoice_service.py b/app/services/recurring_invoice_service.py new file mode 100644 index 00000000..8fb84557 --- /dev/null +++ b/app/services/recurring_invoice_service.py @@ -0,0 +1,146 @@ +""" +Service for recurring invoice business logic (generation from template, list, get). +""" + +from datetime import datetime, timedelta +from decimal import Decimal +from typing import Any, Dict, List, Optional + +from app import db +from app.models import Invoice, InvoiceItem, TimeEntry, Settings, RecurringInvoice +from app.repositories.recurring_invoice_repository import RecurringInvoiceRepository + + +class RecurringInvoiceService: + """Service for recurring invoice operations.""" + + def __init__(self): + self.repo = RecurringInvoiceRepository() + + def list_recurring_invoices( + self, + user_id: int, + is_admin: bool, + is_active: Optional[bool] = None, + ) -> List[RecurringInvoice]: + """List recurring invoices for the user (or all if admin).""" + return self.repo.list_for_user( + created_by=user_id, + is_admin=is_admin, + is_active=is_active, + ) + + def get_by_id(self, recurring_invoice_id: int) -> Optional[RecurringInvoice]: + """Get a recurring invoice by id.""" + return self.repo.get_by_id(recurring_invoice_id) + + def generate_invoice(self, recurring_invoice): + """ + Generate an invoice from a recurring invoice template. + + Args: + recurring_invoice: RecurringInvoice model instance. + + Returns: + Invoice instance if generated, None if should_generate_today() is False. + """ + if not recurring_invoice.should_generate_today(): + return None + + settings = Settings.get_settings() + currency_code = recurring_invoice.currency_code or (settings.currency if settings else "EUR") + + issue_date = datetime.utcnow().date() + due_date = issue_date + timedelta(days=recurring_invoice.due_date_days) + + invoice_number = Invoice.generate_invoice_number() + + invoice = Invoice( + invoice_number=invoice_number, + project_id=recurring_invoice.project_id, + client_name=recurring_invoice.client_name, + due_date=due_date, + created_by=recurring_invoice.created_by, + client_id=recurring_invoice.client_id, + client_email=recurring_invoice.client_email, + client_address=recurring_invoice.client_address, + tax_rate=recurring_invoice.tax_rate, + notes=recurring_invoice.notes, + terms=recurring_invoice.terms, + currency_code=currency_code, + template_id=recurring_invoice.template_id, + issue_date=issue_date, + ) + invoice.recurring_invoice_id = recurring_invoice.id + db.session.add(invoice) + + if recurring_invoice.auto_include_time_entries: + self._add_time_entry_items(recurring_invoice, invoice) + + invoice.calculate_totals() + + recurring_invoice.last_generated_at = datetime.utcnow() + recurring_invoice.next_run_date = recurring_invoice.calculate_next_run_date(issue_date) + + return invoice + + def _add_time_entry_items(self, recurring_invoice, invoice): + """Add invoice items from unbilled time entries for the recurring invoice's project.""" + time_entries = ( + TimeEntry.query.filter( + TimeEntry.project_id == recurring_invoice.project_id, + TimeEntry.end_time.isnot(None), + TimeEntry.billable == True, + ) + .order_by(TimeEntry.start_time.desc()) + .all() + ) + + unbilled_entries = [] + for entry in time_entries: + already_billed = False + for other_invoice in recurring_invoice.project.invoices: + if other_invoice.id != invoice.id: + for item in other_invoice.items: + if item.time_entry_ids and str(entry.id) in item.time_entry_ids.split(","): + already_billed = True + break + if already_billed: + break + if not already_billed: + unbilled_entries.append(entry) + + if not unbilled_entries: + return + + from app.models.rate_override import RateOverride + + grouped_entries = {} + for entry in unbilled_entries: + if entry.task_id: + key = f"task_{entry.task_id}" + description = f"Task: {entry.task.name if entry.task else 'Unknown Task'}" + else: + key = f"project_{entry.project_id}" + description = f"Project: {entry.project.name}" + + if key not in grouped_entries: + grouped_entries[key] = { + "description": description, + "entries": [], + "total_hours": Decimal("0"), + } + grouped_entries[key]["entries"].append(entry) + grouped_entries[key]["total_hours"] += entry.duration_hours + + hourly_rate = RateOverride.resolve_rate(recurring_invoice.project) + for group in grouped_entries.values(): + if group["total_hours"] > 0: + item = InvoiceItem( + invoice_id=invoice.id, + description=group["description"], + quantity=group["total_hours"], + unit_price=hourly_rate, + time_entry_ids=",".join(str(e.id) for e in group["entries"]), + ) + db.session.add(item) diff --git a/app/services/reporting_service.py b/app/services/reporting_service.py index 5a9b697f..de804032 100644 --- a/app/services/reporting_service.py +++ b/app/services/reporting_service.py @@ -21,7 +21,8 @@ from decimal import Decimal from app import db from app.repositories import TimeEntryRepository, ProjectRepository, InvoiceRepository, ExpenseRepository -from app.models import TimeEntry, Project, Invoice, Expense, Payment, User +from app.models import TimeEntry, Project, Invoice, Expense, Payment, User, ProjectCost, InvoiceItem +from sqlalchemy.orm import joinedload from sqlalchemy import func @@ -76,16 +77,16 @@ def get_time_summary( ) billable_hours = billable_seconds / 3600 - # Get entries - entries = self.time_entry_repo.get_by_date_range( - start_date=start_date, end_date=end_date, user_id=user_id, project_id=project_id, include_relations=False + # Count entries without loading all rows + total_entries = self.time_entry_repo.count_for_date_range( + start_date=start_date, end_date=end_date, user_id=user_id, project_id=project_id ) return { "total_hours": round(total_hours, 2), "billable_hours": round(billable_hours, 2), "non_billable_hours": round(total_hours - billable_hours, 2), - "total_entries": len(entries), + "total_entries": total_entries, "start_date": start_date.isoformat(), "end_date": end_date.isoformat(), } @@ -349,3 +350,277 @@ def get_week_in_review( "week_start": week_start.isoformat(), "week_end": week_end.isoformat(), } + + def get_comparison_data( + self, period: str = "month", user_id: Optional[int] = None, can_view_all: bool = False + ) -> Dict[str, Any]: + """ + Get period-over-period comparison (current vs previous period hours). + + Args: + period: "month" or "year" + user_id: Current user ID (used when can_view_all is False) + can_view_all: If True, include all users' time + + Returns: + dict with current hours, previous hours, and change percent + """ + now = datetime.utcnow() + if period == "month": + this_period_start = datetime(now.year, now.month, 1) + last_period_start = (this_period_start - timedelta(days=1)).replace(day=1) + last_period_end = this_period_start - timedelta(seconds=1) + else: + this_period_start = datetime(now.year, 1, 1) + last_period_start = datetime(now.year - 1, 1, 1) + last_period_end = datetime(now.year, 1, 1) - timedelta(seconds=1) + + current_query = db.session.query(func.sum(TimeEntry.duration_seconds)).filter( + TimeEntry.end_time.isnot(None), + TimeEntry.start_time >= this_period_start, + TimeEntry.start_time <= now, + ) + if not can_view_all and user_id is not None: + current_query = current_query.filter(TimeEntry.user_id == user_id) + current_seconds = current_query.scalar() or 0 + + previous_query = db.session.query(func.sum(TimeEntry.duration_seconds)).filter( + TimeEntry.end_time.isnot(None), + TimeEntry.start_time >= last_period_start, + TimeEntry.start_time <= last_period_end, + ) + if not can_view_all and user_id is not None: + previous_query = previous_query.filter(TimeEntry.user_id == user_id) + previous_seconds = previous_query.scalar() or 0 + + current_hours = round(current_seconds / 3600, 2) + previous_hours = round(previous_seconds / 3600, 2) + change = ((current_hours - previous_hours) / previous_hours * 100) if previous_hours > 0 else 0 + + return { + "current": {"hours": current_hours}, + "previous": {"hours": previous_hours}, + "change": round(change, 1), + } + + def get_project_report_data( + self, + start_dt: datetime, + end_dt: datetime, + project_id: Optional[int] = None, + user_id_filter: Optional[int] = None, + current_user_id: int = None, + can_view_all: bool = False, + ) -> Dict[str, Any]: + """ + Get aggregated project report data (entries, projects_data, summary). + + Caller must enforce permission: if not can_view_all and user_id_filter != current_user_id, do not call. + """ + query = TimeEntry.query.filter( + TimeEntry.end_time.isnot(None), + TimeEntry.start_time >= start_dt, + TimeEntry.start_time <= end_dt, + ) + if not can_view_all and current_user_id is not None: + query = query.filter(TimeEntry.user_id == current_user_id) + if project_id: + query = query.filter(TimeEntry.project_id == project_id) + if user_id_filter is not None: + query = query.filter(TimeEntry.user_id == user_id_filter) + + entries = ( + query.options( + joinedload(TimeEntry.project).joinedload(Project.client_obj), + joinedload(TimeEntry.user), + ) + .order_by(TimeEntry.start_time.desc()) + .all() + ) + + projects_map = {} + for entry in entries: + project = entry.project + if not project: + continue + if project.id not in projects_map: + projects_map[project.id] = { + "id": project.id, + "name": project.name, + "client": project.client, + "description": project.description, + "billable": project.billable, + "hourly_rate": float(project.hourly_rate) if project.hourly_rate else None, + "total_hours": 0.0, + "billable_hours": 0.0, + "billable_amount": 0.0, + "total_costs": 0.0, + "billable_costs": 0.0, + "total_value": 0.0, + "user_totals": {}, + } + agg = projects_map[project.id] + hours = entry.duration_hours + agg["total_hours"] += hours + if entry.billable and project.billable: + agg["billable_hours"] += hours + if project.hourly_rate: + agg["billable_amount"] += hours * float(project.hourly_rate) + username = entry.user.display_name if entry.user else "Unknown" + agg["user_totals"][username] = agg["user_totals"].get(username, 0.0) + hours + + for pid, agg in projects_map.items(): + costs_query = ProjectCost.query.filter( + ProjectCost.project_id == pid, + ProjectCost.cost_date >= start_dt.date(), + ProjectCost.cost_date <= end_dt.date(), + ) + if user_id_filter is not None: + costs_query = costs_query.filter(ProjectCost.user_id == user_id_filter) + for cost in costs_query.all(): + agg["total_costs"] += float(cost.amount) + if cost.billable: + agg["billable_costs"] += float(cost.amount) + agg["total_value"] = agg["billable_amount"] + agg["billable_costs"] + + projects_data = [] + total_hours = 0.0 + billable_hours = 0.0 + total_billable_amount = 0.0 + total_costs = 0.0 + total_billable_costs = 0.0 + total_project_value = 0.0 + for agg in projects_map.values(): + total_hours += agg["total_hours"] + billable_hours += agg["billable_hours"] + total_billable_amount += agg["billable_amount"] + total_costs += agg["total_costs"] + total_billable_costs += agg["billable_costs"] + total_project_value += agg["total_value"] + agg["total_hours"] = round(agg["total_hours"], 1) + agg["billable_hours"] = round(agg["billable_hours"], 1) + agg["billable_amount"] = round(agg["billable_amount"], 2) + agg["total_costs"] = round(agg["total_costs"], 2) + agg["billable_costs"] = round(agg["billable_costs"], 2) + agg["total_value"] = round(agg["total_value"], 2) + agg["user_totals"] = [ + {"username": u, "hours": round(h, 1)} for u, h in agg["user_totals"].items() + ] + projects_data.append(agg) + + summary = { + "total_hours": round(total_hours, 1), + "billable_hours": round(billable_hours, 1), + "total_billable_amount": round(total_billable_amount, 2), + "total_costs": round(total_costs, 2), + "total_billable_costs": round(total_billable_costs, 2), + "total_project_value": round(total_project_value, 2), + "projects_count": len(projects_data), + } + return {"entries": entries, "projects_data": projects_data, "summary": summary} + + def get_unpaid_hours_report_data( + self, + start_dt: datetime, + end_dt: datetime, + client_id: Optional[int] = None, + current_user_id: Optional[int] = None, + can_view_all: bool = False, + ) -> Dict[str, Any]: + """ + Get unpaid hours report data: billable entries not in fully-paid invoices, grouped by client. + + Returns: + dict with client_data (list of client aggregates) and summary. + """ + from sqlalchemy.orm import joinedload + + query = TimeEntry.query.options( + joinedload(TimeEntry.user), + joinedload(TimeEntry.project), + joinedload(TimeEntry.task), + joinedload(TimeEntry.client), + ).filter( + TimeEntry.end_time.isnot(None), + TimeEntry.billable == True, + TimeEntry.start_time >= start_dt, + TimeEntry.start_time <= end_dt, + ) + if not can_view_all and current_user_id is not None: + query = query.filter(TimeEntry.user_id == current_user_id) + if client_id: + query = query.filter(TimeEntry.client_id == client_id) + all_entries = query.all() + + all_invoice_items = InvoiceItem.query.join(Invoice).filter( + InvoiceItem.time_entry_ids.isnot(None), InvoiceItem.time_entry_ids != "" + ).all() + billed_entry_ids = set() + for item in all_invoice_items: + if not item.time_entry_ids: + continue + entry_ids = [int(eid.strip()) for eid in item.time_entry_ids.split(",") if eid.strip().isdigit()] + inv = item.invoice + if inv and getattr(inv, "payment_status", None) == "fully_paid": + billed_entry_ids.update(entry_ids) + unpaid_entries = [e for e in all_entries if e.id not in billed_entry_ids] + + client_totals = {} + for entry in unpaid_entries: + client = None + if entry.client_id: + client = getattr(entry, "client", None) + elif entry.project and getattr(entry.project, "client_id", None): + client = getattr(entry.project, "client_obj", None) + if not client: + continue + cid = client.id + if cid not in client_totals: + client_totals[cid] = { + "client": client, + "total_hours": 0.0, + "billable_hours": 0.0, + "estimated_amount": 0.0, + "entries": [], + "projects": {}, + } + hours = entry.duration_hours + client_totals[cid]["total_hours"] += hours + client_totals[cid]["billable_hours"] += hours + client_totals[cid]["entries"].append(entry) + if entry.project: + pid = entry.project.id + if pid not in client_totals[cid]["projects"]: + client_totals[cid]["projects"][pid] = { + "project": entry.project, + "hours": 0.0, + "rate": float(entry.project.hourly_rate) if entry.project.hourly_rate else 0.0, + } + client_totals[cid]["projects"][pid]["hours"] += hours + rate = 0.0 + if entry.project and entry.project.hourly_rate: + rate = float(entry.project.hourly_rate) + elif client and getattr(client, "default_hourly_rate", None): + rate = float(client.default_hourly_rate) + client_totals[cid]["estimated_amount"] += hours * rate + + client_data = [] + total_unpaid_hours = 0.0 + total_estimated_amount = 0.0 + for cid, data in client_totals.items(): + data["total_hours"] = round(data["total_hours"], 2) + data["billable_hours"] = round(data["billable_hours"], 2) + data["estimated_amount"] = round(data["estimated_amount"], 2) + data["projects"] = list(data["projects"].values()) + for proj in data["projects"]: + proj["hours"] = round(proj["hours"], 2) + client_data.append(data) + total_unpaid_hours += data["total_hours"] + total_estimated_amount += data["estimated_amount"] + client_data.sort(key=lambda x: x["total_hours"], reverse=True) + summary = { + "total_unpaid_hours": round(total_unpaid_hours, 2), + "total_estimated_amount": round(total_estimated_amount, 2), + "clients_count": len(client_data), + } + return {"client_data": client_data, "summary": summary} diff --git a/app/templates/_components.html b/app/templates/_components.html index f59c3bb1..1a9530d2 100644 --- a/app/templates/_components.html +++ b/app/templates/_components.html @@ -1,3 +1,4 @@ +{# DEPRECATED: Prefer components/ui.html for page_header, empty_state, and other UI macros. #} {% macro page_header(icon_class, title_text, subtitle_text=None, actions_html=None) %}
diff --git a/app/templates/analytics/mobile_dashboard.html b/app/templates/analytics/mobile_dashboard.html index 0119a84c..ff2c9d9b 100644 --- a/app/templates/analytics/mobile_dashboard.html +++ b/app/templates/analytics/mobile_dashboard.html @@ -4,7 +4,7 @@ {% block content %}
- {% from "_components.html" import page_header %} + {% from "components/ui.html" import page_header %}
{% set actions %} diff --git a/app/templates/base.html b/app/templates/base.html index b519a560..19ed0b9d 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -333,7 +333,7 @@ {{ _('Skip to content') }}
-
+ {% if not is_license_activated and current_user.is_authenticated %} +

+ {{ _('Enjoying TimeTracker?') }} {{ _('You can support development by purchasing a license key.') }} + {{ _('Support the project') }} +

+ {% endif %}
{% endblock %} diff --git a/app/templates/projects/view.html b/app/templates/projects/view.html index 61691067..e74c0606 100644 --- a/app/templates/projects/view.html +++ b/app/templates/projects/view.html @@ -274,22 +274,24 @@

{{ _('Status') }}
- {% if percentage >= 100 %} + {% if budget_status is defined and budget_status == 'over' %} {{ _('Over') }} - {% elif percentage >= threshold %} + {% elif budget_status is defined and budget_status == 'critical' %} {{ _('Critical') }} - {% elif percentage >= (threshold * 0.8) %} + {% elif budget_status is defined and budget_status == 'warning' %} {{ _('Warning') }} - {% else %} + {% elif budget_status is defined and budget_status == 'healthy' %} {{ _('Healthy') }} + {% else %} + {% endif %}
diff --git a/app/templates/reports/index.html b/app/templates/reports/index.html index 16314fe5..28dc1a18 100644 --- a/app/templates/reports/index.html +++ b/app/templates/reports/index.html @@ -15,6 +15,13 @@ actions_html=None ) }} +{% if not is_license_activated and current_user.is_authenticated %} +

+ {{ _('Enjoying TimeTracker?') }} {{ _('You can support development by purchasing a license key.') }} + {{ _('Support the project') }} +

+{% endif %} +
{{ info_card(_("Total Hours"), "%.2f"|format(summary.total_hours), _("All time")) }} {{ info_card(_("Billable Hours"), "%.2f"|format(summary.billable_hours), _("All time")) }} diff --git a/app/templates/reports/time_entries_report.html b/app/templates/reports/time_entries_report.html index 6c87dfa8..1bc6b3ac 100644 --- a/app/templates/reports/time_entries_report.html +++ b/app/templates/reports/time_entries_report.html @@ -138,5 +138,20 @@

{{ _('Time Entries Report') }}

{% endfor %} + {% if pagination and pagination.pages > 1 %} + + {% endif %}
{% endblock %} diff --git a/app/templates/tasks/my_tasks.html b/app/templates/tasks/my_tasks.html index e59ad1cb..052912ec 100644 --- a/app/templates/tasks/my_tasks.html +++ b/app/templates/tasks/my_tasks.html @@ -11,7 +11,7 @@ {% block content %}
- {% from "_components.html" import page_header %} + {% from "components/ui.html" import page_header %}
@@ -38,7 +38,7 @@
{{ _('To Do') }}
-
{{ tasks|selectattr('status', 'equalto', 'todo')|list|length }}
+
{{ task_counts.get('todo', 0) if task_counts is defined and task_counts else (tasks|selectattr('status', 'equalto', 'todo')|list|length) }}
@@ -55,7 +55,7 @@
{{ _('In Progress') }}
-
{{ tasks|selectattr('status', 'equalto', 'in_progress')|list|length }}
+
{{ task_counts.get('in_progress', 0) if task_counts is defined and task_counts else (tasks|selectattr('status', 'equalto', 'in_progress')|list|length) }}

@@ -72,7 +72,7 @@
{{ _('Review') }}
-
{{ tasks|selectattr('status', 'equalto', 'review')|list|length }}
+
{{ task_counts.get('review', 0) if task_counts is defined and task_counts else (tasks|selectattr('status', 'equalto', 'review')|list|length) }}
@@ -89,7 +89,7 @@
{{ _('Completed') }}
-
{{ tasks|selectattr('status', 'equalto', 'done')|list|length }}
+
{{ task_counts.get('done', 0) if task_counts is defined and task_counts else (tasks|selectattr('status', 'equalto', 'done')|list|length) }}
diff --git a/app/templates/timer/_time_entries_list.html b/app/templates/timer/_time_entries_list.html index e496d756..9d4557b2 100644 --- a/app/templates/timer/_time_entries_list.html +++ b/app/templates/timer/_time_entries_list.html @@ -1,3 +1,4 @@ +{% from "components/ui.html" import empty_state_compact %}
@@ -171,20 +172,23 @@ {% endfor %} {% else %} - - -
-
- -
-

{{ _('No time entries found') }}

-

{{ _('Try adjusting your filters or log a new time entry.') }}

- - {{ _('Log Time') }} - -
- - + {% set has_filters = (filters.start_date or filters.end_date or filters.project_id or filters.client_id or filters.paid or filters.billable or (filters.search and filters.search|trim) or (filters.user_id if can_view_all else none) or (filters.client_custom_field and filters.client_custom_field|length > 0)) %} + {% if has_filters %} + {% set clear_filters_url = url_for('timer.time_entries_overview') %} + {% set empty_actions = '' ~ _('Clear filters') ~ '' ~ _('Log Time') ~ '' %} + + + {{ empty_state_compact('fas fa-search', _('No time entries match your filters'), _('Try adjusting your filters or clear them to see all entries. You can also log a new time entry.'), empty_actions, type='no-results') }} + + + {% else %} + {% set log_time_actions = '' ~ _('Log Time') ~ '' %} + + + {{ empty_state_compact('fas fa-clock', _('No time entries yet'), _('Log time to see your entries here. Use the timer on the dashboard or log time manually.'), log_time_actions, type='no-data') }} + + + {% endif %} {% endif %} @@ -192,7 +196,7 @@ {% if pagination.pages > 1 %} -
+
-
+ {% endif %}
diff --git a/app/templates/timer/bulk_entry.html b/app/templates/timer/bulk_entry.html index e0975097..8138a3b8 100644 --- a/app/templates/timer/bulk_entry.html +++ b/app/templates/timer/bulk_entry.html @@ -63,7 +63,7 @@ {% endblock %}
- {% from "_components.html" import page_header %} + {% from "components/ui.html" import page_header %}
{% set actions %} diff --git a/app/templates/timer/time_entries_overview.html b/app/templates/timer/time_entries_overview.html index cd96f93f..5d71c35a 100644 --- a/app/templates/timer/time_entries_overview.html +++ b/app/templates/timer/time_entries_overview.html @@ -30,31 +30,33 @@

{{ _('Filters') }}

- {{ _('Exports use current filters') }} - - - {{ _('Export CSV') }} + {{ _('Export CSV') }} - {{ _('Export PDF') }} + {{ _('Export PDF') }} -
@@ -128,8 +130,8 @@

{{ _('Filters') }}

{% endfor %} {% endif %}
-
@@ -240,9 +242,11 @@

{{ _('Mark Selected Entries as Paid/Unpai function toggleFilterVisibility() { const filterBody = document.getElementById('filterBody'); const icon = document.getElementById('filterToggleIcon'); + const toggleBtn = document.getElementById('toggleFilters'); filterBody.classList.toggle('hidden'); icon.classList.toggle('fa-chevron-up'); icon.classList.toggle('fa-chevron-down'); + if (toggleBtn) toggleBtn.setAttribute('aria-expanded', filterBody.classList.contains('hidden') ? 'false' : 'true'); } function clearAllFilters() { @@ -668,6 +672,8 @@

{{ _('Mark Selected Entries as Paid/Unpai if (filterBody && filterBody.classList.contains('hidden')) { filterBody.classList.remove('hidden'); if (icon) { icon.classList.remove('fa-chevron-down'); icon.classList.add('fa-chevron-up'); } + var t = document.getElementById('toggleFilters'); + if (t) t.setAttribute('aria-expanded', 'true'); } if (filterTimeout) clearTimeout(filterTimeout); if (searchTimeout) clearTimeout(searchTimeout); diff --git a/app/templates/user/license.html b/app/templates/user/license.html new file mode 100644 index 00000000..adadb75f --- /dev/null +++ b/app/templates/user/license.html @@ -0,0 +1,60 @@ +{% extends "base.html" %} +{% block title %}{{ _('License') }} - {{ _('Settings') }}{% endblock %} + +{% block content %} +
+
+

{{ _('License') }}

+

{{ _('View activation status and enter a license key') }}

+
+ +
+

{{ _('License status') }}

+ {% if is_license_activated %} +

+ + {{ _('Active license') }} +

+

{{ _('Thank you for supporting TimeTracker.') }}

+ {% else %} +

+ + {{ _('Not activated') }} +

+

+ {{ _('Want to support development? You can purchase a license key for €25.') }} +

+ + {{ _('Buy license (€25)') }} + + {% endif %} +
+ + {% if not is_license_activated %} +
+

{{ _('Enter license key') }}

+

{{ _('Paste the license key you received by email after purchase.') }}

+
+ +
+ + +
+ +
+

+ {{ _('Need a key?') }} + {{ _('Purchase a license key') }} +

+
+ {% endif %} + +

+ {{ _('Back to Settings') }} +

+
+{% endblock %} diff --git a/app/templates/user/settings.html b/app/templates/user/settings.html index 95d1ed80..c86c5e4e 100644 --- a/app/templates/user/settings.html +++ b/app/templates/user/settings.html @@ -8,6 +8,21 @@

{{ _('Settings') }}

{{ _('Manage your account settings and preferences') }}

+ {% if not is_license_activated %} +
+

{{ _('TimeTracker is free and open-source. If you enjoy using it, you can support development by purchasing a license key.') }}

+ + {{ _('Buy license (€25)') }} + +
+ {% endif %} + + +
diff --git a/app/utils/api_auth.py b/app/utils/api_auth.py index 01154490..170525ce 100644 --- a/app/utils/api_auth.py +++ b/app/utils/api_auth.py @@ -133,6 +133,7 @@ def decorated_function(*args, **kwargs): { "error": "Authentication required", "message": "API token must be provided in Authorization header or X-API-Key header", + "error_code": "unauthorized", } ), 401, @@ -144,7 +145,11 @@ def decorated_function(*args, **kwargs): if not user or not api_token: message = error_msg or "The provided API token is invalid or expired" return ( - jsonify({"error": "Invalid token", "message": message}), + jsonify({ + "error": "Invalid token", + "message": message, + "error_code": "unauthorized", + }), 401, ) @@ -155,6 +160,7 @@ def decorated_function(*args, **kwargs): { "error": "Insufficient permissions", "message": f'This endpoint requires the "{required_scope}" scope', + "error_code": "forbidden", "required_scope": required_scope, "available_scopes": api_token.scopes.split(",") if api_token.scopes else [], } diff --git a/app/utils/api_responses.py b/app/utils/api_responses.py index 855f3e0d..80aea133 100644 --- a/app/utils/api_responses.py +++ b/app/utils/api_responses.py @@ -59,7 +59,13 @@ def error_response( Returns: Flask JSON response """ - response = {"success": False, "error": error_code or "error", "message": message} + # error = user-facing message (backward compat); error_code = machine-readable + response = { + "success": False, + "error": message, + "message": message, + "error_code": error_code or "error", + } if errors: response["errors"] = errors @@ -191,7 +197,7 @@ def created_response(data: Any, message: Optional[str] = None, location: Optiona Returns: Flask JSON response """ - response_data = {"data": data} + response_data = {"success": True, "data": data} if message: response_data["message"] = message diff --git a/app/utils/cache.py b/app/utils/cache.py index 7416aeaf..4d806b8b 100644 --- a/app/utils/cache.py +++ b/app/utils/cache.py @@ -252,6 +252,16 @@ def invalidate_cache(pattern: str) -> None: cache.clear() # For now, just clear all (can be improved) +def invalidate_dashboard_for_user(user_id: int) -> None: + """Invalidate all dashboard-related cache keys for a user (stats, chart, legacy).""" + cache = get_cache() + for key in (f"dashboard:{user_id}", f"dashboard:stats:{user_id}", f"dashboard:chart:{user_id}"): + try: + cache.delete(key) + except Exception: + pass + + def invalidate_pattern(pattern: str) -> None: """ Invalidate cache entries matching a pattern. diff --git a/app/utils/context_processors.py b/app/utils/context_processors.py index 8aa66383..f36b9cc4 100644 --- a/app/utils/context_processors.py +++ b/app/utils/context_processors.py @@ -2,6 +2,7 @@ from flask_babel import get_locale from flask_login import current_user from app.models import Settings +from app.utils.license_utils import is_license_activated from app.utils.timezone import ( get_timezone_offset_for_timezone, get_resolved_date_format_key, @@ -37,6 +38,7 @@ def inject_settings(): "resolved_date_format_key": resolved_date, "resolved_time_format_key": resolved_time, "resolved_week_start_day": resolved_week_start, + "is_license_activated": is_license_activated(settings), } except Exception as e: # Log the error but continue with defaults @@ -66,6 +68,7 @@ def inject_settings(): "resolved_date_format_key": resolved_date, "resolved_time_format_key": resolved_time, "resolved_week_start_day": resolved_week_start, + "is_license_activated": False, } @app.context_processor diff --git a/app/utils/data_export.py b/app/utils/data_export.py index d8291519..914c59e6 100644 --- a/app/utils/data_export.py +++ b/app/utils/data_export.py @@ -10,6 +10,7 @@ from zipfile import ZipFile from flask import current_app from app import db +from app.repositories import TimeEntryRepository from app.models import ( User, Project, @@ -293,9 +294,8 @@ def _export_time_entries(user): def _export_user_projects(user): """Export projects user has worked on""" - # Get unique projects from time entries - project_ids = db.session.query(TimeEntry.project_id).filter_by(user_id=user.id).distinct().all() - project_ids = [pid[0] for pid in project_ids] + time_entry_repo = TimeEntryRepository() + project_ids = time_entry_repo.get_distinct_project_ids_for_user(user.id) projects = Project.query.filter(Project.id.in_(project_ids)).all() return [_project_to_dict(p) for p in projects] diff --git a/app/utils/license_utils.py b/app/utils/license_utils.py new file mode 100644 index 00000000..b35d2ada --- /dev/null +++ b/app/utils/license_utils.py @@ -0,0 +1,12 @@ +""" +Optional support/license visibility helpers. + +Instance-level "license activated" state is represented by Settings.donate_ui_hidden +(set when a user verifies the donate-hide / license key). This is non-blocking +monetization awareness only—no paywall or feature gating. +""" + + +def is_license_activated(settings) -> bool: + """Return True if this instance has an active license (donate/support UI hidden).""" + return bool(getattr(settings, "donate_ui_hidden", False)) diff --git a/app/utils/performance.py b/app/utils/performance.py index 9aaafe35..833bd907 100644 --- a/app/utils/performance.py +++ b/app/utils/performance.py @@ -1,92 +1,60 @@ """ -Performance monitoring utilities. -""" - -from typing import Callable, Any -from functools import wraps -import time -from flask import current_app, g - +Optional performance instrumentation: slow-request logging and query-count profiling. -def measure_time(func: Callable) -> Callable: - """ - Decorator to measure function execution time. - - Usage: - @measure_time - def slow_function(): - # Code - """ +Enable via config: +- PERF_LOG_SLOW_REQUESTS_MS: log when request duration exceeds this many ms (0 = disabled) +- PERF_QUERY_PROFILE: when true, track DB query count per request and include in slow-request logs +""" - @wraps(func) - def wrapper(*args, **kwargs): - start_time = time.time() - try: - result = func(*args, **kwargs) - return result - finally: - elapsed = time.time() - start_time - current_app.logger.debug(f"{func.__name__} took {elapsed:.4f} seconds") - # Store in request context if available - if hasattr(g, "performance_metrics"): - g.performance_metrics[func.__name__] = elapsed - else: - g.performance_metrics = {func.__name__: elapsed} +import logging +from flask import g, request +from sqlalchemy import event +from sqlalchemy.engine import Engine - return wrapper +logger = logging.getLogger("timetracker.perf") -def log_slow_queries(threshold: float = 1.0): +def init_performance_logging(app): """ - Decorator to log slow database queries. - - Args: - threshold: Time threshold in seconds + Register slow-request logging and optional query-count profiling. + No overhead when PERF_LOG_SLOW_REQUESTS_MS is 0 and PERF_QUERY_PROFILE is False. """ + slow_ms = app.config.get("PERF_LOG_SLOW_REQUESTS_MS", 0) or 0 + query_profile = app.config.get("PERF_QUERY_PROFILE", False) - def decorator(func: Callable) -> Callable: - @wraps(func) - def wrapper(*args, **kwargs): - start_time = time.time() - try: - result = func(*args, **kwargs) - return result - finally: - elapsed = time.time() - start_time - if elapsed > threshold: - current_app.logger.warning( - f"Slow query in {func.__name__}: {elapsed:.4f} seconds " f"(threshold: {threshold}s)" - ) - - return wrapper + if query_profile: - return decorator + @app.before_request + def _perf_set_query_count(): + g._perf_query_count = 0 + @event.listens_for(Engine, "before_cursor_execute") + def _perf_count_query(conn, cursor, statement, parameters, context, executemany): + if hasattr(g, "_perf_query_count"): + g._perf_query_count += 1 -class PerformanceMonitor: - """Context manager for performance monitoring""" - - def __init__(self, operation_name: str): - self.operation_name = operation_name - self.start_time = None - - def __enter__(self): - self.start_time = time.time() - return self - - def __exit__(self, exc_type, exc_val, exc_tb): - elapsed = time.time() - self.start_time - current_app.logger.info(f"Performance: {self.operation_name} took {elapsed:.4f} seconds") - return False - - -def get_performance_metrics() -> dict: - """ - Get performance metrics from request context. - - Returns: - dict with performance metrics - """ - if hasattr(g, "performance_metrics"): - return g.performance_metrics - return {} + @app.after_request + def _perf_log_slow_requests(response): + if slow_ms <= 0: + return response + try: + start = getattr(g, "_start_time", None) + if start is None: + return response + duration_ms = (__import__("time").time() - start) * 1000 + if duration_ms < slow_ms: + return response + query_count = getattr(g, "_perf_query_count", getattr(g, "query_count", None)) + if query_count is not None: + logger.warning( + "slow_request path=%s duration_ms=%.0f status=%s query_count=%s", + request.path, duration_ms, response.status_code, query_count, + ) + else: + logger.warning( + "slow_request path=%s duration_ms=%.0f status=%s", + request.path, duration_ms, response.status_code, + ) + except Exception: + pass + return response diff --git a/app/utils/scope_filter.py b/app/utils/scope_filter.py index 4fbee477..56162784 100644 --- a/app/utils/scope_filter.py +++ b/app/utils/scope_filter.py @@ -1,5 +1,7 @@ """Scope filtering for subcontractor role: restrict data to assigned clients/projects.""" +from typing import Tuple, Set + from flask_login import current_user @@ -71,3 +73,35 @@ def user_can_access_project(user, project_id): return True allowed = user.get_allowed_project_ids() return allowed is not None and project_id in allowed + + +def get_accessible_project_and_client_ids_for_user(user_id: int) -> Tuple[Set[int], Set[int]]: + """ + Return (accessible_project_ids, accessible_client_ids) for issue-style access: + projects the user has time entries for or is assigned to tasks on, and clients of those projects. + Used to filter issues for non-admin users without view_all_issues permission. + """ + from app.repositories import TimeEntryRepository + from app.models import Task, Project + + time_entry_repo = TimeEntryRepository() + user_project_ids = set(time_entry_repo.get_distinct_project_ids_for_user(user_id)) + task_project_rows = ( + Task.query.with_entities(Task.project_id) + .filter_by(assigned_to=user_id) + .filter(Task.project_id.isnot(None)) + .distinct() + .all() + ) + task_project_ids = {r[0] for r in task_project_rows} + all_accessible_project_ids = user_project_ids | task_project_ids + if not all_accessible_project_ids: + return set(), set() + client_rows = ( + Project.query.with_entities(Project.client_id) + .filter(Project.id.in_(all_accessible_project_ids), Project.client_id.isnot(None)) + .distinct() + .all() + ) + accessible_client_ids = {r[0] for r in client_rows} + return all_accessible_project_ids, accessible_client_ids diff --git a/docs/ARCHITECTURE_AUDIT.md b/docs/ARCHITECTURE_AUDIT.md new file mode 100644 index 00000000..cccfbd9e --- /dev/null +++ b/docs/ARCHITECTURE_AUDIT.md @@ -0,0 +1,64 @@ +# Architecture Audit + +This document captures a concise architecture audit of the TimeTracker repository (Flask app with server-rendered templates, REST API, and desktop/mobile clients). It is used to drive incremental refactors toward thin routes, reusable services, and a predictable repository layer. + +--- + +## Current strengths + +- **Clear intended layering**: Routes → services → repositories/models is documented in [ARCHITECTURE.md](../ARCHITECTURE.md) and [Architecture Migration Guide](implementation-notes/ARCHITECTURE_MIGRATION_GUIDE.md). +- **Existing repository layer**: `app/repositories/` provides `BaseRepository` and dedicated repos for TimeEntry, Project, Task, Client, Invoice, Expense, Payment, User, Comment with sensible methods (e.g. `TimeEntryRepository.get_active_timer`, `get_by_date_range`, `get_total_duration`). +- **Central API response helpers**: `app/utils/api_responses.py` defines `success_response`, `error_response`, `validation_error_response`, `paginated_response`, etc.; error handlers in `app/utils/error_handlers.py` use them for JSON/API. +- **Blueprint registry**: Single registration point in `app/blueprint_registry.py` keeps app init clean. +- **Refactor examples**: The migration guide gives a clear "after" pattern. (Historical note: previously unregistered modules `timer_refactored.py`, `projects_refactored_example.py`, `invoices_refactored.py` have been merged or removed.) +- **Validation and schemas**: Marshmallow used for time-entry API v1; `app/utils/validation.py` and `app/utils/time_entry_validation.py` exist; `app/schemas/` has schemas for several resources (underused in routes). + +--- + +## Main architectural risks + +1. **Business logic in routes**: Heavy logic in `app/routes/reports.py` (comparison, project_report, unpaid_hours), `app/routes/invoices.py` (many direct queries), `app/routes/recurring_invoices.py`, `app/routes/expenses.py`, `app/routes/deals.py`, `app/routes/gantt.py`, `app/routes/comments.py`, `app/routes/client_notes.py`, `app/routes/audit_logs.py`, and others. +2. **Duplicated query patterns**: "User's distinct project IDs" from TimeEntry appears in budget_alerts, reports, data_export, gantt, timer, api with no shared repo method. Same for "user project IDs + accessible client IDs" in issues.py (4 blocks). Sum(TimeEntry.duration_seconds) repeated in analytics, reports, reporting_service, models, and utils despite `TimeEntryRepository.get_total_duration`. +3. **Inconsistent API contract**: Most api_v1 resource routes return resource-keyed payloads (`{"invoices": [...]}`, `{"invoice": {...}}`) and ad-hoc errors instead of the standard envelope (`success`, `data`, `error`, `error_code`) from `api_responses`. +4. **Validation inconsistency**: Only time-entry API v1 uses Marshmallow; other api_v1_* and all web forms use manual checks. Schemas exist for Invoice, Project, Client, Expense, etc. but are not used in corresponding routes. +5. **God files and tight coupling**: `app/routes/api_v1.py`, `app/routes/timer.py`, `app/routes/api.py`, `app/routes/reports.py`, `app/routes/invoices.py` are very large. Large route files mix many endpoints and inline queries. +6. **Logic in models**: `app/models/recurring_invoice.py` `generate_invoice()` does full workflow. `app/models/expense.py`, `app/models/lead.py`, `app/models/issue.py`, `app/models/project.py` contain state transitions and query/aggregation methods that belong in services/repositories. +7. **Template logic**: Budget and status rules in project view/list templates; task counts via `selectattr` in tasks list/my_tasks/kanban; totals and filters in inventory, client portal, expense_categories. Better to precompute in views. +8. **Missing repositories**: No repository for FocusSession, Activity, AuditLog, StockItem/inventory, RecurringInvoice, and others; services and routes use `Model.query` / `db.session` directly. +9. **Unused/refactor-only code**: (Historical: `timer_refactored.py`, `projects_refactored_example.py`, `invoices_refactored.py` are no longer present; refactors were merged or removed.) + +--- + +## Top 10 refactor targets (ranked by impact and risk) + +| # | Target | Impact | Risk | Action | +|---|--------|--------|------|--------| +| 1 | **Centralize "user's distinct project IDs"** | High | Low | Add `TimeEntryRepository.get_distinct_project_ids_for_user(user_id)`; replace 6+ call sites. | +| 2 | **Move RecurringInvoice.generate_invoice to service** | High | Medium | Create `RecurringInvoiceService.generate_invoice(recurring_invoice)`; keep model for state only; add tests. | +| 3 | **Thin reports routes** | High | Medium | Move comparison_view, project_report, unpaid_hours_report (and export) logic into `ReportingService`. | +| 4 | **Standardize API v1 response envelope** | High | Medium | Use `success_response`/`error_response` in api_v1_* routes; document contract. | +| 5 | **Recurring invoices: service + repository** | Medium–High | Medium | Add `RecurringInvoiceRepository` and `RecurringInvoiceService`; move list/create/edit/delete and generate from routes. | +| 6 | **Invoices route thinning (incremental)** | High | High | Move one invoice handler at a time into `InvoiceService` using existing `InvoiceRepository`; add tests per batch. | +| 7 | **Issues: shared "accessible projects/clients" helper** | Medium | Low | Add repository or scope helper; use in all four places in `app/routes/issues.py`. | +| 8 | **API v1 validation and api_responses** | Medium | Low | Use existing schemas + `handle_validation_error` and `success_response`/`error_response` in api_v1_* modules. | +| 9 | **Gantt: extract data and progress to service** | Medium | Low | Add `GanttService` for `get_gantt_data` and progress calculation; route only delegates. | +| 10 | **Precompute task counts and budget in views** | Medium | Low | In tasks/project routes, compute task_counts and budget fields; pass to templates. | + +--- + +## Refactor progress + +| # | Target | Status | +|---|--------|--------| +| 1 | Centralize "user's distinct project IDs" | Done | +| 2 | RecurringInvoiceService.generate_invoice | Done | +| 3 | Thin reports routes | Done | +| 4 | Standardize API v1 response envelope | Done (documented) | +| 5 | Recurring invoices service + repository | Done | +| 6 | Invoices incremental | Done (get_unbilled_data_for_invoice) | +| 7 | Issues accessible IDs helper | Done | +| 8 | API v1 validation and api_responses | Done (api_v1_projects) | +| 9 | Gantt service | Done | +| 10 | Precompute task counts and budget in views | Done | + +*(Update status to Done as refactors are completed.)* diff --git a/docs/DOCS_AUDIT.md b/docs/DOCS_AUDIT.md new file mode 100644 index 00000000..0c349b8b --- /dev/null +++ b/docs/DOCS_AUDIT.md @@ -0,0 +1,73 @@ +# Documentation Audit Summary + +This audit summarizes the state of TimeTracker documentation as of the audit date. Use it to find accurate sources, fix outdated content, and fill gaps. + +--- + +## Accurate (keep; minimal edits only) + +| Doc | Notes | +|-----|--------| +| **README.md** | Tech stack, quick start (Docker HTTPS/HTTP/SQLite), features, doc links correct. Version: states "defined in setup.py"; avoid hardcoding version examples in What's New. | +| **INSTALLATION.md** | Matches actual flow; points to GETTING_STARTED and DOCKER_COMPOSE_SETUP. | +| **DEVELOPMENT.md** | Venv + `flask run`, `docker-compose.local-test.yml`, folder structure, test commands align with repo. | +| **ARCHITECTURE.md** | Module table, data flow, API structure match app/ (blueprint_registry, routes, services, repositories, models). | +| **API.md** (root), **docs/api/REST_API.md** | Accurate overview and auth; REST_API is full reference. | +| **docs/development/SERVICE_LAYER_AND_BASE_CRUD.md** | Accurately describes service/repository pattern and BaseCRUDService. | +| **docs/development/LOCAL_TESTING_WITH_SQLITE.md** | Correct for docker-compose.local-test.yml and scripts. | +| **docs/TESTING_QUICK_REFERENCE.md**, **docs/TESTING_COVERAGE_GUIDE.md** | Align with Makefile targets and pytest markers. | +| **docs/UI_GUIDELINES.md**, **docs/FRONTEND.md** | Match templates (base.html, components/ui.html) and Tailwind. | +| **docs/GETTING_STARTED.md** | Correct: default compose → https://localhost; documents example compose for http://localhost:8080. | +| **requirements-test.txt** | Exists; referenced by Makefile and CI. | + +--- + +## Outdated + +| Item | Location | Fix | +|------|----------|-----| +| Version numbers | README "What's New" / "Current version" | Point to setup.py + CHANGELOG only; remove or generalize hardcoded v4.14.0, v4.6.0. | +| Hardcoded version | docs/development/PROJECT_STRUCTURE.md | Replace "version='4.20.9'" with "version in setup.py (single source of truth)". | +| Hardcoded version | docs/FEATURES_COMPLETE.md | Remove "Version: 4.20.6" or replace with "See setup.py". | +| Docker access URL | docs/development/CONTRIBUTING.md | Default `docker-compose up --build` → https://localhost. For http://localhost:8080 use docker-compose.example.yml or docker-compose.local-test.yml. | +| Compose role | docs/development/PROJECT_STRUCTURE.md | Describe docker-compose.yml as "Default stack (HTTPS via nginx)"; add docker-compose.example.yml (HTTP 8080) and docker-compose.local-test.yml (SQLite). | +| Refactored modules | docs/ARCHITECTURE_AUDIT.md | Note that timer_refactored/projects_refactored/invoices_refactored are historical (merged or removed). | +| Deployment guide label | docs/guides/DEPLOYMENT_GUIDE.md | Content is feature checklist, not "how to deploy". Add note at top pointing to DOCKER_COMPOSE_SETUP and DOCKER_PUBLIC_SETUP. | + +--- + +## Missing + +| Gap | Resolution | +|-----|------------| +| Single contributor onboarding doc | **CONTRIBUTOR_GUIDE.md** — Architecture, local dev, testing, how to add route/service/repository/template, versioning. | +| Versioning for contributors | Short "For contributors" note: app version in setup.py only; desktop/mobile have own config; link BUILD.md and VERSION_MANAGEMENT. | +| Step-by-step "add route/service/repository/template" | Include in CONTRIBUTOR_GUIDE with concrete steps (files, blueprint_registry, tests). | + +--- + +## Duplicated or Overlapping + +| Area | Notes | +|------|--------| +| **Installation** | README Quick Start, INSTALLATION.md, GETTING_STARTED.md, DOCKER_COMPOSE_SETUP all cover install. Keep overlap; add one-line pointers (e.g. "For step-by-step see INSTALLATION.md", "For all env vars see DOCKER_COMPOSE_SETUP"). | +| **Contributing** | Root CONTRIBUTING.md points to docs/development/CONTRIBUTING.md; fix Docker URL in full CONTRIBUTING. | +| **Deployment** | README, INSTALLATION, DOCKER_COMPOSE_SETUP, DOCKER_PUBLIC_SETUP, guides/DEPLOYMENT_GUIDE. Clarify DEPLOYMENT_GUIDE as feature checklist; "how to deploy" = DOCKER_COMPOSE_SETUP / DOCKER_PUBLIC_SETUP. | + +--- + +## Contradictions + +| Issue | Resolution | +|-------|------------| +| **Docker URL** | CONTRIBUTING (full) said http://localhost:8080 after default `docker-compose up --build`. Default compose serves **HTTPS** (https://localhost). Fix: document default = https://localhost; for 8080 use docker-compose.example.yml or docker-compose.local-test.yml. | +| **Compose purpose** | PROJECT_STRUCTURE said "Local development compose" for docker-compose.yml; README uses it for quick start and production. Unify: default = full stack HTTPS; development options = example (HTTP) or local-test (SQLite). | + +--- + +## File reference + +- **Audit doc**: this file — `docs/DOCS_AUDIT.md` +- **Updates**: README.md, docs/development/CONTRIBUTING.md, docs/development/PROJECT_STRUCTURE.md, docs/FEATURES_COMPLETE.md, docs/ARCHITECTURE_AUDIT.md, docs/README.md, docs/guides/DEPLOYMENT_GUIDE.md +- **New**: docs/development/CONTRIBUTOR_GUIDE.md +- **Cross-links**: CONTRIBUTING.md (root), docs/README.md, DEVELOPMENT.md diff --git a/docs/FEATURES_COMPLETE.md b/docs/FEATURES_COMPLETE.md index 1750ee81..9afd7861 100644 --- a/docs/FEATURES_COMPLETE.md +++ b/docs/FEATURES_COMPLETE.md @@ -1,8 +1,10 @@ # TimeTracker - Complete Features Documentation -**Version:** 4.20.6 (see `setup.py` for single source of truth) +**Version:** See `setup.py` for current version (single source of truth). **Last Updated:** 2025-02-20 +**Navigation:** Many features are optional (see Admin → Module Management). Reports are available from the top-level **Reports** sidebar link (or **Finance & Expenses → Reports** for Report Builder, Saved Views, Scheduled Reports). Time entries export is under **Time entries** (overview page). + --- ## Table of Contents diff --git a/docs/FRONTEND.md b/docs/FRONTEND.md new file mode 100644 index 00000000..833315e8 --- /dev/null +++ b/docs/FRONTEND.md @@ -0,0 +1,70 @@ +# TimeTracker Frontend Guide + +This document describes the main app frontend stack, component usage, and conventions. It does **not** cover the client portal or kiosk bases. + +## Stack + +- **Templates**: Jinja2 (Flask) +- **Styles**: Tailwind CSS (built from `app/static/src/input.css` → `app/static/dist/output.css`) +- **Design tokens**: CSS variables and Tailwind theme in `app/static/src/input.css` and `tailwind.config.js` +- **No React/Vue**: The app remains server-rendered with Jinja; use existing macros and minimal JS for behavior. + +References: [UI_IMPROVEMENTS_SUMMARY.md](implementation-notes/UI_IMPROVEMENTS_SUMMARY.md), [STYLING_CONSISTENCY_SUMMARY.md](implementation-notes/STYLING_CONSISTENCY_SUMMARY.md). + +## Base template and blocks + +- **Main app**: `app/templates/base.html` — provides ``, head (meta, CSS, scripts), skip link, sidebar, header, `
`, footer, and mobile bottom nav. +- **Blocks**: `title`, `content`, `extra_css`, `scripts_extra`, `head_extra`, etc. Page templates extend `base.html` and override these blocks. + +## Component usage + +**Primary source**: `app/templates/components/ui.html` (and `app/templates/components/cards.html` where used). Prefer these over legacy `_components.html`. + +### When to use which + +| Need | Macro / component | Import from | +|------|-------------------|-------------| +| Page title + subtitle + optional breadcrumbs and actions | `page_header(icon_class, title_text, subtitle_text=None, actions_html=None, breadcrumbs=None)` | `components/ui.html` | +| Breadcrumbs only | `breadcrumb_nav(items)` | `components/ui.html` | +| Summary / stat block | `stat_card(title, value, icon_class, color, trend=None, subtitle=None)` | `components/ui.html` or `components/cards.html` | +| Empty list / no results | `empty_state(...)` or `empty_state_compact(...)` with `type='no-data'` or `type='no-results'` | `components/ui.html` | +| Buttons | Use classes `.btn`, `.btn-primary`, `.btn-secondary`, `.btn-danger`, `.btn-ghost`, `.btn-sm`, `.btn-lg` from design system | `app/static/src/input.css` | +| Modals | `modal(id, title, content_html, footer_html=None, size)` | `components/ui.html` | +| Confirm (destructive) | `confirm_dialog(id, title, message, confirm_text, cancel_text, confirm_class)` | `components/ui.html` | +| Pagination | `pagination_nav(pagination, route_name, url_params=None, aria_label=None)` | `components/ui.html` | +| Forms | `form_group`, `form_select`, `form_textarea`, etc. | `components/ui.html` | + +### Empty states + +- Use **no-data** when the list is empty and no filters are applied (e.g. “No time entries yet”). +- Use **no-results** when filters are applied but nothing matches (e.g. “No time entries match your filters”). +- Prefer the macro over inline empty markup so messaging and CTAs stay consistent. + +## Buttons and forms + +- **Buttons**: Use design-system classes (`.btn`, `.btn-primary`, `.btn-secondary`, `.btn-danger`, `.btn-ghost`) so focus states and colors stay consistent. Avoid ad-hoc `bg-*` / `px-*` for primary actions. +- **Forms**: Use `form_group` and related form macros from `ui.html` so labels, `aria-required`, `aria-invalid`, and error blocks are consistent. Shared validation lives in `form-validation.js` / `form-validation.css`. + +## Modals + +- Use the **modal** or **confirm_dialog** macros from `components/ui.html` for new and refactored flows. +- Custom dialogs must provide: + - `role="dialog"` and `aria-modal="true"` + - `aria-labelledby` (and preferably `aria-describedby`) pointing to the title and description + - Focus trap when open (keep focus inside the dialog until closed). + +## Tables and pagination + +- **List tables**: Prefer `data-table-enhanced` (see `data-tables-enhanced.js` / `.css`) for sortable headers and consistent ARIA where applicable. +- **Responsive**: Use `responsive-cards` and `data-label` on cells for small screens. +- **Pagination**: Use the `pagination_nav` macro when possible; otherwise wrap pagination in a `