Files
TimeTracker/app/models/user.py
T
Dries Peeters dcbdfcc288 feat: Add client custom fields, link templates, UI feature flags, and client billing support
Add client custom fields (JSON) for flexible data storage

Implement link templates system for dynamic URL generation from custom fields

Add client_id support to time entries for direct client billing (project_id now nullable)

Implement user-level UI feature flags for customizable navigation visibility

Add system-wide UI feature flags in settings for admin control

Fix metadata column naming (user_badges.achievement_metadata, leaderboard_entries.entry_metadata)

Update templates and routes to support new features

Add comprehensive UI feature flag management in admin and user settings

Enhance client views with custom fields and link template integration

Update time entry forms to support client billing

Add tests for system UI flags

Migrations: 075-080 for custom fields, link templates, UI flags, client billing, and metadata fixes
2025-11-29 06:17:07 +01:00

350 lines
15 KiB
Python

from datetime import datetime
from flask_login import UserMixin
from werkzeug.security import generate_password_hash, check_password_hash
from app import db
import os
class User(UserMixin, db.Model):
"""User model for username-based authentication"""
__tablename__ = "users"
__table_args__ = (db.UniqueConstraint("oidc_issuer", "oidc_sub", name="uq_users_oidc_issuer_sub"),)
id = db.Column(db.Integer, primary_key=True)
username = db.Column(db.String(80), unique=True, nullable=False, index=True)
email = db.Column(db.String(200), nullable=True, index=True)
full_name = db.Column(db.String(200), nullable=True)
role = db.Column(db.String(20), default="user", nullable=False) # 'user' or 'admin'
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
last_login = db.Column(db.DateTime, nullable=True)
is_active = db.Column(db.Boolean, default=True, nullable=False)
theme_preference = db.Column(db.String(10), default=None, nullable=True) # 'light' | 'dark' | None=system
preferred_language = db.Column(db.String(8), default=None, nullable=True) # e.g., 'en', 'de'
oidc_sub = db.Column(db.String(255), nullable=True)
oidc_issuer = db.Column(db.String(255), nullable=True)
avatar_filename = db.Column(db.String(255), nullable=True)
password_hash = db.Column(db.String(255), nullable=True)
password_change_required = db.Column(db.Boolean, default=False, nullable=False) # Force password change on first login
# User preferences and settings
email_notifications = db.Column(db.Boolean, default=True, nullable=False) # Enable/disable email notifications
notification_overdue_invoices = db.Column(db.Boolean, default=True, nullable=False) # Notify about overdue invoices
notification_task_assigned = db.Column(db.Boolean, default=True, nullable=False) # Notify when assigned to task
notification_task_comments = db.Column(db.Boolean, default=True, nullable=False) # Notify about task comments
notification_weekly_summary = db.Column(db.Boolean, default=False, nullable=False) # Send weekly time summary
timezone = db.Column(db.String(50), nullable=True) # User-specific timezone override
date_format = db.Column(db.String(20), default="YYYY-MM-DD", nullable=False) # Date format preference
time_format = db.Column(db.String(10), default="24h", nullable=False) # '12h' or '24h'
week_start_day = db.Column(db.Integer, default=1, nullable=False) # 0=Sunday, 1=Monday, etc.
# Time rounding preferences
time_rounding_enabled = db.Column(db.Boolean, default=True, nullable=False) # Enable/disable time rounding
time_rounding_minutes = db.Column(db.Integer, default=1, nullable=False) # Rounding interval: 1, 5, 10, 15, 30, 60
time_rounding_method = db.Column(db.String(10), default="nearest", nullable=False) # 'nearest', 'up', or 'down'
# Overtime settings
standard_hours_per_day = db.Column(
db.Float, default=8.0, nullable=False
) # Standard working hours per day for overtime calculation
# Client portal settings
client_portal_enabled = db.Column(db.Boolean, default=False, nullable=False) # Enable/disable client portal access
client_id = db.Column(
db.Integer, db.ForeignKey("clients.id", ondelete="SET NULL"), nullable=True, index=True
) # Link user to a client for portal access
# UI feature flags - allow users to customize which features are visible
# All default to True (enabled) for backward compatibility
# Calendar section
ui_show_calendar = db.Column(db.Boolean, default=True, nullable=False) # Show/hide Calendar section
# Time Tracking section items
ui_show_project_templates = db.Column(db.Boolean, default=True, nullable=False) # Show/hide Project Templates
ui_show_gantt_chart = db.Column(db.Boolean, default=True, nullable=False) # Show/hide Gantt Chart
ui_show_kanban_board = db.Column(db.Boolean, default=True, nullable=False) # Show/hide Kanban Board
ui_show_weekly_goals = db.Column(db.Boolean, default=True, nullable=False) # Show/hide Weekly Goals
# CRM section
ui_show_quotes = db.Column(db.Boolean, default=True, nullable=False) # Show/hide Quotes
# Finance & Expenses section items
ui_show_reports = db.Column(db.Boolean, default=True, nullable=False) # Show/hide Reports
ui_show_report_builder = db.Column(db.Boolean, default=True, nullable=False) # Show/hide Report Builder
ui_show_scheduled_reports = db.Column(db.Boolean, default=True, nullable=False) # Show/hide Scheduled Reports
ui_show_invoice_approvals = db.Column(db.Boolean, default=True, nullable=False) # Show/hide Invoice Approvals
ui_show_payment_gateways = db.Column(db.Boolean, default=True, nullable=False) # Show/hide Payment Gateways
ui_show_recurring_invoices = db.Column(db.Boolean, default=True, nullable=False) # Show/hide Recurring Invoices
ui_show_payments = db.Column(db.Boolean, default=True, nullable=False) # Show/hide Payments
ui_show_mileage = db.Column(db.Boolean, default=True, nullable=False) # Show/hide Mileage
ui_show_per_diem = db.Column(db.Boolean, default=True, nullable=False) # Show/hide Per Diem
ui_show_budget_alerts = db.Column(db.Boolean, default=True, nullable=False) # Show/hide Budget Alerts
# Inventory section
ui_show_inventory = db.Column(db.Boolean, default=True, nullable=False) # Show/hide Inventory section
# Analytics
ui_show_analytics = db.Column(db.Boolean, default=True, nullable=False) # Show/hide Analytics
# Tools & Data section
ui_show_tools = db.Column(db.Boolean, default=True, nullable=False) # Show/hide Tools & Data section
# Relationships
time_entries = db.relationship("TimeEntry", backref="user", lazy="dynamic", cascade="all, delete-orphan")
project_costs = db.relationship("ProjectCost", backref="user", lazy="dynamic", cascade="all, delete-orphan")
favorite_projects = db.relationship(
"Project",
secondary="user_favorite_projects",
lazy="dynamic",
backref=db.backref("favorited_by", lazy="dynamic"),
)
roles = db.relationship("Role", secondary="user_roles", lazy="joined", backref=db.backref("users", lazy="dynamic"))
client = db.relationship("Client", backref="portal_users", lazy="joined")
def __init__(self, username, role="user", email=None, full_name=None):
self.username = username.lower().strip()
self.role = role
self.email = email or None
self.full_name = full_name or None
# Set default for standard_hours_per_day if not set by SQLAlchemy
if not hasattr(self, "standard_hours_per_day") or self.standard_hours_per_day is None:
self.standard_hours_per_day = 8.0
def __repr__(self):
return f"<User {self.username}>"
def set_password(self, password):
"""
Set the user's password hash.
For OIDC users, password is optional.
"""
if password:
self.password_hash = generate_password_hash(password)
else:
self.password_hash = None
def check_password(self, password):
"""
Check if the provided password matches the user's password hash.
Returns False if no password is set or if password doesn't match.
"""
if not self.password_hash or not password:
return False
return check_password_hash(self.password_hash, password)
@property
def has_password(self):
"""Check if user has a password set"""
return bool(self.password_hash)
@property
def is_admin(self):
"""Check if user is an admin"""
# Backward compatibility: check legacy role field first
if self.role == "admin":
return True
# Check if user has any admin role
return any(role.name in ["admin", "super_admin"] for role in self.roles)
@property
def active_timer(self):
"""Get the user's currently active timer"""
from .time_entry import TimeEntry
return TimeEntry.query.filter_by(user_id=self.id, end_time=None).first()
@property
def total_hours(self):
"""Calculate total hours worked by this user"""
from .time_entry import TimeEntry
total_seconds = (
db.session.query(db.func.sum(TimeEntry.duration_seconds))
.filter(TimeEntry.user_id == self.id, TimeEntry.end_time.isnot(None))
.scalar()
or 0
)
return round(total_seconds / 3600, 2)
@property
def display_name(self):
"""Preferred display name: full name if available, else username"""
if self.full_name and self.full_name.strip():
return self.full_name.strip()
return self.username
def get_recent_entries(self, limit=10):
"""Get recent time entries for this user"""
from .time_entry import TimeEntry
return (
self.time_entries.filter(TimeEntry.end_time.isnot(None))
.order_by(TimeEntry.start_time.desc())
.limit(limit)
.all()
)
def update_last_login(self):
"""Update the last login timestamp"""
self.last_login = datetime.utcnow()
db.session.commit()
def to_dict(self):
"""Convert user to dictionary for API responses"""
return {
"id": self.id,
"username": self.username,
"email": self.email,
"full_name": self.full_name,
"display_name": self.display_name,
"role": self.role,
"created_at": self.created_at.isoformat() if self.created_at else None,
"last_login": self.last_login.isoformat() if self.last_login else None,
"is_active": self.is_active,
"total_hours": self.total_hours,
"avatar_url": self.get_avatar_url(),
}
# Avatar helpers
def get_avatar_url(self):
"""Return the public URL for the user's avatar, or None if not set"""
if self.avatar_filename:
return f"/uploads/avatars/{self.avatar_filename}"
return None
def get_avatar_path(self):
"""Return absolute filesystem path to the user's avatar, or None if not set"""
if not self.avatar_filename:
return None
try:
from flask import current_app
# Avatars are now stored in /data volume to persist between container updates
upload_folder = os.path.join(current_app.config.get("UPLOAD_FOLDER", "/data/uploads"), "avatars")
return os.path.join(upload_folder, self.avatar_filename)
except Exception:
# Fallback for development/non-docker environments
return os.path.join("/data/uploads", "avatars", self.avatar_filename)
def has_avatar(self):
"""Check whether the user's avatar file exists on disk"""
path = self.get_avatar_path()
return bool(path and os.path.exists(path))
# Favorite projects helpers
def add_favorite_project(self, project):
"""Add a project to user's favorites"""
if not self.is_project_favorite(project):
self.favorite_projects.append(project)
db.session.commit()
def remove_favorite_project(self, project):
"""Remove a project from user's favorites"""
if self.is_project_favorite(project):
self.favorite_projects.remove(project)
db.session.commit()
def is_project_favorite(self, project):
"""Check if a project is in user's favorites"""
from .project import Project
if isinstance(project, int):
project_id = project
return self.favorite_projects.filter_by(id=project_id).count() > 0
elif isinstance(project, Project):
return self.favorite_projects.filter_by(id=project.id).count() > 0
return False
def get_favorite_projects(self, status="active"):
"""Get user's favorite projects, optionally filtered by status"""
query = self.favorite_projects
if status:
query = query.filter_by(status=status)
return query.order_by("name").all()
# Permission and role helpers
def has_permission(self, permission_name):
"""Check if user has a specific permission through any of their roles"""
# Super admin users have all permissions
if self.role == "admin" and not self.roles:
# Legacy admin users without roles have all permissions
return True
# Check if any of the user's roles have this permission
for role in self.roles:
if role.has_permission(permission_name):
return True
return False
def has_any_permission(self, *permission_names):
"""Check if user has any of the specified permissions"""
return any(self.has_permission(perm) for perm in permission_names)
def has_all_permissions(self, *permission_names):
"""Check if user has all of the specified permissions"""
return all(self.has_permission(perm) for perm in permission_names)
def add_role(self, role):
"""Add a role to this user"""
if role not in self.roles:
self.roles.append(role)
def remove_role(self, role):
"""Remove a role from this user"""
if role in self.roles:
self.roles.remove(role)
def get_all_permissions(self):
"""Get all permissions this user has through their roles"""
permissions = set()
for role in self.roles:
for permission in role.permissions:
permissions.add(permission)
return list(permissions)
def get_role_names(self):
"""Get list of role names for this user"""
return [r.name for r in self.roles]
# Client portal helpers
@property
def is_client_portal_user(self):
"""Check if user has client portal access enabled"""
return self.client_portal_enabled and self.client_id is not None
def get_client_portal_data(self):
"""Get data for client portal view (projects, invoices, time entries for assigned client)"""
if not self.is_client_portal_user:
return None
from .project import Project
from .invoice import Invoice
from .time_entry import TimeEntry
from .client import Client
# Get client - try relationship first, then query by ID if needed
client = self.client
if not client and self.client_id:
# Relationship might not be loaded, query directly
client = Client.query.get(self.client_id)
if not client:
return None
# Get active projects for this client
projects = Project.query.filter_by(client_id=client.id, status="active").order_by(Project.name).all()
# Get invoices for this client
invoices = Invoice.query.filter_by(client_id=client.id).order_by(Invoice.issue_date.desc()).limit(50).all()
# Get time entries for projects belonging to this client
project_ids = [p.id for p in projects]
time_entries = (
TimeEntry.query.filter(TimeEntry.project_id.in_(project_ids), TimeEntry.end_time.isnot(None))
.order_by(TimeEntry.start_time.desc())
.limit(100)
.all()
)
return {"client": client, "projects": projects, "invoices": invoices, "time_entries": time_entries}