diff --git a/.github/workflows/ci-comprehensive.yml b/.github/workflows/ci-comprehensive.yml index c31d518f..6a204664 100644 --- a/.github/workflows/ci-comprehensive.yml +++ b/.github/workflows/ci-comprehensive.yml @@ -276,28 +276,78 @@ jobs: - name: Test Docker container startup run: | - docker run -d --name test-container \ + # Start container + CONTAINER_ID=$(docker run -d --name test-container \ -p 8080:8080 \ -e DATABASE_URL="sqlite:////app/test.db" \ -e SECRET_KEY="test-secret-key-for-ci-only-$(openssl rand -hex 32)" \ -e FLASK_ENV="development" \ - timetracker-test:pr-${{ github.event.pull_request.number || 'dev' }} + timetracker-test:pr-${{ github.event.pull_request.number || 'dev' }}) - # Wait for container to be ready - for i in {1..30}; do + echo "๐Ÿณ Started container: $CONTAINER_ID" + + # Wait for container to be ready (increased timeout for migrations) + HEALTH_CHECK_PASSED=false + for i in {1..60}; do + # Check if container is still running + if ! docker ps -q --filter "name=test-container" | grep -q .; then + echo "โŒ Container exited unexpectedly!" + echo "" + echo "๐Ÿ“‹ Container logs:" + docker logs test-container + echo "" + echo "๐Ÿ” Container status:" + docker ps -a --filter "name=test-container" + exit 1 + fi + + # Try health check if curl -f http://localhost:8080/_health >/dev/null 2>&1; then - echo "โœ… Container health check passed" + echo "โœ… Container health check passed (attempt $i/60)" + HEALTH_CHECK_PASSED=true break fi - echo "โณ Waiting for container... ($i/30)" + + # Show progress + if [ $((i % 10)) -eq 0 ]; then + echo "โณ Still waiting for container... ($i/60)" + echo "๐Ÿ“Š Last 10 log lines:" + docker logs --tail 10 test-container + else + echo "โณ Waiting for container... ($i/60)" + fi + sleep 2 done - # Show logs + # Show full logs for debugging + echo "" + echo "๐Ÿ“‹ Full container logs:" docker logs test-container + echo "" - # Final health check - curl -f http://localhost:8080/_health || exit 1 + # Check if health check passed + if [ "$HEALTH_CHECK_PASSED" = false ]; then + echo "โŒ Health check never passed after 120 seconds" + echo "" + echo "๐Ÿ” Container inspect:" + docker inspect test-container + echo "" + echo "๐Ÿ” Container status:" + docker ps -a --filter "name=test-container" + exit 1 + fi + + # Final health check with detailed output + echo "๐Ÿ” Final health check:" + curl -v http://localhost:8080/_health || { + echo "โŒ Final health check failed" + echo "๐Ÿ“‹ Latest logs:" + docker logs --tail 50 test-container + exit 1 + } + + echo "โœ… Docker container test completed successfully" # Cleanup docker stop test-container diff --git a/app/__init__.py b/app/__init__.py index 31291830..4e4ae226 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -770,6 +770,11 @@ def create_app(config=None): from app.routes.expenses import expenses_bp from app.routes.permissions import permissions_bp from app.routes.calendar import calendar_bp + from app.routes.expense_categories import expense_categories_bp + from app.routes.mileage import mileage_bp + from app.routes.per_diem import per_diem_bp + from app.routes.budget_alerts import budget_alerts_bp + from app.routes.import_export import import_export_bp app.register_blueprint(auth_bp) app.register_blueprint(main_bp) @@ -798,6 +803,11 @@ def create_app(config=None): app.register_blueprint(expenses_bp) app.register_blueprint(permissions_bp) app.register_blueprint(calendar_bp) + app.register_blueprint(expense_categories_bp) + app.register_blueprint(mileage_bp) + app.register_blueprint(per_diem_bp) + app.register_blueprint(budget_alerts_bp) + app.register_blueprint(import_export_bp) # Exempt API blueprints from CSRF protection (JSON API uses token authentication, not CSRF tokens) # Only if CSRF is enabled @@ -851,8 +861,13 @@ def create_app(config=None): # Register context processors from app.utils.context_processors import register_context_processors - + register_context_processors(app) + + # Register i18n template filters + from app.utils.i18n_helpers import register_i18n_filters + + register_i18n_filters(app) # (translations compiled and directories set before Babel init) diff --git a/app/config.py b/app/config.py index c992e542..2547dd28 100644 --- a/app/config.py +++ b/app/config.py @@ -118,7 +118,12 @@ class Config: 'fr': 'Franรงais', 'it': 'Italiano', 'fi': 'Suomi', + 'es': 'Espaรฑol', + 'ar': 'ุงู„ุนุฑุจูŠุฉ', + 'he': 'ืขื‘ืจื™ืช', } + # RTL languages + RTL_LANGUAGES = {'ar', 'he'} BABEL_DEFAULT_LOCALE = os.getenv('DEFAULT_LOCALE', 'en') # Comma-separated list of translation directories relative to instance root BABEL_TRANSLATION_DIRECTORIES = os.getenv('BABEL_TRANSLATION_DIRECTORIES', 'translations') diff --git a/app/models/__init__.py b/app/models/__init__.py index 2ab7760d..a1b9e0c3 100644 --- a/app/models/__init__.py +++ b/app/models/__init__.py @@ -11,6 +11,9 @@ from .payments import Payment, CreditNote, InvoiceReminderSchedule from .reporting import SavedReportView, ReportEmailSchedule from .client import Client from .task_activity import TaskActivity +from .expense_category import ExpenseCategory +from .mileage import Mileage +from .per_diem import PerDiem, PerDiemRate from .extra_good import ExtraGood from .comment import Comment from .focus_session import FocusSession @@ -28,6 +31,8 @@ from .expense import Expense from .permission import Permission, Role from .api_token import ApiToken from .calendar_event import CalendarEvent +from .budget_alert import BudgetAlert +from .import_export import DataImport, DataExport __all__ = [ "User", @@ -65,4 +70,7 @@ __all__ = [ "Role", "ApiToken", "CalendarEvent", + "BudgetAlert", + "DataImport", + "DataExport", ] diff --git a/app/models/budget_alert.py b/app/models/budget_alert.py new file mode 100644 index 00000000..3f4dba11 --- /dev/null +++ b/app/models/budget_alert.py @@ -0,0 +1,150 @@ +from datetime import datetime, timedelta +from app import db + +class BudgetAlert(db.Model): + """Budget alert model for tracking project budget warnings and notifications""" + + __tablename__ = 'budget_alerts' + + id = db.Column(db.Integer, primary_key=True) + project_id = db.Column(db.Integer, db.ForeignKey('projects.id'), nullable=False, index=True) + + # Alert details + alert_type = db.Column(db.String(20), nullable=False) # 'warning_80', 'warning_100', 'over_budget' + alert_level = db.Column(db.String(20), nullable=False) # 'info', 'warning', 'critical' + budget_consumed_percent = db.Column(db.Numeric(5, 2), nullable=False) # Percentage of budget consumed + budget_amount = db.Column(db.Numeric(10, 2), nullable=False) # Budget at time of alert + consumed_amount = db.Column(db.Numeric(10, 2), nullable=False) # Amount consumed at time of alert + + # Alert message and status + message = db.Column(db.Text, nullable=False) + is_acknowledged = db.Column(db.Boolean, default=False, nullable=False) + acknowledged_by = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=True, index=True) + acknowledged_at = db.Column(db.DateTime, nullable=True) + + # Metadata + created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False, index=True) + + # Relationships + project = db.relationship('Project', backref=db.backref('budget_alerts', lazy='dynamic')) + + def __init__(self, project_id, alert_type, alert_level, budget_consumed_percent, + budget_amount, consumed_amount, message): + self.project_id = project_id + self.alert_type = alert_type + self.alert_level = alert_level + self.budget_consumed_percent = budget_consumed_percent + self.budget_amount = budget_amount + self.consumed_amount = consumed_amount + self.message = message + + def __repr__(self): + return f'' + + def acknowledge(self, user_id): + """Mark this alert as acknowledged by a user""" + self.is_acknowledged = True + self.acknowledged_by = user_id + self.acknowledged_at = datetime.utcnow() + db.session.commit() + + def to_dict(self): + """Convert budget alert to dictionary for API responses""" + return { + 'id': self.id, + 'project_id': self.project_id, + 'project_name': self.project.name if self.project else None, + 'alert_type': self.alert_type, + 'alert_level': self.alert_level, + 'budget_consumed_percent': float(self.budget_consumed_percent), + 'budget_amount': float(self.budget_amount), + 'consumed_amount': float(self.consumed_amount), + 'message': self.message, + 'is_acknowledged': self.is_acknowledged, + 'acknowledged_by': self.acknowledged_by, + 'acknowledged_at': self.acknowledged_at.isoformat() if self.acknowledged_at else None, + 'created_at': self.created_at.isoformat() if self.created_at else None, + } + + @classmethod + def get_active_alerts(cls, project_id=None, acknowledged=False): + """Get active alerts, optionally filtered by project""" + query = cls.query.filter_by(is_acknowledged=acknowledged) + + if project_id: + query = query.filter_by(project_id=project_id) + + return query.order_by(cls.created_at.desc()).all() + + @classmethod + def create_alert(cls, project_id, alert_type, budget_consumed_percent, + budget_amount, consumed_amount): + """Create a new budget alert""" + # Determine alert level based on type + alert_levels = { + 'warning_80': 'warning', + 'warning_100': 'critical', + 'over_budget': 'critical' + } + alert_level = alert_levels.get(alert_type, 'info') + + # Generate alert message + message = cls._generate_message(alert_type, budget_consumed_percent, + budget_amount, consumed_amount) + + # Check if similar alert already exists (avoid duplicates) + recent_alert = cls.query.filter_by( + project_id=project_id, + alert_type=alert_type, + is_acknowledged=False + ).filter( + cls.created_at >= datetime.utcnow() - timedelta(hours=24) + ).first() + + if recent_alert: + return recent_alert + + # Create new alert + alert = cls( + project_id=project_id, + alert_type=alert_type, + alert_level=alert_level, + budget_consumed_percent=budget_consumed_percent, + budget_amount=budget_amount, + consumed_amount=consumed_amount, + message=message + ) + + db.session.add(alert) + db.session.commit() + + return alert + + @staticmethod + def _generate_message(alert_type, budget_consumed_percent, budget_amount, consumed_amount): + """Generate alert message based on alert type""" + messages = { + 'warning_80': f'Warning: Project has consumed {budget_consumed_percent:.1f}% of budget (${consumed_amount:.2f} of ${budget_amount:.2f})', + 'warning_100': f'Alert: Project has reached 100% of budget (${consumed_amount:.2f} of ${budget_amount:.2f})', + 'over_budget': f'Critical: Project is over budget by ${consumed_amount - budget_amount:.2f} ({budget_consumed_percent:.1f}% consumed)' + } + return messages.get(alert_type, 'Budget alert') + + @classmethod + def get_alert_summary(cls, project_id=None): + """Get summary statistics for budget alerts""" + query = cls.query + + if project_id: + query = query.filter_by(project_id=project_id) + + total_alerts = query.count() + unacknowledged_alerts = query.filter_by(is_acknowledged=False).count() + critical_alerts = query.filter_by(alert_level='critical', is_acknowledged=False).count() + + return { + 'total_alerts': total_alerts, + 'unacknowledged_alerts': unacknowledged_alerts, + 'critical_alerts': critical_alerts + } + diff --git a/app/models/expense_category.py b/app/models/expense_category.py new file mode 100644 index 00000000..e6c0ff45 --- /dev/null +++ b/app/models/expense_category.py @@ -0,0 +1,144 @@ +from datetime import datetime +from decimal import Decimal +from app import db +from sqlalchemy import Index + + +class ExpenseCategory(db.Model): + """Expense category model with budget tracking""" + + __tablename__ = 'expense_categories' + + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(100), nullable=False, unique=True, index=True) + description = db.Column(db.Text, nullable=True) + code = db.Column(db.String(20), nullable=True, unique=True, index=True) # Short code for quick reference + color = db.Column(db.String(7), nullable=True) # Hex color for UI (e.g., #FF5733) + icon = db.Column(db.String(50), nullable=True) # Icon name for UI + + # Budget settings + monthly_budget = db.Column(db.Numeric(10, 2), nullable=True) + quarterly_budget = db.Column(db.Numeric(10, 2), nullable=True) + yearly_budget = db.Column(db.Numeric(10, 2), nullable=True) + budget_threshold_percent = db.Column(db.Integer, nullable=False, default=80) # Alert when exceeded + + # Settings + requires_receipt = db.Column(db.Boolean, default=True, nullable=False) + requires_approval = db.Column(db.Boolean, default=True, nullable=False) + default_tax_rate = db.Column(db.Numeric(5, 2), nullable=True) + is_active = db.Column(db.Boolean, default=True, nullable=False) + + # Metadata + created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) + updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False) + + def __init__(self, name, **kwargs): + self.name = name.strip() + self.description = kwargs.get('description', '').strip() if kwargs.get('description') else None + self.code = kwargs.get('code', '').strip() if kwargs.get('code') else None + self.color = kwargs.get('color') + self.icon = kwargs.get('icon') + self.monthly_budget = Decimal(str(kwargs.get('monthly_budget'))) if kwargs.get('monthly_budget') else None + self.quarterly_budget = Decimal(str(kwargs.get('quarterly_budget'))) if kwargs.get('quarterly_budget') else None + self.yearly_budget = Decimal(str(kwargs.get('yearly_budget'))) if kwargs.get('yearly_budget') else None + self.budget_threshold_percent = kwargs.get('budget_threshold_percent', 80) + self.requires_receipt = kwargs.get('requires_receipt', True) + self.requires_approval = kwargs.get('requires_approval', True) + self.default_tax_rate = Decimal(str(kwargs.get('default_tax_rate'))) if kwargs.get('default_tax_rate') else None + self.is_active = kwargs.get('is_active', True) + + def __repr__(self): + return f'' + + def get_spent_amount(self, start_date, end_date): + """Get total amount spent in this category for date range""" + from app.models.expense import Expense + + query = db.session.query( + db.func.sum(Expense.amount + db.func.coalesce(Expense.tax_amount, 0)) + ).filter( + Expense.category == self.name, + Expense.status.in_(['approved', 'reimbursed']), + Expense.expense_date >= start_date, + Expense.expense_date <= end_date + ) + + total = query.scalar() or Decimal('0') + return float(total) + + def get_budget_utilization(self, period='monthly'): + """Get budget utilization percentage for the current period""" + from datetime import date + + today = date.today() + + if period == 'monthly': + start_date = date(today.year, today.month, 1) + budget = self.monthly_budget + elif period == 'quarterly': + quarter = (today.month - 1) // 3 + 1 + start_month = (quarter - 1) * 3 + 1 + start_date = date(today.year, start_month, 1) + budget = self.quarterly_budget + elif period == 'yearly': + start_date = date(today.year, 1, 1) + budget = self.yearly_budget + else: + return None + + if not budget or budget == 0: + return None + + spent = self.get_spent_amount(start_date, today) + utilization = (spent / float(budget)) * 100 + + return { + 'spent': spent, + 'budget': float(budget), + 'utilization_percent': round(utilization, 2), + 'remaining': float(budget) - spent, + 'over_threshold': utilization >= self.budget_threshold_percent + } + + def to_dict(self): + """Convert category to dictionary for API responses""" + return { + 'id': self.id, + 'name': self.name, + 'description': self.description, + 'code': self.code, + 'color': self.color, + 'icon': self.icon, + 'monthly_budget': float(self.monthly_budget) if self.monthly_budget else None, + 'quarterly_budget': float(self.quarterly_budget) if self.quarterly_budget else None, + 'yearly_budget': float(self.yearly_budget) if self.yearly_budget else None, + 'budget_threshold_percent': self.budget_threshold_percent, + 'requires_receipt': self.requires_receipt, + 'requires_approval': self.requires_approval, + 'default_tax_rate': float(self.default_tax_rate) if self.default_tax_rate else None, + 'is_active': self.is_active, + 'created_at': self.created_at.isoformat() if self.created_at else None, + 'updated_at': self.updated_at.isoformat() if self.updated_at else None + } + + @classmethod + def get_active_categories(cls): + """Get all active categories""" + return cls.query.filter_by(is_active=True).order_by(cls.name).all() + + @classmethod + def get_categories_over_budget(cls, period='monthly'): + """Get categories that are over their budget threshold""" + categories = cls.get_active_categories() + over_budget = [] + + for category in categories: + utilization = category.get_budget_utilization(period) + if utilization and utilization['over_threshold']: + over_budget.append({ + 'category': category, + 'utilization': utilization + }) + + return over_budget + diff --git a/app/models/import_export.py b/app/models/import_export.py new file mode 100644 index 00000000..c1ee965e --- /dev/null +++ b/app/models/import_export.py @@ -0,0 +1,220 @@ +""" +Import/Export tracking models for data import/export operations +""" +from datetime import datetime +from app import db + + +class DataImport(db.Model): + """Model to track import operations""" + + __tablename__ = 'data_imports' + + id = db.Column(db.Integer, primary_key=True) + user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False, index=True) + import_type = db.Column(db.String(50), nullable=False) # 'csv', 'toggl', 'harvest', 'backup' + source_file = db.Column(db.String(500), nullable=True) # Original filename + status = db.Column(db.String(20), default='pending', nullable=False) # 'pending', 'processing', 'completed', 'failed', 'partial' + total_records = db.Column(db.Integer, default=0) + successful_records = db.Column(db.Integer, default=0) + failed_records = db.Column(db.Integer, default=0) + error_log = db.Column(db.Text, nullable=True) # JSON string of errors + import_summary = db.Column(db.Text, nullable=True) # JSON string with details + started_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) + completed_at = db.Column(db.DateTime, nullable=True) + + # Relationship + user = db.relationship('User', backref=db.backref('imports', lazy='dynamic')) + + def __init__(self, user_id, import_type, source_file=None): + self.user_id = user_id + self.import_type = import_type + self.source_file = source_file + self.status = 'pending' + self.total_records = 0 + self.successful_records = 0 + self.failed_records = 0 + + def __repr__(self): + return f'' + + def start_processing(self): + """Mark import as processing""" + self.status = 'processing' + db.session.commit() + + def complete(self): + """Mark import as completed""" + self.status = 'completed' + self.completed_at = datetime.utcnow() + db.session.commit() + + def fail(self, error_message=None): + """Mark import as failed""" + self.status = 'failed' + self.completed_at = datetime.utcnow() + if error_message: + import json + errors = [] + if self.error_log: + try: + errors = json.loads(self.error_log) + except: + pass + errors.append({'error': error_message, 'timestamp': datetime.utcnow().isoformat()}) + self.error_log = json.dumps(errors) + db.session.commit() + + def partial_complete(self): + """Mark import as partially completed (some records failed)""" + self.status = 'partial' + self.completed_at = datetime.utcnow() + db.session.commit() + + def update_progress(self, total, successful, failed): + """Update import progress""" + self.total_records = total + self.successful_records = successful + self.failed_records = failed + if failed > 0 and successful > 0: + self.status = 'partial' + elif failed > 0: + self.status = 'failed' + db.session.commit() + + def add_error(self, error_message, record_data=None): + """Add an error to the error log""" + import json + errors = [] + if self.error_log: + try: + errors = json.loads(self.error_log) + except: + pass + + error_entry = { + 'error': error_message, + 'timestamp': datetime.utcnow().isoformat() + } + if record_data: + error_entry['record'] = record_data + + errors.append(error_entry) + self.error_log = json.dumps(errors) + db.session.commit() + + def set_summary(self, summary_dict): + """Set import summary""" + import json + self.import_summary = json.dumps(summary_dict) + db.session.commit() + + def to_dict(self): + """Convert to dictionary""" + import json + return { + 'id': self.id, + 'user_id': self.user_id, + 'user': self.user.username if self.user else None, + 'import_type': self.import_type, + 'source_file': self.source_file, + 'status': self.status, + 'total_records': self.total_records, + 'successful_records': self.successful_records, + 'failed_records': self.failed_records, + 'error_log': json.loads(self.error_log) if self.error_log else [], + 'import_summary': json.loads(self.import_summary) if self.import_summary else {}, + 'started_at': self.started_at.isoformat() if self.started_at else None, + 'completed_at': self.completed_at.isoformat() if self.completed_at else None, + } + + +class DataExport(db.Model): + """Model to track export operations""" + + __tablename__ = 'data_exports' + + id = db.Column(db.Integer, primary_key=True) + user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False, index=True) + export_type = db.Column(db.String(50), nullable=False) # 'full', 'filtered', 'backup', 'gdpr' + export_format = db.Column(db.String(20), nullable=False) # 'json', 'csv', 'xlsx', 'zip' + file_path = db.Column(db.String(500), nullable=True) # Path to generated file + file_size = db.Column(db.Integer, nullable=True) # File size in bytes + status = db.Column(db.String(20), default='pending', nullable=False) # 'pending', 'processing', 'completed', 'failed' + filters = db.Column(db.Text, nullable=True) # JSON string with export filters + record_count = db.Column(db.Integer, default=0) + error_message = db.Column(db.Text, nullable=True) + created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) + completed_at = db.Column(db.DateTime, nullable=True) + expires_at = db.Column(db.DateTime, nullable=True) # When file should be deleted + + # Relationship + user = db.relationship('User', backref=db.backref('exports', lazy='dynamic')) + + def __init__(self, user_id, export_type, export_format='json', filters=None): + self.user_id = user_id + self.export_type = export_type + self.export_format = export_format + self.status = 'pending' + self.record_count = 0 + if filters: + import json + self.filters = json.dumps(filters) + + def __repr__(self): + return f'' + + def start_processing(self): + """Mark export as processing""" + self.status = 'processing' + db.session.commit() + + def complete(self, file_path, file_size, record_count): + """Mark export as completed""" + self.status = 'completed' + self.file_path = file_path + self.file_size = file_size + self.record_count = record_count + self.completed_at = datetime.utcnow() + # Set expiration to 7 days from now + self.expires_at = datetime.utcnow() + timedelta(days=7) + db.session.commit() + + def fail(self, error_message): + """Mark export as failed""" + self.status = 'failed' + self.error_message = error_message + self.completed_at = datetime.utcnow() + db.session.commit() + + def is_expired(self): + """Check if export has expired""" + if not self.expires_at: + return False + return datetime.utcnow() > self.expires_at + + def to_dict(self): + """Convert to dictionary""" + import json + return { + 'id': self.id, + 'user_id': self.user_id, + 'user': self.user.username if self.user else None, + 'export_type': self.export_type, + 'export_format': self.export_format, + 'file_path': self.file_path, + 'file_size': self.file_size, + 'status': self.status, + 'filters': json.loads(self.filters) if self.filters else {}, + 'record_count': self.record_count, + 'error_message': self.error_message, + 'created_at': self.created_at.isoformat() if self.created_at else None, + 'completed_at': self.completed_at.isoformat() if self.completed_at else None, + 'expires_at': self.expires_at.isoformat() if self.expires_at else None, + 'is_expired': self.is_expired(), + } + + +# Fix missing import +from datetime import timedelta + diff --git a/app/models/mileage.py b/app/models/mileage.py new file mode 100644 index 00000000..8b1524c7 --- /dev/null +++ b/app/models/mileage.py @@ -0,0 +1,249 @@ +from datetime import datetime +from decimal import Decimal +from app import db +from sqlalchemy import Index + + +class Mileage(db.Model): + """Mileage tracking for business travel""" + + __tablename__ = 'mileage' + + id = db.Column(db.Integer, primary_key=True) + user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False, index=True) + project_id = db.Column(db.Integer, db.ForeignKey('projects.id'), nullable=True, index=True) + client_id = db.Column(db.Integer, db.ForeignKey('clients.id'), nullable=True, index=True) + expense_id = db.Column(db.Integer, db.ForeignKey('expenses.id'), nullable=True, index=True) + + # Trip details + trip_date = db.Column(db.Date, nullable=False, index=True) + purpose = db.Column(db.String(200), nullable=False) + description = db.Column(db.Text, nullable=True) + + # Location information + start_location = db.Column(db.String(200), nullable=False) + end_location = db.Column(db.String(200), nullable=False) + start_odometer = db.Column(db.Numeric(10, 2), nullable=True) # Optional odometer readings + end_odometer = db.Column(db.Numeric(10, 2), nullable=True) + + # Distance and calculation + distance_km = db.Column(db.Numeric(10, 2), nullable=False) + distance_miles = db.Column(db.Numeric(10, 2), nullable=True) # Computed or manual + rate_per_km = db.Column(db.Numeric(10, 4), nullable=False) # Rate at time of entry + rate_per_mile = db.Column(db.Numeric(10, 4), nullable=True) + currency_code = db.Column(db.String(3), nullable=False, default='EUR') + + # Vehicle information + vehicle_type = db.Column(db.String(50), nullable=True) # 'car', 'motorcycle', 'van', 'truck' + vehicle_description = db.Column(db.String(200), nullable=True) # e.g., "BMW 3 Series" + license_plate = db.Column(db.String(20), nullable=True) + + # Calculated amount + calculated_amount = db.Column(db.Numeric(10, 2), nullable=False) + + # Round trip + is_round_trip = db.Column(db.Boolean, default=False, nullable=False) + + # Status and approval + status = db.Column(db.String(20), default='pending', nullable=False) # 'pending', 'approved', 'rejected', 'reimbursed' + approved_by = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=True, index=True) + approved_at = db.Column(db.DateTime, nullable=True) + rejection_reason = db.Column(db.Text, nullable=True) + + # Reimbursement + reimbursed = db.Column(db.Boolean, default=False, nullable=False) + reimbursed_at = db.Column(db.DateTime, nullable=True) + + # Notes + notes = db.Column(db.Text, nullable=True) + + # Metadata + created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) + updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False) + + # Relationships + user = db.relationship('User', foreign_keys=[user_id], backref=db.backref('mileage_entries', lazy='dynamic')) + approver = db.relationship('User', foreign_keys=[approved_by], backref=db.backref('approved_mileage', lazy='dynamic')) + project = db.relationship('Project', backref=db.backref('mileage_entries', lazy='dynamic')) + client = db.relationship('Client', backref=db.backref('mileage_entries', lazy='dynamic')) + expense = db.relationship('Expense', backref=db.backref('mileage_entry', uselist=False)) + + # Indexes for common queries + __table_args__ = ( + Index('ix_mileage_user_date', 'user_id', 'trip_date'), + Index('ix_mileage_status_date', 'status', 'trip_date'), + ) + + def __init__(self, user_id, trip_date, purpose, start_location, end_location, + distance_km, rate_per_km, **kwargs): + self.user_id = user_id + self.trip_date = trip_date + self.purpose = purpose.strip() + self.start_location = start_location.strip() + self.end_location = end_location.strip() + self.distance_km = Decimal(str(distance_km)) + self.rate_per_km = Decimal(str(rate_per_km)) + + # Calculate amount + self.calculated_amount = self.distance_km * self.rate_per_km + + # Optional fields + self.description = kwargs.get('description', '').strip() if kwargs.get('description') else None + self.project_id = kwargs.get('project_id') + self.client_id = kwargs.get('client_id') + self.expense_id = kwargs.get('expense_id') + self.start_odometer = Decimal(str(kwargs.get('start_odometer'))) if kwargs.get('start_odometer') else None + self.end_odometer = Decimal(str(kwargs.get('end_odometer'))) if kwargs.get('end_odometer') else None + self.distance_miles = Decimal(str(kwargs.get('distance_miles'))) if kwargs.get('distance_miles') else self.distance_km * Decimal('0.621371') + self.rate_per_mile = Decimal(str(kwargs.get('rate_per_mile'))) if kwargs.get('rate_per_mile') else None + self.currency_code = kwargs.get('currency_code', 'EUR') + self.vehicle_type = kwargs.get('vehicle_type') + self.vehicle_description = kwargs.get('vehicle_description') + self.license_plate = kwargs.get('license_plate') + self.is_round_trip = kwargs.get('is_round_trip', False) + self.notes = kwargs.get('notes', '').strip() if kwargs.get('notes') else None + self.status = kwargs.get('status', 'pending') + + def __repr__(self): + return f' {self.end_location} ({self.distance_km} km)>' + + @property + def total_distance_km(self): + """Get total distance including round trip if applicable""" + multiplier = 2 if self.is_round_trip else 1 + return float(self.distance_km) * multiplier + + @property + def total_amount(self): + """Get total amount including round trip if applicable""" + multiplier = 2 if self.is_round_trip else 1 + return float(self.calculated_amount) * multiplier + + def approve(self, approved_by_user_id, notes=None): + """Approve the mileage entry""" + self.status = 'approved' + self.approved_by = approved_by_user_id + self.approved_at = datetime.utcnow() + if notes: + self.notes = (self.notes or '') + f'\n\nApproval notes: {notes}' + self.updated_at = datetime.utcnow() + + def reject(self, rejected_by_user_id, reason): + """Reject the mileage entry""" + self.status = 'rejected' + self.approved_by = rejected_by_user_id + self.approved_at = datetime.utcnow() + self.rejection_reason = reason + self.updated_at = datetime.utcnow() + + def mark_as_reimbursed(self): + """Mark this mileage entry as reimbursed""" + self.reimbursed = True + self.reimbursed_at = datetime.utcnow() + self.status = 'reimbursed' + self.updated_at = datetime.utcnow() + + def create_expense(self): + """Create an expense from this mileage entry""" + from app.models.expense import Expense + + if self.expense_id: + return None # Already has an expense + + expense = Expense( + user_id=self.user_id, + title=f"Mileage: {self.start_location} to {self.end_location}", + category='travel', + amount=self.total_amount, + expense_date=self.trip_date, + description=f"{self.purpose}\nDistance: {self.total_distance_km} km @ {float(self.rate_per_km)} {self.currency_code}/km", + project_id=self.project_id, + client_id=self.client_id, + currency_code=self.currency_code, + status=self.status + ) + + return expense + + def to_dict(self): + """Convert mileage entry to dictionary for API responses""" + return { + 'id': self.id, + 'user_id': self.user_id, + 'project_id': self.project_id, + 'client_id': self.client_id, + 'expense_id': self.expense_id, + 'trip_date': self.trip_date.isoformat() if self.trip_date else None, + 'purpose': self.purpose, + 'description': self.description, + 'start_location': self.start_location, + 'end_location': self.end_location, + 'start_odometer': float(self.start_odometer) if self.start_odometer else None, + 'end_odometer': float(self.end_odometer) if self.end_odometer else None, + 'distance_km': float(self.distance_km), + 'distance_miles': float(self.distance_miles) if self.distance_miles else None, + 'rate_per_km': float(self.rate_per_km), + 'rate_per_mile': float(self.rate_per_mile) if self.rate_per_mile else None, + 'currency_code': self.currency_code, + 'vehicle_type': self.vehicle_type, + 'vehicle_description': self.vehicle_description, + 'license_plate': self.license_plate, + 'calculated_amount': float(self.calculated_amount), + 'is_round_trip': self.is_round_trip, + 'total_distance_km': self.total_distance_km, + 'total_amount': self.total_amount, + 'status': self.status, + 'approved_by': self.approved_by, + 'approved_at': self.approved_at.isoformat() if self.approved_at else None, + 'rejection_reason': self.rejection_reason, + 'reimbursed': self.reimbursed, + 'reimbursed_at': self.reimbursed_at.isoformat() if self.reimbursed_at else None, + 'notes': self.notes, + 'created_at': self.created_at.isoformat() if self.created_at else None, + 'updated_at': self.updated_at.isoformat() if self.updated_at else None, + 'user': self.user.username if self.user else None, + 'project': self.project.name if self.project else None, + 'client': self.client.name if self.client else None, + 'approver': self.approver.username if self.approver else None + } + + @classmethod + def get_default_rates(cls): + """Get default mileage rates for different vehicle types""" + # These are example rates and should be configurable in settings + return { + 'car': {'km': 0.30, 'mile': 0.48, 'currency': 'EUR'}, + 'motorcycle': {'km': 0.20, 'mile': 0.32, 'currency': 'EUR'}, + 'van': {'km': 0.35, 'mile': 0.56, 'currency': 'EUR'}, + 'truck': {'km': 0.40, 'mile': 0.64, 'currency': 'EUR'} + } + + @classmethod + def get_pending_approvals(cls, user_id=None): + """Get mileage entries pending approval""" + query = cls.query.filter_by(status='pending') + + if user_id: + query = query.filter(cls.user_id == user_id) + + return query.order_by(cls.trip_date.desc()).all() + + @classmethod + def get_total_distance(cls, user_id=None, start_date=None, end_date=None): + """Calculate total distance traveled""" + query = db.session.query(db.func.sum(cls.distance_km)) + + if user_id: + query = query.filter(cls.user_id == user_id) + + if start_date: + query = query.filter(cls.trip_date >= start_date) + + if end_date: + query = query.filter(cls.trip_date <= end_date) + + query = query.filter(cls.status.in_(['approved', 'reimbursed'])) + + total = query.scalar() or Decimal('0') + return float(total) + diff --git a/app/models/per_diem.py b/app/models/per_diem.py new file mode 100644 index 00000000..aa20a69d --- /dev/null +++ b/app/models/per_diem.py @@ -0,0 +1,418 @@ +from datetime import datetime, timedelta +from decimal import Decimal +from app import db +from sqlalchemy import Index + + +class PerDiemRate(db.Model): + """Per diem rate configuration for different locations""" + + __tablename__ = 'per_diem_rates' + + id = db.Column(db.Integer, primary_key=True) + country = db.Column(db.String(100), nullable=False, index=True) + city = db.Column(db.String(100), nullable=True, index=True) + + # Rates + full_day_rate = db.Column(db.Numeric(10, 2), nullable=False) + half_day_rate = db.Column(db.Numeric(10, 2), nullable=False) + breakfast_rate = db.Column(db.Numeric(10, 2), nullable=True) + lunch_rate = db.Column(db.Numeric(10, 2), nullable=True) + dinner_rate = db.Column(db.Numeric(10, 2), nullable=True) + incidental_rate = db.Column(db.Numeric(10, 2), nullable=True) # Tips, etc. + + currency_code = db.Column(db.String(3), nullable=False, default='EUR') + + # Validity period + effective_from = db.Column(db.Date, nullable=False, index=True) + effective_to = db.Column(db.Date, nullable=True, index=True) + + # Settings + is_active = db.Column(db.Boolean, default=True, nullable=False) + notes = db.Column(db.Text, nullable=True) + + # Metadata + created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) + updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False) + + __table_args__ = ( + Index('ix_per_diem_rates_country_city', 'country', 'city'), + Index('ix_per_diem_rates_effective', 'effective_from', 'effective_to'), + ) + + def __init__(self, country, full_day_rate, half_day_rate, effective_from, **kwargs): + self.country = country.strip() + self.city = kwargs.get('city', '').strip() if kwargs.get('city') else None + self.full_day_rate = Decimal(str(full_day_rate)) + self.half_day_rate = Decimal(str(half_day_rate)) + self.breakfast_rate = Decimal(str(kwargs.get('breakfast_rate'))) if kwargs.get('breakfast_rate') else None + self.lunch_rate = Decimal(str(kwargs.get('lunch_rate'))) if kwargs.get('lunch_rate') else None + self.dinner_rate = Decimal(str(kwargs.get('dinner_rate'))) if kwargs.get('dinner_rate') else None + self.incidental_rate = Decimal(str(kwargs.get('incidental_rate'))) if kwargs.get('incidental_rate') else None + self.currency_code = kwargs.get('currency_code', 'EUR') + self.effective_from = effective_from + self.effective_to = kwargs.get('effective_to') + self.is_active = kwargs.get('is_active', True) + self.notes = kwargs.get('notes', '').strip() if kwargs.get('notes') else None + + def __repr__(self): + location = f"{self.city}, {self.country}" if self.city else self.country + return f'' + + def to_dict(self): + """Convert rate to dictionary for API responses""" + return { + 'id': self.id, + 'country': self.country, + 'city': self.city, + 'full_day_rate': float(self.full_day_rate), + 'half_day_rate': float(self.half_day_rate), + 'breakfast_rate': float(self.breakfast_rate) if self.breakfast_rate else None, + 'lunch_rate': float(self.lunch_rate) if self.lunch_rate else None, + 'dinner_rate': float(self.dinner_rate) if self.dinner_rate else None, + 'incidental_rate': float(self.incidental_rate) if self.incidental_rate else None, + 'currency_code': self.currency_code, + 'effective_from': self.effective_from.isoformat() if self.effective_from else None, + 'effective_to': self.effective_to.isoformat() if self.effective_to else None, + 'is_active': self.is_active, + 'notes': self.notes, + 'created_at': self.created_at.isoformat() if self.created_at else None, + 'updated_at': self.updated_at.isoformat() if self.updated_at else None + } + + @classmethod + def get_rate_for_location(cls, country, city=None, date=None): + """Get applicable per diem rate for a location and date""" + from datetime import date as dt_date + + if date is None: + date = dt_date.today() + + query = cls.query.filter( + cls.country == country, + cls.is_active == True, + cls.effective_from <= date + ) + + if city: + # Try to find city-specific rate first + city_rate = query.filter(cls.city == city).filter( + db.or_(cls.effective_to.is_(None), cls.effective_to >= date) + ).first() + + if city_rate: + return city_rate + + # Fall back to country rate + country_rate = query.filter(cls.city.is_(None)).filter( + db.or_(cls.effective_to.is_(None), cls.effective_to >= date) + ).first() + + return country_rate + + +class PerDiem(db.Model): + """Per diem claim for business travel""" + + __tablename__ = 'per_diems' + + id = db.Column(db.Integer, primary_key=True) + user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False, index=True) + project_id = db.Column(db.Integer, db.ForeignKey('projects.id'), nullable=True, index=True) + client_id = db.Column(db.Integer, db.ForeignKey('clients.id'), nullable=True, index=True) + expense_id = db.Column(db.Integer, db.ForeignKey('expenses.id'), nullable=True, index=True) + per_diem_rate_id = db.Column(db.Integer, db.ForeignKey('per_diem_rates.id'), nullable=True, index=True) + + # Trip details + trip_purpose = db.Column(db.String(200), nullable=False) + description = db.Column(db.Text, nullable=True) + + # Date range + start_date = db.Column(db.Date, nullable=False, index=True) + end_date = db.Column(db.Date, nullable=False, index=True) + departure_time = db.Column(db.Time, nullable=True) + return_time = db.Column(db.Time, nullable=True) + + # Location + country = db.Column(db.String(100), nullable=False) + city = db.Column(db.String(100), nullable=True) + + # Calculation details + full_days = db.Column(db.Integer, default=0, nullable=False) + half_days = db.Column(db.Integer, default=0, nullable=False) + + # Meal deductions (if meals were provided) + breakfast_provided = db.Column(db.Integer, default=0, nullable=False) # Number of breakfasts + lunch_provided = db.Column(db.Integer, default=0, nullable=False) + dinner_provided = db.Column(db.Integer, default=0, nullable=False) + + # Rates used (stored at time of creation) + full_day_rate = db.Column(db.Numeric(10, 2), nullable=False) + half_day_rate = db.Column(db.Numeric(10, 2), nullable=False) + breakfast_deduction = db.Column(db.Numeric(10, 2), nullable=True) + lunch_deduction = db.Column(db.Numeric(10, 2), nullable=True) + dinner_deduction = db.Column(db.Numeric(10, 2), nullable=True) + + # Calculated amount + calculated_amount = db.Column(db.Numeric(10, 2), nullable=False) + currency_code = db.Column(db.String(3), nullable=False, default='EUR') + + # Status and approval + status = db.Column(db.String(20), default='pending', nullable=False) # 'pending', 'approved', 'rejected', 'reimbursed' + approved_by = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=True, index=True) + approved_at = db.Column(db.DateTime, nullable=True) + rejection_reason = db.Column(db.Text, nullable=True) + + # Reimbursement + reimbursed = db.Column(db.Boolean, default=False, nullable=False) + reimbursed_at = db.Column(db.DateTime, nullable=True) + + # Notes + notes = db.Column(db.Text, nullable=True) + + # Metadata + created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) + updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False) + + # Relationships + user = db.relationship('User', foreign_keys=[user_id], backref=db.backref('per_diem_claims', lazy='dynamic')) + approver = db.relationship('User', foreign_keys=[approved_by], backref=db.backref('approved_per_diems', lazy='dynamic')) + project = db.relationship('Project', backref=db.backref('per_diem_claims', lazy='dynamic')) + client = db.relationship('Client', backref=db.backref('per_diem_claims', lazy='dynamic')) + expense = db.relationship('Expense', backref=db.backref('per_diem_claim', uselist=False)) + rate = db.relationship('PerDiemRate', backref=db.backref('per_diem_claims', lazy='dynamic')) + + # Indexes for common queries + __table_args__ = ( + Index('ix_per_diems_user_date', 'user_id', 'start_date'), + Index('ix_per_diems_status_date', 'status', 'start_date'), + ) + + def __init__(self, user_id, trip_purpose, start_date, end_date, country, + full_day_rate, half_day_rate, **kwargs): + self.user_id = user_id + self.trip_purpose = trip_purpose.strip() + self.start_date = start_date + self.end_date = end_date + self.country = country.strip() + self.city = kwargs.get('city', '').strip() if kwargs.get('city') else None + + # Store rates + self.full_day_rate = Decimal(str(full_day_rate)) + self.half_day_rate = Decimal(str(half_day_rate)) + + # Optional fields + self.description = kwargs.get('description', '').strip() if kwargs.get('description') else None + self.project_id = kwargs.get('project_id') + self.client_id = kwargs.get('client_id') + self.expense_id = kwargs.get('expense_id') + self.per_diem_rate_id = kwargs.get('per_diem_rate_id') + self.departure_time = kwargs.get('departure_time') + self.return_time = kwargs.get('return_time') + self.full_days = kwargs.get('full_days', 0) + self.half_days = kwargs.get('half_days', 0) + self.breakfast_provided = kwargs.get('breakfast_provided', 0) + self.lunch_provided = kwargs.get('lunch_provided', 0) + self.dinner_provided = kwargs.get('dinner_provided', 0) + self.breakfast_deduction = Decimal(str(kwargs.get('breakfast_deduction', 0))) + self.lunch_deduction = Decimal(str(kwargs.get('lunch_deduction', 0))) + self.dinner_deduction = Decimal(str(kwargs.get('dinner_deduction', 0))) + self.currency_code = kwargs.get('currency_code', 'EUR') + self.notes = kwargs.get('notes', '').strip() if kwargs.get('notes') else None + self.status = kwargs.get('status', 'pending') + + # Calculate amount + self.calculated_amount = self._calculate_amount() + + def _calculate_amount(self): + """Calculate the per diem amount based on days and deductions""" + # Base amount + amount = (self.full_day_rate * self.full_days) + (self.half_day_rate * self.half_days) + + # Deduct provided meals + amount -= (self.breakfast_deduction * self.breakfast_provided) + amount -= (self.lunch_deduction * self.lunch_provided) + amount -= (self.dinner_deduction * self.dinner_provided) + + return max(Decimal('0'), amount) # Ensure non-negative + + def recalculate_amount(self): + """Recalculate the amount (useful when days or deductions change)""" + self.calculated_amount = self._calculate_amount() + return self.calculated_amount + + def __repr__(self): + location = f"{self.city}, {self.country}" if self.city else self.country + return f'' + + @property + def total_days(self): + """Get total number of days (full + half)""" + return self.full_days + (self.half_days * 0.5) + + @property + def trip_duration(self): + """Get trip duration in days""" + return (self.end_date - self.start_date).days + 1 + + def approve(self, approved_by_user_id, notes=None): + """Approve the per diem claim""" + self.status = 'approved' + self.approved_by = approved_by_user_id + self.approved_at = datetime.utcnow() + if notes: + self.notes = (self.notes or '') + f'\n\nApproval notes: {notes}' + self.updated_at = datetime.utcnow() + + def reject(self, rejected_by_user_id, reason): + """Reject the per diem claim""" + self.status = 'rejected' + self.approved_by = rejected_by_user_id + self.approved_at = datetime.utcnow() + self.rejection_reason = reason + self.updated_at = datetime.utcnow() + + def mark_as_reimbursed(self): + """Mark this per diem claim as reimbursed""" + self.reimbursed = True + self.reimbursed_at = datetime.utcnow() + self.status = 'reimbursed' + self.updated_at = datetime.utcnow() + + def create_expense(self): + """Create an expense from this per diem claim""" + from app.models.expense import Expense + + if self.expense_id: + return None # Already has an expense + + location = f"{self.city}, {self.country}" if self.city else self.country + + expense = Expense( + user_id=self.user_id, + title=f"Per Diem: {location}", + category='meals', + amount=self.calculated_amount, + expense_date=self.start_date, + description=f"{self.trip_purpose}\n{self.start_date} to {self.end_date} ({self.total_days} days)", + project_id=self.project_id, + client_id=self.client_id, + currency_code=self.currency_code, + status=self.status + ) + + return expense + + def to_dict(self): + """Convert per diem claim to dictionary for API responses""" + return { + 'id': self.id, + 'user_id': self.user_id, + 'project_id': self.project_id, + 'client_id': self.client_id, + 'expense_id': self.expense_id, + 'per_diem_rate_id': self.per_diem_rate_id, + 'trip_purpose': self.trip_purpose, + 'description': self.description, + 'start_date': self.start_date.isoformat() if self.start_date else None, + 'end_date': self.end_date.isoformat() if self.end_date else None, + 'departure_time': self.departure_time.isoformat() if self.departure_time else None, + 'return_time': self.return_time.isoformat() if self.return_time else None, + 'country': self.country, + 'city': self.city, + 'full_days': self.full_days, + 'half_days': self.half_days, + 'total_days': self.total_days, + 'trip_duration': self.trip_duration, + 'breakfast_provided': self.breakfast_provided, + 'lunch_provided': self.lunch_provided, + 'dinner_provided': self.dinner_provided, + 'full_day_rate': float(self.full_day_rate), + 'half_day_rate': float(self.half_day_rate), + 'breakfast_deduction': float(self.breakfast_deduction) if self.breakfast_deduction else None, + 'lunch_deduction': float(self.lunch_deduction) if self.lunch_deduction else None, + 'dinner_deduction': float(self.dinner_deduction) if self.dinner_deduction else None, + 'calculated_amount': float(self.calculated_amount), + 'currency_code': self.currency_code, + 'status': self.status, + 'approved_by': self.approved_by, + 'approved_at': self.approved_at.isoformat() if self.approved_at else None, + 'rejection_reason': self.rejection_reason, + 'reimbursed': self.reimbursed, + 'reimbursed_at': self.reimbursed_at.isoformat() if self.reimbursed_at else None, + 'notes': self.notes, + 'created_at': self.created_at.isoformat() if self.created_at else None, + 'updated_at': self.updated_at.isoformat() if self.updated_at else None, + 'user': self.user.username if self.user else None, + 'project': self.project.name if self.project else None, + 'client': self.client.name if self.client else None, + 'approver': self.approver.username if self.approver else None + } + + @classmethod + def calculate_days_from_dates(cls, start_date, end_date, departure_time=None, return_time=None): + """ + Calculate full and half days based on departure and return times. + + Rules: + - Departure before 12:00 = full day + - Departure after 12:00 = half day + - Return after 12:00 = full day + - Return before 12:00 = half day + - Middle days = full days + """ + from datetime import time as dt_time + + if start_date > end_date: + return {'full_days': 0, 'half_days': 0} + + trip_days = (end_date - start_date).days + 1 + + if trip_days == 1: + # Single day trip + if departure_time and return_time: + # Check if it qualifies for a full day (>= 8 hours) + departure_datetime = datetime.combine(start_date, departure_time) + return_datetime = datetime.combine(end_date, return_time) + hours = (return_datetime - departure_datetime).total_seconds() / 3600 + + if hours >= 8: + return {'full_days': 1, 'half_days': 0} + else: + return {'full_days': 0, 'half_days': 1} + else: + # Default to half day for single day + return {'full_days': 0, 'half_days': 1} + + full_days = 0 + half_days = 0 + + # First day + noon = dt_time(12, 0) + if departure_time and departure_time < noon: + full_days += 1 + else: + half_days += 1 + + # Middle days (all full days) + if trip_days > 2: + full_days += (trip_days - 2) + + # Last day + if return_time and return_time >= noon: + full_days += 1 + else: + half_days += 1 + + return {'full_days': full_days, 'half_days': half_days} + + @classmethod + def get_pending_approvals(cls, user_id=None): + """Get per diem claims pending approval""" + query = cls.query.filter_by(status='pending') + + if user_id: + query = query.filter(cls.user_id == user_id) + + return query.order_by(cls.start_date.desc()).all() + diff --git a/app/models/task.py b/app/models/task.py index 852c9b4b..0adb75c2 100644 --- a/app/models/task.py +++ b/app/models/task.py @@ -26,7 +26,7 @@ class Task(db.Model): # project relationship is defined via backref in Project model assigned_user = db.relationship('User', foreign_keys=[assigned_to], backref='assigned_tasks') creator = db.relationship('User', foreign_keys=[created_by], backref='created_tasks') - time_entries = db.relationship('TimeEntry', backref='task', lazy='dynamic', cascade='all, delete-orphan') + time_entries = db.relationship('TimeEntry', backref='task', lazy='dynamic') # comments relationship is defined via backref in Comment model def __init__(self, project_id, name, description=None, priority='medium', estimated_hours=None, diff --git a/app/routes/admin.py b/app/routes/admin.py index 400bbd3c..f52f3d98 100644 --- a/app/routes/admin.py +++ b/app/routes/admin.py @@ -396,6 +396,7 @@ def pdf_layout(): html_template = request.form.get('invoice_pdf_template_html', '') css_template = request.form.get('invoice_pdf_template_css', '') design_json = request.form.get('design_json', '') + settings_obj.invoice_pdf_template_html = html_template settings_obj.invoice_pdf_template_css = css_template settings_obj.invoice_pdf_design_json = design_json @@ -437,6 +438,7 @@ def pdf_layout(): def pdf_layout_reset(): """Reset PDF layout to defaults (clear custom templates).""" settings_obj = Settings.get_settings() + settings_obj.invoice_pdf_template_html = '' settings_obj.invoice_pdf_template_css = '' settings_obj.invoice_pdf_design_json = '' @@ -447,6 +449,55 @@ def pdf_layout_reset(): return redirect(url_for('admin.pdf_layout')) +@admin_bp.route('/admin/pdf-layout/debug', methods=['GET']) +@login_required +@admin_or_permission_required('manage_settings') +def pdf_layout_debug(): + """Debug endpoint to show what's saved in the database""" + settings_obj = Settings.get_settings() + + html = settings_obj.invoice_pdf_template_html or '' + css = settings_obj.invoice_pdf_template_css or '' + design_json = settings_obj.invoice_pdf_design_json or '' + + # Check for bugs + has_all_bug = 'invoice.items.all()' in html + has_if_bug = 'invoice.items and invoice.items.all()' in html + + # Get invoice info for testing + from app.models import Invoice + test_invoice = Invoice.query.order_by(Invoice.id.desc()).first() + + debug_info = { + 'saved_template': { + 'html_length': len(html), + 'css_length': len(css), + 'design_json_length': len(design_json), + 'has_html': bool(html), + 'has_bugs': has_all_bug or has_if_bug, + 'bugs_found': [] + }, + 'test_invoice': { + 'exists': test_invoice is not None, + 'invoice_number': test_invoice.invoice_number if test_invoice else None, + 'items_count': test_invoice.items.count() if test_invoice else 0, + } + } + + if has_all_bug: + debug_info['saved_template']['bugs_found'].append('invoice.items.all() found in template') + if has_if_bug: + debug_info['saved_template']['bugs_found'].append('invoice.items and invoice.items.all() found in template') + + # Show snippets of problematic code + if has_all_bug or has_if_bug: + import re + matches = re.finditer(r'.{0,50}invoice\.items\.all\(\).{0,50}', html) + debug_info['saved_template']['bug_snippets'] = [m.group() for m in matches] + + return jsonify(debug_info) + + @admin_bp.route('/admin/pdf-layout/default', methods=['GET']) @login_required @admin_or_permission_required('manage_settings') @@ -516,21 +567,77 @@ def pdf_layout_preview(): ) # Ensure at least one sample item to avoid undefined 'item' in templates that reference it outside loops sample_item = SimpleNamespace(description='Sample item', quantity=1.0, unit_price=0.0, total_amount=0.0, time_entry_ids='') - try: - if not getattr(invoice, 'items', None): - invoice.items = [sample_item] - except Exception: + + # Create a wrapper object with converted Query objects to lists + # We can't modify SQLAlchemy model attributes directly, so we create a wrapper + invoice_wrapper = SimpleNamespace() + + # Copy all simple attributes from the invoice + for attr in ['id', 'invoice_number', 'project_id', 'client_name', 'client_email', + 'client_address', 'client_id', 'issue_date', 'due_date', 'status', + 'subtotal', 'tax_rate', 'tax_amount', 'total_amount', 'currency_code', + 'notes', 'terms', 'payment_date', 'payment_method', 'payment_reference', + 'payment_notes', 'amount_paid', 'payment_status', 'created_by', + 'created_at', 'updated_at']: try: - invoice.items = [sample_item] - except Exception: + setattr(invoice_wrapper, attr, getattr(invoice, attr)) + except AttributeError: pass - # Ensure extra_goods attribute exists + # Copy relationship attributes (project, client) try: - if not hasattr(invoice, 'extra_goods'): - invoice.extra_goods = [] + invoice_wrapper.project = invoice.project + except: + invoice_wrapper.project = SimpleNamespace(name='Sample Project', description='') + + try: + invoice_wrapper.client = invoice.client + except: + invoice_wrapper.client = None + + # Convert items from Query to list + try: + if hasattr(invoice, 'items') and hasattr(invoice.items, 'all'): + # It's a SQLAlchemy Query object - call .all() to get list + items_list = invoice.items.all() + if not items_list: + # No items in database, add sample + items_list = [sample_item] + invoice_wrapper.items = items_list + elif hasattr(invoice, 'items') and isinstance(invoice.items, list): + # Already a list + invoice_wrapper.items = invoice.items if invoice.items else [sample_item] + else: + # Fallback + invoice_wrapper.items = [sample_item] + except Exception as e: + print(f"Error converting invoice items: {e}") + invoice_wrapper.items = [sample_item] + + # Convert extra_goods from Query to list + try: + if hasattr(invoice, 'extra_goods') and hasattr(invoice.extra_goods, 'all'): + invoice_wrapper.extra_goods = invoice.extra_goods.all() + elif hasattr(invoice, 'extra_goods') and isinstance(invoice.extra_goods, list): + invoice_wrapper.extra_goods = invoice.extra_goods + else: + invoice_wrapper.extra_goods = [] except Exception: - pass + invoice_wrapper.extra_goods = [] + + # Convert expenses from Query to list + try: + if hasattr(invoice, 'expenses') and hasattr(invoice.expenses, 'all'): + invoice_wrapper.expenses = invoice.expenses.all() + elif hasattr(invoice, 'expenses') and isinstance(invoice.expenses, list): + invoice_wrapper.expenses = invoice.expenses + else: + invoice_wrapper.expenses = [] + except Exception: + invoice_wrapper.expenses = [] + + # Use the wrapper instead of the original invoice + invoice = invoice_wrapper # Helper: sanitize Jinja blocks to fix entities/smart quotes inserted by editor def _sanitize_jinja_blocks(raw: str) -> str: try: @@ -624,7 +731,9 @@ def pdf_layout_preview(): item=sample_item, ) except Exception as e: - body_html = f"
Template error: {str(e)}
" + sanitized + import traceback + error_details = traceback.format_exc() + body_html = f"

