Files
TimeTracker/app/models/client.py
Dries Peeters 39cf649f8e feat: Add client portal with password setup email functionality
Implement a complete client portal feature that allows clients to access
their projects, invoices, and time entries through a dedicated portal with
separate authentication. Includes password setup via email with secure
token-based authentication.

Client Portal Features:
- Client-based authentication (separate from user accounts)
- Portal access can be enabled/disabled per client
- Clients can view their projects, invoices, and time entries
- Clean, minimal UI without main app navigation elements
- Login page styled to match main app design

Password Setup Email:
- Admin can send password setup emails to clients
- Secure token-based password setup (24-hour expiration)
- Email template with professional styling
- Password setup page matching app login design
- Token validation and automatic cleanup after use

Email Configuration:
- Email settings from admin menu are now used for sending
- Database email settings persist between restarts and updates
- Automatic reload of email configuration when sending emails
- Database settings take precedence over environment variables
- Improved error messages for email configuration issues

Database Changes:
- Add portal_enabled, portal_username, portal_password_hash to clients
- Add password_setup_token and password_setup_token_expires to clients
- Migration 047: Add client portal fields to users (legacy)
- Migration 048: Add client portal credentials to clients
- Migration 049: Add password setup token fields

New Files:
- app/routes/client_portal.py - Client portal routes and authentication
- app/templates/client_portal/ - Portal templates (base, login, dashboard, etc.)
- app/templates/email/client_portal_password_setup.html - Email template
- migrations/versions/047-049 - Database migrations
- tests/test_client_portal.py - Portal tests
- docs/CLIENT_PORTAL.md - Portal documentation

Modified Files:
- app/models/client.py - Add portal fields and password token methods
- app/routes/clients.py - Add send password email route
- app/routes/client_portal.py - Portal routes with redirect handling
- app/utils/email.py - Use database settings, add password setup email
- app/templates/clients/edit.html - Add send email button
- app/templates/components/ui.html - Support client portal breadcrumbs

Security:
- Secure token generation using secrets.token_urlsafe()
- Password hashing with werkzeug.security
- Token expiration (24 hours default)
- Token cleared after successful password setup
- CSRF protection on all forms
2025-11-14 15:15:38 +01:00

322 lines
13 KiB
Python

