Merge branch 'master' into feature-markdown-notes
This commit is contained in:
51
models/__init__.py
Normal file
51
models/__init__.py
Normal file
@@ -0,0 +1,51 @@
|
||||
"""
|
||||
Models package for TimeTrack application.
|
||||
Split from monolithic models.py into domain-specific modules.
|
||||
"""
|
||||
|
||||
from flask_sqlalchemy import SQLAlchemy
|
||||
|
||||
# Initialize SQLAlchemy instance
|
||||
db = SQLAlchemy()
|
||||
|
||||
# Import all models to maintain backward compatibility
|
||||
from .enums import (
|
||||
Role, AccountType, WorkRegion, CommentVisibility,
|
||||
TaskStatus, TaskPriority, SprintStatus, WidgetType, WidgetSize
|
||||
)
|
||||
|
||||
from .company import Company, CompanySettings, CompanyWorkConfig
|
||||
from .user import User, UserPreferences, UserDashboard
|
||||
from .team import Team
|
||||
from .project import Project, ProjectCategory
|
||||
from .task import Task, TaskDependency, SubTask, Comment
|
||||
from .time_entry import TimeEntry
|
||||
from .sprint import Sprint
|
||||
from .system import SystemSettings, BrandingSettings, SystemEvent
|
||||
from .announcement import Announcement
|
||||
from .dashboard import DashboardWidget, WidgetTemplate
|
||||
from .work_config import WorkConfig
|
||||
from .invitation import CompanyInvitation
|
||||
from .note import Note, NoteVisibility, NoteLink, NoteFolder
|
||||
|
||||
# Make all models available at package level
|
||||
__all__ = [
|
||||
'db',
|
||||
# Enums
|
||||
'Role', 'AccountType', 'WorkRegion', 'CommentVisibility',
|
||||
'TaskStatus', 'TaskPriority', 'SprintStatus', 'WidgetType', 'WidgetSize',
|
||||
# Models
|
||||
'Company', 'CompanySettings', 'CompanyWorkConfig',
|
||||
'User', 'UserPreferences', 'UserDashboard',
|
||||
'Team',
|
||||
'Project', 'ProjectCategory',
|
||||
'Task', 'TaskDependency', 'SubTask', 'Comment',
|
||||
'TimeEntry',
|
||||
'Sprint',
|
||||
'SystemSettings', 'BrandingSettings', 'SystemEvent',
|
||||
'Announcement',
|
||||
'DashboardWidget', 'WidgetTemplate',
|
||||
'WorkConfig',
|
||||
'CompanyInvitation',
|
||||
'Note', 'NoteVisibility', 'NoteLink', 'NoteFolder'
|
||||
]
|
||||
91
models/announcement.py
Normal file
91
models/announcement.py
Normal file
@@ -0,0 +1,91 @@
|
||||
"""
|
||||
Announcement model for system-wide notifications
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
import json
|
||||
from . import db
|
||||
|
||||
|
||||
class Announcement(db.Model):
|
||||
"""System-wide announcements"""
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
title = db.Column(db.String(200), nullable=False)
|
||||
content = db.Column(db.Text, nullable=False)
|
||||
|
||||
# Announcement properties
|
||||
is_active = db.Column(db.Boolean, default=True)
|
||||
is_urgent = db.Column(db.Boolean, default=False) # For urgent announcements with different styling
|
||||
announcement_type = db.Column(db.String(20), default='info') # info, warning, success, danger
|
||||
|
||||
# Scheduling
|
||||
start_date = db.Column(db.DateTime, nullable=True) # When to start showing
|
||||
end_date = db.Column(db.DateTime, nullable=True) # When to stop showing
|
||||
|
||||
# Targeting
|
||||
target_all_users = db.Column(db.Boolean, default=True)
|
||||
target_roles = db.Column(db.Text, nullable=True) # JSON string of roles if not all users
|
||||
target_companies = db.Column(db.Text, nullable=True) # JSON string of company IDs if not all companies
|
||||
|
||||
# Metadata
|
||||
created_at = db.Column(db.DateTime, default=datetime.now)
|
||||
updated_at = db.Column(db.DateTime, default=datetime.now, onupdate=datetime.now)
|
||||
created_by_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False)
|
||||
|
||||
# Relationships
|
||||
created_by = db.relationship('User', foreign_keys=[created_by_id])
|
||||
|
||||
def __repr__(self):
|
||||
return f'<Announcement {self.title}>'
|
||||
|
||||
def is_visible_now(self):
|
||||
"""Check if announcement should be visible at current time"""
|
||||
if not self.is_active:
|
||||
return False
|
||||
|
||||
now = datetime.now()
|
||||
|
||||
# Check start date
|
||||
if self.start_date and now < self.start_date:
|
||||
return False
|
||||
|
||||
# Check end date
|
||||
if self.end_date and now > self.end_date:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
def is_visible_to_user(self, user):
|
||||
"""Check if announcement should be visible to specific user"""
|
||||
if not self.is_visible_now():
|
||||
return False
|
||||
|
||||
# If targeting all users, show to everyone
|
||||
if self.target_all_users:
|
||||
return True
|
||||
|
||||
# Check role targeting
|
||||
if self.target_roles:
|
||||
try:
|
||||
target_roles = json.loads(self.target_roles)
|
||||
if user.role.value not in target_roles:
|
||||
return False
|
||||
except (json.JSONDecodeError, AttributeError):
|
||||
pass
|
||||
|
||||
# Check company targeting
|
||||
if self.target_companies:
|
||||
try:
|
||||
target_companies = json.loads(self.target_companies)
|
||||
if user.company_id not in target_companies:
|
||||
return False
|
||||
except (json.JSONDecodeError, AttributeError):
|
||||
pass
|
||||
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def get_active_announcements_for_user(user):
|
||||
"""Get all active announcements visible to a specific user"""
|
||||
announcements = Announcement.query.filter_by(is_active=True).all()
|
||||
return [ann for ann in announcements if ann.is_visible_to_user(user)]
|
||||
20
models/base.py
Normal file
20
models/base.py
Normal file
@@ -0,0 +1,20 @@
|
||||
"""
|
||||
Base model utilities and mixins
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from sqlalchemy.ext.declarative import declared_attr
|
||||
from . import db
|
||||
|
||||
|
||||
class TimestampMixin:
|
||||
"""Mixin for adding created_at and updated_at timestamps"""
|
||||
created_at = db.Column(db.DateTime, default=datetime.now, nullable=False)
|
||||
updated_at = db.Column(db.DateTime, default=datetime.now, onupdate=datetime.now)
|
||||
|
||||
|
||||
class CompanyScopedMixin:
|
||||
"""Mixin for models that belong to a company"""
|
||||
@declared_attr
|
||||
def company_id(cls):
|
||||
return db.Column(db.Integer, db.ForeignKey('company.id'), nullable=False)
|
||||
232
models/company.py
Normal file
232
models/company.py
Normal file
@@ -0,0 +1,232 @@
|
||||
"""
|
||||
Company-related models
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from . import db
|
||||
from .enums import WorkRegion
|
||||
|
||||
|
||||
class Company(db.Model):
|
||||
"""Company model for multi-tenancy"""
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
name = db.Column(db.String(100), nullable=False, unique=True)
|
||||
slug = db.Column(db.String(50), unique=True, nullable=False) # URL-friendly identifier
|
||||
description = db.Column(db.Text)
|
||||
created_at = db.Column(db.DateTime, default=datetime.now)
|
||||
updated_at = db.Column(db.DateTime, default=datetime.now, onupdate=datetime.now)
|
||||
|
||||
# Freelancer support
|
||||
is_personal = db.Column(db.Boolean, default=False) # True for auto-created freelancer companies
|
||||
|
||||
# Company settings
|
||||
is_active = db.Column(db.Boolean, default=True)
|
||||
max_users = db.Column(db.Integer, default=100) # Optional user limit
|
||||
|
||||
# Relationships
|
||||
users = db.relationship('User', backref='company', lazy=True)
|
||||
teams = db.relationship('Team', backref='company', lazy=True)
|
||||
projects = db.relationship('Project', backref='company', lazy=True)
|
||||
|
||||
def __repr__(self):
|
||||
return f'<Company {self.name}>'
|
||||
|
||||
def generate_slug(self):
|
||||
"""Generate URL-friendly slug from company name"""
|
||||
import re
|
||||
slug = re.sub(r'[^\w\s-]', '', self.name.lower())
|
||||
slug = re.sub(r'[-\s]+', '-', slug)
|
||||
return slug.strip('-')
|
||||
|
||||
|
||||
class CompanySettings(db.Model):
|
||||
"""Company-specific settings"""
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
company_id = db.Column(db.Integer, db.ForeignKey('company.id'), unique=True, nullable=False)
|
||||
|
||||
# Work week settings
|
||||
work_week_start = db.Column(db.Integer, default=1) # 1 = Monday, 7 = Sunday
|
||||
work_days = db.Column(db.String(20), default='1,2,3,4,5') # Comma-separated day numbers
|
||||
|
||||
# Time tracking settings
|
||||
allow_overlapping_entries = db.Column(db.Boolean, default=False)
|
||||
require_project_for_time_entry = db.Column(db.Boolean, default=True)
|
||||
allow_future_entries = db.Column(db.Boolean, default=False)
|
||||
max_hours_per_entry = db.Column(db.Float, default=24.0)
|
||||
|
||||
# Feature toggles
|
||||
enable_tasks = db.Column(db.Boolean, default=True)
|
||||
enable_sprints = db.Column(db.Boolean, default=False)
|
||||
enable_client_access = db.Column(db.Boolean, default=False)
|
||||
|
||||
# Notification settings
|
||||
notify_on_overtime = db.Column(db.Boolean, default=True)
|
||||
overtime_threshold_daily = db.Column(db.Float, default=8.0)
|
||||
overtime_threshold_weekly = db.Column(db.Float, default=40.0)
|
||||
|
||||
created_at = db.Column(db.DateTime, default=datetime.now)
|
||||
updated_at = db.Column(db.DateTime, default=datetime.now, onupdate=datetime.now)
|
||||
|
||||
# Relationship
|
||||
company = db.relationship('Company', backref=db.backref('settings', uselist=False))
|
||||
|
||||
@classmethod
|
||||
def get_or_create(cls, company_id):
|
||||
"""Get existing settings or create default ones"""
|
||||
settings = cls.query.filter_by(company_id=company_id).first()
|
||||
if not settings:
|
||||
settings = cls(company_id=company_id)
|
||||
db.session.add(settings)
|
||||
db.session.commit()
|
||||
return settings
|
||||
|
||||
|
||||
class CompanyWorkConfig(db.Model):
|
||||
"""Company-specific work configuration"""
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
company_id = db.Column(db.Integer, db.ForeignKey('company.id'), nullable=False)
|
||||
|
||||
# Work hours configuration
|
||||
standard_hours_per_day = db.Column(db.Float, default=8.0)
|
||||
standard_hours_per_week = db.Column(db.Float, default=40.0)
|
||||
|
||||
# Work region for compliance
|
||||
work_region = db.Column(db.Enum(WorkRegion), default=WorkRegion.OTHER)
|
||||
|
||||
# Overtime rules
|
||||
overtime_enabled = db.Column(db.Boolean, default=True)
|
||||
overtime_rate = db.Column(db.Float, default=1.5) # 1.5x regular rate
|
||||
double_time_enabled = db.Column(db.Boolean, default=False)
|
||||
double_time_threshold = db.Column(db.Float, default=12.0) # Hours after which double time applies
|
||||
double_time_rate = db.Column(db.Float, default=2.0)
|
||||
|
||||
# Break rules
|
||||
require_breaks = db.Column(db.Boolean, default=True)
|
||||
break_duration_minutes = db.Column(db.Integer, default=30)
|
||||
break_after_hours = db.Column(db.Float, default=6.0)
|
||||
|
||||
# Weekly overtime rules
|
||||
weekly_overtime_threshold = db.Column(db.Float, default=40.0)
|
||||
weekly_overtime_rate = db.Column(db.Float, default=1.5)
|
||||
|
||||
created_at = db.Column(db.DateTime, default=datetime.now)
|
||||
updated_at = db.Column(db.DateTime, default=datetime.now, onupdate=datetime.now)
|
||||
|
||||
# Relationships
|
||||
company = db.relationship('Company', backref='work_configs')
|
||||
|
||||
def __repr__(self):
|
||||
return f'<CompanyWorkConfig {self.company.name if self.company else "Unknown"}: {self.work_region.value if self.work_region else "No region"}>'
|
||||
|
||||
@classmethod
|
||||
def get_regional_preset(cls, region):
|
||||
"""Get regional preset configuration."""
|
||||
presets = {
|
||||
WorkRegion.EU: {
|
||||
'standard_hours_per_day': 8.0,
|
||||
'standard_hours_per_week': 40.0,
|
||||
'overtime_enabled': True,
|
||||
'overtime_rate': 1.5,
|
||||
'double_time_enabled': False,
|
||||
'double_time_threshold': 12.0,
|
||||
'double_time_rate': 2.0,
|
||||
'require_breaks': True,
|
||||
'break_duration_minutes': 30,
|
||||
'break_after_hours': 6.0,
|
||||
'weekly_overtime_threshold': 40.0,
|
||||
'weekly_overtime_rate': 1.5,
|
||||
'region_name': 'European Union'
|
||||
},
|
||||
WorkRegion.GERMANY: {
|
||||
'standard_hours_per_day': 8.0,
|
||||
'standard_hours_per_week': 40.0,
|
||||
'overtime_enabled': True,
|
||||
'overtime_rate': 1.25, # German overtime is typically 25% extra
|
||||
'double_time_enabled': False,
|
||||
'double_time_threshold': 10.0,
|
||||
'double_time_rate': 1.5,
|
||||
'require_breaks': True,
|
||||
'break_duration_minutes': 30, # 30 min break after 6 hours
|
||||
'break_after_hours': 6.0,
|
||||
'weekly_overtime_threshold': 48.0, # German law allows up to 48 hours/week
|
||||
'weekly_overtime_rate': 1.25,
|
||||
'region_name': 'Germany'
|
||||
},
|
||||
WorkRegion.USA: {
|
||||
'standard_hours_per_day': 8.0,
|
||||
'standard_hours_per_week': 40.0,
|
||||
'overtime_enabled': True,
|
||||
'overtime_rate': 1.5,
|
||||
'double_time_enabled': False,
|
||||
'double_time_threshold': 12.0,
|
||||
'double_time_rate': 2.0,
|
||||
'require_breaks': False, # No federal requirement
|
||||
'break_duration_minutes': 0,
|
||||
'break_after_hours': 999.0, # Effectively disabled
|
||||
'weekly_overtime_threshold': 40.0,
|
||||
'weekly_overtime_rate': 1.5,
|
||||
'region_name': 'United States'
|
||||
},
|
||||
WorkRegion.UK: {
|
||||
'standard_hours_per_day': 8.0,
|
||||
'standard_hours_per_week': 48.0, # UK has 48-hour week limit
|
||||
'overtime_enabled': True,
|
||||
'overtime_rate': 1.5,
|
||||
'double_time_enabled': False,
|
||||
'double_time_threshold': 12.0,
|
||||
'double_time_rate': 2.0,
|
||||
'require_breaks': True,
|
||||
'break_duration_minutes': 20,
|
||||
'break_after_hours': 6.0,
|
||||
'weekly_overtime_threshold': 48.0,
|
||||
'weekly_overtime_rate': 1.5,
|
||||
'region_name': 'United Kingdom'
|
||||
},
|
||||
WorkRegion.CANADA: {
|
||||
'standard_hours_per_day': 8.0,
|
||||
'standard_hours_per_week': 40.0,
|
||||
'overtime_enabled': True,
|
||||
'overtime_rate': 1.5,
|
||||
'double_time_enabled': False,
|
||||
'double_time_threshold': 12.0,
|
||||
'double_time_rate': 2.0,
|
||||
'require_breaks': True,
|
||||
'break_duration_minutes': 30,
|
||||
'break_after_hours': 5.0,
|
||||
'weekly_overtime_threshold': 40.0,
|
||||
'weekly_overtime_rate': 1.5,
|
||||
'region_name': 'Canada'
|
||||
},
|
||||
WorkRegion.AUSTRALIA: {
|
||||
'standard_hours_per_day': 7.6, # 38-hour week / 5 days
|
||||
'standard_hours_per_week': 38.0,
|
||||
'overtime_enabled': True,
|
||||
'overtime_rate': 1.5,
|
||||
'double_time_enabled': True,
|
||||
'double_time_threshold': 10.0,
|
||||
'double_time_rate': 2.0,
|
||||
'require_breaks': True,
|
||||
'break_duration_minutes': 30,
|
||||
'break_after_hours': 5.0,
|
||||
'weekly_overtime_threshold': 38.0,
|
||||
'weekly_overtime_rate': 1.5,
|
||||
'region_name': 'Australia'
|
||||
},
|
||||
WorkRegion.OTHER: {
|
||||
'standard_hours_per_day': 8.0,
|
||||
'standard_hours_per_week': 40.0,
|
||||
'overtime_enabled': False,
|
||||
'overtime_rate': 1.5,
|
||||
'double_time_enabled': False,
|
||||
'double_time_threshold': 12.0,
|
||||
'double_time_rate': 2.0,
|
||||
'require_breaks': False,
|
||||
'break_duration_minutes': 0,
|
||||
'break_after_hours': 999.0,
|
||||
'weekly_overtime_threshold': 40.0,
|
||||
'weekly_overtime_rate': 1.5,
|
||||
'region_name': 'Other'
|
||||
}
|
||||
}
|
||||
|
||||
return presets.get(region, presets[WorkRegion.OTHER])
|
||||
99
models/dashboard.py
Normal file
99
models/dashboard.py
Normal file
@@ -0,0 +1,99 @@
|
||||
"""
|
||||
Dashboard widget models
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
import json
|
||||
from . import db
|
||||
from .enums import WidgetType, Role
|
||||
|
||||
|
||||
class DashboardWidget(db.Model):
|
||||
"""User dashboard widget configuration"""
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
dashboard_id = db.Column(db.Integer, db.ForeignKey('user_dashboard.id'), nullable=False)
|
||||
widget_type = db.Column(db.Enum(WidgetType), nullable=False)
|
||||
|
||||
# Grid position and size
|
||||
grid_x = db.Column(db.Integer, nullable=False, default=0) # X position in grid
|
||||
grid_y = db.Column(db.Integer, nullable=False, default=0) # Y position in grid
|
||||
grid_width = db.Column(db.Integer, nullable=False, default=1) # Width in grid units
|
||||
grid_height = db.Column(db.Integer, nullable=False, default=1) # Height in grid units
|
||||
|
||||
# Widget configuration
|
||||
title = db.Column(db.String(100)) # Custom widget title
|
||||
config = db.Column(db.Text) # JSON string for widget-specific configuration
|
||||
refresh_interval = db.Column(db.Integer, default=60) # Refresh interval in seconds
|
||||
|
||||
# Widget state
|
||||
is_visible = db.Column(db.Boolean, default=True)
|
||||
is_minimized = db.Column(db.Boolean, default=False)
|
||||
z_index = db.Column(db.Integer, default=1) # Stacking order
|
||||
|
||||
# Metadata
|
||||
created_at = db.Column(db.DateTime, default=datetime.now)
|
||||
updated_at = db.Column(db.DateTime, default=datetime.now, onupdate=datetime.now)
|
||||
|
||||
def __repr__(self):
|
||||
return f'<DashboardWidget {self.widget_type.value} ({self.grid_width}x{self.grid_height})>'
|
||||
|
||||
@property
|
||||
def config_dict(self):
|
||||
"""Parse widget configuration JSON"""
|
||||
if self.config:
|
||||
try:
|
||||
return json.loads(self.config)
|
||||
except:
|
||||
return {}
|
||||
return {}
|
||||
|
||||
@config_dict.setter
|
||||
def config_dict(self, value):
|
||||
"""Set widget configuration as JSON"""
|
||||
self.config = json.dumps(value) if value else None
|
||||
|
||||
|
||||
class WidgetTemplate(db.Model):
|
||||
"""Pre-defined widget templates for easy dashboard setup"""
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
widget_type = db.Column(db.Enum(WidgetType), nullable=False)
|
||||
name = db.Column(db.String(100), nullable=False)
|
||||
description = db.Column(db.Text)
|
||||
icon = db.Column(db.String(50)) # Icon name or emoji
|
||||
|
||||
# Default configuration
|
||||
default_width = db.Column(db.Integer, default=1)
|
||||
default_height = db.Column(db.Integer, default=1)
|
||||
default_config = db.Column(db.Text) # JSON string for default widget configuration
|
||||
|
||||
# Access control
|
||||
required_role = db.Column(db.Enum(Role), default=Role.TEAM_MEMBER)
|
||||
is_active = db.Column(db.Boolean, default=True)
|
||||
|
||||
# Categories for organization
|
||||
category = db.Column(db.String(50), default='General') # Time, Projects, Tasks, Analytics, Team, Actions
|
||||
|
||||
# Metadata
|
||||
created_at = db.Column(db.DateTime, default=datetime.now)
|
||||
|
||||
def __repr__(self):
|
||||
return f'<WidgetTemplate {self.name} ({self.widget_type.value})>'
|
||||
|
||||
def can_user_access(self, user):
|
||||
"""Check if user has required role to use this widget"""
|
||||
if not self.is_active:
|
||||
return False
|
||||
|
||||
# Define role hierarchy
|
||||
role_hierarchy = {
|
||||
Role.TEAM_MEMBER: 1,
|
||||
Role.TEAM_LEADER: 2,
|
||||
Role.SUPERVISOR: 3,
|
||||
Role.ADMIN: 4,
|
||||
Role.SYSTEM_ADMIN: 5
|
||||
}
|
||||
|
||||
user_level = role_hierarchy.get(user.role, 0)
|
||||
required_level = role_hierarchy.get(self.required_role, 0)
|
||||
|
||||
return user_level >= required_level
|
||||
115
models/enums.py
Normal file
115
models/enums.py
Normal file
@@ -0,0 +1,115 @@
|
||||
"""
|
||||
Enum definitions for the TimeTrack application
|
||||
"""
|
||||
|
||||
import enum
|
||||
|
||||
|
||||
class Role(enum.Enum):
|
||||
"""User role enumeration"""
|
||||
TEAM_MEMBER = "Team Member"
|
||||
TEAM_LEADER = "Team Leader"
|
||||
SUPERVISOR = "Supervisor"
|
||||
ADMIN = "Administrator" # Company-level admin
|
||||
SYSTEM_ADMIN = "System Administrator" # System-wide admin
|
||||
|
||||
|
||||
class AccountType(enum.Enum):
|
||||
"""Account type for freelancer support"""
|
||||
COMPANY_USER = "Company User"
|
||||
FREELANCER = "Freelancer"
|
||||
|
||||
|
||||
class WorkRegion(enum.Enum):
|
||||
"""Work region enumeration for different labor law compliance"""
|
||||
USA = "United States"
|
||||
CANADA = "Canada"
|
||||
UK = "United Kingdom"
|
||||
GERMANY = "Germany"
|
||||
EU = "European Union"
|
||||
AUSTRALIA = "Australia"
|
||||
OTHER = "Other"
|
||||
|
||||
|
||||
class CommentVisibility(enum.Enum):
|
||||
"""Comment visibility levels"""
|
||||
PRIVATE = "Private" # Only creator can see
|
||||
TEAM = "Team" # Team members can see
|
||||
COMPANY = "Company" # All company users can see
|
||||
|
||||
|
||||
class TaskStatus(enum.Enum):
|
||||
"""Task status enumeration"""
|
||||
TODO = "To Do"
|
||||
IN_PROGRESS = "In Progress"
|
||||
IN_REVIEW = "In Review"
|
||||
DONE = "Done"
|
||||
CANCELLED = "Cancelled"
|
||||
ARCHIVED = "Archived"
|
||||
|
||||
|
||||
class TaskPriority(enum.Enum):
|
||||
"""Task priority levels"""
|
||||
LOW = "Low"
|
||||
MEDIUM = "Medium"
|
||||
HIGH = "High"
|
||||
URGENT = "Urgent"
|
||||
|
||||
|
||||
class SprintStatus(enum.Enum):
|
||||
"""Sprint status enumeration"""
|
||||
PLANNING = "Planning"
|
||||
ACTIVE = "Active"
|
||||
COMPLETED = "Completed"
|
||||
CANCELLED = "Cancelled"
|
||||
|
||||
|
||||
class WidgetType(enum.Enum):
|
||||
"""Dashboard widget types"""
|
||||
# Time Tracking Widgets
|
||||
CURRENT_TIMER = "current_timer"
|
||||
DAILY_SUMMARY = "daily_summary"
|
||||
WEEKLY_CHART = "weekly_chart"
|
||||
BREAK_REMINDER = "break_reminder"
|
||||
TIME_SUMMARY = "Time Summary"
|
||||
|
||||
# Project Management Widgets
|
||||
ACTIVE_PROJECTS = "active_projects"
|
||||
PROJECT_PROGRESS = "project_progress"
|
||||
PROJECT_ACTIVITY = "project_activity"
|
||||
PROJECT_DEADLINES = "project_deadlines"
|
||||
PROJECT_STATUS = "Project Status"
|
||||
|
||||
# Task Management Widgets
|
||||
ASSIGNED_TASKS = "assigned_tasks"
|
||||
TASK_PRIORITY = "task_priority"
|
||||
TASK_CALENDAR = "task_calendar"
|
||||
UPCOMING_TASKS = "upcoming_tasks"
|
||||
TASK_LIST = "Task List"
|
||||
|
||||
# Sprint Widgets
|
||||
SPRINT_OVERVIEW = "sprint_overview"
|
||||
SPRINT_BURNDOWN = "sprint_burndown"
|
||||
SPRINT_PROGRESS = "Sprint Progress"
|
||||
|
||||
# Team & Analytics Widgets
|
||||
TEAM_WORKLOAD = "team_workload"
|
||||
TEAM_PRESENCE = "team_presence"
|
||||
TEAM_ACTIVITY = "Team Activity"
|
||||
|
||||
# Performance & Stats Widgets
|
||||
PRODUCTIVITY_STATS = "productivity_stats"
|
||||
TIME_DISTRIBUTION = "time_distribution"
|
||||
PERSONAL_STATS = "Personal Stats"
|
||||
|
||||
# Action Widgets
|
||||
QUICK_ACTIONS = "quick_actions"
|
||||
RECENT_ACTIVITY = "recent_activity"
|
||||
|
||||
|
||||
class WidgetSize(enum.Enum):
|
||||
"""Dashboard widget sizes"""
|
||||
SMALL = "small" # 1x1
|
||||
MEDIUM = "medium" # 2x1
|
||||
LARGE = "large" # 2x2
|
||||
WIDE = "wide" # 3x1 or full width
|
||||
56
models/invitation.py
Normal file
56
models/invitation.py
Normal file
@@ -0,0 +1,56 @@
|
||||
"""
|
||||
Invitation model for company email invites
|
||||
"""
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
from . import db
|
||||
import secrets
|
||||
import string
|
||||
|
||||
|
||||
class CompanyInvitation(db.Model):
|
||||
"""Company invitation model for email-based registration"""
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
company_id = db.Column(db.Integer, db.ForeignKey('company.id'), nullable=False)
|
||||
email = db.Column(db.String(120), nullable=False)
|
||||
token = db.Column(db.String(64), unique=True, nullable=False)
|
||||
role = db.Column(db.String(50), default='Team Member') # Role to assign when accepted
|
||||
|
||||
# Invitation metadata
|
||||
invited_by_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False)
|
||||
created_at = db.Column(db.DateTime, default=datetime.now)
|
||||
expires_at = db.Column(db.DateTime, nullable=False)
|
||||
|
||||
# Status tracking
|
||||
accepted = db.Column(db.Boolean, default=False)
|
||||
accepted_at = db.Column(db.DateTime)
|
||||
accepted_by_user_id = db.Column(db.Integer, db.ForeignKey('user.id'))
|
||||
|
||||
# Relationships
|
||||
company = db.relationship('Company', backref='invitations')
|
||||
invited_by = db.relationship('User', foreign_keys=[invited_by_id], backref='sent_invitations')
|
||||
accepted_by = db.relationship('User', foreign_keys=[accepted_by_user_id], backref='accepted_invitation')
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
super(CompanyInvitation, self).__init__(**kwargs)
|
||||
if not self.token:
|
||||
self.token = self.generate_token()
|
||||
if not self.expires_at:
|
||||
self.expires_at = datetime.now() + timedelta(days=7) # 7 days expiry
|
||||
|
||||
@staticmethod
|
||||
def generate_token():
|
||||
"""Generate a secure random token"""
|
||||
alphabet = string.ascii_letters + string.digits
|
||||
return ''.join(secrets.choice(alphabet) for _ in range(32))
|
||||
|
||||
def is_expired(self):
|
||||
"""Check if invitation has expired"""
|
||||
return datetime.now() > self.expires_at
|
||||
|
||||
def is_valid(self):
|
||||
"""Check if invitation is valid (not accepted and not expired)"""
|
||||
return not self.accepted and not self.is_expired()
|
||||
|
||||
def __repr__(self):
|
||||
return f'<CompanyInvitation {self.email} to {self.company.name}>'
|
||||
278
models/note.py
Normal file
278
models/note.py
Normal file
@@ -0,0 +1,278 @@
|
||||
"""
|
||||
Note models for markdown-based documentation and knowledge management.
|
||||
Migrated from models_old.py to maintain consistency with the new modular structure.
|
||||
"""
|
||||
|
||||
import enum
|
||||
import re
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import UniqueConstraint
|
||||
|
||||
from . import db
|
||||
from .enums import Role
|
||||
|
||||
|
||||
class NoteVisibility(enum.Enum):
|
||||
"""Note sharing visibility levels"""
|
||||
PRIVATE = "Private"
|
||||
TEAM = "Team"
|
||||
COMPANY = "Company"
|
||||
|
||||
|
||||
class Note(db.Model):
|
||||
"""Markdown notes with sharing capabilities"""
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
title = db.Column(db.String(200), nullable=False)
|
||||
content = db.Column(db.Text, nullable=False) # Markdown content
|
||||
slug = db.Column(db.String(100), nullable=False) # URL-friendly identifier
|
||||
|
||||
# Visibility and sharing
|
||||
visibility = db.Column(db.Enum(NoteVisibility), nullable=False, default=NoteVisibility.PRIVATE)
|
||||
|
||||
# Folder organization
|
||||
folder = db.Column(db.String(100), nullable=True) # Folder path like "Work/Projects" or "Personal"
|
||||
|
||||
# Metadata
|
||||
created_at = db.Column(db.DateTime, default=datetime.now)
|
||||
updated_at = db.Column(db.DateTime, default=datetime.now, onupdate=datetime.now)
|
||||
|
||||
# Associations
|
||||
created_by_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False)
|
||||
company_id = db.Column(db.Integer, db.ForeignKey('company.id'), nullable=False)
|
||||
|
||||
# Optional associations
|
||||
team_id = db.Column(db.Integer, db.ForeignKey('team.id'), nullable=True) # For team-specific notes
|
||||
project_id = db.Column(db.Integer, db.ForeignKey('project.id'), nullable=True) # Link to project
|
||||
task_id = db.Column(db.Integer, db.ForeignKey('task.id'), nullable=True) # Link to task
|
||||
|
||||
# Tags for organization
|
||||
tags = db.Column(db.String(500)) # Comma-separated tags
|
||||
|
||||
# Pin important notes
|
||||
is_pinned = db.Column(db.Boolean, default=False)
|
||||
|
||||
# Soft delete
|
||||
is_archived = db.Column(db.Boolean, default=False)
|
||||
archived_at = db.Column(db.DateTime, nullable=True)
|
||||
|
||||
# Relationships
|
||||
created_by = db.relationship('User', foreign_keys=[created_by_id], backref='notes')
|
||||
company = db.relationship('Company', backref='notes')
|
||||
team = db.relationship('Team', backref='notes')
|
||||
project = db.relationship('Project', backref='notes')
|
||||
task = db.relationship('Task', backref='notes')
|
||||
|
||||
# Unique constraint on slug per company
|
||||
__table_args__ = (db.UniqueConstraint('company_id', 'slug', name='uq_note_slug_per_company'),)
|
||||
|
||||
def __repr__(self):
|
||||
return f'<Note {self.title}>'
|
||||
|
||||
def generate_slug(self):
|
||||
"""Generate URL-friendly slug from title and set it on the model"""
|
||||
import re
|
||||
# Remove special characters and convert to lowercase
|
||||
slug = re.sub(r'[^\w\s-]', '', self.title.lower())
|
||||
# Replace spaces with hyphens
|
||||
slug = re.sub(r'[-\s]+', '-', slug)
|
||||
# Remove leading/trailing hyphens
|
||||
slug = slug.strip('-')
|
||||
|
||||
# Ensure uniqueness within company
|
||||
base_slug = slug
|
||||
counter = 1
|
||||
while Note.query.filter_by(company_id=self.company_id, slug=slug).filter(Note.id != self.id).first():
|
||||
slug = f"{base_slug}-{counter}"
|
||||
counter += 1
|
||||
|
||||
self.slug = slug
|
||||
return slug
|
||||
|
||||
def can_user_view(self, user):
|
||||
"""Check if user can view this note"""
|
||||
# Creator can always view
|
||||
if user.id == self.created_by_id:
|
||||
return True
|
||||
|
||||
# Check company match
|
||||
if user.company_id != self.company_id:
|
||||
return False
|
||||
|
||||
# Check visibility
|
||||
if self.visibility == NoteVisibility.COMPANY:
|
||||
return True
|
||||
elif self.visibility == NoteVisibility.TEAM:
|
||||
# Check if user is in the same team
|
||||
if self.team_id and user.team_id == self.team_id:
|
||||
return True
|
||||
# Admins can view all team notes
|
||||
if user.role in [Role.ADMIN, Role.SYSTEM_ADMIN]:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def can_user_edit(self, user):
|
||||
"""Check if user can edit this note"""
|
||||
# Creator can always edit
|
||||
if user.id == self.created_by_id:
|
||||
return True
|
||||
|
||||
# Admins can edit company notes
|
||||
if user.role in [Role.ADMIN, Role.SYSTEM_ADMIN] and user.company_id == self.company_id:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def get_tags_list(self):
|
||||
"""Get tags as a list"""
|
||||
if not self.tags:
|
||||
return []
|
||||
return [tag.strip() for tag in self.tags.split(',') if tag.strip()]
|
||||
|
||||
def set_tags_list(self, tags_list):
|
||||
"""Set tags from a list"""
|
||||
self.tags = ','.join(tags_list) if tags_list else None
|
||||
|
||||
def get_preview(self, length=200):
|
||||
"""Get a plain text preview of the note content"""
|
||||
# Strip markdown formatting for preview
|
||||
import re
|
||||
from frontmatter_utils import parse_frontmatter
|
||||
|
||||
# Extract body content without frontmatter
|
||||
_, body = parse_frontmatter(self.content)
|
||||
text = body
|
||||
|
||||
# Remove headers
|
||||
text = re.sub(r'^#+\s+', '', text, flags=re.MULTILINE)
|
||||
# Remove emphasis
|
||||
text = re.sub(r'\*{1,2}([^\*]+)\*{1,2}', r'\1', text)
|
||||
text = re.sub(r'_{1,2}([^_]+)_{1,2}', r'\1', text)
|
||||
# Remove links
|
||||
text = re.sub(r'\[([^\]]+)\]\([^\)]+\)', r'\1', text)
|
||||
# Remove code blocks
|
||||
text = re.sub(r'```[^`]*```', '', text, flags=re.DOTALL)
|
||||
text = re.sub(r'`([^`]+)`', r'\1', text)
|
||||
# Clean up whitespace
|
||||
text = ' '.join(text.split())
|
||||
|
||||
if len(text) > length:
|
||||
return text[:length] + '...'
|
||||
return text
|
||||
|
||||
def render_html(self):
|
||||
"""Render markdown content to HTML"""
|
||||
try:
|
||||
import markdown
|
||||
from frontmatter_utils import parse_frontmatter
|
||||
# Extract body content without frontmatter
|
||||
_, body = parse_frontmatter(self.content)
|
||||
# Use extensions for better markdown support
|
||||
html = markdown.markdown(body, extensions=['extra', 'codehilite', 'toc'])
|
||||
return html
|
||||
except ImportError:
|
||||
# Fallback if markdown not installed
|
||||
return f'<pre>{self.content}</pre>'
|
||||
|
||||
def get_frontmatter(self):
|
||||
"""Get frontmatter metadata from content"""
|
||||
from frontmatter_utils import parse_frontmatter
|
||||
metadata, _ = parse_frontmatter(self.content)
|
||||
return metadata
|
||||
|
||||
def update_frontmatter(self):
|
||||
"""Update content with current metadata as frontmatter"""
|
||||
from frontmatter_utils import update_frontmatter
|
||||
metadata = {
|
||||
'title': self.title,
|
||||
'visibility': self.visibility.value.lower(),
|
||||
'folder': self.folder,
|
||||
'tags': self.get_tags_list() if self.tags else None,
|
||||
'project': self.project.code if self.project else None,
|
||||
'task_id': self.task_id,
|
||||
'pinned': self.is_pinned if self.is_pinned else None,
|
||||
'created': self.created_at.isoformat() if self.created_at else None,
|
||||
'updated': self.updated_at.isoformat() if self.updated_at else None,
|
||||
'author': self.created_by.username if self.created_by else None
|
||||
}
|
||||
# Remove None values
|
||||
metadata = {k: v for k, v in metadata.items() if v is not None}
|
||||
self.content = update_frontmatter(self.content, metadata)
|
||||
|
||||
def sync_from_frontmatter(self):
|
||||
"""Update model fields from frontmatter in content"""
|
||||
from frontmatter_utils import parse_frontmatter
|
||||
metadata, _ = parse_frontmatter(self.content)
|
||||
|
||||
if metadata:
|
||||
# Update fields from frontmatter
|
||||
if 'title' in metadata:
|
||||
self.title = metadata['title']
|
||||
if 'visibility' in metadata:
|
||||
try:
|
||||
self.visibility = NoteVisibility[metadata['visibility'].upper()]
|
||||
except KeyError:
|
||||
pass
|
||||
if 'folder' in metadata:
|
||||
self.folder = metadata['folder']
|
||||
if 'tags' in metadata:
|
||||
if isinstance(metadata['tags'], list):
|
||||
self.set_tags_list(metadata['tags'])
|
||||
elif isinstance(metadata['tags'], str):
|
||||
self.tags = metadata['tags']
|
||||
if 'pinned' in metadata:
|
||||
self.is_pinned = bool(metadata['pinned'])
|
||||
|
||||
|
||||
class NoteLink(db.Model):
|
||||
"""Links between notes for creating relationships"""
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
|
||||
# Source and target notes with cascade deletion
|
||||
source_note_id = db.Column(db.Integer, db.ForeignKey('note.id', ondelete='CASCADE'), nullable=False)
|
||||
target_note_id = db.Column(db.Integer, db.ForeignKey('note.id', ondelete='CASCADE'), nullable=False)
|
||||
|
||||
# Link metadata
|
||||
link_type = db.Column(db.String(50), default='related') # related, parent, child, etc.
|
||||
created_at = db.Column(db.DateTime, default=datetime.now)
|
||||
created_by_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False)
|
||||
|
||||
# Relationships with cascade deletion
|
||||
source_note = db.relationship('Note', foreign_keys=[source_note_id],
|
||||
backref=db.backref('outgoing_links', cascade='all, delete-orphan'))
|
||||
target_note = db.relationship('Note', foreign_keys=[target_note_id],
|
||||
backref=db.backref('incoming_links', cascade='all, delete-orphan'))
|
||||
created_by = db.relationship('User', foreign_keys=[created_by_id])
|
||||
|
||||
# Unique constraint to prevent duplicate links
|
||||
__table_args__ = (db.UniqueConstraint('source_note_id', 'target_note_id', name='uq_note_link'),)
|
||||
|
||||
def __repr__(self):
|
||||
return f'<NoteLink {self.source_note_id} -> {self.target_note_id}>'
|
||||
|
||||
|
||||
class NoteFolder(db.Model):
|
||||
"""Represents a folder for organizing notes"""
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
|
||||
# Folder properties
|
||||
name = db.Column(db.String(100), nullable=False)
|
||||
path = db.Column(db.String(500), nullable=False) # Full path like "Work/Projects/Q1"
|
||||
parent_path = db.Column(db.String(500), nullable=True) # Parent folder path
|
||||
description = db.Column(db.Text, nullable=True)
|
||||
|
||||
# Metadata
|
||||
created_at = db.Column(db.DateTime, default=datetime.now)
|
||||
created_by_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False)
|
||||
company_id = db.Column(db.Integer, db.ForeignKey('company.id'), nullable=False)
|
||||
|
||||
# Relationships
|
||||
created_by = db.relationship('User', foreign_keys=[created_by_id])
|
||||
company = db.relationship('Company', foreign_keys=[company_id])
|
||||
|
||||
# Unique constraint to prevent duplicate paths within a company
|
||||
__table_args__ = (db.UniqueConstraint('path', 'company_id', name='uq_folder_path_company'),)
|
||||
|
||||
def __repr__(self):
|
||||
return f'<NoteFolder {self.path}>'
|
||||
86
models/project.py
Normal file
86
models/project.py
Normal file
@@ -0,0 +1,86 @@
|
||||
"""
|
||||
Project-related models
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from . import db
|
||||
from .enums import Role
|
||||
|
||||
|
||||
class Project(db.Model):
|
||||
"""Project model for time tracking"""
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
name = db.Column(db.String(100), nullable=False)
|
||||
description = db.Column(db.Text, nullable=True)
|
||||
code = db.Column(db.String(20), nullable=False) # Project code (e.g., PRJ001)
|
||||
is_active = db.Column(db.Boolean, default=True)
|
||||
created_at = db.Column(db.DateTime, default=datetime.now)
|
||||
updated_at = db.Column(db.DateTime, default=datetime.now, onupdate=datetime.now)
|
||||
|
||||
# Company association for multi-tenancy
|
||||
company_id = db.Column(db.Integer, db.ForeignKey('company.id'), nullable=False)
|
||||
|
||||
# Foreign key to user who created the project (Admin/Supervisor)
|
||||
created_by_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False)
|
||||
|
||||
# Optional team assignment - if set, only team members can log time to this project
|
||||
team_id = db.Column(db.Integer, db.ForeignKey('team.id'), nullable=True)
|
||||
|
||||
# Project categorization
|
||||
category_id = db.Column(db.Integer, db.ForeignKey('project_category.id'), nullable=True)
|
||||
|
||||
# Project dates
|
||||
start_date = db.Column(db.Date, nullable=True)
|
||||
end_date = db.Column(db.Date, nullable=True)
|
||||
|
||||
# Relationships
|
||||
created_by = db.relationship('User', foreign_keys=[created_by_id], backref='created_projects')
|
||||
team = db.relationship('Team', backref='projects')
|
||||
time_entries = db.relationship('TimeEntry', backref='project', lazy=True)
|
||||
category = db.relationship('ProjectCategory', back_populates='projects')
|
||||
|
||||
# Unique constraint per company
|
||||
__table_args__ = (db.UniqueConstraint('company_id', 'code', name='uq_project_code_per_company'),)
|
||||
|
||||
def __repr__(self):
|
||||
return f'<Project {self.name} ({self.code})>'
|
||||
|
||||
def is_user_allowed(self, user):
|
||||
"""Check if a user can log time to this project"""
|
||||
# User must be in the same company
|
||||
if user.company_id != self.company_id:
|
||||
return False
|
||||
|
||||
# Admins and Supervisors can log time to any project in their company
|
||||
if user.role in [Role.ADMIN, Role.SUPERVISOR]:
|
||||
return True
|
||||
|
||||
# If project is team-specific, only team members can log time
|
||||
if self.team_id:
|
||||
return user.team_id == self.team_id
|
||||
|
||||
# If no team restriction, any user in the company can log time
|
||||
return True
|
||||
|
||||
|
||||
class ProjectCategory(db.Model):
|
||||
"""Project category for organization"""
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
name = db.Column(db.String(50), nullable=False)
|
||||
description = db.Column(db.String(255))
|
||||
color = db.Column(db.String(7), default='#3B82F6') # Hex color for UI
|
||||
|
||||
# Company association
|
||||
company_id = db.Column(db.Integer, db.ForeignKey('company.id'), nullable=False)
|
||||
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
|
||||
# Relationships
|
||||
projects = db.relationship('Project', back_populates='category')
|
||||
company = db.relationship('Company', backref='project_categories')
|
||||
|
||||
# Unique constraint
|
||||
__table_args__ = (db.UniqueConstraint('company_id', 'name', name='uq_category_name_per_company'),)
|
||||
|
||||
def __repr__(self):
|
||||
return f'<ProjectCategory {self.name}>'
|
||||
117
models/sprint.py
Normal file
117
models/sprint.py
Normal file
@@ -0,0 +1,117 @@
|
||||
"""
|
||||
Sprint model for agile project management
|
||||
"""
|
||||
|
||||
from datetime import datetime, date
|
||||
from . import db
|
||||
from .enums import SprintStatus, TaskStatus, Role
|
||||
|
||||
|
||||
class Sprint(db.Model):
|
||||
"""Sprint model for agile project management"""
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
name = db.Column(db.String(200), nullable=False)
|
||||
description = db.Column(db.Text, nullable=True)
|
||||
|
||||
# Sprint status
|
||||
status = db.Column(db.Enum(SprintStatus), nullable=False, default=SprintStatus.PLANNING)
|
||||
|
||||
# Company association - sprints are company-scoped
|
||||
company_id = db.Column(db.Integer, db.ForeignKey('company.id'), nullable=False)
|
||||
|
||||
# Optional project association - can be project-specific or company-wide
|
||||
project_id = db.Column(db.Integer, db.ForeignKey('project.id'), nullable=True)
|
||||
|
||||
# Sprint timeline
|
||||
start_date = db.Column(db.Date, nullable=False)
|
||||
end_date = db.Column(db.Date, nullable=False)
|
||||
|
||||
# Sprint goals and metrics
|
||||
goal = db.Column(db.Text, nullable=True) # Sprint goal description
|
||||
capacity_hours = db.Column(db.Integer, nullable=True) # Planned capacity in hours
|
||||
|
||||
# Metadata
|
||||
created_at = db.Column(db.DateTime, default=datetime.now)
|
||||
updated_at = db.Column(db.DateTime, default=datetime.now, onupdate=datetime.now)
|
||||
created_by_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False)
|
||||
|
||||
# Relationships
|
||||
company = db.relationship('Company', backref='sprints')
|
||||
project = db.relationship('Project', backref='sprints')
|
||||
created_by = db.relationship('User', foreign_keys=[created_by_id])
|
||||
tasks = db.relationship('Task', backref='sprint', lazy=True)
|
||||
|
||||
def __repr__(self):
|
||||
return f'<Sprint {self.name}>'
|
||||
|
||||
@property
|
||||
def is_current(self):
|
||||
"""Check if this sprint is currently active"""
|
||||
today = date.today()
|
||||
return (self.status == SprintStatus.ACTIVE and
|
||||
self.start_date <= today <= self.end_date)
|
||||
|
||||
@property
|
||||
def duration_days(self):
|
||||
"""Get sprint duration in days"""
|
||||
return (self.end_date - self.start_date).days + 1
|
||||
|
||||
@property
|
||||
def days_remaining(self):
|
||||
"""Get remaining days in sprint"""
|
||||
today = date.today()
|
||||
if self.end_date < today:
|
||||
return 0
|
||||
elif self.start_date > today:
|
||||
return self.duration_days
|
||||
else:
|
||||
return (self.end_date - today).days + 1
|
||||
|
||||
@property
|
||||
def progress_percentage(self):
|
||||
"""Calculate sprint progress percentage based on dates"""
|
||||
today = date.today()
|
||||
|
||||
if today < self.start_date:
|
||||
return 0
|
||||
elif today > self.end_date:
|
||||
return 100
|
||||
else:
|
||||
total_days = self.duration_days
|
||||
elapsed_days = (today - self.start_date).days + 1
|
||||
return min(100, int((elapsed_days / total_days) * 100))
|
||||
|
||||
def get_task_summary(self):
|
||||
"""Get summary of tasks in this sprint"""
|
||||
total_tasks = len(self.tasks)
|
||||
completed_tasks = len([t for t in self.tasks if t.status == TaskStatus.DONE])
|
||||
in_progress_tasks = len([t for t in self.tasks if t.status == TaskStatus.IN_PROGRESS])
|
||||
|
||||
return {
|
||||
'total': total_tasks,
|
||||
'completed': completed_tasks,
|
||||
'in_progress': in_progress_tasks,
|
||||
'not_started': total_tasks - completed_tasks - in_progress_tasks,
|
||||
'completion_percentage': int((completed_tasks / total_tasks) * 100) if total_tasks > 0 else 0
|
||||
}
|
||||
|
||||
def can_user_access(self, user):
|
||||
"""Check if user can access this sprint"""
|
||||
# Must be in same company
|
||||
if self.company_id != user.company_id:
|
||||
return False
|
||||
|
||||
# If sprint is project-specific, check project access
|
||||
if self.project_id:
|
||||
return self.project.is_user_allowed(user)
|
||||
|
||||
# Company-wide sprints can be accessed by all company users
|
||||
return True
|
||||
|
||||
def can_user_modify(self, user):
|
||||
"""Check if user can modify this sprint"""
|
||||
if not self.can_user_access(user):
|
||||
return False
|
||||
|
||||
# Only admins and supervisors can modify sprints
|
||||
return user.role in [Role.ADMIN, Role.SUPERVISOR]
|
||||
132
models/system.py
Normal file
132
models/system.py
Normal file
@@ -0,0 +1,132 @@
|
||||
"""
|
||||
System-related models
|
||||
"""
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
from . import db
|
||||
|
||||
|
||||
class SystemSettings(db.Model):
|
||||
"""Key-value store for system-wide settings"""
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
key = db.Column(db.String(50), unique=True, nullable=False)
|
||||
value = db.Column(db.String(255), nullable=False)
|
||||
description = db.Column(db.String(255))
|
||||
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
def __repr__(self):
|
||||
return f'<SystemSettings {self.key}={self.value}>'
|
||||
|
||||
|
||||
class BrandingSettings(db.Model):
|
||||
"""Branding and customization settings"""
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
app_name = db.Column(db.String(100), nullable=False, default='Time Tracker')
|
||||
logo_filename = db.Column(db.String(255), nullable=True) # Filename of uploaded logo
|
||||
logo_alt_text = db.Column(db.String(255), nullable=True, default='Logo')
|
||||
favicon_filename = db.Column(db.String(255), nullable=True) # Filename of uploaded favicon
|
||||
primary_color = db.Column(db.String(7), nullable=True, default='#007bff') # Hex color
|
||||
|
||||
# Imprint/Legal page settings
|
||||
imprint_enabled = db.Column(db.Boolean, default=False) # Enable/disable imprint page
|
||||
imprint_title = db.Column(db.String(200), nullable=True, default='Imprint') # Page title
|
||||
imprint_content = db.Column(db.Text, nullable=True) # HTML content for imprint page
|
||||
|
||||
# Meta fields
|
||||
created_at = db.Column(db.DateTime, default=datetime.now)
|
||||
updated_at = db.Column(db.DateTime, default=datetime.now, onupdate=datetime.now)
|
||||
updated_by_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=True)
|
||||
|
||||
# Relationships
|
||||
updated_by = db.relationship('User', foreign_keys=[updated_by_id])
|
||||
|
||||
def __repr__(self):
|
||||
return f'<BrandingSettings {self.app_name}>'
|
||||
|
||||
@staticmethod
|
||||
def get_settings():
|
||||
"""Get current branding settings or create defaults"""
|
||||
settings = BrandingSettings.query.first()
|
||||
if not settings:
|
||||
settings = BrandingSettings(
|
||||
app_name='Time Tracker',
|
||||
logo_alt_text='Application Logo'
|
||||
)
|
||||
db.session.add(settings)
|
||||
db.session.commit()
|
||||
return settings
|
||||
|
||||
@staticmethod
|
||||
def get_current():
|
||||
"""Alias for get_settings() for backward compatibility"""
|
||||
return BrandingSettings.get_settings()
|
||||
|
||||
|
||||
class SystemEvent(db.Model):
|
||||
"""System event logging for audit and monitoring"""
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
event_type = db.Column(db.String(50), nullable=False) # e.g., 'login', 'logout', 'user_created', 'system_error'
|
||||
event_category = db.Column(db.String(30), nullable=False) # e.g., 'auth', 'user_management', 'system', 'error'
|
||||
description = db.Column(db.Text, nullable=False)
|
||||
severity = db.Column(db.String(20), default='info') # 'info', 'warning', 'error', 'critical'
|
||||
timestamp = db.Column(db.DateTime, default=datetime.now, nullable=False)
|
||||
|
||||
# Optional associations
|
||||
user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=True)
|
||||
company_id = db.Column(db.Integer, db.ForeignKey('company.id'), nullable=True)
|
||||
|
||||
# Additional metadata (JSON string)
|
||||
event_metadata = db.Column(db.Text, nullable=True) # Store additional event data as JSON
|
||||
|
||||
# IP address and user agent for security tracking
|
||||
ip_address = db.Column(db.String(45), nullable=True) # IPv6 compatible
|
||||
user_agent = db.Column(db.Text, nullable=True)
|
||||
|
||||
# Relationships
|
||||
user = db.relationship('User', backref='system_events')
|
||||
company = db.relationship('Company', backref='system_events')
|
||||
|
||||
def __repr__(self):
|
||||
return f'<SystemEvent {self.event_type}: {self.description[:50]}>'
|
||||
|
||||
@staticmethod
|
||||
def log_event(event_type, description, event_category='system', severity='info',
|
||||
user_id=None, company_id=None, event_metadata=None, ip_address=None, user_agent=None):
|
||||
"""Helper method to log system events"""
|
||||
event = SystemEvent(
|
||||
event_type=event_type,
|
||||
event_category=event_category,
|
||||
description=description,
|
||||
severity=severity,
|
||||
user_id=user_id,
|
||||
company_id=company_id,
|
||||
event_metadata=event_metadata,
|
||||
ip_address=ip_address,
|
||||
user_agent=user_agent
|
||||
)
|
||||
db.session.add(event)
|
||||
try:
|
||||
db.session.commit()
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
# Log to application logger if DB logging fails
|
||||
import logging
|
||||
logging.error(f"Failed to log system event: {e}")
|
||||
|
||||
@staticmethod
|
||||
def get_recent_events(days=7, limit=100):
|
||||
"""Get recent system events from the last N days"""
|
||||
since = datetime.now() - timedelta(days=days)
|
||||
return SystemEvent.query.filter(
|
||||
SystemEvent.timestamp >= since
|
||||
).order_by(SystemEvent.timestamp.desc()).limit(limit).all()
|
||||
|
||||
@staticmethod
|
||||
def cleanup_old_events(days=90):
|
||||
"""Delete system events older than specified days"""
|
||||
cutoff_date = datetime.now() - timedelta(days=days)
|
||||
deleted_count = SystemEvent.query.filter(
|
||||
SystemEvent.timestamp < cutoff_date
|
||||
).delete()
|
||||
db.session.commit()
|
||||
return deleted_count
|
||||
245
models/task.py
Normal file
245
models/task.py
Normal file
@@ -0,0 +1,245 @@
|
||||
"""
|
||||
Task-related models
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from . import db
|
||||
from .enums import TaskStatus, TaskPriority, CommentVisibility, Role
|
||||
|
||||
|
||||
class Task(db.Model):
|
||||
"""Task model for project management"""
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
task_number = db.Column(db.String(20), nullable=False, unique=True) # e.g., "TSK-001", "TSK-002"
|
||||
name = db.Column(db.String(200), nullable=False)
|
||||
description = db.Column(db.Text, nullable=True)
|
||||
|
||||
# Task properties
|
||||
status = db.Column(db.Enum(TaskStatus), default=TaskStatus.TODO)
|
||||
priority = db.Column(db.Enum(TaskPriority), default=TaskPriority.MEDIUM)
|
||||
estimated_hours = db.Column(db.Float, nullable=True) # Estimated time to complete
|
||||
|
||||
# Project association
|
||||
project_id = db.Column(db.Integer, db.ForeignKey('project.id'), nullable=False)
|
||||
|
||||
# Sprint association (optional)
|
||||
sprint_id = db.Column(db.Integer, db.ForeignKey('sprint.id'), nullable=True)
|
||||
|
||||
# Task assignment
|
||||
assigned_to_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=True)
|
||||
|
||||
# Task dates
|
||||
start_date = db.Column(db.Date, nullable=True)
|
||||
due_date = db.Column(db.Date, nullable=True)
|
||||
completed_date = db.Column(db.Date, nullable=True)
|
||||
archived_date = db.Column(db.Date, nullable=True)
|
||||
|
||||
# Metadata
|
||||
created_at = db.Column(db.DateTime, default=datetime.now)
|
||||
updated_at = db.Column(db.DateTime, default=datetime.now, onupdate=datetime.now)
|
||||
created_by_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False)
|
||||
|
||||
# Relationships
|
||||
project = db.relationship('Project', backref='tasks')
|
||||
assigned_to = db.relationship('User', foreign_keys=[assigned_to_id], backref='assigned_tasks')
|
||||
created_by = db.relationship('User', foreign_keys=[created_by_id])
|
||||
subtasks = db.relationship('SubTask', backref='parent_task', lazy=True, cascade='all, delete-orphan')
|
||||
time_entries = db.relationship('TimeEntry', backref='task', lazy=True)
|
||||
|
||||
def __repr__(self):
|
||||
return f'<Task {self.name} ({self.status.value})>'
|
||||
|
||||
@property
|
||||
def progress_percentage(self):
|
||||
"""Calculate task progress based on subtasks completion"""
|
||||
if not self.subtasks:
|
||||
return 100 if self.status == TaskStatus.DONE else 0
|
||||
|
||||
completed_subtasks = sum(1 for subtask in self.subtasks if subtask.status == TaskStatus.DONE)
|
||||
return int((completed_subtasks / len(self.subtasks)) * 100)
|
||||
|
||||
@property
|
||||
def total_time_logged(self):
|
||||
"""Calculate total time logged to this task (in seconds)"""
|
||||
return sum(entry.duration or 0 for entry in self.time_entries if entry.duration)
|
||||
|
||||
def can_user_access(self, user):
|
||||
"""Check if a user can access this task"""
|
||||
return self.project.is_user_allowed(user)
|
||||
|
||||
@classmethod
|
||||
def generate_task_number(cls, company_id):
|
||||
"""Generate next task number for the company"""
|
||||
# Get the highest task number for this company
|
||||
last_task = cls.query.join(Project).filter(
|
||||
Project.company_id == company_id,
|
||||
cls.task_number.like('TSK-%')
|
||||
).order_by(cls.task_number.desc()).first()
|
||||
|
||||
if last_task and last_task.task_number:
|
||||
try:
|
||||
# Extract number from TSK-XXX format
|
||||
last_num = int(last_task.task_number.split('-')[1])
|
||||
return f"TSK-{last_num + 1:03d}"
|
||||
except (IndexError, ValueError):
|
||||
pass
|
||||
|
||||
return "TSK-001"
|
||||
|
||||
@property
|
||||
def blocked_by_tasks(self):
|
||||
"""Get tasks that are blocking this task"""
|
||||
return [dep.blocking_task for dep in self.blocked_by_dependencies]
|
||||
|
||||
@property
|
||||
def blocking_tasks(self):
|
||||
"""Get tasks that this task is blocking"""
|
||||
return [dep.blocked_task for dep in self.blocking_dependencies]
|
||||
|
||||
|
||||
class TaskDependency(db.Model):
|
||||
"""Track dependencies between tasks"""
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
|
||||
# The task that is blocked (cannot start until blocking task is done)
|
||||
blocked_task_id = db.Column(db.Integer, db.ForeignKey('task.id'), nullable=False)
|
||||
|
||||
# The task that is blocking (must be completed before blocked task can start)
|
||||
blocking_task_id = db.Column(db.Integer, db.ForeignKey('task.id'), nullable=False)
|
||||
|
||||
# Dependency type (for future extension)
|
||||
dependency_type = db.Column(db.String(50), default='blocks', nullable=False) # 'blocks', 'subtask', etc.
|
||||
|
||||
# Metadata
|
||||
created_at = db.Column(db.DateTime, default=datetime.now)
|
||||
created_by_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False)
|
||||
|
||||
# Relationships
|
||||
blocked_task = db.relationship('Task', foreign_keys=[blocked_task_id],
|
||||
backref=db.backref('blocked_by_dependencies', cascade='all, delete-orphan'))
|
||||
blocking_task = db.relationship('Task', foreign_keys=[blocking_task_id],
|
||||
backref=db.backref('blocking_dependencies', cascade='all, delete-orphan'))
|
||||
created_by = db.relationship('User', foreign_keys=[created_by_id])
|
||||
|
||||
# Ensure a task doesn't block itself and prevent duplicate dependencies
|
||||
__table_args__ = (
|
||||
db.CheckConstraint('blocked_task_id != blocking_task_id', name='no_self_blocking'),
|
||||
db.UniqueConstraint('blocked_task_id', 'blocking_task_id', name='unique_dependency'),
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return f'<TaskDependency {self.blocking_task_id} blocks {self.blocked_task_id}>'
|
||||
|
||||
|
||||
class SubTask(db.Model):
|
||||
"""Subtask model for breaking down tasks"""
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
name = db.Column(db.String(200), nullable=False)
|
||||
description = db.Column(db.Text, nullable=True)
|
||||
|
||||
# SubTask properties
|
||||
status = db.Column(db.Enum(TaskStatus), default=TaskStatus.TODO)
|
||||
priority = db.Column(db.Enum(TaskPriority), default=TaskPriority.MEDIUM)
|
||||
estimated_hours = db.Column(db.Float, nullable=True)
|
||||
|
||||
# Parent task association
|
||||
task_id = db.Column(db.Integer, db.ForeignKey('task.id'), nullable=False)
|
||||
|
||||
# Assignment
|
||||
assigned_to_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=True)
|
||||
|
||||
# Dates
|
||||
start_date = db.Column(db.Date, nullable=True)
|
||||
due_date = db.Column(db.Date, nullable=True)
|
||||
completed_date = db.Column(db.Date, nullable=True)
|
||||
|
||||
# Metadata
|
||||
created_at = db.Column(db.DateTime, default=datetime.now)
|
||||
updated_at = db.Column(db.DateTime, default=datetime.now, onupdate=datetime.now)
|
||||
created_by_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False)
|
||||
|
||||
# Relationships
|
||||
assigned_to = db.relationship('User', foreign_keys=[assigned_to_id], backref='assigned_subtasks')
|
||||
created_by = db.relationship('User', foreign_keys=[created_by_id])
|
||||
time_entries = db.relationship('TimeEntry', backref='subtask', lazy=True)
|
||||
|
||||
def __repr__(self):
|
||||
return f'<SubTask {self.name} ({self.status.value})>'
|
||||
|
||||
@property
|
||||
def total_time_logged(self):
|
||||
"""Calculate total time logged to this subtask (in seconds)"""
|
||||
return sum(entry.duration or 0 for entry in self.time_entries if entry.duration)
|
||||
|
||||
def can_user_access(self, user):
|
||||
"""Check if a user can access this subtask"""
|
||||
return self.parent_task.can_user_access(user)
|
||||
|
||||
|
||||
class Comment(db.Model):
|
||||
"""Comment model for task discussions"""
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
content = db.Column(db.Text, nullable=False)
|
||||
|
||||
# Task association
|
||||
task_id = db.Column(db.Integer, db.ForeignKey('task.id'), nullable=False)
|
||||
|
||||
# Parent comment for thread support
|
||||
parent_comment_id = db.Column(db.Integer, db.ForeignKey('comment.id'), nullable=True)
|
||||
|
||||
# Visibility setting
|
||||
visibility = db.Column(db.Enum(CommentVisibility), default=CommentVisibility.COMPANY)
|
||||
|
||||
# Edit tracking
|
||||
is_edited = db.Column(db.Boolean, default=False)
|
||||
edited_at = db.Column(db.DateTime, nullable=True)
|
||||
|
||||
# Metadata
|
||||
created_at = db.Column(db.DateTime, default=datetime.now)
|
||||
created_by_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False)
|
||||
|
||||
# Relationships
|
||||
task = db.relationship('Task', backref=db.backref('comments', lazy='dynamic', cascade='all, delete-orphan'))
|
||||
created_by = db.relationship('User', foreign_keys=[created_by_id], backref='comments')
|
||||
replies = db.relationship('Comment', backref=db.backref('parent_comment', remote_side=[id]))
|
||||
|
||||
def __repr__(self):
|
||||
return f'<Comment {self.id} on Task {self.task_id}>'
|
||||
|
||||
def can_user_view(self, user):
|
||||
"""Check if a user can view this comment based on visibility settings"""
|
||||
# First check if user can access the task
|
||||
if not self.task.can_user_access(user):
|
||||
return False
|
||||
|
||||
# Check visibility rules
|
||||
if self.visibility == CommentVisibility.PRIVATE:
|
||||
return user.id == self.created_by_id
|
||||
elif self.visibility == CommentVisibility.TEAM:
|
||||
# User must be in the same team as the task's project
|
||||
if self.task.project.team_id:
|
||||
return user.team_id == self.task.project.team_id
|
||||
return True # If no team restriction, all company users can see
|
||||
else: # CommentVisibility.COMPANY
|
||||
return True # All company users can see
|
||||
|
||||
def can_user_edit(self, user):
|
||||
"""Check if a user can edit this comment"""
|
||||
# Only the creator can edit their own comments
|
||||
return user.id == self.created_by_id
|
||||
|
||||
def can_user_delete(self, user):
|
||||
"""Check if a user can delete this comment"""
|
||||
# Creator can delete their own comments
|
||||
if user.id == self.created_by_id:
|
||||
return True
|
||||
|
||||
# Admins can delete any comment in their company
|
||||
if user.role in [Role.ADMIN, Role.SYSTEM_ADMIN]:
|
||||
return True
|
||||
|
||||
# Team leaders can delete comments on their team's tasks
|
||||
if user.role == Role.TEAM_LEADER and self.task.project.team_id == user.team_id:
|
||||
return True
|
||||
|
||||
return False
|
||||
26
models/team.py
Normal file
26
models/team.py
Normal file
@@ -0,0 +1,26 @@
|
||||
"""
|
||||
Team model
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from . import db
|
||||
|
||||
|
||||
class Team(db.Model):
|
||||
"""Team model for organizing users"""
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
name = db.Column(db.String(100), nullable=False)
|
||||
description = db.Column(db.String(255))
|
||||
created_at = db.Column(db.DateTime, default=datetime.now)
|
||||
|
||||
# Company association for multi-tenancy
|
||||
company_id = db.Column(db.Integer, db.ForeignKey('company.id'), nullable=False)
|
||||
|
||||
# Relationship with users (one team has many users)
|
||||
users = db.relationship('User', backref='team', lazy=True)
|
||||
|
||||
# Unique constraint per company
|
||||
__table_args__ = (db.UniqueConstraint('company_id', 'name', name='uq_team_name_per_company'),)
|
||||
|
||||
def __repr__(self):
|
||||
return f'<Team {self.name}>'
|
||||
32
models/time_entry.py
Normal file
32
models/time_entry.py
Normal file
@@ -0,0 +1,32 @@
|
||||
"""
|
||||
Time entry model for tracking work hours
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from . import db
|
||||
|
||||
|
||||
class TimeEntry(db.Model):
|
||||
"""Time entry model for tracking work hours"""
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
arrival_time = db.Column(db.DateTime, nullable=False)
|
||||
departure_time = db.Column(db.DateTime, nullable=True)
|
||||
duration = db.Column(db.Integer, nullable=True) # Duration in seconds
|
||||
is_paused = db.Column(db.Boolean, default=False)
|
||||
pause_start_time = db.Column(db.DateTime, nullable=True)
|
||||
total_break_duration = db.Column(db.Integer, default=0) # Total break duration in seconds
|
||||
user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=True)
|
||||
|
||||
# Project association - nullable for backward compatibility
|
||||
project_id = db.Column(db.Integer, db.ForeignKey('project.id'), nullable=True)
|
||||
|
||||
# Task/SubTask associations - nullable for backward compatibility
|
||||
task_id = db.Column(db.Integer, db.ForeignKey('task.id'), nullable=True)
|
||||
subtask_id = db.Column(db.Integer, db.ForeignKey('sub_task.id'), nullable=True)
|
||||
|
||||
# Optional notes/description for the time entry
|
||||
notes = db.Column(db.Text, nullable=True)
|
||||
|
||||
def __repr__(self):
|
||||
project_info = f" (Project: {self.project.code})" if self.project else ""
|
||||
return f'<TimeEntry {self.id}: {self.arrival_time} - {self.departure_time}{project_info}>'
|
||||
203
models/user.py
Normal file
203
models/user.py
Normal file
@@ -0,0 +1,203 @@
|
||||
"""
|
||||
User-related models
|
||||
"""
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
from werkzeug.security import generate_password_hash, check_password_hash
|
||||
import secrets
|
||||
from . import db
|
||||
from .enums import Role, AccountType, WidgetType, WidgetSize
|
||||
|
||||
|
||||
class User(db.Model):
|
||||
"""User model with multi-tenancy and role-based access"""
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
username = db.Column(db.String(80), nullable=False)
|
||||
email = db.Column(db.String(120), nullable=True)
|
||||
password_hash = db.Column(db.String(128))
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
|
||||
# Company association for multi-tenancy
|
||||
company_id = db.Column(db.Integer, db.ForeignKey('company.id'), nullable=False)
|
||||
|
||||
# Email verification fields
|
||||
is_verified = db.Column(db.Boolean, default=False)
|
||||
verification_token = db.Column(db.String(100), unique=True, nullable=True)
|
||||
token_expiry = db.Column(db.DateTime, nullable=True)
|
||||
|
||||
# New field for blocking users
|
||||
is_blocked = db.Column(db.Boolean, default=False)
|
||||
|
||||
# New fields for role and team
|
||||
role = db.Column(db.Enum(Role, values_callable=lambda obj: [e.value for e in obj]), default=Role.TEAM_MEMBER)
|
||||
team_id = db.Column(db.Integer, db.ForeignKey('team.id'), nullable=True)
|
||||
|
||||
# Freelancer support
|
||||
account_type = db.Column(db.Enum(AccountType, values_callable=lambda obj: [e.value for e in obj]), default=AccountType.COMPANY_USER)
|
||||
business_name = db.Column(db.String(100), nullable=True) # Optional business name for freelancers
|
||||
|
||||
# Unique constraints per company
|
||||
__table_args__ = (
|
||||
db.UniqueConstraint('company_id', 'username', name='uq_user_username_per_company'),
|
||||
db.UniqueConstraint('company_id', 'email', name='uq_user_email_per_company'),
|
||||
)
|
||||
|
||||
# Two-Factor Authentication fields
|
||||
two_factor_enabled = db.Column(db.Boolean, default=False)
|
||||
two_factor_secret = db.Column(db.String(32), nullable=True) # Base32 encoded secret
|
||||
|
||||
# Avatar field
|
||||
avatar_url = db.Column(db.String(255), nullable=True) # URL to user's avatar image
|
||||
|
||||
# Relationships
|
||||
time_entries = db.relationship('TimeEntry', backref='user', lazy=True)
|
||||
work_config = db.relationship('WorkConfig', backref='user', lazy=True, uselist=False)
|
||||
|
||||
def set_password(self, password):
|
||||
self.password_hash = generate_password_hash(password)
|
||||
|
||||
def check_password(self, password):
|
||||
return check_password_hash(self.password_hash, password)
|
||||
|
||||
def generate_verification_token(self):
|
||||
"""Generate a verification token that expires in 24 hours"""
|
||||
self.verification_token = secrets.token_urlsafe(32)
|
||||
self.token_expiry = datetime.utcnow() + timedelta(hours=24)
|
||||
return self.verification_token
|
||||
|
||||
def verify_token(self, token):
|
||||
"""Verify the token and mark user as verified if valid"""
|
||||
if token == self.verification_token and self.token_expiry > datetime.utcnow():
|
||||
self.is_verified = True
|
||||
self.verification_token = None
|
||||
self.token_expiry = None
|
||||
return True
|
||||
return False
|
||||
|
||||
def generate_2fa_secret(self):
|
||||
"""Generate a new 2FA secret"""
|
||||
import pyotp
|
||||
self.two_factor_secret = pyotp.random_base32()
|
||||
return self.two_factor_secret
|
||||
|
||||
def get_2fa_uri(self, issuer_name=None):
|
||||
"""Get the provisioning URI for QR code generation"""
|
||||
if not self.two_factor_secret:
|
||||
return None
|
||||
import pyotp
|
||||
totp = pyotp.TOTP(self.two_factor_secret)
|
||||
if issuer_name is None:
|
||||
issuer_name = "Time Tracker" # Default fallback
|
||||
return totp.provisioning_uri(
|
||||
name=self.email,
|
||||
issuer_name=issuer_name
|
||||
)
|
||||
|
||||
def verify_2fa_token(self, token, allow_setup=False):
|
||||
"""Verify a 2FA token"""
|
||||
if not self.two_factor_secret:
|
||||
return False
|
||||
# During setup, allow verification even if 2FA isn't enabled yet
|
||||
if not allow_setup and not self.two_factor_enabled:
|
||||
return False
|
||||
import pyotp
|
||||
totp = pyotp.TOTP(self.two_factor_secret)
|
||||
return totp.verify(token, valid_window=1) # Allow 1 window tolerance
|
||||
|
||||
def get_avatar_url(self, size=40):
|
||||
"""Get user's avatar URL or generate a default one"""
|
||||
if self.avatar_url:
|
||||
return self.avatar_url
|
||||
|
||||
# Generate a default avatar using DiceBear Avatars (similar to GitHub's identicons)
|
||||
# Using initials style for a clean, professional look
|
||||
import hashlib
|
||||
|
||||
# Create a hash from username for consistent colors
|
||||
hash_input = f"{self.username}_{self.id}".encode('utf-8')
|
||||
hash_hex = hashlib.md5(hash_input).hexdigest()
|
||||
|
||||
# Use DiceBear API for avatar generation
|
||||
# For initials style, we need to provide the actual initials
|
||||
initials = self.get_initials()
|
||||
|
||||
# Generate avatar URL with initials
|
||||
# Using a color based on the hash for consistency
|
||||
bg_colors = ['0ea5e9', '8b5cf6', 'ec4899', 'f59e0b', '10b981', 'ef4444', '3b82f6', '6366f1']
|
||||
color_index = int(hash_hex[:2], 16) % len(bg_colors)
|
||||
bg_color = bg_colors[color_index]
|
||||
|
||||
avatar_url = f"https://api.dicebear.com/7.x/initials/svg?seed={initials}&size={size}&backgroundColor={bg_color}&fontSize=50"
|
||||
|
||||
return avatar_url
|
||||
|
||||
def get_initials(self):
|
||||
"""Get user initials for avatar display"""
|
||||
parts = self.username.split()
|
||||
if len(parts) >= 2:
|
||||
return f"{parts[0][0]}{parts[-1][0]}".upper()
|
||||
elif self.username:
|
||||
return self.username[:2].upper()
|
||||
return "??"
|
||||
|
||||
def __repr__(self):
|
||||
return f'<User {self.username}>'
|
||||
|
||||
|
||||
class UserPreferences(db.Model):
|
||||
"""User preferences and settings"""
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
user_id = db.Column(db.Integer, db.ForeignKey('user.id'), unique=True, nullable=False)
|
||||
|
||||
# UI preferences
|
||||
theme = db.Column(db.String(20), default='light')
|
||||
language = db.Column(db.String(10), default='en')
|
||||
timezone = db.Column(db.String(50), default='UTC')
|
||||
date_format = db.Column(db.String(20), default='YYYY-MM-DD')
|
||||
time_format = db.Column(db.String(10), default='24h')
|
||||
|
||||
# Notification preferences
|
||||
email_notifications = db.Column(db.Boolean, default=True)
|
||||
email_daily_summary = db.Column(db.Boolean, default=False)
|
||||
email_weekly_summary = db.Column(db.Boolean, default=True)
|
||||
|
||||
# Time tracking preferences
|
||||
default_project_id = db.Column(db.Integer, db.ForeignKey('project.id'), nullable=True)
|
||||
timer_reminder_enabled = db.Column(db.Boolean, default=True)
|
||||
timer_reminder_interval = db.Column(db.Integer, default=60) # Minutes
|
||||
|
||||
# Dashboard preferences
|
||||
dashboard_layout = db.Column(db.JSON, nullable=True) # Store custom dashboard layout
|
||||
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
# Relationships
|
||||
user = db.relationship('User', backref=db.backref('preferences', uselist=False))
|
||||
default_project = db.relationship('Project')
|
||||
|
||||
|
||||
class UserDashboard(db.Model):
|
||||
"""User's dashboard configuration"""
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False)
|
||||
name = db.Column(db.String(100), default='My Dashboard')
|
||||
is_default = db.Column(db.Boolean, default=True)
|
||||
layout_config = db.Column(db.Text) # JSON string for grid layout configuration
|
||||
|
||||
# Dashboard settings
|
||||
grid_columns = db.Column(db.Integer, default=6) # Number of grid columns
|
||||
theme = db.Column(db.String(20), default='light') # light, dark, auto
|
||||
auto_refresh = db.Column(db.Integer, default=300) # Auto-refresh interval in seconds
|
||||
|
||||
# Additional configuration (from new model)
|
||||
layout = db.Column(db.JSON, nullable=True) # Grid layout configuration (alternative format)
|
||||
is_locked = db.Column(db.Boolean, default=False) # Prevent accidental changes
|
||||
|
||||
# Timestamps
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
# Relationships
|
||||
user = db.relationship('User', backref=db.backref('dashboards', lazy='dynamic'))
|
||||
widgets = db.relationship('DashboardWidget', backref='dashboard', lazy=True, cascade='all, delete')
|
||||
31
models/work_config.py
Normal file
31
models/work_config.py
Normal file
@@ -0,0 +1,31 @@
|
||||
"""
|
||||
Work configuration model
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from . import db
|
||||
|
||||
|
||||
class WorkConfig(db.Model):
|
||||
"""User-specific work configuration settings"""
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
work_hours_per_day = db.Column(db.Float, default=8.0) # Default 8 hours
|
||||
mandatory_break_minutes = db.Column(db.Integer, default=30) # Default 30 minutes
|
||||
break_threshold_hours = db.Column(db.Float, default=6.0) # Work hours that trigger mandatory break
|
||||
additional_break_minutes = db.Column(db.Integer, default=15) # Default 15 minutes for additional break
|
||||
additional_break_threshold_hours = db.Column(db.Float, default=9.0) # Work hours that trigger additional break
|
||||
|
||||
# Time rounding settings
|
||||
time_rounding_minutes = db.Column(db.Integer, default=0) # 0 = no rounding, 15 = 15 min, 30 = 30 min
|
||||
round_to_nearest = db.Column(db.Boolean, default=True) # True = round to nearest, False = round up
|
||||
|
||||
# Date/time format settings
|
||||
time_format_24h = db.Column(db.Boolean, default=True) # True = 24h, False = 12h (AM/PM)
|
||||
date_format = db.Column(db.String(20), default='ISO') # ISO, US, EU, etc.
|
||||
|
||||
created_at = db.Column(db.DateTime, default=datetime.now)
|
||||
updated_at = db.Column(db.DateTime, default=datetime.now, onupdate=datetime.now)
|
||||
user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=True)
|
||||
|
||||
def __repr__(self):
|
||||
return f'<WorkConfig {self.id}: {self.work_hours_per_day}h/day, {self.mandatory_break_minutes}min break>'
|
||||
Reference in New Issue
Block a user