Template error:

{str(e)}
{error_details}
" + sanitized # Build complete HTML page with embedded styles page_html = f""" diff --git a/app/routes/api.py b/app/routes/api.py index fb9de7b3..3822b2c6 100644 --- a/app/routes/api.py +++ b/app/routes/api.py @@ -1298,6 +1298,145 @@ def serve_editor_image(filename): folder = get_editor_upload_folder() return send_from_directory(folder, filename) +# ================================ +# Activity Feed API +# ================================ + +@api_bp.route('/api/activities') +@login_required +def get_activities(): + """Get recent activities with filtering""" + from app.models import Activity + from sqlalchemy import and_ + + # Get query parameters + limit = request.args.get('limit', 50, type=int) + page = request.args.get('page', 1, type=int) + user_id = request.args.get('user_id', type=int) + entity_type = request.args.get('entity_type', '').strip() + action = request.args.get('action', '').strip() + start_date = request.args.get('start_date', '').strip() + end_date = request.args.get('end_date', '').strip() + + # Build query + query = Activity.query + + # Filter by user (admins can see all, users see only their own) + if not current_user.is_admin: + query = query.filter_by(user_id=current_user.id) + elif user_id: + query = query.filter_by(user_id=user_id) + + # Filter by entity type + if entity_type: + query = query.filter_by(entity_type=entity_type) + + # Filter by action + if action: + query = query.filter_by(action=action) + + # Filter by date range + if start_date: + try: + start_dt = datetime.fromisoformat(start_date) + query = query.filter(Activity.created_at >= start_dt) + except ValueError: + pass + + if end_date: + try: + end_dt = datetime.fromisoformat(end_date) + query = query.filter(Activity.created_at <= end_dt) + except ValueError: + pass + + # Get total count + total = query.count() + + # Apply ordering and pagination + activities = query.order_by(Activity.created_at.desc()).paginate( + page=page, + per_page=limit, + error_out=False + ) + + return jsonify({ + 'activities': [a.to_dict() for a in activities.items], + 'total': total, + 'pages': activities.pages, + 'current_page': activities.page, + 'has_next': activities.has_next, + 'has_prev': activities.has_prev + }) + +@api_bp.route('/api/activities/stats') +@login_required +def get_activity_stats(): + """Get activity statistics""" + from app.models import Activity + from sqlalchemy import func + + # Get date range (default to last 7 days) + days = request.args.get('days', 7, type=int) + since = datetime.utcnow() - timedelta(days=days) + + # Build base query + query = Activity.query.filter(Activity.created_at >= since) + + # Filter by user if not admin + if not current_user.is_admin: + query = query.filter_by(user_id=current_user.id) + + # Get counts by entity type + entity_counts = db.session.query( + Activity.entity_type, + func.count(Activity.id).label('count') + ).filter(Activity.created_at >= since) + + if not current_user.is_admin: + entity_counts = entity_counts.filter_by(user_id=current_user.id) + + entity_counts = entity_counts.group_by(Activity.entity_type).all() + + # Get counts by action + action_counts = db.session.query( + Activity.action, + func.count(Activity.id).label('count') + ).filter(Activity.created_at >= since) + + if not current_user.is_admin: + action_counts = action_counts.filter_by(user_id=current_user.id) + + action_counts = action_counts.group_by(Activity.action).all() + + # Get most active users (admins only) + user_activity = [] + if current_user.is_admin: + user_activity = db.session.query( + User.username, + User.display_name, + func.count(Activity.id).label('count') + ).join( + Activity, User.id == Activity.user_id + ).filter( + Activity.created_at >= since + ).group_by( + User.id, User.username, User.display_name + ).order_by( + func.count(Activity.id).desc() + ).limit(10).all() + + return jsonify({ + 'total_activities': query.count(), + 'entity_counts': {entity: count for entity, count in entity_counts}, + 'action_counts': {action: count for action, count in action_counts}, + 'user_activity': [ + {'username': u[0], 'display_name': u[1], 'count': u[2]} + for u in user_activity + ], + 'period_days': days + }) + # WebSocket event handlers @socketio.on('connect') def handle_connect(): diff --git a/app/routes/budget_alerts.py b/app/routes/budget_alerts.py new file mode 100644 index 00000000..2595850b --- /dev/null +++ b/app/routes/budget_alerts.py @@ -0,0 +1,458 @@ +""" +Budget Alerts Routes + +This module provides API endpoints for managing budget alerts and forecasting. +""" + +from flask import Blueprint, render_template, request, jsonify, flash, redirect, url_for +from flask_login import login_required, current_user +from app import db, log_event, track_event +from app.models import Project, BudgetAlert, User +from app.utils.budget_forecasting import ( + calculate_burn_rate, + estimate_completion_date, + analyze_resource_allocation, + analyze_cost_trends, + get_budget_status, + check_budget_alerts +) +from datetime import datetime, timedelta +from sqlalchemy import func + +budget_alerts_bp = Blueprint('budget_alerts', __name__) + + +@budget_alerts_bp.route('/budget/dashboard') +@login_required +def budget_dashboard(): + """Budget alerts and forecasting dashboard""" + # Get projects with budgets + if current_user.is_admin: + projects = Project.query.filter( + Project.budget_amount.isnot(None), + Project.status == 'active' + ).order_by(Project.name).all() + else: + # For non-admin users, show only 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] + + projects = Project.query.filter( + Project.id.in_(user_project_ids), + Project.budget_amount.isnot(None), + Project.status == 'active' + ).order_by(Project.name).all() + + # Get budget status for each project + project_budgets = [] + for project in projects: + budget_status = get_budget_status(project.id) + if budget_status: + project_budgets.append(budget_status) + + # Get active alerts + if current_user.is_admin: + active_alerts = BudgetAlert.get_active_alerts(acknowledged=False) + else: + # For non-admin, get alerts for their projects + active_alerts = BudgetAlert.query.filter( + BudgetAlert.is_acknowledged == False, + BudgetAlert.project_id.in_(user_project_ids) + ).order_by(BudgetAlert.created_at.desc()).all() + + # Get alert statistics + alert_stats = { + 'total_unacknowledged': len(active_alerts), + 'critical_alerts': len([a for a in active_alerts if a.alert_level == 'critical']), + 'warning_alerts': len([a for a in active_alerts if a.alert_level == 'warning']), + } + + log_event('budget_dashboard_viewed', user_id=current_user.id) + + return render_template('budget/dashboard.html', + projects=project_budgets, + active_alerts=active_alerts, + alert_stats=alert_stats) + + +@budget_alerts_bp.route('/api/budget/burn-rate/') +@login_required +def get_burn_rate(project_id): + """Get burn rate for a project""" + project = Project.query.get_or_404(project_id) + + # Check permissions + if not current_user.is_admin: + # Check if user has worked on this project + from app.models import TimeEntry + has_access = TimeEntry.query.filter_by( + project_id=project_id, + user_id=current_user.id + ).first() is not None + + if not has_access: + return jsonify({'error': 'Access denied'}), 403 + + days = request.args.get('days', 30, type=int) + burn_rate = calculate_burn_rate(project_id, days) + + if burn_rate is None: + return jsonify({'error': 'Project not found or no data available'}), 404 + + log_event('budget_burn_rate_viewed', user_id=current_user.id, project_id=project_id) + + return jsonify(burn_rate) + + +@budget_alerts_bp.route('/api/budget/completion-estimate/') +@login_required +def get_completion_estimate(project_id): + """Get estimated completion date for a project""" + project = Project.query.get_or_404(project_id) + + # Check permissions + if not current_user.is_admin: + from app.models import TimeEntry + has_access = TimeEntry.query.filter_by( + project_id=project_id, + user_id=current_user.id + ).first() is not None + + if not has_access: + return jsonify({'error': 'Access denied'}), 403 + + days = request.args.get('days', 30, type=int) + estimate = estimate_completion_date(project_id, days) + + if estimate is None: + return jsonify({'error': 'Project not found or no budget set'}), 404 + + log_event('budget_completion_estimate_viewed', user_id=current_user.id, project_id=project_id) + + return jsonify(estimate) + + +@budget_alerts_bp.route('/api/budget/resource-allocation/') +@login_required +def get_resource_allocation(project_id): + """Get resource allocation analysis for a project""" + project = Project.query.get_or_404(project_id) + + # Check permissions + if not current_user.is_admin: + from app.models import TimeEntry + has_access = TimeEntry.query.filter_by( + project_id=project_id, + user_id=current_user.id + ).first() is not None + + if not has_access: + return jsonify({'error': 'Access denied'}), 403 + + days = request.args.get('days', 30, type=int) + allocation = analyze_resource_allocation(project_id, days) + + if allocation is None: + return jsonify({'error': 'Project not found'}), 404 + + log_event('budget_resource_allocation_viewed', user_id=current_user.id, project_id=project_id) + + return jsonify(allocation) + + +@budget_alerts_bp.route('/api/budget/cost-trends/') +@login_required +def get_cost_trends(project_id): + """Get cost trend analysis for a project""" + project = Project.query.get_or_404(project_id) + + # Check permissions + if not current_user.is_admin: + from app.models import TimeEntry + has_access = TimeEntry.query.filter_by( + project_id=project_id, + user_id=current_user.id + ).first() is not None + + if not has_access: + return jsonify({'error': 'Access denied'}), 403 + + days = request.args.get('days', 90, type=int) + granularity = request.args.get('granularity', 'week') + + if granularity not in ['day', 'week', 'month']: + return jsonify({'error': 'Invalid granularity. Use day, week, or month'}), 400 + + trends = analyze_cost_trends(project_id, days, granularity) + + if trends is None: + return jsonify({'error': 'Project not found'}), 404 + + log_event('budget_cost_trends_viewed', user_id=current_user.id, project_id=project_id) + + return jsonify(trends) + + +@budget_alerts_bp.route('/api/budget/status/') +@login_required +def get_project_budget_status(project_id): + """Get comprehensive budget status for a project""" + project = Project.query.get_or_404(project_id) + + # Check permissions + if not current_user.is_admin: + from app.models import TimeEntry + has_access = TimeEntry.query.filter_by( + project_id=project_id, + user_id=current_user.id + ).first() is not None + + if not has_access: + return jsonify({'error': 'Access denied'}), 403 + + budget_status = get_budget_status(project_id) + + if budget_status is None: + return jsonify({'error': 'Project not found or no budget set'}), 404 + + return jsonify(budget_status) + + +@budget_alerts_bp.route('/api/budget/alerts') +@login_required +def get_alerts(): + """Get budget alerts""" + project_id = request.args.get('project_id', type=int) + acknowledged = request.args.get('acknowledged', 'false').lower() == 'true' + + if current_user.is_admin: + 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] + + query = BudgetAlert.query.filter( + BudgetAlert.is_acknowledged == acknowledged, + BudgetAlert.project_id.in_(user_project_ids) + ) + + if project_id: + query = query.filter_by(project_id=project_id) + + alerts = query.order_by(BudgetAlert.created_at.desc()).all() + + return jsonify({ + 'alerts': [alert.to_dict() for alert in alerts], + 'count': len(alerts) + }) + + +@budget_alerts_bp.route('/api/budget/alerts//acknowledge', methods=['POST']) +@login_required +def acknowledge_alert(alert_id): + """Acknowledge a budget alert""" + alert = BudgetAlert.query.get_or_404(alert_id) + + # Check permissions + if not current_user.is_admin: + from app.models import TimeEntry + has_access = TimeEntry.query.filter_by( + project_id=alert.project_id, + user_id=current_user.id + ).first() is not None + + if not has_access: + return jsonify({'error': 'Access denied'}), 403 + + if alert.is_acknowledged: + return jsonify({'message': 'Alert already acknowledged'}), 200 + + alert.acknowledge(current_user.id) + + log_event('budget_alert_acknowledged', user_id=current_user.id, + alert_id=alert_id, project_id=alert.project_id) + + return jsonify({ + 'message': 'Alert acknowledged successfully', + 'alert': alert.to_dict() + }) + + +@budget_alerts_bp.route('/api/budget/check-alerts/', methods=['POST']) +@login_required +def check_project_alerts(project_id): + """Manually check and create alerts for a project (admin only)""" + if not current_user.is_admin: + return jsonify({'error': 'Admin access required'}), 403 + + project = Project.query.get_or_404(project_id) + + alerts_to_create = check_budget_alerts(project_id) + + created_alerts = [] + for alert_data in alerts_to_create: + alert = BudgetAlert.create_alert( + project_id=alert_data['project_id'], + alert_type=alert_data['type'], + budget_consumed_percent=alert_data['budget_consumed_percent'], + budget_amount=alert_data['budget_amount'], + consumed_amount=alert_data['consumed_amount'] + ) + created_alerts.append(alert.to_dict()) + + log_event('budget_alerts_checked', user_id=current_user.id, project_id=project_id) + + return jsonify({ + 'message': f'Checked alerts for project {project.name}', + 'alerts_created': len(created_alerts), + 'alerts': created_alerts + }) + + +@budget_alerts_bp.route('/budget/project/') +@login_required +def project_budget_detail(project_id): + """Detailed budget view for a specific project""" + project = Project.query.get_or_404(project_id) + + # Check permissions + if not current_user.is_admin: + from app.models import TimeEntry + has_access = TimeEntry.query.filter_by( + project_id=project_id, + user_id=current_user.id + ).first() is not None + + if not has_access: + flash('You do not have access to this project.', 'error') + return redirect(url_for('budget_alerts.budget_dashboard')) + + # Get budget status + budget_status = get_budget_status(project_id) + + if not budget_status: + flash('This project does not have a budget set.', 'warning') + return redirect(url_for('budget_alerts.budget_dashboard')) + + # Get burn rate + burn_rate = calculate_burn_rate(project_id, 30) + + # Get completion estimate + completion_estimate = estimate_completion_date(project_id, 30) + + # Get resource allocation + resource_allocation = analyze_resource_allocation(project_id, 30) + + # Get cost trends + cost_trends = analyze_cost_trends(project_id, 90, 'week') + + # Get alerts for this project + alerts = BudgetAlert.query.filter_by( + project_id=project_id, + is_acknowledged=False + ).order_by(BudgetAlert.created_at.desc()).all() + + log_event('project_budget_detail_viewed', user_id=current_user.id, project_id=project_id) + + return render_template('budget/project_detail.html', + project=project, + budget_status=budget_status, + burn_rate=burn_rate, + completion_estimate=completion_estimate, + resource_allocation=resource_allocation, + cost_trends=cost_trends, + alerts=alerts) + + +@budget_alerts_bp.route('/api/budget/summary') +@login_required +def get_budget_summary(): + """Get summary of all budget alerts and project statuses""" + if current_user.is_admin: + 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] + + projects = Project.query.filter( + Project.id.in_(user_project_ids), + Project.budget_amount.isnot(None), + Project.status == 'active' + ).all() + + summary = { + 'total_projects': len(projects), + 'healthy': 0, + 'warning': 0, + 'critical': 0, + 'over_budget': 0, + 'total_budget': 0, + 'total_consumed': 0, + 'projects': [] + } + + for project in projects: + budget_status = get_budget_status(project.id) + if budget_status: + summary['total_budget'] += budget_status['budget_amount'] + summary['total_consumed'] += budget_status['consumed_amount'] + summary[budget_status['status']] += 1 + summary['projects'].append(budget_status) + + # Get alert statistics + 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] + + total_alerts = BudgetAlert.query.filter( + BudgetAlert.project_id.in_(user_project_ids) + ).count() + + unacknowledged_alerts = BudgetAlert.query.filter( + BudgetAlert.project_id.in_(user_project_ids), + BudgetAlert.is_acknowledged == False + ).count() + + critical_alerts = BudgetAlert.query.filter( + BudgetAlert.project_id.in_(user_project_ids), + BudgetAlert.alert_level == 'critical', + BudgetAlert.is_acknowledged == False + ).count() + + alert_stats = { + 'total_alerts': total_alerts, + 'unacknowledged_alerts': unacknowledged_alerts, + 'critical_alerts': critical_alerts + } + + summary['alert_stats'] = alert_stats + + return jsonify(summary) + diff --git a/app/routes/clients.py b/app/routes/clients.py index e42e6cff..58c2e385 100644 --- a/app/routes/clients.py +++ b/app/routes/clients.py @@ -1,4 +1,4 @@ -from flask import Blueprint, render_template, request, redirect, url_for, flash, current_app, jsonify +from flask import Blueprint, render_template, request, redirect, url_for, flash, current_app, jsonify, Response from flask_babel import gettext as _ from flask_login import login_required, current_user import app as app_module @@ -8,6 +8,8 @@ from datetime import datetime from decimal import Decimal from app.utils.db import safe_commit from app.utils.permissions import admin_or_permission_required +import csv +import io clients_bp = Blueprint('clients', __name__) @@ -431,6 +433,82 @@ def bulk_status_change(): return redirect(url_for('clients.list_clients')) +@clients_bp.route('/clients/export') +@login_required +def export_clients(): + """Export clients to CSV""" + status = request.args.get('status', 'active') + search = request.args.get('search', '').strip() + + query = Client.query + if status == 'active': + query = query.filter_by(status='active') + elif status == 'inactive': + query = query.filter_by(status='inactive') + + if search: + like = f"%{search}%" + query = query.filter( + db.or_( + Client.name.ilike(like), + Client.description.ilike(like), + Client.contact_person.ilike(like), + Client.email.ilike(like) + ) + ) + + clients = query.order_by(Client.name).all() + + # Create CSV in memory + output = io.StringIO() + writer = csv.writer(output) + + # Write header + writer.writerow([ + 'ID', + 'Name', + 'Description', + 'Contact Person', + 'Email', + 'Phone', + 'Address', + 'Default Hourly Rate', + 'Status', + 'Active Projects', + 'Total Projects', + 'Created At', + 'Updated At' + ]) + + # Write client data + for client in clients: + writer.writerow([ + client.id, + client.name, + client.description or '', + client.contact_person or '', + client.email or '', + client.phone or '', + client.address or '', + client.default_hourly_rate or '', + client.status, + client.active_projects, + client.total_projects, + client.created_at.strftime('%Y-%m-%d %H:%M:%S') if client.created_at else '', + client.updated_at.strftime('%Y-%m-%d %H:%M:%S') if client.updated_at else '' + ]) + + # Create response + output.seek(0) + return Response( + output.getvalue(), + mimetype='text/csv', + headers={ + 'Content-Disposition': f'attachment; filename=clients_export_{datetime.now().strftime("%Y%m%d_%H%M%S")}.csv' + } + ) + + @clients_bp.route('/api/clients') @login_required def api_clients(): diff --git a/app/routes/expense_categories.py b/app/routes/expense_categories.py new file mode 100644 index 00000000..6be092b6 --- /dev/null +++ b/app/routes/expense_categories.py @@ -0,0 +1,253 @@ +from flask import Blueprint, render_template, request, redirect, url_for, flash, jsonify +from flask_babel import gettext as _ +from flask_login import login_required, current_user +from app import db, log_event, track_event +from app.models import ExpenseCategory +from datetime import datetime, date +from decimal import Decimal +from app.utils.db import safe_commit +from app.utils.permissions import admin_or_permission_required + +expense_categories_bp = Blueprint('expense_categories', __name__) + + +@expense_categories_bp.route('/expense-categories') +@login_required +@admin_or_permission_required('expense_categories.view') +def list_categories(): + """List all expense categories""" + from app import track_page_view + track_page_view("expense_categories_list") + + categories = ExpenseCategory.query.order_by(ExpenseCategory.name).all() + + # Get budget utilization for each category + for category in categories: + category.monthly_utilization = category.get_budget_utilization('monthly') + category.yearly_utilization = category.get_budget_utilization('yearly') + + return render_template('expense_categories/list.html', categories=categories) + + +@expense_categories_bp.route('/expense-categories/create', methods=['GET', 'POST']) +@login_required +@admin_or_permission_required('expense_categories.create') +def create_category(): + """Create a new expense category""" + if request.method == 'GET': + return render_template('expense_categories/form.html', category=None) + + try: + # Get form data + name = request.form.get('name', '').strip() + description = request.form.get('description', '').strip() + code = request.form.get('code', '').strip() + color = request.form.get('color', '').strip() + icon = request.form.get('icon', '').strip() + + # Validate required fields + if not name: + flash(_('Category name is required'), 'error') + return redirect(url_for('expense_categories.create_category')) + + # Budget fields + monthly_budget = request.form.get('monthly_budget', '').strip() + quarterly_budget = request.form.get('quarterly_budget', '').strip() + yearly_budget = request.form.get('yearly_budget', '').strip() + budget_threshold_percent = request.form.get('budget_threshold_percent', '80') + + # Settings + requires_receipt = request.form.get('requires_receipt') == 'on' + requires_approval = request.form.get('requires_approval') == 'on' + default_tax_rate = request.form.get('default_tax_rate', '').strip() + is_active = request.form.get('is_active') == 'on' + + # Create category + category = ExpenseCategory( + name=name, + description=description, + code=code if code else None, + color=color if color else None, + icon=icon if icon else None, + monthly_budget=Decimal(monthly_budget) if monthly_budget else None, + quarterly_budget=Decimal(quarterly_budget) if quarterly_budget else None, + yearly_budget=Decimal(yearly_budget) if yearly_budget else None, + budget_threshold_percent=int(budget_threshold_percent) if budget_threshold_percent else 80, + requires_receipt=requires_receipt, + requires_approval=requires_approval, + default_tax_rate=Decimal(default_tax_rate) if default_tax_rate else None, + is_active=is_active + ) + + db.session.add(category) + + if safe_commit(db): + flash(_('Expense category created successfully'), 'success') + log_event('expense_category_created', user_id=current_user.id, category_id=category.id) + track_event(current_user.id, 'expense_category.created', {'category_id': category.id}) + return redirect(url_for('expense_categories.list_categories')) + else: + flash(_('Error creating expense category'), 'error') + return redirect(url_for('expense_categories.create_category')) + + except Exception as e: + from flask import current_app + current_app.logger.error(f"Error creating expense category: {e}") + flash(_('Error creating expense category'), 'error') + return redirect(url_for('expense_categories.create_category')) + + +@expense_categories_bp.route('/expense-categories/') +@login_required +@admin_or_permission_required('expense_categories.view') +def view_category(category_id): + """View expense category details""" + category = ExpenseCategory.query.get_or_404(category_id) + + from app import track_page_view + track_page_view("expense_category_detail", properties={'category_id': category_id}) + + # Get budget utilization + monthly_util = category.get_budget_utilization('monthly') + quarterly_util = category.get_budget_utilization('quarterly') + yearly_util = category.get_budget_utilization('yearly') + + return render_template( + 'expense_categories/view.html', + category=category, + monthly_utilization=monthly_util, + quarterly_utilization=quarterly_util, + yearly_utilization=yearly_util + ) + + +@expense_categories_bp.route('/expense-categories//edit', methods=['GET', 'POST']) +@login_required +@admin_or_permission_required('expense_categories.update') +def edit_category(category_id): + """Edit an expense category""" + category = ExpenseCategory.query.get_or_404(category_id) + + if request.method == 'GET': + return render_template('expense_categories/form.html', category=category) + + try: + # Get form data + name = request.form.get('name', '').strip() + if not name: + flash(_('Category name is required'), 'error') + return redirect(url_for('expense_categories.edit_category', category_id=category_id)) + + # Update category fields + category.name = name + category.description = request.form.get('description', '').strip() + category.code = request.form.get('code', '').strip() or None + category.color = request.form.get('color', '').strip() or None + category.icon = request.form.get('icon', '').strip() or None + + # Budget fields + monthly_budget = request.form.get('monthly_budget', '').strip() + quarterly_budget = request.form.get('quarterly_budget', '').strip() + yearly_budget = request.form.get('yearly_budget', '').strip() + + category.monthly_budget = Decimal(monthly_budget) if monthly_budget else None + category.quarterly_budget = Decimal(quarterly_budget) if quarterly_budget else None + category.yearly_budget = Decimal(yearly_budget) if yearly_budget else None + category.budget_threshold_percent = int(request.form.get('budget_threshold_percent', '80')) + + # Settings + category.requires_receipt = request.form.get('requires_receipt') == 'on' + category.requires_approval = request.form.get('requires_approval') == 'on' + + default_tax_rate = request.form.get('default_tax_rate', '').strip() + category.default_tax_rate = Decimal(default_tax_rate) if default_tax_rate else None + category.is_active = request.form.get('is_active') == 'on' + + category.updated_at = datetime.utcnow() + + if safe_commit(db): + flash(_('Expense category updated successfully'), 'success') + log_event('expense_category_updated', user_id=current_user.id, category_id=category.id) + track_event(current_user.id, 'expense_category.updated', {'category_id': category.id}) + return redirect(url_for('expense_categories.view_category', category_id=category.id)) + else: + flash(_('Error updating expense category'), 'error') + return redirect(url_for('expense_categories.edit_category', category_id=category_id)) + + except Exception as e: + from flask import current_app + current_app.logger.error(f"Error updating expense category: {e}") + flash(_('Error updating expense category'), 'error') + return redirect(url_for('expense_categories.edit_category', category_id=category_id)) + + +@expense_categories_bp.route('/expense-categories//delete', methods=['POST']) +@login_required +@admin_or_permission_required('expense_categories.delete') +def delete_category(category_id): + """Delete an expense category""" + category = ExpenseCategory.query.get_or_404(category_id) + + try: + # Instead of deleting, just deactivate + category.is_active = False + category.updated_at = datetime.utcnow() + + if safe_commit(db): + flash(_('Expense category deactivated successfully'), 'success') + log_event('expense_category_deleted', user_id=current_user.id, category_id=category_id) + track_event(current_user.id, 'expense_category.deleted', {'category_id': category_id}) + else: + flash(_('Error deactivating expense category'), 'error') + + except Exception as e: + from flask import current_app + current_app.logger.error(f"Error deactivating expense category: {e}") + flash(_('Error deactivating expense category'), 'error') + + return redirect(url_for('expense_categories.list_categories')) + + +# API endpoints +@expense_categories_bp.route('/api/expense-categories', methods=['GET']) +@login_required +def api_list_categories(): + """API endpoint to list expense categories""" + categories = ExpenseCategory.get_active_categories() + + return jsonify({ + 'categories': [category.to_dict() for category in categories], + 'count': len(categories) + }) + + +@expense_categories_bp.route('/api/expense-categories/', methods=['GET']) +@login_required +def api_get_category(category_id): + """API endpoint to get a single expense category""" + category = ExpenseCategory.query.get_or_404(category_id) + + return jsonify(category.to_dict()) + + +@expense_categories_bp.route('/api/expense-categories/budget-alerts', methods=['GET']) +@login_required +@admin_or_permission_required('expense_categories.view') +def api_budget_alerts(): + """API endpoint to get categories over budget threshold""" + period = request.args.get('period', 'monthly') + + over_budget = ExpenseCategory.get_categories_over_budget(period) + + return jsonify({ + 'period': period, + 'alerts': [ + { + 'category': item['category'].to_dict(), + 'utilization': item['utilization'] + } + for item in over_budget + ], + 'count': len(over_budget) + }) + diff --git a/app/routes/expenses.py b/app/routes/expenses.py index f4d73901..81c1eac5 100644 --- a/app/routes/expenses.py +++ b/app/routes/expenses.py @@ -6,10 +6,12 @@ from app.models import Expense, Project, Client, User from datetime import datetime, date, timedelta from decimal import Decimal from app.utils.db import safe_commit +from app.utils.ocr import scan_receipt, get_suggested_expense_data, is_ocr_available import csv import io import os from werkzeug.utils import secure_filename +import json expenses_bp = Blueprint('expenses', __name__) @@ -883,3 +885,264 @@ def api_get_expense(expense_id): return jsonify(expense.to_dict()) + +@expenses_bp.route('/api/expenses/scan-receipt', methods=['POST']) +@login_required +def api_scan_receipt(): + """API endpoint to scan a receipt image using OCR""" + if not is_ocr_available(): + return jsonify({ + 'error': 'OCR not available', + 'message': 'Please install Tesseract OCR and pytesseract' + }), 503 + + # Check if file is in request + if 'receipt_file' not in request.files: + return jsonify({'error': 'No file provided'}), 400 + + file = request.files['receipt_file'] + + if not file or not file.filename: + return jsonify({'error': 'No file selected'}), 400 + + if not allowed_file(file.filename): + return jsonify({'error': 'Invalid file type'}), 400 + + try: + # Save file temporarily + filename = secure_filename(file.filename) + timestamp = datetime.now().strftime('%Y%m%d_%H%M%S') + filename = f"temp_{timestamp}_{filename}" + + temp_dir = os.path.join(current_app.root_path, '..', 'uploads', 'temp') + os.makedirs(temp_dir, exist_ok=True) + + temp_path = os.path.join(temp_dir, filename) + file.save(temp_path) + + # Scan receipt + ocr_lang = request.form.get('lang', 'eng') + receipt_data = scan_receipt(temp_path, lang=ocr_lang) + + # Get suggested expense data + suggestions = get_suggested_expense_data(receipt_data) + + # Clean up temp file + try: + os.remove(temp_path) + except Exception: + pass + + # Log event + log_event('receipt_scanned', user_id=current_user.id) + track_event(current_user.id, 'receipt.scanned', { + 'has_amount': bool(receipt_data.get('total')), + 'has_vendor': bool(receipt_data.get('vendor')), + 'has_date': bool(receipt_data.get('date')) + }) + + return jsonify({ + 'success': True, + 'receipt_data': receipt_data, + 'suggestions': suggestions + }) + + except Exception as e: + current_app.logger.error(f"Error scanning receipt: {e}") + return jsonify({ + 'error': 'Failed to scan receipt', + 'message': str(e) + }), 500 + + +@expenses_bp.route('/expenses/scan-receipt', methods=['GET', 'POST']) +@login_required +def scan_receipt_page(): + """Page for scanning receipts with OCR""" + if request.method == 'GET': + return render_template('expenses/scan_receipt.html', ocr_available=is_ocr_available()) + + # POST - handle receipt scanning + if not is_ocr_available(): + flash(_('OCR is not available. Please contact your administrator.'), 'error') + return redirect(url_for('expenses.scan_receipt_page')) + + if 'receipt_file' not in request.files: + flash(_('No file provided'), 'error') + return redirect(url_for('expenses.scan_receipt_page')) + + file = request.files['receipt_file'] + + if not file or not file.filename: + flash(_('No file selected'), 'error') + return redirect(url_for('expenses.scan_receipt_page')) + + if not allowed_file(file.filename): + flash(_('Invalid file type. Allowed types: png, jpg, jpeg, gif, pdf'), 'error') + return redirect(url_for('expenses.scan_receipt_page')) + + try: + # Save file temporarily + filename = secure_filename(file.filename) + timestamp = datetime.now().strftime('%Y%m%d_%H%M%S') + temp_filename = f"temp_{timestamp}_{filename}" + + temp_dir = os.path.join(current_app.root_path, '..', 'uploads', 'temp') + os.makedirs(temp_dir, exist_ok=True) + + temp_path = os.path.join(temp_dir, temp_filename) + file.save(temp_path) + + # Scan receipt + ocr_lang = request.form.get('lang', 'eng') + receipt_data = scan_receipt(temp_path, lang=ocr_lang) + + # Get suggested expense data + suggestions = get_suggested_expense_data(receipt_data) + + # Save receipt permanently + filename = f"{timestamp}_{filename}" + upload_dir = os.path.join(current_app.root_path, '..', UPLOAD_FOLDER) + os.makedirs(upload_dir, exist_ok=True) + + permanent_path = os.path.join(upload_dir, filename) + os.rename(temp_path, permanent_path) + + receipt_path = os.path.join(UPLOAD_FOLDER, filename) + + # Store OCR data in session for use in expense creation + from flask import session + session['scanned_receipt'] = { + 'receipt_path': receipt_path, + 'receipt_data': receipt_data, + 'suggestions': suggestions + } + + # Log event + log_event('receipt_scanned', user_id=current_user.id) + track_event(current_user.id, 'receipt.scanned', { + 'has_amount': bool(receipt_data.get('total')), + 'has_vendor': bool(receipt_data.get('vendor')), + 'has_date': bool(receipt_data.get('date')) + }) + + flash(_('Receipt scanned successfully! You can now create an expense with the extracted data.'), 'success') + return redirect(url_for('expenses.create_expense_from_scan')) + + except Exception as e: + current_app.logger.error(f"Error scanning receipt: {e}") + flash(_('Error scanning receipt. Please try again or enter the expense manually.'), 'error') + return redirect(url_for('expenses.scan_receipt_page')) + + +@expenses_bp.route('/expenses/create-from-scan', methods=['GET', 'POST']) +@login_required +def create_expense_from_scan(): + """Create expense from scanned receipt data""" + from flask import session + + scanned_data = session.get('scanned_receipt') + + if not scanned_data: + flash(_('No scanned receipt data found. Please scan a receipt first.'), 'error') + return redirect(url_for('expenses.scan_receipt_page')) + + if request.method == 'GET': + # Get data for form + projects = Project.query.filter_by(status='active').order_by(Project.name).all() + clients = Client.get_active_clients() + categories = Expense.get_expense_categories() + payment_methods = Expense.get_payment_methods() + + return render_template( + 'expenses/create_from_scan.html', + expense=None, + projects=projects, + clients=clients, + categories=categories, + payment_methods=payment_methods, + suggestions=scanned_data.get('suggestions', {}), + receipt_data=scanned_data.get('receipt_data', {}) + ) + + # POST - create the expense + try: + # Get form data (similar to create_expense) + title = request.form.get('title', '').strip() + description = request.form.get('description', '').strip() + category = request.form.get('category', '').strip() + amount = request.form.get('amount', '0').strip() + currency_code = request.form.get('currency_code', 'EUR').strip() + tax_amount = request.form.get('tax_amount', '0').strip() + expense_date = request.form.get('expense_date', '').strip() + + # Validate required fields + if not all([title, category, amount, expense_date]): + flash(_('Please fill in all required fields'), 'error') + return redirect(url_for('expenses.create_expense_from_scan')) + + # Parse date + try: + expense_date_obj = datetime.strptime(expense_date, '%Y-%m-%d').date() + except ValueError: + flash(_('Invalid date format'), 'error') + return redirect(url_for('expenses.create_expense_from_scan')) + + # Parse amounts + try: + amount_decimal = Decimal(amount) + tax_amount_decimal = Decimal(tax_amount) if tax_amount else Decimal('0') + except (ValueError, Decimal.InvalidOperation): + flash(_('Invalid amount format'), 'error') + return redirect(url_for('expenses.create_expense_from_scan')) + + # Create expense with OCR data + expense = Expense( + user_id=current_user.id, + title=title, + category=category, + amount=amount_decimal, + expense_date=expense_date_obj, + description=description, + currency_code=currency_code, + tax_amount=tax_amount_decimal, + project_id=request.form.get('project_id', type=int), + client_id=request.form.get('client_id', type=int), + payment_method=request.form.get('payment_method', '').strip(), + vendor=request.form.get('vendor', '').strip(), + receipt_number=request.form.get('receipt_number', '').strip(), + receipt_path=scanned_data.get('receipt_path'), + notes=request.form.get('notes', '').strip(), + tags=request.form.get('tags', '').strip(), + billable=request.form.get('billable') == 'on', + reimbursable=request.form.get('reimbursable') == 'on' + ) + + # Store OCR data as JSON + if scanned_data.get('receipt_data'): + # expense.ocr_data = json.dumps(scanned_data['receipt_data']) # Uncomment after migration + pass + + db.session.add(expense) + + if safe_commit(db): + # Clear scanned data from session + session.pop('scanned_receipt', None) + + flash(_('Expense created successfully from scanned receipt'), 'success') + log_event('expense_created_from_scan', user_id=current_user.id, expense_id=expense.id) + track_event(current_user.id, 'expense.created_from_scan', { + 'expense_id': expense.id, + 'category': category, + 'amount': float(amount_decimal) + }) + return redirect(url_for('expenses.view_expense', expense_id=expense.id)) + else: + flash(_('Error creating expense'), 'error') + return redirect(url_for('expenses.create_expense_from_scan')) + + except Exception as e: + current_app.logger.error(f"Error creating expense from scan: {e}") + flash(_('Error creating expense'), 'error') + return redirect(url_for('expenses.create_expense_from_scan')) + diff --git a/app/routes/import_export.py b/app/routes/import_export.py new file mode 100644 index 00000000..66cbdfc9 --- /dev/null +++ b/app/routes/import_export.py @@ -0,0 +1,650 @@ +""" +Import/Export routes for data migration and GDPR compliance +""" +from flask import Blueprint, jsonify, request, send_file, current_app, render_template +from flask_login import login_required, current_user +from werkzeug.utils import secure_filename +from app import db +from app.models import DataImport, DataExport, User +from app.utils.data_import import ( + import_csv_time_entries, + import_from_toggl, + import_from_harvest, + restore_from_backup, + ImportError as DataImportError +) +from app.utils.data_export import ( + export_user_data_gdpr, + export_filtered_data, + create_backup +) +from datetime import datetime, timedelta +import os +import json + +import_export_bp = Blueprint('import_export', __name__) + + +# ============================================================================ +# Import Routes +# ============================================================================ + +@import_export_bp.route('/import-export') +@login_required +def import_export_page(): + """Render the import/export page""" + return render_template('import_export/index.html') + + +@import_export_bp.route('/api/import/csv', methods=['POST']) +@login_required +def import_csv(): + """ + Import time entries from CSV file + + Expected multipart/form-data with 'file' field + """ + if 'file' not in request.files: + return jsonify({'error': 'No file provided'}), 400 + + file = request.files['file'] + + if file.filename == '': + return jsonify({'error': 'No file selected'}), 400 + + if not file.filename.endswith('.csv'): + return jsonify({'error': 'File must be a CSV'}), 400 + + try: + # Read file content + csv_content = file.read().decode('utf-8') + + # Create import record + import_record = DataImport( + user_id=current_user.id, + import_type='csv', + source_file=secure_filename(file.filename) + ) + db.session.add(import_record) + db.session.commit() + + # Perform import + summary = import_csv_time_entries( + user_id=current_user.id, + csv_content=csv_content, + import_record=import_record + ) + + return jsonify({ + 'success': True, + 'import_id': import_record.id, + 'summary': summary + }), 200 + + except DataImportError as e: + return jsonify({'error': str(e)}), 400 + except Exception as e: + current_app.logger.error(f"CSV import error: {str(e)}") + return jsonify({'error': 'Import failed. Please check the file format.'}), 500 + + +@import_export_bp.route('/api/import/toggl', methods=['POST']) +@login_required +def import_toggl(): + """ + Import time entries from Toggl Track + + Expected JSON body: + { + "api_token": "...", + "workspace_id": "...", + "start_date": "2024-01-01", + "end_date": "2024-12-31" + } + """ + data = request.get_json() + + if not data: + return jsonify({'error': 'No data provided'}), 400 + + api_token = data.get('api_token') + workspace_id = data.get('workspace_id') + start_date_str = data.get('start_date') + end_date_str = data.get('end_date') + + if not all([api_token, workspace_id, start_date_str, end_date_str]): + return jsonify({'error': 'Missing required fields'}), 400 + + try: + # Parse dates + start_date = datetime.strptime(start_date_str, '%Y-%m-%d') + end_date = datetime.strptime(end_date_str, '%Y-%m-%d') + + # Create import record + import_record = DataImport( + user_id=current_user.id, + import_type='toggl', + source_file=f'Toggl Workspace {workspace_id}' + ) + db.session.add(import_record) + db.session.commit() + + # Perform import + summary = import_from_toggl( + user_id=current_user.id, + api_token=api_token, + workspace_id=workspace_id, + start_date=start_date, + end_date=end_date, + import_record=import_record + ) + + return jsonify({ + 'success': True, + 'import_id': import_record.id, + 'summary': summary + }), 200 + + except DataImportError as e: + return jsonify({'error': str(e)}), 400 + except Exception as e: + current_app.logger.error(f"Toggl import error: {str(e)}") + return jsonify({'error': 'Import failed. Please check your credentials and try again.'}), 500 + + +@import_export_bp.route('/api/import/harvest', methods=['POST']) +@login_required +def import_harvest(): + """ + Import time entries from Harvest + + Expected JSON body: + { + "account_id": "...", + "api_token": "...", + "start_date": "2024-01-01", + "end_date": "2024-12-31" + } + """ + data = request.get_json() + + if not data: + return jsonify({'error': 'No data provided'}), 400 + + account_id = data.get('account_id') + api_token = data.get('api_token') + start_date_str = data.get('start_date') + end_date_str = data.get('end_date') + + if not all([account_id, api_token, start_date_str, end_date_str]): + return jsonify({'error': 'Missing required fields'}), 400 + + try: + # Parse dates + start_date = datetime.strptime(start_date_str, '%Y-%m-%d') + end_date = datetime.strptime(end_date_str, '%Y-%m-%d') + + # Create import record + import_record = DataImport( + user_id=current_user.id, + import_type='harvest', + source_file=f'Harvest Account {account_id}' + ) + db.session.add(import_record) + db.session.commit() + + # Perform import + summary = import_from_harvest( + user_id=current_user.id, + account_id=account_id, + api_token=api_token, + start_date=start_date, + end_date=end_date, + import_record=import_record + ) + + return jsonify({ + 'success': True, + 'import_id': import_record.id, + 'summary': summary + }), 200 + + except DataImportError as e: + return jsonify({'error': str(e)}), 400 + except Exception as e: + current_app.logger.error(f"Harvest import error: {str(e)}") + return jsonify({'error': 'Import failed. Please check your credentials and try again.'}), 500 + + +@import_export_bp.route('/api/import/status/') +@login_required +def import_status(import_id): + """Get status of an import operation""" + import_record = DataImport.query.get_or_404(import_id) + + # Check permissions + if not current_user.is_admin and import_record.user_id != current_user.id: + return jsonify({'error': 'Unauthorized'}), 403 + + return jsonify(import_record.to_dict()), 200 + + +@import_export_bp.route('/api/import/history') +@login_required +def import_history(): + """Get import history for current user""" + if current_user.is_admin: + imports = DataImport.query.order_by(DataImport.started_at.desc()).limit(50).all() + else: + imports = DataImport.query.filter_by(user_id=current_user.id).order_by( + DataImport.started_at.desc() + ).limit(50).all() + + return jsonify({ + 'imports': [imp.to_dict() for imp in imports] + }), 200 + + +# ============================================================================ +# Export Routes +# ============================================================================ + +@import_export_bp.route('/api/export/gdpr', methods=['POST']) +@login_required +def export_gdpr(): + """ + Export all user data for GDPR compliance + + Expected JSON body: + { + "format": "json" | "zip" + } + """ + data = request.get_json() or {} + export_format = data.get('format', 'json') + + if export_format not in ['json', 'zip']: + return jsonify({'error': 'Invalid format. Use "json" or "zip"'}), 400 + + try: + # Create export record + export_record = DataExport( + user_id=current_user.id, + export_type='gdpr', + export_format=export_format + ) + db.session.add(export_record) + db.session.commit() + + export_record.start_processing() + + # Perform export + result = export_user_data_gdpr( + user_id=current_user.id, + export_format=export_format + ) + + export_record.complete( + file_path=result['filepath'], + file_size=result['file_size'], + record_count=result['record_count'] + ) + + return jsonify({ + 'success': True, + 'export_id': export_record.id, + 'filename': result['filename'], + 'download_url': f'/api/export/download/{export_record.id}' + }), 200 + + except Exception as e: + current_app.logger.error(f"GDPR export error: {str(e)}") + if 'export_record' in locals(): + export_record.fail(str(e)) + return jsonify({'error': 'Export failed. Please try again.'}), 500 + + +@import_export_bp.route('/api/export/filtered', methods=['POST']) +@login_required +def export_filtered(): + """ + Export filtered data + + Expected JSON body: + { + "format": "json" | "csv", + "filters": { + "include_time_entries": true, + "include_projects": false, + "include_expenses": true, + "start_date": "2024-01-01", + "end_date": "2024-12-31", + "project_id": null, + "billable_only": false + } + } + """ + data = request.get_json() + + if not data: + return jsonify({'error': 'No data provided'}), 400 + + export_format = data.get('format', 'json') + filters = data.get('filters', {}) + + if export_format not in ['json', 'csv']: + return jsonify({'error': 'Invalid format. Use "json" or "csv"'}), 400 + + try: + # Create export record + export_record = DataExport( + user_id=current_user.id, + export_type='filtered', + export_format=export_format, + filters=filters + ) + db.session.add(export_record) + db.session.commit() + + export_record.start_processing() + + # Perform export + result = export_filtered_data( + user_id=current_user.id, + filters=filters, + export_format=export_format + ) + + export_record.complete( + file_path=result['filepath'], + file_size=result['file_size'], + record_count=result['record_count'] + ) + + return jsonify({ + 'success': True, + 'export_id': export_record.id, + 'filename': result['filename'], + 'download_url': f'/api/export/download/{export_record.id}' + }), 200 + + except Exception as e: + current_app.logger.error(f"Filtered export error: {str(e)}") + if 'export_record' in locals(): + export_record.fail(str(e)) + return jsonify({'error': 'Export failed. Please try again.'}), 500 + + +@import_export_bp.route('/api/export/backup', methods=['POST']) +@login_required +def export_backup(): + """ + Create a full database backup (admin only) + """ + if not current_user.is_admin: + return jsonify({'error': 'Admin access required'}), 403 + + try: + # Create export record + export_record = DataExport( + user_id=current_user.id, + export_type='backup', + export_format='json' + ) + db.session.add(export_record) + db.session.commit() + + export_record.start_processing() + + # Create backup + result = create_backup(user_id=current_user.id) + + export_record.complete( + file_path=result['filepath'], + file_size=result['file_size'], + record_count=result['record_count'] + ) + + return jsonify({ + 'success': True, + 'export_id': export_record.id, + 'filename': result['filename'], + 'download_url': f'/api/export/download/{export_record.id}' + }), 200 + + except Exception as e: + current_app.logger.error(f"Backup creation error: {str(e)}") + if 'export_record' in locals(): + export_record.fail(str(e)) + return jsonify({'error': 'Backup failed. Please try again.'}), 500 + + +@import_export_bp.route('/api/export/download/') +@login_required +def download_export(export_id): + """Download an export file""" + export_record = DataExport.query.get_or_404(export_id) + + # Check permissions + if not current_user.is_admin and export_record.user_id != current_user.id: + return jsonify({'error': 'Unauthorized'}), 403 + + # Check if export is complete + if export_record.status != 'completed': + return jsonify({'error': 'Export is not ready yet'}), 400 + + # Check if file exists + if not export_record.file_path or not os.path.exists(export_record.file_path): + return jsonify({'error': 'Export file not found'}), 404 + + # Check if expired + if export_record.is_expired(): + return jsonify({'error': 'Export has expired'}), 410 + + return send_file( + export_record.file_path, + as_attachment=True, + download_name=os.path.basename(export_record.file_path) + ) + + +@import_export_bp.route('/api/export/status/') +@login_required +def export_status(export_id): + """Get status of an export operation""" + export_record = DataExport.query.get_or_404(export_id) + + # Check permissions + if not current_user.is_admin and export_record.user_id != current_user.id: + return jsonify({'error': 'Unauthorized'}), 403 + + return jsonify(export_record.to_dict()), 200 + + +@import_export_bp.route('/api/export/history') +@login_required +def export_history(): + """Get export history for current user""" + if current_user.is_admin: + exports = DataExport.query.order_by(DataExport.created_at.desc()).limit(50).all() + else: + exports = DataExport.query.filter_by(user_id=current_user.id).order_by( + DataExport.created_at.desc() + ).limit(50).all() + + return jsonify({ + 'exports': [exp.to_dict() for exp in exports] + }), 200 + + +# ============================================================================ +# Backup/Restore Routes +# ============================================================================ + +@import_export_bp.route('/api/backup/restore', methods=['POST']) +@login_required +def restore_backup(): + """ + Restore from backup file (admin only) + + Expected multipart/form-data with 'file' field + """ + if not current_user.is_admin: + return jsonify({'error': 'Admin access required'}), 403 + + if 'file' not in request.files: + return jsonify({'error': 'No file provided'}), 400 + + file = request.files['file'] + + if file.filename == '': + return jsonify({'error': 'No file selected'}), 400 + + if not file.filename.endswith('.json'): + return jsonify({'error': 'File must be a JSON backup file'}), 400 + + try: + # Save uploaded file temporarily + backup_dir = os.path.join(current_app.config.get('UPLOAD_FOLDER', '/data/uploads'), 'backups') + os.makedirs(backup_dir, exist_ok=True) + + filename = secure_filename(file.filename) + filepath = os.path.join(backup_dir, f'restore_{filename}') + file.save(filepath) + + # Create import record + import_record = DataImport( + user_id=current_user.id, + import_type='backup', + source_file=filename + ) + db.session.add(import_record) + db.session.commit() + + # Perform restore + statistics = restore_from_backup( + user_id=current_user.id, + backup_file_path=filepath + ) + + # Clean up temporary file + os.remove(filepath) + + return jsonify({ + 'success': True, + 'import_id': import_record.id, + 'statistics': statistics, + 'message': 'Backup restored successfully' + }), 200 + + except DataImportError as e: + return jsonify({'error': str(e)}), 400 + except Exception as e: + current_app.logger.error(f"Backup restore error: {str(e)}") + return jsonify({'error': 'Restore failed. Please check the backup file.'}), 500 + + +# ============================================================================ +# Migration Wizard Routes +# ============================================================================ + +@import_export_bp.route('/api/migration/wizard/start', methods=['POST']) +@login_required +def start_migration_wizard(): + """ + Start the migration wizard + + Expected JSON body: + { + "source": "toggl" | "harvest" | "csv", + "credentials": {...}, + "options": {...} + } + """ + data = request.get_json() + + if not data: + return jsonify({'error': 'No data provided'}), 400 + + source = data.get('source') + + if source not in ['toggl', 'harvest', 'csv']: + return jsonify({'error': 'Invalid source'}), 400 + + # Store wizard state in session or return wizard ID + wizard_id = f"wizard_{current_user.id}_{datetime.utcnow().timestamp()}" + + return jsonify({ + 'success': True, + 'wizard_id': wizard_id, + 'next_step': 'credentials', + 'message': f'Migration wizard started for {source}' + }), 200 + + +@import_export_bp.route('/api/migration/wizard//preview', methods=['POST']) +@login_required +def preview_migration(wizard_id): + """ + Preview data before importing + + This would fetch a small sample of data to show the user what will be imported + """ + data = request.get_json() + + # Implementation would depend on the source + # For now, return a mock preview + + return jsonify({ + 'success': True, + 'preview': { + 'sample_entries': [], + 'total_count': 0, + 'date_range': {} + } + }), 200 + + +@import_export_bp.route('/api/migration/wizard//execute', methods=['POST']) +@login_required +def execute_migration(wizard_id): + """ + Execute the migration after preview + """ + data = request.get_json() + + # This would trigger the actual import based on the wizard configuration + + return jsonify({ + 'success': True, + 'message': 'Migration started', + 'import_id': None + }), 200 + + +# ============================================================================ +# Template Endpoints +# ============================================================================ + +@import_export_bp.route('/api/import/template/csv') +@login_required +def download_csv_template(): + """Download CSV import template""" + template_content = """project_name,client_name,task_name,start_time,end_time,duration_hours,notes,tags,billable +Example Project,Example Client,Example Task,2024-01-01 09:00:00,2024-01-01 10:30:00,1.5,Meeting with client,meeting;client,true +Another Project,Another Client,,2024-01-01 14:00:00,2024-01-01 16:00:00,2.0,Development work,dev;coding,true +""" + + from io import BytesIO + + buffer = BytesIO() + buffer.write(template_content.encode('utf-8')) + buffer.seek(0) + + return send_file( + buffer, + mimetype='text/csv', + as_attachment=True, + download_name='timetracker_import_template.csv' + ) + diff --git a/app/routes/invoices.py b/app/routes/invoices.py index 4fd43be5..4bf65f32 100644 --- a/app/routes/invoices.py +++ b/app/routes/invoices.py @@ -9,6 +9,7 @@ import io import csv import json from app.utils.db import safe_commit +from app.utils.excel_export import create_invoices_list_excel from app.utils.posthog_funnels import ( track_invoice_page_viewed, track_invoice_project_selected, @@ -684,3 +685,34 @@ def duplicate_invoice(invoice_id): flash(f'Invoice {new_invoice_number} created as duplicate', 'success') return redirect(url_for('invoices.edit_invoice', invoice_id=new_invoice.id)) + + +@invoices_bp.route('/invoices/export/excel') +@login_required +def export_invoices_excel(): + """Export invoice list as Excel file""" + # Get invoices (scope by user unless admin) + if current_user.is_admin: + invoices = Invoice.query.order_by(Invoice.created_at.desc()).all() + else: + invoices = Invoice.query.filter_by(created_by=current_user.id).order_by(Invoice.created_at.desc()).all() + + # Create Excel file + output, filename = create_invoices_list_excel(invoices) + + # Track Excel export event + log_event("export.excel", + user_id=current_user.id, + export_type="invoices_list", + num_rows=len(invoices)) + track_event(current_user.id, "export.excel", { + "export_type": "invoices_list", + "num_rows": len(invoices) + }) + + return send_file( + output, + mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + as_attachment=True, + download_name=filename + ) diff --git a/app/routes/main.py b/app/routes/main.py index 0f420a7a..39dbb7cf 100644 --- a/app/routes/main.py +++ b/app/routes/main.py @@ -1,6 +1,6 @@ from flask import Blueprint, render_template, request, redirect, url_for, flash, session from flask_login import login_required, current_user -from app.models import User, Project, TimeEntry, Settings, WeeklyTimeGoal +from app.models import User, Project, TimeEntry, Settings, WeeklyTimeGoal, TimeEntryTemplate, Activity from datetime import datetime, timedelta import pytz from app import db, track_page_view @@ -78,6 +78,18 @@ def dashboard(): current_week_goal = WeeklyTimeGoal.get_current_week_goal(current_user.id) if current_week_goal: current_week_goal.update_status() + + # Get user's time entry templates (most recently used first) + from sqlalchemy import desc + templates = TimeEntryTemplate.query.filter_by( + user_id=current_user.id + ).order_by(desc(TimeEntryTemplate.last_used_at)).limit(5).all() + + # Get recent activities for activity feed widget + recent_activities = Activity.get_recent( + user_id=None if current_user.is_admin else current_user.id, + limit=10 + ) return render_template('main/dashboard.html', active_timer=active_timer, @@ -87,7 +99,9 @@ def dashboard(): week_hours=week_hours, month_hours=month_hours, top_projects=top_projects, - current_week_goal=current_week_goal) + current_week_goal=current_week_goal, + templates=templates, + recent_activities=recent_activities) @main_bp.route('/_health') def health_check(): diff --git a/app/routes/mileage.py b/app/routes/mileage.py new file mode 100644 index 00000000..0935b165 --- /dev/null +++ b/app/routes/mileage.py @@ -0,0 +1,466 @@ +from flask import Blueprint, render_template, request, redirect, url_for, flash, jsonify, send_file, current_app +from flask_babel import gettext as _ +from flask_login import login_required, current_user +from app import db, log_event, track_event +from app.models import Mileage, Project, Client, Expense +from datetime import datetime, date, timedelta +from decimal import Decimal +from app.utils.db import safe_commit +import csv +import io + +mileage_bp = Blueprint('mileage', __name__) + + +@mileage_bp.route('/mileage') +@login_required +def list_mileage(): + """List all mileage entries with filters""" + from app import track_page_view + track_page_view("mileage_list") + + page = request.args.get('page', 1, type=int) + per_page = request.args.get('per_page', 25, type=int) + + # Filter parameters + status = request.args.get('status', '').strip() + project_id = request.args.get('project_id', type=int) + client_id = request.args.get('client_id', type=int) + start_date = request.args.get('start_date', '').strip() + end_date = request.args.get('end_date', '').strip() + search = request.args.get('search', '').strip() + + # Build query + query = Mileage.query + + # Non-admin users can only see their own mileage or mileage they approved + if not current_user.is_admin: + query = query.filter( + db.or_( + Mileage.user_id == current_user.id, + Mileage.approved_by == current_user.id + ) + ) + + # Apply filters + if status: + query = query.filter(Mileage.status == status) + + if project_id: + query = query.filter(Mileage.project_id == project_id) + + if client_id: + query = query.filter(Mileage.client_id == client_id) + + if start_date: + try: + start = datetime.strptime(start_date, '%Y-%m-%d').date() + query = query.filter(Mileage.trip_date >= start) + except ValueError: + pass + + if end_date: + try: + end = datetime.strptime(end_date, '%Y-%m-%d').date() + query = query.filter(Mileage.trip_date <= end) + except ValueError: + pass + + if search: + like = f"%{search}%" + query = query.filter( + db.or_( + Mileage.purpose.ilike(like), + Mileage.description.ilike(like), + Mileage.start_location.ilike(like), + Mileage.end_location.ilike(like) + ) + ) + + # Paginate + mileage_pagination = query.order_by(Mileage.trip_date.desc()).paginate( + page=page, + per_page=per_page, + error_out=False + ) + + # Get filter options + projects = Project.query.filter_by(status='active').order_by(Project.name).all() + clients = Client.get_active_clients() + + # Calculate totals + total_distance = Mileage.get_total_distance( + user_id=None if current_user.is_admin else current_user.id, + start_date=datetime.strptime(start_date, '%Y-%m-%d').date() if start_date else None, + end_date=datetime.strptime(end_date, '%Y-%m-%d').date() if end_date else None + ) + + total_amount_query = db.session.query( + db.func.sum(Mileage.calculated_amount * db.case( + (Mileage.is_round_trip, 2), + else_=1 + )) + ).filter(Mileage.status.in_(['approved', 'reimbursed'])) + + if not current_user.is_admin: + total_amount_query = total_amount_query.filter(Mileage.user_id == current_user.id) + + total_amount = total_amount_query.scalar() or 0 + + return render_template( + 'mileage/list.html', + mileage_entries=mileage_pagination.items, + pagination=mileage_pagination, + projects=projects, + clients=clients, + total_distance=total_distance, + total_amount=float(total_amount), + # Pass back filter values + status=status, + project_id=project_id, + client_id=client_id, + start_date=start_date, + end_date=end_date, + search=search + ) + + +@mileage_bp.route('/mileage/create', methods=['GET', 'POST']) +@login_required +def create_mileage(): + """Create a new mileage entry""" + if request.method == 'GET': + projects = Project.query.filter_by(status='active').order_by(Project.name).all() + clients = Client.get_active_clients() + default_rates = Mileage.get_default_rates() + + return render_template( + 'mileage/form.html', + mileage=None, + projects=projects, + clients=clients, + default_rates=default_rates + ) + + try: + # Get form data + trip_date = request.form.get('trip_date', '').strip() + purpose = request.form.get('purpose', '').strip() + description = request.form.get('description', '').strip() + start_location = request.form.get('start_location', '').strip() + end_location = request.form.get('end_location', '').strip() + distance_km = request.form.get('distance_km', '').strip() + rate_per_km = request.form.get('rate_per_km', '').strip() + + # Validate required fields + if not all([trip_date, purpose, start_location, end_location, distance_km, rate_per_km]): + flash(_('Please fill in all required fields'), 'error') + return redirect(url_for('mileage.create_mileage')) + + # Parse date + try: + trip_date_obj = datetime.strptime(trip_date, '%Y-%m-%d').date() + except ValueError: + flash(_('Invalid date format'), 'error') + return redirect(url_for('mileage.create_mileage')) + + # Create mileage entry + mileage = Mileage( + user_id=current_user.id, + trip_date=trip_date_obj, + purpose=purpose, + start_location=start_location, + end_location=end_location, + distance_km=Decimal(distance_km), + rate_per_km=Decimal(rate_per_km), + description=description, + project_id=request.form.get('project_id', type=int), + client_id=request.form.get('client_id', type=int), + start_odometer=request.form.get('start_odometer'), + end_odometer=request.form.get('end_odometer'), + vehicle_type=request.form.get('vehicle_type'), + vehicle_description=request.form.get('vehicle_description'), + license_plate=request.form.get('license_plate'), + is_round_trip=request.form.get('is_round_trip') == 'on', + currency_code=request.form.get('currency_code', 'EUR'), + notes=request.form.get('notes') + ) + + db.session.add(mileage) + + # Create expense if requested + if request.form.get('create_expense') == 'on': + expense = mileage.create_expense() + if expense: + db.session.add(expense) + + if safe_commit(db): + flash(_('Mileage entry created successfully'), 'success') + log_event('mileage_created', user_id=current_user.id, mileage_id=mileage.id) + track_event(current_user.id, 'mileage.created', { + 'mileage_id': mileage.id, + 'distance_km': float(distance_km), + 'amount': float(mileage.total_amount) + }) + return redirect(url_for('mileage.view_mileage', mileage_id=mileage.id)) + else: + flash(_('Error creating mileage entry'), 'error') + return redirect(url_for('mileage.create_mileage')) + + except Exception as e: + current_app.logger.error(f"Error creating mileage entry: {e}") + flash(_('Error creating mileage entry'), 'error') + return redirect(url_for('mileage.create_mileage')) + + +@mileage_bp.route('/mileage/') +@login_required +def view_mileage(mileage_id): + """View mileage entry details""" + mileage = Mileage.query.get_or_404(mileage_id) + + # Check permission + if not current_user.is_admin and mileage.user_id != current_user.id and mileage.approved_by != current_user.id: + flash(_('You do not have permission to view this mileage entry'), 'error') + return redirect(url_for('mileage.list_mileage')) + + from app import track_page_view + track_page_view("mileage_detail", properties={'mileage_id': mileage_id}) + + return render_template('mileage/view.html', mileage=mileage) + + +@mileage_bp.route('/mileage//edit', methods=['GET', 'POST']) +@login_required +def edit_mileage(mileage_id): + """Edit a mileage entry""" + mileage = Mileage.query.get_or_404(mileage_id) + + # Check permission + if not current_user.is_admin and mileage.user_id != current_user.id: + flash(_('You do not have permission to edit this mileage entry'), 'error') + return redirect(url_for('mileage.view_mileage', mileage_id=mileage_id)) + + # Cannot edit approved or reimbursed entries without admin privileges + if not current_user.is_admin and mileage.status in ['approved', 'reimbursed']: + flash(_('Cannot edit approved or reimbursed mileage entries'), 'error') + return redirect(url_for('mileage.view_mileage', mileage_id=mileage_id)) + + if request.method == 'GET': + projects = Project.query.filter_by(status='active').order_by(Project.name).all() + clients = Client.get_active_clients() + default_rates = Mileage.get_default_rates() + + return render_template( + 'mileage/form.html', + mileage=mileage, + projects=projects, + clients=clients, + default_rates=default_rates + ) + + try: + # Update fields + trip_date = request.form.get('trip_date', '').strip() + mileage.trip_date = datetime.strptime(trip_date, '%Y-%m-%d').date() + mileage.purpose = request.form.get('purpose', '').strip() + mileage.description = request.form.get('description', '').strip() + mileage.start_location = request.form.get('start_location', '').strip() + mileage.end_location = request.form.get('end_location', '').strip() + mileage.distance_km = Decimal(request.form.get('distance_km', '0')) + mileage.rate_per_km = Decimal(request.form.get('rate_per_km', '0')) + mileage.calculated_amount = mileage.distance_km * mileage.rate_per_km + mileage.project_id = request.form.get('project_id', type=int) + mileage.client_id = request.form.get('client_id', type=int) + mileage.vehicle_type = request.form.get('vehicle_type') + mileage.vehicle_description = request.form.get('vehicle_description') + mileage.license_plate = request.form.get('license_plate') + mileage.is_round_trip = request.form.get('is_round_trip') == 'on' + mileage.currency_code = request.form.get('currency_code', 'EUR') + mileage.notes = request.form.get('notes') + mileage.updated_at = datetime.utcnow() + + if safe_commit(db): + flash(_('Mileage entry updated successfully'), 'success') + log_event('mileage_updated', user_id=current_user.id, mileage_id=mileage.id) + track_event(current_user.id, 'mileage.updated', {'mileage_id': mileage.id}) + return redirect(url_for('mileage.view_mileage', mileage_id=mileage.id)) + else: + flash(_('Error updating mileage entry'), 'error') + return redirect(url_for('mileage.edit_mileage', mileage_id=mileage_id)) + + except Exception as e: + current_app.logger.error(f"Error updating mileage entry: {e}") + flash(_('Error updating mileage entry'), 'error') + return redirect(url_for('mileage.edit_mileage', mileage_id=mileage_id)) + + +@mileage_bp.route('/mileage//delete', methods=['POST']) +@login_required +def delete_mileage(mileage_id): + """Delete a mileage entry""" + mileage = Mileage.query.get_or_404(mileage_id) + + # Check permission + if not current_user.is_admin and mileage.user_id != current_user.id: + flash(_('You do not have permission to delete this mileage entry'), 'error') + return redirect(url_for('mileage.view_mileage', mileage_id=mileage_id)) + + try: + db.session.delete(mileage) + + if safe_commit(db): + flash(_('Mileage entry deleted successfully'), 'success') + log_event('mileage_deleted', user_id=current_user.id, mileage_id=mileage_id) + track_event(current_user.id, 'mileage.deleted', {'mileage_id': mileage_id}) + else: + flash(_('Error deleting mileage entry'), 'error') + + except Exception as e: + current_app.logger.error(f"Error deleting mileage entry: {e}") + flash(_('Error deleting mileage entry'), 'error') + + return redirect(url_for('mileage.list_mileage')) + + +@mileage_bp.route('/mileage//approve', methods=['POST']) +@login_required +def approve_mileage(mileage_id): + """Approve a mileage entry""" + if not current_user.is_admin: + flash(_('Only administrators can approve mileage entries'), 'error') + return redirect(url_for('mileage.view_mileage', mileage_id=mileage_id)) + + mileage = Mileage.query.get_or_404(mileage_id) + + if mileage.status != 'pending': + flash(_('Only pending mileage entries can be approved'), 'error') + return redirect(url_for('mileage.view_mileage', mileage_id=mileage_id)) + + try: + notes = request.form.get('approval_notes', '').strip() + mileage.approve(current_user.id, notes) + + if safe_commit(db): + flash(_('Mileage entry approved successfully'), 'success') + log_event('mileage_approved', user_id=current_user.id, mileage_id=mileage_id) + track_event(current_user.id, 'mileage.approved', {'mileage_id': mileage_id}) + else: + flash(_('Error approving mileage entry'), 'error') + + except Exception as e: + current_app.logger.error(f"Error approving mileage entry: {e}") + flash(_('Error approving mileage entry'), 'error') + + return redirect(url_for('mileage.view_mileage', mileage_id=mileage_id)) + + +@mileage_bp.route('/mileage//reject', methods=['POST']) +@login_required +def reject_mileage(mileage_id): + """Reject a mileage entry""" + if not current_user.is_admin: + flash(_('Only administrators can reject mileage entries'), 'error') + return redirect(url_for('mileage.view_mileage', mileage_id=mileage_id)) + + mileage = Mileage.query.get_or_404(mileage_id) + + if mileage.status != 'pending': + flash(_('Only pending mileage entries can be rejected'), 'error') + return redirect(url_for('mileage.view_mileage', mileage_id=mileage_id)) + + try: + reason = request.form.get('rejection_reason', '').strip() + if not reason: + flash(_('Rejection reason is required'), 'error') + return redirect(url_for('mileage.view_mileage', mileage_id=mileage_id)) + + mileage.reject(current_user.id, reason) + + if safe_commit(db): + flash(_('Mileage entry rejected'), 'success') + log_event('mileage_rejected', user_id=current_user.id, mileage_id=mileage_id) + track_event(current_user.id, 'mileage.rejected', {'mileage_id': mileage_id}) + else: + flash(_('Error rejecting mileage entry'), 'error') + + except Exception as e: + current_app.logger.error(f"Error rejecting mileage entry: {e}") + flash(_('Error rejecting mileage entry'), 'error') + + return redirect(url_for('mileage.view_mileage', mileage_id=mileage_id)) + + +@mileage_bp.route('/mileage//reimburse', methods=['POST']) +@login_required +def mark_reimbursed(mileage_id): + """Mark a mileage entry as reimbursed""" + if not current_user.is_admin: + flash(_('Only administrators can mark mileage entries as reimbursed'), 'error') + return redirect(url_for('mileage.view_mileage', mileage_id=mileage_id)) + + mileage = Mileage.query.get_or_404(mileage_id) + + if mileage.status != 'approved': + flash(_('Only approved mileage entries can be marked as reimbursed'), 'error') + return redirect(url_for('mileage.view_mileage', mileage_id=mileage_id)) + + try: + mileage.mark_as_reimbursed() + + if safe_commit(db): + flash(_('Mileage entry marked as reimbursed'), 'success') + log_event('mileage_reimbursed', user_id=current_user.id, mileage_id=mileage_id) + track_event(current_user.id, 'mileage.reimbursed', {'mileage_id': mileage_id}) + else: + flash(_('Error marking mileage entry as reimbursed'), 'error') + + except Exception as e: + current_app.logger.error(f"Error marking mileage entry as reimbursed: {e}") + flash(_('Error marking mileage entry as reimbursed'), 'error') + + return redirect(url_for('mileage.view_mileage', mileage_id=mileage_id)) + + +# API endpoints +@mileage_bp.route('/api/mileage', methods=['GET']) +@login_required +def api_list_mileage(): + """API endpoint to list mileage entries""" + status = request.args.get('status', '').strip() + + query = Mileage.query + + if not current_user.is_admin: + query = query.filter_by(user_id=current_user.id) + + if status: + query = query.filter(Mileage.status == status) + + entries = query.order_by(Mileage.trip_date.desc()).all() + + return jsonify({ + 'mileage': [entry.to_dict() for entry in entries], + 'count': len(entries) + }) + + +@mileage_bp.route('/api/mileage/', methods=['GET']) +@login_required +def api_get_mileage(mileage_id): + """API endpoint to get a single mileage entry""" + mileage = Mileage.query.get_or_404(mileage_id) + + # Check permission + if not current_user.is_admin and mileage.user_id != current_user.id: + return jsonify({'error': 'Permission denied'}), 403 + + return jsonify(mileage.to_dict()) + + +@mileage_bp.route('/api/mileage/default-rates', methods=['GET']) +@login_required +def api_get_default_rates(): + """API endpoint to get default mileage rates""" + return jsonify(Mileage.get_default_rates()) + diff --git a/app/routes/payments.py b/app/routes/payments.py index 3c4eda66..3c764eb4 100644 --- a/app/routes/payments.py +++ b/app/routes/payments.py @@ -6,7 +6,9 @@ from app.models import Payment, Invoice, User, Client from datetime import datetime, date from decimal import Decimal, InvalidOperation from sqlalchemy import func, and_, or_ +from flask import send_file from app.utils.db import safe_commit +from app.utils.excel_export import create_payments_list_excel payments_bp = Blueprint('payments', __name__) @@ -469,6 +471,74 @@ def payment_stats(): return jsonify(stats) + +@payments_bp.route('/payments/export/excel') +@login_required +def export_payments_excel(): + """Export payments list as Excel file""" + # Get filter parameters + status_filter = request.args.get('status', '') + method_filter = request.args.get('method', '') + date_from = request.args.get('date_from', '') + date_to = request.args.get('date_to', '') + invoice_id = request.args.get('invoice_id', type=int) + + # Base query + query = Payment.query + + # Apply filters based on user role + if not current_user.is_admin: + # Regular users can only see payments for their own invoices + query = query.join(Invoice).filter(Invoice.created_by == current_user.id) + + # Apply additional filters + if status_filter: + query = query.filter(Payment.status == status_filter) + + if method_filter: + query = query.filter(Payment.method == method_filter) + + if date_from: + try: + date_from_obj = datetime.strptime(date_from, '%Y-%m-%d').date() + query = query.filter(Payment.payment_date >= date_from_obj) + except ValueError: + pass + + if date_to: + try: + date_to_obj = datetime.strptime(date_to, '%Y-%m-%d').date() + query = query.filter(Payment.payment_date <= date_to_obj) + except ValueError: + pass + + if invoice_id: + query = query.filter(Payment.invoice_id == invoice_id) + + # Get payments + payments = query.order_by(Payment.payment_date.desc()).all() + + # Create Excel file + output, filename = create_payments_list_excel(payments) + + # Track Excel export event + log_event("export.excel", + user_id=current_user.id, + export_type="payments_list", + num_rows=len(payments)) + track_event(current_user.id, "export.excel", { + "export_type": "payments_list", + "num_rows": len(payments) + }) + + return send_file( + output, + mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + as_attachment=True, + download_name=filename + ) + + def get_user_invoices(): """Get invoices accessible by current user""" if current_user.is_admin: diff --git a/app/routes/per_diem.py b/app/routes/per_diem.py new file mode 100644 index 00000000..16db8406 --- /dev/null +++ b/app/routes/per_diem.py @@ -0,0 +1,542 @@ +from flask import Blueprint, render_template, request, redirect, url_for, flash, jsonify, current_app +from flask_babel import gettext as _ +from flask_login import login_required, current_user +from app import db, log_event, track_event +from app.models import PerDiem, PerDiemRate, Project, Client +from datetime import datetime, date, time +from decimal import Decimal +from app.utils.db import safe_commit +from app.utils.permissions import admin_or_permission_required + +per_diem_bp = Blueprint('per_diem', __name__) + + +@per_diem_bp.route('/per-diem') +@login_required +def list_per_diem(): + """List all per diem claims with filters""" + from app import track_page_view + track_page_view("per_diem_list") + + page = request.args.get('page', 1, type=int) + per_page = request.args.get('per_page', 25, type=int) + + # Filter parameters + status = request.args.get('status', '').strip() + project_id = request.args.get('project_id', type=int) + client_id = request.args.get('client_id', type=int) + start_date = request.args.get('start_date', '').strip() + end_date = request.args.get('end_date', '').strip() + + # Build query + query = PerDiem.query + + # Non-admin users can only see their own claims + if not current_user.is_admin: + query = query.filter( + db.or_( + PerDiem.user_id == current_user.id, + PerDiem.approved_by == current_user.id + ) + ) + + # Apply filters + if status: + query = query.filter(PerDiem.status == status) + + if project_id: + query = query.filter(PerDiem.project_id == project_id) + + if client_id: + query = query.filter(PerDiem.client_id == client_id) + + if start_date: + try: + start = datetime.strptime(start_date, '%Y-%m-%d').date() + query = query.filter(PerDiem.start_date >= start) + except ValueError: + pass + + if end_date: + try: + end = datetime.strptime(end_date, '%Y-%m-%d').date() + query = query.filter(PerDiem.end_date <= end) + except ValueError: + pass + + # Paginate + per_diem_pagination = query.order_by(PerDiem.start_date.desc()).paginate( + page=page, + per_page=per_page, + error_out=False + ) + + # Get filter options + projects = Project.query.filter_by(status='active').order_by(Project.name).all() + clients = Client.get_active_clients() + + # Calculate totals + total_amount_query = db.session.query( + db.func.sum(PerDiem.calculated_amount) + ).filter(PerDiem.status.in_(['approved', 'reimbursed'])) + + if not current_user.is_admin: + total_amount_query = total_amount_query.filter(PerDiem.user_id == current_user.id) + + total_amount = total_amount_query.scalar() or 0 + + return render_template( + 'per_diem/list.html', + per_diem_claims=per_diem_pagination.items, + pagination=per_diem_pagination, + projects=projects, + clients=clients, + total_amount=float(total_amount), + status=status, + project_id=project_id, + client_id=client_id, + start_date=start_date, + end_date=end_date + ) + + +@per_diem_bp.route('/per-diem/create', methods=['GET', 'POST']) +@login_required +def create_per_diem(): + """Create a new per diem claim""" + if request.method == 'GET': + projects = Project.query.filter_by(status='active').order_by(Project.name).all() + clients = Client.get_active_clients() + + return render_template( + 'per_diem/form.html', + per_diem=None, + projects=projects, + clients=clients + ) + + try: + # Get form data + trip_purpose = request.form.get('trip_purpose', '').strip() + start_date_str = request.form.get('start_date', '').strip() + end_date_str = request.form.get('end_date', '').strip() + country = request.form.get('country', '').strip() + city = request.form.get('city', '').strip() + + # Validate required fields + if not all([trip_purpose, start_date_str, end_date_str, country]): + flash(_('Please fill in all required fields'), 'error') + return redirect(url_for('per_diem.create_per_diem')) + + # Parse dates + try: + start_date = datetime.strptime(start_date_str, '%Y-%m-%d').date() + end_date = datetime.strptime(end_date_str, '%Y-%m-%d').date() + except ValueError: + flash(_('Invalid date format'), 'error') + return redirect(url_for('per_diem.create_per_diem')) + + if start_date > end_date: + flash(_('Start date must be before end date'), 'error') + return redirect(url_for('per_diem.create_per_diem')) + + # Parse times if provided + departure_time = None + return_time = None + departure_time_str = request.form.get('departure_time', '').strip() + return_time_str = request.form.get('return_time', '').strip() + + if departure_time_str: + try: + departure_time = datetime.strptime(departure_time_str, '%H:%M').time() + except ValueError: + pass + + if return_time_str: + try: + return_time = datetime.strptime(return_time_str, '%H:%M').time() + except ValueError: + pass + + # Get or calculate full/half days + auto_calculate = request.form.get('auto_calculate_days') == 'on' + + if auto_calculate: + days_calc = PerDiem.calculate_days_from_dates(start_date, end_date, departure_time, return_time) + full_days = days_calc['full_days'] + half_days = days_calc['half_days'] + else: + full_days = int(request.form.get('full_days', 0)) + half_days = int(request.form.get('half_days', 0)) + + # Get applicable rate + rate = PerDiemRate.get_rate_for_location(country, city, start_date) + + if not rate: + flash(_('No per diem rate found for this location. Please configure rates first.'), 'error') + return redirect(url_for('per_diem.create_per_diem')) + + # Meal deductions + breakfast_provided = int(request.form.get('breakfast_provided', 0)) + lunch_provided = int(request.form.get('lunch_provided', 0)) + dinner_provided = int(request.form.get('dinner_provided', 0)) + + # Create per diem claim + per_diem = PerDiem( + user_id=current_user.id, + trip_purpose=trip_purpose, + start_date=start_date, + end_date=end_date, + country=country, + city=city, + full_day_rate=rate.full_day_rate, + half_day_rate=rate.half_day_rate, + description=request.form.get('description'), + project_id=request.form.get('project_id', type=int), + client_id=request.form.get('client_id', type=int), + per_diem_rate_id=rate.id, + departure_time=departure_time, + return_time=return_time, + full_days=full_days, + half_days=half_days, + breakfast_provided=breakfast_provided, + lunch_provided=lunch_provided, + dinner_provided=dinner_provided, + breakfast_deduction=rate.breakfast_rate or Decimal('0'), + lunch_deduction=rate.lunch_rate or Decimal('0'), + dinner_deduction=rate.dinner_rate or Decimal('0'), + currency_code=rate.currency_code, + notes=request.form.get('notes') + ) + + db.session.add(per_diem) + + # Create expense if requested + if request.form.get('create_expense') == 'on': + expense = per_diem.create_expense() + if expense: + db.session.add(expense) + + if safe_commit(db): + flash(_('Per diem claim created successfully'), 'success') + log_event('per_diem_created', user_id=current_user.id, per_diem_id=per_diem.id) + track_event(current_user.id, 'per_diem.created', { + 'per_diem_id': per_diem.id, + 'amount': float(per_diem.calculated_amount), + 'days': per_diem.total_days + }) + return redirect(url_for('per_diem.view_per_diem', per_diem_id=per_diem.id)) + else: + flash(_('Error creating per diem claim'), 'error') + return redirect(url_for('per_diem.create_per_diem')) + + except Exception as e: + current_app.logger.error(f"Error creating per diem claim: {e}") + flash(_('Error creating per diem claim'), 'error') + return redirect(url_for('per_diem.create_per_diem')) + + +@per_diem_bp.route('/per-diem/') +@login_required +def view_per_diem(per_diem_id): + """View per diem claim details""" + per_diem = PerDiem.query.get_or_404(per_diem_id) + + # Check permission + if not current_user.is_admin and per_diem.user_id != current_user.id and per_diem.approved_by != current_user.id: + flash(_('You do not have permission to view this per diem claim'), 'error') + return redirect(url_for('per_diem.list_per_diem')) + + from app import track_page_view + track_page_view("per_diem_detail", properties={'per_diem_id': per_diem_id}) + + return render_template('per_diem/view.html', per_diem=per_diem) + + +@per_diem_bp.route('/per-diem//edit', methods=['GET', 'POST']) +@login_required +def edit_per_diem(per_diem_id): + """Edit a per diem claim""" + per_diem = PerDiem.query.get_or_404(per_diem_id) + + # Check permission + if not current_user.is_admin and per_diem.user_id != current_user.id: + flash(_('You do not have permission to edit this per diem claim'), 'error') + return redirect(url_for('per_diem.view_per_diem', per_diem_id=per_diem_id)) + + # Cannot edit approved or reimbursed claims without admin privileges + if not current_user.is_admin and per_diem.status in ['approved', 'reimbursed']: + flash(_('Cannot edit approved or reimbursed per diem claims'), 'error') + return redirect(url_for('per_diem.view_per_diem', per_diem_id=per_diem_id)) + + if request.method == 'GET': + projects = Project.query.filter_by(status='active').order_by(Project.name).all() + clients = Client.get_active_clients() + + return render_template( + 'per_diem/form.html', + per_diem=per_diem, + projects=projects, + clients=clients + ) + + try: + # Update fields + per_diem.trip_purpose = request.form.get('trip_purpose', '').strip() + per_diem.description = request.form.get('description', '').strip() + per_diem.start_date = datetime.strptime(request.form.get('start_date'), '%Y-%m-%d').date() + per_diem.end_date = datetime.strptime(request.form.get('end_date'), '%Y-%m-%d').date() + per_diem.country = request.form.get('country', '').strip() + per_diem.city = request.form.get('city', '').strip() + per_diem.project_id = request.form.get('project_id', type=int) + per_diem.client_id = request.form.get('client_id', type=int) + per_diem.full_days = int(request.form.get('full_days', 0)) + per_diem.half_days = int(request.form.get('half_days', 0)) + per_diem.breakfast_provided = int(request.form.get('breakfast_provided', 0)) + per_diem.lunch_provided = int(request.form.get('lunch_provided', 0)) + per_diem.dinner_provided = int(request.form.get('dinner_provided', 0)) + per_diem.notes = request.form.get('notes') + per_diem.updated_at = datetime.utcnow() + + # Recalculate amount + per_diem.recalculate_amount() + + if safe_commit(db): + flash(_('Per diem claim updated successfully'), 'success') + log_event('per_diem_updated', user_id=current_user.id, per_diem_id=per_diem.id) + track_event(current_user.id, 'per_diem.updated', {'per_diem_id': per_diem.id}) + return redirect(url_for('per_diem.view_per_diem', per_diem_id=per_diem.id)) + else: + flash(_('Error updating per diem claim'), 'error') + return redirect(url_for('per_diem.edit_per_diem', per_diem_id=per_diem_id)) + + except Exception as e: + current_app.logger.error(f"Error updating per diem claim: {e}") + flash(_('Error updating per diem claim'), 'error') + return redirect(url_for('per_diem.edit_per_diem', per_diem_id=per_diem_id)) + + +@per_diem_bp.route('/per-diem//delete', methods=['POST']) +@login_required +def delete_per_diem(per_diem_id): + """Delete a per diem claim""" + per_diem = PerDiem.query.get_or_404(per_diem_id) + + # Check permission + if not current_user.is_admin and per_diem.user_id != current_user.id: + flash(_('You do not have permission to delete this per diem claim'), 'error') + return redirect(url_for('per_diem.view_per_diem', per_diem_id=per_diem_id)) + + try: + db.session.delete(per_diem) + + if safe_commit(db): + flash(_('Per diem claim deleted successfully'), 'success') + log_event('per_diem_deleted', user_id=current_user.id, per_diem_id=per_diem_id) + track_event(current_user.id, 'per_diem.deleted', {'per_diem_id': per_diem_id}) + else: + flash(_('Error deleting per diem claim'), 'error') + + except Exception as e: + current_app.logger.error(f"Error deleting per diem claim: {e}") + flash(_('Error deleting per diem claim'), 'error') + + return redirect(url_for('per_diem.list_per_diem')) + + +@per_diem_bp.route('/per-diem//approve', methods=['POST']) +@login_required +def approve_per_diem(per_diem_id): + """Approve a per diem claim""" + if not current_user.is_admin: + flash(_('Only administrators can approve per diem claims'), 'error') + return redirect(url_for('per_diem.view_per_diem', per_diem_id=per_diem_id)) + + per_diem = PerDiem.query.get_or_404(per_diem_id) + + if per_diem.status != 'pending': + flash(_('Only pending per diem claims can be approved'), 'error') + return redirect(url_for('per_diem.view_per_diem', per_diem_id=per_diem_id)) + + try: + notes = request.form.get('approval_notes', '').strip() + per_diem.approve(current_user.id, notes) + + if safe_commit(db): + flash(_('Per diem claim approved successfully'), 'success') + log_event('per_diem_approved', user_id=current_user.id, per_diem_id=per_diem_id) + track_event(current_user.id, 'per_diem.approved', {'per_diem_id': per_diem_id}) + else: + flash(_('Error approving per diem claim'), 'error') + + except Exception as e: + current_app.logger.error(f"Error approving per diem claim: {e}") + flash(_('Error approving per diem claim'), 'error') + + return redirect(url_for('per_diem.view_per_diem', per_diem_id=per_diem_id)) + + +@per_diem_bp.route('/per-diem//reject', methods=['POST']) +@login_required +def reject_per_diem(per_diem_id): + """Reject a per diem claim""" + if not current_user.is_admin: + flash(_('Only administrators can reject per diem claims'), 'error') + return redirect(url_for('per_diem.view_per_diem', per_diem_id=per_diem_id)) + + per_diem = PerDiem.query.get_or_404(per_diem_id) + + if per_diem.status != 'pending': + flash(_('Only pending per diem claims can be rejected'), 'error') + return redirect(url_for('per_diem.view_per_diem', per_diem_id=per_diem_id)) + + try: + reason = request.form.get('rejection_reason', '').strip() + if not reason: + flash(_('Rejection reason is required'), 'error') + return redirect(url_for('per_diem.view_per_diem', per_diem_id=per_diem_id)) + + per_diem.reject(current_user.id, reason) + + if safe_commit(db): + flash(_('Per diem claim rejected'), 'success') + log_event('per_diem_rejected', user_id=current_user.id, per_diem_id=per_diem_id) + track_event(current_user.id, 'per_diem.rejected', {'per_diem_id': per_diem_id}) + else: + flash(_('Error rejecting per diem claim'), 'error') + + except Exception as e: + current_app.logger.error(f"Error rejecting per diem claim: {e}") + flash(_('Error rejecting per diem claim'), 'error') + + return redirect(url_for('per_diem.view_per_diem', per_diem_id=per_diem_id)) + + +# Per Diem Rates Management +@per_diem_bp.route('/per-diem/rates') +@login_required +@admin_or_permission_required('per_diem_rates.view') +def list_rates(): + """List all per diem rates""" + from app import track_page_view + track_page_view("per_diem_rates_list") + + rates = PerDiemRate.query.filter_by(is_active=True).order_by( + PerDiemRate.country, PerDiemRate.city, PerDiemRate.effective_from.desc() + ).all() + + return render_template('per_diem/rates_list.html', rates=rates) + + +@per_diem_bp.route('/per-diem/rates/create', methods=['GET', 'POST']) +@login_required +@admin_or_permission_required('per_diem_rates.create') +def create_rate(): + """Create a new per diem rate""" + if request.method == 'GET': + return render_template('per_diem/rate_form.html', rate=None) + + try: + country = request.form.get('country', '').strip() + full_day_rate = request.form.get('full_day_rate', '').strip() + half_day_rate = request.form.get('half_day_rate', '').strip() + effective_from = request.form.get('effective_from', '').strip() + + if not all([country, full_day_rate, half_day_rate, effective_from]): + flash(_('Please fill in all required fields'), 'error') + return redirect(url_for('per_diem.create_rate')) + + rate = PerDiemRate( + country=country, + city=request.form.get('city'), + full_day_rate=Decimal(full_day_rate), + half_day_rate=Decimal(half_day_rate), + breakfast_rate=request.form.get('breakfast_rate') or None, + lunch_rate=request.form.get('lunch_rate') or None, + dinner_rate=request.form.get('dinner_rate') or None, + incidental_rate=request.form.get('incidental_rate') or None, + currency_code=request.form.get('currency_code', 'EUR'), + effective_from=datetime.strptime(effective_from, '%Y-%m-%d').date(), + effective_to=datetime.strptime(request.form.get('effective_to'), '%Y-%m-%d').date() if request.form.get('effective_to') else None, + notes=request.form.get('notes') + ) + + db.session.add(rate) + + if safe_commit(db): + flash(_('Per diem rate created successfully'), 'success') + log_event('per_diem_rate_created', user_id=current_user.id, rate_id=rate.id) + return redirect(url_for('per_diem.list_rates')) + else: + flash(_('Error creating per diem rate'), 'error') + return redirect(url_for('per_diem.create_rate')) + + except Exception as e: + current_app.logger.error(f"Error creating per diem rate: {e}") + flash(_('Error creating per diem rate'), 'error') + return redirect(url_for('per_diem.create_rate')) + + +# API endpoints +@per_diem_bp.route('/api/per-diem', methods=['GET']) +@login_required +def api_list_per_diem(): + """API endpoint to list per diem claims""" + status = request.args.get('status', '').strip() + + query = PerDiem.query + + if not current_user.is_admin: + query = query.filter_by(user_id=current_user.id) + + if status: + query = query.filter(PerDiem.status == status) + + claims = query.order_by(PerDiem.start_date.desc()).all() + + return jsonify({ + 'per_diem': [claim.to_dict() for claim in claims], + 'count': len(claims) + }) + + +@per_diem_bp.route('/api/per-diem/rates/search', methods=['GET']) +@login_required +def api_search_rates(): + """API endpoint to search for per diem rates""" + country = request.args.get('country', '').strip() + city = request.args.get('city', '').strip() + date_str = request.args.get('date', '').strip() + + if not country: + return jsonify({'error': 'Country is required'}), 400 + + search_date = datetime.strptime(date_str, '%Y-%m-%d').date() if date_str else date.today() + + rate = PerDiemRate.get_rate_for_location(country, city, search_date) + + if rate: + return jsonify(rate.to_dict()) + else: + return jsonify({'error': 'No rate found for this location'}), 404 + + +@per_diem_bp.route('/api/per-diem/calculate-days', methods=['POST']) +@login_required +def api_calculate_days(): + """API endpoint to calculate full/half days from dates and times""" + data = request.get_json() + + try: + start_date = datetime.strptime(data['start_date'], '%Y-%m-%d').date() + end_date = datetime.strptime(data['end_date'], '%Y-%m-%d').date() + departure_time = datetime.strptime(data.get('departure_time', ''), '%H:%M').time() if data.get('departure_time') else None + return_time = datetime.strptime(data.get('return_time', ''), '%H:%M').time() if data.get('return_time') else None + + result = PerDiem.calculate_days_from_dates(start_date, end_date, departure_time, return_time) + + return jsonify(result) + + except Exception as e: + return jsonify({'error': str(e)}), 400 + diff --git a/app/routes/projects.py b/app/routes/projects.py index 1ca7534e..b5ee7145 100644 --- a/app/routes/projects.py +++ b/app/routes/projects.py @@ -1,4 +1,4 @@ -from flask import Blueprint, render_template, request, redirect, url_for, flash, current_app, jsonify, make_response +from flask import Blueprint, render_template, request, redirect, url_for, flash, current_app, jsonify, make_response, Response from flask_babel import gettext as _ from flask_login import login_required, current_user from app import db, log_event, track_event @@ -7,6 +7,8 @@ from datetime import datetime from decimal import Decimal from app.utils.db import safe_commit from app.utils.permissions import admin_or_permission_required, permission_required +import csv +import io from app.utils.posthog_funnels import ( track_onboarding_first_project, track_project_setup_started, @@ -87,6 +89,100 @@ def list_projects(): favorites_only=favorites_only ) +@projects_bp.route('/projects/export') +@login_required +def export_projects(): + """Export projects to CSV""" + status = request.args.get('status', 'active') + client_name = request.args.get('client', '').strip() + search = request.args.get('search', '').strip() + favorites_only = request.args.get('favorites', '').lower() == 'true' + + query = Project.query + + # Filter by favorites if requested + if favorites_only: + query = query.join( + UserFavoriteProject, + db.and_( + UserFavoriteProject.project_id == Project.id, + UserFavoriteProject.user_id == current_user.id + ) + ) + + # Filter by status + if status == 'active': + query = query.filter(Project.status == 'active') + elif status == 'archived': + query = query.filter(Project.status == 'archived') + elif status == 'inactive': + query = query.filter(Project.status == 'inactive') + + if client_name: + query = query.join(Client).filter(Client.name == client_name) + + if search: + like = f"%{search}%" + query = query.filter( + db.or_( + Project.name.ilike(like), + Project.description.ilike(like) + ) + ) + + projects = query.order_by(Project.name).all() + + # Create CSV in memory + output = io.StringIO() + writer = csv.writer(output) + + # Write header + writer.writerow([ + 'ID', + 'Name', + 'Code', + 'Client', + 'Description', + 'Status', + 'Billable', + 'Hourly Rate', + 'Budget Amount', + 'Budget Threshold %', + 'Estimated Hours', + 'Billing Reference', + 'Created At', + 'Updated At' + ]) + + # Write project data + for project in projects: + writer.writerow([ + project.id, + project.name, + project.code or '', + project.client if project.client else '', + project.description or '', + project.status, + 'Yes' if project.billable else 'No', + project.hourly_rate or '', + project.budget_amount or '', + project.budget_threshold_percent or '', + project.estimated_hours or '', + project.billing_ref or '', + project.created_at.strftime('%Y-%m-%d %H:%M:%S') if project.created_at else '', + project.updated_at.strftime('%Y-%m-%d %H:%M:%S') if hasattr(project, 'updated_at') and project.updated_at else '' + ]) + + # Create response + output.seek(0) + return Response( + output.getvalue(), + mimetype='text/csv', + headers={ + 'Content-Disposition': f'attachment; filename=projects_export_{datetime.now().strftime("%Y%m%d_%H%M%S")}.csv' + } + ) + @projects_bp.route('/projects/create', methods=['GET', 'POST']) @login_required @admin_or_permission_required('create_projects') @@ -329,6 +425,185 @@ def view_project(project_id): resp.headers['Expires'] = '0' return resp +@projects_bp.route('/projects//dashboard') +@login_required +def project_dashboard(project_id): + """Project dashboard with comprehensive analytics and visualizations""" + project = Project.query.get_or_404(project_id) + + # Track page view + from app import track_page_view + track_page_view("project_dashboard") + + # Get time period filter (default to all time) + from datetime import datetime, timedelta + period = request.args.get('period', 'all') + start_date = None + end_date = None + + if period == 'week': + start_date = datetime.now() - timedelta(days=7) + elif period == 'month': + start_date = datetime.now() - timedelta(days=30) + elif period == '3months': + start_date = datetime.now() - timedelta(days=90) + elif period == 'year': + start_date = datetime.now() - timedelta(days=365) + + # === Budget vs Actual === + budget_data = { + 'budget_amount': float(project.budget_amount) if project.budget_amount else 0, + 'consumed_amount': project.budget_consumed_amount, + 'remaining_amount': float(project.budget_amount or 0) - project.budget_consumed_amount, + 'percentage': round((project.budget_consumed_amount / float(project.budget_amount or 1)) * 100, 1) if project.budget_amount else 0, + 'threshold_exceeded': project.budget_threshold_exceeded, + 'estimated_hours': project.estimated_hours or 0, + 'actual_hours': project.actual_hours, + 'remaining_hours': (project.estimated_hours or 0) - project.actual_hours, + 'hours_percentage': round((project.actual_hours / (project.estimated_hours or 1)) * 100, 1) if project.estimated_hours else 0 + } + + # === Task Statistics === + all_tasks = project.tasks.all() + task_stats = { + 'total': len(all_tasks), + 'by_status': {}, + 'completed': 0, + 'in_progress': 0, + 'todo': 0, + 'completion_rate': 0, + 'overdue': 0 + } + + for task in all_tasks: + status = task.status + task_stats['by_status'][status] = task_stats['by_status'].get(status, 0) + 1 + if status == 'done': + task_stats['completed'] += 1 + elif status == 'in_progress': + task_stats['in_progress'] += 1 + elif status == 'todo': + task_stats['todo'] += 1 + if task.is_overdue: + task_stats['overdue'] += 1 + + if task_stats['total'] > 0: + task_stats['completion_rate'] = round((task_stats['completed'] / task_stats['total']) * 100, 1) + + # === Team Member Contributions === + user_totals = project.get_user_totals(start_date=start_date, end_date=end_date) + + # Get time entries per user with additional stats + from app.models import User + team_contributions = [] + for user_data in user_totals: + username = user_data['username'] + total_hours = user_data['total_hours'] + + # Get user object + user = User.query.filter( + db.or_( + User.username == username, + User.full_name == username + ) + ).first() + + if user: + # Count entries for this user + entry_count = project.time_entries.filter( + TimeEntry.user_id == user.id, + TimeEntry.end_time.isnot(None) + ) + if start_date: + entry_count = entry_count.filter(TimeEntry.start_time >= start_date) + if end_date: + entry_count = entry_count.filter(TimeEntry.start_time <= end_date) + entry_count = entry_count.count() + + # Count tasks assigned to this user + task_count = project.tasks.filter_by(assigned_to=user.id).count() + + team_contributions.append({ + 'username': username, + 'total_hours': total_hours, + 'entry_count': entry_count, + 'task_count': task_count, + 'percentage': round((total_hours / project.total_hours * 100), 1) if project.total_hours > 0 else 0 + }) + + # Sort by total hours descending + team_contributions.sort(key=lambda x: x['total_hours'], reverse=True) + + # === Recent Activity === + recent_activities = Activity.query.filter( + Activity.entity_type.in_(['project', 'task', 'time_entry']), + db.or_( + Activity.entity_id == project_id, + db.and_( + Activity.entity_type == 'task', + Activity.entity_id.in_([t.id for t in all_tasks]) + ) + ) + ).order_by(Activity.created_at.desc()).limit(20).all() + + # Filter to only project-related activities + project_activities = [] + for activity in recent_activities: + if activity.entity_type == 'project' and activity.entity_id == project_id: + project_activities.append(activity) + elif activity.entity_type == 'task': + # Check if task belongs to this project + task = Task.query.get(activity.entity_id) + if task and task.project_id == project_id: + project_activities.append(activity) + + # === Time Tracking Timeline (last 30 days) === + from sqlalchemy import func + timeline_data = [] + if start_date or period != 'all': + timeline_start = start_date or (datetime.now() - timedelta(days=30)) + + # Group time entries by date + daily_hours = db.session.query( + func.date(TimeEntry.start_time).label('date'), + func.sum(TimeEntry.duration_seconds).label('total_seconds') + ).filter( + TimeEntry.project_id == project_id, + TimeEntry.end_time.isnot(None), + TimeEntry.start_time >= timeline_start + ).group_by(func.date(TimeEntry.start_time)).order_by('date').all() + + timeline_data = [ + { + 'date': str(date), + 'hours': round(total_seconds / 3600, 2) + } + for date, total_seconds in daily_hours + ] + + # === Cost Breakdown === + cost_data = { + 'total_costs': project.total_costs, + 'billable_costs': project.total_billable_costs, + 'by_category': {} + } + + if hasattr(ProjectCost, 'get_costs_by_category'): + cost_breakdown = ProjectCost.get_costs_by_category(project_id, start_date, end_date) + cost_data['by_category'] = cost_breakdown + + return render_template( + 'projects/dashboard.html', + project=project, + budget_data=budget_data, + task_stats=task_stats, + team_contributions=team_contributions, + recent_activities=project_activities[:10], + timeline_data=timeline_data, + cost_data=cost_data, + period=period + ) + @projects_bp.route('/projects//edit', methods=['GET', 'POST']) @login_required @admin_or_permission_required('edit_projects') @@ -415,6 +690,18 @@ def edit_project(project_id): flash('Could not update project due to a database error. Please check server logs.', 'error') return render_template('projects/edit.html', project=project, clients=Client.get_active_clients()) + # Log activity + Activity.log( + user_id=current_user.id, + action='updated', + entity_type='project', + entity_id=project.id, + entity_name=project.name, + description=f'Updated project "{project.name}"', + ip_address=request.remote_addr, + user_agent=request.headers.get('User-Agent') + ) + flash(f'Project "{name}" updated successfully', 'success') return redirect(url_for('projects.view_project', project_id=project.id)) @@ -560,10 +847,24 @@ def delete_project(project_id): return redirect(url_for('projects.view_project', project_id=project_id)) project_name = project.name + project_id_copy = project.id + + # Log activity before deletion + Activity.log( + user_id=current_user.id, + action='deleted', + entity_type='project', + entity_id=project_id_copy, + entity_name=project_name, + description=f'Deleted project "{project_name}"', + ip_address=request.remote_addr, + user_agent=request.headers.get('User-Agent') + ) + db.session.delete(project) - if not safe_commit('delete_project', {'project_id': project.id}): + if not safe_commit('delete_project', {'project_id': project_id_copy}): flash('Could not delete project due to a database error. Please check server logs.', 'error') - return redirect(url_for('projects.view_project', project_id=project.id)) + return redirect(url_for('projects.view_project', project_id=project_id_copy)) flash(f'Project "{project_name}" deleted successfully', 'success') return redirect(url_for('projects.list_projects')) diff --git a/app/routes/tasks.py b/app/routes/tasks.py index fe3caace..ef5a08a3 100644 --- a/app/routes/tasks.py +++ b/app/routes/tasks.py @@ -1,13 +1,15 @@ -from flask import Blueprint, render_template, request, redirect, url_for, flash, jsonify, make_response +from flask import Blueprint, render_template, request, redirect, url_for, flash, jsonify, make_response, Response from flask_babel import gettext as _ from flask_login import login_required, current_user import app as app_module from app import db -from app.models import Task, Project, User, TimeEntry, TaskActivity, KanbanColumn +from app.models import Task, Project, User, TimeEntry, TaskActivity, KanbanColumn, Activity from datetime import datetime, date from decimal import Decimal from app.utils.db import safe_commit from app.utils.timezone import now_in_app_timezone +import csv +import io tasks_bp = Blueprint('tasks', __name__) @@ -168,6 +170,19 @@ def create_task(): "priority": priority }) + # Log activity + Activity.log( + user_id=current_user.id, + action='created', + entity_type='task', + entity_id=task.id, + entity_name=task.name, + description=f'Created task "{task.name}" in project "{project.name}"', + extra_data={'project_id': project_id, 'priority': priority}, + ip_address=request.remote_addr, + user_agent=request.headers.get('User-Agent') + ) + flash(f'Task "{name}" created successfully', 'success') return redirect(url_for('tasks.view_task', task_id=task.id)) @@ -335,6 +350,18 @@ def edit_task(task_id): "project_id": task.project_id }) + # Log activity + Activity.log( + user_id=current_user.id, + action='updated', + entity_type='task', + entity_id=task.id, + entity_name=task.name, + description=f'Updated task "{task.name}"', + ip_address=request.remote_addr, + user_agent=request.headers.get('User-Agent') + ) + flash(f'Task "{name}" updated successfully', 'success') return redirect(url_for('tasks.view_task', task_id=task.id)) @@ -494,10 +521,23 @@ def delete_task(task_id): task_name = task.name task_id_for_log = task.id project_id_for_log = task.project_id + + # Log activity before deletion + Activity.log( + user_id=current_user.id, + action='deleted', + entity_type='task', + entity_id=task_id_for_log, + entity_name=task_name, + description=f'Deleted task "{task_name}"', + ip_address=request.remote_addr, + user_agent=request.headers.get('User-Agent') + ) + db.session.delete(task) - if not safe_commit('delete_task', {'task_id': task.id}): + if not safe_commit('delete_task', {'task_id': task_id_for_log}): flash('Could not delete task due to a database error. Please check server logs.', 'error') - return redirect(url_for('tasks.view_task', task_id=task.id)) + return redirect(url_for('tasks.view_task', task_id=task_id_for_log)) # Log task deletion app_module.log_event("task.deleted", user_id=current_user.id, task_id=task_id_for_log, project_id=project_id_for_log) @@ -734,6 +774,181 @@ def bulk_assign_tasks(): return redirect(url_for('tasks.list_tasks')) +@tasks_bp.route('/tasks/bulk-move-project', methods=['POST']) +@login_required +def bulk_move_project(): + """Move multiple tasks to a different project""" + task_ids = request.form.getlist('task_ids[]') + new_project_id = request.form.get('project_id', type=int) + + if not task_ids: + flash('No tasks selected', 'warning') + return redirect(url_for('tasks.list_tasks')) + + if not new_project_id: + flash('No project selected', 'error') + return redirect(url_for('tasks.list_tasks')) + + # Verify project exists and is active + new_project = Project.query.filter_by(id=new_project_id, status='active').first() + if not new_project: + flash('Invalid project selected', 'error') + return redirect(url_for('tasks.list_tasks')) + + updated_count = 0 + skipped_count = 0 + + for task_id_str in task_ids: + try: + task_id = int(task_id_str) + task = Task.query.get(task_id) + + if not task: + continue + + # Check permissions + if not current_user.is_admin and task.created_by != current_user.id: + skipped_count += 1 + continue + + # Update task project + old_project_id = task.project_id + task.project_id = new_project_id + + # Update related time entries to match the new project + for entry in task.time_entries.all(): + entry.project_id = new_project_id + + # Log activity + db.session.add(TaskActivity( + task_id=task.id, + user_id=current_user.id, + event='project_change', + details=f"Project changed from {old_project_id} to {new_project_id}" + )) + + updated_count += 1 + + except Exception: + skipped_count += 1 + + if updated_count > 0: + if not safe_commit('bulk_move_project', {'count': updated_count, 'project_id': new_project_id}): + flash('Could not move tasks due to a database error', 'error') + return redirect(url_for('tasks.list_tasks')) + + flash(f'Successfully moved {updated_count} task{"s" if updated_count != 1 else ""} to {new_project.name}', 'success') + + if skipped_count > 0: + flash(f'Skipped {skipped_count} task{"s" if skipped_count != 1 else ""} (no permission)', 'warning') + + return redirect(url_for('tasks.list_tasks')) + + +@tasks_bp.route('/tasks/export') +@login_required +def export_tasks(): + """Export tasks to CSV""" + # Get the same filters as the list view + status = request.args.get('status', '') + priority = request.args.get('priority', '') + project_id = request.args.get('project_id', type=int) + assigned_to = request.args.get('assigned_to', type=int) + search = request.args.get('search', '').strip() + overdue_param = request.args.get('overdue', '').strip().lower() + overdue = overdue_param in ['1', 'true', 'on', 'yes'] + + query = Task.query + + # Apply filters (same as list_tasks) + if status: + query = query.filter_by(status=status) + + if priority: + query = query.filter_by(priority=priority) + + if project_id: + query = query.filter_by(project_id=project_id) + + if assigned_to: + query = query.filter_by(assigned_to=assigned_to) + + if search: + like = f"%{search}%" + query = query.filter( + db.or_( + Task.name.ilike(like), + Task.description.ilike(like) + ) + ) + + # Overdue filter + if overdue: + today_local = now_in_app_timezone().date() + query = query.filter( + Task.due_date < today_local, + Task.status.in_(['todo', 'in_progress', 'review']) + ) + + # Show user's tasks first, then others + if not current_user.is_admin: + query = query.filter( + db.or_( + Task.assigned_to == current_user.id, + Task.created_by == current_user.id + ) + ) + + tasks = query.order_by(Task.priority.desc(), Task.due_date.asc(), Task.created_at.asc()).all() + + # Create CSV in memory + output = io.StringIO() + writer = csv.writer(output) + + # Write header + writer.writerow([ + 'ID', + 'Name', + 'Description', + 'Project', + 'Status', + 'Priority', + 'Assigned To', + 'Created By', + 'Due Date', + 'Estimated Hours', + 'Created At', + 'Updated At' + ]) + + # Write task data + for task in tasks: + writer.writerow([ + task.id, + task.name, + task.description or '', + task.project.name if task.project else '', + task.status, + task.priority, + task.assigned_user.display_name if task.assigned_user else '', + task.creator.display_name if task.creator else '', + task.due_date.strftime('%Y-%m-%d') if task.due_date else '', + task.estimated_hours or '', + task.created_at.strftime('%Y-%m-%d %H:%M:%S') if task.created_at else '', + task.updated_at.strftime('%Y-%m-%d %H:%M:%S') if task.updated_at else '' + ]) + + # Create response + output.seek(0) + return Response( + output.getvalue(), + mimetype='text/csv', + headers={ + 'Content-Disposition': f'attachment; filename=tasks_export_{datetime.now().strftime("%Y%m%d_%H%M%S")}.csv' + } + ) + + @tasks_bp.route('/tasks/my-tasks') @login_required def my_tasks(): diff --git a/app/routes/timer.py b/app/routes/timer.py index 08407b91..90b705e5 100644 --- a/app/routes/timer.py +++ b/app/routes/timer.py @@ -2,7 +2,7 @@ from flask import Blueprint, render_template, request, redirect, url_for, flash, from flask_babel import gettext as _ from flask_login import login_required, current_user from app import db, socketio, log_event, track_event -from app.models import User, Project, TimeEntry, Task, Settings +from app.models import User, Project, TimeEntry, Task, Settings, Activity from app.utils.timezone import parse_local_datetime, utc_to_local from datetime import datetime import json @@ -21,7 +21,27 @@ def start_timer(): project_id = request.form.get('project_id', type=int) task_id = request.form.get('task_id', type=int) notes = request.form.get('notes', '').strip() - current_app.logger.info("POST /timer/start user=%s project_id=%s task_id=%s", current_user.username, project_id, task_id) + template_id = request.form.get('template_id', type=int) + current_app.logger.info("POST /timer/start user=%s project_id=%s task_id=%s template_id=%s", current_user.username, project_id, task_id, template_id) + + # Load template data if template_id is provided + if template_id: + from app.models import TimeEntryTemplate + template = TimeEntryTemplate.query.filter_by( + id=template_id, + user_id=current_user.id + ).first() + if template: + # Override with template values if not explicitly set + if not project_id and template.project_id: + project_id = template.project_id + if not task_id and template.task_id: + task_id = template.task_id + if not notes and template.default_notes: + notes = template.default_notes + # Mark template as used + template.record_usage() + db.session.commit() if not project_id: flash('Project is required', 'error') @@ -87,6 +107,19 @@ def start_timer(): "has_description": bool(notes) }) + # Log activity + Activity.log( + user_id=current_user.id, + action='started', + entity_type='time_entry', + entity_id=new_timer.id, + entity_name=f'{project.name}' + (f' - {task.name}' if task else ''), + description=f'Started timer for {project.name}' + (f' - {task.name}' if task else ''), + extra_data={'project_id': project_id, 'task_id': task_id}, + ip_address=request.remote_addr, + user_agent=request.headers.get('User-Agent') + ) + # Check if this is user's first timer (onboarding milestone) timer_count = TimeEntry.query.filter_by( user_id=current_user.id, @@ -121,6 +154,72 @@ def start_timer(): flash(f'Timer started for {project.name}', 'success') return redirect(url_for('main.dashboard')) +@timer_bp.route('/timer/start/from-template/', methods=['GET', 'POST']) +@login_required +def start_timer_from_template(template_id): + """Start a timer directly from a template""" + from app.models import TimeEntryTemplate + + # Load template + template = TimeEntryTemplate.query.filter_by( + id=template_id, + user_id=current_user.id + ).first_or_404() + + # Check if user already has an active timer + active_timer = current_user.active_timer + if active_timer: + flash('You already have an active timer. Stop it before starting a new one.', 'error') + return redirect(url_for('main.dashboard')) + + # Validate template has required data + if not template.project_id: + flash('Template must have a project to start a timer', 'error') + return redirect(url_for('time_entry_templates.list_templates')) + + # Check if project is active + project = Project.query.get(template.project_id) + if not project or project.status != 'active': + flash('Cannot start timer for this project', 'error') + return redirect(url_for('time_entry_templates.list_templates')) + + # Create new timer from template + from app.models.time_entry import local_now + new_timer = TimeEntry( + user_id=current_user.id, + project_id=template.project_id, + task_id=template.task_id, + start_time=local_now(), + notes=template.default_notes, + tags=template.tags, + source='auto', + billable=template.billable + ) + + db.session.add(new_timer) + + # Mark template as used + template.record_usage() + + if not safe_commit('start_timer_from_template', {'template_id': template_id}): + flash('Could not start timer due to a database error. Please check server logs.', 'error') + return redirect(url_for('time_entry_templates.list_templates')) + + # Track events + log_event("timer.started.from_template", + user_id=current_user.id, + template_id=template_id, + project_id=template.project_id) + track_event(current_user.id, "timer.started.from_template", { + "template_id": template_id, + "template_name": template.name, + "project_id": template.project_id, + "has_task": bool(template.task_id) + }) + + flash(f'Timer started from template "{template.name}"', 'success') + return redirect(url_for('main.dashboard')) + @timer_bp.route('/timer/start/') @login_required def start_timer_for_project(project_id): @@ -220,6 +319,21 @@ def stop_timer(): "duration_seconds": duration_seconds }) + # Log activity + project_name = active_timer.project.name if active_timer.project else 'No project' + task_name = active_timer.task.name if active_timer.task else None + Activity.log( + user_id=current_user.id, + action='stopped', + entity_type='time_entry', + entity_id=active_timer.id, + entity_name=f'{project_name}' + (f' - {task_name}' if task_name else ''), + description=f'Stopped timer for {project_name}' + (f' - {task_name}' if task_name else '') + f' - Duration: {active_timer.duration_formatted}', + extra_data={'duration_hours': active_timer.duration_hours, 'project_id': active_timer.project_id, 'task_id': active_timer.task_id}, + ip_address=request.remote_addr, + user_agent=request.headers.get('User-Agent') + ) + # Check if this is user's first completed time entry (onboarding milestone) entry_count = TimeEntry.query.filter_by( user_id=current_user.id @@ -425,6 +539,29 @@ def manual_entry(): # Get project_id and task_id from query parameters for pre-filling project_id = request.args.get('project_id', type=int) task_id = request.args.get('task_id', type=int) + template_id = request.args.get('template', type=int) + + # Load template data if template_id is provided + template_data = None + if template_id: + from app.models import TimeEntryTemplate + template = TimeEntryTemplate.query.filter_by( + id=template_id, + user_id=current_user.id + ).first() + if template: + template_data = { + 'project_id': template.project_id, + 'task_id': template.task_id, + 'notes': template.default_notes, + 'tags': template.tags, + 'billable': template.billable + } + # Override with template values if not explicitly set + if not project_id and template.project_id: + project_id = template.project_id + if not task_id and template.task_id: + task_id = template.task_id if request.method == 'POST': project_id = request.form.get('project_id', type=int) @@ -441,24 +578,24 @@ def manual_entry(): if not all([project_id, start_date, start_time, end_date, end_time]): flash('All fields are required', 'error') return render_template('timer/manual_entry.html', projects=active_projects, - selected_project_id=project_id, selected_task_id=task_id) + selected_project_id=project_id, selected_task_id=task_id, template_data=template_data) # Check if project exists project = Project.query.get(project_id) if not project: flash(_('Invalid project selected'), 'error') return render_template('timer/manual_entry.html', projects=active_projects, - selected_project_id=project_id, selected_task_id=task_id) + selected_project_id=project_id, selected_task_id=task_id, template_data=template_data) # Check if project is active (not archived or inactive) if project.status == 'archived': flash(_('Cannot create time entries for an archived project. Please unarchive the project first.'), 'error') return render_template('timer/manual_entry.html', projects=active_projects, - selected_project_id=project_id, selected_task_id=task_id) + selected_project_id=project_id, selected_task_id=task_id, template_data=template_data) elif project.status != 'active': flash(_('Cannot create time entries for an inactive project'), 'error') return render_template('timer/manual_entry.html', projects=active_projects, - selected_project_id=project_id, selected_task_id=task_id) + selected_project_id=project_id, selected_task_id=task_id, template_data=template_data) # Validate task if provided if task_id: @@ -466,7 +603,7 @@ def manual_entry(): if not task: flash('Invalid task selected', 'error') return render_template('timer/manual_entry.html', projects=active_projects, - selected_project_id=project_id, selected_task_id=task_id) + selected_project_id=project_id, selected_task_id=task_id, template_data=template_data) # Parse datetime with timezone awareness try: @@ -475,13 +612,13 @@ def manual_entry(): except ValueError: flash('Invalid date/time format', 'error') return render_template('timer/manual_entry.html', projects=active_projects, - selected_project_id=project_id, selected_task_id=task_id) + selected_project_id=project_id, selected_task_id=task_id, template_data=template_data) # Validate time range if end_time_parsed <= start_time_parsed: flash('End time must be after start time', 'error') return render_template('timer/manual_entry.html', projects=active_projects, - selected_project_id=project_id, selected_task_id=task_id) + selected_project_id=project_id, selected_task_id=task_id, template_data=template_data) # Create manual entry entry = TimeEntry( @@ -500,7 +637,7 @@ def manual_entry(): if not safe_commit('manual_entry', {'user_id': current_user.id, 'project_id': project_id, 'task_id': task_id}): flash('Could not create manual entry due to a database error. Please check server logs.', 'error') return render_template('timer/manual_entry.html', projects=active_projects, - selected_project_id=project_id, selected_task_id=task_id) + selected_project_id=project_id, selected_task_id=task_id, template_data=template_data) if task_id: task = Task.query.get(task_id) @@ -512,7 +649,7 @@ def manual_entry(): return redirect(url_for('main.dashboard')) return render_template('timer/manual_entry.html', projects=active_projects, - selected_project_id=project_id, selected_task_id=task_id) + selected_project_id=project_id, selected_task_id=task_id, template_data=template_data) @timer_bp.route('/timer/manual/') @login_required @@ -773,3 +910,121 @@ def duplicate_timer(timer_id): prefill_billable=timer.billable, is_duplicate=True, original_entry=timer) + +@timer_bp.route('/timer/resume/') +@login_required +def resume_timer(timer_id): + """Resume an existing time entry - starts a new active timer with same properties""" + timer = TimeEntry.query.get_or_404(timer_id) + + # Check if user can resume this timer + if timer.user_id != current_user.id and not current_user.is_admin: + flash('You can only resume your own timers', 'error') + return redirect(url_for('main.dashboard')) + + # Check if user already has an active timer + active_timer = current_user.active_timer + if active_timer: + flash('You already have an active timer. Stop it before resuming another one.', 'error') + current_app.logger.info("Resume timer blocked: user already has an active timer") + return redirect(url_for('main.dashboard')) + + # Check if project is still active + project = Project.query.get(timer.project_id) + if not project: + flash(_('Project no longer exists'), 'error') + return redirect(url_for('main.dashboard')) + + if project.status == 'archived': + flash(_('Cannot start timer for an archived project. Please unarchive the project first.'), 'error') + return redirect(url_for('main.dashboard')) + elif project.status != 'active': + flash(_('Cannot start timer for an inactive project'), 'error') + return redirect(url_for('main.dashboard')) + + # Validate task if it exists + if timer.task_id: + task = Task.query.filter_by(id=timer.task_id, project_id=timer.project_id).first() + if not task: + # Task was deleted, continue without it + task_id = None + else: + task_id = timer.task_id + else: + task_id = None + + # Create new timer with copied properties + from app.models.time_entry import local_now + new_timer = TimeEntry( + user_id=current_user.id, + project_id=timer.project_id, + task_id=task_id, + start_time=local_now(), + notes=timer.notes, + tags=timer.tags, + source='auto', + billable=timer.billable + ) + + db.session.add(new_timer) + if not safe_commit('resume_timer', {'user_id': current_user.id, 'original_timer_id': timer_id, 'project_id': timer.project_id}): + flash('Could not resume timer due to a database error. Please check server logs.', 'error') + return redirect(url_for('main.dashboard')) + + current_app.logger.info("Resumed timer id=%s from original timer=%s for user=%s project_id=%s", + new_timer.id, timer_id, current_user.username, timer.project_id) + + # Track timer resumed event + log_event("timer.resumed", + user_id=current_user.id, + time_entry_id=new_timer.id, + original_timer_id=timer_id, + project_id=timer.project_id, + task_id=task_id, + description=timer.notes) + track_event(current_user.id, "timer.resumed", { + "time_entry_id": new_timer.id, + "original_timer_id": timer_id, + "project_id": timer.project_id, + "task_id": task_id, + "has_notes": bool(timer.notes), + "has_tags": bool(timer.tags) + }) + + # Log activity + project_name = project.name + task = Task.query.get(task_id) if task_id else None + task_name = task.name if task else None + Activity.log( + user_id=current_user.id, + action='started', + entity_type='time_entry', + entity_id=new_timer.id, + entity_name=f'{project_name}' + (f' - {task_name}' if task_name else ''), + description=f'Resumed timer for {project_name}' + (f' - {task_name}' if task_name else ''), + extra_data={'project_id': timer.project_id, 'task_id': task_id, 'resumed_from': timer_id}, + ip_address=request.remote_addr, + user_agent=request.headers.get('User-Agent') + ) + + # Emit WebSocket event for real-time updates + try: + payload = { + 'user_id': current_user.id, + 'timer_id': new_timer.id, + 'project_name': project_name, + 'start_time': new_timer.start_time.isoformat() + } + if task_id: + payload['task_id'] = task_id + payload['task_name'] = task_name + socketio.emit('timer_started', payload) + except Exception as e: + current_app.logger.warning("Socket emit failed for timer_resumed: %s", e) + + if task_name: + flash(f'Timer resumed for {project_name} - {task_name}', 'success') + else: + flash(f'Timer resumed for {project_name}', 'success') + + return redirect(url_for('main.dashboard')) diff --git a/app/routes/user.py b/app/routes/user.py index 09509232..32b5fe0b 100644 --- a/app/routes/user.py +++ b/app/routes/user.py @@ -213,3 +213,63 @@ def set_theme(): db.session.rollback() return jsonify({'error': str(e)}), 500 + +@user_bp.route('/api/language', methods=['POST']) +@login_required +def set_language(): + """Quick API endpoint to set language (for language switcher)""" + from flask import current_app, session + + try: + data = request.get_json() + language = data.get('language') + + # Get available languages from config + available_languages = current_app.config.get('LANGUAGES', {}) + + if language in available_languages: + # Update user preference + current_user.preferred_language = language + db.session.commit() + + # Also set in session for immediate effect + session['preferred_language'] = language + + return jsonify({ + 'success': True, + 'language': language, + 'message': _('Language updated successfully') + }) + + return jsonify({'error': _('Invalid language')}), 400 + + except Exception as e: + db.session.rollback() + return jsonify({'error': str(e)}), 500 + + +@user_bp.route('/set-language/') +def set_language_direct(language): + """Direct route to set language (for non-JS fallback)""" + from flask import current_app, session + + # Get available languages from config + available_languages = current_app.config.get('LANGUAGES', {}) + + if language in available_languages: + # Set in session for immediate effect + session['preferred_language'] = language + + # If user is logged in, update their preference + if current_user.is_authenticated: + current_user.preferred_language = language + db.session.commit() + flash(_('Language updated to %(language)s', language=available_languages[language]), 'success') + + # Redirect back to referring page or dashboard + next_page = request.referrer or url_for('main.dashboard') + return redirect(next_page) + + flash(_('Invalid language'), 'error') + return redirect(url_for('main.dashboard')) + diff --git a/app/static/commands.js b/app/static/commands.js index 4953b1d2..94c4ce25 100644 --- a/app/static/commands.js +++ b/app/static/commands.js @@ -50,16 +50,16 @@ async function stopTimerQuick(){ try { const active = await getActiveTimer(); - if (!active) { showToast('No active timer', 'warning'); return; } + if (!active) { showToast(window.i18n?.messages?.noActiveTimer || 'No active timer', 'warning'); return; } const token = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || ''; const res = await fetch('/timer/stop', { method: 'POST', headers: { 'X-CSRF-Token': token }, credentials: 'same-origin' }); if (res.ok) { - showToast('Timer stopped', 'info'); + showToast(window.i18n?.messages?.timerStopped || 'Timer stopped', 'info'); } else { - showToast('Failed to stop timer', 'danger'); + showToast(window.i18n?.messages?.timerStopFailed || 'Failed to stop timer', 'danger'); } } catch(e) { - showToast('Failed to stop timer', 'danger'); + showToast(window.i18n?.messages?.timerStopFailed || 'Failed to stop timer', 'danger'); } } diff --git a/app/static/css/rtl-support.css b/app/static/css/rtl-support.css new file mode 100644 index 00000000..b47f60fc --- /dev/null +++ b/app/static/css/rtl-support.css @@ -0,0 +1,218 @@ +/* RTL (Right-to-Left) Language Support */ +/* This file provides comprehensive RTL support for Arabic, Hebrew, and other RTL languages */ + +html[dir="rtl"] { + direction: rtl; +} + +/* Margin and Padding Reversals */ +html[dir="rtl"] .ml-1 { margin-left: 0; margin-right: 0.25rem; } +html[dir="rtl"] .mr-1 { margin-right: 0; margin-left: 0.25rem; } +html[dir="rtl"] .ml-2 { margin-left: 0; margin-right: 0.5rem; } +html[dir="rtl"] .mr-2 { margin-right: 0; margin-left: 0.5rem; } +html[dir="rtl"] .ml-3 { margin-left: 0; margin-right: 0.75rem; } +html[dir="rtl"] .mr-3 { margin-right: 0; margin-left: 0.75rem; } +html[dir="rtl"] .ml-4 { margin-left: 0; margin-right: 1rem; } +html[dir="rtl"] .mr-4 { margin-right: 0; margin-left: 1rem; } +html[dir="rtl"] .ml-6 { margin-left: 0; margin-right: 1.5rem; } +html[dir="rtl"] .mr-6 { margin-right: 0; margin-left: 1.5rem; } +html[dir="rtl"] .ml-8 { margin-left: 0; margin-right: 2rem; } +html[dir="rtl"] .mr-8 { margin-right: 0; margin-left: 2rem; } +html[dir="rtl"] .ml-auto { margin-left: 0; margin-right: auto; } +html[dir="rtl"] .mr-auto { margin-right: 0; margin-left: auto; } + +html[dir="rtl"] .pl-1 { padding-left: 0; padding-right: 0.25rem; } +html[dir="rtl"] .pr-1 { padding-right: 0; padding-left: 0.25rem; } +html[dir="rtl"] .pl-2 { padding-left: 0; padding-right: 0.5rem; } +html[dir="rtl"] .pr-2 { padding-right: 0; padding-left: 0.5rem; } +html[dir="rtl"] .pl-3 { padding-left: 0; padding-right: 0.75rem; } +html[dir="rtl"] .pr-3 { padding-right: 0; padding-left: 0.75rem; } +html[dir="rtl"] .pl-4 { padding-left: 0; padding-right: 1rem; } +html[dir="rtl"] .pr-4 { padding-right: 0; padding-left: 1rem; } +html[dir="rtl"] .pl-10 { padding-left: 0; padding-right: 2.5rem; } +html[dir="rtl"] .pr-10 { padding-right: 0; padding-left: 2.5rem; } +html[dir="rtl"] .pr-14 { padding-right: 0; padding-left: 3.5rem; } + +/* Text Alignment */ +html[dir="rtl"] .text-left { text-align: right; } +html[dir="rtl"] .text-right { text-align: left; } + +/* Positioning */ +html[dir="rtl"] .left-0 { left: auto; right: 0; } +html[dir="rtl"] .right-0 { right: auto; left: 0; } +html[dir="rtl"] .left-2 { left: auto; right: 0.5rem; } +html[dir="rtl"] .right-2 { right: auto; left: 0.5rem; } + +/* Sidebar Adjustments */ +html[dir="rtl"] #sidebar { + left: auto; + right: 0; +} + +html[dir="rtl"] #mainContent { + margin-left: 0; + margin-right: 16rem; +} + +html[dir="rtl"] .sidebar-collapsed #sidebar { + right: -12rem; +} + +/* Mobile Responsiveness */ +@media (max-width: 1024px) { + html[dir="rtl"] #mainContent { + margin-right: 0; + } +} + +/* Border Radius Reversals */ +html[dir="rtl"] .rounded-l { border-radius: 0 0.25rem 0.25rem 0; } +html[dir="rtl"] .rounded-r { border-radius: 0.25rem 0 0 0.25rem; } +html[dir="rtl"] .rounded-tl { border-top-left-radius: 0; border-top-right-radius: 0.25rem; } +html[dir="rtl"] .rounded-tr { border-top-right-radius: 0; border-top-left-radius: 0.25rem; } +html[dir="rtl"] .rounded-bl { border-bottom-left-radius: 0; border-bottom-right-radius: 0.25rem; } +html[dir="rtl"] .rounded-br { border-bottom-right-radius: 0; border-bottom-left-radius: 0.25rem; } + +/* Border Reversals */ +html[dir="rtl"] .border-l { border-left: 0; border-right: 1px solid; } +html[dir="rtl"] .border-r { border-right: 0; border-left: 1px solid; } + +/* Transform Reversals */ +html[dir="rtl"] .rotate-90 { transform: rotate(-90deg); } +html[dir="rtl"] .rotate-180 { transform: rotate(-180deg); } +html[dir="rtl"] .rotate-270 { transform: rotate(-270deg); } + +/* Flex Direction */ +html[dir="rtl"] .flex-row { flex-direction: row-reverse; } +html[dir="rtl"] .flex-row-reverse { flex-direction: row; } + +/* Icons and Chevrons */ +html[dir="rtl"] .fa-chevron-left::before { content: "\f054"; } /* chevron-right */ +html[dir="rtl"] .fa-chevron-right::before { content: "\f053"; } /* chevron-left */ +html[dir="rtl"] .fa-arrow-left::before { content: "\f061"; } /* arrow-right */ +html[dir="rtl"] .fa-arrow-right::before { content: "\f060"; } /* arrow-left */ + +/* Dropdown Menus */ +html[dir="rtl"] .dropdown-menu { + left: auto; + right: 0; +} + +html[dir="rtl"] [id$="Dropdown"] { + left: auto; + right: 0; +} + +/* Search and Input Fields */ +html[dir="rtl"] .search-enhanced .search-icon { + left: auto; + right: 0.75rem; +} + +html[dir="rtl"] .search-enhanced .search-actions { + right: auto; + left: 0.5rem; +} + +/* Forms */ +html[dir="rtl"] input[type="text"], +html[dir="rtl"] input[type="email"], +html[dir="rtl"] input[type="password"], +html[dir="rtl"] input[type="number"], +html[dir="rtl"] input[type="search"], +html[dir="rtl"] textarea, +html[dir="rtl"] select { + text-align: right; +} + +/* Tables */ +html[dir="rtl"] table { + direction: rtl; +} + +html[dir="rtl"] th, +html[dir="rtl"] td { + text-align: right; +} + +/* Tooltips */ +html[dir="rtl"] .tooltip { + direction: rtl; +} + +/* Cards and Containers */ +html[dir="rtl"] .card { + direction: rtl; +} + +/* Buttons with Icons */ +html[dir="rtl"] .btn i { + margin-left: 0.5rem; + margin-right: 0; +} + +html[dir="rtl"] .btn i:first-child { + margin-left: 0; + margin-right: 0.5rem; +} + +html[dir="rtl"] .btn i:last-child { + margin-right: 0; + margin-left: 0.5rem; +} + +/* Calendar and Date Pickers */ +html[dir="rtl"] .calendar, +html[dir="rtl"] .datepicker { + direction: rtl; +} + +/* Progress Bars */ +html[dir="rtl"] .progress-bar { + direction: rtl; +} + +/* Breadcrumbs */ +html[dir="rtl"] .breadcrumb-item + .breadcrumb-item::before { + padding-right: 0; + padding-left: 0.5rem; + content: "\\"; +} + +/* Navigation */ +html[dir="rtl"] nav ul { + padding-left: 0; + padding-right: 0; +} + +html[dir="rtl"] nav li { + text-align: right; +} + +/* Modal Dialogs */ +html[dir="rtl"] .modal { + direction: rtl; +} + +html[dir="rtl"] .modal-header, +html[dir="rtl"] .modal-body, +html[dir="rtl"] .modal-footer { + text-align: right; +} + +/* Alerts and Notifications */ +html[dir="rtl"] .alert { + direction: rtl; + text-align: right; +} + +html[dir="rtl"] .toast-notification { + direction: rtl; + text-align: right; +} + +/* Badges */ +html[dir="rtl"] .badge { + direction: rtl; +} + diff --git a/app/static/idle.js b/app/static/idle.js index 9d0407ae..eb72f5d7 100644 --- a/app/static/idle.js +++ b/app/static/idle.js @@ -31,7 +31,7 @@ async function stopAt(ts){ try { const r = await fetch('/api/timer/stop_at', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ stop_time: new Date(ts).toISOString() }) }); - if (r.ok){ showToast('Timer stopped due to inactivity', 'warning'); location.reload(); } + if (r.ok){ showToast(window.i18n?.messages?.timerStoppedInactivity || 'Timer stopped due to inactivity', 'warning'); location.reload(); } } catch(e) {} } diff --git a/app/static/keyboard-shortcuts-enhanced.js b/app/static/keyboard-shortcuts-enhanced.js index 4c737ff8..01fc5c38 100644 --- a/app/static/keyboard-shortcuts-enhanced.js +++ b/app/static/keyboard-shortcuts-enhanced.js @@ -726,12 +726,12 @@ }); if (res.ok) { - this.showToast('Timer stopped', 'info'); + this.showToast(window.i18n?.messages?.timerStopped || 'Timer stopped', 'info'); } else { - this.showToast('Failed to stop timer', 'warning'); + this.showToast(window.i18n?.messages?.timerStopFailed || 'Failed to stop timer', 'warning'); } } catch (e) { - this.showToast('Error stopping timer', 'danger'); + this.showToast(window.i18n?.messages?.errorStoppingTimer || 'Error stopping timer', 'danger'); } } @@ -785,7 +785,7 @@ if (form) { form.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true })); } else { - this.showToast('No form to save', 'warning'); + this.showToast(window.i18n?.messages?.noFormToSave || 'No form to save', 'warning'); } } diff --git a/app/static/keyboard-shortcuts.js b/app/static/keyboard-shortcuts.js index 2759b983..87f77315 100644 --- a/app/static/keyboard-shortcuts.js +++ b/app/static/keyboard-shortcuts.js @@ -494,7 +494,7 @@ if (timerBtn) { timerBtn.click(); } else { - window.TimeTrackerUI.showToast('No timer found', 'warning'); + window.TimeTrackerUI.showToast(window.i18n?.messages?.noTimerFound || 'No timer found', 'warning'); } } diff --git a/app/static/toast-notifications.js b/app/static/toast-notifications.js index 79ff9e26..50408d38 100644 --- a/app/static/toast-notifications.js +++ b/app/static/toast-notifications.js @@ -226,6 +226,14 @@ class ToastNotificationManager { } getDefaultTitle(type) { + // Try to get translated titles from window.i18n if available + // These are injected by the backend in base template + if (window.i18n && window.i18n.toast) { + const titles = window.i18n.toast; + return titles[type] || titles.info || 'Information'; + } + + // Fallback to English if translations not loaded const titles = { success: 'Success', error: 'Error', diff --git a/app/templates/admin/api_tokens.html b/app/templates/admin/api_tokens.html index c35890f5..b4d5edeb 100644 --- a/app/templates/admin/api_tokens.html +++ b/app/templates/admin/api_tokens.html @@ -1,21 +1,21 @@ {% extends "base.html" %} +{% from "components/ui.html" import page_header, breadcrumb_nav, button, filter_badge %} {% block title %}API Tokens - Admin{% endblock %} {% block content %} -
-
-
-