from datetime import datetime, timedelta
from decimal import Decimal
from werkzeug.security import generate_password_hash, check_password_hash
from app import db
from .client_prepaid_consumption import ClientPrepaidConsumption
import secrets
class Client(db.Model):
"""Client model for managing client information and rates"""
__tablename__ = 'clients'
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(200), nullable=False, unique=True, index=True)
description = db.Column(db.Text, nullable=True)
contact_person = db.Column(db.String(200), nullable=True)
email = db.Column(db.String(200), nullable=True)
phone = db.Column(db.String(50), nullable=True)
address = db.Column(db.Text, nullable=True)
default_hourly_rate = db.Column(db.Numeric(9, 2), nullable=True)
status = db.Column(db.String(20), default='active', nullable=False) # 'active' or 'inactive'
prepaid_hours_monthly = db.Column(db.Numeric(7, 2), nullable=True)
prepaid_reset_day = db.Column(db.Integer, nullable=False, default=1)
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)
# Client portal settings
portal_enabled = db.Column(db.Boolean, default=False, nullable=False) # Enable/disable client portal access
portal_username = db.Column(db.String(80), unique=True, nullable=True, index=True) # Portal login username
portal_password_hash = db.Column(db.String(255), nullable=True) # Hashed password for portal access
password_setup_token = db.Column(db.String(100), nullable=True, index=True) # Token for password setup/reset
password_setup_token_expires = db.Column(db.DateTime, nullable=True) # Token expiration time
# Relationships
projects = db.relationship('Project', backref='client_obj', lazy='dynamic', cascade='all, delete-orphan')
def __init__(self, name, description=None, contact_person=None, email=None, phone=None, address=None, default_hourly_rate=None, company=None, prepaid_hours_monthly=None, prepaid_reset_day=1):
"""Create a Client.
Note: company parameter is accepted for test compatibility but not used,
as the Client model uses 'name' as the primary identifier.
"""
self.name = name.strip()
self.description = description.strip() if description else None
self.contact_person = contact_person.strip() if contact_person else None
self.email = email.strip() if email else None
self.phone = phone.strip() if phone else None
self.address = address.strip() if address else None
self.default_hourly_rate = Decimal(str(default_hourly_rate)) if default_hourly_rate else None
self.prepaid_hours_monthly = Decimal(str(prepaid_hours_monthly)) if prepaid_hours_monthly not in (None, '') else None
try:
reset_day = int(prepaid_reset_day) if prepaid_reset_day is not None else 1
self.prepaid_reset_day = max(1, min(28, reset_day))
except (TypeError, ValueError):
self.prepaid_reset_day = 1
def __repr__(self):
return f'<Client {self.name}>'
@property
def is_active(self):
"""Check if client is active"""
return self.status == 'active'
@property
def total_projects(self):
"""Get total number of projects for this client"""
return self.projects.count()
@property
def active_projects(self):
"""Get number of active projects for this client"""
return self.projects.filter_by(status='active').count()
@property
def total_hours(self):
"""Calculate total hours across all projects for this client"""
total_seconds = 0
for project in self.projects:
total_seconds += project.total_hours * 3600 # Convert hours to seconds
return round(total_seconds / 3600, 2)
@property
def total_billable_hours(self):
"""Calculate total billable hours across all projects for this client"""
total_seconds = 0
for project in self.projects:
total_seconds += project.total_billable_hours * 3600 # Convert hours to seconds
return round(total_seconds / 3600, 2)
@property
def estimated_total_cost(self):
"""Calculate estimated total cost based on billable hours and rates"""
total_cost = 0.0
for project in self.projects:
if project.billable and project.hourly_rate:
total_cost += project.estimated_cost
return total_cost
@property
def prepaid_plan_enabled(self):
"""Return True if client has prepaid hours configured."""
try:
hours = Decimal(str(self.prepaid_hours_monthly)) if self.prepaid_hours_monthly is not None else Decimal('0')
except Exception:
hours = Decimal('0')
return hours > 0
@property
def prepaid_hours_decimal(self):
"""Return prepaid hours as Decimal with two decimal precision."""
if self.prepaid_hours_monthly is None:
return Decimal('0')
try:
return Decimal(str(self.prepaid_hours_monthly)).quantize(Decimal('0.01'))
except Exception:
return Decimal('0')
def prepaid_month_start(self, reference_datetime):
"""
Determine the configured prepaid period start date for a given datetime.
Args:
reference_datetime (datetime): Datetime to evaluate.
Returns:
date: The start date of the prepaid cycle that contains the reference datetime.
"""
from datetime import timedelta
if not reference_datetime:
return None
reset_day = self.prepaid_reset_day or 1
reset_day = max(1, min(28, int(reset_day)))
dt = reference_datetime
if isinstance(dt, datetime) and hasattr(dt, 'date'):
dt_date = dt.date()
else:
dt_date = dt
if dt_date.day >= reset_day:
return dt_date.replace(day=reset_day)
# Move to previous month
first_of_month = dt_date.replace(day=1)
previous_day = first_of_month - timedelta(days=1)
target_day = min(reset_day, previous_day.day)
return previous_day.replace(day=target_day)
def get_prepaid_consumed_hours(self, month_start):
"""Return Decimal hours consumed for the given prepaid cycle."""
if not month_start:
return Decimal('0')
try:
seconds = self.prepaid_consumptions.filter(
ClientPrepaidConsumption.allocation_month == month_start
).with_entities(
db.func.coalesce(db.func.sum(ClientPrepaidConsumption.seconds_consumed), 0)
).scalar() or 0
except Exception:
seconds = 0
return Decimal(seconds) / Decimal('3600')
def get_prepaid_remaining_hours(self, month_start):
"""Return how many prepaid hours remain for the cycle starting at month_start."""
if not self.prepaid_plan_enabled or not month_start:
return Decimal('0')
consumed = self.get_prepaid_consumed_hours(month_start)
remaining = self.prepaid_hours_decimal - consumed
return remaining if remaining > 0 else Decimal('0')
def archive(self):
"""Archive the client"""
self.status = 'inactive'
self.updated_at = datetime.utcnow()
def activate(self):
"""Activate the client"""
self.status = 'active'
self.updated_at = datetime.utcnow()
def to_dict(self):
"""Convert client to dictionary for JSON serialization"""
return {
'id': self.id,
'name': self.name,
'description': self.description,
'contact_person': self.contact_person,
'email': self.email,
'phone': self.phone,
'address': self.address,
'default_hourly_rate': str(self.default_hourly_rate) if self.default_hourly_rate else None,
'status': self.status,
'is_active': self.is_active,
'total_projects': self.total_projects,
'active_projects': self.active_projects,
'prepaid_hours_monthly': float(self.prepaid_hours_monthly) if self.prepaid_hours_monthly is not None else None,
'prepaid_reset_day': self.prepaid_reset_day,
'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_clients(cls):
"""Get all active clients ordered by name"""
return cls.query.filter_by(status='active').order_by(cls.name).all()
@classmethod
def get_all_clients(cls):
"""Get all clients ordered by name"""
return cls.query.order_by(cls.name).all()
# Client portal helpers
def set_portal_password(self, password):
"""Set the portal password for this client"""
if password:
self.portal_password_hash = generate_password_hash(password)
else:
self.portal_password_hash = None
def check_portal_password(self, password):
"""Check if the provided password matches the portal password"""
if not self.portal_password_hash or not password:
return False
return check_password_hash(self.portal_password_hash, password)
@property
def has_portal_access(self):
"""Check if client has portal access enabled and credentials set"""
return self.portal_enabled and self.portal_username and self.portal_password_hash
def get_portal_data(self):
"""Get data for client portal view (projects, invoices, time entries)"""
if not self.has_portal_access:
return None
from .project import Project
from .invoice import Invoice
from .time_entry import TimeEntry
# Get active projects for this client
projects = Project.query.filter_by(
client_id=self.id,
status='active'
).order_by(Project.name).all()
# Get invoices for this client
invoices = Invoice.query.filter_by(
client_id=self.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': self,
'projects': projects,
'invoices': invoices,
'time_entries': time_entries
}
def generate_password_setup_token(self, expires_hours=24):
"""Generate a secure token for password setup/reset"""
token = secrets.token_urlsafe(32)
self.password_setup_token = token
self.password_setup_token_expires = datetime.utcnow() + timedelta(hours=expires_hours)
return token
def verify_password_setup_token(self, token):
"""Verify if a password setup token is valid"""
if not self.password_setup_token or not token:
return False
if self.password_setup_token != token:
return False
if self.password_setup_token_expires and self.password_setup_token_expires < datetime.utcnow():
return False
return True
def clear_password_setup_token(self):
"""Clear the password setup token after use"""
self.password_setup_token = None
self.password_setup_token_expires = None
@classmethod
def authenticate_portal(cls, username, password):
"""Authenticate a client portal login"""
client = cls.query.filter_by(portal_username=username, portal_enabled=True).first()
if not client:
return None
if not client.check_portal_password(password):
return None
if not client.is_active:
return None
return client
@classmethod
def find_by_password_token(cls, token):
"""Find a client by password setup token"""
if not token:
return None
client = cls.query.filter_by(password_setup_token=token).first()
if not client:
return None
if client.password_setup_token_expires and client.password_setup_token_expires < datetime.utcnow():
return None
return client