API Tokens

-

Manage REST API authentication tokens

-
- -
+{% set breadcrumbs = [ + {'text': 'Admin', 'url': url_for('admin.admin_dashboard')}, + {'text': 'API Tokens'} +] %} + +{{ page_header( + icon_class='fas fa-key', + title_text='API Tokens', + subtitle_text='Manage REST API authentication tokens', + breadcrumbs=breadcrumbs, + actions_html='' +) }}
diff --git a/app/templates/admin/backups.html b/app/templates/admin/backups.html index 34cb6496..70be9966 100644 --- a/app/templates/admin/backups.html +++ b/app/templates/admin/backups.html @@ -1,15 +1,21 @@ {% extends "base.html" %} +{% from "components/ui.html" import page_header, breadcrumb_nav, button, filter_badge %} {% block title %}Backups Management - Admin{% endblock %} {% block content %} -
-
-
-

Backups Management

-

Create, download, and restore database backups

-
-
+{% set breadcrumbs = [ + {'text': 'Admin', 'url': url_for('admin.admin_dashboard')}, + {'text': 'Backups Management'} +] %} + +{{ page_header( + icon_class='fas fa-database', + title_text='Backups Management', + subtitle_text='Create, download, and restore database backups', + breadcrumbs=breadcrumbs, + actions_html=None +) }}
diff --git a/app/templates/admin/dashboard.html b/app/templates/admin/dashboard.html index 569a9199..bf98a057 100644 --- a/app/templates/admin/dashboard.html +++ b/app/templates/admin/dashboard.html @@ -1,13 +1,19 @@ {% extends "base.html" %} {% from "components/cards.html" import info_card %} +{% from "components/ui.html" import page_header, breadcrumb_nav, button, filter_badge %} {% block content %} -
-
-

Admin Dashboard

-

System overview and management.

-
-
+{% set breadcrumbs = [ + {'text': 'Admin Dashboard'} +] %} + +{{ page_header( + icon_class='fas fa-cog', + title_text='Admin Dashboard', + subtitle_text='System overview and management', + breadcrumbs=breadcrumbs, + actions_html=None +) }}
{{ info_card("Total Users", stats.total_users, "All time") }} diff --git a/app/templates/admin/oidc_debug.html b/app/templates/admin/oidc_debug.html index 831c3376..0d1a9abc 100644 --- a/app/templates/admin/oidc_debug.html +++ b/app/templates/admin/oidc_debug.html @@ -1,27 +1,28 @@ {% extends "base.html" %} +{% from "components/ui.html" import page_header, breadcrumb_nav, button, filter_badge %} {% block title %}{{ _('OIDC Debug Dashboard') }} - {{ app_name }}{% endblock %} {% block content %} -
-
-

{{ _('OIDC Debug Dashboard') }}

-

{{ _('Inspect configuration, provider metadata and OIDC users') }}

-
- -
+{% set breadcrumbs = [ + {'text': _('Admin'), 'url': url_for('admin.admin_dashboard')}, + {'text': _('OIDC Settings')} +] %} + +{{ page_header( + icon_class='fas fa-shield-alt', + title_text=_('OIDC Debug Dashboard'), + subtitle_text=_('Inspect configuration, provider metadata and OIDC users'), + breadcrumbs=breadcrumbs, + actions_html='' + _('Test Configuration') + '' +) }}
-
+

{{ _('OIDC Configuration') }}

- {{ _('Test Configuration') }}
diff --git a/app/templates/admin/permissions/list.html b/app/templates/admin/permissions/list.html index 7e8b76cb..51b28108 100644 --- a/app/templates/admin/permissions/list.html +++ b/app/templates/admin/permissions/list.html @@ -1,19 +1,20 @@ {% extends "base.html" %} +{% from "components/ui.html" import page_header, breadcrumb_nav, button, filter_badge %} {% block content %} - +{% set breadcrumbs = [ + {'text': _('Admin'), 'url': url_for('admin.admin_dashboard')}, + {'text': _('Roles & Permissions'), 'url': url_for('permissions.list_roles')}, + {'text': _('System Permissions')} +] %} -
-

{{ _('System Permissions') }}

-

{{ _('All available permissions in the system') }}

-
+{{ page_header( + icon_class='fas fa-lock', + title_text=_('System Permissions'), + subtitle_text=_('All available permissions in the system'), + breadcrumbs=breadcrumbs, + actions_html='' + _('Back to Roles') + '' +) }}
{% for category, permissions in permissions_by_category.items() %} diff --git a/app/templates/admin/roles/list.html b/app/templates/admin/roles/list.html index e3e3c4b1..08e2ba3f 100644 --- a/app/templates/admin/roles/list.html +++ b/app/templates/admin/roles/list.html @@ -1,18 +1,23 @@ {% extends "base.html" %} +{% from "components/ui.html" import page_header, breadcrumb_nav, button, filter_badge %} {% block content %} -
-
-

{{ _('Roles & Permissions') }}

-

{{ _('Manage roles and their permissions') }}

-
-
- {{ _('View Permissions') }} - {% if current_user.is_admin or has_permission('manage_roles') %} - {{ _('Create Role') }} - {% endif %} -
-
+{% set breadcrumbs = [ + {'text': _('Admin'), 'url': url_for('admin.admin_dashboard')}, + {'text': _('Roles & Permissions')} +] %} + +{{ page_header( + icon_class='fas fa-shield-alt', + title_text=_('Roles & Permissions'), + subtitle_text=_('Manage roles and their permissions'), + breadcrumbs=breadcrumbs, + actions_html='' + + '
' + + '' + _('View Permissions') + '' + + ('' + _('Create Role') + '' if (current_user.is_admin or has_permission('manage_roles')) else '') + + '
' +) }}
diff --git a/app/templates/admin/settings.html b/app/templates/admin/settings.html index cd974c88..82137d54 100644 --- a/app/templates/admin/settings.html +++ b/app/templates/admin/settings.html @@ -1,12 +1,19 @@ {% extends "base.html" %} +{% from "components/ui.html" import page_header, breadcrumb_nav, button, filter_badge %} {% block content %} -
-
-

Settings

-

Configure system-wide application settings.

-
-
+{% set breadcrumbs = [ + {'text': 'Admin', 'url': url_for('admin.admin_dashboard')}, + {'text': 'System Settings'} +] %} + +{{ page_header( + icon_class='fas fa-sliders-h', + title_text='System Settings', + subtitle_text='Configure system-wide application settings', + breadcrumbs=breadcrumbs, + actions_html=None +) }}
diff --git a/app/templates/admin/system_info.html b/app/templates/admin/system_info.html index 2f96c6c6..e3db5196 100644 --- a/app/templates/admin/system_info.html +++ b/app/templates/admin/system_info.html @@ -1,13 +1,20 @@ {% extends "base.html" %} {% from "components/cards.html" import info_card %} +{% from "components/ui.html" import page_header, breadcrumb_nav, button, filter_badge %} {% block content %} -
-
-

System Information

-

Key metrics and statistics about the application.

-
-
+{% set breadcrumbs = [ + {'text': 'Admin', 'url': url_for('admin.admin_dashboard')}, + {'text': 'System Information'} +] %} + +{{ page_header( + icon_class='fas fa-info-circle', + title_text='System Information', + subtitle_text='Key metrics and statistics about the application', + breadcrumbs=breadcrumbs, + actions_html=None +) }}
{{ info_card("Total Users", total_users, "All time") }} diff --git a/app/templates/admin/users.html b/app/templates/admin/users.html index 0e5eecc3..15123fd1 100644 --- a/app/templates/admin/users.html +++ b/app/templates/admin/users.html @@ -1,13 +1,19 @@ {% extends "base.html" %} +{% from "components/ui.html" import page_header, breadcrumb_nav, button, filter_badge %} {% block content %} -
-
-

Manage Users

-

Add, edit, or remove user accounts.

-
- Create User -
+{% set breadcrumbs = [ + {'text': 'Admin', 'url': url_for('admin.admin_dashboard')}, + {'text': 'Users'} +] %} + +{{ page_header( + icon_class='fas fa-users-cog', + title_text='Manage Users', + subtitle_text='Add, edit, or remove user accounts', + breadcrumbs=breadcrumbs, + actions_html='Create User' +) }}
diff --git a/app/templates/analytics/dashboard.html b/app/templates/analytics/dashboard.html index 98be6b2d..db5ab940 100644 --- a/app/templates/analytics/dashboard.html +++ b/app/templates/analytics/dashboard.html @@ -1,26 +1,36 @@ {% extends "base.html" %} +{% from "components/ui.html" import page_header, breadcrumb_nav, button, filter_badge %} {% block title %}{{ _('Analytics Dashboard') }} - {{ app_name }}{% endblock %} {% block content %} +{% set breadcrumbs = [ + {'text': _('Analytics')} +] %} + +{% set analytics_actions %} +
+ + +
+{% endset %} + +{{ page_header( + icon_class='fas fa-chart-line', + title_text=_('Analytics Dashboard'), + subtitle_text=_('Key metrics and insights about your time tracking'), + breadcrumbs=breadcrumbs, + actions_html=analytics_actions +) }} +
- {% from "_components.html" import page_header %} -
-
- {% set actions %} - - - {% endset %} - {{ page_header('fas fa-chart-line', _('Analytics Dashboard'), _('Key metrics and insights'), actions) }} -
-
diff --git a/app/templates/base.html b/app/templates/base.html index 339c1c5c..f43ab453 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -1,5 +1,5 @@ - + @@ -30,6 +30,20 @@